Coverage for graphica/core/plugin_api.py: 98.1%
221 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"""
2Graphica のプラグイン機構。
4プラグインは探索フォルダ(gui/main_window.py の plugin_search_paths())の下の、
5`__init__.py` と `plugin.json` を持つ Python パッケージ。`__init__.py` に
6`register(api: GraphicaPluginAPI)` を定義し、その中で api.register_xxx() を呼ぶ。
8- plugin.json が無い・壊れている・api_version が合わないプラグインは import しない。
9- プラグインはサンドボックスなしで同じプロセス内で動く。信頼できる配布元のものだけ入れること。
10- 1つのプラグインの失敗で他のプラグインやアプリの起動を止めない(ログに残してスキップ)。
11- 登録はプロセス全体で1回(タブごとではない)。タブに関わる操作は、呼び出し時に渡る
12 PluginContext(core/plugin_context.py)を通す。
13- プラグインが渡す表示名(メニュー項目名など)は翻訳しない。tr() の辞書に載せると、
14 プラグイン作者が本体の翻訳辞書を意識しなければならなくなる。
15"""
16import importlib
17import importlib.util
18import logging
19import os
20import sys
22from graphica.core.analysis import register_fit_function
23# 外部プラグインが core.plugin_api から import している可能性があるため再公開する。
24from graphica.core.plugin_manifest import PLUGIN_API_VERSION, PluginManifestError, load_plugin_manifest # noqa: F401
25from graphica.core.plugin_types import (
26 PluginAnalyzer, PluginExporter, PluginHookKind, PluginImporter, PluginMenuAction,
27 PluginPanel, PluginPlotType, PluginProcessor, PluginRegistrationError,
28 PluginRenderBackend, RenderBackend,
29)
30from typing import Any, Callable
32logger = logging.getLogger(__name__)
34PLUGIN_REGISTER_FUNC = "register"
37def _normalize_extension(extension: str) -> str:
38 """".JDX" / "jdx" を ".jdx" にそろえる。"""
39 ext = extension.lower()
40 return ext if ext.startswith('.') else '.' + ext
43def _check_plugin_dependencies(info: dict[str, Any]) -> list[str]:
44 """plugin.json の requires のうち、import できないモジュール名を返す。"""
45 missing = []
46 for module_name in info.get("requires", []) or []:
47 if importlib.util.find_spec(module_name) is None:
48 missing.append(module_name)
49 return missing
52class PluginLoadError(Exception):
53 """プラグインの読み込みに失敗した(呼び出し側で捕まえて次のプラグインへ進む)。"""
56class GraphicaPluginAPI:
57 """
58 プラグインの register(api) に渡される登録窓口。
60 特定のタブへの参照は持たない。タブを操作する callback には、呼び出し時にそのタブの
61 PluginContext が渡る(最初に開いたタブを握り続けると、複数タブで取り違える)。
62 """
64 def __init__(self) -> None:
65 self._menu_actions: list[PluginMenuAction] = []
66 self._importers: dict[str, list[PluginImporter]] = {} # 拡張子 -> priority の高い順
67 self._exporters: dict[str, PluginExporter] = {} # format_name.lower() -> PluginExporter
68 self._processors: dict[str, PluginProcessor] = {}
69 self._analyzers: dict[str, PluginAnalyzer] = {}
70 self._panels: dict[str, PluginPanel] = {}
71 self._plot_types: dict[str, PluginPlotType] = {}
72 self._render_backends: dict[str, PluginRenderBackend] = {}
74 # 1プラグインの中でもフック単位で失敗を隔離し、プラグイン管理画面に出す。
75 self._registration_errors: list[PluginRegistrationError] = []
76 # register_xxx は呼び出し元のプラグインを知らないので、PluginManager が差し替える。
77 self._current_plugin_name = "(不明なプラグイン)"
79 def _safe_register(self, hook_kind: PluginHookKind, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> bool:
80 """登録に失敗してもこのフック1件だけの失敗として記録し、False を返す。"""
81 try:
82 fn(*args, **kwargs)
83 return True
84 except Exception as e:
85 self._registration_errors.append(
86 PluginRegistrationError(self._current_plugin_name, hook_kind, str(e), e)
87 )
88 logger.warning(
89 "[plugin:%s] %s の登録に失敗しました: %s",
90 self._current_plugin_name, hook_kind.value, e,
91 )
92 return False
94 def register_fit_function(self, name: str, func: Callable[..., Any], param_names: list[str],
95 p0: list[float] | Callable[..., Any] | None = None) -> bool:
96 """
97 曲線フィットの選択肢に関数を追加する。
99 Args:
100 name (str): フィットの種類の選択肢に出す名前(組み込みや他のプラグインと重複不可)。
101 func (callable): scipy.optimize.curve_fit に渡せる f(x, *params)。
102 param_names (list[str]): パラメータ名(結果の表示に使う)。
103 p0 (list[float] | callable | None): 初期値、または (x_data, y_data) -> list[float]。
104 省略時はすべて 1.0。
105 """
106 return self._safe_register(
107 PluginHookKind.FIT_FUNCTION, register_fit_function, name, func, param_names, p0=p0
108 )
110 def register_menu_action(self, text: str, callback: Callable[..., Any], shortcut: str | None = None) -> bool:
111 """
112 「プラグイン」メニューに項目を追加する。
114 Args:
115 text (str): メニューに出す文字列。
116 callback (callable): (PluginContext) -> None。選んだときのタブの窓口が渡る。
117 例外はプラグイン名付きでエラー表示され、アプリは止まらない。
118 shortcut (str | None): キーボードショートカット(例: "Ctrl+Shift+P")。
119 """
120 return self._safe_register(
121 PluginHookKind.MENU_ACTION, self._menu_actions.append,
122 PluginMenuAction(text=text, callback=callback, shortcut=shortcut,
123 plugin_name=self._current_plugin_name),
124 )
126 def register_importer(self, extensions: list[str], loader: Callable[[str], Any], *, name: str | None = None,
127 priority: int = 0) -> bool:
128 """
129 データファイルの読み込み形式を追加する。ファイルを開くダイアログ、ドラッグ&ドロップ、
130 フォルダからの一括インポートのすべてで使われる。
132 Args:
133 extensions (list[str]): 対応する拡張子(例: [".jdx", ".dx"]、ピリオドは省略可)。
134 loader (callable): (ファイルパス: str) -> pandas.DataFrame。
135 name (str | None): エラー表示に使う名前。省略時はプラグイン名。
136 priority (int): 同じ拡張子に複数登録されたときの優先度(大きいほど優先、同点は登録順)。
137 """
138 return self._safe_register(
139 PluginHookKind.IMPORTER, self._do_register_importer, extensions, loader,
140 name=name or self._current_plugin_name, priority=priority,
141 )
143 def _do_register_importer(self, extensions: list[str], loader: Callable[[str], Any], *, name: str,
144 priority: int) -> None:
145 for ext in extensions:
146 ext = _normalize_extension(ext)
147 importer = PluginImporter(extension=ext, loader=loader, name=name, priority=priority)
148 bucket = self._importers.setdefault(ext, [])
149 bucket.append(importer)
150 bucket.sort(key=lambda imp: -imp.priority) # 安定ソートなので同点は登録順のまま
152 def get_importer_for_extension(self, extension: str) -> PluginImporter | None:
153 """最も優先度の高いインポーター。無ければ None。"""
154 bucket = self._importers.get(_normalize_extension(extension))
155 return bucket[0] if bucket else None
157 def get_importer_extensions(self) -> list[str]:
158 return sorted(self._importers.keys())
160 def register_exporter(self, format_name: str, extension: str, writer: Callable[[Any, str], Any], *,
161 name: str | None = None) -> bool:
162 """
163 グラフの書き出し形式を追加する。エクスポートと一括エクスポートの両方で選べる。
165 Args:
166 format_name (str): 形式の選択肢に出す名前。
167 extension (str): 出力ファイルの拡張子(ピリオドは省略可)。
168 writer (callable): (matplotlib.figure.Figure, 出力パス: str) -> None。
169 name (str | None): エラー表示に使う名前。省略時はプラグイン名。
170 """
171 return self._safe_register(
172 PluginHookKind.EXPORTER, self._do_register_exporter, format_name, extension, writer,
173 name=name or self._current_plugin_name,
174 )
176 def _do_register_exporter(self, format_name: str, extension: str, writer: Callable[[Any, str], Any], *,
177 name: str) -> None:
178 self._exporters[format_name.lower()] = PluginExporter(
179 format_name=format_name, extension=_normalize_extension(extension), writer=writer, name=name
180 )
182 def get_exporter(self, format_name: str) -> PluginExporter | None:
183 return self._exporters.get(format_name.lower())
185 def get_exporter_for_extension(self, extension: str) -> PluginExporter | None:
186 ext = _normalize_extension(extension)
187 for exporter in self._exporters.values():
188 if exporter.extension == ext: 188 ↛ 187line 188 didn't jump to line 187 because the condition on line 188 was always true
189 return exporter
190 return None
192 def get_exporters(self) -> list[PluginExporter]:
193 return list(self._exporters.values())
195 def register_processor(self, name: str, fn: Callable[[Any, dict[str, Any]], Any], *, category: str = "general",
196 param_schema: list[dict[str, Any]] | None = None) -> bool:
197 """
198 現在のデータセットから新しいデータセットを作る処理を、「プラグイン ▸ データ処理」に追加する。
199 結果は Undo できる形で追加されるので、プラグイン側で Undo を扱う必要はない。
201 Args:
202 name (str): メニューに出す名前(他のプラグインと重複不可)。
203 fn (callable): (Dataset, dict) -> Dataset。元の Dataset は変更しないこと。
204 第2引数は param_schema から作った入力フォームの値(無ければ空の辞書)。
205 category (str): メニューでのまとまり。
206 param_schema (list[dict] | None): 入力フォームの定義。各要素は "name" と
207 "type"("int"/"float"/"str"/"bool"/"choice")を持つ。
208 例: [{"name": "window", "label": "窓幅", "type": "int", "default": 5, "min": 1, "max": 999}]
209 省略時は入力なしで実行する。
210 """
211 return self._safe_register(
212 PluginHookKind.PROCESSOR, self._do_register_processor, name, fn,
213 category=category, param_schema=param_schema, plugin_name=self._current_plugin_name,
214 )
216 def _do_register_processor(self, name: str, fn: Callable[[Any, dict[str, Any]], Any], *, category: str,
217 param_schema: list[dict[str, Any]] | None, plugin_name: str) -> None:
218 if name in self._processors:
219 raise ValueError(f"データ処理 '{name}' は既に登録されています。")
220 self._processors[name] = PluginProcessor(
221 name=name, fn=fn, category=category, param_schema=list(param_schema or []),
222 plugin_name=plugin_name,
223 )
225 def get_processors(self) -> list[PluginProcessor]:
226 return list(self._processors.values())
228 def get_processor_categories(self) -> list[str]:
229 return sorted({p.category for p in self._processors.values()})
231 def register_analyzer(self, name: str, fn: Callable[[Any, dict[str, Any]], Any], *, output_kind: str = "table",
232 param_schema: list[dict[str, Any]] | None = None) -> bool:
233 """
234 現在のデータセットを解析し、表・注釈・新しいデータセットを返す処理を、
235 「プラグイン ▸ 解析」に追加する。
237 Args:
238 name (str): メニューに出す名前(他のプラグインと重複不可)。
239 fn (callable): (Dataset, dict) -> AnalysisResult。第2引数は register_processor と同じ。
240 output_kind (str): 結果の分類(表示上の区別だけで、動作は変わらない)。
241 param_schema (list[dict] | None): register_processor と同じ形式。
242 """
243 return self._safe_register(
244 PluginHookKind.ANALYZER, self._do_register_analyzer, name, fn,
245 output_kind=output_kind, param_schema=param_schema, plugin_name=self._current_plugin_name,
246 )
248 def _do_register_analyzer(self, name: str, fn: Callable[[Any, dict[str, Any]], Any], *, output_kind: str,
249 param_schema: list[dict[str, Any]] | None, plugin_name: str) -> None:
250 if name in self._analyzers:
251 raise ValueError(f"解析 '{name}' は既に登録されています。")
252 self._analyzers[name] = PluginAnalyzer(
253 name=name, fn=fn, output_kind=output_kind, param_schema=list(param_schema or []),
254 plugin_name=plugin_name,
255 )
257 def get_analyzers(self) -> list[PluginAnalyzer]:
258 return list(self._analyzers.values())
260 def register_panel(self, name: str, widget_factory: Callable[..., Any], *, area: str = "right") -> bool:
261 """
262 ドックパネルを追加する。「プラグイン ▸ パネル」から表示を切り替えられる。
264 Args:
265 name (str): パネルのタイトル(他のプラグインと重複不可)。
266 widget_factory (callable): (PluginContext) -> QWidget。タブごとに、そのタブを
267 作るときに1回呼ばれ、そのタブの窓口が渡る。例外を出すとそのタブにはパネルを作らない。
268 area (str): "right" / "left" / "top" / "bottom"。
269 """
270 return self._safe_register(
271 PluginHookKind.PANEL, self._do_register_panel, name, widget_factory,
272 area=area, plugin_name=self._current_plugin_name,
273 )
275 def _do_register_panel(self, name: str, widget_factory: Callable[..., Any], *, area: str, plugin_name: str) -> None:
276 if name in self._panels:
277 raise ValueError(f"パネル '{name}' は既に登録されています。")
278 self._panels[name] = PluginPanel(
279 name=name, widget_factory=widget_factory, area=area, plugin_name=plugin_name,
280 )
282 def get_panels(self) -> list[PluginPanel]:
283 return list(self._panels.values())
285 def register_plot_type(self, type_name: str, drawer: Callable[..., Any], *, requires_2d: bool = False) -> bool:
286 """
287 データセットのプロット種別を追加する。
289 Args:
290 type_name (str): Dataset.plot_type に入る値で、種別の選択肢にも出る
291 (組み込みや他のプラグインと重複不可)。
292 drawer (callable): (Dataset, Axes, x_data, y_data) -> Artist | None。
293 x_data / y_data はウォーターフォールのずらしなどを適用済みの描画用の配列。
294 返した Artist は凡例に使う(不要なら None)。グラデーションなどの重ね描きは
295 組み込みの種別だけの機能で、プラグインの種別には付かない。
296 requires_2d (bool): 分類用の予約フラグ(いまは動作に影響しない)。
297 """
298 return self._safe_register(
299 PluginHookKind.PLOT_TYPE, self._do_register_plot_type, type_name, drawer,
300 requires_2d=requires_2d, plugin_name=self._current_plugin_name,
301 )
303 def _do_register_plot_type(self, type_name: str, drawer: Callable[..., Any], *, requires_2d: bool,
304 plugin_name: str) -> None:
305 if type_name in self._plot_types:
306 raise ValueError(f"プロット種別 '{type_name}' は既に登録されています。")
307 self._plot_types[type_name] = PluginPlotType(
308 type_name=type_name, drawer=drawer, requires_2d=requires_2d, plugin_name=plugin_name,
309 )
311 def get_plot_types(self) -> list[PluginPlotType]:
312 return list(self._plot_types.values())
314 def get_plot_type(self, type_name: str) -> PluginPlotType | None:
315 return self._plot_types.get(type_name)
317 def register_render_backend(self, name: str, backend: RenderBackend) -> bool:
318 """
319 描画バックエンドの登録枠(予約)。描画にはまだ接続しておらず、登録しても何も起きない。
321 Args:
322 name (str): 識別名(他のプラグインと重複不可)。
323 backend (RenderBackend): 中身の仕様は未定。
324 """
325 return self._safe_register(
326 PluginHookKind.RENDER_BACKEND, self._do_register_render_backend, name, backend,
327 plugin_name=self._current_plugin_name,
328 )
330 def _do_register_render_backend(self, name: str, backend: RenderBackend, *, plugin_name: str) -> None:
331 if name in self._render_backends:
332 raise ValueError(f"描画バックエンド '{name}' は既に登録されています。")
333 self._render_backends[name] = PluginRenderBackend(
334 name=name, backend=backend, plugin_name=plugin_name,
335 )
337 def get_render_backends(self) -> list[PluginRenderBackend]:
338 return list(self._render_backends.values())
340 @property
341 def menu_actions(self) -> list[PluginMenuAction]:
342 return list(self._menu_actions)
344 @property
345 def registration_errors(self) -> list[PluginRegistrationError]:
346 return list(self._registration_errors)
349class PluginManager:
350 """探索フォルダのプラグインを見つけて読み込み、register(api) を呼ぶ。"""
352 def __init__(self, plugins_dir: str | list[str]) -> None:
353 """plugins_dir は1つのパス、または優先順のパスのリスト。"""
354 self.plugins_dirs = [plugins_dir] if isinstance(plugins_dir, str) else list(plugins_dir)
355 self.loaded_plugins: list[dict[str, Any]] = [] # {"name", "info", "error", "disabled"}
356 self._plugin_locations: dict[str, str] = {} # プラグイン名 -> 見つかった探索フォルダ
358 def discover_plugin_dirs(self) -> list[str]:
359 """__init__.py を持つサブフォルダ名を返す。同名は先に見つかった探索フォルダのものを使う。"""
360 self._plugin_locations = {}
361 names = []
362 for plugins_dir in self.plugins_dirs:
363 if not os.path.isdir(plugins_dir):
364 continue
365 for entry in sorted(os.listdir(plugins_dir)):
366 entry_path = os.path.join(plugins_dir, entry)
367 if not (os.path.isdir(entry_path) and os.path.exists(os.path.join(entry_path, "__init__.py"))):
368 continue
369 if entry in self._plugin_locations:
370 logger.warning(
371 "プラグイン '%s' は複数の探索パスに存在するため、'%s' のものを使用します"
372 "('%s' は無視されます)。",
373 entry, self._plugin_locations[entry], plugins_dir,
374 )
375 continue
376 self._plugin_locations[entry] = plugins_dir
377 names.append(entry)
378 return names
380 def _load_module(self, plugin_name: str) -> Any:
381 base_dir = self._plugin_locations[plugin_name]
382 init_path = os.path.join(base_dir, plugin_name, "__init__.py")
383 module_name = f"graphica_plugin_{plugin_name}"
384 spec = importlib.util.spec_from_file_location(
385 module_name, init_path,
386 submodule_search_locations=[os.path.join(base_dir, plugin_name)],
387 )
388 if spec is None or spec.loader is None: 388 ↛ 389line 388 didn't jump to line 389 because the condition on line 388 was never true
389 raise PluginLoadError(f"プラグイン '{plugin_name}' のモジュール仕様を作成できませんでした。")
390 module = importlib.util.module_from_spec(spec)
391 sys.modules[module_name] = module
392 try:
393 spec.loader.exec_module(module)
394 except Exception as e:
395 sys.modules.pop(module_name, None)
396 raise PluginLoadError(f"プラグイン '{plugin_name}' の読み込み中にエラー: {e}") from e
397 return module
399 def load_all(self, api: GraphicaPluginAPI, disabled_names: set[str] | None = None) -> list[dict[str, Any]]:
400 """
401 すべてのプラグインを読み込み register(api) を呼ぶ。1つが失敗しても続ける。
403 disabled_names に含まれるプラグインは、表示用に manifest だけ読み、import しない。
404 """
405 disabled_names = disabled_names or set()
406 self.loaded_plugins = []
407 for plugin_name in self.discover_plugin_dirs():
408 record: dict[str, Any] = {"name": plugin_name, "info": None, "error": None, "disabled": False}
409 plugin_dir = os.path.join(self._plugin_locations[plugin_name], plugin_name)
411 if plugin_name in disabled_names:
412 record["disabled"] = True
413 try:
414 record["info"] = load_plugin_manifest(plugin_dir)
415 except PluginManifestError:
416 logger.debug("無効化中のプラグイン '%s' の manifest を読めません", plugin_name, exc_info=True)
417 self.loaded_plugins.append(record)
418 continue
420 try:
421 # manifest は import より前に検証する。弾いたプラグインのコードは一切実行しない。
422 info = load_plugin_manifest(plugin_dir)
423 record["info"] = info
425 missing = _check_plugin_dependencies(info)
426 if missing:
427 raise PluginLoadError(
428 f"プラグイン '{plugin_name}' の依存パッケージが不足しています: "
429 f"{', '.join(missing)}。プラグインは本体に同梱済みの依存"
430 "(numpy/pandas/scipy/matplotlib/PySide6等)のみ使用できます。"
431 "pip版のGraphicaであれば追加の依存パッケージを導入して動作させられます。"
432 )
434 module = self._load_module(plugin_name)
435 register_func = getattr(module, PLUGIN_REGISTER_FUNC, None)
436 if not callable(register_func):
437 raise PluginLoadError(
438 f"プラグイン '{plugin_name}' に {PLUGIN_REGISTER_FUNC}(api) 関数がありません。"
439 )
440 api._current_plugin_name = plugin_name
441 register_func(api)
443 except Exception as e:
444 # 本体側のコード(manifest の解析など)の不具合もここに来るので、traceback を残す。
445 record["error"] = str(e)
446 logger.exception("プラグイン '%s' の読み込みに失敗しました: %s", plugin_name, e)
448 self.loaded_plugins.append(record)
450 return self.loaded_plugins
453# 登録先(フィット関数の辞書など)がプロセス全体で1つなので、読み込みもプロセスで1回だけ行う。
454# タブごとに読み込むと「既に登録されています」で失敗する。
455_singleton_api = None
456_singleton_manager = None
458# main.py が load_plugins_once() の最初の呼び出しより前に設定する。
459_safe_mode_enabled = False
462def set_safe_mode(enabled: bool) -> None:
463 """load_plugins_once() が一度呼ばれたあとに変えても、読み込み済みの結果には影響しない。"""
464 global _safe_mode_enabled
465 _safe_mode_enabled = bool(enabled)
468def is_safe_mode_enabled() -> bool:
469 return _safe_mode_enabled
472def load_plugins_once(plugins_dir: str | list[str], disabled_names: set[str] | None = None) -> "GraphicaPluginAPI":
473 """
474 最初の呼び出しでだけプラグインを読み込み、以後は同じ GraphicaPluginAPI を返す。
475 セーフモードでは探索フォルダに一切触れず(作成もしない)、空の API を返す。
476 """
477 global _singleton_api, _singleton_manager
478 if _singleton_api is not None:
479 return _singleton_api
481 if is_safe_mode_enabled():
482 _singleton_api = GraphicaPluginAPI()
483 _singleton_manager = None
484 return _singleton_api
486 dirs = [plugins_dir] if isinstance(plugins_dir, str) else list(plugins_dir)
487 for d in dirs:
488 os.makedirs(d, exist_ok=True)
489 _singleton_api = GraphicaPluginAPI()
490 _singleton_manager = PluginManager(plugins_dir)
491 _singleton_manager.load_all(_singleton_api, disabled_names=disabled_names)
492 return _singleton_api
495def get_loaded_plugin_records() -> list[dict[str, Any]] | None:
496 """未読み込みなら None。"""
497 return None if _singleton_manager is None else list(_singleton_manager.loaded_plugins)
500def get_plugin_registration_errors() -> list[PluginRegistrationError] | None:
501 """フック単位の登録失敗(プラグイン全体の読み込みは成功していても記録される)。未読み込みなら None。"""
502 return None if _singleton_api is None else _singleton_api.registration_errors
505def get_plugin_api() -> "GraphicaPluginAPI | None":
506 """読み込み済みの API。未読み込みなら None(読み込みはしない)。"""
507 return _singleton_api
510def get_registered_importer_extensions() -> list[str]:
511 return _singleton_api.get_importer_extensions() if _singleton_api is not None else []
514def get_registered_exporters() -> list[PluginExporter]:
515 return _singleton_api.get_exporters() if _singleton_api is not None else []