From 8287e3c77cb146219417e208c09cdc311d7d97cf Mon Sep 17 00:00:00 2001 From: Lalatendu Mohanty Date: Fri, 13 Mar 2026 06:56:07 -0400 Subject: [PATCH] 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 Signed-off-by: Lalatendu Mohanty --- src/fromager/sources.py | 61 +++++++++++++++++++++++++++++++++++++++++ tests/test_sources.py | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/src/fromager/sources.py b/src/fromager/sources.py index 65b097aa..d65ee9d4 100644 --- a/src/fromager/sources.py +++ b/src/fromager/sources.py @@ -681,6 +681,11 @@ def default_build_sdist( sdist_root_dir=sdist_root_dir, build_dir=build_dir, ) + if not build_dir.joinpath(".git").exists(): + ensure_git_archival( + version=version, + target_dir=build_dir, + ) # The format argument is specified based on # https://peps.python.org/pep-0517/#build-sdist. with tarfile.open(sdist_filename, "x:gz", format=tarfile.PAX_FORMAT) as sdist: @@ -762,6 +767,62 @@ def ensure_pkg_info( return had_pkg_info +# Template .git_archival.txt files contain "$Format:…$" placeholders that +# `git archive` expands into real values. If they survive unexpanded, +# setuptools-scm detects "$FORMAT" in the node field and returns no version +# (see setuptools_scm.git.archival_to_version). +_UNPROCESSED_ARCHIVAL_MARKER = "$Format:" + +# Dummy commit hash used when synthesizing .git_archival.txt without a +# real git repository. The value is never interpreted by setuptools-scm +# beyond checking that it is not an unprocessed $Format:…$ placeholder. +_DUMMY_NODE = "0" * 40 + +_GIT_ARCHIVAL_CONTENT = """\ +node: {node} +node-date: 1970-01-01T00:00:00+00:00 +describe-name: {version}-0-g{node} +""" + + +def ensure_git_archival( + *, + version: Version, + target_dir: pathlib.Path, +) -> bool: + """Ensure that sdist has a usable ``.git_archival.txt`` for setuptools-scm. + + When building from source archives without a ``.git`` directory, + setuptools-scm cannot determine the package version. A synthesized + ``.git_archival.txt`` provides the version through the ``describe-name`` + field so that setuptools-scm resolves it without requiring an environment + variable override. + + See https://setuptools-scm.readthedocs.io/en/latest/usage/#git-archives + + Returns True if a valid archival file was already present (no changes + made), False if a new file was written (file was missing or contained + unprocessed placeholders). + """ + archival_file = target_dir.joinpath(".git_archival.txt") + + if archival_file.is_file(): + content = archival_file.read_text() + if _UNPROCESSED_ARCHIVAL_MARKER not in content: + logger.debug("valid .git_archival.txt already present in %s", target_dir) + return True + logger.warning("replacing unprocessed .git_archival.txt in %s", target_dir) + + archival_file.write_text( + _GIT_ARCHIVAL_CONTENT.format( + node=_DUMMY_NODE, + version=str(version), + ) + ) + logger.info("created .git_archival.txt for version %s in %s", version, target_dir) + return False + + def validate_sdist_filename( req: Requirement, version: Version, diff --git a/tests/test_sources.py b/tests/test_sources.py index 4a6c35e7..57affbb2 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -275,3 +275,51 @@ def test_validate_sdist_file( else: with pytest.raises(ValueError): sources.validate_sdist_filename(req, version, sdist_file) + + +class TestEnsureGitArchival: + """Tests for ensure_git_archival().""" + + def test_creates_file_when_missing(self, tmp_path: pathlib.Path) -> None: + """Verify file is created with correct content when absent.""" + version = Version("1.2.3") + result = sources.ensure_git_archival(version=version, target_dir=tmp_path) + archival = tmp_path / ".git_archival.txt" + + assert result is False + assert archival.is_file() + content = archival.read_text() + assert "describe-name: 1.2.3-0-g" in content + assert "node: " in content + assert "$Format:" not in content + + def test_replaces_unprocessed_file(self, tmp_path: pathlib.Path) -> None: + """Verify unprocessed template file is replaced.""" + archival = tmp_path / ".git_archival.txt" + archival.write_text( + "node: $Format:%H$\n" + "node-date: $Format:%cI$\n" + "describe-name: $Format:%(describe:tags=true)$\n" + ) + version = Version("4.5.6") + result = sources.ensure_git_archival(version=version, target_dir=tmp_path) + + assert result is False + content = archival.read_text() + assert "describe-name: 4.5.6-0-g" in content + assert "$Format:" not in content + + def test_preserves_valid_file(self, tmp_path: pathlib.Path) -> None: + """Verify a valid archival file is left untouched.""" + archival = tmp_path / ".git_archival.txt" + original = ( + "node: abc123\n" + "node-date: 2025-01-01T00:00:00+00:00\n" + "describe-name: v1.0.0-0-gabc123\n" + ) + archival.write_text(original) + version = Version("9.9.9") + result = sources.ensure_git_archival(version=version, target_dir=tmp_path) + + assert result is True + assert archival.read_text() == original