Skip to content

Commit 9cddff6

Browse files
authored
load accepts Sequence rather than Iterable (rejects generators) (#2795)
* load accepts Sequence rather than Iterable (rejects generators) * Fix handling of strings * Fix typing * Use TypeGuards and fix type issues * Update changelog
1 parent bc5cde0 commit 9cddff6

5 files changed

Lines changed: 40 additions & 34 deletions

File tree

CHANGELOG.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ Other changes:
2020
- Typing: `Field <marshmallow.fields.Field>` is now a generic type with a type argument for the internal value type.
2121
- `marshmallow.fields.UUID` no longer subclasses `marshmallow.fields.String`.
2222
- *Backwards-incompatible*: Use `datetime.date.fromisoformat`, `datetime.time.fromisoformat`, and `datetime.datetime.fromisoformat` from the standard library to deserialize dates, times and datetimes (:pr:`2078`).
23+
- `marshmallow.Schema.load` no longer silently fails to call schema validators when a generator is passed (:issue:`1898`).
24+
The typing of `data` is also updated to be more accurate.
25+
Thanks :user:`ziplokk1` for reporting.
2326

2427
As a consequence of this change:
2528
- Time with time offsets are now supported.

src/marshmallow/fields.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535
ValidationError,
3636
_FieldInstanceResolutionError,
3737
)
38-
from marshmallow.utils import is_aware, is_collection
3938
from marshmallow.validate import And, Length
4039

4140
if typing.TYPE_CHECKING:
@@ -501,9 +500,9 @@ def __init__(
501500
**kwargs: Unpack[_BaseFieldKwargs],
502501
):
503502
# Raise error if only or exclude is passed as string, not list of strings
504-
if only is not None and not is_collection(only):
503+
if only is not None and not utils.is_sequence_but_not_string(only):
505504
raise StringNotCollectionError('"only" should be a collection of strings.')
506-
if not is_collection(exclude):
505+
if not utils.is_sequence_but_not_string(exclude):
507506
raise StringNotCollectionError(
508507
'"exclude" should be a collection of strings.'
509508
)
@@ -818,7 +817,7 @@ def _deserialize(
818817
data: typing.Mapping[str, typing.Any] | None,
819818
**kwargs,
820819
) -> tuple:
821-
if not utils.is_collection(value):
820+
if not utils.is_sequence_but_not_string(value):
822821
raise self.make_error("invalid")
823822

824823
self.validate_length(value)
@@ -1322,7 +1321,7 @@ def __init__(
13221321

13231322
def _deserialize(self, value, attr, data, **kwargs) -> dt.datetime:
13241323
ret = super()._deserialize(value, attr, data, **kwargs)
1325-
if is_aware(ret):
1324+
if utils.is_aware(ret):
13261325
if self.timezone is None:
13271326
raise self.make_error(
13281327
"invalid_awareness",
@@ -1359,7 +1358,7 @@ def __init__(
13591358

13601359
def _deserialize(self, value, attr, data, **kwargs) -> dt.datetime:
13611360
ret = super()._deserialize(value, attr, data, **kwargs)
1362-
if not is_aware(ret):
1361+
if not utils.is_aware(ret):
13631362
if self.default_timezone is None:
13641363
raise self.make_error(
13651364
"invalid_awareness",

src/marshmallow/schema.py

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import uuid
1515
from abc import ABCMeta
1616
from collections import defaultdict
17-
from collections.abc import Mapping
17+
from collections.abc import Mapping, Sequence
1818
from itertools import zip_longest
1919

2020
from marshmallow import class_registry, types
@@ -31,7 +31,12 @@
3131
from marshmallow.error_store import ErrorStore
3232
from marshmallow.exceptions import SCHEMA, StringNotCollectionError, ValidationError
3333
from marshmallow.orderedset import OrderedSet
34-
from marshmallow.utils import get_value, is_collection, set_value
34+
from marshmallow.utils import (
35+
get_value,
36+
is_collection,
37+
is_sequence_but_not_string,
38+
set_value,
39+
)
3540

3641
if typing.TYPE_CHECKING:
3742
from marshmallow.fields import Field
@@ -583,10 +588,7 @@ def dumps(self, obj: typing.Any, *args, many: bool | None = None, **kwargs):
583588

584589
def _deserialize(
585590
self,
586-
data: (
587-
typing.Mapping[str, typing.Any]
588-
| typing.Iterable[typing.Mapping[str, typing.Any]]
589-
),
591+
data: Mapping[str, typing.Any] | Sequence[Mapping[str, typing.Any]],
590592
*,
591593
error_store: ErrorStore,
592594
many: bool = False,
@@ -613,13 +615,13 @@ def _deserialize(
613615
index_errors = self.opts.index_errors
614616
index = index if index_errors else None
615617
if many:
616-
if not is_collection(data):
618+
if not is_sequence_but_not_string(data):
617619
error_store.store_error([self.error_messages["type"]], index=index)
618620
ret_l = []
619621
else:
620622
ret_l = [
621623
self._deserialize(
622-
typing.cast(dict, d),
624+
d,
623625
error_store=error_store,
624626
many=False,
625627
partial=partial,
@@ -697,10 +699,7 @@ def getter(
697699

698700
def load(
699701
self,
700-
data: (
701-
typing.Mapping[str, typing.Any]
702-
| typing.Iterable[typing.Mapping[str, typing.Any]]
703-
),
702+
data: Mapping[str, typing.Any] | Sequence[Mapping[str, typing.Any]],
704703
*,
705704
many: bool | None = None,
706705
partial: bool | types.StrSequenceOrSet | None = None,
@@ -808,10 +807,7 @@ def _run_validator(
808807

809808
def validate(
810809
self,
811-
data: (
812-
typing.Mapping[str, typing.Any]
813-
| typing.Iterable[typing.Mapping[str, typing.Any]]
814-
),
810+
data: Mapping[str, typing.Any] | Sequence[Mapping[str, typing.Any]],
815811
*,
816812
many: bool | None = None,
817813
partial: bool | types.StrSequenceOrSet | None = None,
@@ -838,10 +834,7 @@ def validate(
838834

839835
def _do_load(
840836
self,
841-
data: (
842-
typing.Mapping[str, typing.Any]
843-
| typing.Iterable[typing.Mapping[str, typing.Any]]
844-
),
837+
data: (Mapping[str, typing.Any] | Sequence[Mapping[str, typing.Any]]),
845838
*,
846839
many: bool | None = None,
847840
partial: bool | types.StrSequenceOrSet | None = None,
@@ -1093,7 +1086,7 @@ def _invoke_dump_processors(
10931086
def _invoke_load_processors(
10941087
self,
10951088
tag: str,
1096-
data,
1089+
data: Mapping[str, typing.Any] | Sequence[Mapping[str, typing.Any]],
10971090
*,
10981091
many: bool,
10991092
original_data,
@@ -1217,7 +1210,7 @@ def _invoke_processors(
12171210
tag: str,
12181211
*,
12191212
pass_collection: bool,
1220-
data,
1213+
data: Mapping[str, typing.Any] | Sequence[Mapping[str, typing.Any]],
12211214
many: bool,
12221215
original_data=None,
12231216
**kwargs,

src/marshmallow/utils.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,33 @@
66
import datetime as dt
77
import inspect
88
import typing
9-
from collections.abc import Mapping
9+
from collections.abc import Mapping, Sequence
10+
11+
# Remove when we drop Python 3.9
12+
try:
13+
from typing import TypeGuard
14+
except ImportError:
15+
from typing_extensions import TypeGuard
1016

1117
from marshmallow.constants import missing
1218

1319

14-
def is_generator(obj) -> bool:
20+
def is_generator(obj) -> TypeGuard[typing.Generator]:
1521
"""Return True if ``obj`` is a generator"""
1622
return inspect.isgeneratorfunction(obj) or inspect.isgenerator(obj)
1723

1824

19-
def is_iterable_but_not_string(obj) -> bool:
25+
def is_iterable_but_not_string(obj) -> TypeGuard[typing.Iterable]:
2026
"""Return True if ``obj`` is an iterable object that isn't a string."""
2127
return (hasattr(obj, "__iter__") and not hasattr(obj, "strip")) or is_generator(obj)
2228

2329

24-
def is_collection(obj) -> bool:
30+
def is_sequence_but_not_string(obj) -> TypeGuard[Sequence]:
31+
"""Return True if ``obj`` is a sequence that isn't a string."""
32+
return isinstance(obj, Sequence) and not isinstance(obj, (str, bytes))
33+
34+
35+
def is_collection(obj) -> TypeGuard[typing.Iterable]:
2536
"""Return True if ``obj`` is a collection type, e.g list, tuple, queryset."""
2637
return is_iterable_but_not_string(obj) and not isinstance(obj, Mapping)
2738

tests/test_schema.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ class Sch(Schema):
250250
assert e.value.valid_data == []
251251

252252

253-
@pytest.mark.parametrize("val", ([], set()))
253+
@pytest.mark.parametrize("val", ([], tuple()))
254254
def test_load_many_empty_collection(val):
255255
class Sch(Schema):
256256
name = fields.Str()
@@ -276,7 +276,7 @@ class Outer(Schema):
276276
}
277277

278278

279-
@pytest.mark.parametrize("val", ([], set()))
279+
@pytest.mark.parametrize("val", ([], tuple()))
280280
def test_load_many_in_nested_empty_collection(val):
281281
class Inner(Schema):
282282
name = fields.String()

0 commit comments

Comments
 (0)