Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 1 addition & 7 deletions .github/workflows/release-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,7 @@ jobs:
- name: Install package and release checks
run: python -m pip install ".[dev]"
- name: Verify release version
run: |
expected="$RELEASE_VERSION"
if [[ -z "$expected" ]]; then
expected="${GITHUB_REF_NAME#v}"
fi
test -n "$expected"
test "$(tr -d '[:space:]' < VERSION)" = "$expected"
run: ./tests/verify_release_version.sh
- name: Validate repository baseline
run: ./tests/validate.sh
- name: Validate clean package contents
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "base-cli-demo"
version = "0.1.0"
dynamic = ["version"]
description = "Reference consumer and learning application for the base-cli framework"
readme = "README.md"
requires-python = ">=3.10"
Expand Down Expand Up @@ -42,6 +42,9 @@ northstar-telemetry = "base_cli_demo.telemetry_scenario:main"
[tool.setuptools]
package-dir = {"" = "src"}

[tool.setuptools.dynamic]
version = {file = "VERSION"}

[tool.setuptools.packages.find]
where = ["src"]

Expand Down
23 changes: 22 additions & 1 deletion src/base_cli_demo/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
"""A small, realistic consumer application for the base-cli framework."""

from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as distribution_version
from pathlib import Path

__all__ = ["__version__"]

__version__ = "0.1.0"

def _resolve_version() -> str:
"""Use VERSION in a checkout and installed metadata in a built package."""

checkout_root = Path(__file__).resolve().parents[2]
version_file = checkout_root / "VERSION"
if (checkout_root / "pyproject.toml").is_file() and version_file.is_file():
value = version_file.read_text(encoding="utf-8").splitlines()[0].strip()
if value:
return value

try:
return distribution_version("base-cli-demo")
except PackageNotFoundError:
return "0.0.0"


__version__ = _resolve_version()
5 changes: 3 additions & 2 deletions src/base_cli_demo/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
import base_cli
import click

from .profile import get_config, northstar_profile, normalize_release_version
from . import __version__
from .profile import get_config, normalize_release_version, northstar_profile

SERVICE_NAMES = ("orders-api", "billing-worker", "web")
OUTPUT_FORMAT = click.Choice(
Expand Down Expand Up @@ -341,7 +342,7 @@ def show_config(output_format: str) -> None:

app = base_cli.App(
name="northstar",
version="0.1.0",
version=__version__,
profile=northstar_profile(),
lifecycle_options=base_cli.LifecycleOptions(
environment=base_cli.LifecycleOption(
Expand Down
38 changes: 1 addition & 37 deletions tests/package.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,40 +12,4 @@ sdist_path="$(find "$artifact_dir" -maxdepth 1 -name '*.tar.gz' -print -quit)"
[[ -n "$wheel_path" ]] || { printf 'Wheel was not built.\n' >&2; exit 1; }
[[ -n "$sdist_path" ]] || { printf 'Source distribution was not built.\n' >&2; exit 1; }

python - "$wheel_path" "$sdist_path" <<'PY'
from __future__ import annotations

import sys
import tarfile
import zipfile
from pathlib import Path

wheel_path = Path(sys.argv[1])
sdist_path = Path(sys.argv[2])

with zipfile.ZipFile(wheel_path) as wheel:
wheel_names = set(wheel.namelist())
required_wheel_files = {
"base_cli_demo/__init__.py",
"base_cli_demo/cli.py",
"base_cli_demo/fixtures/services.json",
}
missing_wheel = required_wheel_files - wheel_names
if missing_wheel:
raise SystemExit(f"Wheel is missing: {sorted(missing_wheel)}")

with tarfile.open(sdist_path, "r:gz") as sdist:
sdist_names = set(sdist.getnames())
sdist_root = sdist_path.name.removesuffix(".tar.gz")
required_sdist_files = {
f"{sdist_root}/README.md",
f"{sdist_root}/pyproject.toml",
f"{sdist_root}/src/base_cli_demo/cli.py",
f"{sdist_root}/src/base_cli_demo/fixtures/services.json",
}
missing_sdist = required_sdist_files - sdist_names
if missing_sdist:
raise SystemExit(f"Source distribution is missing: {sorted(missing_sdist)}")

print(f"Validated {wheel_path.name} and {sdist_path.name}.")
PY
python tests/package_validation.py "$wheel_path" "$sdist_path"
86 changes: 86 additions & 0 deletions tests/package_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Validate the contents and metadata of built demo distributions."""

from __future__ import annotations

import sys
import tarfile
import zipfile
from email.parser import Parser
from pathlib import Path

from version_identity import assert_versions_match, source_version


def _metadata_name(
names: set[str], suffix: str, archive: str, *, preferred: str | None = None
) -> str:
if preferred is not None and preferred in names:
return preferred
matches = sorted(name for name in names if name.endswith(suffix))
if not matches:
raise SystemExit(f"{archive} is missing metadata entry {suffix!r}.")
if len(matches) > 1:
raise SystemExit(f"{archive} contains multiple metadata entries: {matches}")
return matches[0]


def validate(wheel_path: Path, sdist_path: Path) -> None:
expected_version = source_version()

with zipfile.ZipFile(wheel_path) as wheel:
wheel_names = set(wheel.namelist())
metadata_name = _metadata_name(
wheel_names, ".dist-info/METADATA", wheel_path.name
)
wheel_metadata = Parser().parsestr(
wheel.read(metadata_name).decode("utf-8")
)
required_wheel_files = {
"base_cli_demo/__init__.py",
"base_cli_demo/cli.py",
"base_cli_demo/fixtures/services.json",
}
missing_wheel = required_wheel_files - wheel_names
if missing_wheel:
raise SystemExit(f"Wheel is missing: {sorted(missing_wheel)}")

with tarfile.open(sdist_path, "r:gz") as sdist:
sdist_names = set(sdist.getnames())
sdist_root = sdist_path.name.removesuffix(".tar.gz")
metadata_name = _metadata_name(
sdist_names,
"/PKG-INFO",
sdist_path.name,
preferred=f"{sdist_root}/PKG-INFO",
)
metadata_file = sdist.extractfile(metadata_name)
if metadata_file is None:
raise SystemExit(f"Unable to read source metadata entry {metadata_name!r}.")
sdist_metadata = Parser().parsestr(
metadata_file.read().decode("utf-8")
)
required_sdist_files = {
f"{sdist_root}/README.md",
f"{sdist_root}/pyproject.toml",
f"{sdist_root}/VERSION",
f"{sdist_root}/src/base_cli_demo/cli.py",
f"{sdist_root}/src/base_cli_demo/fixtures/services.json",
}
missing_sdist = required_sdist_files - sdist_names
if missing_sdist:
raise SystemExit(f"Source distribution is missing: {sorted(missing_sdist)}")

assert_versions_match(
expected_version,
**{
"wheel metadata": wheel_metadata["Version"],
"sdist metadata": sdist_metadata["Version"],
},
)
print(f"Validated {wheel_path.name} and {sdist_path.name}.")


if __name__ == "__main__":
if len(sys.argv) != 3:
raise SystemExit("usage: package_validation.py WHEEL SDIST")
validate(Path(sys.argv[1]), Path(sys.argv[2]))
13 changes: 13 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ def test_help_exposes_nested_consumer_commands_and_lifecycle_options() -> None:
assert "--dry-run" in result.stdout


def test_cli_version_matches_the_installed_package_metadata() -> None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup: near-duplicate of tests/test_version_identity.py::test_source_version_matches_installed_metadata_and_cli.

This new test asserts essentially the same property (module __version__ == importlib.metadata.version("base-cli-demo") == CLI --version output) with a weaker, ad-hoc assert __version__ in result.stdout" substring check, duplicating what test_version_identity.py's test already verifies more rigorously via the shared assert_versions_match" helper (which also checks against the VERSION file). Two near-identical tests now need to be kept in sync for any change to the --version output format.

from importlib.metadata import version

from base_cli_demo import __version__

with tempfile.TemporaryDirectory() as directory:
result = invoke(["--version"], Path(directory))

assert result.exit_code == 0, result.output
assert __version__ == version("base-cli-demo")
assert __version__ in result.stdout


def test_status_reads_the_selected_local_fixture_environment() -> None:
with tempfile.TemporaryDirectory() as directory:
result = invoke(
Expand Down
38 changes: 38 additions & 0 deletions tests/test_version_identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from __future__ import annotations

from importlib.metadata import version
from pathlib import Path
from tempfile import TemporaryDirectory

import pytest
import base_cli

from base_cli_demo import __version__
from version_identity import assert_versions_match, source_version


def test_source_version_matches_installed_metadata_and_cli() -> None:
from base_cli_demo.cli import command

expected = source_version()
with TemporaryDirectory() as directory:
result = base_cli.testing.invoke(
command, ["--quiet", "--version"], home=Path(directory)
)

assert result.exit_code == 0, result.output
cli_version = result.stdout.strip().rsplit(" ", maxsplit=1)[-1]
assert_versions_match(
expected,
module=__version__,
distribution=version("base-cli-demo"),
cli=cli_version,
)


@pytest.mark.parametrize("representation", ["tag", "wheel", "sdist", "cli"])
def test_release_gate_rejects_a_mismatched_version_representation(
representation: str,
) -> None:
with pytest.raises(ValueError, match="Release version mismatch"):
assert_versions_match("0.1.0", **{representation: "0.2.0"})
27 changes: 27 additions & 0 deletions tests/verify_release_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Verify VERSION, the release tag, and the requested release version agree."""

from __future__ import annotations

import os

from version_identity import assert_versions_match, source_version


def main() -> None:
expected = os.environ.get("RELEASE_VERSION", "")
ref_type = os.environ.get("GITHUB_REF_TYPE", "")
ref_name = os.environ.get("GITHUB_REF_NAME", "")
if not expected and ref_type == "tag":
expected = ref_name.removeprefix("v")
if not expected:
raise SystemExit("RELEASE_VERSION or a v-prefixed release tag is required.")

representations = {"VERSION": source_version()}
if ref_type == "tag":
representations["tag"] = ref_name.removeprefix("v")
assert_versions_match(expected, **representations)
print(f"Validated release version {expected}.")


if __name__ == "__main__":
main()
4 changes: 4 additions & 0 deletions tests/verify_release_version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail

PYTHONPATH=tests python tests/verify_release_version.py
29 changes: 29 additions & 0 deletions tests/version_identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Shared assertion for the release version identity test and package gate."""

from __future__ import annotations

from pathlib import Path


def source_version() -> str:
"""Read the canonical version from the repository checkout."""

value = Path("VERSION").read_text(encoding="utf-8").strip()
if not value:
raise ValueError("VERSION must contain a non-empty release version.")
return value


def assert_versions_match(expected: str, **representations: str) -> None:
"""Fail with all conflicting representations instead of choosing one."""

mismatches = {
source: value
for source, value in representations.items()
if value != expected
}
if not expected or mismatches:
raise ValueError(
f"Release version mismatch: expected {expected!r}; "
f"conflicting representations: {mismatches}."
)
1 change: 0 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading