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

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 

17 

18logger = logging.getLogger(__name__) 

19 

20 

21SUPPORTED_DATA_FILE_EXTENSIONS = BUILTIN_DATA_FILE_EXTENSIONS 

22 

23 

24def find_unevaluated_formula_cells(file_path, sheet_name=None, max_examples=5, max_scan_cells=200_000): 

25 """openpyxl の読み込み(約350ms)を起動時に払わないよう、呼ぶときに import する。 

26 

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) 

31 

32 

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

36 

37 

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) 

48 

49 

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

61 

62 

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) 

70 

71 

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

82 

83 if skipped_names: 

84 notify.warning( 

85 app, "非対応のファイル形式", 

86 "以下のファイルは対応していない形式のため読み込みをスキップしました:\n" 

87 + "\n".join(skipped_names) 

88 ) 

89 

90 if not valid_paths: 

91 return 

92 

93 app._data_load_queue.extend(valid_paths) 

94 app._data_load_queue_total += len(valid_paths) 

95 

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

98 

99 

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 

108 

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

112 

113 

114def load_data(app, file_path, queue_progress=None): 

115 """別スレッドで読み込み、終わったらデータセットとして加える(大きいファイルで画面を止めない)。 

116 

117 queue_progress=(何件目, 総数) はステータスバーの表示に使う。 

118 """ 

119 if app._data_load_task_runner is not None: 

120 notify.information(app, "読み込み中", "他のファイルを読み込み中です。完了までお待ちください。") 

121 return 

122 

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} ...") 

129 

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

135 

136 

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

144 

145 

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) 

150 

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) 

157 

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 

170 

171 target_folder = app._get_target_folder_for_new_dataset() 

172 added_count = 0 

173 

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 

192 

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 

210 

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) 

217 

218 if preview_dialog.exec() != QDialog.DialogCode.Accepted: 

219 continue 

220 

221 x_col, y_col = preview_dialog.get_selected_columns() 

222 final_df = preview_dialog.get_dataframe() 

223 

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 

240 

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) 

246 

247 

248def apply_filename_regex_columns(df, file_path, pattern): 

249 """フォルダ一括取り込みの正規表現の名前付きグループを、ファイル名から取り出して列にする。 

250 

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 

263 

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 

273 

274 

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 

280 

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 

290 

291 if not file_paths: 

292 notify.information( 

293 app, "フォルダから一括インポート", 

294 "対応する形式のファイルがフォルダ内に見つかりませんでした。" 

295 ) 

296 return 

297 

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 

302 

303 app._batch_import_filename_regex = dialog.get_regex_pattern() 

304 app._queue_data_files(file_paths) 

305 

306 

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 

313 

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 

325 

326 if df.shape[1] < 2: 

327 notify.warning(app, "貼り付けエラー", "クリップボードのデータには少なくとも2列必要です。") 

328 return 

329 

330 app._clipboard_paste_counter = getattr(app, '_clipboard_paste_counter', 0) + 1 

331 dataset_name = f"クリップボード貼り付け {app._clipboard_paste_counter}" 

332 

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 

337 

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) 

344 

345 

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

351 

352 

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