django-compose 0.1.0.dev1__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 (39) hide show
  1. django_compose/__init__.py +0 -0
  2. django_compose/__main__.py +5 -0
  3. django_compose/addons.py +205 -0
  4. django_compose/apps.py +103 -0
  5. django_compose/artifacts.py +224 -0
  6. django_compose/asgi.py +110 -0
  7. django_compose/autoreload.py +110 -0
  8. django_compose/build.py +282 -0
  9. django_compose/checks.py +11 -0
  10. django_compose/cli.py +12 -0
  11. django_compose/conf.py +322 -0
  12. django_compose/declarations.py +168 -0
  13. django_compose/history.py +36 -0
  14. django_compose/management/__init__.py +221 -0
  15. django_compose/management/commands/__init__.py +1 -0
  16. django_compose/management/commands/check_migrations.py +57 -0
  17. django_compose/management/commands/compose.py +42 -0
  18. django_compose/management/commands/makemigrations.py +136 -0
  19. django_compose/management/commands/migrate.py +18 -0
  20. django_compose/management/commands/reset_migration_history.py +34 -0
  21. django_compose/management/commands/retire_history.py +24 -0
  22. django_compose/migration_imports.py +474 -0
  23. django_compose/migrations.py +843 -0
  24. django_compose/model_composition.py +292 -0
  25. django_compose/models.py +65 -0
  26. django_compose/py.typed +0 -0
  27. django_compose/rendering.py +92 -0
  28. django_compose/serving.py +405 -0
  29. django_compose/snapshot.py +419 -0
  30. django_compose/sources.py +147 -0
  31. django_compose/typing.py +633 -0
  32. django_compose/urls.py +58 -0
  33. django_compose-0.1.0.dev1.dist-info/METADATA +101 -0
  34. django_compose-0.1.0.dev1.dist-info/RECORD +39 -0
  35. django_compose-0.1.0.dev1.dist-info/WHEEL +4 -0
  36. django_compose-0.1.0.dev1.dist-info/entry_points.txt +2 -0
  37. django_compose-0.1.0.dev1.dist-info/licenses/COPYING +674 -0
  38. django_compose-0.1.0.dev1.dist-info/licenses/LICENSE +165 -0
  39. django_compose-0.1.0.dev1.dist-info/licenses/NOTICE +10 -0
File without changes
@@ -0,0 +1,5 @@
1
+ """Run the same public command through ``python -m django_compose``."""
2
+
3
+ from django_compose.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,205 @@
1
+ """Resolve installed addon metadata into an ordered selection of native Django apps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+ from dataclasses import dataclass, field
7
+ from graphlib import CycleError, TopologicalSorter
8
+ from importlib import metadata
9
+ from types import MappingProxyType
10
+
11
+ from django.apps import AppConfig
12
+ from django.core.exceptions import ImproperlyConfigured
13
+ from packaging.requirements import Requirement
14
+ from packaging.utils import canonicalize_name
15
+
16
+ from django_compose.snapshot import Entry, app_entries
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Closure:
21
+ """Native app entries and app-name membership for one resolved selection.
22
+
23
+ App_entries captures native config identities in dependency order; entries
24
+ projects their compatible INSTALLED_APPS strings. Roots preserve the
25
+ project's authored order; required contains only dependency-selected apps.
26
+ Addons contains only selected members of the requested entry-point group.
27
+ Dependencies maps selected native app names to their direct dependencies,
28
+ also native app names, independent of the selected config entry strings.
29
+ App_configs retains the same selected native instances for app-owned behavior;
30
+ app_entries freezes their identity facts for composition inputs.
31
+ """
32
+
33
+ app_entries: tuple[Entry, ...]
34
+ roots: tuple[str, ...]
35
+ required: frozenset[str]
36
+ addons: frozenset[str]
37
+ dependencies: Mapping[str, tuple[str, ...]]
38
+ app_configs: tuple[AppConfig, ...] = field(compare=False, repr=False)
39
+
40
+ @property
41
+ def entries(self) -> tuple[str, ...]:
42
+ """Native INSTALLED_APPS strings, projected from canonical app identities."""
43
+ return tuple(entry.entry for entry in self.app_entries)
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class InstalledAddons:
48
+ """Read-only native metadata, indexed without importing apps or loading points.
49
+
50
+ Distributions and entry_points use canonical distribution names as keys.
51
+ Providers maps declared app keys to distribution names. Actual AppConfig
52
+ identity is checked only when enabled_apps activates the declaration. The
53
+ index containers are immutable; their values retain native metadata APIs.
54
+ """
55
+
56
+ distributions: Mapping[str, metadata.Distribution]
57
+ entry_points: Mapping[str, metadata.EntryPoint]
58
+ providers: Mapping[str, str]
59
+
60
+ def active_requirements(self, provider: str) -> tuple[Requirement, ...]:
61
+ """Return sorted native requirements active on this platform with extra=''.
62
+
63
+ Requested extras never activate optional requirements. Missing installed
64
+ dependencies are checked during activation, keeping disabled metadata inert.
65
+ """
66
+ return active_requirements(self.distributions[provider].requires or ())
67
+
68
+
69
+ def active_requirements(requirements: Iterable[str]) -> tuple[Requirement, ...]:
70
+ """Project sorted native requirements active on this platform with extra=''.
71
+
72
+ Accept installed or source metadata without requiring installed providers.
73
+ Requested extras do not activate optional requirements; installation checks
74
+ remain with the caller that activates dependencies.
75
+ """
76
+ parsed = (Requirement(value) for value in sorted(requirements))
77
+ return tuple(
78
+ requirement
79
+ for requirement in parsed
80
+ if requirement.marker is None or requirement.marker.evaluate({"extra": ""})
81
+ )
82
+
83
+
84
+ def installed_addons(group: str) -> InstalledAddons:
85
+ """Index group declarations and declared app-key collisions without imports.
86
+
87
+ Each participating distribution must declare exactly one group entry point.
88
+ Its value remains a native app/config string, never a loaded entry point.
89
+ Native metadata.distribution selects the winner for same-name installations.
90
+ """
91
+ if not isinstance(group, str) or not group:
92
+ raise ImproperlyConfigured("An explicit non-empty addon entry-point group is required")
93
+ distributions: dict[str, metadata.Distribution] = {}
94
+ points: dict[str, metadata.EntryPoint] = {}
95
+ app_providers: dict[str, str] = {}
96
+ names = {canonicalize_name(dist.metadata["Name"]) for dist in metadata.distributions()}
97
+ for name in sorted(names):
98
+ distribution = metadata.distribution(name)
99
+ entries = distribution.entry_points.select(group=group)
100
+ distributions[name] = distribution
101
+ if not entries:
102
+ continue
103
+ if len(entries) != 1:
104
+ raise ImproperlyConfigured(f"Distribution {name!r} must declare one entry point in {group!r}")
105
+ point = next(iter(entries))
106
+ if point.name in app_providers:
107
+ raise ImproperlyConfigured(
108
+ f"Competing providers for app {point.name!r}: {app_providers[point.name]!r} and {name!r}"
109
+ )
110
+ app_providers[point.name] = name
111
+ points[name] = point
112
+ return InstalledAddons(
113
+ MappingProxyType(distributions), MappingProxyType(points), MappingProxyType(app_providers)
114
+ )
115
+
116
+
117
+ def enabled_apps(roots: Iterable[str], *, group: str | None = None) -> Closure:
118
+ """Expand explicit roots using installed providers and selected requires_apps.
119
+
120
+ Settings are never read. Without a group, only native requires_apps edges
121
+ apply and distribution metadata is not inspected. Its entry-point keys are
122
+ native app names and values are native INSTALLED_APPS strings, never loaded
123
+ entry-point objects. An addon's active Requires-Dist edges (extra='') activate
124
+ only distributions in this group. Ordinary roots have no metadata expansion.
125
+ Installation alone never activates an addon or an optional extra.
126
+
127
+ Root configs are preselected and override dependency/default config choices.
128
+ Conflicting dependency configs, duplicate native labels and cycles fail.
129
+ """
130
+ installed = installed_addons(group) if group is not None else None
131
+ root_entries = tuple(roots)
132
+ if any(not isinstance(entry, str) for entry in root_entries):
133
+ raise ImproperlyConfigured("Root INSTALLED_APPS entries must be native app strings")
134
+ root_configs = tuple((entry, AppConfig.create(entry)) for entry in root_entries)
135
+ normalized_roots = app_entries(Entry.from_config(config, entry) for entry, config in root_configs)
136
+ root_names = tuple(entry.name for entry in normalized_roots)
137
+ root_set = frozenset(root_names)
138
+ selected = {config.name: (entry, config) for entry, config in root_configs if config.name in root_set}
139
+ by_app = installed.providers if installed is not None else {}
140
+ graph: dict[str, tuple[str, ...]] = {}
141
+
142
+ def addon_entry(provider: str) -> str:
143
+ assert installed is not None
144
+ point = installed.entry_points[provider]
145
+ config = AppConfig.create(point.value)
146
+ if config.name != point.name:
147
+ raise ImproperlyConfigured(
148
+ f"Entry point {point.name!r} from {provider!r} selects app {config.name!r}"
149
+ )
150
+ return point.value
151
+
152
+ def choose(entry: str) -> str:
153
+ config = AppConfig.create(entry)
154
+ previous = selected.get(config.name)
155
+ if previous is None:
156
+ selected[config.name] = (entry, config)
157
+ elif config.name not in root_set and type(previous[1]) is not type(config):
158
+ raise ImproperlyConfigured(
159
+ f"Conflicting app configs for {config.name!r}: {previous[0]!r} and {entry!r}"
160
+ )
161
+ return config.name
162
+
163
+ def visit(name: str) -> None:
164
+ if name in graph:
165
+ return
166
+ graph[name] = () # Record before recursion so graphlib can diagnose cycles.
167
+ config = selected[name][1]
168
+ required_apps = getattr(config, "requires_apps", ())
169
+ if isinstance(required_apps, str) or not isinstance(required_apps, Iterable):
170
+ raise ImproperlyConfigured(f"{name}.requires_apps must be an iterable of native app strings")
171
+ dependencies = list(required_apps)
172
+ provider = by_app.get(name)
173
+ if provider is not None:
174
+ addon_entry(provider)
175
+ assert installed is not None
176
+ for parsed in installed.active_requirements(provider):
177
+ dependency = canonicalize_name(parsed.name)
178
+ if dependency not in installed.distributions:
179
+ raise ImproperlyConfigured(
180
+ f"Addon {name!r} requires missing installed distribution {parsed.name!r}"
181
+ )
182
+ if dependency in installed.entry_points:
183
+ dependencies.append(addon_entry(dependency))
184
+ if any(not isinstance(entry, str) for entry in dependencies):
185
+ raise ImproperlyConfigured(f"{name}.requires_apps entries must be native app strings")
186
+ graph[name] = tuple(sorted({choose(entry) for entry in sorted(dependencies)}))
187
+ for dependency_name in graph[name]:
188
+ visit(dependency_name)
189
+
190
+ for name in root_names:
191
+ visit(name)
192
+ try:
193
+ ordered = tuple(TopologicalSorter(graph).static_order())
194
+ except CycleError as error:
195
+ raise ImproperlyConfigured(f"App dependency cycle: {' -> '.join(error.args[1])}") from error
196
+ configs = tuple(selected[name][1] for name in ordered)
197
+ entries = app_entries(Entry.from_config(config, selected[config.name][0]) for config in configs)
198
+ return Closure(
199
+ entries,
200
+ root_names,
201
+ frozenset(ordered) - root_set,
202
+ frozenset(ordered).intersection(by_app),
203
+ MappingProxyType(graph),
204
+ app_configs=configs,
205
+ )
django_compose/apps.py ADDED
@@ -0,0 +1,103 @@
1
+ """Native command registration and the final compose-only phase-2 lifecycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from django.apps import AppConfig
6
+ from django.conf import settings
7
+ from django.core.exceptions import ImproperlyConfigured
8
+
9
+ from django_compose import checks # noqa: F401 -- register deferred native system checks
10
+ from django_compose.serving import _close_startup_verification, _finish_startup_verification, mode, verify
11
+ from django_compose.snapshot import Snapshot
12
+
13
+
14
+ class ComposeConfig(AppConfig):
15
+ """Register management commands without importing build or source models."""
16
+
17
+ name = "django_compose"
18
+ default = True
19
+
20
+ def ready(self) -> None:
21
+ try:
22
+ if self.apps is None or tuple(self.apps.get_app_configs())[-1] is not self:
23
+ raise ImproperlyConfigured("ComposeConfig must be last in INSTALLED_APPS")
24
+ if mode() is not None:
25
+ raise ImproperlyConfigured("Compose mode requires BuildComposeConfig last in INSTALLED_APPS")
26
+ _finish_startup_verification(required=True)
27
+ finally:
28
+ _close_startup_verification()
29
+
30
+
31
+ class BuildComposeConfig(AppConfig):
32
+ """Compile last in phase 2 so every ready callback sees concrete models."""
33
+
34
+ name = "django_compose"
35
+ default = False
36
+
37
+ def import_models(self) -> None:
38
+ super().import_models()
39
+ selected_mode = mode()
40
+ if selected_mode is None:
41
+ raise ImproperlyConfigured("BuildComposeConfig requires DJANGO_COMPOSE_MODE before settings load")
42
+ if self.apps is None or tuple(self.apps.get_app_configs())[-1] is not self:
43
+ raise ImproperlyConfigured("BuildComposeConfig must be last in INSTALLED_APPS")
44
+ self.compose_mode = selected_mode
45
+ from django_compose.build import ConcreteModels, emit
46
+ from django_compose.declarations import ArtifactHook, resolve_integrations
47
+
48
+ snap = getattr(settings, "COMPOSE_SNAPSHOT", None)
49
+ if not isinstance(snap, Snapshot):
50
+ raise ImproperlyConfigured("Settings must create COMPOSE_SNAPSHOT before population")
51
+ snap.check_mode()
52
+ output_dir = settings.COMPOSE_OUTPUT_DIR
53
+ output_module = getattr(settings, "COMPOSE_OUTPUT_MODULE", "runtime")
54
+ if selected_mode == "check":
55
+ verify(snap, output_module)
56
+ integrations = resolve_integrations(snap.integration_paths)
57
+ transitions = snap.transition_records
58
+ self.plan = emit(
59
+ snap,
60
+ self.apps.get_app_configs(),
61
+ mode=selected_mode,
62
+ integrations=integrations,
63
+ transitions=transitions,
64
+ output_dir=output_dir,
65
+ output_module=output_module,
66
+ )
67
+ try:
68
+ self.plan.render_artifacts(
69
+ ConcreteModels(self.plan.composition),
70
+ tuple(
71
+ hook for hook in integrations if isinstance(hook, ArtifactHook) and hook.phase == "models"
72
+ ),
73
+ )
74
+ except BaseException:
75
+ self.plan.close()
76
+ raise
77
+
78
+ def ready(self) -> None:
79
+ try:
80
+ if mode() != self.compose_mode:
81
+ raise ImproperlyConfigured("DJANGO_COMPOSE_MODE changed after settings load")
82
+ from django_compose.declarations import ArtifactHook
83
+
84
+ assert self.apps is not None
85
+ self.plan.render_artifacts(
86
+ self.apps,
87
+ tuple(
88
+ hook
89
+ for hook in self.plan.integrations
90
+ if isinstance(hook, ArtifactHook) and hook.phase == "ready"
91
+ ),
92
+ )
93
+ self.receipt = self.plan.finalize()
94
+ if self.compose_mode == "check":
95
+ from django_compose.migrations import TransitionBinding
96
+
97
+ binding = TransitionBinding(labels=self.plan.labels, declarations=self.plan.transitions)
98
+ assert self.apps is not None
99
+ binding.check_native_history(self.apps)
100
+ _finish_startup_verification()
101
+ finally:
102
+ self.plan.close()
103
+ _close_startup_verification()
@@ -0,0 +1,224 @@
1
+ """Guarded atomic generated files and the single receipt publication lock."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import stat
8
+ import tempfile
9
+ from collections.abc import Mapping
10
+ from contextlib import nullcontext
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from django.core.exceptions import ImproperlyConfigured
15
+
16
+ from django_compose.serving import Receipt, _validate_output_root, read_receipt
17
+ from django_compose.serving import receipt_lock as receipt_lock
18
+
19
+
20
+ def atomic_write(path: Path, content: bytes) -> None:
21
+ # Native loaders may otherwise accept same-second, same-size stale bytecode.
22
+ caches: tuple[Path, ...] = ()
23
+ if path.suffix == ".py" and "migrations" not in path.parts:
24
+ cache_dir = path.parent / "__pycache__"
25
+ if cache_dir.is_symlink():
26
+ raise ValueError(f"Generated bytecode directory is a symlink: {cache_dir}")
27
+ caches = tuple(sorted(cache_dir.glob(f"{path.stem}.*.pyc")))
28
+ if any(cache.is_symlink() or not cache.is_file() for cache in caches):
29
+ raise ValueError(f"Unsafe generated bytecode cache for {path}")
30
+ try:
31
+ mode = stat.S_IMODE(path.stat().st_mode)
32
+ except FileNotFoundError:
33
+ mode = None
34
+ path.parent.mkdir(parents=True, exist_ok=True)
35
+ descriptor, temporary = tempfile.mkstemp(prefix=".compose-", dir=path.parent)
36
+ try:
37
+ with os.fdopen(descriptor, "wb") as stream:
38
+ stream.write(content)
39
+ stream.flush()
40
+ if mode is not None:
41
+ os.fchmod(stream.fileno(), mode)
42
+ os.fsync(stream.fileno())
43
+ os.replace(temporary, path)
44
+ for cache in caches:
45
+ cache.unlink(missing_ok=True)
46
+ finally:
47
+ if os.path.exists(temporary):
48
+ os.unlink(temporary)
49
+
50
+
51
+ @dataclass
52
+ class GeneratedTree:
53
+ """Only receipt-indexed outputs may be replaced; native history survives."""
54
+
55
+ root: Path
56
+ artifacts: Mapping[Path, bytes | str]
57
+
58
+ @staticmethod
59
+ def validate_artifacts(root: Path, artifacts: Mapping[Path, bytes | str]) -> None:
60
+ _validate_output_root(root)
61
+ paths = set(artifacts)
62
+ for relative, source in artifacts.items():
63
+ if (
64
+ not isinstance(relative, Path)
65
+ or relative.is_absolute()
66
+ or not relative.parts
67
+ or ".." in relative.parts
68
+ ):
69
+ raise ValueError(f"Generated artifacts require a root-relative path: {relative!r}")
70
+ if not isinstance(source, (str, bytes)):
71
+ raise ValueError(f"Generated artifact must be bytes or text: {relative}")
72
+ for part in (*relative.parents, relative):
73
+ target = root / part
74
+ if target.is_symlink() or (part != relative and target.exists() and not target.is_dir()):
75
+ raise ValueError(f"Unsafe generated path: {relative}")
76
+ if (root / relative).is_dir() or any(parent in paths for parent in relative.parents):
77
+ raise ValueError(f"Competing file/directory artifact: {relative}")
78
+
79
+ def _validate_migration_scaffold(self) -> None:
80
+ """Admit durable native history only before the first receipt exists."""
81
+ if not self.root.exists():
82
+ return
83
+ for path in sorted(self.root.rglob("*")):
84
+ relative = path.relative_to(self.root)
85
+ parts = relative.parts
86
+ if path.is_symlink() or not (path.is_file() or path.is_dir()):
87
+ raise ValueError(f"Unsafe native migration scaffold: {relative}")
88
+ if relative == Path(".compose.lock") and path.is_file():
89
+ continue
90
+ if relative == Path("__init__.py") and path.is_file() and not path.read_bytes():
91
+ continue
92
+ label = self.root / parts[0]
93
+ if not parts[0].isidentifier() or not (label / "migrations").is_dir():
94
+ raise ValueError(f"{self.root} lacks the required generated-output marker: {relative}")
95
+ if len(parts) == 1 and path.is_dir():
96
+ continue
97
+ if len(parts) >= 2 and parts[1] == "migrations":
98
+ continue
99
+ if len(parts) == 2 and parts[1] == "__init__.py" and path.is_file() and not path.read_bytes():
100
+ continue
101
+ raise ValueError(f"{self.root} lacks the required generated-output marker: {relative}")
102
+
103
+ def _validate(self) -> Receipt | None:
104
+ self.validate_artifacts(self.root, self.artifacts)
105
+ receipt = read_receipt(self.root)
106
+ if receipt is None:
107
+ self._validate_migration_scaffold()
108
+ owned = receipt.OWNED_FILES if receipt else {}
109
+ self.validate_artifacts(self.root, {Path(path): b"" for path in owned})
110
+ for relative, source in self.artifacts.items():
111
+ target = self.root / relative
112
+ if (
113
+ "migrations" in relative.parts
114
+ and target.exists()
115
+ and target.read_bytes() != self._bytes(source)
116
+ ):
117
+ raise ValueError(f"Cannot overwrite native migration history: {relative}")
118
+ if (
119
+ target.exists()
120
+ and relative.as_posix() not in owned
121
+ and relative != Path("__init__.py")
122
+ and "migrations" not in relative.parts
123
+ ):
124
+ raise ValueError(f"Generated artifact collides with an unowned file: {relative}")
125
+ return receipt
126
+
127
+ @staticmethod
128
+ def _bytes(value: bytes | str) -> bytes:
129
+ return value.encode() if isinstance(value, str) else value
130
+
131
+ def drift(self) -> list[Path]:
132
+ previous = self._validate()
133
+ changed = {
134
+ p
135
+ for p, value in self.artifacts.items()
136
+ if not (self.root / p).is_file() or (self.root / p).read_bytes() != self._bytes(value)
137
+ }
138
+ old = {Path(p) for p in previous.OWNED_FILES} if previous else set()
139
+ return sorted(
140
+ changed
141
+ | {
142
+ p
143
+ for p in old - set(self.artifacts)
144
+ if "migrations" not in p.parts and (self.root / p).exists()
145
+ }
146
+ )
147
+
148
+ def reconcile(self, *, prune: bool = True) -> bool:
149
+ previous = self._validate()
150
+ changed = False
151
+ for relative, value in sorted(self.artifacts.items()):
152
+ target = self.root / relative
153
+ content = self._bytes(value)
154
+ if not target.is_file() or target.read_bytes() != content:
155
+ atomic_write(target, content)
156
+ changed = True
157
+ if prune and previous:
158
+ for name in sorted(set(previous.OWNED_FILES) - {p.as_posix() for p in self.artifacts}):
159
+ relative = Path(name)
160
+ if "migrations" not in relative.parts and (self.root / relative).is_file():
161
+ (self.root / relative).unlink()
162
+ changed = True
163
+ return changed
164
+
165
+ def hashes(self) -> dict[str, str]:
166
+ return {
167
+ p.as_posix(): hashlib.sha256(self._bytes(value)).hexdigest()
168
+ for p, value in sorted(self.artifacts.items())
169
+ if p != Path("__init__.py") and "migrations" not in p.parts
170
+ }
171
+
172
+ def clean(self) -> tuple[Path, ...]:
173
+ with receipt_lock(self.root):
174
+ receipt = self._validate()
175
+ if receipt is None:
176
+ return ()
177
+ invalid = Receipt(
178
+ receipt.HISTORY_LABELS,
179
+ receipt.RETIRED_LABELS,
180
+ receipt.DIGEST,
181
+ receipt.OWNED_FILES,
182
+ receipt.COMPILER,
183
+ receipt.MODEL_LABELS,
184
+ True,
185
+ )
186
+ atomic_write(self.root / "__init__.py", invalid.source().encode())
187
+ for name in sorted(receipt.OWNED_FILES):
188
+ path = Path(name)
189
+ if "migrations" not in path.parts and (self.root / path).is_file():
190
+ (self.root / path).unlink()
191
+ return tuple(sorted(path.relative_to(self.root) for path in self.root.rglob("*")))
192
+
193
+
194
+ def update_receipt(
195
+ output_dir: Path | str, *, retired_labels: tuple[str, ...], locked: bool = False
196
+ ) -> Receipt:
197
+ """Publish retirement under the same invalidate-first writer protocol."""
198
+ root = Path(output_dir)
199
+ with nullcontext() if locked else receipt_lock(root):
200
+ previous = read_receipt(root)
201
+ if previous is None or previous.IN_PROGRESS:
202
+ raise ImproperlyConfigured("History retirement requires a complete receipt; run compose")
203
+ GeneratedTree(root, {}).validate_artifacts(root, {Path(p): b"" for p in previous.OWNED_FILES})
204
+ invalid = Receipt(
205
+ previous.HISTORY_LABELS,
206
+ previous.RETIRED_LABELS,
207
+ previous.DIGEST,
208
+ previous.OWNED_FILES,
209
+ previous.COMPILER,
210
+ previous.MODEL_LABELS,
211
+ True,
212
+ )
213
+ atomic_write(root / "__init__.py", invalid.source().encode())
214
+ retired = tuple(sorted(set(previous.RETIRED_LABELS) | set(retired_labels)))
215
+ result = Receipt(
216
+ tuple(label for label in previous.HISTORY_LABELS if label not in retired),
217
+ retired,
218
+ previous.DIGEST,
219
+ previous.OWNED_FILES,
220
+ previous.COMPILER,
221
+ previous.MODEL_LABELS,
222
+ )
223
+ atomic_write(root / "__init__.py", result.source().encode())
224
+ return result
django_compose/asgi.py ADDED
@@ -0,0 +1,110 @@
1
+ """Post-setup ASGI composition over native Django HTTP and async contexts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from contextlib import AbstractAsyncContextManager, AsyncExitStack
7
+ from dataclasses import dataclass, field
8
+ from typing import cast
9
+
10
+ from asgiref.typing import ASGI3Application, ASGIReceiveCallable, ASGISendCallable, Scope
11
+ from django.apps import apps
12
+ from django.core.exceptions import ImproperlyConfigured
13
+ from django.core.handlers.asgi import ASGIHandler
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class AsgiContribution:
18
+ """Mount paths and scope handlers; lifespan factories return fresh async contexts.
19
+
20
+ Mounts are absolute decoded paths without a trailing slash (except `/`).
21
+ The mounted app receives the original path with its mount added to root_path,
22
+ following ASGI's path/root_path convention. Query strings are unchanged.
23
+ HTTP and lifespan are reserved scope types.
24
+ """
25
+
26
+ http_mounts: Mapping[str, ASGI3Application] = field(default_factory=dict)
27
+ lifespans: tuple[Callable[[], AbstractAsyncContextManager[object]], ...] = ()
28
+ protocols: Mapping[str, ASGI3Application] = field(default_factory=dict)
29
+
30
+
31
+ class _Application:
32
+ def __init__(self) -> None:
33
+ apps.check_apps_ready()
34
+ if not apps.ready:
35
+ raise RuntimeError("ASGI composition requires completed django.setup()")
36
+ self.http = cast(ASGI3Application, ASGIHandler())
37
+ self.mounts: dict[str, ASGI3Application] = {}
38
+ self.protocols: dict[str, ASGI3Application] = {}
39
+ self.lifespans: list[Callable[[], AbstractAsyncContextManager[object]]] = []
40
+ mount_owners: dict[str, str] = {}
41
+ protocol_owners = {"http": "Django HTTP fallback", "lifespan": "django-compose lifespan"}
42
+ for config in apps.get_app_configs():
43
+ factory = getattr(config, "compose_asgi", None)
44
+ if factory is None:
45
+ continue
46
+ contribution = factory()
47
+ if not isinstance(contribution, AsgiContribution):
48
+ raise ImproperlyConfigured(f"{config.name}.compose_asgi() must return AsgiContribution")
49
+ for prefix, handler in contribution.http_mounts.items():
50
+ if (
51
+ not isinstance(prefix, str)
52
+ or not prefix.startswith("/")
53
+ or (prefix != "/" and prefix.endswith("/"))
54
+ ):
55
+ raise ImproperlyConfigured(f"{config.name}: invalid HTTP mount {prefix!r}")
56
+ self._claim(mount_owners, prefix, config.name)
57
+ self.mounts[prefix] = handler
58
+ for protocol, handler in contribution.protocols.items():
59
+ self._claim(protocol_owners, protocol, config.name)
60
+ self.protocols[protocol] = handler
61
+ self.lifespans.extend(contribution.lifespans)
62
+ self.prefixes = sorted(self.mounts, key=lambda prefix: (-len(prefix), prefix))
63
+
64
+ @staticmethod
65
+ def _claim(owners: dict[str, str], key: str, owner: str) -> None:
66
+ if key in owners:
67
+ raise ImproperlyConfigured(f"ASGI {key!r} is claimed by {owners[key]} and {owner}")
68
+ owners[key] = owner
69
+
70
+ async def __call__(self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None:
71
+ kind = scope["type"]
72
+ if scope["type"] == "lifespan":
73
+ await self._lifespan(receive, send)
74
+ elif scope["type"] == "http":
75
+ root = scope.get("root_path", "")
76
+ relative = scope["path"].removeprefix(root)
77
+ for prefix in self.prefixes:
78
+ if prefix == "/" or relative == prefix or relative.startswith(prefix + "/"):
79
+ mounted = scope.copy()
80
+ mounted["root_path"] = root + ("" if prefix == "/" else prefix)
81
+ await self.mounts[prefix](mounted, receive, send)
82
+ return
83
+ await self.http(scope, receive, send)
84
+ elif kind in self.protocols:
85
+ await self.protocols[kind](scope, receive, send)
86
+ else:
87
+ raise ValueError(f"Unsupported ASGI scope type: {kind!r}")
88
+
89
+ async def _lifespan(self, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None:
90
+ if (await receive())["type"] != "lifespan.startup":
91
+ raise ValueError("Expected lifespan.startup")
92
+ started = False
93
+ try:
94
+ async with AsyncExitStack() as stack:
95
+ for factory in self.lifespans:
96
+ await stack.enter_async_context(factory())
97
+ started = True
98
+ await send({"type": "lifespan.startup.complete"})
99
+ if (await receive())["type"] != "lifespan.shutdown":
100
+ raise ValueError("Expected lifespan.shutdown")
101
+ except Exception as error:
102
+ if started:
103
+ await send({"type": "lifespan.shutdown.failed", "message": str(error)})
104
+ else:
105
+ await send({"type": "lifespan.startup.failed", "message": str(error)})
106
+ else:
107
+ await send({"type": "lifespan.shutdown.complete"})
108
+
109
+
110
+ application = _Application()