|
51 | 51 | TypeAdapter, |
52 | 52 | ValidationError, |
53 | 53 | ) |
| 54 | +from pydantic_core import ErrorDetails |
54 | 55 | from redis.asyncio import Redis |
55 | 56 |
|
56 | 57 | from .defs import UNKNOWN_CONTAINER_ID, RedisRole |
57 | 58 | from .exception import ( |
| 59 | + BackendAIError, |
58 | 60 | BackendAIModelValidationFailed, |
59 | 61 | GenericNotImplementedError, |
60 | 62 | InvalidIpAddressValue, |
|
116 | 118 | "MetricValue", |
117 | 119 | "ModelServiceProfile", |
118 | 120 | "ModelServiceStatus", |
| 121 | + "ModelValidationFailureInfo", |
119 | 122 | "MountExpression", |
120 | 123 | "MountInfoEntry", |
121 | 124 | "MountPermission", |
|
181 | 184 | ) |
182 | 185 |
|
183 | 186 |
|
| 187 | +@dataclass(frozen=True) |
| 188 | +class ModelValidationFailureInfo: |
| 189 | + """Pydantic-decoupled view of a failed ``model_validate*`` call, |
| 190 | + passed to :meth:`BackendAIModel.build_validation_error`. |
| 191 | +
|
| 192 | + ``summary`` is ``str(pydantic.ValidationError)``; ``errors`` is |
| 193 | + ``exc.errors()`` as-is. |
| 194 | + """ |
| 195 | + |
| 196 | + summary: str |
| 197 | + errors: list[ErrorDetails] |
| 198 | + |
| 199 | + |
184 | 200 | class BackendAIModel(BaseModel): |
185 | | - """Project-wide Pydantic base for Backend.AI models. |
186 | | -
|
187 | | - Overrides ``model_validate`` / ``model_validate_json`` / |
188 | | - ``model_validate_strings`` so a ``ValidationError`` is auto-mapped |
189 | | - to :class:`BackendAIModelValidationFailed` (HTTP 400) carrying the structured |
190 | | - per-field error list. Call sites get a clean 4xx without repeating |
191 | | - ``try / except ValidationError`` at every site. |
192 | | -
|
193 | | - Notes: |
194 | | -
|
195 | | - * Pydantic v2 routes nested validation through |
196 | | - ``__pydantic_validator__`` directly, not the classmethod, so this |
197 | | - override only affects explicit ``Model.model_validate(...)`` |
198 | | - calls — nested models are unaffected. |
199 | | - * The ``__init__`` constructor and the compiled validator stay |
200 | | - untouched, so internal default-value construction |
201 | | - (``Model()`` / ``Model(field=...)``) still works exactly like |
202 | | - stock Pydantic. |
| 201 | + """Pydantic base whose ``model_validate`` / ``model_validate_json`` |
| 202 | + auto-map ``ValidationError`` to a :class:`BackendAIError` (HTTP 4xx) |
| 203 | + via :meth:`build_validation_error`. Subclasses override the |
| 204 | + classmethod to inject a domain-specific 400:: |
| 205 | +
|
| 206 | + class MyConfig(BackendAIModel): |
| 207 | + @override |
| 208 | + @classmethod |
| 209 | + def build_validation_error( |
| 210 | + cls, info: ModelValidationFailureInfo |
| 211 | + ) -> BackendAIError: |
| 212 | + return MyConfigParseError( |
| 213 | + extra_msg=info.summary, |
| 214 | + extra_data={"errors": info.errors}, |
| 215 | + ) |
| 216 | +
|
| 217 | + ``__init__`` is left alone: pydantic v2 invokes nested models' |
| 218 | + ``__init__`` from inside the outer validator, so converting there |
| 219 | + would break ``loc``-path aggregation. Direct ``Model(field=...)`` |
| 220 | + construction therefore still raises stock ``pydantic.ValidationError``; |
| 221 | + switch the call site to ``Model.model_validate({...})`` to opt into |
| 222 | + the override path. |
203 | 223 | """ |
204 | 224 |
|
| 225 | + @classmethod |
| 226 | + def build_validation_error(cls, info: ModelValidationFailureInfo) -> BackendAIError: |
| 227 | + """Default override raising the generic |
| 228 | + :class:`BackendAIModelValidationFailed`.""" |
| 229 | + return BackendAIModelValidationFailed( |
| 230 | + extra_msg=info.summary, |
| 231 | + extra_data={"errors": info.errors}, |
| 232 | + ) |
| 233 | + |
| 234 | + @classmethod |
| 235 | + def _validation_failure_info(cls, exc: ValidationError) -> ModelValidationFailureInfo: |
| 236 | + return ModelValidationFailureInfo(summary=str(exc), errors=exc.errors()) |
| 237 | + |
205 | 238 | @classmethod |
206 | 239 | def model_validate(cls, *args: Any, **kwargs: Any) -> Self: |
207 | 240 | try: |
208 | 241 | return super().model_validate(*args, **kwargs) |
209 | 242 | except ValidationError as e: |
210 | | - raise BackendAIModelValidationFailed( |
211 | | - extra_msg=str(e), |
212 | | - extra_data={"errors": e.errors()}, |
213 | | - ) from e |
| 243 | + raise cls.build_validation_error(cls._validation_failure_info(e)) from e |
214 | 244 |
|
215 | 245 | @classmethod |
216 | 246 | def model_validate_json(cls, *args: Any, **kwargs: Any) -> Self: |
217 | 247 | try: |
218 | 248 | return super().model_validate_json(*args, **kwargs) |
219 | 249 | except ValidationError as e: |
220 | | - raise BackendAIModelValidationFailed( |
221 | | - extra_msg=str(e), |
222 | | - extra_data={"errors": e.errors()}, |
223 | | - ) from e |
224 | | - |
225 | | - @classmethod |
226 | | - def model_validate_strings(cls, *args: Any, **kwargs: Any) -> Self: |
227 | | - try: |
228 | | - return super().model_validate_strings(*args, **kwargs) |
229 | | - except ValidationError as e: |
230 | | - raise BackendAIModelValidationFailed( |
231 | | - extra_msg=str(e), |
232 | | - extra_data={"errors": e.errors()}, |
233 | | - ) from e |
| 250 | + raise cls.build_validation_error(cls._validation_failure_info(e)) from e |
234 | 251 |
|
235 | 252 |
|
236 | 253 | class aobject: |
|
0 commit comments