fix: do not include transitive sources from `pyi_deps` in runtime runfiles (#4178)

`pyi_deps` are documented as build-time only dependencies for type
checking and static analysis, but currently leaks into the runfiles.

Before: `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.

After: `create_py_info` only propagates `imports` (for module import
resolution during type checking), `transitive_pyi_files`, and
`transitive_original_sources` from `pyi_deps`. Adds unit tests verifying
`transitive_sources` and binary runfiles exclusion.

Tested: in our repo using `rules_python` and `pyi_deps` for type
checking, this reduced the runfile tree size with 20M for a sample
`py_test`.

---------

Co-authored-by: Richard Levasseur <richardlev@gmail.com>
diff --git a/docs/api/rules_python/python/config_settings/index.md b/docs/api/rules_python/python/config_settings/index.md
index 830d0a6..2def823 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 `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
new file mode 100644
index 0000000..b96863b
--- /dev/null
+++ b/news/4178.fixed.md
@@ -0,0 +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`. 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/config_settings/BUILD.bazel b/python/config_settings/BUILD.bazel
index fc59aaf..3dbb6c1 100644
--- a/python/config_settings/BUILD.bazel
+++ b/python/config_settings/BUILD.bazel
@@ -12,6 +12,7 @@
     "PbsDistributionFlag",
     "PrecompileFlag",
     "PrecompileSourceRetentionFlag",
+    "PyiDepsToRunfilesFlag",
     "ValidateTestMainFlag",
     "VenvsSitePackages",
     "VenvsUseDeclareSymlinkFlag",
@@ -90,6 +91,14 @@
 )
 
 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,
     values = sorted(PrecompileSourceRetentionFlag.__members__.values()),
diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel
index a59bf2a..a393d90 100644
--- a/python/private/BUILD.bazel
+++ b/python/private/BUILD.bazel
@@ -322,6 +322,7 @@
     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 e52cd8d..7fc87ed 100644
--- a/python/private/attributes.bzl
+++ b/python/private/attributes.bzl
@@ -307,7 +307,8 @@
 
 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
 :::
@@ -367,6 +368,10 @@
             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 5cff7f8..0a2d8a1 100644
--- a/python/private/common.bzl
+++ b/python/private/common.bzl
@@ -21,6 +21,7 @@
 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 @@
                 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(_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 082ae06..a3b9b2a 100644
--- a/python/private/common_labels.bzl
+++ b/python/private/common_labels.bzl
@@ -23,6 +23,7 @@
     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 676163a..372a779 100644
--- a/python/private/flags.bzl
+++ b/python/private/flags.bzl
@@ -108,6 +108,35 @@
     is_enabled = _ValidateTestMainFlag_is_enabled,
 )
 
+# Bazel compilation modes (e.g. from `ctx.var["COMPILATION_MODE"]`).
+# buildifier: disable=name-conventions
+CompilationMode = enum(
+    DBG = "dbg",
+    FASTBUILD = "fastbuild",
+    OPT = "opt",
+)
+
+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") == CompilationMode.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/python/private/precompile.bzl b/python/private/precompile.bzl
index ceaa76c..56666fe 100644
--- a/python/private/precompile.bzl
+++ b/python/private/precompile.bzl
@@ -16,7 +16,7 @@
 load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo")
 load(":attributes.bzl", "PrecompileAttr", "PrecompileInvalidationModeAttr", "PrecompileSourceRetentionAttr")
 load(":common.bzl", "actions_run", "is_py_source")
-load(":flags.bzl", "PrecompileFlag")
+load(":flags.bzl", "CompilationMode", "PrecompileFlag")
 load(":py_interpreter_program.bzl", "PyInterpreterProgramInfo")
 load(":toolchain_types.bzl", "EXEC_TOOLS_TOOLCHAIN_TYPE", "TARGET_TOOLCHAIN_TYPE")
 
@@ -144,7 +144,7 @@
 
     invalidation_mode = ctx.attr.precompile_invalidation_mode
     if invalidation_mode == PrecompileInvalidationModeAttr.AUTO:
-        if ctx.var["COMPILATION_MODE"] == "opt":
+        if ctx.var.get("COMPILATION_MODE") == CompilationMode.OPT:
             invalidation_mode = PrecompileInvalidationModeAttr.UNCHECKED_HASH
         else:
             invalidation_mode = PrecompileInvalidationModeAttr.CHECKED_HASH
diff --git a/python/private/py_info.bzl b/python/private/py_info.bzl
index dac1dde..79483ab 100644
--- a/python/private/py_info.bzl
+++ b/python/private/py_info.bzl
@@ -375,6 +375,7 @@
         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 @@
 
     _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 @@
         "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,
     }
@@ -559,6 +563,17 @@
 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 @@
     :type: DepsetBuilder[File]
     :::
 
+    :::{field} type_checking_info
+    :type: PyInfoBuilder | None
+
+    Builder for {obj}`PyInfo.type_checking_info`. `None` on nested
+    type-checking builders.
+
+    ::::{versionadded} VERSION_NEXT_FEATURE
+    ::::
+    :::
+
     :::{field} venv_symlinks
     :type: DepsetBuilder[tuple[str | None, str]]
     """
 
-def _PyInfoBuilder_new():
-    """Creates an instance.
-
-    Returns:
-        {type}`PyInfoBuilder`
-    """
-
+def _new_raw_py_info_builder(type_checking_info = None):
     # buildifier: disable=uninitialized
     self = struct(
         _has_py2_only_sources = [False],
@@ -660,6 +679,7 @@
         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),
@@ -670,10 +690,20 @@
         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.
 
@@ -819,19 +849,7 @@
     """
     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"):
@@ -855,6 +873,77 @@
             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_type_checking(self, *infos, direct = []):
+    """Merge type-checking fields from other PyInfos into this PyInfo.
+
+    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
+    :::
+
+    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)
+
+    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):
@@ -892,15 +981,7 @@
         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()],
@@ -920,9 +1001,22 @@
         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.
 
@@ -961,6 +1055,7 @@
     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 a9fadd7..9ee7e5b 100644
--- a/tests/base_rules/base_tests.bzl
+++ b/tests/base_rules/base_tests.bzl
@@ -72,6 +72,7 @@
         name = name + "_lib2",
         srcs = ["lib2.py"],
         pyi_srcs = ["lib2.pyi"],
+        imports = ["lib2_import"],
     )
 
     analysis_test(
@@ -90,8 +91,8 @@
     ])
     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",
@@ -100,6 +101,23 @@
         "{package}/lib2.pyi",
         "{package}/subject.pyi",
     ])
+    info.transitive_sources().contains_exactly([
+        "{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)
 
diff --git a/tests/base_rules/py_executable_base_tests.bzl b/tests/base_rules/py_executable_base_tests.bzl
index f6b3c9b..34b922a 100644
--- a/tests/base_rules/py_executable_base_tests.bzl
+++ b/tests/base_rules/py_executable_base_tests.bzl
@@ -519,6 +519,141 @@
 
 _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,
+        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_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,
diff --git a/tests/base_rules/py_info/py_info_tests.bzl b/tests/base_rules/py_info/py_info_tests.bzl
index 0e1a965..1aa7ff2 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 @@
     )
 
     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 @@
 
     builder.merge(targets.py3[PyInfo], direct = [targets.py4[PyInfo]])
     builder.merge_all([targets.py5[PyInfo]], direct = [targets.py6[PyInfo]])
+    builder.merge_type_checking(targets.py7[PyInfo], direct = [targets.py8[PyInfo]])
 
     def check(actual):
         subject = py_info_subject(actual, meta = env.expect.meta)
@@ -242,6 +243,7 @@
                 "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,39 @@
                 "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",
+            ])
+
+            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"):
@@ -265,13 +300,63 @@
             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/integration/whl_library/test_contents.py b/tests/integration/whl_library/test_contents.py
index 6db7245..58f1335 100644
--- a/tests/integration/whl_library/test_contents.py
+++ b/tests/integration/whl_library/test_contents.py
@@ -169,6 +169,7 @@
                     "@rules_python//python/config_settings:add_srcs_to_runfiles",
                     "@rules_python//python/config_settings:precompile",
                     "@rules_python//python/config_settings:precompile_source_retention",
+                    "@rules_python//python/config_settings:pyi_deps_to_runfiles",
                     "@rules_python//python/config_settings:venvs_site_packages",
                     "@rules_python//python/private:sentinel",
                 },
diff --git a/tests/support/py_info_subject.bzl b/tests/support/py_info_subject.bzl
index 9122eaa..f526f5d 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 @@
         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 @@
     )
     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 @@
         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.