Skip to content

Commit 9924565

Browse files
committed
🐛 fix(build): carry toml-fmt-common in the sdist
The sdist declared Requires-Dist: toml-fmt-common, so a build from it resolved that name from PyPI, where the last upload is 1.3.5 from 2026-05-29. The tree's copy carries the same version number and over 400 different lines, so the install ran May's settings reader against August's formatter. Vendoring ran in build_wheel alone, and only where the sibling Python source sat beside the package. The sdist shipped the Rust crate without it, and build_editable vendored nothing. Fixes #450
1 parent f089730 commit 9924565

13 files changed

Lines changed: 438 additions & 103 deletions

File tree

.github/workflows/pyproject_fmt_test.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ jobs:
163163
strategy:
164164
fail-fast: false
165165
matrix:
166-
env: [type, dev, pkg_meta]
166+
env: [type, dev, pkg_meta, pkg_sdist]
167167
defaults:
168168
run:
169169
working-directory: pyproject-fmt

.github/workflows/tox_toml_fmt_test.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ jobs:
173173
strategy:
174174
fail-fast: false
175175
matrix:
176-
env: [type, dev, pkg_meta]
176+
env: [type, dev, pkg_meta, pkg_sdist]
177177
defaults:
178178
run:
179179
working-directory: tox-toml-fmt

pyproject-fmt/build_backend.py

Lines changed: 89 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""
2-
Vendor toml-fmt-common into the wheel, as a PEP 517 backend and as a CLI patcher.
2+
Vendor toml-fmt-common into what we build, as a PEP 517 backend and as a CLI patcher.
33
44
A new toml-fmt-common release must never break an already published consumer
5-
(tox-dev/toml-fmt#355), so the wheel is made self-contained instead of depending on it.
5+
(tox-dev/toml-fmt#355), so every artifact is made self-contained instead of depending on it.
66
77
The CLI entry point exists because CI builds wheels with ``maturin build``, which never
88
invokes a PEP 517 backend; the same patch then runs on maturin-action's output.
@@ -17,55 +17,56 @@
1717

1818
from base64 import urlsafe_b64encode
1919
from hashlib import sha256
20+
from io import BytesIO
2021
from os import environ
2122
from pathlib import Path
2223
from re import findall, search, sub
2324
from shutil import copy2
2425
from sys import argv
26+
from tarfile import TarInfo
27+
from tarfile import open as tar_open
2528
from tempfile import mkdtemp
2629
from typing import TYPE_CHECKING
2730
from zipfile import ZIP_DEFLATED, ZipFile
2831

2932
import maturin
3033

3134
if TYPE_CHECKING:
32-
from collections.abc import Mapping
35+
from collections.abc import Callable, Iterator, Mapping
3336

3437
ConfigSettings = Mapping[str, str | list[str]]
3538

3639
# our wrapper backend is intentional; silence maturin's missing-backend warning
3740
environ.setdefault("MATURIN_NO_MISSING_BUILD_BACKEND_WARNING", "1")
3841

3942
_HERE = Path(__file__).resolve().parent
40-
_MODULE = _HERE.name.replace("-", "_")
41-
_COMMON = _HERE.parent / "toml-fmt-common"
43+
_MODULE = findall(r'(?m)^name = "(.*)"', (_HERE / "pyproject.toml").read_text())[0].replace("-", "_")
4244
_VENDOR = "toml_fmt_common"
45+
# a checkout keeps toml-fmt-common beside the package; the sdist carries it within
46+
_COMMON = _HERE / "toml-fmt-common" if (_HERE / "toml-fmt-common").is_dir() else _HERE.parent / "toml-fmt-common"
4347

4448

4549
def build_wheel(
4650
wheel_directory: str,
4751
config_settings: ConfigSettings | None = None,
4852
metadata_directory: str | None = None,
4953
) -> str:
50-
if not (_COMMON / "src" / _VENDOR).is_dir(): # no workspace (e.g. building from sdist)
51-
return maturin.build_wheel(wheel_directory, config_settings, metadata_directory)
52-
tmp = Path(mkdtemp())
53-
name = maturin.build_wheel(str(tmp), config_settings, metadata_directory)
54-
vendor_into_wheel(tmp / name)
55-
copy2(tmp / name, Path(wheel_directory) / name)
56-
return name
54+
return built(maturin.build_wheel, wheel_directory, config_settings, metadata_directory, vendor_into_wheel)
5755

5856

5957
def build_sdist(sdist_directory: str, config_settings: ConfigSettings | None = None) -> str:
60-
return maturin.build_sdist(sdist_directory, config_settings)
58+
name = maturin.build_sdist(sdist_directory, config_settings)
59+
if common_is_present():
60+
vendor_into_sdist(Path(sdist_directory) / name)
61+
return name
6162

6263

6364
def build_editable(
6465
wheel_directory: str,
6566
config_settings: ConfigSettings | None = None,
6667
metadata_directory: str | None = None,
6768
) -> str:
68-
return maturin.build_editable(wheel_directory, config_settings, metadata_directory)
69+
return built(maturin.build_editable, wheel_directory, config_settings, metadata_directory, link_common_into_wheel)
6970

7071

7172
def get_requires_for_build_wheel(config_settings: ConfigSettings | None = None) -> list[str]:
@@ -80,6 +81,26 @@ def get_requires_for_build_editable(config_settings: ConfigSettings | None = Non
8081
return maturin.get_requires_for_build_editable(config_settings)
8182

8283

84+
def built(
85+
build: Callable[[str, ConfigSettings | None, str | None], str],
86+
wheel_directory: str,
87+
config_settings: ConfigSettings | None,
88+
metadata_directory: str | None,
89+
carry: Callable[[Path], None],
90+
) -> str:
91+
if not common_is_present():
92+
return build(wheel_directory, config_settings, metadata_directory)
93+
tmp = Path(mkdtemp())
94+
name = build(str(tmp), config_settings, metadata_directory)
95+
carry(tmp / name)
96+
copy2(tmp / name, Path(wheel_directory) / name)
97+
return name
98+
99+
100+
def common_is_present() -> bool:
101+
return (_COMMON / "src" / _VENDOR).is_dir()
102+
103+
83104
def main() -> None:
84105
target = Path(argv[1])
85106
if not (wheels := sorted(target.glob("*.whl")) if target.is_dir() else [target]):
@@ -91,29 +112,27 @@ def main() -> None:
91112

92113

93114
def vendor_into_wheel(wheel: Path) -> None:
94-
common_src = _COMMON / "src" / _VENDOR
115+
at = f"{_MODULE}/_vendor/"
116+
changed = {f"{at}__init__.py": b""} | {f"{at}{name}": data for name, data in common_sources()}
117+
with ZipFile(wheel) as src:
118+
if (entry := f"{_MODULE}/__main__.py") in src.namelist():
119+
spelled = sub(rf"\b{_VENDOR}\b", f"{_MODULE}._vendor.{_VENDOR}", src.read(entry).decode())
120+
changed[entry] = spelled.encode()
121+
rewrite(wheel, changed)
95122

123+
124+
def link_common_into_wheel(wheel: Path) -> None:
125+
# an editable install reads the package from the source tree, where the import stays unvendored
126+
rewrite(wheel, {f"{_MODULE}_{_VENDOR}.pth": f"{_COMMON / 'src'}\n".encode()})
127+
128+
129+
def rewrite(wheel: Path, changed: dict[str, bytes]) -> None:
96130
with ZipFile(wheel) as src:
97131
names = src.namelist()
98132
dist_info = next(n for n in names if n.endswith(".dist-info/METADATA")).split("/")[0]
99133
out = {n: src.read(n) for n in names if not n.endswith("/RECORD")}
100-
101-
if (entry := f"{_MODULE}/__main__.py") in out:
102-
out[entry] = sub(rf"\b{_VENDOR}\b", f"{_MODULE}._vendor.{_VENDOR}", out[entry].decode()).encode()
103-
meta = f"{dist_info}/METADATA"
104-
deps_block = search(r"(?ms)^dependencies = \[(.*?)\]", (_COMMON / "pyproject.toml").read_text())
105-
deps = findall(r'"([^"]*)"', deps_block.group(1)) if deps_block else []
106-
stripped = sub(r"(?m)^Requires-Dist: toml-fmt-common.*\n", "", out[meta].decode())
107-
# Requires-Dist belongs in the header block; everything past the first blank line is the description
108-
headers, sep, description = stripped.partition("\n\n")
109-
parts = [headers.rstrip("\n"), *(f"\nRequires-Dist: {d}" for d in deps), sep, description]
110-
out[meta] = "".join(parts).encode()
111-
112-
out[f"{_MODULE}/_vendor/__init__.py"] = b""
113-
# vendor only the package's Python sources; local build artifacts (bytecode, caches, ext modules) never leak
114-
for file in common_src.rglob("*"):
115-
if file.is_file() and (file.suffix in {".py", ".pyi"} or file.name == "py.typed"):
116-
out[f"{_MODULE}/_vendor/{file.relative_to(common_src.parent).as_posix()}"] = file.read_bytes()
134+
out.update(changed)
135+
out[f"{dist_info}/METADATA"] = own_metadata(out[f"{dist_info}/METADATA"])
117136

118137
record = []
119138
for name, data in out.items():
@@ -127,5 +146,43 @@ def vendor_into_wheel(wheel: Path) -> None:
127146
zf.writestr(name, data)
128147

129148

149+
def vendor_into_sdist(sdist: Path) -> None:
150+
held = []
151+
with tar_open(sdist) as src:
152+
for member in src.getmembers():
153+
content = src.extractfile(member)
154+
held.append((member, content.read() if content else b""))
155+
root = held[0][0].name.split("/")[0]
156+
157+
added = [(f"{root}/{_COMMON.name}/pyproject.toml", (_COMMON / "pyproject.toml").read_bytes())]
158+
added += [(f"{root}/{_COMMON.name}/src/{name}", data) for name, data in common_sources()]
159+
160+
with tar_open(sdist, "w:gz") as out:
161+
for member, data in held:
162+
out.addfile(member, BytesIO(data) if member.isfile() else None)
163+
for name, data in added:
164+
info = TarInfo(name)
165+
info.size = len(data)
166+
info.mode = 0o644
167+
out.addfile(info, BytesIO(data))
168+
169+
170+
def own_metadata(metadata: bytes) -> bytes:
171+
deps_block = search(r"(?ms)^dependencies = \[(.*?)\]", (_COMMON / "pyproject.toml").read_text())
172+
if not (deps := findall(r'"([^"]*)"', deps_block.group(1)) if deps_block else []):
173+
return metadata
174+
# Requires-Dist belongs in the header block; everything past the first blank line is the description
175+
headers, sep, description = metadata.decode().partition("\n\n")
176+
return "".join([headers.rstrip("\n"), *(f"\nRequires-Dist: {d}" for d in deps), sep, description]).encode()
177+
178+
179+
def common_sources() -> Iterator[tuple[str, bytes]]:
180+
src = _COMMON / "src" / _VENDOR
181+
# vendor only the package's Python sources; local build artifacts (bytecode, caches, ext modules) never leak
182+
for file in sorted(src.rglob("*")):
183+
if file.is_file() and (file.suffix in {".py", ".pyi"} or file.name == "py.typed"):
184+
yield file.relative_to(src.parent).as_posix(), file.read_bytes()
185+
186+
130187
if __name__ == "__main__":
131188
main()

pyproject-fmt/pyproject.toml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,6 @@ classifiers = [
3434
dynamic = [
3535
"version",
3636
]
37-
dependencies = [
38-
"toml-fmt-common",
39-
]
4037
urls."Bug Tracker" = "https://github.com/tox-dev/toml-fmt/issues"
4138
urls."Source Code" = "https://github.com/tox-dev/toml-fmt/tree/main/pyproject-fmt"
4239
urls.Changelog = "https://github.com/tox-dev/toml-fmt/releases?q=pyproject-fmt"

pyproject-fmt/tests/test_build_backend.py

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,52 @@
11
from __future__ import annotations
22

3+
from io import BytesIO
34
from pathlib import Path
45
from runpy import run_path
56
from sys import modules
7+
from tarfile import DIRTYPE, TarInfo
8+
from tarfile import open as tar_open
69
from types import SimpleNamespace
7-
from typing import Final
10+
from typing import TYPE_CHECKING, Final, NamedTuple
811
from zipfile import ZipFile
912

1013
import pytest
1114

15+
if TYPE_CHECKING:
16+
from collections.abc import Callable
17+
1218
_BACKEND: Final[Path] = Path(__file__).parents[1] / "build_backend.py"
1319
_METADATA: Final[str] = """\
1420
Metadata-Version: 2.4
1521
Name: pyproject-fmt
1622
Version: 0
17-
Requires-Dist: toml-fmt-common
1823
Description-Content-Type: text/markdown
1924
2025
# pyproject-fmt
2126
2227
Format your TOML.
2328
"""
29+
_SDIST_HELD: Final[bytes] = b'[project]\nname = "pyproject-fmt"\n'
30+
31+
32+
class Backend(NamedTuple):
33+
vendor_into_wheel: Callable[[Path], None]
34+
link_common_into_wheel: Callable[[Path], None]
35+
vendor_into_sdist: Callable[[Path], None]
2436

2537

2638
@pytest.fixture
27-
def wheel(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
39+
def backend(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Backend:
2840
monkeypatch.setitem(modules, "maturin", SimpleNamespace())
29-
vendor = run_path(str(_BACKEND))["vendor_into_wheel"]
41+
loaded = run_path(str(_BACKEND))
3042

3143
common = tmp_path / "toml-fmt-common"
3244
src = common / "src" / "toml_fmt_common"
3345
src.mkdir(parents=True)
34-
(src / "__init__.py").write_text("VALUE = 1\n", encoding="utf-8")
35-
(src / "_lib.pyi").write_text("VALUE: int\n", encoding="utf-8")
46+
(src / "__init__.py").write_text("VALUE = 1\n", encoding="utf-8", newline="")
47+
(src / "_lib.pyi").write_text("VALUE: int\n", encoding="utf-8", newline="")
3648
(src / "py.typed").write_text("", encoding="utf-8")
37-
(common / "pyproject.toml").write_text('dependencies = [\n "tomlkit>=0.13",\n]\n', encoding="utf-8")
49+
(common / "pyproject.toml").write_text('dependencies = [\n "tomlkit>=0.13",\n]\n', encoding="utf-8", newline="")
3850

3951
cache = src / "__pycache__"
4052
cache.mkdir()
@@ -44,14 +56,44 @@ def wheel(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
4456
(src / "_speedups.so").write_bytes(b"\x7fELF")
4557
(src / ".DS_Store").write_bytes(b"junk")
4658

59+
monkeypatch.setitem(loaded["vendor_into_wheel"].__globals__, "_COMMON", common)
60+
return Backend(loaded["vendor_into_wheel"], loaded["link_common_into_wheel"], loaded["vendor_into_sdist"])
61+
62+
63+
@pytest.fixture
64+
def unvendored(tmp_path: Path) -> Path:
4765
path = tmp_path / "pyproject_fmt-0-py3-none-any.whl"
4866
with ZipFile(path, "w") as zf:
4967
zf.writestr("pyproject_fmt/__main__.py", "import toml_fmt_common\n")
5068
zf.writestr("pyproject_fmt-0.dist-info/METADATA", _METADATA)
5169
zf.writestr("pyproject_fmt-0.dist-info/RECORD", "")
70+
return path
71+
72+
73+
@pytest.fixture
74+
def wheel(backend: Backend, unvendored: Path) -> Path:
75+
backend.vendor_into_wheel(unvendored)
76+
return unvendored
77+
78+
79+
@pytest.fixture
80+
def editable(backend: Backend, unvendored: Path) -> Path:
81+
backend.link_common_into_wheel(unvendored)
82+
return unvendored
83+
5284

53-
monkeypatch.setitem(vendor.__globals__, "_COMMON", common)
54-
vendor(path)
85+
@pytest.fixture
86+
def sdist(backend: Backend, tmp_path: Path) -> Path:
87+
path = tmp_path / "pyproject_fmt-0.tar.gz"
88+
root = TarInfo("pyproject_fmt-0")
89+
root.type = DIRTYPE
90+
held = TarInfo("pyproject_fmt-0/pyproject.toml")
91+
held.size = len(_SDIST_HELD)
92+
with tar_open(path, "w:gz") as tar:
93+
tar.addfile(root)
94+
tar.addfile(held, BytesIO(_SDIST_HELD))
95+
96+
backend.vendor_into_sdist(path)
5597
return path
5698

5799

@@ -79,3 +121,44 @@ def test_vendor_into_wheel_requires_dist_in_header(wheel: Path) -> None:
79121
"Requires-Dist: tomlkit>=0.13",
80122
]
81123
assert description == "# pyproject-fmt\n\nFormat your TOML.\n"
124+
125+
126+
def test_vendor_into_wheel_spells_the_entry_point_import_vendored(wheel: Path) -> None:
127+
with ZipFile(wheel) as zf:
128+
assert zf.read("pyproject_fmt/__main__.py") == b"import pyproject_fmt._vendor.toml_fmt_common\n"
129+
130+
131+
def test_link_common_into_wheel_points_at_the_source_tree(editable: Path, tmp_path: Path) -> None:
132+
with ZipFile(editable) as zf:
133+
assert zf.read("pyproject_fmt_toml_fmt_common.pth").decode() == f"{tmp_path / 'toml-fmt-common' / 'src'}\n"
134+
135+
136+
def test_link_common_into_wheel_copies_nothing(editable: Path) -> None:
137+
with ZipFile(editable) as zf:
138+
assert [n for n in zf.namelist() if n.startswith("toml_fmt_common/")] == []
139+
140+
141+
def test_vendor_into_sdist_carries_common(sdist: Path) -> None:
142+
with tar_open(sdist) as tar:
143+
assert set(tar.getnames()) == {
144+
"pyproject_fmt-0",
145+
"pyproject_fmt-0/pyproject.toml",
146+
"pyproject_fmt-0/toml-fmt-common/pyproject.toml",
147+
"pyproject_fmt-0/toml-fmt-common/src/toml_fmt_common/__init__.py",
148+
"pyproject_fmt-0/toml-fmt-common/src/toml_fmt_common/_lib.pyi",
149+
"pyproject_fmt-0/toml-fmt-common/src/toml_fmt_common/py.typed",
150+
}
151+
152+
153+
@pytest.mark.parametrize(
154+
("member", "content"),
155+
[
156+
pytest.param("pyproject.toml", _SDIST_HELD, id="held"),
157+
pytest.param("toml-fmt-common/src/toml_fmt_common/__init__.py", b"VALUE = 1\n", id="added"),
158+
],
159+
)
160+
def test_vendor_into_sdist_content(sdist: Path, member: str, content: bytes) -> None:
161+
with tar_open(sdist) as tar:
162+
read = tar.extractfile(f"pyproject_fmt-0/{member}")
163+
assert read is not None
164+
assert read.read() == content

0 commit comments

Comments
 (0)