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

1""" 

2プラグインのマニフェスト(plugin.json)の読み込みと検証。 

3 

4プラグイン API の版は "主番号.小番号"。壊す変更で主番号を、互換のある追加で小番号を上げる。 

5plugin.json の api_version は、主番号が本体と同じで小番号が本体以下なら読み込む 

6(使う機能がそろっている)。合わないプラグインは import する前に弾く。 

7entry_point キーは予約済みで未使用(常にパッケージの __init__.py の register(api) を呼ぶ)。 

8""" 

9import json 

10import os 

11from typing import Any 

12 

13PLUGIN_API_VERSION = "2.1" 

14PLUGIN_MANIFEST_FILENAME = "plugin.json" 

15 

16_REQUIRED_MANIFEST_KEYS = ("name", "version", "api_version") 

17 

18 

19class PluginManifestError(Exception): 

20 """plugin.json が無い・壊れている・api_version が合わない(呼び出し側はそのプラグインを飛ばす)。""" 

21 

22 

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 ) 

30 

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 

36 

37 if not isinstance(manifest, dict): 

38 raise PluginManifestError(f"{PLUGIN_MANIFEST_FILENAME} はJSONオブジェクトである必要があります。") 

39 

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 ) 

45 

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 ) 

52 

53 return manifest 

54 

55 

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]) 

62 

63 

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]