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,274 @@
1
+ """Generate flextool/schemas/canonical_databases/*.json from tests/fixtures/*.json.
2
+
3
+ Reads recipes from ``flextool/schemas/canonical_databases/_recipes.yaml``.
4
+
5
+ Format bridge
6
+ -------------
7
+ ``tests/fixtures/*.json`` uses **base64-packed** values (via
8
+ :mod:`tests.db_utils`). ``flextool/schemas/canonical_databases/*.json``
9
+ uses **UTF-8 decoded** values (via
10
+ :mod:`flextool.update_flextool.export_database`). Rather than translating
11
+ between formats directly, each converter is used in its native direction:
12
+
13
+ 1. Read the source JSON via :func:`tests.db_utils.json_to_db` into a temp
14
+ SQLite (base64 -> bytes).
15
+ 2. Run :func:`flextool.update_flextool.db_migration.migrate_database` —
16
+ idempotent after Stage 2a of the test_fixtures migration.
17
+ 3. Filter: delete excluded scenarios, alternatives, and entities listed
18
+ in the recipe. ``spinedb_api`` cascade-removes scenario_alternatives,
19
+ entity_alternatives, parameter_values, and multi-dim child entities
20
+ automatically.
21
+ 4. Export the SQLite via :func:`flextool.update_flextool.export_database`
22
+ (bytes -> UTF-8) over the canonical JSON.
23
+
24
+ CLI
25
+ ---
26
+ ``python -m flextool.update_flextool.generate_canonical``
27
+ Generate every recipe.
28
+
29
+ ``python -m flextool.update_flextool.generate_canonical templates_examples``
30
+ Generate just one recipe.
31
+
32
+ ``python -m flextool.update_flextool.generate_canonical --verify``
33
+ Regenerate to a temp dir and diff against the committed JSONs; exit
34
+ non-zero with a short summary on drift. Intended for CI.
35
+
36
+ Out of scope (still hand-maintained, follow-up work)
37
+ ----------------------------------------------------
38
+ ``howto_*.json`` (eight files) and ``templates_time_settings_only.json``
39
+ are not yet covered by a recipe. Adding them requires per-database
40
+ investigation (each carves a different slice of the test universe); they
41
+ will pick up recipes one at a time in follow-up commits.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import argparse
47
+ import json
48
+ import tempfile
49
+ from pathlib import Path
50
+ from typing import Any
51
+
52
+ import yaml
53
+
54
+ from flextool.update_flextool.db_migration import migrate_database
55
+ from flextool.update_flextool.export_database import export_database
56
+
57
+
58
+ # ``Path(__file__).resolve().parents[1]`` -> ``flextool/`` package root
59
+ # inside the source checkout. Both the recipe file and the destination
60
+ # JSON tree live under ``flextool/schemas/canonical_databases/``.
61
+ RECIPES_FILE = (
62
+ Path(__file__).resolve().parents[1]
63
+ / "schemas"
64
+ / "canonical_databases"
65
+ / "_recipes.yaml"
66
+ )
67
+ OUTPUT_DIR = (
68
+ Path(__file__).resolve().parents[1] / "schemas" / "canonical_databases"
69
+ )
70
+ # Source paths in the recipe (``tests/fixtures/...``) are resolved against
71
+ # the repository root — one level above ``flextool/``.
72
+ SOURCE_ROOT = Path(__file__).resolve().parents[2]
73
+
74
+
75
+ def _load_recipes() -> dict[str, dict[str, Any]]:
76
+ """Parse ``_recipes.yaml`` and return its top-level dict.
77
+
78
+ Fails loudly if the file is missing — this module is a source-checkout
79
+ maintenance tool, never invoked from a wheel install.
80
+ """
81
+ if not RECIPES_FILE.is_file():
82
+ raise RuntimeError(f"Recipes file missing: {RECIPES_FILE}")
83
+ with open(RECIPES_FILE) as f:
84
+ return yaml.safe_load(f) or {}
85
+
86
+
87
+ def _filter_db(
88
+ sqlite_url: str,
89
+ exclude_scenarios: list[str],
90
+ exclude_alternatives: list[str],
91
+ exclude_entities: list[list[str]],
92
+ ) -> None:
93
+ """Apply the recipe's exclusion lists to a staging SQLite.
94
+
95
+ Order matters: scenarios first (cascades scenario_alternatives),
96
+ then alternatives (cascades entity_alternatives + parameter_values
97
+ bound to those alternatives), then entities (cascades multi-dim
98
+ child entities and any remaining parameter_values).
99
+ """
100
+ # spinedb_api is the canonical handle for filtering Spine DBs — it
101
+ # owns the cascade semantics so we don't have to track FK ordering
102
+ # by hand.
103
+ from spinedb_api import DatabaseMapping
104
+
105
+ if not (exclude_scenarios or exclude_alternatives or exclude_entities):
106
+ # Recipe wants the source verbatim — skip the DatabaseMapping
107
+ # open/commit cycle entirely (spinedb_api raises NothingToCommit
108
+ # if commit_session is called with no pending changes).
109
+ return
110
+ with DatabaseMapping(sqlite_url, create=False, upgrade=False) as db:
111
+ for name in exclude_scenarios:
112
+ db.remove_scenario(name=name)
113
+ for name in exclude_alternatives:
114
+ db.remove_alternative(name=name)
115
+ for cls_name, ent_name in exclude_entities:
116
+ db.remove_entity(entity_class_name=cls_name, name=ent_name)
117
+ db.commit_session("Filtered for canonical export")
118
+
119
+
120
+ def generate_one(recipe_name: str, output_path: Path | None = None) -> Path:
121
+ """Generate a single recipe's output JSON.
122
+
123
+ Args:
124
+ recipe_name: Top-level key in ``_recipes.yaml``.
125
+ output_path: Override the default ``OUTPUT_DIR / <recipe>.json``
126
+ destination. Used by :func:`verify_all` to stage into a
127
+ temp directory.
128
+
129
+ Returns the path written.
130
+ """
131
+ recipes = _load_recipes()
132
+ if recipe_name not in recipes:
133
+ raise KeyError(
134
+ f"Unknown recipe {recipe_name!r}. "
135
+ f"Known: {sorted(recipes)}"
136
+ )
137
+ recipe = recipes[recipe_name]
138
+ source_rel = recipe["source"]
139
+ source_abs = (SOURCE_ROOT / source_rel).resolve()
140
+ if not source_abs.is_file():
141
+ raise FileNotFoundError(f"Recipe source missing: {source_abs}")
142
+
143
+ exclude_scenarios = recipe.get("exclude_scenarios") or []
144
+ exclude_alternatives = recipe.get("exclude_alternatives") or []
145
+ exclude_entities = recipe.get("exclude_entities") or []
146
+
147
+ dest = output_path if output_path is not None else (
148
+ OUTPUT_DIR / f"{recipe_name}.json"
149
+ )
150
+
151
+ # The base64 round-trip lives in ``tests.db_utils`` — imported lazily
152
+ # so the module can still be referenced in environments where the
153
+ # ``tests`` package isn't on ``sys.path`` (this matters only for
154
+ # accidental wheel-install invocation; generate_canonical itself is
155
+ # source-checkout only).
156
+ from tests.db_utils import json_to_db
157
+
158
+ with tempfile.TemporaryDirectory() as tmp:
159
+ staging_sqlite = Path(tmp) / f"{recipe_name}.sqlite"
160
+ url = json_to_db(source_abs, staging_sqlite)
161
+ migrate_database(url)
162
+ _filter_db(url, exclude_scenarios, exclude_alternatives, exclude_entities)
163
+ export_database(str(staging_sqlite), str(dest))
164
+
165
+ return dest
166
+
167
+
168
+ def generate_all() -> list[Path]:
169
+ """Generate every recipe defined in ``_recipes.yaml``.
170
+
171
+ Returns the list of destination paths written, in YAML declaration
172
+ order.
173
+ """
174
+ recipes = _load_recipes()
175
+ written: list[Path] = []
176
+ for name in recipes:
177
+ dest = generate_one(name)
178
+ print(f"generated {dest.relative_to(SOURCE_ROOT)}")
179
+ written.append(dest)
180
+ return written
181
+
182
+
183
+ def _summarise_diff(committed: Path, regenerated: Path) -> str:
184
+ """Short top-level-section summary for ``verify``.
185
+
186
+ Lists every key whose list length differs (or that's missing on one
187
+ side). Value-level drift inside a same-length list is reported as
188
+ ``length match, value-level drift``.
189
+ """
190
+ with open(committed) as f:
191
+ a = json.load(f)
192
+ with open(regenerated) as f:
193
+ b = json.load(f)
194
+ keys = sorted(set(a) | set(b))
195
+ lines: list[str] = []
196
+ for key in keys:
197
+ av = a.get(key)
198
+ bv = b.get(key)
199
+ if av == bv:
200
+ continue
201
+ la = len(av) if isinstance(av, list) else "?"
202
+ lb = len(bv) if isinstance(bv, list) else "?"
203
+ if la == lb:
204
+ lines.append(f" {key}: length match ({la}) — value-level drift")
205
+ else:
206
+ lines.append(f" {key}: committed={la} regenerated={lb}")
207
+ return "\n".join(lines) if lines else " (top-level sections match — check whitespace)"
208
+
209
+
210
+ def verify_all() -> int:
211
+ """Regenerate every recipe to a temp dir and diff against committed.
212
+
213
+ Returns the count of mismatched recipes (0 on success). Intended for
214
+ CI: surfaces drift between ``tests/fixtures/*.json`` and the
215
+ committed ``canonical_databases/*.json``.
216
+ """
217
+ recipes = _load_recipes()
218
+ mismatches = 0
219
+ for name in recipes:
220
+ committed = OUTPUT_DIR / f"{name}.json"
221
+ if not committed.is_file():
222
+ print(f"MISSING: {committed}")
223
+ mismatches += 1
224
+ continue
225
+ with tempfile.TemporaryDirectory() as tmp:
226
+ regenerated = Path(tmp) / f"{name}.json"
227
+ generate_one(name, output_path=regenerated)
228
+ with open(committed) as f:
229
+ a = f.read()
230
+ with open(regenerated) as f:
231
+ b = f.read()
232
+ if a == b:
233
+ print(f"ok: {name}.json")
234
+ else:
235
+ mismatches += 1
236
+ print(f"DRIFT: {name}.json")
237
+ print(_summarise_diff(committed, regenerated))
238
+ if mismatches:
239
+ print(
240
+ f"\n{mismatches} canonical database(s) out of sync. "
241
+ "Run `python -m flextool.update_flextool.generate_canonical`."
242
+ )
243
+ return mismatches
244
+
245
+
246
+ def main(argv: list[str] | None = None) -> int:
247
+ parser = argparse.ArgumentParser(description=__doc__)
248
+ parser.add_argument(
249
+ "recipe",
250
+ nargs="?",
251
+ default=None,
252
+ help="Recipe name to generate (default: generate all).",
253
+ )
254
+ parser.add_argument(
255
+ "--verify",
256
+ action="store_true",
257
+ help="CI mode: regenerate to a temp dir and diff against committed.",
258
+ )
259
+ args = parser.parse_args(argv)
260
+
261
+ if args.verify:
262
+ if args.recipe is not None:
263
+ parser.error("--verify takes no positional argument")
264
+ return verify_all()
265
+ if args.recipe is None:
266
+ generate_all()
267
+ else:
268
+ dest = generate_one(args.recipe)
269
+ print(f"generated {dest.relative_to(SOURCE_ROOT)}")
270
+ return 0
271
+
272
+
273
+ if __name__ == "__main__":
274
+ raise SystemExit(main())
@@ -0,0 +1,42 @@
1
+ import json
2
+ import argparse
3
+ from spinedb_api import import_data, DatabaseMapping
4
+
5
+ from flextool.update_flextool.export_database import keep_serialized_unparse
6
+
7
+
8
+ def initialize_database(json_template, database_name="new_database.sqlite"):
9
+
10
+ if not database_name.endswith(".sqlite"):
11
+ print("Give a name with .sqlite file extension")
12
+ exit(-1)
13
+
14
+ with open(json_template) as json_file:
15
+ template = json.load(json_file)
16
+
17
+ with DatabaseMapping('sqlite:///' + database_name, create=True) as new_db:
18
+ # Remove any default alternatives created by spinedb_api (e.g. 'Base')
19
+ # so they don't conflict with the template's own alternatives.
20
+ for alt in new_db.find_alternatives():
21
+ try:
22
+ new_db.remove_alternative(name=alt['name'])
23
+ except Exception:
24
+ pass
25
+ # ``keep_serialized_unparse`` handles both author styles:
26
+ # * raw scalars (``"no_method"``, ``None``, ``5``) used by the
27
+ # hand-maintained ``schemas/spinedb_schema.json`` and
28
+ # other static templates.
29
+ # * ``[json_str, type_str]`` pairs emitted by ``export_database``
30
+ # for canonical databases that carry full parameter values.
31
+ (num, log) = import_data(new_db, unparse_value=keep_serialized_unparse, **template)
32
+ print(str(num) + " imports made")
33
+ print("Initialized " + database_name)
34
+ new_db.commit_session("Initialized")
35
+
36
+
37
+ if __name__ == '__main__':
38
+ parser = argparse.ArgumentParser()
39
+ parser.add_argument('json_filepath',help= "The filepath of the source JSON")
40
+ parser.add_argument('db_filepath',help= "The filepath of the new database")
41
+ args = parser.parse_args()
42
+ initialize_database(args.json_filepath, args.db_filepath)
@@ -0,0 +1,225 @@
1
+ """Detect how FlexTool is installed and build its self-upgrade command.
2
+
3
+ Two supported install shapes:
4
+
5
+ * an editable **git checkout** (``pip install -e .``) — upgraded with
6
+ ``git pull`` followed by an editable reinstall;
7
+ * a **PyPI wheel** install — upgraded with ``pip install --upgrade``.
8
+
9
+ The standalone Tkinter GUI uses this to offer an in-app "Update FlexTool"
10
+ action that works regardless of which shape is in place. The richer Spine
11
+ Toolbox workflow updater (``git restore`` + project-file rebuild) stays in
12
+ :mod:`flextool.update_flextool.self_update`; that behaviour is intentionally
13
+ *not* used from the GUI.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import importlib.metadata as _im
19
+ import json
20
+ import logging
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ from pathlib import Path
25
+ from urllib.request import Request, urlopen
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ def git_checkout_root() -> Path | None:
31
+ """Return the repo root if FlexTool runs from an editable git checkout.
32
+
33
+ Relies on the imported package's own location: an editable install imports
34
+ ``flextool`` straight from the source tree, so its parent directory holds
35
+ both ``.git`` and ``pyproject.toml``. A wheel install imports from
36
+ ``site-packages`` and fails this test (returns ``None``).
37
+ """
38
+ import flextool
39
+
40
+ pkg_dir = Path(flextool.__file__).resolve().parent # …/flextool
41
+ repo_root = pkg_dir.parent
42
+ if (repo_root / ".git").is_dir() and (repo_root / "pyproject.toml").is_file():
43
+ return repo_root
44
+ return None
45
+
46
+
47
+ def is_git_install() -> bool:
48
+ """Whether FlexTool runs from an editable git checkout."""
49
+ return git_checkout_root() is not None
50
+
51
+
52
+ def flextool_version() -> str:
53
+ """Installed FlexTool version, or ``"unknown"`` if it cannot be read."""
54
+ try:
55
+ return _im.version("flextool")
56
+ except _im.PackageNotFoundError:
57
+ return "unknown"
58
+
59
+
60
+ def toolbox_installed() -> bool:
61
+ """Whether Spine Toolbox (and thus the Spine DB Editor) is installed."""
62
+ try:
63
+ _im.distribution("spinetoolbox")
64
+ return True
65
+ except _im.PackageNotFoundError:
66
+ return shutil.which("spine-db-editor") is not None
67
+
68
+
69
+ def describe_install() -> str:
70
+ """One-line, user-facing description of the current install."""
71
+ root = git_checkout_root()
72
+ if root is not None:
73
+ return f"git checkout at {root}\n(version {flextool_version()})"
74
+ return f"PyPI install (version {flextool_version()})"
75
+
76
+
77
+ def upgrade_steps(include_toolbox: bool) -> tuple[list[list[str]], Path | None]:
78
+ """Build the command(s) that upgrade FlexTool in place.
79
+
80
+ Args:
81
+ include_toolbox: when ``True`` the upgrade targets the ``[toolbox]``
82
+ extra, so Spine Toolbox is installed (or kept) alongside FlexTool.
83
+
84
+ Returns:
85
+ ``(steps, cwd)`` where *steps* is a list of ``argv`` lists to run in
86
+ order (stop at the first failure) and *cwd* is the directory they run
87
+ in — the repo root for a git checkout, otherwise ``None``.
88
+ """
89
+ extra = "[toolbox]" if include_toolbox else ""
90
+ py = sys.executable
91
+ root = git_checkout_root()
92
+ if root is not None:
93
+ if _has_upstream(root):
94
+ pull = ["git", "pull", "--ff-only"]
95
+ else:
96
+ # The branch has no upstream tracking ref, so a bare `git pull`
97
+ # fails with "no tracking information"; name the remote and branch
98
+ # explicitly instead.
99
+ remote = _default_remote(root)
100
+ branch = current_branch(root) or "main"
101
+ pull = ["git", "pull", "--ff-only", remote, branch]
102
+ steps = [
103
+ pull,
104
+ [py, "-m", "pip", "install", "--upgrade", "-e", f".{extra}"],
105
+ ]
106
+ return steps, root
107
+ steps = [[py, "-m", "pip", "install", "--upgrade", f"flextool{extra}"]]
108
+ return steps, None
109
+
110
+
111
+ def _git(root: Path, *args: str, timeout: float = 10.0) -> subprocess.CompletedProcess:
112
+ return subprocess.run(
113
+ ["git", *args], cwd=root, timeout=timeout, capture_output=True, text=True
114
+ )
115
+
116
+
117
+ def current_branch(root: Path) -> str | None:
118
+ """Current branch name, or ``None`` (detached HEAD / error)."""
119
+ try:
120
+ result = _git(root, "rev-parse", "--abbrev-ref", "HEAD")
121
+ name = result.stdout.strip()
122
+ return name if result.returncode == 0 and name and name != "HEAD" else None
123
+ except Exception:
124
+ return None
125
+
126
+
127
+ def _has_upstream(root: Path) -> bool:
128
+ """Whether the current branch has an upstream tracking ref."""
129
+ try:
130
+ return _git(
131
+ root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"
132
+ ).returncode == 0
133
+ except Exception:
134
+ return False
135
+
136
+
137
+ def _default_remote(root: Path) -> str:
138
+ """Pick a remote to pull from — ``origin`` if present, else the first."""
139
+ try:
140
+ remotes = _git(root, "remote").stdout.split()
141
+ except Exception:
142
+ remotes = []
143
+ if "origin" in remotes:
144
+ return "origin"
145
+ return remotes[0] if remotes else "origin"
146
+
147
+
148
+ def update_available(timeout: float = 5.0) -> bool:
149
+ """Best-effort check for a newer FlexTool version.
150
+
151
+ For a git checkout this fetches and reports whether the tracked upstream
152
+ branch is ahead; for a PyPI install it compares the installed version with
153
+ the latest on PyPI. Returns ``True`` only on a confident positive — any
154
+ error, offline state, or ambiguity returns ``False`` so the UI never nags
155
+ spuriously. Performs network I/O; call it off the UI thread.
156
+ """
157
+ root = git_checkout_root()
158
+ if root is not None:
159
+ return _git_behind_upstream(root, timeout)
160
+ return _pypi_has_newer(timeout)
161
+
162
+
163
+ def _git_behind_upstream(root: Path, timeout: float) -> bool:
164
+ """Whether the checkout's tracked upstream has commits we don't have."""
165
+ import os
166
+
167
+ # Never let fetch block on an interactive credential / host-key prompt;
168
+ # it must fail fast and silently if auth is needed.
169
+ env = {
170
+ **os.environ,
171
+ "GIT_TERMINAL_PROMPT": "0",
172
+ "GCM_INTERACTIVE": "never",
173
+ "GIT_SSH_COMMAND": "ssh -oBatchMode=yes",
174
+ }
175
+ try:
176
+ subprocess.run(
177
+ ["git", "fetch", "--quiet"],
178
+ cwd=root, timeout=timeout, check=True, capture_output=True, env=env,
179
+ )
180
+ # Prefer the configured upstream; fall back to the remote branch of the
181
+ # same name when the branch has no tracking ref set.
182
+ if _has_upstream(root):
183
+ ref = "@{u}"
184
+ else:
185
+ branch = current_branch(root)
186
+ if branch is None:
187
+ return False
188
+ ref = f"{_default_remote(root)}/{branch}"
189
+ result = subprocess.run(
190
+ ["git", "rev-list", "--count", f"HEAD..{ref}"],
191
+ cwd=root, timeout=timeout, check=True, capture_output=True, text=True,
192
+ )
193
+ return int(result.stdout.strip() or "0") > 0
194
+ except Exception:
195
+ # No upstream, no git, no network, auth needed, detached HEAD, … —
196
+ # don't nag.
197
+ logger.debug("git update check inconclusive for %s", root, exc_info=True)
198
+ return False
199
+
200
+
201
+ def _pypi_latest_version(timeout: float) -> str | None:
202
+ """Latest FlexTool version string on PyPI, or ``None`` on any failure."""
203
+ request = Request(
204
+ "https://pypi.org/pypi/flextool/json",
205
+ headers={"User-Agent": "flextool-gui"},
206
+ )
207
+ try:
208
+ with urlopen(request, timeout=timeout) as response:
209
+ return json.load(response)["info"]["version"]
210
+ except Exception:
211
+ logger.debug("PyPI update check failed", exc_info=True)
212
+ return None
213
+
214
+
215
+ def _pypi_has_newer(timeout: float) -> bool:
216
+ latest = _pypi_latest_version(timeout)
217
+ if latest is None:
218
+ return False
219
+ try:
220
+ from packaging.version import Version
221
+
222
+ return Version(latest) > Version(flextool_version())
223
+ except Exception:
224
+ logger.debug("Version comparison failed (latest=%s)", latest, exc_info=True)
225
+ return False