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
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Native Django dispatch with composition and post-setup execution contexts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from contextlib import ExitStack
|
|
12
|
+
from types import MappingProxyType
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from django.apps import apps
|
|
16
|
+
from django.conf import settings
|
|
17
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
18
|
+
from django.core.management import ManagementUtility
|
|
19
|
+
from django.core.management.base import CommandError, handle_default_options
|
|
20
|
+
from django.utils import autoreload
|
|
21
|
+
from django.utils.module_loading import import_string
|
|
22
|
+
|
|
23
|
+
from django_compose.autoreload import inputs, report_inputs, write_report
|
|
24
|
+
|
|
25
|
+
MIGRATION_COMMANDS = frozenset(
|
|
26
|
+
{"makemigrations", "migrate", "check_migrations", "retire_history", "reset_migration_history"}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def require_serving_mode() -> None:
|
|
31
|
+
"""Migration commands require a process populated from verified serving apps."""
|
|
32
|
+
if "DJANGO_COMPOSE_MODE" in os.environ:
|
|
33
|
+
raise CommandError("Migration commands require serving mode; unset DJANGO_COMPOSE_MODE")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _Dispatch:
|
|
37
|
+
def __init__(self, command: Any) -> None:
|
|
38
|
+
self.command = command
|
|
39
|
+
|
|
40
|
+
def __getattr__(self, name: str) -> Any:
|
|
41
|
+
return getattr(self.command, name)
|
|
42
|
+
|
|
43
|
+
def run_from_argv(self, argv: list[str]) -> Any:
|
|
44
|
+
"""Adapt native execute after parsing, retaining the winning argv dispatcher.
|
|
45
|
+
|
|
46
|
+
Custom run_from_argv implementations must call self.execute to participate.
|
|
47
|
+
Factories receive command, original argv and read-only shallow-copied options.
|
|
48
|
+
The native dispatcher continues to own parsing, errors and connection cleanup.
|
|
49
|
+
"""
|
|
50
|
+
original_execute = self.command.execute
|
|
51
|
+
missing = object()
|
|
52
|
+
previous = vars(self.command).get("execute", missing)
|
|
53
|
+
original_argv = tuple(argv)
|
|
54
|
+
|
|
55
|
+
def execute(*args: Any, **options: Any) -> Any:
|
|
56
|
+
with ExitStack() as stack:
|
|
57
|
+
# runserver recovery fabricates ready flags, but not ready_event.
|
|
58
|
+
if apps.ready_event.is_set():
|
|
59
|
+
parsed_options = MappingProxyType(options.copy())
|
|
60
|
+
for dotted in getattr(settings, "COMPOSE_COMMAND_CONTEXT", ()):
|
|
61
|
+
stack.enter_context(
|
|
62
|
+
import_string(dotted)(original_argv[1], original_argv, parsed_options)
|
|
63
|
+
)
|
|
64
|
+
return original_execute(*args, **options)
|
|
65
|
+
|
|
66
|
+
self.command.execute = execute
|
|
67
|
+
try:
|
|
68
|
+
return self.command.run_from_argv(argv)
|
|
69
|
+
finally:
|
|
70
|
+
if previous is missing:
|
|
71
|
+
del self.command.execute
|
|
72
|
+
else:
|
|
73
|
+
self.command.execute = previous
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _Utility(ManagementUtility):
|
|
77
|
+
def fetch_command(self, subcommand: str) -> Any:
|
|
78
|
+
return _Dispatch(super().fetch_command(subcommand))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _default_options(arguments: list[str]) -> Any:
|
|
82
|
+
from django_compose.management.commands.compose import Command
|
|
83
|
+
|
|
84
|
+
parser = Command().create_parser(arguments[0], "compose")
|
|
85
|
+
options, _ = parser.parse_known_args(arguments[2:])
|
|
86
|
+
handle_default_options(options)
|
|
87
|
+
return options
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _fallback_inputs() -> None:
|
|
91
|
+
module = os.environ.get("DJANGO_SETTINGS_MODULE")
|
|
92
|
+
if module:
|
|
93
|
+
from django_compose.sources import SourceResolver
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
location = SourceResolver(()).resolve(module)
|
|
97
|
+
if location.path is not None:
|
|
98
|
+
report_inputs(files=[location.path])
|
|
99
|
+
except ImportError, ImproperlyConfigured, ValueError:
|
|
100
|
+
pass
|
|
101
|
+
exception = sys.exc_info()[1]
|
|
102
|
+
filename = getattr(exception, "filename", None)
|
|
103
|
+
if filename:
|
|
104
|
+
report_inputs(files=[filename])
|
|
105
|
+
if exception is not None:
|
|
106
|
+
traceback = exception.__traceback__
|
|
107
|
+
while traceback is not None:
|
|
108
|
+
report_inputs(files=[traceback.tb_frame.f_code.co_filename])
|
|
109
|
+
traceback = traceback.tb_next
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _parent(arguments: list[str]) -> None:
|
|
113
|
+
"""Compose before each native reloader child, without populating the parent."""
|
|
114
|
+
options = _default_options(arguments)
|
|
115
|
+
signal.signal(signal.SIGTERM, lambda *args: sys.exit(0))
|
|
116
|
+
environment = {**os.environ, autoreload.DJANGO_AUTORELOAD_ENV: "true"}
|
|
117
|
+
child_arguments = autoreload.get_child_arguments()
|
|
118
|
+
# The compose subprocess uses this launcher, independent of manage.py imports.
|
|
119
|
+
bootstrap = "from django_compose.management import execute; import sys; execute(sys.argv[1:])"
|
|
120
|
+
defaults = [f"--pythonpath={options.pythonpath}"] if options.pythonpath else []
|
|
121
|
+
for name in ("DJANGO_SETTINGS_MODULE",):
|
|
122
|
+
if name in os.environ:
|
|
123
|
+
environment[name] = os.environ[name]
|
|
124
|
+
while True:
|
|
125
|
+
with tempfile.TemporaryFile(mode="w+") as report:
|
|
126
|
+
command = [
|
|
127
|
+
sys.executable,
|
|
128
|
+
"-c",
|
|
129
|
+
bootstrap,
|
|
130
|
+
arguments[0],
|
|
131
|
+
"compose",
|
|
132
|
+
"--if-stale",
|
|
133
|
+
"--report-inputs",
|
|
134
|
+
str(report.fileno()),
|
|
135
|
+
*defaults,
|
|
136
|
+
]
|
|
137
|
+
result = subprocess.run(command, env=os.environ.copy(), pass_fds=(report.fileno(),))
|
|
138
|
+
report.seek(0)
|
|
139
|
+
payload = report.read()
|
|
140
|
+
if payload:
|
|
141
|
+
inputs.load(json.loads(payload))
|
|
142
|
+
if result.returncode:
|
|
143
|
+
reloader = autoreload.get_reloader()
|
|
144
|
+
inputs.attach(reloader)
|
|
145
|
+
print("Composition failed; waiting for input changes.", file=sys.stderr, flush=True)
|
|
146
|
+
try:
|
|
147
|
+
reloader.run_loop()
|
|
148
|
+
except SystemExit as exc:
|
|
149
|
+
if exc.code != 3:
|
|
150
|
+
raise
|
|
151
|
+
continue
|
|
152
|
+
code = subprocess.run(child_arguments, env=environment, close_fds=False).returncode
|
|
153
|
+
if code != 3:
|
|
154
|
+
raise SystemExit(code)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def execute(argv: list[str] | None = None) -> None:
|
|
158
|
+
"""Drop-in management entrypoint retaining Django's native setup policy."""
|
|
159
|
+
arguments = list(sys.argv if argv is None else argv)
|
|
160
|
+
command = arguments[1] if len(arguments) > 1 else "help"
|
|
161
|
+
if command in MIGRATION_COMMANDS:
|
|
162
|
+
require_serving_mode()
|
|
163
|
+
if (
|
|
164
|
+
command == "runserver"
|
|
165
|
+
and "--noreload" not in arguments
|
|
166
|
+
and not any(arg in {"--help", "-h", "--version"} for arg in arguments)
|
|
167
|
+
and os.environ.get(autoreload.DJANGO_AUTORELOAD_ENV) != "true"
|
|
168
|
+
):
|
|
169
|
+
try:
|
|
170
|
+
_parent(arguments)
|
|
171
|
+
except KeyboardInterrupt:
|
|
172
|
+
pass
|
|
173
|
+
return
|
|
174
|
+
report_fd = None
|
|
175
|
+
old_wait = os.environ.get("DJANGO_COMPOSE_WAIT")
|
|
176
|
+
from django_compose.serving import _close_build, _close_startup_verification
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
if command == "compose":
|
|
180
|
+
from django_compose.management.commands.compose import Command
|
|
181
|
+
|
|
182
|
+
if "--help" in arguments[2:] or "-h" in arguments[2:]:
|
|
183
|
+
Command().create_parser(arguments[0], "compose").print_help()
|
|
184
|
+
return
|
|
185
|
+
options = _default_options(arguments)
|
|
186
|
+
report_fd = options.report_inputs
|
|
187
|
+
if settings.configured:
|
|
188
|
+
raise ImproperlyConfigured("Select compose mode before Django settings load")
|
|
189
|
+
expected = "check" if options.check else "write"
|
|
190
|
+
existing = os.environ.get("DJANGO_COMPOSE_MODE")
|
|
191
|
+
if existing is not None and existing != expected:
|
|
192
|
+
raise ImproperlyConfigured("DJANGO_COMPOSE_MODE conflicts with compose arguments")
|
|
193
|
+
os.environ["DJANGO_COMPOSE_MODE"] = expected
|
|
194
|
+
os.environ["DJANGO_COMPOSE_WAIT"] = str(options.wait)
|
|
195
|
+
sys.dont_write_bytecode = True
|
|
196
|
+
if options.if_stale:
|
|
197
|
+
from django_compose.serving import verify
|
|
198
|
+
|
|
199
|
+
# Settings computes the immutable snapshot; no native setup occurs here.
|
|
200
|
+
snap = settings.COMPOSE_SNAPSHOT
|
|
201
|
+
try:
|
|
202
|
+
verify(snap, settings.COMPOSE_OUTPUT_MODULE)
|
|
203
|
+
except ImproperlyConfigured:
|
|
204
|
+
pass
|
|
205
|
+
else:
|
|
206
|
+
print(f"Composition lock: {settings.COMPOSE_OUTPUT_DIR}/.compose.lock")
|
|
207
|
+
print("Composition is current.")
|
|
208
|
+
return
|
|
209
|
+
_Utility(arguments).execute()
|
|
210
|
+
finally:
|
|
211
|
+
try:
|
|
212
|
+
_fallback_inputs()
|
|
213
|
+
if report_fd is not None:
|
|
214
|
+
write_report(report_fd)
|
|
215
|
+
finally:
|
|
216
|
+
_close_build()
|
|
217
|
+
_close_startup_verification()
|
|
218
|
+
if old_wait is None:
|
|
219
|
+
os.environ.pop("DJANGO_COMPOSE_WAIT", None)
|
|
220
|
+
else:
|
|
221
|
+
os.environ["DJANGO_COMPOSE_WAIT"] = old_wait
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Django command discovery package."""
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Check native history or audit its retained Python import providers."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from django.apps import apps
|
|
6
|
+
from django.core.management.base import BaseCommand, CommandError, CommandParser
|
|
7
|
+
from django.db.migrations.loader import MigrationLoader
|
|
8
|
+
|
|
9
|
+
from django_compose.management import require_serving_mode
|
|
10
|
+
from django_compose.management.commands.makemigrations import history_labels
|
|
11
|
+
from django_compose.migration_imports import MigrationImportReport
|
|
12
|
+
from django_compose.migrations import TransitionBinding
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Command(BaseCommand):
|
|
16
|
+
help = "Check native history and pending transitions, or audit retained Python imports."
|
|
17
|
+
requires_system_checks: list[str] = []
|
|
18
|
+
|
|
19
|
+
def add_arguments(self, parser: CommandParser) -> None:
|
|
20
|
+
parser.add_argument("--imports", action="store_true", help="Audit imports without database access.")
|
|
21
|
+
parser.add_argument("--removing", nargs="+", metavar="DIST", help="Prove named providers removable.")
|
|
22
|
+
|
|
23
|
+
def handle(self, *args: Any, **options: Any) -> None:
|
|
24
|
+
require_serving_mode()
|
|
25
|
+
if options.get("removing") and not options.get("imports"):
|
|
26
|
+
raise CommandError("--removing requires --imports")
|
|
27
|
+
if options.get("imports"):
|
|
28
|
+
report = MigrationImportReport()
|
|
29
|
+
items = report.collect()
|
|
30
|
+
for item in items:
|
|
31
|
+
providers = ", ".join(item.distributions)
|
|
32
|
+
detail = ": ".join(value for value in (item.kind, providers, item.detail) if value)
|
|
33
|
+
self.stdout.write(f"{item.path}: {item.module}: {detail}")
|
|
34
|
+
loader_error = None
|
|
35
|
+
if any(item.kind == "unsupported" for item in items):
|
|
36
|
+
# In particular, never execute opaque compiled migrations merely
|
|
37
|
+
# to obtain loader diagnostics for a static import report.
|
|
38
|
+
loader_error = "Native migration loader skipped: report has unsupported sources or imports"
|
|
39
|
+
self.stdout.write(loader_error)
|
|
40
|
+
else:
|
|
41
|
+
try:
|
|
42
|
+
MigrationLoader(None, ignore_no_migrations=True)
|
|
43
|
+
except Exception as error:
|
|
44
|
+
loader_error = f"Native migration loader: {type(error).__name__}: {error}"
|
|
45
|
+
self.stdout.write(loader_error)
|
|
46
|
+
if options.get("removing"):
|
|
47
|
+
refusals = list(report.removal_refusals(options["removing"]))
|
|
48
|
+
if loader_error:
|
|
49
|
+
refusals.append(loader_error)
|
|
50
|
+
if refusals:
|
|
51
|
+
raise CommandError("Cannot remove distributions:\n" + "\n".join(refusals))
|
|
52
|
+
self.stdout.write("No retained history or enabled app requires the named distributions.")
|
|
53
|
+
return
|
|
54
|
+
try:
|
|
55
|
+
TransitionBinding(labels=history_labels()).check_native_history(apps)
|
|
56
|
+
except RuntimeError as error:
|
|
57
|
+
raise CommandError(str(error)) from error
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Report the composition performed by native phase-2 population."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from argparse import ArgumentParser, ArgumentTypeError
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from django.apps import apps
|
|
9
|
+
from django.conf import settings
|
|
10
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
11
|
+
|
|
12
|
+
from django_compose.serving import mode
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _wait(value: str) -> float:
|
|
16
|
+
seconds = float(value)
|
|
17
|
+
if not math.isfinite(seconds) or seconds < 0:
|
|
18
|
+
raise ArgumentTypeError("wait must be finite and nonnegative")
|
|
19
|
+
return seconds
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Command(BaseCommand):
|
|
23
|
+
help = "Compose concrete models and capability artifacts."
|
|
24
|
+
requires_system_checks = []
|
|
25
|
+
|
|
26
|
+
def add_arguments(self, parser: ArgumentParser) -> None:
|
|
27
|
+
parser.add_argument("--check", action="store_true", help="Compare output without writing files")
|
|
28
|
+
parser.add_argument("--if-stale", action="store_true", help="Skip setup when already current")
|
|
29
|
+
parser.add_argument("--report-inputs", type=int, metavar="FD", help="Write input JSON to descriptor")
|
|
30
|
+
parser.add_argument("--wait", type=_wait, default=5.0, metavar="SECONDS", help="Writer lock timeout")
|
|
31
|
+
|
|
32
|
+
def handle(self, *args: Any, **options: Any) -> None:
|
|
33
|
+
expected = "check" if options["check"] else "write"
|
|
34
|
+
config = apps.get_app_config("django_compose")
|
|
35
|
+
if mode() != expected or getattr(config, "compose_mode", None) != expected:
|
|
36
|
+
raise CommandError(
|
|
37
|
+
"Set DJANGO_COMPOSE_MODE before settings load or use "
|
|
38
|
+
"django_compose.management.execute; direct django-admin compose requires the mode variable"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
self.stdout.write(f"Composition lock: {Path(settings.COMPOSE_OUTPUT_DIR) / '.compose.lock'}")
|
|
42
|
+
self.stdout.write("Composition is current." if options["check"] else "Composition generated.")
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Author initial schema, bind transitions, then reconcile native model changes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from django.apps import apps
|
|
10
|
+
from django.conf import settings
|
|
11
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
12
|
+
from django.core.management.base import CommandError
|
|
13
|
+
from django.core.management.commands.makemigrations import Command as NativeCommand
|
|
14
|
+
from django.db.migrations import Migration
|
|
15
|
+
from django.db.migrations.loader import MigrationLoader
|
|
16
|
+
from django.db.migrations.writer import MigrationWriter
|
|
17
|
+
from django.utils.module_loading import module_dir
|
|
18
|
+
|
|
19
|
+
from django_compose.management import require_serving_mode
|
|
20
|
+
from django_compose.migrations import (
|
|
21
|
+
DropGuardAutodetector,
|
|
22
|
+
TransitionBinding,
|
|
23
|
+
_metadata,
|
|
24
|
+
)
|
|
25
|
+
from django_compose.serving import read_receipt
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def history_labels() -> tuple[str, ...]:
|
|
29
|
+
"""Read the compiler receipt without invoking composition."""
|
|
30
|
+
receipt = read_receipt(Path(settings.COMPOSE_OUTPUT_DIR))
|
|
31
|
+
if receipt is None:
|
|
32
|
+
raise CommandError("Migration authoring requires a receipt; run compose first")
|
|
33
|
+
return tuple(receipt.HISTORY_LABELS)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Command(NativeCommand):
|
|
37
|
+
"""Keep native authoring and add immutable reusable origin binding."""
|
|
38
|
+
|
|
39
|
+
autodetector = DropGuardAutodetector
|
|
40
|
+
_initial_history: frozenset[str] | None = None
|
|
41
|
+
|
|
42
|
+
def handle(self, *app_labels: str, **options: Any) -> str | None:
|
|
43
|
+
require_serving_mode()
|
|
44
|
+
name = options.get("name")
|
|
45
|
+
if name and not name.isidentifier():
|
|
46
|
+
return super().handle(*app_labels, **options)
|
|
47
|
+
for label in app_labels:
|
|
48
|
+
try:
|
|
49
|
+
apps.get_app_config(label)
|
|
50
|
+
except LookupError:
|
|
51
|
+
return super().handle(*app_labels, **options)
|
|
52
|
+
labels = history_labels()
|
|
53
|
+
loader = MigrationLoader(None, ignore_no_migrations=True)
|
|
54
|
+
initialized = frozenset(label for label, _ in loader.disk_migrations)
|
|
55
|
+
initial = {
|
|
56
|
+
label
|
|
57
|
+
for label in labels
|
|
58
|
+
if label not in initialized
|
|
59
|
+
and (not app_labels or label in app_labels)
|
|
60
|
+
and MigrationLoader.migrations_module(label)[0] is not None
|
|
61
|
+
and any(apps.get_app_config(label).get_models())
|
|
62
|
+
}
|
|
63
|
+
selected = set(app_labels) if app_labels else None
|
|
64
|
+
if initial:
|
|
65
|
+
for flag in ("merge", "empty", "update"):
|
|
66
|
+
if options.get(flag):
|
|
67
|
+
raise CommandError(f"--{flag} is not applicable before initial schema authoring")
|
|
68
|
+
self._initial_history = initialized
|
|
69
|
+
try:
|
|
70
|
+
super().handle(*sorted(initial), **options)
|
|
71
|
+
finally:
|
|
72
|
+
self._initial_history = None
|
|
73
|
+
if options.get("dry_run") or options.get("check_changes"):
|
|
74
|
+
return None
|
|
75
|
+
if selected is not None:
|
|
76
|
+
# Native dependency expansion can initialize other fresh apps.
|
|
77
|
+
current = MigrationLoader(None, ignore_no_migrations=True)
|
|
78
|
+
selected.update(label for label, _ in current.disk_migrations if label not in initialized)
|
|
79
|
+
if options.get("merge"):
|
|
80
|
+
return super().handle(*app_labels, **options)
|
|
81
|
+
authored = TransitionBinding(labels=labels)
|
|
82
|
+
try:
|
|
83
|
+
if options.get("dry_run") or options.get("check_changes"):
|
|
84
|
+
authored.check(app_labels=selected)
|
|
85
|
+
else:
|
|
86
|
+
authored.materialize(apps, app_labels=selected)
|
|
87
|
+
except (RuntimeError, ImproperlyConfigured) as error:
|
|
88
|
+
raise CommandError(str(error)) from error
|
|
89
|
+
return super().handle(*app_labels, **options)
|
|
90
|
+
|
|
91
|
+
def write_migration_files(
|
|
92
|
+
self,
|
|
93
|
+
changes: dict[str, list[Migration]],
|
|
94
|
+
update_previous_migration_paths: dict[str, str] | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Preflight every destination before handing serialization to Django."""
|
|
97
|
+
if self._initial_history is not None:
|
|
98
|
+
existing = sorted(set(changes) & self._initial_history)
|
|
99
|
+
if existing:
|
|
100
|
+
raise CommandError(
|
|
101
|
+
"Initial schema depends on changes to existing apps; "
|
|
102
|
+
"author their transitions first: " + ", ".join(existing)
|
|
103
|
+
)
|
|
104
|
+
destinations = set()
|
|
105
|
+
paths = set()
|
|
106
|
+
for label, migrations in changes.items():
|
|
107
|
+
for migration in migrations:
|
|
108
|
+
if update_previous_migration_paths and _metadata(migration, "origin") is not None:
|
|
109
|
+
raise CommandError(
|
|
110
|
+
"Cannot update immutable authored migration; create a follow-up migration"
|
|
111
|
+
)
|
|
112
|
+
package, _ = MigrationLoader.migrations_module(label)
|
|
113
|
+
if package is None:
|
|
114
|
+
raise CommandError(f"Migrations are disabled for app {label!r}")
|
|
115
|
+
writer = MigrationWriter(migration, self.include_header)
|
|
116
|
+
key = package, writer.filename
|
|
117
|
+
if key in destinations:
|
|
118
|
+
raise CommandError(f"Multiple migrations target {key!r}")
|
|
119
|
+
destinations.add(key)
|
|
120
|
+
try:
|
|
121
|
+
directory = Path(module_dir(importlib.import_module(package)))
|
|
122
|
+
except ModuleNotFoundError as error:
|
|
123
|
+
if error.name is None or not (
|
|
124
|
+
package == error.name or package.startswith(error.name + ".")
|
|
125
|
+
):
|
|
126
|
+
raise
|
|
127
|
+
if self.dry_run:
|
|
128
|
+
raise CommandError(
|
|
129
|
+
f"Read-only authoring cannot create missing migration package {package!r}"
|
|
130
|
+
) from error
|
|
131
|
+
continue
|
|
132
|
+
path = (directory / writer.filename).resolve()
|
|
133
|
+
if path in paths or path.exists():
|
|
134
|
+
raise CommandError(f"Refusing to overwrite existing migration {path}")
|
|
135
|
+
paths.add(path)
|
|
136
|
+
return super().write_migration_files(changes, update_previous_migration_paths)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Share native makemigrations' autodetector, as required by commands.E001."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from django.core.management.commands.migrate import Command as NativeCommand
|
|
6
|
+
|
|
7
|
+
from django_compose.management import require_serving_mode
|
|
8
|
+
from django_compose.migrations import DropGuardAutodetector
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Command(NativeCommand):
|
|
12
|
+
"""Apply native migrations with the shared model-change notice guard."""
|
|
13
|
+
|
|
14
|
+
autodetector = DropGuardAutodetector
|
|
15
|
+
|
|
16
|
+
def handle(self, *args: Any, **options: Any) -> Any:
|
|
17
|
+
require_serving_mode()
|
|
18
|
+
return super().handle(*args, **options)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Explicitly retire fully applied, model-clean native migration history."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from django.core.management.base import BaseCommand, CommandError, CommandParser
|
|
6
|
+
from django.db import DEFAULT_DB_ALIAS, connections
|
|
7
|
+
|
|
8
|
+
from django_compose.management import require_serving_mode
|
|
9
|
+
from django_compose.management.commands.makemigrations import history_labels
|
|
10
|
+
from django_compose.migrations import reset_migration_history
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Command(BaseCommand):
|
|
14
|
+
"""Dispatch the separately guarded maintenance operation."""
|
|
15
|
+
|
|
16
|
+
help = "Replace selected fully applied migration histories with fresh initials."
|
|
17
|
+
|
|
18
|
+
def add_arguments(self, parser: CommandParser) -> None:
|
|
19
|
+
parser.add_argument("app_labels", nargs="*")
|
|
20
|
+
parser.add_argument("--database", default=DEFAULT_DB_ALIAS, choices=tuple(connections))
|
|
21
|
+
|
|
22
|
+
def handle(self, *args: Any, **options: Any) -> None:
|
|
23
|
+
require_serving_mode()
|
|
24
|
+
allowed = set(history_labels())
|
|
25
|
+
selected = set(options["app_labels"]) or allowed
|
|
26
|
+
if unknown := selected - allowed:
|
|
27
|
+
raise CommandError(f"Unknown composed apps: {', '.join(sorted(unknown))}")
|
|
28
|
+
try:
|
|
29
|
+
labels, removed, recorded = reset_migration_history(selected, database=options["database"])
|
|
30
|
+
except RuntimeError as error:
|
|
31
|
+
raise CommandError(str(error)) from error
|
|
32
|
+
self.stdout.write(
|
|
33
|
+
f"labels: {', '.join(labels)}; files removed: {removed}; migrations recorded: {recorded}"
|
|
34
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Explicit retirement of disabled native migration history."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from django.core.management.base import BaseCommand, CommandError, CommandParser
|
|
6
|
+
|
|
7
|
+
from django_compose.management import require_serving_mode
|
|
8
|
+
from django_compose.migrations import retire_history
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Command(BaseCommand):
|
|
12
|
+
help = "Retire history-only labels after routed database and dependency checks."
|
|
13
|
+
|
|
14
|
+
def add_arguments(self, parser: CommandParser) -> None:
|
|
15
|
+
parser.add_argument("label")
|
|
16
|
+
parser.add_argument("--purge", action="store_true")
|
|
17
|
+
|
|
18
|
+
def handle(self, *args: Any, **options: Any) -> None:
|
|
19
|
+
require_serving_mode()
|
|
20
|
+
try:
|
|
21
|
+
retire_history(options["label"], purge=options["purge"])
|
|
22
|
+
except RuntimeError as error:
|
|
23
|
+
raise CommandError(str(error)) from error
|
|
24
|
+
self.stdout.write(f"Retired history: {options['label']}")
|