rest-filters 0.1.0__tar.gz

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.
@@ -0,0 +1,3 @@
1
+ .venv
2
+ __pycache__/
3
+ *.pyc
@@ -0,0 +1,8 @@
1
+ # Default ignored files
2
+ /shelf/
3
+ /workspace.xml
4
+ # Editor-based HTTP Client requests
5
+ /httpRequests/
6
+ # Datasource local storage ignored files
7
+ /dataSources/
8
+ /dataSources.local.xml
@@ -0,0 +1,10 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <module type="PYTHON_MODULE" version="4">
3
+ <component name="NewModuleRootManager">
4
+ <content url="file://$MODULE_DIR$">
5
+ <excludeFolder url="file://$MODULE_DIR$/.venv" />
6
+ </content>
7
+ <orderEntry type="jdk" jdkName="Python 3.12 (drf-filter)" jdkType="Python SDK" />
8
+ <orderEntry type="sourceFolder" forTests="false" />
9
+ </component>
10
+ </module>
@@ -0,0 +1,54 @@
1
+ <component name="InspectionProjectProfileManager">
2
+ <profile version="1.0">
3
+ <option name="myName" value="Project Default" />
4
+ <inspection_tool class="CssUnknownProperty" enabled="true" level="WARNING" enabled_by_default="true">
5
+ <option name="myCustomPropertiesEnabled" value="true" />
6
+ <option name="myIgnoreVendorSpecificProperties" value="false" />
7
+ <option name="myCustomPropertiesList">
8
+ <value>
9
+ <list size="1">
10
+ <item index="0" class="java.lang.String" itemvalue="paddint-top" />
11
+ </list>
12
+ </value>
13
+ </option>
14
+ </inspection_tool>
15
+ <inspection_tool class="DockerFileAssignments" enabled="false" level="ERROR" enabled_by_default="false" />
16
+ <inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
17
+ <inspection_tool class="HtmlUnknownAttribute" enabled="true" level="WARNING" enabled_by_default="true">
18
+ <option name="myValues">
19
+ <value>
20
+ <list size="1">
21
+ <item index="0" class="java.lang.String" itemvalue="value" />
22
+ </list>
23
+ </value>
24
+ </option>
25
+ <option name="myCustomValuesEnabled" value="true" />
26
+ </inspection_tool>
27
+ <inspection_tool class="PyAttributeOutsideInitInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
28
+ <inspection_tool class="PyCompatibilityInspection" enabled="true" level="WARNING" enabled_by_default="true">
29
+ <option name="ourVersions">
30
+ <value>
31
+ <list size="2">
32
+ <item index="0" class="java.lang.String" itemvalue="3.12" />
33
+ <item index="1" class="java.lang.String" itemvalue="3.11" />
34
+ </list>
35
+ </value>
36
+ </option>
37
+ </inspection_tool>
38
+ <inspection_tool class="PyMethodMayBeStaticInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
39
+ <inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
40
+ <option name="ignoredPackages">
41
+ <value>
42
+ <list size="3">
43
+ <item index="0" class="java.lang.String" itemvalue="zope-interface" />
44
+ <item index="1" class="java.lang.String" itemvalue="prompt-toolkit" />
45
+ <item index="2" class="java.lang.String" itemvalue="typing-extensions" />
46
+ </list>
47
+ </value>
48
+ </option>
49
+ </inspection_tool>
50
+ <inspection_tool class="PyRedundantParenthesesInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
51
+ <option name="myIgnoreTupleInReturn" value="true" />
52
+ </inspection_tool>
53
+ </profile>
54
+ </component>
@@ -0,0 +1,6 @@
1
+ <component name="InspectionProjectProfileManager">
2
+ <settings>
3
+ <option name="USE_PROJECT_PROFILE" value="false" />
4
+ <version value="1.0" />
5
+ </settings>
6
+ </component>
@@ -0,0 +1,7 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="Black">
4
+ <option name="sdkName" value="Python 3.11 (django-sozluk)" />
5
+ </component>
6
+ <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (drf-filter)" project-jdk-type="Python SDK" />
7
+ </project>
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectModuleManager">
4
+ <modules>
5
+ <module fileurl="file://$PROJECT_DIR$/.idea/drf-filter.iml" filepath="$PROJECT_DIR$/.idea/drf-filter.iml" />
6
+ </modules>
7
+ </component>
8
+ </project>
@@ -0,0 +1,9 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="RuffConfigService">
4
+ <option name="globalRuffExecutablePath" value="$PROJECT_DIR$/../pypackages/bin/ruff" />
5
+ <option name="projectRuffExecutablePath" value="$PROJECT_DIR$/.venv/bin/ruff" />
6
+ <option name="runRuffOnSave" value="true" />
7
+ <option name="useRuffFormat" value="true" />
8
+ </component>
9
+ </project>
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="VcsDirectoryMappings">
4
+ <mapping directory="$PROJECT_DIR$" vcs="Git" />
5
+ </component>
6
+ </project>
@@ -0,0 +1,83 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="AutoImportSettings">
4
+ <option name="autoReloadType" value="SELECTIVE" />
5
+ </component>
6
+ <component name="ChangeListManager">
7
+ <list default="true" id="036fc8c0-6a78-4c9e-8607-871c8fafbce2" name="Changes" comment="" />
8
+ <option name="SHOW_DIALOG" value="false" />
9
+ <option name="HIGHLIGHT_CONFLICTS" value="true" />
10
+ <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
11
+ <option name="LAST_RESOLUTION" value="IGNORE" />
12
+ </component>
13
+ <component name="FileTemplateManagerImpl">
14
+ <option name="RECENT_TEMPLATES">
15
+ <list>
16
+ <option value="Python Script" />
17
+ </list>
18
+ </option>
19
+ </component>
20
+ <component name="Git.Settings">
21
+ <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
22
+ </component>
23
+ <component name="ProjectColorInfo">{
24
+ &quot;associatedIndex&quot;: 0
25
+ }</component>
26
+ <component name="ProjectId" id="2plHSYRbsl55LSV0DzU8zKTArqh" />
27
+ <component name="ProjectViewState">
28
+ <option name="hideEmptyMiddlePackages" value="true" />
29
+ <option name="showLibraryContents" value="true" />
30
+ <option name="sortByType" value="true" />
31
+ <option name="sortKey" value="BY_TYPE" />
32
+ </component>
33
+ <component name="PropertiesComponent">{
34
+ &quot;keyToString&quot;: {
35
+ &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
36
+ &quot;git-widget-placeholder&quot;: &quot;main&quot;,
37
+ &quot;last_opened_file_path&quot;: &quot;/Users/suayip/Projects/drf-filter&quot;,
38
+ &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
39
+ &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
40
+ &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
41
+ &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
42
+ &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
43
+ &quot;settings.editor.selected.configurable&quot;: &quot;com.koxudaxi.ruff.RuffConfigurable&quot;,
44
+ &quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
45
+ }
46
+ }</component>
47
+ <component name="SharedIndexes">
48
+ <attachedChunks>
49
+ <set>
50
+ <option value="bundled-js-predefined-1d06a55b98c1-0b3e54e931b4-JavaScript-PY-241.19072.16" />
51
+ <option value="bundled-python-sdk-8336bb23522e-2767605e8bc2-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-241.19072.16" />
52
+ </set>
53
+ </attachedChunks>
54
+ </component>
55
+ <component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
56
+ <component name="TaskManager">
57
+ <task active="true" id="Default" summary="Default task">
58
+ <changelist id="036fc8c0-6a78-4c9e-8607-871c8fafbce2" name="Changes" comment="" />
59
+ <created>1733333741225</created>
60
+ <option name="number" value="Default" />
61
+ <option name="presentableId" value="Default" />
62
+ <updated>1733333741225</updated>
63
+ <workItem from="1733333742261" duration="9945000" />
64
+ <workItem from="1733412373669" duration="11445000" />
65
+ <workItem from="1733509131420" duration="6023000" />
66
+ <workItem from="1733569377429" duration="1942000" />
67
+ <workItem from="1733775621847" duration="6629000" />
68
+ <workItem from="1733863470564" duration="4487000" />
69
+ <workItem from="1734120683127" duration="4258000" />
70
+ <workItem from="1734270684001" duration="1921000" />
71
+ <workItem from="1734273029203" duration="2179000" />
72
+ <workItem from="1734291472802" duration="5672000" />
73
+ <workItem from="1734373962690" duration="2451000" />
74
+ <workItem from="1734378243117" duration="3884000" />
75
+ <workItem from="1734459938142" duration="12963000" />
76
+ <workItem from="1734629741407" duration="2081000" />
77
+ </task>
78
+ <servers />
79
+ </component>
80
+ <component name="TypeScriptGeneratedFilesManager">
81
+ <option name="version" value="3" />
82
+ </component>
83
+ </project>
@@ -0,0 +1,14 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v5.0.0
4
+ hooks:
5
+ - id: trailing-whitespace
6
+ - id: mixed-line-ending
7
+ args: [ '--fix=lf' ]
8
+ - id: end-of-file-fixer
9
+
10
+ - repo: https://github.com/charliermarsh/ruff-pre-commit
11
+ rev: 'v0.8.4'
12
+ hooks:
13
+ - id: ruff
14
+ - id: ruff-format
@@ -0,0 +1,24 @@
1
+ # Django Settings ve What can be be a setting? (blank, known params)
2
+ # modelviewset factory oluşturup ordan kaymaca?
3
+
4
+ # 2.
5
+ # sort
6
+ # OrderingFilter() ? (idk)
7
+ # maybe just do all that in Meta?
8
+ # e.g., ordering = {"default": "field", } idk.. []
9
+
10
+ # -1.
11
+ # big todo: filteset metaclass for Meta>Options creation
12
+ # where we do checks to ensure integrity. we also need to
13
+ # implement introspect() {param: serializer} thing so that
14
+ # we can derive fields and also serializer for sanity checks
15
+ # and also schema generation.
16
+ # ^^ we need to do this because we would like to introspect filterset
17
+ # before initiating it. that how Options.fields wil work too
18
+
19
+ # 0. BONUS: `invalid_query_param` or `%s_query_param`
20
+ # code usage? and maybe add as a setting as well
21
+
22
+ # help_text, start writing tests
23
+
24
+ # extend_known_parameters?
@@ -0,0 +1,27 @@
1
+ Copyright (c) 2025, Şuayip Üzülmez
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ 1. Redistributions of source code must retain the above copyright notice, this
8
+ list of conditions and the following disclaimer.
9
+
10
+ 2. Redistributions in binary form must reproduce the above copyright notice,
11
+ this list of conditions and the following disclaimer in the documentation
12
+ and/or other materials provided with the distribution.
13
+
14
+ 3. Neither the name of the copyright holder nor the names of its
15
+ contributors may be used to endorse or promote products derived from
16
+ this software without specific prior written permission.
17
+
18
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
23
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
26
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: rest-filters
3
+ Version: 0.1.0
4
+ Project-URL: Homepage, https://github.com/realsuayip/rest-filters
5
+ Project-URL: Bug Tracker, https://github.com/realsuayip/rest-filters/issues
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: django>=3.2
9
+ Requires-Dist: djangorestframework>=3.14.0
@@ -0,0 +1 @@
1
+ WIP
@@ -0,0 +1,60 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [tool.hatch.build.targets.wheel]
6
+ packages = ["rest_filters"]
7
+
8
+ [project]
9
+ name = "rest-filters"
10
+ version = "0.1.0"
11
+ requires-python = ">=3.11"
12
+ dependencies = [
13
+ "django>=3.2",
14
+ "djangorestframework>=3.14.0",
15
+ ]
16
+
17
+ [project.urls]
18
+ "Homepage" = "https://github.com/realsuayip/rest-filters"
19
+ "Bug Tracker" = "https://github.com/realsuayip/rest-filters/issues"
20
+
21
+ [tool.ruff]
22
+ target-version = "py312"
23
+ line-length = 88
24
+
25
+ [tool.ruff.lint]
26
+ select = [
27
+ "E", # pycodestyle errors
28
+ "W", # pycodestyle warnings
29
+ "F", # pyflakes
30
+ "C", # flake8-comprehensions
31
+ "B", # flake8-bugbear
32
+ "RUF", # Ruff-specific
33
+ "C4", # flake8-comprehensions
34
+ "C90", # mccabe
35
+ "I", # isort
36
+ ]
37
+ fixable = ["I"]
38
+ ignore = ["B904"]
39
+
40
+ [tool.ruff.lint.isort]
41
+ combine-as-imports = true
42
+ section-order = [
43
+ "future",
44
+ "standard-library",
45
+ "django",
46
+ "rest_framework",
47
+ "third-party",
48
+ "first-party",
49
+ "local-folder",
50
+ ]
51
+
52
+ [tool.ruff.lint.isort.sections]
53
+ django = ["django"]
54
+ rest_framework = ["rest_framework"]
55
+
56
+ [tool.lint.mccabe]
57
+ max-complexity = 10
58
+
59
+ [tool.mypy]
60
+ strict = true
File without changes
@@ -0,0 +1,455 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import operator
5
+ from collections import defaultdict
6
+ from collections.abc import Sequence
7
+ from difflib import get_close_matches
8
+ from typing import Any, Generic, Literal, TypeVar
9
+
10
+ from django.db import models
11
+ from django.db.models import Q, QuerySet
12
+ from django.http.request import QueryDict
13
+ from django.utils.translation import gettext
14
+
15
+ from rest_framework import filters, serializers
16
+ from rest_framework.exceptions import ValidationError
17
+ from rest_framework.fields import SkipField, empty
18
+ from rest_framework.request import Request
19
+ from rest_framework.settings import api_settings
20
+ from rest_framework.views import APIView
21
+
22
+ from rest_filters.utils import fill_q_template, merge_errors
23
+
24
+ _MT_co = TypeVar("_MT_co", bound=models.Model, covariant=True)
25
+
26
+ StrOrPromise = str
27
+
28
+
29
+ class Entry:
30
+ def __init__(
31
+ self,
32
+ *,
33
+ group: str = "chain",
34
+ aliases: dict[str, Any] | None = None,
35
+ value: Any,
36
+ expression: Any,
37
+ ):
38
+ self.group = group
39
+ self.aliases = aliases
40
+ self.value = value
41
+ self.expression = expression
42
+
43
+ def __repr__(self) -> str:
44
+ return "Entry(group=%r, aliases=%r, value=%r, expression=%r)" % (
45
+ self.group,
46
+ self.aliases,
47
+ self.value,
48
+ self.expression,
49
+ )
50
+
51
+
52
+ class Filter:
53
+ def __init__(
54
+ self,
55
+ f: serializers.Field = None,
56
+ /,
57
+ *,
58
+ field: str | None = None,
59
+ lookup: str = "exact",
60
+ template: Q | None = None,
61
+ group: str | None = None,
62
+ distinct: bool = False,
63
+ negate: bool = False,
64
+ method: str | None = None,
65
+ aliases: dict[str, Any] | None = None,
66
+ param: str | None = None,
67
+ children: list[Filter] | None = None,
68
+ namespace: bool = False,
69
+ blank: Literal["keep", "omit"] = "omit",
70
+ ) -> None:
71
+ self._field = field
72
+ self.lookup = lookup or "exact"
73
+ self.template = template
74
+
75
+ # todo needs tons of other checks
76
+ if group is not None and not group.isidentifier():
77
+ raise ValueError("Group names must be valid Python identifiers")
78
+
79
+ if blank not in ("keep", "omit"):
80
+ raise ValueError("blank must either be 'keep' or 'omit'")
81
+
82
+ self._group = group
83
+ self.aliases = aliases # not functional
84
+
85
+ self.negate = negate
86
+ self.distinct = distinct # not functional, todo allow list<str> also
87
+
88
+ self.blank = blank
89
+ self.method = method
90
+ self._param = param
91
+ self._serializer = f
92
+ self._filterset: FilterSet | None = None
93
+
94
+ self.namespace = namespace
95
+ self.parent: Filter | None = None
96
+ self.children = children or []
97
+ for child in self.children:
98
+ child.bind(self)
99
+
100
+ if not self.children and self.namespace:
101
+ raise ValueError("Namespace filters are required to have child filters")
102
+
103
+ def __repr__(self) -> str:
104
+ return "%s(param=%r, group=%r, serializer=%r)" % (
105
+ self.__class__.__name__,
106
+ self.get_param_name(),
107
+ self.get_group(),
108
+ self.get_serializer(),
109
+ )
110
+
111
+ def __set_name__(self, owner: FilterSet, name: str) -> None:
112
+ self.name = name
113
+
114
+ def bind(self, parent: Filter) -> None:
115
+ self.parent = parent
116
+
117
+ if self._param is None:
118
+ raise ValueError("param needs to be set for child filters")
119
+
120
+ def get_group(self) -> str:
121
+ if self._group is not None:
122
+ return self._group
123
+ elif self.parent is not None:
124
+ return self.parent.get_group()
125
+ return "chain"
126
+
127
+ def get_field_name(self):
128
+ if self.parent and self._field is None:
129
+ return self.parent.get_field_name()
130
+ return self._field or self.name
131
+
132
+ def get_param_name(self) -> str:
133
+ name = self._param or self.name
134
+ if self.parent is not None:
135
+ namespace = self.parent.get_param_name()
136
+ return f"{namespace}.{name}"
137
+ return name
138
+
139
+ def get_query_value(self, query_dict: QueryDict) -> str | empty:
140
+ param = self.get_param_name()
141
+ return query_dict.get(param, empty)
142
+
143
+ def get_serializer(self) -> serializers.Field:
144
+ if self._serializer is not None:
145
+ return self._serializer
146
+ elif self.parent is not None:
147
+ return self.parent.get_serializer()
148
+ raise ValueError("Serializer field is not set for this filter")
149
+
150
+ def get_filterset(self) -> FilterSet:
151
+ if self.parent:
152
+ return self.parent.get_filterset()
153
+ return self._filterset
154
+
155
+ def parse_value(self, value: str | empty) -> Any:
156
+ if value is not empty:
157
+ value = serializers.CharField(allow_blank=True).run_validation(value)
158
+ if self.blank == "omit" and value == "":
159
+ value = empty
160
+ filterset, param = self.get_filterset(), self.get_param_name()
161
+ serializer = filterset.get_serializer(param) or self.get_serializer()
162
+ serializer.default = filterset.get_default(param, serializer.default)
163
+ return serializer.run_validation(value)
164
+
165
+ def resolve_entry_attrs(self, value: Any) -> Entry:
166
+ if self.template is not None:
167
+ expression = fill_q_template(self.template, value=value)
168
+ else:
169
+ field = self.get_field_name()
170
+ lookup = f"{field}__{self.lookup}"
171
+ expression = Q(**{lookup: value})
172
+ if self.negate:
173
+ expression = ~expression
174
+ return Entry(
175
+ group=self.get_group(),
176
+ aliases=self.aliases,
177
+ value=value,
178
+ expression=expression,
179
+ )
180
+
181
+ def resolve_entry(self, query_dict: QueryDict) -> Entry | None:
182
+ try:
183
+ value = self.parse_value(self.get_query_value(query_dict))
184
+ except SkipField:
185
+ return None
186
+
187
+ if self.method is not None:
188
+ param = self.get_param_name()
189
+ result = getattr(self.get_filterset(), self.method)(param, value)
190
+ if isinstance(result, Entry):
191
+ return result
192
+ return Entry(group=self.get_group(), value=value, expression=result)
193
+ return self.resolve_entry_attrs(value)
194
+
195
+ def get_all_children(self) -> list[Filter]:
196
+ children = []
197
+ for child in self.children:
198
+ children.append(child)
199
+ children.extend(child.get_all_children())
200
+ return children
201
+
202
+ def resolve(
203
+ self, query_dict: QueryDict
204
+ ) -> tuple[dict[str, Entry | None], dict[str, Any]]:
205
+ entries, errors = {}, {}
206
+ for instance in [self, *self.get_all_children()]:
207
+ if instance.namespace:
208
+ continue
209
+ param = instance.get_param_name()
210
+ try:
211
+ entry = instance.resolve_entry(query_dict)
212
+ except ValidationError as err:
213
+ errors[param] = err.detail
214
+ else:
215
+ entries[param] = entry
216
+ return entries, errors
217
+
218
+
219
+ class Constraint:
220
+ def __init__(
221
+ self,
222
+ *,
223
+ fields: Sequence[str],
224
+ message: StrOrPromise = "",
225
+ method: str | None = None,
226
+ ) -> None:
227
+ self.fields = fields
228
+ self.message = message
229
+ self.method = method
230
+ self.filterset: FilterSet | None = None
231
+
232
+ def get_message(self, **kwargs: Any) -> dict[str, Any]:
233
+ message = self.message or gettext(
234
+ "%(constraint)s failed for fields: %(fields)s"
235
+ ) % {
236
+ "constraint": self.__class__.__name__,
237
+ "fields": ", ".join(f'"{field}"' for field in self.fields),
238
+ }
239
+ return {api_settings.NON_FIELD_ERRORS_KEY: [message]}
240
+
241
+ def check(self, **kwargs: Any) -> bool:
242
+ assert self.method, "Missing method for constraint"
243
+ assert self.filterset, "Missing filterset for constraint"
244
+ return getattr(self.filterset, self.method)(**kwargs)
245
+
246
+
247
+ class MutuallyExclusive(Constraint):
248
+ def __init__(
249
+ self,
250
+ *,
251
+ fields: Sequence[str],
252
+ **kwargs: Any,
253
+ ) -> None:
254
+ assert len(fields) > 1, "Provide 2 or more fields for this constraint"
255
+ super().__init__(fields=fields, **kwargs)
256
+
257
+ def get_message(self, **kwargs: Any) -> dict[str, Any]:
258
+ if self.message:
259
+ return super().get_message(**kwargs)
260
+ return {
261
+ api_settings.NON_FIELD_ERRORS_KEY: [
262
+ gettext(
263
+ "Following fields are mutually exclusive,"
264
+ " you may only provide one of them: %(fields)s"
265
+ )
266
+ % {
267
+ "fields": ", ".join(
268
+ f'"{field}"' for field in self.fields if field in kwargs
269
+ )
270
+ }
271
+ ]
272
+ }
273
+
274
+ def check(self, **kwargs: Any) -> bool:
275
+ return sum(field in kwargs for field in self.fields) <= 1
276
+
277
+
278
+ class MutuallyInclusive(Constraint):
279
+ def __init__(
280
+ self,
281
+ *,
282
+ fields: Sequence[str],
283
+ **kwargs: Any,
284
+ ) -> None:
285
+ assert len(fields) > 1, "Provide 2 or more fields for this constraint"
286
+ super().__init__(fields=fields, **kwargs)
287
+
288
+ def get_message(self, **kwargs: Any) -> dict[str, Any]:
289
+ if self.message:
290
+ return super().get_message(**kwargs)
291
+ return {
292
+ api_settings.NON_FIELD_ERRORS_KEY: [
293
+ gettext(
294
+ "Following fields are mutually inclusive,"
295
+ " you must provide them all at once or none of them: %(fields)s"
296
+ )
297
+ % {"fields": ", ".join(f'"{field}"' for field in self.fields)}
298
+ ]
299
+ }
300
+
301
+ def check(self, **kwargs: Any) -> bool:
302
+ fields = [field in kwargs for field in self.fields]
303
+ return all(fields) if any(fields) else True
304
+
305
+
306
+ class Options:
307
+ def __init__(
308
+ self,
309
+ *,
310
+ fields: Sequence[str] | None = None,
311
+ known_parameters: Sequence[str] | None = None,
312
+ constraints: Sequence[Constraint] | None = None,
313
+ combinators: dict[str, Any] | None = None,
314
+ ) -> None:
315
+ self.fields = fields # todo not functional
316
+ self.known_parameters = known_parameters or []
317
+ self.constraints = constraints or []
318
+ self.combinators = combinators or {}
319
+
320
+
321
+ class FilterSet(Generic[_MT_co]):
322
+ def __init__(
323
+ self, request: Request, queryset: QuerySet[_MT_co], view: APIView
324
+ ) -> None:
325
+ self.request = request
326
+ self.queryset = queryset
327
+ self.view = view
328
+ self.options = self.get_options()
329
+
330
+ def get_options(self) -> Options:
331
+ if meta := getattr(self, "Meta", None):
332
+ args = ("fields", "known_parameters", "constraints", "combinators")
333
+ return Options(**{arg: getattr(meta, arg, None) for arg in args})
334
+ return Options()
335
+
336
+ def get_groups(self) -> dict[str, dict[str, Entry]]:
337
+ params = self.request.query_params
338
+ fields = self.get_fields()
339
+ groupdict, valuedict, errordict = defaultdict(dict), {}, {}
340
+ known = [*self.options.known_parameters]
341
+ for _, field in fields.items():
342
+ field._filterset = self
343
+ entries, errors = field.resolve(params)
344
+ known.extend((*entries, *errors))
345
+ for param, entry in entries.items():
346
+ if entry is not None:
347
+ groupdict[entry.group][param] = entry
348
+ valuedict[param] = entry.value
349
+ for param, error in errors.items():
350
+ errordict[param] = error
351
+ valuedict[param] = empty
352
+ unknown = [field for field in params if field not in known]
353
+ merge_errors(errordict, self.handle_constraints(valuedict))
354
+ errordict |= self.handle_unknown_parameters(unknown, known)
355
+ self.handle_errors(errordict)
356
+ return groupdict
357
+
358
+ def filter_group(
359
+ self,
360
+ queryset: QuerySet[_MT_co],
361
+ name: str,
362
+ entries: dict[str, Entry],
363
+ ) -> QuerySet[_MT_co]:
364
+ merge = getattr(self, f"merge_{name}", None)
365
+ if merge is not None:
366
+ entry = merge(entries)
367
+ return queryset.filter(entry.expression)
368
+ return queryset.filter(self.get_default_group_expression(name, entries))
369
+
370
+ def get_default_group_expression(
371
+ self, group: str, entries: dict[str, Entry]
372
+ ) -> Any:
373
+ combinator = self.options.combinators.get(group, operator.and_)
374
+ expressions = (entry.expression for entry in entries.values())
375
+ return functools.reduce(combinator, expressions)
376
+
377
+ def get_queryset(self) -> QuerySet[_MT_co]:
378
+ queryset = self.queryset
379
+ groupdict = self.get_groups()
380
+
381
+ for entry in groupdict.pop("chain", {}).values():
382
+ queryset = queryset.filter(entry.expression)
383
+
384
+ for name, entries in groupdict.items():
385
+ queryset = self.filter_group(queryset, name, entries)
386
+ return queryset
387
+
388
+ @classmethod
389
+ def get_fields(cls) -> dict[str, Filter]:
390
+ return {
391
+ name: attr for name, attr in vars(cls).items() if isinstance(attr, Filter)
392
+ }
393
+
394
+ def get_default(self, param: str, default: Any) -> Any:
395
+ return default
396
+
397
+ def get_serializer(self, param: str) -> serializers.Field | None:
398
+ return None
399
+
400
+ def get_constraints(self) -> Sequence[Constraint]:
401
+ return self.options.constraints
402
+
403
+ def handle_constraints(self, valuedict: dict[str, Any]) -> dict[str, Any]:
404
+ errors = {}
405
+ constraints = self.get_constraints()
406
+ for constraint in constraints:
407
+ constraint.filterset = self
408
+ if not constraint.check(**valuedict):
409
+ message = constraint.get_message(**valuedict)
410
+ merge_errors(errors, message)
411
+ return errors
412
+
413
+ def handle_unknown_parameters(
414
+ self, unknown: list[str], known: list[str]
415
+ ) -> dict[str, Any]:
416
+ fields = {}
417
+ for param in unknown:
418
+ matches = get_close_matches(param, known)
419
+ if not matches:
420
+ fields[param] = [gettext("This query parameter does not exist.")]
421
+ elif len(matches) == 1:
422
+ fields[param] = [
423
+ gettext(
424
+ "This query parameter does not exist."
425
+ ' Did you mean "%(param)s"?'
426
+ )
427
+ % {"param": matches[0]}
428
+ ]
429
+ else:
430
+ possibilities = ", ".join(f'"{match}"' for match in matches)
431
+ fields[param] = [
432
+ gettext(
433
+ "This query parameter does not exist."
434
+ " Did you mean one of these: %(possibilities)s?"
435
+ )
436
+ % {"possibilities": possibilities}
437
+ ]
438
+ return fields
439
+
440
+ def handle_errors(self, errordict: dict[str, Any]) -> None:
441
+ raise serializers.ValidationError(errordict)
442
+
443
+
444
+ class FilterBackend(filters.BaseFilterBackend):
445
+ def get_filterset_class(self, request, queryset, view):
446
+ # todo def get_filterset_class
447
+ return view.filterset_classes.get(view.action)
448
+
449
+ def get_filterset(self, request, queryset, view):
450
+ klass = self.get_filterset_class(request, queryset, view)
451
+ return klass(request, queryset, view)
452
+
453
+ def filter_queryset(self, request, queryset, view):
454
+ filterset = self.get_filterset(request, queryset, view)
455
+ return filterset.get_queryset()
File without changes
@@ -0,0 +1,36 @@
1
+ from typing import Any
2
+
3
+ from django.db.models import Q
4
+
5
+
6
+ def fill_q_template(template: Q, *, value: Any) -> Q:
7
+ conditions = []
8
+ for child in template.children:
9
+ if isinstance(child, Q):
10
+ conditions.append(fill_q_template(child, value=value))
11
+ else:
12
+ if isinstance(child, tuple):
13
+ field, value = child
14
+ raise ValueError(
15
+ "Q objects should not specify values in templates, got"
16
+ " Q(%(field)s=%(value)r), expected Q(%(field)r)."
17
+ " If you would like to do more complex queries, use `method`"
18
+ " argument." % {"field": field, "value": value}
19
+ )
20
+ conditions.append((child, value))
21
+ return Q(
22
+ *conditions,
23
+ _connector=template.connector,
24
+ _negated=template.negated,
25
+ )
26
+
27
+
28
+ def merge_errors(
29
+ source: dict[str, Any],
30
+ errors: dict[str, Any],
31
+ ) -> None:
32
+ for key, detail in errors.items():
33
+ if isinstance(source.get(key), list):
34
+ source[key].extend(detail)
35
+ else:
36
+ source[key] = detail