sqlalchemy-declarative-filters 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqlalchemy_declarative_filters/__init__.py +41 -0
- sqlalchemy_declarative_filters/__init__.pyi +112 -0
- sqlalchemy_declarative_filters/_backends/__init__.py +59 -0
- sqlalchemy_declarative_filters/_backends/base.py +56 -0
- sqlalchemy_declarative_filters/_backends/dataclass.py +81 -0
- sqlalchemy_declarative_filters/_backends/marshmallow.py +128 -0
- sqlalchemy_declarative_filters/_backends/pydantic.py +64 -0
- sqlalchemy_declarative_filters/_decorators.py +55 -0
- sqlalchemy_declarative_filters/_exceptions.py +43 -0
- sqlalchemy_declarative_filters/_joins.py +313 -0
- sqlalchemy_declarative_filters/_meta.py +235 -0
- sqlalchemy_declarative_filters/_spec.py +182 -0
- sqlalchemy_declarative_filters/marshmallow.py +50 -0
- sqlalchemy_declarative_filters/marshmallow.pyi +46 -0
- sqlalchemy_declarative_filters/py.typed +0 -0
- sqlalchemy_declarative_filters/pydantic.py +44 -0
- sqlalchemy_declarative_filters/pydantic.pyi +60 -0
- sqlalchemy_declarative_filters-0.1.0.dist-info/METADATA +406 -0
- sqlalchemy_declarative_filters-0.1.0.dist-info/RECORD +21 -0
- sqlalchemy_declarative_filters-0.1.0.dist-info/WHEEL +4 -0
- sqlalchemy_declarative_filters-0.1.0.dist-info/licenses/LICENSE.md +21 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Declarative SQLAlchemy filters with auto-generated schemas.
|
|
2
|
+
|
|
3
|
+
Import ``Filters`` and the decorators from this package for the dependency-free
|
|
4
|
+
dataclass backend, or from ``.pydantic`` / ``.marshmallow`` for those. The names are
|
|
5
|
+
the same in all three, so switching a project over is a one-line change::
|
|
6
|
+
|
|
7
|
+
from sqlalchemy_declarative_filters.pydantic import Filters, options, skip_null
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from ._decorators import options, skip_null
|
|
13
|
+
from ._exceptions import (
|
|
14
|
+
BackendNotAvailableError,
|
|
15
|
+
FilterDeclarationError,
|
|
16
|
+
FilterError,
|
|
17
|
+
JoinConflictWarning,
|
|
18
|
+
UnknownFilterError,
|
|
19
|
+
)
|
|
20
|
+
from ._joins import Statement
|
|
21
|
+
from ._meta import Filters, FiltersMeta
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
#: Explicit alias, for when more than one backend's base is in the same module.
|
|
26
|
+
DataclassFilters = Filters
|
|
27
|
+
|
|
28
|
+
__all__ = (
|
|
29
|
+
"BackendNotAvailableError",
|
|
30
|
+
"DataclassFilters",
|
|
31
|
+
"FilterDeclarationError",
|
|
32
|
+
"FilterError",
|
|
33
|
+
"Filters",
|
|
34
|
+
"FiltersMeta",
|
|
35
|
+
"JoinConflictWarning",
|
|
36
|
+
"Statement",
|
|
37
|
+
"UnknownFilterError",
|
|
38
|
+
"__version__",
|
|
39
|
+
"options",
|
|
40
|
+
"skip_null",
|
|
41
|
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Public typing surface for the dataclass namespace.
|
|
2
|
+
|
|
3
|
+
The implementation modules stay plain, annotated Python and are type-checked as such;
|
|
4
|
+
only the three namespace modules carry stubs, because their signatures are the part
|
|
5
|
+
that differs per backend and reads badly inline.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable, Mapping
|
|
9
|
+
from typing import Any, TypeVar
|
|
10
|
+
|
|
11
|
+
from sqlalchemy.sql._typing import (
|
|
12
|
+
_ColumnExpressionArgument,
|
|
13
|
+
_JoinTargetArgument,
|
|
14
|
+
_OnClauseArgument,
|
|
15
|
+
)
|
|
16
|
+
from sqlalchemy.sql.selectable import Select
|
|
17
|
+
|
|
18
|
+
from ._spec import FilterSpec as FilterSpec
|
|
19
|
+
|
|
20
|
+
__version__: str
|
|
21
|
+
|
|
22
|
+
_FuncT = TypeVar("_FuncT", bound=Callable[..., Any])
|
|
23
|
+
_StatementT = TypeVar("_StatementT")
|
|
24
|
+
|
|
25
|
+
class FilterError(Exception): ...
|
|
26
|
+
class FilterDeclarationError(FilterError, TypeError): ...
|
|
27
|
+
class UnknownFilterError(FilterError, KeyError): ...
|
|
28
|
+
class BackendNotAvailableError(FilterError, ImportError): ...
|
|
29
|
+
class JoinConflictWarning(UserWarning): ...
|
|
30
|
+
|
|
31
|
+
class FiltersMeta(type):
|
|
32
|
+
#: The generated schema, in whichever backend ``__backend__`` names.
|
|
33
|
+
@property
|
|
34
|
+
def Schema(cls) -> type[Any]: ...
|
|
35
|
+
#: Alias of :attr:`Schema`.
|
|
36
|
+
@property
|
|
37
|
+
def Model(cls) -> type[Any]: ...
|
|
38
|
+
@property
|
|
39
|
+
def Dataclass(cls) -> type[Any]: ...
|
|
40
|
+
@property
|
|
41
|
+
def Pydantic(cls) -> type[Any]: ...
|
|
42
|
+
@property
|
|
43
|
+
def Marshmallow(cls) -> type[Any]: ...
|
|
44
|
+
@property
|
|
45
|
+
def __filters__(cls) -> tuple[FilterSpec, ...]: ...
|
|
46
|
+
def build_schema(cls, backend: str | None = ...) -> type[Any]: ...
|
|
47
|
+
def apply(
|
|
48
|
+
cls,
|
|
49
|
+
statement: _StatementT,
|
|
50
|
+
values: Mapping[str, Any] | Any | None = ...,
|
|
51
|
+
) -> _StatementT: ...
|
|
52
|
+
|
|
53
|
+
class Statement:
|
|
54
|
+
"""The statement being built, as a filter body sees it.
|
|
55
|
+
|
|
56
|
+
``self`` inside a filter is one of these, and so is what a filter returns.
|
|
57
|
+
Everything a SQLAlchemy ``Select`` offers is forwarded. Only the handful of methods
|
|
58
|
+
a filter body genuinely needs are spelled out, because every name declared here is
|
|
59
|
+
a name a filter cannot have; ``__getattr__`` covers ``order_by``, ``distinct`` and
|
|
60
|
+
the rest of the statement's surface.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def where(self, *whereclause: _ColumnExpressionArgument[bool]) -> Statement: ...
|
|
64
|
+
def having(self, *having: _ColumnExpressionArgument[bool]) -> Statement: ...
|
|
65
|
+
def join(
|
|
66
|
+
self,
|
|
67
|
+
target: _JoinTargetArgument,
|
|
68
|
+
onclause: _OnClauseArgument | None = ...,
|
|
69
|
+
*,
|
|
70
|
+
isouter: bool = ...,
|
|
71
|
+
full: bool = ...,
|
|
72
|
+
) -> Statement:
|
|
73
|
+
"""Join ``target`` unless it is already joined.
|
|
74
|
+
|
|
75
|
+
Deduplicated against the joins the other filters in the same ``apply`` asked
|
|
76
|
+
for and against the ones the incoming statement already carried.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def outerjoin(
|
|
80
|
+
self,
|
|
81
|
+
target: _JoinTargetArgument,
|
|
82
|
+
onclause: _OnClauseArgument | None = ...,
|
|
83
|
+
*,
|
|
84
|
+
full: bool = ...,
|
|
85
|
+
) -> Statement: ...
|
|
86
|
+
#: The underlying SQLAlchemy statement.
|
|
87
|
+
def unwrap(self) -> Select[Any]: ...
|
|
88
|
+
def __getattr__(self, name: str) -> Any: ...
|
|
89
|
+
|
|
90
|
+
class Filters(Statement, metaclass=FiltersMeta):
|
|
91
|
+
#: Which backend ``Schema`` uses; set by the base class you inherit from.
|
|
92
|
+
__backend__: str
|
|
93
|
+
#: Strings a query parameter may use to mean ``null`` on a ``@skip_null`` filter.
|
|
94
|
+
__null_strings__: frozenset[str]
|
|
95
|
+
#: Overrides the generated schema's class name.
|
|
96
|
+
__schema_name__: str
|
|
97
|
+
|
|
98
|
+
DataclassFilters = Filters
|
|
99
|
+
|
|
100
|
+
def options(
|
|
101
|
+
*,
|
|
102
|
+
default_factory: Callable[[], Any] = ...,
|
|
103
|
+
init: bool = ...,
|
|
104
|
+
repr: bool = ...,
|
|
105
|
+
hash: bool | None = ...,
|
|
106
|
+
compare: bool = ...,
|
|
107
|
+
metadata: Mapping[str, Any] = ...,
|
|
108
|
+
kw_only: bool = ...,
|
|
109
|
+
) -> Callable[[_FuncT], _FuncT]:
|
|
110
|
+
"""Keywords for :func:`dataclasses.field`, which is what backs this namespace."""
|
|
111
|
+
|
|
112
|
+
def skip_null(func: _FuncT) -> _FuncT: ...
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Schema backend registry.
|
|
2
|
+
|
|
3
|
+
Backends are imported the first time they are asked for, so importing this package
|
|
4
|
+
never pulls in Pydantic or Marshmallow.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from importlib import import_module
|
|
10
|
+
from typing import TYPE_CHECKING, cast
|
|
11
|
+
|
|
12
|
+
from .._exceptions import BackendNotAvailableError
|
|
13
|
+
from .base import SchemaBackend, SchemaRequest
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
|
|
18
|
+
__all__ = ("SchemaBackend", "SchemaRequest", "backend_names", "get_backend")
|
|
19
|
+
|
|
20
|
+
#: name -> (module, class, extra to install)
|
|
21
|
+
_BACKENDS: dict[str, tuple[str, str, str | None]] = {
|
|
22
|
+
"dataclass": (".dataclass", "DataclassBackend", None),
|
|
23
|
+
"pydantic": (".pydantic", "PydanticBackend", "pydantic"),
|
|
24
|
+
"marshmallow": (".marshmallow", "MarshmallowBackend", "marshmallow"),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_LOADED: dict[str, SchemaBackend] = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def backend_names() -> Iterator[str]:
|
|
31
|
+
"""The names :func:`get_backend` accepts."""
|
|
32
|
+
|
|
33
|
+
return iter(_BACKENDS)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_backend(name: str) -> SchemaBackend:
|
|
37
|
+
"""Return the backend called ``name``, importing it on first use."""
|
|
38
|
+
|
|
39
|
+
if (backend := _LOADED.get(name)) is not None:
|
|
40
|
+
return backend
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
module_name, class_name, extra = _BACKENDS[name]
|
|
44
|
+
except KeyError:
|
|
45
|
+
known = ", ".join(sorted(_BACKENDS))
|
|
46
|
+
raise ValueError(f"Unknown schema backend {name!r}; expected one of {known}.") from None
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
module = import_module(module_name, __name__)
|
|
50
|
+
except ImportError as exc:
|
|
51
|
+
raise BackendNotAvailableError(
|
|
52
|
+
f"The {name!r} schema backend needs a dependency that is not installed: "
|
|
53
|
+
f"{exc}. Install it with "
|
|
54
|
+
f"`pip install sqlalchemy-declarative-filters[{extra}]`."
|
|
55
|
+
) from exc
|
|
56
|
+
|
|
57
|
+
backend = _LOADED[name] = cast("SchemaBackend", getattr(module, class_name)())
|
|
58
|
+
|
|
59
|
+
return backend
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The contract every schema backend implements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .._spec import FilterSpec
|
|
11
|
+
|
|
12
|
+
__all__ = ("SchemaBackend", "SchemaRequest")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class SchemaRequest:
|
|
17
|
+
"""Everything a backend needs to render one filter class as a schema."""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
doc: str | None
|
|
21
|
+
module: str
|
|
22
|
+
specs: tuple[FilterSpec, ...]
|
|
23
|
+
null_strings: frozenset[str]
|
|
24
|
+
|
|
25
|
+
def is_null_string(self, value: Any) -> bool:
|
|
26
|
+
"""Whether ``value`` is one of the strings that stand in for ``null``."""
|
|
27
|
+
|
|
28
|
+
return isinstance(value, str) and value.strip().lower() in self.null_strings
|
|
29
|
+
|
|
30
|
+
def coerce(self, value: Any) -> Any:
|
|
31
|
+
"""Map a null-like string to ``None`` for filters that opted into it."""
|
|
32
|
+
|
|
33
|
+
return None if self.is_null_string(value) else value
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def nullable_names(self) -> tuple[str, ...]:
|
|
37
|
+
"""Names of the filters whose null-like strings should become ``None``."""
|
|
38
|
+
|
|
39
|
+
return tuple(spec.name for spec in self.specs if spec.skip_null)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SchemaBackend(ABC):
|
|
43
|
+
"""Renders :class:`~.._spec.FilterSpec` objects into a schema class."""
|
|
44
|
+
|
|
45
|
+
#: The name this backend is selected by.
|
|
46
|
+
name: ClassVar[str]
|
|
47
|
+
|
|
48
|
+
#: The extra to install to get it, or ``None`` when it needs no dependency.
|
|
49
|
+
extra: ClassVar[str | None] = None
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def build(self, request: SchemaRequest) -> type[Any]:
|
|
53
|
+
"""Build and return the schema class for ``request``."""
|
|
54
|
+
|
|
55
|
+
def __repr__(self) -> str:
|
|
56
|
+
return f"<{type(self).__name__} {self.name!r}>"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""The default backend: a plain :mod:`dataclasses` dataclass, no dependencies.
|
|
2
|
+
|
|
3
|
+
It is a typed container, not a validator. Nothing checks that the values you put in
|
|
4
|
+
match their annotations, and ``@options`` here takes ``dataclasses.field`` keywords
|
|
5
|
+
rather than constraints. Use the Pydantic or Marshmallow backend when the values come
|
|
6
|
+
from outside your own code and need validating.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import dataclasses
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from .base import SchemaBackend
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
|
|
19
|
+
from .base import SchemaRequest
|
|
20
|
+
|
|
21
|
+
__all__ = ("DataclassBackend",)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DataclassBackend(SchemaBackend):
|
|
25
|
+
"""Renders filters as a keyword-only dataclass."""
|
|
26
|
+
|
|
27
|
+
name = "dataclass"
|
|
28
|
+
|
|
29
|
+
def build(self, request: SchemaRequest) -> type[Any]:
|
|
30
|
+
annotations: dict[str, Any] = {}
|
|
31
|
+
namespace: dict[str, Any] = {
|
|
32
|
+
"__annotations__": annotations,
|
|
33
|
+
"__doc__": request.doc,
|
|
34
|
+
"__module__": request.module,
|
|
35
|
+
"__filters_request__": request,
|
|
36
|
+
"from_mapping": classmethod(_from_mapping),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for spec in request.specs:
|
|
40
|
+
annotations[spec.name] = spec.schema_annotation
|
|
41
|
+
namespace[spec.name] = _field_for(spec)
|
|
42
|
+
|
|
43
|
+
# kw_only keeps declaration order regardless of which filters have defaults.
|
|
44
|
+
return dataclasses.dataclass(kw_only=True)(type(request.name, (), namespace))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _field_for(spec: Any) -> Any:
|
|
48
|
+
"""Build the ``dataclasses.field`` for one filter."""
|
|
49
|
+
|
|
50
|
+
options = dict(spec.options)
|
|
51
|
+
metadata = {"description": spec.doc, **options.pop("metadata", {})}
|
|
52
|
+
|
|
53
|
+
if "default_factory" in options:
|
|
54
|
+
# A mutable default has to come from a factory; it replaces the plain default.
|
|
55
|
+
return dataclasses.field(metadata=metadata, **options)
|
|
56
|
+
|
|
57
|
+
return dataclasses.field(default=spec.schema_default, metadata=metadata, **options)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _from_mapping(cls: type[Any], data: Mapping[str, Any]) -> Any:
|
|
61
|
+
"""Build an instance from a mapping, applying null-string coercion.
|
|
62
|
+
|
|
63
|
+
This is what the other backends get from their validation layer. It exists so a
|
|
64
|
+
dataclass schema can still be fed raw query parameters.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
request: SchemaRequest = cls.__filters_request__
|
|
68
|
+
nullable = frozenset(request.nullable_names)
|
|
69
|
+
known = {field.name for field in dataclasses.fields(cls)}
|
|
70
|
+
unknown = set(data) - known
|
|
71
|
+
|
|
72
|
+
if unknown:
|
|
73
|
+
names = ", ".join(sorted(unknown))
|
|
74
|
+
raise TypeError(f"{cls.__name__} has no filter(s) named {names}.")
|
|
75
|
+
|
|
76
|
+
return cls(
|
|
77
|
+
**{
|
|
78
|
+
name: request.coerce(value) if name in nullable else value
|
|
79
|
+
for name, value in data.items()
|
|
80
|
+
}
|
|
81
|
+
)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""The Marshmallow backend: a ``Schema`` subclass whose ``load()`` returns a dict.
|
|
2
|
+
|
|
3
|
+
Marshmallow has no single field class, so annotations are mapped onto concrete field
|
|
4
|
+
types here. ``@options`` keywords go to that field's constructor -- ``validate``,
|
|
5
|
+
``data_key``, ``required`` and the rest -- and constraints are expressed the
|
|
6
|
+
Marshmallow way, with validators::
|
|
7
|
+
|
|
8
|
+
@options(validate=validate.Length(min=3))
|
|
9
|
+
def name(self, value: str): ...
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import datetime
|
|
15
|
+
import decimal
|
|
16
|
+
import enum
|
|
17
|
+
import uuid
|
|
18
|
+
from typing import TYPE_CHECKING, Any, Literal, get_args, get_origin
|
|
19
|
+
|
|
20
|
+
from marshmallow import EXCLUDE, Schema, fields, pre_load, validate
|
|
21
|
+
|
|
22
|
+
from .._spec import unwrap_optional
|
|
23
|
+
from .base import SchemaBackend
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
|
|
28
|
+
from .._spec import FilterSpec
|
|
29
|
+
from .base import SchemaRequest
|
|
30
|
+
|
|
31
|
+
__all__ = ("MarshmallowBackend",)
|
|
32
|
+
|
|
33
|
+
_SCALARS: dict[Any, type[fields.Field[Any]]] = {
|
|
34
|
+
str: fields.String,
|
|
35
|
+
int: fields.Integer,
|
|
36
|
+
float: fields.Float,
|
|
37
|
+
bool: fields.Boolean,
|
|
38
|
+
decimal.Decimal: fields.Decimal,
|
|
39
|
+
datetime.datetime: fields.DateTime,
|
|
40
|
+
datetime.date: fields.Date,
|
|
41
|
+
datetime.time: fields.Time,
|
|
42
|
+
datetime.timedelta: fields.TimeDelta,
|
|
43
|
+
uuid.UUID: fields.UUID,
|
|
44
|
+
dict: fields.Dict,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_SEQUENCES = (list, set, frozenset, tuple)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MarshmallowBackend(SchemaBackend):
|
|
51
|
+
"""Renders filters as a Marshmallow schema."""
|
|
52
|
+
|
|
53
|
+
name = "marshmallow"
|
|
54
|
+
extra = "marshmallow"
|
|
55
|
+
|
|
56
|
+
def build(self, request: SchemaRequest) -> type[Schema]:
|
|
57
|
+
namespace: dict[str, Any] = {
|
|
58
|
+
"__doc__": request.doc,
|
|
59
|
+
"__module__": request.module,
|
|
60
|
+
# Filters are optional by nature; an unknown one is a caller mistake.
|
|
61
|
+
"Meta": type("Meta", (), {"unknown": EXCLUDE}),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for spec in request.specs:
|
|
65
|
+
namespace[spec.name] = _field_for(spec)
|
|
66
|
+
|
|
67
|
+
if request.nullable_names:
|
|
68
|
+
namespace["_coerce_null_strings"] = pre_load(_coercer(request))
|
|
69
|
+
|
|
70
|
+
return type(request.name, (Schema,), namespace)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _field_for(spec: FilterSpec) -> fields.Field[Any]:
|
|
74
|
+
"""Build the Marshmallow field for one filter."""
|
|
75
|
+
|
|
76
|
+
options: dict[str, Any] = {
|
|
77
|
+
"allow_none": spec.optional,
|
|
78
|
+
"load_default": spec.schema_default,
|
|
79
|
+
"metadata": {"description": spec.doc},
|
|
80
|
+
**spec.options,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return _field_for_type(spec.schema_annotation, options)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _field_for_type(annotation: Any, options: dict[str, Any]) -> fields.Field[Any]:
|
|
87
|
+
"""Map a Python annotation onto a concrete Marshmallow field."""
|
|
88
|
+
|
|
89
|
+
annotation, _ = unwrap_optional(annotation)
|
|
90
|
+
|
|
91
|
+
if (scalar := _SCALARS.get(annotation)) is not None:
|
|
92
|
+
return scalar(**options)
|
|
93
|
+
|
|
94
|
+
if isinstance(annotation, type) and issubclass(annotation, enum.Enum):
|
|
95
|
+
# Enums that carry a scalar value round-trip by value; the rest by name.
|
|
96
|
+
by_value = issubclass(annotation, (str, int))
|
|
97
|
+
return fields.Enum(annotation, by_value=by_value, **options)
|
|
98
|
+
|
|
99
|
+
origin = get_origin(annotation)
|
|
100
|
+
|
|
101
|
+
if origin in _SEQUENCES:
|
|
102
|
+
args = [arg for arg in get_args(annotation) if arg is not Ellipsis]
|
|
103
|
+
inner = _field_for_type(args[0], {}) if args else fields.Raw()
|
|
104
|
+
return fields.List(inner, **options)
|
|
105
|
+
|
|
106
|
+
if origin is Literal:
|
|
107
|
+
options.setdefault("validate", validate.OneOf(get_args(annotation)))
|
|
108
|
+
return fields.Raw(**options)
|
|
109
|
+
|
|
110
|
+
if origin is dict:
|
|
111
|
+
return fields.Dict(**options)
|
|
112
|
+
|
|
113
|
+
# Unknown annotation: pass the value through untouched rather than guessing.
|
|
114
|
+
return fields.Raw(**options)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _coercer(request: SchemaRequest) -> Any:
|
|
118
|
+
"""A ``pre_load`` hook turning null-like strings into ``None``."""
|
|
119
|
+
|
|
120
|
+
nullable = frozenset(request.nullable_names)
|
|
121
|
+
|
|
122
|
+
def coerce_null_strings(_: Any, data: Mapping[str, Any], **__: Any) -> dict[str, Any]:
|
|
123
|
+
return {
|
|
124
|
+
key: None if key in nullable and request.is_null_string(value) else value
|
|
125
|
+
for key, value in data.items()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return coerce_null_strings
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""The Pydantic backend: a ``BaseModel`` subclass, validation included.
|
|
2
|
+
|
|
3
|
+
``@options`` keywords go straight to :func:`pydantic.Field`, so constraints such as
|
|
4
|
+
``min_length`` and ``ge`` work as they would on a handwritten model.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, create_model, field_validator
|
|
12
|
+
|
|
13
|
+
from .base import SchemaBackend
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .base import SchemaRequest
|
|
17
|
+
|
|
18
|
+
__all__ = ("PydanticBackend",)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PydanticBackend(SchemaBackend):
|
|
22
|
+
"""Renders filters as a Pydantic model."""
|
|
23
|
+
|
|
24
|
+
name = "pydantic"
|
|
25
|
+
extra = "pydantic"
|
|
26
|
+
|
|
27
|
+
def build(self, request: SchemaRequest) -> type[BaseModel]:
|
|
28
|
+
fields: dict[str, Any] = {}
|
|
29
|
+
|
|
30
|
+
for spec in request.specs:
|
|
31
|
+
options = dict(spec.options)
|
|
32
|
+
options.setdefault("description", spec.doc)
|
|
33
|
+
fields[spec.name] = (
|
|
34
|
+
spec.schema_annotation,
|
|
35
|
+
Field(default=spec.schema_default, **options),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
validators: dict[str, Any] = {}
|
|
39
|
+
|
|
40
|
+
if names := request.nullable_names:
|
|
41
|
+
# `mode="before"` so the empty string is caught ahead of type coercion.
|
|
42
|
+
validators["_coerce_null_strings"] = field_validator(*names, mode="before")(
|
|
43
|
+
_coercer(request)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
model: type[BaseModel] = create_model(
|
|
47
|
+
request.name,
|
|
48
|
+
__module__=request.module,
|
|
49
|
+
__validators__=validators,
|
|
50
|
+
**fields,
|
|
51
|
+
)
|
|
52
|
+
# Assigned rather than passed as `__doc__=`, which create_model only grew in 2.1.
|
|
53
|
+
model.__doc__ = request.doc
|
|
54
|
+
|
|
55
|
+
return model
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _coercer(request: SchemaRequest) -> Any:
|
|
59
|
+
"""A ``mode="before"`` validator turning null-like strings into ``None``."""
|
|
60
|
+
|
|
61
|
+
def coerce_null_strings(_: Any, value: Any) -> Any:
|
|
62
|
+
return None if request.is_null_string(value) else value
|
|
63
|
+
|
|
64
|
+
return classmethod(coerce_null_strings)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Decorators applied to filter methods.
|
|
2
|
+
|
|
3
|
+
Each decorator only records intent on the function object. Nothing is validated or
|
|
4
|
+
built here; that happens once, lazily, when a schema is first requested.
|
|
5
|
+
|
|
6
|
+
``options`` is deliberately untyped at runtime: the keywords it accepts depend on the
|
|
7
|
+
schema backend in use, and each backend namespace ships a stub that pins them down.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
13
|
+
|
|
14
|
+
from ._spec import OPTIONS_ATTR, SKIP_NULL_ATTR
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
|
|
19
|
+
__all__ = ("options", "skip_null")
|
|
20
|
+
|
|
21
|
+
_FuncT = TypeVar("_FuncT", bound="Callable[..., Any]")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def options(**kwargs: Any) -> Callable[[_FuncT], _FuncT]:
|
|
25
|
+
"""Attach schema field options to a filter.
|
|
26
|
+
|
|
27
|
+
The keywords are handed to the active backend verbatim, so they are whatever that
|
|
28
|
+
backend's field constructor takes. Import ``options`` from the same namespace as
|
|
29
|
+
``Filters`` and your editor will hint the right ones.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def decorator(func: _FuncT) -> _FuncT:
|
|
33
|
+
existing = dict(getattr(func, OPTIONS_ATTR, {}))
|
|
34
|
+
existing.update(kwargs)
|
|
35
|
+
setattr(func, OPTIONS_ATTR, existing)
|
|
36
|
+
|
|
37
|
+
return func
|
|
38
|
+
|
|
39
|
+
return decorator
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def skip_null(func: _FuncT) -> _FuncT:
|
|
43
|
+
"""Let an explicit ``null`` switch off a filter that has a default.
|
|
44
|
+
|
|
45
|
+
Without it, a filter declaring ``value: Status = Status.ACTIVE`` always applies.
|
|
46
|
+
With it, the schema field accepts ``None`` -- and the strings ``""``, ``"null"``
|
|
47
|
+
and ``"none"``, so a query string can say it too -- and the filter is skipped.
|
|
48
|
+
|
|
49
|
+
It does nothing on a filter without a default, which is already skipped when its
|
|
50
|
+
value is ``None``.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
setattr(func, SKIP_NULL_ATTR, True)
|
|
54
|
+
|
|
55
|
+
return func
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Exception and warning types raised by the library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = (
|
|
6
|
+
"BackendNotAvailableError",
|
|
7
|
+
"FilterDeclarationError",
|
|
8
|
+
"FilterError",
|
|
9
|
+
"JoinConflictWarning",
|
|
10
|
+
"UnknownFilterError",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FilterError(Exception):
|
|
15
|
+
"""Base class for every error raised by this library."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FilterDeclarationError(FilterError, TypeError):
|
|
19
|
+
"""A filter method is declared incorrectly.
|
|
20
|
+
|
|
21
|
+
Raised while the schema is being built, not while it is being applied.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UnknownFilterError(FilterError, KeyError):
|
|
26
|
+
"""A value was supplied for a name that is not a filter on the class."""
|
|
27
|
+
|
|
28
|
+
def __str__(self) -> str:
|
|
29
|
+
# KeyError.__str__ wraps the message in quotes; undo that.
|
|
30
|
+
return str(self.args[0]) if self.args else ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BackendNotAvailableError(FilterError, ImportError):
|
|
34
|
+
"""A schema backend was requested but its optional dependency is missing."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class JoinConflictWarning(UserWarning):
|
|
38
|
+
"""A join was requested onto a target that is already joined another way.
|
|
39
|
+
|
|
40
|
+
The join already in place wins, whether an earlier filter added it or the caller
|
|
41
|
+
did before handing the statement over. Make the ``self.join(...)`` calls agree, or
|
|
42
|
+
alias the target so the two joins address distinct selectables.
|
|
43
|
+
"""
|