Skip to content

Validate the type arguments of a parameterized generic schema - #479

Open
frenck wants to merge 1 commit into
mainfrom
frenck/translate-generic-alias-schemas
Open

frenck wants to merge 1 commit into
mainfrom
frenck/translate-generic-alias-schemas

Conversation

@frenck

@frenck frenck commented Oct 16, 2026

Copy link
Copy Markdown
Owner

Breaking change

A parameterized generic used as a schema now validates its type arguments. A value that does not match them was accepted before and is now rejected: Schema(dict[str, str])({"a": 1}) raises expected str at 'a'. The constructor coercion is gone too, so Schema(list[str])("abc") no longer returns ["a", "b", "c"], and a list of pairs no longer becomes a dict. A schema that wanted that conversion has to ask for it explicitly (Coerce(list)).

list[SomeEnum] now returns enum members instead of the raw values, like a bare enum schema does.

No schema can meaningfully depend on the type arguments being ignored, so this only breaks schemas that were not validating what they claimed to.

Proposed change

dict[str, str] is a types.GenericAlias, not a type, so compile_node never reached the isinstance path. It is callable, so it compiled as an arbitrary callable and ran as a constructor. int | None is not callable and fell through to an equality literal, rejecting everything. typing.List[int] raised a raw TypeError on every call. voluptuous 0.15.2 behaves the same.

probatio already reads these annotations for dataclass, TypedDict, and decorator fields (_annotation_to_schema). compile_node now sends any node with a typing.get_origin through that same translator and compiles the result, so dict[str, str] validates exactly like {str: str}, int | None like Maybe(int), and Literal["a", "b"] like In(["a", "b"]). A generic and its dataclass field twin can no longer disagree.

The translator is registered with _compile at import, the same way Schema is, so there is no import cycle. The compiled engine needs no change: codegen does not inline a generic, so it calls the interpreted closure.

Rejecting a generic with SchemaError was considered and turned down: it turns every schema written this way into an import time crash on upgrade, while the intent of the schema is unambiguous. The reasoning is in ADR-018, and the deviation is listed in the compatibility matrix.

Verified with a differential grid of 5544 cases (22 generics × 21 values × bare, required, optional, and list nesting × 3 extra policies), interpreted against compiled: no divergence, no crashes.

Heads up for Home Assistant: music_assistant/services.py uses list[MediaType] for album_type, but the selector offers AlbumType values (single, compilation, ep). Today the constructor accepts anything; with this change those selections are rejected. Core needs list[AlbumType] before bumping. The ntfy and reolink schemas from the issue become correct as they are.

Not in this PR: to_json_schema and to_field_list read the raw schema and do not understand a generic. That gap predates this change.

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 parameterized generic used as a schema (dict[str, str], list[int]) is not
a type, so it compiled as a callable and ran as a constructor. The type
arguments were ignored and the input was coerced: list[int]("abc") returned
["a", "b", "c"]. int | None compiled as an equality literal and rejected
everything.

Translate any node with a typing origin through the annotation translator
the dataclass builder already uses, so dict[str, str] validates exactly
like {str: str}. A deliberate deviation from voluptuous, recorded in
ADR-018.

Fixes #469
Copilot AI balanced review requested due to automatic review settings October 16, 2026 19:29
@frenck frenck added the breaking-change A breaking change for existing users. label Oct 16, 2026
@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

📝 Walkthrough

Parameterized generic aliases now compile through the annotation translator into structural schemas. Tests cover validation of type arguments, nested schemas, annotation behavior, and consistency between interpreted and compiled validation. The compatibility documentation and ADR index describe the change.

Changes: Parameterized Generic Schemas

Layer / File(s) Summary
Generic schema translation and validation
src/probatio/_compile.py, src/probatio/dataclass_schema.py, tests/test_generic_alias.py, adr/018-parameterized-generics-as-schemas.md, adr/README.md, docs/src/content/docs/reference/compatibility-matrix.md
compile_node translates schemas with a typing origin through the registered annotation translator. Tests cover generic validation and annotation behavior. The ADR and compatibility matrix document parameterized generic schemas; the matrix also clarifies the float matcher behavior.

Priority: ➖ Normal

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

Severity of issue fixed: Medium

Merge Risk: 🔵 Low · up to 32273

Nonempty tuples can pass a schema intended to accept only an empty tuple. This is a narrow validation gap; fixing it before merge is advisable.

Pre-merge checks: Passed 5
Check name Status Explanation
Description check Passed The description clearly explains the generic-schema validation change, its breaking behavior, affected examples, testing, and known limitations.
Title check Passed The title is concise and accurately states the main change: parameterized generic schemas now validate their type arguments.
Linked Issues check Passed Issue #469 requires parameterized generics to validate their type arguments or fail with SchemaError. The PR implements validation. compile_node detects nodes with typing.get_origin and sends them…
Out of Scope Changes check Passed The changes stay connected to issue #469. The compiler and annotation-translator changes implement generic validation. The generic-alias tests verify the required behavior. ADR-018 and the compatibili…
Docstring Coverage Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 13 functions across 3 files. (3 skipped: 3…
Full details: Linked Issues check

Explanation

Issue #469 requires parameterized generics to validate their type arguments or fail with SchemaError. The PR implements validation. compile_node detects nodes with typing.get_origin and sends them through _annotation_to_schema. This maps dict[str, str] to mapping validation, list[int] to element validation, and nested generics to nested schemas. Tests cover matching values, mismatches and paths, structural equivalence, enum items, and compiled versus interpreted execution. The implementation satisfies the issue without requiring changes to the external Home Assistant schemas.

Full details: Out of Scope Changes check

Explanation

The changes stay connected to issue #469. The compiler and annotation-translator changes implement generic validation. The generic-alias tests verify the required behavior. ADR-018 and the compatibility matrix document the deliberate behavior change. The supplied evidence shows no unrelated product change.

Full details: Docstring Coverage

Explanation

Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 13 functions across 3 files. (3 skipped: 3 unsupported.)

✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
✨ Simplify code
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

@codspeed

codspeed Bot commented Oct 16, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 23 untouched benchmarks


Comparing frenck/translate-generic-alias-schemas (3227331) with main (54bca57)

Open in CodSpeed

@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: Advanced
  • Run ID: a1e03a62-8206-467a-838e-3c5ff401e932
📥 Commits

Reviewing files that changed from the base of the PR and between 54bca57 and 3227331.

📒 Files selected for processing: 6
  • adr/018-parameterized-generics-as-schemas.md
  • adr/README.md
  • docs/src/content/docs/reference/compatibility-matrix.md
  • src/probatio/_compile.py
  • src/probatio/dataclass_schema.py
  • tests/test_generic_alias.py

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

📜 Review details
⏰ Context from checks skipped due to timeout: 7
  • GitHub Check: copilot-pull-request-reviewer
  • GitHub Check: Fuzz fuzz_from_json_schema
  • GitHub Check: Fuzz fuzz_schema
  • GitHub Check: Fuzz fuzz_is_catastrophic
  • GitHub Check: Python 3.12
  • GitHub Check: Python 3.13
  • GitHub Check: Analyze python
🧰 Additional context used
📚 Code guidelines: 2
AGENTS.md — unknown
adr/README.md — unknown
📓 Path-based instructions: 2
Source excerpt: This project is public and read closely.

📄 CodeRabbit inference engine (AGENTS.md)

Files:

  • docs/src/content/docs/reference/compatibility-matrix.md
  • adr/README.md
Source excerpt: Each record captures what was chosen, the alternatives considered, and why.

📄 CodeRabbit inference engine (adr/README.md)

Files:

  • adr/018-parameterized-generics-as-schemas.md
  • adr/README.md
🪛 LanguageTool
adr/018-parameterized-generics-as-schemas.md

[style] ~14-~14: Three successive sentences begin with the same word. Consider rewording the sentence or use a thesaurus to find a synonym.
Context: ... Schema(dict[str, str])([("a", "b")]) returned {"a": "b"}. - Schema(int | None) is...

(ENGLISH_WORD_REPEAT_BEGINNING_RULE)


[style] ~21-~21: ‘by mistake’ might be wordy. Consider a shorter alternative.
Context: ...Assistant service schemas relying on it by mistake, one of them (list[str] for a device ...

(EN_WORDINESS_PREMIUM_BY_MISTAKE)

Comment thread src/probatio/_compile.py
# Translate it the way a dataclass field annotation is translated, so
# ``dict[str, str]`` validates exactly like ``{str: str}`` (a deliberate
# deviation from voluptuous, ADR-018).
return compile_node(_ANNOTATION_TO_SCHEMA(schema, {}, ctx.extra), ctx)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve the empty-tuple constraint during translation.

If the schema is tuple[()], the new dispatch reaches src/probatio/dataclass_schema.py Lines 283-291. get_args returns (), so the translator returns bare tuple. As a result, Schema(tuple[()])((1,)) accepts a nonempty tuple, although tuple[()] denotes only an empty tuple. Handle this alias before the no-arguments fallback, and test both validation engines. (docs.python.org)

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.

🟡 Changes recommended

Unresolved validation gaps affect union mapping keys, combinator extra policies, and forward references.

3 open findings
What changed in this PR

This PR addresses #469 by routing generic schemas through Probatio’s annotation translator, replacing constructor coercion with type-argument validation.

Changes:

  • Integrates annotation translation into schema compilation.
  • Adds generic validation and compiled-engine parity tests.
  • Documents the breaking compatibility change and its rationale.
File Description
tests/​test_generic_alias.py Tests generic validation, nesting, and engine parity.
src/​probatio/​dataclass_schema.py Registers the annotation translator.
src/​probatio/​_compile.py Translates generic schemas before compilation.
docs/​src/​content/​docs/​reference/​compatibility-matrix.md Documents the compatibility deviation.
adr/​README.md Links ADR-018.
adr/​018-parameterized-generics-as-schemas.md Explains the decision and migration implications.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/probatio/_compile.py
and (direct := _compile_nested_schema(schema)) is not None
):
return direct
if get_origin(schema) is not None:
Comment thread src/probatio/_compile.py
# Translate it the way a dataclass field annotation is translated, so
# ``dict[str, str]`` validates exactly like ``{str: str}`` (a deliberate
# deviation from voluptuous, ADR-018).
return compile_node(_ANNOTATION_TO_SCHEMA(schema, {}, ctx.extra), ctx)
Comment thread src/probatio/_compile.py
# Translate it the way a dataclass field annotation is translated, so
# ``dict[str, str]`` validates exactly like ``{str: str}`` (a deliberate
# deviation from voluptuous, ADR-018).
return compile_node(_ANNOTATION_TO_SCHEMA(schema, {}, ctx.extra), ctx)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change A breaking change for existing users.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parameterized generics such as dict[str, str] ignore their type arguments

2 participants