Skip to content

Commit

Permalink
Add Annotated support and therefore set minimum python version as 3.9
Browse files Browse the repository at this point in the history
  • Loading branch information
mvanderlee committed Mar 9, 2024
1 parent d6396c1 commit 658e18f
Show file tree
Hide file tree
Showing 9 changed files with 89 additions and 58 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"python.testing.pytestArgs": [
"-W",
"ignore",
"-vv",
"tests"
],
}
19 changes: 8 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
56 changes: 31 additions & 25 deletions marshmallow_dataclass/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,40 +43,32 @@ 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
import typing_inspect

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"]
Expand Down Expand Up @@ -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(
(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions marshmallow_dataclass/typing.py
Original file line number Diff line number Diff line change
@@ -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
18 changes: 3 additions & 15 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,15 @@
"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",
"Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries",
]

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",
Expand All @@ -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"]
Expand All @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions tests/test_annotated.py
Original file line number Diff line number Diff line change
@@ -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"})
2 changes: 1 addition & 1 deletion tests/test_mypy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down

0 comments on commit 658e18f

Please sign in to comment.