diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77e98ae8e..4e958757b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: - 'docs' env: - BUILDER_VERSION: v0.9.96 + BUILDER_VERSION: latest BUILDER_SOURCE: releases BUILDER_HOST: https://d19elf31gohf1l.cloudfront.net PACKAGE_NAME: aws-crt-python @@ -57,6 +57,8 @@ jobs: - cp313-cp313 - cp314-cp314 - cp314-cp314t + - cp315-cp315 + - cp315-cp315t permissions: id-token: write # This is required for requesting the JWT steps: @@ -83,6 +85,8 @@ jobs: - cp313-cp313 - cp314-cp314 - cp314-cp314t + - cp315-cp315 + - cp315-cp315t permissions: id-token: write # This is required for requesting the JWT steps: @@ -308,6 +312,34 @@ jobs: python -c "from urllib.request import urlretrieve; urlretrieve('${{ env.BUILDER_HOST }}/${{ env.BUILDER_SOURCE }}/${{ env.BUILDER_VERSION }}/builder.pyz?run=${{ env.RUN }}', 'builder.pyz')" python builder.pyz build -p ${{ env.PACKAGE_NAME }} --python "${{ steps.python38.outputs.python-path }}" + windows-315: + # Python 3.15 (standard and free-threaded). Free-threaded exercises the + # abi3t build path (Py_TARGET_ABI3T / PyModExport, see setup.py). + runs-on: windows-2022 + strategy: + fail-fast: false + matrix: + freethreaded: [false, true] + permissions: + id-token: write # This is required for requesting the JWT + steps: + - uses: actions/setup-python@v5 + id: python315 + with: + python-version: '3.15' + allow-prereleases: true + freethreaded: ${{ matrix.freethreaded }} + - uses: ilammy/setup-nasm@v1 + - name: configure AWS credentials (containers) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.CRT_CI_ROLE }} + aws-region: ${{ env.AWS_DEFAULT_REGION }} + - name: Build ${{ env.PACKAGE_NAME }} + consumers + run: | + python -c "from urllib.request import urlretrieve; urlretrieve('${{ env.BUILDER_HOST }}/${{ env.BUILDER_SOURCE }}/${{ env.BUILDER_VERSION }}/builder.pyz?run=${{ env.RUN }}', 'builder.pyz')" + python builder.pyz build -p ${{ env.PACKAGE_NAME }} --python "${{ steps.python315.outputs.python-path }}" + macos: runs-on: macos-14 # latest permissions: @@ -324,6 +356,34 @@ jobs: chmod a+x builder ./builder build -p ${{ env.PACKAGE_NAME }} + macos-315: + # Python 3.15 (standard and free-threaded). Free-threaded exercises the + # abi3t build path (Py_TARGET_ABI3T / PyModExport, see setup.py). + runs-on: macos-14 # latest + strategy: + fail-fast: false + matrix: + freethreaded: [false, true] + permissions: + id-token: write # This is required for requesting the JWT + steps: + - uses: actions/setup-python@v5 + id: python315 + with: + python-version: '3.15' + allow-prereleases: true + freethreaded: ${{ matrix.freethreaded }} + - name: configure AWS credentials (containers) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.CRT_CI_ROLE }} + aws-region: ${{ env.AWS_DEFAULT_REGION }} + - name: Build ${{ env.PACKAGE_NAME }} + consumers + run: | + python3 -c "from urllib.request import urlretrieve; urlretrieve('${{ env.BUILDER_HOST }}/${{ env.BUILDER_SOURCE }}/${{ env.BUILDER_VERSION }}/builder.pyz?run=${{ env.RUN }}', 'builder')" + chmod a+x builder + ./builder build -p ${{ env.PACKAGE_NAME }} --python "${{ steps.python315.outputs.python-path }}" + macos-x64: runs-on: macos-14-large # latest permissions: diff --git a/setup.py b/setup.py index bcf0b0456..c2d9548ec 100644 --- a/setup.py +++ b/setup.py @@ -325,6 +325,44 @@ def __init__(self, name, extra_cmake_args=[], libname=None): class awscrt_build_ext(setuptools.command.build_ext.build_ext): + def get_ext_filename(self, ext_name): + filename = super().get_ext_filename(ext_name) + if FREE_THREADED_BUILD and sys.version_info[:2] >= (3, 15): + # setuptools' build_ext (which this class extends) names extensions with the + # interpreter's EXT_SUFFIX, e.g. "_awscrt.cpython-315t-x86_64-linux-gnu.so" + # -- a name only a 3.15t interpreter will import. This abi3t (free-threaded + # stable ABI) build produces one binary for ALL 3.15+ free-threaded + # interpreters, so swap in ".abi3t.so", the version-agnostic suffix the + # import system accepts. setuptools does this automatically for classic abi3 + # ("_awscrt.abi3.so") but doesn't know abi3t yet. Dropping the arch/OS part + # is safe: platform selection happens via the wheel's platform tag + # (e.g. manylinux2014_x86_64), never via the .so filename. Windows needs no + # rename (abi3t modules keep the plain ".pyd" name); the ".so" check skips it. + # + # We can remove this override once setuptools supports abi3t natively (tracked + # in pypa/setuptools#5205; the check makes it a harmless no-op if setuptools + # starts emitting ".abi3t.so" itself). + ext_suffix = sysconfig.get_config_var('EXT_SUFFIX') + if ext_suffix.endswith('.so') and filename.endswith(ext_suffix): + filename = filename[:-len(ext_suffix)] + '.abi3t.so' + return filename + + def get_export_symbols(self, ext): + if FREE_THREADED_BUILD and sys.version_info[:2] >= (3, 15): + # On Windows, setuptools tells the linker to export "PyInit_" + # (/EXPORT:PyInit__awscrt), but abi3t modules define "PyModExport_" + # instead (PEP 793, see source/module.c) -- PyInit doesn't exist in + # that build, so the link fails with an unresolved external + # (pypa/distutils#387). The /EXPORT flag is redundant anyway: + # PyMODEXPORT_FUNC already declares dllexport, which puts the hook + # in the DLL's export table. So suppress the export list entirely. + # No-op on non-Windows (the list is already empty there). + # + # Like get_ext_filename above, remove this override once + # setuptools supports abi3t natively (pypa/setuptools#5205). + return [] + return super().get_export_symbols(ext) + def _build_dependencies_impl(self, build_dir, install_path, osx_arch=None): cmake = get_cmake_path() @@ -505,9 +543,19 @@ def run(self): class bdist_wheel_abi3(bdist_wheel): def get_tag(self): python, abi, plat = super().get_tag() - # on CPython, our wheels are abi3 and compatible back to 3.11 + # Rewrite the wheel's compatibility tags to match what awscrt_ext() + # actually compiled against -- pip trusts these tags to pick a wheel, + # so each branch below MUST mirror a compile-flag branch there. if FREE_THREADED_BUILD: - # free-threaded builds don't use limited API, so skip abi3 tag + if python.startswith("cp") and sys.version_info >= (3, 15): + # Built against abi3t (see awscrt_ext), the stable ABI for + # free-threaded builds. The bare "abi3t" tag is only accepted + # by free-threaded interpreters. If we ever want ONE wheel for + # all 3.15+ interpreters (free-threaded and not), we can promote + # this tag to "abi3.abi3t" -- cp313-abi3 would then serve 3.13/3.14 only. + return "cp315", "abi3t", plat + # 3.13/3.14 free-threaded builds don't support limited API or + # abi3t, so no stable-ABI tag (version-specific wheel) return python, abi, plat elif python.startswith("cp") and sys.version_info >= (3, 13): # 3.13 deprecates PyWeakref_GetObject(), adds alternative @@ -613,9 +661,20 @@ def awscrt_ext(): extra_link_args += ['-Wl,--fatal-warnings'] # prefer building with stable ABI, so a wheel can work with multiple major versions - if FREE_THREADED_BUILD and sys.version_info[:2] <= (3, 14): - # 3.14 free threaded (aka no gil) does not support limited api. - # disable it for now. 3.15 promises to support limited api + free threading combo + if FREE_THREADED_BUILD and sys.version_info[:2] >= (3, 15): + # 3.15 introduces abi3t: the stable ABI for free-threaded builds. + # Py_LIMITED_API is not supported on free-threaded builds as of 3.15; + # Py_TARGET_ABI3T is the free-threaded equivalent. It requires the + # PyModExport_* module export hook (see source/module.c). + # https://docs.python.org/3.15/howto/abi3t-migration.html + define_macros.append(('Py_TARGET_ABI3T', '0x030F0000')) + # setuptools' py_limited_api machinery is abi3-only; the abi3t wheel + # tag and extension filename are handled manually in bdist_wheel_abi3 + # and awscrt_build_ext.get_ext_filename. + py_limited_api = False + elif FREE_THREADED_BUILD: + # 3.13/3.14 free threaded (aka no gil) support neither limited api + # nor abi3t. Build version-specific wheels (cp313t/cp314t). py_limited_api = False elif sys.version_info >= (3, 13): # 3.13 deprecates PyWeakref_GetObject(), adds alternative diff --git a/source/module.c b/source/module.c index 45f3322ea..06d638218 100644 --- a/source/module.c +++ b/source/module.c @@ -1175,27 +1175,28 @@ AWS_STATIC_STRING_FROM_LITERAL(s_crash_handler_env_var, "AWS_CRT_CRASH_HANDLER") * Module Init ******************************************************************************/ -PyMODINIT_FUNC PyInit__awscrt(void) { - static struct PyModuleDef s_module_def = { - PyModuleDef_HEAD_INIT, - s_module_name, - s_module_doc, - -1, /* size of per-interpreter state of the module, or -1 if the module keeps state in global variables. */ - s_module_methods, - NULL, /* slots for multi-phase initialization */ - NULL, /* traversal fn to call during GC traversal */ - NULL, /* clear fn to call during GC clear */ - NULL, /* fn to call during deallocation of the module object */ - }; +/** + * One-time process-wide initialization, shared by both module init paths below. + * + * WARNING: everything this touches is process-global state (the allocator, + * crash handler, aws-c-* library init, error tables). It must run at most once + * per process. The single-phase PyInit path below guarantees this naturally. + * The abi3t multi-phase path can re-run exec if the module is removed from + * sys.modules and re-imported. This function guards itself with a static + * flag. Python's import machinery serializes extension-module exec, so the plain + * static is not a data race. + * + * Returns 0 on success, -1 (with a Python exception set) on failure, + * matching the Py_mod_exec slot contract. + */ +static int s_module_exec(PyObject *module) { + (void)module; - PyObject *m = PyModule_Create(&s_module_def); - if (!m) { - return NULL; + static bool s_module_initialized = false; + if (s_module_initialized) { + return 0; } - -#ifdef Py_GIL_DISABLED - PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); -#endif + s_module_initialized = true; s_init_allocator(); @@ -1224,9 +1225,85 @@ PyMODINIT_FUNC PyInit__awscrt(void) { aws_register_error_info(&s_error_list); s_error_map_init(); + return 0; +} + +#ifdef Py_TARGET_ABI3T + +/* + * Module export hook (PEP 793) for abi3t builds: the stable ABI for + * free-threaded Python, introduced in CPython 3.15. Py_TARGET_ABI3T is + * defined by setup.py (awscrt_ext) for free-threaded 3.15+ builds. abi3t + * removes PyModuleDef-based single-phase init, so the module is described by + * a static PySlot array instead. Notes on each slot: + * + * - Py_mod_gil = Py_MOD_GIL_NOT_USED replaces the PyUnstable_Module_SetGIL() + * call used on the non-abi3t path (PyUnstable_* is not in any stable ABI). + * - Py_mod_multiple_interpreters = NOT_SUPPORTED is REQUIRED for correctness: + * _awscrt keeps process-global state (see s_module_exec), so subinterpreter + * imports must be refused by the interpreter rather than corrupting that + * state with a second exec. + * - No Py_mod_state_size slot: the module keeps state in globals (the + * equivalent of m_size = -1 in the legacy PyModuleDef). + * + * https://docs.python.org/3.15/howto/abi3t-migration.html + */ + +PyABIInfo_VAR(s_abi_info); + +static PySlot s_module_slots[] = { + PySlot_STATIC_DATA(Py_mod_abi, &s_abi_info), + /* PySlot.sl_ptr is a plain `void *`. These two are read-only strings, so + * casting away const is safe: CPython never writes through the slot. */ + PySlot_STATIC_DATA(Py_mod_name, (void *)s_module_name), + PySlot_STATIC_DATA(Py_mod_doc, (void *)s_module_doc), + PySlot_STATIC_DATA(Py_mod_methods, s_module_methods), + PySlot_DATA(Py_mod_gil, Py_MOD_GIL_NOT_USED), + PySlot_DATA(Py_mod_multiple_interpreters, Py_MOD_MULTIPLE_INTERPRETERS_NOT_SUPPORTED), + PySlot_FUNC(Py_mod_exec, s_module_exec), + PySlot_END, +}; + +PyMODEXPORT_FUNC PyModExport__awscrt(void) { + /* Must ONLY return a pointer to static data; all runtime initialization + * happens later in the Py_mod_exec slot (s_module_exec). */ + return s_module_slots; +} + +#else /* !Py_TARGET_ABI3T */ + +PyMODINIT_FUNC PyInit__awscrt(void) { + static struct PyModuleDef s_module_def = { + PyModuleDef_HEAD_INIT, + s_module_name, + s_module_doc, + -1, /* size of per-interpreter state of the module, or -1 if the module keeps state in global variables. */ + s_module_methods, + NULL, /* slots for multi-phase initialization */ + NULL, /* traversal fn to call during GC traversal */ + NULL, /* clear fn to call during GC clear */ + NULL, /* fn to call during deallocation of the module object */ + }; + + PyObject *m = PyModule_Create(&s_module_def); + if (!m) { + return NULL; + } + +# ifdef Py_GIL_DISABLED + PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); +# endif + + if (s_module_exec(m) != 0) { + Py_DECREF(m); + return NULL; + } + return m; } +#endif /* Py_TARGET_ABI3T */ + /** * align with the the vanilla C types Python tends to use. * This is important when passing arguments between C and Python