blob: 30e93c2e5b6d618b5e6dde2363d70a588a9de2a9 [file] [log] [blame]
# 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 an 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 it's {_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()