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/.gitignore b/.gitignore index 5d8dba9999..9b2b831f68 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ sdist/ var/ wheels/ *.egg-info/ +.build/ .installed.cfg *.egg MANIFEST @@ -129,5 +130,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/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..d161c007dd 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][-rcN]` to support PEP 440 representations. + +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. -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]`)**: + * 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 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+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`**. +* **Current Import Namespace**: + * Consumers install the package via `pip install interuss_monitoring`. + * 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 @@ -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 b53bdcf8b9..4ff34398cb 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_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..82794c844f 100755 --- a/monitoring/build.sh +++ b/monitoring/build.sh @@ -12,14 +12,52 @@ 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 commitsha1) +scripts/git/write_version_file.py > /dev/null + +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 69d3117f1c..5206adf712 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" }, @@ -100,13 +100,13 @@ exclude = [ "monitoring/mock_uss/output/*", "monitoring/uss_qualifier/output/*", "monitoring/benchmarker/output/*", + "dist/*", ] [build-system] 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" @@ -118,16 +118,17 @@ 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 -[tool.setuptools_scm] -write_to = "monitoring/_version.py" -tag_regex = "^interuss/monitoring/v(?P\\d+\\.\\d+\\.\\d+)" +[tool.setuptools.dynamic] +version = {attr = "monitoring._version.__version__"} +[tool.distutils.build] +build_base = ".build" 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..0302beb5b1 --- /dev/null +++ b/scripts/git/get_version.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +""" +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 +from dataclasses import dataclass + +# 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 +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 SHA-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( + ["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.""" + 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.removesuffix(".git") for p in re.split(r"[/:\\]", upstream_repo) if p] + if len(parts) >= 2: + return parts[-2] + + return "unknown" + + +def get_git_info(component: str) -> GitInfo: + """Interrogates git for information relevant to versioning.""" + + upstream_owner = get_upstream_owner() + + tag_match_pattern = f"{upstream_owner}/{component}/*" + + # 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"{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, + ) + is_exact_tag_boundary = exact_res.returncode == 0 + + # Retrieve the full commit hash + full_hash_res = run_git_cmd(["rev-parse", "HEAD"]) + full_commit_hash = full_hash_res.stdout.strip() + + # Retrieve short commit hash + short_hash_res = run_git_cmd(["rev-parse", "--short", "HEAD"]) + 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()) + + # Retrieve whether commit is local-only + cherry_res = run_git_cmd(["cherry"], check=False) + 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, + ) + + +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. + """ + + 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+)?$" + ) + malformed_prerelease_regex = re.compile( + rf"^{re.escape(info.upstream_owner)}/{re.escape(info.component)}/v\d+\.\d+\.\d+-(.*)$" + ) + + 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 '{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." + ) + + if not strict_match: + raise ValueError( + 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") + 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 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)}" + else: + return pep440_base + + +def compute_image_tag(info: GitInfo) -> str: + """Computes the semantic version string with which a docker image should be tagged.""" + + tag = info.baseline_tag.split("/")[-1] + + build_parts = [] + if not info.is_exact_tag_boundary: + build_parts.append(info.short_commit_hash) + if info.is_dirty: + build_parts.append("dirty") + if info.is_localcommit: + build_parts.append("localcommit") + if build_parts: + tag += "-" + "-".join(build_parts) + 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." + ) + parser.add_argument( + "--format", + choices=[ + "pep440", + "imagetag", + "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" + " commitsha1: Current commit full hash abbreviation without any status suffix (e.g., 'd56bb4d417242e0c29bd6b64837aa8bf5adad487')." + ), + ) + + args = parser.parse_args() + + 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(get_pep440_version()) + + else: + raise ValueError(f"Invalid requested format '{args.format}'") + + +if __name__ == "__main__": + main() 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" 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/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/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/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() diff --git a/uv.lock b/uv.lock index 27ad8a2c28..faa0d88e62 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.38.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.6.0" }, + { name = "kubernetes", specifier = ">=36.0.3" }, + { name = "locust", specifier = ">=2.46.4" }, + { name = "loguru", specifier = ">=0.7.3" }, + { name = "lxml", specifier = ">=6.1.3" }, + { 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.5" }, + { 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.5" }, + { 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.38.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.6.0" }, - { name = "kubernetes", specifier = ">=36.0.3" }, - { name = "locust", specifier = ">=2.46.4" }, - { name = "loguru", specifier = ">=0.7.3" }, - { name = "lxml", specifier = ">=6.1.3" }, - { 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.5" }, - { 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.5" }, - { name = "types-lxml", specifier = ">=2026.2.16" }, -] - [[package]] name = "msgpack" version = "1.2.1"