From 4c6b7109fc441564439a28778bd0e4ff447c4e11 Mon Sep 17 00:00:00 2001 From: Alex Faxa Date: Wed, 23 Sep 2026 15:17:44 +0000 Subject: [PATCH 1/7] fix: do not include pyi_deps in PyInfo.transitive_sources or runtime runfiles `pyi_deps` are documented as build-time only dependencies for type checking and static analysis. Previously, `create_py_info` merged `pyi_deps` targets using `py_info.merge()`, which caused all transitive sources from type stubs and their dependencies to be placed in `PyInfo.transitive_sources` and included in downstream binary/test runfiles. This change updates `create_py_info` to only propagate `imports`, `transitive_pyi_files`, and `transitive_original_sources` from `pyi_deps`, preventing them from leaking into runtime runfiles. Adds unit tests verifying `transitive_sources` and binary runfiles exclusion. --- python/private/common.bzl | 9 ++++- tests/base_rules/base_tests.bzl | 3 ++ tests/base_rules/py_executable_base_tests.bzl | 37 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/python/private/common.bzl b/python/private/common.bzl index 5cff7f8723..ec48b43717 100644 --- a/python/private/common.bzl +++ b/python/private/common.bzl @@ -412,7 +412,14 @@ def create_py_info( for target in ctx.attr.pyi_deps: # PyInfo may not be present e.g. cc_library rules. if PyInfo in target or (BuiltinPyInfo != None and BuiltinPyInfo in target): - py_info.merge(_get_py_info(target)) + info = _get_py_info(target) + py_info.imports.add(info.imports) + if hasattr(info, "transitive_pyi_files"): + py_info.transitive_pyi_files.add(info.transitive_pyi_files) + if hasattr(info, "transitive_original_sources"): + py_info.transitive_original_sources.add(info.transitive_original_sources) + elif hasattr(info, "transitive_sources"): + py_info.transitive_original_sources.add(info.transitive_sources) py_info.transitive_sources.add(required_py_files) diff --git a/tests/base_rules/base_tests.bzl b/tests/base_rules/base_tests.bzl index a9fadd7564..08d66ba6b0 100644 --- a/tests/base_rules/base_tests.bzl +++ b/tests/base_rules/base_tests.bzl @@ -100,6 +100,9 @@ def _test_py_info_populated_impl(env, target): "{package}/lib2.pyi", "{package}/subject.pyi", ]) + info.transitive_sources().contains_exactly([ + "{package}/test_py_info_populated_subject.py", + ]) _tests.append(_test_py_info_populated) diff --git a/tests/base_rules/py_executable_base_tests.bzl b/tests/base_rules/py_executable_base_tests.bzl index f6b3c9bb60..48b0129838 100644 --- a/tests/base_rules/py_executable_base_tests.bzl +++ b/tests/base_rules/py_executable_base_tests.bzl @@ -519,6 +519,43 @@ def _test_py_runtime_info_provided_impl(env, target): _tests.append(_test_py_runtime_info_provided) +def _test_pyi_deps_not_in_runfiles(name, config): + rt_util.helper_target( + py_library, + name = name + "_types", + srcs = [rt_util.empty_file(name + "_type_stub.py")], + ) + rt_util.helper_target( + py_library, + name = name + "_lib", + srcs = [rt_util.empty_file(name + "_lib.py")], + pyi_deps = [name + "_types"], + ) + rt_util.helper_target( + config.rule, + name = name + "_subject", + srcs = [name + "_main.py"], + main = name + "_main.py", + deps = [name + "_lib"], + ) + analysis_test( + name = name, + impl = _test_pyi_deps_not_in_runfiles_impl, + target = name + "_subject", + ) + +def _test_pyi_deps_not_in_runfiles_impl(env, target): + target = env.expect.that_target(target) + target.runfiles().contains_at_least([ + "{workspace}/{package}/{test_name}_main.py", + "{workspace}/{package}/{test_name}_lib.py", + ]) + target.runfiles().not_contains( + "{workspace}/{package}/{test_name}_type_stub.py", + ) + +_tests.append(_test_pyi_deps_not_in_runfiles) + def _test_venv_output_prefix_with_path_separators(name, config): rt_util.helper_target( config.rule, From 8cc32e71f15e9309089b5aa0048dabf2695dab84 Mon Sep 17 00:00:00 2001 From: Alex Faxa Date: Wed, 23 Sep 2026 15:25:28 +0000 Subject: [PATCH 2/7] Add CHANGELOG message --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 013d33c686..2d438c7a04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ Unreleased changes are tracked as individual files in the [news/](./news) directory, or view the [latest generated changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). +### Fixed +* Fixed {attr}`py_library.pyi_deps` being erroneously included in {obj}`PyInfo.transitive_sources` and propagated into downstream binary and test runfiles. + {#v2-4-0} ## [2.4.0] - 2026-09-22 From 1e50dc755859bb4a5092a2ac5fb2faa314997483 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 27 Sep 2026 16:21:29 -0700 Subject: [PATCH 3/7] docs: move changelog entry to news/4178.fixed.md Unreleased changes must be recorded as news fragment files under news/ rather than editing CHANGELOG.md directly. Revert the direct edit to CHANGELOG.md and add news/4178.fixed.md. --- CHANGELOG.md | 3 --- news/4178.fixed.md | 5 +++++ 2 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 news/4178.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d438c7a04..013d33c686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,9 +29,6 @@ Unreleased changes are tracked as individual files in the [news/](./news) directory, or view the [latest generated changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). -### Fixed -* Fixed {attr}`py_library.pyi_deps` being erroneously included in {obj}`PyInfo.transitive_sources` and propagated into downstream binary and test runfiles. - {#v2-4-0} ## [2.4.0] - 2026-09-22 diff --git a/news/4178.fixed.md b/news/4178.fixed.md new file mode 100644 index 0000000000..9caff77955 --- /dev/null +++ b/news/4178.fixed.md @@ -0,0 +1,5 @@ +(rules) Fixed {obj}`py_library.pyi_deps` being erroneously included in +{obj}`PyInfo.transitive_sources`, {obj}`PyInfo.transitive_original_sources`, and +{obj}`PyInfo.imports`, which caused type stub dependencies to be propagated into +downstream binary and test runfiles +([#4178](https://github.com/bazel-contrib/rules_python/pull/4178)). From 3f473be8028d2c467e58058adfd6fa91a523ac29 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 27 Sep 2026 16:21:31 -0700 Subject: [PATCH 4/7] refactor(rules): add PyInfoBuilder.merge_build_time for pyi_deps Encapsulate build-time PyInfo merging into PyInfoBuilder.merge_build_time() so create_py_info does not inspect individual PyInfo fields for pyi_deps. Only pyi_files are merged; runtime fields (imports, transitive_sources) and transitive_original_sources are excluded. --- python/private/common.bzl | 9 +----- python/private/py_info.bzl | 33 ++++++++++++++++++++++ tests/base_rules/base_tests.bzl | 3 +- tests/base_rules/py_info/py_info_tests.bzl | 6 +++- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/python/private/common.bzl b/python/private/common.bzl index ec48b43717..be2585f3ad 100644 --- a/python/private/common.bzl +++ b/python/private/common.bzl @@ -412,14 +412,7 @@ def create_py_info( for target in ctx.attr.pyi_deps: # PyInfo may not be present e.g. cc_library rules. if PyInfo in target or (BuiltinPyInfo != None and BuiltinPyInfo in target): - info = _get_py_info(target) - py_info.imports.add(info.imports) - if hasattr(info, "transitive_pyi_files"): - py_info.transitive_pyi_files.add(info.transitive_pyi_files) - if hasattr(info, "transitive_original_sources"): - py_info.transitive_original_sources.add(info.transitive_original_sources) - elif hasattr(info, "transitive_sources"): - py_info.transitive_original_sources.add(info.transitive_sources) + py_info.merge_build_time(_get_py_info(target)) py_info.transitive_sources.add(required_py_files) diff --git a/python/private/py_info.bzl b/python/private/py_info.bzl index dac1ddeff3..af023cdaa4 100644 --- a/python/private/py_info.bzl +++ b/python/private/py_info.bzl @@ -656,6 +656,7 @@ def _PyInfoBuilder_new(): imports = builders.DepsetBuilder(), merge = lambda *a, **k: _PyInfoBuilder_merge(self, *a, **k), merge_all = lambda *a, **k: _PyInfoBuilder_merge_all(self, *a, **k), + merge_build_time = lambda *a, **k: _PyInfoBuilder_merge_build_time(self, *a, **k), merge_has_py2_only_sources = lambda *a, **k: _PyInfoBuilder_merge_has_py2_only_sources(self, *a, **k), merge_has_py3_only_sources = lambda *a, **k: _PyInfoBuilder_merge_has_py3_only_sources(self, *a, **k), merge_target = lambda *a, **k: _PyInfoBuilder_merge_target(self, *a, **k), @@ -857,6 +858,37 @@ def _PyInfoBuilder_merge_all(self, transitive, *, direct = []): return self +def _PyInfoBuilder_merge_build_time(self, *infos, direct = []): + """Merge build-time fields from other PyInfos into this PyInfo. + + Only build-time relevant fields (such as `pyi_files`) are merged, excluding + runtime fields like `imports` and `transitive_sources`. + + :::{versionadded} VERSION_NEXT_FEATURE + ::: + + Args: + self: implicitly added. + *infos: {type}`PyInfo` objects to merge in, but only merge in their + information into this object's transitive fields. + direct: {type}`list[PyInfo]` objects to merge in, but also merge their + direct fields into this object's direct fields. + + Returns: + {type}`PyInfoBuilder` self + """ + for info in direct: + # BuiltinPyInfo doesn't have this field + if hasattr(info, "direct_pyi_files"): + self.direct_pyi_files.add(info.direct_pyi_files) + + for info in direct + list(infos): + # BuiltinPyInfo doesn't have this field + if hasattr(info, "transitive_pyi_files"): + self.transitive_pyi_files.add(info.transitive_pyi_files) + + return self + def _PyInfoBuilder_merge_target(self, target): """Merge a target's Python information in this object. @@ -957,6 +989,7 @@ PyInfoBuilder = struct( get_uses_shared_libraries = _PyInfoBuilder_get_uses_shared_libraries, merge = _PyInfoBuilder_merge, merge_all = _PyInfoBuilder_merge_all, + merge_build_time = _PyInfoBuilder_merge_build_time, merge_has_py2_only_sources = _PyInfoBuilder_merge_has_py2_only_sources, merge_has_py3_only_sources = _PyInfoBuilder_merge_has_py3_only_sources, merge_target = _PyInfoBuilder_merge_target, diff --git a/tests/base_rules/base_tests.bzl b/tests/base_rules/base_tests.bzl index 08d66ba6b0..f7a022e831 100644 --- a/tests/base_rules/base_tests.bzl +++ b/tests/base_rules/base_tests.bzl @@ -72,6 +72,7 @@ def _test_py_info_populated(name, config): name = name + "_lib2", srcs = ["lib2.py"], pyi_srcs = ["lib2.pyi"], + imports = ["lib2_import"], ) analysis_test( @@ -90,8 +91,8 @@ def _test_py_info_populated_impl(env, target): ]) info.transitive_original_sources().contains_exactly([ "{package}/test_py_info_populated_subject.py", - "{package}/lib2.py", ]) + info.imports().contains_exactly([]) info.direct_pyi_files().contains_exactly([ "{package}/subject.pyi", diff --git a/tests/base_rules/py_info/py_info_tests.bzl b/tests/base_rules/py_info/py_info_tests.bzl index 0e1a96548c..b5bf15bd38 100644 --- a/tests/base_rules/py_info/py_info_tests.bzl +++ b/tests/base_rules/py_info/py_info_tests.bzl @@ -124,7 +124,7 @@ def _test_py_info_builder(name): ) py_info_targets = {} - for n in range(1, 7): + for n in range(1, 9): py_info_name = "{}_py{}".format(name, n) py_info_targets["py{}".format(n)] = py_info_name rt_util.helper_target( @@ -181,6 +181,7 @@ def _test_py_info_builder_impl(env, targets): builder.merge(targets.py3[PyInfo], direct = [targets.py4[PyInfo]]) builder.merge_all([targets.py5[PyInfo]], direct = [targets.py6[PyInfo]]) + builder.merge_build_time(targets.py7[PyInfo], direct = [targets.py8[PyInfo]]) def check(actual): subject = py_info_subject(actual, meta = env.expect.meta) @@ -242,6 +243,7 @@ def _test_py_info_builder_impl(env, targets): "tests/base_rules/py_info/direct.pyi", "tests/base_rules/py_info/py4-direct.pyi", "tests/base_rules/py_info/py6-direct.pyi", + "tests/base_rules/py_info/py8-direct.pyi", ]) subject.transitive_pyi_files().contains_exactly([ "tests/base_rules/py_info/trans.pyi", @@ -251,6 +253,8 @@ def _test_py_info_builder_impl(env, targets): "tests/base_rules/py_info/py4-trans.pyi", "tests/base_rules/py_info/py5-trans.pyi", "tests/base_rules/py_info/py6-trans.pyi", + "tests/base_rules/py_info/py7-trans.pyi", + "tests/base_rules/py_info/py8-trans.pyi", ]) if hasattr(actual, "venv_symlinks"): From 86a743882534b9ae60b89f0091610dd21251b586 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sun, 27 Sep 2026 20:25:08 -0700 Subject: [PATCH 5/7] feat(rules): add PyInfo.type_checking_info and merge_type_checking A type-checking-only dependency in pyi_deps can include plain .py files or imports that are needed by static type checkers, but should not be included in runtime outputs. Add PyInfo.type_checking_info and PyInfoBuilder.merge_type_checking() to store and propagate type-checking-only PyInfo separately from runtime PyInfo fields. --- news/4178.fixed.md | 7 +- python/private/common.bzl | 2 +- python/private/py_info.bzl | 132 +++++++++++++++------ tests/base_rules/base_tests.bzl | 14 +++ tests/base_rules/py_info/py_info_tests.bzl | 85 ++++++++++++- tests/support/py_info_subject.bzl | 39 ++++-- 6 files changed, 224 insertions(+), 55 deletions(-) diff --git a/news/4178.fixed.md b/news/4178.fixed.md index 9caff77955..9897385589 100644 --- a/news/4178.fixed.md +++ b/news/4178.fixed.md @@ -1,5 +1,4 @@ -(rules) Fixed {obj}`py_library.pyi_deps` being erroneously included in -{obj}`PyInfo.transitive_sources`, {obj}`PyInfo.transitive_original_sources`, and -{obj}`PyInfo.imports`, which caused type stub dependencies to be propagated into -downstream binary and test runfiles +(rules) Fixed {obj}`py_library.pyi_deps` being included in runtime output and +bloating output. Type-checking only information is now in +{obj}`PyInfo.type_checking_info` ([#4178](https://github.com/bazel-contrib/rules_python/pull/4178)). diff --git a/python/private/common.bzl b/python/private/common.bzl index be2585f3ad..5398fbfea0 100644 --- a/python/private/common.bzl +++ b/python/private/common.bzl @@ -412,7 +412,7 @@ def create_py_info( for target in ctx.attr.pyi_deps: # PyInfo may not be present e.g. cc_library rules. if PyInfo in target or (BuiltinPyInfo != None and BuiltinPyInfo in target): - py_info.merge_build_time(_get_py_info(target)) + py_info.merge_type_checking(_get_py_info(target)) py_info.transitive_sources.add(required_py_files) diff --git a/python/private/py_info.bzl b/python/private/py_info.bzl index af023cdaa4..79483ab2f5 100644 --- a/python/private/py_info.bzl +++ b/python/private/py_info.bzl @@ -375,6 +375,7 @@ def _PyInfo_init( transitive_original_sources = depset(), direct_pyi_files = depset(), transitive_pyi_files = depset(), + type_checking_info = None, venv_symlinks = depset()): _check_arg_type("transitive_sources", "depset", transitive_sources) @@ -396,6 +397,8 @@ def _PyInfo_init( _check_arg_type("direct_pyi_files", "depset", direct_pyi_files) _check_arg_type("transitive_pyi_files", "depset", transitive_pyi_files) + if type_checking_info != None: + _check_arg_type("type_checking_info", "struct", type_checking_info) return { "direct_original_sources": direct_original_sources, "direct_pyc_files": direct_pyc_files, @@ -409,6 +412,7 @@ def _PyInfo_init( "transitive_pyc_files": transitive_pyc_files, "transitive_pyi_files": transitive_pyi_files, "transitive_sources": transitive_sources, + "type_checking_info": type_checking_info, "uses_shared_libraries": uses_shared_libraries, "venv_symlinks": venv_symlinks, } @@ -558,6 +562,17 @@ in this depset being **empty**. The files are considered necessary for downstream binaries to function; previously they were considerd informational and largely unused. :::: +""", + "type_checking_info": """ +:type: PyInfo | None + +Additional `PyInfo` information needed only for static type checking (for +example, from `pyi_deps`). This information is not included into the final +output of a program. Type checkers should merge this into their information to +augment the analyzed output. + +::::{versionadded} VERSION_NEXT_FEATURE +:::: """, "uses_shared_libraries": """ :type: bool @@ -627,17 +642,21 @@ def _PyInfoBuilder_typedef(): :type: DepsetBuilder[File] ::: - :::{field} venv_symlinks - :type: DepsetBuilder[tuple[str | None, str]] - """ + :::{field} type_checking_info + :type: PyInfoBuilder | None -def _PyInfoBuilder_new(): - """Creates an instance. + Builder for {obj}`PyInfo.type_checking_info`. `None` on nested + type-checking builders. - Returns: - {type}`PyInfoBuilder` + ::::{versionadded} VERSION_NEXT_FEATURE + :::: + ::: + + :::{field} venv_symlinks + :type: DepsetBuilder[tuple[str | None, str]] """ +def _new_raw_py_info_builder(type_checking_info = None): # buildifier: disable=uninitialized self = struct( _has_py2_only_sources = [False], @@ -656,11 +675,11 @@ def _PyInfoBuilder_new(): imports = builders.DepsetBuilder(), merge = lambda *a, **k: _PyInfoBuilder_merge(self, *a, **k), merge_all = lambda *a, **k: _PyInfoBuilder_merge_all(self, *a, **k), - merge_build_time = lambda *a, **k: _PyInfoBuilder_merge_build_time(self, *a, **k), merge_has_py2_only_sources = lambda *a, **k: _PyInfoBuilder_merge_has_py2_only_sources(self, *a, **k), merge_has_py3_only_sources = lambda *a, **k: _PyInfoBuilder_merge_has_py3_only_sources(self, *a, **k), merge_target = lambda *a, **k: _PyInfoBuilder_merge_target(self, *a, **k), merge_targets = lambda *a, **k: _PyInfoBuilder_merge_targets(self, *a, **k), + merge_type_checking = lambda *a, **k: _PyInfoBuilder_merge_type_checking(self, *a, **k), merge_uses_shared_libraries = lambda *a, **k: _PyInfoBuilder_merge_uses_shared_libraries(self, *a, **k), set_has_py2_only_sources = lambda *a, **k: _PyInfoBuilder_set_has_py2_only_sources(self, *a, **k), set_has_py3_only_sources = lambda *a, **k: _PyInfoBuilder_set_has_py3_only_sources(self, *a, **k), @@ -671,10 +690,20 @@ def _PyInfoBuilder_new(): transitive_pyc_files = builders.DepsetBuilder(), transitive_pyi_files = builders.DepsetBuilder(), transitive_sources = builders.DepsetBuilder(), + type_checking_info = type_checking_info, venv_symlinks = builders.DepsetBuilder(), ) return self +def _PyInfoBuilder_new(): + """Creates an instance. + + Returns: + {type}`PyInfoBuilder` + """ + type_checking_info = _new_raw_py_info_builder(None) + return _new_raw_py_info_builder(type_checking_info) + def _PyInfoBuilder_add_venv_symlink(self): """Create and return a new VenvSymlinkEntryBuilder. @@ -820,19 +849,7 @@ def _PyInfoBuilder_merge(self, *infos, direct = []): """ return self.merge_all(list(infos), direct = direct) -def _PyInfoBuilder_merge_all(self, transitive, *, direct = []): - """Merge other PyInfos into this PyInfo. - - Args: - self: implicitly added. - transitive: {type}`list[PyInfo]` objects to merge in, but only merge in - their information into this object's transitive fields. - direct: {type}`list[PyInfo]` objects to merge in, but also merge their - direct fields into this object's direct fields. - - Returns: - {type}`PyInfoBuilder` self - """ +def _merge_py_info_fields(self, transitive, *, direct = []): for info in direct: # BuiltinPyInfo doesn't have this field if hasattr(info, "direct_pyc_files"): @@ -856,13 +873,41 @@ def _PyInfoBuilder_merge_all(self, transitive, *, direct = []): self.transitive_pyi_files.add(info.transitive_pyi_files) self.venv_symlinks.add(info.venv_symlinks) +def _PyInfoBuilder_merge_all(self, transitive, *, direct = []): + """Merge other PyInfos into this PyInfo. + + Args: + self: implicitly added. + transitive: {type}`list[PyInfo]` objects to merge in, but only merge in + their information into this object's transitive fields. + direct: {type}`list[PyInfo]` objects to merge in, but also merge their + direct fields into this object's direct fields. + + Returns: + {type}`PyInfoBuilder` self + """ + _merge_py_info_fields(self, transitive, direct = direct) + tc_direct = [ + info.type_checking_info + for info in direct + if getattr(info, "type_checking_info", None) != None + ] + tc_transitive = [ + info.type_checking_info + for info in transitive + if getattr(info, "type_checking_info", None) != None + ] + _merge_py_info_fields(self.type_checking_info, tc_transitive, direct = tc_direct) + return self -def _PyInfoBuilder_merge_build_time(self, *infos, direct = []): - """Merge build-time fields from other PyInfos into this PyInfo. +def _PyInfoBuilder_merge_type_checking(self, *infos, direct = []): + """Merge type-checking fields from other PyInfos into this PyInfo. - Only build-time relevant fields (such as `pyi_files`) are merged, excluding - runtime fields like `imports` and `transitive_sources`. + Merges `pyi_files` into this object's `direct_pyi_files` / + `transitive_pyi_files` and merges the full `PyInfo` (and any nested + `type_checking_info`) into {obj}`type_checking_info`, excluding runtime + fields like `imports` and `transitive_sources` from the top-level `PyInfo`. :::{versionadded} VERSION_NEXT_FEATURE ::: @@ -887,6 +932,18 @@ def _PyInfoBuilder_merge_build_time(self, *infos, direct = []): if hasattr(info, "transitive_pyi_files"): self.transitive_pyi_files.add(info.transitive_pyi_files) + tc_direct = direct + [ + info.type_checking_info + for info in direct + if getattr(info, "type_checking_info", None) != None + ] + tc_transitive = list(infos) + [ + info.type_checking_info + for info in infos + if getattr(info, "type_checking_info", None) != None + ] + _merge_py_info_fields(self.type_checking_info, tc_transitive, direct = tc_direct) + return self def _PyInfoBuilder_merge_target(self, target): @@ -924,15 +981,7 @@ def _PyInfoBuilder_merge_targets(self, targets): self.merge_target(t) return self -def _PyInfoBuilder_build(self): - """Builds into a {obj}`PyInfo` object. - - Args: - self: implicitly added. - - Returns: - {type}`PyInfo` - """ +def _build_py_info_fields(self, type_checking_info = None): venv_symlinks = depset( direct = [b.build() for b in self._venv_symlink_builders], transitive = [self.venv_symlinks.build()], @@ -952,9 +1001,22 @@ def _PyInfoBuilder_build(self): transitive_original_sources = self.transitive_original_sources.build(), transitive_pyc_files = self.transitive_pyc_files.build(), transitive_pyi_files = self.transitive_pyi_files.build(), + type_checking_info = type_checking_info, venv_symlinks = venv_symlinks, ) +def _PyInfoBuilder_build(self): + """Builds into a {obj}`PyInfo` object. + + Args: + self: implicitly added. + + Returns: + {type}`PyInfo` + """ + type_checking_info = _build_py_info_fields(self.type_checking_info, None) + return _build_py_info_fields(self, type_checking_info) + def _PyInfoBuilder_build_builtin_py_info(self): """Builds into a Bazel-builtin PyInfo object, if available. @@ -989,11 +1051,11 @@ PyInfoBuilder = struct( get_uses_shared_libraries = _PyInfoBuilder_get_uses_shared_libraries, merge = _PyInfoBuilder_merge, merge_all = _PyInfoBuilder_merge_all, - merge_build_time = _PyInfoBuilder_merge_build_time, merge_has_py2_only_sources = _PyInfoBuilder_merge_has_py2_only_sources, merge_has_py3_only_sources = _PyInfoBuilder_merge_has_py3_only_sources, merge_target = _PyInfoBuilder_merge_target, merge_targets = _PyInfoBuilder_merge_targets, + merge_type_checking = _PyInfoBuilder_merge_type_checking, merge_uses_shared_libraries = _PyInfoBuilder_merge_uses_shared_libraries, set_has_py2_only_sources = _PyInfoBuilder_set_has_py2_only_sources, set_has_py3_only_sources = _PyInfoBuilder_set_has_py3_only_sources, diff --git a/tests/base_rules/base_tests.bzl b/tests/base_rules/base_tests.bzl index f7a022e831..9ee7e5b46e 100644 --- a/tests/base_rules/base_tests.bzl +++ b/tests/base_rules/base_tests.bzl @@ -105,6 +105,20 @@ def _test_py_info_populated_impl(env, target): "{package}/test_py_info_populated_subject.py", ]) + tc_info = info.type_checking_info() + tc_info.transitive_sources().contains_exactly([ + "{package}/lib2.py", + ]) + tc_info.transitive_original_sources().contains_exactly([ + "{package}/lib2.py", + ]) + tc_info.imports().contains_exactly([ + "{}/{}/lib2_import".format(env.ctx.workspace_name, target.label.package), + ]) + tc_info.transitive_pyi_files().contains_exactly([ + "{package}/lib2.pyi", + ]) + _tests.append(_test_py_info_populated) def _py_info_propagation_setup(name, config, produce_py_info_rule, test_impl): diff --git a/tests/base_rules/py_info/py_info_tests.bzl b/tests/base_rules/py_info/py_info_tests.bzl index b5bf15bd38..1aa7ff2429 100644 --- a/tests/base_rules/py_info/py_info_tests.bzl +++ b/tests/base_rules/py_info/py_info_tests.bzl @@ -181,7 +181,7 @@ def _test_py_info_builder_impl(env, targets): builder.merge(targets.py3[PyInfo], direct = [targets.py4[PyInfo]]) builder.merge_all([targets.py5[PyInfo]], direct = [targets.py6[PyInfo]]) - builder.merge_build_time(targets.py7[PyInfo], direct = [targets.py8[PyInfo]]) + builder.merge_type_checking(targets.py7[PyInfo], direct = [targets.py8[PyInfo]]) def check(actual): subject = py_info_subject(actual, meta = env.expect.meta) @@ -257,6 +257,37 @@ def _test_py_info_builder_impl(env, targets): "tests/base_rules/py_info/py8-trans.pyi", ]) + tc = subject.type_checking_info() + tc.transitive_sources().contains_exactly([ + "tests/base_rules/py_info/py7-trans.py", + "tests/base_rules/py_info/py8-trans.py", + ]) + tc.imports().contains_exactly([ + "py7import", + "py8import", + ]) + tc.direct_pyc_files().contains_exactly([ + "tests/base_rules/py_info/py8-direct.pyc", + ]) + tc.transitive_pyc_files().contains_exactly([ + "tests/base_rules/py_info/py7-trans.pyc", + "tests/base_rules/py_info/py8-trans.pyc", + ]) + tc.direct_original_sources().contains_exactly([ + "tests/base_rules/py_info/py8-original-direct.py", + ]) + tc.transitive_original_sources().contains_exactly([ + "tests/base_rules/py_info/py7-original-trans.py", + "tests/base_rules/py_info/py8-original-trans.py", + ]) + tc.direct_pyi_files().contains_exactly([ + "tests/base_rules/py_info/py8-direct.pyi", + ]) + tc.transitive_pyi_files().contains_exactly([ + "tests/base_rules/py_info/py7-trans.pyi", + "tests/base_rules/py_info/py8-trans.pyi", + ]) + if hasattr(actual, "venv_symlinks"): entries = actual.venv_symlinks.to_list() env.expect.that_int(len(entries)).equals(1) @@ -269,13 +300,63 @@ def _test_py_info_builder_impl(env, targets): env.expect.that_bool(entry.version == None).equals(True) env.expect.that_bool(entry.link_to_file == None).equals(True) - check(builder.build()) + built = builder.build() + check(built) # Call build() again to verify it doesn't duplicate/leak state check(builder.build()) if BuiltinPyInfo != None: check(builder.build_builtin_py_info()) + # Verify type_checking_info propagates transitively across merge() + downstream = PyInfoBuilder.new().merge(built).build() + downstream_tc = py_info_subject(downstream, meta = env.expect.meta).type_checking_info() + downstream_tc.direct_pyc_files().contains_exactly([]) + downstream_tc.direct_original_sources().contains_exactly([]) + downstream_tc.direct_pyi_files().contains_exactly([]) + downstream_tc.transitive_sources().contains_exactly([ + "tests/base_rules/py_info/py7-trans.py", + "tests/base_rules/py_info/py8-trans.py", + ]) + downstream_tc.imports().contains_exactly([ + "py7import", + "py8import", + ]) + + # Verify type_checking_info direct fields propagate when merged as direct + direct_downstream = PyInfoBuilder.new().merge(direct = [built]).build() + direct_downstream_tc = py_info_subject( + direct_downstream, + meta = env.expect.meta, + ).type_checking_info() + direct_downstream_tc.direct_pyc_files().contains_exactly([ + "tests/base_rules/py_info/py8-direct.pyc", + ]) + direct_downstream_tc.direct_original_sources().contains_exactly([ + "tests/base_rules/py_info/py8-original-direct.py", + ]) + direct_downstream_tc.direct_pyi_files().contains_exactly([ + "tests/base_rules/py_info/py8-direct.pyi", + ]) + direct_downstream_tc.transitive_sources().contains_exactly([ + "tests/base_rules/py_info/py7-trans.py", + "tests/base_rules/py_info/py8-trans.py", + ]) + + # Verify combining py_info and py_info.type_checking_info + combined = PyInfoBuilder.new().merge(built, built.type_checking_info).build() + combined_subject = py_info_subject(combined, meta = env.expect.meta) + combined_subject.transitive_sources().contains_at_least([ + "tests/base_rules/py_info/trans.py", + "tests/base_rules/py_info/py7-trans.py", + "tests/base_rules/py_info/py8-trans.py", + ]) + combined_subject.imports().contains_at_least([ + "import-path", + "py7import", + "py8import", + ]) + builder.set_has_py2_only_sources(False) builder.set_has_py3_only_sources(False) builder.set_uses_shared_libraries(False) diff --git a/tests/support/py_info_subject.bzl b/tests/support/py_info_subject.bzl index 9122eaa9fd..f526f5d563 100644 --- a/tests/support/py_info_subject.bzl +++ b/tests/support/py_info_subject.bzl @@ -15,19 +15,7 @@ load("@rules_testing//lib:truth.bzl", "subjects") -def py_info_subject(info, *, meta): - """Creates a new `PyInfoSubject` for a PyInfo provider instance. - - Method: PyInfoSubject.new - - Args: - info: The PyInfo object - meta: ExpectMeta object. - - Returns: - A `PyInfoSubject` struct - """ - +def _new_py_info_subject(info, *, meta): # buildifier: disable=uninitialized public = struct( # go/keep-sorted start @@ -41,6 +29,7 @@ def py_info_subject(info, *, meta): transitive_pyc_files = lambda *a, **k: _py_info_subject_transitive_pyc_files(self, *a, **k), transitive_pyi_files = lambda *a, **k: _py_info_subject_transitive_pyi_files(self, *a, **k), transitive_sources = lambda *a, **k: _py_info_subject_transitive_sources(self, *a, **k), + type_checking_info = lambda *a, **k: _py_info_subject_type_checking_info(self, *a, **k), uses_shared_libraries = lambda *a, **k: _py_info_subject_uses_shared_libraries(self, *a, **k), # go/keep-sorted end ) @@ -50,6 +39,20 @@ def py_info_subject(info, *, meta): ) return public +def py_info_subject(info, *, meta): + """Creates a new `PyInfoSubject` for a PyInfo provider instance. + + Method: PyInfoSubject.new + + Args: + info: The PyInfo object + meta: ExpectMeta object. + + Returns: + A `PyInfoSubject` struct + """ + return _new_py_info_subject(info, meta = meta) + def _py_info_subject_direct_original_sources(self): """Returns a `DepsetFileSubject` for the `direct_original_sources` attribute. """ @@ -144,6 +147,16 @@ def _py_info_subject_transitive_sources(self): meta = self.meta.derive("transitive_sources()"), ) +def _py_info_subject_type_checking_info(self): + """Returns a `PyInfoSubject` for the `type_checking_info` attribute. + + Method: PyInfoSubject.type_checking_info + """ + return _new_py_info_subject( + self.actual.type_checking_info, + meta = self.meta.derive("type_checking_info()"), + ) + def _py_info_subject_uses_shared_libraries(self): """Returns a `BoolSubject` for the `uses_shared_libraries` attribute. From f25c78897aa52f1f9f723aed648b100669081481 Mon Sep 17 00:00:00 2001 From: Alex Faxa Date: Tue, 29 Sep 2026 11:26:27 +0000 Subject: [PATCH 6/7] Guard behavior behind a flag and add tests. Make sure the pyi_deps are pruned from runfiles in opt mode. --- python/config_settings/BUILD.bazel | 9 ++ python/private/BUILD.bazel | 1 + python/private/attributes.bzl | 4 + python/private/common.bzl | 11 ++- python/private/common_labels.bzl | 1 + python/private/flags.bzl | 21 ++++ tests/base_rules/py_executable_base_tests.bzl | 98 +++++++++++++++++++ 7 files changed, 144 insertions(+), 1 deletion(-) diff --git a/python/config_settings/BUILD.bazel b/python/config_settings/BUILD.bazel index a540d1dc3c..5e9211915b 100644 --- a/python/config_settings/BUILD.bazel +++ b/python/config_settings/BUILD.bazel @@ -11,6 +11,7 @@ load( "LibcFlag", "PrecompileFlag", "PrecompileSourceRetentionFlag", + "PyiDepsToRunfilesFlag", "ValidateTestMainFlag", "VenvsSitePackages", "VenvsUseDeclareSymlinkFlag", @@ -86,6 +87,14 @@ string_flag( visibility = NOT_ACTUALLY_PUBLIC, ) +string_flag( + name = "pyi_deps_to_runfiles", + build_setting_default = PyiDepsToRunfilesFlag.AUTO, + values = PyiDepsToRunfilesFlag.flag_values(), + # NOTE: Only public because it's an implicit dependency + visibility = NOT_ACTUALLY_PUBLIC, +) + string_flag( name = "precompile_source_retention", build_setting_default = PrecompileSourceRetentionFlag.AUTO, diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel index b4ff84f14a..59f745d47a 100644 --- a/python/private/BUILD.bazel +++ b/python/private/BUILD.bazel @@ -322,6 +322,7 @@ bzl_library( deps = [ ":builders", ":cc_helper", + ":flags", ":py_cc_link_params_info", ":py_info", ":py_internal", diff --git a/python/private/attributes.bzl b/python/private/attributes.bzl index e52cd8dccd..9af5305e35 100644 --- a/python/private/attributes.bzl +++ b/python/private/attributes.bzl @@ -367,6 +367,10 @@ Allowed `.pyc` and directory inputs in `srcs`. default = labels.PRECOMPILE_SOURCE_RETENTION, providers = [BuildSettingInfo], ), + "_pyi_deps_to_runfiles_flag": lambda: attrb.Label( + default = labels.PYI_DEPS_TO_RUNFILES, + providers = [BuildSettingInfo], + ), # Force enabling auto exec groups, see # https://bazel.build/extending/auto-exec-groups#how-enable-particular-rule "_use_auto_exec_groups": lambda: attrb.Bool( diff --git a/python/private/common.bzl b/python/private/common.bzl index 5398fbfea0..0a2d8a115b 100644 --- a/python/private/common.bzl +++ b/python/private/common.bzl @@ -21,6 +21,7 @@ load("//python/private:py_interpreter_program.bzl", "PyInterpreterProgramInfo") load("//python/private:toolchain_types.bzl", "EXEC_TOOLS_TOOLCHAIN_TYPE", "LAUNCHER_MAKER_TOOLCHAIN_TYPE") load(":builders.bzl", "builders") load(":cc_helper.bzl", "cc_helper") +load(":flags.bzl", "PyiDepsToRunfilesFlag") load(":py_cc_link_params_info.bzl", "PyCcLinkParamsInfo") load(":py_info.bzl", "PyInfo", "PyInfoBuilder") load(":py_internal.bzl", "py_internal") @@ -409,10 +410,18 @@ def create_py_info( 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)) + propagate_pyi_deps = ( + hasattr(ctx.attr, "_pyi_deps_to_runfiles_flag") and + PyiDepsToRunfilesFlag.is_enabled(ctx) + ) for target in ctx.attr.pyi_deps: # PyInfo may not be present e.g. cc_library rules. if PyInfo in target or (BuiltinPyInfo != None and BuiltinPyInfo in target): - py_info.merge_type_checking(_get_py_info(target)) + target_py_info = _get_py_info(target) + if propagate_pyi_deps: + py_info.merge(target_py_info) + else: + py_info.merge_type_checking(target_py_info) py_info.transitive_sources.add(required_py_files) diff --git a/python/private/common_labels.bzl b/python/private/common_labels.bzl index db4a00ba0a..8b3f3c14a0 100644 --- a/python/private/common_labels.bzl +++ b/python/private/common_labels.bzl @@ -23,6 +23,7 @@ labels = struct( PRECOMPILE = str(Label("//python/config_settings:precompile")), PRECOMPILE_SOURCE_RETENTION = str(Label("//python/config_settings:precompile_source_retention")), PYC_COLLECTION = str(Label("//python/config_settings:pyc_collection")), + PYI_DEPS_TO_RUNFILES = str(Label("//python/config_settings:pyi_deps_to_runfiles")), PYTHON_IMPORT_ALL_REPOSITORIES = str(Label("//python/config_settings:experimental_python_import_all_repositories")), PYTHON_SRC = str(Label("//python/bin:python_src")), PYTHON_VERSION = str(Label("//python/config_settings:python_version")), diff --git a/python/private/flags.bzl b/python/private/flags.bzl index 042e4e9838..84314584ba 100644 --- a/python/private/flags.bzl +++ b/python/private/flags.bzl @@ -108,6 +108,27 @@ ValidateTestMainFlag = FlagEnum( is_enabled = _ValidateTestMainFlag_is_enabled, ) +def _PyiDepsToRunfilesFlag_is_enabled(ctx): + value = ctx.attr._pyi_deps_to_runfiles_flag[BuildSettingInfo].value + if value == PyiDepsToRunfilesFlag.AUTO: + # In 'opt' compilation mode, prune pyi_deps from runtime runfiles. + if ctx.var.get("COMPILATION_MODE") == "opt": + return False + return False + return value == PyiDepsToRunfilesFlag.YES + +# Determines if pyi_deps are propagated to runtime runfiles. +# buildifier: disable=name-conventions +PyiDepsToRunfilesFlag = FlagEnum( + # Automatically decide the effective value based on compilation mode. + AUTO = "auto", + # Propagate pyi_deps into runtime runfiles (legacy behavior). + YES = "yes", + # Do not propagate pyi_deps into runtime runfiles. + NO = "no", + is_enabled = _PyiDepsToRunfilesFlag_is_enabled, +) + def _string_flag_impl(ctx): if ctx.attr.override: value = ctx.attr.override diff --git a/tests/base_rules/py_executable_base_tests.bzl b/tests/base_rules/py_executable_base_tests.bzl index 48b0129838..34b922a6fb 100644 --- a/tests/base_rules/py_executable_base_tests.bzl +++ b/tests/base_rules/py_executable_base_tests.bzl @@ -519,6 +519,44 @@ def _test_py_runtime_info_provided_impl(env, target): _tests.append(_test_py_runtime_info_provided) +def _test_pyi_deps_in_runfiles_when_flag_enabled(name, config): + rt_util.helper_target( + py_library, + name = name + "_types", + srcs = [rt_util.empty_file(name + "_type_stub.py")], + ) + rt_util.helper_target( + py_library, + name = name + "_lib", + srcs = [rt_util.empty_file(name + "_lib.py")], + pyi_deps = [name + "_types"], + ) + rt_util.helper_target( + config.rule, + name = name + "_subject", + srcs = [name + "_main.py"], + main = name + "_main.py", + deps = [name + "_lib"], + ) + analysis_test( + name = name, + impl = _test_pyi_deps_in_runfiles_when_flag_enabled_impl, + target = name + "_subject", + config_settings = { + labels.PYI_DEPS_TO_RUNFILES: "yes", + }, + ) + +def _test_pyi_deps_in_runfiles_when_flag_enabled_impl(env, target): + target = env.expect.that_target(target) + target.runfiles().contains_at_least([ + "{workspace}/{package}/{test_name}_main.py", + "{workspace}/{package}/{test_name}_lib.py", + "{workspace}/{package}/{test_name}_type_stub.py", + ]) + +_tests.append(_test_pyi_deps_in_runfiles_when_flag_enabled) + def _test_pyi_deps_not_in_runfiles(name, config): rt_util.helper_target( py_library, @@ -556,6 +594,66 @@ def _test_pyi_deps_not_in_runfiles_impl(env, target): _tests.append(_test_pyi_deps_not_in_runfiles) +def _test_pyi_deps_not_in_runfiles_opt_mode(name, config): + rt_util.helper_target( + py_library, + name = name + "_types", + srcs = [rt_util.empty_file(name + "_type_stub.py")], + ) + rt_util.helper_target( + py_library, + name = name + "_lib", + srcs = [rt_util.empty_file(name + "_lib.py")], + pyi_deps = [name + "_types"], + ) + rt_util.helper_target( + config.rule, + name = name + "_subject", + srcs = [name + "_main.py"], + main = name + "_main.py", + deps = [name + "_lib"], + ) + analysis_test( + name = name, + impl = _test_pyi_deps_not_in_runfiles_impl, + target = name + "_subject", + config_settings = { + "//command_line_option:compilation_mode": "opt", + }, + ) + +_tests.append(_test_pyi_deps_not_in_runfiles_opt_mode) + +def _test_pyi_deps_not_in_runfiles_when_flag_disabled(name, config): + rt_util.helper_target( + py_library, + name = name + "_types", + srcs = [rt_util.empty_file(name + "_type_stub.py")], + ) + rt_util.helper_target( + py_library, + name = name + "_lib", + srcs = [rt_util.empty_file(name + "_lib.py")], + pyi_deps = [name + "_types"], + ) + rt_util.helper_target( + config.rule, + name = name + "_subject", + srcs = [name + "_main.py"], + main = name + "_main.py", + deps = [name + "_lib"], + ) + analysis_test( + name = name, + impl = _test_pyi_deps_not_in_runfiles_impl, + target = name + "_subject", + config_settings = { + labels.PYI_DEPS_TO_RUNFILES: "no", + }, + ) + +_tests.append(_test_pyi_deps_not_in_runfiles_when_flag_disabled) + def _test_venv_output_prefix_with_path_separators(name, config): rt_util.helper_target( config.rule, From dab3815219af5148a95c1ad95967a877ca1957db Mon Sep 17 00:00:00 2001 From: Alex Faxa Date: Tue, 29 Sep 2026 11:32:37 +0000 Subject: [PATCH 7/7] Improve documentation --- .../rules_python/python/config_settings/index.md | 16 ++++++++++++++++ news/4178.fixed.md | 4 +++- python/private/attributes.bzl | 3 ++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/api/rules_python/python/config_settings/index.md b/docs/api/rules_python/python/config_settings/index.md index 9ae0665da2..29bc5dc35d 100644 --- a/docs/api/rules_python/python/config_settings/index.md +++ b/docs/api/rules_python/python/config_settings/index.md @@ -242,6 +242,22 @@ The `auto` value The `omit_if_generated_source` value was removed :::: +::::{bzl:flag} pyi_deps_to_runfiles +Controls whether dependencies listed in `pyi_deps` are propagated to runtime +runfiles. + +Values: + +* `auto`: (default) Automatically decide the effective value based on the + compilation mode. In `opt` and standard builds, `pyi_deps` are pruned from + runtime runfiles. +* `yes`: Propagate `pyi_deps` into runtime runfiles (legacy behavior). +* `no`: Do not propagate `pyi_deps` into runtime runfiles. + +:::{versionadded} VERSION_NEXT_FEATURE +::: +:::: + ::::{bzl:flag} validate_test_main Determines if `py_test` runs a build-time validation that its main module actually runs tests. diff --git a/news/4178.fixed.md b/news/4178.fixed.md index 9897385589..b96863bc6b 100644 --- a/news/4178.fixed.md +++ b/news/4178.fixed.md @@ -1,4 +1,6 @@ (rules) Fixed {obj}`py_library.pyi_deps` being included in runtime output and bloating output. Type-checking only information is now in -{obj}`PyInfo.type_checking_info` +{obj}`PyInfo.type_checking_info`. Added the +{obj}`--//python/config_settings:pyi_deps_to_runfiles` flag to configure or +restore legacy runfile propagation if needed ([#4178](https://github.com/bazel-contrib/rules_python/pull/4178)). diff --git a/python/private/attributes.bzl b/python/private/attributes.bzl index 9af5305e35..7fc87ed81e 100644 --- a/python/private/attributes.bzl +++ b/python/private/attributes.bzl @@ -307,7 +307,8 @@ Dependencies providing type definitions the library needs. These are dependencies that satisfy imports guarded by `typing.TYPE_CHECKING`. These are build-time only dependencies and not included as part of a runnable -program (packaging rules may include them, however). +program (packaging rules may include them, however). Runtime inclusion can be +configured using {obj}`--//python/config_settings:pyi_deps_to_runfiles`. :::{versionadded} 1.1.0 :::