Compare commits

...

12 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
15 changed files with 239 additions and 110 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

@ -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>.
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
```python
from marshmallow import fields
from marshmallow_generic import GenericSchema
from marshmallow_generic import GenericSchema, fields
class User:

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

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

@ -16,6 +16,11 @@ 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):
"""
@ -54,10 +59,10 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
dump_only: Union[Sequence[str], set[str]] = (),
partial: Union[bool, Sequence[str], set[str]] = False,
unknown: Optional[str] = None,
many: Optional[bool] = None,
many: bool = False, # usage discouraged
) -> 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].
@ -72,40 +77,35 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
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.
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.
declared. Propagates down to
[`Nested`][marshmallow.fields.Nested] fields as well. If its
value is an iterable, only missing fields listed in that
iterable will be ignored. Use dot delimiters to specify nested
fields.
unknown:
Whether to exclude, include, or raise an error for unknown
fields in the data. Use `EXCLUDE`, `INCLUDE` or `RAISE`.
many:
!!! warning
Specifying this option schema-wide undermines the type
safety that this class aims to provide and passing any
value other than `None` will trigger a warning. Use the
method-specific `many` parameter, when calling
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] 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)
self._pre_init = True
super().__init__(
only=only,
exclude=exclude,
@ -116,14 +116,27 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
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.
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,
@ -169,8 +182,9 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
"""
Serializes **`Model`** objects to native Python data types.
Same as [`marshmallow.Schema.dump`]
[marshmallow.schema.Schema.dump] at runtime.
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`
@ -254,10 +268,10 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
"""
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]
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
@ -271,11 +285,11 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
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.
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`.
@ -323,10 +337,10 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
"""
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]
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
@ -340,11 +354,11 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
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.
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`.

View File

@ -19,16 +19,20 @@ class GenericSchemaTestCase(TestCase):
"dump_only": object(),
"partial": object(),
"unknown": object(),
"many": None,
"many": object(),
}
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)
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()