Coverage for graphica/gui/datasets/operations/transfer.py: 92.0%

147 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 16:38 +0000

1"""データセットをタブの外とやり取りする本体: データ表の書き出し、別のタブへのコピー・移動、スタイルのコピー、元ファイルからの再読み込み。""" 

2import copy 

3import logging 

4import os 

5import re 

6import uuid 

7 

8import pandas as pd 

9from PySide6.QtWidgets import QApplication 

10 

11from graphica.gui import notify 

12from graphica.gui import dialog_dirs 

13from graphica.core.dataset import COLUMN_REFERENCE_FIELDS, with_string_columns 

14from graphica.core.methods_text import generate_methods_text 

15from graphica.gui.datasets.operations.runner import Operation 

16 

17logger = logging.getLogger(__name__) 

18 

19# スタイルのコピーで写す見た目の属性。data_kind/z_col_name は「どの列を使うか」という構造の選択なので含めない 

20# (写すと相手を意図せず2Dマップ扱いにしてしまう)。 

21STYLE_ATTRS = ('plot_type', 'color', 'linestyle', 'linewidth', 'marker', 'markersize', 'smoothing', 

22 'smoothing_method', 'alpha', 

23 'marker_fill', 'marker_face_color', 'marker_edge', 'marker_edge_color', 'marker_edge_width', 

24 'error_display', 'colormap', 'vmin', 'vmax', 'grid_interp_method') 

25 

26 

27def _write_failed(op, error): 

28 logger.exception("データセットの書き出しに失敗しました") 

29 op.stop_with_warning(f"ファイルの書き出しに失敗しました:\n{error}", title="書き出しエラー") 

30 

31 

32def _export_one(op, dataset): 

33 default_name = re.sub(r'[\\/:*?"<>|]', '_', dataset.name) or "dataset" 

34 file_path, selected_filter = dialog_dirs.get_save_file_name( 

35 op.parent, dialog_dirs.TABLE, "データ表を書き出す", default_name, "CSV Files (*.csv);;Excel Files (*.xlsx)" 

36 ) 

37 if not file_path: 

38 op.stop() 

39 try: 

40 if file_path.lower().endswith('.xlsx') or "Excel" in selected_filter: 

41 if not file_path.lower().endswith('.xlsx'): 41 ↛ 43line 41 didn't jump to line 43 because the condition on line 41 was always true

42 file_path += '.xlsx' 

43 dataset.df.to_excel(file_path, index=False) 

44 else: 

45 if not file_path.lower().endswith('.csv'): 45 ↛ 46line 45 didn't jump to line 46 because the condition on line 45 was never true

46 file_path += '.csv' 

47 dataset.df.to_csv(file_path, index=False, encoding='utf-8-sig') 

48 except Exception as e: 

49 _write_failed(op, e) 

50 op.information(f"書き出しました:\n{file_path}", title="書き出し完了") 

51 

52 

53def _export_book(op, selected): 

54 file_path, _ = dialog_dirs.get_save_file_name( 

55 op.parent, dialog_dirs.TABLE, "データ表を書き出す", "datasets.xlsx", "Excel Files (*.xlsx)") 

56 if not file_path: 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true

57 op.stop() 

58 if not file_path.lower().endswith('.xlsx'): 58 ↛ 59line 58 didn't jump to line 59 because the condition on line 58 was never true

59 file_path += '.xlsx' 

60 used_sheet_names = set() 

61 try: 

62 with pd.ExcelWriter(file_path, engine='openpyxl') as writer: 

63 for dataset in selected: 

64 sheet_name = re.sub(r'[\\/:*?\[\]]', '_', dataset.name)[:31] or "Sheet" 

65 base_name, suffix = sheet_name, 1 

66 while sheet_name in used_sheet_names: 

67 suffix += 1 

68 sheet_name = f"{base_name[:28]}_{suffix}" 

69 used_sheet_names.add(sheet_name) 

70 dataset.df.to_excel(writer, sheet_name=sheet_name, index=False) 

71 except Exception as e: 

72 _write_failed(op, e) 

73 op.information(f"{len(selected)}件を書き出しました:\n{file_path}", title="書き出し完了") 

74 

75 

76def _export_folder(op, selected): 

77 dir_path = dialog_dirs.get_existing_directory(op.parent, dialog_dirs.TABLE, "書き出し先フォルダを選択") 

78 if not dir_path: 

79 op.stop() 

80 succeeded, failed = [], [] 

81 used_names = set() 

82 for dataset in selected: 

83 base_name = re.sub(r'[\\/:*?"<>|]', '_', dataset.name) or "dataset" 

84 file_name, suffix = base_name, 1 

85 while file_name in used_names: 

86 suffix += 1 

87 file_name = f"{base_name}_{suffix}" 

88 used_names.add(file_name) 

89 try: 

90 dataset.df.to_csv(os.path.join(dir_path, f"{file_name}.csv"), index=False, encoding='utf-8-sig') 

91 succeeded.append(dataset.name) 

92 except Exception as e: 

93 failed.append(f"{dataset.name}: {e}") 

94 message = f"{len(succeeded)}件を書き出しました。" 

95 if failed: 95 ↛ 96line 95 didn't jump to line 96 because the condition on line 95 was never true

96 message += "\n\n失敗:\n" + "\n".join(failed) 

97 op.information(message, title="書き出し完了") 

98 

99 

100def export_data(op): 

101 """1件ならCSVかExcelの1ファイル、複数ならデータセットごとのCSVか、シートに分けた1つのExcelブックに書き出す。""" 

102 selected = op.selected_datasets(at_least=1) 

103 if len(selected) == 1: 

104 _export_one(op, selected[0]) 

105 return 

106 

107 format_choice, ok = notify.get_item( 

108 op.parent, "データ表を書き出す", "書き出し形式を選択してください:", 

109 ["CSV (データセットごとに別ファイル)", "Excel (1ブックにシート分け)"], 0, False 

110 ) 

111 if not ok: 

112 op.stop() 

113 if format_choice.startswith("Excel"): 

114 _export_book(op, selected) 

115 else: 

116 _export_folder(op, selected) 

117 

118 

119def copy_or_move_to_tab(op, move): 

120 """ 

121 選んだデータセットを複製して別のタブに足す(移動なら、足した後でこのタブから消す)。 

122 タブごとに Undo の履歴が別なので、相手のタブへの追加は Undo できない。移動を Undo しても 

123 このタブの削除だけが戻り、両方のタブにある状態になる。 

124 """ 

125 selected = op.selected_datasets(at_least=1) 

126 sibling_tabs = op.host.sibling_tabs() 

127 if not sibling_tabs: 

128 op.stop_with_information("コピー/移動先の他のタブがありません。") 

129 

130 tab_titles = [title for title, _ in sibling_tabs] 

131 action_label = "移動" if move else "コピー" 

132 choice, ok = notify.get_item(op.parent, f"別のタブへ{action_label}", "転送先のタブ:", tab_titles, 0, False) 

133 if not ok: 

134 op.stop() 

135 target = sibling_tabs[tab_titles.index(choice)][1] 

136 

137 for dataset in selected: 

138 new_dataset = copy.deepcopy(dataset) 

139 new_dataset.dataset_id = uuid.uuid4().hex 

140 target.add_dataset(new_dataset, target.target_folder_for_new_dataset()) 

141 

142 if move: 

143 op.host.remove_datasets(selected, description=f"別のタブへ移動({len(selected)}件)") 

144 

145 op.host.show_status(f"{len(selected)}件のデータセットを「{choice}」へ{action_label}しました") 

146 

147 

148def copy_style(op): 

149 """今のデータセットの見た目を値で控える(後で元を変えても控えは変わらない)。""" 

150 dataset = op.current_dataset() 

151 op.state.copied_style = {attr: getattr(dataset, attr) for attr in STYLE_ATTRS} 

152 op.host.show_status(f"「{dataset.name}」のスタイルをコピーしました") 

153 

154 

155def paste_style(op): 

156 """控えた見た目を選んだデータセットすべてに当てる(Undo 1回分)。""" 

157 copied_style = op.state.copied_style 

158 if copied_style is None: 

159 op.stop() 

160 selected_datasets = op.selected_datasets(at_least=1) 

161 

162 with op.host.undo_macro(f"スタイルの貼り付け ({len(selected_datasets)}件)", enabled=len(selected_datasets) > 1): 

163 for dataset in selected_datasets: 

164 old_values = {attr: getattr(dataset, attr) for attr in STYLE_ATTRS} 

165 op.host.push_property_change(dataset, old_values, dict(copied_style), description="スタイルの貼り付け") 

166 

167 

168def reload_from_source(op): 

169 """ 

170 元のファイルを読み直して df だけを差し替える。書式・注釈・使う列の選択はそのまま。 

171 取り込みウィザードでの調整(文字コード・区切り等)は保存していないので、自動判定で読み直す。 

172 使っている列が無くなっていたら何も変えずに止める。除外した行は古いデータの行番号なので消す。 

173 """ 

174 dataset = op.current_dataset() 

175 if not dataset.source_file: 

176 op.stop() 

177 

178 if not os.path.exists(dataset.source_file): 

179 op.stop_with_warning(f"元ファイルが見つかりません:\n{dataset.source_file}") 

180 

181 from graphica.gui.workers import read_data_file, excel_engine_for 

182 try: 

183 if dataset.source_sheet: 

184 new_df = pd.read_excel(dataset.source_file, sheet_name=dataset.source_sheet, 

185 engine=excel_engine_for(dataset.source_file)) 

186 else: 

187 new_df = read_data_file(dataset.source_file) 

188 except Exception as e: 

189 logger.exception("元ファイルからの再読み込みに失敗しました") 

190 op.stop_with_warning(f"ファイルの読み込みに失敗しました:\n{e}") 

191 

192 # データセットの列名は文字列にそろえてあるので、読み直した表もそろえてから比べる 

193 new_df = with_string_columns(new_df) 

194 required_columns = [getattr(dataset, name) for name in COLUMN_REFERENCE_FIELDS] 

195 missing = [col for col in required_columns if col and col not in new_df.columns] 

196 if missing: 

197 op.stop_with_warning( 

198 "再読み込みしたファイルに、現在使用中の列が見つかりませんでした:\n" 

199 + "\n".join(missing) 

200 + "\n\nファイルの構造(列名/区切り文字/ヘッダー行など)が変わった可能性があります。" 

201 "「データセット追加」から改めてインポートし直すことをお勧めします。" 

202 "再読み込みは中止しました。" 

203 ) 

204 

205 old_values = {'df': dataset.df, 'masked_row_indices': list(dataset.masked_row_indices)} 

206 new_values = {'df': new_df, 'masked_row_indices': []} 

207 # DataFrame を含む辞書は == で比べられないので、変化の有無は確かめずに積む。 

208 op.host.push_property_change(dataset, old_values, new_values, 

209 f"「{dataset.name}」を再読み込み", skip_if_unchanged=False) 

210 op.host.show_status(f"「{dataset.name}」を元ファイルから再読み込みしました") 

211 

212 

213def copy_methods_text(op): 

214 """今のデータセットの処理履歴から「方法」節向けの説明文を作り、クリップボードに写す。""" 

215 dataset = op.current_dataset() 

216 if dataset.provenance is None: 

217 op.stop() 

218 QApplication.clipboard().setText(generate_methods_text(dataset, op.host.project)) 

219 op.host.show_status("「方法」文をクリップボードにコピーしました") 

220 

221 

222TRANSFER_OPERATIONS = { 

223 "export_data": Operation("データ表を書き出す", export_data), 

224 "copy_to_tab": Operation("タブ間のデータセット転送", lambda op: copy_or_move_to_tab(op, move=False)), 

225 "move_to_tab": Operation("タブ間のデータセット転送", lambda op: copy_or_move_to_tab(op, move=True)), 

226 "copy_style": Operation("スタイルのコピー", copy_style), 

227 "paste_style": Operation("スタイルの貼り付け", paste_style), 

228 "reload_from_source": Operation("再読み込み", reload_from_source), 

229 "copy_methods_text": Operation("「方法」文のコピー", copy_methods_text), 

230}