Skip to content

Commit bbd4ea8

Browse files
committed
Add Annotated support and therefore set minimum python version as 3.9
1 parent c59cdb5 commit bbd4ea8

File tree

8 files changed

+79
-52
lines changed

8 files changed

+79
-52
lines changed

.github/workflows/python-package.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ jobs:
1212
fail-fast: false
1313
matrix:
1414
os: ["ubuntu-latest"]
15-
python_version: ["3.7", "3.8", "3.9", "3.10", "3.11", "pypy3.10"]
15+
python_version: ["3.9", "3.10", "3.11", "3.12", "pypy3.10"]
1616
include:
1717
- os: "ubuntu-20.04"
18-
python_version: "3.6"
18+
python_version: "3.9"
1919

2020
runs-on: ${{ matrix.os }}
2121
steps:

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ repos:
33
rev: v3.3.1
44
hooks:
55
- id: pyupgrade
6-
args: ["--py36-plus"]
6+
args: ["--py37-plus"]
77
- repo: https://github.com/python/black
88
rev: 23.1.0
99
hooks:

README.md

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -247,28 +247,25 @@ class Sample:
247247

248248
See [marshmallow's documentation about extending `Schema`](https://marshmallow.readthedocs.io/en/stable/extending.html).
249249

250-
### Custom NewType declarations
250+
### Custom type declarations
251251

252-
This library exports a `NewType` function to create types that generate [customized marshmallow fields](https://marshmallow.readthedocs.io/en/stable/custom_fields.html#creating-a-field-class).
253-
254-
Keyword arguments to `NewType` are passed to the marshmallow field constructor.
252+
This library allows you to specify [customized marshmallow fields](https://marshmallow.readthedocs.io/en/stable/custom_fields.html#creating-a-field-class) using python's Annoted type [PEP-593](https://peps.python.org/pep-0593/).
255253

256254
```python
257-
import marshmallow.validate
258-
from marshmallow_dataclass import NewType
255+
from typing import Annotated
256+
import marshmallow.fields as mf
257+
import marshmallow.validate as mv
259258

260-
IPv4 = NewType(
261-
"IPv4", str, validate=marshmallow.validate.Regexp(r"^([0-9]{1,3}\\.){3}[0-9]{1,3}$")
262-
)
259+
IPv4 = Annotated[str, mf.String(validate=mv.Regexp(r"^([0-9]{1,3}\\.){3}[0-9]{1,3}$"))]
263260
```
264261

265-
You can also pass a marshmallow field to `NewType`.
262+
You can also pass a marshmallow field class.
266263

267264
```python
268265
import marshmallow
269266
from marshmallow_dataclass import NewType
270267

271-
Email = NewType("Email", str, field=marshmallow.fields.Email)
268+
Email = Annotated[str, marshmallow.fields.Email]
272269
```
273270

274271
For convenience, some custom types are provided:

marshmallow_dataclass/__init__.py

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ class User:
4343
import warnings
4444
from enum import Enum
4545
from functools import lru_cache, partial
46+
from typing import Annotated, Any, Callable, Dict, FrozenSet, List, Mapping
47+
from typing import NewType as typing_NewType
4648
from typing import (
4749
Any,
4850
Callable,
@@ -52,32 +54,28 @@ class User:
5254
Mapping,
5355
NewType as typing_NewType,
5456
Optional,
57+
Sequence,
5558
Set,
5659
Tuple,
5760
Type,
5861
TypeVar,
5962
Union,
6063
cast,
64+
get_args,
65+
get_origin,
6166
get_type_hints,
6267
overload,
63-
Sequence,
64-
FrozenSet,
6568
)
6669

6770
import marshmallow
6871
import typing_inspect
6972

7073
from marshmallow_dataclass.lazy_class_attribute import lazy_class_attribute
7174

72-
7375
if sys.version_info >= (3, 11):
7476
from typing import dataclass_transform
75-
elif sys.version_info >= (3, 7):
76-
from typing_extensions import dataclass_transform
7777
else:
78-
# @dataclass_transform() only helps us with mypy>=1.1 which is only available for python>=3.7
79-
def dataclass_transform(**kwargs):
80-
return lambda cls: cls
78+
from typing_extensions import dataclass_transform
8179

8280

8381
__all__ = ["dataclass", "add_schema", "class_schema", "field_for_schema", "NewType"]
@@ -547,7 +545,7 @@ def _internal_class_schema(
547545

548546
# Update the schema members to contain marshmallow fields instead of dataclass fields
549547
type_hints = get_type_hints(
550-
clazz, globalns=schema_ctx.globalns, localns=schema_ctx.localns
548+
clazz, globalns=schema_ctx.globalns, localns=schema_ctx.localns, include_extras=True,
551549
)
552550
attributes.update(
553551
(
@@ -639,12 +637,27 @@ def _field_for_generic_type(
639637
"""
640638
If the type is a generic interface, resolve the arguments and construct the appropriate Field.
641639
"""
642-
origin = typing_inspect.get_origin(typ)
643-
arguments = typing_inspect.get_args(typ, True)
640+
origin = get_origin(typ)
641+
arguments = get_args(typ)
644642
if origin:
645643
# Override base_schema.TYPE_MAPPING to change the class used for generic types below
646644
type_mapping = base_schema.TYPE_MAPPING if base_schema else {}
647645

646+
if origin is Annotated:
647+
marshmallow_annotations = [
648+
arg
649+
for arg in arguments[1:]
650+
if (inspect.isclass(arg) and issubclass(arg, marshmallow.fields.Field))
651+
or isinstance(arg, marshmallow.fields.Field)
652+
]
653+
if marshmallow_annotations:
654+
field = marshmallow_annotations[-1]
655+
# Got a field instance, return as is. User must know what they're doing
656+
if isinstance(field, marshmallow.fields.Field):
657+
return field
658+
659+
return field(**metadata)
660+
648661
if origin in (list, List):
649662
child_type = _field_for_schema(arguments[0], base_schema=base_schema)
650663
list_type = cast(
@@ -806,6 +819,7 @@ def _field_for_schema(
806819
metadata.setdefault("allow_none", True)
807820
return marshmallow.fields.Raw(**metadata)
808821

822+
# i.e.: Literal['abc']
809823
if typing_inspect.is_literal_type(typ):
810824
arguments = typing_inspect.get_args(typ)
811825
return marshmallow.fields.Raw(
@@ -817,6 +831,7 @@ def _field_for_schema(
817831
**metadata,
818832
)
819833

834+
# i.e.: Final[str] = 'abc'
820835
if typing_inspect.is_final_type(typ):
821836
arguments = typing_inspect.get_args(typ)
822837
if arguments:
@@ -870,13 +885,7 @@ def _field_for_schema(
870885

871886
# enumerations
872887
if issubclass(typ, Enum):
873-
try:
874-
return marshmallow.fields.Enum(typ, **metadata)
875-
except AttributeError:
876-
# Remove this once support for python 3.6 is dropped.
877-
import marshmallow_enum
878-
879-
return marshmallow_enum.EnumField(typ, **metadata)
888+
return marshmallow.fields.Enum(typ, **metadata)
880889

881890
# Nested marshmallow dataclass
882891
# it would be just a class name instead of actual schema util the schema is not ready yet
@@ -939,7 +948,8 @@ def NewType(
939948
field: Optional[Type[marshmallow.fields.Field]] = None,
940949
**kwargs,
941950
) -> Callable[[_U], _U]:
942-
"""NewType creates simple unique types
951+
"""DEPRECATED: Use typing.Annotated instead.
952+
NewType creates simple unique types
943953
to which you can attach custom marshmallow attributes.
944954
All the keyword arguments passed to this function will be transmitted
945955
to the marshmallow field constructor.

marshmallow_dataclass/typing.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
from typing import Annotated
2+
13
import marshmallow.fields
2-
from . import NewType
34

4-
Url = NewType("Url", str, field=marshmallow.fields.Url)
5-
Email = NewType("Email", str, field=marshmallow.fields.Email)
5+
Url = Annotated[str, marshmallow.fields.Url]
6+
Email = Annotated[str, marshmallow.fields.Email]
67

78
# Aliases
89
URL = Url

setup.py

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,15 @@
88
"Operating System :: OS Independent",
99
"License :: OSI Approved :: MIT License",
1010
"Programming Language :: Python",
11-
"Programming Language :: Python :: 3.6",
12-
"Programming Language :: Python :: 3.7",
13-
"Programming Language :: Python :: 3.8",
1411
"Programming Language :: Python :: 3.9",
1512
"Programming Language :: Python :: 3.10",
1613
"Programming Language :: Python :: 3.11",
1714
"Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries",
1815
]
1916

2017
EXTRAS_REQUIRE = {
21-
"enum": [
22-
"marshmallow-enum; python_version < '3.7'",
23-
"marshmallow>=3.18.0,<4.0; python_version >= '3.7'",
24-
],
2518
"union": ["typeguard>=2.4.1,<4.0.0"],
2619
"lint": ["pre-commit~=2.17"],
27-
':python_version == "3.6"': ["dataclasses", "types-dataclasses<0.6.4"],
2820
"docs": ["sphinx"],
2921
"tests": [
3022
"pytest>=5.4",
@@ -34,8 +26,7 @@
3426
],
3527
}
3628
EXTRAS_REQUIRE["dev"] = (
37-
EXTRAS_REQUIRE["enum"]
38-
+ EXTRAS_REQUIRE["union"]
29+
EXTRAS_REQUIRE["union"]
3930
+ EXTRAS_REQUIRE["lint"]
4031
+ EXTRAS_REQUIRE["docs"]
4132
+ EXTRAS_REQUIRE["tests"]
@@ -56,14 +47,11 @@
5647
keywords=["marshmallow", "dataclass", "serialization"],
5748
classifiers=CLASSIFIERS,
5849
license="MIT",
59-
python_requires=">=3.6",
50+
python_requires=">=3.9",
6051
install_requires=[
61-
"marshmallow>=3.13.0,<4.0",
52+
"marshmallow>=3.18.0,<4.0",
6253
"typing-inspect>=0.8.0,<1.0",
63-
# Need `Literal`
64-
"typing-extensions>=3.7.2; python_version < '3.8'",
6554
# Need `dataclass_transform(field_specifiers)`
66-
# NB: typing-extensions>=4.2.0 conflicts with python 3.6
6755
"typing-extensions>=4.2.0; python_version<'3.11' and python_version>='3.7'",
6856
],
6957
extras_require=EXTRAS_REQUIRE,

tests/test_annotated.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import unittest
2+
from typing import Annotated, Optional
3+
4+
import marshmallow
5+
import marshmallow.fields
6+
7+
from marshmallow_dataclass import dataclass
8+
9+
10+
class TestAnnotatedField(unittest.TestCase):
11+
def test_annotated_field(self):
12+
@dataclass
13+
class AnnotatedValue:
14+
value: Annotated[str, marshmallow.fields.Email]
15+
default_string: Annotated[
16+
Optional[str], marshmallow.fields.String(load_default="Default String")
17+
] = None
18+
19+
schema = AnnotatedValue.Schema()
20+
21+
self.assertEqual(
22+
schema.load({"value": "test@test.com"}),
23+
AnnotatedValue(value="test@test.com", default_string="Default String"),
24+
)
25+
self.assertEqual(
26+
schema.load({"value": "test@test.com", "default_string": "override"}),
27+
AnnotatedValue(value="test@test.com", default_string="override"),
28+
)
29+
30+
with self.assertRaises(marshmallow.exceptions.ValidationError):
31+
schema.load({"value": "notavalidemail"})

tests/test_mypy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
reveal_type(user.email) # N: Revealed type is "builtins.str"
2828
2929
User(id=42, email="user@email.com") # E: Argument "id" to "User" has incompatible type "int"; expected "str" [arg-type]
30-
User(id="a"*32, email=["not", "a", "string"]) # E: Argument "email" to "User" has incompatible type "List[str]"; expected "str" [arg-type]
30+
User(id="a"*32, email=["not", "a", "string"]) # E: Argument "email" to "User" has incompatible type "list[str]"; expected "str" [arg-type]
3131
- case: marshmallow_dataclass_keyword_arguments
3232
mypy_config: |
3333
follow_imports = silent

0 commit comments

Comments
 (0)