Skip to content

feat: lazy validation for generated API models#537

Merged
LinoGiger merged 5 commits into
mainfrom
poseidon/lazy-model-validation
Apr 20, 2026
Merged

feat: lazy validation for generated API models#537
LinoGiger merged 5 commits into
mainfrom
poseidon/lazy-model-validation

Conversation

@RapidPoseidon

Copy link
Copy Markdown
Contributor

Summary

  • Adds LazyValidatedModel base class that all generated models now inherit from (instead of BaseModel)
  • On deserialization, models try model_validate first. If a ValidationError is raised (e.g. backend changed a field type), they fall back to model_construct — logging the mismatched fields and recording a trace event
  • A TypeError is only raised when the caller actually accesses a field that failed validation

This means backend schema changes on fields the SDK doesn't use no longer break customers, while type safety is preserved for fields that are read.

What changed

File Change
src/rapidata/api_client/lazy_model.py New base class with _lazy_construct fallback and __getattribute__ access guard
openapi/templates/model_generic.mustache Parent class → LazyValidatedModel, model_validate wrapped in try/except
openapi/templates/model_oneof.mustache Parent class → LazyValidatedModel
openapi/templates/model_anyof.mustache Parent class → LazyValidatedModel
src/rapidata/api_client/models/*.py Regenerated from updated templates

How it works

Backend response → from_dict()
                     ├─ model_validate() succeeds → normal model (zero overhead)
                     └─ model_validate() fails (ValidationError)
                          ├─ log warning with mismatched field names
                          ├─ record trace event on current span
                          └─ model_construct() with per-field errors stored
                               └─ __getattribute__: accessing bad field → TypeError
                                  accessing good field → works normally

Test plan

  • Verify existing SDK tests pass
  • Test with a model that has correct types — should behave identically to before
  • Test with a model where an unused field has a wrong type — should construct successfully and log a warning
  • Test accessing a field that failed validation — should raise TypeError
  • Run pyright src/rapidata/rapidata_client — 0 new errors (3 pre-existing __version__ errors remain)
  • Run a full generate-schema.sh to verify templates produce correct output

🤖 Generated with Claude Code

Replace eager Pydantic validation with a lazy pattern so that
type mismatches on unused fields no longer crash the SDK.

On construction, models try model_validate first. If validation
fails, they fall back to model_construct, log the mismatched
fields, record a trace event, and store per-field errors. A
TypeError is raised only when the caller actually accesses a
field whose value failed validation.

This makes the SDK resilient to backend schema changes on fields
the caller doesn't use, while preserving type-safety for fields
that are read.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude

claude Bot commented Apr 20, 2026

Copy link
Copy Markdown

Code Review — feat: lazy validation for generated API models

Overall the idea is solid: catching ValidationError in from_dict and falling back to model_construct with deferred per-field errors is a good resilience pattern. The implementation in lazy_model.py is clean and well-structured. However, there are a few issues that need attention before merge.


🔴 Critical bug — AttributeError on any oneOf / anyOf instance

LazyValidatedModel.__getattribute__ (line 108) calls object.__getattribute__(self, "_field_validation_errors") unconditionally for every access to a model field. This attribute is only initialised in __init__ (line 41), which means it is never set when an instance is created via model_construct().

Both model_oneof.mustache and model_anyof.mustache still use model_construct() directly in their from_json methods (e.g. instance = cls.model_construct()). Any access to instance.actual_instance (or any other declared field) after that will raise AttributeError — completely breaking oneOf/anyOf models that previously worked.

Minimal fix in __getattribute__:

# Replace:
errors: Dict[str, dict] = object.__getattribute__(self, "_field_validation_errors")

# With:
errors: Dict[str, dict] = (
    object.__getattribute__(self, "__dict__").get("_field_validation_errors") or {}
)

A more robust option is to override model_post_init (called by Pydantic after both normal construction and validators) to unconditionally initialise the attribute, so model_construct() callers are also covered.


🟡 Medium issues

1. __getattribute__ performance on every field read

The guard fires on every access to every declared field on every model instance — even perfectly-valid ones. The cost of type(self).__dict__.get("model_fields"), the set-membership check, and object.__getattribute__ accumulates for high-throughput code paths. Consider short-circuiting with a class-level flag or a sentinel value to skip the dict lookup for the common (no-error) case, e.g.:

# After super().__init__() succeeds, store a sentinel instead of {}
object.__setattr__(self, "_field_validation_errors", None)

# In __getattribute__:
errors = object.__getattribute__(self, "__dict__").get("_field_validation_errors")
if errors and name in errors:
    ...

if errors is already falsy for None and {}, so this adds zero branches but avoids the __getattribute__ call entirely when there are no errors.

2. model_dump bypasses __getattribute__, creating inconsistency

Pydantic's model_dump reads field values through __dict__ internally, not through __getattribute__. This means to_dict() / serialisation on a lazy-constructed model will silently emit the raw wrong-typed value rather than raising TypeError. Callers may not realise they're serialising bad data. This inconsistency should at least be documented, and ideally to_dict() should guard against it.

3. oneOf / anyOf don't benefit from lazy construction

The parent class change for model_oneof.mustache / model_anyof.mustache inherits the __getattribute__ guard but never calls _lazy_construct. The wrapping from_json logic still fails hard on validation errors for the composing types. This is not a regression, but worth documenting — the resilience benefit of this PR only applies to plain-model deserialization, not oneOf/anyOf wrappers.

4. Unused imports in generated models

The regenerated model files (e.g. add_example_to_audience_endpoint_input.py line 20) still import BaseModel and ConfigDict from pydantic even though neither is used directly any more. The mustache template should be updated to drop those from the import list when the parent is LazyValidatedModel.

5. opentelemetry dependency

lazy_model.py imports from opentelemetry import trace. Please verify opentelemetry-api is declared in pyproject.toml — if it is already a transitive dependency that's fine, but it should be explicit.


🟢 Low / style

  • _field_validation_errors is correctly stored outside Pydantic's field system so it won't appear in model_dump / JSON output. However, model_copy() and pickle will not carry it over. Consider documenting this limitation or overriding model_copy.
  • Return-type annotation -> "LazyValidatedModel" in _lazy_construct has redundant quotes — from __future__ import annotations is already active.
  • The PR test plan is entirely unchecked. Unit tests for LazyValidatedModel (at minimum: normal path, fallback path, and access-time guard) should ship with the feature.

Summary

Severity Issue
🔴 Critical model_construct() in oneOf/anyOf from_json skips __init__, leaving _field_validation_errors unset → AttributeError on any field access
🟡 Medium __getattribute__ overhead on every field read; model_dump inconsistency; oneOf/anyOf don't get lazy fallback; stale imports; opentelemetry dependency
🟢 Low Redundant quotes in return type annotation; model_copy/pickle not covered; no unit tests

🤖 Generated with Claude Code

Match the evaluator's pattern: logger.error with exc_info for full
traceback, span.set_status(ERROR) to fail the trace. This ensures
type mismatches are visible in observability even when the SDK
doesn't crash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude

claude Bot commented Apr 20, 2026

Copy link
Copy Markdown

Code Review — PR #537: Lazy validation for generated API models

The design goal is solid: graceful degradation on backend schema drift without breaking SDK callers for unused fields. Here are my findings:


🔴 Critical Bug: model_construct() path leaves _field_validation_errors unset

LazyValidatedModel.__init__ sets _field_validation_errors via object.__setattr__, but model_construct() bypasses __init__. The oneOf and anyOf templates call cls.model_construct() directly in their from_json methods:

instance = cls.model_construct()
instance.actual_instance = SomeType.from_json(json_str)
return instance

When the caller then accesses instance.actual_instance (e.g., .to_dict()), __getattribute__ finds actual_instance in model_fields → calls object.__getattribute__(self, "_field_validation_errors")AttributeError because __init__ never ran.

Fix: Make the attribute lookup safe:

errors = object.__getattribute__(self, "__dict__").get("_field_validation_errors")
if errors and name in errors:
    ...

Or add a __init_subclass__/__new__ override that always sets the attribute.


🟠 logger.error should be logger.warning

The PR description says "log warning with mismatched field names", but the implementation uses logger.error with exc_info=error (full stack trace). This is graceful degradation — logging at ERROR level with full tracebacks will:

  • Trigger production error alerts for expected/routine backend drift
  • Flood logs with stack traces for every response that has an unused-but-drifted field

Use logger.warning("...", extra={"mismatched_fields": error_fields}) and drop exc_info.


🟠 OpenTelemetry is a hard dependency

from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode

If a customer doesn't have opentelemetry-api installed, lazy_model.py (and therefore every generated model) will fail to import. This should be guarded:

try:
    from opentelemetry import trace as _otel_trace
    _OTEL_AVAILABLE = True
except ImportError:
    _OTEL_AVAILABLE = False

🟡 __getattribute__ overhead on every field access (happy path)

For the vast majority of instances (no validation errors), __getattribute__ still executes:

model_fields = type(self).__dict__.get("model_fields")
if model_fields is not None and name in model_fields:
    errors = object.__getattribute__(self, "_field_validation_errors")
    if errors and ...

The if errors and ... short-circuit handles the no-error case quickly, but the type(self).__dict__.get("model_fields") + name in model_fields lookup happens on every field read across all 280+ models. For models accessed in tight loops (e.g., iterating over query results), this is meaningful overhead.

Consider storing _has_field_errors: bool = False as a fast-path flag (as the comment suggests, but the implementation doesn't actually skip the model_fields lookup).


🟡 Stale BaseModel / ConfigDict imports in generated models

The generated models still import BaseModel and ConfigDict directly:

from pydantic import BaseModel, ConfigDict, Field, StrictBool, ...

These are now unused at the model level (only needed by LazyValidatedModel). The model_generic.mustache template should strip them from the per-model import to avoid misleading readers and static analysis warnings.


🟡 oneOf / anyOf models don't get lazy validation in from_json

The model_oneof.mustache and model_anyof.mustache templates inherit from LazyValidatedModel but their from_json methods don't wrap with try/except ValidationError → _lazy_construct. The fallback only applies to model_generic.mustache. This is an inconsistency: oneOf/anyOf models will still raise hard on backend schema changes if inheriting from LazyValidatedModel is the stated goal.


🟡 _lazy_construct sets span status to ERROR on degraded-but-valid construction

span.set_status(Status(StatusCode.ERROR, ...))

Using StatusCode.ERROR marks the entire trace as failed. For a graceful fallback that still returns a usable object, StatusCode.UNSET or a custom span event would be more appropriate. ERROR signals "this request failed" to trace consumers.


🟡 No tests

All test plan items are unchecked. LazyValidatedModel is a subtle piece of infrastructure (custom __getattribute__, model_construct edge cases, alias mapping). Consider adding unit tests before merge:

  • Normal model validates → _field_validation_errors == {}
  • Model with type mismatch → construction succeeds, good fields accessible, bad field raises TypeError
  • model_construct() called directly → no AttributeError on field access
  • Nested model with inner lazy-construct → outer model accessible

Minor

  • The # TODO: Rewrite to not use raise_errors comment in the templates pre-exists but worth tracking.
  • In _lazy_construct, the alias→field mapping iterates model_fields twice (once for alias mapping, once for error extraction). Could be a single pass.

Summary: The architectural approach is the right call for SDK resilience. The critical bug (missing _field_validation_errors when model_construct() is called directly in oneOf/anyOf templates) should be fixed before merge, along with the OTel hard dependency and the logging severity. The performance concern is worth benchmarking given the heavy generated-model usage patterns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude

claude Bot commented Apr 20, 2026

Copy link
Copy Markdown

Code Review

PR Summary: Adds LazyValidatedModel as a new Pydantic base class so that backend schema mismatches on unused fields no longer crash SDK callers. The fallback path uses model_construct to build the object and defers the TypeError until the bad field is actually read.


Critical Issues

1. Incomplete migration — ~544 of ~1097 models (~55%) still extend BaseModel directly

The PR regenerates roughly half the model files. The other half (e.g. campaign_artifact_model.py, ab_test_selection.py, multi_compare_truth.py, and hundreds more) still extend BaseModel with their own model_config and no _lazy_construct fallback. Callers using those models get no protection from backend changes.

$ grep -rl "class.*BaseModel" src/rapidata/api_client/models/ | wc -l   # 544
$ grep -rl "LazyValidatedModel"  src/rapidata/api_client/models/ | wc -l # 499

Either the regeneration script needs to run over the remaining specs, or the PR description should document that coverage is intentionally partial with a follow-up tracked.


2. __init__ ordering — _field_validation_errors is set after super().__init__()

def __init__(self, **data: Any) -> None:
    super().__init__(**data)                                    # ← (A)
    object.__setattr__(self, "_field_validation_errors", {})   # ← (B)

__getattribute__ guards every model-field read, including any that happen inside (A) (e.g. a @field_validator that reads a sibling field, or Pydantic's own validate_assignment machinery). Between (A) starting and (B) completing, object.__getattribute__(self, "_field_validation_errors") will raise AttributeError.

Fix: set the attribute before calling super:

def __init__(self, **data: Any) -> None:
    object.__setattr__(self, "_field_validation_errors", {})
    super().__init__(**data)

Moderate Issues

3. Stale BaseModel import in every migrated model

Every model file regenerated by the updated template still contains:

from pydantic import BaseModel, ConfigDict, StrictStr

BaseModel is no longer the parent class and isn't referenced elsewhere in those files. The mustache template should drop it from the import. Beyond being misleading, static analysers (and pyright) will flag it as unused.


4. to_dict() / model_dump() silently raises on partially-invalid models

If to_dict() is called on a model where a field failed validation, model_dump() internally reads every field through __getattribute__, which raises TypeError for any bad field. The caller gets a confusing error rather than a graceful degradation or a dict with the raw value. Worth either:

  • documenting this explicitly, or
  • having to_dict() catch and handle the per-field errors.

Minor Issues

5. No unit tests

The test plan is entirely unchecked. At minimum, tests for the three key behaviors should ship with this change:

  • happy path: clean model behaves identically to BaseModel
  • bad unused field: construction succeeds, warning logged
  • accessing a bad field: TypeError raised

Without tests, regressions in __getattribute__ or _lazy_construct will be invisible.


6. __getattribute__ performance overhead on hot paths

For every model field read, the override does:

  1. type(self).__dict__.get("model_fields") — dict lookup on mappingproxy
  2. name in model_fields — hash lookup
  3. object.__getattribute__(self, "_field_validation_errors") — attribute access
  4. if errors and name in errors — truthiness + hash lookup

For the 99 % case where _field_validation_errors is {}, steps 1–4 are pure overhead added to every field read. A simple flag (e.g. _has_validation_errors: bool) would let the guard short-circuit without the dict access:

if object.__getattribute__(self, "_has_validation_errors"):
    errors = object.__getattribute__(self, "_field_validation_errors")
    if name in errors:
        ...

7. model_config duplication comment vs. inheritance

The # model_config is inherited from LazyValidatedModel comment in the mustache templates is clear, but the generated files for non-migrated models still define their own model_config = ConfigDict(populate_by_name=True, validate_assignment=True, protected_namespaces=()). Once fully migrated, this becomes dead weight to remove.


Summary

The design is sound and the implementation of LazyValidatedModel itself is clean. The two issues to address before merging:

  1. Fix the __init__ ordering bug (issue Basic documentation #2 above) — it's a latent crash.
  2. Clarify / complete the migration coverage (issue First version #1) — the PR's stated goal of protecting all SDK callers is only half-achieved in the current diff.

🤖 Generated with Claude Code

@claude

claude Bot commented Apr 20, 2026

Copy link
Copy Markdown

Code Review: feat: lazy validation for generated API models

The idea behind this PR is solid — making SDK deserialization resilient to backend schema drift is genuinely valuable. The architecture (try model_validate, fall back to model_construct, guard access) is reasonable. However there are a few issues that need attention before this is ready to merge.


🔴 Critical: Incomplete model regeneration

Only 499 of ~1043 model classes were updated to LazyValidatedModel; 544 still directly inherit from BaseModel.

grep -l "class.*LazyValidatedModel" src/rapidata/api_client/models/*.py | wc -l  → 499
grep -l "class.*BaseModel"          src/rapidata/api_client/models/*.py | wc -l  → 544

Files not regenerated include ab_test_selection.py and all other models whose from_dict still uses cls.model_validate({...}) without any try/except. The PR's stated goal — "backend schema changes on fields the SDK doesn't use no longer break customers" — is only half-achieved.

The generate-schema.sh step from the test plan is listed but unchecked. This needs to be run and its output committed.


🔴 Critical: AttributeError on model_construct-created instances

_field_validation_errors is only initialized in LazyValidatedModel.__init__. Pydantic's model_construct() bypasses __init__, so instances created that way never get the attribute set.

The model_oneof.mustache and model_anyof.mustache templates both call cls.model_construct() directly in from_json:

instance = cls.model_construct()
...
instance.actual_instance = instance.{{vendorExtensions.x-py-name}}  # reads instance.<field>

Reading instance.{{vendorExtensions.x-py-name}} calls __getattribute__, which then does:

errors = object.__getattribute__(self, "_field_validation_errors")  # AttributeError!

This is a regression for every oneOf/anyOf model deserialization path.

Fix: handle the missing attribute gracefully:

try:
    errors = object.__getattribute__(self, "_field_validation_errors")
except AttributeError:
    return super().__getattribute__(name)

Or initialize in a __init_subclass__ / __class_getitem__ hook, or override model_post_init (note: model_post_init is called by Pydantic's __init__ but NOT by model_construct).


🟡 Significant: Wrong exception type at access time

# lazy_model.py:105
raise TypeError(
    f"Field '{name}' on {type(self).__name__} has an unexpected "
    f"type from the backend: {err.get('msg', err)}"
)

Raising TypeError for a validation failure is semantically wrong and breaks callers that catch pydantic.ValidationError. Consider a custom LazyValidationError(TypeError) subclass or wrapping as a Pydantic error — or at minimum document that TypeError is intentional and what callers should catch.


🟡 Significant: fail_current_span marks spans as ERROR

tracer.fail_current_span(
    f"Validation failed for {cls.__name__}: {error_fields}"
)

fail_current_span sets StatusCode.ERROR on the span (see tracer.py:233). A backend field type change on an unused field would mark the entire parent span as errored, potentially triggering alerts. Consider using a span event or attribute instead:

span = trace.get_current_span()
if span.is_recording():
    span.add_event("lazy_validation_fallback", {"fields": str(error_fields), "model": cls.__name__})

🟡 Significant: exc_info=error logs full stacktrace on every mismatch

logger.warning(
    "Validation failed for %s – mismatched fields: %s",
    cls.__name__,
    error_fields,
    exc_info=error,   # ← logs the full Pydantic ValidationError traceback
)

In production, if a backend field changes permanently, this will spam logs with multi-line tracebacks on every deserialized response. The field names in the message are already informative — consider dropping exc_info or only including it at DEBUG level.


🟡 Minor: validation_alias not handled in _lazy_construct

# lazy_model.py:54-57
for field_name, field_info in cls.model_fields.items():
    alias = field_info.alias or field_name
    alias_to_field[alias] = field_name
    alias_to_field[field_name] = field_name

Pydantic v2 also supports validation_alias (and AliasPath/AliasChoices). If any generated model uses those, the field error mapping will be wrong and error dict keys won't match, meaning __getattribute__ won't catch those fields.


🟡 Minor: type(self).__dict__.get("model_fields") skips MRO

model_fields = type(self).__dict__.get("model_fields")

This only checks the direct class __dict__, not inherited ones. For typical Pydantic usage model_fields is always on the class itself (Pydantic sets it per-class), so this is probably fine in practice — but type(self).model_fields would be safer and more idiomatic.


🟠 No tests

All items in the test plan are unchecked. There are zero test files in the repository. Given the complexity of the __getattribute__ override and the fallback construction path, at minimum a few unit tests for LazyValidatedModel would prevent regressions (e.g., clean model, partially-invalid model, model_construct-created model, and field access on each).


Summary

Issue Severity
Regeneration is ~48% complete — 544 models still use BaseModel 🔴 Critical
AttributeError on model_construct-created instances (oneOf/anyOf regression) 🔴 Critical
Wrong exception type (TypeError instead of ValidationError) 🟡 Significant
fail_current_span marks spans ERROR for benign drift 🟡 Significant
exc_info logs full stacktrace on every mismatch 🟡 Significant
validation_alias not handled 🟡 Minor
type(self).__dict__ vs type(self).model_fields 🟡 Minor
No tests 🟠 Blocking

The lazy_model.py design is clean and the mustache template change is correct. Completing the regeneration and fixing the model_construct AttributeError are the two things needed before this is safe to merge.

🤖 Generated with Claude Code

The __getattribute__ guard used type(self).__dict__.get('model_fields'),
but in Pydantic v2 model_fields is a class-property that resolves via
the metaclass — it is not present in the class's own __dict__. The
lookup always returned None, so the guard never fired: fields that
failed validation could be read silently, returning the bad value
instead of raising TypeError.

Switch to getattr(type(self), 'model_fields', None) so MRO/metaclass
resolution works.

Also wrap the _field_validation_errors read in try/except AttributeError.
model_construct() bypasses __init__, so instances produced by the oneOf
and anyOf from_json templates never get the attribute set. Without the
guard, reading any field on such an instance would have raised
AttributeError once the first fix landed.
@claude

claude Bot commented Apr 20, 2026

Copy link
Copy Markdown

Code Review

Overall: The design intent is solid — deferring validation failures to field-access time is a good resilience strategy for a generated client. However there are a few issues that need addressing before this is safe to merge.


🔴 Critical: Incomplete model regeneration

The PR regenerated only models from the Rapidata Asset API service. The Rapidata.Dataset models (~463 files) were not regenerated — they still extend BaseModel directly and their from_dict methods call cls.model_validate(...) with no try/except fallback.

Rapidata Asset API models → 499 updated ✅, 94 still on BaseModel ❌
Rapidata.Dataset models   →   0 updated ❌, 450 still on BaseModel ❌

This means the primary feature of this PR won't work for roughly half the model files. The generate-schema.sh step in the test plan needs to be run for both API specs.


🔴 Architectural concern: inverted package dependency

api_client/lazy_model.py imports from rapidata.rapidata_client.config:

from rapidata.rapidata_client.config import logger, tracer

api_client is the lower-level auto-generated layer; rapidata_client sits on top of it and already imports api_client models. This inverts the intended layering. While there's no circular import today, it creates tight coupling from the generated layer into the business layer and will break if api_client is ever used standalone or packaged separately.

Suggestion: pass logger and tracer in at the call site (e.g. optional params to _lazy_construct) or use logging.getLogger(__name__) directly in lazy_model.py and omit the tracer dependency (which could be an optional hook instead).


🟡 __getattribute__ overhead on every field access

def __getattribute__(self, name: str) -> Any:
    model_fields = getattr(type(self), "model_fields", None)
    if model_fields is not None and name in model_fields:
        ...
    return super().__getattribute__(name)

Every field access on every LazyValidatedModel instance now incurs a getattr(type(self), "model_fields") call and a dict membership test, even for the 99%+ of instances that validated cleanly. The _field_validation_errors early-exit check can be pulled up:

def __getattribute__(self, name: str) -> Any:
    # fast path: no errors on this instance
    errors = object.__getattribute__(self, "_field_validation_errors") if ... else None
    if errors:
        model_fields = getattr(type(self), "model_fields", None)
        if model_fields is not None and name in model_fields and name in errors:
            raise TypeError(...)
    return super().__getattribute__(name)

This avoids the model_fields lookup entirely for clean instances.


🟡 TypeError is the wrong exception type

The PR raises TypeError when a bad field is accessed:

raise TypeError(
    f"Field '{name}' on {type(self).__name__} has an unexpected type from the backend: ..."
)

TypeError is a Python built-in for type contract violations (wrong argument count, etc.). Callers who try to catch Pydantic's ValidationError won't catch this. A dedicated RapidataFieldValidationError(TypeError) or simply re-raising a wrapped ValidationError would be clearer about the source.


🟡 oneOf/anyOf models don't get lazy treatment

model_oneof.mustache and model_anyof.mustache now inherit from LazyValidatedModel, but their from_json/from_dict methods still call raise ValueError(...) directly — the _lazy_construct fallback is never used. The resilience benefit is inconsistent across model types.


🟡 Redundant imports in generated models

Models that were regenerated now import from pydantic import BaseModel, ConfigDict, ... but no longer use BaseModel or ConfigDict directly (both are now provided by LazyValidatedModel). This creates noise. The mustache template should drop those from the generated import line.


🟡 Verbose logging for common cases

logger.warning(
    "Validation failed for %s – mismatched fields: %s",
    cls.__name__,
    error_fields,
    exc_info=error,   # logs full traceback
)

exc_info=error logs the full ValidationError traceback. During normal backend evolution this will fire frequently and pollute customer logs. Consider logging at DEBUG level with the full traceback, and WARNING with just the field names.


🟡 _lazy_construct return type

def _lazy_construct(cls, data, error) -> "LazyValidatedModel":

With from typing_extensions import Self (already used in models), the return type can be Self for better type inference in subclasses. The quoted string is also unnecessary since from __future__ import annotations is already at the top.


🟠 No tests

All checkboxes in the test plan are unchecked. For a change that modifies the base class of every generated model and overrides __getattribute__, unit tests are essential. At minimum:

  1. A model with correct types → behaves identically to BaseModel
  2. A model with a wrong-typed unused field → constructs successfully, logs warning
  3. Accessing the bad field → raises TypeError
  4. to_dict() / to_json() round-trips on a lazily-constructed instance
  5. validate_assignment=True still fires correctly on a field that was not in error

✅ What's good

  • The fallback-to-model_construct approach is clean and correct for Pydantic v2
  • Storing errors per-field so only accessed bad fields raise is the right granularity
  • The flow diagram in the PR description is clear and accurate
  • Recording a trace event on validation failure is good for observability
  • The _field_validation_errors fast-path comment is well-explained

@LinoGiger LinoGiger merged commit 601e465 into main Apr 20, 2026
2 of 4 checks passed
@LinoGiger LinoGiger deleted the poseidon/lazy-model-validation branch April 20, 2026 15:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants