diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 9642f675..148995c4 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -108,6 +108,7 @@ jobs: - elfdeps - prebuilt_wheel_hook - lint_requirements + - pypi_info os: - ubuntu-latest - macos-latest diff --git a/.mergify.yml b/.mergify.yml index 66bbd25e..f7fee856 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -60,6 +60,7 @@ pull_request_rules: - check-success=e2e (3.11, 1.75, extra_metadata, ubuntu-latest) - check-success=e2e (3.11, 1.75, graph_to_constraints, ubuntu-latest) - check-success=e2e (3.11, 1.75, lint_requirements, ubuntu-latest) + - check-success=e2e (3.11, 1.75, pypi_info, ubuntu-latest) - check-success=e2e (3.11, 1.75, meson, ubuntu-latest) - check-success=e2e (3.11, 1.75, migrate_graph, ubuntu-latest) - check-success=e2e (3.11, 1.75, optimize_build, ubuntu-latest) @@ -116,6 +117,8 @@ pull_request_rules: - check-success=e2e (3.12, 1.75, graph_to_constraints, ubuntu-latest) - check-success=e2e (3.12, 1.75, lint_requirements, macos-latest) - check-success=e2e (3.12, 1.75, lint_requirements, ubuntu-latest) + - check-success=e2e (3.12, 1.75, pypi_info, macos-latest) + - check-success=e2e (3.12, 1.75, pypi_info, ubuntu-latest) - check-success=e2e (3.12, 1.75, meson, macos-latest) - check-success=e2e (3.12, 1.75, meson, ubuntu-latest) - check-success=e2e (3.12, 1.75, migrate_graph, macos-latest) diff --git a/e2e/test_pypi_info.sh b/e2e/test_pypi_info.sh new file mode 100755 index 00000000..e8b665c3 --- /dev/null +++ b/e2e/test_pypi_info.sh @@ -0,0 +1,110 @@ +#!/bin/bash +# -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*- + +# Tests the fromager pypi-info command + +set -e +set -u +set -o pipefail + +pass=true + +echo "Testing fromager pypi-info command..." + +# Test 1: Get info for an existing package (requests) +echo "Test 1: Get info for requests package" +if output=$(fromager pypi-info requests 2>&1); then + if echo "$output" | grep -q "Package: requests" && \ + echo "$output" | grep -q "Found on PyPI: Yes" && \ + echo "$output" | grep -q "License:" && \ + echo "$output" | grep -q "Has source distribution (sdist):" && \ + echo "$output" | grep -q "Has wheel:"; then + echo "PASS: Test 1 passed" + else + echo "FAIL: Test 1 failed - missing expected output" 1>&2 + echo "Output: $output" 1>&2 + pass=false + fi +else + echo "FAIL: Test 1 failed - command failed" 1>&2 + echo "Output: $output" 1>&2 + pass=false +fi + +# Test 2: Get info for a specific version +echo "Test 2: Get info for requests==2.32.0" +if output=$(fromager pypi-info "requests==2.32.0" 2>&1); then + if echo "$output" | grep -q "Version: 2.32.0"; then + echo "PASS: Test 2 passed - specific version found" + else + echo "FAIL: Test 2 failed - wrong version in output" 1>&2 + echo "Output: $output" 1>&2 + pass=false + fi +else + if echo "$output" | grep -q "not found on PyPI"; then + echo "PASS: Test 2 passed - version not found (expected for older versions)" + else + echo "FAIL: Test 2 failed with unexpected error" 1>&2 + echo "Output: $output" 1>&2 + pass=false + fi +fi + +# Test 3: Get info for a non-existent package +echo "Test 3: Get info for non-existent package" +if output=$(fromager pypi-info nonexistentpackage123456789 2>&1); then + echo "FAIL: Test 3 failed - should have failed for non-existent package" 1>&2 + echo "Output: $output" 1>&2 + pass=false +else + if echo "$output" | grep -q "not found on PyPI"; then + echo "PASS: Test 3 passed - non-existent package correctly handled" + else + echo "FAIL: Test 3 failed - wrong error message" 1>&2 + echo "Output: $output" 1>&2 + pass=false + fi +fi + +# Test 4: Test with invalid package specification +echo "Test 4: Test with invalid package specification" +if output=$(fromager pypi-info "invalid[package[spec" 2>&1); then + echo "FAIL: Test 4 failed - should have failed for invalid spec" 1>&2 + echo "Output: $output" 1>&2 + pass=false +else + if echo "$output" | grep -q "Invalid package specification"; then + echo "PASS: Test 4 passed - invalid spec correctly handled" + else + echo "FAIL: Test 4 failed - wrong error message" 1>&2 + echo "Output: $output" 1>&2 + pass=false + fi +fi + +# Test 5: Test with unsupported version specification +echo "Test 5: Test with unsupported version specification" +if output=$(fromager pypi-info "requests>=2.0.0" 2>&1); then + echo "FAIL: Test 5 failed - should have failed for unsupported version spec" 1>&2 + echo "Output: $output" 1>&2 + pass=false +else + if echo "$output" | grep -q "Only exact version specifications"; then + echo "PASS: Test 5 passed - unsupported version spec correctly handled" + else + echo "FAIL: Test 5 failed - wrong error message" 1>&2 + echo "Output: $output" 1>&2 + pass=false + fi +fi + +echo "All info command tests completed." + +if $pass; then + echo "ALL TESTS PASSED" + exit 0 +else + echo "SOME TESTS FAILED" + exit 1 +fi \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 65a237b2..a9e6f09b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,6 +115,7 @@ list-overrides = "fromager.commands.list_overrides:list_overrides" list-versions = "fromager.commands.list_versions:list_versions" migrate-config = "fromager.commands.migrate_config:migrate_config" minimize = "fromager.commands.minimize:minimize" +pypi-info = "fromager.commands.pypi_info:pypi_info" stats = "fromager.commands.stats:stats" step = "fromager.commands.step:step" canonicalize = "fromager.commands.canonicalize:canonicalize" diff --git a/src/fromager/commands/pypi_info.py b/src/fromager/commands/pypi_info.py new file mode 100644 index 00000000..4706eed7 --- /dev/null +++ b/src/fromager/commands/pypi_info.py @@ -0,0 +1,155 @@ +import logging +from typing import Any + +import click +from packaging.requirements import Requirement + +from fromager import context +from fromager.request_session import session + +logger = logging.getLogger(__name__) + + +@click.command() +@click.option( + "--pypi-base-url", + default="https://pypi.org/pypi", + help="Base URL for PyPI JSON API", +) +@click.argument("package_spec", required=True) +@click.pass_obj +def pypi_info( + wkctx: context.WorkContext, + package_spec: str, + pypi_base_url: str, +) -> None: + """Get information about a package from PyPI. + + The PACKAGE_SPEC should be a package name with optional version like: + - "package_name" (latest version) + - "package_name==1.0.0" (specific version) + + This command queries the PyPI JSON API to retrieve package information + including license, URLs, and available distribution types. + """ + try: + req = Requirement(package_spec) + package_name = req.name + requested_version = None + + # Extract specific version if provided + if req.specifier: + specs = list(req.specifier) + if len(specs) == 1 and specs[0].operator == "==": + requested_version = specs[0].version + else: + raise click.ClickException( + f"Only exact version specifications (==) are supported, got: {req.specifier}" + ) + except Exception as e: + raise click.ClickException( + f"Invalid package specification '{package_spec}': {e}" + ) from e + + logger.info(f"Fetching information for {package_name}") + if requested_version: + logger.info(f"Requesting specific version: {requested_version}") + + try: + package_info = _get_package_info(pypi_base_url, package_name, requested_version) + _display_package_info(package_info, package_name, requested_version) + except PackageNotFoundError as e: + logger.error(str(e)) + raise click.ClickException(str(e)) from e + except Exception as e: + logger.error(f"Failed to retrieve package information: {e}") + raise click.ClickException( + f"Failed to retrieve package information: {e}" + ) from e + + +class PackageNotFoundError(Exception): + """Raised when a package or version is not found on PyPI.""" + + pass + + +def _get_package_info( + pypi_base_url: str, package_name: str, version: str | None = None +) -> dict[str, Any]: + """Fetch package information from PyPI JSON API.""" + if version: + url = f"{pypi_base_url}/{package_name}/{version}/json" + else: + url = f"{pypi_base_url}/{package_name}/json" + + logger.debug(f"Requesting PyPI data from: {url}") + + response = session.get(url) + if response.status_code == 404: + if version: + raise PackageNotFoundError( + f"Package '{package_name}' version '{version}' not found on PyPI" + ) + else: + raise PackageNotFoundError(f"Package '{package_name}' not found on PyPI") + + response.raise_for_status() + return response.json() + + +def _display_package_info( + package_data: dict[str, Any], package_name: str, requested_version: str | None +) -> None: + """Display package information in a structured format.""" + info = package_data.get("info", {}) + urls = package_data.get("urls", []) + + # Package name and version + name = info.get("name", package_name) + version = info.get("version", "unknown") + + print(f"Package: {name}") + print(f"Version: {version}") + + # Package found status + print("Found on PyPI: Yes") + + # License information + license_info = info.get("license") or "Not specified" + # Handle cases where license is empty string or None + if not license_info or license_info.strip() == "": + license_info = "Not specified" + print(f"License: {license_info}") + + # URLs + home_page = info.get("home_page") or info.get("project_url") + if home_page: + print(f"Homepage: {home_page}") + else: + print("Homepage: Not specified") + + # Check for other relevant URLs + project_urls = info.get("project_urls") or {} + if isinstance(project_urls, dict): + for url_type, url in project_urls.items(): + if url_type.lower() in ("repository", "source", "github", "gitlab"): + print(f"Repository: {url}") + break + + # Distribution types analysis + has_sdist = False + has_wheel = False + + for url_info in urls: + package_type = url_info.get("packagetype", "") + if package_type == "sdist": + has_sdist = True + elif package_type == "bdist_wheel": + has_wheel = True + + print(f"Has source distribution (sdist): {'Yes' if has_sdist else 'No'}") + print(f"Has wheel: {'Yes' if has_wheel else 'No'}") + + # Summary + logger.info(f"Package information retrieved successfully for {name} {version}") diff --git a/tests/test_cli.py b/tests/test_cli.py index 769f3eb0..7593ecba 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -59,6 +59,7 @@ def test_fromager_version(cli_runner: CliRunner) -> None: "list-versions", "migrate-config", "minimize", + "pypi-info", "stats", "step", "wheel-server", diff --git a/tests/test_pypi_info.py b/tests/test_pypi_info.py new file mode 100644 index 00000000..9f7bd8c9 --- /dev/null +++ b/tests/test_pypi_info.py @@ -0,0 +1,244 @@ +from unittest.mock import Mock, patch + +import pytest +import requests +from click.testing import CliRunner + +from fromager.commands.pypi_info import ( + PackageNotFoundError, + _get_package_info, + pypi_info, +) + + +@pytest.fixture +def mock_package_data(): + """Mock PyPI package data for testing.""" + return { + "info": { + "name": "test-package", + "version": "1.0.0", + "license": "MIT", + "home_page": "https://example.com", + "project_urls": { + "Repository": "https://github.com/example/test-package", + "Documentation": "https://docs.example.com", + }, + }, + "urls": [ + {"packagetype": "sdist", "filename": "test-package-1.0.0.tar.gz"}, + { + "packagetype": "bdist_wheel", + "filename": "test_package-1.0.0-py3-none-any.whl", + }, + ], + } + + +@pytest.fixture +def mock_package_data_wheel_only(): + """Mock PyPI package data with only wheel.""" + return { + "info": { + "name": "wheel-only-package", + "version": "2.0.0", + "license": "", + "home_page": "", + "project_urls": {}, + }, + "urls": [ + { + "packagetype": "bdist_wheel", + "filename": "wheel_only_package-2.0.0-py3-none-any.whl", + }, + ], + } + + +class TestGetPackageInfo: + """Tests for _get_package_info function.""" + + @patch("fromager.commands.pypi_info.session") + def test_get_package_info_success_latest(self, mock_session, mock_package_data): + """Test successful package info retrieval for latest version.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = mock_package_data + mock_session.get.return_value = mock_response + + result = _get_package_info("https://pypi.org/pypi", "test-package") + + assert result == mock_package_data + mock_session.get.assert_called_once_with( + "https://pypi.org/pypi/test-package/json" + ) + + @patch("fromager.commands.pypi_info.session") + def test_get_package_info_success_specific_version( + self, mock_session, mock_package_data + ): + """Test successful package info retrieval for specific version.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = mock_package_data + mock_session.get.return_value = mock_response + + result = _get_package_info("https://pypi.org/pypi", "test-package", "1.0.0") + + assert result == mock_package_data + mock_session.get.assert_called_once_with( + "https://pypi.org/pypi/test-package/1.0.0/json" + ) + + @patch("fromager.commands.pypi_info.session") + def test_get_package_info_not_found_package(self, mock_session): + """Test package not found error.""" + mock_response = Mock() + mock_response.status_code = 404 + mock_session.get.return_value = mock_response + + with pytest.raises( + PackageNotFoundError, match="Package 'nonexistent' not found on PyPI" + ): + _get_package_info("https://pypi.org/pypi", "nonexistent") + + @patch("fromager.commands.pypi_info.session") + def test_get_package_info_not_found_version(self, mock_session): + """Test version not found error.""" + mock_response = Mock() + mock_response.status_code = 404 + mock_session.get.return_value = mock_response + + with pytest.raises( + PackageNotFoundError, + match=r"Package 'test-package' version '9.9.9' not found on PyPI", + ): + _get_package_info("https://pypi.org/pypi", "test-package", "9.9.9") + + @patch("fromager.commands.pypi_info.session") + def test_get_package_info_http_error(self, mock_session): + """Test HTTP error handling.""" + mock_response = Mock() + mock_response.status_code = 500 + mock_response.raise_for_status.side_effect = requests.HTTPError("Server error") + mock_session.get.return_value = mock_response + + with pytest.raises(requests.HTTPError): + _get_package_info("https://pypi.org/pypi", "test-package") + + +class TestPypiInfoCommand: + """Tests for the pypi-info command.""" + + def test_pypi_info_command_success(self, mock_package_data): + """Test successful pypi-info command execution.""" + runner = CliRunner() + + with patch("fromager.commands.pypi_info._get_package_info") as mock_get_info: + mock_get_info.return_value = mock_package_data + + result = runner.invoke(pypi_info, ["test-package"], obj=Mock()) + + assert result.exit_code == 0 + output = result.output + assert "Package: test-package" in output + assert "Version: 1.0.0" in output + assert "Found on PyPI: Yes" in output + assert "License: MIT" in output + assert "Homepage: https://example.com" in output + assert "Repository: https://github.com/example/test-package" in output + assert "Has source distribution (sdist): Yes" in output + assert "Has wheel: Yes" in output + + def test_pypi_info_command_wheel_only(self, mock_package_data_wheel_only): + """Test info command with wheel-only package.""" + runner = CliRunner() + + with patch("fromager.commands.pypi_info._get_package_info") as mock_get_info: + mock_get_info.return_value = mock_package_data_wheel_only + + result = runner.invoke(pypi_info, ["wheel-only-package"], obj=Mock()) + + assert result.exit_code == 0 + output = result.output + assert "Package: wheel-only-package" in output + assert "License: Not specified" in output + assert "Homepage: Not specified" in output + assert "Has source distribution (sdist): No" in output + assert "Has wheel: Yes" in output + + def test_pypi_info_command_with_version_spec(self, mock_package_data): + """Test info command with version specification.""" + runner = CliRunner() + + with patch("fromager.commands.pypi_info._get_package_info") as mock_get_info: + mock_get_info.return_value = mock_package_data + + result = runner.invoke(pypi_info, ["test-package==1.0.0"], obj=Mock()) + + assert result.exit_code == 0 + mock_get_info.assert_called_once_with( + "https://pypi.org/pypi", "test-package", "1.0.0" + ) + + def test_pypi_info_command_invalid_package_spec(self): + """Test info command with invalid package specification.""" + runner = CliRunner() + + result = runner.invoke(pypi_info, ["invalid[package[spec"], obj=Mock()) + + assert result.exit_code == 1 + assert "Invalid package specification" in result.output + + def test_pypi_info_command_unsupported_version_spec(self): + """Test info command with unsupported version specification.""" + runner = CliRunner() + + result = runner.invoke(pypi_info, ["test-package>=1.0.0"], obj=Mock()) + + assert result.exit_code == 1 + assert "Only exact version specifications (==) are supported" in result.output + + def test_pypi_info_command_package_not_found(self): + """Test info command with package not found.""" + runner = CliRunner() + + with patch("fromager.commands.pypi_info._get_package_info") as mock_get_info: + mock_get_info.side_effect = PackageNotFoundError( + "Package 'nonexistent' not found on PyPI" + ) + + result = runner.invoke(pypi_info, ["nonexistent"], obj=Mock()) + + assert result.exit_code == 1 + assert "Package 'nonexistent' not found on PyPI" in result.output + + def test_pypi_info_command_custom_pypi_url(self, mock_package_data): + """Test info command with custom PyPI base URL.""" + runner = CliRunner() + + with patch("fromager.commands.pypi_info._get_package_info") as mock_get_info: + mock_get_info.return_value = mock_package_data + + result = runner.invoke( + pypi_info, + ["--pypi-base-url", "https://custom.pypi.org/pypi", "test-package"], + obj=Mock(), + ) + + assert result.exit_code == 0 + mock_get_info.assert_called_once_with( + "https://custom.pypi.org/pypi", "test-package", None + ) + + def test_pypi_info_command_general_exception(self): + """Test info command with general exception.""" + runner = CliRunner() + + with patch("fromager.commands.pypi_info._get_package_info") as mock_get_info: + mock_get_info.side_effect = Exception("Network error") + + result = runner.invoke(pypi_info, ["test-package"], obj=Mock()) + + assert result.exit_code == 1 + assert "Failed to retrieve package information" in result.output