diff --git a/docs/pyproject.toml b/docs/pyproject.toml index 9a089df59c..c928720a22 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -13,5 +13,6 @@ dependencies = [ "absl-py", "typing-extensions", "sphinx-reredirects", - "pefile" + "pefile", + "pyelftools", ] diff --git a/docs/requirements.txt b/docs/requirements.txt index c05554aeeb..e4a4365185 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -236,6 +236,10 @@ pefile==2024.8.26 \ --hash=sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632 \ --hash=sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f # via rules-python-docs (docs/pyproject.toml) +pyelftools==0.32 \ + --hash=sha256:013df952a006db5e138b1edf6d8a68ecc50630adbd0d83a2d41e7f846163d738 \ + --hash=sha256:6de90ee7b8263e740c8715a925382d4099b354f29ac48ea40d840cf7aa14ace5 + # via rules-python-docs (docs/pyproject.toml) pygments==2.19.2 \ --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b diff --git a/python/cc/py_extension.bzl b/python/cc/py_extension.bzl new file mode 100644 index 0000000000..72a9ce843c --- /dev/null +++ b/python/cc/py_extension.bzl @@ -0,0 +1,8 @@ +"""Public API for py_extension.""" + +load( + "//python/private/cc:py_extension_macro.bzl", + _py_extension = "py_extension", +) + +py_extension = _py_extension diff --git a/python/private/cc/py_extension_macro.bzl b/python/private/cc/py_extension_macro.bzl new file mode 100644 index 0000000000..f4f2a27c35 --- /dev/null +++ b/python/private/cc/py_extension_macro.bzl @@ -0,0 +1,39 @@ +"""Wrapper macro for the py_extension rule.""" + +load("@rules_cc//cc:cc_shared_library.bzl", "cc_shared_library") +load("//python/private:util.bzl", "add_tag") +load( + ":py_extension_rule.bzl", + _py_extension = "py_extension", + ##_py_extension_csl_rule = "py_extension_csl", +) + +def py_extension(**kwargs): + """A macro that calls the py_extension rule and adds a tag. + + Args: + **kwargs: Additional arguments to pass to the rule. + """ + add_tag(kwargs, "@rules_python//python/cc:py_extension") + + use_csl = kwargs.pop("use_csl", False) + if use_csl: + _py_extension_csl(**kwargs) + else: + if "libc" not in kwargs: + kwargs["libc"] = select({ + "//conditions:default": "glibc", + "@rules_python//python/config_settings:_is_py_linux_libc_glibc": "glibc", + "@rules_python//python/config_settings:_is_py_linux_libc_musl": "musl", + }) + _py_extension(**kwargs) + +def _py_extension_csl(*, name, module_name = None, **kwargs): + if not module_name: + module_name = name + + cc_shared_library( + name = name, + shared_lib_name = module_name + ".so", + **kwargs + ) diff --git a/python/private/cc/py_extension_rule.bzl b/python/private/cc/py_extension_rule.bzl new file mode 100644 index 0000000000..d716cd981f --- /dev/null +++ b/python/private/cc/py_extension_rule.bzl @@ -0,0 +1,297 @@ +"""Implementation of the py_extension rule.""" + +load("@rules_cc//cc/common:cc_common.bzl", "cc_common") +load("@rules_cc//cc/common:cc_info.bzl", "CcInfo") +load("//python:versions.bzl", "PLATFORMS") +load("//python/private:attr_builders.bzl", "attrb") +load("//python/private:attributes.bzl", "COMMON_ATTRS") +load("//python/private:builders.bzl", "builders") +load("//python/private:py_info.bzl", "PyInfo") +load("//python/private:py_internal.bzl", "py_internal") +load("//python/private:reexports.bzl", "BuiltinPyInfo") +load("//python/private:rule_builders.bzl", "ruleb") +load("//python/private:toolchain_types.bzl", "PY_CC_TOOLCHAIN_TYPE") + +def _py_extension_impl(ctx): + module_name = ctx.attr.module_name or ctx.label.name + repo_name = ctx.label.workspace_name or ctx.workspace_name + import_path = repo_name + if ctx.label.package: + import_path = repo_name + "/" + ctx.label.package + cc_toolchain = ctx.toolchains["@bazel_tools//tools/cpp:toolchain_type"].cc + feature_configuration = cc_common.configure_features( + ctx = ctx, + cc_toolchain = cc_toolchain, + ) + + # Collect CcInfo from all deps for compilation + static_deps_infos = [dep[CcInfo] for dep in ctx.attr.static_deps] + dynamic_deps_infos = [dep[CcSharedLibraryInfo] for dep in ctx.attr.dynamic_deps] + external_deps_infos = [dep[CcInfo] for dep in ctx.attr.external_deps] + + # Static deps are linked directly into the .so + static_cc_info = cc_common.merge_cc_infos( + cc_infos = static_deps_infos, + ) + + # Dynamic deps are linked as shared libraries + linker_inputs = [dep.linker_input for dep in dynamic_deps_infos] + dynamic_linking_context = cc_common.create_linking_context( + linker_inputs = depset(linker_inputs), + ) + + user_link_flags = [] + user_link_flags.append("-Wl,--export-dynamic-symbol=PyInit_{module_name}".format( + module_name = module_name, + )) + + # The PyInit symbol looks unused, so the linker optimizes it away. Telling it + # to treat it as undefined causes it to be retained. + user_link_flags.append("-Wl,--undefined=PyInit_{module_name}".format( + module_name = module_name, + )) + + if ctx.attr.external_deps: + user_link_flags.append("-Wl,--allow-shlib-undefined") + + ext = _get_extension(cc_toolchain) + use_py_limited_api = bool(ctx.attr.py_limited_api) + if use_py_limited_api: + output_filename = "{module_name}.abi3.{ext}".format( + module_name = module_name, + ext = ext, + ) + else: + py_toolchain = ctx.toolchains[PY_CC_TOOLCHAIN_TYPE] + py_cc_toolchain = py_toolchain.py_cc_toolchain + platform_tag = _get_platform(ctx) + output_filename = "{module_name}.{abi_tag}-{platform}.{ext}".format( + module_name = module_name, + abi_tag = py_cc_toolchain.abi_tag, + platform = platform_tag, + ext = ext, + ) + py_dso = ctx.actions.declare_file(output_filename) + + static_linking_context = static_cc_info.linking_context + linking_contexts = [ + static_linking_context, + dynamic_linking_context, + ] + + # Add target-level linkopts last so users can override. + user_link_flags.extend(ctx.attr.linkopts) + + # todo: add linker script to hide symbols by default + # py_internal allows using some private apis, which may or may not be needed. + # based upon cc_shared_library.bzl + cc_linking_outputs = py_internal.link( + actions = ctx.actions, + feature_configuration = feature_configuration, + cc_toolchain = cc_toolchain, + linking_contexts = linking_contexts, + user_link_flags = user_link_flags, + # todo: add additional_inputs + name = ctx.label.name, + output_type = "dynamic_library", + main_output = py_dso, + # todo: maybe variables_extension + # todo: maybe additional_outputs + ) + + # Propagate CcInfo from dynamic and external deps, but not static ones. + dynamic_cc_info = CcInfo(linking_context = dynamic_linking_context) + propagated_cc_info = cc_common.merge_cc_infos( + cc_infos = [dynamic_cc_info] + external_deps_infos, + ) + + runfiles_builder = builders.RunfilesBuilder() + runfiles_builder.add(py_dso) + runfiles_builder.add_targets(ctx.attr.static_deps) + runfiles_builder.add_targets(ctx.attr.dynamic_deps) + runfiles_builder.add_targets(ctx.attr.external_deps) + runfiles = runfiles_builder.build(ctx) + + return [ + DefaultInfo( + files = depset([py_dso]), + runfiles = runfiles, + ), + PyInfo( + transitive_sources = depset([py_dso]), + imports = depset([import_path]), + ), + propagated_cc_info, + ] + + +PY_EXTENSION_ATTRS = COMMON_ATTRS | { + "dynamic_deps": lambda: attrb.LabelList( + providers = [CcSharedLibraryInfo], + doc = "cc_shared_library targets to be dynamically linked.", + default = [], + ), + "external_deps": lambda: attrb.LabelList( + providers = [CcInfo], + doc = "cc_library targets with external linkage.", + default = [], + ), + "static_deps": lambda: attrb.LabelList( + providers = [CcInfo], + doc = "cc_library targets to be statically and privately linked.", + default = [], + ), + "copts": lambda: attrb.StringList(), + "libc": lambda: attrb.String(default = "glibc"), + "linkopts": lambda: attrb.StringList(), + "module_name": lambda: attrb.String(), + "py_limited_api": lambda: attrb.String( + doc = """ +The minimum Python version to target for the Limited API (e.g., '3.8'). + +If set to a version string (e.g., '3.8') instead of '' (empty string): + - Configures the output filename to use the simple '.abi3' suffix + (e.g., 'ext.abi3.so'). + +Note: Since the py_extension rule only links pre-compiled libraries, +you must manually add the preprocessor macro to the cc_library targets +that compile your C/C++ sources, for example: + cc_library( + name = "my_impl", + srcs = ["my_code.c"], + defines = ["Py_LIMITED_API=0x03080000"], + ... + ) + +Set to '' (the default) or None to build a standard, version-specific +extension. +""", + default = "", + ), + "_constraints": lambda: attrb.LabelList( + default = sorted({ + c: None + for info in PLATFORMS.values() + for c in info.compatible_with + }.keys()), + ), +} + +def create_py_extension_rule_builder(**kwargs): + """Create a rule builder for a py_extension.""" + builder = ruleb.Rule( + implementation = _py_extension_impl, + attrs = PY_EXTENSION_ATTRS, + provides = [PyInfo, CcInfo], + toolchains = [ + ruleb.ToolchainType(PY_CC_TOOLCHAIN_TYPE), + ruleb.ToolchainType("@bazel_tools//tools/cpp:toolchain_type"), + ], + fragments = ["cpp"], + **kwargs + ) + return builder + +py_extension = create_py_extension_rule_builder().build() + +def _get_extension(cc_toolchain): + """ + Derives the appropriate file extension from the C++ toolchain. + + Args: + cc_toolchain: The CcToolchainInfo provider (usually obtained via + ctx.toolchains["@bazel_tools//tools/cpp:toolchain_type"].cc) + + Returns: + The extension, e.g. "so" or "pyd" + """ + + # Windows uses .pyd; Unix (Linux/macOS) uses .so for Python modules + target_name = cc_toolchain.target_gnu_system_name + is_windows = "windows" in target_name or "mingw" in target_name or "msvc" in target_name + ext = "pyd" if is_windows else "so" + return ext + +def _derive_pep3149_tag(platform, info): + # platform is the triplet, e.g. "x86_64-unknown-linux-gnu" + p, _, _ = platform.partition("-freethreaded") + parts = p.split("-") + triplet_arch = parts[0] + + if info.os_name == "windows": + if triplet_arch == "x86_64": + return "win_amd64" + elif triplet_arch == "aarch64": + return "win_arm64" + else: + return "win32" + elif info.os_name == "osx": + return "darwin" + elif info.os_name == "linux": + abi = "musl" if p.endswith("-musl") else "gnu" + return "{}-linux-{}".format(triplet_arch, abi) + else: + return triplet_arch + +def _get_platform_from_constraints(ctx): + # Build a map of Label to ConstraintValueInfo from _constraints + constraints_map = {} + for c in ctx.attr._constraints: + if platform_common.ConstraintValueInfo in c: + constraints_map[c.label] = c[platform_common.ConstraintValueInfo] + + # Resolve the target's libc to its config_setting label string + target_libc_setting = None + if ctx.attr.libc == "musl": + target_libc_setting = str(Label("//python/config_settings:_is_py_linux_libc_musl")) + elif ctx.attr.libc == "glibc": + target_libc_setting = str(Label("//python/config_settings:_is_py_linux_libc_glibc")) + + # Find the matching platform in PLATFORMS + for platform, info in PLATFORMS.items(): + # Check if all compatible_with constraints are satisfied + match = True + for c_str in info.compatible_with: + c_label = Label(c_str) + if c_label in constraints_map: + c_val = constraints_map[c_label] + if not ctx.target_platform_has_constraint(c_val): + match = False + break + else: + match = False + break + + if match: + # Additional check for Linux libc consistency using target_settings + if info.os_name == "linux" and target_libc_setting: + if target_libc_setting not in info.target_settings: + match = False + + if match: + return _derive_pep3149_tag(platform, info) + + return None + +def _get_platform(ctx): + """Derives the PEP 3149 platform tag from the target constraints. + + Args: + ctx: The rule context. + + Returns: + The platform tag, e.g. "x86_64-linux-gnu" or "win_amd64" + """ + platform_tag = _get_platform_from_constraints(ctx) + if platform_tag: + return platform_tag + + fail( + """ +ERROR: Unsupported target platform for {self}. + The target platform's constraints do not match any supported platform + in rules_python's central registry (python/versions.bzl). + Please ensure your target platform is configured correctly.""".format( + self = ctx.label, + ), + ) diff --git a/python/private/py_cc_toolchain_info.bzl b/python/private/py_cc_toolchain_info.bzl index 8cb3680b59..da7938503f 100644 --- a/python/private/py_cc_toolchain_info.bzl +++ b/python/private/py_cc_toolchain_info.bzl @@ -17,6 +17,11 @@ PyCcToolchainInfo = provider( doc = "C/C++ information about the Python runtime.", fields = { + "abi_tag": """\ +:type: str + +The ABI tag for extension modules, e.g. 'cpython-311' or 'cpython-313t'. +""", "headers": """\ :type: struct diff --git a/python/private/py_cc_toolchain_rule.bzl b/python/private/py_cc_toolchain_rule.bzl index b5c997ea6e..4067df668d 100644 --- a/python/private/py_cc_toolchain_rule.bzl +++ b/python/private/py_cc_toolchain_rule.bzl @@ -45,7 +45,14 @@ def _py_cc_toolchain_impl(ctx): else: headers_abi3 = None + abi_tag = ctx.attr.abi_tag + if not abi_tag: + # Derive default: cpython-XX + version_parts = ctx.attr.python_version.split(".") + abi_tag = "cpython-{}{}".format(version_parts[0], version_parts[1]) + py_cc_toolchain = PyCcToolchainInfo( + abi_tag = abi_tag, headers = struct( providers_map = { "CcInfo": ctx.attr.headers[CcInfo], @@ -67,6 +74,10 @@ def _py_cc_toolchain_impl(ctx): py_cc_toolchain = rule( implementation = _py_cc_toolchain_impl, attrs = { + "abi_tag": attr.string( + doc = "The ABI tag for extension modules, e.g. 'cpython-311'", + default = "", + ), "headers": attr.label( doc = ("Target that provides the Python headers. Typically this " + "is a cc_library target."), diff --git a/tests/cc/py_extension/BUILD.bazel b/tests/cc/py_extension/BUILD.bazel new file mode 100644 index 0000000000..6a595cf57b --- /dev/null +++ b/tests/cc/py_extension/BUILD.bazel @@ -0,0 +1,132 @@ +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_shared_library.bzl", "cc_shared_library") +load("//python:py_test.bzl", "py_test") + +# buildifier: disable=bzl-visibility +load("//python/cc:py_extension.bzl", "py_extension") +load(":py_extension_tests.bzl", "py_extension_analysis_test_suite") +load(":py_limited_api_tests.bzl", "py_limited_api_test_suite") + +package( + default_testonly = True, + default_visibility = ["//visibility:private"], +) + +licenses(["notice"]) + +py_extension( + name = "ext_static", + ##srcs = ["ext_static.c"], + static_deps = [":static_dep"], +) + +py_extension( + name = "ext_shared", + dynamic_deps = [ + ":add_one_shared", + ], + static_deps = [ + ":ext_shared_impl", + ], +) + +py_extension( + name = "ext_csl_shared", + dynamic_deps = [ + ":add_one_shared", + ], + exports_filter = [ + "//tests/cc/py_extension:ext_shared_impl", + ":ext_shared_impl", + ], + module_name = "ext_shared", + use_csl = True, + deps = [ + ":ext_shared_impl", + ], +) + +cc_library( + name = "ext_shared_impl", + srcs = ["ext_shared.c"], + copts = [ + # Gemini says PIC is needed + "-fPIC", + "-fvisibility=hidden", + ], + deps = [ + #":add_one_headers", + # todo: if we put this here, we statically link add_one into the + # extension. + ":add_one_impl", + "@rules_python//python/cc:current_py_cc_headers", + ], +) + +cc_library( + name = "static_dep", + srcs = ["static_dep.c"], + hdrs = ["static_dep.h"], +) + +cc_shared_library( + name = "add_one_shared", + deps = [":add_one_impl"], +) + +cc_library( + name = "add_one_headers", + hdrs = ["add_one.h"], +) + +cc_library( + name = "add_one_impl", + srcs = ["add_one.c"], + deps = [ + ":add_one_headers", + ":add_one_helper", + ], +) + +cc_library( + name = "add_one_helper", + srcs = ["add_one_helper.c"], + hdrs = ["add_one_helper.h"], +) + +py_extension( + name = "ext_limited", + py_limited_api = "3.8", + static_deps = [":ext_limited_impl"], +) + +cc_library( + name = "ext_limited_impl", + srcs = ["ext_limited.c"], + copts = [ + "-fPIC", + "-fvisibility=hidden", + ], + defines = ["Py_LIMITED_API=0x3080000"], + deps = [ + "@rules_python//python/cc:current_py_cc_headers", + ], +) + +py_test( + name = "py_extension_test", + srcs = ["py_extension_test.py"], + deps = [ + ":ext_shared", + "@dev_pip//pyelftools", + "@rules_python//python/runfiles", + ], +) + +py_extension_analysis_test_suite( + name = "py_extension_analysis_tests", +) + +py_limited_api_test_suite( + name = "py_limited_api_tests", +) diff --git a/tests/cc/py_extension/add_one.c b/tests/cc/py_extension/add_one.c new file mode 100644 index 0000000000..7da8f6796e --- /dev/null +++ b/tests/cc/py_extension/add_one.c @@ -0,0 +1,7 @@ + +#include "add_one_helper.h" + +int add_one(int x) { + x = add_one_helper(x); + return x + 1; +} diff --git a/tests/cc/py_extension/add_one.h b/tests/cc/py_extension/add_one.h new file mode 100644 index 0000000000..eda0abf7e5 --- /dev/null +++ b/tests/cc/py_extension/add_one.h @@ -0,0 +1,6 @@ +#ifndef TESTS_CC_PY_EXTENSION_DYN_DEP_A_H_ +#define TESTS_CC_PY_EXTENSION_DYN_DEP_A_H_ + +int add_one(int x); + +#endif // TESTS_CC_PY_EXTENSION_DYN_DEP_A_H_ diff --git a/tests/cc/py_extension/add_one_helper.c b/tests/cc/py_extension/add_one_helper.c new file mode 100644 index 0000000000..21d19a5823 --- /dev/null +++ b/tests/cc/py_extension/add_one_helper.c @@ -0,0 +1,7 @@ + + +#include "add_one_helper.h" + +int add_one_helper(int i) { + return i + 1; +} diff --git a/tests/cc/py_extension/add_one_helper.h b/tests/cc/py_extension/add_one_helper.h new file mode 100644 index 0000000000..5524d3077f --- /dev/null +++ b/tests/cc/py_extension/add_one_helper.h @@ -0,0 +1,2 @@ + +int add_one_helper(int i); diff --git a/tests/cc/py_extension/ext_limited.c b/tests/cc/py_extension/ext_limited.c new file mode 100644 index 0000000000..f3622c5824 --- /dev/null +++ b/tests/cc/py_extension/ext_limited.c @@ -0,0 +1,22 @@ +#include + +static PyObject* get_limited_api_version(PyObject* self, PyObject* args) { + return PyUnicode_FromFormat("0x%08x", Py_LIMITED_API); +} + +static PyMethodDef ModuleMethods[] = { + {"get_limited_api_version", get_limited_api_version, METH_NOARGS, "Get the version of the limited API this extension was compiled against."}, + {NULL, NULL, 0, NULL} +}; + +static struct PyModuleDef ext_limited_module = { + PyModuleDef_HEAD_INIT, + "ext_limited", + NULL, + -1, + ModuleMethods +}; + +PyMODINIT_FUNC PyInit_ext_limited(void) { + return PyModule_Create(&ext_limited_module); +} diff --git a/tests/cc/py_extension/ext_shared.c b/tests/cc/py_extension/ext_shared.c new file mode 100644 index 0000000000..4b79a6da2f --- /dev/null +++ b/tests/cc/py_extension/ext_shared.c @@ -0,0 +1,33 @@ +#include + +#include "tests/cc/py_extension/add_one.h" + +// A simple function that returns a Python integer. +static PyObject* do_alpha(PyObject* self, PyObject* args) { + return PyLong_FromLong(add_one(41)); +} + +// Method definition object for this extension, these are the functions +// that will be available in the module. +static PyMethodDef ModuleMethods[] = { + {"do_alpha", do_alpha, METH_NOARGS, "A simple C function."}, + {NULL, NULL, 0, NULL} /* Sentinel */ +}; + +// Module definition +// The arguments of this structure tell Python what to call your extension, +// what its methods are and where to look for its method definitions. +static struct PyModuleDef ext_shared_module = { + PyModuleDef_HEAD_INIT, + "ext_shared", /* name of module */ + NULL, /* module documentation, may be NULL */ + -1, /* size of per-interpreter state of the module, + or -1 if the module keeps state in global variables. */ + ModuleMethods +}; + +// The module init function. This must be exported and retained in the +// shared library output. +PyMODINIT_FUNC PyInit_ext_shared(void) { + return PyModule_Create(&ext_shared_module); +} diff --git a/tests/cc/py_extension/ext_static.c b/tests/cc/py_extension/ext_static.c new file mode 100644 index 0000000000..500c52db00 --- /dev/null +++ b/tests/cc/py_extension/ext_static.c @@ -0,0 +1 @@ +/* A no-op C extension for static linking tests. */ diff --git a/tests/cc/py_extension/py_extension_test.py b/tests/cc/py_extension/py_extension_test.py new file mode 100644 index 0000000000..52d5502ea7 --- /dev/null +++ b/tests/cc/py_extension/py_extension_test.py @@ -0,0 +1,49 @@ +import os +import unittest + +import ext_shared +from elftools.elf.dynamic import DynamicSection +from elftools.elf.elffile import ELFFile + +from python.runfiles import runfiles + + +class PyExtensionTest(unittest.TestCase): + def test_inspect_elf(self): + r = runfiles.Create() + ext_path = r.Rlocation( + "rules_python/tests/cc/py_extension/" + + "ext_shared.cpython-311-x86_64-linux-gnu.so" + ) + self.assertTrue( + os.path.exists(ext_path), f"Could not find ext_shared.so at {ext_path}" + ) + + with open(ext_path, "rb") as f: + elf = ELFFile(f) + + # Check for DT_NEEDED entry for the dynamic library + dynamic_section = elf.get_section_by_name(".dynamic") + self.assertIsNotNone(dynamic_section) + self.assertTrue(isinstance(dynamic_section, DynamicSection)) + + needed_libs = [ + tag.needed + for tag in dynamic_section.iter_tags() + if tag.entry.d_tag == "DT_NEEDED" + ] + self.assertIn("libadd_one_shared.so", needed_libs) + + # Check for the PyInit symbol + dynsym_section = elf.get_section_by_name(".dynsym") + self.assertIsNotNone(dynsym_section) + + symbols = [s.name for s in dynsym_section.iter_symbols()] + self.assertIn("PyInit_ext_shared", symbols) + + def test_import_and_call(self): + self.assertEqual(ext_shared.do_alpha(), 43) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cc/py_extension/py_extension_tests.bzl b/tests/cc/py_extension/py_extension_tests.bzl new file mode 100644 index 0000000000..b84ddb9ecb --- /dev/null +++ b/tests/cc/py_extension/py_extension_tests.bzl @@ -0,0 +1,95 @@ +# 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. + +"""Tests for py_extension.""" + +load("@rules_cc//cc/common:cc_info.bzl", "CcInfo") +load("@rules_testing//lib:analysis_test.bzl", "analysis_test", "test_suite") +load("@rules_testing//lib:truth.bzl", "matching") +load("//python/private:py_info.bzl", "PyInfo") + +_tests = [] + +def _test_static_deps_impl(env, target): + env.expect.that_target(target).has_provider(PyInfo) + py_info = target[PyInfo] + env.expect.that_target(target).has_provider(CcInfo) + cc_info = target[CcInfo] + + # The .so should be in PyInfo + env.expect.that_collection(py_info.transitive_sources.to_list()).has_size(1) + env.expect.that_depset_of_files(py_info.transitive_sources).contains_predicate( + matching.file_basename_equals("ext_static.cpython-311-x86_64-linux-gnu.so"), + ) + + # CcInfo from static_deps should not be propagated. + env.expect.that_depset_of_files(cc_info.linking_context.linker_inputs).contains_exactly([]) + +def _test_static_deps(name): + analysis_test( + name = name, + impl = _test_static_deps_impl, + target = "//tests/cc/py_extension:ext_static", + ) + +_tests.append(_test_static_deps) + +def _test_dynamic_deps_impl(env, target): + env.expect.that_target(target).has_provider(PyInfo) + py_info = target[PyInfo] + env.expect.that_target(target).has_provider(CcInfo) + cc_info = target[CcInfo] + + # The .so should be in PyInfo + env.expect.that_collection(py_info.transitive_sources.to_list()).has_size(1) + env.expect.that_depset_of_files(py_info.transitive_sources).contains_predicate( + matching.file_basename_equals("ext_shared.cpython-311-x86_64-linux-gnu.so"), + ) + + # CcInfo from dynamic_deps should be propagated. + env.expect.that_collection(cc_info.linking_context.linker_inputs.to_list()).has_size(1) + +def _test_dynamic_deps(name): + analysis_test( + name = name, + impl = _test_dynamic_deps_impl, + target = "//tests/cc/py_extension:ext_shared", + ) + +_tests.append(_test_dynamic_deps) + +def _test_musl_platform_impl(env, target): + env.expect.that_target(target).has_provider(PyInfo) + py_info = target[PyInfo] + env.expect.that_depset_of_files(py_info.transitive_sources).contains_predicate( + matching.file_basename_equals("ext_static.cpython-311-x86_64-linux-musl.so"), + ) + +def _test_musl_platform(name): + analysis_test( + name = name, + impl = _test_musl_platform_impl, + target = "//tests/cc/py_extension:ext_static", + config_settings = { + str(Label("//python/config_settings:py_linux_libc")): "musl", + }, + ) + +_tests.append(_test_musl_platform) + +def py_extension_analysis_test_suite(name): + test_suite( + name = name, + tests = _tests, + ) diff --git a/tests/cc/py_extension/py_limited_api_tests.bzl b/tests/cc/py_extension/py_limited_api_tests.bzl new file mode 100644 index 0000000000..bf5b8e457e --- /dev/null +++ b/tests/cc/py_extension/py_limited_api_tests.bzl @@ -0,0 +1,139 @@ +# 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. + +"""Tests for the py_limited_api attribute for py_extension.""" + +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_testing//lib:analysis_test.bzl", "analysis_test", "test_suite") +load("@rules_testing//lib:util.bzl", "util") +load("//python/cc:py_extension.bzl", "py_extension") + +def _test_limited_pass_impl(env, target): + env.expect.that_target(target).default_outputs().contains( + "tests/cc/py_extension/{}.abi3.so".format(target.label.name), + ) + +def _test_limited_same_version(name): + util.helper_target( + cc_library, + name = name + "_csl", + defines = ["Py_LIMITED_API=0x03080000"], + deps = [ + "@rules_python//python/cc:current_py_cc_headers", + ], + ) + py_extension( + name = name + "_pyext", + static_deps = [":" + name + "_csl"], + py_limited_api = "3.8", + ) + analysis_test( + name = name, + target = name + "_pyext", + impl = _test_limited_pass_impl, + ) + +def _test_limited_older_dep(name): + util.helper_target( + cc_library, + name = name + "_csl", + defines = ["Py_LIMITED_API=0x03080000"], # 3.8 + deps = [ + "@rules_python//python/cc:current_py_cc_headers", + ], + ) + py_extension( + name = name + "_pyext", + static_deps = [":" + name + "_csl"], + py_limited_api = "3.9", # 3.9 + ) + analysis_test( + name = name, + target = name + "_pyext", + impl = _test_limited_pass_impl, + ) + +def _test_no_limited_api(name): + util.helper_target( + cc_library, + name = name + "_csl", + deps = [ + "@rules_python//python/cc:current_py_cc_headers", + ], + ) + py_extension( + name = name + "_pyext", + static_deps = [":" + name + "_csl"], + ) + analysis_test( + name = name, + target = name + "_pyext", + impl = _test_no_limited_api_impl, + ) + +def _test_no_limited_api_impl(env, target): + # Should pass, nothing to assert on filename since it is platform-specific + _ = env # @unused + _ = target # @unused + +def _test_no_limited_api_dep_has_limited(name): + util.helper_target( + cc_library, + name = name + "_csl", + defines = ["Py_LIMITED_API=0x03080000"], + deps = [ + "@rules_python//python/cc:current_py_cc_headers", + ], + ) + py_extension( + name = name + "_pyext", + static_deps = [":" + name + "_csl"], + ) + analysis_test( + name = name, + target = name + "_pyext", + impl = _test_no_limited_api_dep_has_limited_impl, + ) + +def _test_no_limited_api_dep_has_limited_impl(env, target): + _ = env # @unused + _ = target # @unused + +def _test_limited_api_dep_has_no_python(name): + util.helper_target( + cc_library, + name = name + "_csl", + ) + py_extension( + name = name + "_pyext", + static_deps = [":" + name + "_csl"], + py_limited_api = "3.8", + ) + analysis_test( + name = name, + target = name + "_pyext", + impl = _test_limited_pass_impl, + ) + +def py_limited_api_test_suite(name): + test_suite( + name = name, + tests = [ + _test_limited_same_version, + _test_limited_older_dep, + _test_no_limited_api, + _test_no_limited_api_dep_has_limited, + _test_limited_api_dep_has_no_python, + ], + ) diff --git a/tests/cc/py_extension/static_dep.c b/tests/cc/py_extension/static_dep.c new file mode 100644 index 0000000000..fa95e4dacc --- /dev/null +++ b/tests/cc/py_extension/static_dep.c @@ -0,0 +1,5 @@ +#include "static_dep.h" + +int my_lib_func() { + return 42; +} diff --git a/tests/cc/py_extension/static_dep.h b/tests/cc/py_extension/static_dep.h new file mode 100644 index 0000000000..d0f272abd7 --- /dev/null +++ b/tests/cc/py_extension/static_dep.h @@ -0,0 +1 @@ +int my_lib_func();