Coverage for graphica/core/plugin_manifest.py: 100.0%
33 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"""
2プラグインのマニフェスト(plugin.json)の読み込みと検証。
4プラグイン API の版は "主番号.小番号"。壊す変更で主番号を、互換のある追加で小番号を上げる。
5plugin.json の api_version は、主番号が本体と同じで小番号が本体以下なら読み込む
6(使う機能がそろっている)。合わないプラグインは import する前に弾く。
7entry_point キーは予約済みで未使用(常にパッケージの __init__.py の register(api) を呼ぶ)。
8"""
9import json
10import os
11from typing import Any
13PLUGIN_API_VERSION = "2.1"
14PLUGIN_MANIFEST_FILENAME = "plugin.json"
16_REQUIRED_MANIFEST_KEYS = ("name", "version", "api_version")
19class PluginManifestError(Exception):
20 """plugin.json が無い・壊れている・api_version が合わない(呼び出し側はそのプラグインを飛ばす)。"""
23def load_plugin_manifest(plugin_dir: str) -> dict[str, Any]:
24 """plugin_dir/plugin.json を辞書で返す。不正なら PluginManifestError。"""
25 manifest_path = os.path.join(plugin_dir, PLUGIN_MANIFEST_FILENAME)
26 if not os.path.exists(manifest_path):
27 raise PluginManifestError(
28 f"{PLUGIN_MANIFEST_FILENAME} が見つかりません(プラグインディレクトリ直下に必須です)。"
29 )
31 try:
32 with open(manifest_path, encoding='utf-8') as f:
33 manifest = json.load(f)
34 except (json.JSONDecodeError, OSError) as e:
35 raise PluginManifestError(f"{PLUGIN_MANIFEST_FILENAME} の読み込みに失敗しました: {e}") from e
37 if not isinstance(manifest, dict):
38 raise PluginManifestError(f"{PLUGIN_MANIFEST_FILENAME} はJSONオブジェクトである必要があります。")
40 missing_keys = [k for k in _REQUIRED_MANIFEST_KEYS if k not in manifest]
41 if missing_keys:
42 raise PluginManifestError(
43 f"{PLUGIN_MANIFEST_FILENAME} に必須キーがありません: {', '.join(missing_keys)}"
44 )
46 if not is_compatible_api_version(manifest["api_version"]):
47 raise PluginManifestError(
48 f"api_version '{manifest['api_version']}' はサポート対象外です"
49 f"(このGraphicaのプラグインAPIは '{PLUGIN_API_VERSION}'。主番号が同じで、"
50 f"小番号がこれ以下のプラグインを読み込めます)。"
51 )
53 return manifest
56def parse_api_version(text: object) -> tuple[int, int] | None:
57 """ "2.1" -> (2, 1)。形が違えば None。"""
58 parts = str(text).split(".")
59 if len(parts) != 2 or not all(p.isdigit() for p in parts):
60 return None
61 return int(parts[0]), int(parts[1])
64def is_compatible_api_version(plugin_api_version: object, app_api_version: str = PLUGIN_API_VERSION) -> bool:
65 plugin = parse_api_version(plugin_api_version)
66 app = parse_api_version(app_api_version)
67 return plugin is not None and app is not None and plugin[0] == app[0] and plugin[1] <= app[1]