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/crypto.py ADDED
@@ -0,0 +1,102 @@
1
+ """Fernet symmetric encryption primitive taking its key at construction time.
2
+
3
+ Requires the ``crypto`` extra (``hjtdev-appkit[crypto]``). A missing extra must fail with an
4
+ actionable message, never a bare ImportError — import ``cryptography`` lazily inside the
5
+ function/method that needs it, wrapped in try/except ImportError, re-raised naming the exact
6
+ fix (``Install with: uv add "hjtdev-appkit[crypto]"`` / ``pip install "hjtdev-appkit[crypto]"``).
7
+ This error path is itself unit-tested (docs/CONTRACT.md §9).
8
+
9
+ appkit never reads ``settings.FERNET_KEY`` or any other Django setting for this — the key is
10
+ always a call-time argument. This is the resolution to the tools/-vs-appkit tension
11
+ (docs/CONTRACT.md §3): field-level crypto stays in ``tools/crypto.py`` permanently, wrapping the
12
+ HOST's ``FERNET_KEY``; an app declaring ``hjtdev-appkit[crypto]`` builds a ``Cipher`` from its OWN
13
+ documented ``.env`` key. appkit therefore requires no ``.env`` key and no settings key for
14
+ encryption, under any install combination.
15
+
16
+ Public surface (docs/CONTRACT.md §2.5):
17
+
18
+ class Cipher:
19
+ def __init__(self, key: str | bytes) -> None: ... # raises ImproperlyConfigured on a
20
+ # bad key
21
+ def encrypt(self, value: str) -> str: ...
22
+ def decrypt(self, token: str) -> str: ... # raises
23
+ # cryptography.fernet.InvalidToken
24
+
25
+ def generate_key() -> str: ...
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import TYPE_CHECKING
31
+
32
+ from django.core.exceptions import ImproperlyConfigured
33
+
34
+ if TYPE_CHECKING:
35
+ from cryptography.fernet import Fernet
36
+
37
+ __all__ = ["Cipher", "generate_key"]
38
+
39
+ _INSTALL_HINT = (
40
+ 'Install with: uv add "hjtdev-appkit[crypto]" (or: pip install "hjtdev-appkit[crypto]")'
41
+ )
42
+
43
+
44
+ def _fernet_class() -> type[Fernet]:
45
+ """Lazily imports `cryptography.fernet.Fernet`, behind the `crypto` extra.
46
+
47
+ A missing extra must fail with an actionable message, never a bare `ImportError` three
48
+ frames deep — this path is unit-tested by simulating the import failure.
49
+ """
50
+ try:
51
+ from cryptography.fernet import Fernet
52
+ except ImportError as exc:
53
+ raise ImportError(
54
+ f"appkit.crypto requires the 'cryptography' package. {_INSTALL_HINT}"
55
+ ) from exc
56
+ return Fernet
57
+
58
+
59
+ class Cipher:
60
+ """Fernet symmetric encryption, keyed at construction — never from Django settings.
61
+
62
+ A host's own encryption key is its own documented `.env` key; this class only ever wraps
63
+ whatever key its caller passes in.
64
+ """
65
+
66
+ def __init__(self, key: str | bytes) -> None:
67
+ """Builds the underlying `Fernet` cipher from `key`.
68
+
69
+ Raises:
70
+ ImportError: if the `crypto` extra isn't installed.
71
+ ImproperlyConfigured: if `key` isn't a valid Fernet key (44-byte urlsafe-base64) —
72
+ never the raw `cryptography` `ValueError`/`TypeError`/`binascii.Error` — naming
73
+ `generate_key()` as the fix.
74
+ """
75
+ fernet_cls = _fernet_class()
76
+ try:
77
+ self._fernet = fernet_cls(key)
78
+ except (TypeError, ValueError) as exc:
79
+ raise ImproperlyConfigured(
80
+ "appkit.crypto.Cipher() was given a key that is not a valid Fernet key. "
81
+ "Generate one with appkit.crypto.generate_key()."
82
+ ) from exc
83
+
84
+ def encrypt(self, value: str) -> str:
85
+ """Returns a URL-safe token string. Never raises for any `str` input."""
86
+ return self._fernet.encrypt(value.encode()).decode()
87
+
88
+ def decrypt(self, token: str) -> str:
89
+ """Decrypts a token produced by `encrypt`.
90
+
91
+ Raises `cryptography.fernet.InvalidToken` for a tampered, expired (if a TTL was used),
92
+ or wrong-key token — never silently returns garbage.
93
+ """
94
+ return self._fernet.decrypt(token.encode()).decode()
95
+
96
+
97
+ def generate_key() -> str:
98
+ """Thin wrapper over `Fernet.generate_key().decode()` — provisions a key for `Cipher`
99
+ without a caller needing to `import cryptography` directly.
100
+ """
101
+ fernet_cls = _fernet_class()
102
+ return fernet_cls.generate_key().decode()
appkit/dates.py ADDED
@@ -0,0 +1,183 @@
1
+ """Gregorian <-> Jalali conversion, formatting, and parsing using stdlib types only.
2
+
3
+ No third-party type in any public signature — ``jdatetime``/``jalali-core`` stay internal
4
+ (docs/CONTRACT.md §9). A major-version bump in either can never force an appkit major bump on
5
+ its own.
6
+
7
+ Public surface (docs/CONTRACT.md §2.13):
8
+
9
+ def to_jalali(value: date | datetime) -> tuple[int, int, int]: ...
10
+ def from_jalali(year: int, month: int, day: int) -> date: ... # raises ValueError
11
+ def format_jalali(value: date | datetime, fmt: str = "%Y/%m/%d") -> str: ...
12
+ def parse_jalali(value: str, fmt: str = "%Y/%m/%d") -> date: ... # raises ValueError
13
+
14
+ Golden vectors verified against tests/fixtures/jalali-vectors.json (docs/CONTRACT.md §19):
15
+ every leap year in the 33-year Jalali cycle plus adjacent non-leap years, Esfand 29 vs. 30 in
16
+ both leap and non-leap years, Nowruz across several years, the 31-day/30-day month-length
17
+ boundary, Gregorian leap years including century years, and dates well outside the near present
18
+ — every vector round-tripped in both directions.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import datetime as dt
24
+ import re
25
+ from typing import Final
26
+
27
+ import jdatetime
28
+ from django.utils import timezone
29
+
30
+ from appkit.text import to_english_digits
31
+
32
+ __all__ = ["format_jalali", "from_jalali", "parse_jalali", "to_jalali"]
33
+
34
+ # docs/CONTRACT.md §18: format_jalali/parseJalali support only the numeric directives below —
35
+ # no month names ship in v1.0.0 (removes an entire Persian-spelling-drift divergence class
36
+ # between the two halves). An unrecognised directive raises, mirroring strftime's own failure
37
+ # mode, which stdlib strftime itself does NOT do for %B/%A-style directives it doesn't
38
+ # recognise — this is why format_jalali/parse_jalali can't simply delegate to strftime/strptime.
39
+ _SUPPORTED_DIRECTIVES: Final[frozenset[str]] = frozenset("YmdHMS%")
40
+
41
+ _DIRECTIVE_REGEX: Final[dict[str, str]] = {
42
+ "Y": r"\d{1,4}",
43
+ "m": r"\d{1,2}",
44
+ "d": r"\d{1,2}",
45
+ "H": r"\d{1,2}",
46
+ "M": r"\d{1,2}",
47
+ "S": r"\d{1,2}",
48
+ }
49
+
50
+
51
+ def to_jalali(value: dt.date | dt.datetime) -> tuple[int, int, int]:
52
+ """Returns `(year, month, day)` for the Jalali calendar date corresponding to `value`.
53
+
54
+ **The timezone rule (docs/CONTRACT.md §18), implemented exactly as stated, not re-decided:**
55
+ an *aware* `datetime` is localised via `django.utils.timezone.localtime()` (Django's own
56
+ `TIME_ZONE` setting) before the Jalali date is extracted — a datetime at `23:30 UTC` may
57
+ already be tomorrow in `Asia/Tehran`. A *naive* `datetime` is treated as already-local
58
+ (`localtime()` itself raises on a naive value, so it's simply not called). A plain `date`
59
+ has no timezone component and is converted directly.
60
+
61
+ `datetime` is checked before `date` — `datetime` is a subclass of `date`, so the reverse
62
+ order would silently treat every `datetime` as a plain, timezone-naive date. Never raises
63
+ for any valid `date`/`datetime`.
64
+ """
65
+ if isinstance(value, dt.datetime):
66
+ localized = timezone.localtime(value) if timezone.is_aware(value) else value
67
+ gregorian_date = localized.date()
68
+ else:
69
+ gregorian_date = value
70
+ jalali_date = jdatetime.date.fromgregorian(date=gregorian_date)
71
+ return jalali_date.year, jalali_date.month, jalali_date.day
72
+
73
+
74
+ def from_jalali(year: int, month: int, day: int) -> dt.date:
75
+ """The inverse of `to_jalali`.
76
+
77
+ Raises `ValueError` for an invalid Jalali calendar date (day 31 in a 30-day Jalali month,
78
+ an invalid Esfand 30) — the same exception shape `datetime.date(...)` itself raises for an
79
+ invalid Gregorian date, so callers don't need a Jalali-specific except clause.
80
+ """
81
+ jalali_date = jdatetime.date(year, month, day) # raises ValueError on an invalid date
82
+ result: dt.date = jalali_date.togregorian()
83
+ return result
84
+
85
+
86
+ def _time_components(value: dt.date | dt.datetime) -> tuple[int, int, int]:
87
+ if not isinstance(value, dt.datetime):
88
+ return 0, 0, 0
89
+ localized = timezone.localtime(value) if timezone.is_aware(value) else value
90
+ return localized.hour, localized.minute, localized.second
91
+
92
+
93
+ def format_jalali(value: dt.date | dt.datetime, fmt: str = "%Y/%m/%d") -> str:
94
+ """`strftime`-style formatting of `value`'s Jalali representation.
95
+
96
+ Supports only `%Y %m %d %H %M %S %%` (docs/CONTRACT.md §18 — no month names in v1.0.0).
97
+ Never raises for a valid `date`/`datetime` input; an unsupported directive raises
98
+ `ValueError`, mirroring stdlib `strftime`'s own failure mode for a malformed format string.
99
+ """
100
+ year, month, day = to_jalali(value)
101
+ hour, minute, second = _time_components(value)
102
+ substitutions = {
103
+ "Y": f"{year:04d}",
104
+ "m": f"{month:02d}",
105
+ "d": f"{day:02d}",
106
+ "H": f"{hour:02d}",
107
+ "M": f"{minute:02d}",
108
+ "S": f"{second:02d}",
109
+ "%": "%",
110
+ }
111
+
112
+ result: list[str] = []
113
+ i = 0
114
+ while i < len(fmt):
115
+ char = fmt[i]
116
+ if char != "%":
117
+ result.append(char)
118
+ i += 1
119
+ continue
120
+ if i + 1 >= len(fmt):
121
+ raise ValueError(f"appkit.dates.format_jalali: dangling '%' at end of format {fmt!r}")
122
+ directive = fmt[i + 1]
123
+ if directive not in _SUPPORTED_DIRECTIVES:
124
+ raise ValueError(
125
+ f"appkit.dates.format_jalali: unsupported format directive '%{directive}' in "
126
+ f"{fmt!r}"
127
+ )
128
+ result.append(substitutions[directive])
129
+ i += 2
130
+ return "".join(result)
131
+
132
+
133
+ def _compile_format(fmt: str) -> re.Pattern[str]:
134
+ """Builds a matching regex for `fmt`, one named group per first occurrence of a directive.
135
+
136
+ A directive repeated in `fmt` gets a non-capturing group on its second+ occurrence — Python
137
+ regex forbids duplicate group names, and there's no meaningful way to prefer one occurrence
138
+ over another for parsing anyway.
139
+ """
140
+ parts: list[str] = []
141
+ seen: set[str] = set()
142
+ i = 0
143
+ while i < len(fmt):
144
+ char = fmt[i]
145
+ if char != "%":
146
+ parts.append(re.escape(char))
147
+ i += 1
148
+ continue
149
+ if i + 1 >= len(fmt):
150
+ raise ValueError(f"appkit.dates.parse_jalali: dangling '%' at end of format {fmt!r}")
151
+ directive = fmt[i + 1]
152
+ if directive == "%":
153
+ parts.append(re.escape("%"))
154
+ elif directive in _DIRECTIVE_REGEX:
155
+ body = _DIRECTIVE_REGEX[directive]
156
+ parts.append(f"(?:{body})" if directive in seen else f"(?P<{directive}>{body})")
157
+ seen.add(directive)
158
+ else:
159
+ raise ValueError(
160
+ f"appkit.dates.parse_jalali: unsupported format directive '%{directive}' in {fmt!r}"
161
+ )
162
+ i += 2
163
+ return re.compile("".join(parts))
164
+
165
+
166
+ def parse_jalali(value: str, fmt: str = "%Y/%m/%d") -> dt.date:
167
+ """The inverse of `format_jalali`.
168
+
169
+ Runs `to_english_digits` internally first — Persian-keyboard input is the common real-world
170
+ case for a date typed by a user, not pasted. Raises `ValueError` for a string that doesn't
171
+ match `fmt`, or that names an invalid Jalali date — never returns a best-guess/partial
172
+ result. A directive omitted from `fmt` (e.g. `fmt="%Y"` alone) defaults to `1` for month/day.
173
+ """
174
+ pattern = _compile_format(fmt)
175
+ match = pattern.fullmatch(to_english_digits(value))
176
+ if match is None:
177
+ raise ValueError(f"appkit.dates.parse_jalali: {value!r} does not match format {fmt!r}")
178
+
179
+ groups = match.groupdict()
180
+ year = int(groups["Y"]) if groups.get("Y") is not None else 1
181
+ month = int(groups["m"]) if groups.get("m") is not None else 1
182
+ day = int(groups["d"]) if groups.get("d") is not None else 1
183
+ return from_jalali(year, month, day) # raises ValueError for an invalid Jalali date
appkit/exceptions.py ADDED
@@ -0,0 +1,158 @@
1
+ """The single DRF exception handler producing the standard error envelope.
2
+
3
+ Envelope shape, verbatim (docs/CONTRACT.md §1)::
4
+
5
+ {"error": {"code": "validation_error", "message": "...", "details": {}, "request_id": "..."}}
6
+
7
+ ``details`` is always present (``{}`` when nothing is field-level). ``request_id`` is the same
8
+ correlation ID :data:`appkit.request_id.request_id_var` carries. Headers DRF already sets
9
+ (``Retry-After`` on ``throttled``, ``WWW-Authenticate`` on ``not_authenticated``/
10
+ ``authentication_failed``) are untouched — the handler only ever rewrites ``response.data``.
11
+
12
+ The code set is TEN, not nine (docs/CONTRACT.md §1's documented drift correction) — ``"error"``
13
+ is the documented catch-all for any ``APIException`` DRF resolved to a response but that isn't
14
+ one of the other nine specific types. For ``"error"``, the HTTP status is authoritative, not the
15
+ code.
16
+
17
+ Public surface, implemented in a later phase:
18
+
19
+ ERROR_CODES: Final[tuple[str, ...]]
20
+ The ten codes, in this exact order:
21
+ validation_error, parse_error, not_authenticated, authentication_failed,
22
+ permission_denied, not_found, method_not_allowed, throttled, server_error, error.
23
+ Pinned against tests/fixtures/error-codes.json (docs/CONTRACT.md §19), not hand-verified
24
+ against the frontend's ApiErrorCode union directly.
25
+
26
+ standard_exception_handler(exc: Exception, context: dict[str, Any]) -> Response | None
27
+ A plain Django Http404/PermissionDenied is converted to its DRF equivalent before code
28
+ lookup. An unhandled exception (DRF's handler returns None) is logged via
29
+ logger.exception before being turned into a server_error envelope. Three own
30
+ user-facing strings — "Validation failed.", "Request failed.", "Internal server
31
+ error." — are wrapped in gettext_lazy.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import logging
37
+ from typing import Any, Final
38
+
39
+ from django.conf import settings
40
+ from django.core.exceptions import PermissionDenied as DjangoPermissionDenied
41
+ from django.http import Http404
42
+ from django.utils.translation import gettext_lazy as _
43
+ from rest_framework import exceptions as drf_exceptions
44
+ from rest_framework.response import Response
45
+ from rest_framework.views import exception_handler as drf_exception_handler
46
+
47
+ from appkit.request_id import request_id_var
48
+
49
+ __all__ = ["ERROR_CODES", "standard_exception_handler"]
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+ #: The ten codes, in the exact order given in docs/CONTRACT.md §1 — pinned against
54
+ #: tests/fixtures/error-codes.json rather than hand-verified against the frontend's
55
+ #: ApiErrorCode union directly (docs/CONTRACT.md §19).
56
+ ERROR_CODES: Final[tuple[str, ...]] = (
57
+ "validation_error",
58
+ "parse_error",
59
+ "not_authenticated",
60
+ "authentication_failed",
61
+ "permission_denied",
62
+ "not_found",
63
+ "method_not_allowed",
64
+ "throttled",
65
+ "server_error",
66
+ "error",
67
+ )
68
+
69
+ # Ordered most-specific-first: several of these subclass one another, so isinstance order
70
+ # matters — this is a straight port of the scaffold's list (docs/CONTRACT.md §1, §2.3).
71
+ _CODE_BY_EXCEPTION: list[tuple[type[Exception], str]] = [
72
+ (drf_exceptions.ValidationError, "validation_error"),
73
+ (drf_exceptions.ParseError, "parse_error"),
74
+ (drf_exceptions.NotAuthenticated, "not_authenticated"),
75
+ (drf_exceptions.AuthenticationFailed, "authentication_failed"),
76
+ (drf_exceptions.PermissionDenied, "permission_denied"),
77
+ (drf_exceptions.NotFound, "not_found"),
78
+ (drf_exceptions.MethodNotAllowed, "method_not_allowed"),
79
+ (drf_exceptions.Throttled, "throttled"),
80
+ ]
81
+
82
+
83
+ def _code_for(exc: Exception) -> str:
84
+ for exc_type, code in _CODE_BY_EXCEPTION:
85
+ if isinstance(exc, exc_type):
86
+ return code
87
+ return "error" # the documented catch-all — some other APIException DRF already resolved
88
+
89
+
90
+ def _message_and_details(data: Any, *, code: str) -> tuple[str, dict[str, Any]]:
91
+ """Splits DRF's raw `response.data` into a flat message plus a details dict.
92
+
93
+ A nested per-field dict (a serializer's validation errors) is passed through as `details`
94
+ intact — the naive flat-message collapse used below must not fire for this case, or which
95
+ field each message belongs to is lost.
96
+ """
97
+ if isinstance(data, dict) and set(data) == {"detail"}:
98
+ return str(data["detail"]), {}
99
+ if isinstance(data, list):
100
+ return "; ".join(str(item) for item in data), {"non_field_errors": data}
101
+ if isinstance(data, dict):
102
+ message = (
103
+ str(_("Validation failed."))
104
+ if code == "validation_error"
105
+ else str(_("Request failed."))
106
+ )
107
+ return message, data
108
+ return str(data), {}
109
+
110
+
111
+ def standard_exception_handler(exc: Exception, context: dict[str, Any]) -> Response | None:
112
+ """DRF `EXCEPTION_HANDLER` producing the envelope described in this module's docstring.
113
+
114
+ Delegates to DRF's own `exception_handler` first and rewrites only `response.data` —
115
+ never rebuilds the `Response` from scratch — so headers DRF already set (`Retry-After`
116
+ on a throttled response, `WWW-Authenticate` on a 401) survive untouched.
117
+ """
118
+ # DRF's own exception_handler converts a plain Django Http404/PermissionDenied into its
119
+ # DRF equivalent internally, on a *new* exception object it builds and discards — it never
120
+ # hands that conversion back to us. Without redoing it here, _code_for(exc) below would see
121
+ # the original Http404/PermissionDenied, match nothing, and fall through to "error".
122
+ if isinstance(exc, Http404):
123
+ exc = drf_exceptions.NotFound(*exc.args)
124
+ elif isinstance(exc, DjangoPermissionDenied):
125
+ exc = drf_exceptions.PermissionDenied(*exc.args)
126
+
127
+ response = drf_exception_handler(exc, context)
128
+
129
+ if response is None:
130
+ # DRF returns None for anything that isn't an APIException/Http404/PermissionDenied —
131
+ # a genuinely unhandled exception. That would otherwise propagate to Django's own error
132
+ # handling (which is what triggers `django.request` logging and Sentry capture) —
133
+ # returning a 500 envelope here instead swallows that unless we log explicitly first.
134
+ logger.exception("Unhandled exception in view", exc_info=exc)
135
+ message = str(exc) if settings.DEBUG else str(_("Internal server error."))
136
+ return Response(
137
+ {
138
+ "error": {
139
+ "code": "server_error",
140
+ "message": message,
141
+ "details": {},
142
+ "request_id": request_id_var.get(),
143
+ }
144
+ },
145
+ status=500,
146
+ )
147
+
148
+ code = _code_for(exc)
149
+ message, details = _message_and_details(response.data, code=code)
150
+ response.data = {
151
+ "error": {
152
+ "code": code,
153
+ "message": message,
154
+ "details": details,
155
+ "request_id": request_id_var.get(),
156
+ }
157
+ }
158
+ return response