diff --git a/docs/_includes/py_console_script_binary.md b/docs/_includes/py_console_script_binary.md index aa356e0e94..d327091630 100644 --- a/docs/_includes/py_console_script_binary.md +++ b/docs/_includes/py_console_script_binary.md @@ -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. @@ -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 \ No newline at end of file +[`py_console_script_binary.binary_rule`]: #py_console_script_binary_binary_rule +[PEP441]: https://peps.python.org/pep-0441/#minimal-tooling-the-zipapp-module diff --git a/python/private/py_console_script_binary.bzl b/python/private/py_console_script_binary.bzl index 154fa3bf2f..d98457dbe1 100644 --- a/python/private/py_console_script_binary.bzl +++ b/python/private/py_console_script_binary.bzl @@ -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. @@ -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) @@ -81,6 +84,7 @@ def py_console_script_binary( out = main, console_script = script, console_script_guess = name, + shebang = shebang, visibility = ["//visibility:private"], ) diff --git a/python/private/py_console_script_gen.bzl b/python/private/py_console_script_gen.bzl index 7dd4dd2dad..de016036b2 100644 --- a/python/private/py_console_script_gen.bzl +++ b/python/private/py_console_script_gen.bzl @@ -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) @@ -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, diff --git a/python/private/py_console_script_gen.py b/python/private/py_console_script_gen.py index ffc4e81b3a..4b4f2f6986 100644 --- a/python/private/py_console_script_gen.py +++ b/python/private/py_console_script_gen.py @@ -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): @@ -87,6 +87,7 @@ def run( out: pathlib.Path, console_script: str, console_script_guess: str, + shebang: str, ): """Run the generator @@ -94,6 +95,8 @@ def run( 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. + shebang: The shebang to use for the entry point python file. Defaults to empty string (no shebang). """ config = EntryPointsParser() config.read(entry_points) @@ -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, @@ -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", @@ -173,6 +181,7 @@ def main(): out=args.out, console_script=args.console_script, console_script_guess=args.console_script_guess, + shebang=args.shebang, ) diff --git a/tests/entry_points/py_console_script_gen_test.py b/tests/entry_points/py_console_script_gen_test.py index a5fceb67f9..1bbf5fbf25 100644 --- a/tests/entry_points/py_console_script_gen_test.py +++ b/tests/entry_points/py_console_script_gen_test.py @@ -47,6 +47,7 @@ def test_no_console_scripts_error(self): out=outfile, console_script=None, console_script_guess="", + shebang="", ) self.assertEqual( @@ -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( @@ -106,6 +108,7 @@ def test_incorrect_entry_point(self): out=outfile, console_script="baz", console_script_guess="", + shebang="", ) self.assertEqual( @@ -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() @@ -185,6 +189,7 @@ def test_a_second_entry_point_class_method(self): out=out, console_script="bar", console_script_guess="", + shebang="", ) got = out.read_text() @@ -192,6 +197,35 @@ def test_a_second_entry_point_class_method(self): 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()