Skip to content

Commit 06d6509

Browse files
committed
Test multiple markers, arg validation, fix command print
1 parent 678f496 commit 06d6509

File tree

5 files changed

+213
-21
lines changed

5 files changed

+213
-21
lines changed

README.rst

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ Windows and ``pythonX.Y -m piptools compile`` on other systems.
6060
``pip-compile`` should be run from the same virtual environment as your
6161
project so conditional dependencies that require a specific Python version,
6262
or other environment markers, resolve relative to your project's
63-
environment.
63+
environment. If you need to resolve dependencies for a different environment,
64+
see `Cross-environment`_ for some solutions.
6465

6566
**Note**: If ``pip-compile`` finds an existing ``requirements.txt`` file that
6667
fulfils the dependencies then no changes will be made, even if updates are
@@ -529,6 +530,8 @@ We suggest to use the ``{env}-requirements.txt`` format
529530
(ex: ``win32-py3.7-requirements.txt``, ``macos-py3.10-requirements.txt``, etc.).
530531

531532

533+
.. _Cross-environment:
534+
532535
Cross-environment usage of ``requirements.in``/``requirements.txt`` and ``pip-compile``
533536
=======================================================================================
534537

@@ -559,6 +562,22 @@ when targetting a different environment so the environment is fully defined.
559562

560563
.. _PEP 508 environment markers: https://www.python.org/dev/peps/pep-0508/#environment-markers
561564

565+
For example, if you wanted to evaluate ``requirements.in`` for a typical Linux machine:
566+
567+
.. code-block:: bash
568+
569+
$ pip-compile requirements.in \
570+
--override-environment os_name posix \
571+
--override-environment sys_platform linux \
572+
--override-environment platform_machine x86_64 \
573+
--override-environment platform_python_implementation CPython \
574+
--override-environment platform_release '' \
575+
--override-environment platform_version '' \
576+
--override-environment python_version 3.11 \
577+
--override-environment python_full_version 3.11.0 \
578+
--override-environment implementation_name cpython \
579+
--override-environment implementation_version 3.11.0
580+
562581
Other useful tools
563582
==================
564583

piptools/scripts/compile.py

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@
88
from typing import IO, Any, BinaryIO, cast
99

1010
import click
11-
from build import BuildBackendException
1211
from build.util import project_wheel_metadata
1312
from click.utils import LazyFile, safecall
1413
from pip._internal.commands import create_command
1514
from pip._internal.req import InstallRequirement
1615
from pip._internal.req.constructors import install_req_from_line
1716
from pip._internal.utils.misc import redact_auth_from_url
1817

18+
from build import BuildBackendException
19+
1920
from .._compat import parse_requirements
2021
from ..cache import DependencyCache
2122
from ..exceptions import NoCandidateFound, PipToolsError
@@ -25,12 +26,14 @@
2526
from ..repositories.base import BaseRepository
2627
from ..resolver import BacktrackingResolver, LegacyResolver
2728
from ..utils import (
29+
PEP508_ENVIRONMENT_MARKERS,
2830
UNSAFE_PACKAGES,
2931
dedup,
3032
drop_extras,
3133
is_pinned_requirement,
3234
key_from_ireq,
3335
parse_requirements_from_wheel_metadata,
36+
validate_environment_overrides,
3437
)
3538
from ..writer import OutputWriter
3639

@@ -308,6 +311,7 @@ def _determine_linesep(
308311
type=(str, str),
309312
help="Specify an environment marker to override."
310313
"This can be used to fetch requirements for a different platform",
314+
callback=validate_environment_overrides,
311315
)
312316
def cli(
313317
ctx: click.Context,
@@ -347,7 +351,7 @@ def cli(
347351
emit_index_url: bool,
348352
emit_options: bool,
349353
unsafe_package: tuple[str, ...],
350-
override_environment: tuple[tuple[str, str], ...],
354+
override_environment: dict[str, str],
351355
) -> None:
352356
"""
353357
Compiles requirements.txt from requirements.in, pyproject.toml, setup.cfg,
@@ -447,20 +451,7 @@ def cli(
447451

448452
def overriden_environment() -> dict[str, str]:
449453
return {
450-
k: env_dict.get(k, default_env[k])
451-
for k in [
452-
"implementation_name",
453-
"implementation_version",
454-
"os_name",
455-
"platform_machine",
456-
"platform_release",
457-
"platform_system",
458-
"platform_version",
459-
"python_full_version",
460-
"platform_python_implementation",
461-
"python_version",
462-
"sys_platform",
463-
]
454+
k: env_dict.get(k, default_env[k]) for k in PEP508_ENVIRONMENT_MARKERS
464455
}
465456

466457
pip._vendor.packaging.markers.default_environment = overriden_environment

piptools/utils.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,25 @@
4747
"--no-reuse-hashes",
4848
}
4949

50+
# Only certain environment markers are allowed in requirement specifications.
51+
# Validate that overrides use a valid marker in order to provide better debug
52+
# feedback to the user.
53+
PEP508_ENVIRONMENT_MARKERS = [
54+
"os_name",
55+
"sys_platform",
56+
"platform_machine",
57+
"platform_python_implementation",
58+
"platform_release",
59+
"platform_system",
60+
"platform_version",
61+
"python_version",
62+
"python_full_version",
63+
"implementation_name",
64+
"implementation_version",
65+
# Note that 'extra' is omitted here because that should be set at the wheel
66+
# level, not the runtime level.
67+
]
68+
5069

5170
def key_from_ireq(ireq: InstallRequirement) -> str:
5271
"""Get a standardized key for an InstallRequirement."""
@@ -389,13 +408,18 @@ def get_compile_command(click_ctx: click.Context) -> str:
389408
else:
390409
if isinstance(val, str) and is_url(val):
391410
val = redact_auth_from_url(val)
411+
392412
if option.name == "pip_args_str":
393413
# shlex.quote() would produce functional but noisily quoted results,
394414
# e.g. --pip-args='--cache-dir='"'"'/tmp/with spaces'"'"''
395415
# Instead, we try to get more legible quoting via repr:
396-
left_args.append(f"{option_long_name}={repr(val)}")
416+
quoted_val = repr(val)
417+
elif isinstance(val, (tuple, list)):
418+
quoted_val = " ".join([shlex.quote(str(v)) for v in val])
397419
else:
398-
left_args.append(f"{option_long_name}={shlex.quote(str(val))}")
420+
quoted_val = shlex.quote(str(val))
421+
422+
left_args.append(f"{option_long_name}={quoted_val}")
399423

400424
return " ".join(["pip-compile", *sorted(left_args), *sorted(right_args)])
401425

@@ -522,3 +546,16 @@ def parse_requirements_from_wheel_metadata(
522546
markers=parts.markers,
523547
extras=parts.extras,
524548
)
549+
550+
551+
def validate_environment_overrides(
552+
_ctx: click.Context,
553+
_param: str,
554+
value: list[tuple[str, str]],
555+
) -> list[tuple[str, str]]:
556+
for key, _ in value:
557+
if key not in PEP508_ENVIRONMENT_MARKERS:
558+
raise click.BadParameter(
559+
f"Override key '{key}' must be one of " f"{PEP508_ENVIRONMENT_MARKERS}!"
560+
)
561+
return value

tests/test_cli_compile.py

Lines changed: 133 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2973,7 +2973,7 @@ def test_cross_fetch_top_level(fake_dists, runner, platform):
29732973
"""
29742974
)
29752975

2976-
assert out.exit_code == 0, out
2976+
assert out.exit_code == 0, out.stderr
29772977
assert expected_output == out.stdout
29782978

29792979

@@ -3041,5 +3041,136 @@ def test_cross_fetch_transitive_deps(
30413041
+ expected_output
30423042
)
30433043

3044-
assert out.exit_code == 0, out
3044+
assert out.exit_code == 0, out.stderr
3045+
assert expected_output == out.stdout
3046+
3047+
3048+
def test_multiple_env_overrides(fake_dists, runner):
3049+
"""
3050+
test passing multiple `--override-environment` evaluates top level
3051+
requirements correctly.
3052+
"""
3053+
3054+
# Use arbitrary values for the markers that aren't reasonable values that
3055+
# might come from the test environment.
3056+
with open("requirements.in", "w") as req_in:
3057+
req_in.write('small-fake-a==0.1 ; sys_platform == "foo"\n')
3058+
req_in.write('small-fake-b==0.2 ; sys_platform == "bar"\n')
3059+
req_in.write('small-fake-c==0.3 ; implementation_name == "baz"\n')
3060+
3061+
out = runner.invoke(
3062+
cli,
3063+
[
3064+
"--output-file",
3065+
"-",
3066+
"--quiet",
3067+
"--find-links",
3068+
fake_dists,
3069+
"--no-annotate",
3070+
"--no-emit-options",
3071+
"--no-header",
3072+
"--override-environment",
3073+
"sys_platform",
3074+
"foo",
3075+
"--override-environment",
3076+
"implementation_name",
3077+
"baz",
3078+
],
3079+
)
3080+
3081+
expected_output = dedent(
3082+
"""\
3083+
small-fake-a==0.1 ; sys_platform == "foo"
3084+
small-fake-c==0.3 ; implementation_name == "baz"
3085+
"""
3086+
)
3087+
3088+
assert out.exit_code == 0, out.stderr
30453089
assert expected_output == out.stdout
3090+
3091+
3092+
@pytest.mark.parametrize(
3093+
"marker",
3094+
(
3095+
"os_name",
3096+
"sys_platform",
3097+
"platform_machine",
3098+
"platform_python_implementation",
3099+
"platform_release",
3100+
"platform_version",
3101+
"python_version",
3102+
"python_full_version",
3103+
"implementation_name",
3104+
"implementation_version",
3105+
),
3106+
)
3107+
def test_all_env_overrides(fake_dists, runner, marker):
3108+
"""
3109+
test that each valid `--override-environment` key can be used to select
3110+
requirements.
3111+
"""
3112+
3113+
# Use arbitrary values for the markers that aren't reasonable values that
3114+
# might come from the test environment.
3115+
with open("requirements.in", "w") as req_in:
3116+
req_in.write(f'small-fake-a==0.1 ; {marker} == "foo"\n')
3117+
req_in.write(f'small-fake-b==0.2 ; {marker} == "bar"\n')
3118+
3119+
for marker_value in ["foo", "bar"]:
3120+
out = runner.invoke(
3121+
cli,
3122+
[
3123+
"--output-file",
3124+
"-",
3125+
"--quiet",
3126+
"--find-links",
3127+
fake_dists,
3128+
"--no-annotate",
3129+
"--no-emit-options",
3130+
"--no-header",
3131+
"--override-environment",
3132+
marker,
3133+
marker_value,
3134+
],
3135+
)
3136+
3137+
if marker_value == "foo":
3138+
expected_output = dedent(
3139+
f"""\
3140+
small-fake-a==0.1 ; {marker} == "foo"
3141+
"""
3142+
)
3143+
else:
3144+
expected_output = dedent(
3145+
f"""\
3146+
small-fake-b==0.2 ; {marker} == "bar"
3147+
"""
3148+
)
3149+
3150+
assert out.exit_code == 0, out.stderr
3151+
assert expected_output == out.stdout
3152+
3153+
3154+
def test_invalid_env_override(runner):
3155+
"""
3156+
test passing an invalid `--override-environment` key triggers an error.
3157+
"""
3158+
3159+
with open("requirements.in", "w"):
3160+
pass
3161+
3162+
out = runner.invoke(
3163+
cli,
3164+
[
3165+
"--output-file",
3166+
"-",
3167+
"--override-environment",
3168+
"foo",
3169+
"bar",
3170+
],
3171+
)
3172+
3173+
expected_error = "Invalid value for '--override-environment'"
3174+
3175+
assert out.exit_code == 2
3176+
assert expected_error in out.stderr

tests/test_utils.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,20 @@ def test_is_url_requirement_filename(caplog, from_line, line):
362362
["--pip-args", "--disable-pip-version-check --isolated"],
363363
"pip-compile --pip-args='--disable-pip-version-check --isolated'",
364364
),
365+
(
366+
["--override-environment", "os_name", "posix"],
367+
"pip-compile --override-environment=os_name posix",
368+
),
369+
# Check that an override value with spaces and an empty override are
370+
# properly escaped.
371+
(
372+
["--override-environment", "platform_version", "multiple words"],
373+
"pip-compile --override-environment=platform_version 'multiple words'",
374+
),
375+
(
376+
["--override-environment", "platform_release", ""],
377+
"pip-compile --override-environment=platform_release ''",
378+
),
365379
pytest.param(
366380
["--extra-index-url", "https://username:password@example.com/"],
367381
"pip-compile --extra-index-url='https://username:****@example.com/'",

0 commit comments

Comments
 (0)