Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

All notable changes to pyetm are documented here.

## [2.0.3] - 26-08-2026

### Fixed

- Saving scenarios to MyETM works again on the stable `2025-01` version. pyetm now
sends the request shape its target expects: `2025-01` reads the saved scenario
attributes from the top level, newer engines from a `saved_scenario` root key.
Affected `Scenario.new()`, `Session.save()` and any `Scenarios.from_excel()`
run whose MAIN rows are not marked `session=TRUE`, which previously failed with
`422: title: is missing, scenario_id: is missing`.
- A scenario that cannot be saved to MyETM no longer passes silently. The failure is
recorded on the scenario and reported by `Scenarios.from_excel()`.

## [2.0.2] - 23-07-2026

### Added
Expand Down
36 changes: 36 additions & 0 deletions src/pyetm/config/api_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Request shapes that work across every engine version pyetm targets.

Sibling of :mod:`pyetm.config.curve_registry`, which does the same for curve names.
"""

from __future__ import annotations

from typing import Any
from urllib.parse import urlparse

STABLE_2025_01_LABEL = "2025-01"


def _targets_stable_2025_01(base_url: str | None) -> bool:
"""Is this client pointed at the 2025-01 stable engine?"""
if not base_url:
return False

host = urlparse(base_url).hostname or ""
return host.split(".")[0] == STABLE_2025_01_LABEL


def saved_scenario_payload(
attributes: dict[str, Any], base_url: str | None = None
) -> dict[str, Any]:
"""Build a saved scenario body the target engine can read.

Stable 2025-01 reads the attributes from the top level of the request; every
engine from 2026-01 onwards reads them from a ``saved_scenario`` root key. The
root key doubles as the signal that keeps Rails' ParamsWrapper from choosing the
shape for us, so it is what anything other than 2025-01 gets.
"""
if _targets_stable_2025_01(base_url):
return dict(attributes)

return {"saved_scenario": attributes}
101 changes: 46 additions & 55 deletions src/pyetm/models/scenario_loader.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
"""Utilities for loading scenarios from various sources."""

import logging
from typing import Protocol, Optional, Dict, Any, cast
from typing import Any, Protocol, cast

from pyetm.models.session import Session

logger = logging.getLogger(__name__)


def _warn_save_failed(session: Session, row_label: str, error: Exception) -> Session:
"""Record a failed MyETM save on the session itself."""
message = f"Row '{row_label}' was not saved to MyETM: {error}"
logger.warning("%s Returning session instead.", message)
session.add_warning("save", message)
return session


class ScenarioLoader(Protocol):
"""
Protocol for loading, copying, and creating scenarios.
"""Protocol for loading, copying, and creating scenarios.

Different implementations interpret scenario IDs differently:
- SessionLoader: IDs refer to ETEngine Sessions
Expand All @@ -20,55 +28,53 @@ def load(
self,
scenario_id: int,
area_code: Any,
end_year: Optional[int],
end_year: int | None,
row_label: str,
metadata_updates: Dict[str, Any],
) -> Optional[Session]:
metadata_updates: dict[str, Any],
) -> Session | None:
"""Load an existing scenario by ID and apply metadata updates."""
...

def copy(
self,
scenario_id: int,
row_label: str,
metadata_updates: Dict[str, Any],
) -> Optional[Session]:
metadata_updates: dict[str, Any],
) -> Session | None:
"""Create a deep copy of a scenario (no template link)."""
...

def create_new(
self,
area_code: Any,
end_year: Optional[int],
end_year: int | None,
row_label: str,
metadata_updates: Dict[str, Any],
) -> Optional[Session]:
metadata_updates: dict[str, Any],
) -> Session | None:
"""Create a brand new scenario."""
...


class SessionLoader:
"""
Loader for ETEngine Sessions.
"""Loader for ETEngine Sessions.

Interprets IDs as ETEngine scenario/session IDs.
"""

def __init__(self, packer_helper: Any) -> None:
"""
Args:
packer_helper: Reference to ScenarioPacker instance for helper methods
"""Args:
packer_helper: Reference to ScenarioPacker instance for helper methods
"""
self._helper = packer_helper

def load(
self,
scenario_id: int,
area_code: Any,
end_year: Optional[int],
end_year: int | None,
row_label: str,
metadata_updates: Dict[str, Any],
) -> Optional[Session]:
metadata_updates: dict[str, Any],
) -> Session | None:
"""Load an ETEngine Session by ID."""
scenario = self._helper._load_or_create_scenario(
scenario_id, area_code, end_year, row_label, **metadata_updates
Expand All @@ -82,8 +88,8 @@ def copy(
self,
scenario_id: int,
row_label: str,
metadata_updates: Dict[str, Any],
) -> Optional[Session]:
metadata_updates: dict[str, Any],
) -> Session | None:
"""Deep copy an ETEngine Session."""
try:
source_scenario = Session.load(scenario_id)
Expand All @@ -100,10 +106,10 @@ def copy(
def create_new(
self,
area_code: Any,
end_year: Optional[int],
end_year: int | None,
row_label: str,
metadata_updates: Dict[str, Any],
) -> Optional[Session]:
metadata_updates: dict[str, Any],
) -> Session | None:
"""Create a new ETEngine Session."""
scenario = self._helper._load_or_create_scenario(
None, area_code, end_year, row_label, **metadata_updates
Expand All @@ -115,22 +121,19 @@ def create_new(


class SavedScenarioLoader:
"""
Loader for MyETM SavedScenarios.
"""Loader for MyETM SavedScenarios.

Interprets IDs as MyETM SavedScenario IDs and automatically saves new scenarios.
"""

def __init__(self, packer_helper: Any) -> None:
"""
Args:
packer_helper: Reference to ScenarioPacker instance for helper methods
"""Args:
packer_helper: Reference to ScenarioPacker instance for helper methods
"""
self._helper = packer_helper

def _require_authentication(self) -> None:
"""
Validate that authentication token is available for SavedScenario operations.
"""Validate that authentication token is available for SavedScenario operations.

Raises:
PermissionError: If no ETM_API_TOKEN is configured
Expand All @@ -150,10 +153,10 @@ def load(
self,
scenario_id: int,
area_code: Any,
end_year: Optional[int],
end_year: int | None,
row_label: str,
metadata_updates: Dict[str, Any],
) -> Optional[Session]:
metadata_updates: dict[str, Any],
) -> Session | None:
"""Load a SavedScenario from MyETM."""
from pyetm.models.scenario import Scenario

Expand Down Expand Up @@ -181,8 +184,8 @@ def copy(
self,
scenario_id: int,
row_label: str,
metadata_updates: Dict[str, Any],
) -> Optional[Session]:
metadata_updates: dict[str, Any],
) -> Session | None:
"""Copy a SavedScenario and save the copy to MyETM."""
from pyetm.models.scenario import Scenario

Expand All @@ -197,7 +200,7 @@ def copy(
try:
saved_copy = copied_session.save(title=title)
# Validate that we got a proper SavedScenario back with required attributes
if hasattr(saved_copy, 'id') and hasattr(saved_copy, 'scenario_id'):
if hasattr(saved_copy, "id") and hasattr(saved_copy, "scenario_id"):
logger.info(
"Automatically saved copy to MyETM with ID %s (session ID: %s)",
saved_copy.id,
Expand All @@ -211,12 +214,7 @@ def copy(
)
return copied_session
except Exception as save_error:
logger.warning(
"Failed to save copy to MyETM for row '%s': %s. Returning session instead.",
row_label,
save_error,
)
return copied_session
return _warn_save_failed(copied_session, row_label, save_error)
except Exception as e:
error_msg = str(e)
if "does not exist" in error_msg or "not found" in error_msg.lower():
Expand All @@ -237,13 +235,11 @@ def copy(
def create_new(
self,
area_code: Any,
end_year: Optional[int],
end_year: int | None,
row_label: str,
metadata_updates: Dict[str, Any],
) -> Optional[Session]:
metadata_updates: dict[str, Any],
) -> Session | None:
"""Create a new scenario and save it to MyETM."""
from pyetm.models.scenario import Scenario

# Validate authentication before attempting to save
self._require_authentication()

Expand All @@ -259,7 +255,7 @@ def create_new(
try:
saved_scenario = scenario.save(title=title)
# Validate that we got a proper SavedScenario back with required attributes
if hasattr(saved_scenario, 'id') and hasattr(saved_scenario, 'scenario_id'):
if hasattr(saved_scenario, "id") and hasattr(saved_scenario, "scenario_id"):
logger.info(
"Saved new scenario to MyETM with ID %s (session ID: %s)",
saved_scenario.id,
Expand All @@ -273,9 +269,4 @@ def create_new(
)
return cast(Session, scenario)
except Exception as e:
logger.warning(
"Failed to save new scenario to MyETM for row '%s': %s. Returning session instead.",
row_label,
e,
)
return cast(Session, scenario)
return _warn_save_failed(cast(Session, scenario), row_label, e)
Loading
Loading