pyetm 2.0.2__tar.gz → 2.0.4__tar.gz

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 (97) hide show
  1. {pyetm-2.0.2 → pyetm-2.0.4}/PKG-INFO +2 -1
  2. {pyetm-2.0.2 → pyetm-2.0.4}/pyproject.toml +1 -1
  3. pyetm-2.0.4/src/pyetm/config/api_compat.py +55 -0
  4. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/scenario.py +27 -4
  5. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/scenario_loader.py +46 -55
  6. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/scenarios.py +93 -124
  7. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/create_saved_scenario.py +12 -12
  8. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/update_saved_scenario.py +13 -14
  9. {pyetm-2.0.2 → pyetm-2.0.4}/LICENSE +0 -0
  10. {pyetm-2.0.2 → pyetm-2.0.4}/README.md +0 -0
  11. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/__init__.py +0 -0
  12. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/__main__.py +0 -0
  13. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/clients/__init__.py +0 -0
  14. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/clients/base_client.py +0 -0
  15. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/clients/session.py +0 -0
  16. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/config/__init__.py +0 -0
  17. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/config/curve_registry.py +0 -0
  18. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/config/settings.py +0 -0
  19. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/exporters/__init__.py +0 -0
  20. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/exporters/excel_exporter.py +0 -0
  21. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/__init__.py +0 -0
  22. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/annual_exports.py +0 -0
  23. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/base.py +0 -0
  24. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/collection.py +0 -0
  25. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/couplings.py +0 -0
  26. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/custom_curves.py +0 -0
  27. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/error_policy.py +0 -0
  28. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/export_config.py +0 -0
  29. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/export_data_collection.py +0 -0
  30. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/gqueries.py +0 -0
  31. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/hourly_output_curves.py +0 -0
  32. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/inputs.py +0 -0
  33. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/packables/__init__.py +0 -0
  34. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/packables/annual_exports_pack.py +0 -0
  35. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/packables/custom_curves_pack.py +0 -0
  36. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/packables/hourly_output_curves_pack.py +0 -0
  37. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/packables/inputs_pack.py +0 -0
  38. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/packables/packable.py +0 -0
  39. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/packables/query_pack.py +0 -0
  40. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/packables/sortable_pack.py +0 -0
  41. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/packables/users_pack.py +0 -0
  42. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/scenario_packer.py +0 -0
  43. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/session.py +0 -0
  44. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/sessions.py +0 -0
  45. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/sortables.py +0 -0
  46. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/models/warnings.py +0 -0
  47. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/py.typed +0 -0
  48. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/__init__.py +0 -0
  49. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/__init__.py +0 -0
  50. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/base_runner.py +0 -0
  51. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/break_preset_link.py +0 -0
  52. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/copy_scenario.py +0 -0
  53. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/create_collection.py +0 -0
  54. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/create_scenario.py +0 -0
  55. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/delete_collection.py +0 -0
  56. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/delete_custom_curves.py +0 -0
  57. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/destroy_saved_scenario.py +0 -0
  58. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/destroy_session.py +0 -0
  59. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/discard_saved_scenario.py +0 -0
  60. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/fetch_annual_exports.py +0 -0
  61. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/fetch_collection.py +0 -0
  62. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/fetch_couplings.py +0 -0
  63. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/fetch_curves_generic.py +0 -0
  64. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/fetch_custom_curves.py +0 -0
  65. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/fetch_hourly_output_curves.py +0 -0
  66. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/fetch_inputs.py +0 -0
  67. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/fetch_metadata.py +0 -0
  68. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/fetch_saved_scenario.py +0 -0
  69. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/fetch_sortables.py +0 -0
  70. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/fetch_user_collections.py +0 -0
  71. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/fetch_user_saved_scenarios.py +0 -0
  72. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/fetch_user_scenarios.py +0 -0
  73. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/get_query_results.py +0 -0
  74. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/interpolate_scenarios.py +0 -0
  75. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/saved_scenario_users_create.py +0 -0
  76. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/saved_scenario_users_destroy.py +0 -0
  77. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/saved_scenario_users_index.py +0 -0
  78. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/saved_scenario_users_update.py +0 -0
  79. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/scenario_users_create.py +0 -0
  80. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/scenario_users_destroy.py +0 -0
  81. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/scenario_users_index.py +0 -0
  82. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/scenario_users_update.py +0 -0
  83. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/update_collection.py +0 -0
  84. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/update_couplings.py +0 -0
  85. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/update_custom_curves.py +0 -0
  86. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/update_inputs.py +0 -0
  87. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/update_metadata.py +0 -0
  88. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/scenario_runners/update_sortables.py +0 -0
  89. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/service_result.py +0 -0
  90. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/services/validators/__init__.py +0 -0
  91. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/templates/.env.template +0 -0
  92. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/templates/input_template.xlsx +0 -0
  93. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/types.py +0 -0
  94. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/utils/__init__.py +0 -0
  95. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/utils/excel_utils.py +0 -0
  96. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/utils/safe_cast.py +0 -0
  97. {pyetm-2.0.2 → pyetm-2.0.4}/src/pyetm/validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyetm
3
- Version: 2.0.2
3
+ Version: 2.0.4
4
4
  Summary: Python-ETM Connector
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -13,6 +13,7 @@ Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Programming Language :: Python :: 3.15
16
17
  Requires-Dist: aiohttp (>=3.14.1,<4.0.0)
17
18
  Requires-Dist: click (>=8.0.0)
18
19
  Requires-Dist: openpyxl (>=3.0.0)
@@ -5,7 +5,7 @@ build-backend = "poetry.core.masonry.api"
5
5
 
6
6
  [project]
7
7
  name = "pyetm"
8
- version = "2.0.2"
8
+ version = "2.0.4"
9
9
  description = "Python-ETM Connector"
10
10
  readme = "README.md"
11
11
  license = "MIT"
@@ -0,0 +1,55 @@
1
+ """Request shapes that work across every engine version pyetm targets.
2
+
3
+ Sibling of :mod:`pyetm.config.curve_registry`, which does the same for curve names.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+ from urllib.parse import urlparse
10
+
11
+ STABLE_2025_01_LABEL = "2025-01"
12
+
13
+
14
+ def _targets_stable_2025_01(base_url: str | None) -> bool:
15
+ """Is this client pointed at the 2025-01 stable engine?"""
16
+ if not base_url:
17
+ return False
18
+
19
+ host = urlparse(base_url).hostname or ""
20
+ return host.split(".")[0] == STABLE_2025_01_LABEL
21
+
22
+
23
+ def _environment_label(base_url: str | None) -> str | None:
24
+ """Environment label used to build this host, or None for production/local."""
25
+ if not base_url:
26
+ return None
27
+
28
+ host = urlparse(base_url).hostname or ""
29
+ label = host.split(".")[0]
30
+
31
+ if label in ("", "engine", "localhost"):
32
+ return None
33
+
34
+ return label
35
+
36
+
37
+ def saved_scenario_payload(
38
+ attributes: dict[str, Any], base_url: str | None = None
39
+ ) -> dict[str, Any]:
40
+ """Build a saved scenario body the target engine can read.
41
+
42
+ 2025-01 reads attributes from the top level; later engines from a
43
+ ``saved_scenario`` root key. Also stamps ``version`` with the environment
44
+ label, since MyETM defaults an unlabelled scenario to production.
45
+ """
46
+ body = dict(attributes)
47
+
48
+ label = _environment_label(base_url)
49
+ if label is not None:
50
+ body.setdefault("version", label)
51
+
52
+ if _targets_stable_2025_01(base_url):
53
+ return body
54
+
55
+ return {"saved_scenario": body}
@@ -388,14 +388,28 @@ class Scenario(Base):
388
388
 
389
389
  def update(self, client: Optional[BaseClient] = None, **kwargs: Any) -> None:
390
390
  """
391
- Update this SavedScenario
391
+ Update this SavedScenario.
392
+
393
+ Passing a new ``scenario_id`` saves a version: MyETM moves the current
394
+ session into the scenario's history and points the SavedScenario at the
395
+ new session. Passing an ID already in the history restores that version.
392
396
 
393
397
  Args:
394
398
  client: Optional BaseClient instance
395
- **kwargs: Fields to update (title, private, discarded)
399
+ **kwargs: Fields to update (title, private, discarded, scenario_id)
400
+
401
+ Raises:
402
+ SavedScenarioError: If the update fails
403
+
404
+ Example:
405
+ ```python
406
+ snapshot = scenario.session.copy_with_preset()
407
+ scenario.update(scenario_id=snapshot.id)
408
+ ```
396
409
  """
397
410
  if client is None:
398
411
  client = get_client()
412
+ previous_scenario_id = self.scenario_id
399
413
  result = UpdateSavedScenarioRunner.run(client, self.id, kwargs)
400
414
 
401
415
  if not result.success:
@@ -408,13 +422,22 @@ class Scenario(Base):
408
422
 
409
423
  if result.data:
410
424
  for field, value in result.data.items():
411
- if hasattr(self, field):
425
+ if field in Scenario.model_fields:
412
426
  setattr(self, field, value)
413
427
 
414
428
  for field, value in kwargs.items():
415
- if hasattr(self, field) and (not result.data or field not in result.data):
429
+ if field in Scenario.model_fields and (not result.data or field not in result.data):
416
430
  setattr(self, field, value)
417
431
 
432
+ if self.scenario_id != previous_scenario_id:
433
+ self._forget_session(result.data)
434
+
435
+ def _forget_session(self, response: Optional[Dict[str, Any]]) -> None:
436
+ """Drop the cached session so the next access loads the new scenario_id."""
437
+ self._scenario_session = None
438
+ if not response or "scenario" not in response:
439
+ self.scenario = None
440
+
418
441
  def discard(self, client: Optional[BaseClient] = None) -> None:
419
442
  """
420
443
  Discard this SavedScenario from MyETM (soft-delete, recoverable).
@@ -1,15 +1,23 @@
1
1
  """Utilities for loading scenarios from various sources."""
2
2
 
3
3
  import logging
4
- from typing import Protocol, Optional, Dict, Any, cast
4
+ from typing import Any, Protocol, cast
5
+
5
6
  from pyetm.models.session import Session
6
7
 
7
8
  logger = logging.getLogger(__name__)
8
9
 
9
10
 
11
+ def _warn_save_failed(session: Session, row_label: str, error: Exception) -> Session:
12
+ """Record a failed MyETM save on the session itself."""
13
+ message = f"Row '{row_label}' was not saved to MyETM: {error}"
14
+ logger.warning("%s Returning session instead.", message)
15
+ session.add_warning("save", message)
16
+ return session
17
+
18
+
10
19
  class ScenarioLoader(Protocol):
11
- """
12
- Protocol for loading, copying, and creating scenarios.
20
+ """Protocol for loading, copying, and creating scenarios.
13
21
 
14
22
  Different implementations interpret scenario IDs differently:
15
23
  - SessionLoader: IDs refer to ETEngine Sessions
@@ -20,10 +28,10 @@ class ScenarioLoader(Protocol):
20
28
  self,
21
29
  scenario_id: int,
22
30
  area_code: Any,
23
- end_year: Optional[int],
31
+ end_year: int | None,
24
32
  row_label: str,
25
- metadata_updates: Dict[str, Any],
26
- ) -> Optional[Session]:
33
+ metadata_updates: dict[str, Any],
34
+ ) -> Session | None:
27
35
  """Load an existing scenario by ID and apply metadata updates."""
28
36
  ...
29
37
 
@@ -31,33 +39,31 @@ class ScenarioLoader(Protocol):
31
39
  self,
32
40
  scenario_id: int,
33
41
  row_label: str,
34
- metadata_updates: Dict[str, Any],
35
- ) -> Optional[Session]:
42
+ metadata_updates: dict[str, Any],
43
+ ) -> Session | None:
36
44
  """Create a deep copy of a scenario (no template link)."""
37
45
  ...
38
46
 
39
47
  def create_new(
40
48
  self,
41
49
  area_code: Any,
42
- end_year: Optional[int],
50
+ end_year: int | None,
43
51
  row_label: str,
44
- metadata_updates: Dict[str, Any],
45
- ) -> Optional[Session]:
52
+ metadata_updates: dict[str, Any],
53
+ ) -> Session | None:
46
54
  """Create a brand new scenario."""
47
55
  ...
48
56
 
49
57
 
50
58
  class SessionLoader:
51
- """
52
- Loader for ETEngine Sessions.
59
+ """Loader for ETEngine Sessions.
53
60
 
54
61
  Interprets IDs as ETEngine scenario/session IDs.
55
62
  """
56
63
 
57
64
  def __init__(self, packer_helper: Any) -> None:
58
- """
59
- Args:
60
- packer_helper: Reference to ScenarioPacker instance for helper methods
65
+ """Args:
66
+ packer_helper: Reference to ScenarioPacker instance for helper methods
61
67
  """
62
68
  self._helper = packer_helper
63
69
 
@@ -65,10 +71,10 @@ class SessionLoader:
65
71
  self,
66
72
  scenario_id: int,
67
73
  area_code: Any,
68
- end_year: Optional[int],
74
+ end_year: int | None,
69
75
  row_label: str,
70
- metadata_updates: Dict[str, Any],
71
- ) -> Optional[Session]:
76
+ metadata_updates: dict[str, Any],
77
+ ) -> Session | None:
72
78
  """Load an ETEngine Session by ID."""
73
79
  scenario = self._helper._load_or_create_scenario(
74
80
  scenario_id, area_code, end_year, row_label, **metadata_updates
@@ -82,8 +88,8 @@ class SessionLoader:
82
88
  self,
83
89
  scenario_id: int,
84
90
  row_label: str,
85
- metadata_updates: Dict[str, Any],
86
- ) -> Optional[Session]:
91
+ metadata_updates: dict[str, Any],
92
+ ) -> Session | None:
87
93
  """Deep copy an ETEngine Session."""
88
94
  try:
89
95
  source_scenario = Session.load(scenario_id)
@@ -100,10 +106,10 @@ class SessionLoader:
100
106
  def create_new(
101
107
  self,
102
108
  area_code: Any,
103
- end_year: Optional[int],
109
+ end_year: int | None,
104
110
  row_label: str,
105
- metadata_updates: Dict[str, Any],
106
- ) -> Optional[Session]:
111
+ metadata_updates: dict[str, Any],
112
+ ) -> Session | None:
107
113
  """Create a new ETEngine Session."""
108
114
  scenario = self._helper._load_or_create_scenario(
109
115
  None, area_code, end_year, row_label, **metadata_updates
@@ -115,22 +121,19 @@ class SessionLoader:
115
121
 
116
122
 
117
123
  class SavedScenarioLoader:
118
- """
119
- Loader for MyETM SavedScenarios.
124
+ """Loader for MyETM SavedScenarios.
120
125
 
121
126
  Interprets IDs as MyETM SavedScenario IDs and automatically saves new scenarios.
122
127
  """
123
128
 
124
129
  def __init__(self, packer_helper: Any) -> None:
125
- """
126
- Args:
127
- packer_helper: Reference to ScenarioPacker instance for helper methods
130
+ """Args:
131
+ packer_helper: Reference to ScenarioPacker instance for helper methods
128
132
  """
129
133
  self._helper = packer_helper
130
134
 
131
135
  def _require_authentication(self) -> None:
132
- """
133
- Validate that authentication token is available for SavedScenario operations.
136
+ """Validate that authentication token is available for SavedScenario operations.
134
137
 
135
138
  Raises:
136
139
  PermissionError: If no ETM_API_TOKEN is configured
@@ -150,10 +153,10 @@ class SavedScenarioLoader:
150
153
  self,
151
154
  scenario_id: int,
152
155
  area_code: Any,
153
- end_year: Optional[int],
156
+ end_year: int | None,
154
157
  row_label: str,
155
- metadata_updates: Dict[str, Any],
156
- ) -> Optional[Session]:
158
+ metadata_updates: dict[str, Any],
159
+ ) -> Session | None:
157
160
  """Load a SavedScenario from MyETM."""
158
161
  from pyetm.models.scenario import Scenario
159
162
 
@@ -181,8 +184,8 @@ class SavedScenarioLoader:
181
184
  self,
182
185
  scenario_id: int,
183
186
  row_label: str,
184
- metadata_updates: Dict[str, Any],
185
- ) -> Optional[Session]:
187
+ metadata_updates: dict[str, Any],
188
+ ) -> Session | None:
186
189
  """Copy a SavedScenario and save the copy to MyETM."""
187
190
  from pyetm.models.scenario import Scenario
188
191
 
@@ -197,7 +200,7 @@ class SavedScenarioLoader:
197
200
  try:
198
201
  saved_copy = copied_session.save(title=title)
199
202
  # Validate that we got a proper SavedScenario back with required attributes
200
- if hasattr(saved_copy, 'id') and hasattr(saved_copy, 'scenario_id'):
203
+ if hasattr(saved_copy, "id") and hasattr(saved_copy, "scenario_id"):
201
204
  logger.info(
202
205
  "Automatically saved copy to MyETM with ID %s (session ID: %s)",
203
206
  saved_copy.id,
@@ -211,12 +214,7 @@ class SavedScenarioLoader:
211
214
  )
212
215
  return copied_session
213
216
  except Exception as save_error:
214
- logger.warning(
215
- "Failed to save copy to MyETM for row '%s': %s. Returning session instead.",
216
- row_label,
217
- save_error,
218
- )
219
- return copied_session
217
+ return _warn_save_failed(copied_session, row_label, save_error)
220
218
  except Exception as e:
221
219
  error_msg = str(e)
222
220
  if "does not exist" in error_msg or "not found" in error_msg.lower():
@@ -237,13 +235,11 @@ class SavedScenarioLoader:
237
235
  def create_new(
238
236
  self,
239
237
  area_code: Any,
240
- end_year: Optional[int],
238
+ end_year: int | None,
241
239
  row_label: str,
242
- metadata_updates: Dict[str, Any],
243
- ) -> Optional[Session]:
240
+ metadata_updates: dict[str, Any],
241
+ ) -> Session | None:
244
242
  """Create a new scenario and save it to MyETM."""
245
- from pyetm.models.scenario import Scenario
246
-
247
243
  # Validate authentication before attempting to save
248
244
  self._require_authentication()
249
245
 
@@ -259,7 +255,7 @@ class SavedScenarioLoader:
259
255
  try:
260
256
  saved_scenario = scenario.save(title=title)
261
257
  # Validate that we got a proper SavedScenario back with required attributes
262
- if hasattr(saved_scenario, 'id') and hasattr(saved_scenario, 'scenario_id'):
258
+ if hasattr(saved_scenario, "id") and hasattr(saved_scenario, "scenario_id"):
263
259
  logger.info(
264
260
  "Saved new scenario to MyETM with ID %s (session ID: %s)",
265
261
  saved_scenario.id,
@@ -273,9 +269,4 @@ class SavedScenarioLoader:
273
269
  )
274
270
  return cast(Session, scenario)
275
271
  except Exception as e:
276
- logger.warning(
277
- "Failed to save new scenario to MyETM for row '%s': %s. Returning session instead.",
278
- row_label,
279
- e,
280
- )
281
- return cast(Session, scenario)
272
+ return _warn_save_failed(cast(Session, scenario), row_label, e)