Compare commits

...

4 Commits

6 changed files with 109 additions and 57 deletions

4
.gitignore vendored
View File

@ -21,5 +21,5 @@ __pycache__/
# Testing: # Testing:
/.coverage /.coverage
# mypy: # Miscellaneous cache:
.mypy_cache/ .cache/

View File

@ -10,19 +10,18 @@
--- ---
Extension for <a href="https://github.com/marshmallow-code/marshmallow" target="_blank">`marshmallow`</a> to make <a href="https://marshmallow.readthedocs.io/en/stable/quickstart.html#deserializing-to-objects" target="_blank">deserialization to objects</a> easier and improve type safety. Extension for <a href="https://github.com/marshmallow-code/marshmallow" target="_blank">**`marshmallow`**</a> to make <a href="https://marshmallow.readthedocs.io/en/stable/quickstart.html#deserializing-to-objects" target="_blank">deserialization to objects</a> easier and improve type safety.
The main `GenericSchema` class extends <a href="https://marshmallow.readthedocs.io/en/stable/marshmallow.schema.html#marshmallow.schema.Schema" target="_blank">`marshmallow.Schema`</a> making it **generic** in terms of the class that data should be deserialized to, when calling <a href="https://marshmallow.readthedocs.io/en/stable/marshmallow.schema.html#marshmallow.schema.Schema.load" target="_blank">`load`/`loads`</a>. The main `GenericSchema` class extends <a href="https://marshmallow.readthedocs.io/en/stable/marshmallow.schema.html#marshmallow.schema.Schema" target="_blank">`marshmallow.Schema`</a> making it **generic** in terms of the class that data should be deserialized to, when calling <a href="https://marshmallow.readthedocs.io/en/stable/marshmallow.schema.html#marshmallow.schema.Schema.load" target="_blank">`load`/`loads`</a>.
With `GenericSchema` there is no need to explicitly write `post_load` hooks to initialize the object anymore. 🎉 With `GenericSchema` there is no need to explicitly write `post_load` hooks to initialize the object anymore. 🎉
If the "model" class is (for example) `User`, it just needs to be passed as the type argument, when subclassing `GenericSchema`. Depending on whether `many` is `True` or not, the output of the `load`/`loads` method will then be automatically inferred as either `User` or `list[User]` by any competent type checker. ✨ If the "model" class is (for example) `User`, it just needs to be passed as the type argument, when subclassing `GenericSchema`. The output of the `load`/`loads` method will then be automatically inferred as either `User` or `list[User]` (depending on whether `many` is `True` or not) by any competent type checker. ✨
## Usage Example ## Usage Example
```python ```python
from marshmallow import fields from marshmallow_generic import GenericSchema, fields
from marshmallow_generic import GenericSchema
class User: class User:

View File

@ -65,6 +65,7 @@ version = {attr = "marshmallow_generic.__version__"}
# Static type checking: # # Static type checking: #
[tool.mypy] [tool.mypy]
cache_dir = ".cache/mypy"
files = [ files = [
"src/", "src/",
"tests/", "tests/",
@ -80,6 +81,7 @@ plugins = [
# Unit test coverage: # # Unit test coverage: #
[tool.coverage.run] [tool.coverage.run]
data_file = ".cache/coverage"
source = [ source = [
"src/", "src/",
] ]
@ -105,6 +107,7 @@ omit = [
# Linting and style checking: # # Linting and style checking: #
[tool.ruff] [tool.ruff]
cache-dir = ".cache/ruff"
select = [ select = [
"E", # pycodestyle errors "E", # pycodestyle errors
"W", # pycodestyle warnings "W", # pycodestyle warnings

View File

@ -19,5 +19,41 @@ __doc__ = """
Generic schema with full typing support and minimal boilerplate. Generic schema with full typing support and minimal boilerplate.
""" """
from .decorators import post_load __all__ = [
from .schema import GenericSchema # Custom:
"GenericSchema",
"post_load",
# Re-exports from marshmallow:
"EXCLUDE",
"INCLUDE",
"RAISE",
"Schema",
"SchemaOpts",
"fields",
"validates",
"validates_schema",
"pre_dump",
"post_dump",
"pre_load",
# "post_load",
"pprint",
"ValidationError",
"missing",
]
from marshmallow import fields
from marshmallow.decorators import (
post_dump,
# post_load, # overloaded
pre_dump,
pre_load,
validates,
validates_schema,
)
from marshmallow.exceptions import ValidationError
from marshmallow.schema import Schema, SchemaOpts
from marshmallow.utils import EXCLUDE, INCLUDE, RAISE, missing, pprint
from marshmallow_generic.decorators import post_load
from marshmallow_generic.schema import GenericSchema

View File

@ -54,10 +54,10 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
dump_only: Union[Sequence[str], set[str]] = (), dump_only: Union[Sequence[str], set[str]] = (),
partial: Union[bool, Sequence[str], set[str]] = False, partial: Union[bool, Sequence[str], set[str]] = False,
unknown: Optional[str] = None, unknown: Optional[str] = None,
many: Optional[bool] = None, many: bool = False, # usage discouraged
) -> None: ) -> None:
""" """
Emits a warning, if the `many` argument is not `None`. Emits a warning, if the `many` argument is not `False`.
Otherwise the same as in [`marshmallow.Schema`][marshmallow.Schema]. Otherwise the same as in [`marshmallow.Schema`][marshmallow.Schema].
@ -72,40 +72,34 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
it is not used. Nested fields can be represented with dot it is not used. Nested fields can be represented with dot
delimiters. delimiters.
context: context:
Optional context passed to [`Method`] Optional context passed to
[marshmallow.fields.Method] and [`Function`] [`Method`][marshmallow.fields.Method] and
[marshmallow.fields.Function] fields. [`Function`][marshmallow.fields.Function] fields.
load_only: load_only:
Fields to skip during serialization (write-only fields) Fields to skip during serialization (write-only fields)
dump_only: dump_only:
Fields to skip during deserialization (read-only fields) Fields to skip during deserialization (read-only fields)
partial: partial:
Whether to ignore missing fields and not require any fields Whether to ignore missing fields and not require any fields
declared. Propagates down to [`Nested`] declared. Propagates down to
[marshmallow.fields.Nested] fields as well. If its value is an [`Nested`][marshmallow.fields.Nested] fields as well. If its
iterable, only missing fields listed in that iterable will be value is an iterable, only missing fields listed in that
ignored. Use dot delimiters to specify nested fields. iterable will be ignored. Use dot delimiters to specify nested
fields.
unknown: unknown:
Whether to exclude, include, or raise an error for unknown Whether to exclude, include, or raise an error for unknown
fields in the data. Use `EXCLUDE`, `INCLUDE` or `RAISE`. fields in the data. Use `EXCLUDE`, `INCLUDE` or `RAISE`.
many: many:
!!! warning !!! warning
Specifying this option schema-wide undermines the type Changing this option schema-wide undermines the type
safety that this class aims to provide and passing any safety that this class aims to provide. Passing `True`
value other than `None` will trigger a warning. Use the will therefore trigger a warning. You should instead use
method-specific `many` parameter, when calling the method-specific `many` parameter, when calling
[`dump`][marshmallow_generic.GenericSchema.dump]/ [`dump`][marshmallow_generic.GenericSchema.dump]/
[`dumps`][marshmallow_generic.GenericSchema.dumps] or [`dumps`][marshmallow_generic.GenericSchema.dumps] or
[`load`][marshmallow_generic.GenericSchema.load]/ [`load`][marshmallow_generic.GenericSchema.load]/
[`loads`][marshmallow_generic.GenericSchema.loads] instead. [`loads`][marshmallow_generic.GenericSchema.loads].
""" """
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__( super().__init__(
only=only, only=only,
exclude=exclude, exclude=exclude,
@ -117,13 +111,28 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
unknown=unknown, unknown=unknown,
) )
def __setattr__(self, name: str, value: Any) -> None:
"""
Warns, when trying to set `many` to anything other than `False`.
Otherwise the same the normal
[`object.__setattr__`](https://docs.python.org/3/reference/datamodel.html#object.__setattr__).
"""
if name == "many" and value is not False:
warn(
"Changing `many` schema-wide breaks type safety. Use the the "
"`many` parameter of specific methods (like `load`) instead."
)
super().__setattr__(name, value)
@post_load @post_load
def instantiate(self, data: dict[str, Any], **_kwargs: Any) -> Model: def instantiate(self, data: dict[str, Any], **_kwargs: Any) -> Model:
""" """
Unpacks `data` into the constructor of the specified **`Model`**. Unpacks `data` into the constructor of the specified **`Model`**.
Registered as a [`@post_load`] Registered as a
[marshmallow_generic.decorators.post_load] hook for the schema. [`@post_load`][marshmallow_generic.decorators.post_load]
hook for the schema.
!!! warning !!! warning
You should probably not use this method directly. No parsing, You should probably not use this method directly. No parsing,
@ -169,8 +178,9 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
""" """
Serializes **`Model`** objects to native Python data types. Serializes **`Model`** objects to native Python data types.
Same as [`marshmallow.Schema.dump`] Same as
[marshmallow.schema.Schema.dump] at runtime. [`marshmallow.Schema.dump`][marshmallow.schema.Schema.dump]
at runtime.
Annotations ensure that type checkers will infer the return type Annotations ensure that type checkers will infer the return type
correctly based on the `many` argument, and also enforce the `obj` correctly based on the `many` argument, and also enforce the `obj`
@ -254,10 +264,10 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
""" """
Deserializes data to objects of the specified **`Model`** class. Deserializes data to objects of the specified **`Model`** class.
Same as [`marshmallow.Schema.load`] Same as
[marshmallow.schema.Schema.load] at runtime, but data will always [`marshmallow.Schema.load`][marshmallow.schema.Schema.load] at
pass through the [`instantiate`] runtime, but data will always pass through the
[marshmallow_generic.schema.GenericSchema.instantiate] [`instantiate`][marshmallow_generic.schema.GenericSchema.instantiate]
hook after deserialization. hook after deserialization.
Annotations ensure that type checkers will infer the return type Annotations ensure that type checkers will infer the return type
@ -271,11 +281,11 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
the value for `self.many` is used. the value for `self.many` is used.
partial: partial:
Whether to ignore missing fields and not require any Whether to ignore missing fields and not require any
fields declared. Propagates down to [`Nested`] fields declared. Propagates down to
[marshmallow.fields.Nested] fields as well. If its value [`Nested`][marshmallow.fields.Nested] fields as well. If
is an iterable, only missing fields listed in that its value is an iterable, only missing fields listed in
iterable will be ignored. Use dot delimiters to specify that iterable will be ignored. Use dot delimiters to
nested fields. specify nested fields.
unknown: unknown:
Whether to exclude, include, or raise an error for unknown Whether to exclude, include, or raise an error for unknown
fields in the data. Use `EXCLUDE`, `INCLUDE` or `RAISE`. fields in the data. Use `EXCLUDE`, `INCLUDE` or `RAISE`.
@ -323,10 +333,10 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
""" """
Deserializes data to objects of the specified **`Model`** class. Deserializes data to objects of the specified **`Model`** class.
Same as [`marshmallow.Schema.loads`] Same as
[marshmallow.schema.Schema.loads] at runtime, but data will always [`marshmallow.Schema.loads`][marshmallow.schema.Schema.loads] at
pass through the [`instantiate`] runtime, but data will always pass through the
[marshmallow_generic.schema.GenericSchema.instantiate] [`instantiate`][marshmallow_generic.schema.GenericSchema.instantiate]
hook after deserialization. hook after deserialization.
Annotations ensure that type checkers will infer the return type Annotations ensure that type checkers will infer the return type
@ -340,11 +350,11 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
the value for `self.many` is used. the value for `self.many` is used.
partial: partial:
Whether to ignore missing fields and not require any Whether to ignore missing fields and not require any
fields declared. Propagates down to [`Nested`] fields declared. Propagates down to
[marshmallow.fields.Nested] fields as well. If its value [`Nested`][marshmallow.fields.Nested] fields as well. If
is an iterable, only missing fields listed in that its value is an iterable, only missing fields listed in
iterable will be ignored. Use dot delimiters to specify that iterable will be ignored. Use dot delimiters to
nested fields. specify nested fields.
unknown: unknown:
Whether to exclude, include, or raise an error for unknown Whether to exclude, include, or raise an error for unknown
fields in the data. Use `EXCLUDE`, `INCLUDE` or `RAISE`. fields in the data. Use `EXCLUDE`, `INCLUDE` or `RAISE`.

View File

@ -19,16 +19,20 @@ class GenericSchemaTestCase(TestCase):
"dump_only": object(), "dump_only": object(),
"partial": object(), "partial": object(),
"unknown": object(), "unknown": object(),
"many": None, "many": object(),
} }
schema.GenericSchema[Foo](**kwargs) 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) mock_super_init.assert_called_once_with(**kwargs)
def test___setattr__(self) -> None:
class Foo:
pass
obj = schema.GenericSchema[Foo]()
with self.assertWarns(UserWarning):
obj.many = new = MagicMock()
self.assertIs(new, obj.many)
@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()