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,419 @@
|
|
|
1
|
+
"""Immutable, pre-setup-safe composition inputs shared by builders and readers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import importlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Iterable, Mapping
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from types import MappingProxyType
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
from django.apps import AppConfig
|
|
17
|
+
from django.conf import global_settings
|
|
18
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
19
|
+
from django.utils.module_loading import import_string
|
|
20
|
+
|
|
21
|
+
from django_compose.autoreload import report_inputs, report_watch_paths
|
|
22
|
+
from django_compose.sources import SourceResolver
|
|
23
|
+
|
|
24
|
+
COMPILER = "django-compose:1"
|
|
25
|
+
DEFAULT_EXCLUDES = ("**/__pycache__/**", "**/migrations/**", "**/tests/**")
|
|
26
|
+
|
|
27
|
+
NON_INPUT_SETTINGS = frozenset(
|
|
28
|
+
{"COMPOSE_ROOTS", "COMPOSE_SNAPSHOT", "COMPOSE_CLOSURE", "COMPOSE_COMMAND_CONTEXT"}
|
|
29
|
+
)
|
|
30
|
+
STRUCTURAL_SETTINGS = frozenset(
|
|
31
|
+
{
|
|
32
|
+
"INSTALLED_APPS",
|
|
33
|
+
"MIGRATION_MODULES",
|
|
34
|
+
"DEFAULT_AUTO_FIELD",
|
|
35
|
+
"AUTH_USER_MODEL",
|
|
36
|
+
"COMPOSE_ADDON_GROUP",
|
|
37
|
+
"COMPOSE_OUTPUT_DIR",
|
|
38
|
+
"COMPOSE_OUTPUT_MODULE",
|
|
39
|
+
"COMPOSE_INTEGRATIONS",
|
|
40
|
+
"COMPOSE_TRANSITION_SOURCE",
|
|
41
|
+
"COMPOSE_EXTERNAL_ARTIFACT_ROOTS",
|
|
42
|
+
"COMPOSE_FINGERPRINT_GLOBS",
|
|
43
|
+
"COMPOSE_FINGERPRINT_SETTINGS",
|
|
44
|
+
"COMPOSE_FINGERPRINT_EXCLUDES",
|
|
45
|
+
"COMPOSE_ENV_PREFIXES",
|
|
46
|
+
"COMPOSE_WATCH_PATHS",
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def structural_settings(namespace: Mapping[str, Any], *, settings_keys: Iterable[str] = ()) -> frozenset[str]:
|
|
52
|
+
"""Name compiler inputs, including capability-declared fingerprint settings.
|
|
53
|
+
|
|
54
|
+
Configuration uses this same ownership boundary to refuse environment inputs.
|
|
55
|
+
Derived state and command execution context never participate in a snapshot.
|
|
56
|
+
"""
|
|
57
|
+
declared = namespace.get("COMPOSE_FINGERPRINT_SETTINGS", ())
|
|
58
|
+
if isinstance(declared, str) or not isinstance(declared, Iterable):
|
|
59
|
+
raise ImproperlyConfigured("COMPOSE_FINGERPRINT_SETTINGS must contain setting names")
|
|
60
|
+
custom = (*settings_keys, *declared)
|
|
61
|
+
if any(not isinstance(key, str) or not key for key in custom):
|
|
62
|
+
raise ImproperlyConfigured("COMPOSE_FINGERPRINT_SETTINGS must contain setting names")
|
|
63
|
+
return (
|
|
64
|
+
STRUCTURAL_SETTINGS | {key for key in namespace if key.startswith("COMPOSE_")} | set(custom)
|
|
65
|
+
) - NON_INPUT_SETTINGS
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def mode() -> Literal["write", "check"] | None:
|
|
69
|
+
value = os.environ.get("DJANGO_COMPOSE_MODE")
|
|
70
|
+
if value == "write":
|
|
71
|
+
return "write"
|
|
72
|
+
if value == "check":
|
|
73
|
+
return "check"
|
|
74
|
+
if value is not None:
|
|
75
|
+
raise ImproperlyConfigured("DJANGO_COMPOSE_MODE must be write or check, or absent for serving")
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _freeze(value: Any) -> Any:
|
|
80
|
+
if isinstance(value, Mapping):
|
|
81
|
+
return MappingProxyType({str(key): _freeze(item) for key, item in sorted(value.items())})
|
|
82
|
+
if isinstance(value, (tuple, list)):
|
|
83
|
+
return tuple(_freeze(item) for item in value)
|
|
84
|
+
if value is None or isinstance(value, (str, bool, int, float)):
|
|
85
|
+
return value
|
|
86
|
+
raise ImproperlyConfigured(f"Snapshot value is not a literal: {value!r}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _plain(value: Any) -> Any:
|
|
90
|
+
if isinstance(value, Mapping):
|
|
91
|
+
return {key: _plain(item) for key, item in value.items()}
|
|
92
|
+
if isinstance(value, tuple):
|
|
93
|
+
return [_plain(item) for item in value]
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class Entry:
|
|
99
|
+
"""A selected native app identity; entry preserves the authored spelling."""
|
|
100
|
+
|
|
101
|
+
name: str
|
|
102
|
+
label: str
|
|
103
|
+
config: str
|
|
104
|
+
path: str
|
|
105
|
+
entry: str
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def from_config(cls, config: AppConfig, entry: str) -> Entry:
|
|
109
|
+
"""Capture an already-selected native config without activating it again."""
|
|
110
|
+
return cls(
|
|
111
|
+
config.name,
|
|
112
|
+
config.label,
|
|
113
|
+
f"{type(config).__module__}.{type(config).__qualname__}",
|
|
114
|
+
config.path,
|
|
115
|
+
entry,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class Snapshot:
|
|
121
|
+
"""Frozen build inputs, retaining resolved direct dependencies by native app name."""
|
|
122
|
+
|
|
123
|
+
entries: tuple[Entry, ...]
|
|
124
|
+
files: tuple[tuple[str, str], ...]
|
|
125
|
+
integration_paths: tuple[str, ...]
|
|
126
|
+
transitions: tuple[tuple[str, str], ...]
|
|
127
|
+
transition_records: tuple[Mapping[str, str], ...]
|
|
128
|
+
authored_migration_modules: Mapping[str, str | None]
|
|
129
|
+
settings: Mapping[str, Any]
|
|
130
|
+
compiler: str = COMPILER
|
|
131
|
+
dependencies: Mapping[str, tuple[str, ...]] = field(
|
|
132
|
+
default_factory=lambda: MappingProxyType({}), kw_only=True
|
|
133
|
+
)
|
|
134
|
+
_process_mode: Literal["write", "check"] | None = field(default_factory=mode, repr=False, compare=False)
|
|
135
|
+
|
|
136
|
+
def check_mode(self) -> None:
|
|
137
|
+
if mode() != self._process_mode:
|
|
138
|
+
raise ImproperlyConfigured("DJANGO_COMPOSE_MODE changed after the settings snapshot")
|
|
139
|
+
|
|
140
|
+
def digest(self) -> str:
|
|
141
|
+
payload = {
|
|
142
|
+
"entries": [(e.name, e.label, e.config, e.entry) for e in self.entries],
|
|
143
|
+
"dependencies": self.dependencies,
|
|
144
|
+
"files": self.files,
|
|
145
|
+
"integrations": self.integration_paths,
|
|
146
|
+
"transitions": self.transitions,
|
|
147
|
+
"transition_records": self.transition_records,
|
|
148
|
+
"migrations": self.authored_migration_modules,
|
|
149
|
+
"settings": self.settings,
|
|
150
|
+
"compiler": self.compiler,
|
|
151
|
+
}
|
|
152
|
+
return hashlib.sha256(
|
|
153
|
+
json.dumps(_plain(payload), sort_keys=True, separators=(",", ":")).encode()
|
|
154
|
+
).hexdigest()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def app_entries(entries: Iterable[str | Entry]) -> tuple[Entry, ...]:
|
|
158
|
+
"""Resolve native app identities without importing any app models."""
|
|
159
|
+
result = []
|
|
160
|
+
names: set[str] = set()
|
|
161
|
+
labels: set[str] = set()
|
|
162
|
+
for entry in entries:
|
|
163
|
+
if not isinstance(entry, Entry):
|
|
164
|
+
if not isinstance(entry, str):
|
|
165
|
+
raise ImproperlyConfigured(f"INSTALLED_APPS entry must be a string: {entry!r}")
|
|
166
|
+
config = AppConfig.create(entry)
|
|
167
|
+
entry = Entry.from_config(config, entry)
|
|
168
|
+
if entry.name == "django_compose":
|
|
169
|
+
continue
|
|
170
|
+
if entry.name in names or entry.label in labels:
|
|
171
|
+
raise ImproperlyConfigured(f"Duplicate app identity: {entry.entry!r}")
|
|
172
|
+
names.add(entry.name)
|
|
173
|
+
labels.add(entry.label)
|
|
174
|
+
result.append(entry)
|
|
175
|
+
return tuple(result)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def transition_declarations(source: Any) -> tuple[Mapping[str, str], ...]:
|
|
179
|
+
"""Normalize the explicit pre-setup transition declaration surface once."""
|
|
180
|
+
if isinstance(source, str):
|
|
181
|
+
module, separator, name = source.rpartition(".")
|
|
182
|
+
if not separator:
|
|
183
|
+
raise ImproperlyConfigured("COMPOSE_TRANSITION_SOURCE must name a callable")
|
|
184
|
+
source = getattr(importlib.import_module(module), name)()
|
|
185
|
+
declarations = []
|
|
186
|
+
origins: set[str] = set()
|
|
187
|
+
for declaration in source:
|
|
188
|
+
if not isinstance(declaration, Mapping) or set(declaration) != {
|
|
189
|
+
"declaring_app",
|
|
190
|
+
"name",
|
|
191
|
+
"app_label",
|
|
192
|
+
"module",
|
|
193
|
+
}:
|
|
194
|
+
raise ImproperlyConfigured(f"Invalid transition declaration: {declaration!r}")
|
|
195
|
+
if not all(isinstance(value, str) and value for value in declaration.values()):
|
|
196
|
+
raise ImproperlyConfigured(f"Invalid transition declaration: {declaration!r}")
|
|
197
|
+
origin = f"{declaration['declaring_app']}:{declaration['name']}"
|
|
198
|
+
name = declaration["name"]
|
|
199
|
+
if not ("_" + name).isidentifier() or name != name.lower() or name.startswith("_"):
|
|
200
|
+
raise ImproperlyConfigured(f"{origin}: name must be a lower-case migration name")
|
|
201
|
+
if origin in origins:
|
|
202
|
+
raise ImproperlyConfigured(f"Duplicate transition origin: {origin}")
|
|
203
|
+
origins.add(origin)
|
|
204
|
+
normalized = dict(declaration)
|
|
205
|
+
if not normalized["module"].startswith(normalized["declaring_app"] + "."):
|
|
206
|
+
normalized["module"] = normalized["declaring_app"] + "." + normalized["module"]
|
|
207
|
+
declarations.append(MappingProxyType(normalized))
|
|
208
|
+
return tuple(declarations)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _default_transitions(entries: tuple[Entry, ...]) -> tuple[Mapping[str, str], ...]:
|
|
212
|
+
records = []
|
|
213
|
+
for entry in entries:
|
|
214
|
+
directory = Path(entry.path) / "compose_migrations"
|
|
215
|
+
report_inputs(globs=[(directory, "*/*.py")])
|
|
216
|
+
for path in sorted(directory.glob("*/*.py")):
|
|
217
|
+
if path.name == "__init__.py" or not path.is_file():
|
|
218
|
+
continue
|
|
219
|
+
label = path.parent.name
|
|
220
|
+
if not label.isidentifier():
|
|
221
|
+
raise ImproperlyConfigured(f"Invalid default transition path: {path}")
|
|
222
|
+
records.append(
|
|
223
|
+
{
|
|
224
|
+
"declaring_app": entry.name,
|
|
225
|
+
"name": path.stem,
|
|
226
|
+
"app_label": label,
|
|
227
|
+
"module": f"{entry.name}.compose_migrations.{label}.{path.stem}",
|
|
228
|
+
}
|
|
229
|
+
)
|
|
230
|
+
return transition_declarations(records)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _fingerprints(
|
|
234
|
+
records: tuple[Mapping[str, str], ...], resolver: SourceResolver
|
|
235
|
+
) -> tuple[tuple[str, str], ...]:
|
|
236
|
+
result = []
|
|
237
|
+
for declaration in records:
|
|
238
|
+
location = resolver.resolve(declaration["module"])
|
|
239
|
+
if location.path is not None:
|
|
240
|
+
report_inputs(files=[location.path])
|
|
241
|
+
if location.path is None or not location.path.is_file():
|
|
242
|
+
raise ImproperlyConfigured(f"Cannot fingerprint module {declaration['module']!r}")
|
|
243
|
+
result.append(
|
|
244
|
+
(
|
|
245
|
+
f"{declaration['declaring_app']}:{declaration['name']}",
|
|
246
|
+
hashlib.sha256(location.path.read_bytes()).hexdigest(),
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
return tuple(result)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def transition_fingerprints(source: Any) -> tuple[tuple[str, str], ...]:
|
|
253
|
+
"""Hash declared transition modules without executing their model imports."""
|
|
254
|
+
return _fingerprints(transition_declarations(source), SourceResolver())
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _effective_sequence(
|
|
258
|
+
values: Mapping[str, Any], key: str, argument: Iterable[str] | None, default: Iterable[str] = ()
|
|
259
|
+
) -> tuple[str, ...]:
|
|
260
|
+
explicit = tuple(argument) if argument is not None else None
|
|
261
|
+
value = tuple(values.get(key, default))
|
|
262
|
+
if explicit is not None and key in values and explicit != value:
|
|
263
|
+
raise ImproperlyConfigured(f"Conflicting snapshot argument and {key}")
|
|
264
|
+
result = value if explicit is None else explicit
|
|
265
|
+
if not all(isinstance(item, str) for item in result):
|
|
266
|
+
raise ImproperlyConfigured(f"{key} must contain strings")
|
|
267
|
+
return result
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def snapshot(
|
|
271
|
+
entries: Iterable[str | Entry],
|
|
272
|
+
*,
|
|
273
|
+
integration_paths: Iterable[str] | None = None,
|
|
274
|
+
fingerprint_globs: Iterable[str] | None = None,
|
|
275
|
+
fingerprint_excludes: Iterable[str] | None = None,
|
|
276
|
+
settings_keys: Iterable[str] = (),
|
|
277
|
+
namespace: Mapping[str, Any] | None = None,
|
|
278
|
+
transitions: Any = None,
|
|
279
|
+
authored_migration_modules: Mapping[str, str | None] | None = None,
|
|
280
|
+
dependencies: Mapping[str, tuple[str, ...]] | None = None,
|
|
281
|
+
) -> Snapshot:
|
|
282
|
+
"""Freeze effective compiler inputs once, without importing source models.
|
|
283
|
+
|
|
284
|
+
Explicit integration/glob/exclusion arguments and namespace declarations must
|
|
285
|
+
agree. Extra setting keys extend COMPOSE_FINGERPRINT_SETTINGS. An omitted
|
|
286
|
+
transition source discovers compose_migrations/<label>/<name>.py in app order.
|
|
287
|
+
Dependencies retain already-resolved native app-name edges; targets must
|
|
288
|
+
precede their dependants in entries. This function never resolves app edges.
|
|
289
|
+
"""
|
|
290
|
+
normalized = app_entries(entries)
|
|
291
|
+
positions = {entry.name: index for index, entry in enumerate(normalized)}
|
|
292
|
+
edges = {} if dependencies is None else dependencies
|
|
293
|
+
for name, targets in edges.items():
|
|
294
|
+
if name not in positions:
|
|
295
|
+
raise ImproperlyConfigured(f"Snapshot dependency owner is not selected: {name!r}")
|
|
296
|
+
if isinstance(targets, str) or not isinstance(targets, (tuple, list)):
|
|
297
|
+
raise ImproperlyConfigured(f"Snapshot dependencies for {name!r} must be a sequence of app names")
|
|
298
|
+
for target in targets:
|
|
299
|
+
if not isinstance(target, str) or target not in positions:
|
|
300
|
+
raise ImproperlyConfigured(f"Snapshot dependency target is not selected: {target!r}")
|
|
301
|
+
if positions[target] >= positions[name]:
|
|
302
|
+
raise ImproperlyConfigured(f"Snapshot dependency {target!r} must precede {name!r}")
|
|
303
|
+
values = namespace if namespace is not None else {}
|
|
304
|
+
integrations = _effective_sequence(values, "COMPOSE_INTEGRATIONS", integration_paths)
|
|
305
|
+
globs = tuple(sorted(set(_effective_sequence(values, "COMPOSE_FINGERPRINT_GLOBS", fingerprint_globs))))
|
|
306
|
+
excludes = tuple(
|
|
307
|
+
sorted(
|
|
308
|
+
set(
|
|
309
|
+
_effective_sequence(
|
|
310
|
+
values, "COMPOSE_FINGERPRINT_EXCLUDES", fingerprint_excludes, DEFAULT_EXCLUDES
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
resolver = SourceResolver(normalized)
|
|
316
|
+
if transitions is None and "COMPOSE_TRANSITION_SOURCE" not in values:
|
|
317
|
+
records = _default_transitions(normalized)
|
|
318
|
+
else:
|
|
319
|
+
records = transition_declarations(
|
|
320
|
+
values.get("COMPOSE_TRANSITION_SOURCE", ()) if transitions is None else transitions
|
|
321
|
+
)
|
|
322
|
+
if (
|
|
323
|
+
transitions is not None
|
|
324
|
+
and "COMPOSE_TRANSITION_SOURCE" in values
|
|
325
|
+
and transitions != values["COMPOSE_TRANSITION_SOURCE"]
|
|
326
|
+
):
|
|
327
|
+
if records != transition_declarations(values["COMPOSE_TRANSITION_SOURCE"]):
|
|
328
|
+
raise ImproperlyConfigured("Conflicting snapshot argument and COMPOSE_TRANSITION_SOURCE")
|
|
329
|
+
authored = (
|
|
330
|
+
values.get("MIGRATION_MODULES", {})
|
|
331
|
+
if authored_migration_modules is None
|
|
332
|
+
else authored_migration_modules
|
|
333
|
+
)
|
|
334
|
+
if authored_migration_modules is not None and "MIGRATION_MODULES" in values:
|
|
335
|
+
if dict(authored_migration_modules) != dict(values["MIGRATION_MODULES"]):
|
|
336
|
+
raise ImproperlyConfigured("Conflicting snapshot argument and MIGRATION_MODULES")
|
|
337
|
+
effective_settings = {
|
|
338
|
+
key: value
|
|
339
|
+
for key, value in values.items()
|
|
340
|
+
if key.startswith("COMPOSE_") and key not in NON_INPUT_SETTINGS
|
|
341
|
+
}
|
|
342
|
+
output_dir = values.get("COMPOSE_OUTPUT_DIR")
|
|
343
|
+
effective_settings.update(
|
|
344
|
+
{
|
|
345
|
+
"COMPOSE_OUTPUT_DIR": Path(output_dir).as_posix() if output_dir is not None else None,
|
|
346
|
+
"COMPOSE_OUTPUT_MODULE": values.get("COMPOSE_OUTPUT_MODULE", "runtime"),
|
|
347
|
+
"COMPOSE_INTEGRATIONS": integrations,
|
|
348
|
+
"COMPOSE_FINGERPRINT_GLOBS": globs,
|
|
349
|
+
"COMPOSE_FINGERPRINT_EXCLUDES": excludes,
|
|
350
|
+
"COMPOSE_WATCH_PATHS": report_watch_paths(values.get("COMPOSE_WATCH_PATHS", ())),
|
|
351
|
+
"COMPOSE_TRANSITION_SOURCE": records,
|
|
352
|
+
"COMPOSE_EXTERNAL_ARTIFACT_ROOTS": tuple(
|
|
353
|
+
sorted(set(values.get("COMPOSE_EXTERNAL_ARTIFACT_ROOTS", ())))
|
|
354
|
+
),
|
|
355
|
+
"DEFAULT_AUTO_FIELD": values.get("DEFAULT_AUTO_FIELD", global_settings.DEFAULT_AUTO_FIELD),
|
|
356
|
+
"AUTH_USER_MODEL": values.get("AUTH_USER_MODEL", global_settings.AUTH_USER_MODEL),
|
|
357
|
+
}
|
|
358
|
+
)
|
|
359
|
+
hook_settings = structural_settings(values, settings_keys=settings_keys)
|
|
360
|
+
hook_settings -= STRUCTURAL_SETTINGS
|
|
361
|
+
extras = hook_settings - set(effective_settings)
|
|
362
|
+
effective_settings["COMPOSE_FINGERPRINT_SETTINGS"] = tuple(sorted(extras))
|
|
363
|
+
for key in sorted(extras):
|
|
364
|
+
effective_settings[key] = values.get(key, getattr(global_settings, key, None))
|
|
365
|
+
files: dict[str, str] = {}
|
|
366
|
+
for entry in normalized:
|
|
367
|
+
root = Path(entry.path)
|
|
368
|
+
report_inputs(globs=[(root, "**/*.py"), *((root, pattern) for pattern in globs)])
|
|
369
|
+
paths = {
|
|
370
|
+
path
|
|
371
|
+
for path in root.rglob("*.py")
|
|
372
|
+
if not any(path.relative_to(root).full_match(pattern) for pattern in excludes)
|
|
373
|
+
}
|
|
374
|
+
for pattern in globs:
|
|
375
|
+
paths.update(root.glob(pattern))
|
|
376
|
+
for path in sorted(paths):
|
|
377
|
+
relative = path.relative_to(root)
|
|
378
|
+
if path.is_file():
|
|
379
|
+
files[f"{entry.name}/{relative.as_posix()}"] = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
380
|
+
modules = {
|
|
381
|
+
f"integration:{dotted.rpartition('.')[0]}": dotted.rpartition(".")[0] for dotted in integrations
|
|
382
|
+
}
|
|
383
|
+
for entry in normalized:
|
|
384
|
+
config_class = import_string(entry.config)
|
|
385
|
+
if not isinstance(config_class, type) or not issubclass(config_class, AppConfig):
|
|
386
|
+
raise ImproperlyConfigured(f"Selected config must be an AppConfig class: {entry.config!r}")
|
|
387
|
+
for owner in config_class.__mro__:
|
|
388
|
+
if owner is not object:
|
|
389
|
+
modules[f"config:{owner.__module__}"] = owner.__module__
|
|
390
|
+
for key, module in sorted(modules.items()):
|
|
391
|
+
location = resolver.resolve(module)
|
|
392
|
+
if location.path is not None:
|
|
393
|
+
report_inputs(files=[location.path])
|
|
394
|
+
if location.kind in {"builtin", "frozen"}:
|
|
395
|
+
# The interpreter version owns implementations without a source file.
|
|
396
|
+
data = repr(
|
|
397
|
+
(
|
|
398
|
+
module,
|
|
399
|
+
location.kind,
|
|
400
|
+
sys.implementation.name,
|
|
401
|
+
tuple(sys.implementation.version),
|
|
402
|
+
tuple(sys.version_info),
|
|
403
|
+
)
|
|
404
|
+
).encode()
|
|
405
|
+
elif location.path is not None and location.path.is_file():
|
|
406
|
+
data = location.path.read_bytes()
|
|
407
|
+
else:
|
|
408
|
+
raise ImproperlyConfigured(f"Cannot fingerprint module {module!r}")
|
|
409
|
+
files[key] = hashlib.sha256(data).hexdigest()
|
|
410
|
+
return Snapshot(
|
|
411
|
+
normalized,
|
|
412
|
+
tuple(sorted(files.items())),
|
|
413
|
+
integrations,
|
|
414
|
+
_fingerprints(records, resolver),
|
|
415
|
+
records,
|
|
416
|
+
_freeze(authored),
|
|
417
|
+
_freeze(effective_settings),
|
|
418
|
+
dependencies=_freeze(edges),
|
|
419
|
+
)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Import-free module locations and exact installed-distribution file ownership."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import sysconfig
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from importlib import metadata
|
|
11
|
+
from importlib.machinery import BuiltinImporter, FrozenImporter, ModuleSpec, PathFinder
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Protocol
|
|
14
|
+
from urllib.parse import unquote, urlsplit
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AppSource(Protocol):
|
|
18
|
+
@property
|
|
19
|
+
def name(self) -> str: ...
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def path(self) -> str: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ModuleSource:
|
|
27
|
+
module: str
|
|
28
|
+
path: Path | None
|
|
29
|
+
kind: str
|
|
30
|
+
distributions: tuple[str, ...] = ()
|
|
31
|
+
app: str | None = None
|
|
32
|
+
search_locations: tuple[Path, ...] = ()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SourceResolver:
|
|
36
|
+
"""Resolve without executing packages; keep metadata indexing local to one audit."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
entries: Iterable[AppSource] = (),
|
|
41
|
+
*,
|
|
42
|
+
distributions: Iterable[metadata.Distribution] | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.entries = tuple(sorted(entries, key=lambda entry: -len(entry.name)))
|
|
45
|
+
self._distributions = distributions
|
|
46
|
+
self._files: dict[Path, set[str]] | None = None
|
|
47
|
+
self._projects: list[tuple[Path, str]] = []
|
|
48
|
+
|
|
49
|
+
def distribution_names(self, path: Path) -> tuple[str, ...]:
|
|
50
|
+
"""Match RECORD files or a PEP 610 local project directory, never package names."""
|
|
51
|
+
if self._files is None:
|
|
52
|
+
self._files = {}
|
|
53
|
+
distributions = self._distributions
|
|
54
|
+
for distribution in metadata.distributions() if distributions is None else distributions:
|
|
55
|
+
name = distribution.metadata["Name"]
|
|
56
|
+
if not name:
|
|
57
|
+
continue
|
|
58
|
+
for file in distribution.files or ():
|
|
59
|
+
location = Path(str(distribution.locate_file(file))).resolve()
|
|
60
|
+
self._files.setdefault(location, set()).add(name)
|
|
61
|
+
direct = distribution.read_text("direct_url.json")
|
|
62
|
+
if direct:
|
|
63
|
+
try:
|
|
64
|
+
record = json.loads(direct)
|
|
65
|
+
url = urlsplit(record.get("url", ""))
|
|
66
|
+
if "dir_info" in record and url.scheme == "file" and url.netloc in ("", "localhost"):
|
|
67
|
+
self._projects.append((Path(unquote(url.path)).resolve(), name))
|
|
68
|
+
except ValueError, TypeError, AttributeError:
|
|
69
|
+
continue
|
|
70
|
+
location = path.resolve()
|
|
71
|
+
names = self._files.get(location, set()).copy()
|
|
72
|
+
if location.is_dir():
|
|
73
|
+
names.update(
|
|
74
|
+
name
|
|
75
|
+
for file, owners in self._files.items()
|
|
76
|
+
if file.is_relative_to(location)
|
|
77
|
+
for name in owners
|
|
78
|
+
)
|
|
79
|
+
names.update(name for root, name in self._projects if location.is_relative_to(root))
|
|
80
|
+
return tuple(sorted(names))
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _find(parts: list[str], locations: Iterable[str] | None = None) -> ModuleSpec | None:
|
|
84
|
+
search = list(locations) if locations is not None else None
|
|
85
|
+
for index, part in enumerate(parts):
|
|
86
|
+
spec = PathFinder.find_spec(part, search)
|
|
87
|
+
if spec is None:
|
|
88
|
+
return None
|
|
89
|
+
if index == len(parts) - 1:
|
|
90
|
+
return spec
|
|
91
|
+
if spec.submodule_search_locations is None:
|
|
92
|
+
return None
|
|
93
|
+
search = list(spec.submodule_search_locations)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
def resolve(self, module: str) -> ModuleSource:
|
|
97
|
+
if not module or not all(part and ("_" + part).isidentifier() for part in module.split(".")):
|
|
98
|
+
return ModuleSource(module, None, "unresolved")
|
|
99
|
+
for finder, kind in ((BuiltinImporter, "builtin"), (FrozenImporter, "frozen")):
|
|
100
|
+
if finder.find_spec(module) is not None:
|
|
101
|
+
return ModuleSource(module, None, kind)
|
|
102
|
+
spec = None
|
|
103
|
+
for entry in self.entries:
|
|
104
|
+
if module == entry.name:
|
|
105
|
+
spec = ModuleSpec(module, loader=None, is_package=True)
|
|
106
|
+
spec.submodule_search_locations = [entry.path]
|
|
107
|
+
candidate = Path(entry.path) / "__init__.py"
|
|
108
|
+
if candidate.is_file():
|
|
109
|
+
spec.origin = str(candidate)
|
|
110
|
+
elif module.startswith(entry.name + "."):
|
|
111
|
+
spec = self._find(module[len(entry.name) + 1 :].split("."), (entry.path,))
|
|
112
|
+
if spec is not None:
|
|
113
|
+
break
|
|
114
|
+
if spec is None:
|
|
115
|
+
spec = self._find(module.split("."))
|
|
116
|
+
if spec is None:
|
|
117
|
+
return ModuleSource(module, None, "unresolved")
|
|
118
|
+
locations = tuple(Path(path).resolve() for path in spec.submodule_search_locations or ())
|
|
119
|
+
path = Path(spec.origin).resolve() if spec.origin else next(iter(locations), None)
|
|
120
|
+
if path is None:
|
|
121
|
+
return ModuleSource(module, None, "unresolved")
|
|
122
|
+
paths = sysconfig.get_paths()
|
|
123
|
+
site_roots = {Path(paths[key]).resolve() for key in ("purelib", "platlib")}
|
|
124
|
+
if (
|
|
125
|
+
module.partition(".")[0] in sys.stdlib_module_names
|
|
126
|
+
and path.is_relative_to(Path(paths["stdlib"]).resolve())
|
|
127
|
+
and not any(path.is_relative_to(root) for root in site_roots)
|
|
128
|
+
):
|
|
129
|
+
return ModuleSource(module, path, "stdlib")
|
|
130
|
+
ownership_paths = (path,) if spec.origin else locations
|
|
131
|
+
names = tuple(
|
|
132
|
+
sorted({name for source in ownership_paths for name in self.distribution_names(source)})
|
|
133
|
+
)
|
|
134
|
+
app = next(
|
|
135
|
+
(entry.name for entry in self.entries if path.is_relative_to(Path(entry.path).resolve())), None
|
|
136
|
+
)
|
|
137
|
+
return ModuleSource(
|
|
138
|
+
module, path, "distribution" if names else "app" if app else "project", names, app, locations
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def resolve_module(module: str, entries: Iterable[AppSource] = ()) -> ModuleSource:
|
|
143
|
+
return SourceResolver(entries).resolve(module)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def distribution_names(path: Path) -> tuple[str, ...]:
|
|
147
|
+
return SourceResolver().distribution_names(path)
|