xref: /aosp_15_r20/external/bazelbuild-rules_python/python/private/py_console_script_gen.py (revision 60517a1edbc8ecf509223e9af94a7adec7d736b8)
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