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
« 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
8import pandas as pd
9from PySide6.QtWidgets import QApplication
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
17logger = logging.getLogger(__name__)
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')
27def _write_failed(op, error):
28 logger.exception("データセットの書き出しに失敗しました")
29 op.stop_with_warning(f"ファイルの書き出しに失敗しました:\n{error}", title="書き出しエラー")
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="書き出し完了")
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="書き出し完了")
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="書き出し完了")
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
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)
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("コピー/移動先の他のタブがありません。")
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]
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())
142 if move:
143 op.host.remove_datasets(selected, description=f"別のタブへ移動({len(selected)}件)")
145 op.host.show_status(f"{len(selected)}件のデータセットを「{choice}」へ{action_label}しました")
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}」のスタイルをコピーしました")
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)
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="スタイルの貼り付け")
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()
178 if not os.path.exists(dataset.source_file):
179 op.stop_with_warning(f"元ファイルが見つかりません:\n{dataset.source_file}")
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}")
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 )
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}」を元ファイルから再読み込みしました")
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("「方法」文をクリップボードにコピーしました")
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}