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/__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)
@@ -0,0 +1,6 @@
1
+ """Installed domains (``django.apps``): configuration classes and the registry."""
2
+
3
+ from domainkit.apps.config import DomainConfig
4
+ from domainkit.apps.registry import DomainRegistry, registry
5
+
6
+ __all__ = ["DomainConfig", "DomainRegistry", "registry"]
@@ -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
+ ]