pydantic-marshmallow 1.0.0__py3-none-any.whl → 1.1.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 +11 -5
- pydantic_marshmallow/bridge.py +326 -98
- pydantic_marshmallow/field_conversion.py +28 -7
- pydantic_marshmallow/type_mapping.py +67 -30
- {pydantic_marshmallow-1.0.0.dist-info → pydantic_marshmallow-1.1.0.dist-info}/METADATA +152 -9
- pydantic_marshmallow-1.1.0.dist-info/RECORD +12 -0
- pydantic_marshmallow-1.0.0.dist-info/RECORD +0 -12
- {pydantic_marshmallow-1.0.0.dist-info → pydantic_marshmallow-1.1.0.dist-info}/WHEEL +0 -0
- {pydantic_marshmallow-1.0.0.dist-info → pydantic_marshmallow-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {pydantic_marshmallow-1.0.0.dist-info → pydantic_marshmallow-1.1.0.dist-info}/top_level.txt +0 -0
pydantic_marshmallow/__init__.py
CHANGED
|
@@ -51,16 +51,22 @@ Features:
|
|
|
51
51
|
|
|
52
52
|
# Re-export Marshmallow's validators - these work with PydanticSchema
|
|
53
53
|
# Also export hooks for convenience
|
|
54
|
+
# Note: We re-export Marshmallow's @validates and @validates_schema above.
|
|
55
|
+
# Our bridge's _do_load calls Marshmallow's native validator system,
|
|
56
|
+
# so `from marshmallow import validates` works correctly with PydanticSchema.
|
|
57
|
+
# Version is managed by setuptools-scm from git tags
|
|
58
|
+
from importlib.metadata import version as _version
|
|
59
|
+
|
|
54
60
|
from marshmallow import EXCLUDE, INCLUDE, RAISE, post_dump, post_load, pre_dump, pre_load, validates, validates_schema
|
|
55
61
|
|
|
56
62
|
from .bridge import HybridModel, PydanticSchema, pydantic_schema, schema_for
|
|
57
63
|
from .errors import BridgeValidationError
|
|
58
64
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
__version__ = "0.
|
|
65
|
+
try:
|
|
66
|
+
__version__ = _version("pydantic-marshmallow")
|
|
67
|
+
except Exception:
|
|
68
|
+
# Fallback for development or environments without installed package metadata
|
|
69
|
+
__version__ = "0.0.0.dev0"
|
|
64
70
|
__all__ = [
|
|
65
71
|
"EXCLUDE",
|
|
66
72
|
"INCLUDE",
|
pydantic_marshmallow/bridge.py
CHANGED
|
@@ -7,7 +7,10 @@ Flow: Input → Marshmallow pre_load → PYDANTIC VALIDATES → Marshmallow post
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import threading
|
|
10
11
|
from collections.abc import Callable, Sequence, Set as AbstractSet
|
|
12
|
+
from functools import lru_cache
|
|
13
|
+
from importlib.metadata import version as get_version
|
|
11
14
|
from typing import Any, ClassVar, Generic, TypeVar, cast, get_args, get_origin
|
|
12
15
|
|
|
13
16
|
from marshmallow import EXCLUDE, INCLUDE, RAISE, Schema, fields as ma_fields
|
|
@@ -15,22 +18,76 @@ from marshmallow.decorators import VALIDATES, VALIDATES_SCHEMA
|
|
|
15
18
|
from marshmallow.error_store import ErrorStore
|
|
16
19
|
from marshmallow.exceptions import ValidationError as MarshmallowValidationError
|
|
17
20
|
from marshmallow.schema import SchemaMeta
|
|
21
|
+
from marshmallow.utils import missing as ma_missing # type: ignore[attr-defined,unused-ignore]
|
|
18
22
|
from pydantic import BaseModel, ConfigDict, ValidationError as PydanticValidationError
|
|
19
23
|
|
|
20
24
|
from .errors import BridgeValidationError, convert_pydantic_errors, format_pydantic_error
|
|
21
|
-
from .field_conversion import convert_model_fields, convert_pydantic_field
|
|
25
|
+
from .field_conversion import _get_computed_fields, convert_model_fields, convert_pydantic_field
|
|
22
26
|
from .validators import cache_validators
|
|
23
27
|
|
|
28
|
+
|
|
29
|
+
# Marshmallow version detection for context parameter compatibility
|
|
30
|
+
# MA 4.x removes the `context` parameter from Schema.__init__
|
|
31
|
+
def _parse_version(version_str: str) -> tuple[int, int]:
|
|
32
|
+
"""Parse version string, handling pre-release versions like '4.0.0rc1'."""
|
|
33
|
+
import re
|
|
34
|
+
match = re.match(r'(\d+)\.(\d+)', version_str)
|
|
35
|
+
if match:
|
|
36
|
+
return (int(match.group(1)), int(match.group(2)))
|
|
37
|
+
raise ValueError(f"Cannot parse marshmallow version: {version_str}") # pragma: no cover
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_MARSHMALLOW_VERSION = _parse_version(get_version("marshmallow"))
|
|
41
|
+
_MARSHMALLOW_4_PLUS = _MARSHMALLOW_VERSION >= (4, 0)
|
|
42
|
+
|
|
43
|
+
|
|
24
44
|
M = TypeVar("M", bound=BaseModel)
|
|
25
45
|
|
|
46
|
+
# =============================================================================
|
|
47
|
+
# Module-level Caches
|
|
48
|
+
# =============================================================================
|
|
49
|
+
# Thread Safety: All caches use thread-safe implementations to support both
|
|
50
|
+
# GIL-enabled Python and free-threaded Python 3.14+ (PEP 703).
|
|
51
|
+
# - @lru_cache: Inherently thread-safe (uses internal lock)
|
|
52
|
+
# - Dict caches: Protected by threading.Lock for write operations
|
|
53
|
+
#
|
|
54
|
+
# Cache Invalidation: These caches assume Pydantic models are immutable after
|
|
55
|
+
# definition. Dynamic model modification at runtime (monkey-patching, adding
|
|
56
|
+
# fields) will result in stale cached data. This is an accepted trade-off for
|
|
57
|
+
# performance gains.
|
|
58
|
+
# =============================================================================
|
|
59
|
+
|
|
60
|
+
# Thread lock for cache writes (supports free-threaded Python 3.14+)
|
|
61
|
+
# Use RLock (reentrant) since from_model() may be called recursively
|
|
62
|
+
_cache_lock = threading.RLock()
|
|
63
|
+
|
|
26
64
|
# Module-level cache for HybridModel schemas
|
|
27
65
|
_hybrid_schema_cache: dict[type[Any], type[PydanticSchema[Any]]] = {}
|
|
28
66
|
|
|
67
|
+
# Cache for schema_for/from_model - key is (model, schema_name, frozen options)
|
|
68
|
+
# This avoids recreating schema classes for the same model+options
|
|
69
|
+
_schema_class_cache: dict[tuple[type[Any], str | None, tuple[tuple[str, Any], ...]], type[Any]] = {}
|
|
70
|
+
|
|
29
71
|
# Field validator registry: maps (schema_class, field_name) -> list of validator functions
|
|
30
72
|
_field_validators: dict[tuple[type[Any], str], list[Callable[..., Any]]] = {}
|
|
31
73
|
_schema_validators: dict[type[Any], list[Callable[..., Any]]] = {}
|
|
32
74
|
|
|
33
75
|
|
|
76
|
+
@lru_cache(maxsize=1024)
|
|
77
|
+
def _get_model_field_names_with_aliases(model_class: type[BaseModel]) -> frozenset[str]:
|
|
78
|
+
"""
|
|
79
|
+
Get cached frozenset of model field names including aliases.
|
|
80
|
+
|
|
81
|
+
Thread-safe via @lru_cache. Caches the result per model class to avoid
|
|
82
|
+
repeated computation in the load() hot path.
|
|
83
|
+
"""
|
|
84
|
+
field_names = set(model_class.model_fields.keys())
|
|
85
|
+
for field_info in model_class.model_fields.values():
|
|
86
|
+
if field_info.alias:
|
|
87
|
+
field_names.add(field_info.alias)
|
|
88
|
+
return frozenset(field_names)
|
|
89
|
+
|
|
90
|
+
|
|
34
91
|
class PydanticSchemaMeta(SchemaMeta):
|
|
35
92
|
"""
|
|
36
93
|
Custom metaclass that adds Pydantic model fields BEFORE Marshmallow processes them.
|
|
@@ -130,10 +187,11 @@ class PydanticSchemaMeta(SchemaMeta):
|
|
|
130
187
|
attrs[field_name] = convert_pydantic_field(field_name, field_info)
|
|
131
188
|
|
|
132
189
|
# Add computed fields as dump_only
|
|
133
|
-
|
|
190
|
+
computed_fields = _get_computed_fields(model_class)
|
|
191
|
+
if computed_fields:
|
|
134
192
|
from .field_conversion import convert_computed_field
|
|
135
193
|
|
|
136
|
-
for field_name, computed_info in
|
|
194
|
+
for field_name, computed_info in computed_fields.items():
|
|
137
195
|
if field_name in attrs:
|
|
138
196
|
continue
|
|
139
197
|
# If attrs has pre-filtered fields, don't add computed fields
|
|
@@ -149,7 +207,7 @@ class PydanticSchemaMeta(SchemaMeta):
|
|
|
149
207
|
return cast(PydanticSchemaMeta, super().__new__(mcs, name, bases, attrs))
|
|
150
208
|
|
|
151
209
|
|
|
152
|
-
class
|
|
210
|
+
class _PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
153
211
|
"""
|
|
154
212
|
A Marshmallow schema backed by a Pydantic model.
|
|
155
213
|
|
|
@@ -228,20 +286,27 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
|
228
286
|
self._dump_only_fields: set[str] = set(dump_only) if dump_only else set()
|
|
229
287
|
self._partial: bool | Sequence[str] | AbstractSet[str] | None = partial
|
|
230
288
|
self._unknown_override: str | None = unknown
|
|
231
|
-
self._context = context or {}
|
|
232
289
|
|
|
233
|
-
#
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
many
|
|
239
|
-
load_only
|
|
240
|
-
dump_only
|
|
241
|
-
partial
|
|
242
|
-
unknown
|
|
290
|
+
# Build kwargs for super().__init__()
|
|
291
|
+
# MA 4.x removes context parameter - only pass it for MA 3.x
|
|
292
|
+
parent_kwargs: dict[str, Any] = {
|
|
293
|
+
"only": only,
|
|
294
|
+
"exclude": exclude,
|
|
295
|
+
"many": many,
|
|
296
|
+
"load_only": load_only,
|
|
297
|
+
"dump_only": dump_only,
|
|
298
|
+
"partial": partial,
|
|
299
|
+
"unknown": unknown,
|
|
243
300
|
**kwargs,
|
|
244
|
-
|
|
301
|
+
}
|
|
302
|
+
if not _MARSHMALLOW_4_PLUS:
|
|
303
|
+
parent_kwargs["context"] = context
|
|
304
|
+
|
|
305
|
+
super().__init__(**parent_kwargs)
|
|
306
|
+
|
|
307
|
+
# For MA 4.x, store context manually if provided
|
|
308
|
+
if _MARSHMALLOW_4_PLUS and context is not None: # pragma: no cover
|
|
309
|
+
self.context = context
|
|
245
310
|
self._model_class = self._get_model_class()
|
|
246
311
|
if self._model_class:
|
|
247
312
|
self._setup_fields_from_model()
|
|
@@ -250,7 +315,7 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
|
250
315
|
for field_name, field_obj in self.fields.items():
|
|
251
316
|
self.on_bind_field(field_name, field_obj)
|
|
252
317
|
|
|
253
|
-
def on_bind_field(self, field_name: str, field_obj:
|
|
318
|
+
def on_bind_field(self, field_name: str, field_obj: Any) -> None:
|
|
254
319
|
"""
|
|
255
320
|
Hook called when a field is bound to the schema.
|
|
256
321
|
|
|
@@ -300,16 +365,6 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
|
300
365
|
"""
|
|
301
366
|
raise error
|
|
302
367
|
|
|
303
|
-
@property
|
|
304
|
-
def context(self) -> dict[str, Any]:
|
|
305
|
-
"""Get the validation context."""
|
|
306
|
-
return self._context
|
|
307
|
-
|
|
308
|
-
@context.setter
|
|
309
|
-
def context(self, value: dict[str, Any]) -> None:
|
|
310
|
-
"""Set the validation context."""
|
|
311
|
-
self._context = value
|
|
312
|
-
|
|
313
368
|
def _get_model_class(self) -> type[BaseModel] | None:
|
|
314
369
|
"""Get the Pydantic model class from Meta or generic parameter."""
|
|
315
370
|
# Try Meta.model first
|
|
@@ -389,6 +444,7 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
|
389
444
|
data: dict[str, Any],
|
|
390
445
|
partial: bool | Sequence[str] | AbstractSet[str] | None = None,
|
|
391
446
|
original_data: Any | None = None,
|
|
447
|
+
skip_model_dump: bool = False,
|
|
392
448
|
) -> tuple[dict[str, Any], M | None]:
|
|
393
449
|
"""
|
|
394
450
|
Use Pydantic to validate and coerce the input data.
|
|
@@ -396,6 +452,13 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
|
396
452
|
Filters out marshmallow.missing sentinel values before Pydantic validation,
|
|
397
453
|
allowing Pydantic to use its own defaults for missing fields.
|
|
398
454
|
|
|
455
|
+
Args:
|
|
456
|
+
data: Input data to validate
|
|
457
|
+
partial: Partial validation mode
|
|
458
|
+
original_data: Original input data for error reporting
|
|
459
|
+
skip_model_dump: If True and not partial, skip model_dump() and return
|
|
460
|
+
empty dict. Use when validators don't need the dict.
|
|
461
|
+
|
|
399
462
|
Returns:
|
|
400
463
|
Tuple of (validated_data_dict, model_instance)
|
|
401
464
|
The instance is returned to avoid redundant validation later.
|
|
@@ -404,8 +467,6 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
|
404
467
|
return data, None
|
|
405
468
|
|
|
406
469
|
# Filter out marshmallow.missing values - Pydantic should use its defaults
|
|
407
|
-
from marshmallow.utils import missing as ma_missing
|
|
408
|
-
|
|
409
470
|
clean_data = {
|
|
410
471
|
k: v for k, v in data.items()
|
|
411
472
|
if v is not ma_missing
|
|
@@ -420,6 +481,9 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
|
420
481
|
else:
|
|
421
482
|
# Let Pydantic do all the validation - KEEP THE INSTANCE
|
|
422
483
|
instance = self._model_class.model_validate(clean_data)
|
|
484
|
+
# OPTIMIZATION: Skip model_dump if not needed for validators
|
|
485
|
+
if skip_model_dump:
|
|
486
|
+
return {}, cast(M, instance)
|
|
423
487
|
# Return both the dict (for validators) and instance (for result)
|
|
424
488
|
validated_data = instance.model_dump(by_alias=False)
|
|
425
489
|
# Cast to M since model_validate returns the correct model type
|
|
@@ -545,6 +609,11 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
|
545
609
|
|
|
546
610
|
This ensures 100% Marshmallow hook compatibility.
|
|
547
611
|
"""
|
|
612
|
+
# PERFORMANCE: Hoist frequently accessed attributes to local variables
|
|
613
|
+
# This avoids repeated self.__dict__ lookups in the hot path
|
|
614
|
+
model_class = self._model_class
|
|
615
|
+
hooks = self._hooks
|
|
616
|
+
|
|
548
617
|
# Resolve settings
|
|
549
618
|
if many is None:
|
|
550
619
|
many = self.many
|
|
@@ -562,7 +631,9 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
|
562
631
|
if many:
|
|
563
632
|
if not isinstance(data, list):
|
|
564
633
|
raise MarshmallowValidationError({"_schema": ["Expected a list."]})
|
|
565
|
-
|
|
634
|
+
|
|
635
|
+
# Process each item individually (validators run per-item with pass_many=False)
|
|
636
|
+
results = [
|
|
566
637
|
self._do_load(
|
|
567
638
|
item,
|
|
568
639
|
many=False,
|
|
@@ -574,28 +645,57 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
|
574
645
|
for item in data
|
|
575
646
|
]
|
|
576
647
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
648
|
+
# After all items processed, run @validates_schema(pass_many=True) validators
|
|
649
|
+
# These should see the FULL collection, not individual items
|
|
650
|
+
coll_error_store_cls: Callable[[], Any] = cast(Callable[[], Any], ErrorStore)
|
|
651
|
+
coll_error_store: Any = coll_error_store_cls()
|
|
652
|
+
|
|
653
|
+
# Build kwargs for collection-level validation
|
|
654
|
+
coll_pass_param = "pass_collection" if _MARSHMALLOW_4_PLUS else "pass_many"
|
|
655
|
+
coll_validator_kwargs: dict[str, Any] = {
|
|
656
|
+
"error_store": coll_error_store,
|
|
657
|
+
"data": results,
|
|
658
|
+
"original_data": data,
|
|
659
|
+
"many": True,
|
|
660
|
+
"partial": partial,
|
|
661
|
+
"field_errors": False, # Field errors already handled per-item
|
|
662
|
+
}
|
|
663
|
+
# MA 4.x requires 'unknown' parameter
|
|
664
|
+
if _MARSHMALLOW_4_PLUS: # pragma: no cover
|
|
665
|
+
coll_validator_kwargs["unknown"] = unknown_setting
|
|
666
|
+
# Only run pass_many=True validators (collection-level)
|
|
667
|
+
self._invoke_schema_validators(**{coll_pass_param: True, **coll_validator_kwargs})
|
|
668
|
+
|
|
669
|
+
if coll_error_store.errors:
|
|
670
|
+
error = MarshmallowValidationError(dict(coll_error_store.errors))
|
|
671
|
+
self.handle_error(error, data, many=True)
|
|
672
|
+
|
|
673
|
+
return results
|
|
674
|
+
|
|
675
|
+
# Step 1: Run pre_load hooks
|
|
676
|
+
# MA 4.x requires 'unknown' parameter, MA 3.x doesn't accept it
|
|
677
|
+
pre_load_kwargs: dict[str, Any] = {
|
|
678
|
+
"many": False,
|
|
679
|
+
"original_data": data,
|
|
680
|
+
"partial": partial,
|
|
681
|
+
}
|
|
682
|
+
if _MARSHMALLOW_4_PLUS: # pragma: no cover
|
|
683
|
+
pre_load_kwargs["unknown"] = unknown_setting
|
|
684
|
+
processed_data_raw = self._invoke_load_processors(
|
|
685
|
+
"pre_load",
|
|
686
|
+
data,
|
|
687
|
+
**pre_load_kwargs,
|
|
688
|
+
)
|
|
589
689
|
|
|
590
|
-
#
|
|
591
|
-
|
|
592
|
-
model_fields = set(self._model_class.model_fields.keys())
|
|
593
|
-
# Also include aliases in known fields
|
|
594
|
-
for _field_name, field_info in self._model_class.model_fields.items():
|
|
595
|
-
if field_info.alias:
|
|
596
|
-
model_fields.add(field_info.alias)
|
|
690
|
+
# Type narrowing: at this point (many=False path), data is always a dict
|
|
691
|
+
processed_data: dict[str, Any] = cast(dict[str, Any], processed_data_raw)
|
|
597
692
|
|
|
598
|
-
|
|
693
|
+
# Step 2: Handle unknown fields based on setting
|
|
694
|
+
# PERFORMANCE: Use cached field names instead of computing every time
|
|
695
|
+
model_field_names: frozenset[str] | None = None
|
|
696
|
+
if model_class:
|
|
697
|
+
model_field_names = _get_model_field_names_with_aliases(model_class)
|
|
698
|
+
unkn_fields = set(processed_data.keys()) - model_field_names
|
|
599
699
|
|
|
600
700
|
if unkn_fields:
|
|
601
701
|
if unknown_setting == RAISE:
|
|
@@ -604,17 +704,52 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
|
604
704
|
if unknown_setting == EXCLUDE:
|
|
605
705
|
# Remove unknown fields
|
|
606
706
|
processed_data = {
|
|
607
|
-
k: v for k, v in processed_data.items() if k in
|
|
707
|
+
k: v for k, v in processed_data.items() if k in model_field_names
|
|
608
708
|
}
|
|
609
709
|
# INCLUDE: keep unknown fields in the result (handled below)
|
|
610
710
|
|
|
611
711
|
# Step 3: Pydantic validates the transformed data
|
|
612
712
|
# Returns (validated_dict, instance) - instance reused to avoid double validation
|
|
613
|
-
pydantic_instance = None
|
|
614
|
-
|
|
713
|
+
pydantic_instance: M | None = None
|
|
714
|
+
validated_data: dict[str, Any]
|
|
715
|
+
|
|
716
|
+
# OPTIMIZATION: Determine if we need model_dump() for validators/result
|
|
717
|
+
# Skip expensive model_dump() when not needed
|
|
718
|
+
# Note: MA 3.x uses tuple keys ('validates', field) and ('validates_schema', pass_many)
|
|
719
|
+
# MA 4.x uses string keys ('validates', 'validates_schema')
|
|
720
|
+
# Check for any hook that starts with the validator type
|
|
721
|
+
def _has_hook(hook_type: str) -> bool:
|
|
722
|
+
"""Check if any hook matches the type (handles both MA 3.x and 4.x key formats)."""
|
|
723
|
+
# Check string key (MA 4.x) - safe to access defaultdict
|
|
724
|
+
if hooks.get(hook_type):
|
|
725
|
+
return True
|
|
726
|
+
# Check tuple keys (MA 3.x) - cast to Any to avoid mypy narrowing issues
|
|
727
|
+
# across different Marshmallow versions with different key types
|
|
728
|
+
hooks_any: dict[Any, Any] = cast(dict[Any, Any], hooks)
|
|
729
|
+
for key in hooks_any:
|
|
730
|
+
if isinstance(key, tuple) and len(key) >= 1 and key[0] == hook_type and hooks_any[key]:
|
|
731
|
+
return True
|
|
732
|
+
return False
|
|
733
|
+
|
|
734
|
+
has_validators = bool(
|
|
735
|
+
_has_hook(VALIDATES)
|
|
736
|
+
or _has_hook(VALIDATES_SCHEMA)
|
|
737
|
+
or self._field_validators_cache
|
|
738
|
+
or self._schema_validators_cache
|
|
739
|
+
)
|
|
740
|
+
needs_dict = (
|
|
741
|
+
not return_instance # Need dict for result
|
|
742
|
+
or unknown_setting == INCLUDE # Need dict to merge unknown fields
|
|
743
|
+
or has_validators # Need dict for validators
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
if model_class:
|
|
615
747
|
try:
|
|
616
748
|
validated_data, pydantic_instance = self._validate_with_pydantic(
|
|
617
|
-
processed_data,
|
|
749
|
+
processed_data,
|
|
750
|
+
partial=partial,
|
|
751
|
+
original_data=data,
|
|
752
|
+
skip_model_dump=not needs_dict,
|
|
618
753
|
)
|
|
619
754
|
except MarshmallowValidationError as pydantic_error:
|
|
620
755
|
# Call handle_error for Pydantic validation errors
|
|
@@ -623,8 +758,8 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
|
623
758
|
raise
|
|
624
759
|
|
|
625
760
|
# If INCLUDE, add unknown fields back to validated data
|
|
626
|
-
if unknown_setting == INCLUDE and
|
|
627
|
-
for field in (set(processed_data.keys()) -
|
|
761
|
+
if unknown_setting == INCLUDE and model_field_names is not None:
|
|
762
|
+
for field in (set(processed_data.keys()) - model_field_names):
|
|
628
763
|
validated_data[field] = processed_data[field]
|
|
629
764
|
else:
|
|
630
765
|
validated_data = processed_data
|
|
@@ -635,13 +770,12 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
|
635
770
|
error_store_cls: Callable[[], Any] = cast(Callable[[], Any], ErrorStore)
|
|
636
771
|
error_store: Any = error_store_cls()
|
|
637
772
|
|
|
638
|
-
# 4a: Run Marshmallow's native @validates decorators (from
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
)
|
|
773
|
+
# 4a: Run Marshmallow's native @validates decorators (from hooks)
|
|
774
|
+
self._invoke_field_validators(
|
|
775
|
+
error_store=error_store,
|
|
776
|
+
data=validated_data,
|
|
777
|
+
many=False,
|
|
778
|
+
)
|
|
645
779
|
|
|
646
780
|
# 4b: Run our custom @validates decorators (backwards compatibility)
|
|
647
781
|
try:
|
|
@@ -656,25 +790,25 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
|
656
790
|
|
|
657
791
|
# Step 5: Run schema validators (BOTH Marshmallow native AND our custom)
|
|
658
792
|
# 5a: Run Marshmallow's native @validates_schema decorators
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
793
|
+
# MA 4.x renamed pass_many -> pass_collection
|
|
794
|
+
# Build common kwargs
|
|
795
|
+
validator_kwargs: dict[str, Any] = {
|
|
796
|
+
"error_store": error_store,
|
|
797
|
+
"data": validated_data,
|
|
798
|
+
"original_data": data,
|
|
799
|
+
"many": False,
|
|
800
|
+
"partial": partial,
|
|
801
|
+
"field_errors": has_field_errors,
|
|
802
|
+
}
|
|
803
|
+
# MA 4.x requires 'unknown' parameter
|
|
804
|
+
if _MARSHMALLOW_4_PLUS: # pragma: no cover
|
|
805
|
+
validator_kwargs["unknown"] = unknown_setting
|
|
806
|
+
# Pass the correctly-named parameter for the marshmallow version
|
|
807
|
+
pass_param = "pass_collection" if _MARSHMALLOW_4_PLUS else "pass_many"
|
|
808
|
+
|
|
809
|
+
# Only run pass_many=False validators in per-item mode
|
|
810
|
+
# pass_many=True validators are handled at the collection level (in many=True block above)
|
|
811
|
+
self._invoke_schema_validators(**{pass_param: False, **validator_kwargs})
|
|
678
812
|
|
|
679
813
|
# 5b: Run our custom @validates_schema decorators (backwards compatibility)
|
|
680
814
|
try:
|
|
@@ -690,7 +824,7 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
|
690
824
|
self.handle_error(error, data, many=False)
|
|
691
825
|
|
|
692
826
|
# Step 6: Prepare result based on return_instance flag
|
|
693
|
-
if
|
|
827
|
+
if model_class and return_instance:
|
|
694
828
|
if not partial:
|
|
695
829
|
# OPTIMIZATION: Reuse the instance from _validate_with_pydantic
|
|
696
830
|
result = pydantic_instance if pydantic_instance is not None else validated_data
|
|
@@ -701,7 +835,7 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
|
701
835
|
construct_data = {}
|
|
702
836
|
fields_set = set()
|
|
703
837
|
|
|
704
|
-
for field_name, field_info in
|
|
838
|
+
for field_name, field_info in model_class.model_fields.items():
|
|
705
839
|
if field_name in validated_data:
|
|
706
840
|
construct_data[field_name] = validated_data[field_name]
|
|
707
841
|
fields_set.add(field_name)
|
|
@@ -718,20 +852,26 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
|
718
852
|
construct_data[field_name] = None
|
|
719
853
|
|
|
720
854
|
result = cast(
|
|
721
|
-
M,
|
|
855
|
+
M, model_class.model_construct(_fields_set=fields_set, **construct_data)
|
|
722
856
|
)
|
|
723
857
|
else:
|
|
724
858
|
# Return dict instead of instance
|
|
725
859
|
result = validated_data
|
|
726
860
|
|
|
727
|
-
# Step 7: Run post_load hooks
|
|
728
|
-
if postprocess
|
|
861
|
+
# Step 7: Run post_load hooks
|
|
862
|
+
if postprocess:
|
|
863
|
+
# MA 4.x requires 'unknown' parameter, MA 3.x doesn't accept it
|
|
864
|
+
post_load_kwargs: dict[str, Any] = {
|
|
865
|
+
"many": False,
|
|
866
|
+
"original_data": data,
|
|
867
|
+
"partial": partial,
|
|
868
|
+
}
|
|
869
|
+
if _MARSHMALLOW_4_PLUS: # pragma: no cover
|
|
870
|
+
post_load_kwargs["unknown"] = unknown_setting
|
|
729
871
|
result = self._invoke_load_processors(
|
|
730
872
|
"post_load",
|
|
731
|
-
result,
|
|
732
|
-
|
|
733
|
-
original_data=data,
|
|
734
|
-
partial=partial,
|
|
873
|
+
cast(dict[str, Any], result),
|
|
874
|
+
**post_load_kwargs,
|
|
735
875
|
)
|
|
736
876
|
|
|
737
877
|
return result
|
|
@@ -975,8 +1115,9 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
|
975
1115
|
fields_to_exclude.add(field_name)
|
|
976
1116
|
|
|
977
1117
|
# Extract computed field values BEFORE converting to dict
|
|
978
|
-
|
|
979
|
-
|
|
1118
|
+
computed_fields = _get_computed_fields(model_class)
|
|
1119
|
+
if include_computed and computed_fields:
|
|
1120
|
+
for field_name in computed_fields:
|
|
980
1121
|
value = getattr(obj, field_name)
|
|
981
1122
|
# Apply exclusion rules to computed fields too
|
|
982
1123
|
if exclude_none and value is None:
|
|
@@ -989,7 +1130,7 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
|
989
1130
|
obj = obj.model_dump(by_alias=False)
|
|
990
1131
|
|
|
991
1132
|
# Let Marshmallow handle the standard dump
|
|
992
|
-
result: dict[str, Any] = super().dump(obj, many=False)
|
|
1133
|
+
result: dict[str, Any] = cast(dict[str, Any], super().dump(obj, many=False))
|
|
993
1134
|
|
|
994
1135
|
# Apply field exclusions
|
|
995
1136
|
if fields_to_exclude:
|
|
@@ -1032,6 +1173,20 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
|
1032
1173
|
Returns:
|
|
1033
1174
|
A PydanticSchema subclass
|
|
1034
1175
|
"""
|
|
1176
|
+
# Build cache key from model, schema_name, and options
|
|
1177
|
+
# Sort options to ensure consistent keys
|
|
1178
|
+
# Note: schema_name must be included since it affects the generated class name
|
|
1179
|
+
cache_key: tuple[type[Any], str | None, tuple[tuple[str, Any], ...]] | None = None
|
|
1180
|
+
try:
|
|
1181
|
+
cache_key = (model, schema_name, tuple(sorted(meta_options.items())))
|
|
1182
|
+
# Thread-safe read (atomic dict lookup)
|
|
1183
|
+
cached = _schema_class_cache.get(cache_key)
|
|
1184
|
+
if cached is not None:
|
|
1185
|
+
return cached
|
|
1186
|
+
except TypeError:
|
|
1187
|
+
# Unhashable options (e.g., list values) - skip cache
|
|
1188
|
+
cache_key = None
|
|
1189
|
+
|
|
1035
1190
|
name = schema_name or f"{model.__name__}Schema"
|
|
1036
1191
|
|
|
1037
1192
|
# Extract field filtering options
|
|
@@ -1063,9 +1218,75 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
|
|
|
1063
1218
|
class_dict: dict[str, Any] = {"Meta": Meta, **fields}
|
|
1064
1219
|
|
|
1065
1220
|
schema_cls = type(name, (cls,), class_dict)
|
|
1221
|
+
|
|
1222
|
+
# Thread-safe cache write (supports free-threaded Python 3.14+)
|
|
1223
|
+
if cache_key is not None:
|
|
1224
|
+
with _cache_lock:
|
|
1225
|
+
_schema_class_cache[cache_key] = schema_cls
|
|
1226
|
+
|
|
1066
1227
|
return schema_cls
|
|
1067
1228
|
|
|
1068
1229
|
|
|
1230
|
+
if _MARSHMALLOW_4_PLUS: # pragma: no cover
|
|
1231
|
+
class PydanticSchema(_PydanticSchema[M], Generic[M]):
|
|
1232
|
+
def __init__(
|
|
1233
|
+
self,
|
|
1234
|
+
*,
|
|
1235
|
+
only: Sequence[str] | None = None,
|
|
1236
|
+
exclude: Sequence[str] = (),
|
|
1237
|
+
load_only: Sequence[str] = (),
|
|
1238
|
+
dump_only: Sequence[str] = (),
|
|
1239
|
+
partial: bool | Sequence[str] | AbstractSet[str] | None = None,
|
|
1240
|
+
unknown: str | None = None,
|
|
1241
|
+
many: bool | None = None,
|
|
1242
|
+
**kwargs: Any,
|
|
1243
|
+
) -> None:
|
|
1244
|
+
# MA 4.x removed context parameter - reject it to match native behavior
|
|
1245
|
+
if "context" in kwargs:
|
|
1246
|
+
raise TypeError(
|
|
1247
|
+
"PydanticSchema.__init__() got an unexpected keyword argument 'context'. "
|
|
1248
|
+
"The context parameter was removed in Marshmallow 4.x. "
|
|
1249
|
+
"Use contextvars.ContextVar instead."
|
|
1250
|
+
)
|
|
1251
|
+
super().__init__(
|
|
1252
|
+
only=only,
|
|
1253
|
+
exclude=exclude,
|
|
1254
|
+
load_only=load_only,
|
|
1255
|
+
dump_only=dump_only,
|
|
1256
|
+
partial=partial,
|
|
1257
|
+
unknown=unknown,
|
|
1258
|
+
many=many,
|
|
1259
|
+
**kwargs,
|
|
1260
|
+
)
|
|
1261
|
+
else:
|
|
1262
|
+
class PydanticSchema(_PydanticSchema[M], Generic[M]): # type: ignore[no-redef]
|
|
1263
|
+
def __init__(
|
|
1264
|
+
self,
|
|
1265
|
+
*,
|
|
1266
|
+
only: Sequence[str] | None = None,
|
|
1267
|
+
exclude: Sequence[str] = (),
|
|
1268
|
+
context: dict[str, Any] | None = None,
|
|
1269
|
+
load_only: Sequence[str] = (),
|
|
1270
|
+
dump_only: Sequence[str] = (),
|
|
1271
|
+
partial: bool | Sequence[str] | AbstractSet[str] | None = None,
|
|
1272
|
+
unknown: str | None = None,
|
|
1273
|
+
many: bool | None = None,
|
|
1274
|
+
**kwargs: Any,
|
|
1275
|
+
) -> None:
|
|
1276
|
+
# Pass context to super so Marshmallow sets self.context correctly
|
|
1277
|
+
super().__init__(
|
|
1278
|
+
only=only,
|
|
1279
|
+
exclude=exclude,
|
|
1280
|
+
context=context,
|
|
1281
|
+
load_only=load_only,
|
|
1282
|
+
dump_only=dump_only,
|
|
1283
|
+
partial=partial,
|
|
1284
|
+
unknown=unknown,
|
|
1285
|
+
many=many,
|
|
1286
|
+
**kwargs,
|
|
1287
|
+
)
|
|
1288
|
+
|
|
1289
|
+
|
|
1069
1290
|
def schema_for(model: type[M], **meta_options: Any) -> type[PydanticSchema[M]]:
|
|
1070
1291
|
"""
|
|
1071
1292
|
Shortcut to create a Marshmallow schema from a Pydantic model.
|
|
@@ -1155,10 +1376,17 @@ class HybridModel(BaseModel):
|
|
|
1155
1376
|
|
|
1156
1377
|
@classmethod
|
|
1157
1378
|
def marshmallow_schema(cls) -> type[PydanticSchema[Any]]:
|
|
1158
|
-
"""Get or create the Marshmallow schema for this model."""
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1379
|
+
"""Get or create the Marshmallow schema for this model (thread-safe)."""
|
|
1380
|
+
# Thread-safe read
|
|
1381
|
+
cached = _hybrid_schema_cache.get(cls)
|
|
1382
|
+
if cached is not None:
|
|
1383
|
+
return cached
|
|
1384
|
+
# Thread-safe write
|
|
1385
|
+
with _cache_lock:
|
|
1386
|
+
# Double-check after acquiring lock
|
|
1387
|
+
if cls not in _hybrid_schema_cache:
|
|
1388
|
+
_hybrid_schema_cache[cls] = PydanticSchema.from_model(cls)
|
|
1389
|
+
return _hybrid_schema_cache[cls]
|
|
1162
1390
|
|
|
1163
1391
|
@classmethod
|
|
1164
1392
|
def ma_load(cls, data: dict[str, Any], **kwargs: Any) -> HybridModel:
|
|
@@ -10,17 +10,37 @@ from __future__ import annotations
|
|
|
10
10
|
from types import UnionType
|
|
11
11
|
from typing import Any, Union, get_args, get_origin
|
|
12
12
|
|
|
13
|
-
from marshmallow import fields as ma_fields
|
|
14
13
|
from pydantic import BaseModel
|
|
15
14
|
from pydantic_core import PydanticUndefined
|
|
16
15
|
|
|
17
16
|
from .type_mapping import type_to_marshmallow_field
|
|
18
17
|
|
|
19
18
|
|
|
19
|
+
def _get_computed_fields(model_class: type[BaseModel]) -> dict[str, Any]:
|
|
20
|
+
"""Get computed fields dict from a Pydantic model class.
|
|
21
|
+
|
|
22
|
+
Handles differences between Pydantic versions:
|
|
23
|
+
- Pydantic 2.0.x: model_computed_fields is a property (only works on instances)
|
|
24
|
+
- Pydantic 2.4+: model_computed_fields works directly on classes
|
|
25
|
+
"""
|
|
26
|
+
# Try direct access first (works in newer Pydantic)
|
|
27
|
+
result = getattr(model_class, 'model_computed_fields', None)
|
|
28
|
+
if isinstance(result, dict):
|
|
29
|
+
return result
|
|
30
|
+
|
|
31
|
+
# For Pydantic 2.0.x: model_computed_fields is a property, call its getter
|
|
32
|
+
if isinstance(result, property) and result.fget:
|
|
33
|
+
prop_result = result.fget(model_class)
|
|
34
|
+
if isinstance(prop_result, dict):
|
|
35
|
+
return prop_result
|
|
36
|
+
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
|
|
20
40
|
def convert_pydantic_field(
|
|
21
41
|
field_name: str,
|
|
22
42
|
field_info: Any,
|
|
23
|
-
) ->
|
|
43
|
+
) -> Any:
|
|
24
44
|
"""
|
|
25
45
|
Convert a single Pydantic FieldInfo to a Marshmallow Field.
|
|
26
46
|
|
|
@@ -70,7 +90,7 @@ def convert_pydantic_field(
|
|
|
70
90
|
def convert_computed_field(
|
|
71
91
|
field_name: str,
|
|
72
92
|
computed_info: Any,
|
|
73
|
-
) ->
|
|
93
|
+
) -> Any:
|
|
74
94
|
"""
|
|
75
95
|
Convert a Pydantic computed_field to a dump-only Marshmallow Field.
|
|
76
96
|
|
|
@@ -93,7 +113,7 @@ def convert_model_fields(
|
|
|
93
113
|
include: set[str] | None = None,
|
|
94
114
|
exclude: set[str] | None = None,
|
|
95
115
|
include_computed: bool = True,
|
|
96
|
-
) -> dict[str,
|
|
116
|
+
) -> dict[str, Any]:
|
|
97
117
|
"""
|
|
98
118
|
Convert all fields from a Pydantic model to Marshmallow fields.
|
|
99
119
|
|
|
@@ -111,7 +131,7 @@ def convert_model_fields(
|
|
|
111
131
|
Returns:
|
|
112
132
|
Dict mapping field names to Marshmallow field instances
|
|
113
133
|
"""
|
|
114
|
-
fields: dict[str,
|
|
134
|
+
fields: dict[str, Any] = {}
|
|
115
135
|
exclude_set = exclude or set()
|
|
116
136
|
|
|
117
137
|
# Convert regular model fields
|
|
@@ -125,8 +145,9 @@ def convert_model_fields(
|
|
|
125
145
|
fields[field_name] = convert_pydantic_field(field_name, field_info)
|
|
126
146
|
|
|
127
147
|
# Convert computed fields (dump-only)
|
|
128
|
-
|
|
129
|
-
|
|
148
|
+
computed_fields = _get_computed_fields(model)
|
|
149
|
+
if include_computed and computed_fields:
|
|
150
|
+
for field_name, computed_info in computed_fields.items():
|
|
130
151
|
if field_name in exclude_set:
|
|
131
152
|
continue
|
|
132
153
|
if include is not None and field_name not in include:
|
|
@@ -4,9 +4,13 @@ Type mapping utilities for converting Python/Pydantic types to Marshmallow field
|
|
|
4
4
|
This module provides type-to-field conversions, leveraging Marshmallow's native
|
|
5
5
|
TYPE_MAPPING for basic types and adding support for generic collections.
|
|
6
6
|
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
7
10
|
from enum import Enum as PyEnum
|
|
11
|
+
from functools import lru_cache
|
|
8
12
|
from types import UnionType
|
|
9
|
-
from typing import Any, Literal, Union, get_args, get_origin
|
|
13
|
+
from typing import Any, Literal, Union, cast, get_args, get_origin
|
|
10
14
|
|
|
11
15
|
from marshmallow import Schema, fields as ma_fields
|
|
12
16
|
from pydantic import BaseModel
|
|
@@ -15,7 +19,21 @@ from pydantic import BaseModel
|
|
|
15
19
|
_processing_models: set[type[Any]] = set()
|
|
16
20
|
|
|
17
21
|
|
|
18
|
-
|
|
22
|
+
# Cache for simple type -> field class lookups (str, int, datetime, etc.)
|
|
23
|
+
# These are the most common types and benefit most from caching.
|
|
24
|
+
# maxsize=512 handles large codebases with ~80KB memory overhead.
|
|
25
|
+
@lru_cache(maxsize=512)
|
|
26
|
+
def _get_simple_field_class(type_hint: type) -> type[Any]:
|
|
27
|
+
"""
|
|
28
|
+
Cached lookup for simple, hashable types in Marshmallow's TYPE_MAPPING.
|
|
29
|
+
|
|
30
|
+
This avoids repeated dict lookups and isinstance checks for common types
|
|
31
|
+
like str, int, float, bool, datetime, etc.
|
|
32
|
+
"""
|
|
33
|
+
return Schema.TYPE_MAPPING.get(type_hint, ma_fields.Raw)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def type_to_marshmallow_field(type_hint: Any) -> Any:
|
|
19
37
|
"""
|
|
20
38
|
Map a Python type to a Marshmallow field instance.
|
|
21
39
|
|
|
@@ -36,6 +54,18 @@ def type_to_marshmallow_field(type_hint: Any) -> ma_fields.Field:
|
|
|
36
54
|
Returns:
|
|
37
55
|
An appropriate Marshmallow field instance
|
|
38
56
|
"""
|
|
57
|
+
# FAST PATH: Simple types (str, int, float, bool, datetime, etc.)
|
|
58
|
+
# Check TYPE_MAPPING first to skip all the isinstance/origin checks.
|
|
59
|
+
# This handles ~60-80% of fields in typical models, but we must not
|
|
60
|
+
# short-circuit for Enums or Pydantic models, which have specialized handling.
|
|
61
|
+
if (
|
|
62
|
+
isinstance(type_hint, type)
|
|
63
|
+
and type_hint in Schema.TYPE_MAPPING
|
|
64
|
+
and not issubclass(type_hint, (PyEnum, BaseModel))
|
|
65
|
+
):
|
|
66
|
+
# Cast needed for mypy: type_hint is confirmed to be a type at this point
|
|
67
|
+
return _get_simple_field_class(cast(type, type_hint))()
|
|
68
|
+
|
|
39
69
|
origin = get_origin(type_hint)
|
|
40
70
|
args = get_args(type_hint)
|
|
41
71
|
|
|
@@ -61,40 +91,47 @@ def type_to_marshmallow_field(type_hint: Any) -> ma_fields.Field:
|
|
|
61
91
|
return ma_fields.Raw(allow_none=True)
|
|
62
92
|
|
|
63
93
|
# Handle Enum types
|
|
64
|
-
|
|
65
|
-
|
|
94
|
+
# Use try-except because some type hints pass isinstance(x, type) but fail issubclass()
|
|
95
|
+
try:
|
|
96
|
+
if isinstance(type_hint, type) and issubclass(type_hint, PyEnum):
|
|
97
|
+
return ma_fields.Enum(type_hint)
|
|
98
|
+
except TypeError:
|
|
99
|
+
pass # Not a valid class for issubclass check
|
|
66
100
|
|
|
67
101
|
# Handle nested Pydantic models - use Nested with a dynamically created schema
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
102
|
+
try:
|
|
103
|
+
if isinstance(type_hint, type) and issubclass(type_hint, BaseModel):
|
|
104
|
+
# Import here to avoid circular imports
|
|
105
|
+
from pydantic_marshmallow.bridge import PydanticSchema
|
|
106
|
+
|
|
107
|
+
# Check if we're already processing this model (recursion detection)
|
|
108
|
+
# Use a module-level set to track models being processed
|
|
109
|
+
if type_hint in _processing_models:
|
|
110
|
+
# Self-referential model - use Raw to avoid infinite recursion
|
|
111
|
+
# Pydantic will still handle the validation correctly
|
|
112
|
+
return ma_fields.Raw()
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
_processing_models.add(type_hint)
|
|
116
|
+
# Create a nested schema class for this model
|
|
117
|
+
nested_schema = PydanticSchema.from_model(type_hint)
|
|
118
|
+
return ma_fields.Nested(nested_schema)
|
|
119
|
+
finally:
|
|
120
|
+
_processing_models.discard(type_hint)
|
|
121
|
+
except TypeError:
|
|
122
|
+
pass # Not a valid class for issubclass check
|
|
86
123
|
|
|
87
124
|
# Handle list[T]
|
|
88
125
|
if origin is list:
|
|
89
|
-
inner
|
|
126
|
+
inner = ma_fields.Raw()
|
|
90
127
|
if args:
|
|
91
128
|
inner = type_to_marshmallow_field(args[0])
|
|
92
129
|
return ma_fields.List(inner)
|
|
93
130
|
|
|
94
131
|
# Handle dict[K, V]
|
|
95
132
|
if origin is dict:
|
|
96
|
-
key_field
|
|
97
|
-
value_field
|
|
133
|
+
key_field = ma_fields.String()
|
|
134
|
+
value_field = ma_fields.Raw()
|
|
98
135
|
if args and len(args) >= 2:
|
|
99
136
|
key_field = type_to_marshmallow_field(args[0])
|
|
100
137
|
value_field = type_to_marshmallow_field(args[1])
|
|
@@ -102,7 +139,7 @@ def type_to_marshmallow_field(type_hint: Any) -> ma_fields.Field:
|
|
|
102
139
|
|
|
103
140
|
# Handle set[T] and frozenset[T] - convert to List in Marshmallow
|
|
104
141
|
if origin in (set, frozenset):
|
|
105
|
-
inner_set
|
|
142
|
+
inner_set = ma_fields.Raw()
|
|
106
143
|
if args:
|
|
107
144
|
inner_set = type_to_marshmallow_field(args[0])
|
|
108
145
|
return ma_fields.List(inner_set)
|
|
@@ -128,11 +165,11 @@ def type_to_marshmallow_field(type_hint: Any) -> ma_fields.Field:
|
|
|
128
165
|
if any(ut in type_name for ut in url_types):
|
|
129
166
|
return ma_fields.URL()
|
|
130
167
|
if 'IP' in type_name:
|
|
131
|
-
|
|
132
|
-
return ma_fields.IP() # type: ignore[no-untyped-call]
|
|
168
|
+
return ma_fields.IP() # type: ignore[no-untyped-call,unused-ignore]
|
|
133
169
|
|
|
134
170
|
# Use Marshmallow's native TYPE_MAPPING for basic types
|
|
135
171
|
# This ensures we stay in sync with Marshmallow's type handling
|
|
136
172
|
resolved = origin if origin else type_hint
|
|
137
|
-
|
|
138
|
-
|
|
173
|
+
if isinstance(resolved, type):
|
|
174
|
+
return _get_simple_field_class(resolved)()
|
|
175
|
+
return ma_fields.Raw()
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pydantic-marshmallow
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Bring Pydantic's power to Marshmallow: type annotations, validators, and automatic schema generation
|
|
5
|
-
Author
|
|
5
|
+
Author: Michael Thomas
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/mockodin/pydantic-marshmallow
|
|
8
8
|
Project-URL: Documentation, https://mockodin.github.io/pydantic-marshmallow
|
|
@@ -24,18 +24,17 @@ Classifier: Typing :: Typed
|
|
|
24
24
|
Requires-Python: >=3.10
|
|
25
25
|
Description-Content-Type: text/markdown
|
|
26
26
|
License-File: LICENSE
|
|
27
|
-
Requires-Dist: marshmallow
|
|
28
|
-
Requires-Dist: pydantic
|
|
27
|
+
Requires-Dist: marshmallow<5.0.0,>=3.18.0
|
|
28
|
+
Requires-Dist: pydantic<3.0.0,>=2.0.0
|
|
29
29
|
Requires-Dist: typing-extensions>=4.0.0
|
|
30
30
|
Requires-Dist: orjson>=3.9.0
|
|
31
31
|
Provides-Extra: dev
|
|
32
32
|
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
33
33
|
Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
|
|
34
34
|
Requires-Dist: email-validator>=2.1.0; extra == "dev"
|
|
35
|
-
Requires-Dist: mypy
|
|
36
|
-
Requires-Dist: ruff
|
|
37
|
-
Requires-Dist:
|
|
38
|
-
Requires-Dist: flake8-pyproject>=1.2.4; extra == "dev"
|
|
35
|
+
Requires-Dist: mypy==1.19.1; extra == "dev"
|
|
36
|
+
Requires-Dist: ruff==0.15.0; extra == "dev"
|
|
37
|
+
Requires-Dist: pre-commit>=4.2.0; extra == "dev"
|
|
39
38
|
Requires-Dist: sqlalchemy>=2.0.30; extra == "dev"
|
|
40
39
|
Requires-Dist: marshmallow-sqlalchemy>=1.1.0; extra == "dev"
|
|
41
40
|
Requires-Dist: flask>=3.0.0; extra == "dev"
|
|
@@ -67,6 +66,40 @@ Bridge Pydantic's power with Marshmallow's ecosystem. Use Pydantic models for va
|
|
|
67
66
|
|
|
68
67
|
📖 **[Documentation](https://mockodin.github.io/pydantic-marshmallow)** | 🐙 **[GitHub](https://github.com/mockodin/pydantic-marshmallow)**
|
|
69
68
|
|
|
69
|
+
## Why pydantic-marshmallow?
|
|
70
|
+
|
|
71
|
+
Get the best of both worlds: **Pydantic's speed** with **Marshmallow's ecosystem**.
|
|
72
|
+
|
|
73
|
+
### Performance
|
|
74
|
+
|
|
75
|
+
pydantic-marshmallow uses Pydantic's Rust-powered validation engine under the hood, delivering significant performance improvements over native Marshmallow—especially for nested data structures.
|
|
76
|
+
|
|
77
|
+
#### Performance Comparison
|
|
78
|
+
|
|
79
|
+
| Operation | MA 3.x | MA 4.x | Bridge (MA 3.x) | Bridge (MA 4.x) |
|
|
80
|
+
|-----------|--------|--------|-----------------|-----------------|
|
|
81
|
+
| Simple load | 5.3 µs | 4.8 µs | 2.9 µs | 2.9 µs |
|
|
82
|
+
| Nested model | 10.9 µs | 10.5 µs | 3.4 µs | 3.2 µs |
|
|
83
|
+
| Deep nested (4 levels) | 31.2 µs | 29.0 µs | 5.3 µs | 5.2 µs |
|
|
84
|
+
| Batch (100 items) | 454 µs | 424 µs | 260 µs | 259 µs |
|
|
85
|
+
|
|
86
|
+
#### What the Numbers Show
|
|
87
|
+
|
|
88
|
+
1. **MA 3.x → MA 4.x**: Marshmallow 4.x improved ~10% over 3.x (5.3 → 4.8 µs for simple loads)
|
|
89
|
+
2. **MA 3.x → Bridge**: Adding Pydantic delivers **1.8x–5.9x speedup** over pure MA 3.x
|
|
90
|
+
3. **MA 4.x → Bridge**: Still **1.6x–5.6x faster** than native MA 4.x
|
|
91
|
+
4. **Bridge consistency**: ~2.9 µs for simple loads regardless of Marshmallow version
|
|
92
|
+
|
|
93
|
+
The bridge delegates validation to Pydantic's Rust-powered core, bypassing Marshmallow's field processing. This means bridge performance is **independent of Marshmallow version**—you get consistent speed whether you're on MA 3.x or 4.x.
|
|
94
|
+
|
|
95
|
+
*Benchmarks: Python 3.11, median of 3 runs with IQR outlier removal. Run `python -m benchmarks.run_benchmarks` to reproduce.*
|
|
96
|
+
|
|
97
|
+
### Why it matters
|
|
98
|
+
|
|
99
|
+
- **Existing Marshmallow projects**: Incrementally adopt Pydantic validation without rewriting your API layer
|
|
100
|
+
- **Flask/webargs/apispec users**: Keep your integrations, get faster validation
|
|
101
|
+
- **Performance-sensitive APIs**: Nested model validation is 3-6x faster than native Marshmallow
|
|
102
|
+
|
|
70
103
|
## Features
|
|
71
104
|
|
|
72
105
|
- **Pydantic Validation**: Leverage Pydantic's Rust-powered validation engine
|
|
@@ -84,6 +117,8 @@ Bridge Pydantic's power with Marshmallow's ecosystem. Use Pydantic models for va
|
|
|
84
117
|
pip install pydantic-marshmallow
|
|
85
118
|
```
|
|
86
119
|
|
|
120
|
+
**Requirements:** Python 3.10+, Pydantic 2.0+, Marshmallow 3.18+ (including 4.x)
|
|
121
|
+
|
|
87
122
|
## Quick Start
|
|
88
123
|
|
|
89
124
|
### Basic Usage
|
|
@@ -213,7 +248,7 @@ class User(BaseModel):
|
|
|
213
248
|
first: str
|
|
214
249
|
last: str
|
|
215
250
|
|
|
216
|
-
@computed_field
|
|
251
|
+
@computed_field # type: ignore[misc]
|
|
217
252
|
@property
|
|
218
253
|
def full_name(self) -> str:
|
|
219
254
|
return f"{self.first} {self.last}"
|
|
@@ -237,6 +272,21 @@ schema.dump(user, exclude_unset=True)
|
|
|
237
272
|
schema.dump(user, exclude_defaults=True)
|
|
238
273
|
```
|
|
239
274
|
|
|
275
|
+
## JSON Serialization
|
|
276
|
+
|
|
277
|
+
Direct JSON string support:
|
|
278
|
+
|
|
279
|
+
```python
|
|
280
|
+
# Deserialize from JSON string
|
|
281
|
+
user = schema.loads('{"name": "Alice", "email": "alice@example.com", "age": 30}')
|
|
282
|
+
|
|
283
|
+
# Serialize to JSON string
|
|
284
|
+
json_str = schema.dumps(user)
|
|
285
|
+
|
|
286
|
+
# Batch operations
|
|
287
|
+
users = schema.loads('[{"name": "Alice", ...}, {"name": "Bob", ...}]', many=True)
|
|
288
|
+
```
|
|
289
|
+
|
|
240
290
|
## Flask-Marshmallow Integration
|
|
241
291
|
|
|
242
292
|
```python
|
|
@@ -288,6 +338,99 @@ spec = APISpec(
|
|
|
288
338
|
spec.components.schema("User", schema=UserSchema)
|
|
289
339
|
```
|
|
290
340
|
|
|
341
|
+
## flask-smorest Integration
|
|
342
|
+
|
|
343
|
+
Build REST APIs with automatic OpenAPI documentation:
|
|
344
|
+
|
|
345
|
+
```python
|
|
346
|
+
from flask import Flask
|
|
347
|
+
from flask_smorest import Api, Blueprint
|
|
348
|
+
from pydantic import BaseModel, Field
|
|
349
|
+
from pydantic_marshmallow import schema_for
|
|
350
|
+
|
|
351
|
+
app = Flask(__name__)
|
|
352
|
+
app.config["API_TITLE"] = "My API"
|
|
353
|
+
app.config["API_VERSION"] = "v1"
|
|
354
|
+
app.config["OPENAPI_VERSION"] = "3.0.2"
|
|
355
|
+
|
|
356
|
+
api = Api(app)
|
|
357
|
+
blp = Blueprint("users", __name__, url_prefix="/users")
|
|
358
|
+
|
|
359
|
+
class UserCreate(BaseModel):
|
|
360
|
+
name: str = Field(min_length=1)
|
|
361
|
+
email: str
|
|
362
|
+
|
|
363
|
+
UserCreateSchema = schema_for(UserCreate)
|
|
364
|
+
UserSchema = schema_for(User)
|
|
365
|
+
|
|
366
|
+
@blp.post("/")
|
|
367
|
+
@blp.arguments(UserCreateSchema)
|
|
368
|
+
@blp.response(201, UserSchema)
|
|
369
|
+
def create_user(data):
|
|
370
|
+
# data is a Pydantic UserCreate instance
|
|
371
|
+
user = User(id=1, name=data.name, email=data.email)
|
|
372
|
+
return UserSchema().dump(user)
|
|
373
|
+
|
|
374
|
+
api.register_blueprint(blp)
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
## flask-rebar Integration
|
|
378
|
+
|
|
379
|
+
Build REST APIs with automatic Swagger documentation:
|
|
380
|
+
|
|
381
|
+
```python
|
|
382
|
+
from flask import Flask
|
|
383
|
+
from flask_rebar import Rebar, get_validated_body
|
|
384
|
+
from pydantic_marshmallow import schema_for
|
|
385
|
+
|
|
386
|
+
app = Flask(__name__)
|
|
387
|
+
rebar = Rebar()
|
|
388
|
+
registry = rebar.create_handler_registry()
|
|
389
|
+
|
|
390
|
+
UserCreateSchema = schema_for(UserCreate)
|
|
391
|
+
UserSchema = schema_for(User)
|
|
392
|
+
|
|
393
|
+
@registry.handles(
|
|
394
|
+
rule="/users",
|
|
395
|
+
method="POST",
|
|
396
|
+
request_body_schema=UserCreateSchema(),
|
|
397
|
+
response_body_schema=UserSchema(),
|
|
398
|
+
)
|
|
399
|
+
def create_user():
|
|
400
|
+
data = get_validated_body() # Pydantic UserCreate instance
|
|
401
|
+
user = User(id=1, name=data.name, email=data.email)
|
|
402
|
+
return UserSchema().dump(user)
|
|
403
|
+
|
|
404
|
+
rebar.init_app(app)
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
## SQLAlchemy Pattern
|
|
408
|
+
|
|
409
|
+
Use Pydantic for API validation alongside SQLAlchemy ORM:
|
|
410
|
+
|
|
411
|
+
```python
|
|
412
|
+
from sqlalchemy.orm import Session
|
|
413
|
+
from pydantic_marshmallow import schema_for
|
|
414
|
+
|
|
415
|
+
# Pydantic model for API validation
|
|
416
|
+
class UserCreate(BaseModel):
|
|
417
|
+
name: str = Field(min_length=1)
|
|
418
|
+
email: str
|
|
419
|
+
|
|
420
|
+
UserCreateSchema = schema_for(UserCreate)
|
|
421
|
+
|
|
422
|
+
def create_user(session: Session, data: dict):
|
|
423
|
+
# Validate API input with Pydantic
|
|
424
|
+
schema = UserCreateSchema()
|
|
425
|
+
validated = schema.load(data) # Returns Pydantic model
|
|
426
|
+
|
|
427
|
+
# Create ORM object from validated data
|
|
428
|
+
orm_user = UserModel(name=validated.name, email=validated.email)
|
|
429
|
+
session.add(orm_user)
|
|
430
|
+
session.commit()
|
|
431
|
+
return orm_user
|
|
432
|
+
```
|
|
433
|
+
|
|
291
434
|
## HybridModel
|
|
292
435
|
|
|
293
436
|
For models that need both Pydantic and Marshmallow APIs:
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pydantic_marshmallow/__init__.py,sha256=EQON4HpiOFDtC33MrrE6WFW68GBtDlx1IqbDWxbqghU,3100
|
|
2
|
+
pydantic_marshmallow/bridge.py,sha256=fBk9vv6xZd-r-rhHufl-fbWSt784SEQ-IYrlm3duzpY,58026
|
|
3
|
+
pydantic_marshmallow/errors.py,sha256=slWs9m6Iid32ZUg8uxzNbFoZWFqh7ZuiG5qPlcOaBuc,5304
|
|
4
|
+
pydantic_marshmallow/field_conversion.py,sha256=WaWhvpB8rLL05I_D8b9q7BCymf24L7rxOpjMJY-TtYo,4978
|
|
5
|
+
pydantic_marshmallow/py.typed,sha256=GehYepOFQ5fvmwg3UbxoFj6w91p2kWD3KUBXL_rJRUI,86
|
|
6
|
+
pydantic_marshmallow/type_mapping.py,sha256=XlgOH3kksrv2kratQ1B1J1AJZj2spu7izoIF58hZeJs,6751
|
|
7
|
+
pydantic_marshmallow/validators.py,sha256=hg7ZTrjGHcyzfOtGy6T1LLGx4puRD3VSCLf0KN0PE8g,6686
|
|
8
|
+
pydantic_marshmallow-1.1.0.dist-info/licenses/LICENSE,sha256=y1B14nDO7pR7JdBNaWTLDt9MPBVRVuKZTUiRkw_eVgg,1071
|
|
9
|
+
pydantic_marshmallow-1.1.0.dist-info/METADATA,sha256=gwysXAIX0iAhRfpAe1aCzQPQEX_u5DC-YYNcMu1zghg,15021
|
|
10
|
+
pydantic_marshmallow-1.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
11
|
+
pydantic_marshmallow-1.1.0.dist-info/top_level.txt,sha256=sQCtMe_dpBvCrrBM6YIz1UQmGSf-XjdoBUeA2uyuoh4,21
|
|
12
|
+
pydantic_marshmallow-1.1.0.dist-info/RECORD,,
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
pydantic_marshmallow/__init__.py,sha256=IeZH-ZcLxyy_OMfIn-IxbPRxskOnPxY94E7FLJEEBcA,2831
|
|
2
|
-
pydantic_marshmallow/bridge.py,sha256=JkDF-BozWZtzTl31RMqRHPEV_eKbeybnsuEPNdrU--Q,47688
|
|
3
|
-
pydantic_marshmallow/errors.py,sha256=slWs9m6Iid32ZUg8uxzNbFoZWFqh7ZuiG5qPlcOaBuc,5304
|
|
4
|
-
pydantic_marshmallow/field_conversion.py,sha256=xFQtR63DwTDq-hGA_8Xe8sO6lFW6BCZNmnk5oxz5B6w,4262
|
|
5
|
-
pydantic_marshmallow/py.typed,sha256=GehYepOFQ5fvmwg3UbxoFj6w91p2kWD3KUBXL_rJRUI,86
|
|
6
|
-
pydantic_marshmallow/type_mapping.py,sha256=4gFVZV7WVywyugTyiE-y7saWaHmcaPeCs0vQjBQBQ8I,5285
|
|
7
|
-
pydantic_marshmallow/validators.py,sha256=hg7ZTrjGHcyzfOtGy6T1LLGx4puRD3VSCLf0KN0PE8g,6686
|
|
8
|
-
pydantic_marshmallow-1.0.0.dist-info/licenses/LICENSE,sha256=y1B14nDO7pR7JdBNaWTLDt9MPBVRVuKZTUiRkw_eVgg,1071
|
|
9
|
-
pydantic_marshmallow-1.0.0.dist-info/METADATA,sha256=gVyLe10AtxyW4hJ9NTe43gIyPPBwigeu_BLWyopYfdg,10556
|
|
10
|
-
pydantic_marshmallow-1.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
11
|
-
pydantic_marshmallow-1.0.0.dist-info/top_level.txt,sha256=sQCtMe_dpBvCrrBM6YIz1UQmGSf-XjdoBUeA2uyuoh4,21
|
|
12
|
-
pydantic_marshmallow-1.0.0.dist-info/RECORD,,
|
|
File without changes
|
{pydantic_marshmallow-1.0.0.dist-info → pydantic_marshmallow-1.1.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|