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.
- django_compose/__init__.py +0 -0
- django_compose/__main__.py +5 -0
- django_compose/addons.py +205 -0
- django_compose/apps.py +103 -0
- django_compose/artifacts.py +224 -0
- django_compose/asgi.py +110 -0
- django_compose/autoreload.py +110 -0
- django_compose/build.py +282 -0
- django_compose/checks.py +11 -0
- django_compose/cli.py +12 -0
- django_compose/conf.py +322 -0
- django_compose/declarations.py +168 -0
- django_compose/history.py +36 -0
- django_compose/management/__init__.py +221 -0
- django_compose/management/commands/__init__.py +1 -0
- django_compose/management/commands/check_migrations.py +57 -0
- django_compose/management/commands/compose.py +42 -0
- django_compose/management/commands/makemigrations.py +136 -0
- django_compose/management/commands/migrate.py +18 -0
- django_compose/management/commands/reset_migration_history.py +34 -0
- django_compose/management/commands/retire_history.py +24 -0
- django_compose/migration_imports.py +474 -0
- django_compose/migrations.py +843 -0
- django_compose/model_composition.py +292 -0
- django_compose/models.py +65 -0
- django_compose/py.typed +0 -0
- django_compose/rendering.py +92 -0
- django_compose/serving.py +405 -0
- django_compose/snapshot.py +419 -0
- django_compose/sources.py +147 -0
- django_compose/typing.py +633 -0
- django_compose/urls.py +58 -0
- django_compose-0.1.0.dev1.dist-info/METADATA +101 -0
- django_compose-0.1.0.dev1.dist-info/RECORD +39 -0
- django_compose-0.1.0.dev1.dist-info/WHEEL +4 -0
- django_compose-0.1.0.dev1.dist-info/entry_points.txt +2 -0
- django_compose-0.1.0.dev1.dist-info/licenses/COPYING +674 -0
- django_compose-0.1.0.dev1.dist-info/licenses/LICENSE +165 -0
- django_compose-0.1.0.dev1.dist-info/licenses/NOTICE +10 -0
|
File without changes
|
django_compose/addons.py
ADDED
|
@@ -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()
|