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,474 @@
1
+ """Read retained Python dependencies without importing migration or helper code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import importlib.util
7
+ import pkgutil
8
+ import re
9
+ import symtable
10
+ from collections.abc import Iterable
11
+ from dataclasses import dataclass
12
+ from importlib.machinery import SOURCE_SUFFIXES
13
+ from pathlib import Path
14
+
15
+ from django.apps import apps
16
+ from django.conf import settings
17
+
18
+ from django_compose.sources import ModuleSource, SourceResolver
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class HistoricalImport:
23
+ """One import edge, classified from its resolved file rather than package name."""
24
+
25
+ path: Path
26
+ module: str
27
+ distributions: tuple[str, ...] = ()
28
+ kind: str = "unresolved"
29
+ detail: str = ""
30
+
31
+
32
+ def _distribution_key(name: str) -> str:
33
+ return re.sub(r"[-_.]+", "-", name).lower()
34
+
35
+
36
+ class _ImportCalls(ast.NodeVisitor):
37
+ """Let Python's symbol tables own import bindings across lexical scopes.
38
+
39
+ Retain every imported candidate for a binding: conditional imports and
40
+ global/nonlocal rebindings cannot erase a possible native import callable.
41
+ Unrelated local names never replace candidates from a different scope.
42
+ """
43
+
44
+ def __init__(self, source: str, path: Path, package: str) -> None:
45
+ self.root = symtable.symtable(source, str(path), "exec")
46
+ self.scope = self.root
47
+ self.package = package
48
+ self.parents: dict[symtable.SymbolTable, symtable.SymbolTable] = {}
49
+ self.aliases: dict[symtable.SymbolTable, dict[str, set[str]]] = {}
50
+ self.calls: list[tuple[ast.Call, symtable.SymbolTable, frozenset[str]]] = []
51
+ self.masked: frozenset[str] = frozenset()
52
+
53
+ def _owner(self, scope: symtable.SymbolTable, name: str) -> symtable.SymbolTable:
54
+ try:
55
+ symbol = scope.lookup(name)
56
+ except KeyError:
57
+ return self.root
58
+ if symbol.is_global():
59
+ return self.root
60
+ if symbol.is_free() or symbol.is_nonlocal():
61
+ parent = self.parents[scope]
62
+ while parent is not self.root:
63
+ if parent.get_type() != symtable.SymbolTableType.CLASS:
64
+ candidate = parent.lookup(name) if name in parent.get_identifiers() else None
65
+ if candidate is not None and candidate.is_local():
66
+ return parent
67
+ parent = self.parents[parent]
68
+ return self.root
69
+ return scope
70
+
71
+ def _bind(self, name: str, target: str) -> None:
72
+ owner = self._owner(self.scope, name)
73
+ self.aliases.setdefault(owner, {}).setdefault(name, set()).add(target)
74
+
75
+ def visit_Import(self, node: ast.Import) -> None:
76
+ for alias in node.names:
77
+ name = alias.asname or alias.name.split(".")[0]
78
+ self._bind(name, alias.name if alias.asname else name)
79
+
80
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
81
+ base = node.module or ""
82
+ if node.level:
83
+ try:
84
+ base = importlib.util.resolve_name("." * node.level + base, self.package)
85
+ except ImportError, ValueError:
86
+ return # The report separately records this invalid import.
87
+ for alias in node.names:
88
+ self._bind(alias.asname or alias.name, f"{base}.{alias.name}")
89
+
90
+ def visit_Call(self, node: ast.Call) -> None:
91
+ self.calls.append((node, self.scope, self.masked))
92
+ self.generic_visit(node)
93
+
94
+ def _nested(self, node: ast.stmt | ast.expr, name: str, body: Iterable[ast.AST]) -> None:
95
+ # Type parameter tables wrap generic functions/classes in Python 3.14.
96
+ parent = self.scope
97
+ candidates = [
98
+ child
99
+ for child in parent.get_children()
100
+ if child.get_name() == name and child.get_lineno() == node.lineno
101
+ ]
102
+ if len(candidates) != 1:
103
+ raise ValueError(f"Cannot uniquely match Python lexical scope {name!r} at line {node.lineno}")
104
+ child = candidates[0]
105
+ self.parents[child] = parent
106
+ while child.get_type() == symtable.SymbolTableType.TYPE_PARAMETERS:
107
+ parent = child
108
+ child = next(table for table in child.get_children() if table.get_name() == name)
109
+ self.parents[child] = parent
110
+ previous, self.scope = self.scope, child
111
+ for statement in body:
112
+ self.visit(statement)
113
+ self.scope = previous
114
+
115
+ def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
116
+ for expression in (*node.decorator_list, *node.args.defaults, *node.args.kw_defaults):
117
+ if expression is not None:
118
+ self.visit(expression)
119
+ for argument in (
120
+ *node.args.posonlyargs,
121
+ *node.args.args,
122
+ *node.args.kwonlyargs,
123
+ *([node.args.vararg] if node.args.vararg else []),
124
+ *([node.args.kwarg] if node.args.kwarg else []),
125
+ ):
126
+ if argument.annotation is not None:
127
+ self.visit(argument.annotation)
128
+ if node.returns is not None:
129
+ self.visit(node.returns)
130
+ for parameter in node.type_params:
131
+ self.visit(parameter)
132
+ self._nested(node, node.name, node.body)
133
+
134
+ visit_AsyncFunctionDef = visit_FunctionDef
135
+
136
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
137
+ for expression in (*node.decorator_list, *node.bases, *node.keywords):
138
+ self.visit(expression)
139
+ self._nested(node, node.name, node.body)
140
+
141
+ def visit_Lambda(self, node: ast.Lambda) -> None:
142
+ for expression in (*node.args.defaults, *node.args.kw_defaults):
143
+ if expression is not None:
144
+ self.visit(expression)
145
+ self._nested(node, "lambda", (node.body,))
146
+
147
+ def _comprehension(self, node: ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp) -> None:
148
+ # Python inlines list/set/dict comprehensions; their iteration variables
149
+ # still shadow outer bindings. Generator expressions have a native scope.
150
+ self.visit(node.generators[0].iter)
151
+ expressions = [expression for generator in node.generators for expression in generator.ifs]
152
+ expressions.extend(generator.iter for generator in node.generators[1:])
153
+ if isinstance(node, ast.DictComp):
154
+ expressions.extend((node.key, node.value))
155
+ else:
156
+ expressions.append(node.elt)
157
+ if isinstance(node, ast.GeneratorExp):
158
+ self._nested(node, "genexpr", expressions)
159
+ return
160
+ previous = self.masked
161
+ self.masked |= frozenset(
162
+ target.id
163
+ for generator in node.generators
164
+ for target in ast.walk(generator.target)
165
+ if isinstance(target, ast.Name)
166
+ )
167
+ for expression in expressions:
168
+ self.visit(expression)
169
+ self.masked = previous
170
+
171
+ visit_ListComp = _comprehension
172
+ visit_SetComp = _comprehension
173
+ visit_DictComp = _comprehension
174
+ visit_GeneratorExp = _comprehension
175
+
176
+ def targets(self, node: ast.expr, scope: symtable.SymbolTable, masked: frozenset[str]) -> set[str]:
177
+ if isinstance(node, ast.Name):
178
+ if node.id in masked:
179
+ return set()
180
+ owner = self._owner(scope, node.id)
181
+ owners = {owner}
182
+ if scope.get_type() == symtable.SymbolTableType.CLASS and owner is scope:
183
+ # Class bodies use LOAD_NAME: an unbound local falls back to
184
+ # globals, unlike a function's statically local variables.
185
+ owners.add(self.root)
186
+ targets = {
187
+ target for table in owners for target in self.aliases.get(table, {}).get(node.id, set())
188
+ }
189
+ if node.id == "__import__" and self.root in owners:
190
+ targets.add("__import__")
191
+ return targets
192
+ if isinstance(node, ast.Attribute):
193
+ return {target + "." + node.attr for target in self.targets(node.value, scope, masked)}
194
+ return set()
195
+
196
+
197
+ class MigrationImportReport:
198
+ """Audit selected native migration sources and local helper bodies offline.
199
+
200
+ Distribution source files are leaves: installed distributions own their
201
+ dependencies. Project and enabled-app helpers are recursively inspected,
202
+ including imports inside functions which a RunPython operation may call.
203
+ """
204
+
205
+ def __init__(self) -> None:
206
+ self.resolver = SourceResolver(apps.get_app_configs())
207
+ self.items: list[HistoricalImport] = []
208
+ self._visited: set[Path] = set()
209
+ snapshot = getattr(settings, "COMPOSE_SNAPSHOT", None)
210
+ self._transition_packages = {
211
+ record["module"].rpartition(".")[0] for record in getattr(snapshot, "transition_records", ())
212
+ }
213
+
214
+ def _edge(self, path: Path, source: ModuleSource) -> HistoricalImport:
215
+ if source.path is not None and source.path.is_dir() and source.search_locations:
216
+ # A namespace executes no initializer. Its distribution portions are
217
+ # alternatives for availability, not independent import requirements.
218
+ providers = (
219
+ source.distributions
220
+ if all(self.resolver.distribution_names(location) for location in source.search_locations)
221
+ else ()
222
+ )
223
+ return HistoricalImport(path, source.module, providers, "namespace")
224
+ return HistoricalImport(path, source.module, source.distributions, source.kind)
225
+
226
+ def _record(self, path: Path, module: str) -> None:
227
+ source = self.resolver.resolve(module)
228
+ self.items.append(self._edge(path, source))
229
+ if (
230
+ source.path is not None
231
+ and source.path.is_file()
232
+ and (
233
+ source.kind in {"app", "project"}
234
+ or any(
235
+ module == package or module.startswith(package + ".")
236
+ for package in self._transition_packages
237
+ )
238
+ )
239
+ ):
240
+ self._scan(source.path, module)
241
+ # Importing a child also executes its parent packages. Inspect local
242
+ # package initializers even when the child itself belongs to a dist.
243
+ for index in range(1, len(module.split("."))):
244
+ parent = ".".join(module.split(".")[:index])
245
+ parent_source = self.resolver.resolve(parent)
246
+ self.items.append(self._edge(path, parent_source))
247
+ if (
248
+ parent_source.path is not None
249
+ and parent_source.path.is_file()
250
+ and (parent_source.kind in {"app", "project"} or parent in self._transition_packages)
251
+ ):
252
+ self._scan(parent_source.path, parent)
253
+
254
+ def _unsupported(self, path: Path, node: ast.AST, detail: str) -> None:
255
+ self.items.append(HistoricalImport(path, ast.unparse(node), kind="unsupported", detail=detail))
256
+
257
+ def _scan(self, path: Path, module: str) -> None:
258
+ path = path.resolve()
259
+ if path in self._visited:
260
+ return
261
+ self._visited.add(path)
262
+ if path.suffix not in SOURCE_SUFFIXES:
263
+ self.items.append(
264
+ HistoricalImport(
265
+ path,
266
+ module,
267
+ kind="unsupported",
268
+ detail="migration/helper has no inspectable Python source",
269
+ )
270
+ )
271
+ return
272
+ try:
273
+ source = path.read_text()
274
+ tree = ast.parse(source, filename=str(path))
275
+ except (OSError, SyntaxError, UnicodeError) as error:
276
+ self.items.append(HistoricalImport(path, module, kind="unresolved", detail=str(error)))
277
+ return
278
+ package = module if path.name == "__init__.py" else module.rpartition(".")[0]
279
+ # Copied transitions deliberately retain their original relative package.
280
+ for statement in tree.body:
281
+ if isinstance(statement, ast.Assign) and any(
282
+ isinstance(target, ast.Name) and target.id == "__package__" for target in statement.targets
283
+ ):
284
+ if isinstance(statement.value, ast.Constant) and isinstance(statement.value.value, str):
285
+ package = statement.value.value
286
+ self._transition_packages.add(package)
287
+ try:
288
+ bindings = _ImportCalls(source, path, package)
289
+ bindings.visit(tree)
290
+ except SyntaxError as error:
291
+ self.items.append(HistoricalImport(path, module, kind="unresolved", detail=str(error)))
292
+ return
293
+ except (ValueError, StopIteration) as error:
294
+ self.items.append(HistoricalImport(path, module, kind="unsupported", detail=str(error)))
295
+ for node in ast.walk(tree):
296
+ if isinstance(node, ast.Import):
297
+ for alias in node.names:
298
+ self._record(path, alias.name)
299
+ elif isinstance(node, ast.ImportFrom):
300
+ base = node.module or ""
301
+ if node.level:
302
+ try:
303
+ base = importlib.util.resolve_name("." * node.level + base, package)
304
+ except (ImportError, ValueError) as error:
305
+ self._unsupported(path, node, str(error))
306
+ continue
307
+ if base:
308
+ self._record(path, base)
309
+ # `from package import helper` can import a submodule, while a
310
+ # symbol imported from a module has no separate file provider.
311
+ for alias in node.names:
312
+ child = f"{base}.{alias.name}"
313
+ if alias.name != "*" and self.resolver.resolve(child).kind != "unresolved":
314
+ self._record(path, child)
315
+ for node, scope, masked in bindings.calls:
316
+ for target in bindings.targets(node.func, scope, masked):
317
+ if target not in {
318
+ "__import__",
319
+ "builtins.__import__",
320
+ "importlib.import_module",
321
+ "django.utils.module_loading.import_string",
322
+ }:
323
+ continue
324
+ argument = (
325
+ node.args[0]
326
+ if node.args
327
+ else next(
328
+ (
329
+ keyword.value
330
+ for keyword in node.keywords
331
+ if keyword.arg in {"name", "dotted_path"}
332
+ ),
333
+ None,
334
+ )
335
+ )
336
+ if not isinstance(argument, ast.Constant) or not isinstance(argument.value, str):
337
+ self._unsupported(path, node, "non-literal dynamic import")
338
+ continue
339
+ imported = argument.value
340
+ if target.endswith(".import_string"):
341
+ imported, _, attribute = imported.rpartition(".")
342
+ if not imported or not attribute:
343
+ self._unsupported(path, node, "import_string requires a module and attribute")
344
+ continue
345
+ if target in {"__import__", "builtins.__import__"}:
346
+ level = (
347
+ node.args[4]
348
+ if len(node.args) > 4
349
+ else next(
350
+ (keyword.value for keyword in node.keywords if keyword.arg == "level"),
351
+ ast.Constant(value=0),
352
+ )
353
+ )
354
+ if not isinstance(level, ast.Constant) or type(level.value) is not int or level.value < 0:
355
+ self._unsupported(path, node, "__import__ needs a literal nonnegative level")
356
+ continue
357
+ if level.value:
358
+ try:
359
+ imported = importlib.util.resolve_name("." * level.value + imported, package)
360
+ except (ImportError, ValueError) as error:
361
+ self._unsupported(path, node, str(error))
362
+ continue
363
+ if imported.startswith("."):
364
+ relative_package = (
365
+ node.args[1]
366
+ if len(node.args) > 1
367
+ else next(
368
+ (keyword.value for keyword in node.keywords if keyword.arg == "package"), None
369
+ )
370
+ )
371
+ if isinstance(relative_package, ast.Name) and relative_package.id == "__package__":
372
+ anchor = package
373
+ elif isinstance(relative_package, ast.Constant) and isinstance(
374
+ relative_package.value, str
375
+ ):
376
+ anchor = relative_package.value
377
+ else:
378
+ self._unsupported(path, node, "relative dynamic import needs a literal package")
379
+ continue
380
+ try:
381
+ imported = importlib.util.resolve_name(imported, anchor)
382
+ except (ImportError, ValueError) as error:
383
+ self._unsupported(path, node, str(error))
384
+ continue
385
+ self._record(path, imported)
386
+ if target in {"__import__", "builtins.__import__"}:
387
+ fromlist = (
388
+ node.args[3]
389
+ if len(node.args) > 3
390
+ else next(
391
+ (keyword.value for keyword in node.keywords if keyword.arg == "fromlist"),
392
+ ast.Tuple(elts=[]),
393
+ )
394
+ )
395
+ if not isinstance(fromlist, (ast.Tuple, ast.List)) or not all(
396
+ isinstance(item, ast.Constant) and isinstance(item.value, str)
397
+ for item in fromlist.elts
398
+ ):
399
+ self._unsupported(path, node, "__import__ needs a literal fromlist")
400
+ continue
401
+ for item in fromlist.elts:
402
+ child = imported + "." + str(ast.literal_eval(item))
403
+ if self.resolver.resolve(child).kind != "unresolved":
404
+ self._record(path, child)
405
+
406
+ def collect(self) -> tuple[HistoricalImport, ...]:
407
+ """Enumerate selected modules from native settings, never loader objects."""
408
+ mappings = settings.MIGRATION_MODULES
409
+ for config in sorted(apps.get_app_configs(), key=lambda config: config.label):
410
+ module = mappings.get(config.label, config.name + ".migrations")
411
+ if module is None:
412
+ continue
413
+ source = self.resolver.resolve(module)
414
+ if source.path is None:
415
+ # A plain unmigrated app need not provide the native default.
416
+ if config.label in mappings:
417
+ self.items.append(HistoricalImport(Path(config.path), module, kind="unresolved"))
418
+ continue
419
+ directories = source.search_locations
420
+ if not directories:
421
+ if config.label not in mappings:
422
+ continue # Django treats a non-package default as unmigrated.
423
+ self._scan(source.path, module)
424
+ self.items.append(
425
+ HistoricalImport(
426
+ source.path, module, kind="unresolved", detail="migration module must be a package"
427
+ )
428
+ )
429
+ continue
430
+ self._record(source.path, module)
431
+ for _, name, is_package in sorted(
432
+ pkgutil.iter_modules([str(path) for path in directories]), key=lambda item: item.name
433
+ ):
434
+ if not is_package and not name.startswith(("_", "~")):
435
+ migration_module = module + "." + name
436
+ self._record(source.path, migration_module)
437
+ migration_source = self.resolver.resolve(migration_module)
438
+ if migration_source.path is not None:
439
+ self._scan(migration_source.path, migration_module)
440
+ return tuple(sorted(set(self.items), key=lambda item: (str(item.path), item.module, item.kind)))
441
+
442
+ def removal_refusals(self, removing: Iterable[str]) -> tuple[str, ...]:
443
+ """Refuse enabled providers, retained imports and unprovable dependencies."""
444
+ selected = {_distribution_key(name) for name in removing}
445
+ refusals = []
446
+ for config in apps.get_app_configs():
447
+ source = self.resolver.resolve(config.name)
448
+ providers = set(source.distributions)
449
+ config_source = self.resolver.resolve(type(config).__module__)
450
+ providers.update(config_source.distributions)
451
+ for name in sorted(providers):
452
+ if _distribution_key(name) in selected:
453
+ refusals.append(f"{name} provides enabled app {config.name}")
454
+ for item in self.items:
455
+ if item.kind in {"unsupported", "unresolved"}:
456
+ refusals.append(f"{item.path}: {item.kind} {item.module}: {item.detail}")
457
+ if item.kind == "namespace":
458
+ if item.distributions and all(
459
+ _distribution_key(name) in selected for name in item.distributions
460
+ ):
461
+ refusals.append(
462
+ f"{item.path}: retained namespace import {item.module} needs at least one of "
463
+ + ", ".join(item.distributions)
464
+ )
465
+ continue
466
+ for name in item.distributions:
467
+ if _distribution_key(name) in selected:
468
+ refusals.append(f"{item.path}: retained import {item.module} requires {name}")
469
+ return tuple(sorted(set(refusals)))
470
+
471
+
472
+ def historical_imports() -> tuple[HistoricalImport, ...]:
473
+ """Report selected native history imports without executing imported code."""
474
+ return MigrationImportReport().collect()