Skip to content

Commit 658e18f

Browse files
committed
Add Annotated support and therefore set minimum python version as 3.9
1 parent d6396c1 commit 658e18f

File tree

9 files changed

+89
-58
lines changed

9 files changed

+89
-58
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:

.vscode/settings.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"python.testing.pytestArgs": [
3+
"-W",
4+
"ignore",
5+
"-vv",
6+
"tests"
7+
],
8+
}

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: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -43,40 +43,32 @@ 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 (
47-
Any,
48-
Callable,
49-
Dict,
50-
List,
51-
Mapping,
52-
NewType as typing_NewType,
5349
Optional,
50+
Sequence,
5451
Set,
5552
Tuple,
5653
Type,
5754
TypeVar,
5855
Union,
5956
cast,
57+
get_args,
58+
get_origin,
6059
get_type_hints,
6160
overload,
62-
Sequence,
63-
FrozenSet,
6461
)
6562

6663
import marshmallow
6764
import typing_inspect
6865

6966
from marshmallow_dataclass.lazy_class_attribute import lazy_class_attribute
7067

71-
7268
if sys.version_info >= (3, 11):
7369
from typing import dataclass_transform
74-
elif sys.version_info >= (3, 7):
75-
from typing_extensions import dataclass_transform
7670
else:
77-
# @dataclass_transform() only helps us with mypy>=1.1 which is only available for python>=3.7
78-
def dataclass_transform(**kwargs):
79-
return lambda cls: cls
71+
from typing_extensions import dataclass_transform
8072

8173

8274
__all__ = ["dataclass", "add_schema", "class_schema", "field_for_schema", "NewType"]
@@ -431,7 +423,9 @@ def _internal_class_schema(
431423

432424
# Update the schema members to contain marshmallow fields instead of dataclass fields
433425
type_hints = get_type_hints(
434-
clazz, localns=clazz_frame.f_locals if clazz_frame else None
426+
clazz,
427+
localns=clazz_frame.f_locals if clazz_frame else None,
428+
include_extras=True,
435429
)
436430
attributes.update(
437431
(
@@ -527,12 +521,27 @@ def _field_for_generic_type(
527521
"""
528522
If the type is a generic interface, resolve the arguments and construct the appropriate Field.
529523
"""
530-
origin = typing_inspect.get_origin(typ)
531-
arguments = typing_inspect.get_args(typ, True)
524+
origin = get_origin(typ)
525+
arguments = get_args(typ)
532526
if origin:
533527
# Override base_schema.TYPE_MAPPING to change the class used for generic types below
534528
type_mapping = base_schema.TYPE_MAPPING if base_schema else {}
535529

530+
if origin is Annotated:
531+
marshmallow_annotations = [
532+
arg
533+
for arg in arguments[1:]
534+
if (inspect.isclass(arg) and issubclass(arg, marshmallow.fields.Field))
535+
or isinstance(arg, marshmallow.fields.Field)
536+
]
537+
if marshmallow_annotations:
538+
field = marshmallow_annotations[-1]
539+
# Got a field instance, return as is. User must know what they're doing
540+
if isinstance(field, marshmallow.fields.Field):
541+
return field
542+
543+
return field(**metadata)
544+
536545
if origin in (list, List):
537546
child_type = field_for_schema(
538547
arguments[0], base_schema=base_schema, typ_frame=typ_frame
@@ -684,6 +693,7 @@ def field_for_schema(
684693
metadata.setdefault("allow_none", True)
685694
return marshmallow.fields.Raw(**metadata)
686695

696+
# i.e.: Literal['abc']
687697
if typing_inspect.is_literal_type(typ):
688698
arguments = typing_inspect.get_args(typ)
689699
return marshmallow.fields.Raw(
@@ -695,6 +705,7 @@ def field_for_schema(
695705
**metadata,
696706
)
697707

708+
# i.e.: Final[str] = 'abc'
698709
if typing_inspect.is_final_type(typ):
699710
arguments = typing_inspect.get_args(typ)
700711
if arguments:
@@ -749,13 +760,7 @@ def field_for_schema(
749760

750761
# enumerations
751762
if issubclass(typ, Enum):
752-
try:
753-
return marshmallow.fields.Enum(typ, **metadata)
754-
except AttributeError:
755-
# Remove this once support for python 3.6 is dropped.
756-
import marshmallow_enum
757-
758-
return marshmallow_enum.EnumField(typ, **metadata)
763+
return marshmallow.fields.Enum(typ, **metadata)
759764

760765
# Nested marshmallow dataclass
761766
# it would be just a class name instead of actual schema util the schema is not ready yet
@@ -818,7 +823,8 @@ def NewType(
818823
field: Optional[Type[marshmallow.fields.Field]] = None,
819824
**kwargs,
820825
) -> Callable[[_U], _U]:
821-
"""NewType creates simple unique types
826+
"""DEPRECATED: Use typing.Annotated instead.
827+
NewType creates simple unique types
822828
to which you can attach custom marshmallow attributes.
823829
All the keyword arguments passed to this function will be transmitted
824830
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(missing="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
@@ -6,7 +6,7 @@
66
follow_imports = silent
77
plugins = marshmallow_dataclass.mypy
88
show_error_codes = true
9-
python_version = 3.6
9+
python_version = 3.9
1010
env:
1111
- PYTHONPATH=.
1212
main: |

0 commit comments

Comments
 (0)