|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Resolve the correct version tag for setuptools-scm. |
| 3 | +
|
| 4 | +Called by setuptools-scm via git_describe_command in pyproject.toml. |
| 5 | +Outputs either a bare tag (e.g., "v0.5.10") for exact-match commits, |
| 6 | +or a `git describe --long` string (e.g., "v0.5.10-2-gabcdef0") for |
| 7 | +untagged commits. Both formats are accepted by setuptools-scm. |
| 8 | +
|
| 9 | +This two-step approach avoids a strverscmp bug where |
| 10 | +`git tag --sort=-version:refname` sorts v0.5.10rc0 above v0.5.10, |
| 11 | +which would cause CI to build the wrong version. |
| 12 | +
|
| 13 | +Strategy: |
| 14 | +1. If the current commit has an exact version tag, use it directly. |
| 15 | + This handles CI release builds (both stable and rc). |
| 16 | +2. Otherwise, find the highest version tag across all branches |
| 17 | + and describe relative to it. This handles local dev installs |
| 18 | + from main where release tags only exist on release branches. |
| 19 | +""" |
| 20 | + |
| 21 | +import re |
| 22 | +import subprocess |
| 23 | +import sys |
| 24 | + |
| 25 | + |
| 26 | +def parse_version_tuple(tag: str) -> tuple: |
| 27 | + """Parse a version tag into a sortable tuple using PEP 440 ordering. |
| 28 | +
|
| 29 | + Returns a tuple where: |
| 30 | + - Base version parts are integers: (major, minor, patch) |
| 31 | + - Pre-release suffix gets a lower sort key than bare version: |
| 32 | + v0.5.10rc0 -> (0, 5, 10, 0, 0) # pre-release |
| 33 | + v0.5.10 -> (0, 5, 10, 1, 0) # stable (sorts higher) |
| 34 | + v0.5.10post1 -> (0, 5, 10, 2, 1) # post-release (sorts highest) |
| 35 | + """ |
| 36 | + v = tag.lstrip("v") |
| 37 | + # Split base version from suffix |
| 38 | + m = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:(rc|post)(\d+))?$", v) |
| 39 | + if not m: |
| 40 | + return (0, 0, 0, 0, 0) |
| 41 | + major, minor, patch = int(m.group(1)), int(m.group(2)), int(m.group(3)) |
| 42 | + suffix_type = m.group(4) |
| 43 | + suffix_num = int(m.group(5)) if m.group(5) else 0 |
| 44 | + if suffix_type == "rc": |
| 45 | + return (major, minor, patch, 0, suffix_num) |
| 46 | + elif suffix_type == "post": |
| 47 | + return (major, minor, patch, 2, suffix_num) |
| 48 | + else: |
| 49 | + return (major, minor, patch, 1, 0) |
| 50 | + |
| 51 | + |
| 52 | +def run_git(*args: str, allow_failure: bool = False) -> str: |
| 53 | + """Run a git command and return stripped stdout. |
| 54 | +
|
| 55 | + Args: |
| 56 | + allow_failure: If True, return "" on non-zero exit (expected for |
| 57 | + commands like --exact-match that legitimately fail). |
| 58 | + If False, log stderr on failure before returning "". |
| 59 | + """ |
| 60 | + try: |
| 61 | + result = subprocess.run( |
| 62 | + ["git", *args], |
| 63 | + capture_output=True, |
| 64 | + text=True, |
| 65 | + ) |
| 66 | + except OSError as exc: |
| 67 | + print(f"ERROR: Failed to run 'git {' '.join(args)}': {exc}", file=sys.stderr) |
| 68 | + sys.exit(1) |
| 69 | + |
| 70 | + if result.returncode != 0: |
| 71 | + if not allow_failure: |
| 72 | + stderr_msg = result.stderr.strip() |
| 73 | + print( |
| 74 | + f"WARNING: git {' '.join(args)} failed " |
| 75 | + f"(exit {result.returncode}): {stderr_msg}", |
| 76 | + file=sys.stderr, |
| 77 | + ) |
| 78 | + return "" |
| 79 | + |
| 80 | + return result.stdout.strip() |
| 81 | + |
| 82 | + |
| 83 | +def get_exact_version_tag() -> str: |
| 84 | + """Return the version tag name if HEAD has an exact version tag, or empty string.""" |
| 85 | + return run_git( |
| 86 | + "describe", "--tags", "--exact-match", "--match", "v*", allow_failure=True |
| 87 | + ) |
| 88 | + |
| 89 | + |
| 90 | +def get_latest_version_tag_describe() -> str: |
| 91 | + """Find the highest version tag and build a describe string relative to it. |
| 92 | +
|
| 93 | + Uses PEP 440 version ordering so that stable releases sort above |
| 94 | + pre-release tags (e.g., v0.5.10 > v0.5.10rc0). |
| 95 | +
|
| 96 | + The highest tag may live on a release branch and not be a direct |
| 97 | + ancestor of HEAD (e.g., main diverged before the release tag was |
| 98 | + created). In that case, we compute the commit distance from the |
| 99 | + merge-base and build the describe string manually. |
| 100 | + """ |
| 101 | + tag = get_latest_version_tag() |
| 102 | + if not tag: |
| 103 | + print("WARNING: No version tags (v*.*.*) found in repo", file=sys.stderr) |
| 104 | + return "" |
| 105 | + |
| 106 | + # Fast path: tag is an ancestor of HEAD, git describe works directly |
| 107 | + result = run_git( |
| 108 | + "describe", "--tags", "--long", "--match", tag, "HEAD", allow_failure=True |
| 109 | + ) |
| 110 | + if result: |
| 111 | + return result |
| 112 | + |
| 113 | + # Tag is not an ancestor (e.g., release branch diverged from main). |
| 114 | + # Build describe string manually: {tag}-{distance}-g{hash} |
| 115 | + merge_base = run_git("merge-base", tag, "HEAD", allow_failure=True) |
| 116 | + if not merge_base: |
| 117 | + print( |
| 118 | + f"WARNING: No common ancestor between {tag} and HEAD. " |
| 119 | + f"Is this a shallow clone? Try: git fetch --unshallow --tags", |
| 120 | + file=sys.stderr, |
| 121 | + ) |
| 122 | + return "" |
| 123 | + distance = run_git("rev-list", "--count", f"{merge_base}..HEAD") |
| 124 | + short_hash = run_git("rev-parse", "--short", "HEAD") |
| 125 | + return f"{tag}-{distance}-g{short_hash}" |
| 126 | + |
| 127 | + |
| 128 | +def get_version_describe() -> str: |
| 129 | + """Main entry point: resolve the version describe string.""" |
| 130 | + # Prefer exact match — correct for both stable and pre-release tags |
| 131 | + exact = get_exact_version_tag() |
| 132 | + if exact: |
| 133 | + return exact |
| 134 | + |
| 135 | + # Fallback for untagged commits (e.g., dev install from main) |
| 136 | + return get_latest_version_tag_describe() |
| 137 | + |
| 138 | + |
| 139 | +def get_latest_version_tag() -> str: |
| 140 | + """Return just the highest version tag (PEP 440 ordered), or empty string.""" |
| 141 | + tags_raw = run_git("tag", "--list", "v*.*.*") |
| 142 | + if not tags_raw: |
| 143 | + return "" |
| 144 | + tag_list = sorted(tags_raw.splitlines(), key=parse_version_tuple, reverse=True) |
| 145 | + return tag_list[0] if tag_list else "" |
| 146 | + |
| 147 | + |
| 148 | +def main() -> None: |
| 149 | + # --tag-only: print just the latest version tag (for CI scripts) |
| 150 | + tag_only = "--tag-only" in sys.argv |
| 151 | + if tag_only: |
| 152 | + result = get_latest_version_tag() |
| 153 | + else: |
| 154 | + result = get_version_describe() |
| 155 | + if not result: |
| 156 | + print( |
| 157 | + "ERROR: Could not determine version from git tags.\n" |
| 158 | + "Possible causes:\n" |
| 159 | + " - No version tags (v*.*.*) exist: run 'git fetch --tags'\n" |
| 160 | + " - Shallow clone without tags: run 'git fetch --unshallow --tags'\n" |
| 161 | + " - Git safe.directory issue: run 'git config --global --add safe.directory <repo>'\n" |
| 162 | + " - Not inside a git repository\n" |
| 163 | + "setuptools-scm will fall back to version 0.0.0.dev0", |
| 164 | + file=sys.stderr, |
| 165 | + ) |
| 166 | + sys.exit(1) |
| 167 | + print(result) |
| 168 | + |
| 169 | + |
| 170 | +if __name__ == "__main__": |
| 171 | + main() |
0 commit comments