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
6 changes: 6 additions & 0 deletions .github/workflows/release-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ jobs:
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: '3.12'
- name: Require installable companion distributions for an adcp release
if: inputs.package == 'adcp'
run: |
python -m pip install packaging
version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
python scripts/check_companion_releases.py "$version"
- name: Build and check distributions
env:
PACKAGE: ${{ inputs.package }}
Expand Down
97 changes: 97 additions & 0 deletions scripts/check_companion_releases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Refuse to publish an ``adcp`` release its companion distributions can't install with.

9.0.0 moved the PostgreSQL reporting stores into ``adcp-reporting-storage``
before that package was on PyPI, so the documented
``pip install "adcp-reporting-storage[pg]"`` failed and Postgres adopters were
stuck on the last beta. Before an ``adcp`` release publishes, every companion
distribution must have a published, non-yanked release whose ``adcp``
requirement accepts the version being released.
"""

from __future__ import annotations

import argparse
import json
import sys
import urllib.error
import urllib.request
from collections.abc import Callable
from typing import Any

from packaging.requirements import Requirement
from packaging.utils import canonicalize_name
from packaging.version import InvalidVersion, Version

#: Distributions maintained in this repository that adopters install with adcp.
COMPANIONS = ("adcp-reporting-storage",)

Fetch = Callable[[str], Any]


def _pypi(url: str) -> Any:
request = urllib.request.Request(url, headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310
return json.load(response)
except urllib.error.HTTPError as error:
if error.code == 404:
return None
raise


def _adcp_requirement(requires_dist: list[str] | None) -> Requirement | None:
for line in requires_dist or ():
requirement = Requirement(line)
if canonicalize_name(requirement.name) == "adcp" and requirement.marker is None:
return requirement
return None


def compatible_release(companion: str, adcp_version: Version, fetch: Fetch = _pypi) -> str | None:
"""Return the newest published ``companion`` release that installs with ``adcp_version``."""
project = fetch(f"https://pypi.org/pypi/{companion}/json")
if not project:
return None
candidates = []
for raw, files in project.get("releases", {}).items():
try:
version = Version(raw)
except InvalidVersion:
continue
if files and not all(file.get("yanked") for file in files):
candidates.append(version)
for version in sorted(candidates, reverse=True):
release = fetch(f"https://pypi.org/pypi/{companion}/{version}/json")
requirement = _adcp_requirement((release or {}).get("info", {}).get("requires_dist"))
if requirement is not None and requirement.specifier.contains(
adcp_version, prereleases=True
):
return str(version)
return None


def main(argv: list[str] | None = None, fetch: Fetch = _pypi) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("adcp_version", help="the adcp version about to be published")
args = parser.parse_args(argv)
adcp_version = Version(args.adcp_version)
missing = []
for companion in COMPANIONS:
found = compatible_release(companion, adcp_version, fetch)
if found is None:
missing.append(companion)
else:
print(f"{companion} {found} installs with adcp {adcp_version}")
if missing:
print(
f"No published release of {', '.join(missing)} accepts adcp {adcp_version}. "
"Publish a compatible companion release first.",
file=sys.stderr,
)
return 1
return 0


if __name__ == "__main__":
raise SystemExit(main())
74 changes: 74 additions & 0 deletions tests/test_check_companion_releases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""The adcp release gate requires an installable companion on PyPI."""

from __future__ import annotations

from typing import Any

import pytest
from packaging.version import Version

from scripts import check_companion_releases as gate

_PROJECT = "https://pypi.org/pypi/adcp-reporting-storage/json"


def _release(requirement: str | None) -> dict[str, Any]:
requires = ["psycopg>=3; extra == 'pg'"] + ([requirement] if requirement else [])
return {"info": {"requires_dist": requires}}


def _fetch(releases: dict[str, tuple[str | None, bool]]) -> gate.Fetch:
data: dict[str, Any] = {
_PROJECT: {
"releases": {version: [{"yanked": yanked}] for version, (_, yanked) in releases.items()}
}
}
for version, (requirement, _) in releases.items():
data[f"https://pypi.org/pypi/adcp-reporting-storage/{version}/json"] = _release(requirement)
return lambda url: data.get(url)


def test_missing_companion_blocks_the_release() -> None:
assert gate.main(["9.0.0"], fetch=lambda url: None) == 1


def test_compatible_companion_beta_allows_the_release() -> None:
fetch = _fetch({"0.1.0b4": ("adcp>=9.0.0,<10", False)})
assert gate.compatible_release("adcp-reporting-storage", Version("9.1.0"), fetch) == "0.1.0b4"
assert gate.main(["9.1.0"], fetch=fetch) == 0


def test_a_new_adcp_major_needs_a_companion_that_accepts_it() -> None:
fetch = _fetch({"0.1.0b4": ("adcp>=9.0.0,<10", False)})
assert gate.main(["10.0.0"], fetch=fetch) == 1


def test_prerelease_adcp_versions_match_a_prerelease_floor() -> None:
fetch = _fetch({"0.1.0b1": ("adcp>=9.0.0b4,<10", False)})
assert gate.compatible_release("adcp-reporting-storage", Version("9.0.0b5"), fetch)


@pytest.mark.parametrize(
"releases",
[
{"0.1.0b4": ("adcp>=9.0.0,<10", True)},
{"0.1.0b4": (None, False)},
{"0.1.0b4": ("adcp>=9.2.0,<10", False)},
],
ids=["yanked", "no-adcp-requirement", "floor-above"],
)
def test_unusable_companion_releases_do_not_count(
releases: dict[str, tuple[str | None, bool]],
) -> None:
assert gate.main(["9.1.0"], fetch=_fetch(releases)) == 1


def test_newest_compatible_release_is_reported() -> None:
fetch = _fetch(
{
"0.1.0b1": ("adcp>=9.0.0b4,<10", False),
"0.2.0": ("adcp>=10,<11", False),
"0.1.0b4": ("adcp>=9.0.0,<10", False),
}
)
assert gate.compatible_release("adcp-reporting-storage", Version("9.1.0"), fetch) == "0.1.0b4"
Loading