From 0e392e68aaee19f8ad3e01f17db8e1b935aeb218 Mon Sep 17 00:00:00 2001 From: Poseidon Date: Tue, 21 Apr 2026 08:10:40 +0000 Subject: [PATCH 1/2] fix: make _lazy_construct respect discriminator fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit oneOf/anyOf disambiguation relies on non-matching variants RAISING so that only the correct variant bumps the match counter. After lazy validation landed, every variant's from_dict caught ValidationError and fell back to _lazy_construct, producing 'Multiple matches found' errors on every oneOf deserialization (e.g. display_progress_bar through GetPipelineByIdResult -> IArtifactModel). Rather than bypass lazy in the oneOf loop (which would lose drift tolerance for the picked variant), this change teaches _lazy_construct to distinguish 'this variant is wrong' from 'a field drifted': - A 'strict field' is any field with a custom Pydantic field_validator. The OpenAPI generator emits one on every discriminator field (_t) that enforces the enum value ('FileArtifactModel' etc.). - If any validation error targets a strict field, the whole model is wrong for this payload → re-raise the original ValidationError. - Otherwise (type drifts, missing optional etc.), lazy-construct as before and defer errors to field-access time. Result: oneOf disambiguation works again, and a oneOf-wrapped variant with drift on a non-discriminator field still gets lazy protection — the variant is picked via its _t, and reading a drifted unused field is silent while reading the drifted field raises TypeError. Verified with 6 scenarios: valid payload, drift on non-strict field, discriminator mismatch, oneOf happy path, oneOf + drifted non-strict field, and oneOf gibberish. --- src/rapidata/api_client/lazy_model.py | 46 +++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/src/rapidata/api_client/lazy_model.py b/src/rapidata/api_client/lazy_model.py index 82530d081..12b2f368d 100644 --- a/src/rapidata/api_client/lazy_model.py +++ b/src/rapidata/api_client/lazy_model.py @@ -38,6 +38,25 @@ def __init__(self, **data: Any) -> None: # for the vast majority of instances that validated cleanly. object.__setattr__(self, "_field_validation_errors", {}) + # ------------------------------------------------------------------ + # Strict fields – any error touching one of these re-raises instead + # of lazy-constructing. Discriminator fields like `_t` on oneOf/anyOf + # variants declare a custom field_validator that enforces the enum + # value (e.g. 'FileArtifactModel'), so they appear here automatically. + # This keeps oneOf disambiguation working: a payload that doesn't + # match a variant's discriminator raises, while drifted non-strict + # fields still fall back to the lazy path. + # ------------------------------------------------------------------ + @classmethod + def _strict_field_names(cls) -> set: + decorators = getattr(cls, "__pydantic_decorators__", None) + if decorators is None: + return set() + strict: set = set() + for decorator in getattr(decorators, "field_validators", {}).values(): + strict.update(getattr(decorator.info, "fields", ())) + return strict + # ------------------------------------------------------------------ # Fallback construction – called when model_validate raises # ------------------------------------------------------------------ @@ -47,7 +66,14 @@ def _lazy_construct( data: Dict[str, Any], error: ValidationError, ) -> "LazyValidatedModel": - """Build the model via ``model_construct`` and store per-field errors.""" + """Build the model via ``model_construct`` and store per-field errors. + + Re-raises the original ``ValidationError`` if any error targets a + strict field (a field with a custom ``field_validator``). These are + the discriminator/enum fields the backend guarantees never drift — + a failure there means the model choice is wrong, not that a field + type changed. + """ # --- alias → python field name mapping --- alias_to_field: Dict[str, str] = {} @@ -56,12 +82,6 @@ def _lazy_construct( alias_to_field[alias] = field_name alias_to_field[field_name] = field_name - # --- build kwargs with python names for model_construct --- - construct_kwargs: Dict[str, Any] = {} - for key, value in data.items(): - python_name = alias_to_field.get(key, key) - construct_kwargs[python_name] = value - # --- extract per-field errors keyed by python name --- field_errors: Dict[str, Any] = {} for err in error.errors(): @@ -71,6 +91,18 @@ def _lazy_construct( python_name = alias_to_field.get(alias_key, alias_key) field_errors[python_name] = err + # --- if any strict field failed, the whole model is wrong – re-raise --- + strict_fields = cls._strict_field_names() + strict_violations = strict_fields & field_errors.keys() + if strict_violations: + raise error + + # --- build kwargs with python names for model_construct --- + construct_kwargs: Dict[str, Any] = {} + for key, value in data.items(): + python_name = alias_to_field.get(key, key) + construct_kwargs[python_name] = value + # --- observability: log error + fail the trace --- error_fields = list(field_errors.keys()) logger.warning( From ef332398b8a539a8bd3fe2f0620c879ba359f908 Mon Sep 17 00:00:00 2001 From: Poseidon Agent Date: Tue, 21 Apr 2026 08:28:14 +0000 Subject: [PATCH 2/2] narrow strict-field rule to _t discriminator only Previous rule made every @field_validator field strict (enum and regex validators included), which reintroduced the brittleness lazy validation exists to avoid. The only field that must stay strict is _t, because oneOf/anyOf disambiguation depends on it. Enum/pattern drift on any other field now falls back to lazy construction, as originally intended. --- src/rapidata/api_client/lazy_model.py | 32 +++++++++++++-------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/src/rapidata/api_client/lazy_model.py b/src/rapidata/api_client/lazy_model.py index 12b2f368d..11ef4702b 100644 --- a/src/rapidata/api_client/lazy_model.py +++ b/src/rapidata/api_client/lazy_model.py @@ -40,22 +40,21 @@ def __init__(self, **data: Any) -> None: # ------------------------------------------------------------------ # Strict fields – any error touching one of these re-raises instead - # of lazy-constructing. Discriminator fields like `_t` on oneOf/anyOf - # variants declare a custom field_validator that enforces the enum - # value (e.g. 'FileArtifactModel'), so they appear here automatically. - # This keeps oneOf disambiguation working: a payload that doesn't - # match a variant's discriminator raises, while drifted non-strict - # fields still fall back to the lazy path. + # of lazy-constructing. Only the `_t` discriminator is strict: it's + # a structural requirement for oneOf/anyOf disambiguation (the wrong + # `_t` means the wrong variant was picked, not that a field drifted). + # Every other backend-enforced constraint (enums, regex patterns, + # etc.) stays lazy, which is the whole point of this base class. # ------------------------------------------------------------------ @classmethod def _strict_field_names(cls) -> set: - decorators = getattr(cls, "__pydantic_decorators__", None) - if decorators is None: + model_fields = getattr(cls, "model_fields", None) + if not model_fields: return set() - strict: set = set() - for decorator in getattr(decorators, "field_validators", {}).values(): - strict.update(getattr(decorator.info, "fields", ())) - return strict + for field_name, field_info in model_fields.items(): + if field_info.alias == "_t": + return {field_name} + return set() # ------------------------------------------------------------------ # Fallback construction – called when model_validate raises @@ -68,11 +67,10 @@ def _lazy_construct( ) -> "LazyValidatedModel": """Build the model via ``model_construct`` and store per-field errors. - Re-raises the original ``ValidationError`` if any error targets a - strict field (a field with a custom ``field_validator``). These are - the discriminator/enum fields the backend guarantees never drift — - a failure there means the model choice is wrong, not that a field - type changed. + Re-raises the original ``ValidationError`` if the error targets the + `_t` discriminator field. A bad `_t` means oneOf/anyOf disambiguation + picked the wrong variant, which is a structural failure — not the + kind of backend drift lazy validation is meant to absorb. """ # --- alias → python field name mapping ---