Source code for euromod_linking.session
"""Shared, long-lived EUROMOD connector session.
Owns the single process-wide euromod.Model object and the lock that serializes
all connector access (reads and simulation runs). No business logic and no
mutation helpers — the model is used read-only at runtime; the only simulation
overrides go through System.run() kwargs, which do not mutate the model.
"""
import os
import threading
# The model path is always an argument: a library must not silently resolve
# which model it runs against from the environment. The first caller decides,
# and later callers reuse that loaded model.
# The euromod.Model object is not thread-safe; one reentrant lock serializes
# every connector access. RLock because accessors nest (get_system -> get_country
# -> get_model) and tools hold the lock while calling the accessors.
model_lock = threading.RLock()
_model = None
_loaded_path: str | None = None
[docs]
class ModelLookupError(Exception):
"""Raised when a country/system is not found. ``.available`` lists the names
that would have worked, so a caller can report them."""
def __init__(self, message: str, available: list | None = None):
super().__init__(message)
self.available = available or []
[docs]
def get_model(model_path: str | None = None):
"""Return the cached euromod.Model, loading it once per process.
Pass `model_path` to load (or switch to) a model; omit it to reuse the one
already loaded, which is how callers deep in a call chain avoid threading
the path through every function. Loading is expensive (.NET + the whole
model graph), so a genuinely different path is the only thing that reloads.
"""
global _model, _loaded_path
with model_lock:
if model_path:
norm = os.path.normpath(model_path) # no spurious reloads on Windows separators
if _model is None or _loaded_path != norm:
from euromod import Model # lazy: keep module import .NET-free
from euromod_linking.env import harden_dotnet_console
harden_dotnet_console() # engine warnings must not kill consoleless processes
_model = Model(norm)
_loaded_path = norm
elif _model is None:
raise ModelLookupError(
"No EUROMOD model loaded. Pass model_path (the model root directory) "
"on the first call.")
return _model
[docs]
def adopt(system) -> bool:
"""Make the model behind `system` the one this process reads from.
Callers hand `apply_scenario` a live System they built themselves; the read
helpers underneath resolve the model from here. Adopting the caller's own
model closes that gap, so nobody has to load the model twice or through a
particular door for income-list expansion to work.
Returns True when a model was adopted. Silent False when `system` is not a
live connector object — the caller may have loaded a model already, and this
is a convenience, not a precondition."""
global _model, _loaded_path
model = getattr(getattr(system, "parent", None), "model", None)
path = getattr(model, "model_path", None)
if model is None or not path:
return False
with model_lock:
norm = os.path.normpath(str(path))
if _model is not model:
_model, _loaded_path = model, norm
return True
[docs]
def get_country(country_code: str, model_path: str | None = None):
"""Return the live Country object, or raise ModelLookupError(available=[...])."""
cc = country_code.upper()
with model_lock:
mod = get_model(model_path)
try:
return mod[cc]
except Exception:
available = list(mod.countries.keys()) if hasattr(mod.countries, "keys") else [str(c) for c in mod.countries]
raise ModelLookupError(f"Country '{cc}' not found", available)
[docs]
def get_system(country_code: str, system_name: str, model_path: str | None = None):
"""Return the live System object, or raise ModelLookupError(available=[...])."""
cc = country_code.upper()
with model_lock:
country = get_country(cc, model_path)
try:
return country[system_name]
except Exception:
available = list(country.systems.keys()) if hasattr(country.systems, "keys") else [str(s) for s in country.systems]
raise ModelLookupError(f"System '{system_name}' not found for {cc}", available)