Skip to content

Commit 64ebf70

Browse files
gschaffnerchrysle
andcommitted
Fix pip-sync --python-executable evaluating markers for the wrong environment
Co-authored-by: chrysle <[email protected]>
1 parent 5330964 commit 64ebf70

File tree

2 files changed

+89
-6
lines changed

2 files changed

+89
-6
lines changed

piptools/scripts/sync.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
from __future__ import annotations
22

33
import itertools
4+
import json
45
import os
6+
import platform
57
import shlex
68
import shutil
79
import sys
810
from pathlib import Path
11+
from subprocess import run # nosec
912
from typing import cast
1013

1114
import click
1215
from pip._internal.commands import create_command
1316
from pip._internal.commands.install import InstallCommand
1417
from pip._internal.index.package_finder import PackageFinder
1518
from pip._internal.metadata import get_environment
19+
from pip._vendor.packaging.markers import Environment
1620

1721
from .. import sync
1822
from .._compat import Distribution, parse_requirements
@@ -100,6 +104,9 @@ def cli(
100104

101105
if python_executable:
102106
_validate_python_executable(python_executable)
107+
environment = _get_environment(python_executable)
108+
else:
109+
environment = {}
103110

104111
install_command = cast(InstallCommand, create_command("install"))
105112
options, _ = install_command.parse_args([])
@@ -113,7 +120,9 @@ def cli(
113120
)
114121

115122
try:
116-
merged_requirements = sync.merge(requirements, ignore_conflicts=force)
123+
merged_requirements = sync.merge(
124+
requirements, ignore_conflicts=force, environment=environment
125+
)
117126
except PipToolsError as e:
118127
log.error(str(e))
119128
sys.exit(2)
@@ -128,7 +137,9 @@ def cli(
128137
local_only=python_executable is None,
129138
paths=paths,
130139
)
131-
to_install, to_uninstall = sync.diff(merged_requirements, installed_dists)
140+
to_install, to_uninstall = sync.diff(
141+
merged_requirements, installed_dists, environment
142+
)
132143

133144
install_flags = _compose_install_flags(
134145
finder,
@@ -177,6 +188,40 @@ def _validate_python_executable(python_executable: str) -> None:
177188
sys.exit(2)
178189

179190

191+
def _get_environment(python_executable: str) -> Environment:
192+
"""
193+
Return the marker variables of an environment.
194+
"""
195+
# On all platforms, ``json.loads`` supports only UTF-8, UTF-16, and UTF-32.
196+
# Prior to PEP 686, a Python subprocess's ``sys.stdout`` will not always
197+
# default to using one of these, so we must set it.
198+
if platform.system() != "Windows":
199+
# Set it to UTF-8. (This is mostly unnecessary as on most Unix systems
200+
# Python will already default to this; see
201+
# https://peps.python.org/pep-0686/#backward-compatibility.)
202+
subprocess_PYTHONIOENCODING = "utf8"
203+
else:
204+
# Set it to UTF-16 because:
205+
#
206+
# * On Windows, in this situation (i.e. when stdout is a pipe)
207+
# ``sys.stdout``'s encoding will default to CP-1252, which is not
208+
# supported by ``json.loads``.
209+
# * ``pip`` emits mangled output with UTF-8 on Windows; see
210+
# https://github.com/Textualize/rich/issues/2882.
211+
subprocess_PYTHONIOENCODING = "utf16"
212+
213+
return Environment(
214+
**json.loads(
215+
run( # nosec
216+
[python_executable, "-m", "pip", "inspect"],
217+
env={**os.environ, "PYTHONIOENCODING": subprocess_PYTHONIOENCODING},
218+
check=True,
219+
capture_output=True,
220+
).stdout
221+
)["environment"]
222+
)
223+
224+
180225
def _compose_install_flags(
181226
finder: PackageFinder,
182227
no_index: bool,

piptools/sync.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import os
55
import sys
66
import tempfile
7+
from functools import wraps
78
from subprocess import run # nosec
89
from typing import Deque, Iterable, Mapping, ValuesView
910

@@ -40,6 +41,38 @@
4041
]
4142

4243

44+
def patch_match_markers() -> None:
45+
"""
46+
Monkey patches ``pip._internal.req.InstallRequirement.match_markers`` to
47+
allow us to pass environment other than "extra".
48+
"""
49+
50+
@wraps(InstallRequirement.match_markers)
51+
def match_markers(
52+
self: InstallRequirement,
53+
extras_requested: Iterable[str] | None = None,
54+
environment: dict[str, str] = {},
55+
) -> bool:
56+
assert "extra" not in environment
57+
58+
if not extras_requested:
59+
# Provide an extra to safely evaluate the markers
60+
# without matching any extra
61+
extras_requested = ("",)
62+
if self.markers is not None:
63+
return any(
64+
self.markers.evaluate({"extra": extra, **environment})
65+
for extra in extras_requested
66+
)
67+
else:
68+
return True
69+
70+
InstallRequirement.match_markers = match_markers
71+
72+
73+
patch_match_markers()
74+
75+
4376
def dependency_tree(
4477
installed_keys: Mapping[str, Distribution], root_key: str
4578
) -> set[str]:
@@ -93,15 +126,17 @@ def get_dists_to_ignore(installed: Iterable[Distribution]) -> list[str]:
93126

94127

95128
def merge(
96-
requirements: Iterable[InstallRequirement], ignore_conflicts: bool
129+
requirements: Iterable[InstallRequirement],
130+
ignore_conflicts: bool,
131+
environment: dict[str, str] = {},
97132
) -> ValuesView[InstallRequirement]:
98133
by_key: dict[str, InstallRequirement] = {}
99134

100135
for ireq in requirements:
101136
# Limitation: URL requirements are merged by precise string match, so
102137
# "file:///example.zip#egg=example", "file:///example.zip", and
103138
# "example==1.0" will not merge with each other
104-
if ireq.match_markers():
139+
if ireq.match_markers(environment=environment):
105140
key = key_from_ireq(ireq)
106141

107142
if not ignore_conflicts:
@@ -158,6 +193,7 @@ def diff_key_from_req(req: Distribution) -> str:
158193
def diff(
159194
compiled_requirements: Iterable[InstallRequirement],
160195
installed_dists: Iterable[Distribution],
196+
environment: dict[str, str] = {},
161197
) -> tuple[set[InstallRequirement], set[str]]:
162198
"""
163199
Calculate which packages should be installed or uninstalled, given a set
@@ -172,13 +208,15 @@ def diff(
172208
pkgs_to_ignore = get_dists_to_ignore(installed_dists)
173209
for dist in installed_dists:
174210
key = diff_key_from_req(dist)
175-
if key not in requirements_lut or not requirements_lut[key].match_markers():
211+
if key not in requirements_lut or not requirements_lut[key].match_markers(
212+
environment=environment
213+
):
176214
to_uninstall.add(key)
177215
elif requirements_lut[key].specifier.contains(dist.version):
178216
satisfied.add(key)
179217

180218
for key, requirement in requirements_lut.items():
181-
if key not in satisfied and requirement.match_markers():
219+
if key not in satisfied and requirement.match_markers(environment=environment):
182220
to_install.add(requirement)
183221

184222
# Make sure to not uninstall any packages that should be ignored

0 commit comments

Comments
 (0)