Skip to content

Commit 173746e

Browse files
feat(sources): generate .git_archival.txt for setuptools-scm builds
When building from source archives without a .git directory, setuptools-scm cannot determine the package version. A synthesized .git_archival.txt with the describe-name field lets setuptools-scm resolve the version without requiring an environment variable override. Only replaces existing .git_archival.txt files — does not create new ones. Packages that do not ship an archival file handle versioning through other means (e.g. fallback_version) and injecting a synthetic file can break packages with a custom tag_regex. Simplifications based on setuptools-scm internals: - Only describe-name is required (node/node-date are ignored) - Uses %(describe as the unprocessed marker (matching setuptools-scm) Co-Authored-By: Claude <claude@anthropic.com> Closes: #961
1 parent aec9c9c commit 173746e

2 files changed

Lines changed: 160 additions & 0 deletions

File tree

src/fromager/sources.py

Lines changed: 81 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,79 @@ 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. setuptools-scm detects the string
784+
# "%(describe" in the content to identify unprocessed archival files
785+
# (see setuptools_scm.git.archival_to_version).
786+
_UNPROCESSED_ARCHIVAL_MARKER = "%(describe"
787+
788+
# Fields that must be present for setuptools-scm to resolve a version.
789+
# setuptools-scm only uses ``describe-name``; other fields like ``node``
790+
# and ``node-date`` are ignored and need not be synthesized.
791+
_REQUIRED_ARCHIVAL_FIELDS = {"describe-name"}
792+
793+
_GIT_ARCHIVAL_CONTENT = """\
794+
describe-name: {version}
795+
"""
796+
797+
798+
def ensure_git_archival(
799+
*,
800+
version: Version,
801+
target_dir: pathlib.Path,
802+
) -> bool | None:
803+
"""Ensure that sdist has a usable ``.git_archival.txt`` for setuptools-scm.
804+
805+
When building from source archives without a ``.git`` directory,
806+
setuptools-scm cannot determine the package version. A synthesized
807+
``.git_archival.txt`` provides the version through the ``describe-name``
808+
field so that setuptools-scm resolves it without requiring an environment
809+
variable override.
810+
811+
See https://setuptools-scm.readthedocs.io/en/latest/usage/#git-archives
812+
813+
Returns True if a valid archival file was already present (no changes
814+
made), False if a new file was written (the existing file contained
815+
unprocessed placeholders), or None if no archival file existed (no
816+
changes made).
817+
818+
Only replaces existing files — does not create new ones. Packages
819+
that do not ship a ``.git_archival.txt`` in their sdist handle
820+
versioning through other means (e.g. ``fallback_version``,
821+
``_version_module.py``) and injecting a synthetic file can break
822+
packages with a custom ``tag_regex``.
823+
"""
824+
archival_file = target_dir.joinpath(".git_archival.txt")
825+
826+
if not archival_file.is_file():
827+
logger.debug("no .git_archival.txt in %s, skipping", target_dir)
828+
return None
829+
830+
content = archival_file.read_text()
831+
parsed_fields: dict[str, str] = {}
832+
for line in content.splitlines():
833+
if ":" not in line:
834+
continue
835+
key, _, value = line.partition(":")
836+
parsed_fields[key.strip()] = value.strip()
837+
if (
838+
_UNPROCESSED_ARCHIVAL_MARKER not in content
839+
and _REQUIRED_ARCHIVAL_FIELDS <= set(parsed_fields)
840+
and all(parsed_fields.get(f) for f in _REQUIRED_ARCHIVAL_FIELDS)
841+
):
842+
logger.debug("valid .git_archival.txt already present in %s", target_dir)
843+
return True
844+
logger.warning("replacing invalid .git_archival.txt in %s", target_dir)
845+
846+
archival_file.write_text(
847+
_GIT_ARCHIVAL_CONTENT.format(
848+
version=str(version),
849+
)
850+
)
851+
logger.info("created .git_archival.txt for version %s in %s", version, target_dir)
852+
return False
853+
854+
774855
def validate_sdist_filename(
775856
req: Requirement,
776857
version: Version,

tests/test_sources.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,3 +312,82 @@ 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_no_file_exists(self, tmp_path: pathlib.Path) -> None:
321+
"""Verify no file is created when none existed before."""
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 None
327+
assert not archival.is_file()
328+
329+
def test_replaces_unprocessed_file(self, tmp_path: pathlib.Path) -> None:
330+
"""Verify unprocessed template file is replaced."""
331+
archival = tmp_path / ".git_archival.txt"
332+
archival.write_text(
333+
"node: $Format:%H$\n"
334+
"node-date: $Format:%cI$\n"
335+
"describe-name: $Format:%(describe:tags=true)$\n"
336+
)
337+
version = Version("4.5.6")
338+
result = sources.ensure_git_archival(version=version, target_dir=tmp_path)
339+
340+
assert result is False
341+
content = archival.read_text()
342+
assert "describe-name: 4.5.6\n" in content
343+
assert "%(describe" not in content
344+
345+
def test_preserves_valid_file(self, tmp_path: pathlib.Path) -> None:
346+
"""Verify a valid archival file is left untouched."""
347+
archival = tmp_path / ".git_archival.txt"
348+
original = (
349+
"node: abc123\n"
350+
"node-date: 2025-01-01T00:00:00+00:00\n"
351+
"describe-name: v1.0.0-0-gabc123\n"
352+
)
353+
archival.write_text(original)
354+
version = Version("9.9.9")
355+
result = sources.ensure_git_archival(version=version, target_dir=tmp_path)
356+
357+
assert result is True
358+
assert archival.read_text() == original
359+
360+
def test_preserves_valid_file_describe_name_only(
361+
self, tmp_path: pathlib.Path
362+
) -> None:
363+
"""Verify a file with only describe-name is valid."""
364+
archival = tmp_path / ".git_archival.txt"
365+
original = "describe-name: 2.0.0\n"
366+
archival.write_text(original)
367+
version = Version("9.9.9")
368+
result = sources.ensure_git_archival(version=version, target_dir=tmp_path)
369+
370+
assert result is True
371+
assert archival.read_text() == original
372+
373+
def test_replaces_truncated_file(self, tmp_path: pathlib.Path) -> None:
374+
"""Verify a truncated file missing required fields is replaced."""
375+
archival = tmp_path / ".git_archival.txt"
376+
archival.write_text("node-date: 2025-01-01T00:00:00+00:00\n")
377+
version = Version("3.0.0")
378+
result = sources.ensure_git_archival(version=version, target_dir=tmp_path)
379+
380+
assert result is False
381+
content = archival.read_text()
382+
assert "describe-name: 3.0.0\n" in content
383+
384+
def test_replaces_file_with_empty_values(self, tmp_path: pathlib.Path) -> None:
385+
"""Verify a file with required fields but empty values is replaced."""
386+
archival = tmp_path / ".git_archival.txt"
387+
archival.write_text("describe-name:\n")
388+
version = Version("5.0.0")
389+
result = sources.ensure_git_archival(version=version, target_dir=tmp_path)
390+
391+
assert result is False
392+
content = archival.read_text()
393+
assert "describe-name: 5.0.0\n" in content

0 commit comments

Comments
 (0)