From d24f4e09a006304ed65437d9cf3e31b96e0227ca Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Wed, 2 Sep 2026 20:56:37 +0000 Subject: [PATCH 01/10] Harmonize versions and fix PyPI publishing --- .github/workflows/image-publish.yml | 4 +- .github/workflows/publish.yaml | 2 + .github/workflows/test_publish.yaml | 2 + Makefile | 6 - RELEASE.md | 48 +++-- build/build_and_push.sh | 4 +- monitoring/Dockerfile | 2 +- monitoring/Makefile | 2 +- monitoring/build.sh | 55 +++++- monitoring/build_dev.sh | 26 --- pyproject.toml | 6 +- scripts/git/commit.sh | 14 -- scripts/git/get_version.py | 269 ++++++++++++++++++++++++++++ scripts/git/upstream_owner.sh | 30 ---- scripts/git/version.sh | 68 ------- scripts/tag.sh | 27 --- uv.lock | 232 ++++++++++++------------ 17 files changed, 477 insertions(+), 320 deletions(-) delete mode 100755 monitoring/build_dev.sh delete mode 100755 scripts/git/commit.sh create mode 100755 scripts/git/get_version.py delete mode 100755 scripts/git/upstream_owner.sh delete mode 100755 scripts/git/version.sh delete mode 100755 scripts/tag.sh diff --git a/.github/workflows/image-publish.yml b/.github/workflows/image-publish.yml index a7e24fd07e..35e06aba73 100644 --- a/.github/workflows/image-publish.yml +++ b/.github/workflows/image-publish.yml @@ -10,8 +10,8 @@ on: tags: # To modify to trigger the job for fork's releases # Note: GitHub's filter pattern capabilities are limited[1], so this - # pattern matches more often than it should. A more correct regex would - # be the one found in scripts/tag.sh. + # pattern matches more often than it should. A more correct validation would + # be one compatible with scripts/git/get_version.py. # [1] https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet - "interuss/monitoring/v[0-9]+.[0-9]+.[0-9]+-?*" permissions: diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 0f6a53a082..0df17de5eb 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -12,6 +12,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: setup-python uses: actions/setup-python@v7 with: diff --git a/.github/workflows/test_publish.yaml b/.github/workflows/test_publish.yaml index f2cfb0b624..89c4736db1 100644 --- a/.github/workflows/test_publish.yaml +++ b/.github/workflows/test_publish.yaml @@ -12,6 +12,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: setup-python uses: actions/setup-python@v7 with: diff --git a/Makefile b/Makefile index 5a3e6bc95a..076f63f755 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,5 @@ USER_GROUP := $(shell id -u):$(shell id -g) -UPSTREAM_OWNER := $(shell scripts/git/upstream_owner.sh) -COMMIT := $(shell scripts/git/commit.sh) - ifeq ($(OS),Windows_NT) detected_OS := Windows else @@ -56,9 +53,6 @@ image: image-dev: cd monitoring && make image-dev -tag: - scripts/tag.sh $(UPSTREAM_OWNER)/monitoring/v$(VERSION) - .PHONY: start-locally start-locally: build/dev/run_locally.sh up --wait diff --git a/RELEASE.md b/RELEASE.md index 9b820dba54..10e614026c 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,11 +2,31 @@ ## Background -Releases of monitoring are based on git tags in the format `interuss/monitoring/v[0-9]+\.[0-9]+\.[0-9]+`, optionally suffixed with `-[0-9A-Za-z-.]+`. This tag form follows the pattern `[owner]/[component]/[semantic version]`; see [semantic version](https://semver.org) for more information. +Releases of `monitoring` utilize Git tags structured precisely as `interuss/monitoring/v[X].[Y].[Z]`, optionally accompanied by strict pre-release/candidate identifiers adhering to **PEP 440 Pre-Release Conventions**. + +This formatting conforms to the InterUSS Component Registry pattern: `[owner]/[component]/[semantic version]`. (See [Semantic Versioning](https://semver.org) and [PEP 440 Version Identification](https://peps.python.org/pep-0440/)). Keeping track of breaking changes and migration instructions is done through the [NEXT_RELEASE_NOTES.md](NEXT_RELEASE_NOTES.md) file, which is updated as features are added or modified and serves as a basis for release notes. -When either an executable or image is built from a `git` checkout of the source, the most recent tag is used as the version tag. If no such tag exists, the build system defaults to v0.0.0-[commit_hash]. If commits have been added to the tag, the commit hash is appended to the version. If the workspace is not clean, `-dirty` is appended to it. The version tag is computed by [`scripts/git/version.sh`](scripts/git/version.sh). +### PEP 440 Versioning, Validation, & PyPI Releases +For compatibility with PyPI (Python Package Index) and Docker registry distributions, versions must follow the conventions below: +* **Official Releases (`vX.Y.Z`)**: Output purely as canonical, unmodified Semantic Versions (`X.Y.Z`). These artifacts are fully eligible for publishing to PyPI. +* **Release Candidates (`vX.Y.Z-rc[N]`)**: **Strict Pre-Release Identifiers**. + * Pre-release tags **must** utilize the strictly lowercase, hyphenated `-rc[N]` suffix (e.g., `v0.31.0-rc1`). + * Build tooling normalizes these delimiters into PEP 440-compliant pre-release strings (`X.Y.Zrc[N]`) for PyPI compatibility. + * Note: Pre-releases containing uppercase identifiers (e.g., `-RC1`), space delimiters, alphabetic metadata, or non-numeric suffixes (e.g., `-alpha`, `-1.2`) are prevented to avoid accidental malformed PyPI publication. +* **Development & Branch Builds (`+`)**: + * Any image or PyPI artifact generated from a development branch, Pull Request, or commit other than an explicitly tagged release/pre-release boundary automatically appends PEP 440 Local Version Segments utilizing the `+` delimiter (e.g., `0.31.0+gd56bb4d`). + * Because PyPI rejects package uploads bearing PEP 440 `+` local-version metadata, this provides a safeguard preventing non-release/development builds from accidentally polluting the public package registry. + +## PyPI Package Distribution (`interuss_monitoring`) +As part of broader interoperating improvements for the InterUSS Python ecosystem: +* **Distribution Identity**: The `monitoring` Python codebase is published to PyPI under the canonical ecosystem package name **`interuss_monitoring`**. +* **Interim Import Namespace (Phase 1 Phase-in)**: + * Consumers install the package via `pip install interuss_monitoring`. + * In the current structural phase (Phase 1), internal code modules and external users importing from the PyPI package interact with the Python Import Namespace via **`import monitoring.`**. + * *Warning for Interim Consumers*: Users must ensure their active Python virtual environment does not contain a conflicting top-level `monitoring/` directory from alternative third-party packages to prevent Python import-shadowing and runtime `ModuleNotFoundError` conflicts. +* **Future Transition (Phase 2)**: The repository is systematically migrating towards a fully isolated `src/interuss_monitoring` layout and explicit `import interuss_monitoring.` namespace, perfectly mirrors the architectural patterns established by `implicitdict` and `uas_standards`. ## Release procedure @@ -15,21 +35,19 @@ Releasing a monitoring version requires the following steps: - `X` is the major release number - `Y` is the minor release number - `Z` is the patch number - - (optionally) `W` is the prerelease - - `X.Y.Z[-W]` is according to [semantic versioning](https://semver.org) - - Note that valid examples of this form include `0.1.0`, `20.0.0`, `0.5.0-rc`, `0.5.0-1.2` + - (optionally) `W` is the pre-release candidate (**must strictly be formatted as `rcN`**, e.g., `rc1`, `rc2`) - `X`, `Y`, and `Z` should be selected according to the nature of the changes included in the release - See [NEXT_RELEASE_NOTES.md](./NEXT_RELEASE_NOTES.md) for the minimum version increment, and look for any changes that might suggest a more substantial category of release than the intended next version currently tracked in NEXT_RELEASE_NOTES -- Create a release tag via *one* of the following methods: - - On the InterUSS fork, click Releases -> Draft a new release - - For **Tag**, enter `interuss/monitoring/vX.Y.Z` (see below for format) - - For **Release title**, enter `vX.Y.Z` (corresponding to the tag) - - For Release notes, click **Generate release notes**, then add any content from [NEXT_RELEASE_NOTES.md](./NEXT_RELEASE_NOTES.md) to the top of the notes - - Create a release tag on main using `make tag VERSION=X.Y.Z[-W]`. The script will push a tag (`release tag`) to the remote origin under the form of `[owner]/monitoring/vX.Y.Z[-W]`, where - - `[owner]` is either the organization name or the username of the origin remote url - - Official releases are `interuss/monitoring/v#.#.#`. - - Add the pending release notes from [NEXT_RELEASE_NOTES.md](NEXT_RELEASE_NOTES.md) to the release notes. -- The github workflow ([.github/workflows/image-publish.yml](.github/workflows/image-publish.yml)) is triggered for every new release tag. On the canonical interuss fork, it builds and publishes the monitoring image to the [official docker registry](https://hub.docker.com/repository/docker/interuss/monitoring). +- Create and publish a release in GitHub: + - On the repository, navigate to **Releases** -> **Draft a new release**. + - For **Choose a tag**, enter `interuss/monitoring/vX.Y.Z` (or `interuss/monitoring/vX.Y.Z-rcN` for pre-releases) and click **Create new tag**. + - Ensure the target branch is `main`. + - For **Release title**, enter `vX.Y.Z` (or `vX.Y.Z-rcN`). + - Click **Generate release notes**, then copy and prepend any pending content from [NEXT_RELEASE_NOTES.md](./NEXT_RELEASE_NOTES.md) to the top of the release notes. + - Click **Publish release**. +- The GitHub workflows are triggered by the published release/tag: + - The image publishing workflow ([.github/workflows/image-publish.yml](.github/workflows/image-publish.yml)) is triggered for every new release tag. On the canonical interuss fork, it builds and publishes the monitoring image to the [official docker registry](https://hub.docker.com/repository/docker/interuss/monitoring). + - The package publishing workflow ([.github/workflows/publish.yaml](.github/workflows/publish.yaml)) is triggered when a release is published. It builds the `interuss_monitoring` Python distribution packages (`.tar.gz` and `.whl`) and publishes them to PyPI. - After completing the release, open a PR to remove the pending release notes from [NEXT_RELEASE_NOTES.md](NEXT_RELEASE_NOTES.md) and update the anticipated next release version number assuming just a bug fix (e.g., v0.18.3 -> v0.18.4) - When a PR with a change larger than the current anticipated next release version number in [NEXT_RELEASE_NOTES.md](./NEXT_RELEASE_NOTES.md) is made, it should ideally also adjust the anticipated next release version number in NEXT_RELEASE_NOTES - Example 1: if the most recent release was v0.18.3, NEXT_RELEASE_NOTES indicated v0.18.4, and a PR made a change larger than a bug fix, that PR should change the number in NEXT_RELEASE_NOTES to v0.19.0 diff --git a/build/build_and_push.sh b/build/build_and_push.sh index 9a40f2b988..1450c37344 100755 --- a/build/build_and_push.sh +++ b/build/build_and_push.sh @@ -27,7 +27,7 @@ else fi cd "${BASEDIR}" -VERSION=$(./scripts/git/version.sh monitoring) +VERSION=$(./scripts/git/get_version.py --format imagetag) LATEST_TAG="latest" if [[ -z "${DOCKER_URL}" ]]; then @@ -39,7 +39,7 @@ else TAG="${DOCKER_URL}/monitoring:${VERSION}" echo "Building image ${TAG}" - ./monitoring/build.sh "${TAG}" + ./monitoring/build.sh normal "${TAG}" echo "Pushing docker image ${TAG}..." docker image push "${TAG}" diff --git a/monitoring/Dockerfile b/monitoring/Dockerfile index 382881383e..b9604a46a7 100644 --- a/monitoring/Dockerfile +++ b/monitoring/Dockerfile @@ -105,7 +105,7 @@ ARG version ARG commit_hash ENV MONITORING_VERSION=$version ENV GIT_COMMIT_HASH=$commit_hash -ENV SETUPTOOLS_SCM_PRETEND_VERSION_FOR_MONITORING=$version +ENV SETUPTOOLS_SCM_PRETEND_VERSION_FOR_INTERUSS_MONITORING=$version # Add core content from repo ADD ./interfaces /app/interfaces diff --git a/monitoring/Makefile b/monitoring/Makefile index 2c2030d8b7..ca81bd495f 100644 --- a/monitoring/Makefile +++ b/monitoring/Makefile @@ -15,7 +15,7 @@ image: ../uv.lock ../pyproject.toml $(shell find . -type f ! -path "*/output/*" ./build.sh image-dev: ../uv.lock ../pyproject.toml $(shell find . -type f ! -path "*/output/*" ! -path "*/.*" ! -path "*/__pycache__/*" ! -name image ! -name image-dev ! -name "*.pyc") $(shell find ../interfaces -type f) - ./build_dev.sh + ./build.sh dev .PHONY: test test: diff --git a/monitoring/build.sh b/monitoring/build.sh index b20baa25ff..04372f211c 100755 --- a/monitoring/build.sh +++ b/monitoring/build.sh @@ -12,14 +12,51 @@ else fi cd "${BASEDIR}/.." || exit 1 -TAG="${1:-interuss/monitoring}" +if [[ "$1" == "dev" ]]; then + IMAGE_VARIANT="dev" + TAG="${2:-interuss/monitoring}" +elif [[ "$1" == "normal" ]]; then + IMAGE_VARIANT="normal" + TAG="${2:-interuss/monitoring}" +else + IMAGE_VARIANT="normal" + TAG="${1:-interuss/monitoring}" +fi +VERSION_STR=$(scripts/git/get_version.py --format pep440) +COMMIT_HASH=$(scripts/git/get_version.py --format commit) + +if [[ "${IMAGE_VARIANT}" == "normal" ]]; then + DOCKER_TAG="${TAG}" +else + DOCKER_TAG="${TAG}-dev" +fi -docker image build \ - -f monitoring/Dockerfile \ - -t "${TAG}" \ - --build-arg version="$(scripts/git/version.sh monitoring --long)" \ - --build-arg commit_hash="$(git rev-parse HEAD)" \ - . \ - || exit 1 +echo "=== InterUSS docker ${IMAGE_VARIANT} image build ===" +echo "Version: ${VERSION_STR}" +echo "Docker tag: ${DOCKER_TAG}" +echo "Commit hash: ${COMMIT_HASH}" +echo "======================================" -echo "File created by monitoring/build.sh to keep track of the latest normal image build run date time." > monitoring/image +if [[ "${IMAGE_VARIANT}" == "normal" ]]; then + docker image build \ + -f monitoring/Dockerfile \ + -t "${DOCKER_TAG}" \ + --build-arg version="${VERSION_STR}" \ + --build-arg commit_hash="${COMMIT_HASH}" \ + . \ + || exit 1 + + echo "File created by monitoring/build.sh to keep track of the latest normal image build run date time." > monitoring/image + +else + docker image build \ + -f monitoring/Dockerfile \ + -t "${DOCKER_TAG}" \ + --build-arg BASE_STAGE=dev-dependencies \ + --build-arg version="${VERSION_STR}" \ + --build-arg commit_hash="${COMMIT_HASH}" \ + . \ + || exit 1 + + echo "File created by monitoring/build.sh to keep track of the latest dev image build run date time." > monitoring/image-dev +fi diff --git a/monitoring/build_dev.sh b/monitoring/build_dev.sh deleted file mode 100755 index ccca5a6da6..0000000000 --- a/monitoring/build_dev.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash - -set -eo pipefail - -# Find and change to repo root directory -OS=$(uname) -if [[ "$OS" == "Darwin" ]]; then - # OSX uses BSD readlink - BASEDIR="$(dirname "$0")" -else - BASEDIR=$(readlink -e "$(dirname "$0")") -fi -cd "${BASEDIR}/.." || exit 1 - -TAG="${1:-interuss/monitoring}" - -docker image build \ - -f monitoring/Dockerfile \ - -t "${TAG}-dev" \ - --build-arg BASE_STAGE=dev-dependencies \ - --build-arg version="$(scripts/git/version.sh monitoring --long)" \ - --build-arg commit_hash="$(git rev-parse HEAD)" \ - . \ - || exit 1 - -echo "File created by monitoring/build_dev.sh to keep track of the latest dev image build run date time." > monitoring/image-dev diff --git a/pyproject.toml b/pyproject.toml index c4b3c75187..820ca71c99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "monitoring" +name = "interuss_monitoring" dynamic = ["version"] authors = [ { name="InterUSS Platform", email="tsc@lists.interussplatform.org" }, @@ -129,5 +129,5 @@ namespaces = true [tool.setuptools_scm] write_to = "monitoring/_version.py" -tag_regex = "^interuss/monitoring/v(?P\\d+\\.\\d+\\.\\d+)" - +# Strictly anchor to the repo-level prefix, 3-part numeric PEP440/SemVer, and optionally PEP440 lowercase '-rcN' suffixes. +tag_regex = "^interuss/monitoring/v(?P\\d+\\.\\d+\\.\\d+(?:-rc\\d+)?)$" diff --git a/scripts/git/commit.sh b/scripts/git/commit.sh deleted file mode 100755 index de3af8b644..0000000000 --- a/scripts/git/commit.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env sh - -set -e - -COMMIT=$(git rev-parse --short HEAD) - -if test -n "$(git status -s)"; then - echo "${COMMIT}-dirty" -elif test -n "$(git cherry 2> /dev/null)"; then - echo "${COMMIT}-localcommit" -#TODO: Handle the case where the current branch does not exist remotely -else - echo "${COMMIT}" -fi diff --git a/scripts/git/get_version.py b/scripts/git/get_version.py new file mode 100755 index 0000000000..007a8ca63d --- /dev/null +++ b/scripts/git/get_version.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +""" +scripts/git/get_version.py +InterUSS monitoring version representation utility. + +This CLI combines and standardizes all repository Git inspection, PEP 440 version derivation, +and upstream organization discovery into a single Standard-Library-only tool. +""" + +import argparse +import os +import re +import subprocess +import sys + +# Base repository root determination relative to the scripts/git directory. +BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) + + +def run_git_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProcess: + """Executes a Git command rooted in the repository workspace.""" + return subprocess.run( + ["git"] + args, + cwd=BASE_DIR, + capture_output=True, + text=True, + check=check, + ) + + +def get_upstream_owner() -> str: + """ + Determines the organization/owner of the upstream repository. + Checks 'origin', 'interuss', tracking upstream branch, or defaults to 'unknown'. + """ + backup_repo = "https://github.com/unknown/_" + try: + upstream_branch = run_git_cmd( + ["rev-parse", "--abbrev-ref", "@{upstream}"], check=False + ) + if upstream_branch.returncode == 0 and upstream_branch.stdout.strip(): + backup_repo = f"https://github.com/{upstream_branch.stdout.strip()}" + except Exception: + pass + + upstream_repo = "" + for remote in ["origin", "interuss"]: + res = run_git_cmd(["remote", "get-url", remote], check=False) + if res.returncode == 0 and res.stdout.strip(): + upstream_repo = res.stdout.strip() + break + + if not upstream_repo: + upstream_repo = backup_repo + + # Normalize SSH/HTTPS URL formats to extract owner: + # 1. git@github.com:interuss/monitoring.git + # 2. git@github.com/interuss/monitoring.git + # 3. https://github.com/interuss/monitoring.git + # 4. ssh://git@github.com/interuss/monitoring.git + normalized = upstream_repo.replace(":", "/") + if "github.com/" in normalized: + after_gh = normalized.split("github.com/")[-1] + owner = after_gh.split("/")[0] + if owner and owner != "unknown": + return owner + + # Fallback pattern extraction if not github.com explicitly + parts = [ + p for p in re.split(r"[/:\\]", upstream_repo) if p and not p.endswith(".git") + ] + if len(parts) >= 2: + return parts[-2] + + return "unknown" + + +def get_commit_hash(with_status: bool = True) -> str: + """ + Returns the current short commit hash, optionally suffixed with '-dirty' or '-localcommit'. + """ + res = run_git_cmd(["rev-parse", "--short", "HEAD"]) + commit = res.stdout.strip() + + if not with_status: + return commit + + status_res = run_git_cmd(["status", "--porcelain"], check=False) + if status_res.stdout.strip(): + return f"{commit}-dirty" + + cherry_res = run_git_cmd(["cherry"], check=False) + if cherry_res.returncode == 0 and cherry_res.stdout.strip(): + return f"{commit}-localcommit" + + return commit + + +def get_git_tag_metadata(namespace: str, component: str) -> tuple[str, bool, str, bool]: + """ + Interrogates Git for component tags, exact boundary matches, commit hash, and workspace state. + """ + tag_match_pattern = f"{namespace}/{component}/*" + + # 1. Resolve Baseline Tag (--abbrev=0 isolates tag boundary without distance markers) + baseline_res = run_git_cmd( + ["describe", "--tags", "--abbrev=0", f"--match={tag_match_pattern}"], + check=False, + ) + if baseline_res.returncode == 0 and baseline_res.stdout.strip(): + baseline_tag = baseline_res.stdout.strip() + else: + baseline_tag = f"{namespace}/{component}/v0.0.0" + + # 2. Determine Exact-Match Tag Boundary + exact_res = run_git_cmd( + ["describe", "--tags", f"--match={tag_match_pattern}", "--exact-match"], + check=False, + ) + is_exact_tag_boundary = exact_res.returncode == 0 + + # 3. Retrieve Commit-Hash & Workspace Dirtiness + hash_res = run_git_cmd(["rev-parse", "--short", "HEAD"]) + commit_hash = hash_res.stdout.strip() + + status_res = run_git_cmd(["status", "--porcelain"], check=False) + is_dirty = bool(status_res.stdout.strip()) + + return baseline_tag, is_exact_tag_boundary, commit_hash, is_dirty + + +def derive_pep440_version(namespace: str, component: str) -> str: + """ + Validates Git tag and metadata against strict InterUSS SemVer and Pre-Release conventions, + generating a canonical PEP 440 version string. + """ + baseline_tag, is_exact_tag_boundary, commit_hash, is_dirty = get_git_tag_metadata( + namespace, component + ) + + strict_tag_regex = re.compile( + rf"^{re.escape(namespace)}/{re.escape(component)}/v(?P\d+\.\d+\.\d+)(?P-rc\d+)?$" + ) + malformed_prerelease_regex = re.compile( + rf"^{re.escape(namespace)}/{re.escape(component)}/v\d+\.\d+\.\d+-(.*)$" + ) + + malformed_match = malformed_prerelease_regex.match(baseline_tag) + strict_match = strict_tag_regex.match(baseline_tag) + + if malformed_match and not strict_match: + invalid_suffix = malformed_match.group(1) + raise ValueError( + f"Strict Validation Failure: Tag '{baseline_tag}' contains a non-conforming pre-release " + f"or release-candidate identifier ('-{invalid_suffix}'). InterUSS pre-release tags " + f"must strictly utilize the lowercase, hyphen-prefixed '-rc[N]' convention (e.g., '-rc1'). " + "Case-insensitivity, arbitrary alpha-segments, or alternative delimiters are prohibited." + ) + + if not strict_match: + raise ValueError( + f"Strict Validation Failure: Tag '{baseline_tag}' violates InterUSS repository SemVer conventions. " + f"Expected Pattern: '{namespace}/{component}/vX.Y.Z[-rcN]'." + ) + + semver = strict_match.group("semver") + rc_segment = strict_match.group("rc_segment") + + # Map pre-release: '0.31.0' + '-rc2' -> '0.31.0rc2' + pep440_base = semver + if rc_segment: + pep440_base = f"{semver}{rc_segment.lstrip('-')}" + + # If this is not an exact match on the release tag, or if workspace is dirty, append local version + metadata_segments = [] + if not is_exact_tag_boundary: + metadata_segments.append(f"g{commit_hash.lstrip('g')}") + if is_dirty: + metadata_segments.append("dirty") + + if metadata_segments: + return f"{pep440_base}+{'.'.join(metadata_segments)}" + + return pep440_base + + +def get_git_version(namespace: str, component: str, long_format: bool = False) -> str: + """ + Derives the version string conforming to the legacy version.sh format. + """ + tag_match_pattern = f"{namespace}/{component}/*" + describe_res = run_git_cmd( + ["describe", "--abbrev=1", "--tags", f"--match={tag_match_pattern}"], + check=False, + ) + commit = run_git_cmd(["rev-parse", "--short", "HEAD"]).stdout.strip() + status_res = run_git_cmd(["status", "--porcelain"], check=False) + dirty_suffix = "-dirty" if status_res.stdout.strip() else "" + + if describe_res.returncode != 0 or not describe_res.stdout.strip(): + last_version = f"v0.0.0-{commit}" + else: + full_tag = describe_res.stdout.strip() + last_version = full_tag.split("/")[-1] + if "-" in last_version: + # Commits added on top of tag + base_part = last_version.split("-")[0] + last_version = f"{base_part}-{commit}" + + version_str = f"{last_version}{dirty_suffix}" + if long_format: + return f"{namespace}/{component}/{version_str}" + return version_str + + +def main() -> None: + parser = argparse.ArgumentParser( + description="InterUSS monitoring version representation utility." + ) + parser.add_argument( + "--format", + choices=[ + "pep440", + "imagetag", + "owner", + "commit", + ], + default="pep440", + help=( + "Explicit output format.\n" + " pep440: Canonical PEP440 version (e.g., '0.31.0', '0.31.0rc1', '0.31.0+gd56bb4d.dirty'); fails for malformed pre-releases (-RC, -1.2, etc.).\n" + " imagetag: docker image tag version (e.g., 'v0.31.0', 'v0.31.0-d56bb4d417-dirty').\n" + " owner: Repository organization name (e.g., 'interuss', 'Orbitalize').\n" + " commit: Current commit hash with status suffix (e.g., 'd56bb4d-dirty', 'd56bb4d-localcommit')." + ), + ) + + args = parser.parse_args() + + if args.format == "owner": + print(get_upstream_owner()) + sys.exit(0) + + elif args.format == "commit": + print(get_commit_hash(with_status=True)) + sys.exit(0) + + component = "monitoring" + owner = get_upstream_owner() + + if args.format == "imagetag": + print(get_git_version(owner, component, long_format=False)) + sys.exit(0) + + elif args.format == "pep440": + try: + pep440_version = derive_pep440_version(owner, component) + print(pep440_version) + sys.exit(0) + except ValueError as e: + print(f"[InterUSS Version Validation ERROR] {e}", file=sys.stderr) + sys.exit(1) + + else: + raise ValueError(f"Invalid requested format '{args.format}'") + + +if __name__ == "__main__": + main() diff --git a/scripts/git/upstream_owner.sh b/scripts/git/upstream_owner.sh deleted file mode 100755 index 50ca15f52d..0000000000 --- a/scripts/git/upstream_owner.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash - -set -eo pipefail - -# This script attempts to print the organization of the upstream repository. - -# The following strategies will be used to determine the organization name: -# 1. If a remote named `origin` exists, the organization name will be extracted from the -# remote URL assuming the format below. -# 2. If a remote named `interuss` exists, the organization name will be extracted from the -# remote URL assuming the format below. -# 3. If the upstream of the current branch exists, the organization name will be set to the -# upstream repo name. -# 4. Otherwise, the default organization name of "unknown" will be printed. - -# The expected URL formats for remote URLs are: -# 1. git@github.com:interuss/monitoring.git -# 2. git@github.com/interuss/monitoring.git -# 3. https://github.com/interuss/monitoring.git - -# Determine what remote this branch is tracking, in case `origin` and `interuss` don't exist -BACKUP_REPO="https://github.com/$(git rev-parse --abbrev-ref @\{upstream\} 2> /dev/null || echo unknown/_)" - -UPSTREAM_REPO=$(git remote get-url origin 2> /dev/null || git remote get-url interuss 2> /dev/null || echo "$BACKUP_REPO") -# Replace `:` by `/` to handle git@github.com:interuss/monitoring.git remote reference. -UPSTREAM_REPO=${UPSTREAM_REPO//:/\/} -# Remove hostname part -UPSTREAM_OWNER=$(dirname "${UPSTREAM_REPO#*github.com/*}") - -echo "$UPSTREAM_OWNER" diff --git a/scripts/git/version.sh b/scripts/git/version.sh deleted file mode 100755 index 68dbbbc221..0000000000 --- a/scripts/git/version.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash - -set -eo pipefail - -set -o xtrace - -# This script prints the current version of a component in the repository based on the tags -# of the upstream repository (remote origin) matching the following convention: -# owner/component/version. Examples of values: -# - owner: interuss (automatically extracted from the remote origin url) -# - component: rid, scd, aux, uss_qualifier -# - version: v3.0.1[-hash][-dirty] -# - [-hash] (example: -8a493ef8 ) is added when commits have been added to the latest version tagged. -# - [-dirty] (example: -dirty) when the workspace is not clean. -# Only versions without [-hash] and without [-dirty] shall be released. - -if [[ $# == 0 ]]; then - echo "Usage: $0 [--long]" - echo "Print the component's version number. (ie v0.0.1)" - echo "[--long]: Print the component's version using the long format including the upstream owner (ie interuss/scd/v0.0.1)." - exit 1 -fi - -COMPONENT=${1} - -RELEASE_FORMAT=false -if [[ $2 == "--long" ]]; then - RELEASE_FORMAT=true -fi - -# Set working directory -cd "$(dirname "$0")" || exit 1 - -UPSTREAM_ORG=$(./upstream_owner.sh) - -# Look for the last tag of the component -LAST_VERSION_TAG=$(git describe --abbrev=1 --tags --match="${UPSTREAM_ORG}/${COMPONENT}/*" 2> /dev/null) -#echo "LAST_VERSION_TAG: $LAST_VERSION_TAG" - -# Store in LAST_VERSION the version of the tag (ie v0.0.1) -LAST_VERSION=${LAST_VERSION_TAG##*/} -#echo "LAST_VERSION: $LAST_VERSION" - -# Current commit -COMMIT=$(git rev-parse --short HEAD) - -# If no version was found, use default v0.0.0. -if [[ -z "$LAST_VERSION" ]]; then - LAST_VERSION="v0.0.0-$COMMIT" -# Check if there are some commits on top of the tag by checking if an abbrev part is present. -elif [[ "$LAST_VERSION" == *"-"* ]]; then - # Remove abbrev part - LAST_VERSION=${LAST_VERSION%%-*} - # Append the commit hash - LAST_VERSION=${LAST_VERSION}-${COMMIT} -fi - -# Set the dirty flag if the workspace is not clean. -DIRTY="" -if test -n "$(git status -s)"; then - DIRTY="-dirty" -fi - -if [[ "$RELEASE_FORMAT" == "true" ]]; then - echo "${UPSTREAM_ORG}"/"${COMPONENT}"/"${LAST_VERSION}""${DIRTY}" -else - echo "${LAST_VERSION}""${DIRTY}" -fi diff --git a/scripts/tag.sh b/scripts/tag.sh deleted file mode 100755 index 84a151f91b..0000000000 --- a/scripts/tag.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env sh - -# We only enable -o pipefail after having verified that -# the command line argument satisfies format requirements. -# Semantic versioning regex (suffixed below) from: -# https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string -tag_regex='^[^/]+/[^/]+/v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$' -tag=$(echo "$1" | grep -E "${tag_regex}") - -set -e - -if test -z "${tag}"; then - echo "requested tag \"${1}\" does not match expected tag format [owner]/[component]/[semantic version] using the pattern ${tag_regex}" && false -fi - -branch=$(git rev-parse --abbrev-ref HEAD) - -if test "${branch}" != "main"; then - echo "releases are only supported on main branch (currently on ${branch})" && false -fi - -if test -n "$(git status -s)"; then - echo "releases are only supported in a clean git workspace" && false -fi - -git tag -a "${tag}" -git push origin "${tag}" diff --git a/uv.lock b/uv.lock index 6d432cc736..f94cfaa29e 100644 --- a/uv.lock +++ b/uv.lock @@ -1006,6 +1006,122 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "interuss-monitoring" +source = { editable = "." } +dependencies = [ + { name = "aiohttp" }, + { name = "arrow" }, + { name = "asteval" }, + { name = "bc-jsonpath-ng" }, + { name = "cryptography" }, + { name = "deprecation" }, + { name = "faker" }, + { name = "flask" }, + { name = "flask-login" }, + { name = "geojson" }, + { name = "gevent" }, + { name = "google-auth" }, + { name = "graphviz" }, + { name = "gunicorn" }, + { name = "implicitdict" }, + { name = "jsonnet" }, + { name = "jsonschema" }, + { name = "jwcrypto" }, + { name = "kubernetes" }, + { name = "locust" }, + { name = "loguru" }, + { name = "lxml" }, + { name = "marko" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "oauthlib" }, + { name = "pem" }, + { name = "pip-tools" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pvlib" }, + { name = "pyjwt" }, + { name = "pykml" }, + { name = "pyopenssl" }, + { name = "pyproj" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "s2sphere" }, + { name = "scipy" }, + { name = "shapely" }, + { name = "structlog" }, + { name = "termcolor" }, + { name = "testcontainers" }, + { name = "uas-standards" }, + { name = "uuid6" }, +] + +[package.dev-dependencies] +dev = [ + { name = "basedpyright" }, + { name = "pytest" }, + { name = "pytest-mock" }, + { name = "ruff" }, + { name = "types-lxml" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.14.3" }, + { name = "arrow", specifier = ">=1.4.0" }, + { name = "asteval", specifier = ">=1.0.10" }, + { name = "bc-jsonpath-ng", specifier = ">=1.6.1" }, + { name = "cryptography", specifier = ">=50.0.1" }, + { name = "deprecation", specifier = ">=2.1.0" }, + { name = "faker", specifier = ">=40.37.0" }, + { name = "flask", specifier = ">=3.1.3" }, + { name = "flask-login", specifier = ">=0.6.3" }, + { name = "geojson", specifier = ">=3.3.0" }, + { name = "gevent", specifier = ">=26.8.0" }, + { name = "google-auth", specifier = ">=2.57.0" }, + { name = "graphviz", specifier = ">=0.21" }, + { name = "gunicorn", specifier = ">=26.2.0" }, + { name = "implicitdict", specifier = ">=4.1.0" }, + { name = "jsonnet", specifier = ">=0.22.0" }, + { name = "jsonschema", specifier = ">=4.26.0" }, + { name = "jwcrypto", specifier = ">=1.5.8" }, + { name = "kubernetes", specifier = ">=36.0.3" }, + { name = "locust", specifier = ">=2.46.4" }, + { name = "loguru", specifier = ">=0.7.3" }, + { name = "lxml", specifier = ">=6.1.2" }, + { name = "marko", specifier = ">=2.2.4" }, + { name = "matplotlib", specifier = ">=3.11.1" }, + { name = "numpy", specifier = ">=2.5.2" }, + { name = "oauthlib", specifier = ">=3.3.1" }, + { name = "pem", specifier = ">=23.1.0" }, + { name = "pip-tools", specifier = ">=7.6.1" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" }, + { name = "pvlib", specifier = ">=0.15.2" }, + { name = "pyjwt", specifier = ">=2.13.0" }, + { name = "pykml", specifier = ">=0.2.0" }, + { name = "pyopenssl", specifier = ">=26.4.0" }, + { name = "pyproj", specifier = ">=3.7.2" }, + { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "requests", specifier = ">=2.34.2" }, + { name = "s2sphere", specifier = ">=0.2.5" }, + { name = "scipy", specifier = ">=1.18.1" }, + { name = "shapely", specifier = ">=2.1.2" }, + { name = "structlog", specifier = ">=26.1.0" }, + { name = "termcolor", specifier = ">=3.3.0" }, + { name = "testcontainers", specifier = ">=4.15.0" }, + { name = "uas-standards", specifier = ">=4.4.0" }, + { name = "uuid6", specifier = ">=2025.0.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "basedpyright", specifier = ">=1.39.10" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-mock", specifier = ">=3.15.1" }, + { name = "ruff", specifier = ">=0.16.4" }, + { name = "types-lxml", specifier = ">=2026.2.16" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -1410,122 +1526,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/d4/97c19b77e0a6e3b48581185bb65088f431cd20186076cc0f650a1757ea46/matplotlib-3.11.1-cp314-cp314t-win_arm64.whl", hash = "sha256:54d47b8ae8b579633a3902ca5b4ad6c1e132a5626d64447b2e22a66394e79987", size = 9213472, upload-time = "2026-07-18T03:39:37.141Z" }, ] -[[package]] -name = "monitoring" -source = { editable = "." } -dependencies = [ - { name = "aiohttp" }, - { name = "arrow" }, - { name = "asteval" }, - { name = "bc-jsonpath-ng" }, - { name = "cryptography" }, - { name = "deprecation" }, - { name = "faker" }, - { name = "flask" }, - { name = "flask-login" }, - { name = "geojson" }, - { name = "gevent" }, - { name = "google-auth" }, - { name = "graphviz" }, - { name = "gunicorn" }, - { name = "implicitdict" }, - { name = "jsonnet" }, - { name = "jsonschema" }, - { name = "jwcrypto" }, - { name = "kubernetes" }, - { name = "locust" }, - { name = "loguru" }, - { name = "lxml" }, - { name = "marko" }, - { name = "matplotlib" }, - { name = "numpy" }, - { name = "oauthlib" }, - { name = "pem" }, - { name = "pip-tools" }, - { name = "psycopg", extra = ["binary"] }, - { name = "pvlib" }, - { name = "pyjwt" }, - { name = "pykml" }, - { name = "pyopenssl" }, - { name = "pyproj" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "s2sphere" }, - { name = "scipy" }, - { name = "shapely" }, - { name = "structlog" }, - { name = "termcolor" }, - { name = "testcontainers" }, - { name = "uas-standards" }, - { name = "uuid6" }, -] - -[package.dev-dependencies] -dev = [ - { name = "basedpyright" }, - { name = "pytest" }, - { name = "pytest-mock" }, - { name = "ruff" }, - { name = "types-lxml" }, -] - -[package.metadata] -requires-dist = [ - { name = "aiohttp", specifier = ">=3.14.3" }, - { name = "arrow", specifier = ">=1.4.0" }, - { name = "asteval", specifier = ">=1.0.10" }, - { name = "bc-jsonpath-ng", specifier = ">=1.6.1" }, - { name = "cryptography", specifier = ">=50.0.1" }, - { name = "deprecation", specifier = ">=2.1.0" }, - { name = "faker", specifier = ">=40.37.0" }, - { name = "flask", specifier = ">=3.1.3" }, - { name = "flask-login", specifier = ">=0.6.3" }, - { name = "geojson", specifier = ">=3.3.0" }, - { name = "gevent", specifier = ">=26.8.0" }, - { name = "google-auth", specifier = ">=2.57.0" }, - { name = "graphviz", specifier = ">=0.21" }, - { name = "gunicorn", specifier = ">=26.2.0" }, - { name = "implicitdict", specifier = ">=4.1.0" }, - { name = "jsonnet", specifier = ">=0.22.0" }, - { name = "jsonschema", specifier = ">=4.26.0" }, - { name = "jwcrypto", specifier = ">=1.5.8" }, - { name = "kubernetes", specifier = ">=36.0.3" }, - { name = "locust", specifier = ">=2.46.4" }, - { name = "loguru", specifier = ">=0.7.3" }, - { name = "lxml", specifier = ">=6.1.2" }, - { name = "marko", specifier = ">=2.2.4" }, - { name = "matplotlib", specifier = ">=3.11.1" }, - { name = "numpy", specifier = ">=2.5.2" }, - { name = "oauthlib", specifier = ">=3.3.1" }, - { name = "pem", specifier = ">=23.1.0" }, - { name = "pip-tools", specifier = ">=7.6.1" }, - { name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" }, - { name = "pvlib", specifier = ">=0.15.2" }, - { name = "pyjwt", specifier = ">=2.13.0" }, - { name = "pykml", specifier = ">=0.2.0" }, - { name = "pyopenssl", specifier = ">=26.4.0" }, - { name = "pyproj", specifier = ">=3.7.2" }, - { name = "pyyaml", specifier = ">=6.0.3" }, - { name = "requests", specifier = ">=2.34.2" }, - { name = "s2sphere", specifier = ">=0.2.5" }, - { name = "scipy", specifier = ">=1.18.1" }, - { name = "shapely", specifier = ">=2.1.2" }, - { name = "structlog", specifier = ">=26.1.0" }, - { name = "termcolor", specifier = ">=3.3.0" }, - { name = "testcontainers", specifier = ">=4.15.0" }, - { name = "uas-standards", specifier = ">=4.4.0" }, - { name = "uuid6", specifier = ">=2025.0.1" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "basedpyright", specifier = ">=1.39.10" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "pytest-mock", specifier = ">=3.15.1" }, - { name = "ruff", specifier = ">=0.16.4" }, - { name = "types-lxml", specifier = ">=2026.2.16" }, -] - [[package]] name = "msgpack" version = "1.2.1" From 129f287c65bd7d573cea2a13370a60353325b8ca Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Thu, 3 Sep 2026 16:52:06 +0000 Subject: [PATCH 02/10] Fix commit_hash build arg to full commit hash --- scripts/git/get_version.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/scripts/git/get_version.py b/scripts/git/get_version.py index 007a8ca63d..1ab1640de6 100755 --- a/scripts/git/get_version.py +++ b/scripts/git/get_version.py @@ -75,7 +75,7 @@ def get_upstream_owner() -> str: return "unknown" -def get_commit_hash(with_status: bool = True) -> str: +def get_short_commit_hash(with_status: bool = True) -> str: """ Returns the current short commit hash, optionally suffixed with '-dirty' or '-localcommit'. """ @@ -96,6 +96,12 @@ def get_commit_hash(with_status: bool = True) -> str: return commit +def get_full_commit_hash() -> str: + """Returns the full commit hash. Useful for resource/version navigation on GitHub.""" + res = run_git_cmd(["rev-parse", "HEAD"]) + return res.stdout.strip() + + def get_git_tag_metadata(namespace: str, component: str) -> tuple[str, bool, str, bool]: """ Interrogates Git for component tags, exact boundary matches, commit hash, and workspace state. @@ -231,7 +237,7 @@ def main() -> None: " pep440: Canonical PEP440 version (e.g., '0.31.0', '0.31.0rc1', '0.31.0+gd56bb4d.dirty'); fails for malformed pre-releases (-RC, -1.2, etc.).\n" " imagetag: docker image tag version (e.g., 'v0.31.0', 'v0.31.0-d56bb4d417-dirty').\n" " owner: Repository organization name (e.g., 'interuss', 'Orbitalize').\n" - " commit: Current commit hash with status suffix (e.g., 'd56bb4d-dirty', 'd56bb4d-localcommit')." + " commit: Current commit full hash abbreviation without any status suffix (e.g., 'd56bb4d417242e0c29bd6b64837aa8bf5adad487')." ), ) @@ -242,7 +248,7 @@ def main() -> None: sys.exit(0) elif args.format == "commit": - print(get_commit_hash(with_status=True)) + print(get_full_commit_hash()) sys.exit(0) component = "monitoring" From 0ccad1077a255b110e111195ceaac77b818d1b1b Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Thu, 3 Sep 2026 22:47:33 +0000 Subject: [PATCH 03/10] Address comments --- RELEASE.md | 20 ++-- monitoring/build.sh | 2 +- scripts/git/get_version.py | 203 +++++++++++++++++-------------------- 3 files changed, 105 insertions(+), 120 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 10e614026c..d161c007dd 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,31 +2,31 @@ ## Background -Releases of `monitoring` utilize Git tags structured precisely as `interuss/monitoring/v[X].[Y].[Z]`, optionally accompanied by strict pre-release/candidate identifiers adhering to **PEP 440 Pre-Release Conventions**. +Releases of `monitoring` utilize git tags structured precisely as `interuss/monitoring/v[X].[Y].[Z][-rcN]` to support PEP 440 representations. -This formatting conforms to the InterUSS Component Registry pattern: `[owner]/[component]/[semantic version]`. (See [Semantic Versioning](https://semver.org) and [PEP 440 Version Identification](https://peps.python.org/pep-0440/)). +This formatting conforms to the InterUSS Component Registry pattern `[owner]/[component]/[semantic version]` where the semantic version is constrained to be easily represented as PEP 440. (See [Semantic Versioning](https://semver.org) and [PEP 440 Version Identification](https://peps.python.org/pep-0440/)). Keeping track of breaking changes and migration instructions is done through the [NEXT_RELEASE_NOTES.md](NEXT_RELEASE_NOTES.md) file, which is updated as features are added or modified and serves as a basis for release notes. ### PEP 440 Versioning, Validation, & PyPI Releases For compatibility with PyPI (Python Package Index) and Docker registry distributions, versions must follow the conventions below: * **Official Releases (`vX.Y.Z`)**: Output purely as canonical, unmodified Semantic Versions (`X.Y.Z`). These artifacts are fully eligible for publishing to PyPI. -* **Release Candidates (`vX.Y.Z-rc[N]`)**: **Strict Pre-Release Identifiers**. - * Pre-release tags **must** utilize the strictly lowercase, hyphenated `-rc[N]` suffix (e.g., `v0.31.0-rc1`). +* **Release Candidates (`vX.Y.Z-rc[N]`)**: + * Pre-release tags must utilize the strictly lowercase, hyphenated `-rc[N]` suffix (e.g., `v0.31.0-rc1`). * Build tooling normalizes these delimiters into PEP 440-compliant pre-release strings (`X.Y.Zrc[N]`) for PyPI compatibility. - * Note: Pre-releases containing uppercase identifiers (e.g., `-RC1`), space delimiters, alphabetic metadata, or non-numeric suffixes (e.g., `-alpha`, `-1.2`) are prevented to avoid accidental malformed PyPI publication. + * Note: Pre-releases containing uppercase identifiers (e.g., `-RC1`), space delimiters, alphabetic metadata, or non-numeric suffixes (e.g., `-alpha`, `-1.2`) are prevented to avoid accidental malformed PyPI publication attempts. * **Development & Branch Builds (`+`)**: - * Any image or PyPI artifact generated from a development branch, Pull Request, or commit other than an explicitly tagged release/pre-release boundary automatically appends PEP 440 Local Version Segments utilizing the `+` delimiter (e.g., `0.31.0+gd56bb4d`). + * Any image or PyPI artifact generated from a development branch, Pull Request, or commit other than an explicitly tagged release/pre-release boundary automatically appends PEP 440 Local Version Segments utilizing the `+` delimiter (e.g., `0.31.0+d56bb4d`). * Because PyPI rejects package uploads bearing PEP 440 `+` local-version metadata, this provides a safeguard preventing non-release/development builds from accidentally polluting the public package registry. ## PyPI Package Distribution (`interuss_monitoring`) As part of broader interoperating improvements for the InterUSS Python ecosystem: * **Distribution Identity**: The `monitoring` Python codebase is published to PyPI under the canonical ecosystem package name **`interuss_monitoring`**. -* **Interim Import Namespace (Phase 1 Phase-in)**: +* **Current Import Namespace**: * Consumers install the package via `pip install interuss_monitoring`. - * In the current structural phase (Phase 1), internal code modules and external users importing from the PyPI package interact with the Python Import Namespace via **`import monitoring.`**. - * *Warning for Interim Consumers*: Users must ensure their active Python virtual environment does not contain a conflicting top-level `monitoring/` directory from alternative third-party packages to prevent Python import-shadowing and runtime `ModuleNotFoundError` conflicts. -* **Future Transition (Phase 2)**: The repository is systematically migrating towards a fully isolated `src/interuss_monitoring` layout and explicit `import interuss_monitoring.` namespace, perfectly mirrors the architectural patterns established by `implicitdict` and `uas_standards`. + * Currently, internal code modules and external users importing from the PyPI package interact with the Python import namespace via **`import monitoring.`**. + * Currently, users must ensure their active Python virtual environment does not contain a conflicting top-level `monitoring/` directory from alternative third-party packages to prevent Python import-shadowing and runtime `ModuleNotFoundError` conflicts. +* **Future**: In the future, we anticipate migrating towards a fully isolated `src/interuss_monitoring` layout and explicit `import interuss_monitoring.` namespace, mirroring the architectural patterns established by `implicitdict` and `uas_standards`. ## Release procedure diff --git a/monitoring/build.sh b/monitoring/build.sh index 04372f211c..6489e2958a 100755 --- a/monitoring/build.sh +++ b/monitoring/build.sh @@ -23,7 +23,7 @@ else TAG="${1:-interuss/monitoring}" fi VERSION_STR=$(scripts/git/get_version.py --format pep440) -COMMIT_HASH=$(scripts/git/get_version.py --format commit) +COMMIT_HASH=$(scripts/git/get_version.py --format commitsha1) if [[ "${IMAGE_VARIANT}" == "normal" ]]; then DOCKER_TAG="${TAG}" diff --git a/scripts/git/get_version.py b/scripts/git/get_version.py index 1ab1640de6..d61d9ce11b 100755 --- a/scripts/git/get_version.py +++ b/scripts/git/get_version.py @@ -11,12 +11,40 @@ import os import re import subprocess -import sys +from dataclasses import dataclass +from typing import Any # Base repository root determination relative to the scripts/git directory. BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +@dataclass +class GitInfo: + upstream_owner: str + """Organization/owner of the upstream repository.""" + + component: str + """Component identifier ("monitoring" for this repository).""" + + baseline_tag: str + """Tag content without status information. E.g., interuss/monitoring/v0.0.0""" + + is_exact_tag_boundary: bool + """True when the current state lies exactly on a valid tag (and therefore is suitable for release).""" + + full_commit_hash: str + """Full SHAA-1 commit hash.""" + + short_commit_hash: str + """Current commit hash in short form.""" + + is_dirty: bool + """True when there are uncommitted changes.""" + + is_localcommit: bool + """True when the most recent commit is local-only.""" + + def run_git_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProcess: """Executes a Git command rooted in the repository workspace.""" return subprocess.run( @@ -29,10 +57,7 @@ def run_git_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProc def get_upstream_owner() -> str: - """ - Determines the organization/owner of the upstream repository. - Checks 'origin', 'interuss', tracking upstream branch, or defaults to 'unknown'. - """ + """Determines the organization/owner of the upstream repository.""" backup_repo = "https://github.com/unknown/_" try: upstream_branch = run_git_cmd( @@ -75,89 +100,72 @@ def get_upstream_owner() -> str: return "unknown" -def get_short_commit_hash(with_status: bool = True) -> str: - """ - Returns the current short commit hash, optionally suffixed with '-dirty' or '-localcommit'. - """ - res = run_git_cmd(["rev-parse", "--short", "HEAD"]) - commit = res.stdout.strip() - - if not with_status: - return commit - - status_res = run_git_cmd(["status", "--porcelain"], check=False) - if status_res.stdout.strip(): - return f"{commit}-dirty" - - cherry_res = run_git_cmd(["cherry"], check=False) - if cherry_res.returncode == 0 and cherry_res.stdout.strip(): - return f"{commit}-localcommit" - - return commit +def get_git_info(component: str) -> GitInfo: + """Interrogates git for information relevant to versioning.""" + kwargs: dict[str, Any] = {"component": component} -def get_full_commit_hash() -> str: - """Returns the full commit hash. Useful for resource/version navigation on GitHub.""" - res = run_git_cmd(["rev-parse", "HEAD"]) - return res.stdout.strip() + upstream_owner = get_upstream_owner() + kwargs["upstream_owner"] = upstream_owner + tag_match_pattern = f"{upstream_owner}/{component}/*" -def get_git_tag_metadata(namespace: str, component: str) -> tuple[str, bool, str, bool]: - """ - Interrogates Git for component tags, exact boundary matches, commit hash, and workspace state. - """ - tag_match_pattern = f"{namespace}/{component}/*" - - # 1. Resolve Baseline Tag (--abbrev=0 isolates tag boundary without distance markers) + # Resolve baseline tag (--abbrev=0 isolates tag boundary without distance markers) baseline_res = run_git_cmd( ["describe", "--tags", "--abbrev=0", f"--match={tag_match_pattern}"], check=False, ) if baseline_res.returncode == 0 and baseline_res.stdout.strip(): - baseline_tag = baseline_res.stdout.strip() + kwargs["baseline_tag"] = baseline_res.stdout.strip() else: - baseline_tag = f"{namespace}/{component}/v0.0.0" + kwargs["baseline_tag"] = f"{upstream_owner}/{component}/v0.0.0" - # 2. Determine Exact-Match Tag Boundary + # Determine exact-match tag boundary exact_res = run_git_cmd( ["describe", "--tags", f"--match={tag_match_pattern}", "--exact-match"], check=False, ) - is_exact_tag_boundary = exact_res.returncode == 0 + kwargs["is_exact_tag_boundary"] = exact_res.returncode == 0 - # 3. Retrieve Commit-Hash & Workspace Dirtiness - hash_res = run_git_cmd(["rev-parse", "--short", "HEAD"]) - commit_hash = hash_res.stdout.strip() + # Retrieve the full commit hash + full_hash_res = run_git_cmd(["rev-parse", "HEAD"]) + kwargs["full_commit_hash"] = full_hash_res.stdout.strip() + # Retrieve short commit hash + short_hash_res = run_git_cmd(["rev-parse", "--short", "HEAD"]) + kwargs["short_commit_hash"] = short_hash_res.stdout.strip() + + # Retrieve workspace dirtiness status_res = run_git_cmd(["status", "--porcelain"], check=False) - is_dirty = bool(status_res.stdout.strip()) + kwargs["is_dirty"] = bool(status_res.stdout.strip()) - return baseline_tag, is_exact_tag_boundary, commit_hash, is_dirty + # Retrieve whether commit is local-only + cherry_res = run_git_cmd(["cherry"], check=False) + kwargs["is_localcommit"] = cherry_res.returncode == 0 and cherry_res.stdout.strip() + return GitInfo(**kwargs) -def derive_pep440_version(namespace: str, component: str) -> str: + +def derive_pep440_version(info: GitInfo) -> str: """ Validates Git tag and metadata against strict InterUSS SemVer and Pre-Release conventions, generating a canonical PEP 440 version string. """ - baseline_tag, is_exact_tag_boundary, commit_hash, is_dirty = get_git_tag_metadata( - namespace, component - ) strict_tag_regex = re.compile( - rf"^{re.escape(namespace)}/{re.escape(component)}/v(?P\d+\.\d+\.\d+)(?P-rc\d+)?$" + rf"^{re.escape(info.upstream_owner)}/{re.escape(info.component)}/v(?P\d+\.\d+\.\d+)(?P-rc\d+)?$" ) malformed_prerelease_regex = re.compile( - rf"^{re.escape(namespace)}/{re.escape(component)}/v\d+\.\d+\.\d+-(.*)$" + rf"^{re.escape(info.upstream_owner)}/{re.escape(info.component)}/v\d+\.\d+\.\d+-(.*)$" ) - malformed_match = malformed_prerelease_regex.match(baseline_tag) - strict_match = strict_tag_regex.match(baseline_tag) + malformed_match = malformed_prerelease_regex.match(info.baseline_tag) + strict_match = strict_tag_regex.match(info.baseline_tag) if malformed_match and not strict_match: invalid_suffix = malformed_match.group(1) raise ValueError( - f"Strict Validation Failure: Tag '{baseline_tag}' contains a non-conforming pre-release " + f"Strict Validation Failure: Tag '{info.baseline_tag}' contains a non-conforming pre-release " f"or release-candidate identifier ('-{invalid_suffix}'). InterUSS pre-release tags " f"must strictly utilize the lowercase, hyphen-prefixed '-rc[N]' convention (e.g., '-rc1'). " "Case-insensitivity, arbitrary alpha-segments, or alternative delimiters are prohibited." @@ -165,8 +173,8 @@ def derive_pep440_version(namespace: str, component: str) -> str: if not strict_match: raise ValueError( - f"Strict Validation Failure: Tag '{baseline_tag}' violates InterUSS repository SemVer conventions. " - f"Expected Pattern: '{namespace}/{component}/vX.Y.Z[-rcN]'." + f"Strict Validation Failure: Tag '{info.baseline_tag}' violates InterUSS repository SemVer conventions. " + f"Expected Pattern: '{info.upstream_owner}/{info.component}/vX.Y.Z[-rcN]'." ) semver = strict_match.group("semver") @@ -179,44 +187,36 @@ def derive_pep440_version(namespace: str, component: str) -> str: # If this is not an exact match on the release tag, or if workspace is dirty, append local version metadata_segments = [] - if not is_exact_tag_boundary: - metadata_segments.append(f"g{commit_hash.lstrip('g')}") - if is_dirty: - metadata_segments.append("dirty") + if not info.is_exact_tag_boundary: + metadata_segments.append(info.short_commit_hash) + if info.is_dirty or info.is_localcommit: + metadata_segments.append( + ("dirty" if info.is_dirty else "") + + ("localcommit" if info.is_localcommit else "") + ) if metadata_segments: return f"{pep440_base}+{'.'.join(metadata_segments)}" - - return pep440_base + else: + return pep440_base -def get_git_version(namespace: str, component: str, long_format: bool = False) -> str: - """ - Derives the version string conforming to the legacy version.sh format. - """ - tag_match_pattern = f"{namespace}/{component}/*" - describe_res = run_git_cmd( - ["describe", "--abbrev=1", "--tags", f"--match={tag_match_pattern}"], - check=False, - ) - commit = run_git_cmd(["rev-parse", "--short", "HEAD"]).stdout.strip() - status_res = run_git_cmd(["status", "--porcelain"], check=False) - dirty_suffix = "-dirty" if status_res.stdout.strip() else "" +def compute_image_tag(info: GitInfo) -> str: + """Computes the semantic version string with which a docker image should be tagged.""" - if describe_res.returncode != 0 or not describe_res.stdout.strip(): - last_version = f"v0.0.0-{commit}" - else: - full_tag = describe_res.stdout.strip() - last_version = full_tag.split("/")[-1] - if "-" in last_version: - # Commits added on top of tag - base_part = last_version.split("-")[0] - last_version = f"{base_part}-{commit}" + tag = info.baseline_tag.split("/")[-1] - version_str = f"{last_version}{dirty_suffix}" - if long_format: - return f"{namespace}/{component}/{version_str}" - return version_str + build_parts = [] + if not info.is_exact_tag_boundary: + build_parts.append(info.short_commit_hash) + if info.is_dirty or info.is_localcommit: + build_parts.append( + ("dirty" if info.is_dirty else "") + + ("localcommit" if info.is_localcommit else "") + ) + if build_parts: + tag += "+" + ".".join(build_parts) + return tag def main() -> None: @@ -228,44 +228,29 @@ def main() -> None: choices=[ "pep440", "imagetag", - "owner", - "commit", + "commitsha1", ], default="pep440", help=( "Explicit output format.\n" " pep440: Canonical PEP440 version (e.g., '0.31.0', '0.31.0rc1', '0.31.0+gd56bb4d.dirty'); fails for malformed pre-releases (-RC, -1.2, etc.).\n" " imagetag: docker image tag version (e.g., 'v0.31.0', 'v0.31.0-d56bb4d417-dirty').\n" - " owner: Repository organization name (e.g., 'interuss', 'Orbitalize').\n" - " commit: Current commit full hash abbreviation without any status suffix (e.g., 'd56bb4d417242e0c29bd6b64837aa8bf5adad487')." + " commitsha1: Current commit full hash abbreviation without any status suffix (e.g., 'd56bb4d417242e0c29bd6b64837aa8bf5adad487')." ), ) args = parser.parse_args() - if args.format == "owner": - print(get_upstream_owner()) - sys.exit(0) - - elif args.format == "commit": - print(get_full_commit_hash()) - sys.exit(0) + info = get_git_info("monitoring") - component = "monitoring" - owner = get_upstream_owner() + if args.format == "commitsha1": + print(info.full_commit_hash) - if args.format == "imagetag": - print(get_git_version(owner, component, long_format=False)) - sys.exit(0) + elif args.format == "imagetag": + print(compute_image_tag(info)) elif args.format == "pep440": - try: - pep440_version = derive_pep440_version(owner, component) - print(pep440_version) - sys.exit(0) - except ValueError as e: - print(f"[InterUSS Version Validation ERROR] {e}", file=sys.stderr) - sys.exit(1) + print(derive_pep440_version(info)) else: raise ValueError(f"Invalid requested format '{args.format}'") From e4aadaa7fb30f67bbdff12c0115ad8406b1a7466 Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Fri, 4 Sep 2026 00:18:35 +0000 Subject: [PATCH 04/10] Remove setuptools_scm --- .gitignore | 2 +- MANIFEST.in | 2 ++ monitoring/Dockerfile | 1 - monitoring/build.sh | 1 + pyproject.toml | 7 ++-- scripts/git/get_version.py | 55 ++++++++++++++++++++++++++++--- scripts/git/write_version_file.py | 44 +++++++++++++++++++++++++ setup.py | 19 +++++++++++ 8 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 MANIFEST.in create mode 100755 scripts/git/write_version_file.py create mode 100644 setup.py diff --git a/.gitignore b/.gitignore index 5d8dba9999..8517a8ce8f 100644 --- a/.gitignore +++ b/.gitignore @@ -129,5 +129,5 @@ go # vscode files .vscode -# setuptools_scm +# Generated version file _version.py diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000000..507e9c40fa --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include monitoring/_version.py +recursive-include scripts/git *.py diff --git a/monitoring/Dockerfile b/monitoring/Dockerfile index b9604a46a7..07d8ede4c5 100644 --- a/monitoring/Dockerfile +++ b/monitoring/Dockerfile @@ -105,7 +105,6 @@ ARG version ARG commit_hash ENV MONITORING_VERSION=$version ENV GIT_COMMIT_HASH=$commit_hash -ENV SETUPTOOLS_SCM_PRETEND_VERSION_FOR_INTERUSS_MONITORING=$version # Add core content from repo ADD ./interfaces /app/interfaces diff --git a/monitoring/build.sh b/monitoring/build.sh index 6489e2958a..82794c844f 100755 --- a/monitoring/build.sh +++ b/monitoring/build.sh @@ -24,6 +24,7 @@ else fi VERSION_STR=$(scripts/git/get_version.py --format pep440) COMMIT_HASH=$(scripts/git/get_version.py --format commitsha1) +scripts/git/write_version_file.py > /dev/null if [[ "${IMAGE_VARIANT}" == "normal" ]]; then DOCKER_TAG="${TAG}" diff --git a/pyproject.toml b/pyproject.toml index 820ca71c99..6dcc157f87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,7 +106,6 @@ exclude = [ requires = [ "setuptools>=84.0.0", "wheel>=0.48.0", # for bdist package distribution - "setuptools_scm>=10.2.1", # for automated versioning ] build-backend = "setuptools.build_meta" @@ -127,7 +126,5 @@ include = [ ] namespaces = true -[tool.setuptools_scm] -write_to = "monitoring/_version.py" -# Strictly anchor to the repo-level prefix, 3-part numeric PEP440/SemVer, and optionally PEP440 lowercase '-rcN' suffixes. -tag_regex = "^interuss/monitoring/v(?P\\d+\\.\\d+\\.\\d+(?:-rc\\d+)?)$" +[tool.setuptools.dynamic] +version = {attr = "monitoring._version.__version__"} diff --git a/scripts/git/get_version.py b/scripts/git/get_version.py index d61d9ce11b..217dda5af6 100755 --- a/scripts/git/get_version.py +++ b/scripts/git/get_version.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 """ -scripts/git/get_version.py InterUSS monitoring version representation utility. This CLI combines and standardizes all repository Git inspection, PEP 440 version derivation, @@ -16,6 +15,7 @@ # Base repository root determination relative to the scripts/git directory. BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +VERSION_FILE_PATH = os.path.join(BASE_DIR, "monitoring", "_version.py") @dataclass @@ -152,6 +152,10 @@ def derive_pep440_version(info: GitInfo) -> str: generating a canonical PEP 440 version string. """ + env_version = os.environ.get("MONITORING_VERSION") + if env_version: + return env_version + strict_tag_regex = re.compile( rf"^{re.escape(info.upstream_owner)}/{re.escape(info.component)}/v(?P\d+\.\d+\.\d+)(?P-rc\d+)?$" ) @@ -219,6 +223,49 @@ def compute_image_tag(info: GitInfo) -> str: return tag +def get_pep440_version( + component: str = "monitoring", +) -> str: + """ + Returns the PEP 440 version string, checking in order: + 1. MONITORING_VERSION environment variable override. + 2. Git metadata via get_git_info and derive_pep440_version. + 3. Existing version file (if Git is unavailable, e.g. in an extracted sdist). + 4. PKG-INFO metadata (if present). + 5. Fallback '0.0.0'. + """ + env_version = os.environ.get("MONITORING_VERSION") + if env_version: + return env_version + + try: + info = get_git_info(component) + return derive_pep440_version(info) + except (subprocess.SubprocessError, FileNotFoundError, OSError): + pass + + if os.path.exists(VERSION_FILE_PATH): + try: + with open(VERSION_FILE_PATH) as f: + for line in f: + if line.startswith("__version__ = "): + return line.split('"')[1] + except Exception: + pass + + pkg_info_file = os.path.join(BASE_DIR, "PKG-INFO") + if os.path.exists(pkg_info_file): + try: + with open(pkg_info_file) as f: + for line in f: + if line.startswith("Version: "): + return line.split("Version: ", 1)[1].strip() + except Exception: + pass + + return "0.0.0" + + def main() -> None: parser = argparse.ArgumentParser( description="InterUSS monitoring version representation utility." @@ -241,16 +288,16 @@ def main() -> None: args = parser.parse_args() - info = get_git_info("monitoring") - if args.format == "commitsha1": + info = get_git_info("monitoring") print(info.full_commit_hash) elif args.format == "imagetag": + info = get_git_info("monitoring") print(compute_image_tag(info)) elif args.format == "pep440": - print(derive_pep440_version(info)) + print(get_pep440_version()) else: raise ValueError(f"Invalid requested format '{args.format}'") diff --git a/scripts/git/write_version_file.py b/scripts/git/write_version_file.py new file mode 100755 index 0000000000..b14fb12808 --- /dev/null +++ b/scripts/git/write_version_file.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +""" +InterUSS monitoring version file generator utility. + +This script generates monitoring/_version.py containing __version__ and +version variables resolved via scripts/git/get_version.py. +""" + +import argparse +import os + +from get_version import ( + VERSION_FILE_PATH, + get_pep440_version, +) + + +def write_version_file() -> str: + """Writes the version to a Python file defining __version__.""" + + version = get_pep440_version() + content = f'''# This file is automatically generated by scripts/git/write_version_file.py. +# Do not edit this file manually. +__version__ = "{version}" +version = __version__ +''' + os.makedirs(os.path.dirname(os.path.abspath(VERSION_FILE_PATH)), exist_ok=True) + with open(VERSION_FILE_PATH, "w") as f: + f.write(content) + return version + + +def main() -> None: + parser = argparse.ArgumentParser( + description="InterUSS monitoring version file generator utility." + ) + + parser.parse_args() + version = write_version_file() + print(f"Wrote version {version} to {VERSION_FILE_PATH}") + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000000..4957036351 --- /dev/null +++ b/setup.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""Build configuration for interuss_monitoring.""" + +import os +import sys + +from setuptools import setup + +REPO_ROOT = os.path.dirname(os.path.abspath(__file__)) +SCRIPTS_GIT = os.path.join(REPO_ROOT, "scripts", "git") + +sys.path.insert(0, SCRIPTS_GIT) +from write_version_file import ( # noqa: E402 # pyright: ignore[reportMissingImports] + write_version_file, +) + +write_version_file() + +setup() From bbf8e86c931740430472c41790854287def5b894 Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Fri, 4 Sep 2026 02:51:27 +0000 Subject: [PATCH 05/10] Fix package inclusion in wheel, exclude build/lib from basedpyright, and fix docker tag delimiters --- .gitignore | 2 ++ pyproject.toml | 4 +++- scripts/git/get_version.py | 11 +++++------ 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 8517a8ce8f..664fbacd32 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,8 @@ sdist/ var/ wheels/ *.egg-info/ +build/lib/ +build/bdist*/ .installed.cfg *.egg MANIFEST diff --git a/pyproject.toml b/pyproject.toml index 6dcc157f87..aea1a74cd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,6 +100,8 @@ exclude = [ "monitoring/mock_uss/output/*", "monitoring/uss_qualifier/output/*", "monitoring/benchmarker/output/*", + "build/lib/*", + "dist/*", ] [build-system] @@ -117,12 +119,12 @@ package-dir = { "monitoring" = "monitoring" } [tool.setuptools.packages.find] where = ["."] include = [ + "monitoring", "monitoring.deployment_manager*", "monitoring.mock_uss*", "monitoring.monitorlib*", "monitoring.prober*", "monitoring.uss_qualifier*", - "monitoring._version.py" ] namespaces = true diff --git a/scripts/git/get_version.py b/scripts/git/get_version.py index 217dda5af6..da2d28e865 100755 --- a/scripts/git/get_version.py +++ b/scripts/git/get_version.py @@ -213,13 +213,12 @@ def compute_image_tag(info: GitInfo) -> str: build_parts = [] if not info.is_exact_tag_boundary: build_parts.append(info.short_commit_hash) - if info.is_dirty or info.is_localcommit: - build_parts.append( - ("dirty" if info.is_dirty else "") - + ("localcommit" if info.is_localcommit else "") - ) + if info.is_dirty: + build_parts.append("dirty") + if info.is_localcommit: + build_parts.append("localcommit") if build_parts: - tag += "+" + ".".join(build_parts) + tag += "-" + "-".join(build_parts) return tag From d18d73dabd50cd527e68623a4986878829145dba Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Tue, 8 Sep 2026 19:20:53 +0000 Subject: [PATCH 06/10] Ensure boolean value --- scripts/git/get_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/git/get_version.py b/scripts/git/get_version.py index da2d28e865..59061d7801 100755 --- a/scripts/git/get_version.py +++ b/scripts/git/get_version.py @@ -141,7 +141,7 @@ def get_git_info(component: str) -> GitInfo: # Retrieve whether commit is local-only cherry_res = run_git_cmd(["cherry"], check=False) - kwargs["is_localcommit"] = cherry_res.returncode == 0 and cherry_res.stdout.strip() + kwargs["is_localcommit"] = bool(cherry_res.returncode == 0 and cherry_res.stdout.strip()) return GitInfo(**kwargs) From 2c827f7dbfa7057fc8c06adde6d9688f04ad5517 Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Tue, 8 Sep 2026 20:00:34 +0000 Subject: [PATCH 07/10] `make format` --- scripts/git/get_version.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/git/get_version.py b/scripts/git/get_version.py index 59061d7801..b245381a95 100755 --- a/scripts/git/get_version.py +++ b/scripts/git/get_version.py @@ -141,7 +141,9 @@ def get_git_info(component: str) -> GitInfo: # Retrieve whether commit is local-only cherry_res = run_git_cmd(["cherry"], check=False) - kwargs["is_localcommit"] = bool(cherry_res.returncode == 0 and cherry_res.stdout.strip()) + kwargs["is_localcommit"] = bool( + cherry_res.returncode == 0 and cherry_res.stdout.strip() + ) return GitInfo(**kwargs) From 8c8e9f661b7fe0d0aa8ad1e654205003948a9a5a Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Wed, 9 Sep 2026 15:20:24 +0000 Subject: [PATCH 08/10] Address comments for SHA typo and GitInfo instantiation --- scripts/git/get_version.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/scripts/git/get_version.py b/scripts/git/get_version.py index b245381a95..beb7530e15 100755 --- a/scripts/git/get_version.py +++ b/scripts/git/get_version.py @@ -33,7 +33,7 @@ class GitInfo: """True when the current state lies exactly on a valid tag (and therefore is suitable for release).""" full_commit_hash: str - """Full SHAA-1 commit hash.""" + """Full SHA-1 commit hash.""" short_commit_hash: str """Current commit hash in short form.""" @@ -103,10 +103,7 @@ def get_upstream_owner() -> str: def get_git_info(component: str) -> GitInfo: """Interrogates git for information relevant to versioning.""" - kwargs: dict[str, Any] = {"component": component} - upstream_owner = get_upstream_owner() - kwargs["upstream_owner"] = upstream_owner tag_match_pattern = f"{upstream_owner}/{component}/*" @@ -116,37 +113,44 @@ def get_git_info(component: str) -> GitInfo: check=False, ) if baseline_res.returncode == 0 and baseline_res.stdout.strip(): - kwargs["baseline_tag"] = baseline_res.stdout.strip() + baseline_tag = baseline_res.stdout.strip() else: - kwargs["baseline_tag"] = f"{upstream_owner}/{component}/v0.0.0" + baseline_tag = f"{upstream_owner}/{component}/v0.0.0" # Determine exact-match tag boundary exact_res = run_git_cmd( ["describe", "--tags", f"--match={tag_match_pattern}", "--exact-match"], check=False, ) - kwargs["is_exact_tag_boundary"] = exact_res.returncode == 0 + is_exact_tag_boundary = exact_res.returncode == 0 # Retrieve the full commit hash full_hash_res = run_git_cmd(["rev-parse", "HEAD"]) - kwargs["full_commit_hash"] = full_hash_res.stdout.strip() + full_commit_hash = full_hash_res.stdout.strip() # Retrieve short commit hash short_hash_res = run_git_cmd(["rev-parse", "--short", "HEAD"]) - kwargs["short_commit_hash"] = short_hash_res.stdout.strip() + short_commit_hash = short_hash_res.stdout.strip() # Retrieve workspace dirtiness status_res = run_git_cmd(["status", "--porcelain"], check=False) - kwargs["is_dirty"] = bool(status_res.stdout.strip()) + is_dirty = bool(status_res.stdout.strip()) # Retrieve whether commit is local-only cherry_res = run_git_cmd(["cherry"], check=False) - kwargs["is_localcommit"] = bool( - cherry_res.returncode == 0 and cherry_res.stdout.strip() + is_localcommit = bool(cherry_res.returncode == 0 and cherry_res.stdout.strip()) + + return GitInfo( + upstream_owner=upstream_owner, + component=component, + baseline_tag=baseline_tag, + is_exact_tag_boundary=is_exact_tag_boundary, + full_commit_hash=full_commit_hash, + short_commit_hash=short_commit_hash, + is_dirty=is_dirty, + is_localcommit=is_localcommit, ) - return GitInfo(**kwargs) - def derive_pep440_version(info: GitInfo) -> str: """ From f7127b6df5dcbb20997cd6d49a0eec6631d9820b Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Wed, 9 Sep 2026 23:52:58 +0000 Subject: [PATCH 09/10] Add get_version unit tests --- scripts/git/get_version.py | 5 +- scripts/git/get_version_test.py | 729 ++++++++++++++++++++++++++++++++ 2 files changed, 730 insertions(+), 4 deletions(-) create mode 100644 scripts/git/get_version_test.py diff --git a/scripts/git/get_version.py b/scripts/git/get_version.py index beb7530e15..0302beb5b1 100755 --- a/scripts/git/get_version.py +++ b/scripts/git/get_version.py @@ -11,7 +11,6 @@ import re import subprocess from dataclasses import dataclass -from typing import Any # Base repository root determination relative to the scripts/git directory. BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) @@ -91,9 +90,7 @@ def get_upstream_owner() -> str: return owner # Fallback pattern extraction if not github.com explicitly - parts = [ - p for p in re.split(r"[/:\\]", upstream_repo) if p and not p.endswith(".git") - ] + parts = [p.removesuffix(".git") for p in re.split(r"[/:\\]", upstream_repo) if p] if len(parts) >= 2: return parts[-2] diff --git a/scripts/git/get_version_test.py b/scripts/git/get_version_test.py new file mode 100644 index 0000000000..764bf08e2f --- /dev/null +++ b/scripts/git/get_version_test.py @@ -0,0 +1,729 @@ +#!/usr/bin/env python3 + +import subprocess +import sys +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +import pytest + +# Ensure repository root is in sys.path so scripts.git is importable without PYTHONPATH=. +_repo_root = str(Path(__file__).resolve().parents[2]) +if _repo_root not in sys.path: + sys.path.insert(0, _repo_root) + +from scripts.git.get_version import ( # noqa: E402 + compute_image_tag, + derive_pep440_version, + get_git_info, + get_pep440_version, + get_upstream_owner, + main, + run_git_cmd, +) + +type CommandResult = tuple[int, str] | Exception +type CommandMap = dict[tuple[str, ...], CommandResult] + + +# ============================================================================== +# Git Environment Representation +# ============================================================================== + + +@dataclass(frozen=True) +class GitEnvironment: + """Represents a hypothetical Git repository environment for testing.""" + + name: str + description: str + origin_url: str | None = "https://github.com/interuss/monitoring.git" + interuss_url: str | None = None + upstream_branch: str | None = None + upstream_branch_raises: bool = False + baseline_tag: str | None = "interuss/monitoring/v0.31.0" + is_exact_tag_boundary: bool = True + has_uncommitted_changes: bool = False + has_local_commits: bool = False + full_commit_hash: str = "abcdef0123456789abcdef0123456789abcdef01" + git_available: bool = True + + @property + def short_commit_hash(self) -> str: + return self.full_commit_hash[0:7] + + @property + def command_map(self) -> CommandMap: + """Constructs the command response map corresponding to this environment.""" + if not self.git_available: + error = subprocess.SubprocessError("git: command not found") + return { + ("rev-parse", "--abbrev-ref", "@{upstream}"): error, + ("remote", "get-url", "origin"): error, + ("remote", "get-url", "interuss"): error, + ("describe",): error, + ("rev-parse", "HEAD"): error, + ("rev-parse", "--short", "HEAD"): error, + ("status", "--porcelain"): error, + ("cherry",): error, + } + + cmd_map: CommandMap = {} + + # 1. Upstream branch resolution (rev-parse --abbrev-ref @{upstream}) + if self.upstream_branch_raises: + cmd_map[("rev-parse", "--abbrev-ref", "@{upstream}")] = ( + subprocess.SubprocessError("upstream branch query error") + ) + elif self.upstream_branch: + cmd_map[("rev-parse", "--abbrev-ref", "@{upstream}")] = ( + 0, + f"{self.upstream_branch}\n", + ) + else: + cmd_map[("rev-parse", "--abbrev-ref", "@{upstream}")] = ( + 128, + "fatal: no upstream configured for branch\n", + ) + + # 2. Remote URLs (origin and interuss) + if self.origin_url: + cmd_map[("remote", "get-url", "origin")] = ( + 0, + f"{self.origin_url}\n", + ) + else: + cmd_map[("remote", "get-url", "origin")] = ( + 2, + "error: No such remote 'origin'\n", + ) + + if self.interuss_url: + cmd_map[("remote", "get-url", "interuss")] = ( + 0, + f"{self.interuss_url}\n", + ) + else: + cmd_map[("remote", "get-url", "interuss")] = ( + 2, + "error: No such remote 'interuss'\n", + ) + + # 3. Git tag description (baseline tag and exact match) + if self.baseline_tag is not None: + cmd_map[("describe", "--tags", "--abbrev=0")] = ( + 0, + f"{self.baseline_tag}\n", + ) + if self.is_exact_tag_boundary: + cmd_map[("describe", "--tags", "--exact-match")] = ( + 0, + f"{self.baseline_tag}\n", + ) + else: + cmd_map[("describe", "--tags", "--exact-match")] = ( + 128, + "fatal: no tag exactly matches\n", + ) + else: + cmd_map[("describe", "--tags", "--abbrev=0")] = ( + 128, + "fatal: No names found, cannot describe anything.\n", + ) + cmd_map[("describe", "--tags", "--exact-match")] = ( + 128, + "fatal: no tag exactly matches\n", + ) + + # 4. Commit hashes + cmd_map[("rev-parse", "HEAD")] = (0, f"{self.full_commit_hash}\n") + cmd_map[("rev-parse", "--short", "HEAD")] = ( + 0, + f"{self.short_commit_hash}\n", + ) + + # 5. Workspace uncommitted changes (dirty state) + if self.has_uncommitted_changes: + cmd_map[("status", "--porcelain")] = ( + 0, + " M scripts/git/get_version.py\n", + ) + else: + cmd_map[("status", "--porcelain")] = (0, "") + + # 6. Local-only unpushed commits (cherry) + if self.has_local_commits: + cmd_map[("cherry",)] = (0, f"+ {self.full_commit_hash}\n") + else: + cmd_map[("cherry",)] = (0, "") + + return cmd_map + + +def create_git_environment( + name: str = "custom_env", + description: str = "Custom test environment", + *, + origin_url: str | None = "https://github.com/interuss/monitoring.git", + interuss_url: str | None = None, + upstream_branch: str | None = None, + upstream_branch_raises: bool = False, + baseline_tag: str | None = "interuss/monitoring/v0.31.0", + is_exact_tag_boundary: bool = True, + has_uncommitted_changes: bool = False, + has_local_commits: bool = False, + full_commit_hash: str = "abcdef0123456789abcdef0123456789abcdef01", + git_available: bool = True, +) -> GitEnvironment: + """Factory function producing a GitEnvironment based on repository characteristics.""" + return GitEnvironment( + name=name, + description=description, + origin_url=origin_url, + interuss_url=interuss_url, + upstream_branch=upstream_branch, + upstream_branch_raises=upstream_branch_raises, + baseline_tag=baseline_tag, + is_exact_tag_boundary=is_exact_tag_boundary, + has_uncommitted_changes=has_uncommitted_changes, + has_local_commits=has_local_commits, + full_commit_hash=full_commit_hash, + git_available=git_available, + ) + + +# ============================================================================== +# Specific Named Environments +# ============================================================================== + +NOMINAL_RELEASE_ENV = GitEnvironment( + name="nominal_release", + description="Clean production release build checked out exactly on an official release tag", + origin_url="https://github.com/interuss/monitoring.git", + baseline_tag="interuss/monitoring/v0.31.0", + is_exact_tag_boundary=True, + has_uncommitted_changes=False, + has_local_commits=False, +) + +PRERELEASE_ENV = GitEnvironment( + name="prerelease", + description="Clean build checked out exactly on an official pre-release candidate tag", + origin_url="https://github.com/interuss/monitoring.git", + baseline_tag="interuss/monitoring/v0.31.0-rc1", + is_exact_tag_boundary=True, + has_uncommitted_changes=False, + has_local_commits=False, +) + +COMMITS_AHEAD_CLEAN_ENV = GitEnvironment( + name="commits_ahead_clean", + description="Clean working directory with commits pushed ahead of the latest tag", + origin_url="https://github.com/interuss/monitoring.git", + baseline_tag="interuss/monitoring/v0.31.0", + is_exact_tag_boundary=False, + has_uncommitted_changes=False, + has_local_commits=False, +) + +WIP_DEVELOPER_ENV = GitEnvironment( + name="wip_developer", + description="Active developer fork environment with local unpushed commits and uncommitted changes", + origin_url="git@github.com:devuser/monitoring.git", + interuss_url="https://github.com/interuss/monitoring.git", + baseline_tag="devuser/monitoring/v0.31.0", + is_exact_tag_boundary=False, + has_uncommitted_changes=True, + has_local_commits=True, +) + +DIRTY_ONLY_ENV = GitEnvironment( + name="dirty_only", + description="Workspace directly on a release tag but with uncommitted local edits", + origin_url="https://github.com/interuss/monitoring.git", + baseline_tag="interuss/monitoring/v0.31.0", + is_exact_tag_boundary=True, + has_uncommitted_changes=True, + has_local_commits=False, +) + +LOCAL_COMMIT_ONLY_ENV = GitEnvironment( + name="local_commit_only", + description="Workspace with unpushed commits ahead of tag, but clean working tree", + origin_url="https://github.com/interuss/monitoring.git", + baseline_tag="interuss/monitoring/v0.31.0", + is_exact_tag_boundary=False, + has_uncommitted_changes=False, + has_local_commits=True, +) + +INTERUSS_REMOTE_FALLBACK_ENV = GitEnvironment( + name="interuss_remote_fallback", + description="Environment where origin remote is missing, falling back to interuss remote", + origin_url=None, + interuss_url="https://github.com/interuss/monitoring.git", + baseline_tag="interuss/monitoring/v0.31.0", + is_exact_tag_boundary=True, + has_uncommitted_changes=False, + has_local_commits=False, +) + +UPSTREAM_TRACKING_BRANCH_ENV = GitEnvironment( + name="upstream_tracking_branch", + description="Environment with no remotes, falling back to upstream tracking branch @{upstream}", + origin_url=None, + interuss_url=None, + upstream_branch="upstream-org/main", + baseline_tag="upstream-org/monitoring/v0.31.0", + is_exact_tag_boundary=True, + has_uncommitted_changes=False, + has_local_commits=False, +) + +UPSTREAM_BRANCH_ERROR_ENV = GitEnvironment( + name="upstream_branch_error", + description="Environment where git rev-parse @{upstream} raises an error and no remotes exist", + origin_url=None, + interuss_url=None, + upstream_branch_raises=True, + baseline_tag=None, + is_exact_tag_boundary=False, +) + +NO_TAGS_ENV = GitEnvironment( + name="no_tags", + description="Initial repository state without any matching tags created yet", + origin_url="https://github.com/interuss/monitoring.git", + baseline_tag=None, + is_exact_tag_boundary=False, + has_uncommitted_changes=False, + has_local_commits=False, +) + +NON_GITHUB_REMOTE_ENV = GitEnvironment( + name="non_github_remote", + description="Repository with a GitLab/custom non-GitHub remote URL", + origin_url="https://gitlab.com/custom-org/monitoring.git", + baseline_tag="custom-org/monitoring/v1.0.0", + is_exact_tag_boundary=True, + has_uncommitted_changes=False, + has_local_commits=False, +) + +MALFORMED_PRERELEASE_TAG_ENV = GitEnvironment( + name="malformed_prerelease_tag", + description="Repository where tag has an invalid pre-release identifier (-alpha1 instead of -rcN)", + origin_url="https://github.com/interuss/monitoring.git", + baseline_tag="interuss/monitoring/v0.31.0-alpha1", + is_exact_tag_boundary=True, +) + +NON_SEMVER_TAG_ENV = GitEnvironment( + name="non_semver_tag", + description="Repository where tag does not conform to SemVer pattern", + origin_url="https://github.com/interuss/monitoring.git", + baseline_tag="interuss/monitoring/release-2026", + is_exact_tag_boundary=True, +) + +GIT_UNAVAILABLE_ENV = GitEnvironment( + name="git_unavailable", + description="Environment where git binary is missing or directory is not a git repo", + git_available=False, +) + + +# ============================================================================== +# Mock Execution Engine & Fixtures +# ============================================================================== + + +def _matches_pattern(args: list[str], pattern: tuple[str, ...]) -> bool: + """Matches git command arguments against a pattern tuple.""" + if tuple(args) == pattern: + return True + if len(args) >= len(pattern) and tuple(args[: len(pattern)]) == pattern: + return True + if "describe" in pattern: + if "--exact-match" in pattern: + return "describe" in args and "--exact-match" in args + if "--abbrev=0" in pattern: + return "describe" in args and "--abbrev=0" in args + return args[0] == "describe" + return False + + +def make_mock_run_git_cmd( + command_map: CommandMap, +) -> Callable[[list[str], bool], subprocess.CompletedProcess[str]]: + """Builds a mock run_git_cmd function from a command map.""" + + def fake_run_git_cmd( + args: list[str], check: bool = True + ) -> subprocess.CompletedProcess[str]: + for pattern, result in command_map.items(): + if _matches_pattern(args, pattern): + if isinstance(result, Exception): + raise result + rc, stdout = result + if check and rc != 0: + raise subprocess.CalledProcessError( + rc, ["git"] + args, output=stdout + ) + return subprocess.CompletedProcess( + args=["git"] + args, + returncode=rc, + stdout=stdout, + stderr="", + ) + if check: + raise subprocess.CalledProcessError(1, ["git"] + args, output="") + return subprocess.CompletedProcess( + args=["git"] + args, + returncode=1, + stdout="", + stderr=f"unmapped git command: {args}", + ) + + return fake_run_git_cmd + + +@pytest.fixture +def apply_git_env( + monkeypatch: pytest.MonkeyPatch, +) -> Callable[[GitEnvironment], GitEnvironment]: + """Applies a GitEnvironment to scripts.git.get_version.run_git_cmd for the test duration.""" + # Ensure MONITORING_VERSION is clear by default + monkeypatch.delenv("MONITORING_VERSION", raising=False) + + def _apply(env: GitEnvironment) -> GitEnvironment: + fake_run = make_mock_run_git_cmd(env.command_map) + monkeypatch.setattr("scripts.git.get_version.run_git_cmd", fake_run) + return env + + return _apply + + +# ============================================================================== +# Suite A: Tool Output in Clean Release Environments +# ============================================================================== + + +def test_nominal_release_environment( + apply_git_env: Callable[[GitEnvironment], GitEnvironment], + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verifies tool outputs in a clean release environment checked out on a tag.""" + env = apply_git_env(NOMINAL_RELEASE_ENV) + + assert get_upstream_owner() == "interuss" + + info = get_git_info("monitoring") + assert info.upstream_owner == "interuss" + assert info.component == "monitoring" + assert info.baseline_tag == "interuss/monitoring/v0.31.0" + assert info.is_exact_tag_boundary is True + assert info.is_dirty is False + assert info.is_localcommit is False + assert info.full_commit_hash == env.full_commit_hash + assert info.short_commit_hash == env.short_commit_hash + + # PEP 440 version is canonical and tag-only without metadata + assert derive_pep440_version(info) == "0.31.0" + assert get_pep440_version() == "0.31.0" + + # Image tag matches exact tag + assert compute_image_tag(info) == "v0.31.0" + + # Invariant check: CLI invocation outputs + monkeypatch.setattr("sys.argv", ["get_version.py", "--format", "commitsha1"]) + main() + assert capsys.readouterr().out.strip() == env.full_commit_hash + + monkeypatch.setattr("sys.argv", ["get_version.py", "--format", "imagetag"]) + main() + assert capsys.readouterr().out.strip() == "v0.31.0" + + monkeypatch.setattr("sys.argv", ["get_version.py", "--format", "pep440"]) + main() + assert capsys.readouterr().out.strip() == "0.31.0" + + +def test_prerelease_environment(apply_git_env) -> None: + """Verifies that official -rcN pre-releases translate into canonical PEP 440 rc versions.""" + apply_git_env(PRERELEASE_ENV) + + info = get_git_info("monitoring") + assert get_pep440_version() == "0.31.0rc1" + assert compute_image_tag(info) == "v0.31.0-rc1" + + +# ============================================================================== +# Suite B: Tool Output in Development and WIP Environments +# ============================================================================== + + +def test_commits_ahead_clean_environment(apply_git_env) -> None: + """Verifies that commits ahead of a release tag append the short commit hash.""" + env = apply_git_env(COMMITS_AHEAD_CLEAN_ENV) + + info = get_git_info("monitoring") + assert info.is_exact_tag_boundary is False + assert info.is_dirty is False + assert info.is_localcommit is False + + assert get_pep440_version() == f"0.31.0+{env.short_commit_hash}" + assert compute_image_tag(info) == f"v0.31.0-{env.short_commit_hash}" + + +def test_wip_developer_environment(apply_git_env) -> None: + """Verifies that an active fork developer environment tags dirty and localcommit status.""" + env = apply_git_env(WIP_DEVELOPER_ENV) + + assert get_upstream_owner() == "devuser" + + info = get_git_info("monitoring") + assert info.upstream_owner == "devuser" + assert info.is_exact_tag_boundary is False + assert info.is_dirty is True + assert info.is_localcommit is True + + expected_pep440 = f"0.31.0+{env.short_commit_hash}.dirtylocalcommit" + expected_imagetag = f"v0.31.0-{env.short_commit_hash}-dirty-localcommit" + + assert get_pep440_version() == expected_pep440 + assert compute_image_tag(info) == expected_imagetag + + +def test_dirty_only_environment(apply_git_env) -> None: + """Verifies that uncommitted edits directly on a tag append dirty metadata.""" + apply_git_env(DIRTY_ONLY_ENV) + + info = get_git_info("monitoring") + assert info.is_exact_tag_boundary is True + assert info.is_dirty is True + assert info.is_localcommit is False + + assert get_pep440_version() == "0.31.0+dirty" + assert compute_image_tag(info) == "v0.31.0-dirty" + + +def test_local_commit_only_environment(apply_git_env) -> None: + """Verifies that unpushed commits ahead of tag append commit hash and localcommit metadata.""" + env = apply_git_env(LOCAL_COMMIT_ONLY_ENV) + + info = get_git_info("monitoring") + assert info.is_exact_tag_boundary is False + assert info.is_dirty is False + assert info.is_localcommit is True + + assert get_pep440_version() == f"0.31.0+{env.short_commit_hash}.localcommit" + assert compute_image_tag(info) == f"v0.31.0-{env.short_commit_hash}-localcommit" + + +# ============================================================================== +# Suite C: Upstream Remote Resolution Branches +# ============================================================================== + + +def test_interuss_remote_fallback(apply_git_env) -> None: + """Verifies fallback to interuss remote when origin remote is absent.""" + apply_git_env(INTERUSS_REMOTE_FALLBACK_ENV) + assert get_upstream_owner() == "interuss" + assert get_pep440_version() == "0.31.0" + + +def test_upstream_tracking_branch_fallback(apply_git_env) -> None: + """Verifies fallback to git @{upstream} tracking branch when no remotes are present.""" + apply_git_env(UPSTREAM_TRACKING_BRANCH_ENV) + assert get_upstream_owner() == "upstream-org" + assert get_pep440_version() == "0.31.0" + + +def test_upstream_branch_error_fallback(apply_git_env) -> None: + """Verifies graceful handling when @{upstream} query fails and remotes are absent.""" + apply_git_env(UPSTREAM_BRANCH_ERROR_ENV) + assert get_upstream_owner() == "unknown" + + +def test_non_github_remote_url(apply_git_env) -> None: + """Verifies owner extraction from non-GitHub (e.g. GitLab/internal) remote URLs.""" + apply_git_env(NON_GITHUB_REMOTE_ENV) + assert get_upstream_owner() == "custom-org" + assert get_pep440_version() == "1.0.0" + + +# ============================================================================== +# Suite D: Error Handling and Tag Validation +# ============================================================================== + + +def test_no_tags_environment(apply_git_env) -> None: + """Verifies fallback to v0.0.0 when no matching tags exist in repository history.""" + env = apply_git_env(NO_TAGS_ENV) + + info = get_git_info("monitoring") + assert info.baseline_tag == "interuss/monitoring/v0.0.0" + assert info.is_exact_tag_boundary is False + + assert get_pep440_version() == f"0.0.0+{env.short_commit_hash}" + assert compute_image_tag(info) == f"v0.0.0-{env.short_commit_hash}" + + +def test_malformed_prerelease_tag_fails_validation(apply_git_env) -> None: + """Verifies strict InterUSS validation error on non-conforming pre-release suffix.""" + apply_git_env(MALFORMED_PRERELEASE_TAG_ENV) + + with pytest.raises( + ValueError, + match="Strict Validation Failure: Tag '.*' contains a non-conforming", + ): + get_pep440_version() + + +def test_non_semver_tag_fails_validation(apply_git_env) -> None: + """Verifies strict InterUSS validation error on non-SemVer tag.""" + apply_git_env(NON_SEMVER_TAG_ENV) + + with pytest.raises( + ValueError, match="violates InterUSS repository SemVer conventions" + ): + get_pep440_version() + + +# ============================================================================== +# Suite E: Fallback Chain When Git Is Unavailable +# ============================================================================== + + +def test_git_unavailable_with_monitoring_version_override( + apply_git_env: Callable[[GitEnvironment], GitEnvironment], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verifies that MONITORING_VERSION environment variable takes highest priority.""" + apply_git_env(GIT_UNAVAILABLE_ENV) + monkeypatch.setenv("MONITORING_VERSION", "2.5.0") + assert get_pep440_version() == "2.5.0" + + +def test_git_unavailable_fallback_to_version_file( + apply_git_env: Callable[[GitEnvironment], GitEnvironment], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verifies that get_pep440_version falls back to monitoring/_version.py when git is absent.""" + apply_git_env(GIT_UNAVAILABLE_ENV) + + fake_version_file = tmp_path / "_version.py" + fake_version_file.write_text('__version__ = "0.28.4"\nversion = __version__\n') + monkeypatch.setattr( + "scripts.git.get_version.VERSION_FILE_PATH", str(fake_version_file) + ) + + assert get_pep440_version() == "0.28.4" + + +def test_git_unavailable_fallback_to_pkg_info( + apply_git_env: Callable[[GitEnvironment], GitEnvironment], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verifies that get_pep440_version falls back to PKG-INFO when _version.py is absent.""" + apply_git_env(GIT_UNAVAILABLE_ENV) + + # Point BASE_DIR to tmp_path with a PKG-INFO file and no _version.py + monkeypatch.setattr( + "scripts.git.get_version.VERSION_FILE_PATH", + str(tmp_path / "nonexistent.py"), + ) + monkeypatch.setattr("scripts.git.get_version.BASE_DIR", str(tmp_path)) + + pkg_info = tmp_path / "PKG-INFO" + pkg_info.write_text( + "Metadata-Version: 2.1\nName: interuss_monitoring\nVersion: 0.19.2\n" + ) + + assert get_pep440_version() == "0.19.2" + + +def test_git_unavailable_ultimate_fallback( + apply_git_env: Callable[[GitEnvironment], GitEnvironment], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verifies that get_pep440_version returns 0.0.0 when no version sources are found.""" + apply_git_env(GIT_UNAVAILABLE_ENV) + + monkeypatch.setattr( + "scripts.git.get_version.VERSION_FILE_PATH", + str(tmp_path / "nonexistent.py"), + ) + monkeypatch.setattr("scripts.git.get_version.BASE_DIR", str(tmp_path)) + + assert get_pep440_version() == "0.0.0" + + +# ============================================================================== +# Suite F: Integration and CLI Invariants +# ============================================================================== + + +def test_real_run_git_cmd() -> None: + """Verifies that unmocked run_git_cmd successfully executes the local git binary.""" + res = run_git_cmd(["--version"]) + assert res.returncode == 0 + assert "git version" in res.stdout + + +def test_main_invalid_format( + apply_git_env: Callable[[GitEnvironment], GitEnvironment], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verifies that an unsupported format option is rejected by the CLI parser.""" + apply_git_env(NOMINAL_RELEASE_ENV) + monkeypatch.setattr("sys.argv", ["get_version.py", "--format", "invalid_format"]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code != 0 + + +def test_derive_pep440_version_env_var_override( + apply_git_env: Callable[[GitEnvironment], GitEnvironment], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verifies that MONITORING_VERSION overrides derive_pep440_version directly.""" + apply_git_env(NOMINAL_RELEASE_ENV) + monkeypatch.setenv("MONITORING_VERSION", "9.9.9") + info = get_git_info("monitoring") + assert derive_pep440_version(info) == "9.9.9" + + +def test_git_unavailable_corrupted_files_fall_through( + apply_git_env: Callable[[GitEnvironment], GitEnvironment], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verifies that unreadable version files or PKG-INFO gracefully fall through to 0.0.0.""" + apply_git_env(GIT_UNAVAILABLE_ENV) + bad_version_dir = tmp_path / "_version_dir" + bad_version_dir.mkdir() + monkeypatch.setattr( + "scripts.git.get_version.VERSION_FILE_PATH", str(bad_version_dir) + ) + monkeypatch.setattr("scripts.git.get_version.BASE_DIR", str(tmp_path)) + (tmp_path / "PKG-INFO").mkdir() + assert get_pep440_version() == "0.0.0" + + +def test_upstream_owner_unknown_when_url_parts_insufficient(apply_git_env) -> None: + """Verifies fallback to 'unknown' when remote URL cannot be split into organization parts.""" + env = GitEnvironment( + name="single_part_remote", + description="Remote URL with single part", + origin_url="unparseable", + full_commit_hash="abcdef0123456789abcdef0123456789abcdef01", + ) + apply_git_env(env) + assert get_upstream_owner() == "unknown" From fb889c618e7f98cd6140d95cd59032ae8c720418 Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Thu, 10 Sep 2026 01:09:31 +0000 Subject: [PATCH 10/10] Adjust setuptools build folder --- .gitignore | 3 +-- pyproject.toml | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 664fbacd32..9b2b831f68 100644 --- a/.gitignore +++ b/.gitignore @@ -23,8 +23,7 @@ sdist/ var/ wheels/ *.egg-info/ -build/lib/ -build/bdist*/ +.build/ .installed.cfg *.egg MANIFEST diff --git a/pyproject.toml b/pyproject.toml index 887320aaed..6905f360b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,7 +100,6 @@ exclude = [ "monitoring/mock_uss/output/*", "monitoring/uss_qualifier/output/*", "monitoring/benchmarker/output/*", - "build/lib/*", "dist/*", ] @@ -130,3 +129,6 @@ namespaces = true [tool.setuptools.dynamic] version = {attr = "monitoring._version.__version__"} + +[tool.distutils.build] +build_base = ".build"