Skip to content

Commit 26fa381

Browse files
committed
Fix missing Include/pyconfig.h in 3.13 Windows onedirs
Python <= 3.12 ships PC/pyconfig.h. Python 3.13+ replaced that file with PC/pyconfig.h.in and MSBuild generates the real header into the build output directory. The previous code skipped the copy entirely on 3.13 ("if 3.13 not in ..."), so the produced tarballs had Python.h but no pyconfig.h next to it -- every C extension build against the onedir failed with "fatal error C1083: Cannot open include file: 'pyconfig.h'". Replace the version-string check with a layout-driven copy that mirrors CPython's PC/layout/main.py: when PC/pyconfig.h.in exists, take the generated header from the PCbuild output directory; otherwise take the checked-in PC/pyconfig.h. Raise if neither is found, so a future regression cannot silently ship broken tarballs. Add unit tests for both branches plus the missing-file error paths.
1 parent e9685bd commit 26fa381

2 files changed

Lines changed: 139 additions & 13 deletions

File tree

relenv/build/windows.py

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ def update_sqlite(dirs: Dirs, env: EnvMapping) -> None:
230230
sqliteversion = sqlite_info.get("sqliteversion", "3500400")
231231
url = sqlite_info["url"].format(version=sqliteversion)
232232
sha256 = sqlite_info["sha256"]
233-
ref_loc = f"cpe:2.3:a:sqlite:sqlite:{version}:*:*:*:*:*:*:*"
233+
ref_loc = f"cpe:2.3:a:sqlite:sqlite:{version}:*:*:*:*:*:*:*" # noqa: E231
234234

235235
target_dir = dirs.source / "externals" / f"sqlite-{version}"
236236
update_props(dirs.source, r"sqlite-\d+(\.\d+)*", f"sqlite-{version}")
@@ -268,7 +268,7 @@ def update_xz(dirs: Dirs, env: EnvMapping) -> None:
268268
version = xz_info["version"]
269269
url = xz_info["url"].format(version=version)
270270
sha256 = xz_info["sha256"]
271-
ref_loc = f"cpe:2.3:a:tukaani:xz:{version}:*:*:*:*:*:*:*"
271+
ref_loc = f"cpe:2.3:a:tukaani:xz:{version}:*:*:*:*:*:*:*" # noqa: E231
272272

273273
target_dir = dirs.source / "externals" / f"xz-{version}"
274274
update_props(dirs.source, r"xz-\d+(\.\d+)*", f"xz-{version}")
@@ -372,7 +372,7 @@ def update_openssl(dirs: Dirs, env: EnvMapping) -> None:
372372
version = openssl_info["version"]
373373
url = openssl_info["url"].format(version=version)
374374
sha256 = openssl_info["sha256"]
375-
ref_loc = f"cpe:2.3:a:openssl:openssl:{version}:*:*:*:*:*:*:*"
375+
ref_loc = f"cpe:2.3:a:openssl:openssl:{version}:*:*:*:*:*:*:*" # noqa: E231
376376

377377
is_binary = "cpython-bin-deps" in url
378378
target_dir = (
@@ -456,7 +456,9 @@ def update_openssl(dirs: Dirs, env: EnvMapping) -> None:
456456

457457
env_path = os.environ.get("PATH", "")
458458
build_env = env.copy()
459-
build_env["PATH"] = f"{perl_bin.parent};{nasm_exe[0].parent};{env_path}"
459+
build_env[
460+
"PATH"
461+
] = f"{perl_bin.parent};{nasm_exe[0].parent};{env_path}" # noqa: E231,E702
460462

461463
prefix = target_dir / "build"
462464
openssldir = prefix / "ssl"
@@ -607,7 +609,7 @@ def update_bzip2(dirs: Dirs, env: EnvMapping) -> None:
607609
version = bzip2_info["version"]
608610
url = bzip2_info["url"].format(version=version)
609611
sha256 = bzip2_info["sha256"]
610-
ref_loc = f"cpe:2.3:a:bzip:bzip2:{version}:*:*:*:*:*:*:*"
612+
ref_loc = f"cpe:2.3:a:bzip:bzip2:{version}:*:*:*:*:*:*:*" # noqa: E231
611613

612614
target_dir = dirs.source / "externals" / f"bzip2-{version}"
613615
update_props(dirs.source, r"bzip2-\d+(\.\d+)*", f"bzip2-{version}")
@@ -642,7 +644,7 @@ def update_libffi(dirs: Dirs, env: EnvMapping) -> None:
642644
version = libffi_info["version"]
643645
url = libffi_info["url"].format(version=version)
644646
sha256 = libffi_info["sha256"]
645-
ref_loc = f"cpe:2.3:a:libffi_project:libffi:{version}:*:*:*:*:*:*:*"
647+
ref_loc = f"cpe:2.3:a:libffi_project:libffi:{version}:*:*:*:*:*:*:*" # noqa: E231
646648

647649
target_dir = dirs.source / "externals" / f"libffi-{version}"
648650
update_props(dirs.source, r"libffi-\d+(\.\d+)*", f"libffi-{version}")
@@ -695,7 +697,7 @@ def update_zlib(dirs: Dirs, env: EnvMapping) -> None:
695697
version = zlib_info["version"]
696698
url = zlib_info["url"].format(version=version)
697699
sha256 = zlib_info["sha256"]
698-
ref_loc = f"cpe:2.3:a:gnu:zlib:{version}:*:*:*:*:*:*:*"
700+
ref_loc = f"cpe:2.3:a:gnu:zlib:{version}:*:*:*:*:*:*:*" # noqa: E231
699701

700702
target_dir = dirs.source / "externals" / f"zlib-{version}"
701703
update_props(dirs.source, r"zlib-\d+(\.\d+)*", f"zlib-{version}")
@@ -773,6 +775,37 @@ def update_perl(dirs: Dirs, env: EnvMapping) -> pathlib.Path:
773775
return target_dir
774776

775777

778+
def copy_pyconfig_h(
779+
source: pathlib.Path, build_dir: pathlib.Path, dest_dir: pathlib.Path
780+
) -> pathlib.Path:
781+
"""
782+
Copy ``pyconfig.h`` into the onedir's ``Include`` directory.
783+
784+
Python <= 3.12 ships a checked-in ``PC/pyconfig.h``. Python 3.13+ replaced
785+
that with ``PC/pyconfig.h.in`` and MSBuild generates the real header into
786+
the build output directory. This mirrors the logic in CPython's
787+
``PC/layout/main.py`` so the onedir's ``Include/`` always ends up with a
788+
``pyconfig.h`` next to ``Python.h`` -- without it C extensions cannot
789+
locate the configuration macros and fail with
790+
``fatal error C1083: Cannot open include file: 'pyconfig.h'``.
791+
"""
792+
pc_dir = source / "PC"
793+
if (pc_dir / "pyconfig.h.in").is_file():
794+
pyconfig_src = build_dir / "pyconfig.h"
795+
else:
796+
pyconfig_src = pc_dir / "pyconfig.h"
797+
798+
if not pyconfig_src.is_file():
799+
raise RuntimeError(
800+
f"Expected pyconfig.h at {pyconfig_src} but CPython build did "
801+
"not produce it. Check that the MSBuild step ran successfully."
802+
)
803+
804+
dest = dest_dir / "pyconfig.h"
805+
shutil.copy(src=str(pyconfig_src), dst=str(dest))
806+
return dest
807+
808+
776809
def build_python(env: EnvMapping, dirs: Dirs, logfp: IO[str]) -> None:
777810
"""
778811
Run the commands to build Python.
@@ -843,7 +876,7 @@ def build_python(env: EnvMapping, dirs: Dirs, logfp: IO[str]) -> None:
843876
"python.exe",
844877
"pythonw.exe",
845878
"python3.dll",
846-
f"python{ env['RELENV_PY_MAJOR_VERSION'].replace('.', '') }.dll",
879+
f"python{env['RELENV_PY_MAJOR_VERSION'].replace('.', '')}.dll",
847880
"vcruntime140.dll",
848881
"venvlauncher.exe",
849882
"venvwlauncher.exe",
@@ -862,10 +895,7 @@ def build_python(env: EnvMapping, dirs: Dirs, logfp: IO[str]) -> None:
862895
dst=str(dirs.prefix / "Include"),
863896
dirs_exist_ok=True,
864897
)
865-
if "3.13" not in env["RELENV_PY_MAJOR_VERSION"]:
866-
shutil.copy(
867-
src=str(dirs.source / "PC" / "pyconfig.h"), dst=str(dirs.prefix / "Include")
868-
)
898+
copy_pyconfig_h(dirs.source, build_dir, dirs.prefix / "Include")
869899

870900
shutil.copytree(
871901
src=str(dirs.source / "Lib"), dst=str(dirs.prefix / "Lib"), dirs_exist_ok=True
@@ -877,7 +907,7 @@ def build_python(env: EnvMapping, dirs: Dirs, logfp: IO[str]) -> None:
877907
src=str(build_dir / "python3.lib"),
878908
dst=str(dirs.prefix / "libs" / "python3.lib"),
879909
)
880-
pylib = f"python{ env['RELENV_PY_MAJOR_VERSION'].replace('.', '') }.lib"
910+
pylib = f"python{env['RELENV_PY_MAJOR_VERSION'].replace('.', '')}.lib"
881911
shutil.copy(src=str(build_dir / pylib), dst=str(dirs.prefix / "libs" / pylib))
882912

883913

tests/test_build.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,3 +454,99 @@ def test_download_destination_setter() -> None:
454454
# Set to None
455455
d.destination = None
456456
assert d.destination == pathlib.Path()
457+
458+
459+
# copy_pyconfig_h tests
460+
#
461+
# Regression coverage for the bug where Windows onedirs for Python 3.13+ were
462+
# shipped without Include/pyconfig.h, breaking every C extension build.
463+
# Python <= 3.12 has a checked-in PC/pyconfig.h; Python 3.13+ replaced it
464+
# with PC/pyconfig.h.in and MSBuild generates the real header into the
465+
# PCbuild output directory. copy_pyconfig_h must pick the right one.
466+
467+
468+
def _make_layout(
469+
tmp_path: pathlib.Path,
470+
*,
471+
has_in: bool,
472+
pc_content: str = "",
473+
build_content: str = "",
474+
) -> tuple[pathlib.Path, pathlib.Path, pathlib.Path]:
475+
"""Create source/, build_dir/, dest_dir/ trees mimicking a CPython tree."""
476+
source = tmp_path / "cpython"
477+
build_dir = tmp_path / "PCbuild" / "amd64"
478+
dest_dir = tmp_path / "prefix" / "Include"
479+
(source / "PC").mkdir(parents=True)
480+
build_dir.mkdir(parents=True)
481+
dest_dir.mkdir(parents=True)
482+
if has_in:
483+
(source / "PC" / "pyconfig.h.in").write_text("/* template */\n")
484+
if build_content:
485+
(build_dir / "pyconfig.h").write_text(build_content)
486+
else:
487+
if pc_content:
488+
(source / "PC" / "pyconfig.h").write_text(pc_content)
489+
return source, build_dir, dest_dir
490+
491+
492+
def test_copy_pyconfig_h_legacy(tmp_path: pathlib.Path) -> None:
493+
"""Python <= 3.12: copies PC/pyconfig.h (no pyconfig.h.in template)."""
494+
from relenv.build.windows import copy_pyconfig_h
495+
496+
source, build_dir, dest_dir = _make_layout(
497+
tmp_path, has_in=False, pc_content="/* checked in 3.12 */\n"
498+
)
499+
# Ensure the build_dir variant would NOT be picked up if it happened to
500+
# exist as well -- the legacy path takes precedence when there's no .in.
501+
(build_dir / "pyconfig.h").write_text("/* should be ignored */\n")
502+
503+
result = copy_pyconfig_h(source, build_dir, dest_dir)
504+
505+
assert result == dest_dir / "pyconfig.h"
506+
assert result.is_file()
507+
assert result.read_text() == "/* checked in 3.12 */\n"
508+
509+
510+
def test_copy_pyconfig_h_generated(tmp_path: pathlib.Path) -> None:
511+
"""Python 3.13+: copies the generated pyconfig.h from build_dir."""
512+
from relenv.build.windows import copy_pyconfig_h
513+
514+
source, build_dir, dest_dir = _make_layout(
515+
tmp_path, has_in=True, build_content="/* generated 3.13 */\n"
516+
)
517+
# A stale PC/pyconfig.h must NOT win when pyconfig.h.in is present.
518+
(source / "PC" / "pyconfig.h").write_text("/* stale legacy */\n")
519+
520+
result = copy_pyconfig_h(source, build_dir, dest_dir)
521+
522+
assert result == dest_dir / "pyconfig.h"
523+
assert result.is_file()
524+
assert result.read_text() == "/* generated 3.13 */\n"
525+
526+
527+
def test_copy_pyconfig_h_missing_generated_raises(tmp_path: pathlib.Path) -> None:
528+
"""3.13+ layout but MSBuild didn't produce pyconfig.h -- must raise.
529+
530+
This is the failure mode that previously slipped through silently and
531+
produced unusable tarballs.
532+
"""
533+
from relenv.build.windows import copy_pyconfig_h
534+
535+
source, build_dir, dest_dir = _make_layout(tmp_path, has_in=True)
536+
# No pyconfig.h written into build_dir.
537+
538+
with pytest.raises(RuntimeError, match="Expected pyconfig.h at"):
539+
copy_pyconfig_h(source, build_dir, dest_dir)
540+
assert not (dest_dir / "pyconfig.h").exists()
541+
542+
543+
def test_copy_pyconfig_h_missing_legacy_raises(tmp_path: pathlib.Path) -> None:
544+
"""Legacy layout but PC/pyconfig.h is absent -- must raise rather than silently no-op."""
545+
from relenv.build.windows import copy_pyconfig_h
546+
547+
source, build_dir, dest_dir = _make_layout(tmp_path, has_in=False)
548+
# No pyconfig.h written into PC/ either.
549+
550+
with pytest.raises(RuntimeError, match="Expected pyconfig.h at"):
551+
copy_pyconfig_h(source, build_dir, dest_dir)
552+
assert not (dest_dir / "pyconfig.h").exists()

0 commit comments

Comments
 (0)