diff --git a/docs/api_reference/decorators.md b/docs/api_reference/decorators.md new file mode 100644 index 0000000..53549cb --- /dev/null +++ b/docs/api_reference/decorators.md @@ -0,0 +1 @@ +::: marshmallow_generic.decorators \ No newline at end of file diff --git a/docs/api_reference/schema.md b/docs/api_reference/schema.md new file mode 100644 index 0000000..8e0a448 --- /dev/null +++ b/docs/api_reference/schema.md @@ -0,0 +1 @@ +::: marshmallow_generic.schema diff --git a/docs/img/ide_suggestion_user.png b/docs/img/ide_suggestion_user.png new file mode 100644 index 0000000..971bb6b Binary files /dev/null and b/docs/img/ide_suggestion_user.png differ diff --git a/docs/index.md b/docs/index.md index 8532ea9..eb5c5d1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,15 +1,82 @@ # marshmallow-generic -Generic schema with full typing support and minimal boilerplate +**Generic schema with full typing support and minimal boilerplate** -## Usage +--- + +**Documentation**: daniil-berg.github.io/marshmallow-generic + +**Source Code**: github.com/daniil-berg/marshmallow-generic + +--- + +Extension for `marshmallow` to make deserialization to objects easier and improve type safety. + +The main `GenericSchema` class extends `marshmallow.Schema` making it **generic** in terms of the class that data should be deserialized to, when calling `load`/`loads`. + +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. ✨ + +## Usage Example + +```python +from marshmallow import fields +from marshmallow_generic import GenericSchema + + +class User: + def __init__(self, name: str, email: str) -> None: + self.name = name + self.email = email + + def __repr__(self) -> str: + return "".format(self=self) ... +class UserSchema(GenericSchema[User]): + name = fields.Str() + email = fields.Email() + + +user_data = {"name": "Monty", "email": "monty@python.org"} +schema = UserSchema() +single_user = schema.load(user_data) +print(single_user) # + +json_data = '''[ + {"name": "Monty", "email": "monty@python.org"}, + {"name": "Ronnie", "email": "ronnie@stones.com"} +]''' +multiple_users = schema.loads(json_data, many=True) +print(multiple_users) # [, ] +``` + +Adding `reveal_type(single_user)` and `reveal_type(multiple_users)` at the bottom and running that code through `mypy` would yield the following output: + +``` +# note: Revealed type is "User" +# note: Revealed type is "builtins.list[User]" +``` + +With the regular `marshmallow.Schema`, the output of `mypy` would instead be this: + +``` +# note: Revealed type is "Any" +# note: Revealed type is "Any" +``` + +This also means your IDE will be able to infer the types and thus provide useful auto-suggestions for the loaded objects. 👨‍💻 + +Here is PyCharm with the example from above: + +![Image title](http://daniil-berg.github.io/marshmallow-generic/img/ide_suggestion_user.png) + ## Installation `pip install marshmallow-generic` ## Dependencies -Python Version ..., OS ... +Python Version `3.9+` and `marshmallow` (duh) diff --git a/mkdocs.yaml b/mkdocs.yaml index 5a41e3d..2b927c5 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -26,13 +26,28 @@ extra_css: plugins: - search - - mkdocstrings + - mkdocstrings: + handlers: + python: + options: + show_source: false + show_root_toc_entry: false + import: + - https://marshmallow.readthedocs.io/en/stable/objects.inv markdown_extensions: - admonition - codehilite - extra - pymdownx.superfences + - toc: + permalink: true + +watch: + - src nav: - Home: index.md + - 'API Reference': + - api_reference/schema.md + - api_reference/decorators.md diff --git a/pyproject.toml b/pyproject.toml index 9086c39..5182983 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,6 +120,7 @@ ignore = [ "D203", # 1 blank line required before class docstring -> D211 is better "D212", # Multi-line docstring summary should start at the first line -> ugly, D212 is better "D401", # First line of docstring should be in imperative mood -> no, it shouldn't + "D407", # Missing dashed underline after section -> different docstring style ] [tool.ruff.per-file-ignores] diff --git a/src/marshmallow_generic/decorators.py b/src/marshmallow_generic/decorators.py index 9d672a5..93f50b3 100644 --- a/src/marshmallow_generic/decorators.py +++ b/src/marshmallow_generic/decorators.py @@ -1,4 +1,8 @@ -"""Typed overloads for some of the `marshmallow.decorators` module.""" +""" +Typed overloads for the [`marshmallow.decorators`][marshmallow.decorators] module. + +Implements decorators as generic in terms of the decorated method types. +""" from collections.abc import Callable from typing import Any, Optional, TypeVar, overload @@ -34,9 +38,31 @@ def post_load( pass_original: bool = False, ) -> Callable[..., Any]: """ - Typed overload of the original `marshmallow.post_load` decorator function. + Register a method to invoke after deserializing an object. + Typed overload of the original [`marshmallow.post_load`] + [marshmallow.post_load] decorator function. Generic to ensure that the decorated function retains its type. Runtime behavior is unchanged. + + Receives the deserialized data and returns the processed data. + By default it receives a single object at a time, transparently handling + the `many` argument passed to the [`Schema.load`][marshmallow.Schema.load] + call. + + Args: + fn (Optional[Callable[P, R]]): + The function to decorate or `None`; if a function is supplied, + a decorated version of it is returned; if `None` the decorator + is returned with its other arguments already bound. + pass_many: + If `True`, the raw data (which may be a collection) is passed + pass_original: + If `True`, the original data (before deserializing) will be passed + as an additional argument to the method + + Returns: + (Callable[P, R]): if `fn` is passed a function + (Callable[[Callable[P, R]], Callable[P, R]]): if `fn` is `None` """ return _post_load(fn, pass_many=pass_many, pass_original=pass_original) diff --git a/src/marshmallow_generic/schema.py b/src/marshmallow_generic/schema.py index 797faac..8e68ecd 100644 --- a/src/marshmallow_generic/schema.py +++ b/src/marshmallow_generic/schema.py @@ -1,4 +1,9 @@ -"""Definition of the `GenericSchema` base class.""" +""" +Definition of the `GenericSchema` base class. + +For details about the inherited methods and attributes, see the official +documentation of [`marshmallow.Schema`][marshmallow.Schema]. +""" from collections.abc import Iterable, Mapping, Sequence from typing import TYPE_CHECKING, Any, Literal, Optional, TypeVar, Union, overload @@ -8,23 +13,58 @@ from marshmallow import Schema from ._util import GenericInsightMixin from .decorators import post_load -_T = TypeVar("_T") +Model = TypeVar("Model") -class GenericSchema(GenericInsightMixin[_T], Schema): +class GenericSchema(GenericInsightMixin[Model], Schema): """ - Schema parameterized by the class it deserializes data to. + Generic schema parameterized by a **`Model`** class. + + Data will always be deserialized to instances of that **`Model`** class. + + !!! note + The **`Model`** referred to throughout the documentation is a + **type variable**, not any concrete class. For more information about + type variables, see the "Generics" section in + [PEP 484](https://peps.python.org/pep-0484/#generics). Registers a `post_load` hook to pass validated data to the constructor - of the specified class. + of the specified **`Model`**. - Requires a specific (non-generic) class to be passed as the type argument - for deserialization to work properly. + Requires a specific (non-generic) class to be passed as the **`Model`** + type argument for deserialization to work properly: + + ```python + class Foo: # Model + ... + + class FooSchema(GenericSchema[Foo]): + ... + ``` """ @post_load - def instantiate(self, data: dict[str, Any], **_kwargs: Any) -> _T: - """Unpacks `data` into the constructor of the specified type.""" + def instantiate(self, data: dict[str, Any], **_kwargs: Any) -> Model: + """ + Unpacks `data` into the constructor of the specified **`Model`**. + + Registered as a [`@post_load`] + [marshmallow_generic.decorators.post_load] hook for the schema. + + !!! warning + You should probably **not** use this method directly; + no parsing, transformation or validation of any kind is done + in this method. The `data` passed to the **`Model`** constructor + "as is". + + Args: + data: + The validated data after deserialization; will be unpacked + into the constructor of the specified **`Model`** class. + + Returns: + Instance of the schema's **`Model`** initialized with `**data` + """ return self._get_type_arg()(**data) if TYPE_CHECKING: @@ -37,7 +77,7 @@ class GenericSchema(GenericInsightMixin[_T], Schema): many: Literal[True], partial: Union[bool, Sequence[str], set[str], None] = None, unknown: Optional[str] = None, - ) -> list[_T]: + ) -> list[Model]: ... @overload @@ -48,7 +88,7 @@ class GenericSchema(GenericInsightMixin[_T], Schema): many: Optional[Literal[False]] = None, partial: Union[bool, Sequence[str], set[str], None] = None, unknown: Optional[str] = None, - ) -> _T: + ) -> Model: ... def load( @@ -58,12 +98,40 @@ class GenericSchema(GenericInsightMixin[_T], Schema): many: Optional[bool] = None, partial: Union[bool, Sequence[str], set[str], None] = None, unknown: Optional[str] = None, - ) -> Union[list[_T], _T]: + ) -> Union[list[Model], Model]: """ - Same as `marshmallow.Schema.load` at runtime. + Deserializes data to objects of the specified **`Model`** class. + + Same as [`marshmallow.Schema.load`] + [marshmallow.schema.Schema.load] at runtime, but data will always + pass through the [`instantiate`] + [marshmallow_generic.schema.GenericSchema.instantiate] + hook after deserialization. Annotations ensure that type checkers will infer the return type - correctly based on the type argument passed to a specific subclass. + correctly based on the **`Model`** type argument of the class. + + Args: + data: + The data to deserialize + many: + Whether to deserialize `data` as a collection. If `None`, + the value for `self.many` is used. + 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`. + If `None`, the value for `self.unknown` is used. + + Returns: + (Model): if `many` is set to `False` + (list[Model]): if `many` is set to `True` """ ... @@ -76,7 +144,7 @@ class GenericSchema(GenericInsightMixin[_T], Schema): partial: Union[bool, Sequence[str], set[str], None] = None, unknown: Optional[str] = None, **kwargs: Any, - ) -> list[_T]: + ) -> list[Model]: ... @overload @@ -88,7 +156,7 @@ class GenericSchema(GenericInsightMixin[_T], Schema): partial: Union[bool, Sequence[str], set[str], None] = None, unknown: Optional[str] = None, **kwargs: Any, - ) -> _T: + ) -> Model: ... def loads( @@ -99,11 +167,41 @@ class GenericSchema(GenericInsightMixin[_T], Schema): partial: Union[bool, Sequence[str], set[str], None] = None, unknown: Optional[str] = None, **kwargs: Any, - ) -> Union[list[_T], _T]: + ) -> Union[list[Model], Model]: """ - Same as `marshmallow.Schema.loads` at runtime. + Deserializes data to objects of the specified **`Model`** class. + + Same as [`marshmallow.Schema.loads`] + [marshmallow.schema.Schema.loads] at runtime, but data will always + pass through the [`instantiate`] + [marshmallow_generic.schema.GenericSchema.instantiate] + hook after deserialization. Annotations ensure that type checkers will infer the return type - correctly based on the type argument passed to a specific subclass. + correctly based on the **`Model`** type argument of the class. + + Args: + json_data: + A JSON string of the data to deserialize + many: + Whether to deserialize `data` as a collection. If `None`, + the value for `self.many` is used. + 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`. + If `None`, the value for `self.unknown` is used. + **kwargs: + Passed to the JSON decoder + + Returns: + (Model): if `many` is set to `False` + (list[Model]): if `many` is set to `True` """ ...