Skip to content

Commit efe230a

Browse files
feat(sources): generate .git_archival.txt for setuptools-scm builds
Packages using setuptools-scm fail when built from source archives without .git metadata. Add ensure_git_archival() to synthesize a .git_archival.txt with the resolved version, which setuptools-scm reads before PKG-INFO in its fallback chain. The archival file is only written in default_build_sdist when no .git directory is present, mirroring the guard used elsewhere. Closes: #961 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lalatendu Mohanty <lmohanty@redhat.com>
1 parent 5b3c27e commit efe230a

2 files changed

Lines changed: 159 additions & 0 deletions

File tree

src/fromager/sources.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,11 @@ def default_build_sdist(
690690
sdist_root_dir=sdist_root_dir,
691691
build_dir=build_dir,
692692
)
693+
if not build_dir.joinpath(".git").exists():
694+
ensure_git_archival(
695+
version=version,
696+
target_dir=build_dir,
697+
)
693698
# The format argument is specified based on
694699
# https://peps.python.org/pep-0517/#build-sdist.
695700
with tarfile.open(sdist_filename, "x:gz", format=tarfile.PAX_FORMAT) as sdist:
@@ -771,6 +776,73 @@ def ensure_pkg_info(
771776
return had_pkg_info
772777

773778

779+
# Template .git_archival.txt files contain "$Format:…$" placeholders that
780+
# `git archive` expands into real values. If they survive unexpanded,
781+
# setuptools-scm detects "$FORMAT" in the node field and returns no version
782+
# (see setuptools_scm.git.archival_to_version).
783+
_UNPROCESSED_ARCHIVAL_MARKER = "$Format:"
784+
785+
# Dummy commit hash used when synthesizing .git_archival.txt without a
786+
# real git repository. The value is never interpreted by setuptools-scm
787+
# beyond checking that it is not an unprocessed $Format:…$ placeholder.
788+
_DUMMY_NODE = "0" * 40
789+
790+
# Fields that must be present for setuptools-scm to resolve a version.
791+
_REQUIRED_ARCHIVAL_FIELDS = {"node", "describe-name"}
792+
793+
_GIT_ARCHIVAL_CONTENT = """\
794+
node: {node}
795+
node-date: 1970-01-01T00:00:00+00:00
796+
describe-name: {version}
797+
"""
798+
799+
800+
def ensure_git_archival(
801+
*,
802+
version: Version,
803+
target_dir: pathlib.Path,
804+
) -> bool:
805+
"""Ensure that sdist has a usable ``.git_archival.txt`` for setuptools-scm.
806+
807+
When building from source archives without a ``.git`` directory,
808+
setuptools-scm cannot determine the package version. A synthesized
809+
``.git_archival.txt`` provides the version through the ``describe-name``
810+
field so that setuptools-scm resolves it without requiring an environment
811+
variable override.
812+
813+
See https://setuptools-scm.readthedocs.io/en/latest/usage/#git-archives
814+
815+
Returns True if a valid archival file was already present (no changes
816+
made), False if a new file was written (file was missing or contained
817+
unprocessed placeholders).
818+
"""
819+
archival_file = target_dir.joinpath(".git_archival.txt")
820+
821+
if archival_file.is_file():
822+
content = archival_file.read_text()
823+
fields = {
824+
line.partition(":")[0].strip()
825+
for line in content.splitlines()
826+
if ":" in line
827+
}
828+
if (
829+
_UNPROCESSED_ARCHIVAL_MARKER not in content
830+
and _REQUIRED_ARCHIVAL_FIELDS <= fields
831+
):
832+
logger.debug("valid .git_archival.txt already present in %s", target_dir)
833+
return True
834+
logger.warning("replacing invalid .git_archival.txt in %s", target_dir)
835+
836+
archival_file.write_text(
837+
_GIT_ARCHIVAL_CONTENT.format(
838+
node=_DUMMY_NODE,
839+
version=str(version),
840+
)
841+
)
842+
logger.info("created .git_archival.txt for version %s in %s", version, target_dir)
843+
return False
844+
845+
774846
def validate_sdist_filename(
775847
req: Requirement,
776848
version: Version,

tests/test_sources.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,3 +312,90 @@ 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_creates_file_when_missing(self, tmp_path: pathlib.Path) -> None:
321+
"""Verify file is created with correct content when absent."""
322+
version = Version("1.2.3")
323+
result = sources.ensure_git_archival(version=version, target_dir=tmp_path)
324+
archival = tmp_path / ".git_archival.txt"
325+
326+
assert result is False
327+
assert archival.is_file()
328+
content = archival.read_text()
329+
assert "describe-name: 1.2.3\n" in content
330+
assert "node: " in content
331+
assert "$Format:" not in content
332+
333+
def test_replaces_unprocessed_file(self, tmp_path: pathlib.Path) -> None:
334+
"""Verify unprocessed template file is replaced."""
335+
archival = tmp_path / ".git_archival.txt"
336+
archival.write_text(
337+
"node: $Format:%H$\n"
338+
"node-date: $Format:%cI$\n"
339+
"describe-name: $Format:%(describe:tags=true)$\n"
340+
)
341+
version = Version("4.5.6")
342+
result = sources.ensure_git_archival(version=version, target_dir=tmp_path)
343+
344+
assert result is False
345+
content = archival.read_text()
346+
assert "describe-name: 4.5.6\n" in content
347+
assert "$Format:" not in content
348+
349+
def test_preserves_valid_file(self, tmp_path: pathlib.Path) -> None:
350+
"""Verify a valid archival file is left untouched."""
351+
archival = tmp_path / ".git_archival.txt"
352+
original = (
353+
"node: abc123\n"
354+
"node-date: 2025-01-01T00:00:00+00:00\n"
355+
"describe-name: v1.0.0-0-gabc123\n"
356+
)
357+
archival.write_text(original)
358+
version = Version("9.9.9")
359+
result = sources.ensure_git_archival(version=version, target_dir=tmp_path)
360+
361+
assert result is True
362+
assert archival.read_text() == original
363+
364+
def test_replaces_empty_file(self, tmp_path: pathlib.Path) -> None:
365+
"""Verify an empty archival file is replaced."""
366+
archival = tmp_path / ".git_archival.txt"
367+
archival.write_text("")
368+
version = Version("2.0.0")
369+
result = sources.ensure_git_archival(version=version, target_dir=tmp_path)
370+
371+
assert result is False
372+
content = archival.read_text()
373+
assert "describe-name: 2.0.0\n" in content
374+
375+
def test_replaces_truncated_file(self, tmp_path: pathlib.Path) -> None:
376+
"""Verify a truncated file missing required fields is replaced."""
377+
archival = tmp_path / ".git_archival.txt"
378+
archival.write_text("node-date: 2025-01-01T00:00:00+00:00\n")
379+
version = Version("3.0.0")
380+
result = sources.ensure_git_archival(version=version, target_dir=tmp_path)
381+
382+
assert result is False
383+
content = archival.read_text()
384+
assert "describe-name: 3.0.0\n" in content
385+
386+
def test_preserves_valid_file_with_nonzero_distance(
387+
self, tmp_path: pathlib.Path
388+
) -> None:
389+
"""Verify a valid archival file from a post-tag commit is preserved."""
390+
archival = tmp_path / ".git_archival.txt"
391+
original = (
392+
"node: 5d8d986d96be4575ae4b9fe9675f7eb3623057cb\n"
393+
"node-date: 2026-03-19T06:57:11+00:00\n"
394+
"describe-name: 0.77.0-13-g5d8d986\n"
395+
)
396+
archival.write_text(original)
397+
version = Version("0.77.0")
398+
result = sources.ensure_git_archival(version=version, target_dir=tmp_path)
399+
400+
assert result is True
401+
assert archival.read_text() == original

0 commit comments

Comments
 (0)