nab-python 0.0.7__py3-none-any.whl → 0.0.8__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.
@@ -4,12 +4,13 @@ from __future__ import annotations
4
4
 
5
5
  from typing import TYPE_CHECKING
6
6
 
7
+ from ._vendor.packaging.ranges import VersionRange
8
+
7
9
  if TYPE_CHECKING:
8
10
  from collections.abc import Mapping
9
11
 
10
12
  from nab_resolver.types import Incompatibility, RangeProtocol
11
13
 
12
- from ._vendor.packaging.ranges import VersionRange
13
14
  from ._vendor.packaging.specifiers import SpecifierSet
14
15
  from ._vendor.packaging.version import Version
15
16
 
@@ -47,12 +48,21 @@ class PackagingProvider:
47
48
  return version
48
49
  return None
49
50
 
51
+ def has_satisfying_version(
52
+ self, package: str, version_range: RangeProtocol[Version]
53
+ ) -> bool:
54
+ """Report whether any available version falls in the range."""
55
+ return any(v in version_range for v in self._get_versions(package))
56
+
50
57
  def get_dependencies(
51
58
  self, package: str, version: Version
52
59
  ) -> dict[str, VersionRange]:
53
60
  """Convert SpecifierSet deps to VersionRange deps."""
54
61
  raw = self._packages.get(package, {}).get(version, {})
55
- return {dep: spec.to_range() for dep, spec in raw.items()}
62
+ return {
63
+ dep: (spec.to_range() if spec else VersionRange.full(admit_arbitrary=False))
64
+ for dep, spec in raw.items()
65
+ }
56
66
 
57
67
  _CONFLICT_THRESHOLD = 5
58
68
 
@@ -96,3 +106,15 @@ class PackagingProvider:
96
106
  def consume_force_backtrack_targets(self) -> list[str]:
97
107
  """No force-backtrack signal from this in-memory provider."""
98
108
  return []
109
+
110
+ def widen_decision(self, package: str, version: Version) -> VersionRange | None:
111
+ """No widening: dependency clauses keep the exact decided version."""
112
+ del package, version
113
+ return None
114
+
115
+ def narrow_for_display(
116
+ self, package: str, constraint: RangeProtocol[Version]
117
+ ) -> RangeProtocol[Version]:
118
+ """Identity: constraints render as stored."""
119
+ del package
120
+ return constraint
@@ -43,26 +43,43 @@ def choose_extra_version(
43
43
  _, _, normalized = provider.split_and_normalize(base)
44
44
  version_list = provider.fetch_versions(base)
45
45
  all_versions = provider.versions_only(normalized, version_list)
46
- candidates = list(version_range.filter(all_versions))
47
46
 
48
- # Filter by base's positive range so we don't pick a proxy version
49
- # that would force base==V into a known-conflicting state.
47
+ # Filter by the base's positive range so we don't pick a proxy version
48
+ # that would force base==V into a known-conflicting state. Intersect
49
+ # rather than test membership: the base's range carries the pre-release
50
+ # admission granted by the requirement that named the extra, while the
51
+ # proxy's own range is built full.
50
52
  base_range = provider.solution_ranges.get(normalized)
51
- excluded_by_base: list[Version] = []
52
- if base_range is not None:
53
- kept: list[Version] = []
54
- for v in candidates:
55
- if v in base_range:
56
- kept.append(v)
57
- else:
58
- excluded_by_base.append(v)
59
- candidates = kept
53
+ if base_range is None:
54
+ logger.debug(
55
+ "no base range for %s; base admission cannot be applied to %s",
56
+ normalized,
57
+ package,
58
+ )
59
+ candidates = list(version_range.filter(all_versions))
60
+ else:
61
+ candidates = list((version_range & base_range).filter(all_versions))
60
62
 
61
63
  if provider.wants_lowest(normalized):
62
64
  candidates = list(reversed(candidates))
63
65
 
64
66
  chosen = _pick_in_mode(provider, base, extra, candidates)
65
- if chosen is None and excluded_by_base and base_range is not None:
67
+ # Enumerate pre-releases too: default filtering buffers a pre-release
68
+ # behind any matching final and would drop one that the base's bounds
69
+ # exclude, so it would never be recorded and the proxy would keep a
70
+ # permanent NO_VERSIONS clause past the backjump lifting the base
71
+ # decision. Membership below is bounds-only, so the blocks stay sound.
72
+ if (
73
+ chosen is None
74
+ and base_range is not None
75
+ and (
76
+ excluded_by_base := [
77
+ v
78
+ for v in version_range.filter(all_versions, prereleases=True)
79
+ if v not in base_range
80
+ ]
81
+ )
82
+ ):
66
83
  _record_base_range_blocks(
67
84
  provider, package, normalized, base_range, excluded_by_base
68
85
  )
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
  from typing import TYPE_CHECKING
12
12
 
13
13
  from nab_index.client import SdistFile, WheelFile
14
- from nab_index.local_index import read_wheel_metadata
14
+ from nab_index.local_index import UnsupportedWheelError, read_wheel_metadata
15
15
 
16
16
  from .._conflict_kind import EMPTY_MEMBERSHIP_SETS
17
17
  from .._vcs_admission import admit_vcs_url
@@ -86,7 +86,11 @@ def resolve_metadata(
86
86
  raise integrity_error
87
87
  metadata_text = provider.coordinator.index.get_metadata(normalized, ver_str)
88
88
  elif isinstance(dist, WheelFile) and dist.local_path is not None:
89
- metadata_text = read_wheel_metadata(dist.local_path)
89
+ try:
90
+ metadata_text = read_wheel_metadata(dist.local_path)
91
+ except UnsupportedWheelError:
92
+ # A contradictory .dist-info is unusable, like an unreadable wheel.
93
+ metadata_text = None
90
94
  else:
91
95
  metadata_text = None
92
96
 
@@ -554,16 +558,22 @@ def add_classified_dep(
554
558
  from ..provider import join_extra
555
559
 
556
560
  name = canonicalize_name(req.name)
557
- vi = req.specifier.to_range()
561
+ # A bare dependency enters the solver without arbitrary-string admission;
562
+ # the accumulator identities stay arbitrary-admitting for === literals.
563
+ vi = (
564
+ req.specifier.to_range()
565
+ if req.specifier
566
+ else VersionRange.full(admit_arbitrary=False)
567
+ )
558
568
  dep_extras: set[str] = req.extras
559
569
 
560
570
  if not req_extras:
561
571
  base_deps[name] = base_deps.get(name, VersionRange.full()) & vi
562
572
  for re in dep_extras:
563
- base_deps[join_extra(name, re)] = VersionRange.full()
573
+ base_deps[join_extra(name, re)] = VersionRange.full(admit_arbitrary=False)
564
574
  else:
565
575
  for extra_name in req_extras:
566
576
  edeps = extra_deps_map[extra_name]
567
577
  edeps[name] = edeps.get(name, VersionRange.full()) & vi
568
578
  for re in dep_extras:
569
- edeps[join_extra(name, re)] = VersionRange.full()
579
+ edeps[join_extra(name, re)] = VersionRange.full(admit_arbitrary=False)
@@ -15,6 +15,7 @@ after admission lets the URL through.
15
15
  from __future__ import annotations
16
16
 
17
17
  import enum
18
+ import posixpath
18
19
  from dataclasses import dataclass
19
20
  from urllib.parse import urlsplit, urlunsplit
20
21
 
@@ -142,7 +143,15 @@ def _repo_prefix_matches(inner_url: str, prefix: str) -> bool:
142
143
  prefix and skipped once on the candidate before the boundary check.
143
144
  Both URLs have their authority ``user[:pass]@`` / ``git@`` stripped
144
145
  by the caller.
146
+
147
+ A path git would rewrite is refused first: git applies RFC 3986
148
+ dot-segment removal at fetch time, so a raw ``..`` path could pass the
149
+ string match while git fetches a repo outside the prefix.
145
150
  """
151
+ path = urlsplit(inner_url).path
152
+ if path and posixpath.normpath(path) != path:
153
+ return False
154
+
146
155
  prefix = prefix.removesuffix(".git")
147
156
  if not inner_url.startswith(prefix):
148
157
  return False
@@ -1,92 +1,61 @@
1
1
  # Vendored `packaging` snapshot
2
2
 
3
- This directory holds a copy of the `packaging` library taken from
4
- `pypa/packaging:main`, vendored so nab can use the public `VersionRange`
5
- API before a `packaging` release containing it is published to PyPI.
6
-
7
- ## Source
8
-
9
- - Upstream repository: https://github.com/pypa/packaging
10
- - Source branch: `pypa/packaging:main`
11
- - Pinned commit: `0a85b41e24c9b55b83f05ae696d73e8294a5b094`
12
- - Snapshot date: 2026-07-06
13
-
14
- The snapshot is the full `src/packaging/` tree at that commit, plus
15
- `LICENSE`, `LICENSE.APACHE`, and `LICENSE.BSD` from the repository
16
- root, except that `ranges.py` and `_ranges.py` diverge from the pinned
17
- commit (see "Pre-release opt-in change" below). Relative imports inside
18
- `packaging` (`from .version import Version`, etc.) keep working when the
19
- package is loaded as `nab_python._vendor.packaging`.
20
-
21
- ## Pre-release opt-in change
22
-
23
- `ranges.py` and `_ranges.py` carry the clipped pre-release opt-in region
24
- proposed as the successor to packaging PR #1304, rebased on the
25
- difference-policy guard of PR #1306. `VersionRange` scopes the
26
- autodetected pre-release opt-in to a per-specifier region clipped to the
27
- bounds, so union and difference never force-admit a pre-release no
28
- operand asked for. This closes the whole-range opt-in leak that the flag
29
- model on `pypa/packaging:main` reaches through nab's conflict-resolution
30
- union path. Refresh both files from that branch until the change lands
31
- upstream, then snapshot plain `main` again.
3
+ This directory is a copy of the `packaging` library, vendored so nab can use
4
+ the public `VersionRange` API before a `packaging` release containing it is
5
+ published to PyPI. The tree is pristine `pypa/packaging` at a pinned commit
6
+ plus at most one checked-in patch, and nothing else.
7
+
8
+ ## Model
9
+
10
+ - `tasks/vendoring/packaging.pin` holds the upstream commit the tree is pinned
11
+ to.
12
+ - `tasks/vendoring/packaging.patch`, when present, is the only divergence from
13
+ pristine; the rebuild reapplies it after refreshing. With no patch file the
14
+ tree is byte-identical to upstream at the pin. The current patch adds
15
+ `VersionRange.from_bounds` and `VersionRange.snap_bounds` to `ranges.py`,
16
+ generated from the `range-from-bounds` branch of the packaging fork
17
+ (`git diff upstream/main..range-from-bounds -- src/packaging/ranges.py`,
18
+ paths rewritten to this directory). An upstream PR is planned; until it
19
+ lands the patch carries the two methods.
20
+ - `tasks/vendor-packaging.sh` fetches `pypa/packaging` at the pin, replaces this
21
+ package with the pristine `src/packaging/` tree plus the repo-root license
22
+ texts, and reapplies the patch. `--check` rebuilds into a temp location and
23
+ fails if the committed tree has drifted. This file is nab's own and stays out
24
+ of both the rebuild and the comparison.
25
+ - CI runs `tasks/vendor-packaging.sh --check`, so the committed tree is proven
26
+ to equal pristine-at-pin plus the patch on every push.
27
+
28
+ ## Refreshing
29
+
30
+ 1. Bump `tasks/vendoring/packaging.pin` to the new upstream commit.
31
+ 2. If the patch no longer applies, rebase its source branch onto the new pin
32
+ and regenerate `packaging.patch` from it.
33
+ 3. Run `tasks/vendor-packaging.sh`, then the test suite.
32
34
 
33
35
  ## License
34
36
 
35
- `packaging` is dual-licensed under the Apache License 2.0 and the
36
- 2-Clause BSD License. The LICENSE files in this directory are the
37
- upstream texts; nothing here is relicensed.
37
+ `packaging` is dual-licensed under the Apache License 2.0 and the 2-Clause BSD
38
+ License. The LICENSE files here are the upstream texts; nothing is relicensed.
38
39
 
39
40
  ## Why vendor instead of depending on it
40
41
 
41
42
  `packaging` now ships a public `VersionRange` class with set algebra
42
- (intersection, union, complement, difference), the `is_subset` /
43
- `is_superset` / `is_disjoint` relation predicates, `is_empty`, `filter`,
44
- and a `SpecifierSet.to_range()` factory that nab's PubGrub solver depends
45
- on. The class landed in `main` via
46
- https://github.com/pypa/packaging/pull/1267, and the difference operator
47
- (`-`) plus the relation predicates via
48
- https://github.com/pypa/packaging/pull/1298. None of this has appeared in
49
- a PyPI release yet, so there is no version we can pin in `pyproject.toml`.
50
- Vendoring is a temporary measure.
43
+ (intersection, union, complement, difference), the `is_subset` / `is_superset`
44
+ / `is_disjoint` predicates, `is_empty`, `filter`, and a `SpecifierSet.to_range()`
45
+ factory that nab's PubGrub solver depends on. The class landed in `main` via
46
+ https://github.com/pypa/packaging/pull/1267 and the difference operator plus the
47
+ relation predicates via https://github.com/pypa/packaging/pull/1298. None of
48
+ this has appeared in a PyPI release yet, so there is no version to pin in
49
+ `pyproject.toml`. Vendoring is a temporary measure.
51
50
 
52
51
  ## Removal plan
53
52
 
54
- Delete this entire directory and reinstate `packaging` as a normal
55
- dependency once a `packaging` release containing the merged
56
- `VersionRange` class has been published to PyPI.
57
-
58
- Once that holds:
53
+ Delete this directory and reinstate `packaging` as a normal dependency once a
54
+ release containing the merged `VersionRange` class reaches PyPI:
59
55
 
60
56
  - Add `packaging>=<release>` back to `nab-python/pyproject.toml`
61
57
  `[project].dependencies`.
62
- - Search-replace `from nab_python._vendor.packaging` ->
63
- `from packaging` and `import nab_python._vendor.packaging` ->
64
- `import packaging` across the workspace.
65
- - Remove this `_vendor/` tree.
66
-
67
- ## Updating the snapshot
68
-
69
- To refresh against a newer `pypa/packaging:main`:
70
-
71
- ```
72
- cd packaging # the upstream fork checkout
73
- git fetch upstream main
74
- NEW_SHA=$(git rev-parse upstream/main)
75
- DEST=/path/to/nab/nab-python/src/nab_python/_vendor/packaging
76
- for f in $(git ls-tree -r --name-only upstream/main -- src/packaging); do
77
- rel=${f#src/packaging/}
78
- # ranges.py/_ranges.py carry the clip change; refresh them from the clip
79
- # successor branch, not plain main (see "Pre-release opt-in change").
80
- case "$rel" in ranges.py | _ranges.py) continue ;; esac
81
- mkdir -p "$(dirname "$DEST/$rel")"
82
- git show "upstream/main:$f" > "$DEST/$rel"
83
- done
84
- for f in LICENSE LICENSE.APACHE LICENSE.BSD; do
85
- git show "upstream/main:$f" > "$DEST/$f"
86
- done
87
- ```
88
-
89
- Refresh `ranges.py` and `_ranges.py` from the clip successor branch until
90
- the change lands upstream; overwriting them from plain `main` reintroduces
91
- the whole-range opt-in leak. Then update the **Pinned commit** and
92
- **Snapshot date** lines above.
58
+ - Search-replace `from nab_python._vendor.packaging` -> `from packaging` and
59
+ `import nab_python._vendor.packaging` -> `import packaging` across the
60
+ workspace.
61
+ - Remove this `_vendor/` tree and the `tasks/vendoring/` files.
@@ -30,8 +30,6 @@ _ALLOWED_ARCHS = {
30
30
  }
31
31
 
32
32
 
33
- # `os.PathLike` not a generic type until Python 3.9, so sticking with `str`
34
- # as the type for `path` until then.
35
33
  @contextlib.contextmanager
36
34
  def _parse_elf(path: str) -> Generator[ELFFile | None, None, None]:
37
35
  try:
@@ -84,5 +84,5 @@ if __name__ == "__main__": # pragma: no cover
84
84
  print("plat:", plat)
85
85
  print("musl:", _get_musl_version(sys.executable))
86
86
  print("tags:", end=" ")
87
- for t in platform_tags(re.sub(r"[.-]", "_", plat.split("-", 1)[-1])):
87
+ for t in platform_tags([re.sub(r"[.-]", "_", plat.split("-", 1)[-1])]):
88
88
  print(t, end="\n ")
@@ -292,13 +292,13 @@ class UpperBound:
292
292
 
293
293
 
294
294
  if TYPE_CHECKING:
295
- #: A single contiguous version range, as a (lower, upper) pair.
296
- VersionRange = tuple[LowerBound, UpperBound]
295
+ #: A single contiguous interval as a (lower, upper) bound pair.
296
+ Interval = tuple[LowerBound, UpperBound]
297
297
 
298
298
 
299
299
  NEG_INF: Final[LowerBound] = LowerBound(None, False)
300
300
  POS_INF: Final[UpperBound] = UpperBound(None, False)
301
- FULL_RANGE: Final[tuple[VersionRange]] = ((NEG_INF, POS_INF),)
301
+ FULL_RANGE: Final[tuple[Interval]] = ((NEG_INF, POS_INF),)
302
302
 
303
303
 
304
304
  def trim_release(release: tuple[int, ...]) -> tuple[int, ...]:
@@ -501,11 +501,11 @@ def range_is_empty(lower: LowerBound, upper: UpperBound) -> bool:
501
501
 
502
502
 
503
503
  def intersect_ranges(
504
- left: Sequence[VersionRange],
505
- right: Sequence[VersionRange],
506
- ) -> list[VersionRange]:
504
+ left: Sequence[Interval],
505
+ right: Sequence[Interval],
506
+ ) -> list[Interval]:
507
507
  """Intersect two sorted, non-overlapping range lists (two-pointer merge)."""
508
- result: list[VersionRange] = []
508
+ result: list[Interval] = []
509
509
  left_index = right_index = 0
510
510
  while left_index < len(left) and right_index < len(right):
511
511
  left_lower, left_upper = left[left_index]
@@ -527,11 +527,11 @@ def intersect_ranges(
527
527
 
528
528
 
529
529
  def filter_by_ranges(
530
- ranges: Sequence[VersionRange],
530
+ ranges: Sequence[Interval],
531
531
  iterable: Iterable[Any],
532
532
  key: Callable[[Any], Version | str] | None,
533
533
  prereleases: bool | None,
534
- region: Sequence[VersionRange] = (),
534
+ region: Sequence[Interval] = (),
535
535
  ) -> Iterator[Any]:
536
536
  """Filter *iterable* against precomputed version *ranges*.
537
537
 
@@ -663,7 +663,7 @@ def _lowest_release_at_or_above(value: Version | BoundaryVersion | None) -> Vers
663
663
  return _nearest_release_above_prerelease(value)
664
664
 
665
665
 
666
- def ranges_are_prerelease_only(ranges: Sequence[VersionRange]) -> bool:
666
+ def ranges_are_prerelease_only(ranges: Sequence[Interval]) -> bool:
667
667
  """True when every range in *ranges* contains only pre-releases.
668
668
 
669
669
  Used to detect unsatisfiable specifier sets when ``prereleases=False``:
@@ -678,7 +678,7 @@ def ranges_are_prerelease_only(ranges: Sequence[VersionRange]) -> bool:
678
678
  return True
679
679
 
680
680
 
681
- def wildcard_ranges(op: str, base: Version) -> list[VersionRange]:
681
+ def wildcard_ranges(op: str, base: Version) -> list[Interval]:
682
682
  """Ranges for ==V.* and !=V.*.
683
683
 
684
684
  ==1.2.* -> [1.2.dev0, 1.3.dev0); !=1.2.* -> complement.
@@ -694,7 +694,7 @@ def wildcard_ranges(op: str, base: Version) -> list[VersionRange]:
694
694
  ]
695
695
 
696
696
 
697
- def standard_ranges(op: str, version: Version, has_local: bool) -> list[VersionRange]:
697
+ def standard_ranges(op: str, version: Version, has_local: bool) -> list[Interval]:
698
698
  """Ranges for the standard PEP 440 operators (no wildcard, no ===).
699
699
 
700
700
  *has_local* indicates whether the spec string included a ``+local``
@@ -763,7 +763,7 @@ def standard_ranges(op: str, version: Version, has_local: bool) -> list[VersionR
763
763
  raise ValueError(f"Unknown operator: {op!r}") # pragma: no cover
764
764
 
765
765
 
766
- def bounds_for_spec(op: str, version_str: str, version: Version) -> list[VersionRange]:
766
+ def bounds_for_spec(op: str, version_str: str, version: Version) -> list[Interval]:
767
767
  """Ranges for one specifier's ``(op, version_str)``.
768
768
 
769
769
  Dispatches between the wildcard and standard builders. ``version`` is the
@@ -778,14 +778,14 @@ def bounds_for_spec(op: str, version_str: str, version: Version) -> list[Version
778
778
 
779
779
 
780
780
  def intersect_specifier_bounds(
781
- per_specifier_ranges: Iterable[Sequence[VersionRange]],
782
- ) -> Sequence[VersionRange]:
781
+ per_specifier_ranges: Iterable[Sequence[Interval]],
782
+ ) -> Sequence[Interval]:
783
783
  """Intersect each specifier's ranges into a single sequence.
784
784
 
785
785
  Short-circuits once the running intersection is empty, since no later
786
786
  specifier can revive it. Callers must pass at least one specifier.
787
787
  """
788
- result: Sequence[VersionRange] | None = None
788
+ result: Sequence[Interval] | None = None
789
789
  for sub in per_specifier_ranges:
790
790
  if result is None:
791
791
  result = sub
@@ -800,7 +800,7 @@ def intersect_specifier_bounds(
800
800
  return result
801
801
 
802
802
 
803
- def matches_bounds_only(ranges: Sequence[VersionRange], version: Version) -> bool:
803
+ def matches_bounds_only(ranges: Sequence[Interval], version: Version) -> bool:
804
804
  """Whether ``version`` falls within any of ``ranges``.
805
805
 
806
806
  The pure bounds membership test, for a single already-parsed version with
@@ -4,7 +4,7 @@ import re
4
4
  from collections.abc import Mapping, Sequence
5
5
 
6
6
  from .errors import _ErrorCollector
7
- from .requirements import Requirement
7
+ from .requirements import InvalidRequirement, Requirement
8
8
 
9
9
  __all__ = [
10
10
  "CyclicDependencyGroup",
@@ -241,9 +241,10 @@ class DependencyGroupResolver:
241
241
  for item in raw_group:
242
242
  if isinstance(item, str):
243
243
  # packaging.requirements.Requirement parsing ensures that this is a
244
- # valid PEP 508 Dependency Specifier
245
- # raises InvalidRequirement on failure
246
- elements.append(Requirement(item))
244
+ # valid PEP 508 Dependency Specifier. Collect InvalidRequirement
245
+ # if it throws that.
246
+ with errors.collect(InvalidRequirement):
247
+ elements.append(Requirement(item))
247
248
  elif isinstance(item, Mapping):
248
249
  if tuple(item.keys()) != ("include-group",):
249
250
  errors.error(
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Protocol, TypeVar
9
9
  if TYPE_CHECKING: # pragma: no cover
10
10
  import sys
11
11
  from collections.abc import Collection
12
+ from urllib.parse import SplitResult
12
13
 
13
14
  if sys.version_info >= (3, 11):
14
15
  from typing import Self
@@ -109,6 +110,10 @@ def _strip_url(url: str, safe_user_passwords: Collection[str]) -> str:
109
110
  )
110
111
 
111
112
 
113
+ def _file_url_has_absolute_path(parsed_url: SplitResult) -> bool:
114
+ return parsed_url.path.startswith("/")
115
+
116
+
112
117
  class DirectUrlValidationError(Exception):
113
118
  """Raised when when input data is not spec-compliant.
114
119
 
@@ -289,14 +294,18 @@ class DirectUrl:
289
294
  raise DirectUrlValidationError(
290
295
  "Exactly one of vcs_info, archive_info, dir_info must be present"
291
296
  )
292
- if (
293
- direct_url.dir_info is not None
294
- and urllib.parse.urlsplit(direct_url.url).scheme != "file"
295
- ):
296
- raise DirectUrlValidationError(
297
- "URL scheme must be file:// when dir_info is present",
298
- context="url",
299
- )
297
+ if direct_url.dir_info is not None:
298
+ parsed_url = urllib.parse.urlsplit(direct_url.url)
299
+ if parsed_url.scheme != "file":
300
+ raise DirectUrlValidationError(
301
+ "URL scheme must be file:// when dir_info is present",
302
+ context="url",
303
+ )
304
+ if not _file_url_has_absolute_path(parsed_url):
305
+ raise DirectUrlValidationError(
306
+ "File URL must be absolute when dir_info is present",
307
+ context="url",
308
+ )
300
309
  # XXX subdirectory must be relative, can we, should we validate that here?
301
310
  return direct_url
302
311
 
@@ -4,6 +4,7 @@
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
+ import functools
7
8
  import operator
8
9
  import os
9
10
  import platform
@@ -172,6 +173,18 @@ def _normalize_extras(
172
173
  elif isinstance(rhs, Variable) and rhs.value == "extra" and isinstance(lhs, Value):
173
174
  normalized_extra = canonicalize_name(lhs.value)
174
175
  lhs = Value(normalized_extra)
176
+ elif (
177
+ isinstance(rhs, Variable)
178
+ and rhs.value in MARKERS_ALLOWING_SET
179
+ and isinstance(lhs, Value)
180
+ ):
181
+ # PEP 685 (extras) / PEP 735 (dependency_groups): the set-valued membership
182
+ # literal must also be normalized. evaluate() already canonicalizes both
183
+ # operands for these keys (see _normalize), so normalizing the literal at
184
+ # parse time keeps __str__/__eq__/__hash__ consistent with evaluate() -- e.g.
185
+ # Marker('"Foo" in extras') and Marker('"foo" in extras') must compare and
186
+ # hash equal (the membership variable is always the right-hand operand).
187
+ lhs = Value(canonicalize_name(lhs.value))
175
188
  return lhs, op, rhs
176
189
 
177
190
 
@@ -288,7 +301,13 @@ def _evaluate_markers(
288
301
  environment_key = rhs.value
289
302
  rhs_value = _lookup_environment(environment, environment_key)
290
303
 
291
- assert isinstance(lhs_value, str), "lhs must be a string"
304
+ if not isinstance(lhs_value, str):
305
+ raise UndefinedComparison(
306
+ f"Set-valued marker {environment_key!r} can only be used "
307
+ f'with the membership form (e.g. "<name>" in '
308
+ f"{environment_key}); it cannot appear on the left-hand "
309
+ f"side of {op.serialize()!r}."
310
+ )
292
311
  lhs_value, rhs_value = _normalize(lhs_value, rhs_value, key=environment_key)
293
312
  groups[-1].append(_eval_op(lhs_value, op, rhs_value, key=environment_key))
294
313
  elif marker == "or":
@@ -309,10 +328,14 @@ def _format_full_version(info: sys._version_info) -> str:
309
328
  return version
310
329
 
311
330
 
312
- def default_environment() -> Environment:
313
- """Return the default marker environment for the current Python process.
331
+ @functools.cache
332
+ def _cached_default_environment() -> Environment:
333
+ """Build the default marker environment for the current Python process.
314
334
 
315
- This is the base environment used by :meth:`Marker.evaluate`.
335
+ The values are derived from process-constant data (the running interpreter
336
+ and the host platform), so this is cached and built only once. The result is
337
+ shared between callers and must never be mutated; :func:`default_environment`
338
+ returns a fresh copy.
316
339
  """
317
340
  iver = _format_full_version(sys.implementation.version)
318
341
  implementation_name = sys.implementation.name
@@ -331,6 +354,22 @@ def default_environment() -> Environment:
331
354
  }
332
355
 
333
356
 
357
+ def default_environment() -> Environment:
358
+ """Return the default marker environment for the current Python process.
359
+
360
+ This is the base environment used by :meth:`Marker.evaluate`. A fresh copy
361
+ is returned on every call so callers may freely mutate the result; a shallow
362
+ copy suffices because all values are immutable strings.
363
+
364
+ .. versionchanged:: 26.3
365
+ The environment is computed once per process and cached, since it is
366
+ derived from process-constant data. Patching ``platform``/``sys``/``os``
367
+ after the first call has no effect; pass an explicit ``environment`` to
368
+ :meth:`Marker.evaluate` to evaluate against different values.
369
+ """
370
+ return cast("Environment", dict(_cached_default_environment()))
371
+
372
+
334
373
  class Marker:
335
374
  """Represents a parsed dependency marker expression.
336
375
 
@@ -228,13 +228,19 @@ def _get_payload(msg: email.message.Message, source: bytes | str) -> str:
228
228
  # and we don't need to deal with it.
229
229
  if isinstance(source, str):
230
230
  payload = msg.get_payload()
231
- assert isinstance(payload, str)
231
+ # A multipart payload makes get_payload() return a list of messages
232
+ # rather than a str; route it to ``unparsed``.
233
+ if not isinstance(payload, str):
234
+ raise ValueError("payload is not a string") # noqa: TRY004
232
235
  return payload
233
236
  # If our source is a bytes, then we're managing the encoding and we need
234
237
  # to deal with it.
235
238
  else:
236
239
  bpayload = msg.get_payload(decode=True)
237
- assert isinstance(bpayload, bytes)
240
+ # A multipart payload makes get_payload(decode=True) return None;
241
+ # route it to ``unparsed``.
242
+ if not isinstance(bpayload, bytes):
243
+ raise ValueError("payload in an invalid encoding") # noqa: TRY004
238
244
  try:
239
245
  return bpayload.decode("utf8", "strict")
240
246
  except UnicodeDecodeError as exc:
@@ -105,7 +105,10 @@ def _get(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T | None:
105
105
  """Get a value from the dictionary and verify it's the expected type."""
106
106
  if (value := d.get(key)) is None:
107
107
  return None
108
- if not isinstance(value, expected_type):
108
+ if not isinstance(value, expected_type) or (
109
+ # Special case: bool is a subclass of int, but TOML distinguishes the two
110
+ expected_type is int and isinstance(value, bool)
111
+ ):
109
112
  raise PylockValidationError(
110
113
  f"Unexpected type {type(value).__name__} "
111
114
  f"(expected {expected_type.__name__})",