domainkit 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.
- domainkit/__init__.py +40 -0
- domainkit/apps/__init__.py +6 -0
- domainkit/apps/config.py +118 -0
- domainkit/apps/registry.py +135 -0
- domainkit/architecture.py +193 -0
- domainkit/conf/__init__.py +58 -0
- domainkit/conf/env.py +260 -0
- domainkit/conf/global_settings.py +205 -0
- domainkit/conf/lazy.py +165 -0
- domainkit/context.py +43 -0
- domainkit/contrib/__init__.py +7 -0
- domainkit/contrib/audit/__init__.py +16 -0
- domainkit/contrib/audit/apps.py +32 -0
- domainkit/contrib/audit/management/__init__.py +1 -0
- domainkit/contrib/audit/management/commands/__init__.py +1 -0
- domainkit/contrib/audit/management/commands/auditlog.py +41 -0
- domainkit/contrib/audit/protection.py +79 -0
- domainkit/contrib/audit/recorder.py +133 -0
- domainkit/contrib/audit/settings.py +18 -0
- domainkit/contrib/audit/tables.py +24 -0
- domainkit/contrib/idempotency/__init__.py +11 -0
- domainkit/contrib/idempotency/apps.py +22 -0
- domainkit/contrib/idempotency/middleware.py +238 -0
- domainkit/contrib/idempotency/settings.py +18 -0
- domainkit/contrib/idempotency/tables.py +19 -0
- domainkit/contrib/idempotency/tasks.py +24 -0
- domainkit/contrib/messaging/__init__.py +26 -0
- domainkit/contrib/messaging/admin.py +54 -0
- domainkit/contrib/messaging/apps.py +52 -0
- domainkit/contrib/messaging/management/__init__.py +1 -0
- domainkit/contrib/messaging/management/commands/__init__.py +1 -0
- domainkit/contrib/messaging/management/commands/messages.py +46 -0
- domainkit/contrib/messaging/management/commands/runworker.py +59 -0
- domainkit/contrib/messaging/metrics.py +38 -0
- domainkit/contrib/messaging/outbox.py +155 -0
- domainkit/contrib/messaging/registry.py +174 -0
- domainkit/contrib/messaging/settings.py +39 -0
- domainkit/contrib/messaging/tables.py +34 -0
- domainkit/contrib/messaging/worker.py +320 -0
- domainkit/db/__init__.py +19 -0
- domainkit/db/connections.py +117 -0
- domainkit/db/drivers.py +100 -0
- domainkit/db/migration_env/__init__.py +1 -0
- domainkit/db/migration_env/env.py +5 -0
- domainkit/db/migration_env/script.py.mako +26 -0
- domainkit/db/migrations.py +276 -0
- domainkit/db/orm.py +44 -0
- domainkit/db/repository.py +33 -0
- domainkit/db/types.py +39 -0
- domainkit/db/uow.py +91 -0
- domainkit/ddd/__init__.py +44 -0
- domainkit/ddd/_dataclass.py +47 -0
- domainkit/ddd/events.py +127 -0
- domainkit/ddd/exceptions.py +48 -0
- domainkit/ddd/messages.py +16 -0
- domainkit/ddd/model.py +79 -0
- domainkit/ddd/pagination.py +24 -0
- domainkit/ddd/repository.py +94 -0
- domainkit/ddd/uow.py +114 -0
- domainkit/deprecation.py +21 -0
- domainkit/exceptions.py +8 -0
- domainkit/http/__init__.py +24 -0
- domainkit/http/application.py +73 -0
- domainkit/http/errors.py +179 -0
- domainkit/http/health.py +52 -0
- domainkit/http/metrics.py +150 -0
- domainkit/http/middleware.py +378 -0
- domainkit/http/pagination.py +71 -0
- domainkit/http/ratelimit.py +150 -0
- domainkit/log.py +119 -0
- domainkit/management/__init__.py +129 -0
- domainkit/management/base.py +87 -0
- domainkit/management/commands/__init__.py +1 -0
- domainkit/management/commands/check.py +37 -0
- domainkit/management/commands/makemigrations.py +61 -0
- domainkit/management/commands/migrate.py +64 -0
- domainkit/management/commands/runserver.py +60 -0
- domainkit/management/commands/shell.py +27 -0
- domainkit/management/commands/showmigrations.py +26 -0
- domainkit/management/commands/startdomain.py +165 -0
- domainkit/management/templates/domain/package/__init__.py-tpl +11 -0
- domainkit/management/templates/domain/package/api/__init__.py-tpl +1 -0
- domainkit/management/templates/domain/package/api/dependencies.py-tpl +47 -0
- domainkit/management/templates/domain/package/api/routes.py-tpl +74 -0
- domainkit/management/templates/domain/package/api/schemas.py-tpl +65 -0
- domainkit/management/templates/domain/package/application/__init__.py-tpl +1 -0
- domainkit/management/templates/domain/package/application/commands.py-tpl +24 -0
- domainkit/management/templates/domain/package/application/handlers.py-tpl +18 -0
- domainkit/management/templates/domain/package/application/services.py-tpl +63 -0
- domainkit/management/templates/domain/package/application/unit_of_work.py-tpl +12 -0
- domainkit/management/templates/domain/package/apps.py-tpl +22 -0
- domainkit/management/templates/domain/package/domain/__init__.py-tpl +1 -0
- domainkit/management/templates/domain/package/domain/events.py-tpl +28 -0
- domainkit/management/templates/domain/package/domain/exceptions.py-tpl +15 -0
- domainkit/management/templates/domain/package/domain/model.py-tpl +71 -0
- domainkit/management/templates/domain/package/domain/repositories.py-tpl +33 -0
- domainkit/management/templates/domain/package/infrastructure/__init__.py-tpl +1 -0
- domainkit/management/templates/domain/package/infrastructure/orm.py-tpl +44 -0
- domainkit/management/templates/domain/package/infrastructure/repositories.py-tpl +26 -0
- domainkit/management/templates/domain/package/infrastructure/unit_of_work.py-tpl +15 -0
- domainkit/management/templates/domain/package/management/__init__.py-tpl +1 -0
- domainkit/management/templates/domain/package/management/commands/__init__.py-tpl +1 -0
- domainkit/management/templates/domain/package/management/commands/{{ aggregates }}.py-tpl +25 -0
- domainkit/management/templates/domain/package/settings.py-tpl +30 -0
- domainkit/management/templates/domain/tests/__init__.py-tpl +1 -0
- domainkit/management/templates/domain/tests/fakes.py-tpl +47 -0
- domainkit/management/templates/domain/tests/test_api.py-tpl +104 -0
- domainkit/management/templates/domain/tests/test_model.py-tpl +67 -0
- domainkit/management/templates/domain/tests/test_services.py-tpl +71 -0
- domainkit/management/templates/domain/tests/test_settings.py-tpl +20 -0
- domainkit/py.typed +0 -0
- domainkit/reporting.py +50 -0
- domainkit/testing.py +83 -0
- domainkit/tracing.py +42 -0
- domainkit/utils/__init__.py +1 -0
- domainkit/utils/formatting.py +26 -0
- domainkit/utils/module_loading.py +22 -0
- domainkit/utils/rates.py +17 -0
- domainkit/utils/readiness.py +31 -0
- domainkit/utils/text.py +25 -0
- domainkit-0.1.0.dist-info/METADATA +188 -0
- domainkit-0.1.0.dist-info/RECORD +124 -0
- domainkit-0.1.0.dist-info/WHEEL +4 -0
- domainkit-0.1.0.dist-info/licenses/LICENSE +21 -0
domainkit/conf/env.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Typed environment reader for settings modules (django-environ style).
|
|
2
|
+
|
|
3
|
+
Every variable of a project shares one prefix, derived from the project name
|
|
4
|
+
in ``pyproject.toml`` (``billingAPI`` reads ``BILLING_API_DEBUG``); see
|
|
5
|
+
``project_prefix``. The framework's own variables use it too:
|
|
6
|
+
``<PREFIX>SETTINGS_MODULE`` selects the settings, like Django's
|
|
7
|
+
``DJANGO_SETTINGS_MODULE``.
|
|
8
|
+
|
|
9
|
+
The process environment wins over the project ``.env`` file. Set
|
|
10
|
+
``<PREFIX>ENV_FILE`` to another path, or to an empty value to skip the file;
|
|
11
|
+
relative paths are resolved from the project root.
|
|
12
|
+
|
|
13
|
+
Any variable can instead be given as ``<NAME>_FILE`` pointing to a file that
|
|
14
|
+
holds the value (Docker and Kubernetes secrets). Prefer files for secrets in
|
|
15
|
+
production: environment variables leak through process listings, crash
|
|
16
|
+
reports, and ``docker inspect``. ``.env`` values are taken literally; ``${...}``
|
|
17
|
+
is not expanded.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import builtins
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import tomllib
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from functools import cache, cached_property
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Final
|
|
28
|
+
|
|
29
|
+
from dotenv import dotenv_values
|
|
30
|
+
|
|
31
|
+
from domainkit.exceptions import ImproperlyConfigured
|
|
32
|
+
from domainkit.utils.text import snake_case
|
|
33
|
+
|
|
34
|
+
PROJECT_ROOT_VARIABLE: Final = "DOMAINKIT_PROJECT_ROOT"
|
|
35
|
+
"""Set it when the working directory is not inside the project."""
|
|
36
|
+
ENV_PREFIX_VARIABLE: Final = "DOMAINKIT_ENV_PREFIX"
|
|
37
|
+
"""Set it when the project's ``pyproject.toml`` is not shipped (e.g. wheels)."""
|
|
38
|
+
ENV_FILE_VARIABLE: Final = "ENV_FILE"
|
|
39
|
+
_PREFIX: Final = re.compile(r"(?:[A-Z][A-Z0-9_]*_)?")
|
|
40
|
+
_TRUE: Final = frozenset({"1", "true", "yes", "on"})
|
|
41
|
+
_FALSE: Final = frozenset({"0", "false", "no", "off"})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _Missing:
|
|
45
|
+
"""Sentinel type for settings that have no default and must be provided."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
MISSING: Final = _Missing()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@cache
|
|
52
|
+
def project_root() -> Path:
|
|
53
|
+
"""Return the directory of the project being run (not of domainkit).
|
|
54
|
+
|
|
55
|
+
``DOMAINKIT_PROJECT_ROOT`` wins; otherwise the nearest directory, from the
|
|
56
|
+
working directory upwards, that holds a ``pyproject.toml``; otherwise the
|
|
57
|
+
working directory itself. ``manage.py``, uvicorn, and the Docker image all
|
|
58
|
+
run from the project directory.
|
|
59
|
+
"""
|
|
60
|
+
configured = os.environ.get(PROJECT_ROOT_VARIABLE)
|
|
61
|
+
if configured:
|
|
62
|
+
return Path(configured).expanduser().resolve()
|
|
63
|
+
cwd = Path.cwd().resolve()
|
|
64
|
+
return next(
|
|
65
|
+
(path for path in (cwd, *cwd.parents) if (path / "pyproject.toml").is_file()),
|
|
66
|
+
cwd,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def prefix_for(name: str) -> str:
|
|
71
|
+
"""Turn a project name into a prefix (``billingAPI``: ``BILLING_API_``)."""
|
|
72
|
+
words = re.sub(r"[^A-Za-z0-9]+", "_", snake_case(name)).strip("_").upper()
|
|
73
|
+
return f"{words}_" if words else ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@cache
|
|
77
|
+
def project_prefix() -> str:
|
|
78
|
+
"""Return the prefix shared by this project's environment variables.
|
|
79
|
+
|
|
80
|
+
In order: ``DOMAINKIT_ENV_PREFIX``; ``[tool.domainkit] env-prefix`` in the
|
|
81
|
+
project's ``pyproject.toml`` (an empty string means no prefix); the
|
|
82
|
+
project name, ``[project].name``. Renaming the project renames its
|
|
83
|
+
variables.
|
|
84
|
+
"""
|
|
85
|
+
explicit = os.environ.get(ENV_PREFIX_VARIABLE)
|
|
86
|
+
if explicit is None:
|
|
87
|
+
pyproject = _read_pyproject()
|
|
88
|
+
options: dict[str, Any] = pyproject.get("tool", {}).get("domainkit", {})
|
|
89
|
+
explicit = options.get("env-prefix")
|
|
90
|
+
if explicit is None:
|
|
91
|
+
name = pyproject.get("project", {}).get("name")
|
|
92
|
+
if not name:
|
|
93
|
+
raise ImproperlyConfigured(
|
|
94
|
+
f"Cannot derive the environment prefix: set [project].name in "
|
|
95
|
+
f"{project_root() / 'pyproject.toml'} or {ENV_PREFIX_VARIABLE}."
|
|
96
|
+
)
|
|
97
|
+
explicit = prefix_for(name)
|
|
98
|
+
prefix = str(explicit).upper()
|
|
99
|
+
if prefix and not prefix.endswith("_"):
|
|
100
|
+
prefix = f"{prefix}_"
|
|
101
|
+
if not _PREFIX.fullmatch(prefix):
|
|
102
|
+
raise ImproperlyConfigured(
|
|
103
|
+
f"Environment prefix {prefix!r} must start with a letter and use only "
|
|
104
|
+
"A-Z, 0-9, and underscores."
|
|
105
|
+
)
|
|
106
|
+
return prefix
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _read_pyproject() -> dict[str, Any]:
|
|
110
|
+
"""Parse the project's ``pyproject.toml``; a missing file yields ``{}``."""
|
|
111
|
+
path = project_root() / "pyproject.toml"
|
|
112
|
+
if not path.is_file():
|
|
113
|
+
return {}
|
|
114
|
+
try:
|
|
115
|
+
return tomllib.loads(path.read_text(encoding="utf-8"))
|
|
116
|
+
except tomllib.TOMLDecodeError as exc:
|
|
117
|
+
raise ImproperlyConfigured(f"{path} is not valid TOML: {exc}") from exc
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Env:
|
|
121
|
+
"""Read prefixed variables and convert them, failing with the variable name.
|
|
122
|
+
|
|
123
|
+
``Env().int("WORKERS")`` reads ``<project prefix>WORKERS``; pass
|
|
124
|
+
``prefix`` to read another family of variables (``""`` for none).
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self, prefix: builtins.str | None = None, env_file: Path | None = None
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Use ``env_file`` instead of ``<PREFIX>ENV_FILE`` or the project ``.env``."""
|
|
131
|
+
self._prefix = prefix
|
|
132
|
+
self._env_file = env_file
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def prefix(self) -> builtins.str:
|
|
136
|
+
"""The prefix in use; the project prefix unless one was given."""
|
|
137
|
+
return project_prefix() if self._prefix is None else self._prefix
|
|
138
|
+
|
|
139
|
+
@cached_property
|
|
140
|
+
def file_values(self) -> dict[builtins.str, builtins.str]:
|
|
141
|
+
"""Load the ``.env`` file once; a missing file contributes nothing."""
|
|
142
|
+
path = self._env_file
|
|
143
|
+
if path is None:
|
|
144
|
+
configured = os.environ.get(f"{self.prefix}{ENV_FILE_VARIABLE}")
|
|
145
|
+
if configured == "":
|
|
146
|
+
return {}
|
|
147
|
+
path = _from_root(Path(configured or ".env"))
|
|
148
|
+
if not path.is_file():
|
|
149
|
+
return {}
|
|
150
|
+
values = dotenv_values(path, interpolate=False)
|
|
151
|
+
return {key: value for key, value in values.items() if value is not None}
|
|
152
|
+
|
|
153
|
+
def raw(self, name: builtins.str) -> builtins.str | None:
|
|
154
|
+
"""Return the unconverted value of ``prefix + name``, or None when unset."""
|
|
155
|
+
key = f"{self.prefix}{name}"
|
|
156
|
+
value = self._lookup(key)
|
|
157
|
+
secret_file = self._lookup(f"{key}_FILE")
|
|
158
|
+
if secret_file is None:
|
|
159
|
+
return value
|
|
160
|
+
if value is not None:
|
|
161
|
+
raise ImproperlyConfigured(f"Set either {key} or {key}_FILE, not both.")
|
|
162
|
+
path = _from_root(Path(secret_file))
|
|
163
|
+
try:
|
|
164
|
+
return path.read_text(encoding="utf-8").rstrip("\r\n")
|
|
165
|
+
except OSError as exc:
|
|
166
|
+
raise ImproperlyConfigured(f"{key}_FILE: cannot read {path}.") from exc
|
|
167
|
+
|
|
168
|
+
def str[D](
|
|
169
|
+
self, name: builtins.str, default: D | _Missing = MISSING
|
|
170
|
+
) -> builtins.str | D:
|
|
171
|
+
"""Read text."""
|
|
172
|
+
text = self.raw(name)
|
|
173
|
+
return self._default(name, default) if text is None else text
|
|
174
|
+
|
|
175
|
+
def int[D](
|
|
176
|
+
self, name: builtins.str, default: D | _Missing = MISSING
|
|
177
|
+
) -> builtins.int | D:
|
|
178
|
+
"""Read an integer."""
|
|
179
|
+
text = self.raw(name)
|
|
180
|
+
if text is None:
|
|
181
|
+
return self._default(name, default)
|
|
182
|
+
return self._convert(name, text, builtins.int, "an integer")
|
|
183
|
+
|
|
184
|
+
def float[D](
|
|
185
|
+
self, name: builtins.str, default: D | _Missing = MISSING
|
|
186
|
+
) -> builtins.float | D:
|
|
187
|
+
"""Read a number."""
|
|
188
|
+
text = self.raw(name)
|
|
189
|
+
if text is None:
|
|
190
|
+
return self._default(name, default)
|
|
191
|
+
return self._convert(name, text, builtins.float, "a number")
|
|
192
|
+
|
|
193
|
+
def bool[D](
|
|
194
|
+
self, name: builtins.str, default: D | _Missing = MISSING
|
|
195
|
+
) -> builtins.bool | D:
|
|
196
|
+
"""Read 1/0, true/false, yes/no, or on/off (any case)."""
|
|
197
|
+
text = self.raw(name)
|
|
198
|
+
if text is None:
|
|
199
|
+
return self._default(name, default)
|
|
200
|
+
normalized = text.strip().lower()
|
|
201
|
+
if normalized in _TRUE:
|
|
202
|
+
return True
|
|
203
|
+
if normalized in _FALSE:
|
|
204
|
+
return False
|
|
205
|
+
raise self._invalid(name, text, "a boolean (true/false)")
|
|
206
|
+
|
|
207
|
+
def list[D](
|
|
208
|
+
self, name: builtins.str, default: D | _Missing = MISSING
|
|
209
|
+
) -> builtins.list[builtins.str] | D:
|
|
210
|
+
"""Read comma-separated text; blank items are dropped."""
|
|
211
|
+
text = self.raw(name)
|
|
212
|
+
if text is None:
|
|
213
|
+
return self._default(name, default)
|
|
214
|
+
return [item.strip() for item in text.split(",") if item.strip()]
|
|
215
|
+
|
|
216
|
+
def path[D](self, name: builtins.str, default: D | _Missing = MISSING) -> Path | D:
|
|
217
|
+
"""Read a file system path; relative paths start at the project root."""
|
|
218
|
+
text = self.raw(name)
|
|
219
|
+
if text is None:
|
|
220
|
+
return self._default(name, default)
|
|
221
|
+
return _from_root(Path(text).expanduser())
|
|
222
|
+
|
|
223
|
+
def _lookup(self, key: builtins.str) -> builtins.str | None:
|
|
224
|
+
"""Return ``key`` from the process environment, else from ``.env``."""
|
|
225
|
+
value = os.environ.get(key)
|
|
226
|
+
return value if value is not None else self.file_values.get(key)
|
|
227
|
+
|
|
228
|
+
def _default[D](self, name: builtins.str, default: D | _Missing) -> D:
|
|
229
|
+
"""Return the caller's default, or fail when the variable is required."""
|
|
230
|
+
if isinstance(default, _Missing):
|
|
231
|
+
raise ImproperlyConfigured(
|
|
232
|
+
f"Set the {self.prefix}{name} environment variable."
|
|
233
|
+
)
|
|
234
|
+
return default
|
|
235
|
+
|
|
236
|
+
def _convert[T](
|
|
237
|
+
self,
|
|
238
|
+
name: builtins.str,
|
|
239
|
+
text: builtins.str,
|
|
240
|
+
cast: Callable[[builtins.str], T],
|
|
241
|
+
expected: builtins.str,
|
|
242
|
+
) -> T:
|
|
243
|
+
"""Convert with ``cast`` or explain which variable is invalid."""
|
|
244
|
+
try:
|
|
245
|
+
return cast(text)
|
|
246
|
+
except ValueError as exc:
|
|
247
|
+
raise self._invalid(name, text, expected) from exc
|
|
248
|
+
|
|
249
|
+
def _invalid(
|
|
250
|
+
self, name: builtins.str, text: builtins.str, expected: builtins.str
|
|
251
|
+
) -> ImproperlyConfigured:
|
|
252
|
+
"""Build the error for a value that cannot be converted."""
|
|
253
|
+
return ImproperlyConfigured(
|
|
254
|
+
f"{self.prefix}{name} must be {expected}, got {text!r}."
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _from_root(path: Path) -> Path:
|
|
259
|
+
"""Resolve relative paths against the project root."""
|
|
260
|
+
return path if path.is_absolute() else project_root() / path
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Framework settings and their defaults (Django's ``global_settings``).
|
|
2
|
+
|
|
3
|
+
Every UPPERCASE name in the selected settings module is validated here. A name
|
|
4
|
+
the framework does not declare must belong to an installed domain (see
|
|
5
|
+
``DomainConfig.settings_class``) or to the project's own ``SETTINGS_CLASS``,
|
|
6
|
+
so a misspelled setting fails at startup instead of being silently ignored.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Final, Literal, Self
|
|
12
|
+
|
|
13
|
+
from pydantic import (
|
|
14
|
+
BaseModel,
|
|
15
|
+
ConfigDict,
|
|
16
|
+
Field,
|
|
17
|
+
IPvAnyNetwork,
|
|
18
|
+
SecretStr,
|
|
19
|
+
model_validator,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from domainkit.conf.env import project_root
|
|
23
|
+
from domainkit.utils.rates import parse_rate
|
|
24
|
+
|
|
25
|
+
DEFAULT_DB_ALIAS: Final = "default"
|
|
26
|
+
|
|
27
|
+
Environment = Literal["local", "test", "staging", "production"]
|
|
28
|
+
DEPLOYED_ENVIRONMENTS: Final = frozenset({"staging", "production"})
|
|
29
|
+
"""Environments held to the deployment checks in ``Settings``."""
|
|
30
|
+
|
|
31
|
+
DEFAULT_MIDDLEWARE: Final = (
|
|
32
|
+
"domainkit.http.middleware.ProxyHeadersMiddleware",
|
|
33
|
+
"domainkit.http.middleware.SecurityHeadersMiddleware",
|
|
34
|
+
"domainkit.http.middleware.RequestContextMiddleware",
|
|
35
|
+
"domainkit.http.middleware.AllowedHostsMiddleware",
|
|
36
|
+
"domainkit.http.middleware.RequestBodyLimitMiddleware",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _minimal_logging() -> dict[str, Any]:
|
|
41
|
+
"""Leave existing logging untouched when a module does not define LOGGING."""
|
|
42
|
+
return {"version": 1, "disable_existing_loggers": False}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
SSLMode = Literal["disable", "require", "verify-ca", "verify-full"]
|
|
46
|
+
"""libpq TLS modes, minus ``allow`` and ``prefer`` (they fall back to plaintext)."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_network_database(url: str) -> bool:
|
|
50
|
+
"""Report whether ``url`` reaches a server over the network (not SQLite)."""
|
|
51
|
+
return not url.split(":", 1)[0].split("+", 1)[0].startswith("sqlite")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class DatabaseConfig(BaseModel):
|
|
55
|
+
"""One ``DATABASES`` entry: an SQLAlchemy async URL plus connection options."""
|
|
56
|
+
|
|
57
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
58
|
+
|
|
59
|
+
URL: SecretStr
|
|
60
|
+
"""``dialect+driver://user:password@host/name``; hidden in reprs and errors."""
|
|
61
|
+
MIGRATION_URL: SecretStr | None = None
|
|
62
|
+
"""Schema-owner credentials used by ``migrate``; defaults to ``URL``."""
|
|
63
|
+
ECHO: bool = False
|
|
64
|
+
"""Log every statement (development only)."""
|
|
65
|
+
LOG_PARAMETERS: bool = False
|
|
66
|
+
"""Show bound values in SQL logs and error messages; they may hold secrets."""
|
|
67
|
+
|
|
68
|
+
SSLMODE: SSLMode | None = None
|
|
69
|
+
"""TLS mode; None keeps the driver default. Deployments need ``verify-full``."""
|
|
70
|
+
SSLROOTCERT: Path | None = None
|
|
71
|
+
"""CA bundle that signed the server certificate; None uses the system CAs."""
|
|
72
|
+
SSLCERT: Path | None = None
|
|
73
|
+
"""Client certificate for mutual TLS; requires ``SSLKEY``."""
|
|
74
|
+
SSLKEY: Path | None = None
|
|
75
|
+
|
|
76
|
+
STATEMENT_TIMEOUT: float | None = Field(default=30.0, gt=0)
|
|
77
|
+
"""Seconds before PostgreSQL cancels a statement; migrations are exempt."""
|
|
78
|
+
LOCK_TIMEOUT: float | None = Field(default=10.0, gt=0)
|
|
79
|
+
"""Seconds a statement may wait for a lock."""
|
|
80
|
+
IDLE_IN_TRANSACTION_TIMEOUT: float | None = Field(default=60.0, gt=0)
|
|
81
|
+
"""Seconds an open transaction may sit idle before the server ends it."""
|
|
82
|
+
|
|
83
|
+
POOL_SIZE: int | None = Field(default=None, ge=1)
|
|
84
|
+
MAX_OVERFLOW: int | None = Field(default=None, ge=0)
|
|
85
|
+
POOL_TIMEOUT: float | None = Field(default=None, gt=0)
|
|
86
|
+
POOL_RECYCLE: int | None = Field(default=None, gt=0)
|
|
87
|
+
POOL_PRE_PING: bool = True
|
|
88
|
+
CONNECT_ARGS: dict[str, Any] = Field(default_factory=dict)
|
|
89
|
+
"""Extra driver arguments; they override the ones derived from the settings."""
|
|
90
|
+
|
|
91
|
+
@model_validator(mode="after")
|
|
92
|
+
def check_client_certificate(self) -> Self:
|
|
93
|
+
"""Keep the client certificate and key paired."""
|
|
94
|
+
if (self.SSLCERT is None) != (self.SSLKEY is None):
|
|
95
|
+
raise ValueError("SSLCERT and SSLKEY must be set together")
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Settings(BaseModel):
|
|
100
|
+
"""Validated, read-only settings; extra names are checked at ``setup()``."""
|
|
101
|
+
|
|
102
|
+
model_config = ConfigDict(frozen=True, extra="allow")
|
|
103
|
+
|
|
104
|
+
SETTINGS_MODULE: str = ""
|
|
105
|
+
"""Set by the loader to the module that produced these settings."""
|
|
106
|
+
BASE_DIR: Path = Field(default_factory=project_root)
|
|
107
|
+
ENVIRONMENT: Environment = "local"
|
|
108
|
+
"""Staging and production get the deployment checks below."""
|
|
109
|
+
DEBUG: bool = False
|
|
110
|
+
PROJECT_NAME: str = "API"
|
|
111
|
+
VERSION: str = "0.1.0"
|
|
112
|
+
|
|
113
|
+
INSTALLED_DOMAINS: tuple[str, ...] = ()
|
|
114
|
+
"""Domain packages, started in order and stopped in reverse order."""
|
|
115
|
+
MIDDLEWARE: tuple[str, ...] = DEFAULT_MIDDLEWARE
|
|
116
|
+
"""ASGI middleware classes by dotted path; the first one is outermost."""
|
|
117
|
+
ROOT_ROUTER: str | None = None
|
|
118
|
+
"""Dotted path to the ``APIRouter`` that mounts every route (``ROOT_URLCONF``)."""
|
|
119
|
+
ASGI_APPLICATION: str | None = None
|
|
120
|
+
"""``module:attribute`` that ``runserver`` serves."""
|
|
121
|
+
|
|
122
|
+
DATABASES: dict[str, DatabaseConfig] = Field(default_factory=dict)
|
|
123
|
+
MIGRATIONS_DIR: Path = Field(default_factory=lambda: project_root() / "migrations")
|
|
124
|
+
"""Where ``makemigrations`` writes revision files."""
|
|
125
|
+
LOGGING: dict[str, Any] = Field(default_factory=_minimal_logging)
|
|
126
|
+
"""``logging.config.dictConfig`` mapping applied by ``setup()``."""
|
|
127
|
+
|
|
128
|
+
ALLOWED_HOSTS: tuple[str, ...] = ("localhost", "127.0.0.1", "[::1]")
|
|
129
|
+
"""Accepted ``Host`` header values; ``*.example.com`` matches subdomains."""
|
|
130
|
+
API_DOCS: bool = True
|
|
131
|
+
"""Serve ``/docs``, ``/redoc``, and ``/openapi.json``."""
|
|
132
|
+
MAX_REQUEST_BODY_SIZE: int = Field(default=1_048_576, ge=1)
|
|
133
|
+
"""Largest accepted request body in bytes; larger requests get 413."""
|
|
134
|
+
READINESS_TIMEOUT: float = Field(default=5.0, gt=0)
|
|
135
|
+
"""Seconds each readiness check may take before it counts as failed."""
|
|
136
|
+
READINESS_CACHE_SECONDS: float = Field(default=1.0, ge=0)
|
|
137
|
+
"""Reuse a readiness result this long, so probe floods cannot load the database."""
|
|
138
|
+
READINESS_DETAILS: bool = False
|
|
139
|
+
"""Name failing components in ``/ready`` responses; they are always logged."""
|
|
140
|
+
ACCESS_LOG_QUIET_PATHS: tuple[str, ...] = ("/health", "/ready")
|
|
141
|
+
"""Successful requests to these paths are logged at DEBUG, not INFO."""
|
|
142
|
+
TRUSTED_PROXIES: tuple[IPvAnyNetwork, ...] = ()
|
|
143
|
+
"""Load balancers whose ``X-Forwarded-For``/``-Proto`` headers are believed."""
|
|
144
|
+
RATE_LIMIT: str | None = None
|
|
145
|
+
"""Requests per caller, e.g. ``"300/minute"``; None disables the limit."""
|
|
146
|
+
RATE_LIMIT_EXEMPT_PATHS: tuple[str, ...] = ("/health", "/ready", "/metrics")
|
|
147
|
+
"""Exact paths never counted, so probes and scrapers are not refused."""
|
|
148
|
+
RATE_LIMIT_BACKEND: str = "domainkit.http.ratelimit.MemoryBackend"
|
|
149
|
+
"""Dotted path to a ``Backend`` class; the default counts per process."""
|
|
150
|
+
METRICS_TOKEN: SecretStr | None = None
|
|
151
|
+
"""Bearer token for ``/metrics``; without it staging and production refuse."""
|
|
152
|
+
ERROR_REPORTERS: tuple[str, ...] = ()
|
|
153
|
+
"""Callables ``(exc, context) -> None`` by dotted path, e.g. a Sentry adapter."""
|
|
154
|
+
|
|
155
|
+
SECURE_HSTS_SECONDS: int = Field(default=0, ge=0)
|
|
156
|
+
"""``Strict-Transport-Security`` max-age; 0 omits the header."""
|
|
157
|
+
SECURE_HSTS_INCLUDE_SUBDOMAINS: bool = False
|
|
158
|
+
SECURE_CONTENT_TYPE_NOSNIFF: bool = True
|
|
159
|
+
SECURE_REFERRER_POLICY: str | None = "no-referrer"
|
|
160
|
+
X_FRAME_OPTIONS: Literal["DENY", "SAMEORIGIN"] = "DENY"
|
|
161
|
+
|
|
162
|
+
CORS_ALLOWED_ORIGINS: tuple[str, ...] = ()
|
|
163
|
+
CORS_ALLOWED_METHODS: tuple[str, ...] = ("GET", "POST", "PUT", "PATCH", "DELETE")
|
|
164
|
+
CORS_ALLOWED_HEADERS: tuple[str, ...] = (
|
|
165
|
+
"Authorization",
|
|
166
|
+
"Content-Type",
|
|
167
|
+
"X-Request-ID",
|
|
168
|
+
)
|
|
169
|
+
CORS_ALLOW_CREDENTIALS: bool = False
|
|
170
|
+
|
|
171
|
+
@model_validator(mode="after")
|
|
172
|
+
def check_consistency(self) -> Self:
|
|
173
|
+
"""Reject combinations that are unsafe or would fail later at runtime."""
|
|
174
|
+
if self.DATABASES and DEFAULT_DB_ALIAS not in self.DATABASES:
|
|
175
|
+
raise ValueError(f"DATABASES must define the {DEFAULT_DB_ALIAS!r} alias")
|
|
176
|
+
if len(set(self.INSTALLED_DOMAINS)) != len(self.INSTALLED_DOMAINS):
|
|
177
|
+
raise ValueError("INSTALLED_DOMAINS contains duplicates")
|
|
178
|
+
if self.CORS_ALLOW_CREDENTIALS and "*" in self.CORS_ALLOWED_ORIGINS:
|
|
179
|
+
raise ValueError("CORS_ALLOW_CREDENTIALS cannot be used with origin '*'")
|
|
180
|
+
if self.RATE_LIMIT is not None:
|
|
181
|
+
parse_rate(self.RATE_LIMIT)
|
|
182
|
+
if self.ENVIRONMENT in DEPLOYED_ENVIRONMENTS:
|
|
183
|
+
self._check_deployment()
|
|
184
|
+
return self
|
|
185
|
+
|
|
186
|
+
def _check_deployment(self) -> None:
|
|
187
|
+
"""Enforce the security baseline for staging and production."""
|
|
188
|
+
where = f"in {self.ENVIRONMENT}"
|
|
189
|
+
if self.DEBUG:
|
|
190
|
+
raise ValueError(f"DEBUG must be False {where}")
|
|
191
|
+
if "*" in self.ALLOWED_HOSTS:
|
|
192
|
+
raise ValueError(f"ALLOWED_HOSTS must list real host names {where}")
|
|
193
|
+
rules: dict[str, Callable[[DatabaseConfig], bool]] = {
|
|
194
|
+
"ECHO logs every statement; disable it": lambda db: db.ECHO,
|
|
195
|
+
"LOG_PARAMETERS exposes bound values; disable it": lambda db: (
|
|
196
|
+
db.LOG_PARAMETERS
|
|
197
|
+
),
|
|
198
|
+
"SSLMODE must be 'verify-full'": lambda db: (
|
|
199
|
+
is_network_database(db.URL.get_secret_value())
|
|
200
|
+
and db.SSLMODE != "verify-full"
|
|
201
|
+
),
|
|
202
|
+
}
|
|
203
|
+
for message, violates in rules.items():
|
|
204
|
+
if aliases := sorted(a for a, db in self.DATABASES.items() if violates(db)):
|
|
205
|
+
raise ValueError(f"{message} {where} for {aliases}")
|
domainkit/conf/lazy.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Lazy settings proxy that imports and validates the selected settings module."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from typing import Any, Final
|
|
7
|
+
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
|
|
10
|
+
from domainkit.conf.env import Env
|
|
11
|
+
from domainkit.conf.global_settings import Settings
|
|
12
|
+
from domainkit.exceptions import ImproperlyConfigured
|
|
13
|
+
from domainkit.utils.module_loading import import_string
|
|
14
|
+
|
|
15
|
+
SETTINGS_MODULE_VARIABLE: Final = "SETTINGS_MODULE"
|
|
16
|
+
SETTINGS_CLASS_NAME: Final = "SETTINGS_CLASS"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _Unset:
|
|
20
|
+
"""Sentinel type for ``override_settings(NAME=UNSET)``."""
|
|
21
|
+
|
|
22
|
+
def __repr__(self) -> str:
|
|
23
|
+
"""Name the sentinel in reprs."""
|
|
24
|
+
return "UNSET"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
UNSET: Final = _Unset()
|
|
28
|
+
"""Pass to ``override_settings`` to remove a setting (Django's ``del settings.X``)."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def describe_errors(exc: ValidationError) -> str:
|
|
32
|
+
"""List failing setting paths and reasons, never the submitted values.
|
|
33
|
+
|
|
34
|
+
Pydantic's own message embeds the input (a database URL with its password,
|
|
35
|
+
for example), so it must not reach logs or the console.
|
|
36
|
+
"""
|
|
37
|
+
lines = []
|
|
38
|
+
for error in exc.errors(
|
|
39
|
+
include_url=False, include_input=False, include_context=False
|
|
40
|
+
):
|
|
41
|
+
location = ".".join(str(part) for part in error["loc"]) or "settings"
|
|
42
|
+
lines.append(f" {location}: {error['msg']}")
|
|
43
|
+
return "\n".join(lines)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_settings(module_name: str) -> Settings:
|
|
47
|
+
"""Validate every public UPPERCASE name of ``module_name``.
|
|
48
|
+
|
|
49
|
+
The module may name its own schema with ``SETTINGS_CLASS`` (a ``Settings``
|
|
50
|
+
subclass or a dotted path to one) to declare project-wide settings.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
module = importlib.import_module(module_name)
|
|
54
|
+
except ModuleNotFoundError as exc:
|
|
55
|
+
missing = exc.name or ""
|
|
56
|
+
if module_name == missing or module_name.startswith(f"{missing}."):
|
|
57
|
+
raise ImproperlyConfigured(
|
|
58
|
+
f"Settings module {module_name!r} does not exist."
|
|
59
|
+
) from exc
|
|
60
|
+
raise
|
|
61
|
+
values = {
|
|
62
|
+
name: getattr(module, name)
|
|
63
|
+
for name in dir(module)
|
|
64
|
+
if name.isupper() and not name.startswith("_")
|
|
65
|
+
}
|
|
66
|
+
schema = _schema(values.pop(SETTINGS_CLASS_NAME, Settings))
|
|
67
|
+
values["SETTINGS_MODULE"] = module_name
|
|
68
|
+
try:
|
|
69
|
+
return schema.model_validate(values)
|
|
70
|
+
except ValidationError as exc:
|
|
71
|
+
# "from None": the chained ValidationError would print the inputs.
|
|
72
|
+
raise ImproperlyConfigured(
|
|
73
|
+
f"Invalid settings in {module_name!r}:\n{describe_errors(exc)}"
|
|
74
|
+
) from None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _schema(value: object) -> type[Settings]:
|
|
78
|
+
"""Resolve ``SETTINGS_CLASS`` to a ``Settings`` subclass."""
|
|
79
|
+
if isinstance(value, str):
|
|
80
|
+
try:
|
|
81
|
+
value = import_string(value)
|
|
82
|
+
except ImportError as exc:
|
|
83
|
+
raise ImproperlyConfigured(f"{SETTINGS_CLASS_NAME}: {exc}") from exc
|
|
84
|
+
if isinstance(value, type) and issubclass(value, Settings):
|
|
85
|
+
return value
|
|
86
|
+
raise ImproperlyConfigured(
|
|
87
|
+
f"{SETTINGS_CLASS_NAME} must be a subclass of domainkit.conf.Settings."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class LazySettings:
|
|
92
|
+
"""Load settings on first access, like ``django.conf.settings``."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, env: Env) -> None:
|
|
95
|
+
"""Read the module name from ``<PREFIX>SETTINGS_MODULE`` through ``env``."""
|
|
96
|
+
self._env = env
|
|
97
|
+
self._wrapped: Settings | None = None
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def configured(self) -> bool:
|
|
101
|
+
"""Report whether a settings module has been loaded."""
|
|
102
|
+
return self._wrapped is not None
|
|
103
|
+
|
|
104
|
+
def __getattr__(self, name: str) -> Any: # noqa: ANN401 - proxies any setting.
|
|
105
|
+
"""Load settings on first use, then delegate attribute access."""
|
|
106
|
+
if name.startswith("_"):
|
|
107
|
+
raise AttributeError(name)
|
|
108
|
+
return getattr(self._setup(), name)
|
|
109
|
+
|
|
110
|
+
def __setattr__(self, name: str, value: object) -> None:
|
|
111
|
+
"""Settings are read-only at runtime; tests use ``override_settings``."""
|
|
112
|
+
if not name.startswith("_"):
|
|
113
|
+
raise AttributeError(
|
|
114
|
+
"Settings are read-only; use override_settings() in tests."
|
|
115
|
+
)
|
|
116
|
+
super().__setattr__(name, value)
|
|
117
|
+
|
|
118
|
+
def unwrap(self) -> Settings:
|
|
119
|
+
"""Return the loaded ``Settings`` object itself (loading it if needed)."""
|
|
120
|
+
return self._setup()
|
|
121
|
+
|
|
122
|
+
def configure(self, module_name: str) -> None:
|
|
123
|
+
"""Load ``module_name`` now instead of ``<PREFIX>SETTINGS_MODULE``."""
|
|
124
|
+
self._wrapped = load_settings(module_name)
|
|
125
|
+
|
|
126
|
+
def reset(self) -> None:
|
|
127
|
+
"""Forget loaded settings; the next access loads them again."""
|
|
128
|
+
self._wrapped = None
|
|
129
|
+
|
|
130
|
+
@contextmanager
|
|
131
|
+
def override(self, **changes: object) -> Iterator[None]:
|
|
132
|
+
"""Temporarily replace settings, re-running schema validation.
|
|
133
|
+
|
|
134
|
+
A value of ``UNSET`` removes the setting, restoring its default.
|
|
135
|
+
"""
|
|
136
|
+
current = self._setup()
|
|
137
|
+
values = {**dict(current), **changes}
|
|
138
|
+
values = {name: value for name, value in values.items() if value is not UNSET}
|
|
139
|
+
try:
|
|
140
|
+
replacement = type(current).model_validate(values)
|
|
141
|
+
except ValidationError as exc:
|
|
142
|
+
raise ImproperlyConfigured(
|
|
143
|
+
f"Invalid settings override:\n{describe_errors(exc)}"
|
|
144
|
+
) from None
|
|
145
|
+
self._wrapped = replacement
|
|
146
|
+
try:
|
|
147
|
+
yield
|
|
148
|
+
finally:
|
|
149
|
+
self._wrapped = current
|
|
150
|
+
|
|
151
|
+
@contextmanager
|
|
152
|
+
def use(self, module_name: str) -> Iterator[None]:
|
|
153
|
+
"""Temporarily load another settings module, then restore the current one."""
|
|
154
|
+
previous = self._wrapped
|
|
155
|
+
self._wrapped = load_settings(module_name)
|
|
156
|
+
try:
|
|
157
|
+
yield
|
|
158
|
+
finally:
|
|
159
|
+
self._wrapped = previous
|
|
160
|
+
|
|
161
|
+
def _setup(self) -> Settings:
|
|
162
|
+
"""Return loaded settings, importing the configured module once."""
|
|
163
|
+
if self._wrapped is None:
|
|
164
|
+
self._wrapped = load_settings(self._env.str(SETTINGS_MODULE_VARIABLE))
|
|
165
|
+
return self._wrapped
|
domainkit/context.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Per-request context shared by logging and error responses.
|
|
2
|
+
|
|
3
|
+
The context lives in a ``ContextVar``, so concurrent requests never see each
|
|
4
|
+
other's values, and tasks started inside a request inherit them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from contextvars import ContextVar
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class RequestContext:
|
|
15
|
+
"""Facts about the request or job being served; no personal data.
|
|
16
|
+
|
|
17
|
+
``actor`` names who is acting. Authentication sets it; None means an
|
|
18
|
+
anonymous caller, and background work carries the actor that queued it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
request_id: str
|
|
22
|
+
client_ip: str | None
|
|
23
|
+
method: str
|
|
24
|
+
path: str
|
|
25
|
+
actor: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_current: ContextVar[RequestContext | None] = ContextVar("request", default=None)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def current_request() -> RequestContext | None:
|
|
32
|
+
"""Return the request being served, or None outside a request."""
|
|
33
|
+
return _current.get()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@contextmanager
|
|
37
|
+
def bind_request(context: RequestContext) -> Iterator[None]:
|
|
38
|
+
"""Make ``context`` current for the duration of the block."""
|
|
39
|
+
token = _current.set(context)
|
|
40
|
+
try:
|
|
41
|
+
yield
|
|
42
|
+
finally:
|
|
43
|
+
_current.reset(token)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Optional domains that ship with domainkit; install them in INSTALLED_DOMAINS.
|
|
2
|
+
|
|
3
|
+
- ``domainkit.contrib.messaging``: transactional outbox, durable event
|
|
4
|
+
handlers, background and periodic tasks, and the ``runworker`` command.
|
|
5
|
+
- ``domainkit.contrib.audit``: an append-only audit trail of committed events.
|
|
6
|
+
- ``domainkit.contrib.idempotency``: replay-safe ``Idempotency-Key`` requests.
|
|
7
|
+
"""
|