# Copyright 2023 The Bazel Authors. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ console_script generator from entry_points.txt contents. For Python versions earlier than 3.11 and for earlier bazel versions than 7.0 we need to workaround the issue of sys.path[0] breaking out of the runfiles tree see the following for more context: * https://github.com/bazelbuild/rules_python/issues/382 * https://github.com/bazelbuild/bazel/pull/15701 In affected bazel and Python versions we see in programs such as `flake8`, `pylint` or `pytest` errors because the first `sys.path` element is outside the `runfiles` directory and if the `name` of the `py_binary` is the same as the program name, then the script (e.g. `flake8`) will start failing whilst trying to import its own internals from the bazel entrypoint script. The mitigation strategy is to remove the first entry in the `sys.path` if it does not have `.runfiles` and it seems to fix the behaviour of console_scripts under `bazel run`. This would not happen if we created a console_script binary in the root of an external repository, e.g. `@pypi_pylint//` because the path for the external repository is already in the runfiles directory. """ from __future__ import annotations import argparse import configparser import pathlib import re import sys import textwrap _ENTRY_POINTS_TXT = "entry_points.txt" _TEMPLATE = """\ import sys # See @rules_python//python/private:py_console_script_gen.py for explanation if getattr(sys.flags, "safe_path", False): # We are running on Python 3.11 and we don't need this workaround pass elif ".runfiles" not in sys.path[0]: sys.path = sys.path[1:] try: from {module} import {attr} except ImportError: entries = "\\n".join(sys.path) print("Printing sys.path entries for easier debugging:", file=sys.stderr) print(f"sys.path is:\\n{{entries}}", file=sys.stderr) raise if __name__ == "__main__": sys.exit({entry_point}()) """ class EntryPointsParser(configparser.ConfigParser): """A class handling entry_points.txt See https://packaging.python.org/en/latest/specifications/entry-points/ """ optionxform = staticmethod(str) def _guess_entry_point(guess: str, console_scripts: dict[string, string]) -> str | None: for key, candidate in console_scripts.items(): if guess == key: return candidate def run( *, entry_points: pathlib.Path, out: pathlib.Path, console_script: str, console_script_guess: str, ): """Run the generator Args: entry_points: The entry_points.txt file to be parsed. out: The output file. console_script: The console_script entry in the entry_points.txt file. """ config = EntryPointsParser() config.read(entry_points) try: console_scripts = dict(config["console_scripts"]) except KeyError: raise RuntimeError( f"The package does not provide any console_scripts in its {_ENTRY_POINTS_TXT}" ) if console_script: try: entry_point = console_scripts[console_script] except KeyError: available = ", ".join(sorted(console_scripts.keys())) raise RuntimeError( f"The console_script '{console_script}' was not found, only the following are available: {available}" ) from None else: # Get rid of the extension and the common prefix entry_point = _guess_entry_point( guess=console_script_guess, console_scripts=console_scripts, ) if not entry_point: available = ", ".join(sorted(console_scripts.keys())) raise RuntimeError( f"Tried to guess that you wanted '{console_script_guess}', but could not find it. " f"Please select one of the following console scripts: {available}" ) from None module, _, entry_point = entry_point.rpartition(":") attr, _, _ = entry_point.partition(".") # TODO: handle 'extras' in entry_point generation # See https://github.com/bazelbuild/rules_python/issues/1383 # See https://packaging.python.org/en/latest/specifications/entry-points/ with open(out, "w") as f: f.write( _TEMPLATE.format( module=module, attr=attr, entry_point=entry_point, ), ) def main(): parser = argparse.ArgumentParser(description="console_script generator") parser.add_argument( "--console-script", help="The console_script to generate the entry_point template for.", ) parser.add_argument( "--console-script-guess", required=True, help="The string used for guessing the console_script if it is not provided.", ) parser.add_argument( "entry_points", metavar="ENTRY_POINTS_TXT", type=pathlib.Path, help="The entry_points.txt within the dist-info of a PyPI wheel", ) parser.add_argument( "out", type=pathlib.Path, metavar="OUT", help="The output file.", ) args = parser.parse_args() run( entry_points=args.entry_points, out=args.out, console_script=args.console_script, console_script_guess=args.console_script_guess, ) if __name__ == "__main__": main()