From aa2afb976d7bc5841730bdf715234b883f36e489 Mon Sep 17 00:00:00 2001 From: subotac <73706465+subotac@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:46:19 +0200 Subject: [PATCH 1/2] fix(frontend): remove stale fallback metadata files --- .../+fallback-stale-metadata.bugfix.rst | 1 + src/pyproject_api/_frontend.py | 12 ++++-- tests/test_frontend.py | 37 +++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 docs/changelog.d/+fallback-stale-metadata.bugfix.rst diff --git a/docs/changelog.d/+fallback-stale-metadata.bugfix.rst b/docs/changelog.d/+fallback-stale-metadata.bugfix.rst new file mode 100644 index 0000000..490e1c4 --- /dev/null +++ b/docs/changelog.d/+fallback-stale-metadata.bugfix.rst @@ -0,0 +1 @@ +Remove stale files from an existing dist-info directory when extracting metadata from a built wheel. diff --git a/src/pyproject_api/_frontend.py b/src/pyproject_api/_frontend.py index 7fafbea..a884860 100644 --- a/src/pyproject_api/_frontend.py +++ b/src/pyproject_api/_frontend.py @@ -499,9 +499,15 @@ def metadata_from_built( with ZipFile(str(wheel), "r") as zip_file: for name in zip_file.namelist(): # pragma: no branch root = Path(name).parts[0] - if root.endswith(".dist-info"): - basename = root - zip_file.extract(name, extract_to) + if not root.endswith(".dist-info"): + continue + if basename is None: + destination = metadata_directory / root + if destination.is_symlink(): + destination.unlink() + ensure_empty_dir(destination) + basename = root + zip_file.extract(name, extract_to) if basename is None: # pragma: no branch msg = f"no .dist-info found inside generated wheel {wheel}" raise RuntimeError(msg) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 406c825..be5180d 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -610,6 +610,43 @@ def test_metadata_from_built_wheel( assert not err +@pytest.mark.parametrize("target", ["wheel", "editable"]) +def test_metadata_from_built_removes_stale_files( + demo_pkg_inline: Path, tmp_path: Path, target: Literal["wheel", "editable"] +) -> None: + frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(demo_pkg_inline)[:-1]) + metadata = tmp_path / "demo_pkg_inline-1.0.0.dist-info" + metadata.mkdir() + (metadata / "entry_points.txt").write_text("obsolete entry points") + (tmp_path / "unrelated.txt").write_text("keep") + + path, _, _ = frontend.metadata_from_built(tmp_path, target) + + assert not (path / "entry_points.txt").exists() + assert {p.name for p in path.iterdir()} == {"top_level.txt", "WHEEL", "RECORD", "METADATA"} + assert (tmp_path / "unrelated.txt").read_text() == "keep" + + +@pytest.mark.skipif(sys.platform == "win32", reason="Creating symlinks requires privileges") +def test_metadata_from_built_replaces_directory_symlink( + demo_pkg_inline: Path, tmp_path: Path +) -> None: # pragma: win32 no cover + frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(demo_pkg_inline)[:-1]) + external = tmp_path / "external" + external.mkdir() + (external / "keep.txt").write_text("keep") + output = tmp_path / "metadata" + output.mkdir() + (output / "demo_pkg_inline-1.0.0.dist-info").symlink_to(external, target_is_directory=True) + + path, _, _ = frontend.metadata_from_built(output, "wheel") + + assert not path.is_symlink() + assert (path / "METADATA").is_file() + assert {p.name for p in external.iterdir()} == {"keep.txt"} + assert (external / "keep.txt").read_text() == "keep" + + def test_bad_wheel_metadata_from_built_wheel(local_builder: Callable[[str], Path]) -> None: txt = """ import sys From 105e300ca0eae3349e1a18e5025f98555682187f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 07:38:41 -0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A7=AA=20test(frontend):=20account=20?= =?UTF-8?q?for=20Windows=20symlinks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows suite skips directory symlink creation because it requires privileges. Exclude that branch from Windows coverage while keeping the symlink behavior covered on Linux. Tighten the fallback tests and name both build targets in the changelog. --- .../+fallback-stale-metadata.bugfix.rst | 2 +- src/pyproject_api/_frontend.py | 4 +-- tests/test_frontend.py | 28 +++++++++---------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/changelog.d/+fallback-stale-metadata.bugfix.rst b/docs/changelog.d/+fallback-stale-metadata.bugfix.rst index 490e1c4..d7f1f96 100644 --- a/docs/changelog.d/+fallback-stale-metadata.bugfix.rst +++ b/docs/changelog.d/+fallback-stale-metadata.bugfix.rst @@ -1 +1 @@ -Remove stale files from an existing dist-info directory when extracting metadata from a built wheel. +Clear stale files from reused ``.dist-info`` directories when building wheel or editable metadata. diff --git a/src/pyproject_api/_frontend.py b/src/pyproject_api/_frontend.py index a884860..71df817 100644 --- a/src/pyproject_api/_frontend.py +++ b/src/pyproject_api/_frontend.py @@ -502,8 +502,8 @@ def metadata_from_built( if not root.endswith(".dist-info"): continue if basename is None: - destination = metadata_directory / root - if destination.is_symlink(): + destination: Final[Path] = metadata_directory / root + if destination.is_symlink(): # pragma: win32 no cover destination.unlink() ensure_empty_dir(destination) basename = root diff --git a/tests/test_frontend.py b/tests/test_frontend.py index be5180d..7ae5916 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -610,20 +610,20 @@ def test_metadata_from_built_wheel( assert not err -@pytest.mark.parametrize("target", ["wheel", "editable"]) +@pytest.mark.parametrize("target", [pytest.param("wheel", id="wheel"), pytest.param("editable", id="editable")]) def test_metadata_from_built_removes_stale_files( demo_pkg_inline: Path, tmp_path: Path, target: Literal["wheel", "editable"] ) -> None: - frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(demo_pkg_inline)[:-1]) - metadata = tmp_path / "demo_pkg_inline-1.0.0.dist-info" + metadata: Final[Path] = tmp_path / "demo_pkg_inline-1.0.0.dist-info" metadata.mkdir() (metadata / "entry_points.txt").write_text("obsolete entry points") (tmp_path / "unrelated.txt").write_text("keep") - path, _, _ = frontend.metadata_from_built(tmp_path, target) + metadata_path: Final[Path] = SubprocessFrontend( + *SubprocessFrontend.create_args_from_folder(demo_pkg_inline)[:-1] + ).metadata_from_built(tmp_path, target)[0] - assert not (path / "entry_points.txt").exists() - assert {p.name for p in path.iterdir()} == {"top_level.txt", "WHEEL", "RECORD", "METADATA"} + assert {entry.name for entry in metadata_path.iterdir()} == {"top_level.txt", "WHEEL", "RECORD", "METADATA"} assert (tmp_path / "unrelated.txt").read_text() == "keep" @@ -631,20 +631,20 @@ def test_metadata_from_built_removes_stale_files( def test_metadata_from_built_replaces_directory_symlink( demo_pkg_inline: Path, tmp_path: Path ) -> None: # pragma: win32 no cover - frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(demo_pkg_inline)[:-1]) - external = tmp_path / "external" + external: Final[Path] = tmp_path / "external" external.mkdir() (external / "keep.txt").write_text("keep") - output = tmp_path / "metadata" + output: Final[Path] = tmp_path / "metadata" output.mkdir() (output / "demo_pkg_inline-1.0.0.dist-info").symlink_to(external, target_is_directory=True) - path, _, _ = frontend.metadata_from_built(output, "wheel") + metadata_path: Final[Path] = SubprocessFrontend( + *SubprocessFrontend.create_args_from_folder(demo_pkg_inline)[:-1] + ).metadata_from_built(output, "wheel")[0] - assert not path.is_symlink() - assert (path / "METADATA").is_file() - assert {p.name for p in external.iterdir()} == {"keep.txt"} - assert (external / "keep.txt").read_text() == "keep" + assert not metadata_path.is_symlink() + assert (metadata_path / "METADATA").is_file() + assert {entry.name: entry.read_text() for entry in external.iterdir()} == {"keep.txt": "keep"} def test_bad_wheel_metadata_from_built_wheel(local_builder: Callable[[str], Path]) -> None: