feat!: descriptor graph-object binding as the default - #157
Conversation
- auto-descriptor binding: unchanged syntax, real objects, batched resolution - notation: OoldField()/link=True, Link[T] in annotations, union arms - descriptor/ref variants kept for the comparison matrix - non-data descriptor + instance-dict cache puts link reads at native speed - scripts: requirement matrix and per-operation benchmarks
- uncomment .vscode/ (editor settings are per-developer) - add node_modules/ for the vue UI sources - add .claude/ alongside CLAUDE.md/AGENTS.md
- range= is now optional; the target is read from the annotation - x-oold-link marks a link field without repeating the schema IRI - support PEP 604 unions (X | None) when extracting the link target - add notation smoke example and regression tests
- box a reference as {"@id": ...} when the field also accepts a literal,
so a bare IRI cannot be re-read as text
- emit an inline value without an IRI as a nested object (blank node)
- keep the bare IRI where no literal arm makes it ambiguous
- add round-trip tests and a de-serialisation section to the smoke example
…nding - compat mixin re-implements the inherited LinkedBaseModel surface - __iris__ is a read/write property; downstream assigns it directly - keep get_iri_ref/get_raw shapes; _raw_dict lists every field like the original - accept a source model as first positional arg (cast shorthand) - document which downstream patterns become obsolete under the new binding - parity tests compare both bindings on a generated-style model
- v1 detects the bare range= kwarg from field_info.extra - target and to-many come from field.type_ / field.shape, no unwrapping - dict()/json() collapse links to IRIs; downstream API kept as in v2 - implement get_cls_iri (the abstract base silently returned None) - v1 parity tests mirror the v2 ones against the shipped v1 model
- downstream subclasses LinkedBaseModelMetaClass (aliased as ModelMetaclass) - swapping only the base class breaks the import with a metaclass conflict - verified against a downstream suite: baseline 2 passed, swapped 2 passed
- downstream imports _types (7 sites) and writes into it - a separate registry makes those entries invisible: polymorphic resolution silently falls back to the declared target - use_type_registry() adopts the existing dict by identity - document how to solve both replacement blockers
- register_type / get_registered_type / registered_types - the absence of a public entry point is why callers write into _types - _types stays the live mapping, so existing callers keep working
- _ref, _compat, _descriptor, _notation move into oold/model - v1 binding moves into oold/model/v1 - drop the standalone Ref[T] binding demo and the rejected Annotated form; Ref stays as the value type the descriptor stores - drop descriptor_binding: superseded, the promoted binding covers both forms - codegen_spike stays in experimental, it belongs to the codegen track - record the remaining monkeypatch removal as an xfail, not a silent gap
Running the shipped suite against the new binding surfaced six gaps, all fixed: - guard the metaclass __getattr__ during class construction: pydantic probes getattr(base, field, None) and mistook the FieldProxy for an inherited default - register controllers into _controller_types and honour inherited IRIs - get_cls_iri keeps a list default intact instead of flattening it - from_json/from_jsonld pass the binding base as root so the fallback works - resolve through Resolver.resolve so the backend's format and type dispatch apply - list mutations sync back to the link storage; support the inline query form The switch also moves the two names downstream depends on: the metaclass it subclasses and the _types mapping it writes into. Suite is identical with the flag on and off.
Link behaviour is opt-out without editing declarations: no descriptors are installed, nothing is routed out of the payload, and serialisation is pydantic's own, so a range field keeps the semantics its annotation states. Useful to tell whether a problem is OO-LD's or the model's, and to run an existing code base as plain pydantic. Cheap because the descriptor design only adds behaviour to link fields; the shipped binding intercepts unconditionally and cannot be switched off this way.
The generated packages emit a v1 variant and the production entity models are v1, so a switch covering only v2 never exercises the path that matters. Wiring it surfaces five gaps in the v1 binding - the same classes already fixed for v2 (list write-back, nested IRI serialisation, from_json root class, RDF export). They fail only with the flag on; the default suite is unchanged.
OoldField() defaults to None so pydantic does not require the field, and that runtime constraint leaked into the declared type: with list[T] | None every subscript is a legitimate error on the None arm. pydantic's dataclass_transform makes the annotation authoritative, so the descriptor's __get__ overloads never get consulted for annotated fields. - unset link fields reach the descriptor again (pydantic writes the default into __dict__, which shadows a non-data descriptor), so a to-many link reads as [] and a non-Optional list annotation is truthful - unset links stay out of payloads rather than serialising as [] - to-many links drop "| None"; to-one keeps it, an absent to-one really is None - narrow union links to a local in the example, as any union requires pyright on the example: 21 errors -> 0.
Resolving a link caches the object in __dict__, and pydantic compares __dict__, so reading an attribute changed the result of a comparison. Present in the shipped binding too, not a regression - links are now compared by their stored references and the remaining fields the normal way. Also keep an explicit empty list distinct from unset: unset contributes nothing and is omitted, [] is a statement and round-trips.
- register $id/x-oold-iri alongside the type default via get_cls_iri() - subtract inherited IRIs so a narrowing subclass cannot replace its parent - route controllers to a separate table - use_type_registry() lets the flag point v1 at oold.model.v1._types
…inding
- hide the descriptor while a class is built so a subclass may redeclare
an inherited link field
- drop the pydantic-level default of a link field: the value is routed to
the descriptor, so a declared T.parse_obj("<iri>") default would only
ever raise
- accept internal= in __setattr__, forwarded by BaseController
dict() leaves UUID and datetime as Python objects, so to_json() went through json() with the model's own encoder instead.
The shipped metaclass types Model[...] through __getitem__ overloads; the descriptor binding did not, so the subscript lost its type. Restores it with overloads on both metaclasses and a generic LinkResultList: - Model["iri"] -> Model | None - Model[cond] -> LinkResultList[Model] | None - indexing and filtering a result list keep the item type Pinned by tests/typing/query_dsl.py via pyright.
Three application suites run with the real OOLD_DESCRIPTOR_BINDING switch, each against a same-state baseline. Notes that the shim missed four binding defects the real switch caught.
A link has two types and one annotation can state only one: reads give a resolved object, writes accept that object or a reference to it. Declaring the field as the descriptor type carries both (PEP 681). - Link[T] / LinkList[T] work as the whole annotation, alongside the existing explicit descriptor form; __set__ is TYPE_CHECKING-only so the descriptor stays non-data and the instance-dict cache is untouched - __get_pydantic_core_schema__ builds the target's schema, so the emitted JSON Schema is unchanged - $ref, arrays, unions, forward refs - LinkResultList is list[T | None]: an IRI the backend cannot answer resolves to None and keeps its slot - ty extra-paths for the src layout, and the typing test points ty at the running interpreter: an environment without pydantic resolves imports to Unknown and makes every assert_type pass vacuously - typing contract checked by both pyright and ty
- send a descriptive user agent: Wikidata answers the SPARQLWrapper default with 429 rate-limiting to 1 req/min - class IRI must be the expanded entity IRI, since it is matched against the incoming @type without prefix expansion - alias type to @type, matching the resolver rewriting wdt:P31 - absolute schema IRIs so the imported parent context resolves - Q80 is Tim Berners-Lee, not Douglas Adams
wdt:P373 is sparse - the grandfather in the chain has none, so name read as None. A language-scoped rdfs:label term compacts to a plain string and every entity carries one. Walk one link further to cover the case.
Link[T] read as T | None unconditionally, so every hop of a chain needed a guard and property chaining collapsed. Optionality is now declared. - Link[T] reads T and the binding keeps that promise; Link[T | None] reads T | None because absence is then part of the model - a mandatory link that is absent is rejected when the object is built, which is knowable without resolving anything - a mandatory reference the backend cannot place raises LinkNotResolved on access; a transport error still propagates - query results drop what they could not place, so the element type holds - every other spelling stays optional, so generated models are unaffected
…to SPARQL Rejecting an absent mandatory link at construction over-enforced: the annotation says what reading the link yields, not that every instance carries one, and graph data is routinely partial. Mandatory links now raise on access, so a chain is written plainly and guarded once. - SparqlResolver / LocalSparqlResolver / WikiDataSparqlResolver gain query(), translating Condition and Query (eq, ne, lt, le, gt, ge, and) to SPARQL - predicate and literal come from expanding a probe document against the model context, so language tags and datatypes follow the term definition - WikiDataSparqlResolver constrains by class: a label matches more than one kind of thing - unsupported operators raise rather than returning the wrong rows - tests assert the SPARQL answer against apply_operator over the same data - wiki_data.py walks the ancestry and runs a Person[Person.name == ...] query
The design docs still described the prototypes as living under experimental/, a construction-time mandatory-link check that had moved to access, and Link[T] as something used inside an annotation rather than as the whole one. The module docstrings a reader meets first called the binding a prototype and its descriptor a data descriptor - it is neither. - notation x requirement matrix, with a second table for the notations that were dropped and why - SPARQL query translation, user agent and rate-limiting documented in the backends how-to - typed link declarations, declared optionality and LinkNotResolved documented in the object-graph-mapping how-to - drop the since-disproved claim that ty cannot type Model[...]
oold.model.LinkedBaseModel is now the descriptor binding. The legacy per-attribute-interception binding is reachable with OOLD_DESCRIPTOR_BINDING=0, and oold.model.LINK_NOTATIONS_ACTIVE reports which is in force. - AutoLinkedModel / AutoLinkedModelV1 removed: one name, LinkedBaseModel - Link, LinkList, LinkNotResolved, LinkResultList, OoldExtra and OoldField are exported from oold.model, so nothing reaches into a private module - _LinkedBaseModelLegacy keeps the parity tests comparing two bindings rather than one against itself - examples import public names only - docs updated for the flipped default
The flip made oold.model.LinkedBaseModel the descriptor binding, so the 'shipped' rows silently measured it again - reporting the legacy plain read as 0.8x instead of 16.3x. The legacy rows now name _LinkedBaseModelLegacy explicitly.
A review found behaviour the descriptor binding does not yet reproduce: BaseController.to_json() strips every data field, FieldProxy lost truthiness and default forwarding, x-oold-required-iri is enforced nowhere, __iris__ assignment merges instead of replacing, get_raw answers [None], and an inline linked object without an IRI is dropped. The parity claim that justified making it the default does not hold, so it is opt-in again. - test_binding_switch compared __name__, which both bindings share since AutoLinkedModel was dropped, so neither switch test could fail; it now compares __module__ - the registry assertion was registered_types() is _types, i.e. 'return _types' against itself; it now checks that the binding shares oold.model._types, and only where that is meaningful - examples opt in explicitly, since Link[T] needs the binding
Seven regressions found by review, each now covered by a parity test that fails without its fix (verified by reverting each in turn).
- BaseController.to_json() stripped every data field: the data-model detection accepted LinkedApiMixin, which answers to_json/from_json but declares none. It now requires a class to carry fields.
- FieldProxy lost __bool__ and default forwarding, so downstream 'if Model.field:' was always true and 'Model.field.startswith(...)' raised. It carries the default again.
- x-oold-required-iri was enforced nowhere; v1 spells it with underscores, so both spellings are read. Enforced on a true value rather than on key presence, the one deliberate difference from the legacy check.
- __iris__ assignment merged instead of replacing, so '= {}' did nothing; non-link keys were discarded.
- get_raw answered [None] for an unresolved to-many link by filtering the Ref rather than the object.
- model_dump dropped unset link keys entirely, and an explicit empty list was indistinguishable from unset.
- an inline linked object with no IRI vanished from _raw_dict, and so from cast().
…lost Four confirmed findings from review, each with a test that fails without its fix. - Ref.__getattr__ delegated dunder lookups to resolve(), so copy.deepcopy asked for __deepcopy__, got the target back, and replaced every Ref with a copy of the object it pointed at - after which link_iris/to_json raised. hasattr() also performed I/O. Dunders now raise AttributeError. - _batch_resolve caught every exception and retried through resolve_iris, rebuilding the raw document against the declared target. That masked the original error and cannot work for a JSON-LD backend. A union target is not a class, so passing it as model_cls always failed validation - meaning union links resolved only through this error path, i.e. were broken on every graph backend. The root model is passed instead, and the fallback is narrowed to NotImplementedError. - _notation.OoldModel was a bare BaseModel, so it was not a valid model_cls either and resolved the same way; oold_query was a stub returning a tuple, which its own test asserted against. It now subclasses LinkedBaseModel, which also drops four byte-identical copies. - LinkResultList._sync rebuilt storage from resolved values, deleting references that had not resolved, and only append/remove/extend synced at all. Unresolved slots are written back as references, and every mutating operation syncs. - removes a stray debug print in document_store that corrupted subprocess output
- _neutralise_link_defaults mutated the FieldInfo in place, so a Field() shared between models lost its default process-wide, including for plain BaseModels. It copies first. - a Field() in Annotated metadata was never seen, so its default survived and was evaluated on every construction - the failure the function exists to prevent, reachable by a spelling the tests did not use. - __eq__ ignored __pydantic_extra__ and __pydantic_private__, so models differing only in extras compared equal. - __hash__ = id(self) made models hashable, unlike the legacy binding and plain pydantic; set(models) deduplicated by identity instead of raising. - v1 dict() serialised the resolved values cached by a read, so its output depended on whether a link had been touched. - get_cls_iri appended a list type default as one element, which _register_class then skipped: a class with a type array was registered under its $id only and unreachable by type. Flattened, as v1 and _iri_set already do; the serialised array is unchanged.
- link fields ignored aliases in both directions: a by_alias payload could not be read back (the alias was left for pydantic to validate against the target model) and by_alias output wrote the link under its field name, mixing both spellings in one payload. The serializer now takes SerializationInfo, which is the only way to know - the key is never in the dict to compare against, since link values are routed out of __dict__. - OoldField(default_factory=...) raised: the forced default collided with it. - _notation registered the type default directly into the shared registry, without the inherited-IRI guard, so a subclass that only narrows a field replaced its parent. It goes through _register_class like everything else.
Second attempt. The first rested on three downstream suites passing, which was necessary and not sufficient - a review found seven behaviours the binding did not reproduce, none of them on a path any suite reached. Each is now fixed and covered by a test in tests/test_compat_parity.py that fails without its fix, verified by reverting each in turn. Re-checked downstream with the fixes in place: the dashboard suite (19 passed) and the utilities suite (194 passed, 9 pre-existing failures) are identical to their baselines, and the live controller-tree load - real BaseController usage against a live backend, the path that broke - passes under both bindings. OOLD_DESCRIPTOR_BINDING=0 restores the legacy binding, and oold.model.LINK_NOTATIONS_ACTIVE reports which is in force.
No behaviour change. Dead: - LinkMarker, unreachable since Link[T] became a class rather than Annotated[X, LinkMarker()] - both isinstance checks could never be true - _link_cache PrivateAttr, declared but never read or written - LinkedQueryMetaV1, an alias nothing imports - _ref._strip_optional, referenced only by its own definition - bench_attribute_access's descriptor variant, importing a module that no longer exists, so it always printed FAILED Duplicated: - _notation.OoldField was byte-identical in behaviour to the binding's (verified across all four argument shapes); it is re-exported instead - __eq__, __hash__, get_iri and link_iris in _notation were left over from before OoldModel subclassed LinkedBaseModel - _to_ref_v1 was a copy of _to_ref touching no pydantic-version-specific API - WikiDataSparqlResolver now inherits SparqlResolver, differing only in two hooks: the P31 class constraint and the P31 -> @type rewrite. The CONSTRUCT was triplicated across all three resolvers and is now one function. - an autouse conftest fixture restores the resolver registry after every test, replacing per-module save/restore in two files and closing the same leak in three that never had it
The v1 binding re-implemented all of LinkedApiMixin: nine members at 0.97 to 1.00 similarity, differing only in model_fields vs __fields__ and model_dump vs dict. Those two differences are now hooks - _fields() and _dump() - so v1 inherits get_iri, get_iri_ref, get_raw, link_iris, cast, cast_none_to_default, store_jsonld and _raw_dict instead of carrying copies. get_iri and link_iris were in neither shared place: v1 and v2 each had their own. Both now live on the mixin. v1 keeps what genuinely differs: get_cls_iri (Config.schema_extra rather than model_config), dict/json/to_json, and from_json/from_jsonld, which use the separate v1 registry. This is the fork that produced the get_cls_iri divergence fixed in 8e9c9c2 - one version was corrected and the other was not, because they were copies.
…sions _AutoLinkV1 was a copy of _AutoLink: __get__, set_value, iris and the comparison operators measured 0.75 to 1.00 similarity, and the only real difference is how the target is reached - pydantic v1 resolves it eagerly into field.type_, so there is nothing to look up later. It now inherits and overrides _target_cls alone. That required the two _constructing flags to become one. They had to be: the descriptor is now shared, so its __get__ consults a single guard, and keeping one flag per metaclass would have meant v1 class construction setting one while the guard read the other. The guard is load-bearing in both versions - pydantic probes the bases for same-named attributes while building a class, and an installed descriptor is exactly such an attribute - and test_downstream_shapes covers it for v1 and v2.
- name the legacy v2 base and metaclass `_LinkedBaseModelLegacy` / `_LinkedBaseModelMetaClassLegacy`, so the exported `LinkedBaseModel` has one declaration: pyright keeps the first of two and ignored the swap - bind the exported names under TYPE_CHECKING to the descriptor binding, which is the default; the legacy branch binds them at runtime - point tests/typing at `oold.model` instead of the private module, so the asserted contract is the one consumers get - check `examples` with ty; the exclusion hid a broken import - backend_auth: `SetCredentialParam` was dropped in 34db71b Before, `Model[Model.field == x]` read as `Model | LinkedBaseModelList[Model]` for anyone importing from `oold.model`.
- v1: name the legacy base and metaclass `_LinkedBaseModelLegacy` / `_LinkedBaseModelMetaClassLegacy` and bind the exported names under TYPE_CHECKING, as `oold.model` already does - state `Link[T]` / `LinkList[T]` with `OoldField(range=...)` as the recommended declaration in the README, the how-to and the design doc, and that every other notation keeps working - document the `OoldField` arguments and what each emits into the schema - correct the read type in the `Link` / `LinkList` docstrings: `Link[T]` reads as `T`, `LinkList[T]` as `LinkResultList[T]` - `Optional[Link[T]]` narrows on read since 91e136f but still rejects an IRI on write, so the dropped-notation entry is split from `list[Link[T]]`
Presence of x-oold-range is what makes a property a link, so the recommended declaration emitted a schema that did not round-trip: the only way to get the keyword was to repeat the target in OoldField(range=...), which lets the annotation and the schema disagree. - derive it in __get_pydantic_json_schema__ from the target's get_cls_iri(), and drop the x-oold-link stand-in once it is there - an explicit range= is never overwritten, and a target that cannot name itself contributes nothing - done at schema-generation time: a forward reference is unresolvable when the descriptor is installed, and a shared Field() must not be mutated in place - docs: recommend a bare OoldField(); range= and the legacy Field(range=...) stay supported but unadvertised - docs: x-oold-required-iri is enforced at construction, not on the IRI of an inline object as previously written
Requiredness and the read type are different questions, so they get different carriers. A self-referential link needs them to differ: father: Link["Person"] must read as a Person so a walk needs no guard per hop, while no dataset can require every person to name a father. - add required=; required_iri= stays as the deprecated spelling - emit into the standard `required` array as well as x-oold-required-iri, so a plain JSON Schema validator sees it - a link annotation with no default means required, as it does elsewhere in Python; it previously failed with a misleading pydantic "Field required" about a value that had been supplied - add a _set_link hook: OoldModel set its links after super().__init__(), so the required check ran before them and reported every required link of a notation model as missing - drop range= from wiki_data and the docs; it repeats the annotation
Release previewMerging this PR would release v1.0.0 (current: Changelog preview (truncated)## v1.0.0 (2026-09-15)
### Bug Fixes
- Aliases, default_factory and registration in the notation module
([`4eda165`](https://github.com/OO-LD/oold-python/commit/4eda16566213170fc15324dc80326607cbff17bb))
- Equality no longer depends on whether a link was resolved
([`baea692`](https://github.com/OO-LD/oold-python/commit/baea692cee047d9909eb83ab7056ab84d78dc114))
- Link annotations no longer force a None arm on every dereference
([`09d1c18`](https://github.com/OO-LD/oold-python/commit/09d1c188f0bf011701140b57b21e032f94cde3c8))
- Preserve declared default IRIs and repair link serialisation
([`fac1ef7`](https://github.com/OO-LD/oold-python/commit/fac1ef71a3d553059dcb0a6ae99d59b5db6faca6))
- Reach the annotate function through annotationlib
([`3f36c92`](https://github.com/OO-LD/oold-python/commit/3f36c92855671f02524e7d865a764ec1f080b3ae))
- Read class annotations under PEP 649 deferred evaluation
([`cbc1c98`](https://github.com/OO-LD/oold-python/commit/cbc1c985528e061e7b0bbe959fe1c134daefc2ca))
- Restore legacy behaviour the descriptor binding did not reproduce
([`6fb1076`](https://github.com/OO-LD/oold-python/commit/6fb10768d2258600f32d07a86bbf4df3fe94ee33))
- Shared Field reuse, Annotated defaults, equality and type arrays
([`38d81b0`](https://github.com/OO-LD/oold-python/commit/38d81b05744c96a40cf954afdfdcacc744285f1a))
- Stop resolution failures being hidden, and link mutations being lost
([`91e136f`](https://github.com/OO-LD/oold-python/commit/91e136f57597fa496227435c673ccc77ae4f7b8b))
- Support generated-package declaration shapes in the descriptor binding
([`d8dbac9`](https://github.com/OO-LD/oold-python/commit/d8dbac9ca0a5b14c26ba58c23fba040d83e7e93f))
- **examples**: Benchmark the legacy binding, not the new one twice
([`1e5721c`](https://github.com/OO-LD/oold-python/commit/1e5721ceb64aef5a12cb32829bd6a22f8948e009))
- **examples**: Make wiki_data.py run against the live endpoint
([`8a668a4`](https://github.com/OO-LD/oold-python/commit/8a668a4cd07ba99b73c63b2f07828454c4651cb8))
- **examples**: Read name from rdfs:label, not the Commons category
([`14a8fd5`](https://github.com/OO-LD/oold-python/commit/14a8fd59e5d94b8549966ae6d957598bb01604db))
- **experimental**: Lossless de-serialisation of union link arms
([`ce8eb21`](https://github.com/OO-LD/oold-python/commit/ce8eb217d4f82db57420b770523b57917cbcfc2c))
- **typing**: Make the binding swap visible to a type checker
([`80e8e99`](https://github.com/OO-LD/oold-python/commit/80e8e99c4246ec7f94566e2b1bf3ccb6ffb5adaf))
- **v1**: Encode non-JSON types in to_json
([`d96476c`](https://github.com/OO-LD/oold-python/commit/d96476cbf6139a38c2e44cb301da2a2f83cf3413))
- **v1**: Register full class IRI set and share the type registry
([`9cb6f5f`](https://github.com/OO-LD/oold-python/commit/9cb6f5f9672fda0c85385bf5e3d9460c99895e50))
### Chores
- Ignore .vscode, node_modules and .claude
([`221d88d`](https://github.com/OO-LD/oold-python/commit/221d88d3bfdcf8af746acfd128be171a7e5f9d71))
- Tell deptry annotationlib is stdlib from 3.14
([`2458473`](https://github.com/OO-LD/oold-python/commit/24584736e3b56ea3c14435ad608f4f5c8be0c335))
### Documentation
- Correct stale claims and tabulate notation support
([`0abd4c4`](https://github.com/OO-LD/oold-python/commit/0abd4c43ea4f14039fd098e3aaff338cd8bf8047))
- Name the recommended link notation, and mirror the v1 binding swap
([`df13e87`](https://github.com/OO-LD/oold-python/commit/df13e87c44cc978ba4486160df2c640b09faaa90))
- Record the downstream verification results
([`100f694`](https://github.com/OO-LD/oold-python/commit/100f6949898d7c7fb09815835475e0f45a4ca90c))
- Record the metaclass identity requirement for the replacement
([`c7af84c`](https://github.com/OO-LD/oold-python/commit/c7af84c6d289f507d320d5858577f41d437b50d4))
### Features
- Carry the typed query subscription into the descriptor bindingPreview via python-semantic-release and conventional commits. |
📊 Benchmark ResultsClick to see benchmark comparisonThreshold: 1.3x (30% slower triggers a regression warning) Note: Benchmarks are informational only and won't fail the build. 💡 Tip: Download the |
Three regressions that no local run could show: the tests covering them take the `benchmark` fixture, so without pytest-benchmark they error out instead of failing, and an error reads as environmental. - a declared default IRI was dropped with the pydantic-level default it was buried in. The IRI is recorded in __link_defaults__ and seeded on construction, so the link resolves lazily instead of being lost or fetched on every construction. Same for v1 - `m.one = None` and "never set" are the same statement; serialising distinguished them and left an explicit null behind after a clear - reading a partly unresolvable to-many link caches a None among the objects, which pydantic's serializer cannot render as list[T]. The link keys are replaced anyway, so the read cache is hidden from the handler: to_json() died with "NoneType has no attribute model_fields" - add a benchmark fallback fixture so those tests run without the plugin - docs: fenced code blocks, the RST `::` form broke the docs build Adds three focused regression tests that do not depend on the fixture.
📊 Benchmark ResultsClick to see benchmark comparisonThreshold: 1.3x (30% slower triggers a regression warning) Note: Benchmarks are informational only and won't fail the build. 💡 Tip: Download the |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
On Python 3.14 a class namespace carries __annotate__ instead of an
__annotations__ dict, so the link-default pass iterated nothing: no link
default was stripped, and the generated T.model_validate("<iri>")
default was evaluated on every construction - the fetch that stripping
exists to prevent. Only 3.14 failed, on every platform.
- recover the annotations from __annotate__ in FORWARDREF format, since
a link names its target by string more often than not and VALUE raises
on the unresolved name
- materialise __annotations__ before rewriting Annotated metadata, which
under PEP 649 is not there to write into
📊 Benchmark ResultsClick to see benchmark comparisonThreshold: 1.3x (30% slower triggers a regression warning) Note: Benchmarks are informational only and won't fail the build. 💡 Tip: Download the |
The namespace key is spelled __annotate__ early in 3.14 and __annotate_func__ later, so reading it directly worked on the local alpha and not on the version CI runs. annotationlib's accessor hides the difference, which is what pydantic itself uses. Gated on sys.version_info rather than caught as ImportError, so the module still resolves when checked against the declared 3.10 floor.
📊 Benchmark ResultsClick to see benchmark comparisonThreshold: 1.3x (30% slower triggers a regression warning) Note: Benchmarks are informational only and won't fail the build. 💡 Tip: Download the |
📊 Benchmark ResultsClick to see benchmark comparisonThreshold: 1.3x (30% slower triggers a regression warning) Note: Benchmarks are informational only and won't fail the build. 💡 Tip: Download the |
Replaces the per-attribute-interception binding with one descriptor per link field.
oold.model.LinkedBaseModelis now the descriptor model; the previous one stays reachable withOOLD_DESCRIPTOR_BINDING=0, andOOLD_LINKS=0runs models as plain pydantic.Declaration syntax is unchanged, so generated packages keep working untouched.
Performance
100,000 ops, best of 5, isolated processes.
x= relative to a plain pydantic v2 field read.Reads stay lazy, resolve in batch (a list of IRIs is one backend call) and cache into the instance
__dict__, which is what makes a warm link read cost a dict lookup. Reproduce withpython examples/bench_binding_variants.py.Link notation
New, optional, and the recommended way to declare a link:
T, writing accepts aT, an IRI or a JSON objectx-oold-rangeis derived from the annotation; passingrange=states the target twicerequired=is the requiredness knob, emitted asx-oold-required-iriand into the schema's standardrequiredarray. Requiredness cannot live in the annotation: a self-referential link needs "must supply" and "what do I get on read" to differCompatibility
LinkedBaseModelMetaClassand_typeskeep their object identity, because downstream subclasses the one and writes into the otherget_iri_ref,__iris__(read and write),to_json,from_json,cast, controllers unchangedVerification
assert_typeunder both pyright and tyCloses #107