Coverage for graphica/core/plugin_updates.py: 88.1%
96 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
1"""入れたプラグインに新しい版が出ていないかを調べる(公開プラグインの一覧と GitHub Releases への匿名の GET だけ)。
3一覧は本体リポジトリの docs/plugin_index.json(プラグインのフォルダ名・名前 → GitHub のリポジトリ)。
4入れ替えは利用者が選んだものだけで、ここでは調べるのと、落とした zip を確かめるところまで。
5"""
6import json
7import os
8import urllib.request
9import zipfile
10from typing import Any, Callable, Iterable
12from graphica.core.plugin_manifest import (
13 PLUGIN_MANIFEST_FILENAME, PluginManifestError, is_compatible_api_version, load_plugin_manifest,
14)
15from graphica.core.update_check import GITHUB_REPO, _asset_info, is_newer_version
17PLUGIN_INDEX_URL = f"https://raw.githubusercontent.com/{GITHUB_REPO}/master/Graphica_project/docs/plugin_index.json"
18_DEFAULT_TIMEOUT_SEC = 5
19_HEADERS = {'Accept': 'application/vnd.github+json', 'User-Agent': 'Graphica-plugin-update-check'}
22def _get_json(url: str, timeout: float) -> Any:
23 request = urllib.request.Request(url, headers=_HEADERS)
24 with urllib.request.urlopen(request, timeout=timeout) as response:
25 return json.loads(response.read().decode('utf-8'))
28def parse_plugin_index(data: Any) -> list[dict[str, str]]:
29 """一覧の JSON から、folder と repo のそろった項目だけを返す(壊れた行は飛ばす)。
31 Args:
32 data: {"plugins": [{"folder", "name", "repo"}, ...]}。
34 Returns:
35 {"folder", "name", "repo"} のリスト。
36 """
37 entries = data.get('plugins') if isinstance(data, dict) else None
38 result = []
39 for entry in entries or []:
40 if not isinstance(entry, dict):
41 continue
42 folder, repo = entry.get('folder'), entry.get('repo')
43 if isinstance(folder, str) and folder and isinstance(repo, str) and repo.count('/') == 1:
44 result.append({'folder': folder, 'name': str(entry.get('name') or folder), 'repo': repo})
45 return result
48def fetch_plugin_index(timeout: float = _DEFAULT_TIMEOUT_SEC) -> list[dict[str, str]]:
49 """公開プラグインの一覧。通信の失敗は例外のまま。"""
50 return parse_plugin_index(_get_json(PLUGIN_INDEX_URL, timeout))
53def fetch_latest_plugin_release(repo: str, timeout: float = _DEFAULT_TIMEOUT_SEC) -> dict[str, Any]:
54 """repo の最新リリース: {"version", "html_url", "body", "zip"(添付の zip の情報か None)}。"""
55 data = _get_json(f"https://api.github.com/repos/{repo}/releases/latest", timeout)
56 zips = [_asset_info(asset) for asset in data.get('assets') or []
57 if isinstance(asset, dict) and str(asset.get('name', '')).lower().endswith('.zip')]
58 return {
59 'version': str(data.get('tag_name') or '').lstrip('vV'),
60 'html_url': data.get('html_url') or f"https://github.com/{repo}/releases",
61 'body': data.get('body') or '',
62 'zip': zips[0] if zips else None,
63 }
66def installed_user_plugins(plugins_dir: str) -> list[dict[str, str]]:
67 """plugins_dir の直下のプラグイン({"folder", "name", "version"})。plugin.json が読めないものは飛ばす。"""
68 result: list[dict[str, str]] = []
69 if not os.path.isdir(plugins_dir): 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true
70 return result
71 for folder in sorted(os.listdir(plugins_dir)):
72 path = os.path.join(plugins_dir, folder)
73 if folder.startswith('.') or not os.path.isdir(path):
74 continue
75 manifest: dict[str, Any] | None
76 try:
77 manifest = load_plugin_manifest(path)
78 except PluginManifestError:
79 # API の版が合わなくなった古いものも、新しい版で直るかもしれないので一覧には入れる
80 manifest = _read_manifest_loosely(path)
81 if manifest is None:
82 continue
83 result.append({'folder': folder, 'name': str(manifest.get('name') or folder),
84 'version': str(manifest.get('version') or '0')})
85 return result
88def _read_manifest_loosely(path: str) -> dict[str, Any] | None:
89 try:
90 with open(os.path.join(path, PLUGIN_MANIFEST_FILENAME), encoding='utf-8') as f:
91 manifest = json.load(f)
92 except (OSError, ValueError):
93 return None
94 return manifest if isinstance(manifest, dict) else None
97def find_plugin_updates(installed: Iterable[dict[str, str]], index: list[dict[str, str]],
98 fetch_release: Callable[[str], dict[str, Any]]) -> list[dict[str, Any]]:
99 """入れたプラグインのうち、一覧にあって新しい版の zip が出ているもの。
101 Args:
102 installed: installed_user_plugins() の結果。
103 index: fetch_plugin_index() の結果。フォルダ名で、無ければ名前で突き合わせる。
104 fetch_release: リポジトリ名 → fetch_latest_plugin_release() の結果。1件の失敗は飛ばす。
106 Returns:
107 {"folder", "name", "current", "latest", "repo", "html_url", "body", "zip"} のリスト。
108 """
109 by_folder = {entry['folder']: entry for entry in index}
110 by_name = {entry['name']: entry for entry in index}
111 updates = []
112 for plugin in installed:
113 entry = by_folder.get(plugin['folder']) or by_name.get(plugin['name'])
114 if entry is None:
115 continue
116 try:
117 release = fetch_release(entry['repo'])
118 except (OSError, ValueError, KeyError, TypeError, AttributeError):
119 # 1つのリポジトリの失敗(消された・通信・想定外の応答)でほかを止めない
120 continue
121 if release.get('zip') is None or not is_newer_version(release['version'], plugin['version']):
122 continue
123 updates.append({
124 'folder': plugin['folder'], 'name': plugin['name'], 'current': plugin['version'],
125 'latest': release['version'], 'repo': entry['repo'], 'html_url': release['html_url'],
126 'body': release['body'], 'zip': release['zip'],
127 })
128 return updates
131def check_plugin_updates(plugins_dir: str, timeout: float = _DEFAULT_TIMEOUT_SEC,
132 **_ignored: Any) -> list[dict[str, Any]]:
133 """TaskRunner から呼ぶ入口。入れたものが無ければ通信しない。"""
134 installed = installed_user_plugins(plugins_dir)
135 if not installed: 135 ↛ 137line 135 didn't jump to line 137 because the condition on line 135 was always true
136 return []
137 index = fetch_plugin_index(timeout)
138 return find_plugin_updates(installed, index, lambda repo: fetch_latest_plugin_release(repo, timeout))
141def zip_manifest(zip_path: str) -> dict[str, Any]:
142 """zip の中のプラグインの plugin.json(トップのフォルダ直下)。見つからなければ PluginManifestError。"""
143 try:
144 with zipfile.ZipFile(zip_path) as zf:
145 names = [n for n in zf.namelist()
146 if n.endswith('/' + PLUGIN_MANIFEST_FILENAME) and n.count('/') == 1]
147 if not names:
148 raise PluginManifestError(f"zip に {PLUGIN_MANIFEST_FILENAME} が見つかりません。")
149 manifest = json.loads(zf.read(names[0]).decode('utf-8'))
150 except (zipfile.BadZipFile, ValueError, KeyError) as e:
151 raise PluginManifestError(f"zip の {PLUGIN_MANIFEST_FILENAME} を読めません: {e}") from e
152 if not isinstance(manifest, dict): 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true
153 raise PluginManifestError(f"{PLUGIN_MANIFEST_FILENAME} はJSONオブジェクトである必要があります。")
154 return manifest
157def check_update_zip(zip_path: str, folder: str) -> dict[str, Any]:
158 """入れ替える前の確認: 同じフォルダ名のプラグインで、この Graphica の API で読めること。"""
159 with zipfile.ZipFile(zip_path) as zf:
160 tops = {name.split('/', 1)[0] for name in zf.namelist() if name.strip('/')}
161 if tops != {folder}:
162 raise PluginManifestError(f"zip の中身が「{folder}」のプラグインではありません。")
163 manifest = zip_manifest(zip_path)
164 if not is_compatible_api_version(manifest.get('api_version')):
165 raise PluginManifestError(
166 f"新しい版はこの Graphica では使えません(プラグイン API {manifest.get('api_version')})。"
167 "Graphica を更新してから入れてください。")
168 return manifest