Compare commits

..

1 Commits

Author SHA1 Message Date
daniil-berg e90652ced5 Initial commit 2023-03-09 14:05:17 +01:00
25 changed files with 64 additions and 1057 deletions
-34
View File
@@ -1,34 +0,0 @@
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
+2 -2
View File
@@ -21,5 +21,5 @@ __pycache__/
# Testing: # Testing:
/.coverage /.coverage
# Miscellaneous cache: # mypy:
.cache/ .mypy_cache/
-1
View File
@@ -1 +0,0 @@
::: marshmallow_generic.decorators
-1
View File
@@ -1 +0,0 @@
::: marshmallow_generic.schema
Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

+3 -69
View File
@@ -1,81 +1,15 @@
# marshmallow-generic # 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 ## Installation
`pip install marshmallow-generic` `pip install marshmallow-generic`
## Dependencies ## Dependencies
Python Version `3.9+` and `marshmallow` (duh) Python Version ..., OS ...
+1 -19
View File
@@ -26,31 +26,13 @@ extra_css:
plugins: plugins:
- search - 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: markdown_extensions:
- admonition - admonition
- codehilite - codehilite
- extra - extra
- pymdownx.superfences - pymdownx.superfences
- toc:
permalink: true
watch:
- src
nav: nav:
- Home: index.md - Home: index.md
- 'API Reference':
- api_reference/schema.md
- api_reference/decorators.md
+20 -66
View File
@@ -20,7 +20,7 @@ keywords = [
] ]
license = { text = "Apache Software License Version 2.0" } license = { text = "Apache Software License Version 2.0" }
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 4 - Beta",
"Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
@@ -28,7 +28,6 @@ classifiers = [
"Operating System :: OS Independent", "Operating System :: OS Independent",
"License :: OSI Approved :: Apache Software License", "License :: OSI Approved :: Apache Software License",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"Typing :: Typed",
] ]
dynamic = [ dynamic = [
"dependencies", "dependencies",
@@ -37,36 +36,34 @@ dynamic = [
] ]
[project.optional-dependencies] [project.optional-dependencies]
full = [
]
dev = [ dev = [
"black==23.3.0", "black",
"build==0.10.0", "build",
"coverage[toml]==7.2.3", "coverage",
"isort==5.12.0", "flake8",
"mkdocs-material==9.1.6", "mkdocs-material",
"mkdocstrings[python]==0.21.2", "mkdocstrings[python]",
"mypy==1.2.0", "mypy",
"ruff==0.0.262",
] ]
[project.urls] [project.urls]
"Repository" = "https://github.com/daniil-berg/marshmallow-generic" repository = "https://github.com/daniil-berg/marshmallow-generic"
"Issue Tracker" = "https://github.com/daniil-berg/marshmallow-generic/issues" bug_tracker = "https://github.com/daniil-berg/marshmallow-generic/issues"
"Documentation" = "http://daniil-berg.github.io/marshmallow-generic" documentation = "http://daniil-berg.github.io/marshmallow-generic"
[tool.setuptools.dynamic] [tool.setuptools.dynamic]
dependencies = { file = "requirements/common.txt" } dependencies = { file = "requirements/common.txt" }
readme = { file = ["README.md"], content-type = "text/markdown" } readme = { file = ["README.md"] }
version = {attr = "marshmallow_generic.__version__"} version = {attr = "marshmallow_generic.__version__"}
######################### #########
# Static type checking: # # Mypy: #
[tool.mypy] [tool.mypy]
cache_dir = ".cache/mypy" files = "src/"
files = [
"src/",
"tests/",
]
warn_unused_configs = true warn_unused_configs = true
strict = true strict = true
show_error_codes = true show_error_codes = true
@@ -74,11 +71,10 @@ plugins = [
] ]
####################### #############
# Unit test coverage: # # Coverage: #
[tool.coverage.run] [tool.coverage.run]
data_file = ".cache/coverage"
source = [ source = [
"src/", "src/",
] ]
@@ -99,45 +95,3 @@ exclude_lines = [
omit = [ omit = [
"tests/*", "tests/*",
] ]
###############################
# Linting and style checking: #
[tool.ruff]
cache-dir = ".cache/ruff"
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"D", # pydocstyle
"C", # flake8-comprehensions
"B", # flake8-bugbear
"PL", # pylint
"RUF", # ruff-specific
]
ignore = [
"E501", # Line too long -> handled by black
"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]
"src/**/__init__.py" = [
"D104", # Missing docstring in public package
"F401", # {...} imported but unused
]
"tests/*.py" = [
"D100", # Missing docstring in public module
"D101", # Missing docstring in public class
"D102", # Missing docstring in public method
"D104", # Missing docstring in public package
]
###################
# Import sorting: #
[tool.isort]
profile = "black"
extra_standard_library = ["typing_extensions"]
+1 -1
View File
@@ -1 +1 @@
marshmallow>=3.12.0 marshmallow
+7 -8
View File
@@ -1,9 +1,8 @@
-r common.txt -r common.txt
black==23.3.0 black
build==0.10.0 build
coverage[toml]==7.2.3 coverage
isort==5.12.0 flake8
mkdocs-material==9.1.6 mkdocs-material
mkdocstrings[python]==0.21.2 mkdocstrings[python]
mypy==1.2.0 mypy
ruff==0.0.262
-12
View File
@@ -1,12 +0,0 @@
#!/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
View File
@@ -1,10 +0,0 @@
#!/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}'
+12 -13
View File
@@ -1,17 +1,16 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Runs various linters. # Runs type checker and linters.
source "$(dirname $(realpath $0))/util.sh" # 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
echo 'Linting source and test files...' echo 'Linting source and test files...'
flake8 src/ tests/
echo ' isort - consistent imports' echo -e 'No issues found.'
isort src/ tests/ --check-only
echo ' ruff - extensive linting'
ruff src/ tests/
echo ' black - consistent style'
run_and_capture black src/ tests/ --check
echo -e "${bold_green}No issues found${color_reset}\n"
+14 -9
View File
@@ -1,12 +1,17 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Runs unit tests and reports coverage percentage. # Runs unit tests and prints only coverage percentage, if successful.
# If an error occurs, prints the entire unit tests progress output.
source "$(dirname $(realpath $0))/util.sh" # 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)))"
echo 'Running unit tests...' coverage erase
coverage run # Capture the test progression in a variable:
typeset percentage typeset progress
typeset color progress=$(coverage run 2>&1)
percentage="$(coverage report | awk '$1 == "TOTAL" {print $NF; exit}')" # If tests failed or produced errors, write progress/messages to stderr and exit:
[[ $percentage == "100%" ]] && color="${bold_green}" || color="${yellow}" [[ $? -eq 0 ]] || { >&2 echo "${progress}"; exit 1; }
echo -e "${color}${percentage} coverage${color_reset}\n" # Otherwise extract the total coverage percentage from the produced report and write it to stdout:
coverage report | awk '$1 == "TOTAL" {print $NF; exit}'
-8
View File
@@ -1,8 +0,0 @@
#!/usr/bin/env bash
# Runs type checker.
source "$(dirname $(realpath $0))/util.sh"
echo 'Performing type checks...'
mypy
echo
-20
View File
@@ -1,20 +0,0 @@
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'
+2 -39
View File
@@ -13,45 +13,8 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License.""" limitations under the License."""
__version__ = "1.0.0" __version__ = "0.0.1"
__doc__ = """ __doc__ = """
Generic schema with full typing support and minimal boilerplate. PLACEHOLDER
""" """
__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
-101
View File
@@ -1,101 +0,0 @@
from typing import (
Any,
Generic,
Literal,
Optional,
TypeVar,
Union,
get_args,
get_origin,
overload,
)
_T0 = TypeVar("_T0")
_T1 = TypeVar("_T1")
_T2 = TypeVar("_T2")
_T3 = TypeVar("_T3")
_T4 = TypeVar("_T4")
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:
"""Saves the type argument in the `_type_arg` class attribute."""
super().__init_subclass__(**kwargs)
for base in cls.__orig_bases__: # type: ignore[attr-defined]
origin = get_origin(base)
if origin is None or not issubclass(origin, GenericInsightMixin):
continue
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
@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 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 {idx} unspecified"
)
return type_
class GenericInsightMixin1(GenericInsightMixin[_T0, None, None, None, None]):
pass
class GenericInsightMixin2(GenericInsightMixin[_T0, _T1, None, None, None]):
pass
-68
View File
@@ -1,68 +0,0 @@
"""
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
from typing_extensions import ParamSpec
from marshmallow.decorators import post_load as _post_load
_R = TypeVar("_R")
_P = ParamSpec("_P")
@overload
def post_load(
fn: Callable[_P, _R],
pass_many: bool = False,
pass_original: bool = False,
) -> Callable[_P, _R]:
...
@overload
def post_load(
fn: None = None,
pass_many: bool = False,
pass_original: bool = False,
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
...
def post_load(
fn: Optional[Callable[..., Any]] = None,
pass_many: bool = False,
pass_original: bool = False,
) -> Callable[..., Any]:
"""
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
-373
View File
@@ -1,373 +0,0 @@
"""
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 GenericInsightMixin1
from .decorators import post_load
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(GenericInsightMixin1[Model], Schema):
"""
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 **`Model`**.
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) -> 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,
data: Union[Mapping[str, Any], Iterable[Mapping[str, Any]]],
*,
many: Literal[True],
partial: Union[bool, Sequence[str], set[str], None] = None,
unknown: Optional[str] = None,
) -> list[Model]:
...
@overload
def load(
self,
data: Union[Mapping[str, Any], Iterable[Mapping[str, Any]]],
*,
many: Optional[Literal[False]] = None,
partial: Union[bool, Sequence[str], set[str], None] = None,
unknown: Optional[str] = None,
) -> Model:
...
def load(
self,
data: Union[Mapping[str, Any], Iterable[Mapping[str, Any]]],
*,
many: Optional[bool] = None,
partial: Union[bool, Sequence[str], set[str], None] = None,
unknown: Optional[str] = None,
) -> Union[list[Model], Model]:
"""
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 **`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`
"""
...
@overload # type: ignore[override]
def loads(
self,
json_data: str,
*,
many: Literal[True],
partial: Union[bool, Sequence[str], set[str], None] = None,
unknown: Optional[str] = None,
**kwargs: Any,
) -> list[Model]:
...
@overload
def loads(
self,
json_data: str,
*,
many: Optional[Literal[False]] = None,
partial: Union[bool, Sequence[str], set[str], None] = None,
unknown: Optional[str] = None,
**kwargs: Any,
) -> Model:
...
def loads(
self,
json_data: str,
*,
many: Optional[bool] = None,
partial: Union[bool, Sequence[str], set[str], None] = None,
unknown: Optional[str] = None,
**kwargs: Any,
) -> Union[list[Model], Model]:
"""
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 **`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`
"""
...
+1
View File
@@ -1,6 +1,7 @@
import sys import sys
import unittest import unittest
if __name__ == "__main__": if __name__ == "__main__":
try: try:
pattern = sys.argv[1] pattern = sys.argv[1]
-82
View File
@@ -1,82 +0,0 @@
from typing import Generic, TypeVar
from unittest import TestCase
from unittest.mock import MagicMock, patch
from marshmallow_generic import _util
class GenericInsightMixinTestCase(TestCase):
@patch.object(_util, "super")
def test___init_subclass__(self, mock_super: MagicMock) -> None:
mock_super_meth = MagicMock()
mock_super.return_value = MagicMock(__init_subclass__=mock_super_meth)
# Should be `None` by default:
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:
t = TypeVar("t")
class Foo:
pass
class Bar(Generic[t]):
pass
class TestCls(Bar[str], _util.GenericInsightMixin[t, None, int, str, bool]):
pass
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 arguments were omitted,
# ensure the attributes remained `None`:
class UnspecifiedCls(_util.GenericInsightMixin): # type: ignore[type-arg]
pass
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(0)
_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]
-28
View File
@@ -1,28 +0,0 @@
from collections.abc import Callable
from unittest import TestCase
from unittest.mock import MagicMock, patch
from marshmallow_generic import decorators
class DecoratorsTestCase(TestCase):
@patch.object(decorators, "_post_load")
def test_post_load(self, mock_original_post_load: MagicMock) -> None:
mock_original_post_load.return_value = expected_output = object()
def test_function(x: int) -> str:
return str(x)
pass_many, pass_original = MagicMock(), MagicMock()
# Explicit annotation to possibly catch mypy errors:
output: Callable[[int], str] = decorators.post_load(
test_function,
pass_many=pass_many,
pass_original=pass_original,
)
self.assertIs(expected_output, output)
mock_original_post_load.assert_called_once_with(
test_function,
pass_many=pass_many,
pass_original=pass_original,
)
-92
View File
@@ -1,92 +0,0 @@
from typing import Any
from unittest import TestCase
from unittest.mock import MagicMock, patch
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()
mock_data = {"foo": "bar", "spam": 123}
class Foo:
pass
schema_obj = schema.GenericSchema[Foo]()
# 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(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."""
class Foo:
pass
class TestSchema(schema.GenericSchema[Foo]):
pass
single: Foo
single = TestSchema().load({})
self.assertIsInstance(single, Foo)
single = TestSchema().loads("{}")
self.assertIsInstance(single, Foo)
multiple: list[Foo]
multiple = TestSchema().load([{}], many=True)
self.assertIsInstance(multiple, list)
self.assertIsInstance(multiple[0], Foo)
multiple = TestSchema().loads("[{}]", many=True)
self.assertIsInstance(multiple, list)
self.assertIsInstance(multiple[0], Foo)