Coverage for graphica/gui/data_import_flow.py: 96.5%
231 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"""データファイルの読み込みの流れ: ドロップ・複数ファイルの待ち行列・バックグラウンドの読み込み・列の選択・Excel のシート・フォルダからの一括読み込み・クリップボード。"""
2import io
3import logging
4import os
5import pandas as pd
6import re
7from PySide6.QtWidgets import QApplication, QDialog, QMessageBox
8from graphica.core.dataset import Dataset
9from graphica.core.plugin_api import get_registered_importer_extensions
10from graphica.gui import notify
11from graphica.gui import dialog_dirs
12from graphica.gui.dialogs import ColumnPreviewDialog, ExcelMultiSheetDialog, FolderImportDialog
13from graphica.gui.task_runner import TaskRunner
14from graphica.models.project import PROJECT_FILE_EXTENSIONS
15from graphica.gui.workers import BUILTIN_DATA_FILE_EXTENSIONS, excel_engine_for, is_excel_file, load_data_file_task
16from pathlib import Path
18logger = logging.getLogger(__name__)
21SUPPORTED_DATA_FILE_EXTENSIONS = BUILTIN_DATA_FILE_EXTENSIONS
24def find_unevaluated_formula_cells(file_path, sheet_name=None, max_examples=5, max_scan_cells=200_000):
25 """openpyxl の読み込み(約350ms)を起動時に払わないよう、呼ぶときに import する。
27 テストがこのモジュール属性を monkeypatch で差し替えるので、名前を変えない。
28 """
29 from graphica.core.excel_utils import find_unevaluated_formula_cells as _impl
30 return _impl(file_path, sheet_name, max_examples, max_scan_cells)
33def dragEnterEvent(app, event):
34 if event.mimeData().hasUrls(): 34 ↛ exitline 34 didn't return from function 'dragEnterEvent' because the condition on line 34 was always true
35 event.acceptProposedAction()
38def dropEvent(app, event):
39 urls = event.mimeData().urls()
40 file_paths = [url.toLocalFile() for url in urls if url.toLocalFile()]
41 project_paths = [p for p in file_paths if p.lower().endswith(PROJECT_FILE_EXTENSIONS)]
42 data_paths = [p for p in file_paths if not p.lower().endswith(PROJECT_FILE_EXTENSIONS)]
43 # 先にプロジェクトを開く。一緒に落としたデータはそのプロジェクトに加わる
44 if project_paths:
45 open_dropped_project(app, project_paths)
46 if data_paths:
47 app._queue_data_files(data_paths)
50def open_dropped_project(app, project_paths):
51 """1 つのタブに開けるプロジェクトは 1 つなので、2 つ目以降は開かずに知らせる。"""
52 if len(project_paths) > 1:
53 notify.information(
54 app, "プロジェクトを開く",
55 "一度に開けるプロジェクトは 1 つです。次のファイルだけを開きます:\n"
56 + os.path.basename(project_paths[0])
57 )
58 if not app.confirm_unsaved_changes("別のプロジェクトを開く"):
59 return
60 app._load_project_from_path(project_paths[0])
63def all_supported_data_file_extensions(app):
64 """組み込みの拡張子に、プラグインの読み込み機能の拡張子を足したもの。"""
65 extensions = list(SUPPORTED_DATA_FILE_EXTENSIONS)
66 for ext in get_registered_importer_extensions():
67 if ext not in extensions: 67 ↛ 66line 67 didn't jump to line 66 because the condition on line 67 was always true
68 extensions.append(ext)
69 return tuple(extensions)
72def queue_data_files(app, file_paths):
73 """対応していない拡張子はまとめて1回だけ知らせ、残りを待ち行列に積む。"""
74 valid_paths = []
75 skipped_names = []
76 allowed_extensions = app._all_supported_data_file_extensions()
77 for file_path in file_paths:
78 if file_path.lower().endswith(allowed_extensions):
79 valid_paths.append(file_path)
80 else:
81 skipped_names.append(os.path.basename(file_path))
83 if skipped_names:
84 notify.warning(
85 app, "非対応のファイル形式",
86 "以下のファイルは対応していない形式のため読み込みをスキップしました:\n"
87 + "\n".join(skipped_names)
88 )
90 if not valid_paths:
91 return
93 app._data_load_queue.extend(valid_paths)
94 app._data_load_queue_total += len(valid_paths)
96 if app._data_load_task_runner is None: 96 ↛ exitline 96 didn't return from function 'queue_data_files' because the condition on line 96 was always true
97 app._process_next_queued_file()
100def process_next_queued_file(app):
101 """読み込みの成否によらず、1件終わるたびに呼ばれる。"""
102 if not app._data_load_queue:
103 app._data_load_queue_total = 0
104 app._data_load_queue_done = 0
105 # 待ち行列を使い切ったら戻す(この後の普通の取り込みに引き継がない)
106 app._batch_import_filename_regex = None
107 return
109 next_path = app._data_load_queue.pop(0)
110 app._data_load_queue_done += 1
111 app.load_data(next_path, queue_progress=(app._data_load_queue_done, app._data_load_queue_total))
114def load_data(app, file_path, queue_progress=None):
115 """別スレッドで読み込み、終わったらデータセットとして加える(大きいファイルで画面を止めない)。
117 queue_progress=(何件目, 総数) はステータスバーの表示に使う。
118 """
119 if app._data_load_task_runner is not None:
120 notify.information(app, "読み込み中", "他のファイルを読み込み中です。完了までお待ちください。")
121 return
123 app.ui.add_dataset_button.setEnabled(False)
124 if queue_progress is not None:
125 done, total = queue_progress
126 app.statusBar().showMessage(f"読み込み中 ({done}/{total}): {os.path.basename(file_path)} ...")
127 else:
128 app.statusBar().showMessage(f"読み込み中: {file_path} ...")
130 runner = TaskRunner(load_data_file_task, file_path, parent=app)
131 runner.succeeded.connect(lambda df: app._on_data_load_succeeded(df, file_path))
132 runner.failed.connect(lambda msg: app._on_data_load_failed(msg, file_path))
133 app._data_load_task_runner = runner
134 runner.start()
137def on_data_load_succeeded(app, df, file_path):
138 """途中でキャンセルされても、必ず待ち行列の次へ進む。"""
139 app._cleanup_data_load_task_runner()
140 try:
141 app._import_loaded_dataframe(df, file_path)
142 finally:
143 app._process_next_queued_file()
146def import_loaded_dataframe(app, df, file_path):
147 """Excel でシートを複数選ぶと、シートごとに別のデータセットにする。"""
148 dataset_name = os.path.basename(file_path)
149 is_excel = is_excel_file(file_path)
151 sheet_names = []
152 if is_excel:
153 try:
154 sheet_names = pd.ExcelFile(file_path, engine=excel_engine_for(file_path)).sheet_names
155 except Exception as e:
156 logger.warning("Excelのシート一覧取得に失敗しました: %s", e)
158 # None は「読み込み済みの df をそのまま使う」
159 sheets_to_import = [None]
160 if is_excel and len(sheet_names) > 1:
161 multi_dialog = ExcelMultiSheetDialog(sheet_names, app)
162 if multi_dialog.exec() != QDialog.DialogCode.Accepted:
163 app.statusBar().showMessage("読み込みをキャンセルしました", 3000)
164 return
165 selected_sheets = multi_dialog.get_selected_sheets()
166 if not selected_sheets:
167 app.statusBar().showMessage("シートが選択されなかったため読み込みをキャンセルしました", 3000)
168 return
169 sheets_to_import = selected_sheets
171 target_folder = app._get_target_folder_for_new_dataset()
172 added_count = 0
174 for sheet_name in sheets_to_import:
175 if sheet_name is None:
176 sheet_df = df
177 preview_name = dataset_name
178 else:
179 try:
180 sheet_df = pd.read_excel(file_path, sheet_name=sheet_name, engine=excel_engine_for(file_path))
181 except Exception as e:
182 logger.exception("シート「%s」の読み込みに失敗しました", sheet_name)
183 notify.warning(app, "読み込みエラー", f"シート「{sheet_name}」の読み込みに失敗しました:\n{e}")
184 continue
185 if sheet_df.shape[1] < 2:
186 notify.warning(
187 app, "読み込みエラー",
188 f"シート「{sheet_name}」には少なくとも2列必要です。スキップします。"
189 )
190 continue
191 preview_name = f"{dataset_name} [{sheet_name}]" if len(sheets_to_import) > 1 else dataset_name
193 if is_excel:
194 checked_sheet = sheet_name if sheet_name is not None else (sheet_names[0] if sheet_names else None)
195 found, examples, scanned_all = find_unevaluated_formula_cells(file_path, checked_sheet)
196 if found:
197 example_text = "\n".join(examples)
198 more_note = "" if scanned_all else "\n(他にも存在する可能性があります)"
199 reply = notify.warning(
200 app, "数式セルの警告",
201 f"シート「{checked_sheet}」に、計算済みの値を持たない数式セルが見つかりました:\n"
202 f"{example_text}{more_note}\n\n"
203 "これらのセルは空欄(NaN)として読み込まれます。Excelで開いて再計算・保存してから"
204 "読み込み直すことをお勧めします。このまま続行しますか?",
205 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
206 QMessageBox.StandardButton.Yes
207 )
208 if reply != QMessageBox.StandardButton.Yes:
209 continue
211 # 列の多いファイルで意図しない列が選ばれないよう、プレビューを見せて X/Y の列を選ばせる
212 preview_dialog = ColumnPreviewDialog(sheet_df, preview_name, app, file_path=file_path)
213 if sheet_name is not None and preview_dialog.sheet_combo is not None:
214 preview_dialog.sheet_combo.blockSignals(True)
215 preview_dialog.sheet_combo.setCurrentText(sheet_name)
216 preview_dialog.sheet_combo.blockSignals(False)
218 if preview_dialog.exec() != QDialog.DialogCode.Accepted:
219 continue
221 x_col, y_col = preview_dialog.get_selected_columns()
222 final_df = preview_dialog.get_dataframe()
224 # 再読み込みのために元ファイルとシートを持つ。シートはダイアログで最後に選ばれたもの
225 source_sheet = (
226 preview_dialog.sheet_combo.currentText()
227 if (is_excel and preview_dialog.sheet_combo is not None) else None
228 )
229 if app._batch_import_filename_regex:
230 final_df = app._apply_filename_regex_columns(
231 final_df, file_path, app._batch_import_filename_regex
232 )
233 new_dataset = Dataset(
234 name=preview_name, df=final_df, x_col_name=x_col, y_col_name=y_col,
235 source_file=os.path.abspath(file_path), source_sheet=source_sheet,
236 )
237 app._style_imported_dataset(new_dataset)
238 app._add_dataset(new_dataset, target_folder)
239 added_count += 1
241 if added_count > 0:
242 app.statusBar().showMessage(f"読み込み完了: {file_path} ({added_count}件)", 3000)
243 app._add_recent_file(file_path)
244 else:
245 app.statusBar().showMessage("読み込みをキャンセルしました", 3000)
248def apply_filename_regex_columns(df, file_path, pattern):
249 """フォルダ一括取り込みの正規表現の名前付きグループを、ファイル名から取り出して列にする。
251 数値にできれば float の列。パターンが合わなくても取り込みは続けたいので、そのときは df をそのまま返す。
252 """
253 try:
254 match = re.search(pattern, os.path.basename(file_path))
255 except re.error as e:
256 logger.warning("正規表現が不正なため、ファイル名からの列抽出をスキップしました: %s", e)
257 return df
258 if match is None:
259 return df
260 groups = match.groupdict()
261 if not groups: 261 ↛ 262line 261 didn't jump to line 262 because the condition on line 261 was never true
262 return df
264 df = df.copy()
265 for name, value in groups.items():
266 if value is None: 266 ↛ 267line 266 didn't jump to line 267 because the condition on line 266 was never true
267 continue
268 try:
269 df[name] = float(value)
270 except (TypeError, ValueError):
271 df[name] = value
272 return df
275def on_import_folder(app):
276 """フォルダ内(サブフォルダは除く)の対応ファイルを、確認させてからドラッグ&ドロップと同じ待ち行列に積む。"""
277 dir_path = dialog_dirs.get_existing_directory(app, dialog_dirs.DATA, "フォルダから一括インポート")
278 if not dir_path:
279 return
281 allowed_extensions = app._all_supported_data_file_extensions()
282 try:
283 file_paths = sorted(
284 str(p) for p in Path(dir_path).iterdir()
285 if p.is_file() and p.suffix.lower() in allowed_extensions
286 )
287 except OSError as e:
288 notify.warning(app, "フォルダから一括インポート", f"フォルダの読み取りに失敗しました:\n{e}")
289 return
291 if not file_paths:
292 notify.information(
293 app, "フォルダから一括インポート",
294 "対応する形式のファイルがフォルダ内に見つかりませんでした。"
295 )
296 return
298 file_names = [os.path.basename(p) for p in file_paths]
299 dialog = FolderImportDialog(dir_path, file_names, parent=app)
300 if dialog.exec() != QDialog.DialogCode.Accepted:
301 return
303 app._batch_import_filename_regex = dialog.get_regex_pattern()
304 app._queue_data_files(file_paths)
307def on_paste_data_from_clipboard(app):
308 """区切り文字はファイル読み込みと同じ判定で決める(Excel からのコピーはタブ区切り)。"""
309 text = QApplication.clipboard().text()
310 if not text.strip():
311 notify.information(app, "クリップボードから貼り付け", "クリップボードにテキストデータがありません。")
312 return
314 from graphica.gui.workers import detect_clipboard_delimiter
315 delimiter = detect_clipboard_delimiter(text)
316 try:
317 df = pd.read_csv(io.StringIO(text), sep=delimiter, engine='python')
318 except Exception as e:
319 logger.exception("クリップボードの内容を表として読めませんでした")
320 notify.warning(
321 app, "貼り付けエラー",
322 f"クリップボードの内容を表として解釈できませんでした:\n{e}"
323 )
324 return
326 if df.shape[1] < 2:
327 notify.warning(app, "貼り付けエラー", "クリップボードのデータには少なくとも2列必要です。")
328 return
330 app._clipboard_paste_counter = getattr(app, '_clipboard_paste_counter', 0) + 1
331 dataset_name = f"クリップボード貼り付け {app._clipboard_paste_counter}"
333 preview_dialog = ColumnPreviewDialog(df, dataset_name, app, file_path=None)
334 if preview_dialog.exec() != QDialog.DialogCode.Accepted:
335 app.statusBar().showMessage("貼り付けをキャンセルしました", 3000)
336 return
338 x_col, y_col = preview_dialog.get_selected_columns()
339 final_df = preview_dialog.get_dataframe()
340 new_dataset = Dataset(name=dataset_name, df=final_df, x_col_name=x_col, y_col_name=y_col)
341 app._style_imported_dataset(new_dataset)
342 app._add_dataset(new_dataset, app._get_target_folder_for_new_dataset())
343 app.statusBar().showMessage("クリップボードからデータを貼り付けました", 3000)
346def on_data_load_failed(app, error_message, file_path):
347 app._cleanup_data_load_task_runner()
348 app.statusBar().clearMessage()
349 notify.critical(app, "エラー", f"読み込みエラー: {error_message}")
350 app._process_next_queued_file()
353def cleanup_data_load_task_runner(app):
354 app.ui.add_dataset_button.setEnabled(True)
355 if app._data_load_task_runner is not None: 355 ↛ exitline 355 didn't return from function 'cleanup_data_load_task_runner' because the condition on line 355 was always true
356 app._data_load_task_runner.wait()
357 app._data_load_task_runner.deleteLater()
358 app._data_load_task_runner = None