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.
- graphica/Graphica.ico +0 -0
- graphica/__init__.py +1 -0
- graphica/__main__.py +111 -0
- graphica/assets/__init__.py +0 -0
- graphica/assets/icons/__init__.py +0 -0
- graphica/assets/icons/arrow-right.svg +21 -0
- graphica/assets/icons/bold.svg +20 -0
- graphica/assets/icons/calculator.svg +26 -0
- graphica/assets/icons/chart-histogram.svg +24 -0
- graphica/assets/icons/chart-line.svg +20 -0
- graphica/assets/icons/chevron-down.svg +19 -0
- graphica/assets/icons/chevron-right.svg +19 -0
- graphica/assets/icons/color-swatch.svg +22 -0
- graphica/assets/icons/column-insert-right.svg +21 -0
- graphica/assets/icons/column-remove.svg +21 -0
- graphica/assets/icons/copy.svg +20 -0
- graphica/assets/icons/download.svg +21 -0
- graphica/assets/icons/edit.svg +21 -0
- graphica/assets/icons/eye-off.svg +21 -0
- graphica/assets/icons/eye.svg +20 -0
- graphica/assets/icons/file-plus.svg +22 -0
- graphica/assets/icons/folder-plus.svg +21 -0
- graphica/assets/icons/folder.svg +19 -0
- graphica/assets/icons/highlight.svg +22 -0
- graphica/assets/icons/history.svg +20 -0
- graphica/assets/icons/italic.svg +21 -0
- graphica/assets/icons/layout-grid.svg +22 -0
- graphica/assets/icons/math-function.svg +22 -0
- graphica/assets/icons/message-2.svg +21 -0
- graphica/assets/icons/mountain.svg +20 -0
- graphica/assets/icons/palette.svg +22 -0
- graphica/assets/icons/pointer.svg +19 -0
- graphica/assets/icons/refresh.svg +20 -0
- graphica/assets/icons/row-insert-bottom.svg +21 -0
- graphica/assets/icons/row-remove.svg +21 -0
- graphica/assets/icons/search.svg +20 -0
- graphica/assets/icons/select-all.svg +35 -0
- graphica/assets/icons/subscript.svg +20 -0
- graphica/assets/icons/superscript.svg +20 -0
- graphica/assets/icons/table.svg +21 -0
- graphica/assets/icons/trash.svg +23 -0
- graphica/assets/icons/typography.svg +23 -0
- graphica/assets/icons/x.svg +20 -0
- graphica/core/__init__.py +0 -0
- graphica/core/analysis.py +1278 -0
- graphica/core/app_paths.py +35 -0
- graphica/core/axis_settings.py +128 -0
- graphica/core/caption_export.py +47 -0
- graphica/core/color_palettes.py +56 -0
- graphica/core/commands.py +226 -0
- graphica/core/cvd_simulation.py +49 -0
- graphica/core/dataset.py +443 -0
- graphica/core/diagnostics.py +105 -0
- graphica/core/excel_utils.py +58 -0
- graphica/core/fit_models.py +379 -0
- graphica/core/grid_data.py +128 -0
- graphica/core/i18n.py +44 -0
- graphica/core/json_utils.py +27 -0
- graphica/core/label_utils.py +21 -0
- graphica/core/methods_text.py +115 -0
- graphica/core/named_colors.py +126 -0
- graphica/core/plugin_api.py +515 -0
- graphica/core/plugin_context.py +167 -0
- graphica/core/plugin_install.py +82 -0
- graphica/core/plugin_manifest.py +67 -0
- graphica/core/plugin_testing.py +213 -0
- graphica/core/plugin_types.py +118 -0
- graphica/core/provenance.py +19 -0
- graphica/core/report_export.py +51 -0
- graphica/core/safe_eval.py +194 -0
- graphica/core/script_export.py +272 -0
- graphica/core/translations_en.py +530 -0
- graphica/core/unit_conversion.py +51 -0
- graphica/core/update_check.py +43 -0
- graphica/core/version.py +14 -0
- graphica/gui/__init__.py +0 -0
- graphica/gui/app_settings.py +119 -0
- graphica/gui/axis_bindings.py +173 -0
- graphica/gui/binding.py +135 -0
- graphica/gui/builders/__init__.py +1 -0
- graphica/gui/builders/axis_panel.py +431 -0
- graphica/gui/builders/canvas_area.py +176 -0
- graphica/gui/builders/common.py +120 -0
- graphica/gui/builders/dataset_panel.py +433 -0
- graphica/gui/builders/property_sections.py +208 -0
- graphica/gui/canvas.py +519 -0
- graphica/gui/color_history.py +47 -0
- graphica/gui/color_picker_widget.py +198 -0
- graphica/gui/crash_handler.py +77 -0
- graphica/gui/cvd_preview.py +23 -0
- graphica/gui/data_editor.py +761 -0
- graphica/gui/data_import_flow.py +355 -0
- graphica/gui/dataset_bindings.py +107 -0
- graphica/gui/dataset_style_icon.py +80 -0
- graphica/gui/datasets/__init__.py +1 -0
- graphica/gui/datasets/actions_menu.py +114 -0
- graphica/gui/datasets/colors.py +218 -0
- graphica/gui/datasets/fitting.py +70 -0
- graphica/gui/datasets/host.py +162 -0
- graphica/gui/datasets/operations/__init__.py +1 -0
- graphica/gui/datasets/operations/fitting.py +468 -0
- graphica/gui/datasets/operations/peaks.py +109 -0
- graphica/gui/datasets/operations/processing.py +659 -0
- graphica/gui/datasets/operations/runner.py +148 -0
- graphica/gui/datasets/operations/transfer.py +227 -0
- graphica/gui/datasets/order.py +310 -0
- graphica/gui/datasets/overlays.py +74 -0
- graphica/gui/datasets/peaks.py +21 -0
- graphica/gui/datasets/plugin_runs.py +99 -0
- graphica/gui/datasets/processing.py +68 -0
- graphica/gui/datasets/property_panel.py +538 -0
- graphica/gui/datasets/transfer.py +36 -0
- graphica/gui/detached_canvas_window.py +22 -0
- graphica/gui/dialogs/__init__.py +137 -0
- graphica/gui/dialogs/analysis.py +1322 -0
- graphica/gui/dialogs/app.py +1015 -0
- graphica/gui/dialogs/appearance.py +851 -0
- graphica/gui/dialogs/data_edit.py +577 -0
- graphica/gui/dialogs/data_import.py +679 -0
- graphica/gui/dialogs/export.py +433 -0
- graphica/gui/dock_layout.py +176 -0
- graphica/gui/export_preview_panel.py +321 -0
- graphica/gui/export_settings.py +16 -0
- graphica/gui/file_association.py +97 -0
- graphica/gui/icon_utils.py +44 -0
- graphica/gui/main_app_window.py +210 -0
- graphica/gui/main_window.py +1151 -0
- graphica/gui/mathtext_preview.py +168 -0
- graphica/gui/menu_bar.py +320 -0
- graphica/gui/minimap_widget.py +151 -0
- graphica/gui/mixins/__init__.py +0 -0
- graphica/gui/mixins/export_mixin.py +520 -0
- graphica/gui/mixins/help_mixin.py +127 -0
- graphica/gui/mixins/project_io_mixin.py +289 -0
- graphica/gui/mixins/quick_access_mixin.py +194 -0
- graphica/gui/mixins/ui_setup_mixin.py +206 -0
- graphica/gui/notify.py +61 -0
- graphica/gui/panels/__init__.py +43 -0
- graphica/gui/panels/axis_settings.py +605 -0
- graphica/gui/panels/dataset_tree.py +424 -0
- graphica/gui/plot_type_drawers.py +131 -0
- graphica/gui/plugin_context.py +119 -0
- graphica/gui/project_files.py +361 -0
- graphica/gui/provenance_panel.py +65 -0
- graphica/gui/rendering/__init__.py +1 -0
- graphica/gui/rendering/annotations.py +177 -0
- graphica/gui/rendering/appearance.py +368 -0
- graphica/gui/rendering/common.py +316 -0
- graphica/gui/rendering/data_1d.py +376 -0
- graphica/gui/rendering/data_2d.py +66 -0
- graphica/gui/residual_panel.py +62 -0
- graphica/gui/resources.py +16 -0
- graphica/gui/single_instance.py +141 -0
- graphica/gui/splash.py +83 -0
- graphica/gui/task_runner.py +41 -0
- graphica/gui/theme.py +864 -0
- graphica/gui/tools/__init__.py +56 -0
- graphica/gui/tools/annotation.py +275 -0
- graphica/gui/tools/cursor.py +259 -0
- graphica/gui/tools/layout_edit.py +285 -0
- graphica/gui/tools/manager.py +78 -0
- graphica/gui/tools/peak_placement.py +132 -0
- graphica/gui/tools/range_select.py +191 -0
- graphica/gui/tools/region_highlight.py +222 -0
- graphica/gui/tools/slice_extraction.py +200 -0
- graphica/gui/widget_translation.py +46 -0
- graphica/gui/workers.py +280 -0
- graphica/models/__init__.py +0 -0
- graphica/models/project.py +197 -0
- graphica/plugin/__init__.py +20 -0
- graphica/plugin/testing.py +57 -0
- graphica/sample_data/__init__.py +0 -0
- graphica/sample_data/cooling_curve_sample.csv +42 -0
- graphica/ui_main_window.py +581 -0
- graphica_plot-2.0.0.dist-info/METADATA +317 -0
- graphica_plot-2.0.0.dist-info/RECORD +181 -0
- graphica_plot-2.0.0.dist-info/WHEEL +5 -0
- graphica_plot-2.0.0.dist-info/entry_points.txt +2 -0
- graphica_plot-2.0.0.dist-info/licenses/LICENSE +30 -0
- graphica_plot-2.0.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +91 -0
- graphica_plot-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""プラグインの zip を展開してインストールする(取得はしない。信頼できる配布元のものだけを入れること)。"""
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import tempfile
|
|
5
|
+
import uuid
|
|
6
|
+
import zipfile
|
|
7
|
+
|
|
8
|
+
from graphica.core.app_paths import get_user_plugins_dir
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PluginInstallError(Exception):
|
|
12
|
+
"""メッセージはそのまま利用者に見せる。"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _reject_unsafe_members(zf: zipfile.ZipFile) -> None:
|
|
16
|
+
# zip-slip: 正規化して .. で始まるか絶対パスなら、target_dir の外に書くので拒否する
|
|
17
|
+
for member in zf.namelist():
|
|
18
|
+
normalized = os.path.normpath(member)
|
|
19
|
+
if normalized.startswith("..") or os.path.isabs(normalized):
|
|
20
|
+
raise PluginInstallError(
|
|
21
|
+
f"安全でないパスを含むzipファイルです(不正なエントリ: '{member}')。"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _find_plugin_root(staging_dir: str, zip_path: str) -> tuple[str, str]:
|
|
26
|
+
"""(プラグイン本体のフォルダの絶対パス, 採用するフォルダ名)"""
|
|
27
|
+
if os.path.exists(os.path.join(staging_dir, "__init__.py")):
|
|
28
|
+
# __init__.py が zip の直下にある
|
|
29
|
+
name = os.path.splitext(os.path.basename(zip_path))[0]
|
|
30
|
+
return staging_dir, name
|
|
31
|
+
|
|
32
|
+
entries = [e for e in os.listdir(staging_dir) if os.path.isdir(os.path.join(staging_dir, e))]
|
|
33
|
+
if len(entries) == 1:
|
|
34
|
+
candidate = os.path.join(staging_dir, entries[0])
|
|
35
|
+
if os.path.exists(os.path.join(candidate, "__init__.py")):
|
|
36
|
+
# 1つのフォルダの中に __init__.py がある
|
|
37
|
+
return candidate, entries[0]
|
|
38
|
+
|
|
39
|
+
raise PluginInstallError(
|
|
40
|
+
"zip内に __init__.py を持つプラグインが見つかりませんでした。"
|
|
41
|
+
"プラグインフォルダそのもの、またはそのフォルダを1つだけ含むzipを指定してください。"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def install_plugin_zip(zip_path: str, target_dir: str | None = None) -> str:
|
|
46
|
+
"""target_dir(省略時は get_user_plugins_dir())に入れ、フォルダ名を返す。失敗は PluginInstallError。"""
|
|
47
|
+
if target_dir is None:
|
|
48
|
+
target_dir = get_user_plugins_dir()
|
|
49
|
+
|
|
50
|
+
if not zipfile.is_zipfile(zip_path):
|
|
51
|
+
raise PluginInstallError(f"'{zip_path}' は有効なzipファイルではありません。")
|
|
52
|
+
|
|
53
|
+
# 展開先は target_dir と同じボリュームに作る(最後の os.replace() を原子的な改名にするため)。
|
|
54
|
+
# 展開先の直下には __init__.py が無いので、プラグインとして誤って見つかることはない
|
|
55
|
+
staging_dir = tempfile.mkdtemp(prefix=".tmp_install_", dir=target_dir)
|
|
56
|
+
try:
|
|
57
|
+
try:
|
|
58
|
+
with zipfile.ZipFile(zip_path) as zf:
|
|
59
|
+
_reject_unsafe_members(zf)
|
|
60
|
+
zf.extractall(staging_dir)
|
|
61
|
+
except zipfile.BadZipFile as e:
|
|
62
|
+
raise PluginInstallError(f"zipファイルの展開に失敗しました: {e}") from e
|
|
63
|
+
|
|
64
|
+
plugin_root, plugin_name = _find_plugin_root(staging_dir, zip_path)
|
|
65
|
+
final_path = os.path.join(target_dir, plugin_name)
|
|
66
|
+
|
|
67
|
+
stale_path = None
|
|
68
|
+
if os.path.exists(final_path):
|
|
69
|
+
# 上書きのときは、既存のものを先に退避して最後に消す
|
|
70
|
+
stale_path = f"{final_path}.old-{uuid.uuid4().hex}"
|
|
71
|
+
os.replace(final_path, stale_path)
|
|
72
|
+
|
|
73
|
+
# zip の直下に __init__.py があった場合、改名までの一瞬だけ展開先が半端なプラグインに見える。
|
|
74
|
+
# 1つのプロセスで入れる前提なので、そのままにしている
|
|
75
|
+
os.replace(plugin_root, final_path)
|
|
76
|
+
|
|
77
|
+
if stale_path is not None:
|
|
78
|
+
shutil.rmtree(stale_path, ignore_errors=True)
|
|
79
|
+
|
|
80
|
+
return plugin_name
|
|
81
|
+
finally:
|
|
82
|
+
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
プラグインのマニフェスト(plugin.json)の読み込みと検証。
|
|
3
|
+
|
|
4
|
+
プラグイン API の版は "主番号.小番号"。壊す変更で主番号を、互換のある追加で小番号を上げる。
|
|
5
|
+
plugin.json の api_version は、主番号が本体と同じで小番号が本体以下なら読み込む
|
|
6
|
+
(使う機能がそろっている)。合わないプラグインは import する前に弾く。
|
|
7
|
+
entry_point キーは予約済みで未使用(常にパッケージの __init__.py の register(api) を呼ぶ)。
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
PLUGIN_API_VERSION = "2.0"
|
|
14
|
+
PLUGIN_MANIFEST_FILENAME = "plugin.json"
|
|
15
|
+
|
|
16
|
+
_REQUIRED_MANIFEST_KEYS = ("name", "version", "api_version")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PluginManifestError(Exception):
|
|
20
|
+
"""plugin.json が無い・壊れている・api_version が合わない(呼び出し側はそのプラグインを飛ばす)。"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def 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
|
+
|
|
56
|
+
def 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
|
+
|
|
64
|
+
def 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]
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
プラグイン作者向けのテスト用の偽物。本体(Qt・QApplication)を起動せずにテストできる。
|
|
3
|
+
|
|
4
|
+
api = FakeGraphicaPluginAPI()
|
|
5
|
+
my_plugin.register(api)
|
|
6
|
+
ctx = FakePluginContext(datasets=[ds])
|
|
7
|
+
api.menu_actions[0].callback(ctx)
|
|
8
|
+
assert ctx.messages == [("info", "My Plugin", "3 点")]
|
|
9
|
+
|
|
10
|
+
FakeGraphicaPluginAPI は本物の GraphicaPluginAPI と同じ公開メソッドを持ち、本物が拒む
|
|
11
|
+
重複登録は同じく ValueError にする(偽物だけ通るとテストが通っても本体で失敗する)。
|
|
12
|
+
tests/test_plugin_api_contract.py が両者のシグネチャの一致を確かめている。
|
|
13
|
+
"""
|
|
14
|
+
import tempfile
|
|
15
|
+
|
|
16
|
+
from graphica.core.color_palettes import normalize_palettes
|
|
17
|
+
from graphica.core.named_colors import add_named_color
|
|
18
|
+
from graphica.core.plugin_context import PluginContext
|
|
19
|
+
from graphica.core.plugin_types import PluginMenuAction
|
|
20
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from graphica.core.dataset import Dataset
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FakeGraphicaPluginAPI:
|
|
26
|
+
"""
|
|
27
|
+
登録内容を記録するだけの GraphicaPluginAPI。
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
menu_actions (list[PluginMenuAction]): 本物と同じ形。callback に FakePluginContext を渡して試せる。
|
|
31
|
+
panels (dict): 名前 -> {"widget_factory", "area"}。
|
|
32
|
+
そのほか fit_functions / importers / exporters / processors / analyzers / plot_types /
|
|
33
|
+
render_backends は、名前(または拡張子)をキーにした登録内容の辞書。
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, plugin_name: str = "test_plugin") -> None:
|
|
37
|
+
self.plugin_name = plugin_name
|
|
38
|
+
self.fit_functions: dict[str, dict[str, Any]] = {}
|
|
39
|
+
self.menu_actions: list[PluginMenuAction] = []
|
|
40
|
+
self.importers: dict[str, dict[str, Any]] = {}
|
|
41
|
+
self.exporters: dict[str, dict[str, Any]] = {}
|
|
42
|
+
self.processors: dict[str, dict[str, Any]] = {}
|
|
43
|
+
self.analyzers: dict[str, dict[str, Any]] = {}
|
|
44
|
+
self.panels: dict[str, dict[str, Any]] = {}
|
|
45
|
+
self.plot_types: dict[str, dict[str, Any]] = {}
|
|
46
|
+
self.render_backends: dict[str, Any] = {}
|
|
47
|
+
|
|
48
|
+
def register_fit_function(self, name: str, func: Callable[..., Any], param_names: list[str],
|
|
49
|
+
p0: list[float] | Callable[..., Any] | None = None) -> None:
|
|
50
|
+
if name in self.fit_functions:
|
|
51
|
+
raise ValueError(f"フィット関数 '{name}' は既に登録されています。")
|
|
52
|
+
self.fit_functions[name] = {"func": func, "param_names": param_names, "p0": p0}
|
|
53
|
+
|
|
54
|
+
def register_menu_action(self, text: str, callback: Callable[..., Any], shortcut: str | None = None) -> None:
|
|
55
|
+
self.menu_actions.append(PluginMenuAction(
|
|
56
|
+
text=text, callback=callback, shortcut=shortcut, plugin_name=self.plugin_name,
|
|
57
|
+
))
|
|
58
|
+
|
|
59
|
+
def register_importer(self, extensions: list[str], loader: Callable[[str], Any], *, name: str | None = None,
|
|
60
|
+
priority: int = 0) -> None:
|
|
61
|
+
# 本物は拡張子ごとに複数を優先度順で持つが、使える拡張子を確かめる用途にはこれで足りる。
|
|
62
|
+
for ext in extensions:
|
|
63
|
+
ext = ext.lower()
|
|
64
|
+
if not ext.startswith('.'):
|
|
65
|
+
ext = '.' + ext
|
|
66
|
+
self.importers[ext] = {"loader": loader, "name": name, "priority": priority}
|
|
67
|
+
|
|
68
|
+
def register_exporter(self, format_name: str, extension: str, writer: Callable[[Any, str], Any], *,
|
|
69
|
+
name: str | None = None) -> None:
|
|
70
|
+
self.exporters[format_name.lower()] = {"extension": extension, "writer": writer, "name": name}
|
|
71
|
+
|
|
72
|
+
def register_processor(self, name: str, fn: Callable[[Any, dict[str, Any]], Any], *, category: str = "general",
|
|
73
|
+
param_schema: list[dict[str, Any]] | None = None) -> None:
|
|
74
|
+
if name in self.processors:
|
|
75
|
+
raise ValueError(f"データ処理 '{name}' は既に登録されています。")
|
|
76
|
+
self.processors[name] = {"fn": fn, "category": category, "param_schema": list(param_schema or [])}
|
|
77
|
+
|
|
78
|
+
def register_analyzer(self, name: str, fn: Callable[[Any, dict[str, Any]], Any], *, output_kind: str = "table",
|
|
79
|
+
param_schema: list[dict[str, Any]] | None = None) -> None:
|
|
80
|
+
if name in self.analyzers:
|
|
81
|
+
raise ValueError(f"解析 '{name}' は既に登録されています。")
|
|
82
|
+
self.analyzers[name] = {"fn": fn, "output_kind": output_kind, "param_schema": list(param_schema or [])}
|
|
83
|
+
|
|
84
|
+
def register_panel(self, name: str, widget_factory: Callable[..., Any], *, area: str = "right") -> None:
|
|
85
|
+
if name in self.panels:
|
|
86
|
+
raise ValueError(f"パネル '{name}' は既に登録されています。")
|
|
87
|
+
self.panels[name] = {"widget_factory": widget_factory, "area": area}
|
|
88
|
+
|
|
89
|
+
def register_plot_type(self, type_name: str, drawer: Callable[..., Any], *, requires_2d: bool = False) -> None:
|
|
90
|
+
if type_name in self.plot_types:
|
|
91
|
+
raise ValueError(f"プロット種別 '{type_name}' は既に登録されています。")
|
|
92
|
+
self.plot_types[type_name] = {"drawer": drawer, "requires_2d": requires_2d}
|
|
93
|
+
|
|
94
|
+
def register_render_backend(self, name: str, backend: Any) -> None:
|
|
95
|
+
if name in self.render_backends:
|
|
96
|
+
raise ValueError(f"描画バックエンド '{name}' は既に登録されています。")
|
|
97
|
+
self.render_backends[name] = {"backend": backend}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class FakePluginContext(PluginContext):
|
|
101
|
+
"""
|
|
102
|
+
メモリ上だけで動く PluginContext。本物と同じ検証をし、行った操作を記録する。
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
datasets (list[Dataset] | None): タブにあるデータセット。
|
|
106
|
+
current (Dataset | None): 選ばれているデータセット。
|
|
107
|
+
selected (list[Dataset] | None): 選択中のデータセット。省略時は current だけ。
|
|
108
|
+
plugin_name (str): show_message の既定のタイトル。
|
|
109
|
+
data_dir (str | None): 省略時は一時フォルダを作る。
|
|
110
|
+
named_colors (list[dict] | None) / color_palettes (dict | None) / color_cycle (list[str] | None):
|
|
111
|
+
色の初期値。
|
|
112
|
+
|
|
113
|
+
Attributes:
|
|
114
|
+
messages (list[tuple]): 表示したメッセージ ("info" または "error", タイトル, 本文)。
|
|
115
|
+
undo_descriptions (list[str]): Undo 履歴に積まれた操作の説明。
|
|
116
|
+
redraw_count (int): redraw() が呼ばれた回数。
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self, datasets: "list[Dataset] | None" = None, current: "Dataset | None" = None,
|
|
120
|
+
selected: "list[Dataset] | None" = None, plugin_name: str = "test_plugin",
|
|
121
|
+
data_dir: str | None = None, named_colors: list[dict[str, str]] | None = None,
|
|
122
|
+
color_palettes: dict[str, list[str]] | None = None, color_cycle: list[str] | None = None) -> None:
|
|
123
|
+
self._datasets = list(datasets or [])
|
|
124
|
+
self._current = current
|
|
125
|
+
self._selected = list(selected) if selected is not None else ([current] if current else [])
|
|
126
|
+
self._plugin_name = plugin_name
|
|
127
|
+
self._data_dir = data_dir
|
|
128
|
+
self._named_colors = [dict(e) for e in (named_colors or [])]
|
|
129
|
+
self._palettes = normalize_palettes(color_palettes or {})
|
|
130
|
+
self._color_cycle = list(color_cycle or ["#1f77b4", "#ff7f0e", "#2ca02c"])
|
|
131
|
+
self._datasets_changed_callbacks: list[Callable[[], Any]] = []
|
|
132
|
+
self._selection_changed_callbacks: list[Callable[[Dataset | None], Any]] = []
|
|
133
|
+
self.messages: list[tuple[str, str, str]] = [] # ("info" / "error", タイトル, 本文)
|
|
134
|
+
self.undo_descriptions: list[str | None] = []
|
|
135
|
+
self.redraw_count = 0
|
|
136
|
+
|
|
137
|
+
def datasets(self) -> "list[Dataset]":
|
|
138
|
+
return list(self._datasets)
|
|
139
|
+
|
|
140
|
+
def current_dataset(self) -> "Dataset | None":
|
|
141
|
+
return self._current
|
|
142
|
+
|
|
143
|
+
def selected_datasets(self) -> "list[Dataset]":
|
|
144
|
+
return list(self._selected)
|
|
145
|
+
|
|
146
|
+
def add_dataset(self, dataset: "Dataset", description: str | None = None) -> None:
|
|
147
|
+
self._datasets.append(dataset)
|
|
148
|
+
self.undo_descriptions.append(description or f"[{self._plugin_name}] データセットの追加")
|
|
149
|
+
self.fire_datasets_changed()
|
|
150
|
+
|
|
151
|
+
def set_dataset_properties(self, dataset: "Dataset", values: dict[str, Any], description: str | None = None) -> None:
|
|
152
|
+
unknown = sorted(k for k in values if not hasattr(dataset, k))
|
|
153
|
+
if unknown:
|
|
154
|
+
raise AttributeError(f"Dataset に無い属性です: {', '.join(unknown)}")
|
|
155
|
+
for key, value in values.items():
|
|
156
|
+
setattr(dataset, key, value)
|
|
157
|
+
self.undo_descriptions.append(description or f"[{self._plugin_name}] プロパティの変更")
|
|
158
|
+
self.fire_datasets_changed()
|
|
159
|
+
|
|
160
|
+
def redraw(self) -> None:
|
|
161
|
+
self.redraw_count += 1
|
|
162
|
+
|
|
163
|
+
def on_datasets_changed(self, callback: Callable[[], Any]) -> None:
|
|
164
|
+
self._datasets_changed_callbacks.append(callback)
|
|
165
|
+
|
|
166
|
+
def on_selection_changed(self, callback: Callable[["Dataset | None"], Any]) -> None:
|
|
167
|
+
self._selection_changed_callbacks.append(callback)
|
|
168
|
+
|
|
169
|
+
def fire_datasets_changed(self) -> None:
|
|
170
|
+
"""テスト用: データセットが変わったときの通知を送る。"""
|
|
171
|
+
for callback in list(self._datasets_changed_callbacks):
|
|
172
|
+
callback()
|
|
173
|
+
|
|
174
|
+
def select(self, dataset: "Dataset | None") -> None:
|
|
175
|
+
"""テスト用: データセット一覧で dataset を選んだことにし、通知を送る。"""
|
|
176
|
+
self._current = dataset
|
|
177
|
+
self._selected = [dataset] if dataset is not None else []
|
|
178
|
+
for callback in list(self._selection_changed_callbacks):
|
|
179
|
+
callback(dataset)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def parent_widget(self) -> Any:
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
def show_message(self, text: str, title: str | None = None) -> None:
|
|
186
|
+
self.messages.append(("info", title or self._plugin_name, text))
|
|
187
|
+
|
|
188
|
+
def show_error(self, text: str, title: str | None = None) -> None:
|
|
189
|
+
self.messages.append(("error", title or self._plugin_name, text))
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def data_dir(self) -> str:
|
|
193
|
+
if self._data_dir is None:
|
|
194
|
+
self._data_dir = tempfile.mkdtemp(prefix=f"graphica_{self._plugin_name}_")
|
|
195
|
+
return self._data_dir
|
|
196
|
+
|
|
197
|
+
def named_colors(self) -> list[dict[str, str]]:
|
|
198
|
+
return [dict(e) for e in self._named_colors]
|
|
199
|
+
|
|
200
|
+
def set_named_colors(self, entries: list[dict[str, str]]) -> None:
|
|
201
|
+
validated: list[dict[str, str]] = []
|
|
202
|
+
for entry in entries:
|
|
203
|
+
validated = add_named_color(validated, entry.get("name"), entry.get("color"))
|
|
204
|
+
self._named_colors = validated
|
|
205
|
+
|
|
206
|
+
def color_palettes(self) -> dict[str, list[str]]:
|
|
207
|
+
return {name: list(colors) for name, colors in self._palettes.items()}
|
|
208
|
+
|
|
209
|
+
def set_color_palettes(self, palettes: dict[str, list[str]]) -> None:
|
|
210
|
+
self._palettes = normalize_palettes(palettes)
|
|
211
|
+
|
|
212
|
+
def active_color_cycle(self) -> list[str]:
|
|
213
|
+
return list(self._color_cycle)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""プラグイン API で共有する型。登録されたフックの記録と、失敗の表し方。"""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PluginHookKind(Enum):
|
|
8
|
+
IMPORTER = "importer"
|
|
9
|
+
EXPORTER = "exporter"
|
|
10
|
+
PROCESSOR = "processor"
|
|
11
|
+
ANALYZER = "analyzer"
|
|
12
|
+
PANEL = "panel"
|
|
13
|
+
PLOT_TYPE = "plot_type"
|
|
14
|
+
FIT_FUNCTION = "fit_function"
|
|
15
|
+
MENU_ACTION = "menu_action"
|
|
16
|
+
RENDER_BACKEND = "render_backend"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class PluginRegistrationError:
|
|
21
|
+
"""register_xxx の1回の呼び出しが失敗したこと。"""
|
|
22
|
+
plugin_name: str
|
|
23
|
+
hook_kind: PluginHookKind
|
|
24
|
+
message: str
|
|
25
|
+
exception: Exception | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class PluginMenuAction:
|
|
30
|
+
text: str
|
|
31
|
+
callback: Callable[..., Any] # (PluginContext) -> None
|
|
32
|
+
shortcut: str | None
|
|
33
|
+
plugin_name: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class PluginImporter:
|
|
38
|
+
extension: str # ピリオド付きの小文字(例: ".jdx")
|
|
39
|
+
loader: Callable[[str], Any] # (パス) -> pandas.DataFrame
|
|
40
|
+
name: str # エラー表示用(通常はプラグイン名)
|
|
41
|
+
priority: int = 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class PluginExporter:
|
|
46
|
+
format_name: str
|
|
47
|
+
extension: str # ピリオド付きの小文字
|
|
48
|
+
writer: Callable[[Any, str], Any] # (matplotlib の Figure, 出力パス) -> None
|
|
49
|
+
name: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class PluginProcessor:
|
|
54
|
+
name: str
|
|
55
|
+
fn: Callable[[Any, dict[str, Any]], Any] # (Dataset, 入力値) -> Dataset。元の Dataset は変えない
|
|
56
|
+
category: str
|
|
57
|
+
param_schema: list[dict[str, Any]] # 入力フォームの定義(register_processor の説明を参照)
|
|
58
|
+
plugin_name: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class AnalysisResult:
|
|
63
|
+
"""
|
|
64
|
+
register_analyzer の fn が返す解析結果。どれも省略できる。
|
|
65
|
+
|
|
66
|
+
Attributes:
|
|
67
|
+
table (pandas.DataFrame | None): 結果の表。CSV に保存できる形で表示する。
|
|
68
|
+
annotations (list[dict] | None): 現在の軸に追加する注釈(Undo できる)。
|
|
69
|
+
new_datasets (list[Dataset] | None): 追加するデータセット(Undo できる)。
|
|
70
|
+
"""
|
|
71
|
+
table: Any = None
|
|
72
|
+
annotations: list[dict[str, Any]] | None = None
|
|
73
|
+
new_datasets: list[Any] | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class PluginAnalyzer:
|
|
78
|
+
name: str
|
|
79
|
+
fn: Callable[[Any, dict[str, Any]], Any] # (Dataset, 入力値) -> AnalysisResult
|
|
80
|
+
output_kind: str # 表示上の分類だけ
|
|
81
|
+
param_schema: list[dict[str, Any]]
|
|
82
|
+
plugin_name: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class PluginPanel:
|
|
87
|
+
name: str
|
|
88
|
+
widget_factory: Callable[..., Any] # (PluginContext) -> QWidget。タブごとに1回呼ぶ
|
|
89
|
+
area: str # "right"/"left"/"top"/"bottom"。Qt への対応付けは GUI 側(core は Qt に依存しない)
|
|
90
|
+
plugin_name: str
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class PluginPlotType:
|
|
95
|
+
type_name: str
|
|
96
|
+
drawer: Callable[..., Any] # (Dataset, Axes, x, y) -> Artist | None
|
|
97
|
+
requires_2d: bool # 予約(動作に影響しない)
|
|
98
|
+
plugin_name: str
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class RenderBackend:
|
|
102
|
+
"""描画バックエンドの型の予約。描画には未接続で、持つべきメソッドも未定。"""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class PluginRenderBackend:
|
|
107
|
+
name: str
|
|
108
|
+
backend: "RenderBackend"
|
|
109
|
+
plugin_name: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class PluginExecutionError(Exception):
|
|
113
|
+
"""プラグインのフックの実行時の失敗。文字列にするとプラグイン名が付くので、そのまま表示に使える。"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, plugin_name: str, message: str) -> None:
|
|
116
|
+
self.plugin_name = plugin_name
|
|
117
|
+
self.message = message
|
|
118
|
+
super().__init__(f"[{plugin_name}] {message}")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""派生データセットの由来(Dataset.provenance)。core/methods_text.py がここから「方法」文を作る。"""
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def build_provenance(operation: str, params: dict[str, Any], source_datasets: list[Any]) -> dict[str, Any]:
|
|
7
|
+
"""
|
|
8
|
+
Args:
|
|
9
|
+
operation (str): 操作の名前(methods_text の説明の引き当てに使う)。
|
|
10
|
+
params (dict): 操作の設定。pickle と JSON の両方で往復できるよう素の Python 型だけにする。
|
|
11
|
+
source_datasets (list[Dataset]): 元になったデータセット(複数なら合成)。
|
|
12
|
+
"""
|
|
13
|
+
return {
|
|
14
|
+
'operation': operation,
|
|
15
|
+
'params': params,
|
|
16
|
+
'source_dataset_ids': [ds.dataset_id for ds in source_datasets],
|
|
17
|
+
'source_dataset_names': [ds.name for ds in source_datasets],
|
|
18
|
+
'timestamp': datetime.now(timezone.utc).isoformat(),
|
|
19
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""グラフの画像と処理の「方法」の文をまとめたレポート(HTML)。GUI には依存しない。"""
|
|
2
|
+
import base64
|
|
3
|
+
import datetime
|
|
4
|
+
import html
|
|
5
|
+
|
|
6
|
+
from graphica.core.methods_text import generate_methods_text
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from graphica.models.project import ProjectModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def collect_methods_sections(project: "ProjectModel") -> list[tuple[str, str]]:
|
|
13
|
+
"""履歴を持つデータセットごとの (名前, 方法の文)。元データは含めない。"""
|
|
14
|
+
return [
|
|
15
|
+
(ds.name, generate_methods_text(ds, project))
|
|
16
|
+
for ds in project.datasets
|
|
17
|
+
if ds.provenance
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def generate_html_report(image_png_bytes: bytes, methods_sections: list[tuple[str, str]], title: str = "") -> str:
|
|
22
|
+
"""画像を base64 で埋め込んだ1ファイルで完結する HTML。title が空なら既定のタイトル。"""
|
|
23
|
+
title_text = title.strip() or "実験レポート"
|
|
24
|
+
title_html = html.escape(title_text)
|
|
25
|
+
generated_at = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
26
|
+
image_b64 = base64.b64encode(image_png_bytes).decode('ascii')
|
|
27
|
+
|
|
28
|
+
if methods_sections:
|
|
29
|
+
items = "".join(
|
|
30
|
+
f"<li><b>{html.escape(name)}</b>: {html.escape(text)}</li>"
|
|
31
|
+
for name, text in methods_sections
|
|
32
|
+
)
|
|
33
|
+
methods_html = f"<ul>{items}</ul>"
|
|
34
|
+
else:
|
|
35
|
+
methods_html = "<p>(処理履歴を持つデータセットはありません)</p>"
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
"<!doctype html>\n"
|
|
39
|
+
"<html><head><meta charset=\"utf-8\">"
|
|
40
|
+
f"<title>{title_html}</title>\n"
|
|
41
|
+
"<style>\n"
|
|
42
|
+
"body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; }\n"
|
|
43
|
+
"img { max-width: 100%; border: 1px solid #ccc; }\n"
|
|
44
|
+
"</style></head><body>\n"
|
|
45
|
+
f"<h1>{title_html}</h1>\n"
|
|
46
|
+
f"<p>生成日時: {generated_at}</p>\n"
|
|
47
|
+
f"<img src=\"data:image/png;base64,{image_b64}\" alt=\"plot\">\n"
|
|
48
|
+
"<h2>方法</h2>\n"
|
|
49
|
+
f"{methods_html}\n"
|
|
50
|
+
"</body></html>\n"
|
|
51
|
+
)
|