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