Skip to content

fix(local_runtime): Improve local_runtime usability in macos / windows #3148

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ END_UNRELEASED_TEMPLATE
* (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` Improvements in handling variations across python installations
for Linux, Windows and Mac. See ([#3148](https://github.com/bazel-contrib/rules_python/pull/3148)).
* (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
Expand Down
4 changes: 4 additions & 0 deletions examples/local_python/.bazelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# The equivalent bzlmod behavior is covered by examples/bzlmod/py_proto_library
common --noenable_bzlmod
common --enable_workspace
common --incompatible_python_disallow_native_rules
4 changes: 4 additions & 0 deletions examples/local_python/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# git ignore patterns

/bazel-*
user.bazelrc
6 changes: 6 additions & 0 deletions examples/local_python/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
load("@rules_python//python:py_binary.bzl", "py_binary")

py_binary(
name = "main",
srcs = ["main.py"],
)
31 changes: 31 additions & 0 deletions examples/local_python/WORKSPACE
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
workspace(
name = "local_python_example",
)

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")
21 changes: 21 additions & 0 deletions examples/local_python/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# 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.


def main():
print(42)


if __name__ == "__main__":
main()
199 changes: 170 additions & 29 deletions python/private/get_local_runtime_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,47 +12,188 @@
# 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

data = {
"major": sys.version_info.major,
"minor": sys.version_info.minor,
"micro": sys.version_info.micro,
"include": sysconfig.get_path("include"),
"implementation_name": sys.implementation.name,
"base_executable": sys._base_executable,
}
_IS_WINDOWS = sys.platform == "win32"
_IS_DARWIN = sys.platform == "darwin"


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.
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.
# 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
# For now, it seems LIBDIR has what is needed, so just use that.
# See also: MULTIARCH
"LIBDIR",
#
# 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",
# 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)))
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,
"micro": sys.version_info.micro,
"include": sysconfig.get_path("include"),
"implementation_name": sys.implementation.name,
"base_executable": sys._base_executable,
}
data.update(_get_python_library_info())
print(json.dumps(data))
Loading