From 822533eb44bdc5d77aaba48f7d0c3fb9b21aabe6 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Mon, 12 Jan 2026 15:42:17 +0100 Subject: [PATCH 1/2] Don't automatically add PyInit_ to export symbols in Python 3.15 Since PEP 793, it's valid for modules to not have a PyInit function. Telling MSVC to export a nonexistent function will make it fail. The function should be exported in code, using `PyMODINIT_FUNC` (which adds `__declspec(dllexport)`, which is preferred over `/EXPORT` according to [Microsoft docs].) Microsoft docs: https://learn.microsoft.com/en-us/cpp/build/reference/export-exports-a-function?view=msvc-170 --- distutils/command/build_ext.py | 38 +++++++++++++++++++------------ distutils/tests/test_build_ext.py | 16 +++++++++---- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/distutils/command/build_ext.py b/distutils/command/build_ext.py index ec45b440..060e0c85 100644 --- a/distutils/command/build_ext.py +++ b/distutils/command/build_ext.py @@ -728,23 +728,31 @@ def get_ext_filename(self, ext_name: str) -> str: def get_export_symbols(self, ext: Extension) -> list[str]: """Return the list of symbols that a shared extension has to - export. This either uses 'ext.export_symbols' or, if it's not - provided, "PyInit_" + module_name. Only relevant on Windows, where - the .pyd file (DLL) must export the module "PyInit_" function. + export. This returns, and possibly updates, 'ext.export_symbols'. + + On Python 3.14 and below (that is, before a new export hook name was added), + it adds "PyInit_" + module_name 'ext.export_symbols'. + Only relevant on Windows, where the .pyd file (DLL) must export the module + import hook function. + + Since Python 3.15, don't add anything. + An export directive should be in the code itself. """ - name = self._get_module_name_for_symbol(ext) - try: - # Unicode module name support as defined in PEP-489 - # https://peps.python.org/pep-0489/#export-hook-name - name.encode('ascii') - except UnicodeEncodeError: - suffix = 'U_' + name.encode('punycode').replace(b'-', b'_').decode('ascii') - else: - suffix = "_" + name + if sys.version_info < (3, 15): + name = self._get_module_name_for_symbol(ext) + try: + # Unicode module name support as defined in PEP-489 + # https://peps.python.org/pep-0489/#export-hook-name + name.encode('ascii') + except UnicodeEncodeError: + name_bytes = name.encode('punycode').replace(b'-', b'_') + suffix = 'U_' + name_bytes.decode('ascii') + else: + suffix = "_" + name - initfunc_name = "PyInit" + suffix - if initfunc_name not in ext.export_symbols: - ext.export_symbols.append(initfunc_name) + initfunc_name = "PyInit" + suffix + if initfunc_name not in ext.export_symbols: + ext.export_symbols.append(initfunc_name) return ext.export_symbols def _get_module_name_for_symbol(self, ext): diff --git a/distutils/tests/test_build_ext.py b/distutils/tests/test_build_ext.py index dab0507f..bdaea0bf 100644 --- a/distutils/tests/test_build_ext.py +++ b/distutils/tests/test_build_ext.py @@ -397,8 +397,12 @@ def test_unicode_module_names(self): cmd.ensure_finalized() assert re.search(r'foo(_d)?\..*', cmd.get_ext_filename(modules[0].name)) assert re.search(r'föö(_d)?\..*', cmd.get_ext_filename(modules[1].name)) - assert cmd.get_export_symbols(modules[0]) == ['PyInit_foo'] - assert cmd.get_export_symbols(modules[1]) == ['PyInitU_f_1gaa'] + if sys.version_info < (3, 15): + assert cmd.get_export_symbols(modules[0]) == ['PyInit_foo'] + assert cmd.get_export_symbols(modules[1]) == ['PyInitU_f_1gaa'] + else: + assert cmd.get_export_symbols(modules[0]) == [] + assert cmd.get_export_symbols(modules[1]) == [] def test_export_symbols__init__(self): # https://github.com/python/cpython/issues/80074 @@ -410,8 +414,12 @@ def test_export_symbols__init__(self): dist = Distribution({'name': 'xx', 'ext_modules': modules}) cmd = self.build_ext(dist) cmd.ensure_finalized() - assert cmd.get_export_symbols(modules[0]) == ['PyInit_foo'] - assert cmd.get_export_symbols(modules[1]) == ['PyInitU_f_1gaa'] + if sys.version_info < (3, 15): + assert cmd.get_export_symbols(modules[0]) == ['PyInit_foo'] + assert cmd.get_export_symbols(modules[1]) == ['PyInitU_f_1gaa'] + else: + assert cmd.get_export_symbols(modules[0]) == [] + assert cmd.get_export_symbols(modules[1]) == [] def test_compiler_option(self): # cmd.compiler is an option and From 0bc72c1cc7b496b67388276bbea9df6ac18bd074 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Thu, 15 Jan 2026 13:57:00 +0100 Subject: [PATCH 2/2] Apply suggestions from code review Co-authored-by: Ralf Gommers --- distutils/command/build_ext.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/distutils/command/build_ext.py b/distutils/command/build_ext.py index 060e0c85..66ae7eb9 100644 --- a/distutils/command/build_ext.py +++ b/distutils/command/build_ext.py @@ -731,12 +731,13 @@ def get_export_symbols(self, ext: Extension) -> list[str]: export. This returns, and possibly updates, 'ext.export_symbols'. On Python 3.14 and below (that is, before a new export hook name was added), - it adds "PyInit_" + module_name 'ext.export_symbols'. + it adds "PyInit_" + module_name to 'ext.export_symbols'. Only relevant on Windows, where the .pyd file (DLL) must export the module import hook function. Since Python 3.15, don't add anything. - An export directive should be in the code itself. + An export directive was added to the "PyMODINIT_FUNC" implementation + for Python 3.15, which is all that is needed. """ if sys.version_info < (3, 15): name = self._get_module_name_for_symbol(ext)