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