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""" 16console_script generator from entry_points.txt contents. 17 18For Python versions earlier than 3.11 and for earlier bazel versions than 7.0 we need to workaround the issue of 19sys.path[0] breaking out of the runfiles tree see the following for more context: 20* https://github.com/bazelbuild/rules_python/issues/382 21* https://github.com/bazelbuild/bazel/pull/15701 22 23In affected bazel and Python versions we see in programs such as `flake8`, `pylint` or `pytest` errors because the 24first `sys.path` element is outside the `runfiles` directory and if the `name` of the `py_binary` is the same as 25the program name, then the script (e.g. `flake8`) will start failing whilst trying to import its own internals from 26the bazel entrypoint script. 27 28The mitigation strategy is to remove the first entry in the `sys.path` if it does not have `.runfiles` and it seems 29to fix the behaviour of console_scripts under `bazel run`. 30 31This would not happen if we created a console_script binary in the root of an external repository, e.g. 32`@pypi_pylint//` because the path for the external repository is already in the runfiles directory. 33""" 34 35from __future__ import annotations 36 37import argparse 38import configparser 39import pathlib 40import re 41import sys 42import textwrap 43 44_ENTRY_POINTS_TXT = "entry_points.txt" 45 46_TEMPLATE = """\ 47import sys 48 49# See @rules_python//python/private:py_console_script_gen.py for explanation 50if getattr(sys.flags, "safe_path", False): 51 # We are running on Python 3.11 and we don't need this workaround 52 pass 53elif ".runfiles" not in sys.path[0]: 54 sys.path = sys.path[1:] 55 56try: 57 from {module} import {attr} 58except ImportError: 59 entries = "\\n".join(sys.path) 60 print("Printing sys.path entries for easier debugging:", file=sys.stderr) 61 print(f"sys.path is:\\n{{entries}}", file=sys.stderr) 62 raise 63 64if __name__ == "__main__": 65 sys.exit({entry_point}()) 66""" 67 68 69class EntryPointsParser(configparser.ConfigParser): 70 """A class handling entry_points.txt 71 72 See https://packaging.python.org/en/latest/specifications/entry-points/ 73 """ 74 75 optionxform = staticmethod(str) 76 77 78def _guess_entry_point(guess: str, console_scripts: dict[string, string]) -> str | None: 79 for key, candidate in console_scripts.items(): 80 if guess == key: 81 return candidate 82 83 84def run( 85 *, 86 entry_points: pathlib.Path, 87 out: pathlib.Path, 88 console_script: str, 89 console_script_guess: str, 90): 91 """Run the generator 92 93 Args: 94 entry_points: The entry_points.txt file to be parsed. 95 out: The output file. 96 console_script: The console_script entry in the entry_points.txt file. 97 """ 98 config = EntryPointsParser() 99 config.read(entry_points) 100 101 try: 102 console_scripts = dict(config["console_scripts"]) 103 except KeyError: 104 raise RuntimeError( 105 f"The package does not provide any console_scripts in its {_ENTRY_POINTS_TXT}" 106 ) 107 108 if console_script: 109 try: 110 entry_point = console_scripts[console_script] 111 except KeyError: 112 available = ", ".join(sorted(console_scripts.keys())) 113 raise RuntimeError( 114 f"The console_script '{console_script}' was not found, only the following are available: {available}" 115 ) from None 116 else: 117 # Get rid of the extension and the common prefix 118 entry_point = _guess_entry_point( 119 guess=console_script_guess, 120 console_scripts=console_scripts, 121 ) 122 123 if not entry_point: 124 available = ", ".join(sorted(console_scripts.keys())) 125 raise RuntimeError( 126 f"Tried to guess that you wanted '{console_script_guess}', but could not find it. " 127 f"Please select one of the following console scripts: {available}" 128 ) from None 129 130 module, _, entry_point = entry_point.rpartition(":") 131 attr, _, _ = entry_point.partition(".") 132 # TODO: handle 'extras' in entry_point generation 133 # See https://github.com/bazelbuild/rules_python/issues/1383 134 # See https://packaging.python.org/en/latest/specifications/entry-points/ 135 136 with open(out, "w") as f: 137 f.write( 138 _TEMPLATE.format( 139 module=module, 140 attr=attr, 141 entry_point=entry_point, 142 ), 143 ) 144 145 146def main(): 147 parser = argparse.ArgumentParser(description="console_script generator") 148 parser.add_argument( 149 "--console-script", 150 help="The console_script to generate the entry_point template for.", 151 ) 152 parser.add_argument( 153 "--console-script-guess", 154 required=True, 155 help="The string used for guessing the console_script if it is not provided.", 156 ) 157 parser.add_argument( 158 "entry_points", 159 metavar="ENTRY_POINTS_TXT", 160 type=pathlib.Path, 161 help="The entry_points.txt within the dist-info of a PyPI wheel", 162 ) 163 parser.add_argument( 164 "out", 165 type=pathlib.Path, 166 metavar="OUT", 167 help="The output file.", 168 ) 169 args = parser.parse_args() 170 171 run( 172 entry_points=args.entry_points, 173 out=args.out, 174 console_script=args.console_script, 175 console_script_guess=args.console_script_guess, 176 ) 177 178 179if __name__ == "__main__": 180 main() 181