From f72399d4bfc787be608d869f46cd665e7ece92a2 Mon Sep 17 00:00:00 2001 From: Vadim Mironov Date: Sat, 12 Sep 2026 08:55:45 +0100 Subject: [PATCH 1/4] Add a debug build option for Windows cpython-unix/build.py offers `debug` alongside noopt and pgo, and every Unix target declares it in ci-targets.yaml. Windows accepts only noopt/pgo and hardcodes configuration="Release", so there is no way to produce a debug interpreter. That matters for embedding. MSVC's pyconfig.h selects the import library from _DEBUG, which the debug CRT defines, so a consumer building with /MDd asks for python3XX_d.lib and cannot link against a release distribution. Follows the Unix spelling: `debug` is a peer of noopt and pgo in the option set, so debug+pgo is simply not a valid combination. The set is renamed to `options` to match, since debug is not an optimization. The rest is naming. A Debug configuration suffixes its artifacts with $(PyDebugExt), so PC/layout is passed --debug, and the executables, extension libraries and dependency libraries pick up the _d suffix. The tail-calling interpreter is disabled for Debug. PBS turns it on for 3.15 on x64, but [[msvc::musttail]] requires /O2 and under /Od MSVC reports C4737 for each dispatch site. CPython's early check for this was reverted and the build still fails as of September 2026. https://learn.microsoft.com/en-us/cpp/cpp/attributes#msvcmusttail https://github.com/python/cpython/issues/148047 building.rst documents the option and the debug CRT requirement: the binaries import ucrtbased.dll and vcruntime*d.dll, which ship with Visual Studio and are not redistributable. --- cpython-windows/build.py | 49 +++++++++++++++++++++++++--------------- docs/building.rst | 11 +++++++++ 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/cpython-windows/build.py b/cpython-windows/build.py index 9b36600c3..0a43383f5 100644 --- a/cpython-windows/build.py +++ b/cpython-windows/build.py @@ -777,8 +777,15 @@ def run_msbuild( if freethreaded: args.append("/property:DisableGil=true") - # Build tail-calling Python for 3.15+ - if python_version.startswith("3.15") and platform == "x64": + # Build tail-calling Python for 3.15+, but not in Debug. [[msvc::musttail]] + # requires /O2 and under /Od MSVC reports C4737 at every dispatch site. + # See https://learn.microsoft.com/en-us/cpp/cpp/attributes#msvcmusttail + # and https://github.com/python/cpython/issues/148047 + if ( + python_version.startswith("3.15") + and platform == "x64" + and configuration != "Debug" + ): args.append("/property:UseTailCallInterp=true") exec_and_log(args, str(pcbuild_path), os.environ) @@ -1211,12 +1218,13 @@ def find_additional_dependencies(project: pathlib.Path): else: raise Exception("unhandled architecture: %s" % arch) + debug_suffix = "_d" if config == "Debug" else "" if freethreaded: - abi_tag = ".cp%st-%s" % (python_majmin, abi_platform) - lib_suffix = "t" + abi_tag = "%s.cp%st-%s" % (debug_suffix, python_majmin, abi_platform) + lib_suffix = "t%s" % debug_suffix else: - abi_tag = "" - lib_suffix = "" + abi_tag = debug_suffix + lib_suffix = debug_suffix # Copy object files for core sources into their own directory. core_dir = out_dir / "build" / "core" @@ -1345,15 +1353,15 @@ def find_additional_dependencies(project: pathlib.Path): # Copy libraries for dependencies into the lib directory. for depend in sorted(depends_projects): - static_source = outputs_path / ("%s.lib" % depend) - static_dest = lib_dir / ("%s.lib" % depend) + static_source = outputs_path / ("%s%s.lib" % (depend, debug_suffix)) + static_dest = lib_dir / ("%s%s.lib" % (depend, debug_suffix)) log("copying link library %s" % static_source) shutil.copyfile(static_source, static_dest) - shared_source = outputs_path / ("%s.dll" % depend) + shared_source = outputs_path / ("%s%s.dll" % (depend, debug_suffix)) if shared_source.exists(): - shared_dest = lib_dir / ("%s.dll" % depend) + shared_dest = lib_dir / ("%s%s.dll" % (depend, debug_suffix)) log("copying shared library %s" % shared_source) shutil.copyfile(shared_source, shared_dest) @@ -1372,6 +1380,7 @@ def build_cpython( openssl_entry: str, ) -> pathlib.Path: parsed_build_options = set(build_options.split("+")) + debug = "debug" in parsed_build_options pgo = "pgo" in parsed_build_options freethreaded = "freethreaded" in parsed_build_options @@ -1426,13 +1435,14 @@ def build_cpython( # as we do for Unix builds. mpdecimal_archive = None + debug_suffix = "_d" if debug else "" if freethreaded: (major, minor, _) = python_version.split(".") - python_exe = f"python{major}.{minor}t.exe" - pythonw_exe = f"pythonw{major}.{minor}t.exe" + python_exe = f"python{major}.{minor}t{debug_suffix}.exe" + pythonw_exe = f"pythonw{major}.{minor}t{debug_suffix}.exe" else: - python_exe = "python.exe" - pythonw_exe = "pythonw.exe" + python_exe = f"python{debug_suffix}.exe" + pythonw_exe = f"pythonw{debug_suffix}.exe" # Python 3.15 uses the default name for the executable in a suffixed directory instrumented_python_exe = python_exe @@ -1635,13 +1645,13 @@ def build_cpython( run_msbuild( msbuild, pcbuild_path, - configuration="Release", + configuration="Debug" if debug else "Release", platform=build_platform, python_version=python_version, windows_sdk_version=windows_sdk_version, freethreaded=freethreaded, ) - artifact_config = "Release" + artifact_config = "Debug" if debug else "Release" install_dir = out_dir / "python" / "install" @@ -1675,6 +1685,9 @@ def build_cpython( if freethreaded: args.append("--include-freethreaded") + if debug: + args.append("--debug") + # CPython 3.12 removed distutils. if not meets_python_minimum_version(python_version, "3.12"): args.append("--include-distutils") @@ -1946,10 +1959,10 @@ def main() -> None: default="cpython-3.11", help="Python distribution to build", ) - optimizations = {"noopt", "pgo"} + options = {"debug", "noopt", "pgo"} parser.add_argument( "--options", - choices=optimizations.union({f"freethreaded+{o}" for o in optimizations}), + choices=options.union({f"freethreaded+{o}" for o in options}), default="noopt", help="Build options to apply when compiling Python", ) diff --git a/docs/building.rst b/docs/building.rst index 5ea5ba9a0..ed5e8f5fd 100644 --- a/docs/building.rst +++ b/docs/building.rst @@ -148,5 +148,16 @@ with Visual Studio 2026:: $ uv run --no-dev build.py --sh c:\cygwin\bin\sh.exe --vs 2026 --python cpython-3.15 +To produce a debug build, pass ``--options debug`` or ``freethreaded+debug``:: + + $ uv run --no-dev build.py --sh c:\cygwin\bin\sh.exe --options debug + +The artifacts carry the ``_d`` suffix of CPython's Debug configuration +(``python_d.exe``, ``python314_d.dll``, ``_asyncio_d.pyd``) and link against +the debug C runtime (``ucrtbased.dll`` and ``vcruntime*d.dll``). That +runtime ships with Visual Studio and `is not redistributable +`_, +so a debug distribution only runs on a machine with Visual Studio installed. + To build a 32-bit x86 binary, simply use an ``x86 Native Tools Command Prompt`` instead of ``x64``. From b27d5cfeec0d4607d30e211b88917950fe955ea5 Mon Sep 17 00:00:00 2001 From: Vadim Mironov Date: Sat, 12 Sep 2026 08:55:45 +0100 Subject: [PATCH 2/4] Accept a Windows debug distribution in the validator and disttests validate-distribution rejects a Windows `debug` build on two counts. The PE allow list carries only release names: error: python/install/python_d.exe loads illegal library python314_d.dll error: python/install/python_d.exe loads illegal library VCRUNTIME140D.dll error: python/install/python_d.exe loads illegal library ucrtbased.dll error: python/install/DLLs/_sqlite3_d.pyd loads illegal library sqlite3_d.dll The other two formats already accommodate this. The Mach-O list pairs every release name with its debug counterpart (@rpath/libpython3.14.dylib next to @rpath/libpython3.14d.dylib, and td for free-threaded debug), and the ELF list pushes libpython{ver}d.so.1.0 and libpython{ver}td.so.1.0 unconditionally. Neither gates on build options, so neither does this. Windows spells the suffix _d, after $(PyDebugExt), which combines with the free-threaded t as python314t_d.dll. ucrtbased.dll sits in alphabetical position rather than beside a counterpart because it has none: a release build reaches the UCRT through the api-ms-win-crt-* forwarders, while a debug build imports it directly. The abiflags check is also POSIX-shaped: error: abiflags does not contain 'd' CPython deliberately keeps the lowercase `abiflags` empty on Windows, because it is widely used to calculate paths there. The uppercase `ABIFLAGS` carries the marker, but only from 3.14: https://github.com/python/cpython/blob/v3.14.7/Lib/sysconfig/__init__.py#L407 So consult EXT_SUFFIX instead, which every supported version derives from the importer and which a debug build suffixes with _d. That also drops an unwrap() that would panic on a distribution missing the key. The disttests trip on one more count: test_ssl_with_keylogfile. CPython's own test suite skips keylog on Windows debug builds to avoid mixing the debug and release CRT, and from 3.12 set_keylog_filename refuses the call with NotImplementedError. On 3.11 the unguarded call crashed the interpreter on arm64. So the disttest is skipped for every Windows debug distribution, as CPython does. https://github.com/python/cpython/blob/v3.11.16/Lib/test/test_ssl.py#L4827 https://github.com/python/cpython/blob/v3.12.14/Modules/_ssl/debughelpers.c#L168 https://github.com/python/cpython/pull/131839 --- pythonbuild/disttests/__init__.py | 8 ++++++ src/validation.rs | 41 +++++++++++++++++++++++++------ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/pythonbuild/disttests/__init__.py b/pythonbuild/disttests/__init__.py index 10df91f3d..b366e2ec4 100644 --- a/pythonbuild/disttests/__init__.py +++ b/pythonbuild/disttests/__init__.py @@ -218,6 +218,14 @@ def test_ssl(self): ssl.create_default_context() @unittest.skipIf(os.name != "nt", "Windows-specific OpenSSL uplink regression") + # CPython's own suite skips keylog tests on Windows debug builds, to avoid + # mixing the debug and release CRT; 3.12+ refuses the call outright. + # https://github.com/python/cpython/blob/v3.11.16/Lib/test/test_ssl.py#L4827 + # https://github.com/python/cpython/blob/v3.12.14/Modules/_ssl/debughelpers.c#L168 + @unittest.skipIf( + os.name == "nt" and "debug" in os.environ["BUILD_OPTIONS"].split("+"), + "keylog_filename is unsupported on Windows debug builds", + ) def test_ssl_with_keylogfile(self): # Validate that a SSLContext can be created when SSLKEYLOGFILE is set # https://github.com/astral-sh/python-build-standalone/issues/640 diff --git a/src/validation.rs b/src/validation.rs index 7cdabb378..05da9a58d 100644 --- a/src/validation.rs +++ b/src/validation.rs @@ -104,11 +104,14 @@ const PE_ALLOWED_LIBRARIES: &[&str] = &[ "RPCRT4.dll", "SHELL32.dll", "SHLWAPI.dll", + "ucrtbased.dll", "USER32.dll", "USERENV.dll", "VERSION.dll", "VCRUNTIME140.dll", + "VCRUNTIME140D.dll", "VCRUNTIME140_1.dll", + "VCRUNTIME140_1D.dll", "WINMM.dll", "WS2_32.dll", // Our libraries. @@ -124,18 +127,31 @@ const PE_ALLOWED_LIBRARIES: &[&str] = &[ "libssl-3-arm64.dll", "libssl-3-x64.dll", "python3.dll", + "python3_d.dll", "python3t.dll", + "python3t_d.dll", "python39.dll", + "python39_d.dll", "python310.dll", + "python310_d.dll", "python311.dll", + "python311_d.dll", "python312.dll", + "python312_d.dll", "python313.dll", + "python313_d.dll", "python313t.dll", + "python313t_d.dll", "python314.dll", + "python314_d.dll", "python314t.dll", + "python314t_d.dll", "python315.dll", + "python315_d.dll", "python315t.dll", + "python315t_d.dll", "sqlite3.dll", + "sqlite3_d.dll", "tcl86t.dll", "tk86t.dll", ]; @@ -1931,14 +1947,23 @@ fn validate_json(json: &PythonJsonMain, triple: &str, is_debug: bool) -> Result< )); } - if is_debug - && !json - .python_config_vars - .get("abiflags") - .unwrap() - .contains('d') - { - errors.push("abiflags does not contain 'd'".to_string()); + if is_debug { + // Windows keeps `abiflags` empty on purpose and only emulates `ABIFLAGS` + // from 3.14, so consult EXT_SUFFIX, which every version suffixes with `_d`. + // See https://github.com/python/cpython/blob/v3.14.7/Lib/sysconfig/__init__.py#L407 + let (key, marker) = if triple.contains("-windows-") { + ("EXT_SUFFIX", "_d") + } else { + ("abiflags", "d") + }; + + match json.python_config_vars.get(key) { + Some(value) if value.contains(marker) => {} + Some(value) => { + errors.push(format!("{key} is {value:?}, expected to contain {marker:?}")) + } + None => errors.push(format!("{key} is not set")), + } } for extension in json.build_info.extensions.keys() { From 23044b49da7e2e91d59da76cfdb6c05775fb247d Mon Sep 17 00:00:00 2001 From: Vadim Mironov Date: Sat, 12 Sep 2026 08:55:45 +0100 Subject: [PATCH 3/4] Build the Windows debug configurations in CI The debug option is only useful if something builds it. Unix targets already carry debug and freethreaded+debug beside their optimised options; Windows carries neither. Each Windows target has pgo and freethreaded+pgo today, so each gains the matching debug pair, leaving the three in step with each other as they already were. --- ci-targets.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ci-targets.yaml b/ci-targets.yaml index d75eb60cf..459aba1ac 100644 --- a/ci-targets.yaml +++ b/ci-targets.yaml @@ -387,9 +387,11 @@ windows: - "3.15" vs_version: "2022" build_options: + - debug - pgo build_options_conditional: - options: + - freethreaded+debug - freethreaded+pgo minimum-python-version: "3.13" @@ -408,9 +410,11 @@ windows: vs_version: "2026" minimum-python-version: "3.15" build_options: + - debug - pgo build_options_conditional: - options: + - freethreaded+debug - freethreaded+pgo minimum-python-version: "3.13" @@ -427,8 +431,10 @@ windows: - "3.15" vs_version: "2022" build_options: + - debug - pgo build_options_conditional: - options: + - freethreaded+debug - freethreaded+pgo minimum-python-version: "3.13" From 03d1e30a884c187847b735b8c74016e6f1e7bff4 Mon Sep 17 00:00:00 2001 From: Vadim Mironov Date: Wed, 16 Sep 2026 22:23:35 +0100 Subject: [PATCH 4/4] Use f-strings in the debug build additions --- cpython-windows/build.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cpython-windows/build.py b/cpython-windows/build.py index 0a43383f5..35e958b04 100644 --- a/cpython-windows/build.py +++ b/cpython-windows/build.py @@ -1220,8 +1220,8 @@ def find_additional_dependencies(project: pathlib.Path): debug_suffix = "_d" if config == "Debug" else "" if freethreaded: - abi_tag = "%s.cp%st-%s" % (debug_suffix, python_majmin, abi_platform) - lib_suffix = "t%s" % debug_suffix + abi_tag = f"{debug_suffix}.cp{python_majmin}t-{abi_platform}" + lib_suffix = f"t{debug_suffix}" else: abi_tag = debug_suffix lib_suffix = debug_suffix @@ -1353,15 +1353,15 @@ def find_additional_dependencies(project: pathlib.Path): # Copy libraries for dependencies into the lib directory. for depend in sorted(depends_projects): - static_source = outputs_path / ("%s%s.lib" % (depend, debug_suffix)) - static_dest = lib_dir / ("%s%s.lib" % (depend, debug_suffix)) + static_source = outputs_path / f"{depend}{debug_suffix}.lib" + static_dest = lib_dir / f"{depend}{debug_suffix}.lib" log("copying link library %s" % static_source) shutil.copyfile(static_source, static_dest) - shared_source = outputs_path / ("%s%s.dll" % (depend, debug_suffix)) + shared_source = outputs_path / f"{depend}{debug_suffix}.dll" if shared_source.exists(): - shared_dest = lib_dir / ("%s%s.dll" % (depend, debug_suffix)) + shared_dest = lib_dir / f"{depend}{debug_suffix}.dll" log("copying shared library %s" % shared_source) shutil.copyfile(shared_source, shared_dest)