Skip to content

feat!: descriptor graph-object binding as the default - #157

Merged
simontaurus merged 45 commits into
mainfrom
experiment/graph-object-binding-107
Sep 15, 2026
Merged

simontaurus merged 45 commits into
mainfrom
experiment/graph-object-binding-107

Conversation

@simontaurus

Copy link
Copy Markdown
Contributor

Replaces the per-attribute-interception binding with one descriptor per link field. oold.model.LinkedBaseModel is now the descriptor model; the previous one stays reachable with OOLD_DESCRIPTOR_BINDING=0, and OOLD_LINKS=0 runs 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.

operation before after
plain field read 18.6x 1.0x
link read (warm) 222.9x 1.2x
plain field write 140.5x 9.5x

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 with python examples/bench_binding_variants.py.

Link notation

New, optional, and the recommended way to declare a link:

father:   Link["Person"]        = OoldField()                # chains without a guard per hop
employer: Link["Organization"]  = OoldField(required=True)   # must be supplied
advisor:  Link["Person | None"] = OoldField()                # may read as None
  • types both directions: reading yields T, writing accepts a T, an IRI or a JSON object
  • x-oold-range is derived from the annotation; passing range= states the target twice
  • required= is the requiredness knob, emitted as x-oold-required-iri and into the schema's standard required array. Requiredness cannot live in the annotation: a self-referential link needs "must supply" and "what do I get on read" to differ

Compatibility

  • LinkedBaseModelMetaClass and _types keep their object identity, because downstream subclasses the one and writes into the other
  • pydantic v1 and v2 both covered
  • get_iri_ref, __iris__ (read and write), to_json, from_json, cast, controllers unchanged

Verification

  • 213 tests pass under both bindings, and per-commit across the parity series together with ruff and ruff format
  • the static contract is asserted with assert_type under both pyright and ty
  • three downstream application suites gave results identical to their baseline with the switch active

Closes #107

- 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
@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Release preview

Merging this PR would release v1.0.0 (current: v0.16.5).

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 binding

Preview via python-semantic-release and conventional commits.

@github-actions

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

Click to see benchmark comparison
📊 Benchmark Comparison (threshold: 1.3x)
============================================================

⚠️  Removed benchmark: test_core[v1]
⚠️  Removed benchmark: test_core[v2]
⚠️  Performance Regressions:
  ❌ test_schema_generation[v2]: 0.0014s → 0.0019s (+36.8%, ratio: 1.37x)

✅ Performance Improvements:
  ✅ test_oneof_subschema: 0.0473s → 0.0319s (-32.6%, ratio: 0.67x)
  ✅ test_enum_docstrings: 0.1122s → 0.0275s (-75.5%, ratio: 0.25x)

➖ Unchanged (within threshold):
  ➖ test_simple_dict_document_store: 0.0008s → 0.0009s (+11.2%)
  ➖ test_sqlite_document_store: 0.0009s → 0.0010s (+20.8%)
  ➖ test_local_sparql_store: 0.0209s → 0.0201s (-3.5%)
  ➖ test_subclass_inheritance: 0.0426s → 0.0371s (-12.8%)
  ➖ test_class_hierarchy: 0.0324s → 0.0366s (+13.0%)
  ➖ test_schema_generation[v1]: 0.0008s → 0.0008s (+2.0%)
  ➖ test_simple_json: 0.0004s → 0.0004s (+1.8%)
  ➖ test_complex_graph: 0.0009s → 0.0009s (+1.8%)

============================================================
Summary: 1 regressions, 2 improvements, 8 unchanged
============================================================

⚠️  Regressions detected but not failing build (informational only)

Threshold: 1.3x (30% slower triggers a regression warning)

Note: Benchmarks are informational only and won't fail the build.

💡 Tip: Download the benchmark-results artifact for detailed JSON data

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.
@github-actions

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

Click to see benchmark comparison
📊 Benchmark Comparison (threshold: 1.3x)
============================================================

⚠️  Performance Regressions:
  ❌ test_schema_generation[v2]: 0.0028s → 0.0037s (+34.3%, ratio: 1.34x)

➖ Unchanged (within threshold):
  ➖ test_simple_dict_document_store: 0.0017s → 0.0019s (+13.1%)
  ➖ test_sqlite_document_store: 0.0018s → 0.0021s (+14.2%)
  ➖ test_local_sparql_store: 0.0383s → 0.0383s (+0.2%)
  ➖ test_oneof_subschema: 0.0587s → 0.0596s (+1.4%)
  ➖ test_enum_docstrings: 0.0494s → 0.0508s (+2.8%)
  ➖ test_subclass_inheritance: 0.0526s → 0.0541s (+3.0%)
  ➖ test_class_hierarchy: 0.0512s → 0.0530s (+3.6%)
  ➖ test_core[v1]: 0.0378s → 0.0353s (-6.6%)
  ➖ test_core[v2]: 0.0432s → 0.0436s (+0.9%)
  ➖ test_schema_generation[v1]: 0.0017s → 0.0016s (-3.3%)
  ➖ test_simple_json: 0.0007s → 0.0007s (+4.6%)
  ➖ test_complex_graph: 0.0016s → 0.0016s (-1.9%)

============================================================
Summary: 1 regressions, 0 improvements, 12 unchanged
============================================================

⚠️  Regressions detected but not failing build (informational only)

Threshold: 1.3x (30% slower triggers a regression warning)

Note: Benchmarks are informational only and won't fail the build.

💡 Tip: Download the benchmark-results artifact for detailed JSON data

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
@github-actions

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

Click to see benchmark comparison
📊 Benchmark Comparison (threshold: 1.3x)
============================================================

⚠️  Performance Regressions:
  ❌ test_schema_generation[v2]: 0.0021s → 0.0028s (+30.4%, ratio: 1.30x)

➖ Unchanged (within threshold):
  ➖ test_simple_dict_document_store: 0.0013s → 0.0015s (+14.3%)
  ➖ test_sqlite_document_store: 0.0014s → 0.0015s (+14.1%)
  ➖ test_local_sparql_store: 0.0274s → 0.0276s (+0.5%)
  ➖ test_oneof_subschema: 0.0421s → 0.0426s (+1.2%)
  ➖ test_enum_docstrings: 0.0377s → 0.0366s (-2.8%)
  ➖ test_subclass_inheritance: 0.0398s → 0.0399s (+0.0%)
  ➖ test_class_hierarchy: 0.0368s → 0.0388s (+5.4%)
  ➖ test_core[v1]: 0.0273s → 0.0258s (-5.6%)
  ➖ test_core[v2]: 0.0313s → 0.0319s (+2.0%)
  ➖ test_schema_generation[v1]: 0.0013s → 0.0013s (-3.4%)
  ➖ test_simple_json: 0.0005s → 0.0005s (-0.9%)
  ➖ test_complex_graph: 0.0011s → 0.0011s (+0.3%)

============================================================
Summary: 1 regressions, 0 improvements, 12 unchanged
============================================================

⚠️  Regressions detected but not failing build (informational only)

Threshold: 1.3x (30% slower triggers a regression warning)

Note: Benchmarks are informational only and won't fail the build.

💡 Tip: Download the benchmark-results artifact for detailed JSON data

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.
@github-actions

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

Click to see benchmark comparison
📊 Benchmark Comparison (threshold: 1.3x)
============================================================

⚠️  Performance Regressions:
  ❌ test_schema_generation[v2]: 0.0028s → 0.0037s (+32.1%, ratio: 1.32x)

➖ Unchanged (within threshold):
  ➖ test_simple_dict_document_store: 0.0017s → 0.0019s (+13.1%)
  ➖ test_sqlite_document_store: 0.0019s → 0.0021s (+12.5%)
  ➖ test_local_sparql_store: 0.0383s → 0.0378s (-1.5%)
  ➖ test_oneof_subschema: 0.0601s → 0.0605s (+0.7%)
  ➖ test_enum_docstrings: 0.0515s → 0.0513s (-0.3%)
  ➖ test_subclass_inheritance: 0.0554s → 0.0544s (-1.9%)
  ➖ test_class_hierarchy: 0.0521s → 0.0520s (-0.2%)
  ➖ test_core[v1]: 0.0389s → 0.0369s (-4.9%)
  ➖ test_core[v2]: 0.0514s → 0.0616s (+19.9%)
  ➖ test_schema_generation[v1]: 0.0017s → 0.0016s (-4.0%)
  ➖ test_simple_json: 0.0007s → 0.0007s (-0.9%)
  ➖ test_complex_graph: 0.0016s → 0.0016s (-0.4%)

============================================================
Summary: 1 regressions, 0 improvements, 12 unchanged
============================================================

⚠️  Regressions detected but not failing build (informational only)

Threshold: 1.3x (30% slower triggers a regression warning)

Note: Benchmarks are informational only and won't fail the build.

💡 Tip: Download the benchmark-results artifact for detailed JSON data

@github-actions

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

Click to see benchmark comparison
📊 Benchmark Comparison (threshold: 1.3x)
============================================================

⚠️  Performance Regressions:
  ❌ test_schema_generation[v2]: 0.0027s → 0.0035s (+30.8%, ratio: 1.31x)

➖ Unchanged (within threshold):
  ➖ test_simple_dict_document_store: 0.0016s → 0.0018s (+11.6%)
  ➖ test_sqlite_document_store: 0.0017s → 0.0020s (+14.7%)
  ➖ test_local_sparql_store: 0.0432s → 0.0392s (-9.3%)
  ➖ test_oneof_subschema: 0.0615s → 0.0583s (-5.2%)
  ➖ test_enum_docstrings: 0.0525s → 0.0676s (+28.6%)
  ➖ test_subclass_inheritance: 0.0540s → 0.0528s (-2.2%)
  ➖ test_class_hierarchy: 0.0524s → 0.0515s (-1.6%)
  ➖ test_core[v1]: 0.0378s → 0.0354s (-6.5%)
  ➖ test_core[v2]: 0.0438s → 0.0424s (-3.3%)
  ➖ test_schema_generation[v1]: 0.0015s → 0.0015s (-5.8%)
  ➖ test_simple_json: 0.0006s → 0.0006s (-0.4%)
  ➖ test_complex_graph: 0.0015s → 0.0015s (-0.6%)

============================================================
Summary: 1 regressions, 0 improvements, 12 unchanged
============================================================

⚠️  Regressions detected but not failing build (informational only)

Threshold: 1.3x (30% slower triggers a regression warning)

Note: Benchmarks are informational only and won't fail the build.

💡 Tip: Download the benchmark-results artifact for detailed JSON data

@simontaurus
simontaurus merged commit f176aed into main Sep 15, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Graph-object binding: attribute access costs 11x-18x vs plain pydantic; descriptor binding is a faster strict superset with no syntax change

1 participant