Skip to content

Commit 032f6aa

Browse files
authored
feat(rules): add main_module attribute to run a module name (python -m) (#2671)
This implements the ability to run a module name instead of a file path, aka `python -m` style of invocation. This allows a binary/test to specify what the main module is without having to have a direct dependency on the entry point file. As a side effect, the `srcs` attribute is no longer required. Fixes #2539
1 parent 701ba45 commit 032f6aa

File tree

5 files changed

+131
-47
lines changed

5 files changed

+131
-47
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ Unreleased changes template.
9999
Only applicable for {obj}`--bootstrap_impl=script`.
100100
* (rules) Added {obj}`interpreter_args` attribute to `py_binary` and `py_test`,
101101
which allows pass arguments to the interpreter before the regular args.
102+
* (rules) Added {obj}`main_module` attribute to `py_binary` and `py_test`,
103+
which allows specifying a module name to run (i.e. `python -m <module>`).
102104

103105
{#v0-0-0-removed}
104106
### Removed

python/private/py_executable.bzl

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,24 @@ Optional; the name of the source file that is the main entry point of the
130130
application. This file must also be listed in `srcs`. If left unspecified,
131131
`name`, with `.py` appended, is used instead. If `name` does not match any
132132
filename in `srcs`, `main` must be specified.
133+
134+
This is mutually exclusive with {obj}`main_module`.
135+
""",
136+
),
137+
"main_module": lambda: attrb.String(
138+
doc = """
139+
Module name to execute as the main program.
140+
141+
When set, `srcs` is not required, and it is assumed the module is
142+
provided by a dependency.
143+
144+
See https://docs.python.org/3/using/cmdline.html#cmdoption-m for more
145+
information about running modules as the main program.
146+
147+
This is mutually exclusive with {obj}`main`.
148+
149+
:::{versionadded} VERSION_NEXT_FEATURE
150+
:::
133151
""",
134152
),
135153
"pyc_collection": lambda: attrb.String(
@@ -642,14 +660,19 @@ def _create_stage2_bootstrap(
642660

643661
template = runtime.stage2_bootstrap_template
644662

663+
if main_py:
664+
main_py_path = "{}/{}".format(ctx.workspace_name, main_py.short_path)
665+
else:
666+
main_py_path = ""
645667
ctx.actions.expand_template(
646668
template = template,
647669
output = output,
648670
substitutions = {
649671
"%coverage_tool%": _get_coverage_tool_runfiles_path(ctx, runtime),
650672
"%import_all%": "True" if ctx.fragments.bazel_py.python_import_all_repositories else "False",
651673
"%imports%": ":".join(imports.to_list()),
652-
"%main%": "{}/{}".format(ctx.workspace_name, main_py.short_path),
674+
"%main%": main_py_path,
675+
"%main_module%": ctx.attr.main_module,
653676
"%target%": str(ctx.label),
654677
"%workspace_name%": ctx.workspace_name,
655678
},
@@ -933,7 +956,10 @@ def py_executable_base_impl(ctx, *, semantics, is_test, inherited_environment =
933956
"""
934957
_validate_executable(ctx)
935958

936-
main_py = determine_main(ctx)
959+
if not ctx.attr.main_module:
960+
main_py = determine_main(ctx)
961+
else:
962+
main_py = None
937963
direct_sources = filter_to_py_srcs(ctx.files.srcs)
938964
precompile_result = semantics.maybe_precompile(ctx, direct_sources)
939965

@@ -1053,6 +1079,12 @@ def _validate_executable(ctx):
10531079
if ctx.attr.python_version == "PY2":
10541080
fail("It is not allowed to use Python 2")
10551081

1082+
if ctx.attr.main and ctx.attr.main_module:
1083+
fail((
1084+
"Only one of main and main_module can be set, got: " +
1085+
"main={}, main_module={}"
1086+
).format(ctx.attr.main, ctx.attr.main_module))
1087+
10561088
def _declare_executable_file(ctx):
10571089
if target_platform_has_any_constraint(ctx, ctx.attr._windows_constraints):
10581090
executable = ctx.actions.declare_file(ctx.label.name + ".exe")

python/private/stage2_bootstrap_template.py

Lines changed: 69 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@
2626
# We just put them in one place so its easy to tell which are used.
2727

2828
# Runfiles-relative path to the main Python source file.
29-
MAIN = "%main%"
29+
# Empty if MAIN_MODULE is used
30+
MAIN_PATH = "%main%"
31+
32+
# Module name to execute. Empty if MAIN is used.
33+
MAIN_MODULE = "%main_module%"
3034

3135
# ===== Template substitutions end =====
3236

@@ -249,7 +253,7 @@ def unresolve_symlinks(output_filename):
249253
os.unlink(unfixed_file)
250254

251255

252-
def _run_py(main_filename, *, args, cwd=None):
256+
def _run_py_path(main_filename, *, args, cwd=None):
253257
# type: (str, str, list[str], dict[str, str]) -> ...
254258
"""Executes the given Python file using the various environment settings."""
255259

@@ -269,6 +273,11 @@ def _run_py(main_filename, *, args, cwd=None):
269273
sys.argv = orig_argv
270274

271275

276+
def _run_py_module(module_name):
277+
# Match `python -m` behavior, so modify sys.argv and the run name
278+
runpy.run_module(module_name, alter_sys=True, run_name="__main__")
279+
280+
272281
@contextlib.contextmanager
273282
def _maybe_collect_coverage(enable):
274283
print_verbose_coverage("enabled:", enable)
@@ -356,64 +365,79 @@ def main():
356365
print_verbose("initial environ:", mapping=os.environ)
357366
print_verbose("initial sys.path:", values=sys.path)
358367

359-
main_rel_path = MAIN
360-
if is_windows():
361-
main_rel_path = main_rel_path.replace("/", os.sep)
362-
363-
module_space = find_runfiles_root(main_rel_path)
364-
print_verbose("runfiles root:", module_space)
365-
366-
# Recreate the "add main's dir to sys.path[0]" behavior to match the
367-
# system-python bootstrap / typical Python behavior.
368-
#
369-
# Without safe path enabled, when `python foo/bar.py` is run, python will
370-
# resolve the foo/bar.py symlink to its real path, then add the directory
371-
# of that path to sys.path. But, the resolved directory for the symlink
372-
# depends on if the file is generated or not.
373-
#
374-
# When foo/bar.py is a source file, then it's a symlink pointing
375-
# back to the client source directory. This means anything from that source
376-
# directory becomes importable, i.e. most code is importable.
377-
#
378-
# When foo/bar.py is a generated file, then it's a symlink pointing to
379-
# somewhere under bazel-out/.../bin, i.e. where generated files are. This
380-
# means only other generated files are importable (not source files).
381-
#
382-
# To replicate this behavior, we add main's directory within the runfiles
383-
# when safe path isn't enabled.
384-
if not getattr(sys.flags, "safe_path", False):
385-
prepend_path_entries = [
386-
os.path.join(module_space, os.path.dirname(main_rel_path))
387-
]
368+
main_rel_path = None
369+
# todo: things happen to work because find_runfiles_root
370+
# ends up using stage2_bootstrap, and ends up computing the proper
371+
# runfiles root
372+
if MAIN_PATH:
373+
main_rel_path = MAIN_PATH
374+
if is_windows():
375+
main_rel_path = main_rel_path.replace("/", os.sep)
376+
377+
runfiles_root = find_runfiles_root(main_rel_path)
388378
else:
389-
prepend_path_entries = []
379+
runfiles_root = find_runfiles_root("")
380+
381+
print_verbose("runfiles root:", runfiles_root)
390382

391-
runfiles_envkey, runfiles_envvalue = runfiles_envvar(module_space)
383+
runfiles_envkey, runfiles_envvalue = runfiles_envvar(runfiles_root)
392384
if runfiles_envkey:
393385
os.environ[runfiles_envkey] = runfiles_envvalue
394386

395-
main_filename = os.path.join(module_space, main_rel_path)
396-
main_filename = get_windows_path_with_unc_prefix(main_filename)
397-
assert os.path.exists(main_filename), (
398-
"Cannot exec() %r: file not found." % main_filename
399-
)
400-
assert os.access(main_filename, os.R_OK), (
401-
"Cannot exec() %r: file not readable." % main_filename
402-
)
387+
if MAIN_PATH:
388+
# Recreate the "add main's dir to sys.path[0]" behavior to match the
389+
# system-python bootstrap / typical Python behavior.
390+
#
391+
# Without safe path enabled, when `python foo/bar.py` is run, python will
392+
# resolve the foo/bar.py symlink to its real path, then add the directory
393+
# of that path to sys.path. But, the resolved directory for the symlink
394+
# depends on if the file is generated or not.
395+
#
396+
# When foo/bar.py is a source file, then it's a symlink pointing
397+
# back to the client source directory. This means anything from that source
398+
# directory becomes importable, i.e. most code is importable.
399+
#
400+
# When foo/bar.py is a generated file, then it's a symlink pointing to
401+
# somewhere under bazel-out/.../bin, i.e. where generated files are. This
402+
# means only other generated files are importable (not source files).
403+
#
404+
# To replicate this behavior, we add main's directory within the runfiles
405+
# when safe path isn't enabled.
406+
if not getattr(sys.flags, "safe_path", False):
407+
prepend_path_entries = [
408+
os.path.join(runfiles_root, os.path.dirname(main_rel_path))
409+
]
410+
else:
411+
prepend_path_entries = []
412+
413+
main_filename = os.path.join(runfiles_root, main_rel_path)
414+
main_filename = get_windows_path_with_unc_prefix(main_filename)
415+
assert os.path.exists(main_filename), (
416+
"Cannot exec() %r: file not found." % main_filename
417+
)
418+
assert os.access(main_filename, os.R_OK), (
419+
"Cannot exec() %r: file not readable." % main_filename
420+
)
403421

404-
sys.stdout.flush()
422+
sys.stdout.flush()
405423

406-
sys.path[0:0] = prepend_path_entries
424+
sys.path[0:0] = prepend_path_entries
425+
else:
426+
main_filename = None
407427

408428
if os.environ.get("COVERAGE_DIR"):
409429
import _bazel_site_init
430+
410431
coverage_enabled = _bazel_site_init.COVERAGE_SETUP
411432
else:
412433
coverage_enabled = False
413434

414435
with _maybe_collect_coverage(enable=coverage_enabled):
415-
# The first arg is this bootstrap, so drop that for the re-invocation.
416-
_run_py(main_filename, args=sys.argv[1:])
436+
if MAIN_PATH:
437+
# The first arg is this bootstrap, so drop that for the re-invocation.
438+
_run_py_path(main_filename, args=sys.argv[1:])
439+
else:
440+
_run_py_module(MAIN_MODULE)
417441
sys.exit(0)
418442

419443

tests/bootstrap_impls/BUILD.bazel

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,15 @@ py_reconfig_test(
107107
main = "sys_path_order_test.py",
108108
)
109109

110+
py_reconfig_test(
111+
name = "main_module_test",
112+
srcs = ["main_module.py"],
113+
bootstrap_impl = "script",
114+
imports = ["."],
115+
main_module = "tests.bootstrap_impls.main_module",
116+
target_compatible_with = SUPPORTS_BOOTSTRAP_SCRIPT,
117+
)
118+
110119
sh_py_run_test(
111120
name = "inherit_pythonsafepath_env_test",
112121
bootstrap_impl = "script",

tests/bootstrap_impls/main_module.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import sys
2+
import unittest
3+
4+
5+
class MainModuleTest(unittest.TestCase):
6+
def test_run_as_module(self):
7+
self.assertIsNotNone(__spec__, "__spec__ was none")
8+
# If not run as a module, __spec__ is None
9+
self.assertNotEqual(__name__, __spec__.name)
10+
self.assertEqual(__spec__.name, "tests.bootstrap_impls.main_module")
11+
12+
13+
if __name__ == "__main__":
14+
unittest.main()
15+
else:
16+
# Guard against running it as a module in a non-main way.
17+
sys.exit(f"__name__ should be __main__, got {__name__}")

0 commit comments

Comments
 (0)