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,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()
|