Skip to content

Commit e32b08f

Browse files
refactor/docs: improve compile_pip_requirements error message and docs (#2792)
Resolution failure is the most common error from pip-compile, so we should make sure the error message is as clean as it can be. Previously, the output was cluttered with the exception traceback, which makes the actual error hard to see (several nested traceback). The new output shortens it with a nicer message: ``` Checking _main/requirements_lock.txt ERROR: Cannot install requests<2.24 and requests~=2.25.1 because these package versions have conflicting dependencies. ResolutionImpossible: for help visit https://pip.pypa.io/en/latest/topics/dependency-resolution/#dealing-with-dependency-conflicts ``` Fixes #2763 --------- Co-authored-by: Richard Levasseur <[email protected]>
1 parent fe88b23 commit e32b08f

File tree

3 files changed

+105
-47
lines changed

3 files changed

+105
-47
lines changed

docs/pypi-dependencies.md

+35-4
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,40 @@
55

66
Using PyPI packages (aka "pip install") involves two main steps.
77

8-
1. [Installing third party packages](#installing-third-party-packages)
9-
2. [Using third party packages as dependencies](#using-third-party-packages)
8+
1. [Generating requirements file](#generating-requirements-file)
9+
2. [Installing third party packages](#installing-third-party-packages)
10+
3. [Using third party packages as dependencies](#using-third-party-packages)
11+
12+
{#generating-requirements-file}
13+
## Generating requirements file
14+
15+
Generally, when working on a Python project, you'll have some dependencies that themselves have other dependencies. You might also specify dependency bounds instead of specific versions. So you'll need to generate a full list of all transitive dependencies and pinned versions for every dependency.
16+
17+
Typically, you'd have your dependencies specified in `pyproject.toml` or `requirements.in` and generate the full pinned list of dependencies in `requirements_lock.txt`, which you can manage with the `compile_pip_requirements` Bazel rule:
18+
19+
```starlark
20+
load("@rules_python//python:pip.bzl", "compile_pip_requirements")
21+
22+
compile_pip_requirements(
23+
name = "requirements",
24+
src = "requirements.in",
25+
requirements_txt = "requirements_lock.txt",
26+
)
27+
```
28+
29+
This rule generates two targets:
30+
- `bazel run [name].update` will regenerate the `requirements_txt` file
31+
- `bazel test [name]_test` will test that the `requirements_txt` file is up to date
32+
33+
For more documentation, see the API docs under {obj}`@rules_python//python:pip.bzl`.
34+
35+
Once you generate this fully specified list of requirements, you can install the requirements with the instructions in [Installing third party packages](#installing-third-party-packages).
36+
37+
:::{warning}
38+
If you're specifying dependencies in `pyproject.toml`, make sure to include the `[build-system]` configuration, with pinned dependencies. `compile_pip_requirements` will use the build system specified to read your project's metadata, and you might see non-hermetic behavior if you don't pin the build system.
39+
40+
Not specifying `[build-system]` at all will result in using a default `[build-system]` configuration, which uses unpinned versions ([ref](https://peps.python.org/pep-0518/#build-system-table)).
41+
:::
1042

1143
{#installing-third-party-packages}
1244
## Installing third party packages
@@ -27,8 +59,7 @@ pip.parse(
2759
)
2860
use_repo(pip, "my_deps")
2961
```
30-
For more documentation, including how the rules can update/create a requirements
31-
file, see the bzlmod examples under the {gh-path}`examples` folder or the documentation
62+
For more documentation, see the bzlmod examples under the {gh-path}`examples` folder or the documentation
3263
for the {obj}`@rules_python//python/extensions:pip.bzl` extension.
3364

3465
```{note}

python/private/pypi/dependency_resolver/dependency_resolver.py

+69-42
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,17 @@
1515
"Set defaults for the pip-compile command to run it under Bazel"
1616

1717
import atexit
18+
import functools
1819
import os
1920
import shutil
2021
import sys
2122
from pathlib import Path
22-
from typing import Optional, Tuple
23+
from typing import List, Optional, Tuple
2324

2425
import click
2526
import piptools.writer as piptools_writer
27+
from pip._internal.exceptions import DistributionNotFound
28+
from pip._vendor.resolvelib.resolvers import ResolutionImpossible
2629
from piptools.scripts.compile import cli
2730

2831
from python.runfiles import runfiles
@@ -82,15 +85,15 @@ def _locate(bazel_runfiles, file):
8285
@click.command(context_settings={"ignore_unknown_options": True})
8386
@click.option("--src", "srcs", multiple=True, required=True)
8487
@click.argument("requirements_txt")
85-
@click.argument("update_target_label")
88+
@click.argument("target_label_prefix")
8689
@click.option("--requirements-linux")
8790
@click.option("--requirements-darwin")
8891
@click.option("--requirements-windows")
8992
@click.argument("extra_args", nargs=-1, type=click.UNPROCESSED)
9093
def main(
9194
srcs: Tuple[str, ...],
9295
requirements_txt: str,
93-
update_target_label: str,
96+
target_label_prefix: str,
9497
requirements_linux: Optional[str],
9598
requirements_darwin: Optional[str],
9699
requirements_windows: Optional[str],
@@ -152,9 +155,10 @@ def main(
152155
# or shutil.copyfile, as they will fail with OSError: [Errno 18] Invalid cross-device link.
153156
shutil.copy(resolved_requirements_file, requirements_out)
154157

155-
update_command = os.getenv("CUSTOM_COMPILE_COMMAND") or "bazel run %s" % (
156-
update_target_label,
158+
update_command = (
159+
os.getenv("CUSTOM_COMPILE_COMMAND") or f"bazel run {target_label_prefix}.update"
157160
)
161+
test_command = f"bazel test {target_label_prefix}_test"
158162

159163
os.environ["CUSTOM_COMPILE_COMMAND"] = update_command
160164
os.environ["PIP_CONFIG_FILE"] = os.getenv("PIP_CONFIG_FILE") or os.devnull
@@ -168,6 +172,12 @@ def main(
168172
)
169173
argv.extend(extra_args)
170174

175+
_run_pip_compile = functools.partial(
176+
run_pip_compile,
177+
argv,
178+
srcs_relative=srcs_relative,
179+
)
180+
171181
if UPDATE:
172182
print("Updating " + requirements_file_relative)
173183

@@ -187,49 +197,66 @@ def main(
187197
atexit.register(
188198
lambda: shutil.copy(absolute_output_file, requirements_file_tree)
189199
)
190-
cli(argv, standalone_mode=False)
200+
_run_pip_compile(verbose_command=f"{update_command} -- --verbose")
191201
requirements_file_relative_path = Path(requirements_file_relative)
192202
content = requirements_file_relative_path.read_text()
193203
content = content.replace(absolute_path_prefix, "")
194204
requirements_file_relative_path.write_text(content)
195205
else:
196-
# cli will exit(0) on success
197-
try:
198-
print("Checking " + requirements_file)
199-
cli(argv)
200-
print("cli() should exit", file=sys.stderr)
206+
print("Checking " + requirements_file)
207+
sys.stdout.flush()
208+
_run_pip_compile(verbose_command=f"{test_command} --test_arg=--verbose")
209+
golden = open(_locate(bazel_runfiles, requirements_file)).readlines()
210+
out = open(requirements_out).readlines()
211+
out = [line.replace(absolute_path_prefix, "") for line in out]
212+
if golden != out:
213+
import difflib
214+
215+
print("".join(difflib.unified_diff(golden, out)), file=sys.stderr)
216+
print(
217+
f"Lock file out of date. Run '{update_command}' to update.",
218+
file=sys.stderr,
219+
)
220+
sys.exit(1)
221+
222+
223+
def run_pip_compile(
224+
args: List[str],
225+
*,
226+
srcs_relative: List[str],
227+
verbose_command: str,
228+
) -> None:
229+
try:
230+
cli(args, standalone_mode=False)
231+
except DistributionNotFound as e:
232+
if isinstance(e.__cause__, ResolutionImpossible):
233+
# pip logs an informative error to stderr already
234+
# just render the error and exit
235+
print(e)
236+
sys.exit(1)
237+
else:
238+
raise
239+
except SystemExit as e:
240+
if e.code == 0:
241+
return # shouldn't happen, but just in case
242+
elif e.code == 2:
243+
print(
244+
"pip-compile exited with code 2. This means that pip-compile found "
245+
"incompatible requirements or could not find a version that matches "
246+
f"the install requirement in one of {srcs_relative}.\n"
247+
"Try re-running with verbose:\n"
248+
f" {verbose_command}",
249+
file=sys.stderr,
250+
)
251+
sys.exit(1)
252+
else:
253+
print(
254+
f"pip-compile unexpectedly exited with code {e.code}.\n"
255+
"Try re-running with verbose:\n"
256+
f" {verbose_command}",
257+
file=sys.stderr,
258+
)
201259
sys.exit(1)
202-
except SystemExit as e:
203-
if e.code == 2:
204-
print(
205-
"pip-compile exited with code 2. This means that pip-compile found "
206-
"incompatible requirements or could not find a version that matches "
207-
f"the install requirement in one of {srcs_relative}.",
208-
file=sys.stderr,
209-
)
210-
sys.exit(1)
211-
elif e.code == 0:
212-
golden = open(_locate(bazel_runfiles, requirements_file)).readlines()
213-
out = open(requirements_out).readlines()
214-
out = [line.replace(absolute_path_prefix, "") for line in out]
215-
if golden != out:
216-
import difflib
217-
218-
print("".join(difflib.unified_diff(golden, out)), file=sys.stderr)
219-
print(
220-
"Lock file out of date. Run '"
221-
+ update_command
222-
+ "' to update.",
223-
file=sys.stderr,
224-
)
225-
sys.exit(1)
226-
sys.exit(0)
227-
else:
228-
print(
229-
f"pip-compile unexpectedly exited with code {e.code}.",
230-
file=sys.stderr,
231-
)
232-
sys.exit(1)
233260

234261

235262
if __name__ == "__main__":

python/private/pypi/pip_compile.bzl

+1-1
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ def pip_compile(
110110

111111
args = ["--src=%s" % loc.format(src) for src in srcs] + [
112112
loc.format(requirements_txt),
113-
"//%s:%s.update" % (native.package_name(), name),
113+
"//%s:%s" % (native.package_name(), name),
114114
"--resolver=backtracking",
115115
"--allow-unsafe",
116116
]

0 commit comments

Comments
 (0)