Source code for sparsehydro.registry
"""Model registration service for sparsehydro.
:class:`ModelRegistry` tracks compatible :class:`~sparsehydro.models.IModel`
implementations and lets calibration frameworks discover and instantiate models
by name without importing them directly.
A module-level default registry instance is provided as :data:`registry`.
Models can self-register at definition time using it as a decorator::
from sparsehydro.registry import registry
@registry.register
class LinearReservoir(IModel):
model_name = "linear-reservoir"
...
"""
from __future__ import annotations
import inspect
from typing import Any, Iterator
[docs]
class ModelRegistry:
"""Central registry for tracking compatible parsimonious model classes.
A model class is considered *compatible* if it:
1. Is a **concrete** (non-abstract) subclass of
:class:`~sparsehydro.models.IModel`.
2. Defines a non-empty ``model_name`` class variable.
The registry can be used directly as a class decorator::
registry = ModelRegistry()
@registry.register
class LinearReservoir(IModel):
model_name = "linear-reservoir"
...
model = registry.create("linear-reservoir")
"""
def __init__(self) -> None:
self._models: dict[str, type] = {}
# ------------------------------------------------------------------
# Registration
# ------------------------------------------------------------------
[docs]
def register(self, model_cls: type) -> type:
"""Register a compatible model class.
May be used as a class decorator.
:param model_cls: The model class to register. Must be a concrete
subclass of :class:`~sparsehydro.models.IModel` that defines
a non-empty ``model_name`` class variable.
:type model_cls: type[IModel]
:returns: The model class unchanged (enables decorator use).
:rtype: type[IModel]
:raises TypeError: If ``model_cls`` is not a concrete
:class:`~sparsehydro.models.IModel` subclass or does not
define a valid ``model_name``.
:raises ValueError: If a model with the same name is already
registered.
"""
self._assert_compatible(model_cls)
name: str = model_cls.model_name
if name in self._models:
existing = self._models[name].__qualname__
raise ValueError(
f"A model named '{name}' is already registered "
f"({existing}). Unregister it first or choose a "
"different model_name."
)
self._models[name] = model_cls
return model_cls
[docs]
def unregister(self, name: str) -> None:
"""Remove the model registered under ``name``.
:param name: The ``model_name`` of the model to remove.
:type name: str
:raises KeyError: If no model with ``name`` is registered.
"""
if name not in self._models:
raise KeyError(
f"No model named '{name}' is registered. "
f"Registered names: {self.names()}"
)
del self._models[name]
# ------------------------------------------------------------------
# Retrieval
# ------------------------------------------------------------------
[docs]
def get(self, name: str) -> type[IModel]:
"""Return the model class registered under ``name``.
:param name: The ``model_name`` of the desired model.
:type name: str
:returns: The registered model class.
:rtype: type[IModel]
:raises KeyError: If no model with ``name`` is registered.
"""
if name not in self._models:
raise KeyError(
f"No model named '{name}' is registered. "
f"Registered names: {self.names()}"
)
return self._models[name]
[docs]
def create(self, name: str, *args: Any, **kwargs: Any) -> IModel:
"""Instantiate the model class registered under ``name``.
All positional and keyword arguments are forwarded to the model
constructor.
:param name: The ``model_name`` of the model to instantiate.
:type name: str
:returns: A new instance of the registered model.
:rtype: IModel
:raises KeyError: If no model with ``name`` is registered.
"""
return self.get(name)(*args, **kwargs)
[docs]
def names(self) -> list[str]:
"""Return a sorted list of all registered model names.
:returns: Sorted list of ``model_name`` strings.
:rtype: list[str]
"""
return sorted(self._models)
[docs]
def is_registered(self, name: str) -> bool:
"""Return ``True`` if a model with ``name`` is registered.
:param name: Model name to check.
:type name: str
:rtype: bool
"""
return name in self._models
# ------------------------------------------------------------------
# Compatibility check
# ------------------------------------------------------------------
@staticmethod
def _assert_compatible(model_cls: type) -> None:
"""Raise :class:`TypeError` if ``model_cls`` is not a compatible model.
:param model_cls: The class to check.
:raises TypeError: If incompatible.
"""
from .models.base import IModel as _IModel # lazy to avoid circular import
if not (isinstance(model_cls, type) and issubclass(model_cls, _IModel)):
raise TypeError(
f"{model_cls!r} is not a subclass of IModel."
)
if inspect.isabstract(model_cls):
abstract = sorted(model_cls.__abstractmethods__)
raise TypeError(
f"'{model_cls.__qualname__}' is abstract and cannot be "
f"registered. Unimplemented methods: {abstract}"
)
name = getattr(model_cls, "model_name", None)
if not isinstance(name, str) or not name.strip():
raise TypeError(
f"'{model_cls.__qualname__}' must define a non-empty "
f"'model_name' class variable; got {name!r}."
)
# ------------------------------------------------------------------
# Collections protocol
# ------------------------------------------------------------------
def __contains__(self, name: object) -> bool:
return name in self._models
def __len__(self) -> int:
return len(self._models)
def __iter__(self) -> Iterator[str]:
return iter(sorted(self._models))
def __repr__(self) -> str: # pragma: no cover
return f"ModelRegistry({self.names()})"
#: Default module-level registry. Import and use this instance directly
#: rather than creating your own unless you need isolation.
registry: ModelRegistry = ModelRegistry()