Skip to content

Commit b0ce0a8

Browse files
committed
PYCBC-1854: Migrate _core to the stable ABI (abi3)
Changes -------- * Convert every C-extension type (logger, exceptions, result/streamed result/scan iterator, connection, transactions, hdr_histogram, kv_request) from static PyTypeObject structs to PyType_Spec/ PyType_FromSpec heap types, using PyType_GenericAlloc for allocation and a shared free_heap_type_instance() helper for teardown: heap-type instances hold a reference to their type, so every tp_dealloc must free through the type's own Py_tp_free slot (a Python subclass is GC-allocated and needs PyObject_GC_Del) and then release that reference * Regenerate pycbc_kv_request.cxx/.hxx via autogen from updated jinja2 templates carrying the same heap-type conversion * Modernize module init: PyModule_AddType/PyModule_AddObjectRef replace the now-dead register_pytype/register_heap_type helpers (deleted) at all call sites; spot-sweep Py_INCREF+assign pairs to Py_NewRef * Add limited-API compatibility shims in pytype_utils.hxx (Py_T_* PyMemberDef macros, a PyUnicode_AsUTF8 shim pre-3.13) and fix a missing include in pytocbpp_defs.hxx that needed it * Add PYCBC_PY_LIMITED_API build option (default on, floor 3.10): CMakeLists.txt/setup.py/pycbc_build_setup.py wire it through to abi3 wheel tagging and bdist_wheel's --py-limited-api; python_requires corrected to >=3.10 * Build pycbc_core with hidden default visibility (no-op under MSVC), leaving PyInit__core -- which carries its own visibility("default") via PyMODINIT_FUNC -- as the module's only exported symbol * Link python3.lib rather than the version-specific python3XY.lib on Windows for a limited-API build; explicitly-listed libraries beat the /DEFAULTLIB that pyconfig.h's auto-link pragma injects, so the old link would have bound an abi3-tagged .pyd to one interpreter * Fix get_ext_filename/CMake filename agreement: a limited-API build emits <name>.abi3.<so|pyd>, and a non-limited build keeps the interpreter-specific suffix instead of an untagged .so that any interpreter's import machinery would load with no version check * pycbc_build_setup.py: pass the package directory once, on every platform, as -DPYCBC_MODULE_OUTPUT_DIRECTORY, replacing the global -DCMAKE_LIBRARY_OUTPUT_DIRECTORY[_<CONFIG>] and the Windows-only -DCMAKE_RUNTIME_OUTPUT_DIRECTORY_<CONFIG>, which retargeted every library, executable and DLL in the build tree rather than just the extension module (on Windows that pulled BoringSSL's bssl.exe into the wheel) * CMakeLists.txt: apply that path as a per-target property on pycbc_core only, selecting the category by platform (RUNTIME for the Windows DLL, LIBRARY everywhere else) and pointing PDB_OUTPUT_DIRECTORY at the build tree, including the per-config variants that multi-config generators require * CMakeLists.txt: warn when PYCBC_MODULE_OUTPUT_DIRECTORY is unset; nothing else places the module, so the wheel would otherwise be packed without an extension and fail at import instead of at build time * Modernize license metadata to SPDX (license="Apache-2.0", license_files), drop the now-deprecated OSI classifier, and raise the setuptools floor to >=70.1 Change-Id: I0f80fa57dd775df007600bf51f10007529a91c9b Reviewed-on: https://review.couchbase.org/c/couchbase-python-client/+/250742 Tested-by: Build Bot <build@couchbase.com> Reviewed-by: Dimitris Christodoulou <dimitris.christodoulou@couchbase.com>
1 parent cf977c7 commit b0ce0a8

27 files changed

Lines changed: 774 additions & 605 deletions

CMakeLists.txt

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,15 @@ endif()
6767

6868

6969
if(WIN32)
70-
set(PYCBC_C_MOD_SUFFIX ".pyd")
70+
set(PYCBC_C_MOD_SUFFIX_DEFAULT ".pyd")
7171
else()
72-
set(PYCBC_C_MOD_SUFFIX ".so")
72+
set(PYCBC_C_MOD_SUFFIX_DEFAULT ".so")
7373
endif()
74+
# Normally set by pycbc_build_setup.py to the building interpreter's own
75+
# sysconfig EXT_SUFFIX (e.g. .cpython-310-darwin.so). This is what makes a
76+
# version-specific (non-abi3) build loadable only by a matching interpreter;
77+
# the plain fallback below is for a raw `cmake` invocation outside setup.py.
78+
set(PYCBC_C_MOD_SUFFIX "${PYCBC_C_MOD_SUFFIX_DEFAULT}" CACHE STRING "Compiled extension module suffix, including any interpreter tag")
7479

7580
file(READ "${PROJECT_SOURCE_DIR}/couchbase/_version.py" PYCBC_VERSION_CONTENTS)
7681
string(REGEX MATCH "__version__.*([0-9]+\\.[0-9]+\\.[0-9]+)(\\.?[+a-z0-9]*)" PYCBC_FOUND_VERSION ${PYCBC_VERSION_CONTENTS})
@@ -254,6 +259,36 @@ add_library(pycbc_core SHARED ${SOURCE_FILES})
254259

255260
target_compile_definitions(pycbc_core PRIVATE COUCHBASE_CXX_CLIENT_IGNORE_CORE_DEPRECATIONS)
256261

262+
# PYCBC-1854: Build against the Python Stable ABI (abi3) by default so a single
263+
# wheel can target every supported CPython. Power users can opt out via the
264+
# PYCBC_PY_LIMITED_API env var (handled in pycbc_build_setup.py) which passes
265+
# -DPYCBC_PY_LIMITED_API:BOOL=OFF here, producing a version-specific build.
266+
option(PYCBC_PY_LIMITED_API "Build the C extension against Py_LIMITED_API (abi3)" ON)
267+
# PYCBC_PY_LIMITED_API_HEX is the lowest CPython release this binary will load on.
268+
# Driven by PYCBC_PY_LIMITED_API_VERSION in pycbc_build_setup.py so it stays in
269+
# lockstep with the bdist_wheel --py-limited-api tag. 0x030A0000 == CPython 3.10,
270+
# the floor required by the limited-API symbols pycbc uses.
271+
set(PYCBC_PY_LIMITED_API_HEX "0x030A0000" CACHE STRING "Py_LIMITED_API hex (e.g. 0x030A0000 for CPython 3.10)")
272+
message(STATUS "PYCBC_PY_LIMITED_API=${PYCBC_PY_LIMITED_API}")
273+
set(PYCBC_C_MOD_OUTPUT_NAME "_core")
274+
if(PYCBC_PY_LIMITED_API)
275+
message(STATUS "PYCBC_PY_LIMITED_API_HEX=${PYCBC_PY_LIMITED_API_HEX}")
276+
target_compile_definitions(pycbc_core PRIVATE Py_LIMITED_API=${PYCBC_PY_LIMITED_API_HEX})
277+
# abi3 tag goes on the suffix (_core.abi3.so), not the output name. The
278+
# interpreter tag in PYCBC_C_MOD_SUFFIX is dropped here so the filename matches
279+
# CMakeBuildExt.get_ext_filename(), which pairs ".abi3" with the bare .so.
280+
string(REGEX REPLACE "^.*(\\.[^.]+)$" "\\1" PYCBC_C_MOD_SHARED_LIB_EXT "${PYCBC_C_MOD_SUFFIX}")
281+
if(WIN32)
282+
# Windows has no ".abi3" import suffix: importlib only recognizes
283+
# ".cp3XY-win_amd64.pyd" and a bare ".pyd", so the stable-ABI module is _core.pyd.
284+
set(PYCBC_C_MOD_FULL_SUFFIX "${PYCBC_C_MOD_SHARED_LIB_EXT}")
285+
else()
286+
set(PYCBC_C_MOD_FULL_SUFFIX ".abi3${PYCBC_C_MOD_SHARED_LIB_EXT}")
287+
endif()
288+
else()
289+
set(PYCBC_C_MOD_FULL_SUFFIX "${PYCBC_C_MOD_SUFFIX}")
290+
endif()
291+
257292
target_include_directories(
258293
pycbc_core PRIVATE SYSTEM
259294
"${COUCHBASE_CXX_BINARY_DIR}/generated"
@@ -264,10 +299,28 @@ target_include_directories(
264299
set(COUCHBASE_CXX_CLIENT_TARGET couchbase_cxx_client_static_intermediate)
265300

266301
if(WIN32)
302+
# Python3_LIBRARIES is the version-specific python3XY.lib. Linking it in a stable-ABI
303+
# build takes precedence over the python3.lib that pyconfig.h's MSVC auto-link pragma
304+
# selects under Py_LIMITED_API, which would bind an abi3-tagged .pyd to one interpreter
305+
# version. Prefer python3.lib explicitly, and fall back to the pragma if it isn't found.
306+
if(PYCBC_PY_LIMITED_API)
307+
find_library(
308+
PYCBC_PYTHON3_SABI_LIBRARY
309+
NAMES python3
310+
HINTS ${Python3_LIBRARY_DIRS}
311+
NO_DEFAULT_PATH)
312+
set(PYCBC_PYTHON_LINK_LIBRARIES "")
313+
if(PYCBC_PYTHON3_SABI_LIBRARY)
314+
set(PYCBC_PYTHON_LINK_LIBRARIES ${PYCBC_PYTHON3_SABI_LIBRARY})
315+
endif()
316+
message(STATUS "PYCBC_PYTHON3_SABI_LIBRARY=${PYCBC_PYTHON3_SABI_LIBRARY}")
317+
else()
318+
set(PYCBC_PYTHON_LINK_LIBRARIES ${Python3_LIBRARIES})
319+
endif()
267320
target_link_libraries(
268321
pycbc_core PRIVATE
269322
${COUCHBASE_CXX_CLIENT_TARGET}
270-
${Python3_LIBRARIES}
323+
${PYCBC_PYTHON_LINK_LIBRARIES}
271324
asio
272325
Microsoft.GSL::GSL
273326
taocpp::json
@@ -299,8 +352,55 @@ if(NOT USE_STATIC_BORINGSSL)
299352
target_link_libraries(pycbc_core PUBLIC ${OPENSSL_LIBRARIES})
300353
endif()
301354

355+
# PyInit__core carries its own visibility("default") via PyMODINIT_FUNC, so hiding
356+
# everything else keeps the module's exported surface to the one symbol CPython needs.
357+
# No-op on MSVC, which exports nothing without __declspec(dllexport).
302358
set_target_properties(
303359
pycbc_core
304360
PROPERTIES PREFIX ""
305-
OUTPUT_NAME _core
306-
SUFFIX ${PYCBC_C_MOD_SUFFIX})
361+
OUTPUT_NAME ${PYCBC_C_MOD_OUTPUT_NAME}
362+
SUFFIX ${PYCBC_C_MOD_FULL_SUFFIX}
363+
C_VISIBILITY_PRESET hidden
364+
CXX_VISIBILITY_PRESET hidden
365+
VISIBILITY_INLINES_HIDDEN ON)
366+
367+
# The extension module is the only build artifact that belongs in the Python package
368+
# directory, so the directory is set on the target rather than through a global
369+
# CMAKE_<category>_OUTPUT_DIRECTORY, which applies to every target in the build tree
370+
# (PYCBC-1878). The category differs by platform: on Windows a DLL is RUNTIME, which also
371+
# covers executables, and everywhere else the module is LIBRARY. PDB_OUTPUT_DIRECTORY
372+
# otherwise follows the target's own output directory, which would put the symbols back into
373+
# the package directory and therefore into the wheel.
374+
#
375+
# The path is used as passed. These properties accept native separators, and
376+
# file(TO_CMAKE_PATH) splits on the host's path-list separator, so it is not a safe
377+
# normalizer for a single path.
378+
if(PYCBC_MODULE_OUTPUT_DIRECTORY)
379+
message(STATUS "PYCBC_MODULE_OUTPUT_DIRECTORY=${PYCBC_MODULE_OUTPUT_DIRECTORY}")
380+
if(WIN32)
381+
set(PYCBC_MOD_DIR_CATEGORY RUNTIME)
382+
else()
383+
set(PYCBC_MOD_DIR_CATEGORY LIBRARY)
384+
endif()
385+
set_target_properties(
386+
pycbc_core
387+
PROPERTIES ${PYCBC_MOD_DIR_CATEGORY}_OUTPUT_DIRECTORY "${PYCBC_MODULE_OUTPUT_DIRECTORY}"
388+
PDB_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}")
389+
# Multi-config generators (Visual Studio, Xcode) append the configuration name unless the
390+
# per-config property is set. Single-config generators leave CMAKE_CONFIGURATION_TYPES
391+
# empty, so this loop is a no-op there and the plain property above applies.
392+
foreach(pycbc_config IN LISTS CMAKE_CONFIGURATION_TYPES)
393+
string(TOUPPER "${pycbc_config}" pycbc_config_uc)
394+
set_target_properties(
395+
pycbc_core
396+
PROPERTIES ${PYCBC_MOD_DIR_CATEGORY}_OUTPUT_DIRECTORY_${pycbc_config_uc} "${PYCBC_MODULE_OUTPUT_DIRECTORY}"
397+
PDB_OUTPUT_DIRECTORY_${pycbc_config_uc} "${CMAKE_BINARY_DIR}")
398+
endforeach()
399+
else()
400+
# Nothing else places the module, and the CMake-driven build_ext does not check, so the
401+
# wheel would be packed without an extension and fail at import instead of here.
402+
message(
403+
WARNING
404+
"PYCBC_MODULE_OUTPUT_DIRECTORY is not set: ${PYCBC_C_MOD_OUTPUT_NAME}${PYCBC_C_MOD_FULL_SUFFIX} will be left in the build tree, not the package directory"
405+
)
406+
endif()

pycbc_build_setup.py

Lines changed: 125 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,57 @@
4343
ENV_TRUE = ['true', '1', 'y', 'yes', 'on']
4444

4545

46+
def use_py_limited_api() -> bool:
47+
"""Return True if the extension should be built against Py_LIMITED_API (abi3).
48+
49+
Defaults to True so we publish a single stable-ABI wheel per platform.
50+
Power users can disable this by exporting ``PYCBC_PY_LIMITED_API=false``
51+
(or 0/no/off) to build a CPython-version-specific binary.
52+
"""
53+
return os.getenv('PYCBC_PY_LIMITED_API', 'true').lower() in ENV_TRUE
54+
55+
56+
# Lowest CPython version pycbc's C extension supports under the stable ABI.
57+
# Raising this floor frees up symbols (e.g. Py_T_OBJECT_EX is limited-API only
58+
# from 3.12), but invalidates wheels for older CPython releases.
59+
DEFAULT_PY_LIMITED_API_VERSION = '3.10'
60+
61+
62+
def _parse_py_limited_api_version(value: str) -> tuple:
63+
try:
64+
major_str, minor_str = value.split('.', 1)
65+
major, minor = int(major_str), int(minor_str)
66+
except (ValueError, AttributeError) as e:
67+
raise OptionError(
68+
f'PYCBC_PY_LIMITED_API_VERSION must be in MAJOR.MINOR form (e.g. "3.10"); got {value!r}'
69+
) from e
70+
if major != 3 or minor < 10:
71+
raise OptionError(
72+
f'PYCBC_PY_LIMITED_API_VERSION must be 3.10 or newer; got {value!r}. '
73+
'pycbc relies on stable-ABI symbols (PyType_FromSpec, PyUnicode_AsUTF8, ...) '
74+
'first exposed in 3.10.'
75+
)
76+
return major, minor
77+
78+
79+
def py_limited_api_version() -> tuple:
80+
"""Return the (major, minor) CPython version that bounds the stable-ABI build."""
81+
return _parse_py_limited_api_version(
82+
os.getenv('PYCBC_PY_LIMITED_API_VERSION', DEFAULT_PY_LIMITED_API_VERSION))
83+
84+
85+
def py_limited_api_hex() -> str:
86+
"""Return the Py_LIMITED_API hex literal (e.g. '0x030A0000') passed to the C compiler."""
87+
major, minor = py_limited_api_version()
88+
return f'0x{major:02X}{minor:02X}0000'
89+
90+
91+
def py_limited_api_wheel_tag() -> str:
92+
"""Return the bdist_wheel --py-limited-api tag (e.g. 'cp310')."""
93+
major, minor = py_limited_api_version()
94+
return f'cp{major}{minor}'
95+
96+
4697
def check_for_cmake():
4798
if not CMAKE_EXE:
4899
print('cmake executable not found. '
@@ -120,6 +171,27 @@ def process_build_env_vars(): # noqa: C901
120171
if pycbc_tls_key_log_file is not None:
121172
cmake_extra_args += [f'-DCOUCHBASE_CXX_CLIENT_TLS_KEY_LOG_FILE={pycbc_tls_key_log_file}']
122173

174+
# Hand CMake this interpreter's own module suffix (e.g. .cpython-310-darwin.so)
175+
# so it stays the single source of truth for both build modes: a version-specific
176+
# build uses it as-is, while a stable-ABI build keeps only its trailing .so/.pyd
177+
# (pairing the .so with ".abi3"), matching CMakeBuildExt.get_ext_filename() below.
178+
# Without the interpreter tag, a version-specific build would produce a bare
179+
# "_core.so" that any interpreter's import machinery will load, defeating the
180+
# point of opting out of the stable ABI.
181+
cmake_extra_args += [f'-DPYCBC_C_MOD_SUFFIX={get_config_var("EXT_SUFFIX")}']
182+
183+
# PYCBC_PY_LIMITED_API: build against the Python stable ABI (abi3).
184+
# Defaults to ON; opt out for a Python-version-specific binary.
185+
# The hex value (e.g. 0x030A0000 == 3.10) is the lowest CPython the resulting
186+
# binary will load against, and must match the wheel tag set in BdistWheelCommand.
187+
if use_py_limited_api():
188+
cmake_extra_args += [
189+
'-DPYCBC_PY_LIMITED_API:BOOL=ON',
190+
f'-DPYCBC_PY_LIMITED_API_HEX={py_limited_api_hex()}',
191+
]
192+
else:
193+
cmake_extra_args += ['-DPYCBC_PY_LIMITED_API:BOOL=OFF']
194+
123195
# now pop these in CMAKE_COMMON_VARIABLES, and they will be used by cmake...
124196
os.environ['CMAKE_COMMON_VARIABLES'] = ' '.join(cmake_extra_args)
125197

@@ -143,11 +215,12 @@ def create_cmake_config(cls, # noqa: C901
143215
build_type = env.pop('PYCBC_BUILD_TYPE')
144216
cmake_generator = env.pop('PYCBC_CMAKE_SET_GENERATOR', None)
145217
cmake_arch = env.pop('PYCBC_CMAKE_SET_ARCH', None)
218+
# PYCBC_MODULE_OUTPUT_DIRECTORY is consumed by a per-target property, so only the
219+
# extension module lands in the package directory (PYCBC-1878).
146220
cmake_config_args = [CMAKE_EXE,
147221
source_dir,
148222
f'-DCMAKE_BUILD_TYPE={build_type}',
149-
f'-DCMAKE_LIBRARY_OUTPUT_DIRECTORY={output_dir}',
150-
f'-DCMAKE_LIBRARY_OUTPUT_DIRECTORY_{build_type.upper()}={output_dir}']
223+
f'-DPYCBC_MODULE_OUTPUT_DIRECTORY={output_dir}']
151224

152225
cmake_config_args.extend(
153226
[x for x in
@@ -207,8 +280,6 @@ def create_cmake_config(cls, # noqa: C901
207280
cmake_config_args.append(f'-DCOUCHBASE_CXX_CLIENT_EMBED_MOZILLA_CA_BUNDLE_ROOT={CXXCBC_CACHE_DIR}')
208281

209282
if platform.system() == "Windows":
210-
cmake_config_args += [f'-DCMAKE_RUNTIME_OUTPUT_DIRECTORY_{build_type.upper()}={output_dir}']
211-
212283
if cmake_generator:
213284
if cmake_generator.upper() == 'TRUE':
214285
cmake_config_args += ['-G', 'Visual Studio 16 2019']
@@ -232,8 +303,10 @@ def create_cmake_config(cls, # noqa: C901
232303

233304

234305
class CMakeExtension(Extension):
235-
def __init__(self, name, sourcedir=''):
236-
Extension.__init__(self, name, sources=[])
306+
def __init__(self, name, sourcedir='', py_limited_api=False):
307+
# py_limited_api is a documented setuptools Extension kwarg; passing it
308+
# via Extension.__init__ lets bdist_wheel auto-tag the wheel as abi3.
309+
Extension.__init__(self, name, sources=[], py_limited_api=py_limited_api)
237310
self.sourcedir = os.path.abspath(sourcedir)
238311

239312

@@ -288,8 +361,25 @@ class CMakeBuildExt(build_ext):
288361

289362
def get_ext_filename(self, ext_name):
290363
ext_path = ext_name.split('.')
291-
ext_suffix = get_config_var('EXT_SUFFIX')
292-
ext_suffix = "." + ext_suffix.split('.')[-1]
364+
ext = next(
365+
(e for e in self.distribution.ext_modules if e.name == ext_name),
366+
None,
367+
)
368+
if ext is not None and getattr(ext, 'py_limited_api', False):
369+
# Stable-ABI build: emit `<name>.abi3.so` so the file CMake produces
370+
# (see CMakeLists.txt) matches what setuptools expects to find on disk,
371+
# regardless of which interpreter compiled it. Windows is the exception:
372+
# importlib knows only `.cp3XY-win_amd64.pyd` and `.pyd` there, so an
373+
# `.abi3.pyd` is unimportable and the module must stay `<name>.pyd`.
374+
so_ext = "." + get_config_var('EXT_SUFFIX').split('.')[-1]
375+
ext_suffix = so_ext if platform.system() == 'Windows' else '.abi3' + so_ext
376+
else:
377+
# Full C-API build: keep the interpreter-specific suffix (e.g.
378+
# .cpython-310-darwin.so) so this extension can't be picked up
379+
# by a mismatched interpreter's import machinery. A bare .so
380+
# would be loadable by any version, defeating the point of a
381+
# non-limited build.
382+
ext_suffix = get_config_var('EXT_SUFFIX')
293383
return os.path.join(*ext_path) + ext_suffix
294384

295385
def build_extension(self, ext): # noqa: C901
@@ -340,6 +430,33 @@ def _clean_cache_cpm_dependencies(self):
340430
print(line.rstrip())
341431

342432

433+
# bdist_wheel moved from the `wheel` package into setuptools in setuptools 70.1.
434+
# Prefer the setuptools-vendored location so we work without an explicit `wheel`
435+
# build dependency, but fall back for older setuptools.
436+
try:
437+
from setuptools.command.bdist_wheel import bdist_wheel as _bdist_wheel
438+
except ImportError: # pragma: no cover - older setuptools
439+
from wheel.bdist_wheel import bdist_wheel as _bdist_wheel # type: ignore[no-redef]
440+
441+
442+
class BdistWheelCommand(_bdist_wheel):
443+
"""bdist_wheel that tags the wheel as abi3 when building against Py_LIMITED_API.
444+
445+
`pip wheel .` invokes bdist_wheel without flags, so even if Extension
446+
declares py_limited_api=True the wheel still gets a CPython-specific tag.
447+
Default --py-limited-api here so the produced wheel is named
448+
`<dist>-<ver>-cp310-abi3-<platform>.whl` and installs onto any CPython >=3.10.
449+
450+
Both the wheel tag and the C-level Py_LIMITED_API hex come from
451+
py_limited_api_version() so they cannot drift apart.
452+
"""
453+
454+
def finalize_options(self):
455+
if use_py_limited_api() and not self.py_limited_api:
456+
self.py_limited_api = py_limited_api_wheel_tag()
457+
super().finalize_options()
458+
459+
343460
class BuildCommand(build):
344461
def finalize_options(self):
345462
# Setting the build_base to an absolute path will make sure that build (i.e. temp) and lib dirs are in sync

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
[build-system]
22
requires = [
3-
"setuptools>=42",
3+
# 70.1 is when bdist_wheel moved into setuptools proper; see the fallback
4+
# import in pycbc_build_setup.py. Also comfortably new enough to treat
5+
# setup.py's SPDX `license="Apache-2.0"` as a real license expression.
6+
"setuptools>=70.1",
47
"wheel",
58
]
69
build-backend = "setuptools.build_meta"

0 commit comments

Comments
 (0)