nab-python 0.0.1__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 (71) hide show
  1. nab_python/__init__.py +1 -0
  2. nab_python/_build/__init__.py +1 -0
  3. nab_python/_build/env.py +364 -0
  4. nab_python/_build/errors.py +17 -0
  5. nab_python/_build/runner.py +254 -0
  6. nab_python/_lockfile/__init__.py +1 -0
  7. nab_python/_lockfile/builder.py +339 -0
  8. nab_python/_lockfile/disjointness.py +207 -0
  9. nab_python/_lockfile/pylock.py +323 -0
  10. nab_python/_lockfile/requirements.py +121 -0
  11. nab_python/_packaging_provider.py +98 -0
  12. nab_python/_provider/__init__.py +1 -0
  13. nab_python/_provider/build_remote.py +95 -0
  14. nab_python/_provider/extras.py +231 -0
  15. nab_python/_provider/listing.py +442 -0
  16. nab_python/_provider/lookahead.py +156 -0
  17. nab_python/_provider/metadata_resolver.py +450 -0
  18. nab_python/_provider/priority.py +174 -0
  19. nab_python/_provider/sources.py +215 -0
  20. nab_python/_testing/__init__.py +1 -0
  21. nab_python/_testing/coordinator_fake.py +240 -0
  22. nab_python/_vcs_admission.py +209 -0
  23. nab_python/_vendor/__init__.py +6 -0
  24. nab_python/_vendor/packaging/LICENSE +3 -0
  25. nab_python/_vendor/packaging/LICENSE.APACHE +177 -0
  26. nab_python/_vendor/packaging/LICENSE.BSD +23 -0
  27. nab_python/_vendor/packaging/PROVENANCE.md +73 -0
  28. nab_python/_vendor/packaging/__init__.py +15 -0
  29. nab_python/_vendor/packaging/_elffile.py +108 -0
  30. nab_python/_vendor/packaging/_manylinux.py +265 -0
  31. nab_python/_vendor/packaging/_musllinux.py +88 -0
  32. nab_python/_vendor/packaging/_parser.py +394 -0
  33. nab_python/_vendor/packaging/_structures.py +33 -0
  34. nab_python/_vendor/packaging/_tokenizer.py +196 -0
  35. nab_python/_vendor/packaging/dependency_groups.py +302 -0
  36. nab_python/_vendor/packaging/direct_url.py +325 -0
  37. nab_python/_vendor/packaging/errors.py +94 -0
  38. nab_python/_vendor/packaging/licenses/__init__.py +186 -0
  39. nab_python/_vendor/packaging/licenses/_spdx.py +799 -0
  40. nab_python/_vendor/packaging/markers.py +506 -0
  41. nab_python/_vendor/packaging/metadata.py +964 -0
  42. nab_python/_vendor/packaging/py.typed +0 -0
  43. nab_python/_vendor/packaging/pylock.py +910 -0
  44. nab_python/_vendor/packaging/ranges.py +1803 -0
  45. nab_python/_vendor/packaging/requirements.py +132 -0
  46. nab_python/_vendor/packaging/specifiers.py +1141 -0
  47. nab_python/_vendor/packaging/tags.py +929 -0
  48. nab_python/_vendor/packaging/utils.py +296 -0
  49. nab_python/_vendor/packaging/version.py +1230 -0
  50. nab_python/build_backend.py +184 -0
  51. nab_python/config.py +805 -0
  52. nab_python/download.py +170 -0
  53. nab_python/fetch.py +827 -0
  54. nab_python/lockfile.py +238 -0
  55. nab_python/metadata.py +145 -0
  56. nab_python/provider.py +1235 -0
  57. nab_python/py.typed +0 -0
  58. nab_python/requirements_file.py +180 -0
  59. nab_python/resolve.py +497 -0
  60. nab_python/universal/__init__.py +1 -0
  61. nab_python/universal/matrix.py +235 -0
  62. nab_python/universal/provider.py +214 -0
  63. nab_python/universal/reresolve.py +310 -0
  64. nab_python/universal/resolve.py +508 -0
  65. nab_python/universal/validate.py +439 -0
  66. nab_python/universal/wheel_selection.py +327 -0
  67. nab_python/workspace.py +214 -0
  68. nab_python-0.0.1.dist-info/METADATA +49 -0
  69. nab_python-0.0.1.dist-info/RECORD +71 -0
  70. nab_python-0.0.1.dist-info/WHEEL +4 -0
  71. nab_python-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,174 @@
1
+ """Priority computation for :class:`nab_python.provider.Provider`.
2
+
3
+ Owns the tier/matching/culprit logic that backs ``prioritize``.
4
+ Affected packages with high conflict counts get tier 0 (decide
5
+ first inside a conflict cluster); runaway top culprits get tier 2
6
+ (uv's deprioritise-on-conflict); everything else gets tier 1.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Mapping
15
+
16
+ from nab_resolver.types import RangeProtocol
17
+
18
+ from .._vendor.packaging.version import Version
19
+ from ..provider import Provider
20
+
21
+
22
+ CONFLICT_THRESHOLD = 5
23
+
24
+ # Demotion requires a runaway gap to the second-highest culprit, so
25
+ # co-dominant culprits keep standard ordering.
26
+ CULPRIT_DEMOTE_THRESHOLD = 5
27
+
28
+ # Lower number = higher priority.
29
+ TIER_AFFECTED = 0
30
+ TIER_NORMAL = 1
31
+ TIER_CULPRIT = 2
32
+
33
+ # Matching count used while a listing is in flight, so not-yet-fetched
34
+ # packages sort behind ready ones.
35
+ _NO_LISTING_PRIOR = 1000
36
+
37
+
38
+ def compute_tier(
39
+ normalized: str,
40
+ affected_count: int,
41
+ culprit_count: int,
42
+ culprit_counts: Mapping[str, int] | None,
43
+ *,
44
+ force_backtracked: bool = False,
45
+ ) -> int:
46
+ """Decide the priority tier from conflict and culprit counts.
47
+
48
+ ``force_backtracked`` short-circuits the gap rule: the look-ahead
49
+ abort is a precise enough culprit signal on its own.
50
+ """
51
+ if affected_count >= CONFLICT_THRESHOLD:
52
+ return TIER_AFFECTED
53
+ if force_backtracked:
54
+ return TIER_CULPRIT
55
+ if is_dominant_culprit(normalized, culprit_count, culprit_counts):
56
+ return TIER_CULPRIT
57
+ return TIER_NORMAL
58
+
59
+
60
+ def compute_matching(
61
+ provider: Provider,
62
+ normalized: str,
63
+ version_range: RangeProtocol[Version],
64
+ ) -> int:
65
+ """Return the count of cached versions of ``normalized`` in ``version_range``.
66
+
67
+ Also fires speculative metadata prefetch when this is the first time we
68
+ notice the listing has arrived in the coordinator index. Returns
69
+ :data:`_NO_LISTING_PRIOR` while the listing is still in flight.
70
+ """
71
+ per_pkg = provider.matching_cache.get(normalized)
72
+ if per_pkg is not None:
73
+ cached = per_pkg.get(version_range)
74
+ if cached is not None:
75
+ return cached
76
+
77
+ # Local/VCS sources short-circuit the listing path; their synthetic
78
+ # listing is materialised lazily by fetch_versions.
79
+ has_local_source = (
80
+ normalized in provider.local_sources or normalized in provider.vcs_sources
81
+ )
82
+ if normalized not in provider.versions_cache and not has_local_source:
83
+ files = provider.coordinator.index.get_listing(normalized)
84
+ if files is not None:
85
+ versions = provider.filter_distributions(normalized, files)
86
+ provider.versions_cache[normalized] = versions
87
+ provider.stats.listings_fetched += 1
88
+ provider.speculative_prefetch(normalized, versions)
89
+
90
+ if normalized in provider.versions_cache:
91
+ versions = provider.versions_cache[normalized]
92
+ matching = sum(1 for v, _ in versions if v in version_range)
93
+ elif has_local_source:
94
+ matching = 1
95
+ else:
96
+ matching = _NO_LISTING_PRIOR
97
+
98
+ if per_pkg is None:
99
+ per_pkg = provider.matching_cache[normalized] = {}
100
+ per_pkg[version_range] = matching
101
+ return matching
102
+
103
+
104
+ def is_dominant_culprit(
105
+ package: str,
106
+ package_count: int,
107
+ culprit_counts: Mapping[str, int] | None,
108
+ ) -> bool:
109
+ """Return True when ``package`` is the runaway top culprit.
110
+
111
+ Demote only when the gap to the next culprit is >= CULPRIT_DEMOTE_THRESHOLD;
112
+ co-dominant culprits stay within ~1 of each other so the standard ordering
113
+ wins.
114
+ """
115
+ if culprit_counts is None or package_count < CULPRIT_DEMOTE_THRESHOLD:
116
+ return False
117
+ second_highest = max(
118
+ (count for other, count in culprit_counts.items() if other != package),
119
+ default=0,
120
+ )
121
+ return package_count - second_highest >= CULPRIT_DEMOTE_THRESHOLD
122
+
123
+
124
+ def prioritize(
125
+ provider: Provider,
126
+ package: str,
127
+ version_range: RangeProtocol[Version],
128
+ conflict_counts: Mapping[str, int],
129
+ culprit_counts: Mapping[str, int] | None = None,
130
+ ) -> tuple[int, int, bool]:
131
+ """Prioritize packages for resolution order.
132
+
133
+ Returns ``(tier, matching_count, is_base)``. Extras proxies sort before
134
+ their base at equal tier so they pin the base version directly (avoids
135
+ the backtrack storm when the base is decided before the extras proxy).
136
+
137
+ Never blocks on I/O.
138
+ """
139
+ provider.stats.prioritize_calls += 1
140
+ _, extra, normalized = provider.split_and_normalize(package)
141
+ affected_count = conflict_counts.get(normalized, 0)
142
+ culprit_count = (
143
+ culprit_counts.get(normalized, 0) if culprit_counts is not None else 0
144
+ )
145
+ force_backtracked = provider.force_backtrack_count(normalized) > 0
146
+
147
+ # Fast path: when culprit_count is below the demote threshold AND the
148
+ # package was not force-backtracked, the tier depends only on
149
+ # affected_count and is safe to cache by Range identity.
150
+ cacheable = culprit_count < CULPRIT_DEMOTE_THRESHOLD and not force_backtracked
151
+ if cacheable:
152
+ cached = provider.priority_cache.get(package)
153
+ if (
154
+ cached is not None
155
+ and cached[0] is version_range
156
+ and cached[1] == affected_count
157
+ ):
158
+ return cached[2]
159
+
160
+ tier = compute_tier(
161
+ normalized,
162
+ affected_count,
163
+ culprit_count,
164
+ culprit_counts,
165
+ force_backtracked=force_backtracked,
166
+ )
167
+ matching = compute_matching(provider, normalized, version_range)
168
+ priority = (tier, matching, extra is None)
169
+
170
+ # Don't cache the in-flight placeholder; compute_matching's listing-arrival
171
+ # side effect (speculative prefetch) lives in the cache-miss branch.
172
+ if cacheable and normalized in provider.versions_cache:
173
+ provider.priority_cache[package] = (version_range, affected_count, priority)
174
+ return priority
@@ -0,0 +1,215 @@
1
+ """Local-source and VCS-source materialisation for the provider.
2
+
3
+ A ``LocalSource`` becomes the only candidate for a package: PyPI is
4
+ not consulted. A ``VcsSource`` clones the repo and reuses the
5
+ ``LocalSource`` extraction path. Both produce a single synthetic
6
+ ``SdistFile`` whose version is read from ``[project].version``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING
13
+
14
+ from nab_index.client import SdistFile
15
+ from nab_index.vcs import VcsCloneError, VcsRequest
16
+
17
+ from .._vendor.packaging.utils import canonicalize_name
18
+
19
+ if TYPE_CHECKING:
20
+ from .._vendor.packaging.version import Version
21
+ from ..metadata import WheelMetadata
22
+ from ..provider import LocalSource, Provider, VcsSource
23
+
24
+
25
+ def index_local_sources(
26
+ provider: Provider, # noqa: ARG001 (signature parity with index_vcs_sources)
27
+ sources: list[LocalSource],
28
+ ) -> dict[str, LocalSource]:
29
+ """Validate ``LocalSource`` entries and return a canonical-name map.
30
+
31
+ Admitted at every :class:`~nab_python.provider.BuildPolicy` level; the
32
+ policy only governs whether the backend may run when the static
33
+ pyproject read returns nothing usable (see
34
+ :func:`extract_source_metadata`).
35
+ """
36
+ if not sources:
37
+ return {}
38
+ out: dict[str, LocalSource] = {}
39
+ for src in sources:
40
+ canonical = canonicalize_name(src.name)
41
+ if canonical in out:
42
+ msg = f"duplicate local source for {src.name!r}"
43
+ raise ValueError(msg)
44
+ out[canonical] = src
45
+ return out
46
+
47
+
48
+ def materialize_local_source(
49
+ provider: Provider,
50
+ normalized: str,
51
+ source: LocalSource,
52
+ ) -> list[tuple[Version, SdistFile]]:
53
+ """Read metadata from ``source`` and seed caches with one synthetic version.
54
+
55
+ Static path: ``extract_static_metadata`` reads ``pyproject.toml``
56
+ directly. Backend path: requires :attr:`BuildPolicy.BUILD_LOCAL`
57
+ or looser; raises :class:`UnsupportedSdistError` otherwise.
58
+ """
59
+ path = Path(source.path)
60
+ metadata = extract_source_metadata(
61
+ provider,
62
+ path,
63
+ descriptor=f"local source {source.name!r}",
64
+ package=canonicalize_name(source.name),
65
+ kind="local",
66
+ )
67
+ return seed_synthetic_listing(provider, normalized, path, metadata)
68
+
69
+
70
+ def extract_source_metadata(
71
+ provider: Provider,
72
+ path: Path,
73
+ *,
74
+ descriptor: str,
75
+ package: str,
76
+ kind: str,
77
+ ) -> WheelMetadata:
78
+ """Read metadata from a directory; gates the backend path on policy.
79
+
80
+ ``kind`` is ``"local"`` for :class:`LocalSource` directories
81
+ (admitted at :attr:`BuildPolicy.BUILD_LOCAL` and above) or
82
+ ``"vcs"`` for :class:`VcsSource` clones (admitted only at
83
+ :attr:`BuildPolicy.BUILD_REMOTE`).
84
+ """
85
+ # Module-level attribute access lets tests patch
86
+ # ``nab_python.build_backend.extract_metadata`` from the source.
87
+ from .. import build_backend
88
+ from ..build_backend import BuildBackendError, extract_static_metadata
89
+ from ..provider import BuildPolicy, UnsupportedSdistError
90
+
91
+ metadata = extract_static_metadata(path)
92
+ if metadata is not None:
93
+ return metadata
94
+ effective = provider.effective_build_policy(package)
95
+ if kind == "local":
96
+ allowed = {BuildPolicy.BUILD_LOCAL, BuildPolicy.BUILD_REMOTE}
97
+ minimum = BuildPolicy.BUILD_LOCAL
98
+ else:
99
+ allowed = {BuildPolicy.BUILD_REMOTE}
100
+ minimum = BuildPolicy.BUILD_REMOTE
101
+ if effective not in allowed:
102
+ provider.stats.excluded_by_build_policy += 1
103
+ msg = (
104
+ f"{descriptor} at {path} has dynamic metadata; building requires"
105
+ f" BuildPolicy.{minimum.name} but the effective policy is"
106
+ f" {effective.value}"
107
+ )
108
+ raise UnsupportedSdistError(msg)
109
+ try:
110
+ return build_backend.extract_metadata(
111
+ path,
112
+ config=provider.build_config,
113
+ python_version=provider.python_version,
114
+ )
115
+ except BuildBackendError as exc:
116
+ msg = f"{descriptor}: {exc}"
117
+ raise UnsupportedSdistError(msg) from exc
118
+
119
+
120
+ def seed_synthetic_listing(
121
+ provider: Provider,
122
+ normalized: str,
123
+ path: Path,
124
+ metadata: WheelMetadata,
125
+ ) -> list[tuple[Version, SdistFile]]:
126
+ """Produce a one-version listing for a materialised source."""
127
+ synthetic_file = SdistFile(
128
+ filename=f"{normalized}-{metadata.version}.tar.gz",
129
+ url=path.as_uri(),
130
+ version=str(metadata.version),
131
+ requires_python=(
132
+ str(metadata.requires_python)
133
+ if metadata.requires_python is not None
134
+ else None
135
+ ),
136
+ upload_time=None,
137
+ )
138
+ version = metadata.version
139
+ provider.metadata_cache[(normalized, version)] = metadata
140
+ return [(version, synthetic_file)]
141
+
142
+
143
+ def index_vcs_sources(
144
+ provider: Provider,
145
+ sources: list[VcsSource],
146
+ ) -> dict[str, VcsSource]:
147
+ """Validate VCS sources and return a canonical-name map.
148
+
149
+ Admitted at every :class:`~nab_python.provider.BuildPolicy` level; the
150
+ policy only governs whether the backend may run on the clone (see
151
+ :func:`extract_source_metadata`). ``VcsPolicy.BLOCK`` still refuses
152
+ any declaration up-front because that is an independent decision
153
+ about whether VCS fetching is permitted at all.
154
+ """
155
+ # Late import: ``pypi`` imports this module at module load.
156
+ from ..provider import VcsPolicy
157
+
158
+ if not sources:
159
+ return {}
160
+
161
+ if provider.vcs_config.policy is VcsPolicy.BLOCK:
162
+ msg = (
163
+ "vcs_sources require VcsPolicy.ALLOW; current policy is"
164
+ f" {provider.vcs_config.policy.value}. Set vcs_config to"
165
+ " a permissive VcsConfig before declaring sources."
166
+ )
167
+ raise ValueError(msg)
168
+
169
+ out: dict[str, VcsSource] = {}
170
+ for src in sources:
171
+ canonical = canonicalize_name(src.name)
172
+ if canonical in out or canonical in provider.local_sources:
173
+ msg = f"duplicate source declared for {src.name!r}"
174
+ raise ValueError(msg)
175
+ out[canonical] = src
176
+ return out
177
+
178
+
179
+ def materialize_vcs_source(
180
+ provider: Provider,
181
+ normalized: str,
182
+ source: VcsSource,
183
+ ) -> list[tuple[Version, SdistFile]]:
184
+ """Clone ``source`` and materialise it via the same path as a LocalSource."""
185
+ # Module-level attribute access lets tests patch
186
+ # ``nab_index.vcs.prepare_clone`` from the source.
187
+ from nab_index import vcs as _vcs
188
+
189
+ from ..provider import UnsupportedSdistError
190
+
191
+ if provider.vcs_cache_dir is None:
192
+ msg = (
193
+ f"vcs source {source.name!r} declared but no"
194
+ f" vcs_cache_dir was supplied to Provider"
195
+ )
196
+ raise UnsupportedSdistError(msg)
197
+ try:
198
+ request = VcsRequest.parse(source.url)
199
+ clone = _vcs.prepare_clone(
200
+ provider.vcs_cache_dir,
201
+ request,
202
+ require_pin=provider.vcs_config.require_pin,
203
+ )
204
+ except VcsCloneError as exc:
205
+ msg = f"vcs source {source.name!r}: {exc}"
206
+ raise UnsupportedSdistError(msg) from exc
207
+ path = clone.path / clone.subdirectory if clone.subdirectory else clone.path
208
+ metadata = extract_source_metadata(
209
+ provider,
210
+ path,
211
+ descriptor=f"vcs source {source.name!r}",
212
+ package=canonicalize_name(source.name),
213
+ kind="vcs",
214
+ )
215
+ return seed_synthetic_listing(provider, normalized, path, metadata)
@@ -0,0 +1 @@
1
+ """Internal test helpers for nab-python."""
@@ -0,0 +1,240 @@
1
+ """Shared mock-coordinator builder for the nab-python test suite.
2
+
3
+ A ``FetchCoordinator``-shaped :class:`unittest.mock.MagicMock` wrapped
4
+ around a real :class:`~nab_python.fetch.InMemoryIndex`. The mock's
5
+ request methods write to the index and return an already-set
6
+ :class:`threading.Event`, so the synchronous provider code under test
7
+ sees fetches resolve immediately.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ from typing import TYPE_CHECKING
14
+ from unittest.mock import MagicMock
15
+
16
+ from nab_python.fetch import InMemoryIndex
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Callable, Mapping, Sequence
20
+
21
+ from nab_index.client import SdistFile, WheelFile
22
+
23
+
24
+ _MINIMAL_METADATA = "Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n\n"
25
+
26
+
27
+ def _done_event() -> threading.Event:
28
+ """Return an already-set :class:`threading.Event`."""
29
+ ev = threading.Event()
30
+ ev.set()
31
+ return ev
32
+
33
+
34
+ def _pre_populate_index(
35
+ index: InMemoryIndex,
36
+ listings_map: Mapping[str, Sequence[WheelFile | SdistFile]],
37
+ *,
38
+ baseline_metadata: Mapping[str, str] | None,
39
+ per_wheel_metadata: Mapping[str, str] | None,
40
+ sdist_pyproject: Mapping[str, str] | None,
41
+ ) -> None:
42
+ """Load listings and pre-store validator-visible slots into ``index``."""
43
+ for pkg_name, pkg_wheels in listings_map.items():
44
+ index.store_listing(pkg_name, pkg_wheels)
45
+ if baseline_metadata is not None and pkg_name in baseline_metadata:
46
+ for w in pkg_wheels:
47
+ index.store_metadata(pkg_name, w.version, baseline_metadata[pkg_name])
48
+ break
49
+ if per_wheel_metadata is not None:
50
+ for w in pkg_wheels:
51
+ if w.filename in per_wheel_metadata:
52
+ index.store_metadata(
53
+ pkg_name,
54
+ f"{w.version}#{w.filename}",
55
+ per_wheel_metadata[w.filename],
56
+ )
57
+ if sdist_pyproject is not None and pkg_name in sdist_pyproject:
58
+ for w in pkg_wheels:
59
+ index.store_sdist_pyproject(
60
+ pkg_name, w.version, sdist_pyproject[pkg_name]
61
+ )
62
+ break
63
+
64
+
65
+ def _resolve_listings(
66
+ wheels: Sequence[WheelFile | SdistFile] | None,
67
+ package: str,
68
+ listings: Mapping[str, Sequence[WheelFile | SdistFile]] | None,
69
+ ) -> Mapping[str, Sequence[WheelFile | SdistFile]]:
70
+ """Pick the listings map: explicit ``listings`` wins over ``wheels``."""
71
+ if listings is not None:
72
+ return listings
73
+ if wheels is not None:
74
+ return {package: wheels}
75
+ return {}
76
+
77
+
78
+ def _make_metadata_resolver(
79
+ *,
80
+ metadata_text: str | None,
81
+ metadata_by_version: Mapping[str, str | None] | None,
82
+ auto_metadata: bool,
83
+ ) -> Callable[[str, str], str | None]:
84
+ """Return a callable that picks metadata text for ``(pkg, version)``."""
85
+
86
+ def _resolve(pkg: str, ver: str) -> str | None:
87
+ if metadata_by_version is not None:
88
+ return metadata_by_version.get(ver)
89
+ if metadata_text is not None:
90
+ return metadata_text
91
+ if auto_metadata:
92
+ return _MINIMAL_METADATA.format(name=pkg, version=ver)
93
+ return None
94
+
95
+ return _resolve
96
+
97
+
98
+ def _wire_metadata_side_effects(
99
+ coordinator: MagicMock,
100
+ index: InMemoryIndex,
101
+ resolve_metadata: Callable[[str, str], str | None],
102
+ ) -> None:
103
+ """Attach ``request_listing``/``request_metadata``/batch side effects."""
104
+
105
+ def _request_listing(_pkg: str) -> threading.Event:
106
+ return _done_event()
107
+
108
+ def _request_metadata(pkg: str, ver: str, _url: str) -> threading.Event:
109
+ text = resolve_metadata(pkg, ver)
110
+ if text is not None:
111
+ index.store_metadata(pkg, ver, text)
112
+ return _done_event()
113
+
114
+ def _request_metadata_batch(
115
+ items: list[tuple[str, str, str]],
116
+ ) -> list[tuple[str, str, threading.Event]]:
117
+ results: list[tuple[str, str, threading.Event]] = []
118
+ for pkg, ver, _url in items:
119
+ text = resolve_metadata(pkg, ver)
120
+ if text is not None:
121
+ index.store_metadata(pkg, ver, text)
122
+ results.append((pkg, ver, _done_event()))
123
+ return results
124
+
125
+ coordinator.request_listing.side_effect = _request_listing
126
+ coordinator.request_metadata.side_effect = _request_metadata
127
+ coordinator.request_metadata_batch.side_effect = _request_metadata_batch
128
+
129
+
130
+ def _wire_sdist_side_effects(
131
+ coordinator: MagicMock,
132
+ index: InMemoryIndex,
133
+ *,
134
+ sdist_pkg_info: str | None,
135
+ sdist_pyproject_toml: str | None,
136
+ failures: set[str],
137
+ ) -> None:
138
+ """Attach ``request_sdist`` and ``request_wheel_metadata`` side effects."""
139
+
140
+ def _request_sdist(pkg: str, ver: str, _url: str) -> threading.Event:
141
+ # ``store_sdist_metadata`` is always called; passing ``None``
142
+ # poisons the cache slot, matching the original test_provider
143
+ # helper's contract for sdist-fetch failures.
144
+ index.store_sdist_metadata(pkg, ver, sdist_pkg_info)
145
+ if sdist_pyproject_toml is not None:
146
+ index.store_sdist_pyproject(pkg, ver, sdist_pyproject_toml)
147
+ return _done_event()
148
+
149
+ def _request_wheel_metadata(
150
+ pkg: str, ver: str, filename: str, _url: str
151
+ ) -> threading.Event:
152
+ if filename in failures:
153
+ index.store_metadata(pkg, f"{ver}#{filename}", None)
154
+ return _done_event()
155
+
156
+ coordinator.request_sdist.side_effect = _request_sdist
157
+ coordinator.request_wheel_metadata.side_effect = _request_wheel_metadata
158
+
159
+
160
+ def make_coordinator( # noqa: PLR0913
161
+ wheels: Sequence[WheelFile | SdistFile] | None = None,
162
+ *,
163
+ package: str = "pkg",
164
+ listings: Mapping[str, Sequence[WheelFile | SdistFile]] | None = None,
165
+ metadata_text: str | None = None,
166
+ metadata_by_version: Mapping[str, str | None] | None = None,
167
+ auto_metadata: bool = False,
168
+ sdist_pkg_info: str | None = None,
169
+ sdist_pyproject_toml: str | None = None,
170
+ baseline_metadata: Mapping[str, str] | None = None,
171
+ per_wheel_metadata: Mapping[str, str] | None = None,
172
+ sdist_pyproject: Mapping[str, str] | None = None,
173
+ fetch_failures: set[str] | None = None,
174
+ ) -> MagicMock:
175
+ """Build a mock :class:`FetchCoordinator` backed by an :class:`InMemoryIndex`.
176
+
177
+ Listing setup (one of):
178
+
179
+ * ``wheels`` + ``package``: pre-load ``wheels`` under ``package``.
180
+ Passing ``None`` skips listing setup, e.g. for tests that only
181
+ need the coordinator handle.
182
+ * ``listings``: pre-load each ``(package, wheels)`` pair. Overrides
183
+ ``wheels``/``package``.
184
+
185
+ Request side effects:
186
+
187
+ * ``request_listing`` and ``request_wheel_metadata`` always return a
188
+ set event. ``request_wheel_metadata`` honours ``fetch_failures``:
189
+ filenames in the set store ``None`` at the sentinel
190
+ ``f"{version}#{filename}"`` key.
191
+ * ``request_metadata`` and ``request_metadata_batch`` write
192
+ ``metadata_text`` (or the entry from ``metadata_by_version``, or
193
+ auto-generated minimal METADATA when ``auto_metadata`` is true).
194
+ * ``request_sdist`` writes ``sdist_pkg_info`` and, if not ``None``,
195
+ ``sdist_pyproject_toml``.
196
+
197
+ Pre-stores written directly to the index before the coordinator
198
+ fires:
199
+
200
+ * ``baseline_metadata`` keys on package name and writes once per
201
+ package using the first wheel's version.
202
+ * ``per_wheel_metadata`` keys on wheel filename and writes at the
203
+ validator's ``f"{version}#{filename}"`` sentinel.
204
+ * ``sdist_pyproject`` keys on package name and writes the
205
+ pyproject.toml text used by the PEP 621 fast path.
206
+
207
+ Call sites that need request side effects beyond what this helper
208
+ wires up (for example ``request_sdist_archive``) can reassign
209
+ ``.side_effect`` on the returned mock; the index is exposed at
210
+ ``coordinator.index`` for direct manipulation.
211
+ """
212
+ index = InMemoryIndex()
213
+ failures = fetch_failures if fetch_failures is not None else set()
214
+
215
+ listings_map = _resolve_listings(wheels, package, listings)
216
+ _pre_populate_index(
217
+ index,
218
+ listings_map,
219
+ baseline_metadata=baseline_metadata,
220
+ per_wheel_metadata=per_wheel_metadata,
221
+ sdist_pyproject=sdist_pyproject,
222
+ )
223
+
224
+ coordinator = MagicMock()
225
+ coordinator.index = index
226
+
227
+ resolve_metadata = _make_metadata_resolver(
228
+ metadata_text=metadata_text,
229
+ metadata_by_version=metadata_by_version,
230
+ auto_metadata=auto_metadata,
231
+ )
232
+ _wire_metadata_side_effects(coordinator, index, resolve_metadata)
233
+ _wire_sdist_side_effects(
234
+ coordinator,
235
+ index,
236
+ sdist_pkg_info=sdist_pkg_info,
237
+ sdist_pyproject_toml=sdist_pyproject_toml,
238
+ failures=failures,
239
+ )
240
+ return coordinator