From 2a25bd6050822a8f942f5c129466f46325d85c42 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 1 Aug 2025 15:17:27 +0200 Subject: [PATCH] expand documentation on git archival/gitattributes and add cli tools for creating them closes #987 --- CHANGELOG.md | 2 + docs/usage.md | 116 +++++++++++++++++++++++++-- src/setuptools_scm/_cli.py | 102 +++++++++++++++++++++++ testing/test_cli.py | 160 +++++++++++++++++++++++++++++++++++++ 4 files changed, 374 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca407920..52c86e28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ - fix #1099 use file modification times for dirty working directory timestamps instead of current time - fix #1059: add `SETUPTOOLS_SCM_PRETEND_METADATA` environment variable to override individual ScmVersion fields - add `scm` parameter support to `get_version()` function for nested SCM configuration +- fix #987: expand documentation on git archival files and add cli tools for good defaults + ### Changed - add `pip` to test optional dependencies for improved uv venv compatibility diff --git a/docs/usage.md b/docs/usage.md index d48a4252..efa922cb 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -376,15 +376,41 @@ accordingly. ### Git archives -Git archives are supported, but a few changes to your repository are required. +Git archives are supported, but require specific setup and understanding of how they work with package building. -Ensure the content of the following files: +#### Overview +When you create a `.git_archival.txt` file in your repository, it enables setuptools-scm to extract version information from git archives (e.g., GitHub's source downloads). However, this file contains template placeholders that must be expanded by `git archive` - they won't work when building directly from your working directory. + +#### Setting up git archival support + +You can generate a `.git_archival.txt` file using the setuptools-scm CLI: + +```commandline +# Generate a stable archival file (recommended for releases) +$ python -m setuptools_scm create-archival-file --stable + +# Generate a full archival file with all metadata (use with caution) +$ python -m setuptools_scm create-archival-file --full +``` + +Alternatively, you can create the file manually: + +**Stable version (recommended):** ```{ .text file=".git_archival.txt"} +node: $Format:%H$ +node-date: $Format:%cI$ +describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$ +``` +**Full version (with branch information - can cause instability):** +```{ .text file=".git_archival.txt"} +# WARNING: Including ref-names can make archive checksums unstable +# after commits are added post-release. Use only if describe-name is insufficient. node: $Format:%H$ node-date: $Format:%cI$ describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$ +ref-names: $Format:%D$ ``` Feel free to alter the `match` field in `describe-name` to match your project's @@ -402,14 +428,92 @@ tagging style. .git_archival.txt export-subst ``` -Finally, don't forget to commit the two files: +Finally, commit both files: ```commandline -$ git add .git_archival.txt .gitattributes && git commit -m "add export config" +$ git add .git_archival.txt .gitattributes && git commit -m "add git archive support" +``` + +#### Understanding the warnings + +If you see warnings like these when building your package: + ``` +UserWarning: git archive did not support describe output +UserWarning: unprocessed git archival found (no export subst applied) +``` + +This typically happens when: + +1. **Building from working directory**: You're running `python -m build` directly in your repository +2. **Sdist extraction**: A build tool extracts your sdist to build wheels, but the extracted directory isn't a git repository + +#### Recommended build workflows + +**For development builds:** +Exclude `.git_archival.txt` from your package to avoid warnings: + +```{ .text file="MANIFEST.in"} +# Exclude archival file from development builds +exclude .git_archival.txt +``` + +**For release builds from archives:** +Build from an actual git archive to ensure proper template expansion: + +```commandline +# Create archive from a specific tag/commit +$ git archive --output=../source_archive.tar v1.2.3 +$ cd .. +$ tar -xf source_archive.tar +$ cd extracted_directory/ +$ python -m build . +``` + +**For automated releases:** +Many CI systems and package repositories (like GitHub Actions) automatically handle this correctly when building from git archives. + +#### Integration with package managers + +**MANIFEST.in exclusions:** +```{ .text file="MANIFEST.in"} +# Exclude development files from packages +exclude .git_archival.txt +exclude .gitattributes +``` + + +```{ .text file=".gitattributes"} +# Archive configuration +.git_archival.txt export-subst +.gitignore export-ignore +``` + +#### Troubleshooting + +**Problem: "unprocessed git archival found" warnings** +- ✅ **Solution**: Add `exclude .git_archival.txt` to `MANIFEST.in` for development builds +- ✅ **Alternative**: Build from actual git archives for releases + +**Problem: "git archive did not support describe output" warnings** +- ℹ️ **Information**: This is expected when `.git_archival.txt` contains unexpanded templates +- ✅ **Solution**: Same as above - exclude file or build from git archives + +**Problem: Version detection fails in git archives** +- ✅ **Check**: Is `.gitattributes` configured with `export-subst`? +- ✅ **Check**: Are you building from a properly created git archive? +- ✅ **Check**: Does your git hosting provider support archive template expansion? + +!!! warning "Branch Names and Archive Stability" + + Including `ref-names: $Format:%D$` in your `.git_archival.txt` can make archive checksums change when new commits are added to branches referenced in the archive. This primarily affects GitHub's automatic source archives. Use the stable format (without `ref-names`) unless you specifically need branch information and understand the stability implications. +!!! note "Version Files" -Note that if you are creating a `_version.py` file, note that it should not -be kept in version control. It's strongly recommended to be put into gitignore. + If you are creating a `_version.py` file, it should not be kept in version control. Add it to `.gitignore`: + ``` + # Generated version file + src/mypackage/_version.py + ``` [git-archive-issue]: https://github.com/pypa/setuptools-scm/issues/806 diff --git a/src/setuptools_scm/_cli.py b/src/setuptools_scm/_cli.py index 3f33f83d..1f104f46 100644 --- a/src/setuptools_scm/_cli.py +++ b/src/setuptools_scm/_cli.py @@ -5,6 +5,7 @@ import os import sys +from pathlib import Path from typing import Any from setuptools_scm import Configuration @@ -106,6 +107,28 @@ def _get_cli_opts(args: list[str] | None) -> argparse.Namespace: # We avoid `metavar` to prevent printing repetitive information desc = "List information about the package, e.g. included files" sub.add_parser("ls", help=desc[0].lower() + desc[1:], description=desc) + + # Add create-archival-file subcommand + archival_desc = "Create .git_archival.txt file for git archive support" + archival_parser = sub.add_parser( + "create-archival-file", + help=archival_desc[0].lower() + archival_desc[1:], + description=archival_desc, + ) + archival_group = archival_parser.add_mutually_exclusive_group(required=True) + archival_group.add_argument( + "--stable", + action="store_true", + help="create stable archival file (recommended, no branch names)", + ) + archival_group.add_argument( + "--full", + action="store_true", + help="create full archival file with branch information (can cause instability)", + ) + archival_parser.add_argument( + "--force", action="store_true", help="overwrite existing .git_archival.txt file" + ) return parser.parse_args(args) @@ -116,6 +139,9 @@ def command(opts: argparse.Namespace, version: str, config: Configuration) -> in if opts.command == "ls": opts.query = ["files"] + if opts.command == "create-archival-file": + return _create_archival_file(opts, config) + if opts.query == []: opts.no_version = True sys.stderr.write("Available queries:\n\n") @@ -187,3 +213,79 @@ def _find_pyproject(parent: str) -> str: return os.path.abspath( "pyproject.toml" ) # use default name to trigger the default errors + + +def _create_archival_file(opts: argparse.Namespace, config: Configuration) -> int: + """Create .git_archival.txt file with appropriate content.""" + archival_path = Path(config.root, ".git_archival.txt") + + # Check if file exists and force flag + if archival_path.exists() and not opts.force: + print( + f"Error: {archival_path} already exists. Use --force to overwrite.", + file=sys.stderr, + ) + return 1 + + if opts.stable: + content = _get_stable_archival_content() + print("Creating stable .git_archival.txt (recommended for releases)") + elif opts.full: + content = _get_full_archival_content() + print("Creating full .git_archival.txt with branch information") + print("WARNING: This can cause archive checksums to be unstable!") + + try: + archival_path.write_text(content, encoding="utf-8") + print(f"Created: {archival_path}") + + gitattributes_path = Path(config.root, ".gitattributes") + needs_gitattributes = True + + if gitattributes_path.exists(): + # TODO: more nuanced check later + gitattributes_content = gitattributes_path.read_text("utf-8") + if ( + ".git_archival.txt" in gitattributes_content + and "export-subst" in gitattributes_content + ): + needs_gitattributes = False + + if needs_gitattributes: + print("\nNext steps:") + print("1. Add this line to .gitattributes:") + print(" .git_archival.txt export-subst") + print("2. Commit both files:") + print(" git add .git_archival.txt .gitattributes") + print(" git commit -m 'add git archive support'") + else: + print("\nNext step:") + print("Commit the archival file:") + print(" git add .git_archival.txt") + print(" git commit -m 'update git archival file'") + + return 0 + except OSError as e: + print(f"Error: Could not create {archival_path}: {e}", file=sys.stderr) + return 1 + + +def _get_stable_archival_content() -> str: + """Generate stable archival file content (no branch names).""" + return """\ +node: $Format:%H$ +node-date: $Format:%cI$ +describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$ +""" + + +def _get_full_archival_content() -> str: + """Generate full archival file content with branch information.""" + return """\ +# WARNING: Including ref-names can make archive checksums unstable +# after commits are added post-release. Use only if describe-name is insufficient. +node: $Format:%H$ +node-date: $Format:%cI$ +describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$ +ref-names: $Format:%D$ +""" diff --git a/testing/test_cli.py b/testing/test_cli.py index 050fe031..480793c5 100644 --- a/testing/test_cli.py +++ b/testing/test_cli.py @@ -80,3 +80,163 @@ def test_cli_force_version_files( assert version_file.exists() assert output[:5] in version_file.read_text("utf-8") + + +def test_cli_create_archival_file_stable( + wd: WorkDir, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test creating stable .git_archival.txt file.""" + wd.commit_testfile() + wd.write(PYPROJECT_TOML, PYPROJECT_SIMPLE) + monkeypatch.chdir(wd.cwd) + + archival_file = wd.cwd / ".git_archival.txt" + assert not archival_file.exists() + + # Test successful creation + result = main(["create-archival-file", "--stable"]) + assert result == 0 + assert archival_file.exists() + + content = archival_file.read_text("utf-8") + expected_lines = [ + "node: $Format:%H$", + "node-date: $Format:%cI$", + "describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$", + ] + for line in expected_lines: + assert line in content + + # Stable version should not contain ref-names + assert "ref-names" not in content + + +def test_cli_create_archival_file_full( + wd: WorkDir, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test creating full .git_archival.txt file with branch information.""" + wd.commit_testfile() + wd.write(PYPROJECT_TOML, PYPROJECT_SIMPLE) + monkeypatch.chdir(wd.cwd) + + archival_file = wd.cwd / ".git_archival.txt" + assert not archival_file.exists() + + # Test successful creation + result = main(["create-archival-file", "--full"]) + assert result == 0 + assert archival_file.exists() + + content = archival_file.read_text("utf-8") + expected_lines = [ + "node: $Format:%H$", + "node-date: $Format:%cI$", + "describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$", + "ref-names: $Format:%D$", + ] + for line in expected_lines: + assert line in content + + # Full version should contain warning comment + assert "WARNING" in content + assert "unstable" in content + + +def test_cli_create_archival_file_exists_no_force( + wd: WorkDir, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test that existing .git_archival.txt file prevents creation without --force.""" + wd.commit_testfile() + wd.write(PYPROJECT_TOML, PYPROJECT_SIMPLE) + monkeypatch.chdir(wd.cwd) + + archival_file = wd.cwd / ".git_archival.txt" + archival_file.write_text("existing content", encoding="utf-8") + + # Should fail without --force + result = main(["create-archival-file", "--stable"]) + assert result == 1 + + # Content should be unchanged + assert archival_file.read_text("utf-8") == "existing content" + + +def test_cli_create_archival_file_exists_with_force( + wd: WorkDir, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test that --force overwrites existing .git_archival.txt file.""" + wd.commit_testfile() + wd.write(PYPROJECT_TOML, PYPROJECT_SIMPLE) + monkeypatch.chdir(wd.cwd) + + archival_file = wd.cwd / ".git_archival.txt" + archival_file.write_text("existing content", encoding="utf-8") + + # Should succeed with --force + result = main(["create-archival-file", "--stable", "--force"]) + assert result == 0 + + # Content should be updated + content = archival_file.read_text("utf-8") + assert "existing content" not in content + assert "node: $Format:%H$" in content + + +def test_cli_create_archival_file_requires_stable_or_full( + wd: WorkDir, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test that create-archival-file requires either --stable or --full.""" + wd.commit_testfile() + wd.write(PYPROJECT_TOML, PYPROJECT_SIMPLE) + monkeypatch.chdir(wd.cwd) + + # Should fail without --stable or --full + with pytest.raises(SystemExit): + main(["create-archival-file"]) + + +def test_cli_create_archival_file_mutually_exclusive( + wd: WorkDir, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test that --stable and --full are mutually exclusive.""" + wd.commit_testfile() + wd.write(PYPROJECT_TOML, PYPROJECT_SIMPLE) + monkeypatch.chdir(wd.cwd) + + # Should fail with both --stable and --full + with pytest.raises(SystemExit): + main(["create-archival-file", "--stable", "--full"]) + + +def test_cli_create_archival_file_existing_gitattributes( + wd: WorkDir, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test behavior when .gitattributes already has export-subst configuration.""" + wd.commit_testfile() + wd.write(PYPROJECT_TOML, PYPROJECT_SIMPLE) + monkeypatch.chdir(wd.cwd) + + # Create .gitattributes with export-subst configuration + gitattributes_file = wd.cwd / ".gitattributes" + gitattributes_file.write_text(".git_archival.txt export-subst\n", encoding="utf-8") + + result = main(["create-archival-file", "--stable"]) + assert result == 0 + + archival_file = wd.cwd / ".git_archival.txt" + assert archival_file.exists() + + +def test_cli_create_archival_file_no_gitattributes( + wd: WorkDir, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test behavior when .gitattributes doesn't exist or lacks export-subst.""" + wd.commit_testfile() + wd.write(PYPROJECT_TOML, PYPROJECT_SIMPLE) + monkeypatch.chdir(wd.cwd) + + result = main(["create-archival-file", "--stable"]) + assert result == 0 + + archival_file = wd.cwd / ".git_archival.txt" + assert archival_file.exists()