# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Tests for PyPI client helpers."""
from __future__ import annotations
import json
from collections.abc import Mapping
from contextlib import AbstractContextManager
from http.client import IncompleteRead
from unittest.mock import MagicMock, patch
from urllib.error import URLError
import pytest
from repomatic.pypi import (
PYPI_TRUSTED_PUBLISHER_SETTINGS_URL,
TrustedPublisher,
get_latest_release_file,
get_release_dates,
get_source_url,
get_trusted_publishers,
github_repo_root,
pypi_trusted_publisher_settings_url,
)
from tests.conftest import FakeResponse
def _patch_pypi_json(
payload: Mapping[str, object] | None,
) -> AbstractContextManager[MagicMock]:
"""Patch `_fetch_json` to return `payload`."""
return patch("repomatic.pypi._fetch_json", return_value=payload)
[docs]
@pytest.mark.parametrize(
("url", "expected"),
[
("https://github.com/papaya/kiwi", "https://github.com/papaya/kiwi"),
("https://github.com/papaya/kiwi/", "https://github.com/papaya/kiwi"),
("https://github.com/papaya/kiwi.git", "https://github.com/papaya/kiwi"),
("https://github.com/papaya/kiwi/issues", "https://github.com/papaya/kiwi"),
("https://github.com/papaya/kiwi/releases", "https://github.com/papaya/kiwi"),
(
"https://github.com/papaya/kiwi/blob/main/CHANGELOG.md",
"https://github.com/papaya/kiwi",
),
# No repository to point at.
("https://github.com/papaya", None),
("https://github.com/", None),
("https://gitlab.com/papaya/kiwi", None),
("", None),
],
)
def test_github_repo_root(url, expected):
assert github_repo_root(url) == expected
[docs]
def test_get_source_url_matches_keys_case_insensitively():
"""A lowercase `homepage` key must not fall through to the value scan.
PyPI preserves whatever spelling a project wrote. A case-sensitive miss used
to drop through to the "any github.com value" fallback and return whichever
URL came first, which for these packages is the bug tracker.
"""
payload = {
"info": {
"project_urls": {
"Bug Tracker": "https://github.com/papaya/kiwi/issues",
"homepage": "https://github.com/papaya/kiwi",
}
}
}
with _patch_pypi_json(payload):
assert get_source_url("kiwi") == "https://github.com/papaya/kiwi"
[docs]
def test_get_source_url_reduces_a_sub_path_to_the_repo_root():
"""Even the value-scan fallback yields a slug the releases API accepts."""
payload = {
"info": {
"project_urls": {"Bug Tracker": "https://github.com/papaya/kiwi/issues"}
}
}
with _patch_pypi_json(payload):
assert get_source_url("kiwi") == "https://github.com/papaya/kiwi"
[docs]
def test_get_source_url_none_without_a_github_url():
payload = {"info": {"project_urls": {"Docs": "https://kiwi.example.com"}}}
with _patch_pypi_json(payload):
assert get_source_url("kiwi") is None
[docs]
def test_latest_release_file_picks_most_recent_wheel():
"""Pick the wheel from the version with the most recent earliest upload."""
payload = {
"releases": {
"1.0.0": [
{
"filename": "cherries-1.0.0.tar.gz",
"upload_time": "2026-01-01T00:00:00",
},
{
"filename": "cherries-1.0.0-py3-none-any.whl",
"upload_time": "2026-01-01T00:00:01",
},
],
"1.1.0": [
{
"filename": "cherries-1.1.0.tar.gz",
"upload_time": "2026-03-01T00:00:00",
},
{
"filename": "cherries-1.1.0-py3-none-any.whl",
"upload_time": "2026-03-01T00:00:01",
},
],
},
}
with _patch_pypi_json(payload):
result = get_latest_release_file("cherries")
assert result == ("1.1.0", "cherries-1.1.0-py3-none-any.whl")
[docs]
def test_latest_release_file_falls_back_to_sdist():
"""Fall back to the sdist when no wheel exists for the latest release."""
payload = {
"releases": {
"0.1.0": [
{
"filename": "cherries-0.1.0.tar.gz",
"upload_time": "2026-04-01T00:00:00",
},
],
},
}
with _patch_pypi_json(payload):
assert get_latest_release_file("cherries") == (
"0.1.0",
"cherries-0.1.0.tar.gz",
)
[docs]
def test_latest_release_file_skips_yanked_versions():
"""Yanked-only versions do not count as the latest release."""
payload = {
"releases": {
"1.0.0": [
{
"filename": "cherries-1.0.0-py3-none-any.whl",
"upload_time": "2026-01-01T00:00:00",
},
],
"2.0.0": [
{
"filename": "cherries-2.0.0-py3-none-any.whl",
"upload_time": "2026-04-01T00:00:00",
"yanked": True,
},
],
},
}
with _patch_pypi_json(payload):
assert get_latest_release_file("cherries") == (
"1.0.0",
"cherries-1.0.0-py3-none-any.whl",
)
[docs]
def test_latest_release_file_no_releases():
"""Return None when the package has no releases at all."""
with _patch_pypi_json({"releases": {}}):
assert get_latest_release_file("cherries") is None
[docs]
def test_latest_release_file_api_failure():
"""Return None when the metadata fetch itself failed."""
with _patch_pypi_json(None):
assert get_latest_release_file("cherries") is None
[docs]
def test_release_dates_capture_yank_reason():
"""The first non-empty per-file yank reason rides along with the release."""
payload = {
"releases": {
"0.9.0": [
{
"filename": "cherries-0.9.0-py3-none-any.whl",
"upload_time": "2025-12-01T00:00:00",
},
],
"1.0.0": [
{
"filename": "cherries-1.0.0-py3-none-any.whl",
"upload_time": "2026-01-01T00:00:00",
"yanked": True,
"yanked_reason": None,
},
{
"filename": "cherries-1.0.0.tar.gz",
"upload_time": "2026-01-01T00:00:00",
"yanked": True,
"yanked_reason": "Superseded by a corrected upload.",
},
],
},
}
with _patch_pypi_json(payload):
releases = get_release_dates("cherries")
assert releases["1.0.0"].yanked is True
assert releases["1.0.0"].yanked_reason == "Superseded by a corrected upload."
# A live release records no reason at all.
assert releases["0.9.0"].yanked is False
assert releases["0.9.0"].yanked_reason == ""
[docs]
def test_get_trusted_publishers_match():
"""Parse a single GitHub publisher bundle into a TrustedPublisher tuple."""
payload = {
"version": 1,
"attestation_bundles": [
{
"publisher": {
"kind": "GitHub",
"repository": "owner/cherries",
"workflow": "release.yaml",
"environment": None,
},
"attestations": [],
},
],
}
body = json.dumps(payload).encode()
with patch(
"repomatic.http.urlopen",
return_value=FakeResponse(body),
):
result = get_trusted_publishers(
"cherries", "1.2.3", "cherries-1.2.3-py3-none-any.whl"
)
assert result == [
TrustedPublisher(
kind="GitHub",
repository="owner/cherries",
workflow="release.yaml",
environment=None,
),
]
[docs]
def test_get_trusted_publishers_empty_bundles():
"""Return an empty list when provenance exists but lists no bundles."""
body = json.dumps({"version": 1, "attestation_bundles": []}).encode()
with patch(
"repomatic.http.urlopen",
return_value=FakeResponse(body),
):
assert (
get_trusted_publishers(
"cherries", "1.2.3", "cherries-1.2.3-py3-none-any.whl"
)
== []
)
[docs]
def test_get_trusted_publishers_network_failure():
"""Return None on URL or network errors."""
with patch(
"repomatic.http.urlopen",
side_effect=URLError("not found"),
):
assert (
get_trusted_publishers(
"cherries", "1.2.3", "cherries-1.2.3-py3-none-any.whl"
)
is None
)
[docs]
def test_get_trusted_publishers_invalid_json():
"""Return None when the response body cannot be parsed."""
with patch(
"repomatic.http.urlopen",
return_value=FakeResponse(b"not json"),
):
assert (
get_trusted_publishers(
"cherries", "1.2.3", "cherries-1.2.3-py3-none-any.whl"
)
is None
)
[docs]
def test_get_trusted_publishers_retries_incomplete_read():
"""A truncated provenance body is retried before the lookup gives up."""
body = json.dumps({"version": 1, "attestation_bundles": []}).encode()
with (
patch(
"repomatic.http.urlopen",
side_effect=[IncompleteRead(b""), FakeResponse(body)],
),
patch("repomatic.http.time.sleep"),
):
assert (
get_trusted_publishers(
"cherries", "1.2.3", "cherries-1.2.3-py3-none-any.whl"
)
== []
)
[docs]
def test_settings_url_bare_without_prefill_args():
"""No keyword args returns the bare settings URL."""
assert pypi_trusted_publisher_settings_url(
"cherries"
) == PYPI_TRUSTED_PUBLISHER_SETTINGS_URL.format(package="cherries")
[docs]
@pytest.mark.parametrize(
("kwargs", "expected_query"),
[
({"owner": "alice"}, "provider=github&owner=alice"),
(
{"owner": "alice", "repository": "cherries"},
"provider=github&owner=alice&repository=cherries",
),
(
{
"owner": "alice",
"repository": "cherries",
"workflow_filename": "release.yaml",
"environment": "production",
},
(
"provider=github&owner=alice&repository=cherries"
"&workflow_filename=release.yaml&environment=production"
),
),
],
)
def test_settings_url_prefill_query(
kwargs: dict[str, str], expected_query: str
) -> None:
"""Provided GitHub fields produce a `?provider=github&…` prefill suffix."""
url = pypi_trusted_publisher_settings_url("cherries", **kwargs)
base = PYPI_TRUSTED_PUBLISHER_SETTINGS_URL.format(package="cherries")
assert url == f"{base}?{expected_query}"
[docs]
def test_settings_url_prefill_skips_blank_fields():
"""Empty-string fields are dropped from the prefill query."""
url = pypi_trusted_publisher_settings_url(
"cherries",
owner="alice",
repository="",
workflow_filename="release.yaml",
environment=None,
)
base = PYPI_TRUSTED_PUBLISHER_SETTINGS_URL.format(package="cherries")
assert url == f"{base}?provider=github&owner=alice&workflow_filename=release.yaml"
[docs]
def test_settings_url_url_encodes_unsafe_characters():
"""Special characters in field values are URL-encoded."""
url = pypi_trusted_publisher_settings_url(
"cherries", owner="alice & bob", environment="staging/edge"
)
assert "owner=alice+%26+bob" in url
assert "environment=staging%2Fedge" in url