pydantic-marshmallow 1.0.1__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.
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
  import threading
11
11
  from collections.abc import Callable, Sequence, Set as AbstractSet
12
12
  from functools import lru_cache
13
+ from importlib.metadata import version as get_version
13
14
  from typing import Any, ClassVar, Generic, TypeVar, cast, get_args, get_origin
14
15
 
15
16
  from marshmallow import EXCLUDE, INCLUDE, RAISE, Schema, fields as ma_fields
@@ -17,13 +18,29 @@ from marshmallow.decorators import VALIDATES, VALIDATES_SCHEMA
17
18
  from marshmallow.error_store import ErrorStore
18
19
  from marshmallow.exceptions import ValidationError as MarshmallowValidationError
19
20
  from marshmallow.schema import SchemaMeta
20
- from marshmallow.utils import missing as ma_missing
21
+ from marshmallow.utils import missing as ma_missing # type: ignore[attr-defined,unused-ignore]
21
22
  from pydantic import BaseModel, ConfigDict, ValidationError as PydanticValidationError
22
23
 
23
24
  from .errors import BridgeValidationError, convert_pydantic_errors, format_pydantic_error
24
- from .field_conversion import convert_model_fields, convert_pydantic_field
25
+ from .field_conversion import _get_computed_fields, convert_model_fields, convert_pydantic_field
25
26
  from .validators import cache_validators
26
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
+
27
44
  M = TypeVar("M", bound=BaseModel)
28
45
 
29
46
  # =============================================================================
@@ -170,10 +187,11 @@ class PydanticSchemaMeta(SchemaMeta):
170
187
  attrs[field_name] = convert_pydantic_field(field_name, field_info)
171
188
 
172
189
  # Add computed fields as dump_only
173
- if hasattr(model_class, 'model_computed_fields'):
190
+ computed_fields = _get_computed_fields(model_class)
191
+ if computed_fields:
174
192
  from .field_conversion import convert_computed_field
175
193
 
176
- for field_name, computed_info in model_class.model_computed_fields.items():
194
+ for field_name, computed_info in computed_fields.items():
177
195
  if field_name in attrs:
178
196
  continue
179
197
  # If attrs has pre-filtered fields, don't add computed fields
@@ -189,7 +207,7 @@ class PydanticSchemaMeta(SchemaMeta):
189
207
  return cast(PydanticSchemaMeta, super().__new__(mcs, name, bases, attrs))
190
208
 
191
209
 
192
- class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
210
+ class _PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
193
211
  """
194
212
  A Marshmallow schema backed by a Pydantic model.
195
213
 
@@ -268,20 +286,27 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
268
286
  self._dump_only_fields: set[str] = set(dump_only) if dump_only else set()
269
287
  self._partial: bool | Sequence[str] | AbstractSet[str] | None = partial
270
288
  self._unknown_override: str | None = unknown
271
- self._context = context or {}
272
289
 
273
- # Pass all known kwargs to parent including context
274
- super().__init__(
275
- only=only,
276
- exclude=exclude,
277
- context=context,
278
- many=many,
279
- load_only=load_only,
280
- dump_only=dump_only,
281
- partial=partial,
282
- unknown=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,
283
300
  **kwargs,
284
- )
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
285
310
  self._model_class = self._get_model_class()
286
311
  if self._model_class:
287
312
  self._setup_fields_from_model()
@@ -290,7 +315,7 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
290
315
  for field_name, field_obj in self.fields.items():
291
316
  self.on_bind_field(field_name, field_obj)
292
317
 
293
- def on_bind_field(self, field_name: str, field_obj: ma_fields.Field) -> None:
318
+ def on_bind_field(self, field_name: str, field_obj: Any) -> None:
294
319
  """
295
320
  Hook called when a field is bound to the schema.
296
321
 
@@ -340,16 +365,6 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
340
365
  """
341
366
  raise error
342
367
 
343
- @property
344
- def context(self) -> dict[str, Any]:
345
- """Get the validation context."""
346
- return self._context
347
-
348
- @context.setter
349
- def context(self, value: dict[str, Any]) -> None:
350
- """Set the validation context."""
351
- self._context = value
352
-
353
368
  def _get_model_class(self) -> type[BaseModel] | None:
354
369
  """Get the Pydantic model class from Meta or generic parameter."""
355
370
  # Try Meta.model first
@@ -616,7 +631,9 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
616
631
  if many:
617
632
  if not isinstance(data, list):
618
633
  raise MarshmallowValidationError({"_schema": ["Expected a list."]})
619
- return [
634
+
635
+ # Process each item individually (validators run per-item with pass_many=False)
636
+ results = [
620
637
  self._do_load(
621
638
  item,
622
639
  many=False,
@@ -628,18 +645,47 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
628
645
  for item in data
629
646
  ]
630
647
 
631
- # Step 1: Run pre_load hooks ONLY if they exist (PERFORMANCE OPTIMIZATION)
632
- # Skipping _invoke_load_processors when empty saves ~5ms per 10k loads
633
- if hooks.get("pre_load"):
634
- processed_data_raw = self._invoke_load_processors(
635
- "pre_load",
636
- data,
637
- many=False,
638
- original_data=data,
639
- partial=partial,
640
- )
641
- else:
642
- processed_data_raw = data
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
+ )
643
689
 
644
690
  # Type narrowing: at this point (many=False path), data is always a dict
645
691
  processed_data: dict[str, Any] = cast(dict[str, Any], processed_data_raw)
@@ -669,9 +715,25 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
669
715
 
670
716
  # OPTIMIZATION: Determine if we need model_dump() for validators/result
671
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
+
672
734
  has_validators = bool(
673
- hooks[VALIDATES]
674
- or hooks[VALIDATES_SCHEMA]
735
+ _has_hook(VALIDATES)
736
+ or _has_hook(VALIDATES_SCHEMA)
675
737
  or self._field_validators_cache
676
738
  or self._schema_validators_cache
677
739
  )
@@ -709,12 +771,11 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
709
771
  error_store: Any = error_store_cls()
710
772
 
711
773
  # 4a: Run Marshmallow's native @validates decorators (from hooks)
712
- if hooks[VALIDATES]:
713
- self._invoke_field_validators(
714
- error_store=error_store,
715
- data=validated_data,
716
- many=False,
717
- )
774
+ self._invoke_field_validators(
775
+ error_store=error_store,
776
+ data=validated_data,
777
+ many=False,
778
+ )
718
779
 
719
780
  # 4b: Run our custom @validates decorators (backwards compatibility)
720
781
  try:
@@ -729,25 +790,25 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
729
790
 
730
791
  # Step 5: Run schema validators (BOTH Marshmallow native AND our custom)
731
792
  # 5a: Run Marshmallow's native @validates_schema decorators
732
- if hooks[VALIDATES_SCHEMA]:
733
- self._invoke_schema_validators(
734
- error_store=error_store,
735
- pass_many=True,
736
- data=validated_data,
737
- original_data=data,
738
- many=False,
739
- partial=partial,
740
- field_errors=has_field_errors,
741
- )
742
- self._invoke_schema_validators(
743
- error_store=error_store,
744
- pass_many=False,
745
- data=validated_data,
746
- original_data=data,
747
- many=False,
748
- partial=partial,
749
- field_errors=has_field_errors,
750
- )
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})
751
812
 
752
813
  # 5b: Run our custom @validates_schema decorators (backwards compatibility)
753
814
  try:
@@ -797,14 +858,20 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
797
858
  # Return dict instead of instance
798
859
  result = validated_data
799
860
 
800
- # Step 7: Run post_load hooks ONLY if they exist (PERFORMANCE OPTIMIZATION)
801
- if postprocess and hooks.get("post_load"):
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
802
871
  result = self._invoke_load_processors(
803
872
  "post_load",
804
- result,
805
- many=False,
806
- original_data=data,
807
- partial=partial,
873
+ cast(dict[str, Any], result),
874
+ **post_load_kwargs,
808
875
  )
809
876
 
810
877
  return result
@@ -1048,8 +1115,9 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
1048
1115
  fields_to_exclude.add(field_name)
1049
1116
 
1050
1117
  # Extract computed field values BEFORE converting to dict
1051
- if include_computed and hasattr(model_class, 'model_computed_fields'):
1052
- for field_name in model_class.model_computed_fields:
1118
+ computed_fields = _get_computed_fields(model_class)
1119
+ if include_computed and computed_fields:
1120
+ for field_name in computed_fields:
1053
1121
  value = getattr(obj, field_name)
1054
1122
  # Apply exclusion rules to computed fields too
1055
1123
  if exclude_none and value is None:
@@ -1159,6 +1227,66 @@ class PydanticSchema(Schema, Generic[M], metaclass=PydanticSchemaMeta):
1159
1227
  return schema_cls
1160
1228
 
1161
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
+
1162
1290
  def schema_for(model: type[M], **meta_options: Any) -> type[PydanticSchema[M]]:
1163
1291
  """
1164
1292
  Shortcut to create a Marshmallow schema from a Pydantic model.
@@ -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
- ) -> ma_fields.Field:
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
- ) -> ma_fields.Field:
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, ma_fields.Field]:
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, ma_fields.Field] = {}
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
- if include_computed and hasattr(model, 'model_computed_fields'):
129
- for field_name, computed_info in model.model_computed_fields.items():
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,6 +4,9 @@ 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
8
11
  from functools import lru_cache
9
12
  from types import UnionType
@@ -20,7 +23,7 @@ _processing_models: set[type[Any]] = set()
20
23
  # These are the most common types and benefit most from caching.
21
24
  # maxsize=512 handles large codebases with ~80KB memory overhead.
22
25
  @lru_cache(maxsize=512)
23
- def _get_simple_field_class(type_hint: type) -> type[ma_fields.Field]:
26
+ def _get_simple_field_class(type_hint: type) -> type[Any]:
24
27
  """
25
28
  Cached lookup for simple, hashable types in Marshmallow's TYPE_MAPPING.
26
29
 
@@ -30,7 +33,7 @@ def _get_simple_field_class(type_hint: type) -> type[ma_fields.Field]:
30
33
  return Schema.TYPE_MAPPING.get(type_hint, ma_fields.Raw)
31
34
 
32
35
 
33
- def type_to_marshmallow_field(type_hint: Any) -> ma_fields.Field:
36
+ def type_to_marshmallow_field(type_hint: Any) -> Any:
34
37
  """
35
38
  Map a Python type to a Marshmallow field instance.
36
39
 
@@ -88,40 +91,47 @@ def type_to_marshmallow_field(type_hint: Any) -> ma_fields.Field:
88
91
  return ma_fields.Raw(allow_none=True)
89
92
 
90
93
  # Handle Enum types
91
- if isinstance(type_hint, type) and issubclass(type_hint, PyEnum):
92
- return ma_fields.Enum(type_hint)
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
93
100
 
94
101
  # Handle nested Pydantic models - use Nested with a dynamically created schema
95
- if isinstance(type_hint, type) and issubclass(type_hint, BaseModel):
96
- # Import here to avoid circular imports
97
- from pydantic_marshmallow.bridge import PydanticSchema
98
-
99
- # Check if we're already processing this model (recursion detection)
100
- # Use a module-level set to track models being processed
101
- if type_hint in _processing_models:
102
- # Self-referential model - use Raw to avoid infinite recursion
103
- # Pydantic will still handle the validation correctly
104
- return ma_fields.Raw()
105
-
106
- try:
107
- _processing_models.add(type_hint)
108
- # Create a nested schema class for this model
109
- nested_schema = PydanticSchema.from_model(type_hint)
110
- return ma_fields.Nested(nested_schema)
111
- finally:
112
- _processing_models.discard(type_hint)
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
113
123
 
114
124
  # Handle list[T]
115
125
  if origin is list:
116
- inner: ma_fields.Field = ma_fields.Raw()
126
+ inner = ma_fields.Raw()
117
127
  if args:
118
128
  inner = type_to_marshmallow_field(args[0])
119
129
  return ma_fields.List(inner)
120
130
 
121
131
  # Handle dict[K, V]
122
132
  if origin is dict:
123
- key_field: ma_fields.Field = ma_fields.String()
124
- value_field: ma_fields.Field = ma_fields.Raw()
133
+ key_field = ma_fields.String()
134
+ value_field = ma_fields.Raw()
125
135
  if args and len(args) >= 2:
126
136
  key_field = type_to_marshmallow_field(args[0])
127
137
  value_field = type_to_marshmallow_field(args[1])
@@ -129,7 +139,7 @@ def type_to_marshmallow_field(type_hint: Any) -> ma_fields.Field:
129
139
 
130
140
  # Handle set[T] and frozenset[T] - convert to List in Marshmallow
131
141
  if origin in (set, frozenset):
132
- inner_set: ma_fields.Field = ma_fields.Raw()
142
+ inner_set = ma_fields.Raw()
133
143
  if args:
134
144
  inner_set = type_to_marshmallow_field(args[0])
135
145
  return ma_fields.List(inner_set)
@@ -155,8 +165,7 @@ def type_to_marshmallow_field(type_hint: Any) -> ma_fields.Field:
155
165
  if any(ut in type_name for ut in url_types):
156
166
  return ma_fields.URL()
157
167
  if 'IP' in type_name:
158
- # IP field exists in marshmallow but may not have type stubs
159
- return ma_fields.IP() # type: ignore[no-untyped-call]
168
+ return ma_fields.IP() # type: ignore[no-untyped-call,unused-ignore]
160
169
 
161
170
  # Use Marshmallow's native TYPE_MAPPING for basic types
162
171
  # This ensures we stay in sync with Marshmallow's type handling
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pydantic-marshmallow
3
- Version: 1.0.1
3
+ Version: 1.1.0
4
4
  Summary: Bring Pydantic's power to Marshmallow: type annotations, validators, and automatic schema generation
5
- Author-email: Your Name <your.email@example.com>
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
@@ -32,10 +32,9 @@ 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>=1.10.0; extra == "dev"
36
- Requires-Dist: ruff>=0.4.0; extra == "dev"
37
- Requires-Dist: flake8>=7.1.0; extra == "dev"
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"
@@ -73,16 +72,27 @@ Get the best of both worlds: **Pydantic's speed** with **Marshmallow's ecosystem
73
72
 
74
73
  ### Performance
75
74
 
76
- pydantic-marshmallow uses Pydantic's Rust-powered validation engine under the hood, delivering significant performance improvements over native Marshmallow—especially for nested data structures:
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.
77
76
 
78
- | Operation | pydantic-marshmallow | Marshmallow | Speedup |
79
- |-----------|---------------------|-------------|---------|
80
- | Simple load | 3.0 µs | 5.3 µs | **1.8x faster** |
81
- | Nested model | 3.5 µs | 11.6 µs | **3.3x faster** |
82
- | Deep nested (4 levels) | 5.6 µs | 32.3 µs | **5.8x faster** |
83
- | Batch (100 items) | 255 µs | 474 µs | **1.9x faster** |
77
+ #### Performance Comparison
84
78
 
85
- *Benchmarks run on Python 3.11. Run `python -m benchmarks.run_benchmarks` to reproduce.*
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.*
86
96
 
87
97
  ### Why it matters
88
98
 
@@ -107,6 +117,8 @@ pydantic-marshmallow uses Pydantic's Rust-powered validation engine under the ho
107
117
  pip install pydantic-marshmallow
108
118
  ```
109
119
 
120
+ **Requirements:** Python 3.10+, Pydantic 2.0+, Marshmallow 3.18+ (including 4.x)
121
+
110
122
  ## Quick Start
111
123
 
112
124
  ### Basic Usage
@@ -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=EQON4HpiOFDtC33MrrE6WFW68GBtDlx1IqbDWxbqghU,3100
2
- pydantic_marshmallow/bridge.py,sha256=MOBwr0casMgaPjP5GAcYjnG8bGgNpmpEa-zagjkClEY,52147
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=NYTJdmTVL-EjNW93ysb2KN9ASAj0mQu8rQpa_vOgqbU,6529
7
- pydantic_marshmallow/validators.py,sha256=hg7ZTrjGHcyzfOtGy6T1LLGx4puRD3VSCLf0KN0PE8g,6686
8
- pydantic_marshmallow-1.0.1.dist-info/licenses/LICENSE,sha256=y1B14nDO7pR7JdBNaWTLDt9MPBVRVuKZTUiRkw_eVgg,1071
9
- pydantic_marshmallow-1.0.1.dist-info/METADATA,sha256=yNbwhXPJSg0WZAc8tfFo4U6fy57gX0eVNRf4JPLHO4g,14302
10
- pydantic_marshmallow-1.0.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
11
- pydantic_marshmallow-1.0.1.dist-info/top_level.txt,sha256=sQCtMe_dpBvCrrBM6YIz1UQmGSf-XjdoBUeA2uyuoh4,21
12
- pydantic_marshmallow-1.0.1.dist-info/RECORD,,