Skip to content

Commit b274e5c

Browse files
committed
feat: add pypi-info command to retrieve package information from PyPI
Add a new command 'pypi-info' that queries the PyPI JSON API to retrieve comprehensive package information including: - Package name, version, and availability status - License information - Homepage and repository URLs - Distribution type availability (sdist/wheel) The command supports both latest version queries and specific version lookups with proper error handling for missing packages/versions. Example usage: fromager pypi-info requests fromager pypi-info "requests==2.32.0" fromager pypi-info --pypi-base-url https://custom.pypi.org/pypi requests Example of output: ``` $ fromager pypi-info requests 23:04:52 INFO Fetching information for requests Package: requests Version: 2.32.5 Found on PyPI: Yes License: Apache-2.0 Homepage: https://requests.readthedocs.io Repository: https://github.com/psf/requests Has source distribution (sdist): Yes Has wheel: Yes 23:04:52 INFO Package information retrieved successfully for requests 2.32.5 ``` Includes comprehensive unit tests (13 test cases) and e2e integration tests covering all functionality including error conditions and edge cases. Co-Authored-By: Claude <noreply@anthropic.com> claude-sonnet-4@20250514 Signed-off-by: Emilien Macchi <emacchi@redhat.com>
1 parent 566dab9 commit b274e5c

File tree

7 files changed

+515
-0
lines changed

7 files changed

+515
-0
lines changed

.github/workflows/test.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ jobs:
108108
- elfdeps
109109
- prebuilt_wheel_hook
110110
- lint_requirements
111+
- pypi_info
111112
os:
112113
- ubuntu-latest
113114
- macos-latest

.mergify.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ pull_request_rules:
6060
- check-success=e2e (3.11, 1.75, extra_metadata, ubuntu-latest)
6161
- check-success=e2e (3.11, 1.75, graph_to_constraints, ubuntu-latest)
6262
- check-success=e2e (3.11, 1.75, lint_requirements, ubuntu-latest)
63+
- check-success=e2e (3.11, 1.75, pypi_info, ubuntu-latest)
6364
- check-success=e2e (3.11, 1.75, meson, ubuntu-latest)
6465
- check-success=e2e (3.11, 1.75, migrate_graph, ubuntu-latest)
6566
- check-success=e2e (3.11, 1.75, optimize_build, ubuntu-latest)
@@ -116,6 +117,8 @@ pull_request_rules:
116117
- check-success=e2e (3.12, 1.75, graph_to_constraints, ubuntu-latest)
117118
- check-success=e2e (3.12, 1.75, lint_requirements, macos-latest)
118119
- check-success=e2e (3.12, 1.75, lint_requirements, ubuntu-latest)
120+
- check-success=e2e (3.12, 1.75, pypi_info, macos-latest)
121+
- check-success=e2e (3.12, 1.75, pypi_info, ubuntu-latest)
119122
- check-success=e2e (3.12, 1.75, meson, macos-latest)
120123
- check-success=e2e (3.12, 1.75, meson, ubuntu-latest)
121124
- check-success=e2e (3.12, 1.75, migrate_graph, macos-latest)

e2e/test_pypi_info.sh

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
#!/bin/bash
2+
# -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*-
3+
4+
# Tests the fromager pypi-info command
5+
6+
set -e
7+
set -u
8+
set -o pipefail
9+
10+
pass=true
11+
12+
echo "Testing fromager pypi-info command..."
13+
14+
# Test 1: Get info for an existing package (requests)
15+
echo "Test 1: Get info for requests package"
16+
if output=$(fromager pypi-info requests 2>&1); then
17+
if echo "$output" | grep -q "Package: requests" && \
18+
echo "$output" | grep -q "Found on PyPI: Yes" && \
19+
echo "$output" | grep -q "License:" && \
20+
echo "$output" | grep -q "Has source distribution (sdist):" && \
21+
echo "$output" | grep -q "Has wheel:"; then
22+
echo "PASS: Test 1 passed"
23+
else
24+
echo "FAIL: Test 1 failed - missing expected output" 1>&2
25+
echo "Output: $output" 1>&2
26+
pass=false
27+
fi
28+
else
29+
echo "FAIL: Test 1 failed - command failed" 1>&2
30+
echo "Output: $output" 1>&2
31+
pass=false
32+
fi
33+
34+
# Test 2: Get info for a specific version
35+
echo "Test 2: Get info for requests==2.32.0"
36+
if output=$(fromager pypi-info "requests==2.32.0" 2>&1); then
37+
if echo "$output" | grep -q "Version: 2.32.0"; then
38+
echo "PASS: Test 2 passed - specific version found"
39+
else
40+
echo "FAIL: Test 2 failed - wrong version in output" 1>&2
41+
echo "Output: $output" 1>&2
42+
pass=false
43+
fi
44+
else
45+
if echo "$output" | grep -q "not found on PyPI"; then
46+
echo "PASS: Test 2 passed - version not found (expected for older versions)"
47+
else
48+
echo "FAIL: Test 2 failed with unexpected error" 1>&2
49+
echo "Output: $output" 1>&2
50+
pass=false
51+
fi
52+
fi
53+
54+
# Test 3: Get info for a non-existent package
55+
echo "Test 3: Get info for non-existent package"
56+
if output=$(fromager pypi-info nonexistentpackage123456789 2>&1); then
57+
echo "FAIL: Test 3 failed - should have failed for non-existent package" 1>&2
58+
echo "Output: $output" 1>&2
59+
pass=false
60+
else
61+
if echo "$output" | grep -q "not found on PyPI"; then
62+
echo "PASS: Test 3 passed - non-existent package correctly handled"
63+
else
64+
echo "FAIL: Test 3 failed - wrong error message" 1>&2
65+
echo "Output: $output" 1>&2
66+
pass=false
67+
fi
68+
fi
69+
70+
# Test 4: Test with invalid package specification
71+
echo "Test 4: Test with invalid package specification"
72+
if output=$(fromager pypi-info "invalid[package[spec" 2>&1); then
73+
echo "FAIL: Test 4 failed - should have failed for invalid spec" 1>&2
74+
echo "Output: $output" 1>&2
75+
pass=false
76+
else
77+
if echo "$output" | grep -q "Invalid package specification"; then
78+
echo "PASS: Test 4 passed - invalid spec correctly handled"
79+
else
80+
echo "FAIL: Test 4 failed - wrong error message" 1>&2
81+
echo "Output: $output" 1>&2
82+
pass=false
83+
fi
84+
fi
85+
86+
# Test 5: Test with unsupported version specification
87+
echo "Test 5: Test with unsupported version specification"
88+
if output=$(fromager pypi-info "requests>=2.0.0" 2>&1); then
89+
echo "FAIL: Test 5 failed - should have failed for unsupported version spec" 1>&2
90+
echo "Output: $output" 1>&2
91+
pass=false
92+
else
93+
if echo "$output" | grep -q "Only exact version specifications"; then
94+
echo "PASS: Test 5 passed - unsupported version spec correctly handled"
95+
else
96+
echo "FAIL: Test 5 failed - wrong error message" 1>&2
97+
echo "Output: $output" 1>&2
98+
pass=false
99+
fi
100+
fi
101+
102+
echo "All info command tests completed."
103+
104+
if $pass; then
105+
echo "ALL TESTS PASSED"
106+
exit 0
107+
else
108+
echo "SOME TESTS FAILED"
109+
exit 1
110+
fi

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ list-overrides = "fromager.commands.list_overrides:list_overrides"
115115
list-versions = "fromager.commands.list_versions:list_versions"
116116
migrate-config = "fromager.commands.migrate_config:migrate_config"
117117
minimize = "fromager.commands.minimize:minimize"
118+
pypi-info = "fromager.commands.pypi_info:pypi_info"
118119
stats = "fromager.commands.stats:stats"
119120
step = "fromager.commands.step:step"
120121
canonicalize = "fromager.commands.canonicalize:canonicalize"

src/fromager/commands/pypi_info.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import logging
2+
from typing import Any
3+
4+
import click
5+
from packaging.requirements import Requirement
6+
7+
from fromager import context
8+
from fromager.request_session import session
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
@click.command()
14+
@click.option(
15+
"--pypi-base-url",
16+
default="https://pypi.org/pypi",
17+
help="Base URL for PyPI JSON API",
18+
)
19+
@click.argument("package_spec", required=True)
20+
@click.pass_obj
21+
def pypi_info(
22+
wkctx: context.WorkContext,
23+
package_spec: str,
24+
pypi_base_url: str,
25+
) -> None:
26+
"""Get information about a package from PyPI.
27+
28+
The PACKAGE_SPEC should be a package name with optional version like:
29+
- "package_name" (latest version)
30+
- "package_name==1.0.0" (specific version)
31+
32+
This command queries the PyPI JSON API to retrieve package information
33+
including license, URLs, and available distribution types.
34+
"""
35+
try:
36+
req = Requirement(package_spec)
37+
package_name = req.name
38+
requested_version = None
39+
40+
# Extract specific version if provided
41+
if req.specifier:
42+
specs = list(req.specifier)
43+
if len(specs) == 1 and specs[0].operator == "==":
44+
requested_version = specs[0].version
45+
else:
46+
raise click.ClickException(
47+
f"Only exact version specifications (==) are supported, got: {req.specifier}"
48+
)
49+
except Exception as e:
50+
raise click.ClickException(
51+
f"Invalid package specification '{package_spec}': {e}"
52+
) from e
53+
54+
logger.info(f"Fetching information for {package_name}")
55+
if requested_version:
56+
logger.info(f"Requesting specific version: {requested_version}")
57+
58+
try:
59+
package_info = _get_package_info(pypi_base_url, package_name, requested_version)
60+
_display_package_info(package_info, package_name, requested_version)
61+
except PackageNotFoundError as e:
62+
logger.error(str(e))
63+
raise click.ClickException(str(e)) from e
64+
except Exception as e:
65+
logger.error(f"Failed to retrieve package information: {e}")
66+
raise click.ClickException(
67+
f"Failed to retrieve package information: {e}"
68+
) from e
69+
70+
71+
class PackageNotFoundError(Exception):
72+
"""Raised when a package or version is not found on PyPI."""
73+
74+
pass
75+
76+
77+
def _get_package_info(
78+
pypi_base_url: str, package_name: str, version: str | None = None
79+
) -> dict[str, Any]:
80+
"""Fetch package information from PyPI JSON API."""
81+
if version:
82+
url = f"{pypi_base_url}/{package_name}/{version}/json"
83+
else:
84+
url = f"{pypi_base_url}/{package_name}/json"
85+
86+
logger.debug(f"Requesting PyPI data from: {url}")
87+
88+
response = session.get(url)
89+
if response.status_code == 404:
90+
if version:
91+
raise PackageNotFoundError(
92+
f"Package '{package_name}' version '{version}' not found on PyPI"
93+
)
94+
else:
95+
raise PackageNotFoundError(f"Package '{package_name}' not found on PyPI")
96+
97+
response.raise_for_status()
98+
return response.json()
99+
100+
101+
def _display_package_info(
102+
package_data: dict[str, Any], package_name: str, requested_version: str | None
103+
) -> None:
104+
"""Display package information in a structured format."""
105+
info = package_data.get("info", {})
106+
urls = package_data.get("urls", [])
107+
108+
# Package name and version
109+
name = info.get("name", package_name)
110+
version = info.get("version", "unknown")
111+
112+
print(f"Package: {name}")
113+
print(f"Version: {version}")
114+
115+
# Package found status
116+
print("Found on PyPI: Yes")
117+
118+
# License information
119+
license_info = info.get("license") or "Not specified"
120+
# Handle cases where license is empty string or None
121+
if not license_info or license_info.strip() == "":
122+
license_info = "Not specified"
123+
print(f"License: {license_info}")
124+
125+
# URLs
126+
home_page = info.get("home_page") or info.get("project_url")
127+
if home_page:
128+
print(f"Homepage: {home_page}")
129+
else:
130+
print("Homepage: Not specified")
131+
132+
# Check for other relevant URLs
133+
project_urls = info.get("project_urls") or {}
134+
if isinstance(project_urls, dict):
135+
for url_type, url in project_urls.items():
136+
if url_type.lower() in ("repository", "source", "github", "gitlab"):
137+
print(f"Repository: {url}")
138+
break
139+
140+
# Distribution types analysis
141+
has_sdist = False
142+
has_wheel = False
143+
144+
for url_info in urls:
145+
package_type = url_info.get("packagetype", "")
146+
if package_type == "sdist":
147+
has_sdist = True
148+
elif package_type == "bdist_wheel":
149+
has_wheel = True
150+
151+
print(f"Has source distribution (sdist): {'Yes' if has_sdist else 'No'}")
152+
print(f"Has wheel: {'Yes' if has_wheel else 'No'}")
153+
154+
# Summary
155+
logger.info(f"Package information retrieved successfully for {name} {version}")

tests/test_cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def test_fromager_version(cli_runner: CliRunner) -> None:
5959
"list-versions",
6060
"migrate-config",
6161
"minimize",
62+
"pypi-info",
6263
"stats",
6364
"step",
6465
"wheel-server",

0 commit comments

Comments
 (0)