Skip to content

Run a metadata coercer before a producing Annotated base#215

Merged
frenck merged 2 commits into
mainfrom
frenck/annotated-coercer-ordering
Jul 9, 2026
Merged

Run a metadata coercer before a producing Annotated base#215
frenck merged 2 commits into
mainfrom
frenck/annotated-coercer-ordering

Conversation

@frenck

@frenck frenck commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Breaking change

None in the common case, but it is a deliberate ordering change worth stating. When a dataclass field's Annotated metadata contains a coercer (Coerce or Map, including one wrapped in Maybe or a union) and the base is a producing type (an enum, a nested dataclass, a container), the coercer now runs before the base instead of after it. Metadata that only asserts (like In) and bare callables are unchanged, and a plain-type base was already metadata-first, so no existing schema of those shapes changes. The full suite and the compiled-vs-interpreted parity lane both stay green.

Proposed change

A dataclass field's Annotated base is pinned at position 0 by Python and is also the field's static type, so you cannot place metadata before it. probatio ordered the base relative to its metadata by the base's role: a plain type asserts and runs last (a coercer in the metadata runs first, the type confirms, ADR-008), but a producing base (an enum, a nested schema, a container) runs first. That left no way to reshape a raw value before a producing base coerced it. The concrete case: a Map that folds an enum sentinel ("N.v.t.") to None never ran, because the enum rejected the sentinel first.

This extends the ADR-008 rule the other way: when the metadata itself carries a coercer, the coercer runs first and the producing base confirms the result. Only the coercion markers flip the order; an asserting constraint like In, or a bare callable that cannot be told apart from a plain check, keeps the base-first order and sees the produced value.

So an enum-typed field stays typed as the enum while a Map cleans its sentinels ahead of it:

@dataclass
class Vehicle:
    vehicle_type: Annotated[
        VehicleType | None,
        Key(alias="voertuigsoort"),
        Map({"N.v.t.": None}, default=PASSTHROUGH),
    ] = None

This came out of a real migration (an RDW client moving off mashumaro): a scraped government dataset sends sentinel strings for missing enum values, and the only way to null them per field previously was a hand-written from_dict override or dropping the field to str. With this change the annotation-only form works and keeps the enum type.

Follows the Map passthrough default (#214), which is the other half of the same use case.

Type of change

  • Dependency or tooling upgrade
  • Bugfix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Deprecation (replaces or removes a feature, with a migration path)
  • Breaking change (a fix or feature that changes existing behavior)
  • Code quality, refactor, or test-only change
  • Documentation only

Additional information

Checklist

  • I fully understand the code in this pull request and can explain every line, including any AI-assisted changes.
  • The change is covered by tests, and uv run --no-sync just test passes locally. A pull request cannot be merged unless CI is green.
  • uv run --no-sync just lint passes.
  • uv run --no-sync just typecheck passes.
  • No commented-out or dead code is left in the pull request.

A dataclass field's Annotated base is pinned at position 0 by Python and
is also the field's static type, so metadata cannot be placed before it.
probatio ordered the base relative to its metadata by the base's role: a
plain type asserts and runs last (a coercer in the metadata runs first,
the type confirms, ADR-008), but a producing base (an enum, a nested
schema, a container) runs first. That left no way to reshape a raw value
before a producing base coerced it, so a Map that folds an enum sentinel
to None never ran: the enum rejected the sentinel first.

Extend the ADR-008 rule the other way: when the metadata itself carries a
coercer (a Coerce or Map, including one wrapped in Maybe or a union), the
coercer runs first and the producing base confirms the result. Only the
coercion markers flip the order; an asserting constraint like In, or a
bare callable that cannot be told apart from a plain check, keeps the
base-first order and sees the produced value.

This keeps an enum-typed field typed as the enum while a Map cleans its
sentinels ahead of it, so a from_dict override is no longer needed for
that pattern.
Copilot AI review requested due to automatic review settings July 9, 2026 12:35
@frenck frenck added the new-feature New features or options. label Jul 9, 2026
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@frenck, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 6 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 58133d06-d5d0-437e-a496-455818facaf3

📥 Commits

Reviewing files that changed from the base of the PR and between 9d033e2 and 08e83f7.

📒 Files selected for processing (1)
  • docs/src/content/docs/recipes/cookbook.md
📝 Walkthrough

Walkthrough

This PR changes how Annotated[...] metadata is ordered relative to a producing base schema in dataclass_schema.py, adding a _metadata_coerces helper to detect coercing metadata (Coerce, Map, or wrapped variants) so it runs before base coercion, plus tests and documentation.

Changes

Annotated Metadata Ordering

Layer / File(s) Summary
Coercer detection and ordering logic
src/probatio/dataclass_schema.py
Adds _metadata_coerces to detect Coerce/Map (including wrapped in Maybe/Any/Union) and expands the ordering condition in _annotation_to_schema so coercing metadata runs before a producing base.
Ordering tests
tests/test_dataclass_schema.py
Adds dataclass fixtures and tests confirming Map, Maybe(Coerce(...)), and union-nested coercers run before enum base coercion.
Documentation updates
docs/src/content/docs/guides/dataclasses.md, docs/src/content/docs/recipes/cookbook.md
Clarifies layering semantics, adds a dataclass cookbook example with Map/PASSTHROUGH, and notes that asserting constraints run after the base.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • frenck/probatio#110: Prior change to the same _annotation_to_schema ordering logic and matching test updates for Annotated[...] layering.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: moving metadata coercion before a producing Annotated base.
Description check ✅ Passed The description accurately explains the ordering change, scope, and examples covered by the pull request.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch frenck/annotated-coercer-ordering

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 0ef821d6-164c-4c19-b340-c0d105dfbf8a

📥 Commits

Reviewing files that changed from the base of the PR and between cbe3927 and 9d033e2.

📒 Files selected for processing (4)
  • docs/src/content/docs/guides/dataclasses.md
  • docs/src/content/docs/recipes/cookbook.md
  • src/probatio/dataclass_schema.py
  • tests/test_dataclass_schema.py
📜 Review details
⏰ Context from checks skipped due to timeout. (2)
  • GitHub Check: Fuzz fuzz_from_json_schema
  • GitHub Check: Fuzz fuzz_is_catastrophic
🧰 Additional context used
📓 Path-based instructions (3)
src/probatio/**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

src/probatio/**/*.py: Keep the public API aligned with voluptuous, and do not rename or re-signature public names without a documented reason.
Every public function and class must have a docstring.
Do not copy code from voluptuous; implement behavior cleanly and independently while matching behavior rather than source.

Files:

  • src/probatio/dataclass_schema.py
{src/probatio,tests}/**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

Match the surrounding code style and prefer small, well-named functions.

Files:

  • src/probatio/dataclass_schema.py
  • tests/test_dataclass_schema.py
tests/**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

Every test must have a one-line docstring describing what it verifies.

Files:

  • tests/test_dataclass_schema.py
🔇 Additional comments (9)
src/probatio/dataclass_schema.py (2)

179-204: LGTM!


251-258: LGTM!

tests/test_dataclass_schema.py (4)

543-548: LGTM!


550-554: LGTM!


557-561: LGTM!


522-563: 🎯 Functional Correctness

AnyOf is already Any here; no change needed. tests/test_dataclass_schema.py imports Any as AnyOf, so _metadata_coerces already matches it via Any.

			> Likely an incorrect or invalid review comment.
docs/src/content/docs/guides/dataclasses.md (1)

145-149: LGTM!

docs/src/content/docs/recipes/cookbook.md (2)

168-171: LGTM!


191-194: LGTM!

Comment thread docs/src/content/docs/recipes/cookbook.md

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR extends the Annotated metadata ordering rule in DataclassSchema so that a coercer in the metadata (Coerce or Map, including one wrapped in a Maybe/union) runs before a producing base such as an Enum. This enables preprocessing a raw value (e.g. folding an enum sentinel to None) before the producing base coerces it, while keeping the field typed as the enum.

Changes:

  • Add _metadata_coerces helper and use it in _annotation_to_schema to flip the base/metadata order when the metadata coerces.
  • Add tests covering Map sentinel, Maybe(Coerce(...)), and union-wrapped coercer cases.
  • Document the new behavior in the dataclasses guide and cookbook.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
src/probatio/dataclass_schema.py Introduces _metadata_coerces and flips ordering so a metadata coercer runs before a producing base.
tests/test_dataclass_schema.py Adds dataclasses and tests validating the new coercer-first ordering.
docs/src/content/docs/recipes/cookbook.md Adds a dataclass-field example for the enum-sentinel Map case.
docs/src/content/docs/guides/dataclasses.md Documents that a metadata coercer runs ahead of a producing base.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread docs/src/content/docs/recipes/cookbook.md Outdated
The block used Map, PASSTHROUGH, and VehicleType from an earlier block on
the page. It runs on the docs site (blocks share a namespace) but the
cookbook's promise is that a single block copies and runs on its own, so
import the coercers and define the enum in the block.
Copilot AI review requested due to automatic review settings July 9, 2026 13:28
@codspeed-hq

codspeed-hq Bot commented Jul 9, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 23 untouched benchmarks


Comparing frenck/annotated-coercer-ordering (08e83f7) with main (cbe3927)

Open in CodSpeed

@frenck frenck merged commit 674f16d into main Jul 9, 2026
27 checks passed
@frenck frenck deleted the frenck/annotated-coercer-ordering branch July 9, 2026 13:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

@github-actions github-actions Bot locked as resolved and limited conversation to collaborators Jul 11, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

new-feature New features or options.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants