Coverage for graphica/gui/datasets/operations/runner.py: 100.0%
91 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"""データセットの処理の流れ(対象の確認 → ダイアログ → 入力の確認 → 計算 → 結果の追加)を担う実行役。
3操作ごとの違い(確認の順番、警告の題と文言、例外を捕まえる範囲、Undo の有無)は操作の本体に残す。
4ここで捕まえるのは本体が calculate() に渡した計算の ValueError だけで、それ以外の例外は今までどおり外に出る。
5"""
6from dataclasses import dataclass
7from typing import Callable
9import numpy as np
10from PySide6.QtWidgets import QDialog
12from graphica.gui import notify
14INPUT_ERROR_TITLE = "入力エラー"
15EMPTY_OUTPUT_NAME_TEXT = "出力データセット名が空です。"
18class OperationStopped(Exception):
19 """流れを止める。知らせる必要があれば、止める前に知らせてある。"""
22class OperationState:
23 """操作をまたいで持つもの。非モーダルの結果の窓は、次に同じ処理をしたときに閉じる。"""
25 def __init__(self):
26 self.result_windows = {}
27 self.copied_style = None # スタイルのコピーの控え(貼り付けるまで持つ)
28 self.runners = {} # バックグラウンドで動いている計算。終わるまで同じ種類の計算は始めない
31@dataclass(frozen=True)
32class Operation:
33 title: str # 警告・お知らせの題
34 body: Callable[["OperationContext"], None]
37class OperationContext:
38 """操作の本体に渡す。流れを止める手順と、結果の追加の手順を持つ。"""
40 def __init__(self, host, state, title):
41 self.host = host
42 self.state = state
43 self.title = title
45 @property
46 def parent(self):
47 return self.host.parent_widget
49 # --- 流れを止める ---
51 def stop(self):
52 raise OperationStopped
54 def stop_with_warning(self, text, title=None):
55 notify.warning(self.parent, title or self.title, text)
56 raise OperationStopped
58 def stop_with_information(self, text, title=None):
59 notify.information(self.parent, title or self.title, text)
60 raise OperationStopped
62 def information(self, text, title=None):
63 notify.information(self.parent, title or self.title, text)
65 # --- 対象 ---
67 def current_dataset(self):
68 """今のデータセット。無ければ何も言わずに止める。"""
69 dataset = self.host.current_dataset()
70 if dataset is None:
71 self.stop()
72 return dataset
74 def selected_datasets(self, *, exactly=None, at_least=None, message=None):
75 """数が合わなければ止める。message が無ければ何も言わずに止める。"""
76 selected = self.host.selected_datasets()
77 if (exactly is not None and len(selected) != exactly) or (at_least is not None and len(selected) < at_least):
78 if message is None:
79 self.stop()
80 self.stop_with_information(message)
81 return selected
83 def as_numbers(self, dataset, axis):
84 """X か Y の列を数に直す。文字の列(カテゴリ軸)なら、その処理の題で知らせて止める。"""
85 values, column = (dataset.x_data, dataset.x_col_name) if axis == "x" else (dataset.y_data, dataset.y_col_name)
86 try:
87 return np.asarray(values, dtype=float)
88 except (TypeError, ValueError):
89 if axis == "x":
90 self.stop_with_warning(
91 f"X軸の列「{column}」が数値ではないため、この処理はできません"
92 "(文字の列はカテゴリ軸として表示だけできます)。")
93 self.stop_with_warning(f"Y軸の列「{column}」が数値ではないため、この処理はできません。")
95 def valid_points(self, dataset):
96 """X と Y を数に直し、どちらかが NaN の点を除く。"""
97 x = self.as_numbers(dataset, "x")
98 y = self.as_numbers(dataset, "y")
99 valid = ~(np.isnan(x) | np.isnan(y))
100 return x[valid], y[valid]
102 # --- ダイアログと入力 ---
104 def ask(self, dialog):
105 """取り消されたら止める。"""
106 if dialog.exec() != QDialog.DialogCode.Accepted:
107 self.stop()
108 return dialog
110 def require_output_name(self, output_name):
111 if not output_name:
112 self.stop_with_warning(EMPTY_OUTPUT_NAME_TEXT, title=INPUT_ERROR_TITLE)
114 def calculate(self, compute):
115 """compute() の ValueError は、その文言を警告にして止める。"""
116 try:
117 return compute()
118 except ValueError as e:
119 self.stop_with_warning(str(e))
121 # --- 結果 ---
123 def add(self, dataset):
124 """Undo できる追加。Undo の名前はその処理の題。"""
125 self.host.add_dataset_with_undo(dataset, self.host.target_folder_for_new_dataset(), description=self.title)
127 def status(self, text, timeout_ms):
128 self.host.show_status(text, timeout_ms)
130 def add_and_report(self, dataset, timeout_ms=3000):
131 self.add(dataset)
132 self.status(f"「{dataset.name}」を追加しました", timeout_ms)
134 def show_result_window(self, key, create_window):
135 """同じ key の前の窓を閉じてから作って出す。"""
136 previous = self.state.result_windows.get(key)
137 if previous is not None:
138 previous.close()
139 window = create_window()
140 self.state.result_windows[key] = window
141 window.show()
144def run_operation(operation, host, state):
145 try:
146 operation.body(OperationContext(host, state, operation.title))
147 except OperationStopped:
148 pass