Skip to content

Commit 6ab5393

Browse files
authored
Fix pex3 lock subset. (#2684)
Lock subsetting could crash previously. Also fix `pex3 lock {create,sync,update} --elide-unused-requires-dist`, which would not crash, but would fail to elide some dists. Fixes #2683
1 parent 16932ad commit 6ab5393

File tree

7 files changed

+5796
-41
lines changed

7 files changed

+5796
-41
lines changed

CHANGES.md

+17
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
# Release Notes
22

3+
## 2.33.1
4+
5+
This release fixes a bug in both `pex3 lock subset` and
6+
`pex3 lock {create,sync,update} --elide-unused-requires-dist` for `--style universal` locks whose
7+
locked requirements have dependencies de-selected by the following environment markers:
8+
+ `os_name`
9+
+ `platform_system`
10+
+ `sys_platform`
11+
+ `python_version`
12+
+ `python_full_version`
13+
14+
The first three could lead to errors when the universal lock was generated with `--target-system`s
15+
and the last two could lead to errors when the universal lock was generated with
16+
`--interpreter-constraint`.
17+
18+
* Fix `pex3 lock subset`. (#2684)
19+
320
## 2.33.0
421

522
This release adds support for Pip 25.0.1.

pex/cli/commands/lock.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -1447,7 +1447,14 @@ def _subset(self):
14471447
constraint=constraint_by_project_name[req.project_name],
14481448
)
14491449
)
1450-
to_resolve.extend(requires_dist.filter_dependencies(req, locked_req))
1450+
to_resolve.extend(
1451+
requires_dist.filter_dependencies(
1452+
req,
1453+
locked_req,
1454+
requires_python=lock_file.requires_python,
1455+
target_systems=lock_file.target_systems,
1456+
)
1457+
)
14511458

14521459
resolve_subsets.append(
14531460
attr.evolve(

pex/resolve/lockfile/model.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,12 @@ def extract_requirement(req):
121121
overridden=SortedTuple(overridden),
122122
locked_resolves=SortedTuple(
123123
(
124-
requires_dist.remove_unused_requires_dist(resolve_requirements, locked_resolve)
124+
requires_dist.remove_unused_requires_dist(
125+
resolve_requirements,
126+
locked_resolve,
127+
requires_python=requires_python,
128+
target_systems=target_systems,
129+
)
125130
if elide_unused_requires_dist
126131
else locked_resolve
127132
)

pex/resolve/lockfile/requires_dist.py

+198-38
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,97 @@
88

99
from pex.dist_metadata import Requirement
1010
from pex.exceptions import production_assert
11+
from pex.interpreter_constraints import iter_compatible_versions
1112
from pex.orderedset import OrderedSet
13+
from pex.pep_440 import Version
1214
from pex.pep_503 import ProjectName
13-
from pex.resolve.locked_resolve import LockedRequirement, LockedResolve
15+
from pex.resolve.locked_resolve import LockedRequirement, LockedResolve, TargetSystem
1416
from pex.sorted_tuple import SortedTuple
1517
from pex.third_party.packaging.markers import Marker, Variable
16-
from pex.typing import TYPE_CHECKING, cast
18+
from pex.typing import TYPE_CHECKING, Generic, cast
1719

1820
if TYPE_CHECKING:
19-
from typing import Callable, DefaultDict, Dict, Iterable, Iterator, List, Optional, Tuple, Union
21+
from typing import (
22+
Any,
23+
Callable,
24+
DefaultDict,
25+
Dict,
26+
FrozenSet,
27+
Iterable,
28+
Iterator,
29+
List,
30+
Optional,
31+
Tuple,
32+
Type,
33+
TypeVar,
34+
Union,
35+
)
2036

2137
import attr # vendor:skip
2238

23-
EvalExtra = Callable[[ProjectName], bool]
39+
EvalMarker = Callable[["MarkerEnv"], bool]
2440
else:
2541
from pex.third_party import attr
2642

2743

44+
@attr.s(frozen=True)
45+
class MarkerEnv(object):
46+
@classmethod
47+
def create(
48+
cls,
49+
extras, # type: Iterable[str]
50+
requires_python, # type: Iterable[str]
51+
target_systems, # type: Iterable[TargetSystem.Value]
52+
):
53+
# type: (...) -> MarkerEnv
54+
55+
python_full_versions = (
56+
list(iter_compatible_versions(requires_python)) if requires_python else []
57+
)
58+
python_versions = OrderedSet(
59+
python_full_version[:2] for python_full_version in python_full_versions
60+
)
61+
62+
os_names = []
63+
platform_systems = []
64+
sys_platforms = []
65+
for target_system in target_systems:
66+
if target_system is TargetSystem.LINUX:
67+
os_names.append("posix")
68+
platform_systems.append("Linux")
69+
sys_platforms.append("linux")
70+
sys_platforms.append("linux2")
71+
elif target_system is TargetSystem.MAC:
72+
os_names.append("posix")
73+
platform_systems.append("Darwin")
74+
sys_platforms.append("darwin")
75+
elif target_system is TargetSystem.WINDOWS:
76+
os_names.append("nt")
77+
platform_systems.append("Windows")
78+
sys_platforms.append("win32")
79+
80+
return cls(
81+
extras=frozenset(ProjectName(extra) for extra in (extras or [""])),
82+
os_names=frozenset(os_names),
83+
platform_systems=frozenset(platform_systems),
84+
sys_platforms=frozenset(sys_platforms),
85+
python_versions=frozenset(
86+
Version(".".join(map(str, python_version))) for python_version in python_versions
87+
),
88+
python_full_versions=frozenset(
89+
Version(".".join(map(str, python_full_version)))
90+
for python_full_version in python_full_versions
91+
),
92+
)
93+
94+
extras = attr.ib() # type: FrozenSet[ProjectName]
95+
os_names = attr.ib() # type: FrozenSet[str]
96+
platform_systems = attr.ib() # type: FrozenSet[str]
97+
sys_platforms = attr.ib() # type: FrozenSet[str]
98+
python_versions = attr.ib() # type: FrozenSet[Version]
99+
python_full_versions = attr.ib() # type: FrozenSet[Version]
100+
101+
28102
_OPERATORS = {
29103
"in": lambda lhs, rhs: lhs in rhs,
30104
"not in": lambda lhs, rhs: lhs not in rhs,
@@ -39,26 +113,110 @@
39113

40114
class _Op(object):
41115
def __init__(self, lhs):
42-
self.lhs = lhs # type: EvalExtra
43-
self.rhs = None # type: Optional[EvalExtra]
116+
self.lhs = lhs # type: EvalMarker
117+
self.rhs = None # type: Optional[EvalMarker]
44118

45119

46120
class _And(_Op):
47-
def __call__(self, extra):
48-
# type: (ProjectName) -> bool
121+
def __call__(self, marker_env):
122+
# type: (MarkerEnv) -> bool
49123
production_assert(self.rhs is not None)
50-
return self.lhs(extra) and cast("EvalExtra", self.rhs)(extra)
124+
return self.lhs(marker_env) and cast("EvalMarker", self.rhs)(marker_env)
51125

52126

53127
class _Or(_Op):
54-
def __call__(self, extra):
55-
# type: (ProjectName) -> bool
128+
def __call__(self, marker_env):
129+
# type: (MarkerEnv) -> bool
56130
production_assert(self.rhs is not None)
57-
return self.lhs(extra) or cast("EvalExtra", self.rhs)(extra)
131+
return self.lhs(marker_env) or cast("EvalMarker", self.rhs)(marker_env)
132+
58133

134+
def _get_values_func(marker):
135+
# type: (str) -> Optional[Tuple[Callable[[MarkerEnv], FrozenSet], Type]]
59136

60-
def _parse_extra_item(
61-
stack, # type: List[EvalExtra]
137+
if marker == "extra":
138+
return lambda marker_env: marker_env.extras, ProjectName
139+
elif marker == "os_name":
140+
return lambda marker_env: marker_env.os_names, str
141+
elif marker == "platform_system":
142+
return lambda marker_env: marker_env.platform_systems, str
143+
elif marker == "sys_platform":
144+
return lambda marker_env: marker_env.sys_platforms, str
145+
elif marker == "python_version":
146+
return lambda marker_env: marker_env.python_versions, Version
147+
elif marker == "python_full_version":
148+
return lambda marker_env: marker_env.python_full_versions, Version
149+
return None
150+
151+
152+
if TYPE_CHECKING:
153+
_T = TypeVar("_T")
154+
155+
156+
class EvalMarkerFunc(Generic["_T"]):
157+
@classmethod
158+
def create(
159+
cls,
160+
lhs, # type: Any
161+
op, # type: Any
162+
rhs, # type: Any
163+
):
164+
# type: (...) -> Callable[[MarkerEnv], bool]
165+
166+
if isinstance(lhs, Variable):
167+
value = _get_values_func(str(lhs))
168+
if value:
169+
get_values, operand_type = value
170+
return cls(
171+
get_values=get_values,
172+
op=_OPERATORS[str(op)],
173+
operand_type=operand_type,
174+
rhs=operand_type(rhs),
175+
)
176+
177+
if isinstance(rhs, Variable):
178+
value = _get_values_func(str(rhs))
179+
if value:
180+
get_values, operand_type = value
181+
return cls(
182+
get_values=get_values,
183+
op=_OPERATORS[str(op)],
184+
operand_type=operand_type,
185+
lhs=operand_type(lhs),
186+
)
187+
188+
return lambda _: True
189+
190+
def __init__(
191+
self,
192+
get_values, # type: Callable[[MarkerEnv], Iterable[_T]]
193+
op, # type: Callable[[_T, _T], bool]
194+
operand_type, # type: Callable[[Any], _T]
195+
lhs=None, # type: Optional[_T]
196+
rhs=None, # type: Optional[_T]
197+
):
198+
# type: (...) -> None
199+
200+
self._get_values = get_values
201+
if lhs is not None:
202+
self._func = lambda value: op(cast("_T", lhs), operand_type(value))
203+
elif rhs is not None:
204+
self._func = lambda value: op(operand_type(value), cast("_T", rhs))
205+
else:
206+
raise ValueError(
207+
"Must be called with exactly one of lhs or rhs but not both. "
208+
"Given lhs={lhs} and rhs={rhs}".format(lhs=lhs, rhs=rhs)
209+
)
210+
211+
def __call__(self, marker_env):
212+
# type: (MarkerEnv) -> bool
213+
214+
values = self._get_values(marker_env)
215+
return any(map(self._func, values)) if values else True
216+
217+
218+
def _parse_marker_item(
219+
stack, # type: List[EvalMarker]
62220
item, # type: Union[str, List, Tuple]
63221
marker, # type: Marker
64222
):
@@ -70,16 +228,10 @@ def _parse_extra_item(
70228
stack.append(_Or(stack.pop()))
71229
elif isinstance(item, list):
72230
for element in item:
73-
_parse_extra_item(stack, element, marker)
231+
_parse_marker_item(stack, element, marker)
74232
elif isinstance(item, tuple):
75233
lhs, op, rhs = item
76-
if isinstance(lhs, Variable) and "extra" == str(lhs):
77-
check = lambda extra: _OPERATORS[str(op)](extra, ProjectName(str(rhs)))
78-
elif isinstance(rhs, Variable) and "extra" == str(rhs):
79-
check = lambda extra: _OPERATORS[str(op)](extra, ProjectName(str(lhs)))
80-
else:
81-
# Any other condition could potentially be true.
82-
check = lambda _: True
234+
check = EvalMarkerFunc.create(lhs, op, rhs)
83235
if stack:
84236
production_assert(isinstance(stack[-1], _Op))
85237
cast(_Op, stack[-1]).rhs = check
@@ -89,47 +241,53 @@ def _parse_extra_item(
89241
raise ValueError("Marker is invalid: {marker}".format(marker=marker))
90242

91243

92-
def _parse_extra_check(marker):
93-
# type: (Marker) -> EvalExtra
94-
checks = [] # type: List[EvalExtra]
244+
def _parse_marker_check(marker):
245+
# type: (Marker) -> EvalMarker
246+
checks = [] # type: List[EvalMarker]
95247
for item in marker._markers:
96-
_parse_extra_item(checks, item, marker)
248+
_parse_marker_item(checks, item, marker)
97249
production_assert(len(checks) == 1)
98250
return checks[0]
99251

100252

101-
_EXTRA_CHECKS = {} # type: Dict[str, EvalExtra]
253+
_MARKER_CHECKS = {} # type: Dict[str, EvalMarker]
102254

103255

104-
def _parse_marker_for_extra_check(marker):
105-
# type: (Marker) -> EvalExtra
256+
def _parse_marker(marker):
257+
# type: (Marker) -> EvalMarker
106258
maker_str = str(marker)
107-
eval_extra = _EXTRA_CHECKS.get(maker_str)
108-
if not eval_extra:
109-
eval_extra = _parse_extra_check(marker)
110-
_EXTRA_CHECKS[maker_str] = eval_extra
111-
return eval_extra
259+
eval_marker = _MARKER_CHECKS.get(maker_str)
260+
if not eval_marker:
261+
eval_marker = _parse_marker_check(marker)
262+
_MARKER_CHECKS[maker_str] = eval_marker
263+
return eval_marker
112264

113265

114266
def filter_dependencies(
115267
requirement, # type: Requirement
116268
locked_requirement, # type: LockedRequirement
269+
requires_python=(), # type: Iterable[str]
270+
target_systems=(), # type: Iterable[TargetSystem.Value]
117271
):
118272
# type: (...) -> Iterator[Requirement]
119273

120-
extras = requirement.extras or [""]
274+
marker_env = MarkerEnv.create(
275+
extras=requirement.extras, requires_python=requires_python, target_systems=target_systems
276+
)
121277
for dep in locked_requirement.requires_dists:
122278
if not dep.marker:
123279
yield dep
124280
else:
125-
eval_extra = _parse_marker_for_extra_check(dep.marker)
126-
if any(eval_extra(ProjectName(extra)) for extra in extras):
281+
eval_marker = _parse_marker(dep.marker)
282+
if eval_marker(marker_env):
127283
yield dep
128284

129285

130286
def remove_unused_requires_dist(
131287
resolve_requirements, # type: Iterable[Requirement]
132288
locked_resolve, # type: LockedResolve
289+
requires_python=(), # type: Iterable[str]
290+
target_systems=(), # type: Iterable[TargetSystem.Value]
133291
):
134292
# type: (...) -> LockedResolve
135293

@@ -151,7 +309,9 @@ def remove_unused_requires_dist(
151309
if not locked_req:
152310
continue
153311

154-
for dep in filter_dependencies(requirement, locked_req):
312+
for dep in filter_dependencies(
313+
requirement, locked_req, requires_python=requires_python, target_systems=target_systems
314+
):
155315
if dep.project_name in locked_req_by_project_name:
156316
requires_dist_by_locked_req[locked_req].add(dep)
157317
requirements.append(dep)

pex/version.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# Copyright 2015 Pex project contributors.
22
# Licensed under the Apache License, Version 2.0 (see LICENSE).
33

4-
__version__ = "2.33.0"
4+
__version__ = "2.33.1"

0 commit comments

Comments
 (0)