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 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 diff --git a/src/spec0/cli.py b/src/spec0/cli.py index c2bfc9b..ce4fd8e 100644 --- a/src/spec0/cli.py +++ b/src/spec0/cli.py @@ -1,9 +1,15 @@ import argparse import logging +import os from functools import partial -from spec0.releasesource import PyPIReleaseSource, CondaReleaseSource +from spec0.releasesource import ( + PyPIReleaseSource, + CondaReleaseSource, + GitHubReleaseSource, + DefaultReleaseSource, +) from spec0.releasefilters import SPEC0StrictDate, SPEC0Quarter from spec0.output import terminal_output, json_output, specifier_output from spec0.main import main @@ -55,7 +61,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,20 +122,22 @@ 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]) + 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)] - # elif selected_github: - # source = GitHubReleaseSource() + source = CondaReleaseSource(platforms) + elif selected_github: + 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..c0ac236 --- /dev/null +++ b/src/spec0/data/github-releases.json @@ -0,0 +1,4 @@ +{ + "rust": "rust-lang/rust", + "python": "python/cpython" +} 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/releasefilters.py b/src/spec0/releasefilters.py index c26dbca..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 @@ -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; 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 06166ad..2bc3bce 100644 --- a/src/spec0/releasesource.py +++ b/src/spec0/releasesource.py @@ -1,9 +1,11 @@ import dataclasses import datetime import json +import os import requests import warnings from packaging.version import Version, InvalidVersion +import importlib.resources from typing import Generator @@ -22,6 +24,10 @@ class Release: release_date: datetime.datetime +class NoReleaseFound(Exception): + pass + + class ReleaseSource: """ABC for a source of package releases.""" @@ -42,7 +48,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", {}) @@ -74,16 +86,164 @@ 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 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. + + 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 + trav = importlib.resources.files("spec0") + 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 package.count("/") == 1: + return True + elif package in self.canonical_sources: + return True + else: + return False + + def _get_releases(self, package: str): + if package.count("/") == 1: + owner_repo = package + elif package in self.canonical_sources: + owner_repo = self.canonical_sources[package] + else: + raise NoReleaseFound(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). + + 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) { + refs(after:$after, first:100, refPrefix:"refs/tags/", orderBy:{field:TAG_COMMIT_DATE, direction:DESC}) { + + pageInfo { + endCursor + hasNextPage + } + + nodes { + 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 + } + } + } + } + } + } + } + """ + + 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 {token}", + "Accept": "application/vnd.github.v3+json", + } + + has_next_page = True + after_cursor = None + + found_package = False + + 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"]["refs"] + + for node in releases_data["nodes"]: + 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( + 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"] -class GitHubTagReleaseSource(ReleaseSource): - def _get_releases(self, package: str) -> Generator[Release, None, None]: ... + if not found_package: + raise NoReleaseFound( + f"No releases found for GitHub repository '{owner_repo}'" + ) class CondaReleaseSource(ReleaseSource): @@ -145,5 +305,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_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 2cd7153..b99f1c5 100644 --- a/tests/test_releasesource.py +++ b/tests/test_releasesource.py @@ -2,6 +2,9 @@ import responses 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 @@ -85,6 +88,51 @@ 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")) + + @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): @@ -182,3 +230,410 @@ 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": { + "refs": { + "pageInfo": {"endCursor": None, "hasNextPage": False}, + "nodes": [ + { + "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"}, + }, + ], + } + } + } +} + +MOCK_GH_RESPONSE_MIXED = { + "data": { + "repository": { + "refs": { + "pageInfo": {"endCursor": None, "hasNextPage": False}, + "nodes": [ + { + "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"}, + }, + ], + } + } + } +} + +MOCK_GH_RESPONSE_PAGE1 = { + "data": { + "repository": { + "refs": { + "pageInfo": {"endCursor": "CURSOR1", "hasNextPage": True}, + "nodes": [ + { + "name": "3.0.0", + "target": {"committedDate": "2024-01-05T12:00:00Z"}, + }, + { + "name": "2.5.0", + "target": {"committedDate": "2023-12-20T12:00:00Z"}, + }, + ], + } + } + } +} + +MOCK_GH_RESPONSE_PAGE2 = { + "data": { + "repository": { + "refs": { + "pageInfo": {"endCursor": None, "hasNextPage": False}, + "nodes": [ + { + "name": "2.2.0", + "target": {"committedDate": "2023-11-10T12:00:00Z"}, + }, + { + "name": "2.0.0", + "target": {"committedDate": "2023-10-01T12:00:00Z"}, + }, + ], + } + } + } +} + + +class TestGitHubReleaseSource: + @pytest.mark.parametrize("inputstr", ["octohello", "octocat/Hello-World"]) + @responses.activate + 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( + responses.POST, + url, + json=MOCK_GH_RESPONSE_VALID_ONLY, + status=200, + ) + + token = "FAKE_TOKEN" + source = GitHubReleaseSource(token) + source.canonical_sources["octohello"] = "octocat/Hello-World" + releases = list(source.get_releases(inputstr)) + + # 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_owner_repo("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_owner_repo("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(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) + for package in source.canonical_sources: + 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 + + @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)) + + @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) + 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"))