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.
Files changed (124) hide show
  1. domainkit/__init__.py +40 -0
  2. domainkit/apps/__init__.py +6 -0
  3. domainkit/apps/config.py +118 -0
  4. domainkit/apps/registry.py +135 -0
  5. domainkit/architecture.py +193 -0
  6. domainkit/conf/__init__.py +58 -0
  7. domainkit/conf/env.py +260 -0
  8. domainkit/conf/global_settings.py +205 -0
  9. domainkit/conf/lazy.py +165 -0
  10. domainkit/context.py +43 -0
  11. domainkit/contrib/__init__.py +7 -0
  12. domainkit/contrib/audit/__init__.py +16 -0
  13. domainkit/contrib/audit/apps.py +32 -0
  14. domainkit/contrib/audit/management/__init__.py +1 -0
  15. domainkit/contrib/audit/management/commands/__init__.py +1 -0
  16. domainkit/contrib/audit/management/commands/auditlog.py +41 -0
  17. domainkit/contrib/audit/protection.py +79 -0
  18. domainkit/contrib/audit/recorder.py +133 -0
  19. domainkit/contrib/audit/settings.py +18 -0
  20. domainkit/contrib/audit/tables.py +24 -0
  21. domainkit/contrib/idempotency/__init__.py +11 -0
  22. domainkit/contrib/idempotency/apps.py +22 -0
  23. domainkit/contrib/idempotency/middleware.py +238 -0
  24. domainkit/contrib/idempotency/settings.py +18 -0
  25. domainkit/contrib/idempotency/tables.py +19 -0
  26. domainkit/contrib/idempotency/tasks.py +24 -0
  27. domainkit/contrib/messaging/__init__.py +26 -0
  28. domainkit/contrib/messaging/admin.py +54 -0
  29. domainkit/contrib/messaging/apps.py +52 -0
  30. domainkit/contrib/messaging/management/__init__.py +1 -0
  31. domainkit/contrib/messaging/management/commands/__init__.py +1 -0
  32. domainkit/contrib/messaging/management/commands/messages.py +46 -0
  33. domainkit/contrib/messaging/management/commands/runworker.py +59 -0
  34. domainkit/contrib/messaging/metrics.py +38 -0
  35. domainkit/contrib/messaging/outbox.py +155 -0
  36. domainkit/contrib/messaging/registry.py +174 -0
  37. domainkit/contrib/messaging/settings.py +39 -0
  38. domainkit/contrib/messaging/tables.py +34 -0
  39. domainkit/contrib/messaging/worker.py +320 -0
  40. domainkit/db/__init__.py +19 -0
  41. domainkit/db/connections.py +117 -0
  42. domainkit/db/drivers.py +100 -0
  43. domainkit/db/migration_env/__init__.py +1 -0
  44. domainkit/db/migration_env/env.py +5 -0
  45. domainkit/db/migration_env/script.py.mako +26 -0
  46. domainkit/db/migrations.py +276 -0
  47. domainkit/db/orm.py +44 -0
  48. domainkit/db/repository.py +33 -0
  49. domainkit/db/types.py +39 -0
  50. domainkit/db/uow.py +91 -0
  51. domainkit/ddd/__init__.py +44 -0
  52. domainkit/ddd/_dataclass.py +47 -0
  53. domainkit/ddd/events.py +127 -0
  54. domainkit/ddd/exceptions.py +48 -0
  55. domainkit/ddd/messages.py +16 -0
  56. domainkit/ddd/model.py +79 -0
  57. domainkit/ddd/pagination.py +24 -0
  58. domainkit/ddd/repository.py +94 -0
  59. domainkit/ddd/uow.py +114 -0
  60. domainkit/deprecation.py +21 -0
  61. domainkit/exceptions.py +8 -0
  62. domainkit/http/__init__.py +24 -0
  63. domainkit/http/application.py +73 -0
  64. domainkit/http/errors.py +179 -0
  65. domainkit/http/health.py +52 -0
  66. domainkit/http/metrics.py +150 -0
  67. domainkit/http/middleware.py +378 -0
  68. domainkit/http/pagination.py +71 -0
  69. domainkit/http/ratelimit.py +150 -0
  70. domainkit/log.py +119 -0
  71. domainkit/management/__init__.py +129 -0
  72. domainkit/management/base.py +87 -0
  73. domainkit/management/commands/__init__.py +1 -0
  74. domainkit/management/commands/check.py +37 -0
  75. domainkit/management/commands/makemigrations.py +61 -0
  76. domainkit/management/commands/migrate.py +64 -0
  77. domainkit/management/commands/runserver.py +60 -0
  78. domainkit/management/commands/shell.py +27 -0
  79. domainkit/management/commands/showmigrations.py +26 -0
  80. domainkit/management/commands/startdomain.py +165 -0
  81. domainkit/management/templates/domain/package/__init__.py-tpl +11 -0
  82. domainkit/management/templates/domain/package/api/__init__.py-tpl +1 -0
  83. domainkit/management/templates/domain/package/api/dependencies.py-tpl +47 -0
  84. domainkit/management/templates/domain/package/api/routes.py-tpl +74 -0
  85. domainkit/management/templates/domain/package/api/schemas.py-tpl +65 -0
  86. domainkit/management/templates/domain/package/application/__init__.py-tpl +1 -0
  87. domainkit/management/templates/domain/package/application/commands.py-tpl +24 -0
  88. domainkit/management/templates/domain/package/application/handlers.py-tpl +18 -0
  89. domainkit/management/templates/domain/package/application/services.py-tpl +63 -0
  90. domainkit/management/templates/domain/package/application/unit_of_work.py-tpl +12 -0
  91. domainkit/management/templates/domain/package/apps.py-tpl +22 -0
  92. domainkit/management/templates/domain/package/domain/__init__.py-tpl +1 -0
  93. domainkit/management/templates/domain/package/domain/events.py-tpl +28 -0
  94. domainkit/management/templates/domain/package/domain/exceptions.py-tpl +15 -0
  95. domainkit/management/templates/domain/package/domain/model.py-tpl +71 -0
  96. domainkit/management/templates/domain/package/domain/repositories.py-tpl +33 -0
  97. domainkit/management/templates/domain/package/infrastructure/__init__.py-tpl +1 -0
  98. domainkit/management/templates/domain/package/infrastructure/orm.py-tpl +44 -0
  99. domainkit/management/templates/domain/package/infrastructure/repositories.py-tpl +26 -0
  100. domainkit/management/templates/domain/package/infrastructure/unit_of_work.py-tpl +15 -0
  101. domainkit/management/templates/domain/package/management/__init__.py-tpl +1 -0
  102. domainkit/management/templates/domain/package/management/commands/__init__.py-tpl +1 -0
  103. domainkit/management/templates/domain/package/management/commands/{{ aggregates }}.py-tpl +25 -0
  104. domainkit/management/templates/domain/package/settings.py-tpl +30 -0
  105. domainkit/management/templates/domain/tests/__init__.py-tpl +1 -0
  106. domainkit/management/templates/domain/tests/fakes.py-tpl +47 -0
  107. domainkit/management/templates/domain/tests/test_api.py-tpl +104 -0
  108. domainkit/management/templates/domain/tests/test_model.py-tpl +67 -0
  109. domainkit/management/templates/domain/tests/test_services.py-tpl +71 -0
  110. domainkit/management/templates/domain/tests/test_settings.py-tpl +20 -0
  111. domainkit/py.typed +0 -0
  112. domainkit/reporting.py +50 -0
  113. domainkit/testing.py +83 -0
  114. domainkit/tracing.py +42 -0
  115. domainkit/utils/__init__.py +1 -0
  116. domainkit/utils/formatting.py +26 -0
  117. domainkit/utils/module_loading.py +22 -0
  118. domainkit/utils/rates.py +17 -0
  119. domainkit/utils/readiness.py +31 -0
  120. domainkit/utils/text.py +25 -0
  121. domainkit-0.1.0.dist-info/METADATA +188 -0
  122. domainkit-0.1.0.dist-info/RECORD +124 -0
  123. domainkit-0.1.0.dist-info/WHEEL +4 -0
  124. 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
+ """