Skip to content

feat: add shebang attribute on py_console_script_binary #2867

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
May 25, 2025
Merged
23 changes: 22 additions & 1 deletion docs/_includes/py_console_script_binary.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,26 @@ py_console_script_binary(
)
```

#### Adding a Shebang Line

You can specify a shebang line for the generated binary, useful for Unix-like
systems where the shebang line determines which interpreter is used to execute
the script, per [PEP441]:

```starlark
load("@rules_python//python/entry_points:py_console_script_binary.bzl", "py_console_script_binary")

py_console_script_binary(
name = "black",
pkg = "@pip//black",
shebang = "#!/usr/bin/env python3",
)
```

Note that to execute via the shebang line, you need to ensure the specified
Python interpreter is available in the environment.


#### Using a specific Python Version directly from a Toolchain
:::{deprecated} 1.1.0
The toolchain specific `py_binary` and `py_test` symbols are aliases to the regular rules.
Expand All @@ -70,4 +90,5 @@ py_console_script_binary(
```

[specification]: https://packaging.python.org/en/latest/specifications/entry-points/
[`py_console_script_binary.binary_rule`]: #py_console_script_binary_binary_rule
[`py_console_script_binary.binary_rule`]: #py_console_script_binary_binary_rule
[PEP441]: https://peps.python.org/pep-0441/#minimal-tooling-the-zipapp-module
4 changes: 4 additions & 0 deletions python/private/py_console_script_binary.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def py_console_script_binary(
entry_points_txt = None,
script = None,
binary_rule = py_binary,
shebang = "",
**kwargs):
"""Generate a py_binary for a console_script entry_point.

Expand All @@ -68,6 +69,8 @@ def py_console_script_binary(
binary_rule: {type}`callable`, The rule/macro to use to instantiate
the target. It's expected to behave like {obj}`py_binary`.
Defaults to {obj}`py_binary`.
shebang: {type}`str`, The shebang to use for the entry point python file.
Defaults to empty string.
**kwargs: Extra parameters forwarded to `binary_rule`.
"""
main = "rules_python_entry_point_{}.py".format(name)
Expand All @@ -81,6 +84,7 @@ def py_console_script_binary(
out = main,
console_script = script,
console_script_guess = name,
shebang = shebang,
visibility = ["//visibility:private"],
)

Expand Down
5 changes: 5 additions & 0 deletions python/private/py_console_script_gen.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def _py_console_script_gen_impl(ctx):
args = ctx.actions.args()
args.add("--console-script", ctx.attr.console_script)
args.add("--console-script-guess", ctx.attr.console_script_guess)
args.add("--shebang", ctx.attr.shebang)
args.add(entry_points_txt)
args.add(ctx.outputs.out)

Expand Down Expand Up @@ -81,6 +82,10 @@ py_console_script_gen = rule(
doc = "Output file location.",
mandatory = True,
),
"shebang": attr.string(
doc = "The shebang to use for the entry point python file.",
default = "",
),
"_tool": attr.label(
default = ":py_console_script_gen_py",
executable = True,
Expand Down
11 changes: 10 additions & 1 deletion python/private/py_console_script_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
_ENTRY_POINTS_TXT = "entry_points.txt"

_TEMPLATE = """\
import sys
{shebang}import sys

# See @rules_python//python/private:py_console_script_gen.py for explanation
if getattr(sys.flags, "safe_path", False):
Expand Down Expand Up @@ -87,13 +87,16 @@ def run(
out: pathlib.Path,
console_script: str,
console_script_guess: str,
shebang: 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.
console_script_guess: The string used for guessing the console_script if it is not provided.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused?

Copy link
Contributor Author

@chrisirhc chrisirhc May 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a previously undocumented arg. I thought it seems reasonable to document it to maintain the order of arguments, and for consistency.

shebang: The shebang to use for the entry point python file. Defaults to empty string (no shebang).
"""
config = EntryPointsParser()
config.read(entry_points)
Expand Down Expand Up @@ -136,6 +139,7 @@ def run(
with open(out, "w") as f:
f.write(
_TEMPLATE.format(
shebang=f"{shebang}\n" if shebang else "",
module=module,
attr=attr,
entry_point=entry_point,
Expand All @@ -154,6 +158,10 @@ def main():
required=True,
help="The string used for guessing the console_script if it is not provided.",
)
parser.add_argument(
"--shebang",
help="The shebang to use for the entry point python file.",
)
parser.add_argument(
"entry_points",
metavar="ENTRY_POINTS_TXT",
Expand All @@ -173,6 +181,7 @@ def main():
out=args.out,
console_script=args.console_script,
console_script_guess=args.console_script_guess,
shebang=args.shebang,
)


Expand Down
34 changes: 34 additions & 0 deletions tests/entry_points/py_console_script_gen_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def test_no_console_scripts_error(self):
out=outfile,
console_script=None,
console_script_guess="",
shebang="",
)

self.assertEqual(
Expand Down Expand Up @@ -76,6 +77,7 @@ def test_no_entry_point_selected_error(self):
out=outfile,
console_script=None,
console_script_guess="bar-baz",
shebang="",
)

self.assertEqual(
Expand Down Expand Up @@ -106,6 +108,7 @@ def test_incorrect_entry_point(self):
out=outfile,
console_script="baz",
console_script_guess="",
shebang="",
)

self.assertEqual(
Expand Down Expand Up @@ -134,6 +137,7 @@ def test_a_single_entry_point(self):
out=out,
console_script=None,
console_script_guess="foo",
shebang="",
)

got = out.read_text()
Expand Down Expand Up @@ -185,13 +189,43 @@ def test_a_second_entry_point_class_method(self):
out=out,
console_script="bar",
console_script_guess="",
shebang="",
)

got = out.read_text()

self.assertRegex(got, "from foo\.baz import Bar")
self.assertRegex(got, "sys\.exit\(Bar\.baz\(\)\)")

def test_shebang_included(self):
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = pathlib.Path(tmpdir)
given_contents = (
textwrap.dedent(
"""
[console_scripts]
foo = foo.bar:baz
"""
).strip()
+ "\n"
)
entry_points = tmpdir / "entry_points.txt"
entry_points.write_text(given_contents)
out = tmpdir / "foo.py"

shebang = "#!/usr/bin/env python3"
run(
entry_points=entry_points,
out=out,
console_script=None,
console_script_guess="foo",
shebang=shebang,
)

got = out.read_text()

self.assertTrue(got.startswith(shebang + "\n"))


if __name__ == "__main__":
unittest.main()