hjtdev-appkit 2.0.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.
appkit/validation.py ADDED
@@ -0,0 +1,195 @@
1
+ """Query-param validation, HTML sanitisation, and an ORM lookup allowlist for user-driven
2
+ filtering.
3
+
4
+ No custom validation framework — DRF serializers already do the work; this module adds a thin
5
+ helper for validating ``request.query_params`` through a serializer, not a new declaration
6
+ syntax. No generic XSS/SQL-injection scanner either: the ORM already prevents SQL injection, and
7
+ string-scanning for ``<script>`` is a blocklist that provides false confidence, not real
8
+ protection (docs/CONTRACT.md §J).
9
+
10
+ Public surface (docs/CONTRACT.md §2.8):
11
+
12
+ def validate_query_params(serializer_class: type[S], params: QueryDict) -> S: ...
13
+ # raises rest_framework.exceptions.ValidationError
14
+
15
+ def sanitize_html(value: str, *, allowed_tags: Iterable[str] | None = None) -> str: ...
16
+ # default tag set: p, br, strong, em, a, ul, ol, li
17
+
18
+ def strip_html(value: str) -> str: ...
19
+
20
+ ALLOWED_LOOKUPS: Final[frozenset[str]]
21
+ # exact, iexact, contains, icontains, startswith, endswith, gt, gte, lt, lte, in,
22
+ # isnull, range. regex and iregex are excluded on purpose.
23
+ #
24
+ # Reading flag: docs/CONTRACT.md §2.8 writes the first member as "eq/exact" — only
25
+ # "exact" is a real Django ORM lookup (there is no "eq"), so only "exact" goes in the
26
+ # set. Admitting a literal "eq" would let a caller build filter(x__eq=...) and get a
27
+ # FieldError from a function whose entire job is to prevent exactly that.
28
+
29
+ def validate_lookup(lookup: str) -> bool: ...
30
+
31
+ def safe_filter_kwargs(
32
+ params: QueryDict, allowed_fields: Iterable[str], *, allow_relations: bool = False
33
+ ) -> dict[str, Any]: ...
34
+ # allow_relations exists specifically to prevent filter-based data exfiltration across
35
+ # relations when left at its default False.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from typing import TYPE_CHECKING, Any, Final
41
+
42
+ import nh3
43
+
44
+ if TYPE_CHECKING:
45
+ from collections.abc import Iterable
46
+
47
+ from django.http import QueryDict
48
+ from rest_framework.serializers import Serializer
49
+
50
+ __all__ = [
51
+ "ALLOWED_LOOKUPS",
52
+ "safe_filter_kwargs",
53
+ "sanitize_html",
54
+ "strip_html",
55
+ "validate_lookup",
56
+ "validate_query_params",
57
+ ]
58
+
59
+ #: nh3's own default HTML tag allowlist is much larger than this — this is appkit's
60
+ #: deliberately minimal default for "safe rich text" (a comment body, a bio field), not nh3's.
61
+ _DEFAULT_ALLOWED_TAGS: Final[frozenset[str]] = frozenset(
62
+ {"p", "br", "strong", "em", "a", "ul", "ol", "li"}
63
+ )
64
+
65
+ #: The ORM lookup allowlist. `regex`/`iregex` are excluded on purpose — a user-controlled regex
66
+ #: against Postgres is a ReDoS vector, and this allowlist's whole job is to be the thing an app
67
+ #: checks before building a `filter(**kwargs)` from user input.
68
+ ALLOWED_LOOKUPS: Final[frozenset[str]] = frozenset(
69
+ {
70
+ "exact",
71
+ "iexact",
72
+ "contains",
73
+ "icontains",
74
+ "startswith",
75
+ "endswith",
76
+ "gt",
77
+ "gte",
78
+ "lt",
79
+ "lte",
80
+ "in",
81
+ "isnull",
82
+ "range",
83
+ }
84
+ )
85
+
86
+
87
+ def validate_query_params[S: Serializer[Any]](serializer_class: type[S], params: QueryDict) -> S:
88
+ """Runs `params` through `serializer_class` for read-side validation, returning the
89
+ validated serializer instance.
90
+
91
+ Raises `rest_framework.exceptions.ValidationError` on invalid input — deliberately DRF's
92
+ own exception, so it flows straight into `standard_exception_handler` without a
93
+ translation layer. This is a thin helper pointing DRF serializers at
94
+ `request.query_params` instead of `request.data`, not a parallel validation framework.
95
+ """
96
+ serializer = serializer_class(data=params)
97
+ serializer.is_valid(raise_exception=True)
98
+ return serializer
99
+
100
+
101
+ def sanitize_html(value: str, *, allowed_tags: Iterable[str] | None = None) -> str:
102
+ """`nh3`-based allowlist HTML sanitisation. `allowed_tags=None` uses the documented minimal
103
+ default tag set (`p`, `br`, `strong`, `em`, `a`, `ul`, `ol`, `li`).
104
+
105
+ Never raises; malformed HTML is repaired, not rejected, matching `nh3`'s own behaviour.
106
+ `<script>` tags and `on*=` event-handler attributes are stripped even when nested inside an
107
+ otherwise-allowed tag (`<a onmouseover="...">`) — `nh3` strips any tag not in `tags` (down
108
+ to its inert text content, never its markup) and any attribute not in its own default
109
+ attribute allowlist (which never includes an event handler), at every nesting depth, not
110
+ only the top level.
111
+ """
112
+ tags = frozenset(allowed_tags) if allowed_tags is not None else _DEFAULT_ALLOWED_TAGS
113
+ return nh3.clean(value, tags=set(tags))
114
+
115
+
116
+ def strip_html(value: str) -> str:
117
+ """Removes all tags, returning plain text. For fields that must never contain markup at
118
+ all (a display name), not fields that may contain safe rich text.
119
+ """
120
+ return nh3.clean(value, tags=set())
121
+
122
+
123
+ def validate_lookup(lookup: str) -> bool:
124
+ """Pure membership check against `ALLOWED_LOOKUPS`. Never raises."""
125
+ return lookup in ALLOWED_LOOKUPS
126
+
127
+
128
+ def _split_filter_key(key: str, *, allow_relations: bool) -> tuple[str, str] | None:
129
+ """Parses `key` into `(field_path, lookup)`, or `None` if the shape isn't allowed.
130
+
131
+ Counts `__`-delimited segments rather than checking a prefix: `allowed_fields=["created_at"]`
132
+ must accept `?created_at__gte=x` (2 segments, second is a valid lookup) and reject
133
+ `?created_at__related__gte=x` (3 segments) even though the first segment matches.
134
+ """
135
+ segments = key.split("__")
136
+
137
+ if not allow_relations:
138
+ if len(segments) == 1:
139
+ return segments[0], "exact"
140
+ if len(segments) == 2 and segments[1] in ALLOWED_LOOKUPS:
141
+ return segments[0], segments[1]
142
+ return None
143
+
144
+ # allow_relations=True: any number of `field__field__...` segments is a candidate relation
145
+ # path; only the *trailing* segment is ever treated as a lookup, and only if it's a
146
+ # recognised one — the caller's `allowed_fields` is what actually decides which paths exist.
147
+ if len(segments) >= 2 and segments[-1] in ALLOWED_LOOKUPS:
148
+ return "__".join(segments[:-1]), segments[-1]
149
+ return "__".join(segments), "exact"
150
+
151
+
152
+ def _coerce_filter_value(lookup: str, raw: str) -> Any:
153
+ """Type-coerces a raw query-param string for the given lookup.
154
+
155
+ Without the `isnull` coercion, `?x__isnull=false` would filter as `True` (any non-empty
156
+ string is truthy) — a silent wrong-results bug, not a style choice.
157
+ """
158
+ if lookup in ("in", "range"):
159
+ return [item.strip() for item in raw.split(",") if item.strip()]
160
+ if lookup == "isnull":
161
+ return raw.strip().lower() in ("1", "true", "yes")
162
+ return raw
163
+
164
+
165
+ def safe_filter_kwargs(
166
+ params: QueryDict, allowed_fields: Iterable[str], *, allow_relations: bool = False
167
+ ) -> dict[str, Any]:
168
+ """Builds a `.filter()`-safe kwargs dict from raw query params.
169
+
170
+ Only `allowed_fields` may appear, only `ALLOWED_LOOKUPS` suffixes are accepted, and unknown
171
+ params are **dropped, not errored** — a client typo degrades to "no filter applied", not a
172
+ 500.
173
+
174
+ `allow_relations=False` (the default) is the load-bearing default and the whole point of
175
+ this function's existence: with it, `field__related__field` double-underscore traversal is
176
+ rejected outright — `?user__email__icontains=` cannot be used to exfiltrate another table's
177
+ data through a filter an app author only meant to expose one field of. Passing
178
+ `allow_relations=True` opts a specific relation path in only when that exact dotted path
179
+ (e.g. `"user__email"`) is itself listed in `allowed_fields`.
180
+ """
181
+ allowed = frozenset(allowed_fields)
182
+ result: dict[str, Any] = {}
183
+ for key in params:
184
+ parsed = _split_filter_key(key, allow_relations=allow_relations)
185
+ if parsed is None:
186
+ continue
187
+ field_path, lookup = parsed
188
+ if field_path not in allowed:
189
+ continue
190
+ raw = params.get(key)
191
+ if raw is None:
192
+ continue
193
+ kwarg_key = field_path if lookup == "exact" else f"{field_path}__{lookup}"
194
+ result[kwarg_key] = _coerce_filter_value(lookup, raw)
195
+ return result
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: hjtdev-appkit
3
+ Version: 2.0.0
4
+ Summary: Shared Django + DRF foundation every app package and host in this ecosystem depends on — cache, mixins, error envelope, request-ID plumbing, and the HttpClient/provider contract. Not an installable feature; the thing every other app is built on.
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.13
7
+ License-File: LICENSE
8
+ Requires-Dist: django<7.0,>=5.2
9
+ Requires-Dist: djangorestframework<4.0,>=3.15
10
+ Requires-Dist: nh3<1.0,>=0.2
11
+ Requires-Dist: puremagic<3,>=2
12
+ Requires-Dist: jdatetime<7,>=5
13
+ Provides-Extra: crypto
14
+ Requires-Dist: cryptography<51,>=42; extra == "crypto"
15
+ Provides-Extra: images
16
+ Requires-Dist: pillow<13,>=11.3; extra == "images"
17
+ Dynamic: license-file
@@ -0,0 +1,28 @@
1
+ appkit/__init__.py,sha256=Zpwi2ONa8CmyX-ZLCY7NF0fMwVCNaVilYEBpMNqQ1l4,2673
2
+ appkit/apps.py,sha256=Y1Uu1sMIYFB82MFEosIv5NcJ0kO7rZyZhjT0qIYIgag,1215
3
+ appkit/cache.py,sha256=u3B42OPqX4aTfAA0Bf4sW_6T37yyL9MwArJirjx0_wM,10504
4
+ appkit/checks.py,sha256=PZcanJi_zNV-mNPO8EPlIWLp0cRp2g5acTw6_WdG8Zo,24385
5
+ appkit/conf.py,sha256=RNEtJudQJEYdLxW0aIORSOUHGwKOfp1qFH59Du_AFeE,3245
6
+ appkit/crypto.py,sha256=NhhHC3-DWGdH_UhiuIfVoL8d5SIM0o6zv4PxiDU7MqQ,4217
7
+ appkit/dates.py,sha256=cxQRj2KYMCGP9BpJSBDnOc8-wYqr_Vx1-nIeqroK_h4,7826
8
+ appkit/exceptions.py,sha256=g2w9eJ_sV4K__ykcfhov4zmcV3ib3OS4MtKY3XkUniw,6963
9
+ appkit/files.py,sha256=ibOq68Zx9dg5UB0LIZ2WEIzaWl2xphrBOSRJOmxNLyc,11512
10
+ appkit/media.py,sha256=hPFXGtHfRTjeFB5JA2GeuhGcoXyBIq8QTIpZL7IEh10,3945
11
+ appkit/mixins.py,sha256=gjV73yP6PV9vrcfz75wgnhTqSsYx7HFvzO2vwxRBXI4,3022
12
+ appkit/money.py,sha256=3ygp5w6gKme0L-gNsPG_RlUft1JokaQfSw4tFqFgR7o,3143
13
+ appkit/net.py,sha256=MlVugRDh8OOMry5ymGqYnhUyICIZpzE3pcHUfukdNRQ,4468
14
+ appkit/pagination.py,sha256=7XKT6qiP8uVYFaWACOOpBbSQIJTQco20yLmEvoFkyso,1022
15
+ appkit/permissions.py,sha256=IsgAM77cBCt2hZiYbeeSxzSbSkSLe_J8bPAzJeZldIw,1753
16
+ appkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
+ appkit/request_id.py,sha256=wKaPbWH_sS6g7C9pFjA9gMtod1nhn41ZLjAP6UX8dGs,4939
18
+ appkit/testing.py,sha256=BzLuAXDgKrUOR2Uw3Gra3TxwSWE3B7-rrK9xx1wmFYI,11403
19
+ appkit/text.py,sha256=ktnt00ZORjKsbXQSrrLRB91yBOhi-vgGDfeNWG4eA2I,2681
20
+ appkit/throttling.py,sha256=nvcwYqhjwvxtr5lUQq9xIzAWtenyLMIj7g9iydEFlBo,1949
21
+ appkit/validation.py,sha256=p4VIVGtXEQ8tJYl4kYp6aNTRYJHvH1LyBL-owGiYjh8,8202
22
+ appkit/locale/fa/LC_MESSAGES/django.mo,sha256=SzaSQMeft3nTCS0F6PqnZ0r7e84MuU8E6g2RyfCnRgA,634
23
+ appkit/locale/fa/LC_MESSAGES/django.po,sha256=g8ghpaWUA2yT2BFqxaeswxYgp25qXfccd5S3DaA2QP0,850
24
+ hjtdev_appkit-2.0.0.dist-info/licenses/LICENSE,sha256=Fo2xRX9Qv4cl-jHlwoUOMUyLa36ibcxL6uf3viFRGHo,1083
25
+ hjtdev_appkit-2.0.0.dist-info/METADATA,sha256=QMb3xUdF8wJ_aHFj1nor9vVgiRfMEGlRzM4GBUUSsww,727
26
+ hjtdev_appkit-2.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
27
+ hjtdev_appkit-2.0.0.dist-info/top_level.txt,sha256=DBzILdCgXpBuKO5efVZjyoSdJ-t6fKsiz4-rKwrhpRg,7
28
+ hjtdev_appkit-2.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mohammad Hojjat Nikoobakht
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ appkit