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
3 changes: 3 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ Changelog

- Added helpful links to some error messages.

- Added support for checking packages on a custom PyPI-compatible index with
the ``--index-url`` option.


5.0.1 (2025-12-09)
------------------
Expand Down
5 changes: 5 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ Or you can give it a package name on CheeseShop:

$ pyroma pyroma

If you use an internal PyPI-compatible package index, specify it with
``--index-url``:

$ pyroma --index-url https://packages.example.com internal-package

Giving it a name on CheeseShop is the most extensive test, as it will
test for several things isn't otherwise tested.

Expand Down
11 changes: 8 additions & 3 deletions pyroma/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ def main():
type=skip_tests,
help="Skip the named tests",
)
parser.add_argument(
"--index-url",
dest="index_url",
help="Base URL of a PyPI-compatible package index (default: https://pypi.org)",
)

args = parser.parse_args()

Expand All @@ -149,13 +154,13 @@ def main():
else:
mode = "pypi"

rating = run(mode, args.package, args.quiet, args.skip_tests)
rating = run(mode, args.package, args.quiet, args.skip_tests, args.index_url)
if rating < args.min:
sys.exit(2)
sys.exit(0)


def run(mode, argument, quiet=False, skip_tests=None):
def run(mode, argument, quiet=False, skip_tests=None, index_url=None):
if quiet:
logger = logging.getLogger()
logger.disabled = True
Expand All @@ -171,7 +176,7 @@ def run(mode, argument, quiet=False, skip_tests=None):
logging.info("Found " + data.get("name", "nothing"))
else:
# It's probably a package name
data = pypidata.get_data(argument)
data = pypidata.get_data(argument, index_url=index_url)
logging.info("Found " + data.get("name", "nothing"))

rating = ratings.rate(data, skip_tests)
Expand Down
38 changes: 29 additions & 9 deletions pyroma/pypidata.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,40 @@
"project-url": "home-page",
}

DEFAULT_PYPI_XMLRPC_URL = "https://pypi.org/pypi"


def normalize(name):
return re.sub(r"[-_.]+", "-", name).lower()


def _get_project_data(project):
def _get_xmlrpc_url(index_url=None):
if not index_url:
return DEFAULT_PYPI_XMLRPC_URL

base_url = index_url.rstrip("/")
if base_url.endswith("/pypi"):
return base_url
return f"{base_url}/pypi"


def _get_project_data(project, index_url=None):
# I think I should be able to monkeypatch a mock-thingy here... I think.
response = requests.get(f"https://pypi.org/pypi/{project}/json")
xmlrpc_url = _get_xmlrpc_url(index_url)
response = requests.get(f"{xmlrpc_url}/{project}/json")
if response.status_code == 404:
raise ValueError(f"Did not find '{project}' on PyPI. Did you misspell it?")
if xmlrpc_url == DEFAULT_PYPI_XMLRPC_URL:
raise ValueError(f"Did not find '{project}' on PyPI. Did you misspell it?")
raise ValueError(f"Did not find '{project}' on package index {xmlrpc_url}.")
if not response.ok:
raise ValueError(f"Unknown http error: {response.status_code} {response.reason}")

return response.json()


def get_data(project):
def get_data(project, index_url=None):
# Pick the latest release.
project_data = _get_project_data(project)
project_data = _get_project_data(project, index_url=index_url)
releases = project_data["releases"]
data = {}

Expand All @@ -45,10 +60,15 @@ def get_data(project):
release = data["version"]
logging.debug(f"Found {project} version {release}")

with xmlrpc.client.ServerProxy("https://pypi.org/pypi") as xmlrpc_client:
roles = xmlrpc_client.package_roles(project)

data["_owners"] = [user for (role, user) in roles if role == "Owner"]
try:
with xmlrpc.client.ServerProxy(_get_xmlrpc_url(index_url)) as xmlrpc_client:
roles = xmlrpc_client.package_roles(project)
data["_owners"] = [user for (role, user) in roles if role == "Owner"]
except xmlrpc.client.ProtocolError:
logging.warning(
"Could not get package roles from XMLRPC API. Not all custom indexes "
"support this, and some may have it disabled. Skipping role checks."
)

# Get download_urls:
urls = releases[release]
Expand Down
49 changes: 49 additions & 0 deletions pyroma/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import unittest.mock
from pathlib import Path

import pyroma
from xmlrpc import client as xmlrpclib

from pyroma import projectdata, distributiondata, pypidata
Expand Down Expand Up @@ -340,6 +341,54 @@ def test_complete(self, requestmock, projectdatamock):

self.assertEqual(rating, (10, []))

@unittest.mock.patch("pyroma.pypidata.requests.get")
def test_get_project_data_custom_index_url(self, requestmock):
requestmock.return_value = unittest.mock.Mock()
requestmock.return_value.ok = True
requestmock.return_value.status_code = 200
requestmock.return_value.json.return_value = {}

pypidata._get_project_data("internalpkg", index_url="https://packages.example.com")

requestmock.assert_called_once_with("https://packages.example.com/pypi/internalpkg/json")

@unittest.mock.patch("pyroma.pypidata.requests.get")
def test_get_project_data_custom_index_url_with_pypi_path(self, requestmock):
requestmock.return_value = unittest.mock.Mock()
requestmock.return_value.ok = True
requestmock.return_value.status_code = 200
requestmock.return_value.json.return_value = {}

pypidata._get_project_data("internalpkg", index_url="https://packages.example.com/pypi")

requestmock.assert_called_once_with("https://packages.example.com/pypi/internalpkg/json")

@unittest.mock.patch("pyroma.pypidata.xmlrpc.client.ServerProxy")
@unittest.mock.patch("pyroma.pypidata._get_project_data")
def test_get_data_custom_index_url_uses_xmlrpc_endpoint(self, projectdatamock, proxymock):
projectdatamock.return_value = {
"info": {"name": "internalpkg", "version": "1.2.3"},
"releases": {"1.2.3": []},
}

proxymock.return_value.__enter__.return_value.package_roles.return_value = [("Owner", "dev1")]

data = pypidata.get_data("internalpkg", index_url="https://packages.example.com")

self.assertEqual(data["_owners"], ["dev1"])
proxymock.assert_called_once_with("https://packages.example.com/pypi")

@unittest.mock.patch("pyroma.ratings.rate")
@unittest.mock.patch("pyroma.pypidata.get_data")
def test_run_forwards_custom_index_url(self, datamock, ratemock):
datamock.return_value = {"name": "internalpkg"}
ratemock.return_value = (10, [])

result = pyroma.run("pypi", "internalpkg", quiet=True, index_url="https://packages.example.com")

self.assertEqual(result, 10)
datamock.assert_called_once_with("internalpkg", index_url="https://packages.example.com")


class ProjectDataTest(unittest.TestCase):
maxDiff = None
Expand Down
Loading