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,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']}")