From 658e18fd2e17e187e0f016f43dc3352ef5d8a5c1 Mon Sep 17 00:00:00 2001 From: Michiel <918128+mvanderlee@users.noreply.github.com> Date: Sat, 9 Mar 2024 22:54:33 +0100 Subject: [PATCH] Add Annotated support and therefore set minimum python version as 3.9 --- .github/workflows/python-package.yml | 4 +- .pre-commit-config.yaml | 2 +- .vscode/settings.json | 8 ++++ README.md | 19 ++++------ marshmallow_dataclass/__init__.py | 56 +++++++++++++++------------- marshmallow_dataclass/typing.py | 7 ++-- setup.py | 18 ++------- tests/test_annotated.py | 31 +++++++++++++++ tests/test_mypy.yml | 2 +- 9 files changed, 89 insertions(+), 58 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 tests/test_annotated.py diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 90b6f7e1..c5fd3c2d 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -12,10 +12,10 @@ jobs: fail-fast: false matrix: os: ["ubuntu-latest"] - python_version: ["3.7", "3.8", "3.9", "3.10", "3.11", "pypy3.10"] + python_version: ["3.9", "3.10", "3.11", "3.12", "pypy3.10"] include: - os: "ubuntu-20.04" - python_version: "3.6" + python_version: "3.9" runs-on: ${{ matrix.os }} steps: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0aa3b11a..d465df2a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ repos: rev: v3.3.1 hooks: - id: pyupgrade - args: ["--py36-plus"] + args: ["--py37-plus"] - repo: https://github.com/python/black rev: 23.1.0 hooks: diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..8c1061d9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "python.testing.pytestArgs": [ + "-W", + "ignore", + "-vv", + "tests" +], +} \ No newline at end of file diff --git a/README.md b/README.md index cbc7bea4..fc8f9728 100644 --- a/README.md +++ b/README.md @@ -247,28 +247,25 @@ class Sample: See [marshmallow's documentation about extending `Schema`](https://marshmallow.readthedocs.io/en/stable/extending.html). -### Custom NewType declarations +### Custom type declarations -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). - -Keyword arguments to `NewType` are passed to the marshmallow field constructor. +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/). ```python -import marshmallow.validate -from marshmallow_dataclass import NewType +from typing import Annotated +import marshmallow.fields as mf +import marshmallow.validate as mv -IPv4 = NewType( - "IPv4", str, validate=marshmallow.validate.Regexp(r"^([0-9]{1,3}\\.){3}[0-9]{1,3}$") -) +IPv4 = Annotated[str, mf.String(validate=mv.Regexp(r"^([0-9]{1,3}\\.){3}[0-9]{1,3}$"))] ``` -You can also pass a marshmallow field to `NewType`. +You can also pass a marshmallow field class. ```python import marshmallow from marshmallow_dataclass import NewType -Email = NewType("Email", str, field=marshmallow.fields.Email) +Email = Annotated[str, marshmallow.fields.Email] ``` For convenience, some custom types are provided: diff --git a/marshmallow_dataclass/__init__.py b/marshmallow_dataclass/__init__.py index 7312374d..61c4e085 100644 --- a/marshmallow_dataclass/__init__.py +++ b/marshmallow_dataclass/__init__.py @@ -43,24 +43,21 @@ class User: import warnings from enum import Enum from functools import lru_cache, partial +from typing import Annotated, Any, Callable, Dict, FrozenSet, List, Mapping +from typing import NewType as typing_NewType from typing import ( - Any, - Callable, - Dict, - List, - Mapping, - NewType as typing_NewType, Optional, + Sequence, Set, Tuple, Type, TypeVar, Union, cast, + get_args, + get_origin, get_type_hints, overload, - Sequence, - FrozenSet, ) import marshmallow @@ -68,15 +65,10 @@ class User: from marshmallow_dataclass.lazy_class_attribute import lazy_class_attribute - if sys.version_info >= (3, 11): from typing import dataclass_transform -elif sys.version_info >= (3, 7): - from typing_extensions import dataclass_transform else: - # @dataclass_transform() only helps us with mypy>=1.1 which is only available for python>=3.7 - def dataclass_transform(**kwargs): - return lambda cls: cls + from typing_extensions import dataclass_transform __all__ = ["dataclass", "add_schema", "class_schema", "field_for_schema", "NewType"] @@ -431,7 +423,9 @@ def _internal_class_schema( # Update the schema members to contain marshmallow fields instead of dataclass fields type_hints = get_type_hints( - clazz, localns=clazz_frame.f_locals if clazz_frame else None + clazz, + localns=clazz_frame.f_locals if clazz_frame else None, + include_extras=True, ) attributes.update( ( @@ -527,12 +521,27 @@ def _field_for_generic_type( """ If the type is a generic interface, resolve the arguments and construct the appropriate Field. """ - origin = typing_inspect.get_origin(typ) - arguments = typing_inspect.get_args(typ, True) + origin = get_origin(typ) + arguments = get_args(typ) if origin: # Override base_schema.TYPE_MAPPING to change the class used for generic types below type_mapping = base_schema.TYPE_MAPPING if base_schema else {} + if origin is Annotated: + marshmallow_annotations = [ + arg + for arg in arguments[1:] + if (inspect.isclass(arg) and issubclass(arg, marshmallow.fields.Field)) + or isinstance(arg, marshmallow.fields.Field) + ] + if marshmallow_annotations: + field = marshmallow_annotations[-1] + # Got a field instance, return as is. User must know what they're doing + if isinstance(field, marshmallow.fields.Field): + return field + + return field(**metadata) + if origin in (list, List): child_type = field_for_schema( arguments[0], base_schema=base_schema, typ_frame=typ_frame @@ -684,6 +693,7 @@ def field_for_schema( metadata.setdefault("allow_none", True) return marshmallow.fields.Raw(**metadata) + # i.e.: Literal['abc'] if typing_inspect.is_literal_type(typ): arguments = typing_inspect.get_args(typ) return marshmallow.fields.Raw( @@ -695,6 +705,7 @@ def field_for_schema( **metadata, ) + # i.e.: Final[str] = 'abc' if typing_inspect.is_final_type(typ): arguments = typing_inspect.get_args(typ) if arguments: @@ -749,13 +760,7 @@ def field_for_schema( # enumerations if issubclass(typ, Enum): - try: - return marshmallow.fields.Enum(typ, **metadata) - except AttributeError: - # Remove this once support for python 3.6 is dropped. - import marshmallow_enum - - return marshmallow_enum.EnumField(typ, **metadata) + return marshmallow.fields.Enum(typ, **metadata) # Nested marshmallow dataclass # it would be just a class name instead of actual schema util the schema is not ready yet @@ -818,7 +823,8 @@ def NewType( field: Optional[Type[marshmallow.fields.Field]] = None, **kwargs, ) -> Callable[[_U], _U]: - """NewType creates simple unique types + """DEPRECATED: Use typing.Annotated instead. + NewType creates simple unique types to which you can attach custom marshmallow attributes. All the keyword arguments passed to this function will be transmitted to the marshmallow field constructor. diff --git a/marshmallow_dataclass/typing.py b/marshmallow_dataclass/typing.py index 01291eb8..35bdee23 100644 --- a/marshmallow_dataclass/typing.py +++ b/marshmallow_dataclass/typing.py @@ -1,8 +1,9 @@ +from typing import Annotated + import marshmallow.fields -from . import NewType -Url = NewType("Url", str, field=marshmallow.fields.Url) -Email = NewType("Email", str, field=marshmallow.fields.Email) +Url = Annotated[str, marshmallow.fields.Url] +Email = Annotated[str, marshmallow.fields.Email] # Aliases URL = Url diff --git a/setup.py b/setup.py index 6c4aa144..003fe1f7 100644 --- a/setup.py +++ b/setup.py @@ -8,9 +8,6 @@ "Operating System :: OS Independent", "License :: OSI Approved :: MIT License", "Programming Language :: Python", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -18,13 +15,8 @@ ] EXTRAS_REQUIRE = { - "enum": [ - "marshmallow-enum; python_version < '3.7'", - "marshmallow>=3.18.0,<4.0; python_version >= '3.7'", - ], "union": ["typeguard>=2.4.1,<4.0.0"], "lint": ["pre-commit~=2.17"], - ':python_version == "3.6"': ["dataclasses", "types-dataclasses<0.6.4"], "docs": ["sphinx"], "tests": [ "pytest>=5.4", @@ -34,8 +26,7 @@ ], } EXTRAS_REQUIRE["dev"] = ( - EXTRAS_REQUIRE["enum"] - + EXTRAS_REQUIRE["union"] + EXTRAS_REQUIRE["union"] + EXTRAS_REQUIRE["lint"] + EXTRAS_REQUIRE["docs"] + EXTRAS_REQUIRE["tests"] @@ -56,14 +47,11 @@ keywords=["marshmallow", "dataclass", "serialization"], classifiers=CLASSIFIERS, license="MIT", - python_requires=">=3.6", + python_requires=">=3.9", install_requires=[ - "marshmallow>=3.13.0,<4.0", + "marshmallow>=3.18.0,<4.0", "typing-inspect>=0.8.0,<1.0", - # Need `Literal` - "typing-extensions>=3.7.2; python_version < '3.8'", # Need `dataclass_transform(field_specifiers)` - # NB: typing-extensions>=4.2.0 conflicts with python 3.6 "typing-extensions>=4.2.0; python_version<'3.11' and python_version>='3.7'", ], extras_require=EXTRAS_REQUIRE, diff --git a/tests/test_annotated.py b/tests/test_annotated.py new file mode 100644 index 00000000..25516f29 --- /dev/null +++ b/tests/test_annotated.py @@ -0,0 +1,31 @@ +import unittest +from typing import Annotated, Optional + +import marshmallow +import marshmallow.fields + +from marshmallow_dataclass import dataclass + + +class TestAnnotatedField(unittest.TestCase): + def test_annotated_field(self): + @dataclass + class AnnotatedValue: + value: Annotated[str, marshmallow.fields.Email] + default_string: Annotated[ + Optional[str], marshmallow.fields.String(missing="Default String") + ] = None + + schema = AnnotatedValue.Schema() + + self.assertEqual( + schema.load({"value": "test@test.com"}), + AnnotatedValue(value="test@test.com", default_string="Default String"), + ) + self.assertEqual( + schema.load({"value": "test@test.com", "default_string": "override"}), + AnnotatedValue(value="test@test.com", default_string="override"), + ) + + with self.assertRaises(marshmallow.exceptions.ValidationError): + schema.load({"value": "notavalidemail"}) diff --git a/tests/test_mypy.yml b/tests/test_mypy.yml index d4f9c863..535de5d4 100644 --- a/tests/test_mypy.yml +++ b/tests/test_mypy.yml @@ -6,7 +6,7 @@ follow_imports = silent plugins = marshmallow_dataclass.mypy show_error_codes = true - python_version = 3.6 + python_version = 3.9 env: - PYTHONPATH=. main: |