fix(local_runtime): Improve local_runtime usability in macos / windows (#3148)
local_runtime fails to handle many variations of python install on
Windows and
MacOS, such as:
* LDLIBRARY on MacOS may refer to a file under PYTHONFRAMEWORKPREFIX,
not LIBDIR
* LDLIBRARY on Windows refers to pythonXY.dll, not the linkable
pythonXY.lib
* LIBDIR may not be correctly set on Windows.
* On windows, interpreter_path needs to be normalized. Other paths also
require this.
* SHLIB_SUFFIX does not indicate the correct suffix.
For examples, see: https://docs.python.org/3/extending/windows.html
In order to resolve this the shared library resolution has been moved
into get_local_runtime_info.py, which now does the following:
* Constructs a list of paths to search based on LIBDIR, LIBPL,
PYTHONFRAMEWORKPREFIX,
and the executable directory.
* Constructs a list of libraries to search based on INSTSONAME,
LDLIBRARY,
pythonXY.lib, etc.
* Checks to see which files exist, partitioning the result into a list
of
"dynamic_libraries" and "static_libraries"
On Windows and macOS, since SHLIB_SUFFIX does not always indicate the
filenames
needed searching, this has been removed from local_runtime_repo_setup
and
replaced with an explicit file.
On Windows the interpreter_path and other search paths are now
normalized
(`\` converted to `/`).
Additional logging added to local_runtime_repo.
Fixes https://github.com/bazel-contrib/rules_python/issues/3055
Work towards https://github.com/bazel-contrib/rules_python/issues/824
---------
Co-authored-by: Richard Levasseur <richardlev@gmail.com>
diff --git a/.bazelci/presubmit.yml b/.bazelci/presubmit.yml
index 6457363..5889823 100644
--- a/.bazelci/presubmit.yml
+++ b/.bazelci/presubmit.yml
@@ -57,6 +57,7 @@
- "--enable_workspace"
- "--build_tag_filters=-integration-test"
bazel: 7.x
+# NOTE: The Mac and Windows bazelinbazel jobs override parts of this config.
.common_bazelinbazel_config: &common_bazelinbazel_config
build_flags:
- "--build_tag_filters=integration-test"
@@ -503,6 +504,24 @@
<<: *common_bazelinbazel_config
name: "tests/integration bazel-in-bazel: Debian"
platform: debian11
+ # The bazelinbazel tests were disabled on Mac to save CI jobs slots, and
+ # have bitrotted a bit. For now, just run a subset of what we're most
+ # interested in.
+ integration_test_bazelinbazel_macos:
+ <<: *common_bazelinbazel_config
+ name: "tests/integration bazel-in-bazel: macOS (subset)"
+ platform: macos
+ build_targets: ["//tests/integration:local_toolchains_test_bazel_self"]
+ test_targets: ["//tests/integration:local_toolchains_test_bazel_self"]
+ # The bazelinbazel tests were disabled on Windows to save CI jobs slots, and
+ # have bitrotted a bit. For now, just run a subset of what we're most
+ # interested in.
+ integration_test_bazelinbazel_windows:
+ <<: *common_bazelinbazel_config
+ name: "tests/integration bazel-in-bazel: Windows (subset)"
+ platform: windows
+ build_targets: ["//tests/integration:local_toolchains_test_bazel_self"]
+ test_targets: ["//tests/integration:local_toolchains_test_bazel_self"]
integration_test_compile_pip_requirements_ubuntu:
<<: *reusable_build_test_all
diff --git a/.gitignore b/.gitignore
index 863b0e9..fb1b17e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,6 +37,8 @@
/bazel-genfiles
/bazel-out
/bazel-testlogs
+**/bazel-*
+
user.bazelrc
# vim swap files
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 37a8c1d..2dc235f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -106,6 +106,12 @@
* (toolchains) use "command -v" to find interpreter in `$PATH`
([#3150](https://github.com/bazel-contrib/rules_python/pull/3150)).
* (pypi) `bazel vendor` now works in `bzlmod` ({gh-issue}`3079`).
+* (toolchains) `local_runtime_repo` now works on Windows
+ ([#3055](https://github.com/bazel-contrib/rules_python/issues/3055)).
+* (toolchains) `local_runtime_repo` supports more types of Python
+ installations (Mac frameworks, missing dynamic libraries, and other
+ esoteric cases, see
+ [#3148](https://github.com/bazel-contrib/rules_python/pull/3148) for details).
{#v0-0-0-added}
### Added
diff --git a/python/private/get_local_runtime_info.py b/python/private/get_local_runtime_info.py
index c837135..ff3b0ae 100644
--- a/python/private/get_local_runtime_info.py
+++ b/python/private/get_local_runtime_info.py
@@ -12,10 +12,181 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+"""Returns information about the local Python runtime as JSON."""
+
import json
+import os
import sys
import sysconfig
+_IS_WINDOWS = sys.platform == "win32"
+_IS_DARWIN = sys.platform == "darwin"
+
+
+def _search_directories(get_config):
+ """Returns a list of library directories to search for shared libraries."""
+ # There's several types of libraries with different names and a plethora
+ # of settings, and many different config variables to check:
+ #
+ # LIBPL is used in python-config when shared library is not enabled:
+ # https://github.com/python/cpython/blob/v3.12.0/Misc/python-config.in#L63
+ #
+ # LIBDIR may also be the python directory with library files.
+ # https://stackoverflow.com/questions/47423246/get-pythons-lib-path
+ # See also: MULTIARCH
+ #
+ # On MacOS, the LDLIBRARY may be a relative path under /Library/Frameworks,
+ # such as "Python.framework/Versions/3.12/Python", not a file under the
+ # LIBDIR/LIBPL directory, so include PYTHONFRAMEWORKPREFIX.
+ lib_dirs = [get_config(x) for x in ("PYTHONFRAMEWORKPREFIX", "LIBPL", "LIBDIR")]
+
+ # On Debian, with multiarch enabled, prior to Python 3.10, `LIBDIR` didn't
+ # tell the location of the libs, just the base directory. The `MULTIARCH`
+ # sysconfig variable tells the subdirectory within it with the libs.
+ # See:
+ # https://wiki.debian.org/Python/MultiArch
+ # https://git.launchpad.net/ubuntu/+source/python3.12/tree/debian/changelog#n842
+ multiarch = get_config("MULTIARCH")
+ if multiarch:
+ for x in ("LIBPL", "LIBDIR"):
+ config_value = get_config(x)
+ if config_value and not config_value.endswith(multiarch):
+ lib_dirs.append(os.path.join(config_value, multiarch))
+
+ if _IS_WINDOWS:
+ # On Windows DLLs go in the same directory as the executable, while .lib
+ # files live in the lib/ or libs/ subdirectory.
+ lib_dirs.append(get_config("BINDIR"))
+ lib_dirs.append(os.path.join(os.path.dirname(sys.executable)))
+ lib_dirs.append(os.path.join(os.path.dirname(sys.executable), "lib"))
+ lib_dirs.append(os.path.join(os.path.dirname(sys.executable), "libs"))
+ elif not _IS_DARWIN:
+ # On most systems the executable is in a bin/ directory and the libraries
+ # are in a sibling lib/ directory.
+ lib_dirs.append(
+ os.path.join(os.path.dirname(os.path.dirname(sys.executable)), "lib")
+ )
+
+ # Dedup and remove empty values, keeping the order.
+ lib_dirs = [v for v in lib_dirs if v]
+ return {k: None for k in lib_dirs}.keys()
+
+
+def _search_library_names(get_config):
+ """Returns a list of library files to search for shared libraries."""
+ # Quoting configure.ac in the cpython code base:
+ # "INSTSONAME is the name of the shared library that will be use to install
+ # on the system - some systems like version suffix, others don't.""
+ #
+ # A typical INSTSONAME is 'libpython3.8.so.1.0' on Linux, or
+ # 'Python.framework/Versions/3.9/Python' on MacOS.
+ #
+ # A typical LDLIBRARY is 'libpythonX.Y.so' on Linux, or 'pythonXY.dll' on
+ # Windows, or 'Python.framework/Versions/3.9/Python' on MacOS.
+ #
+ # A typical LIBRARY is 'libpythonX.Y.a' on Linux.
+ lib_names = [
+ get_config(x)
+ for x in (
+ "LDLIBRARY",
+ "INSTSONAME",
+ "PY3LIBRARY",
+ "LIBRARY",
+ "DLLLIBRARY",
+ )
+ ]
+
+ # Set the prefix and suffix to construct the library name used for linking.
+ # The suffix and version are set here to the default values for the OS,
+ # since they are used below to construct "default" library names.
+ if _IS_DARWIN:
+ suffix = ".dylib"
+ prefix = "lib"
+ elif _IS_WINDOWS:
+ suffix = ".dll"
+ prefix = ""
+ else:
+ suffix = get_config("SHLIB_SUFFIX")
+ prefix = "lib"
+ if not suffix:
+ suffix = ".so"
+
+ version = get_config("VERSION")
+
+ # Ensure that the pythonXY.dll files are included in the search.
+ lib_names.append(f"{prefix}python{version}{suffix}")
+
+ # If there are ABIFLAGS, also add them to the python version lib search.
+ abiflags = get_config("ABIFLAGS") or get_config("abiflags") or ""
+ if abiflags:
+ lib_names.append(f"{prefix}python{version}{abiflags}{suffix}")
+
+ # Dedup and remove empty values, keeping the order.
+ lib_names = [v for v in lib_names if v]
+ return {k: None for k in lib_names}.keys()
+
+
+def _get_python_library_info():
+ """Returns a dictionary with the static and dynamic python libraries."""
+ config_vars = sysconfig.get_config_vars()
+
+ # VERSION is X.Y in Linux/macOS and XY in Windows. This is used to
+ # construct library paths such as python3.12, so ensure it exists.
+ if not config_vars.get("VERSION"):
+ if sys.platform == "win32":
+ config_vars["VERSION"] = f"{sys.version_info.major}{sys.version_info.minor}"
+ else:
+ config_vars["VERSION"] = (
+ f"{sys.version_info.major}.{sys.version_info.minor}"
+ )
+
+ search_directories = _search_directories(config_vars.get)
+ search_libnames = _search_library_names(config_vars.get)
+
+ def _add_if_exists(target, path):
+ if os.path.exists(path) or os.path.isdir(path):
+ target[path] = None
+
+ interface_libraries = {}
+ dynamic_libraries = {}
+ static_libraries = {}
+ for root_dir in search_directories:
+ for libname in search_libnames:
+ composed_path = os.path.join(root_dir, libname)
+ if libname.endswith(".a"):
+ _add_if_exists(static_libraries, composed_path)
+ continue
+
+ _add_if_exists(dynamic_libraries, composed_path)
+ if libname.endswith(".dll"):
+ # On windows a .lib file may be an "import library" or a static library.
+ # The file could be inspected to determine which it is; typically python
+ # is used as a shared library.
+ #
+ # On Windows, extensions should link with the pythonXY.lib interface
+ # libraries.
+ #
+ # See: https://docs.python.org/3/extending/windows.html
+ # https://learn.microsoft.com/en-us/windows/win32/dlls/dynamic-link-library-creation
+ _add_if_exists(
+ interface_libraries, os.path.join(root_dir, libname[:-3] + "lib")
+ )
+ elif libname.endswith(".so"):
+ # It's possible, though unlikely, that interface stubs (.ifso) exist.
+ _add_if_exists(
+ interface_libraries, os.path.join(root_dir, libname[:-2] + "ifso")
+ )
+
+ # When no libraries are found it's likely that the python interpreter is not
+ # configured to use shared or static libraries (minilinux). If this seems
+ # suspicious try running `uv tool run find_libpython --list-all -v`
+ return {
+ "dynamic_libraries": list(dynamic_libraries.keys()),
+ "static_libraries": list(static_libraries.keys()),
+ "interface_libraries": list(interface_libraries.keys()),
+ }
+
+
data = {
"major": sys.version_info.major,
"minor": sys.version_info.minor,
@@ -24,35 +195,5 @@
"implementation_name": sys.implementation.name,
"base_executable": sys._base_executable,
}
-
-config_vars = [
- # The libpythonX.Y.so file. Usually?
- # It might be a static archive (.a) file instead.
- "LDLIBRARY",
- # The directory with library files. Supposedly.
- # It's not entirely clear how to get the directory with libraries.
- # There's several types of libraries with different names and a plethora
- # of settings.
- # https://stackoverflow.com/questions/47423246/get-pythons-lib-path
- # For now, it seems LIBDIR has what is needed, so just use that.
- # See also: MULTIARCH
- "LIBDIR",
- # On Debian, with multiarch enabled, prior to Python 3.10, `LIBDIR` didn't
- # tell the location of the libs, just the base directory. The `MULTIARCH`
- # sysconfig variable tells the subdirectory within it with the libs.
- # See:
- # https://wiki.debian.org/Python/MultiArch
- # https://git.launchpad.net/ubuntu/+source/python3.12/tree/debian/changelog#n842
- "MULTIARCH",
- # The versioned libpythonX.Y.so.N file. Usually?
- # It might be a static archive (.a) file instead.
- "INSTSONAME",
- # The libpythonX.so file. Usually?
- # It might be a static archive (a.) file instead.
- "PY3LIBRARY",
- # The platform-specific filename suffix for library files.
- # Includes the dot, e.g. `.so`
- "SHLIB_SUFFIX",
-]
-data.update(zip(config_vars, sysconfig.get_config_vars(*config_vars)))
+data.update(_get_python_library_info())
print(json.dumps(data))
diff --git a/python/private/local_runtime_repo.bzl b/python/private/local_runtime_repo.bzl
index b8b7164..21bdfa6 100644
--- a/python/private/local_runtime_repo.bzl
+++ b/python/private/local_runtime_repo.bzl
@@ -31,27 +31,67 @@
define_local_runtime_toolchain_impl(
name = "local_runtime",
- lib_ext = "{lib_ext}",
major = "{major}",
minor = "{minor}",
micro = "{micro}",
interpreter_path = "{interpreter_path}",
+ interface_library = {interface_library},
+ libraries = {libraries},
implementation_name = "{implementation_name}",
os = "{os}",
)
"""
+def _norm_path(path):
+ """Returns a path using '/' separators and no trailing slash."""
+ path = path.replace("\\", "/")
+ if path[-1] == "/":
+ path = path[:-1]
+ return path
+
+def _symlink_first_library(rctx, logger, libraries):
+ """Symlinks the shared libraries into the lib/ directory.
+
+ Args:
+ rctx: A repository_ctx object
+ logger: A repo_utils.logger object
+ libraries: A list of static library paths to potentially symlink.
+ Returns:
+ A single library path linked by the action.
+ """
+ linked = None
+ for target in libraries:
+ origin = rctx.path(target)
+ if not origin.exists:
+ # The reported names don't always exist; it depends on the particulars
+ # of the runtime installation.
+ continue
+ if target.endswith("/Python"):
+ linked = "lib/{}.dylib".format(origin.basename)
+ else:
+ linked = "lib/{}".format(origin.basename)
+ logger.debug("Symlinking {} to {}".format(origin, linked))
+ repo_utils.watch(rctx, origin)
+ rctx.symlink(origin, linked)
+ break
+
+ return linked
+
def _local_runtime_repo_impl(rctx):
logger = repo_utils.logger(rctx)
on_failure = rctx.attr.on_failure
+ def _emit_log(msg):
+ if on_failure == "fail":
+ logger.fail(msg)
+ elif on_failure == "warn":
+ logger.warn(msg)
+ else:
+ logger.debug(msg)
+
result = _resolve_interpreter_path(rctx)
if not result.resolved_path:
- if on_failure == "fail":
- fail("interpreter not found: {}".format(result.describe_failure()))
-
- if on_failure == "warn":
- logger.warn(lambda: "interpreter not found: {}".format(result.describe_failure()))
+ _emit_log(lambda: "interpreter not found: {}".format(result.describe_failure()))
# else, on_failure must be skip
rctx.file("BUILD.bazel", _expand_incompatible_template())
@@ -72,10 +112,7 @@
logger = logger,
)
if exec_result.return_code != 0:
- if on_failure == "fail":
- fail("GetPythonInfo failed: {}".format(exec_result.describe_failure()))
- if on_failure == "warn":
- logger.warn(lambda: "GetPythonInfo failed: {}".format(exec_result.describe_failure()))
+ _emit_log(lambda: "GetPythonInfo failed: {}".format(exec_result.describe_failure()))
# else, on_failure must be skip
rctx.file("BUILD.bazel", _expand_incompatible_template())
@@ -112,53 +149,37 @@
# The cc_library.includes values have to be non-absolute paths, otherwise
# the toolchain will give an error. Work around this error by making them
# appear as part of this repo.
- rctx.symlink(info["include"], "include")
+ rctx.symlink(include_path, "include")
- shared_lib_names = [
- info["PY3LIBRARY"],
- info["LDLIBRARY"],
- info["INSTSONAME"],
- ]
-
- # In some cases, the value may be empty. Not clear why.
- shared_lib_names = [v for v in shared_lib_names if v]
-
- # In some cases, the same value is returned for multiple keys. Not clear why.
- shared_lib_names = {v: None for v in shared_lib_names}.keys()
- shared_lib_dir = info["LIBDIR"]
- multiarch = info["MULTIARCH"]
-
- # The specific files are symlinked instead of the whole directory
- # because it can point to a directory that has more than just
- # the Python runtime shared libraries, e.g. /usr/lib, or a Python
- # specific directory with pip-installed shared libraries.
rctx.report_progress("Symlinking external Python shared libraries")
- for name in shared_lib_names:
- origin = rctx.path("{}/{}".format(shared_lib_dir, name))
+ interface_library = _symlink_first_library(rctx, logger, info["interface_libraries"])
+ shared_library = _symlink_first_library(rctx, logger, info["dynamic_libraries"])
+ static_library = _symlink_first_library(rctx, logger, info["static_libraries"])
- # If the origin doesn't exist, try the multiarch location, in case
- # it's an older Python / Debian release.
- if not origin.exists and multiarch:
- origin = rctx.path("{}/{}/{}".format(shared_lib_dir, multiarch, name))
+ libraries = []
+ if shared_library:
+ libraries.append(shared_library)
+ elif static_library:
+ libraries.append(static_library)
+ else:
+ logger.warn("No external python libraries found.")
- # The reported names don't always exist; it depends on the particulars
- # of the runtime installation.
- if origin.exists:
- repo_utils.watch(rctx, origin)
- rctx.symlink(origin, "lib/" + name)
+ build_bazel = _TOOLCHAIN_IMPL_TEMPLATE.format(
+ major = info["major"],
+ minor = info["minor"],
+ micro = info["micro"],
+ interpreter_path = _norm_path(interpreter_path),
+ interface_library = repr(interface_library),
+ libraries = repr(libraries),
+ implementation_name = info["implementation_name"],
+ os = "@platforms//os:{}".format(repo_utils.get_platforms_os_name(rctx)),
+ )
+ logger.debug(lambda: "BUILD.bazel\n{}".format(build_bazel))
rctx.file("WORKSPACE", "")
rctx.file("MODULE.bazel", "")
rctx.file("REPO.bazel", "")
- rctx.file("BUILD.bazel", _TOOLCHAIN_IMPL_TEMPLATE.format(
- major = info["major"],
- minor = info["minor"],
- micro = info["micro"],
- interpreter_path = interpreter_path,
- lib_ext = info["SHLIB_SUFFIX"],
- implementation_name = info["implementation_name"],
- os = "@platforms//os:{}".format(repo_utils.get_platforms_os_name(rctx)),
- ))
+ rctx.file("BUILD.bazel", build_bazel)
local_runtime_repo = repository_rule(
implementation = _local_runtime_repo_impl,
@@ -218,7 +239,8 @@
return _TOOLCHAIN_IMPL_TEMPLATE.format(
interpreter_path = "/incompatible",
implementation_name = "incompatible",
- lib_ext = "incompatible",
+ interface_library = "None",
+ libraries = "[]",
major = "0",
minor = "0",
micro = "0",
diff --git a/python/private/local_runtime_repo_setup.bzl b/python/private/local_runtime_repo_setup.bzl
index 37eab59..5d3a781 100644
--- a/python/private/local_runtime_repo_setup.bzl
+++ b/python/private/local_runtime_repo_setup.bzl
@@ -15,6 +15,7 @@
"""Setup code called by the code generated by `local_runtime_repo`."""
load("@bazel_skylib//lib:selects.bzl", "selects")
+load("@rules_cc//cc:cc_import.bzl", "cc_import")
load("@rules_cc//cc:cc_library.bzl", "cc_library")
load("@rules_python//python:py_runtime.bzl", "py_runtime")
load("@rules_python//python:py_runtime_pair.bzl", "py_runtime_pair")
@@ -25,11 +26,12 @@
def define_local_runtime_toolchain_impl(
name,
- lib_ext,
major,
minor,
micro,
interpreter_path,
+ interface_library,
+ libraries,
implementation_name,
os):
"""Defines a toolchain implementation for a local Python runtime.
@@ -45,11 +47,14 @@
Args:
name: `str` Only present to satisfy tooling
- lib_ext: `str` The file extension for the `libpython` shared libraries
major: `str` The major Python version, e.g. `3` of `3.9.1`.
minor: `str` The minor Python version, e.g. `9` of `3.9.1`.
micro: `str` The micro Python version, e.g. "1" of `3.9.1`.
interpreter_path: `str` Absolute path to the interpreter.
+ interface_library: `str` Path to the interface library.
+ e.g. "lib/python312.lib"
+ libraries: `list[str]` Path[s] to the python libraries.
+ e.g. ["lib/python312.dll"] or ["lib/python312.so"]
implementation_name: `str` The implementation name, as returned by
`sys.implementation.name`.
os: `str` A label to the OS constraint (e.g. `@platforms//os:linux`) for
@@ -58,30 +63,36 @@
major_minor = "{}.{}".format(major, minor)
major_minor_micro = "{}.{}".format(major_minor, micro)
+ # To build Python C/C++ extension on Windows, we need to link to python import library pythonXY.lib
+ # See https://docs.python.org/3/extending/windows.html
+ # However not all python installations (such as manylinux) include shared or static libraries,
+ # so only create the import library when interface_library is set.
+ import_deps = []
+ if interface_library:
+ cc_import(
+ name = "_python_interface_library",
+ interface_library = interface_library,
+ system_provided = 1,
+ )
+ import_deps = [":_python_interface_library"]
+
cc_library(
name = "_python_headers",
# NOTE: Keep in sync with watch_tree() called in local_runtime_repo
srcs = native.glob(
- ["include/**/*.h"],
- # A Python install may not have C headers
- allow_empty = True,
+ include = ["include/**/*.h"],
+ exclude = ["include/numpy/**"], # numpy headers are handled separately
+ allow_empty = True, # A Python install may not have C headers
),
+ deps = import_deps,
includes = ["include"],
)
cc_library(
name = "_libpython",
- # Don't use a recursive glob because the lib/ directory usually contains
- # a subdirectory of the stdlib -- lots of unrelated files
- srcs = native.glob(
- [
- "lib/*{}".format(lib_ext), # Match libpython*.so
- "lib/*{}*".format(lib_ext), # Also match libpython*.so.1.0
- ],
- # A Python install may not have shared libraries.
- allow_empty = True,
- ),
hdrs = [":_python_headers"],
+ srcs = libraries,
+ deps = [],
)
py_runtime(
diff --git a/tests/integration/BUILD.bazel b/tests/integration/BUILD.bazel
index d178e0f..df7fe15 100644
--- a/tests/integration/BUILD.bazel
+++ b/tests/integration/BUILD.bazel
@@ -96,6 +96,17 @@
)
rules_python_integration_test(
+ name = "local_toolchains_workspace_test",
+ bazel_versions = [
+ version
+ for version in bazel_binaries.versions.all
+ if not version.startswith("6.")
+ ],
+ bzlmod = False,
+ workspace_path = "local_toolchains",
+)
+
+rules_python_integration_test(
name = "pip_parse_test",
)
diff --git a/tests/integration/local_toolchains/BUILD.bazel b/tests/integration/local_toolchains/BUILD.bazel
index 6b73118..a0cb2b1 100644
--- a/tests/integration/local_toolchains/BUILD.bazel
+++ b/tests/integration/local_toolchains/BUILD.bazel
@@ -13,7 +13,9 @@
# limitations under the License.
load("@bazel_skylib//rules:common_settings.bzl", "string_flag")
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
load("@rules_python//python:py_test.bzl", "py_test")
+load(":py_extension.bzl", "py_extension")
py_test(
name = "test",
@@ -35,3 +37,30 @@
name = "py",
build_setting_default = "",
)
+
+# Build rules to generate a python extension.
+cc_library(
+ name = "echo_ext_cc",
+ testonly = True,
+ srcs = ["echo_ext.cc"],
+ deps = [
+ "@rules_python//python/cc:current_py_cc_headers",
+ ],
+ alwayslink = True,
+)
+
+py_extension(
+ name = "echo_ext",
+ testonly = True,
+ copts = select({
+ "@rules_cc//cc/compiler:msvc-cl": [],
+ "//conditions:default": ["-fvisibility=hidden"],
+ }),
+ deps = [":echo_ext_cc"],
+)
+
+py_test(
+ name = "echo_test",
+ srcs = ["echo_test.py"],
+ deps = [":echo_ext"],
+)
diff --git a/tests/integration/local_toolchains/MODULE.bazel b/tests/integration/local_toolchains/MODULE.bazel
index 6c06909..45afaaf 100644
--- a/tests/integration/local_toolchains/MODULE.bazel
+++ b/tests/integration/local_toolchains/MODULE.bazel
@@ -16,6 +16,7 @@
bazel_dep(name = "rules_python", version = "0.0.0")
bazel_dep(name = "bazel_skylib", version = "1.7.1")
bazel_dep(name = "platforms", version = "0.0.11")
+bazel_dep(name = "rules_cc", version = "0.0.16")
local_path_override(
module_name = "rules_python",
diff --git a/tests/integration/local_toolchains/WORKSPACE b/tests/integration/local_toolchains/WORKSPACE
index e69de29..480cd27 100644
--- a/tests/integration/local_toolchains/WORKSPACE
+++ b/tests/integration/local_toolchains/WORKSPACE
@@ -0,0 +1,31 @@
+workspace(
+ name = "module_under_test",
+)
+
+local_repository(
+ name = "rules_python",
+ path = "../../..",
+)
+
+load("@rules_python//python:repositories.bzl", "py_repositories")
+
+py_repositories()
+
+load("@rules_python//python/local_toolchains:repos.bzl", "local_runtime_repo", "local_runtime_toolchains_repo")
+
+# Step 1: Define the python runtime.
+local_runtime_repo(
+ name = "local_python3",
+ interpreter_path = "python3",
+ on_failure = "fail",
+ # or interpreter_path = "C:\\path\\to\\python.exe"
+)
+
+# Step 2: Create toolchains for the runtimes
+local_runtime_toolchains_repo(
+ name = "local_toolchains",
+ runtimes = ["local_python3"],
+)
+
+# Step 3: Register the toolchains
+register_toolchains("@local_toolchains//:all")
diff --git a/tests/integration/local_toolchains/echo_ext.cc b/tests/integration/local_toolchains/echo_ext.cc
new file mode 100644
index 0000000..367d1a1
--- /dev/null
+++ b/tests/integration/local_toolchains/echo_ext.cc
@@ -0,0 +1,21 @@
+#include <Python.h>
+
+static PyObject *echoArgs(PyObject *self, PyObject *args) { return args; }
+
+static PyMethodDef echo_methods[] = {
+ { "echo", echoArgs, METH_VARARGS, "Returns a tuple of the input args" },
+ { NULL, NULL, 0, NULL },
+};
+
+extern "C" {
+
+PyMODINIT_FUNC PyInit_echo_ext(void) {
+ static struct PyModuleDef echo_module_def = {
+ // Module definition
+ PyModuleDef_HEAD_INIT, "echo_ext", "'echo_ext' module", -1, echo_methods
+ };
+
+ return PyModule_Create(&echo_module_def);
+}
+
+} // extern "C"
diff --git a/tests/integration/local_toolchains/echo_test.py b/tests/integration/local_toolchains/echo_test.py
new file mode 100644
index 0000000..4cc31ff
--- /dev/null
+++ b/tests/integration/local_toolchains/echo_test.py
@@ -0,0 +1,9 @@
+import unittest
+
+import echo_ext
+
+
+class ExtensionTest(unittest.TestCase):
+
+ def test_echo_extension(self):
+ self.assertEqual(echo_ext.echo(42, "str"), tuple(42, "str"))
diff --git a/tests/integration/local_toolchains/py_extension.bzl b/tests/integration/local_toolchains/py_extension.bzl
new file mode 100644
index 0000000..5d37fd7
--- /dev/null
+++ b/tests/integration/local_toolchains/py_extension.bzl
@@ -0,0 +1,154 @@
+# Copyright 2025 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.
+
+"""Macro to build a python C/C++ extension.
+
+There are variants of py_extension in many other projects, such as:
+* https://github.com/protocolbuffers/protobuf/tree/main/python/py_extension.bzl
+* https://github.com/google/riegeli/blob/master/python/riegeli/py_extension.bzl
+* https://github.com/pybind/pybind11_bazel/blob/master/build_defs.bzl
+
+The issue for a generic verion is:
+* https://github.com/bazel-contrib/rules_python/issues/824
+"""
+
+load("@bazel_skylib//rules:copy_file.bzl", "copy_file")
+load("@rules_cc//cc:cc_binary.bzl", "cc_binary")
+load("@rules_python//python:defs.bzl", "py_library")
+
+def py_extension(
+ *,
+ name,
+ deps = None,
+ linkopts = None,
+ imports = None,
+ visibility = None,
+ **kwargs):
+ """Creates a Python module implemented in C++.
+
+ A Python extension has 2 essential parts:
+ 1. An internal shared object / pyd package for the extension, `name.pyd`/`name.so`
+ 2. The py_library target for the extension.`
+
+ Python modules can depend on a py_extension.
+
+ Args:
+ name: `str`. Name for this target. This is typically the module name.
+ deps: `list`. Required. C++ libraries to link into the module.
+ linkopts: `list`. Linking options for the shared library.
+ imports: `list`. Additional imports for the py_library rule.
+ visibility: `str`. Visibility for target.
+ **kwargs: Additional options for the cc_library rule.
+ """
+ if not name:
+ fail("py_extension requires a name")
+ if not deps:
+ fail("py_extension requires a non-empty deps attribute")
+ if "linkshared" in kwargs:
+ fail("py_extension attribute linkshared not allowed")
+
+ if not linkopts:
+ linkopts = []
+
+ testonly = kwargs.get("testonly")
+ tags = kwargs.pop("tags", [])
+
+ cc_binary_so_name = name + ".so"
+ cc_binary_dll_name = name + ".dll"
+ cc_binary_pyd_name = name + ".pyd"
+ linker_script_name = name + ".lds"
+ linker_script_name_rule = name + "_lds"
+ shared_objects_name = name + "__shared_objects"
+
+ # On Unix, restrict symbol visibility.
+ exported_symbol = "PyInit_" + name
+
+ # Generate linker script used on non-macOS unix platforms.
+ native.genrule(
+ name = linker_script_name_rule,
+ outs = [linker_script_name],
+ cmd = "\n".join([
+ "cat <<'EOF' >$@",
+ "{",
+ " global: " + exported_symbol + ";",
+ " local: *;",
+ "};",
+ "EOF",
+ ]),
+ )
+
+ for cc_binary_name in [cc_binary_dll_name, cc_binary_so_name]:
+ cur_linkopts = linkopts
+ cur_deps = deps
+ if cc_binary_name == cc_binary_so_name:
+ cur_linkopts = linkopts + select({
+ "@platforms//os:macos": [
+ # Avoid undefined symbol errors for CPython symbols that
+ # will be resolved at runtime.
+ "-undefined",
+ "dynamic_lookup",
+ # On macOS, the linker does not support version scripts. Use
+ # the `-exported_symbol` option instead to restrict symbol
+ # visibility.
+ "-Wl,-exported_symbol",
+ # On macOS, the symbol starts with an underscore.
+ "-Wl,_" + exported_symbol,
+ ],
+ # On non-macOS unix, use a version script to restrict symbol
+ # visibility.
+ "//conditions:default": [
+ "-Wl,--version-script",
+ "-Wl,$(location :" + linker_script_name + ")",
+ ],
+ })
+ cur_deps = cur_deps + select({
+ "@platforms//os:macos": [],
+ "//conditions:default": [linker_script_name],
+ })
+
+ cc_binary(
+ name = cc_binary_name,
+ linkshared = True,
+ visibility = ["//visibility:private"],
+ deps = cur_deps,
+ tags = tags + ["manual"],
+ linkopts = cur_linkopts,
+ **kwargs
+ )
+
+ copy_file(
+ name = cc_binary_pyd_name + "__pyd_copy",
+ src = ":" + cc_binary_dll_name,
+ out = cc_binary_pyd_name,
+ visibility = visibility,
+ tags = ["manual"],
+ testonly = testonly,
+ )
+
+ native.filegroup(
+ name = shared_objects_name,
+ data = select({
+ "@platforms//os:windows": [":" + cc_binary_pyd_name],
+ "//conditions:default": [":" + cc_binary_so_name],
+ }),
+ testonly = testonly,
+ )
+ py_library(
+ name = name,
+ data = [":" + shared_objects_name],
+ imports = imports,
+ tags = tags,
+ testonly = testonly,
+ visibility = visibility,
+ )
diff --git a/tests/integration/local_toolchains/test.py b/tests/integration/local_toolchains/test.py
index 8e37fff..0a0d6be 100644
--- a/tests/integration/local_toolchains/test.py
+++ b/tests/integration/local_toolchains/test.py
@@ -20,18 +20,20 @@
# things like pyenv: they install a shim that re-execs python.
# The shim is e.g. /home/user/.pyenv/shims/python3, which then
# runs e.g. /usr/bin/python3
- with tempfile.NamedTemporaryFile(suffix="_info.py", mode="w+") as f:
- f.write(
+ with tempfile.TemporaryDirectory() as temp_dir:
+ file_path = os.path.join(temp_dir, "info.py")
+ with open(file_path, 'w') as f:
+ f.write(
"""
import sys
print(sys.executable)
print(sys._base_executable)
"""
- )
- f.flush()
+ )
+ f.flush()
output_lines = (
subprocess.check_output(
- [shell_path, f.name],
+ [shell_path, file_path],
text=True,
)
.strip()