Skip to content

Commit 16a06e4

Browse files
feat(sources): generate .git_archival.txt for setuptools-scm builds
Ensure sdist builds without .git metadata have a valid .git_archival.txt for setuptools-scm version resolution. Replaces existing invalid files unconditionally. Creates new files only when generate_git_archival is enabled in package settings, since bare versions break custom tag_regex. Co-Authored-By: Claude <claude@anthropic.com> Closes: #961 Signed-off-by: Lalatendu Mohanty <lmohanty@redhat.com>
1 parent aec9c9c commit 16a06e4

File tree

6 files changed

+278
-1
lines changed

6 files changed

+278
-1
lines changed

src/fromager/packagesettings/_models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ class BuildOptions(pydantic.BaseModel):
147147
exclusive_build: bool = False
148148
"""If true, this package must be built on its own (not in parallel with other packages). Default: False."""
149149

150+
generate_git_archival: bool = False
151+
"""If true, create a ``.git_archival.txt`` when missing for setuptools-scm version resolution. Default: False."""
152+
150153

151154
class ProjectOverride(pydantic.BaseModel):
152155
"""Override pyproject.toml settings

src/fromager/packagesettings/_pbi.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,10 @@ def project_override(self) -> ProjectOverride:
409409
def exclusive_build(self) -> bool:
410410
return self._ps.build_options.exclusive_build
411411

412+
@property
413+
def generate_git_archival(self) -> bool:
414+
return self._ps.build_options.generate_git_archival
415+
412416
@property
413417
def variants(self) -> Mapping[Variant, VariantInfo]:
414418
"""Get the variant configuration for the current package"""

src/fromager/sources.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,15 @@ def prepare_source(
532532
f"do not know how to unpack {prepare_source_details}, expected 1 or 2 members"
533533
)
534534
write_build_meta(source_root_dir.parent, req, source_filename, version)
535+
# Ensure .git_archival.txt is valid before the build backend is
536+
# imported — setuptools-scm resolves the version during
537+
# get_requires_for_build_wheel().
538+
ensure_git_archival(
539+
ctx=ctx,
540+
req=req,
541+
sdist_root_dir=source_root_dir,
542+
version=version,
543+
)
535544
if source_root_dir is not None:
536545
logger.info(f"prepared source for {req} at {source_root_dir}")
537546
return source_root_dir
@@ -771,6 +780,93 @@ def ensure_pkg_info(
771780
return had_pkg_info
772781

773782

783+
# Template .git_archival.txt files contain "$Format:…$" placeholders that
784+
# `git archive` expands into real values. setuptools-scm detects the string
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 _REQUIRED_ARCHIVAL_FIELDS <= set(fields) and all(
801+
fields.get(f) for f in _REQUIRED_ARCHIVAL_FIELDS
802+
)
803+
804+
805+
def _has_git_metadata(sdist_root_dir: pathlib.Path, build_dir: pathlib.Path) -> bool:
806+
"""Check whether ``.git`` exists in sdist root or build directory."""
807+
if build_dir.joinpath(".git").exists():
808+
return True
809+
return sdist_root_dir != build_dir and sdist_root_dir.joinpath(".git").exists()
810+
811+
812+
def _write_git_archival(archival_file: pathlib.Path, version: Version) -> None:
813+
"""Write a ``.git_archival.txt`` with the given version."""
814+
archival_file.write_text(_GIT_ARCHIVAL_CONTENT.format(version=version))
815+
816+
817+
def ensure_git_archival(
818+
*,
819+
ctx: context.WorkContext,
820+
req: Requirement,
821+
sdist_root_dir: pathlib.Path,
822+
version: Version,
823+
) -> bool | None:
824+
"""Ensure ``.git_archival.txt`` is valid for setuptools-scm version resolution.
825+
826+
Behaviour:
827+
828+
* Skips packages with ``.git`` metadata (git clones need no fix).
829+
* Replaces existing files that are unprocessed or missing required fields.
830+
* Creates a new file **only** when ``generate_git_archival`` is enabled
831+
in package settings (bare versions break custom ``tag_regex``).
832+
833+
Returns ``True`` (valid file present), ``False`` (created/replaced),
834+
or ``None`` (no action taken).
835+
"""
836+
pbi = ctx.package_build_info(req)
837+
build_dir = pbi.build_dir(sdist_root_dir)
838+
839+
if _has_git_metadata(sdist_root_dir, build_dir):
840+
logger.debug("git metadata found, skipping .git_archival.txt for %s", req.name)
841+
return True
842+
843+
if not build_dir.is_dir():
844+
logger.debug("build directory %s does not exist, skipping", build_dir)
845+
return None
846+
847+
archival_file = build_dir / ".git_archival.txt"
848+
849+
# Existing file: validate and replace if invalid
850+
if archival_file.is_file():
851+
if _is_valid_git_archival(archival_file.read_text()):
852+
logger.debug("valid .git_archival.txt already present in %s", build_dir)
853+
return True
854+
logger.info("replacing invalid .git_archival.txt in %s", build_dir)
855+
_write_git_archival(archival_file, version)
856+
return False
857+
858+
# No file: create only when config opt-in is enabled
859+
if pbi.generate_git_archival:
860+
logger.info(
861+
"creating .git_archival.txt for %s (generate_git_archival enabled)",
862+
req.name,
863+
)
864+
_write_git_archival(archival_file, version)
865+
return False
866+
867+
return None
868+
869+
774870
def validate_sdist_filename(
775871
req: Requirement,
776872
version: Version,

tests/test_bootstrapper.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ def test_build_from_source_returns_dataclass(tmp_context: WorkContext) -> None:
261261
bt = bootstrapper.Bootstrapper(tmp_context)
262262

263263
mock_sdist_root = tmp_context.work_dir / "package-1.0.0" / "package-1.0.0"
264-
mock_sdist_root.parent.mkdir(parents=True, exist_ok=True)
264+
mock_sdist_root.mkdir(parents=True, exist_ok=True)
265265
mock_source_file = tmp_context.work_dir / "package-1.0.0.tar.gz"
266266
mock_wheel = tmp_context.work_dir / "package-1.0.0-py3-none-any.whl"
267267
expected_unpack_dir = mock_sdist_root.parent

tests/test_packagesettings.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"cpu_cores_per_job": 4,
4242
"memory_per_job_gb": 4.0,
4343
"exclusive_build": False,
44+
"generate_git_archival": False,
4445
},
4546
"changelog": {
4647
Version("1.0.1"): ["fixed bug"],
@@ -119,6 +120,7 @@
119120
"cpu_cores_per_job": 1,
120121
"memory_per_job_gb": 1.0,
121122
"exclusive_build": False,
123+
"generate_git_archival": False,
122124
},
123125
"changelog": {},
124126
"config_settings": {},
@@ -156,6 +158,7 @@
156158
"cpu_cores_per_job": 1,
157159
"memory_per_job_gb": 1.0,
158160
"exclusive_build": False,
161+
"generate_git_archival": False,
159162
},
160163
"changelog": {
161164
Version("1.0.1"): ["onboard"],

tests/test_sources.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,3 +312,174 @@ 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+
@pytest.fixture()
321+
def mock_ctx(self, tmp_path: pathlib.Path) -> Mock:
322+
"""Create a mock context whose build_dir returns sdist_root_dir."""
323+
ctx = Mock(spec=context.WorkContext)
324+
mock_pbi = Mock()
325+
mock_pbi.build_dir.side_effect = lambda d: d
326+
mock_pbi.generate_git_archival = False
327+
ctx.package_build_info.return_value = mock_pbi
328+
return ctx
329+
330+
@pytest.fixture()
331+
def req(self) -> Requirement:
332+
"""Create a test requirement."""
333+
return Requirement("test-package")
334+
335+
def test_skips_when_no_file_exists(
336+
self, tmp_path: pathlib.Path, mock_ctx: Mock, req: Requirement
337+
) -> None:
338+
"""Verify no file is created when none existed before."""
339+
version = Version("1.2.3")
340+
result = sources.ensure_git_archival(
341+
ctx=mock_ctx, req=req, sdist_root_dir=tmp_path, version=version
342+
)
343+
archival = tmp_path / ".git_archival.txt"
344+
345+
assert result is None
346+
assert not archival.is_file()
347+
348+
def test_replaces_unprocessed_file(
349+
self, tmp_path: pathlib.Path, mock_ctx: Mock, req: Requirement
350+
) -> None:
351+
"""Verify unprocessed template file is replaced."""
352+
archival = tmp_path / ".git_archival.txt"
353+
archival.write_text(
354+
"node: $Format:%H$\n"
355+
"node-date: $Format:%cI$\n"
356+
"describe-name: $Format:%(describe:tags=true)$\n"
357+
)
358+
version = Version("4.5.6")
359+
result = sources.ensure_git_archival(
360+
ctx=mock_ctx, req=req, sdist_root_dir=tmp_path, version=version
361+
)
362+
363+
assert result is False
364+
content = archival.read_text()
365+
assert "describe-name: 4.5.6\n" in content
366+
assert "%(describe" not in content
367+
368+
def test_preserves_valid_file(
369+
self, tmp_path: pathlib.Path, mock_ctx: Mock, req: Requirement
370+
) -> None:
371+
"""Verify a valid archival file is left untouched."""
372+
archival = tmp_path / ".git_archival.txt"
373+
original = (
374+
"node: abc123\n"
375+
"node-date: 2025-01-01T00:00:00+00:00\n"
376+
"describe-name: v1.0.0-0-gabc123\n"
377+
)
378+
archival.write_text(original)
379+
version = Version("9.9.9")
380+
result = sources.ensure_git_archival(
381+
ctx=mock_ctx, req=req, sdist_root_dir=tmp_path, version=version
382+
)
383+
384+
assert result is True
385+
assert archival.read_text() == original
386+
387+
def test_preserves_valid_file_describe_name_only(
388+
self, tmp_path: pathlib.Path, mock_ctx: Mock, req: Requirement
389+
) -> None:
390+
"""Verify a file with only describe-name is valid."""
391+
archival = tmp_path / ".git_archival.txt"
392+
original = "describe-name: 2.0.0\n"
393+
archival.write_text(original)
394+
version = Version("9.9.9")
395+
result = sources.ensure_git_archival(
396+
ctx=mock_ctx, req=req, sdist_root_dir=tmp_path, version=version
397+
)
398+
399+
assert result is True
400+
assert archival.read_text() == original
401+
402+
def test_replaces_truncated_file(
403+
self, tmp_path: pathlib.Path, mock_ctx: Mock, req: Requirement
404+
) -> None:
405+
"""Verify a truncated file missing required fields is replaced."""
406+
archival = tmp_path / ".git_archival.txt"
407+
archival.write_text("node-date: 2025-01-01T00:00:00+00:00\n")
408+
version = Version("3.0.0")
409+
result = sources.ensure_git_archival(
410+
ctx=mock_ctx, req=req, sdist_root_dir=tmp_path, version=version
411+
)
412+
413+
assert result is False
414+
content = archival.read_text()
415+
assert "describe-name: 3.0.0\n" in content
416+
417+
def test_replaces_file_with_empty_values(
418+
self, tmp_path: pathlib.Path, mock_ctx: Mock, req: Requirement
419+
) -> None:
420+
"""Verify a file with required fields but empty values is replaced."""
421+
archival = tmp_path / ".git_archival.txt"
422+
archival.write_text("describe-name:\n")
423+
version = Version("5.0.0")
424+
result = sources.ensure_git_archival(
425+
ctx=mock_ctx, req=req, sdist_root_dir=tmp_path, version=version
426+
)
427+
428+
assert result is False
429+
content = archival.read_text()
430+
assert "describe-name: 5.0.0\n" in content
431+
432+
def test_skips_when_build_dir_missing(
433+
self, tmp_path: pathlib.Path, mock_ctx: Mock, req: Requirement
434+
) -> None:
435+
"""Verify no error when build directory does not exist."""
436+
nonexistent = tmp_path / "no-such-dir"
437+
version = Version("1.0.0")
438+
result = sources.ensure_git_archival(
439+
ctx=mock_ctx, req=req, sdist_root_dir=nonexistent, version=version
440+
)
441+
442+
assert result is None
443+
assert not nonexistent.exists()
444+
445+
def test_skips_when_git_dir_exists(
446+
self, tmp_path: pathlib.Path, mock_ctx: Mock, req: Requirement
447+
) -> None:
448+
"""Verify no file is created when .git directory exists."""
449+
(tmp_path / ".git").mkdir()
450+
version = Version("1.0.0")
451+
result = sources.ensure_git_archival(
452+
ctx=mock_ctx, req=req, sdist_root_dir=tmp_path, version=version
453+
)
454+
455+
assert result is True
456+
assert not (tmp_path / ".git_archival.txt").exists()
457+
458+
def test_creates_file_when_config_enabled(
459+
self, tmp_path: pathlib.Path, mock_ctx: Mock, req: Requirement
460+
) -> None:
461+
"""Verify file is created when generate_git_archival is enabled."""
462+
mock_ctx.package_build_info.return_value.generate_git_archival = True
463+
version = Version("1.2.3")
464+
result = sources.ensure_git_archival(
465+
ctx=mock_ctx, req=req, sdist_root_dir=tmp_path, version=version
466+
)
467+
archival = tmp_path / ".git_archival.txt"
468+
469+
assert result is False
470+
assert archival.is_file()
471+
content = archival.read_text()
472+
assert "describe-name: 1.2.3\n" in content
473+
474+
def test_skips_creation_when_config_disabled(
475+
self, tmp_path: pathlib.Path, mock_ctx: Mock, req: Requirement
476+
) -> None:
477+
"""Verify no file is created when generate_git_archival is disabled."""
478+
version = Version("1.2.3")
479+
result = sources.ensure_git_archival(
480+
ctx=mock_ctx, req=req, sdist_root_dir=tmp_path, version=version
481+
)
482+
archival = tmp_path / ".git_archival.txt"
483+
484+
assert result is None
485+
assert not archival.is_file()

0 commit comments

Comments
 (0)