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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy