Skip to content

Commit 149e91a

Browse files
authored
Fix Pex locking for source requirements. (#2750)
Previously, locking VCS requirements would fail for projects with non-normalized project names, e.g.: PySocks vs its normalized form of pysocks. Additionally, locking would fail when the requirements were specified at least in part via requirements files (`-r` / `--requirements`) and there was either a local project or a VCS requirement contained in the requirements files.
1 parent dff0a23 commit 149e91a

File tree

9 files changed

+182
-56
lines changed

9 files changed

+182
-56
lines changed

CHANGES.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Release Notes
22

3+
## 2.36.1
4+
5+
This release fixes a few issues with creating Pex locks when source requirements were involved.
6+
7+
Previously, locking VCS requirements would fail for projects with non-normalized project names,
8+
e.g.: PySocks vs its normalized form of pysocks.
9+
10+
Additionally, locking would fail when the requirements were specified at least in part via
11+
requirements files (`-r` / `--requirements`) and there was either a local project or a VCS
12+
requirement contained in the requirements files.
13+
14+
* Fix Pex locking for source requirements. (#2750)
15+
316
## 2.36.0
417

518
This release brings support for creating PEXes that target Android. The Pip 25.1 upgrade in Pex

build-backend/pex_build/setuptools/build.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919

2020
from pex import hashing, toml, windows
2121
from pex.common import open_zip, safe_copy, safe_mkdir, temporary_dir
22-
from pex.orderedset import OrderedSet
2322
from pex.pep_376 import Hash, InstalledFile, Record
2423
from pex.typing import cast
2524
from pex.version import __version__
@@ -28,6 +27,15 @@
2827
from typing import Any, Dict, List, Optional
2928

3029

30+
def get_requires_for_build_sdist(config_settings=None):
31+
# type: (Optional[Dict[str, Any]]) -> List[str]
32+
33+
# N.B.: The default setuptools implementation would eventually return nothing, but only after
34+
# running code that can temporarily pollute our project directory, foiling concurrent test runs;
35+
# so we short-circuit the answer here. Faster and safer.
36+
return []
37+
38+
3139
def build_sdist(
3240
sdist_directory, # type: str
3341
config_settings=None, # type: Optional[Dict[str, Any]]
@@ -77,17 +85,15 @@ def prepare_metadata_for_build_editable(
7785
def get_requires_for_build_wheel(config_settings=None):
7886
# type: (Optional[Dict[str, Any]]) -> List[str]
7987

80-
reqs = OrderedSet(
81-
setuptools.build_meta.get_requires_for_build_wheel(config_settings=config_settings)
82-
) # type: OrderedSet[str]
83-
if pex_build.INCLUDE_DOCS:
84-
pyproject_data = toml.load("pyproject.toml")
85-
return cast(
86-
"List[str]",
87-
# Here we skip any included dependency groups and just grab the direct doc requirements.
88-
[req for req in pyproject_data["dependency-groups"]["docs"] if isinstance(req, str)],
89-
)
90-
return list(reqs)
88+
if not pex_build.INCLUDE_DOCS:
89+
return []
90+
91+
pyproject_data = toml.load("pyproject.toml")
92+
return cast(
93+
"List[str]",
94+
# Here we skip any included dependency groups and just grab the direct doc requirements.
95+
[req for req in pyproject_data["dependency-groups"]["docs"] if isinstance(req, str)],
96+
)
9197

9298

9399
def prepare_metadata_for_build_wheel(

pex/pip/vcs.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,16 @@ def _find_built_source_dist(
3434
# encoded in: `pip._internal.req.req_install.InstallRequirement.archive`.
3535

3636
listing = os.listdir(build_dir)
37-
pattern = re.compile(
38-
r"{project_name}-(?P<version>.+)\.zip".format(
39-
project_name=project_name.normalized.replace("-", "[-_.]+")
40-
)
41-
)
37+
pattern = re.compile(r"(?P<project_name>.+)-(?P<version>.+)\.zip")
4238
for name in listing:
4339
match = pattern.match(name)
44-
if match and Version(match.group("version")) == version:
45-
return os.path.join(build_dir, name)
40+
if not match:
41+
continue
42+
if ProjectName(match.group("project_name")) != project_name:
43+
continue
44+
if Version(match.group("version")) != version:
45+
continue
46+
return os.path.join(build_dir, name)
4647

4748
return Error(
4849
"Expected to find built sdist for {project_name} {version} in {build_dir} but only found:\n"

pex/resolve/locker.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ def build_result(self, line):
216216
# type: (str) -> Optional[ArtifactBuildResult]
217217

218218
match = re.search(
219-
r"Source in .+ has version (?P<version>[^\s]+), which satisfies requirement "
219+
r"Source in .+ has version (?P<version>\S+), which satisfies requirement "
220220
r"(?P<requirement>.+) .*from {url}".format(url=re.escape(self._artifact_url.raw_url)),
221221
line,
222222
)
@@ -434,7 +434,7 @@ def analyze(self, line):
434434
return self.Continue()
435435

436436
match = re.search(
437-
r"Fetched page (?P<index_url>[^\s]+) as (?P<content_type>{content_types})".format(
437+
r"Fetched page (?P<index_url>.+\S) as (?P<content_type>{content_types})".format(
438438
content_types="|".join(
439439
re.escape(content_type) for content_type in self._fingerprint_service.accept
440440
)
@@ -447,18 +447,20 @@ def analyze(self, line):
447447
)
448448
return self.Continue()
449449

450-
match = re.search(r"Looking up \"(?P<url>[^\s]+)\" in the cache", line)
450+
match = re.search(r"Looking up \"(?P<url>.+\S)\" in the cache", line)
451451
if match:
452452
self._maybe_record_wheel(match.group("url"))
453453

454-
match = re.search(r"Processing (?P<path>.*\.(whl|tar\.(gz|bz2|xz)|tgz|tbz2|txz|zip))", line)
454+
match = re.search(r"Processing (?P<path>.+\.(whl|tar\.(gz|bz2|xz)|tgz|tbz2|txz|zip))", line)
455455
if match:
456456
self._maybe_record_wheel(
457457
"file://{path}".format(path=os.path.abspath(match.group("path")))
458458
)
459459

460460
match = re.search(
461-
r"Added (?P<requirement>.+) from (?P<url>[^\s]+) .*to build tracker",
461+
r"Added (?P<requirement>.+) from (?P<url>.+\S) \(from", line
462+
) or re.search(
463+
r"Added (?P<requirement>.+) from (?P<url>.+\S) to build tracker",
462464
line,
463465
)
464466
if match:
@@ -478,13 +480,15 @@ def analyze(self, line):
478480
)
479481
return self.Continue()
480482

481-
match = re.search(r"Added (?P<file_url>file:.+) to build tracker", line)
483+
match = re.search(r"Added (?P<file_url>file:.+\S) \(from", line) or re.search(
484+
r"Added (?P<file_url>file:.+\S) to build tracker", line
485+
)
482486
if match:
483487
file_url = match.group("file_url")
484488
self._artifact_build_observer = ArtifactBuildObserver(
485489
done_building_patterns=(
486490
re.compile(
487-
r"Removed .+ from {file_url} from build tracker".format(
491+
r"Removed .+ from {file_url} (?:.* )?from build tracker".format(
488492
file_url=re.escape(file_url)
489493
)
490494
),
@@ -503,7 +507,7 @@ def analyze(self, line):
503507
return self.Continue()
504508

505509
if self.style in (LockStyle.SOURCES, LockStyle.UNIVERSAL):
506-
match = re.search(r"Found link (?P<url>[^\s]+)(?: \(from .*\))?, version: ", line)
510+
match = re.search(r"Found link (?P<url>\S+)(?: \(from .*\))?, version: ", line)
507511
if match:
508512
url = self.parse_url_and_maybe_record_fingerprint(match.group("url"))
509513
pin, partial_artifact = self._extract_resolve_data(url)

pex/version.py

Lines changed: 1 addition & 1 deletion
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.36.0"
4+
__version__ = "2.36.1"

tests/integration/cli/commands/test_issue_1665.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,4 @@ def assert_lock(*extra_lock_args, **extra_popen_args):
4545
cwd = os.path.join(str(tmpdir), "cwd")
4646
tmpdir = os.path.join(cwd, ".tmp")
4747
os.makedirs(tmpdir)
48-
assert_lock("--tmpdir", ".tmp", cwd=cwd)
48+
assert_lock(cwd=cwd)
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Copyright 2025 Pex project contributors.
2+
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3+
4+
from __future__ import absolute_import, print_function
5+
6+
import difflib
7+
import filecmp
8+
import os
9+
10+
import pytest
11+
12+
from pex.pip.version import PipVersion
13+
from testing.cli import run_pex3
14+
from testing.pytest_utils.tmp import Tempdir
15+
16+
17+
def diff(
18+
file1, # type: str
19+
file2, # type: str
20+
):
21+
# type: (...) -> str
22+
23+
with open(file1) as fp1, open(file2) as fp2:
24+
return os.linesep.join(
25+
difflib.context_diff(fp1.readlines(), fp2.readlines(), fp1.name, fp2.name)
26+
)
27+
28+
29+
def assert_locks_match(
30+
tmpdir, # type: Tempdir
31+
*requirements # type: str
32+
):
33+
# type: (...) -> None
34+
35+
lock1 = tmpdir.join("lock1.json")
36+
run_pex3(
37+
"lock",
38+
"create",
39+
"--pip-version",
40+
"latest-compatible",
41+
"-o",
42+
lock1,
43+
"--indent",
44+
"2",
45+
*requirements
46+
).assert_success()
47+
48+
requirements_file = tmpdir.join("requirements.txt")
49+
with open(requirements_file, "w") as fp:
50+
for requirement in requirements:
51+
print(requirement, file=fp)
52+
53+
lock2 = tmpdir.join("lock2.json")
54+
run_pex3(
55+
"lock",
56+
"create",
57+
"--pip-version",
58+
"latest-compatible",
59+
"-o",
60+
lock2,
61+
"--indent",
62+
"2",
63+
"-r",
64+
requirements_file,
65+
).assert_success()
66+
67+
assert filecmp.cmp(lock1, lock2, shallow=False), diff(lock1, lock2)
68+
69+
70+
def test_lock_by_name(tmpdir):
71+
# type: (Tempdir) -> None
72+
73+
assert_locks_match(tmpdir, "cowsay<6")
74+
75+
76+
def test_lock_vcs(tmpdir):
77+
# type: (Tempdir) -> None
78+
79+
assert_locks_match(
80+
tmpdir, "ansicolors @ git+https://github.com/jonathaneunice/colors.git@c965f5b9"
81+
)
82+
83+
84+
@pytest.mark.skipif(
85+
PipVersion.LATEST_COMPATIBLE is PipVersion.VENDORED,
86+
reason="Vendored Pip cannot handle modern pyproject.toml with heterogeneous arrays.",
87+
)
88+
def test_lock_local_project(
89+
tmpdir, # type: Tempdir
90+
pex_project_dir, # type: str
91+
):
92+
# type: (...) -> None
93+
94+
assert_locks_match(tmpdir, pex_project_dir)
95+
96+
97+
def test_lock_mixed(
98+
tmpdir, # type: Tempdir
99+
pex_project_dir, # type: str
100+
):
101+
# type: (...) -> None
102+
103+
requirements = [
104+
"cowsay<6",
105+
"ansicolors @ git+https://github.com/jonathaneunice/colors.git@c965f5b9",
106+
]
107+
# N.B.: Vendored Pip cannot handle modern pyproject.toml with heterogeneous arrays, which ours
108+
# uses.
109+
if PipVersion.LATEST_COMPATIBLE is not PipVersion.VENDORED:
110+
requirements.append(pex_project_dir)
111+
112+
assert_locks_match(tmpdir, *requirements)

tests/integration/conftest.py

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import pytest
1010

1111
from pex.atomic_directory import atomic_directory
12-
from pex.common import temporary_dir
1312
from pex.interpreter import PythonInterpreter
1413
from pex.os import WINDOWS
1514
from pex.pip.version import PipVersion
@@ -19,7 +18,7 @@
1918
from testing.mitmproxy import Proxy
2019

2120
if TYPE_CHECKING:
22-
from typing import Any, Callable, Iterator
21+
from typing import Any, Callable
2322

2423

2524
@pytest.fixture(scope="session")
@@ -92,18 +91,6 @@ def pex_bdist(
9291
return wheels[0]
9392

9493

95-
@pytest.fixture
96-
def tmp_workdir():
97-
# type: () -> Iterator[str]
98-
cwd = os.getcwd()
99-
with temporary_dir() as tmpdir:
100-
os.chdir(tmpdir)
101-
try:
102-
yield os.path.realpath(tmpdir)
103-
finally:
104-
os.chdir(cwd)
105-
106-
10794
@pytest.fixture(scope="session")
10895
def mitmdump_venv(shared_integration_test_tmpdir):
10996
# type: (str) -> Virtualenv

tests/integration/test_integration.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
from testing.pep_427 import get_installable_type_flag
6060
from testing.pip import skip_if_only_vendored_pip_supported
6161
from testing.pytest_utils import IS_CI
62+
from testing.pytest_utils.tmp import Tempdir
6263

6364
if TYPE_CHECKING:
6465
from typing import Any, Callable, Iterator, List, Optional, Tuple
@@ -1552,8 +1553,9 @@ def test_unzip_mode(tmpdir):
15521553
assert "PEXWarning: The `PEX_UNZIP` env var is deprecated." in error2.decode("utf-8")
15531554

15541555

1555-
def test_tmpdir_absolute(tmp_workdir):
1556-
# type: (str) -> None
1556+
def test_tmpdir_absolute(tmpdir):
1557+
# type: (Tempdir) -> None
1558+
tmp_workdir = str(tmpdir)
15571559
result = run_pex_command(
15581560
args=[
15591561
"--tmpdir",
@@ -1569,26 +1571,27 @@ def test_tmpdir_absolute(tmp_workdir):
15691571
print(tempfile.gettempdir())
15701572
"""
15711573
),
1572-
]
1574+
],
1575+
cwd=tmp_workdir,
15731576
)
15741577
result.assert_success()
15751578
assert [tmp_workdir, tmp_workdir] == result.output.strip().splitlines()
15761579

15771580

1578-
def test_tmpdir_dne(tmp_workdir):
1579-
# type: (str) -> None
1580-
tmpdir_dne = os.path.join(tmp_workdir, ".tmp")
1581-
result = run_pex_command(args=["--tmpdir", ".tmp", "--", "-c", ""])
1581+
def test_tmpdir_dne(tmpdir):
1582+
# type: (Tempdir) -> None
1583+
tmpdir_dne = tmpdir.join(".tmp")
1584+
result = run_pex_command(args=["--tmpdir", ".tmp", "--", "-c", ""], cwd=str(tmpdir))
15821585
result.assert_failure()
15831586
assert tmpdir_dne in result.error
15841587
assert "does not exist" in result.error
15851588

15861589

1587-
def test_tmpdir_file(tmp_workdir):
1588-
# type: (str) -> None
1589-
tmpdir_file = os.path.join(tmp_workdir, ".tmp")
1590+
def test_tmpdir_file(tmpdir):
1591+
# type: (Tempdir) -> None
1592+
tmpdir_file = tmpdir.join(".tmp")
15901593
touch(tmpdir_file)
1591-
result = run_pex_command(args=["--tmpdir", ".tmp", "--", "-c", ""])
1594+
result = run_pex_command(args=["--tmpdir", ".tmp", "--", "-c", ""], cwd=str(tmpdir))
15921595
result.assert_failure()
15931596
assert tmpdir_file in result.error
15941597
assert "is not a directory" in result.error
@@ -1601,8 +1604,8 @@ def test_tmpdir_file(tmp_workdir):
16011604
)
16021605

16031606

1604-
def test_requirements_network_configuration(proxy, tmp_workdir):
1605-
# type: (Proxy, str) -> None
1607+
def test_requirements_network_configuration(proxy):
1608+
# type: (Proxy) -> None
16061609
def req(
16071610
contents, # type: str
16081611
line_no, # type: int

0 commit comments

Comments
 (0)