fix(precompile): skip precompilation for directory and pyc inputs in srcs (#4113)
Precompiling `py_library`, `py_binary`, or `py_test` targets fails when
`srcs` contains directory artifacts or `.pyc` files. The precompiler
declares invalid actions on directories and drops non-`.py` sources from
default outputs.
This change:
* Expands direct sources to include Python files, `.pyc` files, and
directories.
* Skips precompilation for directories and non-Python files while
retaining them in default outputs.
* Updates `srcs` validation and documentation.
---------
Co-authored-by: Ignas Anikevicius <240938+aignas@users.noreply.github.com>
diff --git a/news/4113.fixed.md b/news/4113.fixed.md
new file mode 100644
index 0000000..e852e1a
--- /dev/null
+++ b/news/4113.fixed.md
@@ -0,0 +1,4 @@
+(precompile) Fixed handling of directory and `.pyc` file inputs in
+{obj}`srcs` when {obj}`precompile` is enabled on {obj}`py_library`,
+{obj}`py_binary`, and {obj}`py_test` targets
+([#4113](https://github.com/bazel-contrib/rules_python/pull/4113)).
diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel
index 0b0dc97..72bf713 100644
--- a/python/private/BUILD.bazel
+++ b/python/private/BUILD.bazel
@@ -469,6 +469,7 @@
srcs = ["precompile.bzl"],
deps = [
":attributes",
+ ":common",
":flags",
":py_interpreter_program",
":toolchain_types",
diff --git a/python/private/attributes.bzl b/python/private/attributes.bzl
index e1e77cb..5b842f8 100644
--- a/python/private/attributes.bzl
+++ b/python/private/attributes.bzl
@@ -331,14 +331,29 @@
allow_files = True,
),
"srcs": lambda: attrb.LabelList(
- allow_files = [".py", ".py3"],
+ allow_files = True,
# Necessary for --compile_one_dependency to work.
flags = ["DIRECT_COMPILE_TIME_INPUT"],
doc = """
The list of Python source files that are processed to create the target. This
-includes all your checked-in code and may include generated source files. The
-`.py` files belong in `srcs` and library targets belong in `deps`. Other binary
-files that may be needed at run time belong in `data`.
+includes all your checked-in code and may include generated source files.
+
+Allowed file types:
+* `.py`
+* `.pyc`
+* directories
+
+Library targets belong in `deps`. Other binary files that may be needed at run
+time belong in `data`.
+
+:::{versionchanged} 2.3.2
+As an exception, empty targets in `srcs` that provide {obj}`PyInfo` are
+allowed. Ordinary library dependencies should remain in `deps`.
+:::
+
+:::{versionchanged} VERSION_NEXT_PATCH
+Allowed `.pyc` and directory inputs in `srcs`.
+:::
""",
),
"srcs_version": lambda: attrb.String(
diff --git a/python/private/common.bzl b/python/private/common.bzl
index 7e8c6de..5cff7f8 100644
--- a/python/private/common.bzl
+++ b/python/private/common.bzl
@@ -198,13 +198,21 @@
"""Convert a list of strings to comma separated value string."""
return ", ".join(sorted(values))
+def is_py_source(f):
+ """Whether the given file is considered a Python source file."""
+ return f.extension == "py"
+
def filter_to_py_srcs(srcs):
"""Filters .py files from the given list of files"""
+ return [f for f in srcs if is_py_source(f)]
- # TODO(b/203567235): Get the set of recognized extensions from
- # elsewhere, as there may be others. e.g. Bazel recognizes .py3
- # as a valid extension.
- return [f for f in srcs if f.extension == "py"]
+def filter_to_direct_sources(srcs):
+ """Filters Python sources, pyc files, and directory artifacts from srcs."""
+ return [
+ f
+ for f in srcs
+ if f.is_directory or is_py_source(f) or f.extension == "pyc"
+ ]
def collect_cc_info(ctx, extra_deps = []):
"""Collect C++ information from dependencies for Bazel.
@@ -398,7 +406,7 @@
# longer supported in `deps`.
files = target[DefaultInfo].files.to_list()
for f in files:
- if f.extension == "py":
+ if is_py_source(f):
py_info.transitive_sources.add(f)
py_info.merge_uses_shared_libraries(cc_helper.is_valid_shared_library_artifact(f))
for target in ctx.attr.pyi_deps:
diff --git a/python/private/precompile.bzl b/python/private/precompile.bzl
index 898dc3e..ceaa76c 100644
--- a/python/private/precompile.bzl
+++ b/python/private/precompile.bzl
@@ -15,7 +15,7 @@
load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo")
load(":attributes.bzl", "PrecompileAttr", "PrecompileInvalidationModeAttr", "PrecompileSourceRetentionAttr")
-load(":common.bzl", "actions_run")
+load(":common.bzl", "actions_run", "is_py_source")
load(":flags.bzl", "PrecompileFlag")
load(":py_interpreter_program.bzl", "PyInterpreterProgramInfo")
load(":toolchain_types.bzl", "EXEC_TOOLS_TOOLCHAIN_TYPE", "TARGET_TOOLCHAIN_TYPE")
@@ -98,7 +98,7 @@
file.
Returns:
- File of the generated pyc file.
+ File of the generated pyc file, or None if the source file was skipped.
"""
# Generating a file in another package is an error, so we have to skip
@@ -106,6 +106,12 @@
if ctx.label.package != src.owner.package:
return None
+ if src.is_directory:
+ return None
+
+ if not is_py_source(src):
+ return None
+
exec_tools_info = ctx.toolchains[EXEC_TOOLS_TOOLCHAIN_TYPE].exec_tools
target_toolchain = ctx.toolchains[TARGET_TOOLCHAIN_TYPE].py3_runtime
diff --git a/python/private/py_executable.bzl b/python/private/py_executable.bzl
index 0e9c315..368a785 100644
--- a/python/private/py_executable.bzl
+++ b/python/private/py_executable.bzl
@@ -50,6 +50,7 @@
"create_py_info",
"create_windows_exe_launcher",
"csv",
+ "filter_to_direct_sources",
"filter_to_py_srcs",
"is_bool",
"is_windows_platform",
@@ -1198,13 +1199,13 @@
# precompiled pyc below) so the test-main validation can statically analyze
# the original source.
main_py_source = main_py
- direct_sources = filter_to_py_srcs(ctx.files.srcs)
+ direct_sources = filter_to_direct_sources(ctx.files.srcs)
precompile_result = maybe_precompile(ctx, direct_sources)
- required_py_files = precompile_result.keep_srcs
+ required_py_files = filter_to_py_srcs(precompile_result.keep_srcs)
required_pyc_files = []
implicit_pyc_files = []
- implicit_pyc_source_files = direct_sources
+ implicit_pyc_source_files = filter_to_py_srcs(direct_sources)
if ctx.attr.precompile == PrecompileAttr.ENABLED:
required_pyc_files.extend(precompile_result.pyc_files)
diff --git a/python/private/py_library.bzl b/python/private/py_library.bzl
index 3118d3c..6282b71 100644
--- a/python/private/py_library.bzl
+++ b/python/private/py_library.bzl
@@ -34,6 +34,7 @@
"create_instrumented_files_info",
"create_output_group_info",
"create_py_info",
+ "filter_to_direct_sources",
"filter_to_py_srcs",
)
load(":common_labels.bzl", "labels")
@@ -128,19 +129,13 @@
):
continue
- found_match = False
- for file in files:
- if file.is_directory or file.extension in ("py", "py3"):
- found_match = True
- break
-
- if found_match:
+ if filter_to_direct_sources(files):
continue
fail(
("{} does not produce any py_library srcs files " +
- "(expected .py or .py3) and is not an empty target providing " +
- "PyInfo").format(
+ "(expected .py, .pyc, or directory) and is not an empty target " +
+ "providing PyInfo").format(
target.label,
),
attr = "srcs",
@@ -156,14 +151,14 @@
A list of modern providers to propagate.
"""
_validate_srcs(ctx)
- direct_sources = filter_to_py_srcs(ctx.files.srcs)
+ direct_sources = filter_to_direct_sources(ctx.files.srcs)
precompile_result = maybe_precompile(ctx, direct_sources)
- required_py_files = precompile_result.keep_srcs
+ required_py_files = filter_to_py_srcs(precompile_result.keep_srcs)
required_pyc_files = []
implicit_pyc_files = []
- implicit_pyc_source_files = direct_sources
+ implicit_pyc_source_files = filter_to_py_srcs(direct_sources)
precompile_attr = ctx.attr.precompile
precompile_flag = ctx.attr._precompile_flag[BuildSettingInfo].value
@@ -314,7 +309,7 @@
{obj}`ruleb.Rule` with the necessary settings
for creating a `py_library` rule.
"""
- builder = ruleb.Rule(
+ return ruleb.Rule(
implementation = py_library_impl,
doc = _DEFAULT_PY_LIBRARY_DOC,
exec_groups = dict(REQUIRED_EXEC_GROUP_BUILDERS),
@@ -326,13 +321,3 @@
ruleb.ToolchainType(EXEC_TOOLS_TOOLCHAIN_TYPE, mandatory = False),
],
)
- srcs_attr = builder.attrs.get("srcs")
- srcs_attr.set_allow_files(True)
- srcs_attr.set_doc(srcs_attr.doc() + """
-
-:::{versionchanged} 2.3.2
-As an exception, empty targets in `srcs` that provide {obj}`PyInfo` are
-allowed. Ordinary library dependencies should remain in `deps`.
-:::
-""")
- return builder
diff --git a/tests/base_rules/precompile/precompile_tests.bzl b/tests/base_rules/precompile/precompile_tests.bzl
index d2c1da6..63865a3 100644
--- a/tests/base_rules/precompile/precompile_tests.bzl
+++ b/tests/base_rules/precompile/precompile_tests.bzl
@@ -529,6 +529,148 @@
_tests.append(_test_precompile_enabled_succeeds)
+def _directory_impl(ctx):
+ out = ctx.actions.declare_directory(ctx.label.name)
+ ctx.actions.run_shell(
+ outputs = [out],
+ command = """\
+mkdir -p "$1"
+echo "x = 1" > "$1/foo.py"
+echo "y = 2" > "$1/bar.py"
+""",
+ arguments = [out.path],
+ mnemonic = "TestDirectory",
+ )
+ return [DefaultInfo(files = depset([out]))]
+
+_directory = rule(implementation = _directory_impl)
+
+def _test_directory_input(name):
+ rt_util.helper_target(
+ _directory,
+ name = name + "_dir.py",
+ )
+ rt_util.helper_target(
+ py_library,
+ name = name + "_subject",
+ srcs = ["lib.py", name + "_dir.py"],
+ precompile = "enabled",
+ )
+ analysis_test(
+ name = name,
+ impl = _test_directory_input_impl,
+ target = name + "_subject",
+ config_settings = _COMMON_CONFIG_SETTINGS,
+ )
+
+def _test_directory_input_impl(env, target):
+ target = env.expect.that_target(target)
+ target.default_outputs().contains_at_least_predicates([
+ matching.file_path_matches("__pycache__/lib.fakepy-45.pyc"),
+ matching.file_path_matches("/lib.py"),
+ matching.file_path_matches("/" + env.ctx.label.name + "_dir.py"),
+ ])
+ py_info = target.provider(PyInfo, factory = py_info_subject)
+ py_info.direct_pyc_files().contains_exactly([
+ "{package}/__pycache__/lib.fakepy-45.pyc",
+ ])
+ py_info.transitive_pyc_files().contains_exactly([
+ "{package}/__pycache__/lib.fakepy-45.pyc",
+ ])
+
+_tests.append(_test_directory_input)
+
+# buildifier: disable=function-docstring-header
+def _test_directory_input_succeeds(name):
+ """Verify that a `py_test` target with a directory input in srcs builds
+ and runs when precompiling is enabled.
+ """
+ _directory(
+ name = name + "_dir.py",
+ )
+ write_file(
+ name = name + "_main",
+ out = name + "_main.py",
+ content = [
+ "print('Hello from directory input test')",
+ "",
+ ],
+ )
+ py_test(
+ name = name,
+ srcs = [name + "_main.py", name + "_dir.py"],
+ main = name + "_main.py",
+ precompile = "enabled",
+ tags = ["no-pyrefly"],
+ )
+
+_tests.append(_test_directory_input_succeeds)
+
+def _test_pyc_source_input(name):
+ rt_util.helper_target(
+ write_file,
+ name = name + "_pyc",
+ out = name + "_foo.pyc",
+ content = [""],
+ )
+ rt_util.helper_target(
+ py_library,
+ name = name + "_subject",
+ srcs = ["lib.py", name + "_pyc"],
+ precompile = "enabled",
+ )
+ analysis_test(
+ name = name,
+ impl = _test_pyc_source_input_impl,
+ target = name + "_subject",
+ config_settings = _COMMON_CONFIG_SETTINGS,
+ )
+
+def _test_pyc_source_input_impl(env, target):
+ target = env.expect.that_target(target)
+ target.default_outputs().contains_at_least_predicates([
+ matching.file_path_matches("__pycache__/lib.fakepy-45.pyc"),
+ matching.file_path_matches("/lib.py"),
+ matching.file_path_matches("/" + env.ctx.label.name + "_foo.pyc"),
+ ])
+ py_info = target.provider(PyInfo, factory = py_info_subject)
+ py_info.direct_pyc_files().contains_exactly([
+ "{package}/__pycache__/lib.fakepy-45.pyc",
+ ])
+ py_info.transitive_pyc_files().contains_exactly([
+ "{package}/__pycache__/lib.fakepy-45.pyc",
+ ])
+
+_tests.append(_test_pyc_source_input)
+
+# buildifier: disable=function-docstring-header
+def _test_pyc_source_input_succeeds(name):
+ """Verify that a `py_test` target with a pyc input in srcs builds
+ and runs when precompiling is enabled.
+ """
+ write_file(
+ name = name + "_pyc",
+ out = name + "_foo.pyc",
+ content = [""],
+ )
+ write_file(
+ name = name + "_main",
+ out = name + "_main.py",
+ content = [
+ "print('Hello from pyc input test')",
+ "",
+ ],
+ )
+ py_test(
+ name = name,
+ srcs = [name + "_main.py", name + "_pyc"],
+ main = name + "_main.py",
+ precompile = "enabled",
+ tags = ["no-pyrefly"],
+ )
+
+_tests.append(_test_pyc_source_input_succeeds)
+
def runfiles_contains_at_least_predicates(runfiles, predicates):
for predicate in predicates:
runfiles.contains_predicate(predicate)
diff --git a/tests/base_rules/py_library/py_library_tests.bzl b/tests/base_rules/py_library/py_library_tests.bzl
index a3be7c4..80e3e1b 100644
--- a/tests/base_rules/py_library/py_library_tests.bzl
+++ b/tests/base_rules/py_library/py_library_tests.bzl
@@ -134,7 +134,9 @@
)
def _test_srcs_can_contain_tree_artifact_impl(env, target):
- env.expect.that_target(target).default_outputs().contains_exactly([])
+ env.expect.that_target(target).default_outputs().contains_exactly([
+ "{package}/{test_name}_tree.dir",
+ ])
_tests.append(_test_srcs_can_contain_tree_artifact)