-
Notifications
You must be signed in to change notification settings - Fork 22
fix: resolve parameter type names case-insensitively in reference validation #372
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -260,6 +260,10 @@ def _validate_model_template_variable_references( | |
| # The errors that we're collecting for this node in the traversal, and will return from the function call. | ||
| errors: list[InitErrorDetails] = [] | ||
|
|
||
| expr_enabled = bool( | ||
| context is not None and getattr(context, "_prevalidation_expr_enabled", False) | ||
| ) | ||
|
|
||
| model_origin = typing.get_origin(model) | ||
|
|
||
| # Unwrap the Optional types | ||
|
|
@@ -345,7 +349,9 @@ def _validate_model_template_variable_references( | |
|
|
||
| # Unwrap a discriminated union to the selected type | ||
| if model_origin is Union and discriminator is not None: | ||
| unioned_model = _get_model_for_singleton_value(model, value, discriminator) | ||
| unioned_model = _get_model_for_singleton_value( | ||
| model, value, discriminator, expr_enabled=expr_enabled | ||
| ) | ||
| if unioned_model is not None: | ||
| return _validate_model_template_variable_references( | ||
| unioned_model, | ||
|
|
@@ -398,10 +404,6 @@ def _validate_model_template_variable_references( | |
| # If the node doesn't modify the variable prefix, then symbol_prefix will be the empty string | ||
| symbol_prefix += variable_defs.symbol_prefix | ||
|
|
||
| expr_enabled = bool( | ||
| context is not None and getattr(context, "_prevalidation_expr_enabled", False) | ||
| ) | ||
|
|
||
| # Recursively collect all of the variable definitions at this node and its | ||
| # child nodes. Symbol EXPR types are collected only when the EXPR | ||
| # extension is active, keeping the Rust expr surface off the non-EXPR | ||
|
|
@@ -709,8 +711,17 @@ def _validate_let_bindings( | |
| return errors | ||
|
|
||
|
|
||
| # Parameter type names are ASCII (RFC 0007 §2). str.upper() is Unicode-aware | ||
| # and folds U+0131 to 'I', which would make 'ıNT' a spelling of 'INT'. | ||
| _ASCII_UPPERCASE = str.maketrans("abcdefghijklmnopqrstuvwxyz", "ABCDEFGHIJKLMNOPQRSTUVWXYZ") | ||
|
|
||
|
|
||
| def _get_model_for_singleton_value( | ||
| model: Any, value: Any, discriminator: Union[str, Discriminator, None] = None | ||
| model: Any, | ||
| value: Any, | ||
| discriminator: Union[str, Discriminator, None] = None, | ||
| *, | ||
| expr_enabled: bool = False, | ||
| ) -> Optional[Type]: | ||
| """Given a FieldInfo and the value that we're given for that field, determine | ||
| the actual Model for the value in the event that the FieldInfo may be for | ||
|
|
@@ -756,7 +767,20 @@ def _get_model_for_singleton_value( | |
| raise NotImplementedError( | ||
| "You have hit an unimplemented code path. Please report this as a bug." | ||
| ) | ||
| if typing.get_args(sub_model_discr_value)[0] == discr_value: | ||
| literal_value = typing.get_args(sub_model_discr_value)[0] | ||
| if literal_value == discr_value: | ||
| return sub_model | ||
| # RFC 0007 §2: parameter type names are case-insensitive when the EXPR | ||
| # extension is active. The `_normalize_parameter_type_case` field | ||
| # validator uppercases the `type` discriminator before pydantic's | ||
| # union resolution, but this prevalidation traversal runs earlier and | ||
| # sees the raw values — so match the discriminator case-insensitively | ||
| # here, or a lowercase-typed parameter defines no Param.* symbols. | ||
| if ( | ||
| expr_enabled | ||
| and isinstance(literal_value, str) | ||
| and literal_value.translate(_ASCII_UPPERCASE) == discr_value.translate(_ASCII_UPPERCASE) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The case-insensitive fallback is applied to every string- The risk is forward-looking, and it is a silent divergence rather than an error: if any future union is discriminated on some other Scoping it to the field the normalizer actually covers keeps the two resolutions in lockstep: if (
expr_enabled
and discriminator == "type"
and isinstance(literal_value, str)
and ...
): |
||
| ): | ||
| return sub_model | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ordering issue: the case-insensitive fallback is evaluated inside the same loop iteration as the exact-match check, so a loose match on an earlier union member wins over an exact match on a later one. Concretely, for a union ordered No current union has two members whose for sub_model in typing.get_args(model):
...
if literal_value == discr_value:
return sub_model
if not expr_enabled:
return None
folded = discr_value.translate(_ASCII_UPPERCASE)
for sub_model in typing.get_args(model):
... # second pass, folded comparisonThat also makes the invariant "exact spelling always wins" explicit rather than dependent on union declaration order. |
||
|
|
||
| return None | ||
|
|
@@ -881,9 +905,13 @@ def _collect_variable_definitions( # noqa: C901 (suppress: too complex) | |
| ) | ||
| return {"__export__": symtab} | ||
|
|
||
| # Unwrap a discriminated union to the selected type | ||
| # Unwrap a discriminated union to the selected type. `collect_types` is | ||
| # set exactly when the EXPR extension is active, which also governs the | ||
| # case-insensitive `type` discriminator matching (RFC 0007 §2). | ||
| if model_origin is Union and discriminator is not None: | ||
| unioned_model = _get_model_for_singleton_value(model, value, discriminator) | ||
| unioned_model = _get_model_for_singleton_value( | ||
| model, value, discriminator, expr_enabled=collect_types | ||
| ) | ||
| if unioned_model is not None: | ||
| return _collect_variable_definitions( | ||
| unioned_model, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This
_ASCII_UPPERCASEtable (plus its comment) is a byte-for-byte duplicate ofsrc/openjd/model/v2023_09/_model.py:4012, and the two now have to agree for correctness — this PR exists precisely because the prevalidation fold and the pydantic-side fold disagreed. Leaving two independent copies re-creates that hazard: if someone later widens/narrows the fold in one module (say, to handle a new bracketed type name or to switch tocasefold), prevalidation and union resolution silently diverge again and the failure mode is the same confusing "does not exist at this location" rather than a test that obviously breaks.Since
v2023_09/_model.pyalready imports from_internal, the dependency direction allows the constant to live here (or in a small shared helper under_internal) and be imported by_model.py, so there is exactly one definition of what "the fold" means.Worth noting there is a third fold in play:
expr_type_for_openjd_type/ the Rustjob_parameter_type_expr_specis documented as case-insensitive and does its own normalization. That one is fine to keep separate since it lives behind an API boundary, but it does mean the invariant "all three folds agree on which spellings are equivalent" is currently unpinned by any test.