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

1"""データセットの処理の流れ(対象の確認 → ダイアログ → 入力の確認 → 計算 → 結果の追加)を担う実行役。 

2 

3操作ごとの違い(確認の順番、警告の題と文言、例外を捕まえる範囲、Undo の有無)は操作の本体に残す。 

4ここで捕まえるのは本体が calculate() に渡した計算の ValueError だけで、それ以外の例外は今までどおり外に出る。 

5""" 

6from dataclasses import dataclass 

7from typing import Callable 

8 

9import numpy as np 

10from PySide6.QtWidgets import QDialog 

11 

12from graphica.gui import notify 

13 

14INPUT_ERROR_TITLE = "入力エラー" 

15EMPTY_OUTPUT_NAME_TEXT = "出力データセット名が空です。" 

16 

17 

18class OperationStopped(Exception): 

19 """流れを止める。知らせる必要があれば、止める前に知らせてある。""" 

20 

21 

22class OperationState: 

23 """操作をまたいで持つもの。非モーダルの結果の窓は、次に同じ処理をしたときに閉じる。""" 

24 

25 def __init__(self): 

26 self.result_windows = {} 

27 self.copied_style = None # スタイルのコピーの控え(貼り付けるまで持つ) 

28 self.runners = {} # バックグラウンドで動いている計算。終わるまで同じ種類の計算は始めない 

29 

30 

31@dataclass(frozen=True) 

32class Operation: 

33 title: str # 警告・お知らせの題 

34 body: Callable[["OperationContext"], None] 

35 

36 

37class OperationContext: 

38 """操作の本体に渡す。流れを止める手順と、結果の追加の手順を持つ。""" 

39 

40 def __init__(self, host, state, title): 

41 self.host = host 

42 self.state = state 

43 self.title = title 

44 

45 @property 

46 def parent(self): 

47 return self.host.parent_widget 

48 

49 # --- 流れを止める --- 

50 

51 def stop(self): 

52 raise OperationStopped 

53 

54 def stop_with_warning(self, text, title=None): 

55 notify.warning(self.parent, title or self.title, text) 

56 raise OperationStopped 

57 

58 def stop_with_information(self, text, title=None): 

59 notify.information(self.parent, title or self.title, text) 

60 raise OperationStopped 

61 

62 def information(self, text, title=None): 

63 notify.information(self.parent, title or self.title, text) 

64 

65 # --- 対象 --- 

66 

67 def current_dataset(self): 

68 """今のデータセット。無ければ何も言わずに止める。""" 

69 dataset = self.host.current_dataset() 

70 if dataset is None: 

71 self.stop() 

72 return dataset 

73 

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 

82 

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}」が数値ではないため、この処理はできません。") 

94 

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] 

101 

102 # --- ダイアログと入力 --- 

103 

104 def ask(self, dialog): 

105 """取り消されたら止める。""" 

106 if dialog.exec() != QDialog.DialogCode.Accepted: 

107 self.stop() 

108 return dialog 

109 

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) 

113 

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

120 

121 # --- 結果 --- 

122 

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) 

126 

127 def status(self, text, timeout_ms): 

128 self.host.show_status(text, timeout_ms) 

129 

130 def add_and_report(self, dataset, timeout_ms=3000): 

131 self.add(dataset) 

132 self.status(f"「{dataset.name}」を追加しました", timeout_ms) 

133 

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

142 

143 

144def run_operation(operation, host, state): 

145 try: 

146 operation.body(OperationContext(host, state, operation.title)) 

147 except OperationStopped: 

148 pass