Skip to content
Closed
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
1 change: 1 addition & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ jobs:
- elfdeps
- prebuilt_wheel_hook
- lint_requirements
- pypi_info
os:
- ubuntu-latest
- macos-latest
Expand Down
3 changes: 3 additions & 0 deletions .mergify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
110 changes: 110 additions & 0 deletions e2e/test_pypi_info.sh
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
155 changes: 155 additions & 0 deletions src/fromager/commands/pypi_info.py
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about you make this two arguments: fromager pypi-info torch 2.7.1 with version as an optional argument?

@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}")
Comment on lines +118 to +123
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to handle several additional special cases. License information can be in three fields. Try fromager pypi-info fromager and fromager pypi-info cryptography to see two special cases.


# 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 {}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
project_urls = info.get("project_urls") or {}
project_urls = info.get("project_urls", {})

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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On recurring problem we have is packages with only prerelease versions, which are not resolved by default. It would be useful to show that sort of detail here when no version is specified.

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}")
1 change: 1 addition & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ def test_fromager_version(cli_runner: CliRunner) -> None:
"list-versions",
"migrate-config",
"minimize",
"pypi-info",
"stats",
"step",
"wheel-server",
Expand Down
Loading
Loading