graphica-plot 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. graphica/Graphica.ico +0 -0
  2. graphica/__init__.py +1 -0
  3. graphica/__main__.py +111 -0
  4. graphica/assets/__init__.py +0 -0
  5. graphica/assets/icons/__init__.py +0 -0
  6. graphica/assets/icons/arrow-right.svg +21 -0
  7. graphica/assets/icons/bold.svg +20 -0
  8. graphica/assets/icons/calculator.svg +26 -0
  9. graphica/assets/icons/chart-histogram.svg +24 -0
  10. graphica/assets/icons/chart-line.svg +20 -0
  11. graphica/assets/icons/chevron-down.svg +19 -0
  12. graphica/assets/icons/chevron-right.svg +19 -0
  13. graphica/assets/icons/color-swatch.svg +22 -0
  14. graphica/assets/icons/column-insert-right.svg +21 -0
  15. graphica/assets/icons/column-remove.svg +21 -0
  16. graphica/assets/icons/copy.svg +20 -0
  17. graphica/assets/icons/download.svg +21 -0
  18. graphica/assets/icons/edit.svg +21 -0
  19. graphica/assets/icons/eye-off.svg +21 -0
  20. graphica/assets/icons/eye.svg +20 -0
  21. graphica/assets/icons/file-plus.svg +22 -0
  22. graphica/assets/icons/folder-plus.svg +21 -0
  23. graphica/assets/icons/folder.svg +19 -0
  24. graphica/assets/icons/highlight.svg +22 -0
  25. graphica/assets/icons/history.svg +20 -0
  26. graphica/assets/icons/italic.svg +21 -0
  27. graphica/assets/icons/layout-grid.svg +22 -0
  28. graphica/assets/icons/math-function.svg +22 -0
  29. graphica/assets/icons/message-2.svg +21 -0
  30. graphica/assets/icons/mountain.svg +20 -0
  31. graphica/assets/icons/palette.svg +22 -0
  32. graphica/assets/icons/pointer.svg +19 -0
  33. graphica/assets/icons/refresh.svg +20 -0
  34. graphica/assets/icons/row-insert-bottom.svg +21 -0
  35. graphica/assets/icons/row-remove.svg +21 -0
  36. graphica/assets/icons/search.svg +20 -0
  37. graphica/assets/icons/select-all.svg +35 -0
  38. graphica/assets/icons/subscript.svg +20 -0
  39. graphica/assets/icons/superscript.svg +20 -0
  40. graphica/assets/icons/table.svg +21 -0
  41. graphica/assets/icons/trash.svg +23 -0
  42. graphica/assets/icons/typography.svg +23 -0
  43. graphica/assets/icons/x.svg +20 -0
  44. graphica/core/__init__.py +0 -0
  45. graphica/core/analysis.py +1278 -0
  46. graphica/core/app_paths.py +35 -0
  47. graphica/core/axis_settings.py +128 -0
  48. graphica/core/caption_export.py +47 -0
  49. graphica/core/color_palettes.py +56 -0
  50. graphica/core/commands.py +226 -0
  51. graphica/core/cvd_simulation.py +49 -0
  52. graphica/core/dataset.py +443 -0
  53. graphica/core/diagnostics.py +105 -0
  54. graphica/core/excel_utils.py +58 -0
  55. graphica/core/fit_models.py +379 -0
  56. graphica/core/grid_data.py +128 -0
  57. graphica/core/i18n.py +44 -0
  58. graphica/core/json_utils.py +27 -0
  59. graphica/core/label_utils.py +21 -0
  60. graphica/core/methods_text.py +115 -0
  61. graphica/core/named_colors.py +126 -0
  62. graphica/core/plugin_api.py +515 -0
  63. graphica/core/plugin_context.py +167 -0
  64. graphica/core/plugin_install.py +82 -0
  65. graphica/core/plugin_manifest.py +67 -0
  66. graphica/core/plugin_testing.py +213 -0
  67. graphica/core/plugin_types.py +118 -0
  68. graphica/core/provenance.py +19 -0
  69. graphica/core/report_export.py +51 -0
  70. graphica/core/safe_eval.py +194 -0
  71. graphica/core/script_export.py +272 -0
  72. graphica/core/translations_en.py +530 -0
  73. graphica/core/unit_conversion.py +51 -0
  74. graphica/core/update_check.py +43 -0
  75. graphica/core/version.py +14 -0
  76. graphica/gui/__init__.py +0 -0
  77. graphica/gui/app_settings.py +119 -0
  78. graphica/gui/axis_bindings.py +173 -0
  79. graphica/gui/binding.py +135 -0
  80. graphica/gui/builders/__init__.py +1 -0
  81. graphica/gui/builders/axis_panel.py +431 -0
  82. graphica/gui/builders/canvas_area.py +176 -0
  83. graphica/gui/builders/common.py +120 -0
  84. graphica/gui/builders/dataset_panel.py +433 -0
  85. graphica/gui/builders/property_sections.py +208 -0
  86. graphica/gui/canvas.py +519 -0
  87. graphica/gui/color_history.py +47 -0
  88. graphica/gui/color_picker_widget.py +198 -0
  89. graphica/gui/crash_handler.py +77 -0
  90. graphica/gui/cvd_preview.py +23 -0
  91. graphica/gui/data_editor.py +761 -0
  92. graphica/gui/data_import_flow.py +355 -0
  93. graphica/gui/dataset_bindings.py +107 -0
  94. graphica/gui/dataset_style_icon.py +80 -0
  95. graphica/gui/datasets/__init__.py +1 -0
  96. graphica/gui/datasets/actions_menu.py +114 -0
  97. graphica/gui/datasets/colors.py +218 -0
  98. graphica/gui/datasets/fitting.py +70 -0
  99. graphica/gui/datasets/host.py +162 -0
  100. graphica/gui/datasets/operations/__init__.py +1 -0
  101. graphica/gui/datasets/operations/fitting.py +468 -0
  102. graphica/gui/datasets/operations/peaks.py +109 -0
  103. graphica/gui/datasets/operations/processing.py +659 -0
  104. graphica/gui/datasets/operations/runner.py +148 -0
  105. graphica/gui/datasets/operations/transfer.py +227 -0
  106. graphica/gui/datasets/order.py +310 -0
  107. graphica/gui/datasets/overlays.py +74 -0
  108. graphica/gui/datasets/peaks.py +21 -0
  109. graphica/gui/datasets/plugin_runs.py +99 -0
  110. graphica/gui/datasets/processing.py +68 -0
  111. graphica/gui/datasets/property_panel.py +538 -0
  112. graphica/gui/datasets/transfer.py +36 -0
  113. graphica/gui/detached_canvas_window.py +22 -0
  114. graphica/gui/dialogs/__init__.py +137 -0
  115. graphica/gui/dialogs/analysis.py +1322 -0
  116. graphica/gui/dialogs/app.py +1015 -0
  117. graphica/gui/dialogs/appearance.py +851 -0
  118. graphica/gui/dialogs/data_edit.py +577 -0
  119. graphica/gui/dialogs/data_import.py +679 -0
  120. graphica/gui/dialogs/export.py +433 -0
  121. graphica/gui/dock_layout.py +176 -0
  122. graphica/gui/export_preview_panel.py +321 -0
  123. graphica/gui/export_settings.py +16 -0
  124. graphica/gui/file_association.py +97 -0
  125. graphica/gui/icon_utils.py +44 -0
  126. graphica/gui/main_app_window.py +210 -0
  127. graphica/gui/main_window.py +1151 -0
  128. graphica/gui/mathtext_preview.py +168 -0
  129. graphica/gui/menu_bar.py +320 -0
  130. graphica/gui/minimap_widget.py +151 -0
  131. graphica/gui/mixins/__init__.py +0 -0
  132. graphica/gui/mixins/export_mixin.py +520 -0
  133. graphica/gui/mixins/help_mixin.py +127 -0
  134. graphica/gui/mixins/project_io_mixin.py +289 -0
  135. graphica/gui/mixins/quick_access_mixin.py +194 -0
  136. graphica/gui/mixins/ui_setup_mixin.py +206 -0
  137. graphica/gui/notify.py +61 -0
  138. graphica/gui/panels/__init__.py +43 -0
  139. graphica/gui/panels/axis_settings.py +605 -0
  140. graphica/gui/panels/dataset_tree.py +424 -0
  141. graphica/gui/plot_type_drawers.py +131 -0
  142. graphica/gui/plugin_context.py +119 -0
  143. graphica/gui/project_files.py +361 -0
  144. graphica/gui/provenance_panel.py +65 -0
  145. graphica/gui/rendering/__init__.py +1 -0
  146. graphica/gui/rendering/annotations.py +177 -0
  147. graphica/gui/rendering/appearance.py +368 -0
  148. graphica/gui/rendering/common.py +316 -0
  149. graphica/gui/rendering/data_1d.py +376 -0
  150. graphica/gui/rendering/data_2d.py +66 -0
  151. graphica/gui/residual_panel.py +62 -0
  152. graphica/gui/resources.py +16 -0
  153. graphica/gui/single_instance.py +141 -0
  154. graphica/gui/splash.py +83 -0
  155. graphica/gui/task_runner.py +41 -0
  156. graphica/gui/theme.py +864 -0
  157. graphica/gui/tools/__init__.py +56 -0
  158. graphica/gui/tools/annotation.py +275 -0
  159. graphica/gui/tools/cursor.py +259 -0
  160. graphica/gui/tools/layout_edit.py +285 -0
  161. graphica/gui/tools/manager.py +78 -0
  162. graphica/gui/tools/peak_placement.py +132 -0
  163. graphica/gui/tools/range_select.py +191 -0
  164. graphica/gui/tools/region_highlight.py +222 -0
  165. graphica/gui/tools/slice_extraction.py +200 -0
  166. graphica/gui/widget_translation.py +46 -0
  167. graphica/gui/workers.py +280 -0
  168. graphica/models/__init__.py +0 -0
  169. graphica/models/project.py +197 -0
  170. graphica/plugin/__init__.py +20 -0
  171. graphica/plugin/testing.py +57 -0
  172. graphica/sample_data/__init__.py +0 -0
  173. graphica/sample_data/cooling_curve_sample.csv +42 -0
  174. graphica/ui_main_window.py +581 -0
  175. graphica_plot-2.0.0.dist-info/METADATA +317 -0
  176. graphica_plot-2.0.0.dist-info/RECORD +181 -0
  177. graphica_plot-2.0.0.dist-info/WHEEL +5 -0
  178. graphica_plot-2.0.0.dist-info/entry_points.txt +2 -0
  179. graphica_plot-2.0.0.dist-info/licenses/LICENSE +30 -0
  180. graphica_plot-2.0.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +91 -0
  181. graphica_plot-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,515 @@
1
+ """
2
+ Graphica のプラグイン機構。
3
+
4
+ プラグインは探索フォルダ(gui/main_window.py の plugin_search_paths())の下の、
5
+ `__init__.py` と `plugin.json` を持つ Python パッケージ。`__init__.py` に
6
+ `register(api: GraphicaPluginAPI)` を定義し、その中で api.register_xxx() を呼ぶ。
7
+
8
+ - plugin.json が無い・壊れている・api_version が合わないプラグインは import しない。
9
+ - プラグインはサンドボックスなしで同じプロセス内で動く。信頼できる配布元のものだけ入れること。
10
+ - 1つのプラグインの失敗で他のプラグインやアプリの起動を止めない(ログに残してスキップ)。
11
+ - 登録はプロセス全体で1回(タブごとではない)。タブに関わる操作は、呼び出し時に渡る
12
+ PluginContext(core/plugin_context.py)を通す。
13
+ - プラグインが渡す表示名(メニュー項目名など)は翻訳しない。tr() の辞書に載せると、
14
+ プラグイン作者が本体の翻訳辞書を意識しなければならなくなる。
15
+ """
16
+ import importlib
17
+ import importlib.util
18
+ import logging
19
+ import os
20
+ import sys
21
+
22
+ from graphica.core.analysis import register_fit_function
23
+ # 外部プラグインが core.plugin_api から import している可能性があるため再公開する。
24
+ from graphica.core.plugin_manifest import PLUGIN_API_VERSION, PluginManifestError, load_plugin_manifest # noqa: F401
25
+ from graphica.core.plugin_types import (
26
+ PluginAnalyzer, PluginExporter, PluginHookKind, PluginImporter, PluginMenuAction,
27
+ PluginPanel, PluginPlotType, PluginProcessor, PluginRegistrationError,
28
+ PluginRenderBackend, RenderBackend,
29
+ )
30
+ from typing import Any, Callable
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ PLUGIN_REGISTER_FUNC = "register"
35
+
36
+
37
+ def _normalize_extension(extension: str) -> str:
38
+ """".JDX" / "jdx" を ".jdx" にそろえる。"""
39
+ ext = extension.lower()
40
+ return ext if ext.startswith('.') else '.' + ext
41
+
42
+
43
+ def _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
50
+
51
+
52
+ class PluginLoadError(Exception):
53
+ """プラグインの読み込みに失敗した(呼び出し側で捕まえて次のプラグインへ進む)。"""
54
+
55
+
56
+ class GraphicaPluginAPI:
57
+ """
58
+ プラグインの register(api) に渡される登録窓口。
59
+
60
+ 特定のタブへの参照は持たない。タブを操作する callback には、呼び出し時にそのタブの
61
+ PluginContext が渡る(最初に開いたタブを握り続けると、複数タブで取り違える)。
62
+ """
63
+
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] = {}
73
+
74
+ # 1プラグインの中でもフック単位で失敗を隔離し、プラグイン管理画面に出す。
75
+ self._registration_errors: list[PluginRegistrationError] = []
76
+ # register_xxx は呼び出し元のプラグインを知らないので、PluginManager が差し替える。
77
+ self._current_plugin_name = "(不明なプラグイン)"
78
+
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
93
+
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
+ 曲線フィットの選択肢に関数を追加する。
98
+
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
+ )
109
+
110
+ def register_menu_action(self, text: str, callback: Callable[..., Any], shortcut: str | None = None) -> bool:
111
+ """
112
+ 「プラグイン」メニューに項目を追加する。
113
+
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
+ )
125
+
126
+ def register_importer(self, extensions: list[str], loader: Callable[[str], Any], *, name: str | None = None,
127
+ priority: int = 0) -> bool:
128
+ """
129
+ データファイルの読み込み形式を追加する。ファイルを開くダイアログ、ドラッグ&ドロップ、
130
+ フォルダからの一括インポートのすべてで使われる。
131
+
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
+ )
142
+
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) # 安定ソートなので同点は登録順のまま
151
+
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
156
+
157
+ def get_importer_extensions(self) -> list[str]:
158
+ return sorted(self._importers.keys())
159
+
160
+ def register_exporter(self, format_name: str, extension: str, writer: Callable[[Any, str], Any], *,
161
+ name: str | None = None) -> bool:
162
+ """
163
+ グラフの書き出し形式を追加する。エクスポートと一括エクスポートの両方で選べる。
164
+
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
+ )
175
+
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
+ )
181
+
182
+ def get_exporter(self, format_name: str) -> PluginExporter | None:
183
+ return self._exporters.get(format_name.lower())
184
+
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:
189
+ return exporter
190
+ return None
191
+
192
+ def get_exporters(self) -> list[PluginExporter]:
193
+ return list(self._exporters.values())
194
+
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 を扱う必要はない。
200
+
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
+ )
215
+
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
+ )
224
+
225
+ def get_processors(self) -> list[PluginProcessor]:
226
+ return list(self._processors.values())
227
+
228
+ def get_processor_categories(self) -> list[str]:
229
+ return sorted({p.category for p in self._processors.values()})
230
+
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
+ 「プラグイン ▸ 解析」に追加する。
236
+
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
+ )
247
+
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
+ )
256
+
257
+ def get_analyzers(self) -> list[PluginAnalyzer]:
258
+ return list(self._analyzers.values())
259
+
260
+ def register_panel(self, name: str, widget_factory: Callable[..., Any], *, area: str = "right") -> bool:
261
+ """
262
+ ドックパネルを追加する。「プラグイン ▸ パネル」から表示を切り替えられる。
263
+
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
+ )
274
+
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
+ )
281
+
282
+ def get_panels(self) -> list[PluginPanel]:
283
+ return list(self._panels.values())
284
+
285
+ def register_plot_type(self, type_name: str, drawer: Callable[..., Any], *, requires_2d: bool = False) -> bool:
286
+ """
287
+ データセットのプロット種別を追加する。
288
+
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
+ )
302
+
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
+ )
310
+
311
+ def get_plot_types(self) -> list[PluginPlotType]:
312
+ return list(self._plot_types.values())
313
+
314
+ def get_plot_type(self, type_name: str) -> PluginPlotType | None:
315
+ return self._plot_types.get(type_name)
316
+
317
+ def register_render_backend(self, name: str, backend: RenderBackend) -> bool:
318
+ """
319
+ 描画バックエンドの登録枠(予約)。描画にはまだ接続しておらず、登録しても何も起きない。
320
+
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
+ )
329
+
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
+ )
336
+
337
+ def get_render_backends(self) -> list[PluginRenderBackend]:
338
+ return list(self._render_backends.values())
339
+
340
+ @property
341
+ def menu_actions(self) -> list[PluginMenuAction]:
342
+ return list(self._menu_actions)
343
+
344
+ @property
345
+ def registration_errors(self) -> list[PluginRegistrationError]:
346
+ return list(self._registration_errors)
347
+
348
+
349
+ class PluginManager:
350
+ """探索フォルダのプラグインを見つけて読み込み、register(api) を呼ぶ。"""
351
+
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] = {} # プラグイン名 -> 見つかった探索フォルダ
357
+
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
379
+
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:
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
398
+
399
+ def load_all(self, api: GraphicaPluginAPI, disabled_names: set[str] | None = None) -> list[dict[str, Any]]:
400
+ """
401
+ すべてのプラグインを読み込み register(api) を呼ぶ。1つが失敗しても続ける。
402
+
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)
410
+
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
419
+
420
+ try:
421
+ # manifest は import より前に検証する。弾いたプラグインのコードは一切実行しない。
422
+ info = load_plugin_manifest(plugin_dir)
423
+ record["info"] = info
424
+
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
+ )
433
+
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)
442
+
443
+ except Exception as e:
444
+ # 本体側のコード(manifest の解析など)の不具合もここに来るので、traceback を残す。
445
+ record["error"] = str(e)
446
+ logger.exception("プラグイン '%s' の読み込みに失敗しました: %s", plugin_name, e)
447
+
448
+ self.loaded_plugins.append(record)
449
+
450
+ return self.loaded_plugins
451
+
452
+
453
+ # 登録先(フィット関数の辞書など)がプロセス全体で1つなので、読み込みもプロセスで1回だけ行う。
454
+ # タブごとに読み込むと「既に登録されています」で失敗する。
455
+ _singleton_api = None
456
+ _singleton_manager = None
457
+
458
+ # main.py が load_plugins_once() の最初の呼び出しより前に設定する。
459
+ _safe_mode_enabled = False
460
+
461
+
462
+ def set_safe_mode(enabled: bool) -> None:
463
+ """load_plugins_once() が一度呼ばれたあとに変えても、読み込み済みの結果には影響しない。"""
464
+ global _safe_mode_enabled
465
+ _safe_mode_enabled = bool(enabled)
466
+
467
+
468
+ def is_safe_mode_enabled() -> bool:
469
+ return _safe_mode_enabled
470
+
471
+
472
+ def 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
480
+
481
+ if is_safe_mode_enabled():
482
+ _singleton_api = GraphicaPluginAPI()
483
+ _singleton_manager = None
484
+ return _singleton_api
485
+
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
493
+
494
+
495
+ def 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)
498
+
499
+
500
+ def get_plugin_registration_errors() -> list[PluginRegistrationError] | None:
501
+ """フック単位の登録失敗(プラグイン全体の読み込みは成功していても記録される)。未読み込みなら None。"""
502
+ return None if _singleton_api is None else _singleton_api.registration_errors
503
+
504
+
505
+ def get_plugin_api() -> "GraphicaPluginAPI | None":
506
+ """読み込み済みの API。未読み込みなら None(読み込みはしない)。"""
507
+ return _singleton_api
508
+
509
+
510
+ def get_registered_importer_extensions() -> list[str]:
511
+ return _singleton_api.get_importer_extensions() if _singleton_api is not None else []
512
+
513
+
514
+ def get_registered_exporters() -> list[PluginExporter]:
515
+ return _singleton_api.get_exporters() if _singleton_api is not None else []
@@ -0,0 +1,167 @@
1
+ """
2
+ プラグインが本体を操作するための窓口(プラグイン API 2.0)。
3
+
4
+ メニューの callback とパネルの widget_factory が受け取る。1つのタブと1つのプラグインの
5
+ 組ごとに1つ作られるので、どのメソッドも「そのタブ」を相手にする(複数タブでも取り違えない)。
6
+ 本体の内部(PlotterApp やその private メソッド)には、この窓口の外から触らないこと。
7
+ 本体の実装は gui/plugin_context.py、テスト用は core.plugin_testing.FakePluginContext。
8
+ """
9
+ from typing import TYPE_CHECKING, Any, Callable
10
+ if TYPE_CHECKING:
11
+ from graphica.core.dataset import Dataset
12
+
13
+
14
+ class PluginContext:
15
+ """プラグイン向けの窓口の仕様。メソッドの中身は実装側が持つ。"""
16
+
17
+ # --- データセット ---
18
+
19
+ def datasets(self) -> "list[Dataset]":
20
+ """
21
+ タブの全データセットを返す。
22
+
23
+ Returns:
24
+ list[Dataset]: 表示順。リスト自体はコピーなので、並べ替えても本体は変わらない。
25
+ 要素の Dataset は本体と同じオブジェクト。変更は set_dataset_properties() で行う。
26
+ """
27
+ raise NotImplementedError
28
+
29
+ def current_dataset(self) -> "Dataset | None":
30
+ """
31
+ Returns:
32
+ Dataset | None: データセット一覧で現在選ばれているもの。なければ None。
33
+ """
34
+ raise NotImplementedError
35
+
36
+ def selected_datasets(self) -> "list[Dataset]":
37
+ """
38
+ Returns:
39
+ list[Dataset]: データセット一覧で選択されているもの(複数選択を含む)。
40
+ """
41
+ raise NotImplementedError
42
+
43
+ def add_dataset(self, dataset: "Dataset", description: str | None = None) -> None:
44
+ """
45
+ データセットを追加する。Undo で取り消せる。
46
+
47
+ Args:
48
+ dataset (Dataset): 追加するデータセット。
49
+ description (str | None): 「元に戻す」メニューに出す説明。省略時は既定の文言。
50
+ """
51
+ raise NotImplementedError
52
+
53
+ def set_dataset_properties(self, dataset: "Dataset", values: dict[str, Any], description: str | None = None) -> None:
54
+ """
55
+ データセットの属性をまとめて変更し、再描画する。Undo で取り消せる。
56
+
57
+ Args:
58
+ dataset (Dataset): 対象。datasets() などで得たもの。
59
+ values (dict): {属性名: 新しい値}。例: {"color": "#1f77b4", "linewidth": 2.0}
60
+ description (str | None): 「元に戻す」メニューに出す説明。
61
+ Raises:
62
+ AttributeError: Dataset に無い属性名が含まれる。
63
+ """
64
+ raise NotImplementedError
65
+
66
+ def redraw(self) -> None:
67
+ """グラフを描き直す。データセットの中身を直接変えたあとに呼ぶ。"""
68
+ raise NotImplementedError
69
+
70
+ # --- 変化の通知 ---
71
+
72
+ def on_datasets_changed(self, callback: Callable[[], Any]) -> None:
73
+ """
74
+ データセットの追加・削除・変更・プロジェクトの読み込みのあとに callback() を呼ぶ。
75
+ パネルの表示を最新に保つのに使う。描き直しのたびに呼ばれるので、callback は軽くする。
76
+
77
+ Args:
78
+ callback (callable): 引数なし。
79
+ """
80
+ raise NotImplementedError
81
+
82
+ def on_selection_changed(self, callback: Callable[["Dataset | None"], Any]) -> None:
83
+ """
84
+ データセット一覧の選択が変わったら callback(current_dataset) を呼ぶ。
85
+
86
+ Args:
87
+ callback (callable): Dataset | None を1つ受け取る。
88
+ """
89
+ raise NotImplementedError
90
+
91
+ # --- 画面 ---
92
+
93
+ @property
94
+ def parent_widget(self) -> Any:
95
+ """
96
+ Returns:
97
+ QWidget | None: ダイアログの親にするウィンドウ。
98
+ """
99
+ raise NotImplementedError
100
+
101
+ def show_message(self, text: str, title: str | None = None) -> None:
102
+ """情報メッセージを表示する。title の省略時はプラグイン名。"""
103
+ raise NotImplementedError
104
+
105
+ def show_error(self, text: str, title: str | None = None) -> None:
106
+ """エラーメッセージを表示する。title の省略時はプラグイン名。"""
107
+ raise NotImplementedError
108
+
109
+ # --- 保存場所 ---
110
+
111
+ @property
112
+ def data_dir(self) -> str:
113
+ """
114
+ Returns:
115
+ str: このプラグイン専用の書き込み可能なフォルダ(無ければ作る)。設定やライブラリの保存先。
116
+ 本体のインストール先は読み取り専用のことがあるので、ここ以外には書かない。
117
+ """
118
+ raise NotImplementedError
119
+
120
+ # --- 色 ---
121
+
122
+ def named_colors(self) -> list[dict[str, str]]:
123
+ """
124
+ 本体に登録された名前付きの色。
125
+
126
+ Returns:
127
+ list[dict]: [{"name": str, "color": "#rrggbb"}, ...]。並び順は利用者が決めた表示順。
128
+ """
129
+ raise NotImplementedError
130
+
131
+ def set_named_colors(self, entries: list[dict[str, str]]) -> None:
132
+ """
133
+ 名前付きの色を丸ごと置き換える。
134
+
135
+ Args:
136
+ entries (list[dict]): named_colors() と同じ形。
137
+ Raises:
138
+ core.named_colors.NamedColorError: 名前か色コードが不正、または名前が重複している。
139
+ """
140
+ raise NotImplementedError
141
+
142
+ def color_palettes(self) -> dict[str, list[str]]:
143
+ """
144
+ 利用者が作った配色パレット(組み込みのパレットは含まない)。
145
+
146
+ Returns:
147
+ dict[str, list[str]]: {パレット名: ["#rrggbb", ...]}
148
+ """
149
+ raise NotImplementedError
150
+
151
+ def set_color_palettes(self, palettes: dict[str, list[str]]) -> None:
152
+ """
153
+ 利用者の配色パレットを丸ごと置き換える。
154
+
155
+ Args:
156
+ palettes (dict[str, list[str]]): color_palettes() と同じ形。
157
+ Raises:
158
+ ValueError: 形が不正、または色コードが不正。
159
+ """
160
+ raise NotImplementedError
161
+
162
+ def active_color_cycle(self) -> list[str]:
163
+ """
164
+ Returns:
165
+ list[str]: いま選ばれているパレットの色(系列に順に割り当てる色)。
166
+ """
167
+ raise NotImplementedError