flextool 4.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. flextool/__init__.py +41 -0
  2. flextool/_mem_sampler.py +193 -0
  3. flextool/_resources.py +43 -0
  4. flextool/calibrate/__init__.py +51 -0
  5. flextool/calibrate/__main__.py +11 -0
  6. flextool/calibrate/_cli.py +316 -0
  7. flextool/calibrate/_db_alt.py +166 -0
  8. flextool/calibrate/_final_outputs.py +110 -0
  9. flextool/calibrate/_guard.py +151 -0
  10. flextool/calibrate/_loop.py +558 -0
  11. flextool/calibrate/_readers.py +223 -0
  12. flextool/calibrate/_report.py +263 -0
  13. flextool/calibrate/_sizing.py +699 -0
  14. flextool/calibrate/_solve.py +134 -0
  15. flextool/calibrate/_solve_status.py +495 -0
  16. flextool/cli/__init__.py +9 -0
  17. flextool/cli/_console.py +51 -0
  18. flextool/cli/_timing.py +147 -0
  19. flextool/cli/cmd_execute_flextool_workflow.py +187 -0
  20. flextool/cli/cmd_export_to_tabular.py +56 -0
  21. flextool/cli/cmd_import_sensitivities.py +75 -0
  22. flextool/cli/cmd_migrate_database.py +13 -0
  23. flextool/cli/cmd_open_results_db.py +269 -0
  24. flextool/cli/cmd_read_matpower.py +66 -0
  25. flextool/cli/cmd_read_old_flextool.py +63 -0
  26. flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
  27. flextool/cli/cmd_read_tabular_input.py +81 -0
  28. flextool/cli/cmd_run_flextool.py +1095 -0
  29. flextool/cli/cmd_scenario_results.py +284 -0
  30. flextool/cli/cmd_solve_mps.py +169 -0
  31. flextool/cli/cmd_update_flextool.py +17 -0
  32. flextool/cli/cmd_write_outputs.py +125 -0
  33. flextool/common_utils/__init__.py +1 -0
  34. flextool/common_utils/plot_mem_shape.py +77 -0
  35. flextool/common_utils/precision.py +451 -0
  36. flextool/decomposition/__init__.py +0 -0
  37. flextool/decomposition/region_decomposition.py +128 -0
  38. flextool/decomposition/region_filter.py +1261 -0
  39. flextool/engine_polars/__init__.py +110 -0
  40. flextool/engine_polars/_axis_enums.py +742 -0
  41. flextool/engine_polars/_benders.py +3462 -0
  42. flextool/engine_polars/_block_layout.py +1479 -0
  43. flextool/engine_polars/_blocks.py +1515 -0
  44. flextool/engine_polars/_commodity_ladder.py +660 -0
  45. flextool/engine_polars/_cumulative_invest.py +1165 -0
  46. flextool/engine_polars/_db_loader.py +153 -0
  47. flextool/engine_polars/_db_reader.py +127 -0
  48. flextool/engine_polars/_dc_power_flow.py +445 -0
  49. flextool/engine_polars/_delay.py +442 -0
  50. flextool/engine_polars/_derived_arithmetic.py +432 -0
  51. flextool/engine_polars/_derived_block.py +990 -0
  52. flextool/engine_polars/_derived_branch.py +769 -0
  53. flextool/engine_polars/_derived_existing.py +1353 -0
  54. flextool/engine_polars/_derived_npv.py +1297 -0
  55. flextool/engine_polars/_derived_params.py +9850 -0
  56. flextool/engine_polars/_derived_profile.py +881 -0
  57. flextool/engine_polars/_derived_walks.py +276 -0
  58. flextool/engine_polars/_determinism.py +70 -0
  59. flextool/engine_polars/_direct_params.py +2186 -0
  60. flextool/engine_polars/_dump_csvs.py +1009 -0
  61. flextool/engine_polars/_emit_arc_unions.py +1631 -0
  62. flextool/engine_polars/_emit_calc_params.py +729 -0
  63. flextool/engine_polars/_emit_chain_params.py +709 -0
  64. flextool/engine_polars/_emit_co2_accumulators.py +400 -0
  65. flextool/engine_polars/_emit_dispatchers.py +690 -0
  66. flextool/engine_polars/_emit_energy_margin.py +125 -0
  67. flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
  68. flextool/engine_polars/_emit_entity_annual.py +428 -0
  69. flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
  70. flextool/engine_polars/_emit_leaf_sets.py +550 -0
  71. flextool/engine_polars/_emit_lp_scaling.py +665 -0
  72. flextool/engine_polars/_emit_mid_sets.py +859 -0
  73. flextool/engine_polars/_emit_pdt_params.py +759 -0
  74. flextool/engine_polars/_emit_per_solve.py +774 -0
  75. flextool/engine_polars/_emit_period_calc.py +504 -0
  76. flextool/engine_polars/_emit_period_params.py +2398 -0
  77. flextool/engine_polars/_emit_provider_io.py +141 -0
  78. flextool/engine_polars/_emit_reserve.py +574 -0
  79. flextool/engine_polars/_emit_solve_time.py +311 -0
  80. flextool/engine_polars/_emit_solve_writers.py +1249 -0
  81. flextool/engine_polars/_flex_data_accumulator.py +388 -0
  82. flextool/engine_polars/_flex_data_provider.py +478 -0
  83. flextool/engine_polars/_group_slack.py +1253 -0
  84. flextool/engine_polars/_inmemory_reader.py +140 -0
  85. flextool/engine_polars/_input_source.py +336 -0
  86. flextool/engine_polars/_invest_seeds.py +191 -0
  87. flextool/engine_polars/_native_input_writer.py +100 -0
  88. flextool/engine_polars/_native_run_model.py +1348 -0
  89. flextool/engine_polars/_orchestration.py +4314 -0
  90. flextool/engine_polars/_output_writer.py +439 -0
  91. flextool/engine_polars/_param_shapes.py +1595 -0
  92. flextool/engine_polars/_parquet_bundle.py +723 -0
  93. flextool/engine_polars/_pdt_join.py +167 -0
  94. flextool/engine_polars/_pdt_lookup.py +547 -0
  95. flextool/engine_polars/_per_solve_sets.py +335 -0
  96. flextool/engine_polars/_projection_params.py +2056 -0
  97. flextool/engine_polars/_provider_keys.py +173 -0
  98. flextool/engine_polars/_provider_translators.py +225 -0
  99. flextool/engine_polars/_recursive_solve.py +703 -0
  100. flextool/engine_polars/_region_filter.py +2508 -0
  101. flextool/engine_polars/_reserve.py +649 -0
  102. flextool/engine_polars/_solve_acceptance.py +331 -0
  103. flextool/engine_polars/_solve_config.py +1001 -0
  104. flextool/engine_polars/_solve_context.py +885 -0
  105. flextool/engine_polars/_solve_handoff.py +164 -0
  106. flextool/engine_polars/_solve_state.py +232 -0
  107. flextool/engine_polars/_solver_base.py +36 -0
  108. flextool/engine_polars/_solver_dispatch.py +511 -0
  109. flextool/engine_polars/_spinedb_reader.py +1165 -0
  110. flextool/engine_polars/_stochastic.py +593 -0
  111. flextool/engine_polars/_subprocess_solve.py +1838 -0
  112. flextool/engine_polars/_timeline.py +1416 -0
  113. flextool/engine_polars/_vectorize.py +438 -0
  114. flextool/engine_polars/_warm.py +858 -0
  115. flextool/engine_polars/autoscale/__init__.py +107 -0
  116. flextool/engine_polars/autoscale/_config.py +218 -0
  117. flextool/engine_polars/autoscale/_layer2.py +1253 -0
  118. flextool/engine_polars/autoscale/_layer2_types.py +584 -0
  119. flextool/engine_polars/autoscale/_quantity_types.py +621 -0
  120. flextool/engine_polars/autoscale/_report.py +336 -0
  121. flextool/engine_polars/chain.py +259 -0
  122. flextool/engine_polars/input.py +6638 -0
  123. flextool/engine_polars/model.py +4754 -0
  124. flextool/env_check.py +388 -0
  125. flextool/export_to_tabular/__init__.py +5 -0
  126. flextool/export_to_tabular/db_reader.py +224 -0
  127. flextool/export_to_tabular/excel_writer.py +3559 -0
  128. flextool/export_to_tabular/export_settings.yaml +377 -0
  129. flextool/export_to_tabular/export_to_excel.py +227 -0
  130. flextool/export_to_tabular/formatting.py +543 -0
  131. flextool/export_to_tabular/sheet_config.py +876 -0
  132. flextool/gui/__init__.py +0 -0
  133. flextool/gui/__main__.py +118 -0
  134. flextool/gui/calibrate_commands.py +184 -0
  135. flextool/gui/calibrate_jobs.py +424 -0
  136. flextool/gui/check_tree.py +142 -0
  137. flextool/gui/cli_format.py +83 -0
  138. flextool/gui/config_parser.py +68 -0
  139. flextool/gui/data_models.py +362 -0
  140. flextool/gui/db_editor_integration.py +202 -0
  141. flextool/gui/db_version_check.py +269 -0
  142. flextool/gui/dialogs/__init__.py +0 -0
  143. flextool/gui/dialogs/add_dialog.py +1098 -0
  144. flextool/gui/dialogs/calibrate_dialog.py +1259 -0
  145. flextool/gui/dialogs/file_picker.py +473 -0
  146. flextool/gui/dialogs/group_picker.py +299 -0
  147. flextool/gui/dialogs/migration_consent_dialog.py +106 -0
  148. flextool/gui/dialogs/migration_progress_dialog.py +237 -0
  149. flextool/gui/dialogs/plot_dialog.py +459 -0
  150. flextool/gui/dialogs/plot_settings_picker.py +2184 -0
  151. flextool/gui/dialogs/project_dialog.py +426 -0
  152. flextool/gui/dialogs/update_dialog.py +212 -0
  153. flextool/gui/downsampling.py +88 -0
  154. flextool/gui/error_handling.py +50 -0
  155. flextool/gui/execution_manager.py +1715 -0
  156. flextool/gui/execution_window.py +1377 -0
  157. flextool/gui/hover_tooltip.py +111 -0
  158. flextool/gui/input_sources.py +730 -0
  159. flextool/gui/main_window.py +6181 -0
  160. flextool/gui/network_graph.py +215 -0
  161. flextool/gui/output_actions.py +393 -0
  162. flextool/gui/output_log_window.py +159 -0
  163. flextool/gui/platform_utils.py +421 -0
  164. flextool/gui/plot_cache.py +88 -0
  165. flextool/gui/plot_canvas.py +543 -0
  166. flextool/gui/plot_config_reader.py +272 -0
  167. flextool/gui/project_utils.py +100 -0
  168. flextool/gui/result_viewer.py +4394 -0
  169. flextool/gui/scenario_key.py +162 -0
  170. flextool/gui/scenario_lists.py +516 -0
  171. flextool/gui/settings_io.py +360 -0
  172. flextool/gui/solve_reader.py +103 -0
  173. flextool/gui/tree_reorder.py +88 -0
  174. flextool/gui/ui_metrics.py +420 -0
  175. flextool/input_derivation/__init__.py +281 -0
  176. flextool/input_derivation/_commodity_ladder.py +375 -0
  177. flextool/input_derivation/_commodity_ladder_sets.py +70 -0
  178. flextool/input_derivation/_dc_power_flow.py +377 -0
  179. flextool/input_derivation/_method_constants.py +77 -0
  180. flextool/input_derivation/_process_method.py +258 -0
  181. flextool/input_derivation/_specs.py +1026 -0
  182. flextool/input_derivation/_validators.py +321 -0
  183. flextool/lean_parquet.py +159 -0
  184. flextool/model_builder/__init__.py +5 -0
  185. flextool/model_builder/build_model.py +589 -0
  186. flextool/model_builder/encoding.py +67 -0
  187. flextool/model_builder/names.py +34 -0
  188. flextool/model_builder/profiles.py +129 -0
  189. flextool/plot_outputs/__init__.py +14 -0
  190. flextool/plot_outputs/axis_helpers.py +355 -0
  191. flextool/plot_outputs/color_template.py +888 -0
  192. flextool/plot_outputs/config.py +171 -0
  193. flextool/plot_outputs/format_helpers.py +345 -0
  194. flextool/plot_outputs/legend_helpers.py +143 -0
  195. flextool/plot_outputs/orchestrator.py +1141 -0
  196. flextool/plot_outputs/perf.py +37 -0
  197. flextool/plot_outputs/plan.py +1787 -0
  198. flextool/plot_outputs/plot_bars.py +1510 -0
  199. flextool/plot_outputs/plot_bars_detail.py +753 -0
  200. flextool/plot_outputs/plot_lines.py +951 -0
  201. flextool/plot_outputs/shared_manifest.py +564 -0
  202. flextool/plot_outputs/subplot_helpers.py +137 -0
  203. flextool/process_inputs/__init__.py +188 -0
  204. flextool/process_inputs/import_old_excel_input.json +4159 -0
  205. flextool/process_inputs/read_matpower.py +451 -0
  206. flextool/process_inputs/read_old_flextool.py +1288 -0
  207. flextool/process_inputs/read_self_describing_excel.py +1423 -0
  208. flextool/process_inputs/read_tabular_with_specification.py +1114 -0
  209. flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
  210. flextool/process_inputs/write_self_describing_to_db.py +977 -0
  211. flextool/process_inputs/write_to_input_db.py +269 -0
  212. flextool/process_outputs/__init__.py +7 -0
  213. flextool/process_outputs/_annualize.py +55 -0
  214. flextool/process_outputs/_inmemory_helpers.py +292 -0
  215. flextool/process_outputs/_output_meta.py +672 -0
  216. flextool/process_outputs/calc_capacity_flows.py +107 -0
  217. flextool/process_outputs/calc_connections.py +136 -0
  218. flextool/process_outputs/calc_costs.py +260 -0
  219. flextool/process_outputs/calc_group_flows.py +192 -0
  220. flextool/process_outputs/calc_slacks.py +103 -0
  221. flextool/process_outputs/calc_storage_vre.py +160 -0
  222. flextool/process_outputs/drop_levels.py +208 -0
  223. flextool/process_outputs/handoff_writers.py +1315 -0
  224. flextool/process_outputs/out_ancillary.py +544 -0
  225. flextool/process_outputs/out_capacity.py +179 -0
  226. flextool/process_outputs/out_costs.py +334 -0
  227. flextool/process_outputs/out_flowgroup.py +189 -0
  228. flextool/process_outputs/out_flows.py +301 -0
  229. flextool/process_outputs/out_group.py +475 -0
  230. flextool/process_outputs/out_node.py +190 -0
  231. flextool/process_outputs/persist_realized_slice.py +601 -0
  232. flextool/process_outputs/process_results.py +24 -0
  233. flextool/process_outputs/read_highs_solution.py +2256 -0
  234. flextool/process_outputs/read_parameters.py +1799 -0
  235. flextool/process_outputs/read_sets.py +1095 -0
  236. flextool/process_outputs/read_variables.py +553 -0
  237. flextool/process_outputs/solve_order.py +81 -0
  238. flextool/process_outputs/spinedb_replay.py +412 -0
  239. flextool/process_outputs/union_realized_slice.py +224 -0
  240. flextool/process_outputs/write_outputs.py +1286 -0
  241. flextool/process_outputs/write_spinedb.py +1267 -0
  242. flextool/representative_periods/__init__.py +5 -0
  243. flextool/representative_periods/clustering.py +165 -0
  244. flextool/representative_periods/force_include.py +563 -0
  245. flextool/representative_periods/netload.py +365 -0
  246. flextool/representative_periods/netload_inputs.py +345 -0
  247. flextool/representative_periods/netload_iterate.py +722 -0
  248. flextool/representative_periods/preprocess.py +948 -0
  249. flextool/representative_periods/scenario_stack.py +195 -0
  250. flextool/representative_periods/weights.py +124 -0
  251. flextool/scenario_comparison/__init__.py +13 -0
  252. flextool/scenario_comparison/config_builder.py +158 -0
  253. flextool/scenario_comparison/constants.py +20 -0
  254. flextool/scenario_comparison/data_models.py +222 -0
  255. flextool/scenario_comparison/db_reader.py +399 -0
  256. flextool/scenario_comparison/dispatch_data.py +1002 -0
  257. flextool/scenario_comparison/dispatch_mappings.py +205 -0
  258. flextool/scenario_comparison/dispatch_plots.py +691 -0
  259. flextool/scenario_comparison/input_entity_colors.py +319 -0
  260. flextool/scenario_comparison/orchestrator.py +453 -0
  261. flextool/scenario_comparison/plan_union.py +244 -0
  262. flextool/scenario_comparison/plot_settings_seed.py +205 -0
  263. flextool/schemas/AXIS_CONTRACT.md +71 -0
  264. flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
  265. flextool/schemas/canonical_databases/howto_connections.json +5606 -0
  266. flextool/schemas/canonical_databases/howto_demand.json +5518 -0
  267. flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
  268. flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
  269. flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
  270. flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
  271. flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
  272. flextool/schemas/canonical_databases/templates_examples.json +13532 -0
  273. flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
  274. flextool/schemas/comparison_settings_template.json +197 -0
  275. flextool/schemas/default_plot_settings.yaml +260 -0
  276. flextool/schemas/default_plots.yaml +2293 -0
  277. flextool/schemas/flextool_axis_contract.json +303 -0
  278. flextool/schemas/flextool_axis_contract.schema.json +247 -0
  279. flextool/schemas/old_flextool_import_template.json +4443 -0
  280. flextool/schemas/output_info_template.json +48 -0
  281. flextool/schemas/output_settings_template.json +256 -0
  282. flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
  283. flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
  284. flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
  285. flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
  286. flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
  287. flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
  288. flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
  289. flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
  290. flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
  291. flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
  292. flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
  293. flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
  294. flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
  295. flextool/schemas/spinedb_results_schema.json +581 -0
  296. flextool/schemas/spinedb_schema.json +4636 -0
  297. flextool/solver_config/copt.opt.template +18 -0
  298. flextool/solver_config/cplex.opt.template +25 -0
  299. flextool/solver_config/gurobi.opt.template +18 -0
  300. flextool/solver_config/highs.opt.template +18 -0
  301. flextool/solver_config/xpress.opt.template +26 -0
  302. flextool/spinedb_backend/__init__.py +26 -0
  303. flextool/spinedb_backend/_axis_enums.py +1119 -0
  304. flextool/spinedb_backend/_backend.py +1139 -0
  305. flextool/update_flextool/__init__.py +12 -0
  306. flextool/update_flextool/canonical_databases.py +251 -0
  307. flextool/update_flextool/db_migration.py +7108 -0
  308. flextool/update_flextool/ensure_settings_db.py +138 -0
  309. flextool/update_flextool/export_database.py +103 -0
  310. flextool/update_flextool/extend_tests_fixture.py +772 -0
  311. flextool/update_flextool/generate_canonical.py +274 -0
  312. flextool/update_flextool/initialize_database.py +42 -0
  313. flextool/update_flextool/install_info.py +225 -0
  314. flextool/update_flextool/self_update.py +464 -0
  315. flextool/update_flextool/sync_master_json_template.py +125 -0
  316. flextool/update_flextool/test_fixtures.py +187 -0
  317. flextool-4.0.0.dist-info/METADATA +217 -0
  318. flextool-4.0.0.dist-info/RECORD +322 -0
  319. flextool-4.0.0.dist-info/WHEEL +5 -0
  320. flextool-4.0.0.dist-info/entry_points.txt +17 -0
  321. flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
  322. flextool-4.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,426 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import shutil
5
+ import tkinter as tk
6
+ import tkinter.font as tkfont
7
+ from tkinter import ttk, messagebox
8
+
9
+ from flextool.gui.project_utils import (
10
+ create_project,
11
+ get_projects_dir,
12
+ list_projects,
13
+ rename_project,
14
+ )
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class ProjectDialog(tk.Toplevel):
20
+ """Modal dialog for creating, selecting, renaming, and deleting projects.
21
+
22
+ After the dialog closes:
23
+ * ``self.result`` holds the selected project name (Open clicked) or
24
+ ``None`` if the user cancelled.
25
+ * ``self.deleted_names`` lists projects that were deleted while the
26
+ dialog was open. The caller can use it to check whether the
27
+ currently-open project was removed.
28
+ """
29
+
30
+ def __init__(self, parent: tk.Misc, current_project: str | None = None) -> None:
31
+ super().__init__(parent)
32
+ self.title("Project menu")
33
+ self.result: str | None = None
34
+ self.deleted_names: list[str] = []
35
+ self._current_project = current_project
36
+
37
+ # ── Modal behaviour ─────────────────────────────────────────
38
+ self.transient(parent)
39
+ self.grab_set()
40
+
41
+ # ── Font metrics for DPI-aware sizing ──────────────────────
42
+ from flextool.gui.ui_metrics import get_metrics
43
+ _metrics = get_metrics(self)
44
+ cw: int = _metrics.cw
45
+ lh: int = _metrics.lh
46
+
47
+ # ── Dialog size ─────────────────────────────────────────────
48
+ self.geometry(f"{cw * 70}x{lh * 30}")
49
+ self.minsize(cw * 50, lh * 20)
50
+
51
+ self._rename_entry: tk.Entry | None = None
52
+
53
+ self._build_widgets()
54
+ self._populate_projects()
55
+
56
+ # Close via window-manager "X"
57
+ self.protocol("WM_DELETE_WINDOW", self._on_cancel)
58
+
59
+ # Centre on parent
60
+ self.update_idletasks()
61
+ px = parent.winfo_rootx()
62
+ py = parent.winfo_rooty()
63
+ pw = parent.winfo_width()
64
+ ph = parent.winfo_height()
65
+ w = self.winfo_width()
66
+ h = self.winfo_height()
67
+ x = px + (pw - w) // 2
68
+ y = py + (ph - h) // 2
69
+ self.geometry(f"+{x}+{y}")
70
+
71
+ # Block until closed
72
+ parent.wait_window(self)
73
+
74
+ # ── Widget construction ─────────────────────────────────────────
75
+
76
+ def _build_widgets(self) -> None:
77
+ pad = dict(padx=10, pady=5)
78
+
79
+ # -- New project section --
80
+ new_frame = ttk.LabelFrame(self, text="Create an empty project", padding=8)
81
+ new_frame.pack(fill="x", **pad)
82
+
83
+ entry_row = ttk.Frame(new_frame)
84
+ entry_row.pack(fill="x")
85
+
86
+ ttk.Label(entry_row, text="Name:").pack(side="left")
87
+ self._new_name_var = tk.StringVar()
88
+ self._new_name_entry = ttk.Entry(entry_row, textvariable=self._new_name_var, width=30)
89
+ self._new_name_entry.pack(side="left", padx=(5, 0), fill="x", expand=True)
90
+
91
+ self._create_btn = ttk.Button(new_frame, text="Create", command=self._on_create)
92
+ self._create_btn.pack(anchor="w", pady=(5, 0))
93
+
94
+ # Allow Enter in the entry to create
95
+ self._new_name_entry.bind("<Return>", lambda _e: self._on_create())
96
+
97
+ # -- Existing projects section --
98
+ existing_frame = ttk.LabelFrame(self, text="Existing projects", padding=8)
99
+ existing_frame.pack(fill="both", expand=True, **pad)
100
+
101
+ list_frame = ttk.Frame(existing_frame)
102
+ list_frame.pack(fill="both", expand=True)
103
+ list_frame.columnconfigure(0, weight=1)
104
+ list_frame.rowconfigure(0, weight=1)
105
+
106
+ self._project_listbox = tk.Listbox(list_frame, selectmode="browse", activestyle="dotbox")
107
+ self._project_listbox.grid(row=0, column=0, sticky="nsew")
108
+
109
+ scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=self._project_listbox.yview)
110
+ self._project_listbox.configure(yscrollcommand=scrollbar.set)
111
+ scrollbar.grid(row=0, column=1, sticky="ns")
112
+
113
+ # Double-click opens
114
+ self._project_listbox.bind("<Double-Button-1>", lambda _e: self._on_open())
115
+ # F2 triggers inline rename
116
+ self._project_listbox.bind("<F2>", lambda _e: self._start_inline_rename())
117
+
118
+ # Enable/disable Ok when listbox selection changes
119
+ self._project_listbox.bind("<<ListboxSelect>>", lambda _e: self._update_ok_state())
120
+
121
+ # -- Bottom buttons --
122
+ btn_frame = ttk.Frame(self)
123
+ btn_frame.pack(fill="x", **pad)
124
+
125
+ self._open_btn = ttk.Button(btn_frame, text="Open the selected project", command=self._on_open, state="disabled")
126
+ self._open_btn.pack(side="left")
127
+
128
+ self._delete_btn = ttk.Button(
129
+ btn_frame,
130
+ text="Delete",
131
+ command=self._on_delete_clicked,
132
+ state="disabled",
133
+ )
134
+ self._delete_btn.pack(side="left", padx=(10, 0))
135
+
136
+ self._cancel_btn = ttk.Button(btn_frame, text="Cancel", command=self._on_cancel)
137
+ self._cancel_btn.pack(side="right")
138
+
139
+ # ── Helpers ──────────────────────────────────────────────────────
140
+
141
+ def _update_ok_state(self) -> None:
142
+ """Enable Open/Delete buttons when a project is selected."""
143
+ if self._project_listbox.curselection():
144
+ self._open_btn.configure(state="normal")
145
+ self._delete_btn.configure(state="normal")
146
+ else:
147
+ self._open_btn.configure(state="disabled")
148
+ self._delete_btn.configure(state="disabled")
149
+
150
+ def _populate_projects(self) -> None:
151
+ """Refresh the listbox with the current project list."""
152
+ self._project_listbox.delete(0, "end")
153
+ for name in list_projects():
154
+ self._project_listbox.insert("end", name)
155
+
156
+ def _selected_project(self) -> str | None:
157
+ """Return the currently selected project name, or None."""
158
+ sel = self._project_listbox.curselection()
159
+ if not sel:
160
+ return None
161
+ return self._project_listbox.get(sel[0])
162
+
163
+ # ── Event handlers ──────────────────────────────────────────────
164
+
165
+ def _on_create(self) -> None:
166
+ name = self._new_name_var.get().strip()
167
+ if not name:
168
+ messagebox.showwarning("Invalid name", "Please enter a project name.", parent=self)
169
+ return
170
+ # Disallow names with path separators or other problematic chars
171
+ if "/" in name or "\\" in name:
172
+ messagebox.showwarning(
173
+ "Invalid name",
174
+ "Project name must not contain '/' or '\\'.",
175
+ parent=self,
176
+ )
177
+ return
178
+ # Check for duplicate before creating
179
+ if (get_projects_dir() / name).exists():
180
+ messagebox.showwarning(
181
+ "Already exists",
182
+ f"A project named '{name}' already exists.",
183
+ parent=self,
184
+ )
185
+ return
186
+ try:
187
+ create_project(name)
188
+ except OSError as exc:
189
+ messagebox.showerror("Error", str(exc), parent=self)
190
+ return
191
+
192
+ self._populate_projects()
193
+ # Select the newly created project in the listbox
194
+ items = self._project_listbox.get(0, "end")
195
+ try:
196
+ idx = list(items).index(name)
197
+ self._project_listbox.selection_clear(0, "end")
198
+ self._project_listbox.selection_set(idx)
199
+ self._project_listbox.see(idx)
200
+ except ValueError:
201
+ pass
202
+ self._new_name_var.set("")
203
+ self._update_ok_state()
204
+
205
+ def _on_open(self) -> None:
206
+ name = self._selected_project()
207
+ if name is None:
208
+ messagebox.showinfo("No selection", "Please select a project first.", parent=self)
209
+ return
210
+ self.result = name
211
+ self.grab_release()
212
+ self.destroy()
213
+
214
+ def _on_cancel(self) -> None:
215
+ self.result = None
216
+ self.grab_release()
217
+ self.destroy()
218
+
219
+ # ── Delete project ──────────────────────────────────────────────
220
+
221
+ def _on_delete_clicked(self) -> None:
222
+ """Confirm and recursively delete the selected project directory."""
223
+ name = self._selected_project()
224
+ if name is None:
225
+ return
226
+ if not self._confirm_delete(name):
227
+ return
228
+
229
+ project_path = get_projects_dir() / name
230
+ try:
231
+ shutil.rmtree(project_path)
232
+ except OSError as exc:
233
+ logger.warning("Failed to delete project %r", name, exc_info=True)
234
+ messagebox.showerror(
235
+ "Delete failed",
236
+ f"Could not delete project '{name}':\n\n{exc}",
237
+ parent=self,
238
+ )
239
+ return
240
+
241
+ self.deleted_names.append(name)
242
+ self._populate_projects()
243
+ self._update_ok_state()
244
+
245
+ def _confirm_delete(self, name: str) -> bool:
246
+ """Show a custom confirmation dialog; return True if user confirms.
247
+
248
+ The confirmation uses a capital-letters warning line and a
249
+ regular-text explanation that the deletion is recursive
250
+ (including any imported input databases). Default focus is
251
+ Cancel so an accidental Enter doesn't trigger deletion.
252
+ """
253
+ dlg = tk.Toplevel(self)
254
+ dlg.title("Delete project")
255
+ dlg.transient(self)
256
+ dlg.grab_set()
257
+ dlg.resizable(False, False)
258
+
259
+ # Sizing — match the parent dialog's DPI-aware metric helper.
260
+ from flextool.gui.ui_metrics import get_metrics
261
+ _m = get_metrics(dlg)
262
+ cw, lh = _m.cw, _m.lh
263
+ wrap_px = cw * 60
264
+
265
+ confirmed = {"value": False}
266
+
267
+ body = ttk.Frame(dlg, padding=(cw * 2, lh))
268
+ body.pack(fill="both", expand=True)
269
+
270
+ warning_font = tkfont.Font(font="TkDefaultFont")
271
+ warning_font.configure(weight="bold", size=warning_font.cget("size") + 2)
272
+
273
+ ttk.Label(
274
+ body,
275
+ text="ALL PROJECT DATA WILL BE PERMANENTLY DELETED",
276
+ font=warning_font,
277
+ foreground="#b00020",
278
+ wraplength=wrap_px,
279
+ justify="left",
280
+ ).pack(anchor="w", pady=(0, lh))
281
+
282
+ ttk.Label(
283
+ body,
284
+ text=(
285
+ "This includes input databases (Excel, SQLite) that were "
286
+ "copied or imported into the project folder, as well as "
287
+ "all output results, plots, settings, and any other files "
288
+ "under the project directory."
289
+ ),
290
+ wraplength=wrap_px,
291
+ justify="left",
292
+ ).pack(anchor="w")
293
+
294
+ ttk.Label(
295
+ body,
296
+ text=f"Project: {name}",
297
+ wraplength=wrap_px,
298
+ justify="left",
299
+ ).pack(anchor="w", pady=(lh, 0))
300
+
301
+ # -- Buttons --
302
+ btn_row = ttk.Frame(dlg, padding=(cw * 2, 0, cw * 2, lh))
303
+ btn_row.pack(fill="x")
304
+
305
+ def _do_delete() -> None:
306
+ confirmed["value"] = True
307
+ dlg.grab_release()
308
+ dlg.destroy()
309
+
310
+ def _do_cancel() -> None:
311
+ confirmed["value"] = False
312
+ dlg.grab_release()
313
+ dlg.destroy()
314
+
315
+ # Try to use a danger style if the theme defines one; fall back
316
+ # silently to the default button style.
317
+ delete_btn = ttk.Button(btn_row, text="Delete", command=_do_delete)
318
+ try:
319
+ ttk.Style().configure("Danger.TButton", foreground="#b00020")
320
+ delete_btn.configure(style="Danger.TButton")
321
+ except tk.TclError:
322
+ pass
323
+ delete_btn.pack(side="left")
324
+
325
+ cancel_btn = ttk.Button(btn_row, text="Cancel", command=_do_cancel)
326
+ cancel_btn.pack(side="right")
327
+
328
+ dlg.protocol("WM_DELETE_WINDOW", _do_cancel)
329
+ dlg.bind("<Escape>", lambda _e: _do_cancel())
330
+ # Default focus = Cancel (defensive — Enter should not delete).
331
+ cancel_btn.focus_set()
332
+
333
+ # Centre on parent dialog.
334
+ dlg.update_idletasks()
335
+ px = self.winfo_rootx()
336
+ py = self.winfo_rooty()
337
+ pw = self.winfo_width()
338
+ ph = self.winfo_height()
339
+ w = dlg.winfo_width()
340
+ h = dlg.winfo_height()
341
+ x = px + (pw - w) // 2
342
+ y = py + (ph - h) // 2
343
+ dlg.geometry(f"+{x}+{y}")
344
+
345
+ self.wait_window(dlg)
346
+ return confirmed["value"]
347
+
348
+ # ── Inline rename ───────────────────────────────────────────────
349
+
350
+ def _start_inline_rename(self) -> None:
351
+ """Overlay an Entry widget on the selected listbox item for renaming."""
352
+ sel = self._project_listbox.curselection()
353
+ if not sel:
354
+ return
355
+ index = sel[0]
356
+ old_name = self._project_listbox.get(index)
357
+
358
+ # Cancel any previous rename entry
359
+ self._cancel_inline_rename()
360
+
361
+ # Position the entry over the listbox item
362
+ bbox = self._project_listbox.bbox(index)
363
+ if bbox is None:
364
+ return
365
+ x, y, width, height = bbox
366
+
367
+ entry = tk.Entry(self._project_listbox, width=0)
368
+ entry.place(x=x, y=y, width=max(width, self._project_listbox.winfo_width()), height=height)
369
+ entry.insert(0, old_name)
370
+ entry.select_range(0, "end")
371
+ entry.focus_set()
372
+
373
+ self._rename_entry = entry
374
+ self._rename_index = index
375
+ self._rename_old_name = old_name
376
+
377
+ entry.bind("<Return>", lambda _e: self._finish_inline_rename())
378
+ entry.bind("<Escape>", lambda _e: self._cancel_inline_rename())
379
+ entry.bind("<FocusOut>", lambda _e: self._cancel_inline_rename())
380
+
381
+ def _finish_inline_rename(self) -> None:
382
+ if self._rename_entry is None:
383
+ return
384
+ new_name = self._rename_entry.get().strip()
385
+ old_name = self._rename_old_name
386
+ self._rename_entry.destroy()
387
+ self._rename_entry = None
388
+
389
+ if not new_name or new_name == old_name:
390
+ return
391
+
392
+ try:
393
+ rename_project(old_name, new_name)
394
+ except FileExistsError:
395
+ messagebox.showwarning(
396
+ "Already exists",
397
+ f"A project named '{new_name}' already exists.",
398
+ parent=self,
399
+ )
400
+ return
401
+ except FileNotFoundError:
402
+ messagebox.showerror(
403
+ "Not found",
404
+ f"Project '{old_name}' no longer exists.",
405
+ parent=self,
406
+ )
407
+ return
408
+ except OSError as exc:
409
+ messagebox.showerror("Error", str(exc), parent=self)
410
+ return
411
+
412
+ self._populate_projects()
413
+ # Re-select the renamed project
414
+ items = self._project_listbox.get(0, "end")
415
+ try:
416
+ idx = list(items).index(new_name)
417
+ self._project_listbox.selection_clear(0, "end")
418
+ self._project_listbox.selection_set(idx)
419
+ self._project_listbox.see(idx)
420
+ except ValueError:
421
+ pass
422
+
423
+ def _cancel_inline_rename(self) -> None:
424
+ if self._rename_entry is not None:
425
+ self._rename_entry.destroy()
426
+ self._rename_entry = None
@@ -0,0 +1,212 @@
1
+ """Modal dialog for the in-app "Update FlexTool" action."""
2
+ from __future__ import annotations
3
+
4
+ import threading
5
+ import tkinter as tk
6
+ from collections.abc import Callable
7
+ from tkinter import ttk
8
+
9
+
10
+ class UpdateDialog(tk.Toplevel):
11
+ """Ask the user to confirm a FlexTool self-update.
12
+
13
+ Exposes these attributes after it closes:
14
+
15
+ - ``proceed`` -- ``True`` if the user clicked Update.
16
+ - ``include_toolbox`` -- ``True`` if Spine Toolbox should be installed
17
+ alongside FlexTool (the ``[toolbox]`` extra).
18
+ - ``check_on_startup`` -- the (possibly changed) "check for updates on
19
+ startup" preference; persisted whether or not the user updated.
20
+ - ``update_available`` -- the latest known availability (possibly
21
+ refreshed by the in-dialog Check button); ``None`` if never determined.
22
+
23
+ The **Update** button is disabled when we know there is no update; the
24
+ **Check** button re-queries on demand (useful when the startup check is
25
+ off) and re-enables Update if the situation changed.
26
+
27
+ Show it by constructing it and calling ``parent.wait_window(self)``.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ parent: tk.Misc,
33
+ *,
34
+ install_description: str,
35
+ is_git: bool,
36
+ default_toolbox: bool,
37
+ check_on_startup: bool,
38
+ update_available: bool | None,
39
+ check_fn: Callable[[], bool],
40
+ post_to_main: Callable[..., None],
41
+ ) -> None:
42
+ super().__init__(parent)
43
+ self.title("Update FlexTool")
44
+ self.transient(parent)
45
+ self.resizable(False, False)
46
+
47
+ self.proceed: bool = False
48
+ self.include_toolbox: bool = default_toolbox
49
+ self.check_on_startup: bool = check_on_startup
50
+ self.update_available: bool | None = update_available
51
+ self._initial_check_on_startup: bool = check_on_startup
52
+ self._check_fn = check_fn
53
+ self._post_to_main = post_to_main
54
+
55
+ self._toolbox_var = tk.BooleanVar(value=default_toolbox)
56
+ self._check_startup_var = tk.BooleanVar(value=check_on_startup)
57
+ self._status_var = tk.StringVar()
58
+
59
+ body = ttk.Frame(self, padding=16)
60
+ body.pack(fill="both", expand=True)
61
+
62
+ ttk.Label(
63
+ body,
64
+ text="Update FlexTool to the latest version.",
65
+ font=("TkDefaultFont", 11, "bold"),
66
+ ).pack(anchor="w")
67
+
68
+ method = (
69
+ "git pull + editable reinstall" if is_git else "pip upgrade from PyPI"
70
+ )
71
+ ttk.Label(
72
+ body,
73
+ text=f"Current install: {install_description}\nUpdate method: {method}.",
74
+ wraplength=440,
75
+ justify="left",
76
+ ).pack(anchor="w", pady=(8, 8))
77
+
78
+ ttk.Label(
79
+ body, textvariable=self._status_var, wraplength=440, justify="left",
80
+ ).pack(anchor="w", pady=(0, 8))
81
+
82
+ ttk.Checkbutton(
83
+ body, text="Install Spine Toolbox", variable=self._toolbox_var,
84
+ ).pack(anchor="w")
85
+ ttk.Label(
86
+ body,
87
+ text=(
88
+ "Spine Toolbox is a large optional dependency. It is required "
89
+ "to open .sqlite input sources in the Spine DB Editor. Leave "
90
+ "unchecked if you do not need it."
91
+ ),
92
+ wraplength=440,
93
+ justify="left",
94
+ foreground="#888888",
95
+ ).pack(anchor="w", padx=(24, 0), pady=(0, 8))
96
+
97
+ ttk.Label(
98
+ body,
99
+ text=(
100
+ "FlexTool must be restarted after an update. Progress is shown "
101
+ "in the Execution window."
102
+ ),
103
+ wraplength=440,
104
+ justify="left",
105
+ ).pack(anchor="w", pady=(0, 8))
106
+
107
+ ttk.Separator(body, orient="horizontal").pack(fill="x", pady=(0, 8))
108
+ ttk.Checkbutton(
109
+ body,
110
+ text="Check for updates on startup",
111
+ variable=self._check_startup_var,
112
+ ).pack(anchor="w", pady=(0, 8))
113
+
114
+ btns = ttk.Frame(body)
115
+ btns.pack(fill="x", pady=(4, 0))
116
+ # Check on the left; right-aligned left→right: Update | OK | Cancel.
117
+ # Update = save settings and upgrade; OK = save settings only; Cancel =
118
+ # close without changing anything.
119
+ self._check_btn = ttk.Button(btns, text="Check", command=self._on_check)
120
+ self._check_btn.pack(side="left")
121
+ ttk.Button(btns, text="Cancel", command=self._on_cancel).pack(side="right")
122
+ ttk.Button(btns, text="OK", command=self._on_ok).pack(
123
+ side="right", padx=(0, 8)
124
+ )
125
+ self._update_btn = ttk.Button(btns, text="Update", command=self._on_update)
126
+ self._update_btn.pack(side="right", padx=(0, 8))
127
+
128
+ self._render_availability()
129
+
130
+ self.protocol("WM_DELETE_WINDOW", self._on_cancel)
131
+ self.bind("<Escape>", lambda _e: self._on_cancel())
132
+
133
+ self.grab_set()
134
+ self.update_idletasks()
135
+ pw, ph = parent.winfo_width(), parent.winfo_height()
136
+ px, py = parent.winfo_rootx(), parent.winfo_rooty()
137
+ dw, dh = self.winfo_width(), self.winfo_height()
138
+ self.geometry(f"+{px + (pw - dw) // 2}+{py + (ph - dh) // 2}")
139
+
140
+ # ── Availability state ──────────────────────────────────────────
141
+
142
+ def _render_availability(self) -> None:
143
+ """Reflect ``update_available`` in the status label and Update button."""
144
+ if self.update_available is True:
145
+ self._status_var.set("Status: a newer version is available.")
146
+ self._set_update_enabled(True)
147
+ elif self.update_available is False:
148
+ self._status_var.set("Status: you are up to date.")
149
+ self._set_update_enabled(False)
150
+ else:
151
+ self._status_var.set(
152
+ "Status: not checked — click Check to look for updates."
153
+ )
154
+ self._set_update_enabled(True)
155
+
156
+ def _set_update_enabled(self, enabled: bool) -> None:
157
+ try:
158
+ self._update_btn.configure(state="normal" if enabled else "disabled")
159
+ except tk.TclError:
160
+ pass
161
+
162
+ def _on_check(self) -> None:
163
+ """Query for an update off the UI thread; update state when it returns."""
164
+ self._check_btn.configure(state="disabled")
165
+ self._status_var.set("Status: checking…")
166
+
167
+ def _worker() -> None:
168
+ try:
169
+ available = bool(self._check_fn())
170
+ except Exception:
171
+ available = False
172
+ self._post_to_main(self._apply_check_result, available)
173
+
174
+ threading.Thread(target=_worker, daemon=True).start()
175
+
176
+ def _apply_check_result(self, available: bool) -> None:
177
+ """Apply a Check result on the main thread."""
178
+ if not self.winfo_exists():
179
+ return
180
+ self.update_available = available
181
+ self._render_availability()
182
+ try:
183
+ self._check_btn.configure(state="normal")
184
+ except tk.TclError:
185
+ pass
186
+
187
+ # ── Button handlers ─────────────────────────────────────────────
188
+
189
+ def _on_update(self) -> None:
190
+ self.proceed = True
191
+ self.include_toolbox = bool(self._toolbox_var.get())
192
+ self.check_on_startup = bool(self._check_startup_var.get())
193
+ self._close()
194
+
195
+ def _on_ok(self) -> None:
196
+ """Save the settings (e.g. the startup-check toggle) without updating."""
197
+ self.proceed = False
198
+ self.check_on_startup = bool(self._check_startup_var.get())
199
+ self._close()
200
+
201
+ def _on_cancel(self) -> None:
202
+ """Close without applying any change (discard the toggle edits)."""
203
+ self.proceed = False
204
+ self.check_on_startup = self._initial_check_on_startup
205
+ self._close()
206
+
207
+ def _close(self) -> None:
208
+ try:
209
+ self.grab_release()
210
+ except tk.TclError:
211
+ pass
212
+ self.destroy()