• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2023 The Bazel Authors. All rights reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#     http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"Set defaults for the pip-compile command to run it under Bazel"
16
17import atexit
18import os
19import shutil
20import sys
21from pathlib import Path
22from typing import Optional, Tuple
23
24import click
25import piptools.writer as piptools_writer
26from piptools.scripts.compile import cli
27
28from python.runfiles import runfiles
29
30# Replace the os.replace function with shutil.copy to work around os.replace not being able to
31# replace or move files across filesystems.
32os.replace = shutil.copy
33
34# Next, we override the annotation_style_split and annotation_style_line functions to replace the
35# backslashes in the paths with forward slashes. This is so that we can have the same requirements
36# file on Windows and Unix-like.
37original_annotation_style_split = piptools_writer.annotation_style_split
38original_annotation_style_line = piptools_writer.annotation_style_line
39
40
41def annotation_style_split(required_by) -> str:
42    required_by = set([v.replace("\\", "/") for v in required_by])
43    return original_annotation_style_split(required_by)
44
45
46def annotation_style_line(required_by) -> str:
47    required_by = set([v.replace("\\", "/") for v in required_by])
48    return original_annotation_style_line(required_by)
49
50
51piptools_writer.annotation_style_split = annotation_style_split
52piptools_writer.annotation_style_line = annotation_style_line
53
54
55def _select_golden_requirements_file(
56    requirements_txt, requirements_linux, requirements_darwin, requirements_windows
57):
58    """Switch the golden requirements file, used to validate if updates are needed,
59    to a specified platform specific one.  Fallback on the platform independent one.
60    """
61
62    plat = sys.platform
63    if plat == "linux" and requirements_linux is not None:
64        return requirements_linux
65    elif plat == "darwin" and requirements_darwin is not None:
66        return requirements_darwin
67    elif plat == "win32" and requirements_windows is not None:
68        return requirements_windows
69    else:
70        return requirements_txt
71
72
73def _locate(bazel_runfiles, file):
74    """Look up the file via Rlocation"""
75
76    if not file:
77        return file
78
79    return bazel_runfiles.Rlocation(file)
80
81
82@click.command(context_settings={"ignore_unknown_options": True})
83@click.option("--src", "srcs", multiple=True, required=True)
84@click.argument("requirements_txt")
85@click.argument("update_target_label")
86@click.option("--requirements-linux")
87@click.option("--requirements-darwin")
88@click.option("--requirements-windows")
89@click.argument("extra_args", nargs=-1, type=click.UNPROCESSED)
90def main(
91    srcs: Tuple[str, ...],
92    requirements_txt: str,
93    update_target_label: str,
94    requirements_linux: Optional[str],
95    requirements_darwin: Optional[str],
96    requirements_windows: Optional[str],
97    extra_args: Tuple[str, ...],
98) -> None:
99    bazel_runfiles = runfiles.Create()
100
101    requirements_file = _select_golden_requirements_file(
102        requirements_txt=requirements_txt,
103        requirements_linux=requirements_linux,
104        requirements_darwin=requirements_darwin,
105        requirements_windows=requirements_windows,
106    )
107
108    resolved_srcs = [_locate(bazel_runfiles, src) for src in srcs]
109    resolved_requirements_file = _locate(bazel_runfiles, requirements_file)
110
111    # Files in the runfiles directory has the following naming schema:
112    # Main repo: __main__/<path_to_file>
113    # External repo: <workspace name>/<path_to_file>
114    # We want to strip both __main__ and <workspace name> from the absolute prefix
115    # to keep the requirements lock file agnostic.
116    repository_prefix = requirements_file[: requirements_file.index("/") + 1]
117    absolute_path_prefix = resolved_requirements_file[
118        : -(len(requirements_file) - len(repository_prefix))
119    ]
120
121    # As srcs might contain references to generated files we want to
122    # use the runfiles file first. Thus, we need to compute the relative path
123    # from the execution root.
124    # Note: Windows cannot reference generated files without runfiles support enabled.
125    srcs_relative = [src[len(repository_prefix) :] for src in srcs]
126    requirements_file_relative = requirements_file[len(repository_prefix) :]
127
128    # Before loading click, set the locale for its parser.
129    # If it leaks through to the system setting, it may fail:
130    # RuntimeError: Click will abort further execution because Python 3 was configured to use ASCII
131    # as encoding for the environment. Consult https://click.palletsprojects.com/python3/ for
132    # mitigation steps.
133    os.environ["LC_ALL"] = "C.UTF-8"
134    os.environ["LANG"] = "C.UTF-8"
135
136    argv = []
137
138    UPDATE = True
139    # Detect if we are running under `bazel test`.
140    if "TEST_TMPDIR" in os.environ:
141        UPDATE = False
142        # pip-compile wants the cache files to be writeable, but if we point
143        # to the real user cache, Bazel sandboxing makes the file read-only
144        # and we fail.
145        # In theory this makes the test more hermetic as well.
146        argv.append(f"--cache-dir={os.environ['TEST_TMPDIR']}")
147        # Make a copy for pip-compile to read and mutate.
148        requirements_out = os.path.join(
149            os.environ["TEST_TMPDIR"], os.path.basename(requirements_file) + ".out"
150        )
151        # Those two files won't necessarily be on the same filesystem, so we can't use os.replace
152        # or shutil.copyfile, as they will fail with OSError: [Errno 18] Invalid cross-device link.
153        shutil.copy(resolved_requirements_file, requirements_out)
154
155    update_command = os.getenv("CUSTOM_COMPILE_COMMAND") or "bazel run %s" % (
156        update_target_label,
157    )
158
159    os.environ["CUSTOM_COMPILE_COMMAND"] = update_command
160    os.environ["PIP_CONFIG_FILE"] = os.getenv("PIP_CONFIG_FILE") or os.devnull
161
162    argv.append(
163        f"--output-file={requirements_file_relative if UPDATE else requirements_out}"
164    )
165    argv.extend(
166        (src_relative if Path(src_relative).exists() else resolved_src)
167        for src_relative, resolved_src in zip(srcs_relative, resolved_srcs)
168    )
169    argv.extend(extra_args)
170
171    if UPDATE:
172        print("Updating " + requirements_file_relative)
173
174        # Make sure the output file for pip_compile exists. It won't if we are on Windows and --enable_runfiles is not set.
175        if not os.path.exists(requirements_file_relative):
176            os.makedirs(os.path.dirname(requirements_file_relative), exist_ok=True)
177            shutil.copy(resolved_requirements_file, requirements_file_relative)
178
179        if "BUILD_WORKSPACE_DIRECTORY" in os.environ:
180            workspace = os.environ["BUILD_WORKSPACE_DIRECTORY"]
181            requirements_file_tree = os.path.join(workspace, requirements_file_relative)
182            absolute_output_file = Path(requirements_file_relative).absolute()
183            # In most cases, requirements_file will be a symlink to the real file in the source tree.
184            # If symlinks are not enabled (e.g. on Windows), then requirements_file will be a copy,
185            # and we should copy the updated requirements back to the source tree.
186            if not absolute_output_file.samefile(requirements_file_tree):
187                atexit.register(
188                    lambda: shutil.copy(
189                        absolute_output_file, requirements_file_tree
190                    )
191                )
192        cli(argv, standalone_mode = False)
193        requirements_file_relative_path = Path(requirements_file_relative)
194        content = requirements_file_relative_path.read_text()
195        content = content.replace(absolute_path_prefix, "")
196        requirements_file_relative_path.write_text(content)
197    else:
198        # cli will exit(0) on success
199        try:
200            print("Checking " + requirements_file)
201            cli(argv)
202            print("cli() should exit", file=sys.stderr)
203            sys.exit(1)
204        except SystemExit as e:
205            if e.code == 2:
206                print(
207                    "pip-compile exited with code 2. This means that pip-compile found "
208                    "incompatible requirements or could not find a version that matches "
209                    f"the install requirement in one of {srcs_relative}.",
210                    file=sys.stderr,
211                )
212                sys.exit(1)
213            elif e.code == 0:
214                golden = open(_locate(bazel_runfiles, requirements_file)).readlines()
215                out = open(requirements_out).readlines()
216                out = [line.replace(absolute_path_prefix, "") for line in out]
217                if golden != out:
218                    import difflib
219
220                    print("".join(difflib.unified_diff(golden, out)), file=sys.stderr)
221                    print(
222                        "Lock file out of date. Run '"
223                        + update_command
224                        + "' to update.",
225                        file=sys.stderr,
226                    )
227                    sys.exit(1)
228                sys.exit(0)
229            else:
230                print(
231                    f"pip-compile unexpectedly exited with code {e.code}.",
232                    file=sys.stderr,
233                )
234                sys.exit(1)
235
236
237if __name__ == "__main__":
238    main()
239