Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.12
rev: v0.13.0
hooks:
- id: ruff-check
args: [ --fix ]
Expand Down
13 changes: 5 additions & 8 deletions leak/main.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,23 @@
import sys
from contextlib import contextmanager

import requests

from leak import config, console, logger, ui
from leak.utils import dummy_context, handle_requests_errors


@contextmanager
def dummy_context(*args, **kwargs):
yield


@handle_requests_errors
def get_package_data(package_name: str) -> dict:
url = f"https://pypi.org/pypi/{package_name}/json"
resp = requests.get(url)
resp = requests.get(url, timeout=config.REQUESTS_TIMEOUT)
if resp.status_code != 200:
raise ValueError("No such package")

data = resp.json()
return data


@handle_requests_errors
def get_downloads_data(package_name: str) -> dict:
if not config.API_KEY or not config.SHOW_DOWNLOADS:
logger.warning("Skipping downloads data retrieval")
Expand All @@ -30,7 +27,7 @@ def get_downloads_data(package_name: str) -> dict:
headers = {
"X-API-Key": config.API_KEY,
}
resp = requests.get(url, headers=headers)
resp = requests.get(url, headers=headers, timeout=config.REQUESTS_TIMEOUT)
if resp.status_code != 200:
logger.error(f"Cannot get downloads data: [{resp.status_code}] {resp.text}")
return {}
Expand Down
38 changes: 29 additions & 9 deletions leak/parser.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
from datetime import datetime
from email.header import decode_header
from email.utils import parseaddr
Expand Down Expand Up @@ -57,35 +58,54 @@ def decode_name(encoded_data) -> str:
return name.decode(encoding) if isinstance(name, bytes) else name


def maybe_with_email(author_line: str) -> str:
parsed = parseaddr(author_line)
author = author_line
if all(parsed):
author = parsed[0]

return decode_name(author)
Comment on lines +63 to +67
Copy link

Copilot AI Sep 12, 2025

Choose a reason for hiding this comment

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

The function calls decode_name(author) but author could be the original author_line string when all(parsed) is False. The decode_name function expects email header encoded data, but author_line might not be encoded, causing potential decoding errors.

Suggested change
author = author_line
if all(parsed):
author = parsed[0]
return decode_name(author)
if all(parsed):
return decode_name(parsed[0])
else:
return author_line

Copilot uses AI. Check for mistakes.


def get_author(info: dict) -> str:
if "author" in info and info["author"]:
return info["author"]
return maybe_with_email(info["author"])

if "author_email" in info and info["author_email"]:
emails = info["author_email"].split(",")
return decode_name(parseaddr(emails[0])[0])
shared = shared_email(info["author_email"])
return decode_name(shared[0] if shared else parseaddr(emails[0])[0])

if "maintainer" in info and info["maintainer"]:
return info["maintainer"]
return maybe_with_email(info["maintainer"])

if "maintainer_email" in info and info["maintainer_email"]:
emails = info["maintainer_email"].split(",")
return decode_name(parseaddr(emails[0])[0])
shared = shared_email(info["maintainer_email"])
return decode_name(shared[0] if shared else parseaddr(emails[0])[0])
return "n/a"


def get_email(info) -> str:
def shared_email(author_line: str) -> Optional[tuple[str, str]]:
shared_match = re.match(r'^"([^"]+)"\s*<([^>]+)>$', author_line.strip())
if shared_match:
return shared_match.groups()


def get_email(info: dict) -> str:
if "author_email" in info and info["author_email"]:
emails = info["author_email"].split(",")
return parseaddr(emails[0])[1]
shared = shared_email(info["author_email"])
return shared[1] if shared else parseaddr(emails[0])[1]

if "maintainer_email" in info and info["maintainer_email"]:
emails = info["maintainer_email"].split(",")
return parseaddr(emails[0])[1]
shared = shared_email(info["maintainer_email"])
return shared[1] if shared else parseaddr(emails[0])[1]
return "n/a"


def get_homepage(info) -> str:
def get_homepage(info: dict) -> str:
if "home_page" in info and info["home_page"]:
return info["home_page"]
if "project_url" in info and info["project_url"]:
Expand All @@ -94,7 +114,7 @@ def get_homepage(info) -> str:
return homepage


def get_license(info) -> str:
def get_license(info: dict) -> str:
if "license" in info and info["license"]:
# Return the first non-empty line of the license
# as license field can contain full license text
Expand Down
1 change: 1 addition & 0 deletions leak/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ def allowed_config_keys(cls) -> set[str]:
PANEL_WIDTH: int = 70
API_KEY: Optional[str] = None
SHOW_DOWNLOADS: bool = True
REQUESTS_TIMEOUT: float = 5.0


config = Settings()
3 changes: 0 additions & 3 deletions leak/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,6 @@ def show_package_versions(releases, downloads: dict, showall: bool = False):

for release_num, release_data in releases.items():
downloads_count = parser.get_max_downloads_for_release(release_data)
if downloads_count:
print(downloads_count)

upload_date = parser.get_latest_time_for_release(release_data)
if upload_date > most_recent_date:
most_recent_date = upload_date
Expand Down
29 changes: 29 additions & 0 deletions leak/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import sys
from contextlib import contextmanager
from functools import wraps
from typing import Any, Callable, Generator

import requests

from leak import logger, ui


@contextmanager
def dummy_context(*args, **kwargs) -> Generator[None, None, None]:
yield


def handle_requests_errors(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except requests.exceptions.RequestException as e:
logger.error(e)
ui.warning(
"[bold red]Unexpected network error occurred. "
"Please try again later.[/]"
)
return sys.exit(1)

return wrapper
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "leak"
version = "2.2.1"
version = "2.2.2"
description = "Show release information about packages on PyPI"
authors = [
{ name = "Misha Behersky", email = "bmwant@gmail.com" },
Expand Down
26 changes: 26 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import json
from pathlib import Path

import pytest
from click.testing import CliRunner

from leak import settings

CURRENT_DIR = Path(__file__).parent.resolve()
DATA_DIR = CURRENT_DIR / "data"


@pytest.fixture
def runner():
Expand All @@ -14,3 +20,23 @@ def runner():
def patch_settings(monkeypatch):
monkeypatch.setattr(settings.config, "API_KEY", "")
monkeypatch.setattr(settings.config, "SHOW_DOWNLOADS", True)


@pytest.fixture
def package_data():
def _get_package_data(package_name: str) -> dict:
with open(DATA_DIR / f"{package_name}_package_data.json") as f:
data = json.load(f)
return data["info"]

return _get_package_data


@pytest.fixture
def downloads_data():
def _get_downloads_data(package_name: str) -> dict:
with open(DATA_DIR / f"{package_name}_downloads_data.json") as f:
data = json.load(f)
return data

return _get_downloads_data
86 changes: 86 additions & 0 deletions tests/data/aiohttp_package_data.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
{
"info": {
"author": null,
"author_email": null,
"bugtrack_url": null,
"classifiers": [
"Development Status :: 5 - Production/Stable",
"Framework :: AsyncIO",
"Intended Audience :: Developers",
"Operating System :: MacOS :: MacOS X",
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.9",
"Topic :: Internet :: WWW/HTTP"
],
"description": "==================================\nAsync http client/server framework\n==================================\n\n.. image:: https://raw.githubusercontent.com/aio-libs/aiohttp/master/docs/aiohttp-plain.svg\n :height: 64px\n :width: 64px\n :alt: aiohttp logo\n\n|\n\n.. image:: https://github.com/aio-libs/aiohttp/workflows/CI/badge.svg\n :target: https://github.com/aio-libs/aiohttp/actions?query=workflow%3ACI\n :alt: GitHub Actions status for master branch\n\n.. image:: https://codecov.io/gh/aio-libs/aiohttp/branch/master/graph/badge.svg\n :target: https://codecov.io/gh/aio-libs/aiohttp\n :alt: codecov.io status for master branch\n\n.. image:: https://img.shields.io/endpoint?url=https://codspeed.io/badge.json\n :target: https://codspeed.io/aio-libs/aiohttp\n :alt: Codspeed.io status for aiohttp\n\n.. image:: https://badge.fury.io/py/aiohttp.svg\n :target: https://pypi.org/project/aiohttp\n :alt: Latest PyPI package version\n\n.. image:: https://readthedocs.org/projects/aiohttp/badge/?version=latest\n :target: https://docs.aiohttp.org/\n :alt: Latest Read The Docs\n\n.. image:: https://img.shields.io/matrix/aio-libs:matrix.org?label=Discuss%20on%20Matrix%20at%20%23aio-libs%3Amatrix.org&logo=matrix&server_fqdn=matrix.org&style=flat\n :target: https://matrix.to/#/%23aio-libs:matrix.org\n :alt: Matrix Room — #aio-libs:matrix.org\n\n.. image:: https://img.shields.io/matrix/aio-libs-space:matrix.org?label=Discuss%20on%20Matrix%20at%20%23aio-libs-space%3Amatrix.org&logo=matrix&server_fqdn=matrix.org&style=flat\n :target: https://matrix.to/#/%23aio-libs-space:matrix.org\n :alt: Matrix Space — #aio-libs-space:matrix.org\n\n\nKey Features\n============\n\n- Supports both client and server side of HTTP protocol.\n- Supports both client and server Web-Sockets out-of-the-box and avoids\n Callback Hell.\n- Provides Web-server with middleware and pluggable routing.\n\n\nGetting started\n===============\n\nClient\n------\n\nTo get something from the web:\n\n.. code-block:: python\n\n import aiohttp\n import asyncio\n\n async def main():\n\n async with aiohttp.ClientSession() as session:\n async with session.get('http://python.org') as response:\n\n print(\"Status:\", response.status)\n print(\"Content-type:\", response.headers['content-type'])\n\n html = await response.text()\n print(\"Body:\", html[:15], \"...\")\n\n asyncio.run(main())\n\nThis prints:\n\n.. code-block::\n\n Status: 200\n Content-type: text/html; charset=utf-8\n Body: <!doctype html> ...\n\nComing from `requests <https://requests.readthedocs.io/>`_ ? Read `why we need so many lines <https://aiohttp.readthedocs.io/en/latest/http_request_lifecycle.html>`_.\n\nServer\n------\n\nAn example using a simple server:\n\n.. code-block:: python\n\n # examples/server_simple.py\n from aiohttp import web\n\n async def handle(request):\n name = request.match_info.get('name', \"Anonymous\")\n text = \"Hello, \" + name\n return web.Response(text=text)\n\n async def wshandle(request):\n ws = web.WebSocketResponse()\n await ws.prepare(request)\n\n async for msg in ws:\n if msg.type == web.WSMsgType.text:\n await ws.send_str(\"Hello, {}\".format(msg.data))\n elif msg.type == web.WSMsgType.binary:\n await ws.send_bytes(msg.data)\n elif msg.type == web.WSMsgType.close:\n break\n\n return ws\n\n\n app = web.Application()\n app.add_routes([web.get('/', handle),\n web.get('/echo', wshandle),\n web.get('/{name}', handle)])\n\n if __name__ == '__main__':\n web.run_app(app)\n\n\nDocumentation\n=============\n\nhttps://aiohttp.readthedocs.io/\n\n\nDemos\n=====\n\nhttps://github.com/aio-libs/aiohttp-demos\n\n\nExternal links\n==============\n\n* `Third party libraries\n <http://aiohttp.readthedocs.io/en/latest/third_party.html>`_\n* `Built with aiohttp\n <http://aiohttp.readthedocs.io/en/latest/built_with.html>`_\n* `Powered by aiohttp\n <http://aiohttp.readthedocs.io/en/latest/powered_by.html>`_\n\nFeel free to make a Pull Request for adding your link to these pages!\n\n\nCommunication channels\n======================\n\n*aio-libs Discussions*: https://github.com/aio-libs/aiohttp/discussions\n\n*Matrix*: `#aio-libs:matrix.org <https://matrix.to/#/#aio-libs:matrix.org>`_\n\nWe support `Stack Overflow\n<https://stackoverflow.com/questions/tagged/aiohttp>`_.\nPlease add *aiohttp* tag to your question there.\n\nRequirements\n============\n\n- attrs_\n- multidict_\n- yarl_\n- frozenlist_\n\nOptionally you may install the aiodns_ library (highly recommended for sake of speed).\n\n.. _aiodns: https://pypi.python.org/pypi/aiodns\n.. _attrs: https://github.com/python-attrs/attrs\n.. _multidict: https://pypi.python.org/pypi/multidict\n.. _frozenlist: https://pypi.org/project/frozenlist/\n.. _yarl: https://pypi.python.org/pypi/yarl\n.. _async-timeout: https://pypi.python.org/pypi/async_timeout\n\nLicense\n=======\n\n``aiohttp`` is offered under the Apache 2 license.\n\n\nKeepsafe\n========\n\nThe aiohttp community would like to thank Keepsafe\n(https://www.getkeepsafe.com) for its support in the early days of\nthe project.\n\n\nSource code\n===========\n\nThe latest developer version is available in a GitHub repository:\nhttps://github.com/aio-libs/aiohttp\n\nBenchmarks\n==========\n\nIf you are interested in efficiency, the AsyncIO community maintains a\nlist of benchmarks on the official wiki:\nhttps://github.com/python/asyncio/wiki/Benchmarks\n",
"description_content_type": "text/x-rst",
"docs_url": null,
"download_url": null,
"downloads": {
"last_day": -1,
"last_month": -1,
"last_week": -1
},
"dynamic": [
"License-File"
],
"home_page": "https://github.com/aio-libs/aiohttp",
"keywords": null,
"license": "Apache-2.0 AND MIT",
"license_expression": null,
"license_files": [
"LICENSE.txt",
"vendor/llhttp/LICENSE"
],
"maintainer": "aiohttp team <team@aiohttp.org>",
"maintainer_email": "team@aiohttp.org",
"name": "aiohttp",
"package_url": "https://pypi.org/project/aiohttp/",
"platform": null,
"project_url": "https://pypi.org/project/aiohttp/",
"project_urls": {
"CI: GitHub Actions": "https://github.com/aio-libs/aiohttp/actions?query=workflow%3ACI",
"Chat: Matrix": "https://matrix.to/#/#aio-libs:matrix.org",
"Chat: Matrix Space": "https://matrix.to/#/#aio-libs-space:matrix.org",
"Coverage: codecov": "https://codecov.io/github/aio-libs/aiohttp",
"Docs: Changelog": "https://docs.aiohttp.org/en/stable/changes.html",
"Docs: RTD": "https://docs.aiohttp.org",
"GitHub: issues": "https://github.com/aio-libs/aiohttp/issues",
"GitHub: repo": "https://github.com/aio-libs/aiohttp",
"Homepage": "https://github.com/aio-libs/aiohttp"
},
"provides_extra": [
"speedups"
],
"release_url": "https://pypi.org/project/aiohttp/3.12.15/",
"requires_dist": [
"aiohappyeyeballs>=2.5.0",
"aiosignal>=1.4.0",
"async-timeout<6.0,>=4.0; python_version < \"3.11\"",
"attrs>=17.3.0",
"frozenlist>=1.1.1",
"multidict<7.0,>=4.5",
"propcache>=0.2.0",
"yarl<2.0,>=1.17.0",
"aiodns>=3.3.0; extra == \"speedups\"",
"Brotli; platform_python_implementation == \"CPython\" and extra == \"speedups\"",
"brotlicffi; platform_python_implementation != \"CPython\" and extra == \"speedups\""
],
"requires_python": ">=3.9",
"summary": "Async http client/server framework (asyncio)",
"version": "3.12.15",
"yanked": false,
"yanked_reason": null
},
"last_serial": 30396837,
"releases": {},
"urls": [],
"vulnerabilities": []
}
Loading