pydantic-marshmallow 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pydantic_marshmallow/__init__.py +79 -0
- pydantic_marshmallow/bridge.py +1187 -0
- pydantic_marshmallow/errors.py +154 -0
- pydantic_marshmallow/field_conversion.py +137 -0
- pydantic_marshmallow/py.typed +2 -0
- pydantic_marshmallow/type_mapping.py +138 -0
- pydantic_marshmallow/validators.py +207 -0
- pydantic_marshmallow-1.0.0.dist-info/METADATA +367 -0
- pydantic_marshmallow-1.0.0.dist-info/RECORD +12 -0
- pydantic_marshmallow-1.0.0.dist-info/WHEEL +5 -0
- pydantic_marshmallow-1.0.0.dist-info/licenses/LICENSE +21 -0
- pydantic_marshmallow-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Error handling utilities for the Marshmallow-Pydantic bridge.
|
|
3
|
+
|
|
4
|
+
This module provides:
|
|
5
|
+
- BridgeValidationError: Exception with valid_data tracking for partial success
|
|
6
|
+
- Error path building and formatting utilities for Pydantic→Marshmallow conversion
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from marshmallow.exceptions import ValidationError as MarshmallowValidationError
|
|
14
|
+
from pydantic import BaseModel, ValidationError as PydanticValidationError
|
|
15
|
+
from pydantic_core import ErrorDetails
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BridgeValidationError(MarshmallowValidationError):
|
|
19
|
+
"""
|
|
20
|
+
Validation error that bridges Marshmallow and Pydantic error formats.
|
|
21
|
+
|
|
22
|
+
Extends Marshmallow's ValidationError to track partially valid data,
|
|
23
|
+
enabling graceful handling of multi-field validation failures.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
messages: Dict of field -> error messages (Marshmallow format)
|
|
27
|
+
valid_data: Dict of successfully validated fields
|
|
28
|
+
data: Original input data
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
message: str | list[Any] | dict[str, Any],
|
|
34
|
+
field_name: str = "_schema",
|
|
35
|
+
data: Any | None = None,
|
|
36
|
+
valid_data: dict[str, Any] | None = None,
|
|
37
|
+
**kwargs: Any,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.data = data
|
|
40
|
+
self.valid_data = valid_data or {}
|
|
41
|
+
super().__init__(message, field_name, data, valid_data=valid_data or {}, **kwargs)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_error_path(loc: tuple[Any, ...]) -> str:
|
|
45
|
+
"""
|
|
46
|
+
Build a dotted error path from Pydantic's location tuple.
|
|
47
|
+
|
|
48
|
+
Converts Pydantic's error location format to Marshmallow's dotted path format.
|
|
49
|
+
Handles collection indices like ("items", 0, "name") -> "items.0.name".
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
loc: Pydantic error location tuple (e.g., ('addresses', 0, 'zip_code'))
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Dotted path string (e.g., 'addresses.0.zip_code')
|
|
56
|
+
"""
|
|
57
|
+
if len(loc) == 1:
|
|
58
|
+
return str(loc[0])
|
|
59
|
+
return ".".join(str(part) for part in loc)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def format_pydantic_error(
|
|
63
|
+
error: ErrorDetails,
|
|
64
|
+
model_class: type[BaseModel] | None = None,
|
|
65
|
+
) -> str:
|
|
66
|
+
"""
|
|
67
|
+
Format a Pydantic error dict into a user-friendly message.
|
|
68
|
+
|
|
69
|
+
Checks for custom error messages in field metadata when model_class is provided.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
error: Pydantic ErrorDetails with 'msg', 'type', 'loc' keys
|
|
73
|
+
model_class: Optional Pydantic model for custom message lookup
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Formatted error message string
|
|
77
|
+
"""
|
|
78
|
+
msg: str = error.get("msg", "Validation error")
|
|
79
|
+
error_type: str = error.get("type", "")
|
|
80
|
+
loc = error.get("loc", ())
|
|
81
|
+
|
|
82
|
+
# Check for custom error message in model field metadata
|
|
83
|
+
if model_class and loc:
|
|
84
|
+
field_name = str(loc[0])
|
|
85
|
+
field_info = model_class.model_fields.get(field_name)
|
|
86
|
+
if field_info and field_info.json_schema_extra:
|
|
87
|
+
extra = field_info.json_schema_extra
|
|
88
|
+
if isinstance(extra, dict):
|
|
89
|
+
custom_messages = extra.get("error_messages")
|
|
90
|
+
if isinstance(custom_messages, dict):
|
|
91
|
+
if error_type in custom_messages:
|
|
92
|
+
custom_msg = custom_messages[error_type]
|
|
93
|
+
if isinstance(custom_msg, str):
|
|
94
|
+
return custom_msg
|
|
95
|
+
if "default" in custom_messages:
|
|
96
|
+
default_msg = custom_messages["default"]
|
|
97
|
+
if isinstance(default_msg, str):
|
|
98
|
+
return default_msg
|
|
99
|
+
|
|
100
|
+
return msg
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def convert_pydantic_errors(
|
|
104
|
+
pydantic_error: PydanticValidationError,
|
|
105
|
+
model_class: type[BaseModel] | None = None,
|
|
106
|
+
original_data: dict[str, Any] | None = None,
|
|
107
|
+
) -> BridgeValidationError:
|
|
108
|
+
"""
|
|
109
|
+
Convert a Pydantic ValidationError to BridgeValidationError.
|
|
110
|
+
|
|
111
|
+
Transforms Pydantic's error format to Marshmallow-compatible format,
|
|
112
|
+
tracking which fields failed and which succeeded.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
pydantic_error: The Pydantic ValidationError to convert
|
|
116
|
+
model_class: The Pydantic model class (for custom messages)
|
|
117
|
+
original_data: The original input data
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
BridgeValidationError with Marshmallow-formatted messages and valid_data
|
|
121
|
+
"""
|
|
122
|
+
errors: dict[str, Any] = {}
|
|
123
|
+
failed_fields: set[str] = set()
|
|
124
|
+
|
|
125
|
+
for error in pydantic_error.errors():
|
|
126
|
+
loc = error.get("loc", ())
|
|
127
|
+
msg = format_pydantic_error(error, model_class)
|
|
128
|
+
|
|
129
|
+
if loc:
|
|
130
|
+
field_path = build_error_path(loc)
|
|
131
|
+
failed_fields.add(str(loc[0])) # Track top-level field
|
|
132
|
+
|
|
133
|
+
if field_path in errors:
|
|
134
|
+
if isinstance(errors[field_path], list):
|
|
135
|
+
errors[field_path].append(msg)
|
|
136
|
+
else:
|
|
137
|
+
errors[field_path] = [errors[field_path], msg]
|
|
138
|
+
else:
|
|
139
|
+
errors[field_path] = [msg]
|
|
140
|
+
else:
|
|
141
|
+
errors["_schema"] = [*errors.get("_schema", []), msg]
|
|
142
|
+
|
|
143
|
+
# Build valid_data: fields that weren't in the error list
|
|
144
|
+
valid_data: dict[str, Any] = {}
|
|
145
|
+
if original_data and model_class:
|
|
146
|
+
for field_name in original_data:
|
|
147
|
+
if field_name not in failed_fields and field_name in model_class.model_fields:
|
|
148
|
+
valid_data[field_name] = original_data[field_name]
|
|
149
|
+
|
|
150
|
+
return BridgeValidationError(
|
|
151
|
+
errors,
|
|
152
|
+
data=original_data,
|
|
153
|
+
valid_data=valid_data,
|
|
154
|
+
)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Field conversion utilities for Pydantic to Marshmallow field mapping.
|
|
3
|
+
|
|
4
|
+
This module centralizes the conversion of Pydantic model fields to Marshmallow fields,
|
|
5
|
+
eliminating duplication between the metaclass, instance setup, and factory methods.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from types import UnionType
|
|
11
|
+
from typing import Any, Union, get_args, get_origin
|
|
12
|
+
|
|
13
|
+
from marshmallow import fields as ma_fields
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
from pydantic_core import PydanticUndefined
|
|
16
|
+
|
|
17
|
+
from .type_mapping import type_to_marshmallow_field
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def convert_pydantic_field(
|
|
21
|
+
field_name: str,
|
|
22
|
+
field_info: Any,
|
|
23
|
+
) -> ma_fields.Field:
|
|
24
|
+
"""
|
|
25
|
+
Convert a single Pydantic FieldInfo to a Marshmallow Field.
|
|
26
|
+
|
|
27
|
+
Handles:
|
|
28
|
+
- Type conversion via type_mapping
|
|
29
|
+
- Required status (fields without defaults are required)
|
|
30
|
+
- Default values (static and factory)
|
|
31
|
+
- Field aliases (data_key)
|
|
32
|
+
- Optional/None handling (allow_none)
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
field_name: Name of the field
|
|
36
|
+
field_info: Pydantic FieldInfo object
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Configured Marshmallow field instance
|
|
40
|
+
"""
|
|
41
|
+
annotation = field_info.annotation
|
|
42
|
+
ma_field = type_to_marshmallow_field(annotation)
|
|
43
|
+
|
|
44
|
+
# Apply default values and set required status
|
|
45
|
+
if field_info.default is not PydanticUndefined:
|
|
46
|
+
ma_field.load_default = field_info.default
|
|
47
|
+
ma_field.dump_default = field_info.default
|
|
48
|
+
ma_field.required = False
|
|
49
|
+
elif field_info.default_factory is not None:
|
|
50
|
+
ma_field.load_default = field_info.default_factory
|
|
51
|
+
ma_field.required = False
|
|
52
|
+
else:
|
|
53
|
+
# No default - field is required
|
|
54
|
+
ma_field.required = True
|
|
55
|
+
|
|
56
|
+
# Handle alias → data_key
|
|
57
|
+
if field_info.alias:
|
|
58
|
+
ma_field.data_key = field_info.alias
|
|
59
|
+
|
|
60
|
+
# Handle Optional types (X | None or Optional[X]) → allow_none
|
|
61
|
+
origin = get_origin(annotation)
|
|
62
|
+
args = get_args(annotation)
|
|
63
|
+
is_union = origin is Union or origin is UnionType
|
|
64
|
+
if origin is type(None) or (is_union and type(None) in args):
|
|
65
|
+
ma_field.allow_none = True
|
|
66
|
+
|
|
67
|
+
return ma_field
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def convert_computed_field(
|
|
71
|
+
field_name: str,
|
|
72
|
+
computed_info: Any,
|
|
73
|
+
) -> ma_fields.Field:
|
|
74
|
+
"""
|
|
75
|
+
Convert a Pydantic computed_field to a dump-only Marshmallow Field.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
field_name: Name of the computed field
|
|
79
|
+
computed_info: Pydantic ComputedFieldInfo object
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Dump-only Marshmallow field instance
|
|
83
|
+
"""
|
|
84
|
+
return_type = getattr(computed_info, 'return_type', Any)
|
|
85
|
+
ma_field = type_to_marshmallow_field(return_type)
|
|
86
|
+
ma_field.dump_only = True
|
|
87
|
+
return ma_field
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def convert_model_fields(
|
|
91
|
+
model: type[BaseModel],
|
|
92
|
+
*,
|
|
93
|
+
include: set[str] | None = None,
|
|
94
|
+
exclude: set[str] | None = None,
|
|
95
|
+
include_computed: bool = True,
|
|
96
|
+
) -> dict[str, ma_fields.Field]:
|
|
97
|
+
"""
|
|
98
|
+
Convert all fields from a Pydantic model to Marshmallow fields.
|
|
99
|
+
|
|
100
|
+
This is the main entry point for field conversion, used by:
|
|
101
|
+
- PydanticSchemaMeta.__new__()
|
|
102
|
+
- PydanticSchema._setup_fields_from_model()
|
|
103
|
+
- PydanticSchema.from_model()
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
model: Pydantic model class
|
|
107
|
+
include: Optional whitelist of field names to include
|
|
108
|
+
exclude: Optional blacklist of field names to exclude
|
|
109
|
+
include_computed: Whether to include @computed_field properties
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Dict mapping field names to Marshmallow field instances
|
|
113
|
+
"""
|
|
114
|
+
fields: dict[str, ma_fields.Field] = {}
|
|
115
|
+
exclude_set = exclude or set()
|
|
116
|
+
|
|
117
|
+
# Convert regular model fields
|
|
118
|
+
for field_name, field_info in model.model_fields.items():
|
|
119
|
+
# Apply filtering
|
|
120
|
+
if field_name in exclude_set:
|
|
121
|
+
continue
|
|
122
|
+
if include is not None and field_name not in include:
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
fields[field_name] = convert_pydantic_field(field_name, field_info)
|
|
126
|
+
|
|
127
|
+
# Convert computed fields (dump-only)
|
|
128
|
+
if include_computed and hasattr(model, 'model_computed_fields'):
|
|
129
|
+
for field_name, computed_info in model.model_computed_fields.items():
|
|
130
|
+
if field_name in exclude_set:
|
|
131
|
+
continue
|
|
132
|
+
if include is not None and field_name not in include:
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
fields[field_name] = convert_computed_field(field_name, computed_info)
|
|
136
|
+
|
|
137
|
+
return fields
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Type mapping utilities for converting Python/Pydantic types to Marshmallow fields.
|
|
3
|
+
|
|
4
|
+
This module provides type-to-field conversions, leveraging Marshmallow's native
|
|
5
|
+
TYPE_MAPPING for basic types and adding support for generic collections.
|
|
6
|
+
"""
|
|
7
|
+
from enum import Enum as PyEnum
|
|
8
|
+
from types import UnionType
|
|
9
|
+
from typing import Any, Literal, Union, get_args, get_origin
|
|
10
|
+
|
|
11
|
+
from marshmallow import Schema, fields as ma_fields
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
# Track models being processed to detect recursion
|
|
15
|
+
_processing_models: set[type[Any]] = set()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def type_to_marshmallow_field(type_hint: Any) -> ma_fields.Field:
|
|
19
|
+
"""
|
|
20
|
+
Map a Python type to a Marshmallow field instance.
|
|
21
|
+
|
|
22
|
+
Uses Marshmallow's native TYPE_MAPPING for basic types (str, int, datetime, etc.)
|
|
23
|
+
and adds support for:
|
|
24
|
+
- Generic collections (list[T], dict[K, V], set[T], frozenset[T])
|
|
25
|
+
- Optional types (T | None)
|
|
26
|
+
- Union types (X | Y)
|
|
27
|
+
- Tuple types
|
|
28
|
+
- Nested Pydantic models
|
|
29
|
+
- Enums
|
|
30
|
+
- Literal types
|
|
31
|
+
- Pydantic special types (EmailStr, AnyUrl, etc.)
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
type_hint: A Python type annotation
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
An appropriate Marshmallow field instance
|
|
38
|
+
"""
|
|
39
|
+
origin = get_origin(type_hint)
|
|
40
|
+
args = get_args(type_hint)
|
|
41
|
+
|
|
42
|
+
# Handle NoneType
|
|
43
|
+
if type_hint is type(None):
|
|
44
|
+
return ma_fields.Raw(allow_none=True)
|
|
45
|
+
|
|
46
|
+
# Handle Literal types
|
|
47
|
+
if origin is Literal:
|
|
48
|
+
# For single-value Literal, could be a constant
|
|
49
|
+
if args and len(args) == 1:
|
|
50
|
+
# Use Raw with validation - Pydantic will enforce the literal
|
|
51
|
+
return ma_fields.Raw()
|
|
52
|
+
return ma_fields.Raw()
|
|
53
|
+
|
|
54
|
+
# Handle Union (including Optional) - supports both Union[X, Y] and X | Y syntax
|
|
55
|
+
if origin is Union or origin is UnionType:
|
|
56
|
+
non_none_args = [a for a in args if a is not type(None)]
|
|
57
|
+
if len(non_none_args) == 1:
|
|
58
|
+
field = type_to_marshmallow_field(non_none_args[0])
|
|
59
|
+
field.allow_none = True
|
|
60
|
+
return field
|
|
61
|
+
return ma_fields.Raw(allow_none=True)
|
|
62
|
+
|
|
63
|
+
# Handle Enum types
|
|
64
|
+
if isinstance(type_hint, type) and issubclass(type_hint, PyEnum):
|
|
65
|
+
return ma_fields.Enum(type_hint)
|
|
66
|
+
|
|
67
|
+
# Handle nested Pydantic models - use Nested with a dynamically created schema
|
|
68
|
+
if isinstance(type_hint, type) and issubclass(type_hint, BaseModel):
|
|
69
|
+
# Import here to avoid circular imports
|
|
70
|
+
from pydantic_marshmallow.bridge import PydanticSchema
|
|
71
|
+
|
|
72
|
+
# Check if we're already processing this model (recursion detection)
|
|
73
|
+
# Use a module-level set to track models being processed
|
|
74
|
+
if type_hint in _processing_models:
|
|
75
|
+
# Self-referential model - use Raw to avoid infinite recursion
|
|
76
|
+
# Pydantic will still handle the validation correctly
|
|
77
|
+
return ma_fields.Raw()
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
_processing_models.add(type_hint)
|
|
81
|
+
# Create a nested schema class for this model
|
|
82
|
+
nested_schema = PydanticSchema.from_model(type_hint)
|
|
83
|
+
return ma_fields.Nested(nested_schema)
|
|
84
|
+
finally:
|
|
85
|
+
_processing_models.discard(type_hint)
|
|
86
|
+
|
|
87
|
+
# Handle list[T]
|
|
88
|
+
if origin is list:
|
|
89
|
+
inner: ma_fields.Field = ma_fields.Raw()
|
|
90
|
+
if args:
|
|
91
|
+
inner = type_to_marshmallow_field(args[0])
|
|
92
|
+
return ma_fields.List(inner)
|
|
93
|
+
|
|
94
|
+
# Handle dict[K, V]
|
|
95
|
+
if origin is dict:
|
|
96
|
+
key_field: ma_fields.Field = ma_fields.String()
|
|
97
|
+
value_field: ma_fields.Field = ma_fields.Raw()
|
|
98
|
+
if args and len(args) >= 2:
|
|
99
|
+
key_field = type_to_marshmallow_field(args[0])
|
|
100
|
+
value_field = type_to_marshmallow_field(args[1])
|
|
101
|
+
return ma_fields.Dict(keys=key_field, values=value_field)
|
|
102
|
+
|
|
103
|
+
# Handle set[T] and frozenset[T] - convert to List in Marshmallow
|
|
104
|
+
if origin in (set, frozenset):
|
|
105
|
+
inner_set: ma_fields.Field = ma_fields.Raw()
|
|
106
|
+
if args:
|
|
107
|
+
inner_set = type_to_marshmallow_field(args[0])
|
|
108
|
+
return ma_fields.List(inner_set)
|
|
109
|
+
|
|
110
|
+
# Handle Tuple - use Marshmallow's Tuple field if available
|
|
111
|
+
if origin is tuple:
|
|
112
|
+
if args:
|
|
113
|
+
# Fixed-length tuple with specific types
|
|
114
|
+
tuple_fields = [type_to_marshmallow_field(arg) for arg in args if arg is not ...]
|
|
115
|
+
if tuple_fields:
|
|
116
|
+
return ma_fields.Tuple(tuple_fields=tuple(tuple_fields))
|
|
117
|
+
return ma_fields.List(ma_fields.Raw())
|
|
118
|
+
|
|
119
|
+
# Check for Pydantic special types by module
|
|
120
|
+
type_module = getattr(type_hint, '__module__', '')
|
|
121
|
+
type_name = getattr(type_hint, '__name__', str(type_hint))
|
|
122
|
+
|
|
123
|
+
# Handle Pydantic networking types
|
|
124
|
+
if 'pydantic' in type_module:
|
|
125
|
+
if 'EmailStr' in type_name or type_name == 'EmailStr':
|
|
126
|
+
return ma_fields.Email()
|
|
127
|
+
url_types = ('Url', 'URL', 'HttpUrl', 'AnyUrl')
|
|
128
|
+
if any(ut in type_name for ut in url_types):
|
|
129
|
+
return ma_fields.URL()
|
|
130
|
+
if 'IP' in type_name:
|
|
131
|
+
# IP field exists in marshmallow but may not have type stubs
|
|
132
|
+
return ma_fields.IP() # type: ignore[no-untyped-call]
|
|
133
|
+
|
|
134
|
+
# Use Marshmallow's native TYPE_MAPPING for basic types
|
|
135
|
+
# This ensures we stay in sync with Marshmallow's type handling
|
|
136
|
+
resolved = origin if origin else type_hint
|
|
137
|
+
field_cls = Schema.TYPE_MAPPING.get(resolved, ma_fields.Raw)
|
|
138
|
+
return field_cls()
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom validator decorators for PydanticSchema.
|
|
3
|
+
|
|
4
|
+
These provide backwards-compatible validators that work alongside Marshmallow's
|
|
5
|
+
native @validates and @validates_schema decorators. In most cases, users should
|
|
6
|
+
prefer Marshmallow's native decorators (from marshmallow import validates).
|
|
7
|
+
|
|
8
|
+
Note: These custom decorators are provided for scenarios where additional
|
|
9
|
+
functionality beyond Marshmallow's native validators is needed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any, Protocol, TypeVar, cast, overload
|
|
15
|
+
|
|
16
|
+
F = TypeVar("F", bound="ValidatorFunc")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ValidatorFunc(Protocol):
|
|
20
|
+
"""Protocol for validator functions with metadata attributes."""
|
|
21
|
+
|
|
22
|
+
_validates_field: str
|
|
23
|
+
_validates_schema: bool
|
|
24
|
+
_pass_many: bool
|
|
25
|
+
_skip_on_field_errors: bool
|
|
26
|
+
|
|
27
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
28
|
+
"""Call the validator function."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FieldValidatorFunc(Protocol):
|
|
33
|
+
"""Protocol for field validator functions."""
|
|
34
|
+
|
|
35
|
+
_validates_field: str
|
|
36
|
+
|
|
37
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
38
|
+
"""Call the field validator."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SchemaValidatorFunc(Protocol):
|
|
43
|
+
"""Protocol for schema validator functions."""
|
|
44
|
+
|
|
45
|
+
_validates_schema: bool
|
|
46
|
+
_pass_many: bool
|
|
47
|
+
_skip_on_field_errors: bool
|
|
48
|
+
|
|
49
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
50
|
+
"""Call the schema validator."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Registry for custom validators (used for backwards compatibility)
|
|
55
|
+
_field_validators: dict[tuple[type[Any], str], list[FieldValidatorFunc]] = {}
|
|
56
|
+
_schema_validators: dict[type[Any], list[SchemaValidatorFunc]] = {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validates(field_name: str) -> ValidatesDecorator:
|
|
60
|
+
"""
|
|
61
|
+
Decorator to register a field validator on a PydanticSchema.
|
|
62
|
+
|
|
63
|
+
The decorated method receives the field value and should raise
|
|
64
|
+
ValidationError if validation fails. Compatible with Marshmallow's
|
|
65
|
+
@validates decorator.
|
|
66
|
+
|
|
67
|
+
Note: In most cases, prefer `from marshmallow import validates` which
|
|
68
|
+
works natively with PydanticSchema via Marshmallow's hook system.
|
|
69
|
+
|
|
70
|
+
Example:
|
|
71
|
+
class UserSchema(PydanticSchema[User]):
|
|
72
|
+
class Meta:
|
|
73
|
+
model = User
|
|
74
|
+
|
|
75
|
+
@validates("name")
|
|
76
|
+
def validate_name(self, value):
|
|
77
|
+
if value.lower() == "admin":
|
|
78
|
+
raise ValidationError("Cannot use 'admin' as name")
|
|
79
|
+
"""
|
|
80
|
+
return ValidatesDecorator(field_name)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ValidatesDecorator:
|
|
84
|
+
"""Decorator class for @validates that preserves function types."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, field_name: str) -> None:
|
|
87
|
+
self.field_name = field_name
|
|
88
|
+
|
|
89
|
+
def __call__(self, fn: F) -> F:
|
|
90
|
+
"""Decorate a function as a field validator."""
|
|
91
|
+
# Use object.__setattr__ to set attributes on the function
|
|
92
|
+
object.__setattr__(fn, "_validates_field", self.field_name)
|
|
93
|
+
return fn
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@overload
|
|
97
|
+
def validates_schema(fn: F) -> F:
|
|
98
|
+
...
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@overload
|
|
102
|
+
def validates_schema(
|
|
103
|
+
fn: None = None,
|
|
104
|
+
*,
|
|
105
|
+
pass_many: bool = False,
|
|
106
|
+
skip_on_field_errors: bool = True,
|
|
107
|
+
) -> ValidatesSchemaDecorator:
|
|
108
|
+
...
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def validates_schema(
|
|
112
|
+
fn: F | None = None,
|
|
113
|
+
*,
|
|
114
|
+
pass_many: bool = False,
|
|
115
|
+
skip_on_field_errors: bool = True,
|
|
116
|
+
) -> F | ValidatesSchemaDecorator:
|
|
117
|
+
"""
|
|
118
|
+
Decorator to register a schema-level validator on a PydanticSchema.
|
|
119
|
+
|
|
120
|
+
The decorated method receives the full data dict and should raise
|
|
121
|
+
ValidationError if validation fails. Compatible with Marshmallow's
|
|
122
|
+
@validates_schema decorator.
|
|
123
|
+
|
|
124
|
+
Note: In most cases, prefer `from marshmallow import validates_schema`
|
|
125
|
+
which works natively with PydanticSchema via Marshmallow's hook system.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
fn: The validator function (when used without parentheses)
|
|
129
|
+
pass_many: Whether to pass the full collection when many=True
|
|
130
|
+
skip_on_field_errors: Skip this validator if field errors exist
|
|
131
|
+
|
|
132
|
+
Example:
|
|
133
|
+
class UserSchema(PydanticSchema[User]):
|
|
134
|
+
class Meta:
|
|
135
|
+
model = User
|
|
136
|
+
|
|
137
|
+
@validates_schema
|
|
138
|
+
def validate_user(self, data, **kwargs):
|
|
139
|
+
if data.get("password") != data.get("confirm_password"):
|
|
140
|
+
raise ValidationError("Passwords must match", "_schema")
|
|
141
|
+
"""
|
|
142
|
+
decorator = ValidatesSchemaDecorator(pass_many, skip_on_field_errors)
|
|
143
|
+
if fn is not None:
|
|
144
|
+
return decorator(fn)
|
|
145
|
+
return decorator
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ValidatesSchemaDecorator:
|
|
149
|
+
"""Decorator class for @validates_schema that preserves function types."""
|
|
150
|
+
|
|
151
|
+
def __init__(self, pass_many: bool, skip_on_field_errors: bool) -> None:
|
|
152
|
+
self.pass_many = pass_many
|
|
153
|
+
self.skip_on_field_errors = skip_on_field_errors
|
|
154
|
+
|
|
155
|
+
def __call__(self, func: F) -> F:
|
|
156
|
+
"""Decorate a function as a schema validator."""
|
|
157
|
+
object.__setattr__(func, "_validates_schema", True)
|
|
158
|
+
object.__setattr__(func, "_pass_many", self.pass_many)
|
|
159
|
+
object.__setattr__(func, "_skip_on_field_errors", self.skip_on_field_errors)
|
|
160
|
+
return func
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class SchemaWithValidatorCache(Protocol):
|
|
164
|
+
"""Protocol for schema classes that have validator caches."""
|
|
165
|
+
|
|
166
|
+
_field_validators_cache: dict[str, list[str]]
|
|
167
|
+
_schema_validators_cache: list[str]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def cache_validators(cls: type[Any]) -> None:
|
|
171
|
+
"""
|
|
172
|
+
Cache custom validators at class creation time.
|
|
173
|
+
|
|
174
|
+
Scans the class for methods decorated with @validates and @validates_schema
|
|
175
|
+
and caches them for efficient lookup during validation.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
cls: The schema class to cache validators for (must have validator cache attrs)
|
|
179
|
+
"""
|
|
180
|
+
# Set cache attributes on the class
|
|
181
|
+
field_cache: dict[str, list[str]] = {}
|
|
182
|
+
schema_cache: list[str] = []
|
|
183
|
+
|
|
184
|
+
for attr_name in dir(cls):
|
|
185
|
+
if attr_name.startswith('_'):
|
|
186
|
+
continue
|
|
187
|
+
try:
|
|
188
|
+
attr = getattr(cls, attr_name, None)
|
|
189
|
+
except AttributeError:
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
if callable(attr):
|
|
193
|
+
if hasattr(attr, "_validates_field"):
|
|
194
|
+
# Cast to protocol type since hasattr confirmed the attribute exists
|
|
195
|
+
field_validator = cast(FieldValidatorFunc, attr)
|
|
196
|
+
field: str = field_validator._validates_field
|
|
197
|
+
if field not in field_cache:
|
|
198
|
+
field_cache[field] = []
|
|
199
|
+
field_cache[field].append(attr_name)
|
|
200
|
+
elif hasattr(attr, "_validates_schema"):
|
|
201
|
+
schema_cache.append(attr_name)
|
|
202
|
+
|
|
203
|
+
# Assign to class - cast to type that has the cache attributes
|
|
204
|
+
# PydanticSchema defines these as ClassVar, so they exist at runtime
|
|
205
|
+
schema_cls = cast(type[SchemaWithValidatorCache], cls)
|
|
206
|
+
schema_cls._field_validators_cache = field_cache
|
|
207
|
+
schema_cls._schema_validators_cache = schema_cache
|