Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
8 changes: 3 additions & 5 deletions openapi/templates/model_anyof.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import re # noqa: F401
from typing import Union, Any, List, Set, TYPE_CHECKING, Optional, Dict
from typing_extensions import Literal, Self
from pydantic import Field
from rapidata.api_client.lazy_model import LazyValidatedModel

{{#lambda.uppercase}}{{{classname}}}{{/lambda.uppercase}}_ANY_OF_SCHEMAS = [{{#anyOf}}"{{.}}"{{^-last}}, {{/-last}}{{/anyOf}}]

class {{classname}}({{#parent}}{{{.}}}{{/parent}}{{^parent}}BaseModel{{/parent}}):
class {{classname}}({{#parent}}{{{.}}}{{/parent}}{{^parent}}LazyValidatedModel{{/parent}}):
"""
{{{description}}}{{^description}}{{{classname}}}{{/description}}
"""
Expand All @@ -30,10 +31,7 @@ class {{classname}}({{#parent}}{{{.}}}{{/parent}}{{^parent}}BaseModel{{/parent}}
actual_instance: Any = None
any_of_schemas: Set[str] = { {{#anyOf}}"{{.}}"{{^-last}}, {{/-last}}{{/anyOf}} }

model_config = {
"validate_assignment": True,
"protected_namespaces": (),
}
# model_config is inherited from LazyValidatedModel
{{#discriminator}}

discriminator_value_class_map: Dict[str, str] = {
Expand Down
18 changes: 10 additions & 8 deletions openapi/templates/model_generic.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import json
{{#vendorExtensions.x-py-model-imports}}
{{{.}}}
{{/vendorExtensions.x-py-model-imports}}
from pydantic import ValidationError
from rapidata.api_client.lazy_model import LazyValidatedModel
from typing import Optional, Set
from typing_extensions import Self

Expand All @@ -23,7 +25,7 @@ if TYPE_CHECKING:

{{/discriminator}}
{{/hasChildren}}
class {{classname}}({{#parent}}{{{.}}}{{/parent}}{{^parent}}BaseModel{{/parent}}):
class {{classname}}({{#parent}}{{{.}}}{{/parent}}{{^parent}}LazyValidatedModel{{/parent}}):
"""
{{#description}}{{{description}}}{{/description}}{{^description}}{{{classname}}}{{/description}}
""" # noqa: E501
Expand Down Expand Up @@ -86,11 +88,7 @@ class {{classname}}({{#parent}}{{{.}}}{{/parent}}{{^parent}}BaseModel{{/parent}}
{{/isEnum}}
{{/vars}}

model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
# model_config is inherited from LazyValidatedModel


{{#hasChildren}}
Expand Down Expand Up @@ -281,7 +279,7 @@ class {{classname}}({{#parent}}{{{.}}}{{/parent}}{{^parent}}BaseModel{{/parent}}

{{/isAdditionalPropertiesTrue}}
{{/disallowAdditionalPropertiesIfNotPresent}}
_obj = cls.model_validate({
_data = {
{{#allVars}}
{{#isContainer}}
{{#isArray}}
Expand Down Expand Up @@ -376,7 +374,11 @@ class {{classname}}({{#parent}}{{{.}}}{{/parent}}{{^parent}}BaseModel{{/parent}}
{{/isPrimitiveType}}
{{/isContainer}}
{{/allVars}}
})
}
try:
_obj = cls.model_validate(_data)
except ValidationError as _val_error:
_obj = cls._lazy_construct(_data, _val_error)
{{#isAdditionalPropertiesTrue}}
# store additional fields in additional_properties
for _key in obj.keys():
Expand Down
8 changes: 3 additions & 5 deletions openapi/templates/model_oneof.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import pprint
{{{.}}}
{{/vendorExtensions.x-py-model-imports}}
from pydantic import StrictStr, Field
from rapidata.api_client.lazy_model import LazyValidatedModel
from typing import Union, List, Set, Optional, Dict
from typing_extensions import Literal, Self

{{#lambda.uppercase}}{{{classname}}}{{/lambda.uppercase}}_ONE_OF_SCHEMAS = [{{#oneOf}}"{{.}}"{{^-last}}, {{/-last}}{{/oneOf}}]

class {{classname}}({{#parent}}{{{.}}}{{/parent}}{{^parent}}BaseModel{{/parent}}):
class {{classname}}({{#parent}}{{{.}}}{{/parent}}{{^parent}}LazyValidatedModel{{/parent}}):
"""
{{{description}}}{{^description}}{{{classname}}}{{/description}}
"""
Expand All @@ -24,10 +25,7 @@ class {{classname}}({{#parent}}{{{.}}}{{/parent}}{{^parent}}BaseModel{{/parent}}
actual_instance: Optional[Union[{{#oneOf}}{{{.}}}{{^-last}}, {{/-last}}{{/oneOf}}]] = None
one_of_schemas: Set[str] = { {{#oneOf}}"{{.}}"{{^-last}}, {{/-last}}{{/oneOf}} }

model_config = ConfigDict(
validate_assignment=True,
protected_namespaces=(),
)
# model_config is inherited from LazyValidatedModel

{{#discriminator}}

Expand Down
119 changes: 119 additions & 0 deletions src/rapidata/api_client/lazy_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Lazy-validated Pydantic base model for generated API models.

Replaces ``BaseModel`` as the parent of every generated model so that
construction never raises on a type mismatch. Instead, per-field
validation errors are logged, recorded on the current OpenTelemetry
span, and stored on the instance. A ``TypeError`` is raised only when
the caller actually *accesses* a field whose value failed validation.

This means backend schema changes that affect unused fields no longer
break SDK callers, while type-safety is preserved for fields that are
read.
"""

from __future__ import annotations

from typing import Any, Dict, Optional

from pydantic import BaseModel, ConfigDict, ValidationError

from rapidata.rapidata_client.config import logger, tracer


class LazyValidatedModel(BaseModel):
"""BaseModel subclass with deferred per-field validation."""

model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)

# ------------------------------------------------------------------
# Normal construction path – mark the instance as error-free
# ------------------------------------------------------------------
def __init__(self, **data: Any) -> None:
super().__init__(**data)
# Fast-path flag so __getattribute__ can skip the dict lookup
# for the vast majority of instances that validated cleanly.
object.__setattr__(self, "_field_validation_errors", {})

# ------------------------------------------------------------------
# Fallback construction – called when model_validate raises
# ------------------------------------------------------------------
@classmethod
def _lazy_construct(
cls,
data: Dict[str, Any],
error: ValidationError,
) -> "LazyValidatedModel":
"""Build the model via ``model_construct`` and store per-field errors."""

# --- alias → python field name mapping ---
alias_to_field: Dict[str, str] = {}
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

# --- 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():
loc = err.get("loc", ())
if loc:
alias_key = str(loc[0])
python_name = alias_to_field.get(alias_key, alias_key)
field_errors[python_name] = err

# --- observability: log error + fail the trace ---
error_fields = list(field_errors.keys())
logger.warning(
"Validation failed for %s – mismatched fields: %s",
cls.__name__,
error_fields,
exc_info=error,
)

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

# --- construct without validation ---
instance = cls.model_construct(**construct_kwargs)
object.__setattr__(instance, "_field_validation_errors", field_errors)
return instance

# ------------------------------------------------------------------
# Access-time guard – raise only when a bad field is actually read
# ------------------------------------------------------------------
def __getattribute__(self, name: str) -> Any:
# Only intercept known model fields; everything else takes the
# fast super() path (Pydantic internals, dunder attrs, methods).
# Use getattr (MRO-aware) rather than type(self).__dict__.get():
# in Pydantic v2, fields are exposed via the `model_fields`
# class-property and are NOT present in the class's own __dict__,
# so a __dict__ lookup always misses and the guard never fires.
model_fields = getattr(type(self), "model_fields", None)
if model_fields is not None and name in model_fields:
# model_construct() bypasses __init__, so `_field_validation_errors`
# may not be set on every instance. Treat missing as "no errors".
errors: Optional[Dict[str, dict]]
try:
errors = object.__getattribute__(
self, "_field_validation_errors"
)
except AttributeError:
errors = None
if errors and name in errors:
err = errors[name]
raise TypeError(
f"Field '{name}' on {type(self).__name__} has an unexpected "
f"type from the backend: {err.get('msg', err)}"
)
return super().__getattribute__(name)
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@
from rapidata.api_client.models.i_asset_input import IAssetInput
from rapidata.api_client.models.i_example_payload import IExamplePayload
from rapidata.api_client.models.i_example_truth import IExampleTruth
from pydantic import ValidationError
from rapidata.api_client.lazy_model import LazyValidatedModel
from typing import Optional, Set
from typing_extensions import Self

class AddExampleToAudienceEndpointInput(BaseModel):
class AddExampleToAudienceEndpointInput(LazyValidatedModel):
"""
Input model for adding an example to an audience.
""" # noqa: E501
Expand All @@ -42,11 +44,7 @@ class AddExampleToAudienceEndpointInput(BaseModel):
is_common_sense: Optional[StrictBool] = Field(default=None, description="Whether this example should be treated as commonsense validation. When true, incorrect answers are not accepted and the example affects global score. When null, AI auto-detection will determine if the example is common sense.", alias="isCommonSense")
__properties: ClassVar[List[str]] = ["asset", "payload", "truth", "randomCorrectProbability", "explanation", "context", "contextAsset", "sortIndex", "featureFlags", "isCommonSense"]

model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
# model_config is inherited from LazyValidatedModel


def to_str(self) -> str:
Expand Down Expand Up @@ -131,7 +129,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
if not isinstance(obj, dict):
return cls.model_validate(obj)

_obj = cls.model_validate({
_data = {
"asset": IAssetInput.from_dict(obj["asset"]) if obj.get("asset") is not None else None,
"payload": IExamplePayload.from_dict(obj["payload"]) if obj.get("payload") is not None else None,
"truth": IExampleTruth.from_dict(obj["truth"]) if obj.get("truth") is not None else None,
Expand All @@ -142,7 +140,11 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"sortIndex": obj.get("sortIndex"),
"featureFlags": [FeatureFlag.from_dict(_item) for _item in obj["featureFlags"]] if obj.get("featureFlags") is not None else None,
"isCommonSense": obj.get("isCommonSense")
})
}
try:
_obj = cls.model_validate(_data)
except ValidationError as _val_error:
_obj = cls._lazy_construct(_data, _val_error)
return _obj


18 changes: 10 additions & 8 deletions src/rapidata/api_client/models/add_user_response_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@
from typing import Any, ClassVar, Dict, List, Optional, Union
from rapidata.api_client.models.i_validation_truth import IValidationTruth
from rapidata.api_client.models.translated_string import TranslatedString
from pydantic import ValidationError
from rapidata.api_client.lazy_model import LazyValidatedModel
from typing import Optional, Set
from typing_extensions import Self

class AddUserResponseResult(BaseModel):
class AddUserResponseResult(LazyValidatedModel):
"""
AddUserResponseResult
""" # noqa: E501
Expand All @@ -34,11 +36,7 @@ class AddUserResponseResult(BaseModel):
user_score: Union[StrictFloat, StrictInt] = Field(alias="userScore")
__properties: ClassVar[List[str]] = ["isAccepted", "validationTruth", "explanation", "userScore"]

model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
# model_config is inherited from LazyValidatedModel


def to_str(self) -> str:
Expand Down Expand Up @@ -100,12 +98,16 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
if not isinstance(obj, dict):
return cls.model_validate(obj)

_obj = cls.model_validate({
_data = {
"isAccepted": obj.get("isAccepted"),
"validationTruth": IValidationTruth.from_dict(obj["validationTruth"]) if obj.get("validationTruth") is not None else None,
"explanation": TranslatedString.from_dict(obj["explanation"]) if obj.get("explanation") is not None else None,
"userScore": obj.get("userScore")
})
}
try:
_obj = cls.model_validate(_data)
except ValidationError as _val_error:
_obj = cls._lazy_construct(_data, _val_error)
return _obj


18 changes: 10 additions & 8 deletions src/rapidata/api_client/models/add_validation_rapid_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@
from rapidata.api_client.models.i_asset_input import IAssetInput
from rapidata.api_client.models.i_rapid_payload import IRapidPayload
from rapidata.api_client.models.i_validation_truth_model import IValidationTruthModel
from pydantic import ValidationError
from rapidata.api_client.lazy_model import LazyValidatedModel
from typing import Optional, Set
from typing_extensions import Self

class AddValidationRapidModel(BaseModel):
class AddValidationRapidModel(LazyValidatedModel):
"""
The model for adding a validation rapid with asset in JSON body.
""" # noqa: E501
Expand All @@ -40,11 +42,7 @@ class AddValidationRapidModel(BaseModel):
feature_flags: Optional[List[FeatureFlag]] = Field(default=None, alias="featureFlags")
__properties: ClassVar[List[str]] = ["asset", "payload", "truth", "randomCorrectProbability", "explanation", "context", "contextAsset", "featureFlags"]

model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
# model_config is inherited from LazyValidatedModel


def to_str(self) -> str:
Expand Down Expand Up @@ -124,7 +122,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
if not isinstance(obj, dict):
return cls.model_validate(obj)

_obj = cls.model_validate({
_data = {
"asset": IAssetInput.from_dict(obj["asset"]) if obj.get("asset") is not None else None,
"payload": IRapidPayload.from_dict(obj["payload"]) if obj.get("payload") is not None else None,
"truth": IValidationTruthModel.from_dict(obj["truth"]) if obj.get("truth") is not None else None,
Expand All @@ -133,7 +131,11 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"context": obj.get("context"),
"contextAsset": IAssetInput.from_dict(obj["contextAsset"]) if obj.get("contextAsset") is not None else None,
"featureFlags": [FeatureFlag.from_dict(_item) for _item in obj["featureFlags"]] if obj.get("featureFlags") is not None else None
})
}
try:
_obj = cls.model_validate(_data)
except ValidationError as _val_error:
_obj = cls._lazy_construct(_data, _val_error)
return _obj


Loading
Loading