-
Notifications
You must be signed in to change notification settings - Fork 39
feat: add pypi-info command to retrieve package information from PyPI #829
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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) | ||||||
| @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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||
|
|
||||||
| # 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 {} | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| 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: | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}") | ||||||
There was a problem hiding this comment.
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.1with version as an optional argument?