fix(local): Fix local_runtime use with free-threaded python (#3399)

* Return abi_flags from get_local_runtime_info and pass it into the
py3_runtime
* Rework how shared-libraries are links are constructed to better meet
@rules_cc cc_library.srcs requirements

This improves runtime detection for macos when using a python3.14t
framework runtime.

---------

Co-authored-by: Richard Levasseur <rlevasseur@google.com>
Co-authored-by: Richard Levasseur <richardlev@gmail.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9c6a6b6..8c472c0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,6 +30,31 @@
 
 {#v0-0-0-removed}
 ### Removed
+* Nothing removed.
+
+{#v0-0-0-changed}
+### Changed
+* Nothing changed.
+
+{#v0-0-0-fixed}
+### Fixed
+* Nothing fixed.
+
+{#v0-0-0-added}
+### Added
+* Nothing added.
+
+END_UNRELEASED_TEMPLATE
+-->
+
+
+{#v0-0-0}
+## Unreleased
+
+[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0
+
+{#v0-0-0-removed}
+### Removed
 * (toolchain) Remove all of the python 3.9 toolchain versions except for the `3.9.25`.
   This version has reached EOL and will no longer receive any security fixes, please update to
   `3.10` or above.
@@ -46,16 +71,14 @@
   implementation assumes that it is always four levels below the runfiles
   directory, leading to incorrect path checks
   ([#3085](https://github.com/bazel-contrib/rules_python/issues/3085)).
+* (toolchains) local toolchains now tell the `sys.abiflags` value of the
+  underlying runtime.
 
 {#v0-0-0-added}
 ### Added
 * (toolchains) `3.9.25` Python toolchain from [20251031] release.
 
 [20251031]: https://github.com/astral-sh/python-build-standalone/releases/tag/20251031
-
-END_UNRELEASED_TEMPLATE
--->
-
 {#v1-7-0}
 ## [1.7.0] - 2025-10-11
 
diff --git a/python/private/get_local_runtime_info.py b/python/private/get_local_runtime_info.py
index d176b1a..b20c159 100644
--- a/python/private/get_local_runtime_info.py
+++ b/python/private/get_local_runtime_info.py
@@ -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.
-
 """Returns information about the local Python runtime as JSON."""
 
 import json
@@ -38,7 +37,9 @@
     # 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")]
+    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`
@@ -55,8 +56,8 @@
 
     if not _IS_DARWIN:
         for exec_dir in (
-            os.path.dirname(base_executable) if base_executable else None,
-            get_config("BINDIR"),
+                os.path.dirname(base_executable) if base_executable else None,
+                get_config("BINDIR"),
         ):
             if not exec_dir:
                 continue
@@ -67,8 +68,8 @@
                 lib_dirs.append(os.path.join(exec_dir, "lib"))
                 lib_dirs.append(os.path.join(exec_dir, "libs"))
             else:
-                # On most systems the executable is in a bin/ directory and the libraries
-                # are in a sibling lib/ directory.
+                # On most non-windows 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(exec_dir), "lib"))
 
     # Dedup and remove empty values, keeping the order.
@@ -76,7 +77,19 @@
     return {k: None for k in lib_dirs}.keys()
 
 
-def _search_library_names(get_config):
+def _get_shlib_suffix(get_config) -> str:
+    """Returns the suffix for shared libraries."""
+    if _IS_DARWIN:
+        return ".dylib"
+    if _IS_WINDOWS:
+        return ".dll"
+    suffix = get_config("SHLIB_SUFFIX")
+    if not suffix:
+        suffix = ".so"
+    return suffix
+
+
+def _search_library_names(get_config, shlib_suffix):
     """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
@@ -90,8 +103,7 @@
     #
     # A typical LIBRARY is 'libpythonX.Y.a' on Linux.
     lib_names = [
-        get_config(x)
-        for x in (
+        get_config(x) for x in (
             "LDLIBRARY",
             "INSTSONAME",
             "PY3LIBRARY",
@@ -104,26 +116,24 @@
     # 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}")
+    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}{suffix}")
+        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]
@@ -138,30 +148,31 @@
     # 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}"
+            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}"
-            )
+                f"{sys.version_info.major}.{sys.version_info.minor}")
 
+    shlib_suffix = _get_shlib_suffix(config_vars.get)
     search_directories = _search_directories(config_vars.get, base_executable)
-    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
+    search_libnames = _search_library_names(config_vars.get, shlib_suffix)
 
     interface_libraries = {}
     dynamic_libraries = {}
     static_libraries = {}
+
     for root_dir in search_directories:
         for libname in search_libnames:
+            # Check whether the library exists.
             composed_path = os.path.join(root_dir, libname)
-            if libname.endswith(".a"):
-                _add_if_exists(static_libraries, composed_path)
-                continue
+            if os.path.exists(composed_path) or os.path.isdir(composed_path):
+                if libname.endswith(".a"):
+                    static_libraries[composed_path] = None
+                else:
+                    dynamic_libraries[composed_path] = None
 
-            _add_if_exists(dynamic_libraries, composed_path)
+            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
@@ -172,14 +183,20 @@
                 #
                 # 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")
-                )
+                interface_path = 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")
-                )
+                interface_path = os.path.join(root_dir, libname[:-2] + "ifso")
+
+            # Check whether an interface library exists.
+            if interface_path and os.path.exists(interface_path):
+                interface_libraries[interface_path] = None
+
+    # Non-windows typically has abiflags.
+    if hasattr(sys, "abiflags"):
+        abiflags = sys.abiflags
+    else:
+        abiflags = ""
 
     # 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
@@ -188,6 +205,8 @@
         "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,
     }
 
 
diff --git a/python/private/local_runtime_repo.bzl b/python/private/local_runtime_repo.bzl
index 583926b..024f7c5 100644
--- a/python/private/local_runtime_repo.bzl
+++ b/python/private/local_runtime_repo.bzl
@@ -39,6 +39,7 @@
     libraries = {libraries},
     implementation_name = "{implementation_name}",
     os = "{os}",
+    abi_flags = "{abi_flags}",
 )
 """
 
@@ -49,33 +50,33 @@
         path = path[:-1]
     return path
 
-def _symlink_first_library(rctx, logger, libraries):
+def _symlink_first_library(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.
     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)
+        if shlib_suffix and not target.endswith(shlib_suffix):
+            linked = "lib/{}{}".format(origin.basename, shlib_suffix)
         else:
             linked = "lib/{}".format(origin.basename)
         logger.debug("Symlinking {} to {}".format(origin, linked))
         rctx.watch(origin)
         rctx.symlink(origin, linked)
-        break
-
-    return linked
+        return linked
+    return None
 
 def _local_runtime_repo_impl(rctx):
     logger = repo_utils.logger(rctx)
@@ -152,9 +153,9 @@
     rctx.symlink(include_path, "include")
 
     rctx.report_progress("Symlinking external Python shared libraries")
-    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"])
+    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:
@@ -173,6 +174,7 @@
         libraries = repr(libraries),
         implementation_name = info["implementation_name"],
         os = "@platforms//os:{}".format(repo_utils.get_platforms_os_name(rctx)),
+        abi_flags = info["abi_flags"],
     )
     logger.debug(lambda: "BUILD.bazel\n{}".format(build_bazel))
 
@@ -269,6 +271,7 @@
         minor = "0",
         micro = "0",
         os = "@platforms//:incompatible",
+        abi_flags = "",
     )
 
 def _find_python_exe_from_target(rctx):
diff --git a/python/private/local_runtime_repo_setup.bzl b/python/private/local_runtime_repo_setup.bzl
index 6cff1ae..0ce1d4d 100644
--- a/python/private/local_runtime_repo_setup.bzl
+++ b/python/private/local_runtime_repo_setup.bzl
@@ -33,7 +33,8 @@
         interface_library,
         libraries,
         implementation_name,
-        os):
+        os,
+        abi_flags):
     """Defines a toolchain implementation for a local Python runtime.
 
     Generates public targets:
@@ -59,6 +60,7 @@
             `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.
     """
     major_minor = "{}.{}".format(major, minor)
     major_minor_micro = "{}.{}".format(major_minor, micro)
@@ -113,6 +115,7 @@
             "minor": minor,
         },
         implementation_name = implementation_name,
+        abi_flags = abi_flags,
     )
 
     py_runtime_pair(