Repository navigation
Conversation
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
📝 Walkthrough
Merge Risk: 🔵 Low · up to
|
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
- Configuration used: Repository UI
- Review profile: CHILL
- Plan: Advanced
- Run ID:
a1e03a62-8206-467a-838e-3c5ff401e932
📒 Files selected for processing: 6
adr/018-parameterized-generics-as-schemas.mdadr/README.mddocs/src/content/docs/reference/compatibility-matrix.mdsrc/probatio/_compile.pysrc/probatio/dataclass_schema.pytests/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.mdadr/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.mdadr/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)
| # 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) |
There was a problem hiding this comment.
🎯 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)
There was a problem hiding this comment.
🟡 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.
| and (direct := _compile_nested_schema(schema)) is not None | ||
| ): | ||
| return direct | ||
| if get_origin(schema) is not None: |
| # 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) |
| # 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) |

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})raisesexpected str at 'a'. The constructor coercion is gone too, soSchema(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 atypes.GenericAlias, not atype, socompile_nodenever reached theisinstancepath. It is callable, so it compiled as an arbitrary callable and ran as a constructor.int | Noneis not callable and fell through to an equality literal, rejecting everything.typing.List[int]raised a rawTypeErroron every call. voluptuous 0.15.2 behaves the same.probatio already reads these annotations for dataclass, TypedDict, and decorator fields (
_annotation_to_schema).compile_nodenow sends any node with atyping.get_originthrough that same translator and compiles the result, sodict[str, str]validates exactly like{str: str},int | NonelikeMaybe(int), andLiteral["a", "b"]likeIn(["a", "b"]). A generic and its dataclass field twin can no longer disagree.The translator is registered with
_compileat import, the same waySchemais, 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
SchemaErrorwas 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.pyuseslist[MediaType]foralbum_type, but the selector offersAlbumTypevalues (single,compilation,ep). Today the constructor accepts anything; with this change those selections are rejected. Core needslist[AlbumType]before bumping. The ntfy and reolink schemas from the issue become correct as they are.Not in this PR:
to_json_schemaandto_field_listread the raw schema and do not understand a generic. That gap predates this change.Type of change
Additional information
Checklist
uv run --no-sync just testpasses locally. A pull request cannot be merged unless CI is green.uv run --no-sync just lintpasses.uv run --no-sync just typecheckpasses.