Compare commits

...

16 Commits

Author SHA1 Message Date
3f0e3db427 👷 Use own workflow for creating and publishing new releases 2023-04-26 17:04:12 +02:00
862a517018 🚨 Set explicit stacklevel when warning about schema-wide many setting depending on initialization state.
This should help users see the exact line they wrote that triggered the warning.
2023-04-21 15:52:33 +02:00
3c16b4ebd6 📌 Restrict main dependency marshmallow>=3.12.0;
pin all development dependencies to specific versions
2023-04-21 15:47:03 +02:00
0e443f08b9 👷 Use own Python testing workflow from reusable-workflows repository 2023-04-21 15:14:37 +02:00
2ca7ccde4a 🔨 Rework and refactor development scripts 2023-04-21 15:09:09 +02:00
a7caa876c4 🔧 Fix minor cosmetic issues in project config 2023-04-21 15:08:55 +02:00
da244e15ba 👷 Add Github CI workflow for unit testing and linting 2023-03-13 20:59:42 +01:00
485aea005e 🔖 v1.0.0 2023-03-13 20:27:09 +01:00
d272864e44 👨‍💻 Explicitly (re-)export in the package __init__.py (including marshmallow exports) 2023-03-13 18:54:18 +01:00
4dd1fbaf53 🔧 Store miscellaneous dev cache files in their own directory 2023-03-13 18:35:14 +01:00
4878d550fe ♻️ Move the schema-wide many warning to __setattr__ instead 2023-03-13 15:48:05 +01:00
2a5e35b334 🏷️ Change the many type back to bool in the __init__ method 2023-03-13 15:20:33 +01:00
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
21c201b681 📝 Write and configure documentation 2023-03-11 16:33:06 +01:00
22 changed files with 746 additions and 109 deletions

34
.github/workflows/ci.yaml vendored Normal file
View File

@ -0,0 +1,34 @@
name: CI
on:
push:
branches: master
tags: 'v*.*.*'
jobs:
test:
name: Test
uses: daniil-berg/reusable-workflows/.github/workflows/python-test.yaml@v0.2.1
with:
versions: '["3.9", "3.10", "3.11"]'
unittest-command: 'scripts/test.sh'
coverage-command: 'scripts/cov.sh'
unittest-requirements: "-e '.[dev]'"
typecheck-command: 'scripts/typecheck.sh'
typecheck-requirements: '-Ur requirements/dev.txt'
typecheck-all-versions: true
lint-command: 'scripts/lint.sh'
lint-requirements: '-Ur requirements/dev.txt'
release:
name: Release
if: ${{ github.ref_type == 'tag' }}
needs: test
uses: daniil-berg/reusable-workflows/.github/workflows/python-release.yaml@v0.2.1
with:
git-ref: ${{ github.ref_name }}
secrets:
release-token: ${{ secrets.TOKEN_GITHUB_CREATE_RELEASE }}
publish-token: ${{ secrets.TOKEN_PYPI_PROJECT }}
permissions:
contents: write

4
.gitignore vendored
View File

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

View File

@ -0,0 +1 @@
::: marshmallow_generic.decorators

View File

@ -0,0 +1 @@
::: marshmallow_generic.schema

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -1,15 +1,81 @@
# marshmallow-generic
Generic schema with full typing support and minimal boilerplate
**Generic schema with full typing support and minimal boilerplate**
## Usage
---
**Documentation**: <a href="http://daniil-berg.github.io/marshmallow-generic" target="_blank"> daniil-berg.github.io/marshmallow-generic </a>
**Source Code**: <a href="https://github.com/daniil-berg/marshmallow-generic" target="_blank"> github.com/daniil-berg/marshmallow-generic </a>
---
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>.
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`. 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
```python
from marshmallow_generic import GenericSchema, fields
class User:
def __init__(self, name: str, email: str) -> None:
self.name = name
self.email = email
def __repr__(self) -> str:
return "<User(name={self.name!r})>".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) # <User(name='Monty')>
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) # [<User(name='Monty')>, <User(name='Ronnie')>]
```
Adding `reveal_type(single_user)` and `reveal_type(multiple_users)` at the bottom and running that code through <a href="https://mypy.readthedocs.io/en/stable/" target="_blank">`mypy`</a> 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)

View File

@ -26,13 +26,31 @@ extra_css:
plugins:
- search
- mkdocstrings
- mkdocstrings:
handlers:
python:
options:
line_length: 80
show_source: false
show_root_toc_entry: false
separate_signature: true
show_signature_annotations: true
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

View File

@ -20,7 +20,7 @@ keywords = [
]
license = { text = "Apache Software License Version 2.0" }
classifiers = [
"Development Status :: 4 - Beta",
"Development Status :: 5 - Production/Stable",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
@ -37,34 +37,32 @@ dynamic = [
]
[project.optional-dependencies]
full = [
]
dev = [
"black",
"build",
"coverage[toml]",
"isort",
"mkdocs-material",
"mkdocstrings[python]",
"mypy",
"ruff",
"black==23.3.0",
"build==0.10.0",
"coverage[toml]==7.2.3",
"isort==5.12.0",
"mkdocs-material==9.1.6",
"mkdocstrings[python]==0.21.2",
"mypy==1.2.0",
"ruff==0.0.262",
]
[project.urls]
repository = "https://github.com/daniil-berg/marshmallow-generic"
bug_tracker = "https://github.com/daniil-berg/marshmallow-generic/issues"
documentation = "http://daniil-berg.github.io/marshmallow-generic"
"Repository" = "https://github.com/daniil-berg/marshmallow-generic"
"Issue Tracker" = "https://github.com/daniil-berg/marshmallow-generic/issues"
"Documentation" = "http://daniil-berg.github.io/marshmallow-generic"
[tool.setuptools.dynamic]
dependencies = { file = "requirements/common.txt" }
readme = { file = ["README.md"] }
version = {attr = "marshmallow_generic.__version__"}
readme = { file = ["README.md"], content-type = "text/markdown" }
version = { attr = "marshmallow_generic.__version__" }
#########################
# Static type checking: #
[tool.mypy]
cache_dir = ".cache/mypy"
files = [
"src/",
"tests/",
@ -80,6 +78,7 @@ plugins = [
# Unit test coverage: #
[tool.coverage.run]
data_file = ".cache/coverage"
source = [
"src/",
]
@ -105,6 +104,7 @@ omit = [
# Linting and style checking: #
[tool.ruff]
cache-dir = ".cache/ruff"
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
@ -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]

View File

@ -1 +1 @@
marshmallow
marshmallow>=3.12.0

View File

@ -1,9 +1,9 @@
-r common.txt
black
build
coverage[toml]
isort
mkdocs-material
mkdocstrings[python]
mypy
ruff
black==23.3.0
build==0.10.0
coverage[toml]==7.2.3
isort==5.12.0
mkdocs-material==9.1.6
mkdocstrings[python]==0.21.2
mypy==1.2.0
ruff==0.0.262

12
scripts/ci.sh Executable file
View File

@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Runs full CI pipeline (test, typecheck, lint).
typeset scripts_dir="$(dirname $(realpath $0))"
source "${scripts_dir}/util.sh"
"${scripts_dir}/test.sh"
"${scripts_dir}/typecheck.sh"
"${scripts_dir}/lint.sh"
echo -e "${background_black}${bold_green}✅ 🎉 All checks passed!${color_reset}"

10
scripts/cov.sh Executable file
View File

@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Runs unit tests.
# If successful, prints only the coverage percentage.
# If an error occurs, prints the entire unit tests progress output.
source "$(dirname $(realpath $0))/util.sh"
coverage erase
run_and_capture coverage run
coverage report | awk '$1 == "TOTAL" {print $NF; exit}'

View File

@ -1,18 +1,17 @@
#!/usr/bin/env bash
# Runs type checker and linters.
# Runs various linters.
# Ensure that we return to the current working directory
# and exit the script immediately in case of an error:
trap "cd $(realpath ${PWD}); exit 1" ERR
# Change into project root directory:
cd "$(dirname $(dirname $(realpath $0)))"
echo 'Performing type checks...'
mypy
echo
source "$(dirname $(realpath $0))/util.sh"
echo 'Linting source and test files...'
echo ' isort - consistent imports'
isort src/ tests/ --check-only
echo ' ruff - extensive linting'
ruff src/ tests/
black src/ tests/ --check
echo -e 'No issues found.'
echo ' black - consistent style'
run_and_capture black src/ tests/ --check
echo -e "${bold_green}No issues found${color_reset}\n"

View File

@ -1,17 +1,12 @@
#!/usr/bin/env bash
# Runs unit tests and prints only coverage percentage, if successful.
# If an error occurs, prints the entire unit tests progress output.
# Runs unit tests and reports coverage percentage.
# Ensure that we return to the current working directory in case of an error:
trap "cd $(realpath ${PWD})" ERR
# Change into project root directory:
cd "$(dirname $(dirname $(realpath $0)))"
source "$(dirname $(realpath $0))/util.sh"
coverage erase
# Capture the test progression in a variable:
typeset progress
progress=$(coverage run 2>&1)
# If tests failed or produced errors, write progress/messages to stderr and exit:
[[ $? -eq 0 ]] || { >&2 echo "${progress}"; exit 1; }
# Otherwise extract the total coverage percentage from the produced report and write it to stdout:
coverage report | awk '$1 == "TOTAL" {print $NF; exit}'
echo 'Running unit tests...'
coverage run
typeset percentage
typeset color
percentage="$(coverage report | awk '$1 == "TOTAL" {print $NF; exit}')"
[[ $percentage == "100%" ]] && color="${bold_green}" || color="${yellow}"
echo -e "${color}${percentage} coverage${color_reset}\n"

8
scripts/typecheck.sh Executable file
View File

@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Runs type checker.
source "$(dirname $(realpath $0))/util.sh"
echo 'Performing type checks...'
mypy
echo

20
scripts/util.sh Normal file
View File

@ -0,0 +1,20 @@
run_and_capture() {
# Captures stderr of any command passed to it
# and releases it only if the command exits with a non-zero code.
typeset output
output=$($@ 2>&1)
typeset exit_status=$?
[[ $exit_status == 0 ]] || >&2 echo "${output}"
return $exit_status
}
# Ensure that we return to the current working directory
# and exit the script immediately in case of an error:
trap "cd $(realpath ${PWD}); exit 1" ERR
# Change into project root directory:
cd "$(dirname $(dirname $(realpath $0)))"
typeset background_black='\033[40m'
typeset bold_green='\033[1;92m'
typeset yellow='\033[0;33m'
typeset color_reset='\033[0m'

View File

@ -13,11 +13,45 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License."""
__version__ = "0.0.1"
__version__ = "1.0.0"
__doc__ = """
Generic schema with full typing support and minimal boilerplate.
"""
from .decorators import post_load
from .schema import GenericSchema
__all__ = [
# 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_load` overloaded
post_dump,
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

@ -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]):
_type_arg: Optional[type[_T]] = None
class GenericInsightMixin(Generic[_T0, _T1, _T2, _T3, _T4]):
_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
def __init_subclass__(cls, **kwargs: Any) -> None:
@ -14,17 +32,70 @@ class GenericInsightMixin(Generic[_T]):
origin = get_origin(base)
if origin is None or not issubclass(origin, GenericInsightMixin):
continue
type_arg = get_args(base)[0]
# Do not set the attribute for GENERIC subclasses!
if not isinstance(type_arg, TypeVar):
cls._type_arg = type_arg
return
type_args = get_args(base)
for idx, arg in enumerate(type_args):
# Do not set the attribute for generics:
if isinstance(arg, TypeVar):
continue
# Do not set `NoneType`:
if isinstance(arg, type) and isinstance(None, arg):
continue
setattr(cls, f"_type_arg_{idx}", arg)
return
@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)."""
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(
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

@ -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)

View File

@ -1,34 +1,240 @@
"""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
from warnings import warn
from marshmallow import Schema
from ._util import GenericInsightMixin
from ._util import GenericInsightMixin1
from .decorators import post_load
_T = TypeVar("_T")
Model = TypeVar("Model")
MANY_SCHEMA_UNSAFE = (
"Changing `many` schema-wide breaks type safety. "
"Use the the `many` parameter of specific methods (like `load`) instead."
)
class GenericSchema(GenericInsightMixin[_T], Schema):
class GenericSchema(GenericInsightMixin1[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]):
...
```
"""
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: bool = False, # usage discouraged
) -> None:
"""
Emits a warning, if the `many` argument is not `False`.
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
Changing this option schema-wide undermines the type
safety that this class aims to provide. Passing `True`
will therefore trigger a warning. You should instead 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].
"""
self._pre_init = True
super().__init__(
only=only,
exclude=exclude,
many=many,
context=context,
load_only=load_only,
dump_only=dump_only,
partial=partial,
unknown=unknown,
)
self._pre_init = False
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(MANY_SCHEMA_UNSAFE, stacklevel=4 if self._pre_init else 2)
super().__setattr__(name, value)
@post_load
def instantiate(self, data: dict[str, Any], **_kwargs: Any) -> _T:
"""Unpacks `data` into the constructor of the specified type."""
return self._get_type_arg()(**data)
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` is 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(0)(**data)
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]
def load(
self,
@ -37,7 +243,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 +254,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 +264,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 +310,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 +322,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 +333,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`
"""
...

View File

@ -12,7 +12,11 @@ class GenericInsightMixinTestCase(TestCase):
mock_super.return_value = MagicMock(__init_subclass__=mock_super_meth)
# 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),
# ensure that the attribute remains `None` on the subclass:
@ -24,30 +28,55 @@ class GenericInsightMixinTestCase(TestCase):
class Bar(Generic[t]):
pass
class TestSchema1(Bar[str], _util.GenericInsightMixin[t]):
class TestCls(Bar[str], _util.GenericInsightMixin[t, None, int, str, bool]):
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_meth.assert_called_once_with()
mock_super.reset_mock()
mock_super_meth.reset_mock()
# If the mixin type argument was specified,
# ensure it was assigned to the attribute on the child class:
# If the mixin type arguments were omitted,
# ensure the attributes remained `None`:
class TestSchema2(Bar[str], _util.GenericInsightMixin[Foo]):
class UnspecifiedCls(_util.GenericInsightMixin): # type: ignore[type-arg]
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_meth.assert_called_once_with()
def test__get_type_arg(self) -> None:
with self.assertRaises(AttributeError):
_util.GenericInsightMixin._get_type_arg()
_util.GenericInsightMixin._get_type_arg(0)
_type = object()
with patch.object(_util.GenericInsightMixin, "_type_arg", new=_type):
self.assertIs(_type, _util.GenericInsightMixin._get_type_arg())
_type_0 = object()
_type_1 = object()
_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.mock import MagicMock, patch
@ -5,6 +6,33 @@ from marshmallow_generic import _util, schema
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": object(),
}
schema.GenericSchema[Foo](**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")
def test_instantiate(self, mock__get_type_arg: MagicMock) -> None:
mock__get_type_arg.return_value = mock_cls = MagicMock()
@ -17,9 +45,29 @@ class GenericSchemaTestCase(TestCase):
# Explicit annotation to possibly catch mypy errors:
output: Foo = schema_obj.instantiate(mock_data)
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)
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:
"""Mainly for static type checking purposes."""