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/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""domainkit: a Django-style framework for domain-driven FastAPI services.
|
|
2
|
+
|
|
3
|
+
Layers, lowest first; a module may import only from its own layer or below:
|
|
4
|
+
|
|
5
|
+
1. ``exceptions``, ``utils``, ``ddd``: no settings, no I/O. ``ddd`` holds the
|
|
6
|
+
domain building blocks (entities, value objects, events, repositories).
|
|
7
|
+
2. ``conf``: settings loading and validation.
|
|
8
|
+
3. ``apps``, ``context``, ``log``, ``reporting``: the domain registry and
|
|
9
|
+
observability.
|
|
10
|
+
4. ``db``, ``http``: SQLAlchemy and FastAPI integration.
|
|
11
|
+
5. ``management``: the ``manage.py`` commands.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
__version__ = version("domainkit")
|
|
18
|
+
except PackageNotFoundError: # pragma: no cover - running from a source tree
|
|
19
|
+
__version__ = "0.0.0+unknown"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def setup() -> None:
|
|
23
|
+
"""Configure logging and load the installed domains (``django.setup()``).
|
|
24
|
+
|
|
25
|
+
Safe to call more than once; later calls do nothing.
|
|
26
|
+
"""
|
|
27
|
+
from domainkit.apps import registry
|
|
28
|
+
from domainkit.conf import settings
|
|
29
|
+
from domainkit.ddd import event_bus
|
|
30
|
+
from domainkit.log import configure_logging
|
|
31
|
+
from domainkit.reporting import report_error, reporters
|
|
32
|
+
|
|
33
|
+
if registry.ready:
|
|
34
|
+
return
|
|
35
|
+
configure_logging(settings.LOGGING)
|
|
36
|
+
reporters()
|
|
37
|
+
event_bus.error_hook = lambda exc, event: report_error(
|
|
38
|
+
exc, event=type(event).__qualname__, event_id=str(event.event_id)
|
|
39
|
+
)
|
|
40
|
+
registry.populate(settings.INSTALLED_DOMAINS)
|
domainkit/apps/config.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""``DomainConfig``: Django's ``AppConfig`` for a bounded context."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from importlib import import_module
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from types import ModuleType
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from domainkit.exceptions import ImproperlyConfigured
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from domainkit.ddd import DeferredTask, DomainEvent, UnitOfWork
|
|
15
|
+
|
|
16
|
+
CONFIG_MODULE = "apps"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DomainConfig:
|
|
20
|
+
"""Describe one installed domain and its lifecycle hooks.
|
|
21
|
+
|
|
22
|
+
Each domain package may define exactly one subclass in its ``apps``
|
|
23
|
+
module; a package without one gets this base class. Override only the
|
|
24
|
+
hooks the domain needs.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
label: str = ""
|
|
28
|
+
"""Unique short name; defaults to the last part of the package path."""
|
|
29
|
+
verbose_name: str = ""
|
|
30
|
+
settings_class: type[BaseModel] | None = None
|
|
31
|
+
"""Schema for this domain's settings, read from ``settings_name``."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, name: str, module: ModuleType) -> None:
|
|
34
|
+
"""Bind the config to the domain package ``name``."""
|
|
35
|
+
self.name = name
|
|
36
|
+
self.module = module
|
|
37
|
+
self.label = self.label or name.rpartition(".")[2]
|
|
38
|
+
if not self.label.isidentifier():
|
|
39
|
+
raise ImproperlyConfigured(
|
|
40
|
+
f"Domain label {self.label!r} is not a valid Python identifier."
|
|
41
|
+
)
|
|
42
|
+
self.verbose_name = self.verbose_name or self.label.replace("_", " ").title()
|
|
43
|
+
|
|
44
|
+
def __repr__(self) -> str:
|
|
45
|
+
"""Show the class and the domain label."""
|
|
46
|
+
return f"<{type(self).__name__}: {self.label}>"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def settings_name(self) -> str:
|
|
50
|
+
"""Settings-module name holding this domain's settings, e.g. ``BILLING``."""
|
|
51
|
+
return self.label.upper()
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def path(self) -> Path:
|
|
55
|
+
"""Directory of the domain package."""
|
|
56
|
+
return Path(next(iter(self.module.__path__)))
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def create(cls, name: str) -> "DomainConfig":
|
|
60
|
+
"""Import package ``name`` and instantiate its ``DomainConfig``."""
|
|
61
|
+
module = _import_domain(name)
|
|
62
|
+
config_module_name = f"{name}.{CONFIG_MODULE}"
|
|
63
|
+
try:
|
|
64
|
+
config_module = import_module(config_module_name)
|
|
65
|
+
except ModuleNotFoundError as exc:
|
|
66
|
+
if exc.name != config_module_name:
|
|
67
|
+
raise
|
|
68
|
+
return cls(name, module)
|
|
69
|
+
configs = [
|
|
70
|
+
value
|
|
71
|
+
for value in vars(config_module).values()
|
|
72
|
+
if isinstance(value, type)
|
|
73
|
+
and issubclass(value, DomainConfig)
|
|
74
|
+
and value.__module__ == config_module_name
|
|
75
|
+
]
|
|
76
|
+
if len(configs) != 1:
|
|
77
|
+
raise ImproperlyConfigured(
|
|
78
|
+
f"{config_module_name!r} must define exactly one DomainConfig "
|
|
79
|
+
f"subclass, found {len(configs)}."
|
|
80
|
+
)
|
|
81
|
+
return configs[0](name, module)
|
|
82
|
+
|
|
83
|
+
def ready(self) -> None:
|
|
84
|
+
"""Wire the domain once settings are loaded: ORM mappers, event handlers."""
|
|
85
|
+
|
|
86
|
+
async def startup(self) -> None:
|
|
87
|
+
"""Open process resources such as clients and pools."""
|
|
88
|
+
|
|
89
|
+
async def shutdown(self) -> None:
|
|
90
|
+
"""Release resources opened by ``startup``."""
|
|
91
|
+
|
|
92
|
+
async def check(self) -> None:
|
|
93
|
+
"""Raise when the domain cannot serve traffic (readiness probe)."""
|
|
94
|
+
|
|
95
|
+
async def stage(
|
|
96
|
+
self,
|
|
97
|
+
uow: "UnitOfWork",
|
|
98
|
+
events: Sequence["DomainEvent"],
|
|
99
|
+
tasks: Sequence["DeferredTask"],
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Add rows to a committing SQL unit of work (outbox, audit trail)."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _import_domain(name: str) -> ModuleType:
|
|
105
|
+
"""Import a domain package, explaining a missing one."""
|
|
106
|
+
try:
|
|
107
|
+
module = import_module(name)
|
|
108
|
+
except ModuleNotFoundError as exc:
|
|
109
|
+
if exc.name is not None and (
|
|
110
|
+
name == exc.name or name.startswith(f"{exc.name}.")
|
|
111
|
+
):
|
|
112
|
+
raise ImproperlyConfigured(
|
|
113
|
+
f"Installed domain {name!r} cannot be imported."
|
|
114
|
+
) from exc
|
|
115
|
+
raise
|
|
116
|
+
if not hasattr(module, "__path__"):
|
|
117
|
+
raise ImproperlyConfigured(f"Installed domain {name!r} must be a package.")
|
|
118
|
+
return module
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""The registry of installed domains (``django.apps.apps``)."""
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
from collections.abc import AsyncIterator, Iterable, Iterator
|
|
5
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
6
|
+
from typing import Final, cast
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ValidationError
|
|
9
|
+
|
|
10
|
+
from domainkit.apps.config import DomainConfig
|
|
11
|
+
from domainkit.conf import describe_errors, settings, unwrap_settings
|
|
12
|
+
from domainkit.exceptions import ImproperlyConfigured
|
|
13
|
+
from domainkit.utils.readiness import failing_checks
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DomainRegistry:
|
|
17
|
+
"""Installed domains in ``INSTALLED_DOMAINS`` order."""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
"""Start empty; ``populate`` loads the domains."""
|
|
21
|
+
self._configs: dict[str, DomainConfig] = {}
|
|
22
|
+
self._settings_cache: dict[type[BaseModel], tuple[object, BaseModel]] = {}
|
|
23
|
+
self.ready = False
|
|
24
|
+
|
|
25
|
+
def populate(self, installed: Iterable[str]) -> None:
|
|
26
|
+
"""Load every domain, validate its settings, then call ``ready()``.
|
|
27
|
+
|
|
28
|
+
Does nothing once the registry is ready; ``clear()`` starts over.
|
|
29
|
+
"""
|
|
30
|
+
if self.ready:
|
|
31
|
+
return
|
|
32
|
+
configs: dict[str, DomainConfig] = {}
|
|
33
|
+
for name in installed:
|
|
34
|
+
config = DomainConfig.create(name)
|
|
35
|
+
if config.label in configs:
|
|
36
|
+
raise ImproperlyConfigured(
|
|
37
|
+
f"Domain label {config.label!r} is not unique "
|
|
38
|
+
f"({configs[config.label].name!r} and {name!r})."
|
|
39
|
+
)
|
|
40
|
+
configs[config.label] = config
|
|
41
|
+
self._configs = configs
|
|
42
|
+
self._settings_cache.clear()
|
|
43
|
+
self._check_settings()
|
|
44
|
+
for config in configs.values():
|
|
45
|
+
config.ready()
|
|
46
|
+
self.ready = True
|
|
47
|
+
|
|
48
|
+
def clear(self) -> None:
|
|
49
|
+
"""Forget every domain (tests); the next ``populate`` loads them again."""
|
|
50
|
+
self._configs = {}
|
|
51
|
+
self._settings_cache.clear()
|
|
52
|
+
self.ready = False
|
|
53
|
+
|
|
54
|
+
def __iter__(self) -> Iterator[DomainConfig]:
|
|
55
|
+
"""Iterate in ``INSTALLED_DOMAINS`` order."""
|
|
56
|
+
return iter(self._configs.values())
|
|
57
|
+
|
|
58
|
+
def __len__(self) -> int:
|
|
59
|
+
"""Return the number of installed domains."""
|
|
60
|
+
return len(self._configs)
|
|
61
|
+
|
|
62
|
+
def get_domain_config(self, label: str) -> DomainConfig:
|
|
63
|
+
"""Return the domain with ``label``."""
|
|
64
|
+
try:
|
|
65
|
+
return self._configs[label]
|
|
66
|
+
except KeyError:
|
|
67
|
+
raise LookupError(f"No installed domain with label {label!r}.") from None
|
|
68
|
+
|
|
69
|
+
def is_installed(self, name: str) -> bool:
|
|
70
|
+
"""Report whether the package ``name`` is an installed domain."""
|
|
71
|
+
return any(config.name == name for config in self)
|
|
72
|
+
|
|
73
|
+
def settings[S: BaseModel](self, settings_class: type[S]) -> S:
|
|
74
|
+
"""Return the validated settings of the domain that declares ``settings_class``.
|
|
75
|
+
|
|
76
|
+
Values are re-validated when ``override_settings`` replaces them.
|
|
77
|
+
"""
|
|
78
|
+
config = next((c for c in self if c.settings_class is settings_class), None)
|
|
79
|
+
if config is None:
|
|
80
|
+
raise ImproperlyConfigured(
|
|
81
|
+
f"No installed domain declares {settings_class.__qualname__}."
|
|
82
|
+
)
|
|
83
|
+
raw = getattr(settings, config.settings_name, None)
|
|
84
|
+
cached = self._settings_cache.get(settings_class)
|
|
85
|
+
if cached is not None and cached[0] is raw:
|
|
86
|
+
return cast(S, cached[1])
|
|
87
|
+
try:
|
|
88
|
+
value = settings_class.model_validate({} if raw is None else raw)
|
|
89
|
+
except ValidationError as exc:
|
|
90
|
+
raise ImproperlyConfigured(
|
|
91
|
+
f"Invalid {config.settings_name} settings:\n{describe_errors(exc)}"
|
|
92
|
+
) from None
|
|
93
|
+
self._settings_cache[settings_class] = (raw, value)
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
@asynccontextmanager
|
|
97
|
+
async def lifespan(self) -> AsyncIterator[None]:
|
|
98
|
+
"""Start domains in order and stop started ones in reverse, even on failure."""
|
|
99
|
+
async with AsyncExitStack() as stack:
|
|
100
|
+
for config in self:
|
|
101
|
+
await config.startup()
|
|
102
|
+
stack.push_async_callback(config.shutdown)
|
|
103
|
+
yield
|
|
104
|
+
|
|
105
|
+
async def check(self, seconds: float) -> list[str]:
|
|
106
|
+
"""Run every domain's readiness check; return the labels that failed."""
|
|
107
|
+
return await failing_checks(
|
|
108
|
+
{config.label: config.check for config in self}, seconds
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def _check_settings(self) -> None:
|
|
112
|
+
"""Validate domain settings and reject names nothing declares."""
|
|
113
|
+
claimed = set()
|
|
114
|
+
for config in self:
|
|
115
|
+
if config.settings_class is not None:
|
|
116
|
+
claimed.add(config.settings_name)
|
|
117
|
+
self.settings(config.settings_class)
|
|
118
|
+
current = unwrap_settings()
|
|
119
|
+
if unknown := sorted(set(current.model_extra or {}) - claimed):
|
|
120
|
+
known = [*type(current).model_fields, *claimed]
|
|
121
|
+
raise ImproperlyConfigured(
|
|
122
|
+
f"Unknown settings in {current.SETTINGS_MODULE!r}: "
|
|
123
|
+
+ ", ".join(_describe_unknown(name, known) for name in unknown)
|
|
124
|
+
+ ". Declare them in a domain's settings_class or SETTINGS_CLASS."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _describe_unknown(name: str, known: list[str]) -> str:
|
|
129
|
+
"""Name an unknown setting and suggest the closest known one."""
|
|
130
|
+
matches = difflib.get_close_matches(name, known, n=1)
|
|
131
|
+
return f"{name} (did you mean {matches[0]}?)" if matches else name
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
registry: Final = DomainRegistry()
|
|
135
|
+
"""The process-wide registry populated by ``domainkit.setup()``."""
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Enforce the dependency rules of a domain-driven package layout.
|
|
2
|
+
|
|
3
|
+
Each domain package has four layers; dependencies point inward only::
|
|
4
|
+
|
|
5
|
+
api -> infrastructure -> application -> domain
|
|
6
|
+
|
|
7
|
+
- ``domain``: the standard library, ``domainkit.ddd``, and its own domain layer.
|
|
8
|
+
No frameworks, no I/O, no settings.
|
|
9
|
+
- ``application``: no ``infrastructure`` or ``api`` code, and no web or
|
|
10
|
+
database libraries; it reaches storage through repository ports.
|
|
11
|
+
- ``infrastructure``: no ``api`` code and no web libraries.
|
|
12
|
+
- Another domain may be used only through its ``application`` layer or its
|
|
13
|
+
``domain.events`` (bounded contexts stay decoupled).
|
|
14
|
+
|
|
15
|
+
``python manage.py check`` and the test suite run these rules for every
|
|
16
|
+
installed domain. Adjust the tables below to change the policy project-wide.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import ast
|
|
20
|
+
import sys
|
|
21
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from importlib.util import find_spec
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Final
|
|
26
|
+
|
|
27
|
+
LAYERS: Final = ("domain", "application", "infrastructure", "api")
|
|
28
|
+
|
|
29
|
+
DOMAIN_ALLOWED_PREFIXES: Final = ("domainkit.ddd",)
|
|
30
|
+
"""Non-stdlib modules a domain layer may import (besides its own layer)."""
|
|
31
|
+
|
|
32
|
+
FORBIDDEN_LIBRARIES: Final = {
|
|
33
|
+
"application": (
|
|
34
|
+
"fastapi",
|
|
35
|
+
"starlette",
|
|
36
|
+
"sqlalchemy",
|
|
37
|
+
"alembic",
|
|
38
|
+
"domainkit.db",
|
|
39
|
+
"domainkit.http",
|
|
40
|
+
),
|
|
41
|
+
"infrastructure": ("fastapi", "starlette", "domainkit.http"),
|
|
42
|
+
}
|
|
43
|
+
"""Libraries each layer must not import."""
|
|
44
|
+
|
|
45
|
+
FORBIDDEN_LAYERS: Final = {
|
|
46
|
+
"application": ("infrastructure", "api"),
|
|
47
|
+
"infrastructure": ("api",),
|
|
48
|
+
}
|
|
49
|
+
"""Layers of the same domain that each layer must not import."""
|
|
50
|
+
|
|
51
|
+
PUBLIC_SURFACE: Final = ("application", "domain.events")
|
|
52
|
+
"""Parts of a domain that other domains may import."""
|
|
53
|
+
|
|
54
|
+
FRAMEWORK_PACKAGES: Final = ("domainkit",)
|
|
55
|
+
"""Installed framework apps (``domainkit.contrib.*``) are services, not domains:
|
|
56
|
+
they are neither checked nor protected by the cross-domain rule."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class Violation:
|
|
61
|
+
"""One import that breaks a rule."""
|
|
62
|
+
|
|
63
|
+
module: str
|
|
64
|
+
imported: str
|
|
65
|
+
rule: str
|
|
66
|
+
|
|
67
|
+
def __str__(self) -> str:
|
|
68
|
+
"""Render as ``module imports name: rule``."""
|
|
69
|
+
return f"{self.module} imports {self.imported}: {self.rule}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def iter_imports(package: str) -> Iterator[tuple[str, str]]:
|
|
73
|
+
"""Yield ``(module, imported)`` for every import in ``package``'s files.
|
|
74
|
+
|
|
75
|
+
Relative imports are resolved, and ``from x import y`` yields both ``x``
|
|
76
|
+
and ``x.y`` because ``y`` may be a submodule. Imports under
|
|
77
|
+
``TYPE_CHECKING`` count: they are dependencies too.
|
|
78
|
+
"""
|
|
79
|
+
for path, module in _modules(package):
|
|
80
|
+
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
81
|
+
is_package = path.name == "__init__.py"
|
|
82
|
+
for node in ast.walk(tree):
|
|
83
|
+
if isinstance(node, ast.Import):
|
|
84
|
+
for alias in node.names:
|
|
85
|
+
yield module, alias.name
|
|
86
|
+
elif isinstance(node, ast.ImportFrom):
|
|
87
|
+
base = _resolve(module, is_package, node.level, node.module)
|
|
88
|
+
yield module, base
|
|
89
|
+
for alias in node.names:
|
|
90
|
+
if alias.name != "*":
|
|
91
|
+
yield module, f"{base}.{alias.name}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def check_domain(package: str, installed: Sequence[str] = ()) -> list[Violation]:
|
|
95
|
+
"""Return the rule violations in domain ``package``.
|
|
96
|
+
|
|
97
|
+
``installed`` lists the other domain packages, for the cross-domain rule.
|
|
98
|
+
"""
|
|
99
|
+
others = [
|
|
100
|
+
name
|
|
101
|
+
for name in installed
|
|
102
|
+
if name != package and not _within(name, FRAMEWORK_PACKAGES)
|
|
103
|
+
]
|
|
104
|
+
violations = []
|
|
105
|
+
for module, imported in iter_imports(package):
|
|
106
|
+
layer = _layer(package, module)
|
|
107
|
+
rule = _cross_domain_rule(imported, others) or _layer_rule(
|
|
108
|
+
package, layer, imported
|
|
109
|
+
)
|
|
110
|
+
if rule:
|
|
111
|
+
violations.append(Violation(module, imported, rule))
|
|
112
|
+
return list(dict.fromkeys(violations))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def check_domains(installed: Iterable[str]) -> list[Violation]:
|
|
116
|
+
"""Check every domain in ``installed``."""
|
|
117
|
+
names = [name for name in installed if not _within(name, FRAMEWORK_PACKAGES)]
|
|
118
|
+
return [violation for name in names for violation in check_domain(name, names)]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _layer_rule(package: str, layer: str | None, imported: str) -> str | None:
|
|
122
|
+
"""Return the broken layer rule, if any."""
|
|
123
|
+
if layer == "domain":
|
|
124
|
+
own = f"{package}.domain"
|
|
125
|
+
allowed = (own, *DOMAIN_ALLOWED_PREFIXES)
|
|
126
|
+
if not _is_stdlib(imported) and not _within(imported, allowed):
|
|
127
|
+
return (
|
|
128
|
+
"the domain layer may import only the standard library, "
|
|
129
|
+
"domainkit.ddd, and its own domain layer"
|
|
130
|
+
)
|
|
131
|
+
return None
|
|
132
|
+
if layer is None:
|
|
133
|
+
return None
|
|
134
|
+
forbidden = [f"{package}.{name}" for name in FORBIDDEN_LAYERS.get(layer, ())]
|
|
135
|
+
if _within(imported, forbidden):
|
|
136
|
+
return f"the {layer} layer must not depend on outer layers"
|
|
137
|
+
if _within(imported, FORBIDDEN_LIBRARIES.get(layer, ())):
|
|
138
|
+
return f"the {layer} layer must not use {imported.partition('.')[0]}"
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _cross_domain_rule(imported: str, others: Sequence[str]) -> str | None:
|
|
143
|
+
"""Return the broken cross-domain rule, if any."""
|
|
144
|
+
for other in others:
|
|
145
|
+
if _within(imported, (other,)) and imported != other:
|
|
146
|
+
public = tuple(f"{other}.{part}" for part in PUBLIC_SURFACE)
|
|
147
|
+
if not _within(imported, public):
|
|
148
|
+
return (
|
|
149
|
+
f"use another domain only through {other}.application "
|
|
150
|
+
f"or {other}.domain.events"
|
|
151
|
+
)
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _modules(package: str) -> Iterator[tuple[Path, str]]:
|
|
156
|
+
"""Yield ``(path, dotted name)`` for each Python file of ``package``."""
|
|
157
|
+
spec = find_spec(package)
|
|
158
|
+
if spec is None or not spec.submodule_search_locations:
|
|
159
|
+
raise ValueError(f"{package!r} is not an importable package.")
|
|
160
|
+
for location in spec.submodule_search_locations:
|
|
161
|
+
root = Path(location)
|
|
162
|
+
for path in sorted(root.rglob("*.py")):
|
|
163
|
+
parts = path.relative_to(root).with_suffix("").parts
|
|
164
|
+
if parts[-1] == "__init__":
|
|
165
|
+
parts = parts[:-1]
|
|
166
|
+
yield path, ".".join((package, *parts))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _resolve(module: str, is_package: bool, level: int, target: str | None) -> str:
|
|
170
|
+
"""Turn a possibly relative ``from`` import into an absolute module name."""
|
|
171
|
+
if level == 0:
|
|
172
|
+
return target or ""
|
|
173
|
+
base = module.split(".")
|
|
174
|
+
if not is_package:
|
|
175
|
+
base = base[:-1]
|
|
176
|
+
base = base[: len(base) - (level - 1)]
|
|
177
|
+
return ".".join([*base, target] if target else base)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _layer(package: str, module: str) -> str | None:
|
|
181
|
+
"""Return the layer ``module`` belongs to, or None for top-level modules."""
|
|
182
|
+
first = module.removeprefix(f"{package}.").partition(".")[0]
|
|
183
|
+
return first if module != package and first in LAYERS else None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _within(name: str, prefixes: Iterable[str]) -> bool:
|
|
187
|
+
"""Report whether ``name`` is one of ``prefixes`` or inside one."""
|
|
188
|
+
return any(name == prefix or name.startswith(f"{prefix}.") for prefix in prefixes)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _is_stdlib(name: str) -> bool:
|
|
192
|
+
"""Report whether ``name`` belongs to the standard library."""
|
|
193
|
+
return name.partition(".")[0] in sys.stdlib_module_names
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Django-style settings access.
|
|
2
|
+
|
|
3
|
+
Select a module with ``<PREFIX>SETTINGS_MODULE`` (for example
|
|
4
|
+
``BILLING_API_SETTINGS_MODULE=config.settings.local``; the prefix comes
|
|
5
|
+
from the project name), in the environment or the ``.env`` file, then read
|
|
6
|
+
values anywhere::
|
|
7
|
+
|
|
8
|
+
from domainkit.conf import settings
|
|
9
|
+
|
|
10
|
+
settings.DEBUG
|
|
11
|
+
settings.DATABASES["default"].ECHO
|
|
12
|
+
|
|
13
|
+
Settings are loaded on first access, so importing this module has no side
|
|
14
|
+
effects.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from typing import Final, cast
|
|
18
|
+
|
|
19
|
+
from domainkit.conf.env import Env
|
|
20
|
+
from domainkit.conf.global_settings import (
|
|
21
|
+
DEFAULT_DB_ALIAS,
|
|
22
|
+
DatabaseConfig,
|
|
23
|
+
Settings,
|
|
24
|
+
)
|
|
25
|
+
from domainkit.conf.lazy import (
|
|
26
|
+
SETTINGS_MODULE_VARIABLE,
|
|
27
|
+
UNSET,
|
|
28
|
+
LazySettings,
|
|
29
|
+
describe_errors,
|
|
30
|
+
load_settings,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_lazy: Final = LazySettings(Env())
|
|
34
|
+
|
|
35
|
+
settings: Final = cast(Settings, _lazy)
|
|
36
|
+
"""Typed, lazily loaded settings for the selected module."""
|
|
37
|
+
|
|
38
|
+
configure: Final = _lazy.configure
|
|
39
|
+
override_settings: Final = _lazy.override
|
|
40
|
+
reset_settings: Final = _lazy.reset
|
|
41
|
+
unwrap_settings: Final = _lazy.unwrap
|
|
42
|
+
use_settings: Final = _lazy.use
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"DEFAULT_DB_ALIAS",
|
|
46
|
+
"SETTINGS_MODULE_VARIABLE",
|
|
47
|
+
"UNSET",
|
|
48
|
+
"DatabaseConfig",
|
|
49
|
+
"Settings",
|
|
50
|
+
"configure",
|
|
51
|
+
"describe_errors",
|
|
52
|
+
"load_settings",
|
|
53
|
+
"override_settings",
|
|
54
|
+
"reset_settings",
|
|
55
|
+
"settings",
|
|
56
|
+
"unwrap_settings",
|
|
57
|
+
"use_settings",
|
|
58
|
+
]
|