From e777b7091484f6aad4edbe3cc3e0419deccc23df Mon Sep 17 00:00:00 2001 From: SimonTaurus Date: Fri, 25 Sep 2026 06:26:33 +0200 Subject: [PATCH 1/6] feat: public lookup for the controller type registry - register_controller_type, get_registered_controller_types and registered_controller_types mirror the type-registry API - find_controller_type maps an entity's type IRIs to a controller, optionally filtered by the interface it has to provide - lets a caller act on an entity without importing the concrete controller class --- src/oold/model/__init__.py | 68 +++++++++++++++ tests/test_register_controller_type.py | 114 +++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 tests/test_register_controller_type.py diff --git a/src/oold/model/__init__.py b/src/oold/model/__init__.py index 99ae4ad..7ec941a 100644 --- a/src/oold/model/__init__.py +++ b/src/oold/model/__init__.py @@ -152,6 +152,74 @@ def registered_types() -> dict: return _types +def register_controller_type(cls: type, iri: str | list[str] | None = None) -> None: + """Register a controller class under the type IRI(s) it can act on. + + Controller subclasses register themselves on creation, so this is only + needed for controllers built dynamically or registered under an extra IRI. + Several controllers may answer to the same IRI; they are kept in + registration order. + + Parameters + ---------- + cls + The controller class to register. + iri + The IRI(s) to register under. Defaults to ``cls.get_cls_iri()``. + """ + if iri is None: + iri = cls.get_cls_iri() if hasattr(cls, "get_cls_iri") else None + if iri is None: + raise ValueError(f"{cls.__name__} has no type IRI: pass iri= or define get_cls_iri()") + for value in iri if isinstance(iri, list) else [iri]: + if isinstance(value, str): + entries = _controller_types.setdefault(value, []) + if cls not in entries: + entries.append(cls) + + +def get_registered_controller_types(iri: str) -> list: + """The controller classes registered for a type IRI, in registration order.""" + return list(_controller_types.get(iri, [])) + + +def registered_controller_types() -> dict: + """The live controller registry. + + Prefer :func:`register_controller_type` over writing to it directly. + """ + return _controller_types + + +def find_controller_type(type_iris, base: type | None = None) -> type | None: + """A registered controller for any of ``type_iris``. + + Lets a caller act on an entity without knowing which concrete controller + implements it: pass the entity's type IRIs and, optionally, the interface + the controller has to provide. The IRIs are tried in order; within one IRI + the most recently registered controller wins, so a downstream package can + override an implementation by importing its own. + + Parameters + ---------- + type_iris + A type IRI or a list of them, tried in order. + base + If given, only controllers that are a subclass of it are considered. + + Returns + ------- + The controller class, or ``None`` if nothing matches. + """ + if isinstance(type_iris, str): + type_iris = [type_iris] + for iri in type_iris or []: + for cls in reversed(_controller_types.get(iri, [])): + if base is None or issubclass(cls, base): + return cls + return None + + _logger = logging.getLogger(__name__) diff --git a/tests/test_register_controller_type.py b/tests/test_register_controller_type.py new file mode 100644 index 0000000..40f9c1d --- /dev/null +++ b/tests/test_register_controller_type.py @@ -0,0 +1,114 @@ +"""Tests for the public controller-registry API. + +Controllers register themselves in ``_controller_types`` on creation, but no +public entry point existed to read that mapping, so a caller holding an entity +could not find the controller that implements it without knowing the concrete +class. These functions are that entry point. +""" + +import pytest +from pydantic import ConfigDict + +from oold.model import ( + BaseController, + LinkedBaseModel, + _controller_types, + _types, + find_controller_type, + get_registered_controller_types, + register_controller_type, + registered_controller_types, +) + + +class CtrlModel(LinkedBaseModel): + model_config = ConfigDict(json_schema_extra={"title": "CtrlModel"}) + type: list[str] | None = ["Category:OSWCtrlModel"] + field_a: str = "default_a" + + +class ExampleController(BaseController, CtrlModel): + controller_field: str = "ctrl_value" + + +def test_controllers_register_themselves_on_creation(): + assert ExampleController in get_registered_controller_types("Category:OSWCtrlModel") + + +def test_controllers_do_not_shadow_the_data_model(): + """The type registry must still resolve the IRI to the model.""" + assert _types["Category:OSWCtrlModel"] is CtrlModel + + +def test_register_controller_type_with_explicit_iri(): + dyn = type("CtrlDyn", (ExampleController,), {}) + register_controller_type(dyn, "Category:OSWCtrlAlias") + assert get_registered_controller_types("Category:OSWCtrlAlias") == [dyn] + + +def test_register_controller_type_is_idempotent(): + register_controller_type(ExampleController) + assert get_registered_controller_types("Category:OSWCtrlModel").count(ExampleController) == 1 + + +def test_register_controller_type_accepts_a_list_of_iris(): + dyn = type("CtrlMulti", (ExampleController,), {}) + register_controller_type(dyn, ["Category:OSWCtrlA", "Category:OSWCtrlB"]) + assert get_registered_controller_types("Category:OSWCtrlA") == [dyn] + assert get_registered_controller_types("Category:OSWCtrlB") == [dyn] + + +def test_registry_identity_is_shared(): + assert registered_controller_types() is _controller_types + + +def test_unknown_iri_returns_empty_list(): + assert get_registered_controller_types("Category:OSWNeverRegistered") == [] + + +def test_class_without_iri_raises_clearly(): + with pytest.raises(ValueError, match="no type IRI"): + register_controller_type(type("CtrlNoIri", (object,), {})) + + +class LookupModel(LinkedBaseModel): + """Own IRI, so subclasses created elsewhere cannot register against it.""" + + model_config = ConfigDict(json_schema_extra={"title": "LookupModel"}) + type: list[str] | None = ["Category:OSWLookupModel"] + + +class LookupController(BaseController, LookupModel): + pass + + +def test_find_controller_type_tries_iris_in_order(): + found = find_controller_type(["Category:OSWUnknown", "Category:OSWLookupModel"]) + assert found is LookupController + + +def test_find_controller_type_accepts_a_single_iri(): + assert find_controller_type("Category:OSWLookupModel") is LookupController + + +def test_find_controller_type_filters_by_base(): + assert find_controller_type("Category:OSWLookupModel", base=BaseController) is LookupController + + class Unrelated: + pass + + assert find_controller_type("Category:OSWLookupModel", base=Unrelated) is None + + +def test_find_controller_type_prefers_the_latest_registration(): + """A downstream package overrides an implementation by importing its own.""" + iri = "Category:OSWCtrlOverride" + first = type("CtrlFirst", (ExampleController,), {}) + second = type("CtrlSecond", (ExampleController,), {}) + register_controller_type(first, iri) + register_controller_type(second, iri) + assert find_controller_type(iri) is second + + +def test_find_controller_type_returns_none_for_unknown_iri(): + assert find_controller_type("Category:OSWNeverRegistered") is None From 84b76cb003e9560dc1c6c2d2262c0e363dfcc85b Mon Sep 17 00:00:00 2001 From: SimonTaurus Date: Fri, 25 Sep 2026 06:48:39 +0200 Subject: [PATCH 2/6] feat: public registry lookup for the pydantic v1 models - mirrors register_type, get_registered_type and registered_types onto the v1 type registry - adds the controller counterparts and find_controller_type - the v1 models keep their own registries, so a caller has to reach the matching one --- src/oold/model/v1/__init__.py | 96 +++++++++++++++++++++++ tests/test_register_controller_type_v1.py | 96 +++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 tests/test_register_controller_type_v1.py diff --git a/src/oold/model/v1/__init__.py b/src/oold/model/v1/__init__.py index 467ebab..8b68ed5 100644 --- a/src/oold/model/v1/__init__.py +++ b/src/oold/model/v1/__init__.py @@ -117,6 +117,102 @@ def __getattr__(self, name): _logger = logging.getLogger(__name__) +def register_type(cls: type, iri=None) -> None: + """Register a model class under the type IRI(s) it answers to. + + The v1 counterpart of ``oold.model.register_type``; the two versions keep + separate registries, so a class registers in the one of its own flavour. + + Parameters + ---------- + cls + The model class to register. + iri + The IRI(s) to register under. Defaults to ``cls.get_cls_iri()``. + """ + if iri is None: + iri = cls.get_cls_iri() if hasattr(cls, "get_cls_iri") else None + if iri is None: + raise ValueError(f"{cls.__name__} has no type IRI: pass iri= or define get_cls_iri()") + for value in iri if isinstance(iri, list) else [iri]: + if isinstance(value, str): + _types[value] = cls + + +def get_registered_type(iri: str): + """The model class registered for a type IRI, or ``None``.""" + return _types.get(iri) + + +def registered_types() -> dict: + """The live type registry. Prefer :func:`register_type` over writing to it.""" + return _types + + +def register_controller_type(cls: type, iri=None) -> None: + """Register a controller class under the type IRI(s) it can act on. + + Controllers register themselves on creation, so this is only needed for + controllers built dynamically or registered under an extra IRI. + + Parameters + ---------- + cls + The controller class to register. + iri + The IRI(s) to register under. Defaults to ``cls.get_cls_iri()``. + """ + if iri is None: + iri = cls.get_cls_iri() if hasattr(cls, "get_cls_iri") else None + if iri is None: + raise ValueError(f"{cls.__name__} has no type IRI: pass iri= or define get_cls_iri()") + for value in iri if isinstance(iri, list) else [iri]: + if isinstance(value, str): + entries = _controller_types.setdefault(value, []) + if cls not in entries: + entries.append(cls) + + +def get_registered_controller_types(iri: str) -> list: + """The controller classes registered for a type IRI, in registration order.""" + return list(_controller_types.get(iri, [])) + + +def registered_controller_types() -> dict: + """The live controller registry. + + Prefer :func:`register_controller_type` over writing to it directly. + """ + return _controller_types + + +def find_controller_type(type_iris, base: type | None = None): + """A registered controller for any of ``type_iris``. + + The v1 counterpart of ``oold.model.find_controller_type``. The IRIs are + tried in order; within one IRI the most recently registered controller + wins, so a downstream package can override an implementation. + + Parameters + ---------- + type_iris + A type IRI or a list of them, tried in order. + base + If given, only controllers that are a subclass of it are considered. + + Returns + ------- + The controller class, or ``None`` if nothing matches. + """ + if isinstance(type_iris, str): + type_iris = [type_iris] + for iri in type_iris or []: + for cls in reversed(_controller_types.get(iri, [])): + if base is None or issubclass(cls, base): + return cls + return None + + # pydantic v1 class _LinkedBaseModelMetaClassLegacy(pydantic.v1.main.ModelMetaclass): _constructing: bool = False diff --git a/tests/test_register_controller_type_v1.py b/tests/test_register_controller_type_v1.py new file mode 100644 index 0000000..e03001c --- /dev/null +++ b/tests/test_register_controller_type_v1.py @@ -0,0 +1,96 @@ +"""Tests for the public registry API of the pydantic v1 models. + +The v1 models keep their own ``_types`` / ``_controller_types`` mappings, so +they need the same public entry points as the v2 module, operating on those +registries. +""" + +import pytest +from pydantic.v1 import BaseModel # noqa: F401 + +from oold.model import BaseController +from oold.model.v1 import ( + LinkedBaseModel, + _controller_types, + _types, + find_controller_type, + get_registered_controller_types, + get_registered_type, + register_controller_type, + register_type, + registered_controller_types, + registered_types, +) + + +class V1Model(LinkedBaseModel): + id: str = "x" + type: str = "ex:V1RegModel" + + +class V1Controller(BaseController, V1Model): + pass + + +def test_models_register_themselves_on_creation(): + assert get_registered_type("ex:V1RegModel") is V1Model + + +def test_controllers_register_themselves_on_creation(): + assert V1Controller in get_registered_controller_types("ex:V1RegModel") + + +def test_controllers_do_not_shadow_the_data_model(): + assert get_registered_type("ex:V1RegModel") is V1Model + + +def test_registries_are_separate_from_the_v2_ones(): + from oold import model as v2_model + + assert registered_types() is _types + assert registered_controller_types() is _controller_types + assert _types is not v2_model._types + assert _controller_types is not v2_model._controller_types + + +def test_register_type_with_explicit_iri(): + dyn = type("V1RegDyn", (V1Model,), {}) + register_type(dyn, "ex:V1RegAlias") + assert get_registered_type("ex:V1RegAlias") is dyn + + +def test_register_controller_type_with_explicit_iri(): + dyn = type("V1CtrlDyn", (V1Controller,), {}) + register_controller_type(dyn, "ex:V1CtrlAlias") + assert get_registered_controller_types("ex:V1CtrlAlias") == [dyn] + + +def test_register_controller_type_is_idempotent(): + register_controller_type(V1Controller) + registered = get_registered_controller_types("ex:V1RegModel") + assert registered.count(V1Controller) == 1 + + +def test_unknown_iri_returns_empty_list(): + assert get_registered_controller_types("ex:V1NeverRegistered") == [] + + +def test_class_without_iri_raises_clearly(): + with pytest.raises(ValueError, match="no type IRI"): + register_controller_type(type("V1CtrlNoIri", (object,), {})) + + +def test_find_controller_type_filters_by_base(): + iri = "ex:V1FindCtrl" + dyn = type("V1FindCtrl", (V1Controller,), {}) + register_controller_type(dyn, iri) + assert find_controller_type(iri, base=BaseController) is dyn + + class Unrelated: + pass + + assert find_controller_type(iri, base=Unrelated) is None + + +def test_find_controller_type_returns_none_for_unknown_iri(): + assert find_controller_type("ex:V1NeverRegistered") is None From 2b3f70dc27d42a508a90ebaa4eeca1dae0fd16a2 Mon Sep 17 00:00:00 2001 From: SimonTaurus Date: Thu, 15 Oct 2026 07:48:47 +0200 Subject: [PATCH 3/6] fix: one precedence rule for controllers, isolated per pydantic layer - resolve_type takes the controller registry of the model registry's layer - several controllers for one IRI: the last registered wins, instead of falling back to the pure model and disabling controller resolution - get_registered_controller_types returns last-first, like the other two accessors - register_type / register_controller_type flatten nested IRI lists and raise on an unusable IRI instead of no-op - v1 register_type gets the inherited-IRI guard the v2 path has - every registration logged at info, overrides at warning - conftest restores the controller registries, deep-copying the lists --- docs/how-to/controller.md | 19 +++- src/oold/model/__init__.py | 128 ++++++++++++++++----- src/oold/model/_compat.py | 6 +- src/oold/model/_descriptor.py | 11 +- src/oold/model/v1/__init__.py | 68 +++++++----- src/oold/model/v1/_descriptor.py | 10 +- src/oold/static.py | 70 ++++++++---- tests/conftest.py | 23 +++- tests/test_registry_precedence.py | 178 ++++++++++++++++++++++++++++++ 9 files changed, 423 insertions(+), 90 deletions(-) create mode 100644 tests/test_registry_precedence.py diff --git a/docs/how-to/controller.md b/docs/how-to/controller.md index 2d5457c..c86d387 100644 --- a/docs/how-to/controller.md +++ b/docs/how-to/controller.md @@ -74,7 +74,24 @@ print(ctrl.to_json()) ## Type registry behavior -`BaseController` excludes itself from the `_types` lookup. When the backend resolves `"ex:robot-1"`, it returns a plain `Robot` instance, not a `RobotController`. This prevents controller logic from leaking into resolved data objects. +A controller is kept out of `_types`, the data model registry, and collected in `_controller_types` instead. The data model therefore stays the class a type IRI maps to, and `to_json()` keeps producing controller-free documents. + +Resolution still hands out the controller: `from_json()`, `from_jsonld()` and anything else going through `resolve_type()` return `RobotController` for `"ex:robot-1"`, so behaviour is available wherever a model is read back. Pass `prefer_controller=False` to `resolve_type()` to get the pure `Robot` class. + +When several controllers answer to the same IRI, the one registered **last** wins - a package imported later overrides an implementation without the earlier one knowing about it. Every registration is logged at `INFO` under the `oold.model` logger, and an override at `WARNING`, so an unexpected winner is visible: + +```python +import logging +logging.getLogger("oold.model").setLevel(logging.INFO) +``` + +Resolution never crosses pydantic versions: `oold.model` and `oold.model.v1` keep separate registries, and a lookup is answered from the one the model registry belongs to. + +To pick a controller explicitly, construct it from the data model: + +```python +ctrl = RobotController(robot, connection_url="tcp://192.168.1.10:5000") +``` --- diff --git a/src/oold/model/__init__.py b/src/oold/model/__init__.py index 7ec941a..259ae69 100644 --- a/src/oold/model/__init__.py +++ b/src/oold/model/__init__.py @@ -107,10 +107,82 @@ def __getattr__(self, name): _types: dict[str, pydantic.main._model_construction.ModelMetaclass] = {} _controller_types: dict[str, list] = {} +_logger = logging.getLogger(__name__) M = TypeVar("M", bound="_LinkedBaseModelLegacy") +def _cls_name(cls: type) -> str: + return f"{cls.__module__}.{cls.__qualname__}" + + +def _usable_iris(cls: type, iri) -> list[str]: + """The IRIs to register ``cls`` under, flattened and validated. + + ``get_cls_iri()`` returns a bare string or a list, and the list may hold + the default of the type field, which is a list again. Flattening here is + what keeps the public API in step with the metaclass; silently skipping a + nested or empty shape left a class unregistered and unreachable by type. + """ + if iri is None: + iri = cls.get_cls_iri() if hasattr(cls, "get_cls_iri") else None + if iri is None: + raise ValueError(f"{_cls_name(cls)} has no type IRI: pass iri= or define get_cls_iri()") + out: list[str] = [] + for value in sorted(_iri_set(iri)): + if not isinstance(value, str) or not value.strip(): + continue + if value not in out: + out.append(value) + if not out: + raise ValueError(f"{_cls_name(cls)}: no usable type IRI in {iri!r}") + return out + + +def _put_type(iri: str, cls: type, registry: dict | None = None) -> None: + """Write one model registration, reporting what it replaces. + + ``registry`` selects the pydantic layer; it defaults to the v2 one. The two + layers keep separate registries, and a class of the wrong flavour handed to + deserialization does not validate. + """ + registry = _types if registry is None else registry + previous = registry.get(iri) + if previous is cls: + return + if previous is not None: + _logger.warning( + "Type %s: %s replaces %s", + iri, + _cls_name(cls), + _cls_name(previous), + ) + else: + _logger.info("Type %s: %s", iri, _cls_name(cls)) + registry[iri] = cls + + +def _put_controller(iri: str, cls: type, registry: dict | None = None) -> None: + """Append one controller registration, reporting what it takes over from.""" + registry = _controller_types if registry is None else registry + entries = registry.setdefault(iri, []) + if entries and entries[-1] is cls: + return + if cls in entries: + # re-registered under the same IRI: move it to the front of resolution + entries.remove(cls) + if entries: + _logger.warning( + "Controller %s: %s takes precedence over %s", + iri, + _cls_name(cls), + ", ".join(_cls_name(c) for c in entries), + ) + else: + _logger.info("Controller %s: %s", iri, _cls_name(cls)) + entries.append(cls) + + def register_type(cls: type, iri: str | list[str] | None = None) -> None: """Register a model class under the type IRI(s) it answers to. @@ -122,6 +194,11 @@ def register_type(cls: type, iri: str | list[str] | None = None) -> None: Without it callers reach for the private ``_types`` mapping and write into it directly, which couples them to an internal and offers no validation. + A class may only claim the IRIs it introduces itself: a subclass that + merely narrows a field reports its parent's IRI, and registering it would + make every parent document deserialize as the subclass. Pass ``iri`` + explicitly to override that. + Parameters ---------- cls @@ -129,13 +206,14 @@ def register_type(cls: type, iri: str | list[str] | None = None) -> None: iri The IRI(s) to register under. Defaults to ``cls.get_cls_iri()``. """ - if iri is None: - iri = cls.get_cls_iri() if hasattr(cls, "get_cls_iri") else None - if iri is None: - raise ValueError(f"{cls.__name__} has no type IRI: pass iri= or define get_cls_iri()") - for value in iri if isinstance(iri, list) else [iri]: - if isinstance(value, str): - _types[value] = cls + explicit = iri is not None + iris = _usable_iris(cls, iri) + inherited = frozenset() if explicit else _inherited_cls_iris(cls) + for value in iris: + if value in inherited: + _logger.debug("%s: not registering inherited type IRI %s", _cls_name(cls), value) + continue + _put_type(value, cls) def get_registered_type(iri: str) -> type | None: @@ -157,8 +235,8 @@ def register_controller_type(cls: type, iri: str | list[str] | None = None) -> N Controller subclasses register themselves on creation, so this is only needed for controllers built dynamically or registered under an extra IRI. - Several controllers may answer to the same IRI; they are kept in - registration order. + Several controllers may answer to the same IRI; the one registered last + wins, so a package imported later overrides an implementation. Parameters ---------- @@ -167,20 +245,17 @@ def register_controller_type(cls: type, iri: str | list[str] | None = None) -> N iri The IRI(s) to register under. Defaults to ``cls.get_cls_iri()``. """ - if iri is None: - iri = cls.get_cls_iri() if hasattr(cls, "get_cls_iri") else None - if iri is None: - raise ValueError(f"{cls.__name__} has no type IRI: pass iri= or define get_cls_iri()") - for value in iri if isinstance(iri, list) else [iri]: - if isinstance(value, str): - entries = _controller_types.setdefault(value, []) - if cls not in entries: - entries.append(cls) + for value in _usable_iris(cls, iri): + _put_controller(value, cls) def get_registered_controller_types(iri: str) -> list: - """The controller classes registered for a type IRI, in registration order.""" - return list(_controller_types.get(iri, [])) + """The controller classes registered for a type IRI, last registered first. + + The order resolution uses: :func:`find_controller_type` and + :func:`~oold.static.resolve_type` both take the most recent registration. + """ + return list(reversed(_controller_types.get(iri, []))) def registered_controller_types() -> dict: @@ -220,9 +295,6 @@ def find_controller_type(type_iris, base: type | None = None) -> type | None: return None -_logger = logging.getLogger(__name__) - - def _iri_set(value) -> frozenset: """Flatten whatever get_cls_iri() returned into a set of IRIs. @@ -292,10 +364,12 @@ def __new__(mcs, name, bases, namespace, **kwargs): # IRIs it introduces itself. inherited = frozenset() if _is_ctrl else _inherited_cls_iris(cls) for i in iris: + if not isinstance(i, str): + continue if _is_ctrl: - _controller_types.setdefault(i, []).append(cls) + _put_controller(i, cls) elif i not in inherited: - _types[i] = cls + _put_type(i, cls) return cls # override operators, see https://docs.python.org/3/library/operator.html @@ -1107,7 +1181,7 @@ def to_jsonld(self) -> dict: @classmethod def from_jsonld(cls, jsonld: dict) -> "_LinkedBaseModelLegacy": """Constructs a model instance from a JSON-LD representation.""" - return import_jsonld(BaseModel, _LinkedBaseModelLegacy, cls, jsonld, _types) + return import_jsonld(BaseModel, _LinkedBaseModelLegacy, cls, jsonld, _types, _controller_types) def to_json(self, exclude_defaults: bool = False) -> dict: """Return the JSON representation of the object. @@ -1139,7 +1213,7 @@ def to_json(self, exclude_defaults: bool = False) -> dict: @classmethod def from_json(cls, data: dict) -> "_LinkedBaseModelLegacy": """Constructs a model instance from a JSON representation.""" - return import_json(BaseModel, _LinkedBaseModelLegacy, cls, data, _types) + return import_json(BaseModel, _LinkedBaseModelLegacy, cls, data, _types, _controller_types) # @classmethod # def model_json_schema( diff --git a/src/oold/model/_compat.py b/src/oold/model/_compat.py index a81557b..c3e7a49 100644 --- a/src/oold/model/_compat.py +++ b/src/oold/model/_compat.py @@ -270,21 +270,23 @@ def _root_cls(cls) -> type: @classmethod def from_json(cls, data: dict[str, Any]) -> Any: + from oold.model import _controller_types from oold.model._descriptor import _TYPE_REGISTRY # root must be the binding base: import_json only falls back to the # given class when the two differ, which is how a payload without a # type IRI still constructs. - return import_json(BaseModel, cls._root_cls(), cls, data, _TYPE_REGISTRY) + return import_json(BaseModel, cls._root_cls(), cls, data, _TYPE_REGISTRY, _controller_types) def to_jsonld(self) -> dict[str, Any]: return export_jsonld(self, BaseModel) @classmethod def from_jsonld(cls, jsonld: dict[str, Any]) -> Any: + from oold.model import _controller_types from oold.model._descriptor import _TYPE_REGISTRY - return import_jsonld(BaseModel, cls._root_cls(), cls, jsonld, _TYPE_REGISTRY) + return import_jsonld(BaseModel, cls._root_cls(), cls, jsonld, _TYPE_REGISTRY, _controller_types) def store_jsonld(self) -> None: from oold.backend.interface import GetBackendParam, StoreParam, get_backend diff --git a/src/oold/model/_descriptor.py b/src/oold/model/_descriptor.py index 94b5314..0b305bf 100644 --- a/src/oold/model/_descriptor.py +++ b/src/oold/model/_descriptor.py @@ -930,7 +930,12 @@ def _register_class(cls: type) -> None: only claim the IRIs it introduces itself - a subclass that merely narrows a field reports its parent's IRI and would otherwise replace it. """ - from oold.model import _controller_types, _inherited_cls_iris + from oold.model import ( + _controller_types, + _inherited_cls_iris, + _put_controller, + _put_type, + ) iri = cls.get_cls_iri() if hasattr(cls, "get_cls_iri") else None if iri is None: @@ -941,9 +946,9 @@ def _register_class(cls: type) -> None: if not isinstance(value, str): continue if is_ctrl: - _controller_types.setdefault(value, []).append(cls) + _put_controller(value, cls, _controller_types) elif value not in inherited: - _TYPE_REGISTRY[value] = cls + _put_type(value, cls, _TYPE_REGISTRY) class _AutoLink: diff --git a/src/oold/model/v1/__init__.py b/src/oold/model/v1/__init__.py index 8b68ed5..c9a8078 100644 --- a/src/oold/model/v1/__init__.py +++ b/src/oold/model/v1/__init__.py @@ -123,6 +123,9 @@ def register_type(cls: type, iri=None) -> None: The v1 counterpart of ``oold.model.register_type``; the two versions keep separate registries, so a class registers in the one of its own flavour. + A class may only claim the IRIs it introduces itself; pass ``iri`` + explicitly to override that. + Parameters ---------- cls @@ -130,13 +133,16 @@ def register_type(cls: type, iri=None) -> None: iri The IRI(s) to register under. Defaults to ``cls.get_cls_iri()``. """ - if iri is None: - iri = cls.get_cls_iri() if hasattr(cls, "get_cls_iri") else None - if iri is None: - raise ValueError(f"{cls.__name__} has no type IRI: pass iri= or define get_cls_iri()") - for value in iri if isinstance(iri, list) else [iri]: - if isinstance(value, str): - _types[value] = cls + from oold.model import _inherited_cls_iris, _put_type, _usable_iris + + explicit = iri is not None + iris = _usable_iris(cls, iri) + inherited = frozenset() if explicit else _inherited_cls_iris(cls) + for value in iris: + if value in inherited: + _logger.debug("%s: not registering inherited type IRI %s", cls.__name__, value) + continue + _put_type(value, cls, _types) def get_registered_type(iri: str): @@ -153,7 +159,8 @@ def register_controller_type(cls: type, iri=None) -> None: """Register a controller class under the type IRI(s) it can act on. Controllers register themselves on creation, so this is only needed for - controllers built dynamically or registered under an extra IRI. + controllers built dynamically or registered under an extra IRI. Several + controllers may answer to the same IRI; the one registered last wins. Parameters ---------- @@ -162,20 +169,19 @@ def register_controller_type(cls: type, iri=None) -> None: iri The IRI(s) to register under. Defaults to ``cls.get_cls_iri()``. """ - if iri is None: - iri = cls.get_cls_iri() if hasattr(cls, "get_cls_iri") else None - if iri is None: - raise ValueError(f"{cls.__name__} has no type IRI: pass iri= or define get_cls_iri()") - for value in iri if isinstance(iri, list) else [iri]: - if isinstance(value, str): - entries = _controller_types.setdefault(value, []) - if cls not in entries: - entries.append(cls) + from oold.model import _put_controller, _usable_iris + + for value in _usable_iris(cls, iri): + _put_controller(value, cls, _controller_types) def get_registered_controller_types(iri: str) -> list: - """The controller classes registered for a type IRI, in registration order.""" - return list(_controller_types.get(iri, [])) + """The controller classes registered for a type IRI, last registered first. + + The order resolution uses: :func:`find_controller_type` and + :func:`~oold.static.resolve_type` both take the most recent registration. + """ + return list(reversed(_controller_types.get(iri, []))) def registered_controller_types() -> dict: @@ -229,18 +235,28 @@ def __new__(mcs, name, bases, namespace): finally: _LinkedBaseModelMetaClassLegacy._constructing = False - # Register type IRI mapping. Controllers go to _controller_types. + # Register type IRI mapping. Controllers go to _controller_types + # (they extend data models but should not replace them in + # the type lookup table). + from oold.model import _inherited_cls_iris, _put_controller, _put_type + _is_ctrl = any(b.__module__ == "oold.model" and b.__name__ == "BaseController" for b in cls.__mro__) if hasattr(cls, "get_cls_iri"): iri = cls.get_cls_iri() if iri is not None: iris = iri if isinstance(iri, list) else [iri] - registry = _controller_types if _is_ctrl else _types + # Controllers are collected in a list and never shadow a data + # model, so they may register under an inherited IRI. A data + # model replaces whatever is there, so it may only claim the + # IRIs it introduces itself. + inherited = frozenset() if _is_ctrl else _inherited_cls_iris(cls) for i in iris: + if not isinstance(i, str): + continue if _is_ctrl: - registry.setdefault(i, []).append(cls) - else: - registry[i] = cls + _put_controller(i, cls, _controller_types) + elif i not in inherited: + _put_type(i, cls, _types) return cls # override operators, see https://docs.python.org/3/library/operator.html @@ -933,7 +949,7 @@ def to_jsonld(self) -> builtins.dict: @classmethod def from_jsonld(cls, jsonld: builtins.dict) -> "_LinkedBaseModelLegacy": """Constructs a model instance from a JSON-LD representation.""" - return import_jsonld(BaseModel, _LinkedBaseModelLegacy, cls, jsonld, _types) + return import_jsonld(BaseModel, _LinkedBaseModelLegacy, cls, jsonld, _types, _controller_types) def to_json(self, exclude_defaults: bool = False) -> builtins.dict: """Return the JSON representation of the object as dict. @@ -965,7 +981,7 @@ def to_json(self, exclude_defaults: bool = False) -> builtins.dict: @classmethod def from_json(cls, json_dict: builtins.dict) -> "_LinkedBaseModelLegacy": """Constructs a model instance from a JSON representation.""" - return import_json(BaseModel, _LinkedBaseModelLegacy, cls, json_dict, _types) + return import_json(BaseModel, _LinkedBaseModelLegacy, cls, json_dict, _types, _controller_types) # Re-export BaseController from v2 module (it's a plain class, no Pydantic dep) diff --git a/src/oold/model/v1/_descriptor.py b/src/oold/model/v1/_descriptor.py index ad19092..80acb5d 100644 --- a/src/oold/model/v1/_descriptor.py +++ b/src/oold/model/v1/_descriptor.py @@ -75,7 +75,7 @@ def _register_class_v1(cls: type) -> None: IRIs it introduces itself - a subclass that merely narrows a field reports its parent's IRI and would otherwise replace it. """ - from oold.model import _inherited_cls_iris + from oold.model import _inherited_cls_iris, _put_controller, _put_type iri = cls.get_cls_iri() if hasattr(cls, "get_cls_iri") else None if iri is None: @@ -86,9 +86,9 @@ def _register_class_v1(cls: type) -> None: if not isinstance(value, str): continue if is_ctrl: - _CONTROLLER_REGISTRY.setdefault(value, []).append(cls) + _put_controller(value, cls, _CONTROLLER_REGISTRY) elif value not in inherited: - _TYPE_REGISTRY[value] = cls + _put_type(value, cls, _TYPE_REGISTRY) def _neutralise_field(field: Any) -> Any: @@ -363,7 +363,7 @@ def to_json(self, exclude_defaults: bool = False) -> dict[str, Any]: def from_json(cls, data: dict[str, Any]) -> Any: from oold.static import import_json - return import_json(BaseModel, LinkedBaseModel, cls, data, _TYPE_REGISTRY) + return import_json(BaseModel, LinkedBaseModel, cls, data, _TYPE_REGISTRY, _CONTROLLER_REGISTRY) def to_jsonld(self) -> dict[str, Any]: from oold.static import export_jsonld @@ -374,4 +374,4 @@ def to_jsonld(self) -> dict[str, Any]: def from_jsonld(cls, jsonld: dict[str, Any]) -> Any: from oold.static import import_jsonld - return import_jsonld(BaseModel, LinkedBaseModel, cls, jsonld, _TYPE_REGISTRY) + return import_jsonld(BaseModel, LinkedBaseModel, cls, jsonld, _TYPE_REGISTRY, _CONTROLLER_REGISTRY) diff --git a/src/oold/static.py b/src/oold/static.py index 6172e43..13bd9a7 100644 --- a/src/oold/static.py +++ b/src/oold/static.py @@ -446,17 +446,44 @@ def normalize_iri(iri: str, model_cls, expand: bool) -> str: return compacted_iri +def matching_controller_registry(_types: dict) -> dict: + """The controller registry of the same pydantic layer as ``_types``. + + The two layers keep separate registries and a v2 class handed to v1 + deserialisation does not validate, so a caller that only knows the model + registry must not be answered from the other one. + """ + try: + from oold.model.v1 import _controller_types as _v1_controllers + from oold.model.v1 import _types as _v1_types + + if _types is _v1_types: + return _v1_controllers + except ImportError: # pragma: no cover + pass + try: + from oold.model import _controller_types as _v2_controllers + except ImportError: # pragma: no cover + return {} + return _v2_controllers + + def resolve_type( type_iri, _types: dict[str, type], prefer_controller: bool = True, + _controller_types: dict[str, list] | None = None, ): """Resolve a type IRI to a class. - If prefer_controller is True and exactly one controller is registered - for the type IRI, returns the controller class. Otherwise returns the + If prefer_controller is True and a controller is registered for the type + IRI, returns the controller class - the most recently registered one, so a + package imported later overrides an implementation. Otherwise returns the pure data model class. + ``_controller_types`` defaults to the registry of the same pydantic layer + as ``_types``; pass it to keep resolution within one layer explicitly. + Returns the resolved class or None. """ if isinstance(type_iri, list): @@ -465,25 +492,22 @@ def resolve_type( type_iri = type_iri.get("@id") if type_iri is None: return None - # Prefer controller if exactly one is registered if prefer_controller: - try: - from oold.model import _controller_types - - ctrls = _controller_types.get(type_iri, []) - if len(ctrls) == 1: - return ctrls[0] - elif len(ctrls) > 1: - names = [c.__module__ + "." + c.__name__ for c in ctrls] - _logger.info( - "Multiple controllers for %s: %s. " - "Falling back to pure model. To use a specific " - "controller: ctrl = ControllerClass(model_instance)", - type_iri, - names, - ) - except ImportError: - pass + if _controller_types is None: + _controller_types = matching_controller_registry(_types) + ctrls = _controller_types.get(type_iri, []) + if len(ctrls) > 1: + names = [c.__module__ + "." + c.__name__ for c in ctrls] + _logger.warning( + "Multiple controllers for %s: %s. Using the last registered " + "one, %s. To use a specific controller: " + "ctrl = ControllerClass(model_instance)", + type_iri, + names, + names[-1], + ) + if ctrls: + return ctrls[-1] return _types.get(type_iri) @@ -493,6 +517,7 @@ def import_jsonld( model_cls: GenericLinkedBaseModel, jsonld_dict: dict, _types: dict[str, type], + _controller_types: dict[str, list] | None = None, ) -> GenericLinkedBaseModel: """Return the object instance from the JSON-LD representation.""" # ToDo: apply jsonld frame with @id restriction @@ -509,7 +534,7 @@ def import_jsonld( f"and no type IRI found in JSON-LD dict: {jsonld_dict}" ) else: - model_cls = resolve_type(type_iri, _types) + model_cls = resolve_type(type_iri, _types, _controller_types=_controller_types) if model_cls is None: raise ValueError(f"Unknown model type IRI: {type_iri}") if model_type == BaseModel: @@ -547,6 +572,7 @@ def import_json( model_cls: GenericLinkedBaseModel, json_dict: dict, _types: dict[str, type], + _controller_types: dict[str, list] | None = None, ) -> GenericLinkedBaseModel: """Return the object instance from the JSON representation.""" # get the type from the json_dict @@ -562,7 +588,7 @@ def import_json( f"and no type IRI found in JSON dict: {json_dict}" ) else: - model_cls = resolve_type(type_iri, _types) + model_cls = resolve_type(type_iri, _types, _controller_types=_controller_types) if model_cls is None: raise ValueError(f"Unknown model type IRI: {type_iri}") try: diff --git a/tests/conftest.py b/tests/conftest.py index 49a57b9..812100e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -50,12 +50,27 @@ def _restore_global_registries(): Classes registered at *module import* are left alone: they are set up before this fixture runs, so the snapshot already contains them. + + The controller registries hold a *list* per IRI, so the snapshot copies + those lists too - a shallow copy of the mapping shares them, and the + appends of one test then pile up in the next. """ - from oold.model import _descriptor, _types + from oold.model import _controller_types, _descriptor, _types + from oold.model.v1 import _controller_types as _controller_types_v1 from oold.model.v1 import _descriptor as _descriptor_v1 - - registries = [interface._resolvers, _types, _descriptor._TYPE_REGISTRY, _descriptor_v1._TYPE_REGISTRY] - saved = [dict(r) for r in registries] + from oold.model.v1 import _types as _types_v1 + + registries = [ + interface._resolvers, + _types, + _types_v1, + _controller_types, + _controller_types_v1, + _descriptor._TYPE_REGISTRY, + _descriptor_v1._TYPE_REGISTRY, + _descriptor_v1._CONTROLLER_REGISTRY, + ] + saved = [{k: list(v) if isinstance(v, list) else v for k, v in r.items()} for r in registries] yield for registry, snapshot in zip(registries, saved, strict=True): registry.clear() diff --git a/tests/test_registry_precedence.py b/tests/test_registry_precedence.py new file mode 100644 index 0000000..7b90710 --- /dev/null +++ b/tests/test_registry_precedence.py @@ -0,0 +1,178 @@ +"""Precedence and isolation of the type registries. + +Three accessors read the controller registry - ``get_registered_controller_types``, +``find_controller_type`` and ``resolve_type`` - and they used to disagree: the +first returned earliest-first, the second last-first, and the third refused to +resolve at all once a second controller was registered, which turned the +documented override mechanism into a way of disabling controllers. The rule is +now one: the controller registered last wins, in every accessor. + +The second rule is that the two pydantic layers never mix. A v2 class handed to +v1 deserialization does not validate, so resolution has to stay in the layer the +model registry belongs to. +""" + +import logging + +import pytest +from pydantic import ConfigDict + +from oold.model import ( + BaseController, + LinkedBaseModel, + _types, + find_controller_type, + get_registered_controller_types, + register_controller_type, + register_type, +) +from oold.static import resolve_type + +IRI = "Category:OSWPrecedenceModel" + + +class PrecedenceModel(LinkedBaseModel): + model_config = ConfigDict(json_schema_extra={"title": "PrecedenceModel"}) + type: list[str] | None = [IRI] + + +class FirstController(BaseController, PrecedenceModel): + pass + + +class SecondController(BaseController, PrecedenceModel): + pass + + +# -- the three accessors agree ------------------------------------------------ + + +def test_accessors_agree_on_the_last_registration(): + registered = get_registered_controller_types(IRI) + assert registered[0] is SecondController + assert find_controller_type(IRI) is registered[0] + assert resolve_type(IRI, _types) is registered[0] + + +def test_a_later_registration_takes_over(): + third = type("ThirdController", (FirstController,), {}) + register_controller_type(third, IRI) + assert get_registered_controller_types(IRI)[0] is third + assert find_controller_type(IRI) is third + assert resolve_type(IRI, _types) is third + + +def test_re_registering_moves_a_controller_to_the_front(): + register_controller_type(FirstController, IRI) + assert resolve_type(IRI, _types) is FirstController + assert get_registered_controller_types(IRI).count(FirstController) == 1 + + +def test_prefer_controller_false_still_gives_the_pure_model(): + assert resolve_type(IRI, _types, prefer_controller=False) is PrecedenceModel + + +# -- the layers stay apart ---------------------------------------------------- + + +def test_v1_registration_does_not_leak_into_v2(): + from oold.model.v1 import BaseController as BaseControllerV1 + from oold.model.v1 import LinkedBaseModel as LinkedBaseModelV1 + from oold.model.v1 import _types as _types_v1 + + class SharedIriModelV1(LinkedBaseModelV1): + class Config: + schema_extra = {"title": "SharedIriModelV1"} + + type: list = [IRI] + + class SharedIriControllerV1(BaseControllerV1, SharedIriModelV1): + pass + + assert resolve_type(IRI, _types_v1) is SharedIriControllerV1 + assert resolve_type(IRI, _types) is SecondController + + +def test_explicit_registry_argument_wins_over_inference(): + assert resolve_type(IRI, _types, _controller_types={}) is PrecedenceModel + + +# -- registrations are reported ----------------------------------------------- + + +def test_override_is_warned_about(caplog): + iri = "Category:OSWWarnOverride" + first = type("WarnFirst", (FirstController,), {}) + second = type("WarnSecond", (FirstController,), {}) + register_controller_type(first, iri) + with caplog.at_level(logging.WARNING, logger="oold.model"): + register_controller_type(second, iri) + assert "WarnSecond" in caplog.text + assert "WarnFirst" in caplog.text + + +def test_first_registration_is_logged_at_info(caplog): + iri = "Category:OSWInfoOnce" + # created outside the capture: a subclass registers under the inherited IRI + # on creation, which is itself an override of the controllers already there + dyn = type("InfoOnce", (FirstController,), {}) + caplog.clear() + with caplog.at_level(logging.INFO, logger="oold.model"): + register_controller_type(dyn, iri) + assert iri in caplog.text + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + +def test_replacing_a_model_is_warned_about(caplog): + iri = "Category:OSWModelReplaced" + register_type(PrecedenceModel, iri) + with caplog.at_level(logging.WARNING, logger="oold.model"): + register_type(type("Replacement", (PrecedenceModel,), {}), iri) + assert "Replacement" in caplog.text + + +# -- the public API flattens and validates like the metaclass ----------------- + + +def test_register_type_flattens_a_nested_iri_list(): + dyn = type("NestedIriModel", (PrecedenceModel,), {}) + register_type(dyn, [["Category:OSWNestedA"], "Category:OSWNestedB"]) + assert _types["Category:OSWNestedA"] is dyn + assert _types["Category:OSWNestedB"] is dyn + + +@pytest.mark.parametrize("iri", [[], "", ["", None]]) +def test_register_type_rejects_an_unusable_iri(iri): + dyn = type("UnusableIriModel", (PrecedenceModel,), {}) + with pytest.raises(ValueError, match="no usable type IRI"): + register_type(dyn, iri) + + +def test_register_type_skips_an_inherited_iri(): + """A subclass that only narrows a field must not replace its parent.""" + + class NarrowedModel(PrecedenceModel): + extra: str = "x" + + register_type(NarrowedModel) + assert _types[IRI] is PrecedenceModel + + +def test_v1_register_type_skips_an_inherited_iri(): + from oold.model.v1 import LinkedBaseModel as LinkedBaseModelV1 + from oold.model.v1 import _types as _types_v1 + from oold.model.v1 import register_type as register_type_v1 + + v1_iri = "Category:OSWV1InheritGuard" + + class V1Parent(LinkedBaseModelV1): + class Config: + schema_extra = {"title": "V1Parent"} + + type: list = [v1_iri] + + class V1Child(V1Parent): + extra: str = "x" + + register_type_v1(V1Child) + assert _types_v1[v1_iri] is V1Parent From e501f0103e488d2354cd3b34a0561e1fd146c125 Mon Sep 17 00:00:00 2001 From: SimonTaurus Date: Thu, 15 Oct 2026 08:04:27 +0200 Subject: [PATCH 4/6] feat: let a class opt out of the type registries __oold_register__ = False keeps a controller that reuses another model out of the lookup, so it stops competing for that model's type IRI. It stays constructible from the model and explicitly registerable. --- docs/how-to/controller.md | 7 +++++++ src/oold/model/__init__.py | 14 ++++++++++++- src/oold/model/_descriptor.py | 3 ++- src/oold/model/v1/__init__.py | 9 +++++++-- src/oold/model/v1/_descriptor.py | 9 +++++++-- tests/test_registry_precedence.py | 33 +++++++++++++++++++++++++++++++ 6 files changed, 69 insertions(+), 6 deletions(-) diff --git a/docs/how-to/controller.md b/docs/how-to/controller.md index c86d387..c1f766c 100644 --- a/docs/how-to/controller.md +++ b/docs/how-to/controller.md @@ -93,6 +93,13 @@ To pick a controller explicitly, construct it from the data model: ctrl = RobotController(robot, connection_url="tcp://192.168.1.10:5000") ``` +A controller that reuses a model without being the handler for it - a simulator over `Robot`, an in-memory buffer over a local file - sets `__oold_register__ = False` and stays out of the lookup. It is still constructed from the model as above: + +```python +class SimulatedRobot(BaseController, Robot): + __oold_register__ = False +``` + --- ## Multi-model controller diff --git a/src/oold/model/__init__.py b/src/oold/model/__init__.py index 259ae69..48aecca 100644 --- a/src/oold/model/__init__.py +++ b/src/oold/model/__init__.py @@ -139,6 +139,18 @@ def _usable_iris(cls: type, iri) -> list[str]: return out +def registers_itself(cls: type) -> bool: + """Whether a class wants to be in the registries. + + A class opts out with ``__oold_register__ = False``. The case for it is a + controller that reuses another model without being the handler for it - an + in-memory buffer over a local-file model, say: it is asked for by name and + constructed from the model, so having it compete for the model's type IRI + only makes resolution ambiguous. + """ + return bool(getattr(cls, "__oold_register__", True)) + + def _put_type(iri: str, cls: type, registry: dict | None = None) -> None: """Write one model registration, reporting what it replaces. @@ -354,7 +366,7 @@ def __new__(mcs, name, bases, namespace, **kwargs): # (they extend data models but should not replace them in # the type lookup table). _is_ctrl = any(b.__module__ == "oold.model" and b.__name__ == "BaseController" for b in cls.__mro__) - if hasattr(cls, "get_cls_iri"): + if hasattr(cls, "get_cls_iri") and registers_itself(cls): iri = cls.get_cls_iri() if iri is not None: iris = iri if isinstance(iri, list) else [iri] diff --git a/src/oold/model/_descriptor.py b/src/oold/model/_descriptor.py index 0b305bf..fab8310 100644 --- a/src/oold/model/_descriptor.py +++ b/src/oold/model/_descriptor.py @@ -935,10 +935,11 @@ def _register_class(cls: type) -> None: _inherited_cls_iris, _put_controller, _put_type, + registers_itself, ) iri = cls.get_cls_iri() if hasattr(cls, "get_cls_iri") else None - if iri is None: + if iri is None or not registers_itself(cls): return is_ctrl = any(b.__module__ == "oold.model" and b.__name__ == "BaseController" for b in cls.__mro__) inherited = frozenset() if is_ctrl else _inherited_cls_iris(cls) diff --git a/src/oold/model/v1/__init__.py b/src/oold/model/v1/__init__.py index c9a8078..cac7c5e 100644 --- a/src/oold/model/v1/__init__.py +++ b/src/oold/model/v1/__init__.py @@ -238,10 +238,15 @@ def __new__(mcs, name, bases, namespace): # Register type IRI mapping. Controllers go to _controller_types # (they extend data models but should not replace them in # the type lookup table). - from oold.model import _inherited_cls_iris, _put_controller, _put_type + from oold.model import ( + _inherited_cls_iris, + _put_controller, + _put_type, + registers_itself, + ) _is_ctrl = any(b.__module__ == "oold.model" and b.__name__ == "BaseController" for b in cls.__mro__) - if hasattr(cls, "get_cls_iri"): + if hasattr(cls, "get_cls_iri") and registers_itself(cls): iri = cls.get_cls_iri() if iri is not None: iris = iri if isinstance(iri, list) else [iri] diff --git a/src/oold/model/v1/_descriptor.py b/src/oold/model/v1/_descriptor.py index 80acb5d..23b7372 100644 --- a/src/oold/model/v1/_descriptor.py +++ b/src/oold/model/v1/_descriptor.py @@ -75,10 +75,15 @@ def _register_class_v1(cls: type) -> None: IRIs it introduces itself - a subclass that merely narrows a field reports its parent's IRI and would otherwise replace it. """ - from oold.model import _inherited_cls_iris, _put_controller, _put_type + from oold.model import ( + _inherited_cls_iris, + _put_controller, + _put_type, + registers_itself, + ) iri = cls.get_cls_iri() if hasattr(cls, "get_cls_iri") else None - if iri is None: + if iri is None or not registers_itself(cls): return is_ctrl = any(b.__name__ == "BaseController" for b in cls.__mro__) inherited = frozenset() if is_ctrl else _inherited_cls_iris(cls) diff --git a/tests/test_registry_precedence.py b/tests/test_registry_precedence.py index 7b90710..901096e 100644 --- a/tests/test_registry_precedence.py +++ b/tests/test_registry_precedence.py @@ -176,3 +176,36 @@ class V1Child(V1Parent): register_type_v1(V1Child) assert _types_v1[v1_iri] is V1Parent + + +# -- opting out of the registries -- + + +def test_a_class_can_opt_out_of_registration(): + """``InMemoryController`` over a local-file model is the case for it.""" + iri = "Category:OSWOptOut" + + class OptOutModel(LinkedBaseModel): + model_config = ConfigDict(json_schema_extra={"title": "OptOutModel"}) + type: list[str] | None = [iri] + + class OptOutController(BaseController, OptOutModel): + __oold_register__ = False + + assert get_registered_controller_types(iri) == [] + assert resolve_type(iri, _types) is OptOutModel + assert OptOutController(OptOutModel()).type == [iri] + + +def test_opting_out_still_allows_an_explicit_registration(): + iri = "Category:OSWOptOutExplicit" + + class ExplicitModel(LinkedBaseModel): + model_config = ConfigDict(json_schema_extra={"title": "ExplicitModel"}) + type: list[str] | None = [iri] + + class ExplicitController(BaseController, ExplicitModel): + __oold_register__ = False + + register_controller_type(ExplicitController, iri) + assert resolve_type(iri, _types) is ExplicitController From 12e979b65e2acfe1a5342c0118df6ef6206108b2 Mon Sep 17 00:00:00 2001 From: SimonTaurus Date: Thu, 15 Oct 2026 08:19:23 +0200 Subject: [PATCH 5/6] feat(auth): expose the registered credentials A caller that has to build a connection it was not handed needs to see which credentials are available, or report the choice as ambiguous. --- src/oold/backend/auth.py | 10 ++++++++++ tests/test_auth.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/oold/backend/auth.py b/src/oold/backend/auth.py index 8b5a38b..5ba6420 100644 --- a/src/oold/backend/auth.py +++ b/src/oold/backend/auth.py @@ -105,6 +105,16 @@ def set_credential(credential: BaseCredential): _credentials.set(creds) +def registered_credentials() -> dict[str, BaseCredential]: + """The credentials registered in this context, keyed by IRI. + + A caller that has to build a connection without being told which one - a + file controller that was handed no session - needs to see what is on offer + before it can pick, or report that the choice is ambiguous. + """ + return dict(_credentials.get()) + + def get_credential(iri: str, exact: bool = False) -> BaseCredential: """Retrieve the credential for the given IRI from the global store. diff --git a/tests/test_auth.py b/tests/test_auth.py index 8ee1058..82612e3 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -16,6 +16,7 @@ dump_credentials, get_credential, load_credentials, + registered_credentials, set_credential, ) @@ -38,6 +39,21 @@ def test_set_credential_with_token(): assert cred.token.get_secret_value() == "tok_abc" +def test_registered_credentials_lists_what_is_on_offer(): + set_credential(TokenCredential(iri="one.example.com", token=SecretStr("a"))) + set_credential(TokenCredential(iri="two.example.com", token=SecretStr("b"))) + registered = registered_credentials() + assert {"one.example.com", "two.example.com"} <= set(registered) + assert registered["one.example.com"].token.get_secret_value() == "a" + + +def test_registered_credentials_is_a_copy(): + set_credential(TokenCredential(iri="three.example.com", token=SecretStr("c"))) + registered = registered_credentials() + registered.clear() + assert get_credential("three.example.com") is not None + + def test_get_credential_missing_raises(): with pytest.raises(ValueError, match="No credentials found"): get_credential("nonexistent.host:9999") From 37c578e7b81f91075e5c6ef2ce9ed993ad0e4dda Mon Sep 17 00:00:00 2001 From: SimonTaurus Date: Thu, 15 Oct 2026 10:21:41 +0200 Subject: [PATCH 6/6] fix: quieter registry logging, and controllers for inline documents - resolve_type logs a multi-controller IRI at debug; the warning belongs to registration, which happens once - a reloaded module re-registers rather than piling up a second copy and reporting it as an override - an unusable IRI next to a usable one no longer breaks registration - an inline linked document resolves to the controller too, falling back to the pure model when the controller cannot be built from it - say that __oold_register__ is inherited, and report an inherited-only registration instead of silently doing nothing - tests for the v1 accessors, the v1 opt-out and re-registration --- docs/how-to/controller.md | 2 + src/oold/model/__init__.py | 54 ++++++++++++------ src/oold/model/_descriptor.py | 58 +++++++++++++++++--- src/oold/model/v1/__init__.py | 6 +- src/oold/static.py | 4 +- tests/test_registry_precedence.py | 91 +++++++++++++++++++++++++++++++ 6 files changed, 187 insertions(+), 28 deletions(-) diff --git a/docs/how-to/controller.md b/docs/how-to/controller.md index c1f766c..8864758 100644 --- a/docs/how-to/controller.md +++ b/docs/how-to/controller.md @@ -100,6 +100,8 @@ class SimulatedRobot(BaseController, Robot): __oold_register__ = False ``` +The flag is inherited, so a subclass of `SimulatedRobot` stays out of the registries too; set `__oold_register__ = True` on it to opt back in. `register_controller_type()` registers an opted-out class regardless - the flag governs the automatic registration on class creation, not an explicit request. + --- ## Multi-model controller diff --git a/src/oold/model/__init__.py b/src/oold/model/__init__.py index 48aecca..b566d46 100644 --- a/src/oold/model/__init__.py +++ b/src/oold/model/__init__.py @@ -128,15 +128,13 @@ def _usable_iris(cls: type, iri) -> list[str]: iri = cls.get_cls_iri() if hasattr(cls, "get_cls_iri") else None if iri is None: raise ValueError(f"{_cls_name(cls)} has no type IRI: pass iri= or define get_cls_iri()") - out: list[str] = [] - for value in sorted(_iri_set(iri)): - if not isinstance(value, str) or not value.strip(): - continue - if value not in out: - out.append(value) - if not out: + # filter before sorting: get_cls_iri() may return the default of a required + # type field, which is PydanticUndefined, and sorting that against a string + # raises - throwing away the usable IRI next to it + usable = {value for value in _iri_set(iri) if isinstance(value, str) and value.strip()} + if not usable: raise ValueError(f"{_cls_name(cls)}: no usable type IRI in {iri!r}") - return out + return sorted(usable) def registers_itself(cls: type) -> bool: @@ -147,6 +145,10 @@ def registers_itself(cls: type) -> bool: in-memory buffer over a local-file model, say: it is asked for by name and constructed from the model, so having it compete for the model's type IRI only makes resolution ambiguous. + + The flag is a plain class attribute, so it is **inherited**: a subclass of + an opted-out class stays out too, unless it sets ``__oold_register__ = + True`` itself. """ return bool(getattr(cls, "__oold_register__", True)) @@ -162,36 +164,48 @@ def _put_type(iri: str, cls: type, registry: dict | None = None) -> None: previous = registry.get(iri) if previous is cls: return - if previous is not None: + if previous is None: + _logger.info("Type %s: %s", iri, _cls_name(cls)) + elif _cls_name(previous) == _cls_name(cls): + # the same class from a reloaded module: not an override, and saying + # "X replaces X" for every class of a regenerated model module is noise + _logger.debug("Type %s: %s re-registered", iri, _cls_name(cls)) + else: _logger.warning( "Type %s: %s replaces %s", iri, _cls_name(cls), _cls_name(previous), ) - else: - _logger.info("Type %s: %s", iri, _cls_name(cls)) registry[iri] = cls def _put_controller(iri: str, cls: type, registry: dict | None = None) -> None: - """Append one controller registration, reporting what it takes over from.""" + """Append one controller registration, reporting what it takes over from. + + A class registered again - re-imported, or defined by a reloaded module - + moves to the front rather than piling up: resolution takes the last entry, + so a stale copy left behind would only ever be dead weight. + """ registry = _controller_types if registry is None else registry entries = registry.setdefault(iri, []) if entries and entries[-1] is cls: return - if cls in entries: - # re-registered under the same IRI: move it to the front of resolution - entries.remove(cls) + name = _cls_name(cls) + superseded = [c for c in entries if c is cls or _cls_name(c) == name] + for stale in superseded: + entries.remove(stale) if entries: _logger.warning( "Controller %s: %s takes precedence over %s", iri, - _cls_name(cls), + name, ", ".join(_cls_name(c) for c in entries), ) + elif superseded: + _logger.debug("Controller %s: %s re-registered", iri, name) else: - _logger.info("Controller %s: %s", iri, _cls_name(cls)) + _logger.info("Controller %s: %s", iri, name) entries.append(cls) @@ -223,7 +237,11 @@ def register_type(cls: type, iri: str | list[str] | None = None) -> None: inherited = frozenset() if explicit else _inherited_cls_iris(cls) for value in iris: if value in inherited: - _logger.debug("%s: not registering inherited type IRI %s", _cls_name(cls), value) + _logger.info( + "%s: %s is inherited, not registering it. Pass iri= to claim it anyway.", + _cls_name(cls), + value, + ) continue _put_type(value, cls) diff --git a/src/oold/model/_descriptor.py b/src/oold/model/_descriptor.py index fab8310..2fb9727 100644 --- a/src/oold/model/_descriptor.py +++ b/src/oold/model/_descriptor.py @@ -40,6 +40,7 @@ class Person(LinkedBaseModel): from __future__ import annotations import contextlib +import logging import os import sys import types @@ -649,6 +650,8 @@ def _is_link_annotation(annotation: Any) -> bool: return False +_logger = logging.getLogger(__name__) + _TYPE_REGISTRY: dict[str, type] = {} """Maps a ``type`` field default (the class IRI) to its model class. @@ -675,17 +678,61 @@ def use_type_registry(registry: dict) -> None: def _resolve_cls(data: dict[str, Any], target: Any) -> Any: - """Pick the most specific class for a document, by its type IRI.""" + """Pick the most specific class for a document, by its type IRI. + + A registered controller wins over the pure model, the same way + ``from_json`` resolves it - otherwise the same document would come back as + a controller when the link carries an IRI and as a plain model when it is + inlined. + """ + from oold.model import _controller_types + type_iri = data.get("type") if isinstance(type_iri, list): type_iri = type_iri[0] if type_iri else None if isinstance(type_iri, str): + controllers = _controller_types.get(type_iri) + if controllers: + return controllers[-1] found = _TYPE_REGISTRY.get(type_iri) if found is not None: return found return target +def _pure_cls(data: dict[str, Any]) -> Any: + """The data model for a document's type IRI, ignoring controllers.""" + type_iri = data.get("type") + if isinstance(type_iri, list): + type_iri = type_iri[0] if type_iri else None + return _TYPE_REGISTRY.get(type_iri) if isinstance(type_iri, str) else None + + +def _construct_resolved(data: dict[str, Any], target: Any) -> Any: + """Build a linked document, falling back to its pure model. + + A controller may declare fields the document does not carry. Resolution + prefers it, so without this a document that used to build as a plain model + would start raising; ``import_json`` makes the same trade. + """ + cls = _resolve_cls(data, target) + if cls is None: + raise ValueError(f"Cannot construct link from {data!r}: unknown target") + try: + return _construct(cls, data) + except Exception: + pure = _pure_cls(data) + if pure is None or pure is cls: + raise + _logger.warning( + "Controller %s could not be built from a linked document " + "(e.g. missing mandatory fields), falling back to %s", + cls.__name__, + pure.__name__, + ) + return _construct(pure, data) + + class LinkResultList(list[T]): """List returned by a to-many link. @@ -896,9 +943,7 @@ def _batch_resolve(refs: list[Ref | None], target: Any) -> list[Any]: # a second request whose result was then built against the declared # target, losing the original error and silently mis-constructing. fetched = resolver.resolve_iris(iris) - nodes = { - iri: (_construct(_resolve_cls(d, target), d) if d is not None else None) for iri, d in fetched.items() - } + nodes = {iri: (_construct_resolved(d, target) if d is not None else None) for iri, d in fetched.items()} for r in group: r._obj = nodes.get(r.iri) return [None if r is None else r._obj for r in refs] @@ -915,10 +960,7 @@ def _to_ref(value: Any, target: Any) -> Ref | None: return Ref(iri=value, target=target) if isinstance(value, dict): # construct through the model so the linked object is validated - cls = _resolve_cls(value, target) - if cls is None: - raise ValueError(f"Cannot construct link from {value!r}: unknown target") - return Ref(obj=_construct(cls, value), target=target) + return Ref(obj=_construct_resolved(value, target), target=target) return Ref(obj=value, target=target) diff --git a/src/oold/model/v1/__init__.py b/src/oold/model/v1/__init__.py index cac7c5e..ae95f27 100644 --- a/src/oold/model/v1/__init__.py +++ b/src/oold/model/v1/__init__.py @@ -140,7 +140,11 @@ def register_type(cls: type, iri=None) -> None: inherited = frozenset() if explicit else _inherited_cls_iris(cls) for value in iris: if value in inherited: - _logger.debug("%s: not registering inherited type IRI %s", cls.__name__, value) + _logger.info( + "%s: %s is inherited, not registering it. Pass iri= to claim it anyway.", + cls.__name__, + value, + ) continue _put_type(value, cls, _types) diff --git a/src/oold/static.py b/src/oold/static.py index 13bd9a7..6db9c00 100644 --- a/src/oold/static.py +++ b/src/oold/static.py @@ -497,8 +497,10 @@ def resolve_type( _controller_types = matching_controller_registry(_types) ctrls = _controller_types.get(type_iri, []) if len(ctrls) > 1: + # debug, not warning: registration already warned once about the + # override, and this runs per resolved document names = [c.__module__ + "." + c.__name__ for c in ctrls] - _logger.warning( + _logger.debug( "Multiple controllers for %s: %s. Using the last registered " "one, %s. To use a specific controller: " "ctrl = ControllerClass(model_instance)", diff --git a/tests/test_registry_precedence.py b/tests/test_registry_precedence.py index 901096e..2de920f 100644 --- a/tests/test_registry_precedence.py +++ b/tests/test_registry_precedence.py @@ -209,3 +209,94 @@ class ExplicitController(BaseController, ExplicitModel): register_controller_type(ExplicitController, iri) assert resolve_type(iri, _types) is ExplicitController + + +# -- the v1 layer answers the same way ---------------------------------------- + + +def _v1_controller(iri): + """A v1 model with a controller on it, registered under ``iri``.""" + from oold.model.v1 import BaseController as BaseControllerV1 + from oold.model.v1 import LinkedBaseModel as LinkedBaseModelV1 + + class Model(LinkedBaseModelV1): + class Config: + schema_extra = {"title": "V1PrecedenceModel"} + + type: list = [iri] + + class First(BaseControllerV1, Model): + pass + + class Second(BaseControllerV1, Model): + pass + + return Model, First, Second + + +def test_v1_accessors_agree_on_the_last_registration(): + from oold.model.v1 import _types as _types_v1 + from oold.model.v1 import find_controller_type as find_v1 + from oold.model.v1 import get_registered_controller_types as registered_v1 + + iri = "Category:OSWV1Precedence" + model, _first, second = _v1_controller(iri) + + assert registered_v1(iri)[0] is second + assert find_v1(iri) is second + assert resolve_type(iri, _types_v1) is second + assert resolve_type(iri, _types_v1, prefer_controller=False) is model + + +def test_v1_class_can_opt_out_of_registration(): + from oold.model.v1 import BaseController as BaseControllerV1 + from oold.model.v1 import LinkedBaseModel as LinkedBaseModelV1 + from oold.model.v1 import _types as _types_v1 + from oold.model.v1 import get_registered_controller_types as registered_v1 + + iri = "Category:OSWV1OptOut" + + class Model(LinkedBaseModelV1): + class Config: + schema_extra = {"title": "V1OptOutModel"} + + type: list = [iri] + + class OptedOut(BaseControllerV1, Model): + __oold_register__ = False + + assert registered_v1(iri) == [] + assert resolve_type(iri, _types_v1) is Model + assert OptedOut(Model()).type == [iri] + + +# -- a reloaded module is not an override ------------------------------------- + + +def test_re_registering_the_same_class_does_not_pile_up(caplog): + """A regenerated model module re-runs every class body.""" + iri = "Category:OSWReloaded" + + def make(): + class ReloadedModel(LinkedBaseModel): + model_config = ConfigDict(json_schema_extra={"title": "ReloadedModel"}) + type: list[str] | None = [iri] + + class ReloadedController(BaseController, ReloadedModel): + pass + + return ReloadedController + + make() + caplog.clear() + with caplog.at_level(logging.WARNING, logger="oold.model"): + make() + assert len(get_registered_controller_types(iri)) == 1 + assert not caplog.records + + +def test_an_unusable_iri_next_to_a_usable_one_does_not_break_registration(): + """get_cls_iri() may return the default of a required field.""" + dyn = type("MixedIriModel", (PrecedenceModel,), {}) + register_type(dyn, ["Category:OSWMixedUsable", object()]) + assert _types["Category:OSWMixedUsable"] is dyn