fix(local) Add api3 targets and additional defines. (#3408)
Propagate defines and additional dll requirements for local python
installs.
In get_local_runtime_info.py:
* detect abi3 vs. full abi libraries.
* Ensure that returned libraries are unique.
* Add additional dlls required by pythonXY.dll / pythonX.dll on windows.
* Add default defines for Py_GIL_DISABLED when the local python is a
freethreaded install.
* Add defines (windows) for Py_NO_LINK_LIB to avoid #pragma comment(lib
...) macros
In local_runtime_repo_setup.bzl
* More closely match hermetic_runtime_repo_setup
* Add abi3 header targets.
In local_runtime_repo.bzl
* rework linking to local repository directories to handl abi3 and extra
dlls.
* Update parameters passed into local_runtime_repo_setup.bzl
Before these changes, some bazel builds using local Python fail to link
properly.
This happens due to a mismatch in the interpreter and the python GIL
DISABLED mode, or (on Windows), where both freethreaded and
non-freethreaded libraries may attempt to be linked at the same time.
---------
Co-authored-by: Richard Levasseur <richardlev@gmail.com>
Co-authored-by: Richard Levasseur <rlevasseur@google.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4f1911f..6709a9d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -78,6 +78,9 @@
([#3085](https://github.com/bazel-contrib/rules_python/issues/3085)).
* (toolchains) local toolchains now tell the `sys.abiflags` value of the
underlying runtime.
+* (toolchains) various local toolchain fixes: add abi3 header targets,
+ fixes to linking, Windows DLL detection, and defines for free threaded
+ runtimes.
* (toolchains) The `python_headers` target is now compatible with
layering_check.
* (performance) 90% reduction in py_binary/py_test analysis phase cost.
diff --git a/python/private/get_local_runtime_info.py b/python/private/get_local_runtime_info.py
index b20c159..a59e17a 100644
--- a/python/private/get_local_runtime_info.py
+++ b/python/private/get_local_runtime_info.py
@@ -13,16 +13,27 @@
# limitations under the License.
"""Returns information about the local Python runtime as JSON."""
+import glob
import json
import os
import sys
import sysconfig
+from typing import Any
_IS_WINDOWS = sys.platform == "win32"
_IS_DARWIN = sys.platform == "darwin"
-def _search_directories(get_config, base_executable):
+def _get_abi_flags(get_config) -> str:
+ """Returns the ABI flags for the Python runtime."""
+ # sys.abiflags may not exist, but it still may be set in the config.
+ abi_flags = getattr(sys, "abiflags", None)
+ if abi_flags is None:
+ abi_flags = get_config("ABIFLAGS") or get_config("abiflags") or ""
+ return abi_flags
+
+
+def _search_directories(get_config, base_executable) -> list[str]:
"""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:
@@ -73,23 +84,31 @@
lib_dirs.append(os.path.join(os.path.dirname(exec_dir), "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()
+ return list(dict.fromkeys(d for d in lib_dirs if d))
-def _get_shlib_suffix(get_config) -> str:
- """Returns the suffix for shared libraries."""
- if _IS_DARWIN:
- return ".dylib"
+def _default_library_names(version, abi_flags) -> tuple[str, ...]:
+ """Returns a list of default library files to search for shared libraries."""
if _IS_WINDOWS:
- return ".dll"
- suffix = get_config("SHLIB_SUFFIX")
- if not suffix:
- suffix = ".so"
- return suffix
+ return (
+ f"python{version}{abi_flags}.dll",
+ f"python{version}.dll",
+ )
+ elif _IS_DARWIN:
+ return (
+ f"libpython{version}{abi_flags}.dylib",
+ f"libpython{version}.dylib",
+ )
+ else:
+ return (
+ f"libpython{version}{abi_flags}.so",
+ f"libpython{version}.so",
+ f"libpython{version}{abi_flags}.so.1.0",
+ f"libpython{version}.so.1.0",
+ )
-def _search_library_names(get_config, shlib_suffix):
+def _search_library_names(get_config, version, abi_flags) -> list[str]:
"""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
@@ -112,71 +131,75 @@
)
]
- # 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:
- prefix = "lib"
- elif _IS_WINDOWS:
- prefix = ""
- else:
- prefix = "lib"
+ # Include the default libraries for the system.
+ lib_names.extend(_default_library_names(version, abi_flags))
- version = get_config("VERSION")
+ # Also include the abi3 libraries for the system.
+ lib_names.extend(_default_library_names(sys.version_info.major, abi_flags))
- # Ensure that the pythonXY.dll files are included in the search.
- lib_names.append(f"{prefix}python{version}{shlib_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}{shlib_suffix}")
-
- # Add the abi-version includes to the search list.
- lib_names.append(f"{prefix}python{sys.version_info.major}{shlib_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()
+ # Uniqify, preserving order.
+ return list(dict.fromkeys(k for k in lib_names if k))
-def _get_python_library_info(base_executable):
+def _get_python_library_info(base_executable) -> dict[str, Any]:
"""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}")
+ version = config_vars.get("VERSION")
+ if not version:
+ if _IS_WINDOWS:
+ version = f"{sys.version_info.major}{sys.version_info.minor}"
else:
- config_vars["VERSION"] = (
- f"{sys.version_info.major}.{sys.version_info.minor}")
+ version = f"{sys.version_info.major}.{sys.version_info.minor}"
- shlib_suffix = _get_shlib_suffix(config_vars.get)
+ defines = []
+ if config_vars.get("Py_GIL_DISABLED", "0") == "1":
+ defines.append("Py_GIL_DISABLED")
+
+ # Avoid automatically linking the libraries on windows via pydefine.h
+ # pragma comment(lib ...)
+ if _IS_WINDOWS:
+ defines.append("Py_NO_LINK_LIB")
+
+ # sys.abiflags may not exist, but it still may be set in the config.
+ abi_flags = _get_abi_flags(config_vars.get)
+
search_directories = _search_directories(config_vars.get, base_executable)
- search_libnames = _search_library_names(config_vars.get, shlib_suffix)
+ search_libnames = _search_library_names(config_vars.get, version,
+ abi_flags)
- interface_libraries = {}
- dynamic_libraries = {}
- static_libraries = {}
+ # Used to test whether the library is an abi3 library or a full api library.
+ abi3_libraries = _default_library_names(sys.version_info.major, abi_flags)
+
+ # Found libraries
+ static_libraries: dict[str, None] = {}
+ dynamic_libraries: dict[str, None] = {}
+ interface_libraries: dict[str, None] = {}
+ abi_dynamic_libraries: dict[str, None] = {}
+ abi_interface_libraries: dict[str, None] = {}
for root_dir in search_directories:
for libname in search_libnames:
- # Check whether the library exists.
composed_path = os.path.join(root_dir, libname)
+ is_abi3_file = os.path.basename(composed_path) in abi3_libraries
+
+ # Check whether the library exists and add it to the appropriate list.
if os.path.exists(composed_path) or os.path.isdir(composed_path):
- if libname.endswith(".a"):
+ if is_abi3_file:
+ if not libname.endswith(".a"):
+ abi_dynamic_libraries[composed_path] = None
+ elif libname.endswith(".a"):
static_libraries[composed_path] = None
else:
dynamic_libraries[composed_path] = None
interface_path = None
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 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.
@@ -190,39 +213,51 @@
# Check whether an interface library exists.
if interface_path and os.path.exists(interface_path):
- interface_libraries[interface_path] = None
+ if is_abi3_file:
+ abi_interface_libraries[interface_path] = None
+ else:
+ interface_libraries[interface_path] = None
- # Non-windows typically has abiflags.
- if hasattr(sys, "abiflags"):
- abiflags = sys.abiflags
- else:
- abiflags = ""
+ # Additional DLLs are needed on Windows to link properly.
+ dlls = []
+ if _IS_WINDOWS:
+ dlls.extend(
+ glob.glob(os.path.join(os.path.dirname(base_executable), "*.dll")))
+ dlls = [
+ x for x in dlls
+ if x not in dynamic_libraries and x not in abi_dynamic_libraries
+ ]
+
+ def _unique_basenames(inputs: dict[str, None]) -> list[str]:
+ """Returns a list of paths, keeping only the first path for each basename."""
+ result = []
+ seen = set()
+ for k in inputs:
+ b = os.path.basename(k)
+ if b not in seen:
+ seen.add(b)
+ result.append(k)
+ return result
# 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()),
- "shlib_suffix": "" if _IS_WINDOWS else shlib_suffix,
- "abi_flags": abiflags,
+ "dynamic_libraries": _unique_basenames(dynamic_libraries),
+ "static_libraries": _unique_basenames(static_libraries),
+ "interface_libraries": _unique_basenames(interface_libraries),
+ "abi_dynamic_libraries": _unique_basenames(abi_dynamic_libraries),
+ "abi_interface_libraries": _unique_basenames(abi_interface_libraries),
+ "abi_flags": abi_flags,
+ "shlib_suffix": ".dylib" if _IS_DARWIN else "",
+ "additional_dlls": dlls,
+ "defines": defines,
}
-def _get_base_executable():
+def _get_base_executable() -> str:
"""Returns the base executable path."""
- try:
- if sys._base_executable: # pylint: disable=protected-access
- return sys._base_executable # pylint: disable=protected-access
- except AttributeError:
- # Bug reports indicate sys._base_executable doesn't exist in some cases,
- # but it's not clear why.
- # See https://github.com/bazel-contrib/rules_python/issues/3172
- pass
- # The normal sys.executable is the next-best guess if sys._base_executable
- # is missing.
- return sys.executable
+ return getattr(sys, "_base_executable", None) or sys.executable
data = {
diff --git a/python/private/local_runtime_repo.bzl b/python/private/local_runtime_repo.bzl
index 024f7c5..df27c74 100644
--- a/python/private/local_runtime_repo.bzl
+++ b/python/private/local_runtime_repo.bzl
@@ -34,15 +34,36 @@
major = "{major}",
minor = "{minor}",
micro = "{micro}",
+ abi_flags = "{abi_flags}",
+ os = "{os}",
+ implementation_name = "{implementation_name}",
interpreter_path = "{interpreter_path}",
interface_library = {interface_library},
libraries = {libraries},
- implementation_name = "{implementation_name}",
- os = "{os}",
- abi_flags = "{abi_flags}",
+ defines = {defines},
+ abi3_interface_library = {abi3_interface_library},
+ abi3_libraries = {abi3_libraries},
+ additional_dlls = {additional_dlls},
)
"""
+def _expand_incompatible_template():
+ return _TOOLCHAIN_IMPL_TEMPLATE.format(
+ major = "0",
+ minor = "0",
+ micro = "0",
+ abi_flags = "",
+ os = "@platforms//:incompatible",
+ implementation_name = "incompatible",
+ interpreter_path = "/incompatible",
+ interface_library = "None",
+ libraries = "[]",
+ defines = "[]",
+ abi3_interface_library = "None",
+ abi3_libraries = "[]",
+ additional_dlls = "[]",
+ )
+
def _norm_path(path):
"""Returns a path using '/' separators and no trailing slash."""
path = path.replace("\\", "/")
@@ -50,33 +71,39 @@
path = path[:-1]
return path
-def _symlink_first_library(rctx, logger, libraries, shlib_suffix):
+def _symlink_libraries(rctx, logger, libraries, shlib_suffix):
"""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.
- shlib_suffix: A suffix only provided for shared libraries to ensure
- that the srcs restriction of cc_library targets are met.
+ libraries: paths to libraries to attempt to symlink.
+ shlib_suffix: Optional. Ensure that the generated symlinks end with this suffix.
Returns:
- A single library path linked by the action.
+ A list of library paths (under lib/) linked by the action.
+
+ Individual files are symlinked instead of the whole directory because
+ shared_lib_dirs contains multiple search paths for the shared libraries,
+ and the python files may be missing from any of those directories, and
+ any of those directories may include non-python runtime libraries,
+ as would be the case if LIBDIR were, for example, /usr/lib.
"""
- for target in libraries:
- origin = rctx.path(target)
+ result = []
+ for source in libraries:
+ origin = rctx.path(source)
if not origin.exists:
# The reported names don't always exist; it depends on the particulars
# of the runtime installation.
continue
- if shlib_suffix and not target.endswith(shlib_suffix):
- linked = "lib/{}{}".format(origin.basename, shlib_suffix)
+ if shlib_suffix and not origin.basename.endswith(shlib_suffix):
+ target = "lib/{}{}".format(origin.basename, shlib_suffix)
else:
- linked = "lib/{}".format(origin.basename)
- logger.debug("Symlinking {} to {}".format(origin, linked))
+ target = "lib/{}".format(origin.basename)
+ logger.debug(lambda: "Symlinking {} to {}".format(origin, target))
rctx.watch(origin)
- rctx.symlink(origin, linked)
- return linked
- return None
+ rctx.symlink(origin, target)
+ result.append(target)
+ return result
def _local_runtime_repo_impl(rctx):
logger = repo_utils.logger(rctx)
@@ -150,31 +177,48 @@
# 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.
+ logger.debug(lambda: "Symlinking {} to include".format(include_path))
rctx.symlink(include_path, "include")
rctx.report_progress("Symlinking external Python shared libraries")
- interface_library = _symlink_first_library(rctx, logger, info["interface_libraries"], None)
- shared_library = _symlink_first_library(rctx, logger, info["dynamic_libraries"], info["shlib_suffix"])
- static_library = _symlink_first_library(rctx, logger, info["static_libraries"], None)
- libraries = []
- if shared_library:
- libraries.append(shared_library)
- elif static_library:
- libraries.append(static_library)
+ interface_library = None
+ if info["dynamic_libraries"]:
+ libraries = _symlink_libraries(rctx, logger, info["dynamic_libraries"][:1], info["shlib_suffix"])
+ symlinked = _symlink_libraries(rctx, logger, info["interface_libraries"][:1], None)
+ if symlinked:
+ interface_library = symlinked[0]
else:
- logger.warn("No external python libraries found.")
+ libraries = _symlink_libraries(rctx, logger, info["static_libraries"], None)
+ if not libraries:
+ logger.info("No python libraries found.")
+
+ abi3_interface_library = None
+ if info["abi_dynamic_libraries"]:
+ abi3_libraries = _symlink_libraries(rctx, logger, info["abi_dynamic_libraries"][:1], info["shlib_suffix"])
+ symlinked = _symlink_libraries(rctx, logger, info["abi_interface_libraries"][:1], None)
+ if symlinked:
+ abi3_interface_library = symlinked[0]
+ else:
+ abi3_libraries = []
+ logger.info("No abi3 python libraries found.")
+
+ additional_dlls = _symlink_libraries(rctx, logger, info["additional_dlls"], None)
build_bazel = _TOOLCHAIN_IMPL_TEMPLATE.format(
major = info["major"],
minor = info["minor"],
micro = info["micro"],
+ abi_flags = info["abi_flags"],
+ os = "@platforms//os:{}".format(repo_utils.get_platforms_os_name(rctx)),
+ implementation_name = info["implementation_name"],
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)),
- abi_flags = info["abi_flags"],
+ defines = repr(info["defines"]),
+ abi3_interface_library = repr(abi3_interface_library),
+ abi3_libraries = repr(abi3_libraries),
+ additional_dlls = repr(additional_dlls),
)
logger.debug(lambda: "BUILD.bazel\n{}".format(build_bazel))
@@ -261,19 +305,6 @@
environ = ["PATH", REPO_DEBUG_ENV_VAR, "DEVELOPER_DIR", "XCODE_VERSION"],
)
-def _expand_incompatible_template():
- return _TOOLCHAIN_IMPL_TEMPLATE.format(
- interpreter_path = "/incompatible",
- implementation_name = "incompatible",
- interface_library = "None",
- libraries = "[]",
- major = "0",
- minor = "0",
- micro = "0",
- os = "@platforms//:incompatible",
- abi_flags = "",
- )
-
def _find_python_exe_from_target(rctx):
base_path = rctx.path(rctx.attr.interpreter_target)
if base_path.exists:
diff --git a/python/private/local_runtime_repo_setup.bzl b/python/private/local_runtime_repo_setup.bzl
index 0ce1d4d..0922181 100644
--- a/python/private/local_runtime_repo_setup.bzl
+++ b/python/private/local_runtime_repo_setup.bzl
@@ -11,7 +11,6 @@
# 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.
-
"""Setup code called by the code generated by `local_runtime_repo`."""
load("@bazel_skylib//lib:selects.bzl", "selects")
@@ -29,12 +28,16 @@
major,
minor,
micro,
+ abi_flags,
+ os,
+ implementation_name,
interpreter_path,
interface_library,
libraries,
- implementation_name,
- os,
- abi_flags):
+ defines,
+ abi3_interface_library,
+ abi3_libraries,
+ additional_dlls):
"""Defines a toolchain implementation for a local Python runtime.
Generates public targets:
@@ -51,16 +54,23 @@
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`.
+ abi_flags: `str` The abi flags, as returned by `sys.abiflags`.
+ os: `str` A label to the OS constraint (e.g. `@platforms//os:linux`) for
+ this runtime.
+ implementation_name: `str` The implementation name, as returned by
+ `sys.implementation.name`.
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
- this runtime.
- abi_flags: `str` Str. Flags provided by sys.abiflags for the runtime.
+ defines: `list[str]` List of additional defines.
+ abi3_interface_library: `str` Path to the interface library.
+ e.g. "lib/python3.lib"
+ abi3_libraries: `list[str]` Path[s] to the python libraries.
+ e.g. ["lib/python3.dll"] or ["lib/python3.so"]
+ additional_dlls: `list[str]` Path[s] to additional DLLs.
+ e.g. ["lib/msvcrt123.dll"]
"""
major_minor = "{}.{}".format(major, minor)
major_minor_micro = "{}.{}".format(major_minor, micro)
@@ -69,44 +79,70 @@
# 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.
- full_abi_deps = []
- abi3_deps = []
if interface_library:
cc_import(
- name = "_python_interface_library",
+ name = "interface",
interface_library = interface_library,
- system_provided = 1,
+ system_provided = True,
)
- if interface_library.endswith("{}.lib".format(major)):
- abi3_deps = [":_python_interface_library"]
- else:
- full_abi_deps = [":_python_interface_library"]
- cc_library(
- name = "_python_headers_abi3",
- # NOTE: Keep in sync with watch_tree() called in local_runtime_repo
+ if abi3_interface_library:
+ cc_import(
+ name = "abi3_interface",
+ interface_library = abi3_interface_library,
+ system_provided = True,
+ )
+
+ native.filegroup(
+ name = "includes",
srcs = native.glob(
include = ["include/**/*.h"],
exclude = ["include/numpy/**"], # numpy headers are handled separately
allow_empty = True, # A Python install may not have C headers
),
- deps = abi3_deps,
+ )
+
+ # header libraries.
+ cc_library(
+ name = "python_headers_abi3",
+ hdrs = [":includes"],
includes = ["include"],
- )
- cc_library(
- name = "_python_headers",
- deps = [":_python_headers_abi3"] + full_abi_deps,
+ defines = defines, # NOTE: Users should define Py_LIMITED_API=3
+ deps = select({
+ "@bazel_tools//src/conditions:windows": [":abi3_interface"],
+ "//conditions:default": [],
+ }),
)
cc_library(
- name = "_libpython",
- hdrs = [":_python_headers"],
- srcs = libraries,
- deps = [],
+ name = "python_headers",
+ hdrs = [":includes"],
+ includes = ["include"],
+ defines = defines,
+ deps = select({
+ "@bazel_tools//src/conditions:windows": [":interface"],
+ "//conditions:default": [],
+ }),
)
+ # python libraries
+ cc_library(
+ name = "libpython_abi3",
+ hdrs = [":includes"],
+ defines = defines, # NOTE: Users should define Py_LIMITED_API=3
+ srcs = abi3_libraries + additional_dlls,
+ )
+
+ cc_library(
+ name = "libpython",
+ hdrs = [":includes"],
+ defines = defines,
+ srcs = libraries + additional_dlls,
+ )
+
+ # runtime configuration
py_runtime(
- name = "_py3_runtime",
+ name = "py3_runtime",
interpreter_path = interpreter_path,
python_version = "PY3",
interpreter_version_info = {
@@ -116,12 +152,13 @@
},
implementation_name = implementation_name,
abi_flags = abi_flags,
+ pyc_tag = "{}-{}{}{}".format(implementation_name, major, minor, abi_flags),
)
py_runtime_pair(
name = "python_runtimes",
py2_runtime = None,
- py3_runtime = ":_py3_runtime",
+ py3_runtime = ":py3_runtime",
visibility = ["//visibility:public"],
)
@@ -133,9 +170,9 @@
py_cc_toolchain(
name = "py_cc_toolchain",
- headers = ":_python_headers",
- headers_abi3 = ":_python_headers_abi3",
- libs = ":_libpython",
+ headers = ":python_headers",
+ headers_abi3 = ":python_headers_abi3",
+ libs = ":libpython",
python_version = major_minor_micro,
visibility = ["//visibility:public"],
)