Skip to content

Commit 58993f9

Browse files
authored
Feat/0.5 (#159)
# Pull Request Fixes # ## Proposed Changes - - - --------- Co-authored-by: viseshrp <[email protected]>
1 parent c95aeae commit 58993f9

File tree

7 files changed

+106
-74
lines changed

7 files changed

+106
-74
lines changed

CHANGELOG.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
77

88
## [0.5.0] - <Unreleased>
99

10-
- TODO: Add ability to update package versions in pyproject.toml files.
10+
### Added
11+
12+
- TODO: Add ability to add/update packages in pyproject.toml files.
13+
14+
### Changed
15+
16+
- Trim package/release info output.
17+
18+
### Security
19+
20+
- Fixed a security vulnerability in the `requests` library dependency by upgrading to version 2.32.4.
1121

1222
## [0.4.2] - 2025-05-24
1323

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,14 @@ Options:
148148
> $ whatsonpypi django --history -5
149149
> ```
150150
151+
- Filter display output using the `--fields` flag.
152+
153+
> Examples:
154+
>
155+
> ``` bash
156+
> $ whatsonpypi django --fields name,current_version,latest_releases
157+
> ```
158+
151159
## 🧾 Changelog
152160
153161
See [CHANGELOG.md](https://github.com/viseshrp/whatsonpypi/blob/main/CHANGELOG.md)

codecov.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ coverage:
33
round: down
44
precision: 1
55
status:
6+
patch: off
67
project:
78
default:
89
target: 85%

tests/test_cli.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,19 +53,19 @@ def test_open_flag_opens_pypi(monkeypatch: pytest.MonkeyPatch) -> None:
5353
def test_history_with_value(arg: str) -> None:
5454
result = CliRunner().invoke(cli.main, ["django", arg])
5555
assert result.exit_code == 0
56-
assert "SDIST" in result.output
56+
assert "MD5" in result.output and "FILENAME" in result.output
5757

5858

5959
def test_history_old() -> None:
6060
result = CliRunner().invoke(cli.main, ["django", "--history=-5"])
6161
assert result.exit_code == 0
62-
assert "SDIST" in result.output and "BDIST" not in result.output and "1.2" in result.output
62+
assert "1.2" in result.output
6363

6464

6565
def test_history_new() -> None:
6666
result = CliRunner().invoke(cli.main, ["django", "--history=5"])
6767
assert result.exit_code == 0
68-
assert "SDIST" in result.output and "BDIST" in result.output and "1.2" not in result.output
68+
assert "1.2" not in result.output
6969

7070

7171
def test_invalid_package() -> None:

tests/test_whatsonpypi.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,4 @@ def test_run_query_typical_with_more() -> None:
4747
result = run_query("rich", version=None, more_out=True, launch_docs=False, open_page=False)
4848
assert result is not None
4949
assert isinstance(result["dependencies"], str)
50-
assert "sha256" in str(result["current_package_info"]).lower()
50+
assert "yanked" in str(result["current_package_info"]).lower()

whatsonpypi/client.py

Lines changed: 43 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from requests import Request, Session, hooks
1212
from requests.adapters import HTTPAdapter
13+
from requests.exceptions import RequestException
1314
from requests.packages.urllib3.util.retry import Retry
1415

1516
from .constants import PYPI_BASE_URL
@@ -23,7 +24,7 @@ class WoppResponse:
2324
A structured wrapper for PyPI JSON API responses.
2425
"""
2526

26-
def __init__(self, status_code: int, json: dict[str, Any]) -> None:
27+
def __init__(self, status_code: int, json: dict[str, Any] | None) -> None:
2728
self.status_code: int = status_code
2829
self.json: dict[str, Any] = json or {}
2930
self._cache: dict[str, Any] = {}
@@ -113,21 +114,14 @@ def get_releases_with_dates(self) -> list[tuple[str, datetime]]:
113114
releases_with_dates = []
114115
for release in self.releases:
115116
info = self.release_data.get(release, {})
116-
release_date = None
117117
if info:
118-
# loop through package types to find the first valid upload time
119-
for metadata in info.values():
120-
upload_time = metadata.get("upload_time")
121-
if upload_time:
122-
try:
123-
release_date = datetime.fromisoformat(
124-
upload_time.replace("Z", "+00:00")
125-
)
126-
break
127-
except ValueError:
128-
continue
129-
if release_date:
130-
releases_with_dates.append((release, release_date))
118+
upload_time = info.get("upload_time")
119+
if isinstance(upload_time, str):
120+
try:
121+
release_date = datetime.fromisoformat(upload_time.replace("Z", "+00:00"))
122+
releases_with_dates.append((release, release_date))
123+
except ValueError:
124+
continue
131125
return releases_with_dates
132126

133127
def get_sorted_releases(self) -> list[str]:
@@ -170,6 +164,25 @@ def __init__(
170164
self.session: Session | None = Session() if pool_connections else None
171165
self.request_hooks: dict[str, list[Any]] = request_hooks or hooks.default_hooks()
172166

167+
def _build_url(
168+
self,
169+
package: str,
170+
version: str | None,
171+
) -> str:
172+
"""
173+
Construct a fully qualified PyPI API URL.
174+
175+
:param package: The package name
176+
:param version: Optional version
177+
:return: URL string
178+
"""
179+
180+
return (
181+
f"{self.base_url}/{package}/{version}/json"
182+
if version
183+
else f"{self.base_url}/{package}/json"
184+
)
185+
173186
def request(
174187
self,
175188
package: str | None = None,
@@ -186,8 +199,11 @@ def request(
186199
:param max_retries: Retry attempts for failed requests
187200
:return: WoppResponse object with parsed data
188201
:raises PackageNotProvidedError: if package is None
189-
:raises PackageNotFoundError: if the PyPI API returns 404
202+
:raises PackageNotFoundError: if the PyPI API returns 404 or 5xx status codes
190203
"""
204+
if package is None:
205+
raise PackageNotProvidedError
206+
191207
url = self._build_url(package, version)
192208
req_kwargs = {
193209
"method": "GET",
@@ -212,35 +228,15 @@ def request(
212228
session.mount("http://", adapter)
213229
session.mount("https://", adapter)
214230

215-
response = session.send(
216-
prepared_request,
217-
timeout=timeout,
218-
allow_redirects=True,
219-
)
220-
221-
if response.status_code == 404:
222-
raise PackageNotFoundError
223-
224-
return WoppResponse(response.status_code, response.cleaned_json)
225-
226-
def _build_url(
227-
self,
228-
package: str | None,
229-
version: str | None,
230-
) -> str:
231-
"""
232-
Construct a fully qualified PyPI API URL.
233-
234-
:param package: The package name
235-
:param version: Optional version
236-
:return: URL string
237-
:raises PackageNotProvidedError: if package is None
238-
"""
239-
if package is None:
240-
raise PackageNotProvidedError
231+
try:
232+
response = session.send(
233+
prepared_request,
234+
timeout=timeout,
235+
allow_redirects=True,
236+
)
237+
if response.status_code == 404 or response.status_code >= 500:
238+
raise PackageNotFoundError # Treat all 5xx as failure to find package
239+
except RequestException as e:
240+
raise PackageNotFoundError from e
241241

242-
return (
243-
f"{self.base_url}/{package}/{version}/json"
244-
if version
245-
else f"{self.base_url}/{package}/json"
246-
)
242+
return WoppResponse(response.status_code, getattr(response, "cleaned_json", None))

whatsonpypi/utils.py

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -118,28 +118,43 @@ def render_table(input_dict: dict[str, Any]) -> Table:
118118
click.echo("\t" * (indent + 1) + format_value(value))
119119

120120

121-
def filter_release_info(pkg_url_list: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
121+
def get_human_size(size_bytes: float) -> str | None:
122+
if size_bytes < 0:
123+
return None
124+
for unit in ["B", "KB", "MB", "GB"]:
125+
if size_bytes < 1024.0:
126+
return f"{size_bytes:.2f} {unit}"
127+
size_bytes /= 1024.0
128+
return f"{size_bytes:.2f} TB"
129+
130+
131+
def filter_release_info(pkg_url_list: list[dict[str, Any]]) -> dict[str, Any | None]:
122132
"""
123-
Converts a list of package info dicts into a dict keyed by packagetype.
133+
Converts a list of package info dicts into a dict.
124134
"""
125-
result = {}
135+
136+
def _get_info(pkg_: dict[str, Any]) -> dict[str, Any | None]:
137+
return {
138+
"filename": pkg_.get("filename"),
139+
"size": get_human_size(float(pkg_.get("size", 0))),
140+
"upload_time": pkg_.get("upload_time_iso_8601"),
141+
"requires_python": pkg_.get("requires_python"),
142+
"url": pkg_.get("url"),
143+
"yanked": pkg_.get("yanked"),
144+
"md5": pkg_.get("digests", {}).get("md5"),
145+
}
146+
147+
info = {}
126148
for pkg in pkg_url_list:
127-
key = pkg.get("packagetype")
128-
if key:
129-
digests = pkg.get("digests") or {}
130-
result[key] = {
131-
"filename": pkg.get("filename"),
132-
"size": pkg.get("size"),
133-
"upload_time": pkg.get("upload_time_iso_8601"),
134-
"requires_python": pkg.get("requires_python"),
135-
"python_version": pkg.get("python_version"),
136-
"url": pkg.get("url"),
137-
"yanked": pkg.get("yanked"),
138-
"yanked_reason": pkg.get("yanked_reason"),
139-
"md5": digests.get("md5"),
140-
"sha256": digests.get("sha256"),
141-
}
142-
return result
149+
package_type = pkg.get("packagetype", "").lower()
150+
if "wheel" in package_type:
151+
info = _get_info(pkg)
152+
else:
153+
# for other types, just use the first one found
154+
if not info:
155+
info = _get_info(pkg)
156+
157+
return info
143158

144159

145160
def clean_response(r: Any, *_args: Any, **_kwargs: Any) -> Any:
@@ -175,9 +190,11 @@ def clean_response(r: Any, *_args: Any, **_kwargs: Any) -> Any:
175190
releases = dirty.get("releases")
176191
if releases:
177192
release_list = list(releases.keys())
178-
release_info = {
179-
version: filter_release_info(files) for version, files in releases.items() if files
180-
}
193+
release_info = {}
194+
for release_version, file_info_list in releases.items():
195+
if not file_info_list:
196+
continue
197+
release_info[release_version] = filter_release_info(file_info_list)
181198
clean.update(
182199
{
183200
"releases": release_list,

0 commit comments

Comments
 (0)