refactor(pystar): load (but don't use) Starlark implementation. (#1428)

Always loading the code provides several benefits:
  * It's easier to reason about what code paths are taken.
* Iteratively working on them is simply changing an environment variable
instead of editing several files.
  * Ensures the files are loadable on older versions of Bazel.

Usage of the Starlark implemenation is controlled by an environment
variable, `RULES_PYTHON_ENABLE_PYSTAR=1`. An environment variable must
be used because the decision
about which implementation to use must be made before regular build
flags are able to
run (loading phase logic is affected).

The Starlark implementation is almost entirely compatible with pre-Bazel
7, except for the `py_internal` symbol. This symbol is special in a
couple ways:
  * It only exists within the `@rules_python` repo
  * It does not exist prior to Bazel 7.

This requires using a repo rule, `@rules_python_internal`, to do some
feature/version detection to generate a shim bzl file so that the
`py_internal` symbol is always loadable. Regular rules_python code then
loads the shim and can act accordingly.

Also fixes some other loading-time issues (beyond simply py_internal
being None):
* `configuration_field()` args are validated at time of call, so those
must
    be guarded so Bazel 5.4 doesn't fail on them.
* The `init` arg of `provider()` isn't supported under Bazel 5.4; change
them
    to no-op stubs behind a guard.
* The `|` operator for dicts isn't supported under Bazel 5.4; change to
use
    skylib's `dicts.add`


Work towards #1069
diff --git a/.bazelignore b/.bazelignore
index 135f709..025277a 100644
--- a/.bazelignore
+++ b/.bazelignore
@@ -6,6 +6,10 @@
 bazel-bin
 bazel-out
 bazel-testlogs
+# Prevent the convenience symlinks within the examples from being
+# treated as directories with valid BUILD files for the main repo.
 examples/bzlmod/bazel-bzlmod
+examples/bzlmod/other_module/bazel-other_module
 examples/bzlmod_build_file_generation/bazel-bzlmod_build_file_generation
+examples/pip_parse/bazel-pip_parse
 examples/py_proto_library/bazel-py_proto_library
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e319d28..2609bb2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,11 @@
 * (multi-version) The `distribs` attribute is no longer propagated. This
   attribute has been long deprecated by Bazel and shouldn't be used.
 
+* Calling `//python:repositories.bzl#py_repositories()` is required. It has
+  always been documented as necessary, but it was possible to omit it in certain
+  cases. An error about `@rules_python_internal` means the `py_repositories()`
+  call is missing in `WORKSPACE`.
+
 
 ### Added
 
diff --git a/MODULE.bazel b/MODULE.bazel
index aaa5c86..ab7b597 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -15,6 +15,7 @@
 internal_deps.install()
 use_repo(
     internal_deps,
+    "rules_python_internal",
     # START: maintained by 'bazel run //tools/private:update_pip_deps'
     "pypi__build",
     "pypi__click",
diff --git a/examples/build_file_generation/WORKSPACE b/examples/build_file_generation/WORKSPACE
index fa11380..03085d8 100644
--- a/examples/build_file_generation/WORKSPACE
+++ b/examples/build_file_generation/WORKSPACE
@@ -71,8 +71,11 @@
     path = "../../gazelle",
 )
 
-# Next we load the toolchain from rules_python.
-load("@rules_python//python:repositories.bzl", "python_register_toolchains")
+# Next we load the setup and toolchain from rules_python.
+load("@rules_python//python:repositories.bzl", "py_repositories", "python_register_toolchains")
+
+# Perform general setup
+py_repositories()
 
 # We now register a hermetic Python interpreter rather than relying on a system-installed interpreter.
 # This toolchain will allow bazel to download a specific python version, and use that version
diff --git a/gazelle/WORKSPACE b/gazelle/WORKSPACE
index b9ba91d..fe7ac3e 100644
--- a/gazelle/WORKSPACE
+++ b/gazelle/WORKSPACE
@@ -34,7 +34,9 @@
     path = "..",
 )
 
-load("@rules_python//python:repositories.bzl", "python_register_toolchains")
+load("@rules_python//python:repositories.bzl", "py_repositories", "python_register_toolchains")
+
+py_repositories()
 
 python_register_toolchains(
     name = "python39",
diff --git a/internal_setup.bzl b/internal_setup.bzl
index c3a7ad4..0c9d6c4 100644
--- a/internal_setup.bzl
+++ b/internal_setup.bzl
@@ -20,10 +20,13 @@
 load("@rules_proto//proto:repositories.bzl", "rules_proto_dependencies", "rules_proto_toolchains")
 load("//:version.bzl", "SUPPORTED_BAZEL_VERSIONS")
 load("//python/pip_install:repositories.bzl", "pip_install_dependencies")
+load("//python/private:internal_config_repo.bzl", "internal_config_repo")  # buildifier: disable=bzl-visibility
 
 def rules_python_internal_setup():
     """Setup for rules_python tests and tools."""
 
+    internal_config_repo(name = "rules_python_internal")
+
     # Because we don't use the pip_install rule, we have to call this to fetch its deps
     pip_install_dependencies()
 
diff --git a/python/BUILD.bazel b/python/BUILD.bazel
index aa8c8bf..3e0919c 100644
--- a/python/BUILD.bazel
+++ b/python/BUILD.bazel
@@ -84,7 +84,11 @@
 bzl_library(
     name = "py_binary_bzl",
     srcs = ["py_binary.bzl"],
-    deps = ["//python/private:util_bzl"],
+    deps = [
+        "//python/private:util_bzl",
+        "//python/private/common:py_binary_macro_bazel_bzl",
+        "@rules_python_internal//:rules_python_config_bzl",
+    ],
 )
 
 bzl_library(
@@ -101,19 +105,31 @@
 bzl_library(
     name = "py_info_bzl",
     srcs = ["py_info.bzl"],
-    deps = ["//python/private:reexports_bzl"],
+    deps = [
+        "//python/private:reexports_bzl",
+        "//python/private/common:providers_bzl",
+        "@rules_python_internal//:rules_python_config_bzl",
+    ],
 )
 
 bzl_library(
     name = "py_library_bzl",
     srcs = ["py_library.bzl"],
-    deps = ["//python/private:util_bzl"],
+    deps = [
+        "//python/private:util_bzl",
+        "//python/private/common:py_library_macro_bazel_bzl",
+        "@rules_python_internal//:rules_python_config_bzl",
+    ],
 )
 
 bzl_library(
     name = "py_runtime_bzl",
     srcs = ["py_runtime.bzl"],
-    deps = ["//python/private:util_bzl"],
+    deps = [
+        "//python/private:util_bzl",
+        "//python/private/common:py_runtime_macro_bzl",
+        "@rules_python_internal//:rules_python_config_bzl",
+    ],
 )
 
 bzl_library(
@@ -125,13 +141,21 @@
 bzl_library(
     name = "py_runtime_info_bzl",
     srcs = ["py_runtime_info.bzl"],
-    deps = ["//python/private:reexports_bzl"],
+    deps = [
+        "//python/private:reexports_bzl",
+        "//python/private/common:providers_bzl",
+        "@rules_python_internal//:rules_python_config_bzl",
+    ],
 )
 
 bzl_library(
     name = "py_test_bzl",
     srcs = ["py_test.bzl"],
-    deps = ["//python/private:util_bzl"],
+    deps = [
+        "//python/private:util_bzl",
+        "//python/private/common:py_test_macro_bazel_bzl",
+        "@rules_python_internal//:rules_python_config_bzl",
+    ],
 )
 
 # NOTE: Remember to add bzl_library targets to //tests:bzl_libraries
diff --git a/python/extensions/private/internal_deps.bzl b/python/extensions/private/internal_deps.bzl
index 8a98b82..aadf2cc 100644
--- a/python/extensions/private/internal_deps.bzl
+++ b/python/extensions/private/internal_deps.bzl
@@ -9,9 +9,11 @@
 "Python toolchain module extension for internal rule use"
 
 load("//python/pip_install:repositories.bzl", "pip_install_dependencies")
+load("//python/private:internal_config_repo.bzl", "internal_config_repo")
 
 # buildifier: disable=unused-variable
 def _internal_deps_impl(module_ctx):
+    internal_config_repo(name = "rules_python_internal")
     pip_install_dependencies()
 
 internal_deps = module_extension(
diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel
index 48c3f8c..5dd7b35 100644
--- a/python/private/BUILD.bazel
+++ b/python/private/BUILD.bazel
@@ -22,7 +22,11 @@
 
 filegroup(
     name = "distribution",
-    srcs = glob(["**"]) + ["//python/private/proto:distribution"],
+    srcs = glob(["**"]) + [
+        "//python/private/common:distribution",
+        "//python/private/proto:distribution",
+        "//tools/build_defs/python/private:distribution",
+    ],
     visibility = ["//python:__pkg__"],
 )
 
diff --git a/python/private/common/BUILD.bazel b/python/private/common/BUILD.bazel
index aa21042..f20e682 100644
--- a/python/private/common/BUILD.bazel
+++ b/python/private/common/BUILD.bazel
@@ -11,3 +11,194 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
+
+load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+
+package(
+    default_visibility = ["//python:__subpackages__"],
+)
+
+bzl_library(
+    name = "attributes_bazel_bzl",
+    srcs = ["attributes_bazel.bzl"],
+)
+
+bzl_library(
+    name = "attributes_bzl",
+    srcs = ["attributes.bzl"],
+    deps = [
+        ":common_bzl",
+        ":providers_bzl",
+        ":py_internal_bzl",
+        ":semantics_bzl",
+    ],
+)
+
+bzl_library(
+    name = "cc_helper_bzl",
+    srcs = ["cc_helper.bzl"],
+    deps = [":py_internal_bzl"],
+)
+
+bzl_library(
+    name = "common_bazel_bzl",
+    srcs = ["common_bazel.bzl"],
+    deps = [
+        ":common_bzl",
+        ":providers_bzl",
+        ":py_internal_bzl",
+        "@bazel_skylib//lib:paths",
+    ],
+)
+
+bzl_library(
+    name = "common_bzl",
+    srcs = ["common.bzl"],
+    deps = [
+        ":cc_helper_bzl",
+        ":providers_bzl",
+        ":py_internal_bzl",
+        ":semantics_bzl",
+    ],
+)
+
+filegroup(
+    name = "distribution",
+    srcs = glob(["**"]),
+)
+
+bzl_library(
+    name = "providers_bzl",
+    srcs = ["providers.bzl"],
+    deps = [
+        ":semantics_bzl",
+        "@rules_python_internal//:rules_python_config_bzl",
+    ],
+)
+
+bzl_library(
+    name = "py_binary_macro_bazel_bzl",
+    srcs = ["py_binary_macro_bazel.bzl"],
+    deps = [
+        ":common_bzl",
+        ":py_binary_rule_bazel_bzl",
+    ],
+)
+
+bzl_library(
+    name = "py_binary_rule_bazel_bzl",
+    srcs = ["py_binary_rule_bazel.bzl"],
+    deps = [
+        ":attributes_bzl",
+        ":py_executable_bazel_bzl",
+        ":semantics_bzl",
+        "@bazel_skylib//lib:dicts",
+    ],
+)
+
+bzl_library(
+    name = "py_executable_bazel_bzl",
+    srcs = ["py_executable_bazel.bzl"],
+    deps = [
+        ":attributes_bazel_bzl",
+        ":common_bazel_bzl",
+        ":common_bzl",
+        ":providers_bzl",
+        ":py_executable_bzl",
+        ":py_internal_bzl",
+        ":semantics_bzl",
+    ],
+)
+
+bzl_library(
+    name = "py_executable_bzl",
+    srcs = ["py_executable.bzl"],
+    deps = [
+        ":attributes_bzl",
+        ":cc_helper_bzl",
+        ":common_bzl",
+        ":providers_bzl",
+        ":py_internal_bzl",
+        "@bazel_skylib//lib:dicts",
+    ],
+)
+
+bzl_library(
+    name = "py_internal_bzl",
+    srcs = ["py_internal.bzl"],
+    deps = ["@rules_python_internal//:py_internal_bzl"],
+)
+
+bzl_library(
+    name = "py_library_bzl",
+    srcs = ["py_library.bzl"],
+    deps = [
+        ":attributes_bzl",
+        ":common_bzl",
+        ":providers_bzl",
+        ":py_internal_bzl",
+        "@bazel_skylib//lib:dicts",
+    ],
+)
+
+bzl_library(
+    name = "py_library_macro_bazel_bzl",
+    srcs = ["py_library_macro_bazel.bzl"],
+    deps = [":py_library_rule_bazel_bzl"],
+)
+
+bzl_library(
+    name = "py_library_rule_bazel_bzl",
+    srcs = ["py_library_rule_bazel.bzl"],
+    deps = [
+        ":attributes_bazel_bzl",
+        ":common_bazel_bzl",
+        ":common_bzl",
+        ":py_library_bzl",
+    ],
+)
+
+bzl_library(
+    name = "py_runtime_macro_bzl",
+    srcs = ["py_runtime_macro.bzl"],
+    deps = [":py_runtime_rule_bzl"],
+)
+
+bzl_library(
+    name = "py_runtime_rule_bzl",
+    srcs = ["py_runtime_rule.bzl"],
+    deps = [
+        ":attributes_bzl",
+        ":common_bzl",
+        ":providers_bzl",
+        ":py_internal_bzl",
+        "@bazel_skylib//lib:dicts",
+        "@bazel_skylib//lib:paths",
+    ],
+)
+
+bzl_library(
+    name = "py_test_macro_bazel_bzl",
+    srcs = ["py_test_macro_bazel.bzl"],
+    deps = [
+        ":common_bazel_bzl",
+        ":py_test_rule_bazel_bzl",
+    ],
+)
+
+bzl_library(
+    name = "py_test_rule_bazel_bzl",
+    srcs = ["py_test_rule_bazel.bzl"],
+    deps = [
+        ":attributes_bzl",
+        ":common_bzl",
+        ":py_executable_bazel_bzl",
+        ":semantics_bzl",
+        "@bazel_skylib//lib:dicts",
+    ],
+)
+
+bzl_library(
+    name = "semantics_bzl",
+    srcs = ["semantics.bzl"],
+)
diff --git a/python/private/common/attributes.bzl b/python/private/common/attributes.bzl
index ea43cea..6e184c0 100644
--- a/python/private/common/attributes.bzl
+++ b/python/private/common/attributes.bzl
@@ -26,7 +26,7 @@
 
 # TODO: Load CcInfo from rules_cc
 _CcInfo = CcInfo
-_PackageSpecificationInfo = py_internal.PackageSpecificationInfo
+_PackageSpecificationInfo = getattr(py_internal, "PackageSpecificationInfo", None)
 
 _STAMP_VALUES = [-1, 0, 1]
 
@@ -85,15 +85,28 @@
     ),
 }
 
-NATIVE_RULES_ALLOWLIST_ATTRS = {
-    "_native_rules_allowlist": attr.label(
+def _create_native_rules_allowlist_attrs():
+    if py_internal:
+        # The fragment and name are validated when configuration_field is called
         default = configuration_field(
             fragment = "py",
             name = "native_rules_allowlist",
+        )
+
+        # A None provider isn't allowed
+        providers = [_PackageSpecificationInfo]
+    else:
+        default = None
+        providers = []
+
+    return {
+        "_native_rules_allowlist": attr.label(
+            default = default,
+            providers = providers,
         ),
-        providers = [_PackageSpecificationInfo],
-    ),
-}
+    }
+
+NATIVE_RULES_ALLOWLIST_ATTRS = _create_native_rules_allowlist_attrs()
 
 # Attributes common to all rules.
 COMMON_ATTRS = union_attrs(
diff --git a/python/private/common/cc_helper.bzl b/python/private/common/cc_helper.bzl
index cef1ab1..552b42e 100644
--- a/python/private/common/cc_helper.bzl
+++ b/python/private/common/cc_helper.bzl
@@ -20,4 +20,4 @@
 
 load(":py_internal.bzl", "py_internal")
 
-cc_helper = py_internal.cc_helper
+cc_helper = getattr(py_internal, "cc_helper", None)
diff --git a/python/private/common/common.bzl b/python/private/common/common.bzl
index 8522d80..bffbf6f 100644
--- a/python/private/common/common.bzl
+++ b/python/private/common/common.bzl
@@ -27,7 +27,7 @@
 _platform_common = platform_common
 _coverage_common = coverage_common
 _py_builtins = py_internal
-PackageSpecificationInfo = py_internal.PackageSpecificationInfo
+PackageSpecificationInfo = getattr(py_internal, "PackageSpecificationInfo", None)
 
 TOOLCHAIN_TYPE = "@" + TOOLS_REPO + "//tools/python:toolchain_type"
 
diff --git a/python/private/common/providers.bzl b/python/private/common/providers.bzl
index 237a3e4..8a5089d 100644
--- a/python/private/common/providers.bzl
+++ b/python/private/common/providers.bzl
@@ -13,6 +13,7 @@
 # limitations under the License.
 """Providers for Python rules."""
 
+load("@rules_python_internal//:rules_python_config.bzl", "config")
 load(":semantics.bzl", "TOOLS_REPO")
 
 # TODO: load CcInfo from rules_cc
@@ -23,6 +24,18 @@
 DEFAULT_BOOTSTRAP_TEMPLATE = "@" + TOOLS_REPO + "//tools/python:python_bootstrap_template.txt"
 _PYTHON_VERSION_VALUES = ["PY2", "PY3"]
 
+# Helper to make the provider definitions not crash under Bazel 5.4:
+# Bazel 5.4 doesn't support the `init` arg of `provider()`, so we have to
+# not pass that when using Bazel 5.4. But, not passing the `init` arg
+# changes the return value from a two-tuple to a single value, which then
+# breaks Bazel 6+ code.
+# This isn't actually used under Bazel 5.4, so just stub out the values
+# to get past the loading phase.
+def _define_provider(doc, fields, **kwargs):
+    if not config.enable_pystar:
+        return provider("Stub, not used", fields = []), None
+    return provider(doc = doc, fields = fields, **kwargs)
+
 def _PyRuntimeInfo_init(
         *,
         interpreter_path = None,
@@ -82,7 +95,7 @@
 
 # TODO(#15897): Rename this to PyRuntimeInfo when we're ready to replace the Java
 # implemented provider with the Starlark one.
-PyRuntimeInfo, _unused_raw_py_runtime_info_ctor = provider(
+PyRuntimeInfo, _unused_raw_py_runtime_info_ctor = _define_provider(
     doc = """Contains information about a Python runtime, as returned by the `py_runtime`
 rule.
 
@@ -169,8 +182,8 @@
         "uses_shared_libraries": uses_shared_libraries,
     }
 
-PyInfo, _unused_raw_py_info_ctor = provider(
-    "Encapsulates information provided by the Python rules.",
+PyInfo, _unused_raw_py_info_ctor = _define_provider(
+    doc = "Encapsulates information provided by the Python rules.",
     init = _PyInfo_init,
     fields = {
         "has_py2_only_sources": "Whether any of this target's transitive sources requires a Python 2 runtime.",
@@ -200,7 +213,7 @@
     }
 
 # buildifier: disable=name-conventions
-PyCcLinkParamsProvider, _unused_raw_py_cc_link_params_provider_ctor = provider(
+PyCcLinkParamsProvider, _unused_raw_py_cc_link_params_provider_ctor = _define_provider(
     doc = ("Python-wrapper to forward CcInfo.linking_context. This is to " +
            "allow Python targets to propagate C++ linking information, but " +
            "without the Python target appearing to be a valid C++ rule dependency"),
diff --git a/python/private/common/py_binary_rule_bazel.bzl b/python/private/common/py_binary_rule_bazel.bzl
index 6c324d8..491d905 100644
--- a/python/private/common/py_binary_rule_bazel.bzl
+++ b/python/private/common/py_binary_rule_bazel.bzl
@@ -13,6 +13,7 @@
 # limitations under the License.
 """Rule implementation of py_binary for Bazel."""
 
+load("@bazel_skylib//lib:dicts.bzl", "dicts")
 load(":attributes.bzl", "AGNOSTIC_BINARY_ATTRS")
 load(
     ":py_executable_bazel.bzl",
@@ -43,6 +44,6 @@
 
 py_binary = create_executable_rule(
     implementation = _py_binary_impl,
-    attrs = AGNOSTIC_BINARY_ATTRS | _PY_TEST_ATTRS,
+    attrs = dicts.add(AGNOSTIC_BINARY_ATTRS, _PY_TEST_ATTRS),
     executable = True,
 )
diff --git a/python/private/common/py_executable.bzl b/python/private/common/py_executable.bzl
index 7a50a75..98a29be 100644
--- a/python/private/common/py_executable.bzl
+++ b/python/private/common/py_executable.bzl
@@ -13,6 +13,7 @@
 # limitations under the License.
 """Common functionality between test/binary executables."""
 
+load("@bazel_skylib//lib:dicts.bzl", "dicts")
 load(
     ":attributes.bzl",
     "AGNOSTIC_EXECUTABLE_ATTRS",
@@ -452,7 +453,7 @@
 
     ctx.actions.run(
         executable = ctx.executable._build_data_gen,
-        env = {
+        env = dicts.add({
             # NOTE: ctx.info_file is undocumented; see
             # https://github.com/bazelbuild/bazel/issues/9363
             "INFO_FILE": ctx.info_file.path,
@@ -460,7 +461,7 @@
             "PLATFORM": cc_helper.find_cpp_toolchain(ctx).toolchain_id,
             "TARGET": str(ctx.label),
             "VERSION_FILE": version_file.path,
-        } | extra_write_build_data_env,
+        }, extra_write_build_data_env),
         inputs = depset(
             direct = direct_inputs,
         ),
@@ -808,8 +809,8 @@
         fragments = fragments + ["py"]
     return rule(
         # TODO: add ability to remove attrs, i.e. for imports attr
-        attrs = EXECUTABLE_ATTRS | attrs,
-        toolchains = [TOOLCHAIN_TYPE] + cc_helper.use_cpp_toolchain(),
+        attrs = dicts.add(EXECUTABLE_ATTRS, attrs),
+        toolchains = [TOOLCHAIN_TYPE] + (cc_helper.use_cpp_toolchain() if cc_helper else []),
         fragments = fragments,
         **kwargs
     )
diff --git a/python/private/common/py_executable_bazel.bzl b/python/private/common/py_executable_bazel.bzl
index a145d42..6c50b75 100644
--- a/python/private/common/py_executable_bazel.bzl
+++ b/python/private/common/py_executable_bazel.bzl
@@ -13,6 +13,7 @@
 # limitations under the License.
 """Implementation for Bazel Python executable."""
 
+load("@bazel_skylib//lib:dicts.bzl", "dicts")
 load("@bazel_skylib//lib:paths.bzl", "paths")
 load(":attributes_bazel.bzl", "IMPORTS_ATTRS")
 load(
@@ -62,10 +63,13 @@
             executable = True,
         ),
         "_py_interpreter": attr.label(
+            # The configuration_field args are validated when called;
+            # we use the precense of py_internal to indicate this Bazel
+            # build has that fragment and name.
             default = configuration_field(
                 fragment = "bazel_py",
                 name = "python_top",
-            ),
+            ) if py_internal else None,
         ),
         # TODO: This appears to be vestigial. It's only added because
         # GraphlessQueryTest.testLabelsOperator relies on it to test for
@@ -88,7 +92,7 @@
 
 def create_executable_rule(*, attrs, **kwargs):
     return create_base_executable_rule(
-        attrs = BAZEL_EXECUTABLE_ATTRS | attrs,
+        attrs = dicts.add(BAZEL_EXECUTABLE_ATTRS, attrs),
         fragments = ["py", "bazel_py"],
         **kwargs
     )
diff --git a/python/private/common/py_internal.bzl b/python/private/common/py_internal.bzl
index c17bbf0..4296372 100644
--- a/python/private/common/py_internal.bzl
+++ b/python/private/common/py_internal.bzl
@@ -18,7 +18,9 @@
 These may change at any time and are closely coupled to the rule implementation.
 """
 
-# buildifier: disable=bzl-visibility
-load("//tools/build_defs/python/private:py_internal_renamed.bzl", "py_internal_renamed")
+# The py_internal global is only available in Bazel 7+, so loading of it
+# must go through a repo rule with Bazel version detection logic.
+load("@rules_python_internal//:py_internal.bzl", "py_internal_impl")
 
-py_internal = py_internal_renamed
+# NOTE: This is None prior to Bazel 7, as set by @rules_python_internal
+py_internal = py_internal_impl
diff --git a/python/private/common/py_library.bzl b/python/private/common/py_library.bzl
index ca71e72..8d09c51 100644
--- a/python/private/common/py_library.bzl
+++ b/python/private/common/py_library.bzl
@@ -13,6 +13,7 @@
 # limitations under the License.
 """Implementation of py_library rule."""
 
+load("@bazel_skylib//lib:dicts.bzl", "dicts")
 load(
     ":attributes.bzl",
     "COMMON_ATTRS",
@@ -92,7 +93,7 @@
         A rule object
     """
     return rule(
-        attrs = LIBRARY_ATTRS | attrs,
+        attrs = dicts.add(LIBRARY_ATTRS, attrs),
         # TODO(b/253818097): fragments=py is only necessary so that
         # RequiredConfigFragmentsTest passes
         fragments = ["py"],
diff --git a/python/private/common/py_runtime_rule.bzl b/python/private/common/py_runtime_rule.bzl
index 4bffb87..3943404 100644
--- a/python/private/common/py_runtime_rule.bzl
+++ b/python/private/common/py_runtime_rule.bzl
@@ -13,6 +13,7 @@
 # limitations under the License.
 """Implementation of py_runtime rule."""
 
+load("@bazel_skylib//lib:dicts.bzl", "dicts")
 load("@bazel_skylib//lib:paths.bzl", "paths")
 load(":attributes.bzl", "NATIVE_RULES_ALLOWLIST_ATTRS")
 load(":common.bzl", "check_native_allowed")
@@ -128,7 +129,7 @@
 ```
 """,
     fragments = ["py"],
-    attrs = NATIVE_RULES_ALLOWLIST_ATTRS | {
+    attrs = dicts.add(NATIVE_RULES_ALLOWLIST_ATTRS, {
         "bootstrap_template": attr.label(
             allow_single_file = True,
             default = DEFAULT_BOOTSTRAP_TEMPLATE,
@@ -211,5 +212,5 @@
 Does not apply to Windows.
 """,
         ),
-    },
+    }),
 )
diff --git a/python/private/common/py_test_rule_bazel.bzl b/python/private/common/py_test_rule_bazel.bzl
index de1aa45..348935e 100644
--- a/python/private/common/py_test_rule_bazel.bzl
+++ b/python/private/common/py_test_rule_bazel.bzl
@@ -13,6 +13,7 @@
 # limitations under the License.
 """Rule implementation of py_test for Bazel."""
 
+load("@bazel_skylib//lib:dicts.bzl", "dicts")
 load(":attributes.bzl", "AGNOSTIC_TEST_ATTRS")
 load(":common.bzl", "maybe_add_test_execution_info")
 load(
@@ -50,6 +51,6 @@
 
 py_test = create_executable_rule(
     implementation = _py_test_impl,
-    attrs = AGNOSTIC_TEST_ATTRS | _BAZEL_PY_TEST_ATTRS,
+    attrs = dicts.add(AGNOSTIC_TEST_ATTRS, _BAZEL_PY_TEST_ATTRS),
     test = True,
 )
diff --git a/python/private/internal_config_repo.bzl b/python/private/internal_config_repo.bzl
new file mode 100644
index 0000000..cfc7616
--- /dev/null
+++ b/python/private/internal_config_repo.bzl
@@ -0,0 +1,99 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Repository to generate configuration settings info from the environment.
+
+This handles settings that can't be encoded as regular build configuration flags,
+such as globals available to Bazel versions, or propagating user environment
+settings for rules to later use.
+"""
+
+load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED")
+
+_ENABLE_PYSTAR_ENVVAR_NAME = "RULES_PYTHON_ENABLE_PYSTAR"
+_ENABLE_PYSTAR_DEFAULT = "0"
+
+_CONFIG_TEMPLATE = """\
+config = struct(
+  enable_pystar = {enable_pystar},
+)
+"""
+
+# The py_internal symbol is only accessible from within @rules_python, so we have to
+# load it from there and re-export it so that rules_python can later load it.
+_PY_INTERNAL_SHIM = """\
+load("@rules_python//tools/build_defs/python/private:py_internal_renamed.bzl", "py_internal_renamed")
+py_internal_impl = py_internal_renamed
+"""
+
+ROOT_BUILD_TEMPLATE = """\
+load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+
+package(
+    default_visibility = [
+        "{visibility}",
+    ]
+)
+
+bzl_library(
+    name = "rules_python_config_bzl",
+    srcs = ["rules_python_config.bzl"]
+)
+
+bzl_library(
+    name = "py_internal_bzl",
+    srcs = ["py_internal.bzl"],
+    deps = [{py_internal_dep}],
+)
+"""
+
+def _internal_config_repo_impl(rctx):
+    enable_pystar = _bool_from_environ(rctx, _ENABLE_PYSTAR_ENVVAR_NAME, _ENABLE_PYSTAR_DEFAULT)
+    rctx.file("rules_python_config.bzl", _CONFIG_TEMPLATE.format(
+        enable_pystar = enable_pystar,
+    ))
+
+    if enable_pystar or (
+        # Bazel 7+ (dev and later) has native.starlark_doc_extract, and thus the py_internal global
+        hasattr(native, "starlark_doc_extract") and
+        # The logic to allow the symbol doesn't work properly under bzlmod,
+        # even if the symbol is otherwise functional.
+        not BZLMOD_ENABLED
+    ):
+        shim_content = _PY_INTERNAL_SHIM
+        py_internal_dep = '"@rules_python//tools/build_defs/python/private:py_internal_renamed_bzl"'
+    else:
+        shim_content = "py_internal_impl = None\n"
+        py_internal_dep = ""
+
+    # Bazel 5 doesn't support repository visibility, so just use public
+    # as a stand-in
+    if native.bazel_version.startswith("5."):
+        visibility = "//visibility:public"
+    else:
+        visibility = "@rules_python//:__subpackages__"
+
+    rctx.file("BUILD", ROOT_BUILD_TEMPLATE.format(
+        py_internal_dep = py_internal_dep,
+        visibility = visibility,
+    ))
+    rctx.file("py_internal.bzl", shim_content)
+    return None
+
+internal_config_repo = repository_rule(
+    implementation = _internal_config_repo_impl,
+    environ = [_ENABLE_PYSTAR_ENVVAR_NAME],
+)
+
+def _bool_from_environ(rctx, key, default):
+    return bool(int(rctx.os.environ.get(key, default)))
diff --git a/python/py_binary.bzl b/python/py_binary.bzl
index 6b6f7e0..6dcb4ad 100644
--- a/python/py_binary.bzl
+++ b/python/py_binary.bzl
@@ -14,7 +14,12 @@
 
 """Public entry point for py_binary."""
 
+load("@rules_python_internal//:rules_python_config.bzl", "config")
 load("//python/private:util.bzl", "add_migration_tag")
+load("//python/private/common:py_binary_macro_bazel.bzl", _starlark_py_binary = "py_binary")
+
+# buildifier: disable=native-python
+_py_binary_impl = _starlark_py_binary if config.enable_pystar else native.py_binary
 
 def py_binary(**attrs):
     """See the Bazel core [py_binary](https://docs.bazel.build/versions/master/be/python.html#py_binary) documentation.
@@ -27,5 +32,4 @@
     if attrs.get("srcs_version") in ("PY2", "PY2ONLY"):
         fail("Python 2 is no longer supported: https://github.com/bazelbuild/rules_python/issues/886")
 
-    # buildifier: disable=native-python
-    native.py_binary(**add_migration_tag(attrs))
+    _py_binary_impl(**add_migration_tag(attrs))
diff --git a/python/py_info.bzl b/python/py_info.bzl
index 2c3997d..cbf145d 100644
--- a/python/py_info.bzl
+++ b/python/py_info.bzl
@@ -14,6 +14,8 @@
 
 """Public entry point for PyInfo."""
 
+load("@rules_python_internal//:rules_python_config.bzl", "config")
 load("//python/private:reexports.bzl", "internal_PyInfo")
+load("//python/private/common:providers.bzl", _starlark_PyInfo = "PyInfo")
 
-PyInfo = internal_PyInfo
+PyInfo = _starlark_PyInfo if config.enable_pystar else internal_PyInfo
diff --git a/python/py_library.bzl b/python/py_library.bzl
index d54cbb2..ef4c3c3 100644
--- a/python/py_library.bzl
+++ b/python/py_library.bzl
@@ -14,7 +14,12 @@
 
 """Public entry point for py_library."""
 
+load("@rules_python_internal//:rules_python_config.bzl", "config")
 load("//python/private:util.bzl", "add_migration_tag")
+load("//python/private/common:py_library_macro_bazel.bzl", _starlark_py_library = "py_library")
+
+# buildifier: disable=native-python
+_py_library_impl = _starlark_py_library if config.enable_pystar else native.py_library
 
 def py_library(**attrs):
     """See the Bazel core [py_library](https://docs.bazel.build/versions/master/be/python.html#py_library) documentation.
@@ -25,5 +30,4 @@
     if attrs.get("srcs_version") in ("PY2", "PY2ONLY"):
         fail("Python 2 is no longer supported: https://github.com/bazelbuild/rules_python/issues/886")
 
-    # buildifier: disable=native-python
-    native.py_library(**add_migration_tag(attrs))
+    _py_library_impl(**add_migration_tag(attrs))
diff --git a/python/py_runtime.bzl b/python/py_runtime.bzl
index b70f9d4..ac8b090 100644
--- a/python/py_runtime.bzl
+++ b/python/py_runtime.bzl
@@ -14,7 +14,12 @@
 
 """Public entry point for py_runtime."""
 
+load("@rules_python_internal//:rules_python_config.bzl", "config")
 load("//python/private:util.bzl", "add_migration_tag")
+load("//python/private/common:py_runtime_macro.bzl", _starlark_py_runtime = "py_runtime")
+
+# buildifier: disable=native-python
+_py_runtime_impl = _starlark_py_runtime if config.enable_pystar else native.py_runtime
 
 def py_runtime(**attrs):
     """See the Bazel core [py_runtime](https://docs.bazel.build/versions/master/be/python.html#py_runtime) documentation.
@@ -25,5 +30,4 @@
     if attrs.get("python_version") == "PY2":
         fail("Python 2 is no longer supported: see https://github.com/bazelbuild/rules_python/issues/886")
 
-    # buildifier: disable=native-python
-    native.py_runtime(**add_migration_tag(attrs))
+    _py_runtime_impl(**add_migration_tag(attrs))
diff --git a/python/py_runtime_info.bzl b/python/py_runtime_info.bzl
index 15598ee..699b31d 100644
--- a/python/py_runtime_info.bzl
+++ b/python/py_runtime_info.bzl
@@ -14,6 +14,8 @@
 
 """Public entry point for PyRuntimeInfo."""
 
+load("@rules_python_internal//:rules_python_config.bzl", "config")
 load("//python/private:reexports.bzl", "internal_PyRuntimeInfo")
+load("//python/private/common:providers.bzl", _starlark_PyRuntimeInfo = "PyRuntimeInfo")
 
-PyRuntimeInfo = internal_PyRuntimeInfo
+PyRuntimeInfo = _starlark_PyRuntimeInfo if config.enable_pystar else internal_PyRuntimeInfo
diff --git a/python/py_test.bzl b/python/py_test.bzl
index 09580c0..ad9bdc0 100644
--- a/python/py_test.bzl
+++ b/python/py_test.bzl
@@ -14,7 +14,12 @@
 
 """Public entry point for py_test."""
 
+load("@rules_python_internal//:rules_python_config.bzl", "config")
 load("//python/private:util.bzl", "add_migration_tag")
+load("//python/private/common:py_test_macro_bazel.bzl", _starlark_py_test = "py_test")
+
+# buildifier: disable=native-python
+_py_test_impl = _starlark_py_test if config.enable_pystar else native.py_test
 
 def py_test(**attrs):
     """See the Bazel core [py_test](https://docs.bazel.build/versions/master/be/python.html#py_test) documentation.
@@ -28,4 +33,4 @@
         fail("Python 2 is no longer supported: https://github.com/bazelbuild/rules_python/issues/886")
 
     # buildifier: disable=native-python
-    native.py_test(**add_migration_tag(attrs))
+    _py_test_impl(**add_migration_tag(attrs))
diff --git a/python/repositories.bzl b/python/repositories.bzl
index ea4a927..9b3926a 100644
--- a/python/repositories.bzl
+++ b/python/repositories.bzl
@@ -21,6 +21,7 @@
 load("@bazel_tools//tools/build_defs/repo:utils.bzl", "maybe", "read_netrc", "read_user_netrc", "use_netrc")
 load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED")
 load("//python/private:coverage_deps.bzl", "coverage_dep")
+load("//python/private:internal_config_repo.bzl", "internal_config_repo")
 load(
     "//python/private:toolchains_repo.bzl",
     "multi_toolchain_aliases",
@@ -46,6 +47,10 @@
     This function should be loaded and called in the user's WORKSPACE.
     With bzlmod enabled, this function is not needed since MODULE.bazel handles transitive deps.
     """
+    maybe(
+        internal_config_repo,
+        name = "rules_python_internal",
+    )
     http_archive(
         name = "bazel_skylib",
         sha256 = "74d544d96f4a5bb630d465ca8bbcfe231e3594e5aae57e1edbf17a6eb3ca2506",
diff --git a/python/tests/toolchains/workspace_template/WORKSPACE.tmpl b/python/tests/toolchains/workspace_template/WORKSPACE.tmpl
index 973e020..3335f4b 100644
--- a/python/tests/toolchains/workspace_template/WORKSPACE.tmpl
+++ b/python/tests/toolchains/workspace_template/WORKSPACE.tmpl
@@ -19,7 +19,9 @@
     path = "",
 )
 
-load("@rules_python//python:repositories.bzl", "python_register_toolchains")
+load("@rules_python//python:repositories.bzl", "python_register_toolchains", "py_repositories")
+
+py_repositories()
 
 python_register_toolchains(
     name = "python",
diff --git a/tests/ignore_root_user_error/WORKSPACE b/tests/ignore_root_user_error/WORKSPACE
index e0528e4..d1249fe 100644
--- a/tests/ignore_root_user_error/WORKSPACE
+++ b/tests/ignore_root_user_error/WORKSPACE
@@ -3,7 +3,9 @@
     path = "../..",
 )
 
-load("@rules_python//python:repositories.bzl", "python_register_toolchains")
+load("@rules_python//python:repositories.bzl", "py_repositories", "python_register_toolchains")
+
+py_repositories()
 
 python_register_toolchains(
     name = "python39",
diff --git a/tools/build_defs/python/private/BUILD.bazel b/tools/build_defs/python/private/BUILD.bazel
index aa21042..0a7f308 100644
--- a/tools/build_defs/python/private/BUILD.bazel
+++ b/tools/build_defs/python/private/BUILD.bazel
@@ -11,3 +11,17 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
+
+load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+
+filegroup(
+    name = "distribution",
+    srcs = glob(["**"]),
+    visibility = ["//python:__subpackages__"],
+)
+
+bzl_library(
+    name = "py_internal_renamed_bzl",
+    srcs = ["py_internal_renamed.bzl"],
+    visibility = ["@rules_python_internal//:__subpackages__"],
+)
diff --git a/tools/build_defs/python/private/py_internal_renamed.bzl b/tools/build_defs/python/private/py_internal_renamed.bzl
index abab31c..a12fc2d 100644
--- a/tools/build_defs/python/private/py_internal_renamed.bzl
+++ b/tools/build_defs/python/private/py_internal_renamed.bzl
@@ -13,6 +13,10 @@
 # limitations under the License.
 """PYTHON RULE IMPLEMENTATION ONLY: Do not use outside of the rule implementations and their tests.
 
+NOTE: This file is only loaded by @rules_python_internal//:py_internal.bzl. This
+is because the `py_internal` global symbol is only present in Bazel 7+, so
+a repo rule has to conditionally load this depending on the Bazel version.
+
 Re-exports the restricted-use py_internal helper under another name. This is
 necessary because `py_internal = py_internal` results in an error (trying
 to bind a local symbol to itself before its defined).
@@ -22,4 +26,5 @@
 
 These may change at any time and are closely coupled to the rule implementation.
 """
+
 py_internal_renamed = py_internal