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
@@ -0,0 +1,110 @@
1
+ """Compiler input evidence and adapters to Django's native reload backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections.abc import Iterable
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from django.core.exceptions import ImproperlyConfigured
12
+ from django.utils import autoreload
13
+
14
+
15
+ class InputReport:
16
+ """Declared candidates survive failed reads and empty glob expansion."""
17
+
18
+ def __init__(self) -> None:
19
+ self.files: set[Path] = set()
20
+ self.globs: set[tuple[Path, str]] = set()
21
+
22
+ def add(self, *, files: Iterable[str | Path] = (), globs: Iterable[tuple[str | Path, str]] = ()) -> None:
23
+ self.files.update(Path(path).absolute() for path in files)
24
+ self.globs.update((Path(path).absolute(), pattern) for path, pattern in globs)
25
+
26
+ def payload(self) -> dict[str, Any]:
27
+ return {
28
+ "files": sorted(map(str, self.files)),
29
+ "globs": sorted((str(path), pattern) for path, pattern in self.globs),
30
+ }
31
+
32
+ def load(self, payload: dict[str, Any]) -> None:
33
+ self.add(files=payload["files"], globs=payload["globs"])
34
+
35
+ def state(self) -> tuple[dict[Path, bool], dict[tuple[Path, str], frozenset[Path]]]:
36
+ return (
37
+ {path: path.exists() for path in self.files},
38
+ {item: frozenset(item[0].glob(item[1])) for item in self.globs},
39
+ )
40
+
41
+ def attach(self, reloader: Any) -> None:
42
+ """Extend native tick polling, retaining native content-change detection."""
43
+ reloader.extra_files.update(self.files)
44
+ for directory, pattern in sorted(self.globs):
45
+ reloader.watch_dir(directory, pattern)
46
+ previous_files, previous_globs = self.state()
47
+ native_tick = reloader.tick
48
+
49
+ def tick() -> Any:
50
+ nonlocal previous_files, previous_globs
51
+ for result in native_tick():
52
+ current_files, current_globs = self.state()
53
+ changed = {
54
+ path for path, exists in previous_files.items() if current_files.get(path) != exists
55
+ }
56
+ for item, members in previous_globs.items():
57
+ changed.update(members ^ current_globs.get(item, frozenset()))
58
+ previous_files, previous_globs = current_files, current_globs
59
+ for path in sorted(changed):
60
+ reloader.notify_file_changed(path)
61
+ yield result
62
+
63
+ reloader.tick = tick
64
+
65
+
66
+ inputs = InputReport()
67
+
68
+
69
+ def report_inputs(*, files: Iterable[str | Path] = (), globs: Iterable[tuple[str | Path, str]] = ()) -> None:
70
+ """Publish settings-reader candidates BEFORE reading, even when absent."""
71
+ inputs.add(files=files, globs=globs)
72
+
73
+
74
+ def report_watch_paths(paths: Iterable[str | Path | tuple[str | Path, str]]) -> tuple[Any, ...]:
75
+ """Validate/report the mixed COMPOSE_WATCH_PATHS setting once at its owner."""
76
+ if isinstance(paths, (str, Path)):
77
+ raise ImproperlyConfigured(
78
+ "COMPOSE_WATCH_PATHS must be a sequence of files or (directory, glob) pairs"
79
+ )
80
+ normalized: list[Any] = []
81
+ for item in paths:
82
+ if isinstance(item, (str, Path)):
83
+ report_inputs(files=[item])
84
+ normalized.append(str(item))
85
+ elif (
86
+ isinstance(item, (tuple, list))
87
+ and len(item) == 2
88
+ and isinstance(item[0], (str, Path))
89
+ and isinstance(item[1], str)
90
+ and item[1]
91
+ ):
92
+ report_inputs(globs=[(item[0], item[1])])
93
+ normalized.append((str(item[0]), item[1]))
94
+ else:
95
+ raise ImproperlyConfigured("COMPOSE_WATCH_PATHS requires files or (directory, glob) pairs")
96
+ return tuple(normalized)
97
+
98
+
99
+ def write_report(fd: int) -> None:
100
+ """Write a report to a caller-owned regular inherited descriptor."""
101
+ with os.fdopen(os.dup(fd), "w") as stream:
102
+ json.dump(inputs.payload(), stream)
103
+ stream.write("\n")
104
+
105
+
106
+ def _started(sender: Any, **kwargs: Any) -> None:
107
+ inputs.attach(sender)
108
+
109
+
110
+ autoreload.autoreload_started.connect(_started, weak=False, dispatch_uid="django_compose.inputs")
@@ -0,0 +1,282 @@
1
+ """Compose-only model emission, concrete imports, artifacts and final publication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import os
7
+ from collections.abc import Iterable
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any, Literal
11
+
12
+ from django.apps import AppConfig, apps
13
+ from django.apps.registry import Apps
14
+ from django.conf import settings
15
+ from django.core.exceptions import ImproperlyConfigured
16
+
17
+ from django_compose import serving
18
+ from django_compose.artifacts import GeneratedTree, atomic_write, receipt_lock
19
+ from django_compose.declarations import ArtifactHook, ModelDeclarationHook, ModelsView, model_declaration
20
+ from django_compose.model_composition import ModelComposition
21
+ from django_compose.rendering import render_models
22
+ from django_compose.serving import Receipt, read_receipt, verify
23
+ from django_compose.snapshot import Snapshot
24
+
25
+
26
+ def discover(configs: Iterable[AppConfig]) -> ModelComposition:
27
+ return ModelComposition.discover(tuple(configs))
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ConcreteModels:
32
+ composition: ModelComposition
33
+
34
+ def get_model(self, label: str, name: str) -> Any:
35
+ return apps.get_model(label, name, require_ready=False)
36
+
37
+
38
+ class BuildPlan:
39
+ """One phase-2 build, retaining its writer lock until final publication."""
40
+
41
+ def __init__(
42
+ self,
43
+ snap: Snapshot,
44
+ configs: Iterable[AppConfig],
45
+ *,
46
+ mode: Literal["write", "check"],
47
+ integrations: tuple[object, ...],
48
+ transitions: Iterable[Any],
49
+ output_dir: Path,
50
+ output_module: str,
51
+ ) -> None:
52
+ if mode not in ("write", "check"):
53
+ raise ImproperlyConfigured("BuildPlan requires write or check mode")
54
+ if (
55
+ not all(part.isidentifier() for part in output_module.split("."))
56
+ or not output_dir.name.isidentifier()
57
+ ):
58
+ raise ImproperlyConfigured("Output package must have Python identifiers")
59
+ self.snapshot = snap
60
+ selected = {entry.name for entry in snap.entries}
61
+ self.configs = tuple(c for c in configs if c.name in selected)
62
+ self.mode = mode
63
+ self.integrations = integrations
64
+ self.transitions = tuple(transitions)
65
+ self.output_dir = output_dir
66
+ self.output_module = output_module
67
+ self.previous = read_receipt(output_dir)
68
+ self.sources: dict[Path, str | bytes] = {}
69
+ self._lock = receipt_lock(
70
+ output_dir,
71
+ write=mode == "write",
72
+ wait=float(os.environ["DJANGO_COMPOSE_WAIT"]) if "DJANGO_COMPOSE_WAIT" in os.environ else 5.0,
73
+ )
74
+ self._locked = False
75
+
76
+ @property
77
+ def labels(self) -> tuple[str, ...]:
78
+ return self.composition.labels
79
+
80
+ def _receipt(self, *, progress: bool, hashes: dict[str, str] | None = None) -> Receipt:
81
+ previous = self.previous
82
+ retired = previous.RETIRED_LABELS if previous else ()
83
+ history = set(previous.HISTORY_LABELS if previous else ())
84
+ history.update(getattr(self, "labels", ()))
85
+ history.update(path.parent.name for path in self.output_dir.glob("*/migrations") if path.is_dir())
86
+ history.update(transition["app_label"] for transition in self.transitions)
87
+ return Receipt(
88
+ tuple(sorted(history - set(retired))),
89
+ retired,
90
+ self.snapshot.digest(),
91
+ hashes if hashes is not None else (dict(previous.OWNED_FILES) if previous else {}),
92
+ self.snapshot.compiler,
93
+ tuple(getattr(self, "labels", ())),
94
+ progress,
95
+ )
96
+
97
+ def emit(self) -> BuildPlan:
98
+ self._lock.__enter__()
99
+ self._locked = True
100
+ try:
101
+ serving._register_build(self.close)
102
+ if self.mode == "check":
103
+ self.previous = verify(self.snapshot, self.output_module)
104
+ # Guard ownership before invalidation. A failed discovery remains invalid.
105
+ self.previous = GeneratedTree(self.output_dir, {})._validate()
106
+ if self.mode == "write":
107
+ receipt = self._receipt(progress=True)
108
+ if self.previous is None:
109
+ self.sources.update({Path(label) / "__init__.py": "" for label in receipt.HISTORY_LABELS})
110
+ receipt = self._receipt(
111
+ progress=True, hashes=GeneratedTree(self.output_dir, self.sources).hashes()
112
+ )
113
+ atomic_write(self.output_dir / "__init__.py", receipt.source().encode())
114
+ self.composition = discover(self.configs)
115
+ models = {model for config in self.configs for model in (config.models or {}).values()}
116
+ models.update(self.composition.ordered_models)
117
+ for source in self.composition.ordered_models:
118
+ models.update(self.composition.donors(source))
119
+ for model in sorted(models, key=lambda model: model._meta.label):
120
+ swappable = model._meta.swappable
121
+ if swappable and swappable not in self.snapshot.settings:
122
+ raise ImproperlyConfigured(
123
+ f"Swappable setting {swappable!r} for {model._meta.label} must be declared "
124
+ "in COMPOSE_FINGERPRINT_SETTINGS"
125
+ )
126
+ declarations = {
127
+ source: model_declaration(
128
+ self.composition,
129
+ source,
130
+ tuple(h for h in self.integrations if isinstance(h, ModelDeclarationHook)),
131
+ )
132
+ for source in self.composition.ordered_models
133
+ }
134
+ history_labels = self._receipt(progress=True).HISTORY_LABELS
135
+ for label in history_labels:
136
+ self.sources[Path(label) / "__init__.py"] = ""
137
+ config = next((c for c in self.configs if c.label == label), None)
138
+ if config and label in self.labels:
139
+ config_path = f"{type(config).__module__}.{type(config).__qualname__}"
140
+ module, _, name = config_path.rpartition(".")
141
+ model_module = f"{self.output_module}.{label}.models"
142
+ body = (
143
+ f"from {module} import {name} as SourceConfig\n\n"
144
+ "class GeneratedConfig(SourceConfig):\n"
145
+ f" name = {config.name!r}\n label = {label!r}\n\n"
146
+ " def import_models(self):\n"
147
+ " super().import_models()\n"
148
+ " from importlib import import_module\n"
149
+ f" self.models_module = import_module({model_module!r})\n"
150
+ )
151
+ self.sources[Path(label) / "apps.py"] = body
152
+ if label in self.labels:
153
+ self.sources[Path(label) / "models.py"] = render_models(
154
+ self.composition,
155
+ label,
156
+ runtime_module=self.output_module,
157
+ declarations=declarations,
158
+ )
159
+ migration_init = self.output_dir / label / "migrations" / "__init__.py"
160
+ effective = getattr(settings, "MIGRATION_MODULES", {}).get(
161
+ label, f"{self.output_module}.{label}.migrations"
162
+ )
163
+ if effective == f"{self.output_module}.{label}.migrations" and not migration_init.exists():
164
+ self.sources[Path(label) / "migrations" / "__init__.py"] = ""
165
+ if self.mode == "check":
166
+ changed = [
167
+ p
168
+ for p, value in self.sources.items()
169
+ if not (self.output_dir / p).is_file()
170
+ or (self.output_dir / p).read_bytes() != GeneratedTree._bytes(value)
171
+ ]
172
+ if changed:
173
+ raise ImproperlyConfigured(f"Generated models are stale: {changed}; run compose")
174
+ else:
175
+ GeneratedTree(self.output_dir, self.sources).reconcile(prune=False)
176
+ provisional = dict(self.previous.OWNED_FILES) if self.previous else {}
177
+ provisional.update(GeneratedTree(self.output_dir, self.sources).hashes())
178
+ atomic_write(
179
+ self.output_dir / "__init__.py",
180
+ self._receipt(progress=True, hashes=provisional).source().encode(),
181
+ )
182
+ importlib.invalidate_caches()
183
+ for label in self.labels:
184
+ importlib.import_module(f"{self.output_module}.{label}.models")
185
+ return self
186
+ except BaseException:
187
+ self.close()
188
+ raise
189
+
190
+ def render_artifacts(self, models_view: ModelsView | Apps, hooks: Iterable[ArtifactHook]) -> None:
191
+ external = set(getattr(settings, "COMPOSE_EXTERNAL_ARTIFACT_ROOTS", ()))
192
+ receipt = self._receipt(progress=True)
193
+ reserved = {*receipt.MODEL_LABELS, *receipt.HISTORY_LABELS, *receipt.RETIRED_LABELS}
194
+ for hook in hooks:
195
+ root = Path(hook.external_root)
196
+ if (
197
+ root.is_absolute()
198
+ or len(root.parts) != 1
199
+ or root.name in {*reserved, "migrations", "__pycache__", ".."}
200
+ or root.as_posix() not in external
201
+ ):
202
+ raise ImproperlyConfigured(f"Undeclared artifact root: {hook.external_root!r}")
203
+ rendered = hook.render(self.composition, models_view)
204
+ GeneratedTree.validate_artifacts(self.output_dir, rendered)
205
+ for path, content in sorted(rendered.items()):
206
+ if (
207
+ path in self.sources
208
+ or len(path.parts) < 2
209
+ or path.parts[0] != root.as_posix()
210
+ or "migrations" in path.parts
211
+ ):
212
+ raise ImproperlyConfigured(f"Competing or undeclared artifact: {path}")
213
+ if not isinstance(content, bytes):
214
+ raise ImproperlyConfigured(f"ArtifactHook must render bytes: {path}")
215
+ self.sources[path] = content
216
+ tree = GeneratedTree(self.output_dir, self.sources)
217
+ tree._validate()
218
+ if self.mode == "check":
219
+ if any(
220
+ not (self.output_dir / path).is_file()
221
+ or (self.output_dir / path).read_bytes() != tree._bytes(value)
222
+ for path, value in self.sources.items()
223
+ ):
224
+ raise ImproperlyConfigured("Generated artifacts are stale; run compose")
225
+ else:
226
+ current = read_receipt(self.output_dir)
227
+ provisional = dict(current.OWNED_FILES) if current else {}
228
+ provisional.update(tree.hashes())
229
+ atomic_write(
230
+ self.output_dir / "__init__.py",
231
+ self._receipt(progress=True, hashes=provisional).source().encode(),
232
+ )
233
+ tree.reconcile(prune=False)
234
+ serving._publish_build_artifacts(
235
+ self.close, self.output_dir, self._receipt(progress=True, hashes=tree.hashes())
236
+ )
237
+
238
+ def finalize(self) -> Receipt:
239
+ try:
240
+ receipt = self._receipt(
241
+ progress=False, hashes=GeneratedTree(self.output_dir, self.sources).hashes()
242
+ )
243
+ tree = GeneratedTree(self.output_dir, self.sources)
244
+ if self.mode == "write":
245
+ tree.reconcile()
246
+ atomic_write(self.output_dir / "__init__.py", receipt.source().encode())
247
+ elif tree.drift() or self.previous != receipt:
248
+ raise ImproperlyConfigured("Generated receipt or artifacts are stale; run compose")
249
+ return receipt
250
+ finally:
251
+ self.close()
252
+
253
+ def close(self) -> None:
254
+ serving._clear_build(self.close)
255
+ if self._locked:
256
+ self._locked = False
257
+ self._lock.__exit__(None, None, None)
258
+
259
+
260
+ def emit(
261
+ snap: Snapshot,
262
+ configs: Iterable[AppConfig],
263
+ *,
264
+ mode: Literal["write", "check"],
265
+ integrations: tuple[object, ...],
266
+ transitions: Iterable[Any],
267
+ output_dir: Path | str,
268
+ output_module: str,
269
+ ) -> BuildPlan:
270
+ return BuildPlan(
271
+ snap,
272
+ configs,
273
+ mode=mode,
274
+ integrations=integrations,
275
+ transitions=transitions,
276
+ output_dir=Path(output_dir),
277
+ output_module=output_module,
278
+ ).emit()
279
+
280
+
281
+ def clean(output_dir: Path | str) -> tuple[Path, ...]:
282
+ return GeneratedTree(Path(output_dir), {}).clean()
@@ -0,0 +1,11 @@
1
+ """Bootstrap-safe registration of deferred native serving checks."""
2
+
3
+ from django.core.checks import Error, Tags, register
4
+
5
+
6
+ @register(Tags.urls)
7
+ def check_url_collisions(**kwargs: object) -> list[Error]:
8
+ """Load URL policy only when native checks run after setup."""
9
+ from django_compose.urls import check_url_collisions as check
10
+
11
+ return check(**kwargs)
django_compose/cli.py ADDED
@@ -0,0 +1,12 @@
1
+ """The standalone launcher uses the same native management entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+
8
+ def main(argv: list[str] | None = None) -> int:
9
+ from django_compose.management import execute
10
+
11
+ execute(["django-compose", *(sys.argv[1:] if argv is None else argv)])
12
+ return 0