From fb6618ed7efbad152eb31d905ab1c5fbcb6d033f Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 18 Mar 2025 16:28:24 -0500 Subject: [PATCH 01/15] Draft of GitHubReleaseSource --- src/spec0/releasesource.py | 94 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 4 deletions(-) diff --git a/src/spec0/releasesource.py b/src/spec0/releasesource.py index 028a090..34ba019 100644 --- a/src/spec0/releasesource.py +++ b/src/spec0/releasesource.py @@ -74,11 +74,97 @@ def _get_releases(self, package: str) -> Generator[Release, None, None]: class GitHubReleaseSource(ReleaseSource): - def _get_releases(self, package: str) -> Generator[Release, None, None]: ... - + """Class to fetch all GitHub releases for a given repository using the GitHub GraphQL API.""" + + def __init__(self, github_token: str): + """ + Initialize the release source with a GitHub token. + + Parameters + ---------- + github_token : str + Personal access token (PAT) with permissions to query the desired repository. + """ + self.github_token = github_token + + def _get_releases(self, owner_repo: str): + """ + Generate all releases for a repository in descending order of creation date (most recent first). + + Parameters + ---------- + owner_repo : str + A string in the format "owner/repo" that identifies the repository. + + Yields + ------ + Release + A `Release` object containing: + - `version` (packaging.version.Version) + - `release_date` (datetime.datetime) + + Warns + ----- + UserWarning + When `tagName` from GitHub cannot be parsed as a valid version. + """ + owner, repo = owner_repo.split("/", 1) + + query = """ + query($owner: String!, $repo: String!, $after: String) { + repository(owner: $owner, name: $repo) { + releases(first: 100, after: $after, orderBy: {field: CREATED_AT, direction: DESC}) { + pageInfo { + endCursor + hasNextPage + } + nodes { + tagName + createdAt + } + } + } + } + """ + + url = "https://api.github.com/graphql" + headers = { + "Authorization": f"Bearer {self.github_token}", + "Accept": "application/vnd.github.v3+json", + } + + has_next_page = True + after_cursor = None + + while has_next_page: + variables = { + "owner": owner, + "repo": repo, + "after": after_cursor, + } + response = requests.post( + url, json={"query": query, "variables": variables}, headers=headers + ) + response.raise_for_status() + + data = response.json() + releases_data = data["data"]["repository"]["releases"] + + for node in releases_data["nodes"]: + tag_name = node["tagName"] + try: + version = Version(tag_name) + except InvalidVersion: + warnings.warn(f"Skipping invalid version: {tag_name}", UserWarning) + continue # Skip this release + + release_date = datetime.fromisoformat( + node["createdAt"].replace("Z", "+00:00") + ) + yield Release(version, release_date) -class GitHubTagReleaseSource(ReleaseSource): - def _get_releases(self, package: str) -> Generator[Release, None, None]: ... + has_next_page = releases_data["pageInfo"]["hasNextPage"] + after_cursor = releases_data["pageInfo"]["endCursor"] class CondaReleaseSource(ReleaseSource): From daa8e9c84833fe7986fffd7c7086f91d59005900 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 18 Mar 2025 16:56:24 -0500 Subject: [PATCH 02/15] Add tests for GitHubReleaseSource --- src/spec0/releasesource.py | 5 +- tests/test_releasesource.py | 183 ++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 2 deletions(-) diff --git a/src/spec0/releasesource.py b/src/spec0/releasesource.py index 34ba019..ad2df47 100644 --- a/src/spec0/releasesource.py +++ b/src/spec0/releasesource.py @@ -89,7 +89,8 @@ def __init__(self, github_token: str): def _get_releases(self, owner_repo: str): """ - Generate all releases for a repository in descending order of creation date (most recent first). + Generate all releases for a repository in descending order of + creation date (most recent first). Parameters ---------- @@ -158,7 +159,7 @@ def _get_releases(self, owner_repo: str): warnings.warn(f"Skipping invalid version: {tag_name}", UserWarning) continue # Skip this release - release_date = datetime.fromisoformat( + release_date = datetime.datetime.fromisoformat( node["createdAt"].replace("Z", "+00:00") ) yield Release(version, release_date) diff --git a/tests/test_releasesource.py b/tests/test_releasesource.py index 2cd7153..542b4f4 100644 --- a/tests/test_releasesource.py +++ b/tests/test_releasesource.py @@ -2,6 +2,7 @@ import responses import warnings import datetime +import os from packaging.version import Version from requires_internet import requires_internet @@ -182,3 +183,185 @@ def test_integration_releases(self, package_name): dates = [r.release_date for r in releases if r.release_date is not None] assert_is_descending(dates) + + +MOCK_GH_RESPONSE_VALID_ONLY = { + "data": { + "repository": { + "releases": { + "pageInfo": {"endCursor": None, "hasNextPage": False}, + "nodes": [ + {"tagName": "2.2.0", "createdAt": "2023-03-03T12:00:00Z"}, + {"tagName": "2.1.0", "createdAt": "2023-02-10T09:00:00Z"}, + {"tagName": "1.9.0", "createdAt": "2023-01-15T20:00:00Z"}, + ], + } + } + } +} + +MOCK_GH_RESPONSE_MIXED = { + "data": { + "repository": { + "releases": { + "pageInfo": {"endCursor": None, "hasNextPage": False}, + "nodes": [ + {"tagName": "10.0.0", "createdAt": "2024-01-01T12:00:00Z"}, + { + "tagName": "not-a-valid-version", + "createdAt": "2024-01-02T12:00:00Z", + }, + {"tagName": "9.9.9", "createdAt": "2023-12-15T12:00:00Z"}, + ], + } + } + } +} + +MOCK_GH_RESPONSE_PAGE1 = { + "data": { + "repository": { + "releases": { + "pageInfo": {"endCursor": "CURSOR1", "hasNextPage": True}, + "nodes": [ + {"tagName": "3.0.0", "createdAt": "2024-01-05T12:00:00Z"}, + {"tagName": "2.5.0", "createdAt": "2023-12-20T12:00:00Z"}, + ], + } + } + } +} + +MOCK_GH_RESPONSE_PAGE2 = { + "data": { + "repository": { + "releases": { + "pageInfo": {"endCursor": None, "hasNextPage": False}, + "nodes": [ + {"tagName": "2.2.0", "createdAt": "2023-11-10T12:00:00Z"}, + {"tagName": "2.0.0", "createdAt": "2023-10-01T12:00:00Z"}, + ], + } + } + } +} + + +class TestGitHubReleaseSource: + @responses.activate + def test_valid_only_versions(self): + """Test that valid versions are parsed and returned in descending order.""" + url = "https://api.github.com/graphql" + responses.add( + responses.POST, + url, + json=MOCK_GH_RESPONSE_VALID_ONLY, + status=200, + ) + + token = "FAKE_TOKEN" + source = GitHubReleaseSource(token) + releases = list(source._get_releases("octocat/Hello-World")) + + # Verify the number of releases. + assert len(releases) == 3 + + # Check that the versions are correctly parsed. + versions = [r.version for r in releases] + assert versions == [Version("2.2.0"), Version("2.1.0"), Version("1.9.0")] + + # Verify that the release dates are in descending order. + release_dates = [r.release_date for r in releases] + assert_is_descending(release_dates) + assert release_dates == [ + datetime.datetime(2023, 3, 3, 12, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2023, 2, 10, 9, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2023, 1, 15, 20, 0, tzinfo=datetime.timezone.utc), + ] + + @responses.activate + def test_mixed_versions_warning(self): + """Test that invalid version strings trigger a warning and are skipped.""" + url = "https://api.github.com/graphql" + responses.add( + responses.POST, + url, + json=MOCK_GH_RESPONSE_MIXED, + status=200, + ) + + token = "FAKE_TOKEN" + with pytest.warns(UserWarning, match="Skipping invalid version"): + warnings.simplefilter("always") + source = GitHubReleaseSource(token) + releases = list(source._get_releases("octocat/Hello-World")) + + # Only the valid versions should be returned. + assert len(releases) == 2 + versions = [r.version for r in releases] + assert versions == [Version("10.0.0"), Version("9.9.9")] + + release_dates = [r.release_date for r in releases] + assert_is_descending(release_dates) + assert release_dates == [ + datetime.datetime(2024, 1, 1, 12, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2023, 12, 15, 12, 0, tzinfo=datetime.timezone.utc), + ] + + @responses.activate + def test_pagination(self): + """ + Test that pagination is handled correctly by returning all releases from + multiple pages in the proper order. + """ + url = "https://api.github.com/graphql" + # Simulate two paginated responses. + responses.add( + responses.POST, + url, + json=MOCK_GH_RESPONSE_PAGE1, + status=200, + ) + responses.add( + responses.POST, + url, + json=MOCK_GH_RESPONSE_PAGE2, + status=200, + ) + + token = "FAKE_TOKEN" + source = GitHubReleaseSource(token) + releases = list(source._get_releases("octocat/Hello-World")) + + # We expect 4 releases total. + assert len(releases) == 4 + + # Check the expected version order. + expected_versions = [ + Version("3.0.0"), + Version("2.5.0"), + Version("2.2.0"), + Version("2.0.0"), + ] + versions = [r.version for r in releases] + assert versions == expected_versions + + # Verify that the release dates are in descending order. + release_dates = [r.release_date for r in releases] + assert_is_descending(release_dates) + + @pytest.mark.skipif( + not os.environ.get("GITHUB_TOKEN"), reason="GITHUB_TOKEN not set" + ) + def test_integration_openpathsampling(self): + """ + Integration test using the real GitHub API to fetch releases for the + repository openpathsampling/openpathsampling. Checks that at least one + release is returned and that the releases are in descending order. + """ + token = os.environ["GITHUB_TOKEN"] + source = GitHubReleaseSource(token) + releases = list(source._get_releases("openpathsampling/openpathsampling")) + assert len(releases) > 0 + release_dates = [r.release_date for r in releases] + assert_is_descending(release_dates) From 1a6329f3a20ebc801432e8cbc67fede8ca09d0b0 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 3 Apr 2025 13:37:19 -0500 Subject: [PATCH 03/15] cleanup docstrings a bit --- src/spec0/releasesource.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/spec0/releasesource.py b/src/spec0/releasesource.py index ad2df47..91f7cc4 100644 --- a/src/spec0/releasesource.py +++ b/src/spec0/releasesource.py @@ -74,17 +74,16 @@ def _get_releases(self, package: str) -> Generator[Release, None, None]: class GitHubReleaseSource(ReleaseSource): - """Class to fetch all GitHub releases for a given repository using the GitHub GraphQL API.""" + """ + Class to fetch all GitHub releases for a given repository using the GitHub GraphQL API. - def __init__(self, github_token: str): - """ - Initialize the release source with a GitHub token. + Parameters + ---------- + github_token : str + Personal access token (PAT) with permissions to query the desired repository. + """ - Parameters - ---------- - github_token : str - Personal access token (PAT) with permissions to query the desired repository. - """ + def __init__(self, github_token: str): self.github_token = github_token def _get_releases(self, owner_repo: str): From 0fb2e566f5f80a3b14831292afbaf3d3c6db240f Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 8 Apr 2025 18:16:23 -0500 Subject: [PATCH 04/15] Working example with GitHub releases; too bad I need tags! --- src/spec0/cli.py | 24 ++++++++++++++++++------ src/spec0/data/github-releases.json | 3 +++ src/spec0/releasefilters.py | 5 ++++- src/spec0/releasesource.py | 20 ++++++++++++++++++-- tests/test_releasesource.py | 12 +++++++----- 5 files changed, 50 insertions(+), 14 deletions(-) create mode 100644 src/spec0/data/github-releases.json diff --git a/src/spec0/cli.py b/src/spec0/cli.py index c2bfc9b..aa709b9 100644 --- a/src/spec0/cli.py +++ b/src/spec0/cli.py @@ -1,9 +1,14 @@ import argparse import logging +import os from functools import partial -from spec0.releasesource import PyPIReleaseSource, CondaReleaseSource +from spec0.releasesource import ( + PyPIReleaseSource, + CondaReleaseSource, + GitHubReleaseSource, +) from spec0.releasefilters import SPEC0StrictDate, SPEC0Quarter from spec0.output import terminal_output, json_output, specifier_output from spec0.main import main @@ -55,7 +60,7 @@ def make_parser(): default=["noarch", "linux-64"], help=("Conda architectures to check, only used if conda-channel is specified"), ) - # source.add_argument('--github', action='store_true') + source.add_argument("--github", action="store_true") # filter options filterg = parser.add_argument_group( @@ -116,8 +121,8 @@ def select_source(opts): """ selected_pypi = opts.pypi selected_conda = opts.conda_channel is not None - # selected_github = opts.github - n_selected = sum([selected_pypi, selected_conda]) + selected_github = opts.github + n_selected = sum([selected_pypi, selected_conda, selected_github]) if n_selected == 0: source = None elif n_selected > 1: @@ -128,8 +133,15 @@ def select_source(opts): elif selected_conda: platforms = [f"{opts.conda_channel}/{arch}" for arch in opts.conda_arch] source = [CondaReleaseSource(platforms)] - # elif selected_github: - # source = GitHubReleaseSource() + elif selected_github: + if (token := os.getenv("GITHUB_TOKEN")) is None: + raise ValueError( + "GITHUB_TOKEN environment variable must be set to use GitHub " + "releases" + ) + + source = [GitHubReleaseSource(token)] + return source diff --git a/src/spec0/data/github-releases.json b/src/spec0/data/github-releases.json new file mode 100644 index 0000000..7a84d06 --- /dev/null +++ b/src/spec0/data/github-releases.json @@ -0,0 +1,3 @@ +{ + "rust": "rust-lang/rust" +} diff --git a/src/spec0/releasefilters.py b/src/spec0/releasefilters.py index c26dbca..8ba4181 100644 --- a/src/spec0/releasefilters.py +++ b/src/spec0/releasefilters.py @@ -66,7 +66,10 @@ def _get_n_months(self, package: str): def _get_minimum_supported(self, package: str, releases: Iterable[Release]): oldest_minor_release = get_oldest_minor_release(releases) - max_minor_release = max(oldest_minor_release) + try: + max_minor_release = max(oldest_minor_release) + except ValueError: # no releases found + raise RuntimeError(f"No releases found for package '{package}'") # always support at least the most recent minor release supported = {max_minor_release: oldest_minor_release[max_minor_release]} diff --git a/src/spec0/releasesource.py b/src/spec0/releasesource.py index c9eefbb..a9f2731 100644 --- a/src/spec0/releasesource.py +++ b/src/spec0/releasesource.py @@ -4,6 +4,8 @@ import requests import warnings from packaging.version import Version, InvalidVersion +import importlib.resources +import collections from typing import Generator @@ -80,7 +82,8 @@ def _get_releases(self, package: str) -> Generator[Release, None, None]: class GitHubReleaseSource(ReleaseSource): """ - Class to fetch all GitHub releases for a given repository using the GitHub GraphQL API. + Class to fetch all GitHub releases for a given repository using the GitHub GraphQL + API. Parameters ---------- @@ -90,8 +93,21 @@ class GitHubReleaseSource(ReleaseSource): def __init__(self, github_token: str): self.github_token = github_token + trav = importlib.resources.files("spec0") + jsonstr = trav.joinpath("data/github-releases.json").read_text() + self.canonical_sources = json.loads(jsonstr) - def _get_releases(self, owner_repo: str): + def _get_releases(self, package: str): + if collections.Counter(package).get("/") == 1: + owner_repo = package + elif package in self.canonical_sources: + owner_repo = self.canonical_sources[package] + else: + raise ValueError(f"GitHub repository for package '{package}' not found") + + yield from self._get_releases_owner_repo(owner_repo) + + def _get_releases_owner_repo(self, owner_repo: str): """ Generate all releases for a repository in descending order of creation date (most recent first). diff --git a/tests/test_releasesource.py b/tests/test_releasesource.py index 542b4f4..0cda56b 100644 --- a/tests/test_releasesource.py +++ b/tests/test_releasesource.py @@ -248,8 +248,9 @@ def test_integration_releases(self, package_name): class TestGitHubReleaseSource: + @pytest.mark.parametrize("inputstr", ["octohello", "octocat/Hello-World"]) @responses.activate - def test_valid_only_versions(self): + def test_valid_only_versions(self, inputstr): """Test that valid versions are parsed and returned in descending order.""" url = "https://api.github.com/graphql" responses.add( @@ -261,7 +262,8 @@ def test_valid_only_versions(self): token = "FAKE_TOKEN" source = GitHubReleaseSource(token) - releases = list(source._get_releases("octocat/Hello-World")) + source.canonical_sources["octohello"] = "octocat/Hello-World" + releases = list(source.get_releases(inputstr)) # Verify the number of releases. assert len(releases) == 3 @@ -294,7 +296,7 @@ def test_mixed_versions_warning(self): with pytest.warns(UserWarning, match="Skipping invalid version"): warnings.simplefilter("always") source = GitHubReleaseSource(token) - releases = list(source._get_releases("octocat/Hello-World")) + releases = list(source._get_releases_owner_repo("octocat/Hello-World")) # Only the valid versions should be returned. assert len(releases) == 2 @@ -331,7 +333,7 @@ def test_pagination(self): token = "FAKE_TOKEN" source = GitHubReleaseSource(token) - releases = list(source._get_releases("octocat/Hello-World")) + releases = list(source._get_releases_owner_repo("octocat/Hello-World")) # We expect 4 releases total. assert len(releases) == 4 @@ -361,7 +363,7 @@ def test_integration_openpathsampling(self): """ token = os.environ["GITHUB_TOKEN"] source = GitHubReleaseSource(token) - releases = list(source._get_releases("openpathsampling/openpathsampling")) + releases = list(source.get_releases("openpathsampling/openpathsampling")) assert len(releases) > 0 release_dates = [r.release_date for r in releases] assert_is_descending(release_dates) From 92b4746b2396ca0905aa02058ea01c22d1793d13 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 8 Apr 2025 19:16:25 -0500 Subject: [PATCH 05/15] Switch so that we're actually doing tags --- src/spec0/data/github-releases.json | 3 +- src/spec0/releasesource.py | 31 +++++++++++--- tests/test_releasesource.py | 63 ++++++++++++++++++++--------- 3 files changed, 71 insertions(+), 26 deletions(-) diff --git a/src/spec0/data/github-releases.json b/src/spec0/data/github-releases.json index 7a84d06..c0ac236 100644 --- a/src/spec0/data/github-releases.json +++ b/src/spec0/data/github-releases.json @@ -1,3 +1,4 @@ { - "rust": "rust-lang/rust" + "rust": "rust-lang/rust", + "python": "python/cpython" } diff --git a/src/spec0/releasesource.py b/src/spec0/releasesource.py index a9f2731..c94acf6 100644 --- a/src/spec0/releasesource.py +++ b/src/spec0/releasesource.py @@ -134,14 +134,28 @@ def _get_releases_owner_repo(self, owner_repo: str): query = """ query($owner: String!, $repo: String!, $after: String) { repository(owner: $owner, name: $repo) { - releases(first: 100, after: $after, orderBy: {field: CREATED_AT, direction: DESC}) { + refs(after:$after, first:100, refPrefix:"refs/tags/", orderBy:{field:TAG_COMMIT_DATE, direction:DESC}) { + pageInfo { endCursor hasNextPage } + nodes { - tagName - createdAt + name + + target { + # looks like this is what you get if you create the tag in the UI + ... on Commit { + committedDate + } + # and this is if you don't? or something? + ... on Tag { + tagger { + date + } + } + } } } } @@ -169,18 +183,23 @@ def _get_releases_owner_repo(self, owner_repo: str): response.raise_for_status() data = response.json() - releases_data = data["data"]["repository"]["releases"] + releases_data = data["data"]["repository"]["refs"] for node in releases_data["nodes"]: - tag_name = node["tagName"] + tag_name = node["name"] try: version = Version(tag_name) except InvalidVersion: warnings.warn(f"Skipping invalid version: {tag_name}", UserWarning) continue # Skip this release + if "tagger" in node["target"]: + datestr = node["target"]["tagger"]["date"] + else: + datestr = node["target"]["committedDate"] + release_date = datetime.datetime.fromisoformat( - node["createdAt"].replace("Z", "+00:00") + datestr.replace("Z", "+00:00") ) yield Release(version, release_date) diff --git a/tests/test_releasesource.py b/tests/test_releasesource.py index 0cda56b..ecb80ef 100644 --- a/tests/test_releasesource.py +++ b/tests/test_releasesource.py @@ -188,12 +188,21 @@ def test_integration_releases(self, package_name): MOCK_GH_RESPONSE_VALID_ONLY = { "data": { "repository": { - "releases": { + "refs": { "pageInfo": {"endCursor": None, "hasNextPage": False}, "nodes": [ - {"tagName": "2.2.0", "createdAt": "2023-03-03T12:00:00Z"}, - {"tagName": "2.1.0", "createdAt": "2023-02-10T09:00:00Z"}, - {"tagName": "1.9.0", "createdAt": "2023-01-15T20:00:00Z"}, + { + "name": "v2.2.0", + "target": {"committedDate": "2023-03-03T12:00:00Z"}, + }, + { + "name": "2.1.0", + "target": {"tagger": {"date": "2023-02-10T09:00:00Z"}}, + }, + { + "name": "1.9.0", + "target": {"committedDate": "2023-01-15T20:00:00Z"}, + }, ], } } @@ -203,15 +212,21 @@ def test_integration_releases(self, package_name): MOCK_GH_RESPONSE_MIXED = { "data": { "repository": { - "releases": { + "refs": { "pageInfo": {"endCursor": None, "hasNextPage": False}, "nodes": [ - {"tagName": "10.0.0", "createdAt": "2024-01-01T12:00:00Z"}, { - "tagName": "not-a-valid-version", - "createdAt": "2024-01-02T12:00:00Z", + "name": "10.0.0", + "target": {"committedDate": "2024-01-01T12:00:00Z"}, + }, + { + "name": "not-a-valid-version", + "target": {"committedDate": "2024-01-02T12:00:00Z"}, + }, + { + "name": "9.9.9", + "target": {"committedDate": "2023-12-15T12:00:00Z"}, }, - {"tagName": "9.9.9", "createdAt": "2023-12-15T12:00:00Z"}, ], } } @@ -221,11 +236,17 @@ def test_integration_releases(self, package_name): MOCK_GH_RESPONSE_PAGE1 = { "data": { "repository": { - "releases": { + "refs": { "pageInfo": {"endCursor": "CURSOR1", "hasNextPage": True}, "nodes": [ - {"tagName": "3.0.0", "createdAt": "2024-01-05T12:00:00Z"}, - {"tagName": "2.5.0", "createdAt": "2023-12-20T12:00:00Z"}, + { + "name": "3.0.0", + "target": {"committedDate": "2024-01-05T12:00:00Z"}, + }, + { + "name": "2.5.0", + "target": {"committedDate": "2023-12-20T12:00:00Z"}, + }, ], } } @@ -235,11 +256,17 @@ def test_integration_releases(self, package_name): MOCK_GH_RESPONSE_PAGE2 = { "data": { "repository": { - "releases": { + "refs": { "pageInfo": {"endCursor": None, "hasNextPage": False}, "nodes": [ - {"tagName": "2.2.0", "createdAt": "2023-11-10T12:00:00Z"}, - {"tagName": "2.0.0", "createdAt": "2023-10-01T12:00:00Z"}, + { + "name": "2.2.0", + "target": {"committedDate": "2023-11-10T12:00:00Z"}, + }, + { + "name": "2.0.0", + "target": {"committedDate": "2023-10-01T12:00:00Z"}, + }, ], } } @@ -355,7 +382,7 @@ def test_pagination(self): @pytest.mark.skipif( not os.environ.get("GITHUB_TOKEN"), reason="GITHUB_TOKEN not set" ) - def test_integration_openpathsampling(self): + def test_integration_python(self): """ Integration test using the real GitHub API to fetch releases for the repository openpathsampling/openpathsampling. Checks that at least one @@ -363,7 +390,5 @@ def test_integration_openpathsampling(self): """ token = os.environ["GITHUB_TOKEN"] source = GitHubReleaseSource(token) - releases = list(source.get_releases("openpathsampling/openpathsampling")) + releases = list(source.get_releases("python")) assert len(releases) > 0 - release_dates = [r.release_date for r in releases] - assert_is_descending(release_dates) From 39eb3281db0f113ceaac33444f70be937a681086 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 8 Apr 2025 19:42:26 -0500 Subject: [PATCH 06/15] Add GITHUB_TOKEN to pytest --- .github/workflows/ci.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dc77344..e0e2739 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -30,6 +30,8 @@ jobs: - name: Run tests run: python -m pytest --cov -v + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # needed for some tests - name: Upload coverage uses: codecov/codecov-action@v5 From af1972bae7b56a75a03281d01b7db9ef4b1e438e Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 9 Apr 2025 15:27:33 -0500 Subject: [PATCH 07/15] DefaultReleaseSource, and CLI talks to GitHubReleaseSource --- src/spec0/cli.py | 16 ++--- src/spec0/main.py | 24 ++----- src/spec0/releasesource.py | 75 +++++++++++++++++++- tests/test_main.py | 45 ++---------- tests/test_releasesource.py | 134 ++++++++++++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 70 deletions(-) diff --git a/src/spec0/cli.py b/src/spec0/cli.py index aa709b9..ce4fd8e 100644 --- a/src/spec0/cli.py +++ b/src/spec0/cli.py @@ -8,6 +8,7 @@ PyPIReleaseSource, CondaReleaseSource, GitHubReleaseSource, + DefaultReleaseSource, ) from spec0.releasefilters import SPEC0StrictDate, SPEC0Quarter from spec0.output import terminal_output, json_output, specifier_output @@ -123,24 +124,19 @@ def select_source(opts): selected_conda = opts.conda_channel is not None selected_github = opts.github n_selected = sum([selected_pypi, selected_conda, selected_github]) + token = os.getenv("GITHUB_TOKEN") if n_selected == 0: - source = None + source = DefaultReleaseSource(token) elif n_selected > 1: raise ValueError("Only one source can be selected") else: if selected_pypi: - source = [PyPIReleaseSource()] + source = PyPIReleaseSource() elif selected_conda: platforms = [f"{opts.conda_channel}/{arch}" for arch in opts.conda_arch] - source = [CondaReleaseSource(platforms)] + source = CondaReleaseSource(platforms) elif selected_github: - if (token := os.getenv("GITHUB_TOKEN")) is None: - raise ValueError( - "GITHUB_TOKEN environment variable must be set to use GitHub " - "releases" - ) - - source = [GitHubReleaseSource(token)] + source = GitHubReleaseSource(token) return source diff --git a/src/spec0/main.py b/src/spec0/main.py index b4b1928..d1f803b 100644 --- a/src/spec0/main.py +++ b/src/spec0/main.py @@ -1,4 +1,3 @@ -from spec0.releasesource import PyPIReleaseSource, CondaReleaseSource from spec0.releasefilters import SPEC0StrictDate import logging @@ -6,28 +5,20 @@ _logger = logging.getLogger(__name__) -def default_sources(): - """Default release sources if none are provided.""" - return [ - PyPIReleaseSource(), - CondaReleaseSource(["conda-forge/noarch", "conda-forge/linux-64"]), - ] - - def default_filter(): """Default support filter if none is provided.""" return SPEC0StrictDate() -def main(package, sources=None, filter_=None): +def main(package, source, filter_=None): """Main function to get release info for a package. Parameters ---------- package : str The name of the package to get release info for. - sources : list, optional - A list of release sources to use. If None, default sources are used. + source : ReleaseSource + The source to use for getting release info. filter_ : ReleaseFilter, optional A release filter to use. If None, default filter is used. @@ -42,17 +33,10 @@ def main(package, sources=None, filter_=None): (datetime) release date of the release, and "drop-date" is the (datetime) drop date of the release, according to the input filter. """ - if sources is None: - sources = default_sources() - if filter_ is None: filter_ = default_filter() - # we take the releases from the first source that has them - for source in sources: - if releases := source.get_releases(package): - break - + releases = source.get_releases(package) filtered = filter_.filter(package, releases) result = { "package": package, diff --git a/src/spec0/releasesource.py b/src/spec0/releasesource.py index c94acf6..6a7c439 100644 --- a/src/spec0/releasesource.py +++ b/src/spec0/releasesource.py @@ -1,6 +1,7 @@ import dataclasses import datetime import json +import os import requests import warnings from packaging.version import Version, InvalidVersion @@ -24,6 +25,10 @@ class Release: release_date: datetime.datetime +class NoReleaseFound(Exception): + pass + + class ReleaseSource: """ABC for a source of package releases.""" @@ -44,7 +49,13 @@ def _get_releases(self, package: str) -> Generator[Release, None, None]: url = f"https://pypi.org/pypi/{package}/json" _logger.debug(f"Fetching {url}") response = requests.get(url) - response.raise_for_status() + try: + response.raise_for_status() + except requests.exceptions.HTTPError as e: + if response.status_code == 404: + raise NoReleaseFound(f"No PyPI package '{package}'") from e + else: + raise data = response.json() releases_data = data.get("releases", {}) @@ -76,6 +87,8 @@ def _get_releases(self, package: str) -> Generator[Release, None, None]: release_list.append(release) release_list.sort(key=lambda r: r.release_date, reverse=True) + if not release_list: + raise NoReleaseFound(f"No releases found for package '{package}'") for release in release_list: yield release @@ -97,13 +110,22 @@ def __init__(self, github_token: str): jsonstr = trav.joinpath("data/github-releases.json").read_text() self.canonical_sources = json.loads(jsonstr) + def is_github_package(self, package: str) -> bool: + """Check if the package is a GitHub package.""" + if collections.Counter(package).get("/") == 1: + return True + elif package in self.canonical_sources: + return True + else: + return False + def _get_releases(self, package: str): if collections.Counter(package).get("/") == 1: owner_repo = package elif package in self.canonical_sources: owner_repo = self.canonical_sources[package] else: - raise ValueError(f"GitHub repository for package '{package}' not found") + raise NoReleaseFound(f"GitHub repository for package '{package}' not found") yield from self._get_releases_owner_repo(owner_repo) @@ -162,6 +184,16 @@ def _get_releases_owner_repo(self, owner_repo: str): } """ + token = self.github_token + if token is None: + token = os.environ.get("GITHUB_TOKEN") + + if token is None: + raise ValueError( + "GitHub token not provided. Please set the GITHUB_TOKEN " + "environment variable." + ) + url = "https://api.github.com/graphql" headers = { "Authorization": f"Bearer {self.github_token}", @@ -266,5 +298,44 @@ def _get_releases(self, package): key=lambda r: (r.release_date is None, r.release_date), reverse=True ) + if not releases: + raise NoReleaseFound(f"No releases found for package '{package}'") + for release_obj in releases: yield release_obj + + +class DefaultReleaseSource(ReleaseSource): + """ + Release source that tries (1) GitHub releases; (2) PyPI; (3) conda-forge. + + Parameters + ---------- + github_token : str + Personal access token (PAT) with permissions to query the desired repository. + """ + + def __init__(self, github_token: str = None): + self.github_source = GitHubReleaseSource(github_token) + self.pypi_source = PyPIReleaseSource() + self.conda_source = CondaReleaseSource( + [ + "conda-forge/linux-64", + "conda-forge/noarch", + ] + ) + + def _get_releases(self, package: str) -> Generator[Release, None, None]: + # check whether the package should be a GitHub release, try GitHub if so + if self.github_source.is_github_package(package): + yield from self.github_source.get_releases(package) + return + + try: + yield from self.pypi_source.get_releases(package) + except NoReleaseFound: # TODO: handle exception + pass + else: + return + + yield from self.conda_source.get_releases(package) diff --git a/tests/test_main.py b/tests/test_main.py index 83467b3..12fbcd9 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,7 +1,7 @@ import datetime from packaging.version import Version -from spec0.releasesource import PyPIReleaseSource, CondaReleaseSource, Release +from spec0.releasesource import Release, DefaultReleaseSource from spec0.releasefilters import SPEC0StrictDate from spec0.main import * @@ -34,15 +34,6 @@ def drop_date(self, package, release): return datetime.datetime(2021, 1, 1) -def test_default_sources(): - sources = default_sources() - assert isinstance(sources, list) - assert len(sources) == 2 - - assert isinstance(sources[0], PyPIReleaseSource) - assert isinstance(sources[1], CondaReleaseSource) - - def test_default_filter(): filter_obj = default_filter() assert isinstance(filter_obj, SPEC0StrictDate) @@ -50,20 +41,13 @@ def test_default_filter(): assert filter_obj.python_override is True -def test_main_uses_first_source_with_releases(): +def test_main(): v = Version("1.0") release_date = datetime.datetime(2020, 1, 1) dummy_release = Release(v, release_date) - - # The first source returns a valid releases dict. - # The second source should not be used. - source1 = DummySource({"r1": dummy_release}) - v2 = Version("2.0") - source2 = DummySource({"r2": Release(v2, datetime.datetime(2020, 2, 2))}) - + source = DummySource({"r1": dummy_release}) filter_obj = DummyFilter() - result = main("testpkg", sources=[source1, source2], filter_=filter_obj) - + result = main("testpkg", source=source, filter_=filter_obj) assert result["package"] == "testpkg" assert isinstance(result["releases"], list) assert len(result["releases"]) == 1 @@ -73,25 +57,10 @@ def test_main_uses_first_source_with_releases(): assert release_info["drop-date"] == datetime.datetime(2021, 1, 1) -def test_main_uses_second_source_when_first_returns_none(): - v = Version("1.0") - release_date = datetime.datetime(2020, 1, 1) - dummy_release = Release(v, release_date) - - source1 = DummySource(None) - source2 = DummySource({"r1": dummy_release}) - - filter_obj = DummyFilter() - result = main("testpkg", sources=[source1, source2], filter_=filter_obj) - - assert result["package"] == "testpkg" - assert isinstance(result["releases"], list) - assert len(result["releases"]) == 1 - - -def test_main_default(): +def test_main_integration(): # smoke test for integration with default params - results = main("numpy") + source = DefaultReleaseSource() + results = main("numpy", source) assert results["package"] == "numpy" assert len(results["releases"]) > 0 recent_release = results["releases"][0] diff --git a/tests/test_releasesource.py b/tests/test_releasesource.py index ecb80ef..17f41f0 100644 --- a/tests/test_releasesource.py +++ b/tests/test_releasesource.py @@ -3,6 +3,8 @@ import warnings import datetime import os +from contextlib import ExitStack +from unittest.mock import patch from packaging.version import Version from requires_internet import requires_internet @@ -392,3 +394,135 @@ def test_integration_python(self): source = GitHubReleaseSource(token) releases = list(source.get_releases("python")) assert len(releases) > 0 + + +def make_release(version: str, date_str: str): + dt = datetime.datetime.fromisoformat(date_str).replace(tzinfo=datetime.timezone.utc) + return Release(Version(version), dt) + + +class TestDefaultReleaseSource: + def test_get_releases_github(self): + with ExitStack() as stack: + mock_github_cls = stack.enter_context( + patch("spec0.releasesource.GitHubReleaseSource") + ) + mock_pypi_cls = stack.enter_context( + patch("spec0.releasesource.PyPIReleaseSource") + ) + mock_conda_cls = stack.enter_context( + patch("spec0.releasesource.CondaReleaseSource") + ) + + mock_github = mock_github_cls.return_value + mock_github.is_github_package.return_value = True + mock_github.get_releases.return_value = iter( + [make_release("1.0.0", "2023-01-01T00:00:00")] + ) + + source = DefaultReleaseSource("fake-token") + releases = list(source.get_releases("somegithub/repo")) + + assert len(releases) == 1 + assert releases[0].version == Version("1.0.0") + mock_github.get_releases.assert_called_once() + mock_pypi_cls.return_value.get_releases.assert_not_called() + mock_conda_cls.return_value.get_releases.assert_not_called() + + def test_get_releases_pypi(self): + with ExitStack() as stack: + mock_github_cls = stack.enter_context( + patch("spec0.releasesource.GitHubReleaseSource") + ) + mock_pypi_cls = stack.enter_context( + patch("spec0.releasesource.PyPIReleaseSource") + ) + mock_conda_cls = stack.enter_context( + patch("spec0.releasesource.CondaReleaseSource") + ) + + mock_github = mock_github_cls.return_value + mock_github.is_github_package.return_value = False + + mock_pypi = mock_pypi_cls.return_value + mock_pypi.get_releases.return_value = iter( + [make_release("2.0.0", "2022-01-01T00:00:00")] + ) + + source = DefaultReleaseSource("fake-token") + releases = list(source.get_releases("non-github-package")) + + assert len(releases) == 1 + assert releases[0].version == Version("2.0.0") + mock_github.get_releases.assert_not_called() + mock_pypi.get_releases.assert_called_once() + mock_conda_cls.return_value.get_releases.assert_not_called() + + def test_get_releases_conda(self): + with ExitStack() as stack: + mock_github_cls = stack.enter_context( + patch("spec0.releasesource.GitHubReleaseSource") + ) + mock_pypi_cls = stack.enter_context( + patch("spec0.releasesource.PyPIReleaseSource") + ) + mock_conda_cls = stack.enter_context( + patch("spec0.releasesource.CondaReleaseSource") + ) + + mock_github = mock_github_cls.return_value + mock_github.is_github_package.return_value = False + + mock_pypi = mock_pypi_cls.return_value + mock_pypi.get_releases.side_effect = NoReleaseFound("PyPI failed") + + mock_conda = mock_conda_cls.return_value + mock_conda.get_releases.return_value = iter( + [make_release("3.0.0", "2021-01-01T00:00:00")] + ) + + source = DefaultReleaseSource("fake-token") + releases = list(source.get_releases("fallback-package")) + + assert len(releases) == 1 + assert releases[0].version == Version("3.0.0") + mock_github.get_releases.assert_not_called() + mock_pypi.get_releases.assert_called_once() + mock_conda.get_releases.assert_called_once() + + def test_get_releases_fail(self): + with ExitStack() as stack: + mock_github_cls = stack.enter_context( + patch("spec0.releasesource.GitHubReleaseSource") + ) + mock_pypi_cls = stack.enter_context( + patch("spec0.releasesource.PyPIReleaseSource") + ) + mock_conda_cls = stack.enter_context( + patch("spec0.releasesource.CondaReleaseSource") + ) + + mock_github = mock_github_cls.return_value + mock_github.is_github_package.return_value = False + + mock_pypi = mock_pypi_cls.return_value + mock_pypi.get_releases.side_effect = NoReleaseFound("PyPI failed") + + mock_conda = mock_conda_cls.return_value + mock_conda.get_releases.side_effect = NoReleaseFound("Conda failed") + + source = DefaultReleaseSource("fake-token") + + with pytest.raises(NoReleaseFound, match="Conda failed"): + list(source.get_releases("bad-package")) + + def test_github_token_required(self, monkeypatch): + """Test that a ValueError is raised when no GitHub token is provided.""" + # Ensure environment is clean + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + + # This will trigger the GitHub release path due to the slash + source = DefaultReleaseSource(github_token=None) + + with pytest.raises(ValueError, match="GitHub token not provided"): + list(source.get_releases("someuser/someproject")) From ab4bf27f944d111fc488a053b75019bcc2bfb24f Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 9 Apr 2025 15:29:37 -0500 Subject: [PATCH 08/15] Add MANIFEST --- MANIFEST.in | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 MANIFEST.in diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..c72fd0c --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include LICENSE +include src/spec0/data/*.json From 04c271dcb5a733449dbf53467ba7ba4e03cef0a7 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 9 Apr 2025 15:51:50 -0500 Subject: [PATCH 09/15] Smoke test all known GitHub repos --- tests/test_releasesource.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_releasesource.py b/tests/test_releasesource.py index 17f41f0..3b80d46 100644 --- a/tests/test_releasesource.py +++ b/tests/test_releasesource.py @@ -384,7 +384,7 @@ def test_pagination(self): @pytest.mark.skipif( not os.environ.get("GITHUB_TOKEN"), reason="GITHUB_TOKEN not set" ) - def test_integration_python(self): + def test_integration(self): """ Integration test using the real GitHub API to fetch releases for the repository openpathsampling/openpathsampling. Checks that at least one @@ -392,8 +392,9 @@ def test_integration_python(self): """ token = os.environ["GITHUB_TOKEN"] source = GitHubReleaseSource(token) - releases = list(source.get_releases("python")) - assert len(releases) > 0 + for package in source.canonical_sources: + releases = list(source.get_releases(package)) + assert len(releases) > 0 def make_release(version: str, date_str: str): From 9d8009b1cf8a2241b21238eeeb649695c5a56dd4 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 9 Apr 2025 16:11:12 -0500 Subject: [PATCH 10/15] add test for is_github_package --- tests/test_releasesource.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_releasesource.py b/tests/test_releasesource.py index 3b80d46..bcb8554 100644 --- a/tests/test_releasesource.py +++ b/tests/test_releasesource.py @@ -396,6 +396,25 @@ def test_integration(self): releases = list(source.get_releases(package)) assert len(releases) > 0 + @pytest.mark.parametrize( + "package,expected", + [ + ("owner/repo", True), + ("python", True), + ("package-without-slash", False), + ("too/many/slashes", False), + ("", False), + ], + ) + def test_is_github_package(self, package, expected): + """Test the is_github_package method with various inputs.""" + source = GitHubReleaseSource("FAKE_TOKEN") + # Ensure the canonical_sources has expected entries + source.canonical_sources = {"python": "python/cpython"} + + result = source.is_github_package(package) + assert result == expected + def make_release(version: str, date_str: str): dt = datetime.datetime.fromisoformat(date_str).replace(tzinfo=datetime.timezone.utc) From c964e1d8e65f9abf365ed773a25e409306514492 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 9 Apr 2025 17:38:53 -0500 Subject: [PATCH 11/15] Add TestPyPIReleaseSource.test_http_error_responses --- tests/test_releasesource.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_releasesource.py b/tests/test_releasesource.py index bcb8554..aad8eda 100644 --- a/tests/test_releasesource.py +++ b/tests/test_releasesource.py @@ -88,6 +88,28 @@ def test_mixed_versions_warning(self): datetime.datetime(2023, 12, 15, 12, 0, tzinfo=datetime.timezone.utc), ] + @pytest.mark.parametrize( + "status_code,exception_class", + [ + (404, NoReleaseFound), + (503, requests.HTTPError), + ], + ) + @responses.activate + def test_http_error_responses(self, status_code, exception_class): + url = "https://pypi.org/pypi/nonexistent-package/json" + responses.add( + method=responses.GET, + url=url, + body=f"Error {status_code}", + status=status_code, + ) + + source = PyPIReleaseSource() + + with pytest.raises(exception_class): + list(source.get_releases("nonexistent-package")) + @pytest.mark.parametrize("package_name", ["pandas", "numpy", "scipy"]) @requires_internet def test_integration_packages(self, package_name): From d0db1071ccfe56c7e4ec7625864cca41d386a2f0 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 9 Apr 2025 18:09:59 -0500 Subject: [PATCH 12/15] Add tests that we raise NoReleaseFound --- tests/test_releasesource.py | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_releasesource.py b/tests/test_releasesource.py index aad8eda..8c8d66d 100644 --- a/tests/test_releasesource.py +++ b/tests/test_releasesource.py @@ -110,6 +110,29 @@ def test_http_error_responses(self, status_code, exception_class): with pytest.raises(exception_class): list(source.get_releases("nonexistent-package")) + @responses.activate + def test_get_release_no_release(self): + """ + Test that the PyPIReleaseSource correctly handles packages that exist but have no releases. + It should raise a NoReleaseFound exception. + """ + url = "https://pypi.org/pypi/package-with-no-releases/json" + # Create a mock response with an empty releases dictionary + responses.add( + method=responses.GET, + url=url, + json={"releases": {}}, + status=200, + ) + + source = PyPIReleaseSource() + + with pytest.raises( + NoReleaseFound, + match="No releases found for package 'package-with-no-releases'", + ): + list(source.get_releases("package-with-no-releases")) + @pytest.mark.parametrize("package_name", ["pandas", "numpy", "scipy"]) @requires_internet def test_integration_packages(self, package_name): @@ -437,6 +460,21 @@ def test_is_github_package(self, package, expected): result = source.is_github_package(package) assert result == expected + @pytest.mark.parametrize( + "package", + [ + "unknown-package", + "too/many/slashes", + ], + ) + def test_get_releases_not_found(self, package): + source = GitHubReleaseSource("FAKE_TOKEN") + source.canonical_sources = {"python": "python/cpython"} + + expected_message = f"GitHub repository for package '{package}' not found" + with pytest.raises(NoReleaseFound, match=expected_message): + list(source._get_releases(package)) + def make_release(version: str, date_str: str): dt = datetime.datetime.fromisoformat(date_str).replace(tzinfo=datetime.timezone.utc) From 7af89e95c0420fc23e249e5f4dff4322eef868b7 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 9 Apr 2025 18:51:14 -0500 Subject: [PATCH 13/15] Finish up NoReleaseFound, and add tests --- src/spec0/releasefilters.py | 6 +++--- src/spec0/releasesource.py | 8 ++++++++ tests/test_releasefilters.py | 19 +++++++++++++++++++ tests/test_releasesource.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/spec0/releasefilters.py b/src/spec0/releasefilters.py index 8ba4181..30d8fc8 100644 --- a/src/spec0/releasefilters.py +++ b/src/spec0/releasefilters.py @@ -5,7 +5,7 @@ from packaging.specifiers import SpecifierSet from packaging.version import Version -from .releasesource import Release +from .releasesource import Release, NoReleaseFound from .utils.dates import next_quarter, quarter_to_date, shift_date_by_months import logging @@ -68,8 +68,8 @@ def _get_minimum_supported(self, package: str, releases: Iterable[Release]): try: max_minor_release = max(oldest_minor_release) - except ValueError: # no releases found - raise RuntimeError(f"No releases found for package '{package}'") + except ValueError: # no releases found; should be caught by source + raise NoReleaseFound(f"No releases found for package '{package}'") # always support at least the most recent minor release supported = {max_minor_release: oldest_minor_release[max_minor_release]} diff --git a/src/spec0/releasesource.py b/src/spec0/releasesource.py index 6a7c439..2b74510 100644 --- a/src/spec0/releasesource.py +++ b/src/spec0/releasesource.py @@ -203,6 +203,8 @@ def _get_releases_owner_repo(self, owner_repo: str): has_next_page = True after_cursor = None + found_package = False + while has_next_page: variables = { "owner": owner, @@ -234,10 +236,16 @@ def _get_releases_owner_repo(self, owner_repo: str): datestr.replace("Z", "+00:00") ) yield Release(version, release_date) + found_package = True has_next_page = releases_data["pageInfo"]["hasNextPage"] after_cursor = releases_data["pageInfo"]["endCursor"] + if not found_package: + raise NoReleaseFound( + f"No releases found for GitHub repository '{owner_repo}'" + ) + class CondaReleaseSource(ReleaseSource): """ diff --git a/tests/test_releasefilters.py b/tests/test_releasefilters.py index 6839b53..3cf7f00 100644 --- a/tests/test_releasefilters.py +++ b/tests/test_releasefilters.py @@ -4,6 +4,7 @@ from unittest.mock import patch from spec0.releasefilters import * +from spec0.releasesource import NoReleaseFound read_datetime = datetime.datetime @@ -104,6 +105,15 @@ def test_filter(self, releases): ) assert key in supported + def test_filter_empty_releases(self): + package_name = "nonexistent-package" + spec_strict = SPEC0StrictDate() + + with pytest.raises( + NoReleaseFound, match=f"No releases found for package '{package_name}'" + ): + spec_strict.filter(package_name, []) + @pytest.mark.parametrize("python_override", [True, False]) @pytest.mark.parametrize("package", ["foo", "python"]) def test_drop_date(self, python_override, package): @@ -141,6 +151,15 @@ def test_filter(self, releases): ) assert key in supported + def test_filter_empty_releases(self): + package_name = "nonexistent-package" + spec_quarter = SPEC0Quarter() + + with pytest.raises( + NoReleaseFound, match=f"No releases found for package '{package_name}'" + ): + spec_quarter.filter(package_name, []) + @pytest.mark.parametrize("python_override", [True, False]) @pytest.mark.parametrize("package", ["foo", "python"]) def test_drop_date(self, python_override, package): diff --git a/tests/test_releasesource.py b/tests/test_releasesource.py index 8c8d66d..b99f1c5 100644 --- a/tests/test_releasesource.py +++ b/tests/test_releasesource.py @@ -475,6 +475,37 @@ def test_get_releases_not_found(self, package): with pytest.raises(NoReleaseFound, match=expected_message): list(source._get_releases(package)) + @responses.activate + def test_get_releases_no_releases_found(self): + """Test that a NoReleaseFound exception is raised when a GitHub repository exists but has no releases.""" + url = "https://api.github.com/graphql" + # Mock an empty response with no releases + empty_response = { + "data": { + "repository": { + "refs": { + "pageInfo": {"endCursor": None, "hasNextPage": False}, + "nodes": [], + } + } + } + } + responses.add( + responses.POST, + url, + json=empty_response, + status=200, + ) + + token = "FAKE_TOKEN" + source = GitHubReleaseSource(token) + + expected_message = ( + "No releases found for GitHub repository 'octocat/empty-repo'" + ) + with pytest.raises(NoReleaseFound, match=expected_message): + list(source._get_releases_owner_repo("octocat/empty-repo")) + def make_release(version: str, date_str: str): dt = datetime.datetime.fromisoformat(date_str).replace(tzinfo=datetime.timezone.utc) From 4d1d04e1ee38b6942a9704746ab7ca29f27893d9 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 9 Apr 2025 19:10:05 -0500 Subject: [PATCH 14/15] I guess I still have Python tricks to learn! --- src/spec0/releasesource.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/spec0/releasesource.py b/src/spec0/releasesource.py index 2b74510..658ee73 100644 --- a/src/spec0/releasesource.py +++ b/src/spec0/releasesource.py @@ -6,7 +6,6 @@ import warnings from packaging.version import Version, InvalidVersion import importlib.resources -import collections from typing import Generator @@ -112,7 +111,7 @@ def __init__(self, github_token: str): def is_github_package(self, package: str) -> bool: """Check if the package is a GitHub package.""" - if collections.Counter(package).get("/") == 1: + if package.count("/") == 1: return True elif package in self.canonical_sources: return True @@ -120,7 +119,7 @@ def is_github_package(self, package: str) -> bool: return False def _get_releases(self, package: str): - if collections.Counter(package).get("/") == 1: + if package.count("/") == 1: owner_repo = package elif package in self.canonical_sources: owner_repo = self.canonical_sources[package] From fa1a7bda1a7a9633a5bacaa6f614fe9e561cb50c Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 9 Apr 2025 19:11:29 -0500 Subject: [PATCH 15/15] `self.github_token` -> `token` Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/spec0/releasesource.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spec0/releasesource.py b/src/spec0/releasesource.py index 658ee73..2bc3bce 100644 --- a/src/spec0/releasesource.py +++ b/src/spec0/releasesource.py @@ -195,7 +195,7 @@ def _get_releases_owner_repo(self, owner_repo: str): url = "https://api.github.com/graphql" headers = { - "Authorization": f"Bearer {self.github_token}", + "Authorization": f"Bearer {token}", "Accept": "application/vnd.github.v3+json", }