Skip to content

Commit 2115231

Browse files
authored
Implement prepare_metadata_for_build_wheel PEP 517 hook (#39)
* add prepare_metadata_for_build_wheel * do not generate wheel * wip * simplify
1 parent 1847db1 commit 2115231

3 files changed

Lines changed: 143 additions & 9 deletions

File tree

src/conan_py_build/build.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ def _create_dist_info(staging_dir: Path, metadata: dict, project_dir: Path) -> P
317317
return dist_info_dir
318318

319319

320+
320321
# PEP 517 Hooks
321322

322323
def get_requires_for_build_wheel(config_settings: Optional[dict] = None) -> list:
@@ -333,6 +334,34 @@ def get_requires_for_build_sdist(config_settings: Optional[dict] = None) -> list
333334
return []
334335

335336

337+
def prepare_metadata_for_build_wheel(
338+
metadata_directory: str,
339+
config_settings: Optional[dict] = None,
340+
) -> str:
341+
"""
342+
PEP 517 hook: Prepare wheel metadata without building the full wheel.
343+
344+
Creates a .dist-info directory inside metadata_directory containing only
345+
METADATA. The WHEEL file is intentionally omitted: wheel tags depend on
346+
Conan's VirtualBuildEnv and cannot be computed reliably at this stage.
347+
348+
Returns the name of the created directory.
349+
"""
350+
metadata_dir = Path(metadata_directory)
351+
metadata_dir.mkdir(parents=True, exist_ok=True)
352+
353+
source_dir = Path.cwd()
354+
project_metadata = _get_project_metadata(source_dir)
355+
_resolve_version(project_metadata, source_dir)
356+
357+
name = _normalize_name(project_metadata.get("name", "unknown"))
358+
version = project_metadata.get("version", "0.0.0")
359+
print(f"Preparing metadata for {name} {version}...", flush=True)
360+
361+
dist_info_dir = _create_dist_info(metadata_dir, project_metadata, source_dir)
362+
return dist_info_dir.name
363+
364+
336365
def build_wheel(
337366
wheel_directory: str,
338367
config_settings: Optional[dict] = None,
@@ -341,14 +370,12 @@ def build_wheel(
341370
"""
342371
PEP 517 hook: Build a wheel from the source tree.
343372
344-
Note: prepare_metadata_for_build_wheel is not implemented, so
345-
metadata_directory is ignored if provided.
373+
When metadata_directory is provided its contents (METADATA, license files,
374+
and any extra entries) are copied into the wheel unchanged. The WHEEL file
375+
and RECORD are still written by distlib during wheel packaging. Wheel tags
376+
are always recomputed inside VirtualBuildEnv.
346377
"""
347378

348-
if metadata_directory is not None:
349-
print(f"WARNING: metadata_directory provided: '{metadata_directory}' - " \
350-
"backend will ignore/recreate dist-info.")
351-
352379
wheel_dir = Path(wheel_directory)
353380
wheel_dir.mkdir(parents=True, exist_ok=True)
354381

@@ -370,6 +397,7 @@ def build_wheel(
370397
version,
371398
project_metadata,
372399
config,
400+
metadata_directory,
373401
)
374402

375403

@@ -426,6 +454,7 @@ def _do_build_wheel(
426454
version: str,
427455
project_metadata: dict,
428456
config: dict,
457+
metadata_directory: Optional[str] = None,
429458
) -> str:
430459
"""Internal function that performs the actual wheel build."""
431460

@@ -539,8 +568,12 @@ def _do_build_wheel(
539568
# Copy shared libs from Conan's runtime_deploy to the wheel layout.
540569
move_deploy_to_wheel(runtime_deploy_dir, staging_dir)
541570

542-
# Create dist-info
543-
_create_dist_info(staging_dir, project_metadata, source_dir)
571+
# Create dist-info (or reuse the one prepared by prepare_metadata_for_build_wheel)
572+
if metadata_directory is not None:
573+
src = Path(metadata_directory) # PEP 517: metadata_directory is the .dist-info path itself
574+
shutil.copytree(src, staging_dir / src.name, dirs_exist_ok=True)
575+
else:
576+
_create_dist_info(staging_dir, project_metadata, source_dir)
544577

545578
# Build wheel using distlib. Apply Conan's buildenv to get cross-compile
546579
# wheel tags from [buildenv]

tests/test_integration.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import pytest
1010

11-
from conan_py_build.build import build_sdist, build_wheel
11+
from conan_py_build.build import build_sdist, build_wheel, prepare_metadata_for_build_wheel
1212

1313

1414
_DEFAULT_PYPROJECT = """\
@@ -137,6 +137,24 @@ def test_sdist_pkg_info_and_wheel_metadata_identical(integration_project):
137137
assert pkg_info.strip() == wheel_metadata.strip(), "PKG-INFO and METADATA must be the same core metadata"
138138

139139

140+
def test_prepare_metadata_matches_wheel_metadata(integration_project):
141+
"""Integration: METADATA from prepare_metadata_for_build_wheel matches the METADATA in the final wheel."""
142+
meta_dir = integration_project.work_dir / "meta"
143+
meta_dir.mkdir()
144+
dist_info_name = prepare_metadata_for_build_wheel(str(meta_dir))
145+
prepared = (meta_dir / dist_info_name / "METADATA").read_text(encoding="utf-8")
146+
147+
wheel_dir = integration_project.work_dir / "dist"
148+
wheel_dir.mkdir()
149+
wheel_name = build_wheel(str(wheel_dir))
150+
151+
with zipfile.ZipFile(wheel_dir / wheel_name) as zf:
152+
(entry,) = [n for n in zf.namelist() if n.endswith(".dist-info/METADATA")]
153+
built = zf.read(entry).decode("utf-8")
154+
155+
assert prepared.strip() == built.strip()
156+
157+
140158
def test_build_wheel_integration(integration_project, capfd):
141159
"""Integration: build_wheel on a real project."""
142160
wheel_dir = integration_project.work_dir / "wheelhouse"
@@ -148,6 +166,29 @@ def test_build_wheel_integration(integration_project, capfd):
148166
assert "source_called" in err
149167

150168

169+
def test_build_wheel_uses_metadata_directory(integration_project):
170+
"""build_wheel copies pre-built dist-info when metadata_directory is provided (PEP 517 contract)."""
171+
meta_dir = integration_project.work_dir / "meta"
172+
meta_dir.mkdir()
173+
dist_info_name = prepare_metadata_for_build_wheel(str(meta_dir))
174+
dist_info_path = meta_dir / dist_info_name
175+
# Simulate a frontend adding an extra entry to the pre-built dist-info
176+
(dist_info_path / "extra.txt").write_text("sentinel")
177+
178+
wheel_dir = integration_project.work_dir / "dist"
179+
wheel_dir.mkdir()
180+
# Pass the .dist-info path directly (spec-correct form)
181+
wheel_name = build_wheel(str(wheel_dir), metadata_directory=str(dist_info_path))
182+
183+
with zipfile.ZipFile(wheel_dir / wheel_name) as zf:
184+
names = zf.namelist()
185+
assert any(n.endswith(".dist-info/extra.txt") for n in names), \
186+
"Extra file from metadata_directory must be preserved in the wheel"
187+
(entry,) = [n for n in names if n.endswith(".dist-info/METADATA")]
188+
prepared = (dist_info_path / "METADATA").read_text(encoding="utf-8")
189+
assert prepared.strip() == zf.read(entry).decode("utf-8").strip()
190+
191+
151192
def test_build_wheel_with_profile_autodetect(integration_project, monkeypatch):
152193
"""With CONAN_PY_BUILD_PROFILE_AUTODETECT=1 a local profile is created; by default Conan default is used."""
153194
profile_path = integration_project.project_dir / "conan-py-build.profile"
@@ -207,6 +248,7 @@ def test_build_sdist_version_file(tmp_path, monkeypatch):
207248
assert build_sdist(str(sdist_dir)) == "file-pkg-2.3.4.tar.gz"
208249

209250

251+
210252
def test_build_sdist_version_scm(tmp_path, monkeypatch):
211253
"""Integration: build_sdist resolves dynamic version from setuptools_scm (git tag)."""
212254
proj = tmp_path / "proj"

tests/test_unit.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
_copy_license_files_from_paths,
2525
_validate_version_config,
2626
_get_version_from_scm,
27+
prepare_metadata_for_build_wheel,
2728
)
2829

2930

@@ -280,6 +281,7 @@ def test_create_dist_info_includes_license_file_and_metadata(tmp_path):
280281
assert "License-File: LICENSE" in meta_content
281282

282283

284+
283285
def test_build_wheel_with_tags_produces_whl(tmp_path):
284286
wheel_dir = tmp_path / "dist"
285287
wheel_dir.mkdir(parents=True)
@@ -317,3 +319,60 @@ def test_validate_version_config_invalid_provider_raises(tmp_path):
317319
""", encoding="utf-8")
318320
with pytest.raises(RuntimeError, match="must be 'setuptools_scm'"):
319321
_validate_version_config(tmp_path)
322+
323+
324+
@pytest.fixture
325+
def prepared_dist_info(tmp_path, monkeypatch):
326+
"""Run prepare_metadata_for_build_wheel on a minimal project; return the dist-info Path."""
327+
make_pyproject_minimal(tmp_path)
328+
monkeypatch.chdir(tmp_path)
329+
name = prepare_metadata_for_build_wheel(str(tmp_path / "meta"))
330+
return tmp_path / "meta" / name
331+
332+
333+
def test_prepare_metadata_returns_dist_info_name(tmp_path, monkeypatch):
334+
"""Returns the .dist-info directory name with normalised package name and version."""
335+
make_pyproject_minimal(tmp_path)
336+
monkeypatch.chdir(tmp_path)
337+
assert prepare_metadata_for_build_wheel(str(tmp_path / "meta")) == "test_pkg-1.2.3.dist-info"
338+
339+
340+
def test_prepare_metadata_metadata_content(prepared_dist_info):
341+
"""METADATA contains the Name and Version headers from pyproject.toml."""
342+
content = (prepared_dist_info / "METADATA").read_text(encoding="utf-8")
343+
assert "Name: test-pkg" in content
344+
assert "Version: 1.2.3" in content
345+
346+
347+
def test_prepare_metadata_no_wheel_file(prepared_dist_info):
348+
"""WHEEL file is not written: tags depend on VirtualBuildEnv and cannot be computed here."""
349+
assert not (prepared_dist_info / "WHEEL").exists()
350+
351+
352+
def test_prepare_metadata_dynamic_version_from_file(tmp_path, monkeypatch):
353+
"""Dynamic version resolved from [tool.conan-py-build.version].file is used in the directory name."""
354+
make_pyproject_with_tool_config(tmp_path)
355+
monkeypatch.chdir(tmp_path)
356+
name = prepare_metadata_for_build_wheel(str(tmp_path / "meta"))
357+
assert name == "myadder_pybind11-0.5.0.dist-info"
358+
assert "Version: 0.5.0" in (tmp_path / "meta" / name / "METADATA").read_text(encoding="utf-8")
359+
360+
361+
def test_prepare_metadata_with_license_file(tmp_path, monkeypatch):
362+
"""License file declared in pyproject.toml is copied into .dist-info/licenses/."""
363+
(tmp_path / "pyproject.toml").write_text("""\
364+
[project]
365+
name = "licensed-pkg"
366+
version = "0.9.0"
367+
description = "Test"
368+
license-files = ["LICENSE"]
369+
370+
[build-system]
371+
requires = ["conan-py-build"]
372+
build-backend = "conan_py_build.build"
373+
""", encoding="utf-8")
374+
(tmp_path / "LICENSE").write_text("MIT", encoding="utf-8")
375+
monkeypatch.chdir(tmp_path)
376+
dist_info = tmp_path / "meta" / prepare_metadata_for_build_wheel(str(tmp_path / "meta"))
377+
assert (dist_info / "licenses" / "LICENSE").read_text() == "MIT"
378+
assert "License-File: LICENSE" in (dist_info / "METADATA").read_text(encoding="utf-8")

0 commit comments

Comments
 (0)