Skip to content

Commit 96ffb9a

Browse files
feat(sources): generate .git_archival.txt for setuptools-scm builds
Synthesize .git_archival.txt with the resolved version for packages built from source archives without .git metadata, enabling setuptools-scm to resolve the version without environment variable overrides. Closes: #961 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Lalatendu Mohanty <lmohanty@redhat.com>
1 parent 5b3c27e commit 96ffb9a

File tree

2 files changed

+147
-0
lines changed

2 files changed

+147
-0
lines changed

src/fromager/sources.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,14 @@ def default_build_sdist(
690690
sdist_root_dir=sdist_root_dir,
691691
build_dir=build_dir,
692692
)
693+
has_git_metadata = build_dir.joinpath(".git").exists() or (
694+
sdist_root_dir != build_dir and sdist_root_dir.joinpath(".git").exists()
695+
)
696+
if not has_git_metadata:
697+
ensure_git_archival(
698+
version=version,
699+
target_dir=build_dir,
700+
)
693701
# The format argument is specified based on
694702
# https://peps.python.org/pep-0517/#build-sdist.
695703
with tarfile.open(sdist_filename, "x:gz", format=tarfile.PAX_FORMAT) as sdist:
@@ -771,6 +779,74 @@ def ensure_pkg_info(
771779
return had_pkg_info
772780

773781

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

tests/test_sources.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,3 +312,74 @@ 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_truncated_file(self, tmp_path: pathlib.Path) -> None:
365+
"""Verify a truncated file missing required fields is replaced."""
366+
archival = tmp_path / ".git_archival.txt"
367+
archival.write_text("node-date: 2025-01-01T00:00:00+00:00\n")
368+
version = Version("3.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: 3.0.0\n" in content
374+
375+
def test_replaces_file_with_empty_values(self, tmp_path: pathlib.Path) -> None:
376+
"""Verify a file with required fields but empty values is replaced."""
377+
archival = tmp_path / ".git_archival.txt"
378+
archival.write_text("node:\ndescribe-name:\n")
379+
version = Version("5.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: 5.0.0\n" in content
385+

0 commit comments

Comments
 (0)