Compare commits

...

3 Commits

Author SHA1 Message Date
fe5decad4f
🔧 Show more function signature details in documentation 2023-03-13 00:10:37 +01:00
712f7fca7b
Overload dump/dumps methods to increase type safety;
issue a warning, when using the `many` parameter in `__init__`
2023-03-13 00:08:57 +01:00
84fa2d2cd9
🚧 Expand generic utility mixin to handle up to 5 type arguments 2023-03-12 18:04:28 +01:00
5 changed files with 331 additions and 32 deletions

View File

@ -30,8 +30,11 @@ plugins:
handlers: handlers:
python: python:
options: options:
line_length: 80
show_source: false show_source: false
show_root_toc_entry: false show_root_toc_entry: false
separate_signature: true
show_signature_annotations: true
import: import:
- https://marshmallow.readthedocs.io/en/stable/objects.inv - https://marshmallow.readthedocs.io/en/stable/objects.inv

View File

@ -1,10 +1,28 @@
from typing import Any, Generic, Optional, TypeVar, get_args, get_origin from typing import (
Any,
Generic,
Literal,
Optional,
TypeVar,
Union,
get_args,
get_origin,
overload,
)
_T = TypeVar("_T") _T0 = TypeVar("_T0")
_T1 = TypeVar("_T1")
_T2 = TypeVar("_T2")
_T3 = TypeVar("_T3")
_T4 = TypeVar("_T4")
class GenericInsightMixin(Generic[_T]): class GenericInsightMixin(Generic[_T0, _T1, _T2, _T3, _T4]):
_type_arg: Optional[type[_T]] = None _type_arg_0: Optional[type[_T0]] = None
_type_arg_1: Optional[type[_T1]] = None
_type_arg_2: Optional[type[_T2]] = None
_type_arg_3: Optional[type[_T3]] = None
_type_arg_4: Optional[type[_T4]] = None
@classmethod @classmethod
def __init_subclass__(cls, **kwargs: Any) -> None: def __init_subclass__(cls, **kwargs: Any) -> None:
@ -14,17 +32,70 @@ class GenericInsightMixin(Generic[_T]):
origin = get_origin(base) origin = get_origin(base)
if origin is None or not issubclass(origin, GenericInsightMixin): if origin is None or not issubclass(origin, GenericInsightMixin):
continue continue
type_arg = get_args(base)[0] type_args = get_args(base)
# Do not set the attribute for GENERIC subclasses! for idx, arg in enumerate(type_args):
if not isinstance(type_arg, TypeVar): # Do not set the attribute for generics:
cls._type_arg = type_arg if isinstance(arg, TypeVar):
return continue
# Do not set `NoneType`:
if isinstance(arg, type) and isinstance(None, arg):
continue
setattr(cls, f"_type_arg_{idx}", arg)
return
@classmethod @classmethod
def _get_type_arg(cls) -> type[_T]: @overload
def _get_type_arg(cls, idx: Literal[0]) -> type[_T0]:
...
@classmethod
@overload
def _get_type_arg(cls, idx: Literal[1]) -> type[_T1]:
...
@classmethod
@overload
def _get_type_arg(cls, idx: Literal[2]) -> type[_T2]:
...
@classmethod
@overload
def _get_type_arg(cls, idx: Literal[3]) -> type[_T3]:
...
@classmethod
@overload
def _get_type_arg(cls, idx: Literal[4]) -> type[_T4]:
...
@classmethod
def _get_type_arg(
cls,
idx: Literal[0, 1, 2, 3, 4],
) -> Union[type[_T0], type[_T1], type[_T2], type[_T3], type[_T4]]:
"""Returns the type argument of the class (if specified).""" """Returns the type argument of the class (if specified)."""
if cls._type_arg is None: if idx == 0:
type_ = cls._type_arg_0
elif idx == 1:
type_ = cls._type_arg_1
elif idx == 2: # noqa: PLR2004
type_ = cls._type_arg_2
elif idx == 3: # noqa: PLR2004
type_ = cls._type_arg_3
elif idx == 4: # noqa: PLR2004
type_ = cls._type_arg_4
else:
raise ValueError("Only 5 type parameters available")
if type_ is None:
raise AttributeError( raise AttributeError(
f"{cls.__name__} is generic; type argument unspecified" f"{cls.__name__} is generic; type argument {idx} unspecified"
) )
return cls._type_arg return type_
class GenericInsightMixin1(GenericInsightMixin[_T0, None, None, None, None]):
pass
class GenericInsightMixin2(GenericInsightMixin[_T0, _T1, None, None, None]):
pass

View File

@ -7,16 +7,17 @@ documentation of [`marshmallow.Schema`][marshmallow.Schema].
from collections.abc import Iterable, Mapping, Sequence from collections.abc import Iterable, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Literal, Optional, TypeVar, Union, overload from typing import TYPE_CHECKING, Any, Literal, Optional, TypeVar, Union, overload
from warnings import warn
from marshmallow import Schema from marshmallow import Schema
from ._util import GenericInsightMixin from ._util import GenericInsightMixin1
from .decorators import post_load from .decorators import post_load
Model = TypeVar("Model") Model = TypeVar("Model")
class GenericSchema(GenericInsightMixin[Model], Schema): class GenericSchema(GenericInsightMixin1[Model], Schema):
""" """
Generic schema parameterized by a **`Model`** class. Generic schema parameterized by a **`Model`** class.
@ -43,6 +44,79 @@ class GenericSchema(GenericInsightMixin[Model], Schema):
``` ```
""" """
def __init__(
self,
*,
only: Union[Sequence[str], set[str], None] = None,
exclude: Union[Sequence[str], set[str]] = (),
context: Union[dict[str, Any], None] = None,
load_only: Union[Sequence[str], set[str]] = (),
dump_only: Union[Sequence[str], set[str]] = (),
partial: Union[bool, Sequence[str], set[str]] = False,
unknown: Optional[str] = None,
many: Optional[bool] = None,
) -> None:
"""
Emits a warning, if the `many` argument is not `None`.
Otherwise the same as in [`marshmallow.Schema`][marshmallow.Schema].
Args:
only:
Whitelist of the declared fields to select when instantiating
the Schema. If `None`, all fields are used. Nested fields can
be represented with dot delimiters.
exclude:
Blacklist of the declared fields to exclude when instantiating
the Schema. If a field appears in both `only` and `exclude`,
it is not used. Nested fields can be represented with dot
delimiters.
context:
Optional context passed to [`Method`]
[marshmallow.fields.Method] and [`Function`]
[marshmallow.fields.Function] fields.
load_only:
Fields to skip during serialization (write-only fields)
dump_only:
Fields to skip during deserialization (read-only fields)
partial:
Whether to ignore missing fields and not require any fields
declared. Propagates down to [`Nested`]
[marshmallow.fields.Nested] fields as well. If its value is an
iterable, only missing fields listed in that iterable will be
ignored. Use dot delimiters to specify nested fields.
unknown:
Whether to exclude, include, or raise an error for unknown
fields in the data. Use `EXCLUDE`, `INCLUDE` or `RAISE`.
many:
!!! warning
Specifying this option schema-wide undermines the type
safety that this class aims to provide and passing any
value other than `None` will trigger a warning. Use the
method-specific `many` parameter, when calling
[`dump`][marshmallow_generic.GenericSchema.dump]/
[`dumps`][marshmallow_generic.GenericSchema.dumps] or
[`load`][marshmallow_generic.GenericSchema.load]/
[`loads`][marshmallow_generic.GenericSchema.loads] instead.
"""
if many is not None:
warn(
"Setting `many` schema-wide breaks type safety. Use the the "
"`many` parameter of specific methods (like `load`) instead."
)
else:
many = bool(many)
super().__init__(
only=only,
exclude=exclude,
many=many,
context=context,
load_only=load_only,
dump_only=dump_only,
partial=partial,
unknown=unknown,
)
@post_load @post_load
def instantiate(self, data: dict[str, Any], **_kwargs: Any) -> Model: def instantiate(self, data: dict[str, Any], **_kwargs: Any) -> Model:
""" """
@ -52,10 +126,9 @@ class GenericSchema(GenericInsightMixin[Model], Schema):
[marshmallow_generic.decorators.post_load] hook for the schema. [marshmallow_generic.decorators.post_load] hook for the schema.
!!! warning !!! warning
You should probably **not** use this method directly; You should probably not use this method directly. No parsing,
no parsing, transformation or validation of any kind is done transformation or validation of any kind is done in this method.
in this method. The `data` passed to the **`Model`** constructor The `data` is passed to the **`Model`** constructor "as is".
"as is".
Args: Args:
data: data:
@ -65,10 +138,89 @@ class GenericSchema(GenericInsightMixin[Model], Schema):
Returns: Returns:
Instance of the schema's **`Model`** initialized with `**data` Instance of the schema's **`Model`** initialized with `**data`
""" """
return self._get_type_arg()(**data) return self._get_type_arg(0)(**data)
if TYPE_CHECKING: if TYPE_CHECKING:
@overload # type: ignore[override]
def dump(
self,
obj: Iterable[Model],
*,
many: Literal[True],
) -> list[dict[str, Any]]:
...
@overload
def dump(
self,
obj: Model,
*,
many: Optional[Literal[False]] = None,
) -> dict[str, Any]:
...
def dump(
self,
obj: Union[Model, Iterable[Model]],
*,
many: Optional[bool] = None,
) -> Union[dict[str, Any], list[dict[str, Any]]]:
"""
Serializes **`Model`** objects to native Python data types.
Same as [`marshmallow.Schema.dump`]
[marshmallow.schema.Schema.dump] at runtime.
Annotations ensure that type checkers will infer the return type
correctly based on the `many` argument, and also enforce the `obj`
argument to be an a `list` of **`Model`** instances, if `many` is
set to `True` or a single instance of it, if `many` is `False`
(or omitted).
Args:
obj:
The object or iterable of objects to serialize
many:
Whether to serialize `obj` as a collection. If `None`, the
value for `self.many` is used.
Returns:
(dict[str, Any]): if `many` is set to `False`
(list[dict[str, Any]]): if `many` is set to `True`
"""
...
@overload # type: ignore[override]
def dumps(
self,
obj: Iterable[Model],
*args: Any,
many: Literal[True],
**kwargs: Any,
) -> str:
...
@overload
def dumps(
self,
obj: Model,
*args: Any,
many: Optional[Literal[False]] = None,
**kwargs: Any,
) -> str:
...
def dumps(
self,
obj: Union[Model, Iterable[Model]],
*args: Any,
many: Optional[bool] = None,
**kwargs: Any,
) -> str:
"""Same as [`dump`][marshmallow_generic.GenericSchema.dump], but returns a JSON-encoded string."""
...
@overload # type: ignore[override] @overload # type: ignore[override]
def load( def load(
self, self,

View File

@ -12,7 +12,11 @@ class GenericInsightMixinTestCase(TestCase):
mock_super.return_value = MagicMock(__init_subclass__=mock_super_meth) mock_super.return_value = MagicMock(__init_subclass__=mock_super_meth)
# Should be `None` by default: # Should be `None` by default:
self.assertIsNone(_util.GenericInsightMixin._type_arg) # type: ignore[misc] self.assertIsNone(_util.GenericInsightMixin._type_arg_0) # type: ignore[misc]
self.assertIsNone(_util.GenericInsightMixin._type_arg_1) # type: ignore[misc]
self.assertIsNone(_util.GenericInsightMixin._type_arg_2) # type: ignore[misc]
self.assertIsNone(_util.GenericInsightMixin._type_arg_3) # type: ignore[misc]
self.assertIsNone(_util.GenericInsightMixin._type_arg_4) # type: ignore[misc]
# If the mixin type argument was not specified (still generic), # If the mixin type argument was not specified (still generic),
# ensure that the attribute remains `None` on the subclass: # ensure that the attribute remains `None` on the subclass:
@ -24,30 +28,55 @@ class GenericInsightMixinTestCase(TestCase):
class Bar(Generic[t]): class Bar(Generic[t]):
pass pass
class TestSchema1(Bar[str], _util.GenericInsightMixin[t]): class TestCls(Bar[str], _util.GenericInsightMixin[t, None, int, str, bool]):
pass pass
self.assertIsNone(TestSchema1._type_arg) # type: ignore[misc] self.assertIsNone(TestCls._type_arg_0) # type: ignore[misc]
self.assertIsNone(TestCls._type_arg_1) # type: ignore[misc]
self.assertIs(int, TestCls._type_arg_2) # type: ignore[misc]
self.assertIs(str, TestCls._type_arg_3) # type: ignore[misc]
self.assertIs(bool, TestCls._type_arg_4) # type: ignore[misc]
mock_super.assert_called_once() mock_super.assert_called_once()
mock_super_meth.assert_called_once_with() mock_super_meth.assert_called_once_with()
mock_super.reset_mock() mock_super.reset_mock()
mock_super_meth.reset_mock() mock_super_meth.reset_mock()
# If the mixin type argument was specified, # If the mixin type arguments were omitted,
# ensure it was assigned to the attribute on the child class: # ensure the attributes remained `None`:
class TestSchema2(Bar[str], _util.GenericInsightMixin[Foo]): class UnspecifiedCls(_util.GenericInsightMixin): # type: ignore[type-arg]
pass pass
self.assertIs(Foo, TestSchema2._type_arg) # type: ignore[misc] self.assertIsNone(UnspecifiedCls._type_arg_0) # type: ignore[misc]
self.assertIsNone(UnspecifiedCls._type_arg_1) # type: ignore[misc]
self.assertIsNone(UnspecifiedCls._type_arg_2) # type: ignore[misc]
self.assertIsNone(UnspecifiedCls._type_arg_3) # type: ignore[misc]
self.assertIsNone(UnspecifiedCls._type_arg_4) # type: ignore[misc]
mock_super.assert_called_once() mock_super.assert_called_once()
mock_super_meth.assert_called_once_with() mock_super_meth.assert_called_once_with()
def test__get_type_arg(self) -> None: def test__get_type_arg(self) -> None:
with self.assertRaises(AttributeError): with self.assertRaises(AttributeError):
_util.GenericInsightMixin._get_type_arg() _util.GenericInsightMixin._get_type_arg(0)
_type = object() _type_0 = object()
with patch.object(_util.GenericInsightMixin, "_type_arg", new=_type): _type_1 = object()
self.assertIs(_type, _util.GenericInsightMixin._get_type_arg()) _type_2 = object()
_type_3 = object()
_type_4 = object()
with patch.multiple(
_util.GenericInsightMixin,
_type_arg_0=_type_0,
_type_arg_1=_type_1,
_type_arg_2=_type_2,
_type_arg_3=_type_3,
_type_arg_4=_type_4,
):
self.assertIs(_type_0, _util.GenericInsightMixin._get_type_arg(0))
self.assertIs(_type_1, _util.GenericInsightMixin._get_type_arg(1))
self.assertIs(_type_2, _util.GenericInsightMixin._get_type_arg(2))
self.assertIs(_type_3, _util.GenericInsightMixin._get_type_arg(3))
self.assertIs(_type_4, _util.GenericInsightMixin._get_type_arg(4))
with self.assertRaises(ValueError):
_util.GenericInsightMixin._get_type_arg(5) # type: ignore[call-overload]

View File

@ -1,3 +1,4 @@
from typing import Any
from unittest import TestCase from unittest import TestCase
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@ -5,6 +6,29 @@ from marshmallow_generic import _util, schema
class GenericSchemaTestCase(TestCase): class GenericSchemaTestCase(TestCase):
@patch("marshmallow.schema.Schema.__init__")
def test___init__(self, mock_super_init: MagicMock) -> None:
class Foo:
pass
kwargs: dict[str, Any] = {
"only": object(),
"exclude": object(),
"context": object(),
"load_only": object(),
"dump_only": object(),
"partial": object(),
"unknown": object(),
"many": None,
}
schema.GenericSchema[Foo](**kwargs)
mock_super_init.assert_called_once_with(**kwargs | {"many": False})
mock_super_init.reset_mock()
kwargs["many"] = True
with self.assertWarns(UserWarning):
schema.GenericSchema[Foo](**kwargs)
mock_super_init.assert_called_once_with(**kwargs)
@patch.object(_util.GenericInsightMixin, "_get_type_arg") @patch.object(_util.GenericInsightMixin, "_get_type_arg")
def test_instantiate(self, mock__get_type_arg: MagicMock) -> None: def test_instantiate(self, mock__get_type_arg: MagicMock) -> None:
mock__get_type_arg.return_value = mock_cls = MagicMock() mock__get_type_arg.return_value = mock_cls = MagicMock()
@ -17,9 +41,29 @@ class GenericSchemaTestCase(TestCase):
# Explicit annotation to possibly catch mypy errors: # Explicit annotation to possibly catch mypy errors:
output: Foo = schema_obj.instantiate(mock_data) output: Foo = schema_obj.instantiate(mock_data)
self.assertIs(mock_cls.return_value, output) self.assertIs(mock_cls.return_value, output)
mock__get_type_arg.assert_called_once_with() mock__get_type_arg.assert_called_once_with(0)
mock_cls.assert_called_once_with(**mock_data) mock_cls.assert_called_once_with(**mock_data)
def test_dump_and_dumps(self) -> None:
"""Mainly for static type checking purposes."""
class Foo:
pass
class TestSchema(schema.GenericSchema[Foo]):
pass
foo = Foo()
single: dict[str, Any] = TestSchema().dump(foo)
self.assertDictEqual({}, single)
json_string: str = TestSchema().dumps(foo)
self.assertEqual("{}", json_string)
multiple: list[dict[str, Any]] = TestSchema().dump([foo], many=True)
self.assertListEqual([{}], multiple)
json_string = TestSchema().dumps([foo], many=True)
self.assertEqual("[{}]", json_string)
def test_load_and_loads(self) -> None: def test_load_and_loads(self) -> None:
"""Mainly for static type checking purposes.""" """Mainly for static type checking purposes."""