Skip to content
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()