Skip to content

Commit 16e6406

Browse files
feat(sources): generate .git_archival.txt for setuptools-scm builds
When building from an sdist that lacks .git metadata, setuptools-scm cannot determine the package version. This adds a .git_archival.txt file with the correct version so setuptools-scm can resolve it. - Skips packages with .git directories (no fix needed) - Replaces existing files that have unprocessed placeholders or missing fields - Creates a new file only when PKG-INFO is also absent (git clones or custom downloads, not PyPI sdists) Closes: #961 Co-Authored-By: Claude <claude@anthropic.com> Signed-off-by: Lalatendu Mohanty <lmohanty@redhat.com>
1 parent 2cf0e7d commit 16e6406

2 files changed

Lines changed: 189 additions & 0 deletions

File tree

src/fromager/sources.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,13 @@ def prepare_source(
533533
)
534534
write_build_meta(source_root_dir.parent, req, source_filename, version)
535535
if source_root_dir is not None:
536+
# Ensure .git_archival.txt is valid before the build backend is
537+
# imported — setuptools-scm resolves the version during
538+
# get_requires_for_build_wheel().
539+
ensure_git_archival(
540+
sdist_root_dir=source_root_dir,
541+
version=version,
542+
)
536543
logger.info(f"prepared source for {req} at {source_root_dir}")
537544
return source_root_dir
538545

@@ -771,6 +778,88 @@ def ensure_pkg_info(
771778
return had_pkg_info
772779

773780

781+
# Template .git_archival.txt files contain "$Format:…$" placeholders that
782+
# `git archive` expands into real values. setuptools-scm detects the
783+
# unexpanded "%(describe" placeholder and falls back to other version-detection
784+
# methods when it is present.
785+
_UNPROCESSED_ARCHIVAL_MARKER = "%(describe"
786+
_REQUIRED_ARCHIVAL_FIELDS = {"describe-name"}
787+
_GIT_ARCHIVAL_CONTENT = "describe-name: {version}\n"
788+
789+
790+
def _is_valid_git_archival(content: str) -> bool:
791+
"""Check whether ``.git_archival.txt`` content has the required fields."""
792+
if _UNPROCESSED_ARCHIVAL_MARKER in content:
793+
return False
794+
fields: dict[str, str] = {}
795+
for line in content.splitlines():
796+
if ":" not in line:
797+
continue
798+
key, _, value = line.partition(":")
799+
fields[key.strip()] = value.strip()
800+
return all(fields.get(f) for f in _REQUIRED_ARCHIVAL_FIELDS)
801+
802+
803+
def _has_git_metadata(sdist_root_dir: pathlib.Path) -> bool:
804+
"""Check whether ``.git`` exists in sdist root directory."""
805+
return sdist_root_dir.joinpath(".git").exists()
806+
807+
808+
def _write_git_archival(archival_file: pathlib.Path, version: Version) -> None:
809+
"""Write a ``.git_archival.txt`` with the given version."""
810+
archival_file.write_text(_GIT_ARCHIVAL_CONTENT.format(version=version))
811+
812+
813+
def ensure_git_archival(
814+
*,
815+
version: Version,
816+
sdist_root_dir: pathlib.Path,
817+
) -> bool | None:
818+
"""Ensure ``.git_archival.txt`` is valid for setuptools-scm version resolution.
819+
820+
Behaviour:
821+
822+
* Skips packages with ``.git`` metadata (git clones need no fix).
823+
* Replaces existing files that are unprocessed or missing required fields.
824+
* Creates a new file when no ``.git_archival.txt`` exists **and**
825+
``PKG-INFO`` is also absent (indicating a git clone or custom
826+
download rather than a PyPI sdist).
827+
828+
Returns ``True`` (valid file present), ``False`` (created/replaced),
829+
or ``None`` (no action taken).
830+
"""
831+
if _has_git_metadata(sdist_root_dir):
832+
logger.debug(
833+
"git metadata found, skipping .git_archival.txt for %s", sdist_root_dir
834+
)
835+
return True
836+
837+
archival_file = sdist_root_dir / ".git_archival.txt"
838+
839+
# Existing file: validate and replace if invalid
840+
if archival_file.is_file():
841+
if _is_valid_git_archival(archival_file.read_text()):
842+
logger.debug(
843+
"valid .git_archival.txt already present in %s", sdist_root_dir
844+
)
845+
return True
846+
logger.info("replacing invalid .git_archival.txt in %s", sdist_root_dir)
847+
_write_git_archival(archival_file, version)
848+
return False
849+
850+
# No file: create when PKG-INFO is also absent (git clone / custom download)
851+
pkg_info = sdist_root_dir / "PKG-INFO"
852+
if not pkg_info.is_file():
853+
logger.info(
854+
"creating .git_archival.txt in %s (no PKG-INFO, likely a git clone)",
855+
sdist_root_dir,
856+
)
857+
_write_git_archival(archival_file, version)
858+
return False
859+
860+
return None
861+
862+
774863
def validate_sdist_filename(
775864
req: Requirement,
776865
version: Version,

tests/test_sources.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,3 +312,103 @@ def test_scan_compiled_extensions(
312312
assert matches == [pathlib.Path(filename)]
313313
else:
314314
assert matches == []
315+
316+
317+
class TestEnsureGitArchival:
318+
"""Tests for ensure_git_archival()."""
319+
320+
def test_skips_when_pkg_info_present(self, tmp_path: pathlib.Path) -> None:
321+
"""Verify no file is created when PKG-INFO exists (PyPI sdist)."""
322+
(tmp_path / "PKG-INFO").write_text("Metadata-Version: 1.0\n")
323+
version = Version("1.2.3")
324+
result = sources.ensure_git_archival(sdist_root_dir=tmp_path, version=version)
325+
archival = tmp_path / ".git_archival.txt"
326+
327+
assert result is None
328+
assert not archival.is_file()
329+
330+
def test_creates_file_when_no_pkg_info(self, tmp_path: pathlib.Path) -> None:
331+
"""Verify file is created when PKG-INFO is missing (git clone)."""
332+
version = Version("1.2.3")
333+
result = sources.ensure_git_archival(sdist_root_dir=tmp_path, version=version)
334+
archival = tmp_path / ".git_archival.txt"
335+
336+
assert result is False
337+
assert archival.is_file()
338+
content = archival.read_text()
339+
assert "describe-name: 1.2.3\n" in content
340+
341+
def test_replaces_unprocessed_file(self, tmp_path: pathlib.Path) -> None:
342+
"""Verify unprocessed template file is replaced."""
343+
archival = tmp_path / ".git_archival.txt"
344+
archival.write_text(
345+
"node: $Format:%H$\n"
346+
"node-date: $Format:%cI$\n"
347+
"describe-name: $Format:%(describe:tags=true)$\n"
348+
)
349+
version = Version("4.5.6")
350+
result = sources.ensure_git_archival(sdist_root_dir=tmp_path, version=version)
351+
352+
assert result is False
353+
content = archival.read_text()
354+
assert "describe-name: 4.5.6\n" in content
355+
assert "%(describe" not in content
356+
357+
def test_preserves_valid_file(self, tmp_path: pathlib.Path) -> None:
358+
"""Verify a valid archival file is left untouched."""
359+
archival = tmp_path / ".git_archival.txt"
360+
original = (
361+
"node: abc123\n"
362+
"node-date: 2025-01-01T00:00:00+00:00\n"
363+
"describe-name: v1.0.0-0-gabc123\n"
364+
)
365+
archival.write_text(original)
366+
version = Version("9.9.9")
367+
result = sources.ensure_git_archival(sdist_root_dir=tmp_path, version=version)
368+
369+
assert result is True
370+
assert archival.read_text() == original
371+
372+
def test_preserves_valid_file_describe_name_only(
373+
self, tmp_path: pathlib.Path
374+
) -> None:
375+
"""Verify a file with only describe-name is valid."""
376+
archival = tmp_path / ".git_archival.txt"
377+
original = "describe-name: 2.0.0\n"
378+
archival.write_text(original)
379+
version = Version("9.9.9")
380+
result = sources.ensure_git_archival(sdist_root_dir=tmp_path, version=version)
381+
382+
assert result is True
383+
assert archival.read_text() == original
384+
385+
def test_replaces_truncated_file(self, tmp_path: pathlib.Path) -> None:
386+
"""Verify a truncated file missing required fields is replaced."""
387+
archival = tmp_path / ".git_archival.txt"
388+
archival.write_text("node-date: 2025-01-01T00:00:00+00:00\n")
389+
version = Version("3.0.0")
390+
result = sources.ensure_git_archival(sdist_root_dir=tmp_path, version=version)
391+
392+
assert result is False
393+
content = archival.read_text()
394+
assert "describe-name: 3.0.0\n" in content
395+
396+
def test_replaces_file_with_empty_values(self, tmp_path: pathlib.Path) -> None:
397+
"""Verify a file with required fields but empty values is replaced."""
398+
archival = tmp_path / ".git_archival.txt"
399+
archival.write_text("describe-name:\n")
400+
version = Version("5.0.0")
401+
result = sources.ensure_git_archival(sdist_root_dir=tmp_path, version=version)
402+
403+
assert result is False
404+
content = archival.read_text()
405+
assert "describe-name: 5.0.0\n" in content
406+
407+
def test_skips_when_git_dir_exists(self, tmp_path: pathlib.Path) -> None:
408+
"""Verify no file is created when .git directory exists."""
409+
(tmp_path / ".git").mkdir()
410+
version = Version("1.0.0")
411+
result = sources.ensure_git_archival(sdist_root_dir=tmp_path, version=version)
412+
413+
assert result is True
414+
assert not (tmp_path / ".git_archival.txt").exists()

0 commit comments

Comments
 (0)