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,339 @@
1
+ """Build a :class:`LockInput` from a finished resolve.
2
+
3
+ The provider's caches still hold the listings the resolver consumed
4
+ when this runs, so artefact hashes and per-file Requires-Python can
5
+ be read directly without a second fetch. This module also owns the
6
+ ``read_lockfile_anchor`` helper used by ``nab lock`` to keep
7
+ ``P<n>D`` durations stable across re-locks.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Protocol, overload
15
+
16
+ import tomli
17
+
18
+ from nab_index.client import SdistFile, WheelFile
19
+
20
+ from .._vendor.packaging.utils import canonicalize_name
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Iterable, Mapping, Sequence
24
+
25
+ from nab_index.multi_index import IndexConfig
26
+
27
+ from .._vendor.packaging.version import Version
28
+ from ..lockfile import (
29
+ IndexPin,
30
+ LockInput,
31
+ PinShape,
32
+ SdistArtifact,
33
+ VcsPin,
34
+ WheelArtifact,
35
+ )
36
+ from ..provider import DistPolicy, LocalSource, VcsSource
37
+
38
+
39
+ __all__ = [
40
+ "MissingHashError",
41
+ "build_lock_input_from_provider",
42
+ "read_lockfile_anchor",
43
+ ]
44
+
45
+
46
+ class _LockInputIndex(Protocol):
47
+ """Protocol for the InMemoryIndex slice the builder reads."""
48
+
49
+ def get_listing_index(self, package: str) -> str | None:
50
+ """Return the configured index name that served ``package``."""
51
+ ...
52
+
53
+
54
+ class _LockInputCoordinator(Protocol):
55
+ """Protocol for the FetchCoordinator slice the builder reads."""
56
+
57
+ @property
58
+ def index(self) -> _LockInputIndex:
59
+ """The underlying index used to look up serving-index labels."""
60
+ ...
61
+
62
+
63
+ class LockInputProvider(Protocol):
64
+ """Structural protocol for the provider slice the builder reads.
65
+
66
+ Mirrors the public surface :class:`~nab_python.provider.Provider`
67
+ exposes that :func:`build_lock_input_from_provider` consumes; tests
68
+ may supply a stub without inheriting the full Provider class.
69
+ """
70
+
71
+ @property
72
+ def coordinator(self) -> _LockInputCoordinator:
73
+ """Coordinator used to look up the index that served a listing."""
74
+ ...
75
+
76
+ def local_source_for(self, canonical_name: str, /) -> LocalSource | None:
77
+ """Return the configured LocalSource for ``canonical_name`` or None."""
78
+ ...
79
+
80
+ def vcs_source_for(self, canonical_name: str, /) -> VcsSource | None:
81
+ """Return the configured VcsSource for ``canonical_name`` or None."""
82
+ ...
83
+
84
+ def dist_files_for(
85
+ self, canonical_name: str, version: Version, /
86
+ ) -> list[WheelFile | SdistFile]:
87
+ """Return the listing slice that matches ``(canonical_name, version)``."""
88
+ ...
89
+
90
+ def effective_dist_policy(self, canonical_name: str, /) -> DistPolicy:
91
+ """Return the effective :class:`DistPolicy` for ``canonical_name``."""
92
+ ...
93
+
94
+
95
+ class MissingHashError(ValueError):
96
+ """A distribution chosen by the resolver has no usable hash.
97
+
98
+ PEP 751 requires at least one hash per artefact. When an index
99
+ serves a wheel or sdist without a ``hashes`` map (rare on PyPI,
100
+ common on file:// indexes), the lock writer cannot emit a
101
+ spec-compliant entry. Surface the failure with the offending
102
+ package and filename so the user can either add a hash to their
103
+ local index or exclude the package.
104
+ """
105
+
106
+
107
+ def read_lockfile_anchor(path: Path) -> datetime | None:
108
+ """Return the ``[tool.nab].created-at`` timestamp from ``path`` if any.
109
+
110
+ Used by ``nab lock`` to keep ``P<n>D`` durations stable across
111
+ re-locks: the anchor used for the previous resolve is read back
112
+ and reused unless the user passes ``--upgrade``.
113
+
114
+ Returns ``None`` when ``path`` does not exist, is not valid TOML,
115
+ is not a PEP 751-shaped pylock, or is missing the ``[tool.nab]``
116
+ block. Naive timestamps (no timezone offset) are coerced to UTC
117
+ for symmetry with the writer; this is informational provenance, so
118
+ a missing offset is recoverable rather than fatal.
119
+ """
120
+ if not path.is_file():
121
+ return None
122
+ try:
123
+ with path.open("rb") as f:
124
+ data = tomli.load(f)
125
+ except (OSError, tomli.TOMLDecodeError):
126
+ return None
127
+ raw = data.get("tool", {}).get("nab", {}).get("created-at")
128
+ if isinstance(raw, datetime):
129
+ return raw if raw.tzinfo else raw.replace(tzinfo=timezone.utc)
130
+ if isinstance(raw, str):
131
+ try:
132
+ dt = datetime.fromisoformat(raw)
133
+ except ValueError:
134
+ return None
135
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
136
+ return None
137
+
138
+
139
+ def build_lock_input_from_provider(
140
+ provider: LockInputProvider,
141
+ pins: Mapping[str, Version],
142
+ *,
143
+ requires_python: str | None = None,
144
+ extras: Sequence[str] = (),
145
+ dependency_groups: Sequence[str] = (),
146
+ default_groups: Sequence[str] = (),
147
+ created_by: str = "nab",
148
+ indexes: Sequence[IndexConfig] = (),
149
+ ) -> LockInput:
150
+ """Build a :class:`LockInput` from a finished resolve.
151
+
152
+ ``provider`` is the :class:`Provider` that drove the resolve;
153
+ its caches still hold the listings the resolver consumed. ``pins``
154
+ is the canonical-name -> :class:`Version` mapping returned by the
155
+ resolver after extras keys have been stripped.
156
+
157
+ ``dependency_groups`` lists the PEP 735 groups whose requirements
158
+ were folded into this resolve; ``default_groups`` is the subset
159
+ that a default install (no ``--group`` flag) should apply.
160
+
161
+ All wheels and the sdist for each pinned version are recorded so
162
+ the lockfile is portable across architectures of the same Python.
163
+ """
164
+ from ..lockfile import LocalPin, LockInput
165
+
166
+ lock_pins: dict[str, PinShape] = {}
167
+ for raw_name, version in pins.items():
168
+ canonical = canonicalize_name(raw_name)
169
+ local_source = provider.local_source_for(canonical)
170
+ if local_source is not None:
171
+ lock_pins[canonical] = LocalPin(
172
+ name=canonical,
173
+ version=str(version),
174
+ path=str(Path(local_source.path).resolve()),
175
+ )
176
+ continue
177
+ vcs_source = provider.vcs_source_for(canonical)
178
+ if vcs_source is not None:
179
+ lock_pins[canonical] = _vcs_pin_from_source(canonical, version, vcs_source)
180
+ continue
181
+ lock_pins[canonical] = _index_pin_from_listing(
182
+ provider, canonical, version, indexes
183
+ )
184
+ return LockInput(
185
+ pins=lock_pins,
186
+ requires_python=requires_python,
187
+ created_by=created_by,
188
+ extras=tuple(extras),
189
+ dependency_groups=tuple(dependency_groups),
190
+ default_groups=tuple(default_groups),
191
+ )
192
+
193
+
194
+ def _index_pin_from_listing(
195
+ provider: LockInputProvider,
196
+ canonical: str,
197
+ version: Version,
198
+ indexes: Sequence[IndexConfig],
199
+ ) -> IndexPin:
200
+ """Construct an :class:`IndexPin` for an index-served package.
201
+
202
+ The recorded ``index`` is the URL of the configured index that
203
+ served the package's listing during the resolve, looked up from
204
+ the coordinator's :class:`InMemoryIndex` (which records the
205
+ serving index by name) and resolved against ``indexes`` for the
206
+ URL. When ``indexes`` is empty or the route is unknown the URL
207
+ falls back to the default Simple-API root.
208
+
209
+ Under :attr:`~nab_python.provider.DistPolicy.SDIST_INSTALL` the
210
+ package's wheels stayed in ``versions_cache`` as a possible
211
+ metadata source for the resolver; only the sdist is emitted
212
+ into the lock so installers download and build that archive.
213
+ """
214
+ from ..fetch import DEFAULT_INDEX_URL
215
+ from ..lockfile import IndexPin, SdistArtifact, WheelArtifact
216
+ from ..provider import DistPolicy
217
+
218
+ files = list(provider.dist_files_for(canonical, version))
219
+ if provider.effective_dist_policy(canonical) is DistPolicy.SDIST_INSTALL:
220
+ files = [f for f in files if not isinstance(f, WheelFile)]
221
+
222
+ wheels = tuple(
223
+ _build_artifact(canonical, f, WheelArtifact)
224
+ for f in files
225
+ if isinstance(f, WheelFile)
226
+ )
227
+ sdist_file = next((f for f in files if isinstance(f, SdistFile)), None)
228
+ sdist = (
229
+ _build_artifact(canonical, sdist_file, SdistArtifact)
230
+ if sdist_file is not None
231
+ else None
232
+ )
233
+ requires_python = _common_requires_python(files)
234
+ serving_name = provider.coordinator.index.get_listing_index(canonical)
235
+ by_name = {ix.name: ix.url for ix in indexes}
236
+
237
+ if serving_name is not None and serving_name in by_name:
238
+ index_url = by_name[serving_name]
239
+ elif indexes:
240
+ index_url = indexes[0].url
241
+ else:
242
+ index_url = DEFAULT_INDEX_URL
243
+ return IndexPin(
244
+ name=canonical,
245
+ version=str(version),
246
+ index=index_url,
247
+ sdist=sdist,
248
+ wheels=wheels,
249
+ requires_python=requires_python,
250
+ )
251
+
252
+
253
+ @overload
254
+ def _build_artifact(
255
+ canonical: str,
256
+ source: WheelFile | SdistFile,
257
+ cls: type[WheelArtifact],
258
+ ) -> WheelArtifact: ...
259
+ @overload
260
+ def _build_artifact(
261
+ canonical: str,
262
+ source: WheelFile | SdistFile,
263
+ cls: type[SdistArtifact],
264
+ ) -> SdistArtifact: ...
265
+ def _build_artifact(
266
+ canonical: str,
267
+ source: WheelFile | SdistFile,
268
+ cls: type[WheelArtifact | SdistArtifact],
269
+ ) -> WheelArtifact | SdistArtifact:
270
+ hashes = _filter_acceptable_hashes(canonical, source.filename, source.hashes)
271
+ return cls(
272
+ filename=source.filename,
273
+ url=source.url,
274
+ hashes=hashes,
275
+ size=source.size,
276
+ )
277
+
278
+
279
+ def _filter_acceptable_hashes(
280
+ canonical: str, filename: str, hashes: tuple[tuple[str, str], ...]
281
+ ) -> tuple[tuple[str, str], ...]:
282
+ """Return the subset of ``hashes`` whose algorithm is consumable.
283
+
284
+ Pip's hash-checking mode and PEP 751 both accept any of sha256,
285
+ sha384, or sha512; nab forwards every recorded entry so consumers
286
+ can pick. Raise :class:`MissingHashError` when none of the
287
+ acceptable algorithms are present.
288
+ """
289
+ from ..lockfile import ACCEPTED_HASH_ALGORITHMS
290
+
291
+ accepted = tuple(
292
+ (algo, digest)
293
+ for algo, digest in sorted(hashes)
294
+ if algo in ACCEPTED_HASH_ALGORITHMS
295
+ )
296
+ if not accepted:
297
+ algos = sorted({algo for algo, _ in hashes})
298
+ msg = (
299
+ f"{canonical}: artefact {filename!r} has no acceptable hash"
300
+ f" (need one of {list(ACCEPTED_HASH_ALGORITHMS)!r}, got {algos!r})"
301
+ )
302
+ raise MissingHashError(msg)
303
+ return accepted
304
+
305
+
306
+ def _common_requires_python(files: Iterable[WheelFile | SdistFile]) -> str | None:
307
+ """Return a single Requires-Python value if all files agree, else ``None``."""
308
+ seen: set[str] = set()
309
+ for f in files:
310
+ if f.requires_python is not None:
311
+ seen.add(f.requires_python)
312
+ if len(seen) == 1:
313
+ return next(iter(seen))
314
+ return None
315
+
316
+
317
+ def _vcs_pin_from_source(canonical: str, version: Version, source: VcsSource) -> VcsPin:
318
+ """Build a :class:`VcsPin` from a :class:`VcsSource`.
319
+
320
+ The commit id is the ``@<ref>`` portion of the source URL when
321
+ present, parsed via :class:`nab_index.vcs.VcsRequest`. Unparseable
322
+ URLs fall through to an empty commit id; the source URL itself is
323
+ recorded verbatim.
324
+ """
325
+ from nab_index.vcs import VcsCloneError, VcsRequest
326
+
327
+ from ..lockfile import VcsPin
328
+
329
+ try:
330
+ parsed = VcsRequest.parse(source.url)
331
+ commit_id = parsed.ref
332
+ except VcsCloneError:
333
+ commit_id = ""
334
+ return VcsPin(
335
+ name=canonical,
336
+ version=str(version),
337
+ repo_url=source.url,
338
+ commit_id=commit_id,
339
+ )
@@ -0,0 +1,207 @@
1
+ """Per-name marker disjointness validation for the PEP 751 emitter.
2
+
3
+ PEP 751 forbids two ``[[packages]]`` entries with the same name from
4
+ firing under one install context. This module owns the validator
5
+ that enumerates the install-context universe (environments x extras
6
+ powerset x dependency-groups powerset) and reports any pair that
7
+ collide, plus the bookkeeping helpers that prune the powerset axes
8
+ to the marker variables a same-name candidate actually references.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import functools
14
+ import itertools
15
+ import re
16
+ from collections import defaultdict
17
+ from typing import TYPE_CHECKING
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Iterable, Mapping, Sequence
21
+ from collections.abc import Set as AbstractSet
22
+
23
+ from .._vendor.packaging.markers import Marker
24
+ from .._vendor.packaging.pylock import Package
25
+
26
+
27
+ __all__ = [
28
+ "DisjointnessError",
29
+ ]
30
+
31
+
32
+ class DisjointnessError(ValueError):
33
+ """Two same-name ``[[packages]]`` entries can fire under one context.
34
+
35
+ PEP 751 forbids ambiguous installer matches. When more than one
36
+ same-name entry has a marker that holds for the same install
37
+ context, the consumer cannot pick deterministically. The
38
+ validator surfaces the colliding name, the witness context
39
+ (environment + extras + groups), and the colliding versions so
40
+ the producer can either change the resolution or declare a
41
+ conflict.
42
+ """
43
+
44
+
45
+ def validate_marker_disjointness(
46
+ packages: Sequence[Package],
47
+ *,
48
+ environments: Mapping[str, Mapping[str, str]],
49
+ extras: Sequence[str],
50
+ groups: Sequence[str],
51
+ ) -> None:
52
+ """Confirm same-name ``[[packages]]`` entries are pairwise disjoint.
53
+
54
+ Builds the universe of install contexts as the cartesian product
55
+ of declared environments, the powerset of declared ``extras``,
56
+ and the powerset of declared ``dependency_groups``. For every
57
+ point in that universe and every package name, evaluates each
58
+ candidate entry's marker. When two or more entries hold for the
59
+ same point, raises :class:`DisjointnessError` with the witness.
60
+
61
+ The empty-environments path skips validation: a producer that
62
+ does not declare a universe cannot specify what "all envs" means
63
+ and the validator would over-report when entries have ``marker
64
+ is None``. Callers that emit per-tuple markers (universal
65
+ mode) populate ``LockInput.tuple_environments`` from the
66
+ matrix.
67
+
68
+ Powerset pruning: ``extras`` and ``dependency_groups`` are PEP
69
+ 685 / PEP 735 marker variables that markers may or may not
70
+ reference. Materialising the full ``2**N`` powerset is
71
+ intractable for projects that declare many extras. Inspect each
72
+ marker's string form via :func:`_referenced_membership_names`
73
+ and only iterate the powerset over names that any marker
74
+ actually mentions. When no marker references a variable, that
75
+ powerset collapses to ``{()}`` and the cartesian explosion
76
+ disappears.
77
+ """
78
+ if not environments:
79
+ return
80
+ by_name: defaultdict[str, list[Package]] = defaultdict(list)
81
+ for pkg in packages:
82
+ by_name[str(pkg.name)].append(pkg)
83
+ same_name_entries = [entries for entries in by_name.values() if len(entries) > 1]
84
+ if not same_name_entries:
85
+ return
86
+ candidate_markers = [pkg.marker for entries in same_name_entries for pkg in entries]
87
+ relevant_extras = _restrict_to_referenced(extras, candidate_markers, "extras")
88
+ relevant_groups = _restrict_to_referenced(
89
+ groups, candidate_markers, "dependency_groups"
90
+ )
91
+ extras_subsets = list(_powerset(relevant_extras))
92
+ group_subsets = list(_powerset(relevant_groups))
93
+ for entries in same_name_entries:
94
+ name = str(entries[0].name)
95
+ for env_label, env_dict in environments.items():
96
+ for extra_subset in extras_subsets:
97
+ for group_subset in group_subsets:
98
+ context: dict[str, str | AbstractSet[str]] = dict(env_dict)
99
+ context["extras"] = frozenset(extra_subset)
100
+ context["dependency_groups"] = frozenset(group_subset)
101
+ matching = [
102
+ pkg for pkg in entries if _marker_holds(pkg.marker, context)
103
+ ]
104
+ if len(matching) <= 1:
105
+ continue
106
+ versions = sorted(
107
+ str(p.version) if p.version else "" for p in matching
108
+ )
109
+ msg = (
110
+ f"{name}: {len(matching)} entries fire under"
111
+ f" env={env_label!r} extras={sorted(extra_subset)!r}"
112
+ f" groups={sorted(group_subset)!r}: versions={versions}"
113
+ )
114
+ raise DisjointnessError(msg)
115
+
116
+
117
+ @functools.cache
118
+ def _membership_name_pattern(variable: str) -> re.Pattern[str]:
119
+ """Compile (and cache) the regex matching ``"NAME" [not] in <variable>``.
120
+
121
+ Used to detect which extras / dependency-group names a marker
122
+ references. PEP 508 reserves ``extras`` (PEP 685) and
123
+ ``dependency_groups`` (PEP 735) as bare-token marker variables,
124
+ so a literal-vs-variable membership test always serialises as
125
+ ``"<lit>" [not] in <var>`` after :func:`Marker.__str__`
126
+ normalisation. Rejected alternatives: walking
127
+ ``Marker._markers`` (private packaging API) or vendoring
128
+ ``Marker.as_ast()`` from packaging PR #1145 (still open; loses
129
+ operand-vs-variable distinction in the proposed shape).
130
+ """
131
+ return re.compile(
132
+ r"""(['"])([^'"]*)\1\s+(?:not\s+)?in\s+""" + re.escape(variable) + r"\b",
133
+ re.IGNORECASE,
134
+ )
135
+
136
+
137
+ def _referenced_membership_names(
138
+ markers: Iterable[Marker | None], variable: str
139
+ ) -> tuple[frozenset[str], bool]:
140
+ """Return the literals any marker tests for membership in ``variable``.
141
+
142
+ ``variable`` is one of ``"extras"`` or ``"dependency_groups"``;
143
+ a literal ``"foo"`` referenced as ``"foo" in extras`` (or its
144
+ ``not in`` form) lands in the result. The regex matches
145
+ ``str(marker)`` because :class:`Marker` re-emits a canonical
146
+ form where the literal is always quoted and the variable is
147
+ always a bare token.
148
+
149
+ Also returns a flag set when *any* marker contains the bare
150
+ ``variable`` token; callers can use it to detect unusual marker
151
+ shapes that mention the variable but did not match the regex
152
+ (a future PEP form, a comparison flipped to put the variable
153
+ on the LHS, etc.) and fall back to a safe over-approximation.
154
+ """
155
+ pattern = _membership_name_pattern(variable)
156
+ bare_token = re.compile(r"\b" + re.escape(variable) + r"\b")
157
+ found: set[str] = set()
158
+ has_bare_reference = False
159
+ for marker in markers:
160
+ if marker is None:
161
+ continue
162
+ text = str(marker)
163
+ if bare_token.search(text):
164
+ has_bare_reference = True
165
+ for match in pattern.finditer(text):
166
+ found.add(match.group(2))
167
+ return frozenset(found), has_bare_reference
168
+
169
+
170
+ def _restrict_to_referenced(
171
+ declared: Sequence[str],
172
+ markers: Sequence[Marker | None],
173
+ variable: str,
174
+ ) -> tuple[str, ...]:
175
+ """Restrict ``declared`` to the subset that any marker references.
176
+
177
+ ``variable`` is the marker token (``"extras"`` or
178
+ ``"dependency_groups"``). The intersection of declared names
179
+ and regex-matched literals shrinks the powerset axis to what
180
+ the markers actually depend on. When the bare token appears
181
+ in some marker but no literals were extracted (an unusual form
182
+ the regex did not anticipate), fall back to the full declared
183
+ list so the validator over-approximates rather than silently
184
+ misses a collision.
185
+ """
186
+ referenced, has_bare = _referenced_membership_names(markers, variable)
187
+ if not has_bare:
188
+ return ()
189
+ if not referenced:
190
+ return tuple(declared)
191
+ return tuple(name for name in declared if name in referenced)
192
+
193
+
194
+ def _marker_holds(
195
+ marker: Marker | None, context: Mapping[str, str | AbstractSet[str]]
196
+ ) -> bool:
197
+ """Return True when ``marker`` is absent or evaluates True under ``context``."""
198
+ if marker is None:
199
+ return True
200
+ return bool(marker.evaluate(dict(context)))
201
+
202
+
203
+ def _powerset(items: Sequence[str]) -> Iterable[tuple[str, ...]]:
204
+ """Yield every subset of ``items`` as a sorted tuple, including ``()``."""
205
+ seen = sorted(set(items))
206
+ for r in range(len(seen) + 1):
207
+ yield from itertools.combinations(seen, r)