|
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 | + """Stable representation of a failed pydantic ``model_validate*`` |
| 190 | + call. Passed to :meth:`BackendAIModel.build_validation_error` so |
| 191 | + subclasses can produce a domain-specific error without depending |
| 192 | + on pydantic's ``ValidationError`` internals. |
| 193 | +
|
| 194 | + ``summary`` is the multi-line human-readable form (the same string |
| 195 | + ``str(pydantic.ValidationError)`` produces). ``errors`` is the |
| 196 | + per-field list produced by ``exc.errors()``; each entry carries |
| 197 | + ``type``/``loc``/``msg``/``input``/``ctx``/``url``. |
| 198 | + """ |
| 199 | + |
| 200 | + summary: str |
| 201 | + errors: list[ErrorDetails] |
| 202 | + |
| 203 | + |
184 | 204 | class BackendAIModel(BaseModel): |
185 | 205 | """Project-wide Pydantic base for Backend.AI models. |
186 | 206 |
|
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. |
| 207 | + Overrides ``model_validate`` / ``model_validate_json`` so a |
| 208 | + ``ValidationError`` is auto-mapped to a :class:`BackendAIError` |
| 209 | + (HTTP 4xx) carrying the structured per-field error list. Call |
| 210 | + sites get a clean 4xx without repeating ``try / except |
| 211 | + ValidationError`` at every site. |
| 212 | +
|
| 213 | + The exception instance is produced by :meth:`build_validation_error`, |
| 214 | + which defaults to :class:`BackendAIModelValidationFailed`. Subclasses |
| 215 | + override the classmethod to surface a domain-specific 400 directly |
| 216 | + (no caller-side re-wrap needed). The override receives a |
| 217 | + :class:`ModelValidationFailureInfo` (not a raw ``pydantic.ValidationError``) |
| 218 | + so subclasses do not depend on pydantic's exception API:: |
| 219 | +
|
| 220 | + class MyConfig(BackendAIModel): |
| 221 | + ... |
| 222 | +
|
| 223 | + @override |
| 224 | + @classmethod |
| 225 | + def build_validation_error( |
| 226 | + cls, info: ModelValidationFailureInfo |
| 227 | + ) -> BackendAIError: |
| 228 | + return MyConfigParseError( |
| 229 | + extra_msg=info.summary, |
| 230 | + extra_data={"errors": info.errors}, |
| 231 | + ) |
192 | 232 |
|
193 | 233 | Notes: |
194 | 234 |
|
195 | 235 | * Pydantic v2 routes nested validation through |
196 | 236 | ``__pydantic_validator__`` directly, not the classmethod, so this |
197 | 237 | override only affects explicit ``Model.model_validate(...)`` |
198 | 238 | 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. |
| 239 | + * The ``__init__`` constructor stays untouched. Pydantic v2 invokes |
| 240 | + ``__init__`` while constructing nested models inside an outer |
| 241 | + ``model_validate`` call, so converting the constructor's |
| 242 | + ``ValidationError`` here would break the outer validator's |
| 243 | + ability to aggregate field errors with proper ``loc`` paths. |
| 244 | + Direct ``Model(field=...)`` construction therefore still raises |
| 245 | + stock ``pydantic.ValidationError``. |
203 | 246 | """ |
204 | 247 |
|
| 248 | + @classmethod |
| 249 | + def build_validation_error(cls, info: ModelValidationFailureInfo) -> BackendAIError: |
| 250 | + """Produce the :class:`BackendAIError` to raise when a |
| 251 | + ``model_validate*`` call fails. Default surfaces a generic |
| 252 | + :class:`BackendAIModelValidationFailed`; override on subclasses |
| 253 | + to inject a domain-specific 400.""" |
| 254 | + return BackendAIModelValidationFailed( |
| 255 | + extra_msg=info.summary, |
| 256 | + extra_data={"errors": info.errors}, |
| 257 | + ) |
| 258 | + |
| 259 | + @classmethod |
| 260 | + def _validation_failure_info(cls, exc: ValidationError) -> ModelValidationFailureInfo: |
| 261 | + return ModelValidationFailureInfo(summary=str(exc), errors=exc.errors()) |
| 262 | + |
205 | 263 | @classmethod |
206 | 264 | def model_validate(cls, *args: Any, **kwargs: Any) -> Self: |
207 | 265 | try: |
208 | 266 | return super().model_validate(*args, **kwargs) |
209 | 267 | except ValidationError as e: |
210 | | - raise BackendAIModelValidationFailed( |
211 | | - extra_msg=str(e), |
212 | | - extra_data={"errors": e.errors()}, |
213 | | - ) from e |
| 268 | + raise cls.build_validation_error(cls._validation_failure_info(e)) from e |
214 | 269 |
|
215 | 270 | @classmethod |
216 | 271 | def model_validate_json(cls, *args: Any, **kwargs: Any) -> Self: |
217 | 272 | try: |
218 | 273 | return super().model_validate_json(*args, **kwargs) |
219 | 274 | 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 |
| 275 | + raise cls.build_validation_error(cls._validation_failure_info(e)) from e |
234 | 276 |
|
235 | 277 |
|
236 | 278 | class aobject: |
|
0 commit comments