Add better handling for systems without python3 installed.

The current behavior will crash any Bazel command immediately, due to our declared pip dependencies in WORKSPACE, if python3 can't be found.  The new behavior will mock out these workspace dependencies and allow any non-python targets to run.  Python targets will be skipped by wildcard expressions if there's no system python3, and will fail when run directly, due to compatibility mismatch.

PiperOrigin-RevId: 492085254
diff --git a/bazel/system_python.bzl b/bazel/system_python.bzl
index 7e2dac9..89cef15 100644
--- a/bazel/system_python.bzl
+++ b/bazel/system_python.bzl
@@ -25,7 +25,60 @@
 
 """Repository rule for using Python 3.x headers from the system."""
 
+# Mock out rules_python's pip.bzl for cases where no system python is found.
+_mock_pip = """
+def _pip_install_impl(repository_ctx):
+    repository_ctx.file("BUILD.bazel", '''
+py_library(
+    name = "noop",
+    visibility = ["//visibility:public"],
+)
+''')
+    repository_ctx.file("requirements.bzl", '''
+def install_deps(*args, **kwargs):
+    print("WARNING: could not install pip dependencies")
+
+def requirement(*args, **kwargs):
+    return "@{}//:noop"
+'''.format(repository_ctx.attr.name))
+pip_install = repository_rule(
+    implementation = _pip_install_impl,
+    attrs = {
+        "requirements": attr.string(),
+        "requirements_overrides": attr.string_dict(),
+        "python_interpreter_target": attr.string(),
+    },
+)
+pip_parse = pip_install
+"""
+
+# Alias rules_python's pip.bzl for cases where a system pythong is found.
+_alias_pip = """
+load("@rules_python//python:pip.bzl", _pip_install = "pip_install", _pip_parse = "pip_parse")
+def _get_requirements(requirements, requirements_overrides):
+    for version, override in requirements_overrides.items():
+        if version in "{python_version}":
+            requirements = override
+            break
+    return requirements
+
+def pip_install(requirements, requirements_overrides={{}}, **kwargs):
+    _pip_install(
+        python_interpreter_target = "@{repo}//:interpreter",
+        requirements = _get_requirements(requirements, requirements_overrides),
+        **kwargs,
+    )
+def pip_parse(requirements, requirements_overrides={{}}, **kwargs):
+    _pip_parse(
+        python_interpreter_target = "@{repo}//:interpreter",
+        requirements = _get_requirements(requirements, requirements_overrides),
+        **kwargs,
+    )
+"""
+
 _build_file = """
+load("@bazel_skylib//lib:selects.bzl", "selects")
+load("@bazel_skylib//rules:common_settings.bzl", "string_flag")
 load("@bazel_tools//tools/python:toolchain.bzl", "py_runtime_pair")
 
 cc_library(
@@ -35,9 +88,55 @@
    visibility = ["//visibility:public"],
 )
 
+string_flag(
+    name = "internal_python_support",
+    build_setting_default = "{support}",
+    values = [
+        "None",
+        "Supported",
+        "Unsupported",
+    ]
+)
+
+config_setting(
+    name = "none",
+    flag_values = {{
+        ":internal_python_support": "None",
+    }},
+    visibility = ["//visibility:public"],
+)
+
+config_setting(
+    name = "supported",
+    flag_values = {{
+        ":internal_python_support": "Supported",
+    }},
+    visibility = ["//visibility:public"],
+)
+
+config_setting(
+    name = "unsupported",
+    flag_values = {{
+        ":internal_python_support": "Unsupported",
+    }},
+    visibility = ["//visibility:public"],
+)
+
+selects.config_setting_group(
+    name = "exists",
+    match_any = [":supported", ":unsupported"],
+    visibility = ["//visibility:public"],
+)
+
+sh_binary(
+    name = "interpreter",
+    srcs = ["interpreter"],
+    visibility = ["//visibility:public"],
+)
+
 py_runtime(
     name = "py3_runtime",
-    interpreter_path = "{}",
+    interpreter_path = "{interpreter}",
     python_version = "PY3",
 )
 
@@ -53,40 +152,105 @@
 )
 """
 
-def _get_python_version(repository_ctx):
-    py_program = "import sys; print(str(sys.version_info.major) + str(sys.version_info.minor))"
-    result = repository_ctx.execute(["python3", "-c", py_program])
-    return (result.stdout).strip()
+_register = """
+def register_system_python():
+    native.register_toolchains("@{}//:python_toolchain")
+"""
 
-def _get_config_var(repository_ctx, name):
+_mock_register = """
+def register_system_python():
+    pass
+"""
+
+def _get_python_version(repository_ctx):
+    py_program = "import sys; print(str(sys.version_info.major) + '.' + str(sys.version_info.minor) + '.' + str(sys.version_info.micro))"
+    result = repository_ctx.execute(["python3", "-c", py_program])
+    return (result.stdout).strip().split(".")
+
+def _get_python_path(repository_ctx):
     py_program = "import sysconfig; print(sysconfig.get_config_var('%s'), end='')"
-    result = repository_ctx.execute(["python3", "-c", py_program % (name)])
+    result = repository_ctx.execute(["python3", "-c", py_program % ("INCLUDEPY")])
     if result.return_code != 0:
         return None
     return result.stdout
 
-def _python_headers_impl(repository_ctx):
-    path = _get_config_var(repository_ctx, "INCLUDEPY")
-    if not path:
-        # buildifier: disable=print
-        print("WARNING: no system python available, builds against system python will fail")
-        repository_ctx.file("BUILD.bazel", "")
-        repository_ctx.file("version.bzl", "SYSTEM_PYTHON_VERSION = None")
-        return
-    repository_ctx.symlink(path, "python")
+def _populate_package(ctx, path, python3, python_version):
+    ctx.symlink(path, "python")
+    support = "Supported"
+    for idx, v in enumerate(ctx.attr.minimum_python_version.split(".")):
+        if int(python_version[idx]) < int(v):
+            support = "Unsupported"
+            break
+
+    build_file = _build_file.format(
+        interpreter = python3,
+        support = support,
+    )
+
+    ctx.file("interpreter", "exec {} \"$@\"".format(python3))
+    ctx.file("BUILD.bazel", build_file)
+    ctx.file("version.bzl", "SYSTEM_PYTHON_VERSION = '{}{}'".format(python_version[0], python_version[1]))
+    ctx.file("register.bzl", _register.format(ctx.attr.name))
+    ctx.file("pip.bzl", _alias_pip.format(
+        python_version = ".".join(python_version),
+        repo = ctx.attr.name,
+    ))
+
+def _populate_empty_package(ctx):
+    # Mock out all the entrypoints we need to run from WORKSPACE.  Targets that
+    # actually need python should use `target_compatible_with` and the generated
+    # @system_python//:exists or @system_python//:supported constraints.
+    ctx.file(
+        "BUILD.bazel",
+        _build_file.format(
+            interpreter = "",
+            support = "None",
+        ),
+    )
+    ctx.file("version.bzl", "SYSTEM_PYTHON_VERSION = 'None'")
+    ctx.file("register.bzl", _mock_register)
+    ctx.file("pip.bzl", _mock_pip)
+
+def _system_python_impl(repository_ctx):
+    path = _get_python_path(repository_ctx)
     python3 = repository_ctx.which("python3")
     python_version = _get_python_version(repository_ctx)
-    repository_ctx.file("BUILD.bazel", _build_file.format(python3))
-    repository_ctx.file("version.bzl", "SYSTEM_PYTHON_VERSION = '{}'".format(python_version))
 
-# The system_python() repository rule exposes Python headers from the system.
+    if path and python_version[0] == "3":
+        _populate_package(repository_ctx, path, python3, python_version)
+    else:
+        # buildifier: disable=print
+        print("WARNING: no system python available, builds against system python will fail")
+        _populate_empty_package(repository_ctx)
+
+# The system_python() repository rule exposes information from the version of python installed in the current system.
 #
 # In WORKSPACE:
 #   system_python(
 #       name = "system_python_repo",
+#       minimum_python_version = "3.7",
 #   )
 #
-# This repository exposes a single rule that you can depend on from BUILD:
+# This repository exposes some repository rules for configuring python in Bazel.  The python toolchain
+# *must* be registered in your WORKSPACE:
+#   load("@system_python_repo//:register.bzl", "register_system_python")
+#   register_system_python()
+#
+# Pip dependencies can optionally be specified using a wrapper around rules_python's repository rules:
+#   load("@system_python//:pip.bzl", "pip_install")
+#   pip_install(
+#       name="pip_deps",
+#       requirements = "@com_google_protobuf//python:requirements.txt",
+#   )
+# An optional argument `requirements_overrides` takes a dictionary mapping python versions to alternate
+# requirements files.  This works around the requirement for fully pinned dependencies in python_rules.
+#
+# Four config settings are exposed from this repository to help declare target compatibility in Bazel.
+# For example, `@system_python_repo//:exists` will be true if a system python version has been found.
+# The `none` setting will be true only if no python version was found, and `supported`/`unsupported`
+# correspond to whether or not the system version is compatible with `minimum_python_version`.
+#
+# This repository also exposes a header rule that you can depend on from BUILD files:
 #   cc_library(
 #     name = "foobar",
 #     srcs = ["foobar.cc"],
@@ -96,6 +260,9 @@
 # The headers should correspond to the version of python obtained by running
 # the `python3` command on the system.
 system_python = repository_rule(
-    implementation = _python_headers_impl,
+    implementation = _system_python_impl,
     local = True,
+    attrs = {
+        "minimum_python_version": attr.string(default = "3.7"),
+    },
 )