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,405 @@
1
+ """Literal receipt verification and settings projection; never imports the compiler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import fcntl
7
+ import hashlib
8
+ import math
9
+ import os
10
+ import time
11
+ from collections.abc import Callable, Iterable, Iterator, Mapping
12
+ from contextlib import AbstractContextManager, contextmanager
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from types import MappingProxyType
16
+
17
+ from django.core.exceptions import ImproperlyConfigured
18
+
19
+ from django_compose.snapshot import Entry, Snapshot, app_entries
20
+ from django_compose.snapshot import mode as mode
21
+ from django_compose.sources import SourceResolver
22
+
23
+ GENERATED_SENTINEL = "# django-compose generated output v1"
24
+
25
+
26
+ def _validate_output_root(root: Path) -> None:
27
+ if any(parent.is_symlink() for parent in (root, *root.parents)):
28
+ raise ValueError(f"Generated root traverses a symlink: {root}")
29
+ if root.exists() and not root.is_dir():
30
+ raise ValueError(f"Generated root is not a directory: {root}")
31
+
32
+
33
+ @contextmanager
34
+ def receipt_lock(
35
+ output_dir: Path | str, *, write: bool = True, required: bool = False, wait: float | None = None
36
+ ) -> Iterator[None]:
37
+ """Serialize mutations; a check never creates a directory or lock file."""
38
+ if wait is not None and (not math.isfinite(wait) or wait < 0):
39
+ raise ValueError("Composition wait must be finite and non-negative")
40
+ root = Path(output_dir)
41
+ _validate_output_root(root)
42
+ path = root / ".compose.lock"
43
+ if path.is_symlink() or (path.exists() and not path.is_file()):
44
+ raise ValueError("Generated lock must be a regular file, not a symlink")
45
+ if not write:
46
+ if path.exists():
47
+ with path.open("rb") as stream:
48
+ os.set_inheritable(stream.fileno(), False)
49
+ fcntl.flock(stream, fcntl.LOCK_SH)
50
+ try:
51
+ yield
52
+ finally:
53
+ fcntl.flock(stream, fcntl.LOCK_UN)
54
+ else:
55
+ if required:
56
+ raise ImproperlyConfigured("Missing generated composition lock; run compose")
57
+ yield
58
+ return
59
+ root.mkdir(parents=True, exist_ok=True)
60
+ with path.open("a+b") as stream:
61
+ os.set_inheritable(stream.fileno(), False)
62
+ if wait is None:
63
+ fcntl.flock(stream, fcntl.LOCK_EX)
64
+ else:
65
+ deadline = time.monotonic() + wait
66
+ while True:
67
+ try:
68
+ fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
69
+ break
70
+ except BlockingIOError:
71
+ if time.monotonic() >= deadline:
72
+ raise ImproperlyConfigured(
73
+ f"Timed out after {wait:g}s waiting for composition lock {path}; "
74
+ "another reader or writer holds it"
75
+ ) from None
76
+ time.sleep(min(0.05, max(0, deadline - time.monotonic())))
77
+ try:
78
+ yield
79
+ finally:
80
+ fcntl.flock(stream, fcntl.LOCK_UN)
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class Receipt:
85
+ HISTORY_LABELS: tuple[str, ...]
86
+ RETIRED_LABELS: tuple[str, ...]
87
+ DIGEST: str
88
+ OWNED_FILES: Mapping[str, str]
89
+ COMPILER: str
90
+ MODEL_LABELS: tuple[str, ...] = ()
91
+ IN_PROGRESS: bool = False
92
+
93
+ def source(self) -> str:
94
+ return (
95
+ GENERATED_SENTINEL
96
+ + "\n"
97
+ + "".join(
98
+ f"{name} = {value!r}\n"
99
+ for name, value in (
100
+ ("HISTORY_LABELS", self.HISTORY_LABELS),
101
+ ("RETIRED_LABELS", self.RETIRED_LABELS),
102
+ ("DIGEST", self.DIGEST),
103
+ ("OWNED_FILES", dict(sorted(self.OWNED_FILES.items()))),
104
+ ("COMPILER", self.COMPILER),
105
+ ("MODEL_LABELS", self.MODEL_LABELS),
106
+ ("IN_PROGRESS", self.IN_PROGRESS),
107
+ )
108
+ )
109
+ )
110
+
111
+
112
+ def read_receipt(output_module: Path | str) -> Receipt | None:
113
+ """Read a dotted output package without executing it or any dotted parent.
114
+
115
+ Native path-based publication uses the same literal parser via Path inputs.
116
+ """
117
+ root = output_directory(output_module) if isinstance(output_module, str) else output_module
118
+ _validate_output_root(root)
119
+ path = root / "__init__.py"
120
+ if not path.exists():
121
+ return None
122
+ if root.is_symlink() or path.is_symlink() or not path.is_file():
123
+ raise ImproperlyConfigured("Generated receipt must be a regular file, not a symlink")
124
+ try:
125
+ source = path.read_text()
126
+ if not source:
127
+ return None
128
+ if GENERATED_SENTINEL not in source:
129
+ raise ValueError("missing generated-output marker")
130
+ values = {}
131
+ for node in ast.parse(source).body:
132
+ if (
133
+ not isinstance(node, ast.Assign)
134
+ or len(node.targets) != 1
135
+ or not isinstance(node.targets[0], ast.Name)
136
+ ):
137
+ raise ValueError("receipt must contain only literal assignments")
138
+ name = node.targets[0].id
139
+ if name in values:
140
+ raise ValueError(f"duplicate receipt constant {name}")
141
+ values[name] = ast.literal_eval(node.value)
142
+ # Accept the former receipt format solely to preserve native history on rebuild.
143
+ values.pop("GENERATION", None)
144
+ receipt = Receipt(**values)
145
+ if not isinstance(receipt.IN_PROGRESS, bool) or not all(
146
+ isinstance(value, str) for value in (receipt.DIGEST, receipt.COMPILER)
147
+ ):
148
+ raise ValueError("invalid receipt metadata")
149
+ if not all(
150
+ isinstance(labels, tuple) and len(labels) == len(set(labels))
151
+ for labels in (receipt.HISTORY_LABELS, receipt.RETIRED_LABELS, receipt.MODEL_LABELS)
152
+ ):
153
+ raise ValueError("invalid receipt labels")
154
+ if not all(
155
+ isinstance(label, str) and label.isidentifier()
156
+ for label in (*receipt.HISTORY_LABELS, *receipt.RETIRED_LABELS, *receipt.MODEL_LABELS)
157
+ ):
158
+ raise ValueError("invalid receipt label")
159
+ if not isinstance(receipt.OWNED_FILES, dict):
160
+ raise ValueError("invalid owned-file index")
161
+ for relative, digest in receipt.OWNED_FILES.items():
162
+ if not isinstance(relative, str):
163
+ raise ValueError("invalid owned file name")
164
+ candidate = Path(relative)
165
+ if (
166
+ candidate.is_absolute()
167
+ or ".." in candidate.parts
168
+ or not candidate.parts
169
+ or candidate == Path("__init__.py")
170
+ or not isinstance(digest, str)
171
+ or len(digest) != 64
172
+ or any(char not in "0123456789abcdef" for char in digest)
173
+ ):
174
+ raise ValueError(f"invalid owned file {relative!r}")
175
+ return Receipt(
176
+ tuple(receipt.HISTORY_LABELS),
177
+ tuple(receipt.RETIRED_LABELS),
178
+ receipt.DIGEST,
179
+ MappingProxyType(receipt.OWNED_FILES),
180
+ receipt.COMPILER,
181
+ tuple(receipt.MODEL_LABELS),
182
+ receipt.IN_PROGRESS,
183
+ )
184
+ except (OSError, SyntaxError, ValueError, TypeError) as error:
185
+ raise ImproperlyConfigured(f"Invalid generated receipt: {error}; run compose") from error
186
+
187
+
188
+ def output_directory(output_module: str) -> Path:
189
+ source = SourceResolver().resolve(output_module)
190
+ if len(source.search_locations) != 1:
191
+ raise ImproperlyConfigured(f"Missing generated package {output_module!r}; run compose")
192
+ root = source.search_locations[0]
193
+ _validate_output_root(root)
194
+ return root
195
+
196
+
197
+ def _verify(snap: Snapshot, root: Path) -> Receipt:
198
+ snap.check_mode()
199
+ receipt = read_receipt(root)
200
+ if (
201
+ receipt is None
202
+ or receipt.IN_PROGRESS
203
+ or receipt.DIGEST != snap.digest()
204
+ or receipt.COMPILER != snap.compiler
205
+ ):
206
+ raise ImproperlyConfigured("Generated composition is absent, interrupted or stale; run compose")
207
+ for relative, digest in receipt.OWNED_FILES.items():
208
+ _owned_path(root, Path(relative), digest)
209
+ return receipt
210
+
211
+
212
+ @dataclass
213
+ class _StartupVerification:
214
+ root: Path
215
+ source: bytes
216
+ lock: AbstractContextManager[None]
217
+ receipt: Receipt
218
+
219
+
220
+ _startup_verification: _StartupVerification | None = None
221
+ _verified: _StartupVerification | None = None
222
+ _pending_artifacts: tuple[Path, Receipt] | None = None
223
+ _lifetime_lock: AbstractContextManager[None] | None = None
224
+ _build_cleanup: Callable[[], None] | None = None
225
+
226
+
227
+ def _register_build(cleanup: Callable[[], None]) -> None:
228
+ """Register the one active build's cleanup at the process lifecycle owner."""
229
+ global _build_cleanup
230
+ if _build_cleanup is not None and _build_cleanup != cleanup:
231
+ raise ImproperlyConfigured("A composition build is already active")
232
+ _build_cleanup = cleanup
233
+
234
+
235
+ def _publish_build_artifacts(cleanup: Callable[[], None], root: Path, receipt: Receipt) -> None:
236
+ """Expose the active build's published models artifacts to ready callbacks."""
237
+ global _pending_artifacts
238
+ if _build_cleanup != cleanup:
239
+ raise ImproperlyConfigured("Cannot publish artifacts for an inactive build")
240
+ _pending_artifacts = (root, receipt)
241
+
242
+
243
+ def _clear_build(cleanup: Callable[[], None]) -> None:
244
+ """Clear process build state only when the closing build still owns it."""
245
+ global _build_cleanup, _pending_artifacts
246
+ if _build_cleanup == cleanup:
247
+ _pending_artifacts = None
248
+ _build_cleanup = None
249
+
250
+
251
+ def _close_build() -> None:
252
+ """Release the current failed native build at the launcher boundary."""
253
+ if _build_cleanup is not None:
254
+ _build_cleanup()
255
+
256
+
257
+ def _close_startup_verification() -> None:
258
+ global _startup_verification
259
+ session, _startup_verification = _startup_verification, None
260
+ if session is not None:
261
+ session.lock.__exit__(None, None, None)
262
+
263
+
264
+ def _finish_startup_verification(*, required: bool = False) -> None:
265
+ global _lifetime_lock, _startup_verification
266
+ session = _startup_verification
267
+ if required and session is None:
268
+ raise ImproperlyConfigured("Startup must verify composition before population; use configure()")
269
+ try:
270
+ if session is not None and (session.root / "__init__.py").read_bytes() != session.source:
271
+ raise ImproperlyConfigured("Generated composition changed during population; run compose")
272
+ if session is not None and os.environ.get("RUN_MAIN") == "true":
273
+ _lifetime_lock = session.lock
274
+ _startup_verification = None
275
+ finally:
276
+ _close_startup_verification()
277
+
278
+
279
+ def verify(snap: Snapshot, output_module: str, *, retain_startup: bool = False) -> Receipt:
280
+ """Verify under a shared lock, optionally retaining it through final ready.
281
+
282
+ Retained verification belongs to the single native Django startup. A failed
283
+ bare setup is unrecoverable; its process must exit to release resources.
284
+ """
285
+ global _startup_verification, _verified
286
+ if retain_startup and mode() == "write":
287
+ raise ImproperlyConfigured("Write composition cannot retain a startup reader lock")
288
+ if retain_startup and _startup_verification is not None:
289
+ raise ImproperlyConfigured("Composition startup verification is already active")
290
+ root = output_directory(output_module)
291
+ lock = receipt_lock(root, write=False, required=True)
292
+ lock.__enter__()
293
+ try:
294
+ receipt = _verify(snap, root)
295
+ if retain_startup:
296
+ _startup_verification = _StartupVerification(
297
+ root, (root / "__init__.py").read_bytes(), lock, receipt
298
+ )
299
+ _verified = _startup_verification
300
+ return receipt
301
+ except BaseException:
302
+ lock.__exit__(None, None, None)
303
+ raise
304
+ lock.__exit__(None, None, None)
305
+ return receipt
306
+
307
+
308
+ def installed_apps(entries: Iterable[str | Entry], receipt: Receipt, output_module: str) -> tuple[str, ...]:
309
+ selected = app_entries(entries)
310
+ return tuple(
311
+ f"{output_module}.{e.label}.apps.GeneratedConfig" if e.label in receipt.MODEL_LABELS else e.entry
312
+ for e in selected
313
+ ) + tombstones(receipt, selected, output_module)
314
+
315
+
316
+ def tombstones(
317
+ receipt: Receipt | None, entries: Iterable[str | Entry], output_module: str
318
+ ) -> tuple[str, ...]:
319
+ active = {entry.label for entry in app_entries(entries)}
320
+ return (
321
+ tuple(
322
+ f"django_compose.history.History_{label}"
323
+ for label in receipt.HISTORY_LABELS
324
+ if label not in active and label not in receipt.RETIRED_LABELS
325
+ )
326
+ if receipt
327
+ else ()
328
+ )
329
+
330
+
331
+ def migration_modules(
332
+ authored: Mapping[str, str | None], receipt: Receipt | None, output_module: str
333
+ ) -> dict[str, str | None]:
334
+ result: dict[str, str | None] = (
335
+ {
336
+ label: f"{output_module}.{label}.migrations"
337
+ for label in receipt.HISTORY_LABELS
338
+ if label not in receipt.RETIRED_LABELS
339
+ }
340
+ if receipt
341
+ else {}
342
+ )
343
+ result.update(authored)
344
+ return result
345
+
346
+
347
+ def verified_receipt() -> Receipt:
348
+ """Return the receipt verified for this process's native startup."""
349
+ if _verified is None:
350
+ raise ImproperlyConfigured("No startup-verified composition")
351
+ return _verified.receipt
352
+
353
+
354
+ @contextmanager
355
+ def receipt_lease() -> Iterator[Receipt]:
356
+ """Exclude publication while acting on the exact startup composition."""
357
+ session = _verified
358
+ if session is None:
359
+ raise ImproperlyConfigured("No startup-verified composition")
360
+ with receipt_lock(session.root, write=False, required=True):
361
+ if (session.root / "__init__.py").read_bytes() != session.source:
362
+ raise ImproperlyConfigured("Generated composition changed since startup; restart this process")
363
+ yield session.receipt
364
+
365
+
366
+ def artifact_path(relative: Path | str) -> Path:
367
+ """Resolve a declared, receipt-owned artifact and verify its current bytes.
368
+
369
+ During build-ready callbacks models-phase artifacts use the pending receipt.
370
+ Serving callers performing longer operations should also hold receipt_lease().
371
+ """
372
+ from django.conf import settings
373
+
374
+ path = Path(relative)
375
+ roots = set(getattr(settings, "COMPOSE_EXTERNAL_ARTIFACT_ROOTS", ()))
376
+ if (
377
+ path.is_absolute()
378
+ or len(path.parts) < 2
379
+ or ".." in path.parts
380
+ or "migrations" in path.parts
381
+ or path.parts[0] not in roots
382
+ ):
383
+ raise ImproperlyConfigured(f"Undeclared artifact: {relative}")
384
+ if _pending_artifacts is not None:
385
+ root, receipt = _pending_artifacts
386
+ elif _verified is not None:
387
+ root, receipt = _verified.root, _verified.receipt
388
+ else:
389
+ raise ImproperlyConfigured("No startup-verified composition")
390
+ digest = receipt.OWNED_FILES.get(path.as_posix())
391
+ return _owned_path(root, path, digest)
392
+
393
+
394
+ def _owned_path(root: Path, relative: Path, digest: str | None) -> Path:
395
+ """The single current-byte and safe-path check for receipt-owned files."""
396
+ _validate_output_root(root)
397
+ target = root / relative
398
+ if (
399
+ digest is None
400
+ or any((root / part).is_symlink() for part in (*relative.parents, relative))
401
+ or not target.is_file()
402
+ or hashlib.sha256(target.read_bytes()).hexdigest() != digest
403
+ ):
404
+ raise ImproperlyConfigured(f"Generated file is missing or altered: {relative}; run compose")
405
+ return target