generated from daniil-berg/boilerplate-py
Compare commits
1 Commits
master
...
094e8b93f0
Author | SHA1 | Date | |
---|---|---|---|
094e8b93f0
|
34
.github/workflows/ci.yaml
vendored
34
.github/workflows/ci.yaml
vendored
@ -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
|
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -21,5 +21,5 @@ __pycache__/
|
|||||||
# Testing:
|
# Testing:
|
||||||
/.coverage
|
/.coverage
|
||||||
|
|
||||||
# Miscellaneous cache:
|
# mypy:
|
||||||
.cache/
|
.mypy_cache/
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 72 KiB |
@ -10,18 +10,19 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Extension for <a href="https://github.com/marshmallow-code/marshmallow" target="_blank">**`marshmallow`**</a> to make <a href="https://marshmallow.readthedocs.io/en/stable/quickstart.html#deserializing-to-objects" target="_blank">deserialization to objects</a> easier and improve type safety.
|
Extension for <a href="https://github.com/marshmallow-code/marshmallow" target="_blank">`marshmallow`</a> to make <a href="https://marshmallow.readthedocs.io/en/stable/quickstart.html#deserializing-to-objects" target="_blank">deserialization to objects</a> easier and improve type safety.
|
||||||
|
|
||||||
The main `GenericSchema` class extends <a href="https://marshmallow.readthedocs.io/en/stable/marshmallow.schema.html#marshmallow.schema.Schema" target="_blank">`marshmallow.Schema`</a> making it **generic** in terms of the class that data should be deserialized to, when calling <a href="https://marshmallow.readthedocs.io/en/stable/marshmallow.schema.html#marshmallow.schema.Schema.load" target="_blank">`load`/`loads`</a>.
|
The main `GenericSchema` class extends <a href="https://marshmallow.readthedocs.io/en/stable/marshmallow.schema.html#marshmallow.schema.Schema" target="_blank">`marshmallow.Schema`</a> making it **generic** in terms of the class that data should be deserialized to, when calling <a href="https://marshmallow.readthedocs.io/en/stable/marshmallow.schema.html#marshmallow.schema.Schema.load" target="_blank">`load`/`loads`</a>.
|
||||||
|
|
||||||
With `GenericSchema` there is no need to explicitly write `post_load` hooks to initialize the object anymore. 🎉
|
With `GenericSchema` there is no need to explicitly write `post_load` hooks to initialize the object anymore. 🎉
|
||||||
|
|
||||||
If the "model" class is (for example) `User`, it just needs to be passed as the type argument, when subclassing `GenericSchema`. 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. ✨
|
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
|
## Usage Example
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from marshmallow_generic import GenericSchema, fields
|
from marshmallow import fields
|
||||||
|
from marshmallow_generic import GenericSchema
|
||||||
|
|
||||||
|
|
||||||
class User:
|
class User:
|
||||||
@ -70,7 +71,7 @@ This also means your IDE will be able to infer the types and thus provide useful
|
|||||||
|
|
||||||
Here is PyCharm with the example from above:
|
Here is PyCharm with the example from above:
|
||||||
|
|
||||||

|
{ width="540" }
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
@ -30,11 +30,8 @@ plugins:
|
|||||||
handlers:
|
handlers:
|
||||||
python:
|
python:
|
||||||
options:
|
options:
|
||||||
line_length: 80
|
|
||||||
show_source: false
|
show_source: false
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
separate_signature: true
|
|
||||||
show_signature_annotations: true
|
|
||||||
import:
|
import:
|
||||||
- https://marshmallow.readthedocs.io/en/stable/objects.inv
|
- https://marshmallow.readthedocs.io/en/stable/objects.inv
|
||||||
|
|
||||||
|
@ -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",
|
||||||
@ -37,32 +37,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[toml]",
|
||||||
"isort==5.12.0",
|
"isort",
|
||||||
"mkdocs-material==9.1.6",
|
"mkdocs-material",
|
||||||
"mkdocstrings[python]==0.21.2",
|
"mkdocstrings[python]",
|
||||||
"mypy==1.2.0",
|
"mypy",
|
||||||
"ruff==0.0.262",
|
"ruff",
|
||||||
]
|
]
|
||||||
|
|
||||||
[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: #
|
# Static type checking: #
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
cache_dir = ".cache/mypy"
|
|
||||||
files = [
|
files = [
|
||||||
"src/",
|
"src/",
|
||||||
"tests/",
|
"tests/",
|
||||||
@ -78,7 +80,6 @@ plugins = [
|
|||||||
# Unit test coverage: #
|
# Unit test coverage: #
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
data_file = ".cache/coverage"
|
|
||||||
source = [
|
source = [
|
||||||
"src/",
|
"src/",
|
||||||
]
|
]
|
||||||
@ -104,7 +105,6 @@ omit = [
|
|||||||
# Linting and style checking: #
|
# Linting and style checking: #
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
cache-dir = ".cache/ruff"
|
|
||||||
select = [
|
select = [
|
||||||
"E", # pycodestyle errors
|
"E", # pycodestyle errors
|
||||||
"W", # pycodestyle warnings
|
"W", # pycodestyle warnings
|
||||||
|
@ -1 +1 @@
|
|||||||
marshmallow>=3.12.0
|
marshmallow
|
@ -1,9 +1,9 @@
|
|||||||
-r common.txt
|
-r common.txt
|
||||||
black==23.3.0
|
black
|
||||||
build==0.10.0
|
build
|
||||||
coverage[toml]==7.2.3
|
coverage[toml]
|
||||||
isort==5.12.0
|
isort
|
||||||
mkdocs-material==9.1.6
|
mkdocs-material
|
||||||
mkdocstrings[python]==0.21.2
|
mkdocstrings[python]
|
||||||
mypy==1.2.0
|
mypy
|
||||||
ruff==0.0.262
|
ruff
|
||||||
|
@ -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}"
|
|
@ -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}'
|
|
@ -1,17 +1,18 @@
|
|||||||
#!/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...'
|
||||||
|
|
||||||
echo ' isort - consistent imports'
|
|
||||||
isort src/ tests/ --check-only
|
isort src/ tests/ --check-only
|
||||||
|
|
||||||
echo ' ruff - extensive linting'
|
|
||||||
ruff src/ tests/
|
ruff src/ tests/
|
||||||
|
black src/ tests/ --check
|
||||||
echo ' black - consistent style'
|
echo -e 'No issues found.'
|
||||||
run_and_capture black src/ tests/ --check
|
|
||||||
|
|
||||||
echo -e "${bold_green}No issues found${color_reset}\n"
|
|
||||||
|
@ -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}'
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Runs type checker.
|
|
||||||
|
|
||||||
source "$(dirname $(realpath $0))/util.sh"
|
|
||||||
|
|
||||||
echo 'Performing type checks...'
|
|
||||||
mypy
|
|
||||||
echo
|
|
@ -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'
|
|
@ -13,45 +13,11 @@ 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.
|
Generic schema with full typing support and minimal boilerplate.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__all__ = [
|
from .decorators import post_load
|
||||||
# Custom:
|
from .schema import GenericSchema
|
||||||
"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
|
|
||||||
|
@ -1,28 +1,10 @@
|
|||||||
from typing import (
|
from typing import Any, Generic, Optional, TypeVar, get_args, get_origin
|
||||||
Any,
|
|
||||||
Generic,
|
|
||||||
Literal,
|
|
||||||
Optional,
|
|
||||||
TypeVar,
|
|
||||||
Union,
|
|
||||||
get_args,
|
|
||||||
get_origin,
|
|
||||||
overload,
|
|
||||||
)
|
|
||||||
|
|
||||||
_T0 = TypeVar("_T0")
|
_T = TypeVar("_T")
|
||||||
_T1 = TypeVar("_T1")
|
|
||||||
_T2 = TypeVar("_T2")
|
|
||||||
_T3 = TypeVar("_T3")
|
|
||||||
_T4 = TypeVar("_T4")
|
|
||||||
|
|
||||||
|
|
||||||
class GenericInsightMixin(Generic[_T0, _T1, _T2, _T3, _T4]):
|
class GenericInsightMixin(Generic[_T]):
|
||||||
_type_arg_0: Optional[type[_T0]] = None
|
_type_arg: Optional[type[_T]] = 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
|
@classmethod
|
||||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||||
@ -32,70 +14,17 @@ class GenericInsightMixin(Generic[_T0, _T1, _T2, _T3, _T4]):
|
|||||||
origin = get_origin(base)
|
origin = get_origin(base)
|
||||||
if origin is None or not issubclass(origin, GenericInsightMixin):
|
if origin is None or not issubclass(origin, GenericInsightMixin):
|
||||||
continue
|
continue
|
||||||
type_args = get_args(base)
|
type_arg = get_args(base)[0]
|
||||||
for idx, arg in enumerate(type_args):
|
# Do not set the attribute for GENERIC subclasses!
|
||||||
# Do not set the attribute for generics:
|
if not isinstance(type_arg, TypeVar):
|
||||||
if isinstance(arg, TypeVar):
|
cls._type_arg = type_arg
|
||||||
continue
|
|
||||||
# Do not set `NoneType`:
|
|
||||||
if isinstance(arg, type) and isinstance(None, arg):
|
|
||||||
continue
|
|
||||||
setattr(cls, f"_type_arg_{idx}", arg)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@overload
|
def _get_type_arg(cls) -> type[_T]:
|
||||||
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)."""
|
"""Returns the type argument of the class (if specified)."""
|
||||||
if idx == 0:
|
if cls._type_arg is None:
|
||||||
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(
|
raise AttributeError(
|
||||||
f"{cls.__name__} is generic; type argument {idx} unspecified"
|
f"{cls.__name__} is generic; type argument unspecified"
|
||||||
)
|
)
|
||||||
return type_
|
return cls._type_arg
|
||||||
|
|
||||||
|
|
||||||
class GenericInsightMixin1(GenericInsightMixin[_T0, None, None, None, None]):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class GenericInsightMixin2(GenericInsightMixin[_T0, _T1, None, None, None]):
|
|
||||||
pass
|
|
||||||
|
@ -7,22 +7,16 @@ documentation of [`marshmallow.Schema`][marshmallow.Schema].
|
|||||||
|
|
||||||
from collections.abc import Iterable, Mapping, Sequence
|
from collections.abc import Iterable, Mapping, Sequence
|
||||||
from typing import TYPE_CHECKING, Any, Literal, Optional, TypeVar, Union, overload
|
from typing import TYPE_CHECKING, Any, Literal, Optional, TypeVar, Union, overload
|
||||||
from warnings import warn
|
|
||||||
|
|
||||||
from marshmallow import Schema
|
from marshmallow import Schema
|
||||||
|
|
||||||
from ._util import GenericInsightMixin1
|
from ._util import GenericInsightMixin
|
||||||
from .decorators import post_load
|
from .decorators import post_load
|
||||||
|
|
||||||
Model = TypeVar("Model")
|
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[Model], Schema):
|
||||||
class GenericSchema(GenericInsightMixin1[Model], Schema):
|
|
||||||
"""
|
"""
|
||||||
Generic schema parameterized by a **`Model`** class.
|
Generic schema parameterized by a **`Model`** class.
|
||||||
|
|
||||||
@ -49,99 +43,19 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
|
|||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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
|
@post_load
|
||||||
def instantiate(self, data: dict[str, Any], **_kwargs: Any) -> Model:
|
def instantiate(self, data: dict[str, Any], **_kwargs: Any) -> Model:
|
||||||
"""
|
"""
|
||||||
Unpacks `data` into the constructor of the specified **`Model`**.
|
Unpacks `data` into the constructor of the specified **`Model`**.
|
||||||
|
|
||||||
Registered as a
|
Registered as a [`@post_load`]
|
||||||
[`@post_load`][marshmallow_generic.decorators.post_load]
|
[marshmallow_generic.decorators.post_load] hook for the schema.
|
||||||
hook for the schema.
|
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
You should probably not use this method directly. No parsing,
|
You should probably **not** use this method directly;
|
||||||
transformation or validation of any kind is done in this method.
|
no parsing, transformation or validation of any kind is done
|
||||||
The `data` is passed to the **`Model`** constructor "as is".
|
in this method. The `data` passed to the **`Model`** constructor
|
||||||
|
"as is".
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data:
|
data:
|
||||||
@ -151,90 +65,10 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
|
|||||||
Returns:
|
Returns:
|
||||||
Instance of the schema's **`Model`** initialized with `**data`
|
Instance of the schema's **`Model`** initialized with `**data`
|
||||||
"""
|
"""
|
||||||
return self._get_type_arg(0)(**data)
|
return self._get_type_arg()(**data)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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]
|
@overload # type: ignore[override]
|
||||||
def load(
|
def load(
|
||||||
self,
|
self,
|
||||||
@ -268,10 +102,10 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
|
|||||||
"""
|
"""
|
||||||
Deserializes data to objects of the specified **`Model`** class.
|
Deserializes data to objects of the specified **`Model`** class.
|
||||||
|
|
||||||
Same as
|
Same as [`marshmallow.Schema.load`]
|
||||||
[`marshmallow.Schema.load`][marshmallow.schema.Schema.load] at
|
[marshmallow.schema.Schema.load] at runtime, but data will always
|
||||||
runtime, but data will always pass through the
|
pass through the [`instantiate`]
|
||||||
[`instantiate`][marshmallow_generic.schema.GenericSchema.instantiate]
|
[marshmallow_generic.schema.GenericSchema.instantiate]
|
||||||
hook after deserialization.
|
hook after deserialization.
|
||||||
|
|
||||||
Annotations ensure that type checkers will infer the return type
|
Annotations ensure that type checkers will infer the return type
|
||||||
@ -285,11 +119,11 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
|
|||||||
the value for `self.many` is used.
|
the value for `self.many` is used.
|
||||||
partial:
|
partial:
|
||||||
Whether to ignore missing fields and not require any
|
Whether to ignore missing fields and not require any
|
||||||
fields declared. Propagates down to
|
fields declared. Propagates down to [`Nested`]
|
||||||
[`Nested`][marshmallow.fields.Nested] fields as well. If
|
[marshmallow.fields.Nested] fields as well. If its value
|
||||||
its value is an iterable, only missing fields listed in
|
is an iterable, only missing fields listed in that
|
||||||
that iterable will be ignored. Use dot delimiters to
|
iterable will be ignored. Use dot delimiters to specify
|
||||||
specify nested fields.
|
nested fields.
|
||||||
unknown:
|
unknown:
|
||||||
Whether to exclude, include, or raise an error for unknown
|
Whether to exclude, include, or raise an error for unknown
|
||||||
fields in the data. Use `EXCLUDE`, `INCLUDE` or `RAISE`.
|
fields in the data. Use `EXCLUDE`, `INCLUDE` or `RAISE`.
|
||||||
@ -337,10 +171,10 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
|
|||||||
"""
|
"""
|
||||||
Deserializes data to objects of the specified **`Model`** class.
|
Deserializes data to objects of the specified **`Model`** class.
|
||||||
|
|
||||||
Same as
|
Same as [`marshmallow.Schema.loads`]
|
||||||
[`marshmallow.Schema.loads`][marshmallow.schema.Schema.loads] at
|
[marshmallow.schema.Schema.loads] at runtime, but data will always
|
||||||
runtime, but data will always pass through the
|
pass through the [`instantiate`]
|
||||||
[`instantiate`][marshmallow_generic.schema.GenericSchema.instantiate]
|
[marshmallow_generic.schema.GenericSchema.instantiate]
|
||||||
hook after deserialization.
|
hook after deserialization.
|
||||||
|
|
||||||
Annotations ensure that type checkers will infer the return type
|
Annotations ensure that type checkers will infer the return type
|
||||||
@ -354,11 +188,11 @@ class GenericSchema(GenericInsightMixin1[Model], Schema):
|
|||||||
the value for `self.many` is used.
|
the value for `self.many` is used.
|
||||||
partial:
|
partial:
|
||||||
Whether to ignore missing fields and not require any
|
Whether to ignore missing fields and not require any
|
||||||
fields declared. Propagates down to
|
fields declared. Propagates down to [`Nested`]
|
||||||
[`Nested`][marshmallow.fields.Nested] fields as well. If
|
[marshmallow.fields.Nested] fields as well. If its value
|
||||||
its value is an iterable, only missing fields listed in
|
is an iterable, only missing fields listed in that
|
||||||
that iterable will be ignored. Use dot delimiters to
|
iterable will be ignored. Use dot delimiters to specify
|
||||||
specify nested fields.
|
nested fields.
|
||||||
unknown:
|
unknown:
|
||||||
Whether to exclude, include, or raise an error for unknown
|
Whether to exclude, include, or raise an error for unknown
|
||||||
fields in the data. Use `EXCLUDE`, `INCLUDE` or `RAISE`.
|
fields in the data. Use `EXCLUDE`, `INCLUDE` or `RAISE`.
|
||||||
|
@ -12,11 +12,7 @@ class GenericInsightMixinTestCase(TestCase):
|
|||||||
mock_super.return_value = MagicMock(__init_subclass__=mock_super_meth)
|
mock_super.return_value = MagicMock(__init_subclass__=mock_super_meth)
|
||||||
|
|
||||||
# Should be `None` by default:
|
# Should be `None` by default:
|
||||||
self.assertIsNone(_util.GenericInsightMixin._type_arg_0) # type: ignore[misc]
|
self.assertIsNone(_util.GenericInsightMixin._type_arg) # 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),
|
# If the mixin type argument was not specified (still generic),
|
||||||
# ensure that the attribute remains `None` on the subclass:
|
# ensure that the attribute remains `None` on the subclass:
|
||||||
@ -28,55 +24,30 @@ class GenericInsightMixinTestCase(TestCase):
|
|||||||
class Bar(Generic[t]):
|
class Bar(Generic[t]):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class TestCls(Bar[str], _util.GenericInsightMixin[t, None, int, str, bool]):
|
class TestSchema1(Bar[str], _util.GenericInsightMixin[t]):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self.assertIsNone(TestCls._type_arg_0) # type: ignore[misc]
|
self.assertIsNone(TestSchema1._type_arg) # 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.assert_called_once()
|
||||||
mock_super_meth.assert_called_once_with()
|
mock_super_meth.assert_called_once_with()
|
||||||
|
|
||||||
mock_super.reset_mock()
|
mock_super.reset_mock()
|
||||||
mock_super_meth.reset_mock()
|
mock_super_meth.reset_mock()
|
||||||
|
|
||||||
# If the mixin type arguments were omitted,
|
# If the mixin type argument was specified,
|
||||||
# ensure the attributes remained `None`:
|
# ensure it was assigned to the attribute on the child class:
|
||||||
|
|
||||||
class UnspecifiedCls(_util.GenericInsightMixin): # type: ignore[type-arg]
|
class TestSchema2(Bar[str], _util.GenericInsightMixin[Foo]):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self.assertIsNone(UnspecifiedCls._type_arg_0) # type: ignore[misc]
|
self.assertIs(Foo, TestSchema2._type_arg) # 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.assert_called_once()
|
||||||
mock_super_meth.assert_called_once_with()
|
mock_super_meth.assert_called_once_with()
|
||||||
|
|
||||||
def test__get_type_arg(self) -> None:
|
def test__get_type_arg(self) -> None:
|
||||||
with self.assertRaises(AttributeError):
|
with self.assertRaises(AttributeError):
|
||||||
_util.GenericInsightMixin._get_type_arg(0)
|
_util.GenericInsightMixin._get_type_arg()
|
||||||
|
|
||||||
_type_0 = object()
|
_type = object()
|
||||||
_type_1 = object()
|
with patch.object(_util.GenericInsightMixin, "_type_arg", new=_type):
|
||||||
_type_2 = object()
|
self.assertIs(_type, _util.GenericInsightMixin._get_type_arg())
|
||||||
_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]
|
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
from typing import Any
|
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
@ -6,33 +5,6 @@ from marshmallow_generic import _util, schema
|
|||||||
|
|
||||||
|
|
||||||
class GenericSchemaTestCase(TestCase):
|
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")
|
@patch.object(_util.GenericInsightMixin, "_get_type_arg")
|
||||||
def test_instantiate(self, mock__get_type_arg: MagicMock) -> None:
|
def test_instantiate(self, mock__get_type_arg: MagicMock) -> None:
|
||||||
mock__get_type_arg.return_value = mock_cls = MagicMock()
|
mock__get_type_arg.return_value = mock_cls = MagicMock()
|
||||||
@ -45,29 +17,9 @@ class GenericSchemaTestCase(TestCase):
|
|||||||
# Explicit annotation to possibly catch mypy errors:
|
# Explicit annotation to possibly catch mypy errors:
|
||||||
output: Foo = schema_obj.instantiate(mock_data)
|
output: Foo = schema_obj.instantiate(mock_data)
|
||||||
self.assertIs(mock_cls.return_value, output)
|
self.assertIs(mock_cls.return_value, output)
|
||||||
mock__get_type_arg.assert_called_once_with(0)
|
mock__get_type_arg.assert_called_once_with()
|
||||||
mock_cls.assert_called_once_with(**mock_data)
|
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:
|
def test_load_and_loads(self) -> None:
|
||||||
"""Mainly for static type checking purposes."""
|
"""Mainly for static type checking purposes."""
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user