From 1eeb671264e128c9a4680bc5fb8159be905a7384 Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Sun, 13 Feb 2022 19:07:52 +0000 Subject: [PATCH 01/19] To-do file is going elsewhere --- TODO.md | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 TODO.md diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 0c794ed..0000000 --- a/TODO.md +++ /dev/null @@ -1,27 +0,0 @@ -- For arguments of the form `package==version`, use `packaging`'s version - classes for the version comparison (falling back to string comparison in the - pathological case of two different releases with equivalent versions) - - Support full PEP 440 version specifiers -- When using a package name as a dictionary key in output, use the same casing - as on the command line? - - Normalize package names in output? -- Add examples to the README for every subcommand -- Write tests -- Give `--latest-version` a short form? -- Give `releases` an option for sorting by release date? -- Try to make the use of "release" vs. "version" consistent - - Give `releases` a `versions` synonym? - - cf. ? - - Rename `releases` to `versions`? -- Change the output formats of `releases`, `owner`, and `owned` to be lists of - objects, each of which has a `package`/`name`/`user` field giving what was - formerly the entry's key and another field for what was formerly the value? -- Add a `-q`/`--quiet` option for suppressing errors about missing - packages/versions -- Honor package yanking (PEP 592) -- Remove `--trust-downloads` -- Give `info` (et alii?) a `--raw` option for disabling post-processing - customizations -- Set User-Agent for ServerProxy in Python 3.8+ -- Eliminate the `--packages`/`--releases` options from `search`? (but not from - `browse`) PyPI seems to now always return one result per package From 028c2645eb4cfc85df64ef29d56d0d99f57f580e Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Sun, 13 Feb 2022 19:24:34 +0000 Subject: [PATCH 02/19] Make QyPI into a context manager --- setup.cfg | 2 +- src/qypi/__main__.py | 2 +- src/qypi/api.py | 25 ++++++++++----- test/test_main.py | 74 ++++++++++++++++++++++++++++++++++---------- 4 files changed, 78 insertions(+), 25 deletions(-) diff --git a/setup.cfg b/setup.cfg index f82997e..091239f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -46,7 +46,7 @@ package_dir = =src python_requires = >=3.6 install_requires = - click ~= 8.0 + click >= 8.0 packaging >= 16 requests ~= 2.20 diff --git a/src/qypi/__main__.py b/src/qypi/__main__.py index 45e7bb6..04426c5 100644 --- a/src/qypi/__main__.py +++ b/src/qypi/__main__.py @@ -36,7 +36,7 @@ @click.pass_context def qypi(ctx, index_url): """Query PyPI from the command line""" - ctx.obj = QyPI(index_url) + ctx.obj = ctx.with_resource(QyPI(index_url)) @qypi.result_callback() diff --git a/src/qypi/api.py b/src/qypi/api.py index 4b04f38..e573127 100644 --- a/src/qypi/api.py +++ b/src/qypi/api.py @@ -1,4 +1,6 @@ +from contextlib import ExitStack import platform +import sys from xmlrpc.client import ServerProxy import click from packaging.version import parse @@ -17,17 +19,28 @@ class QyPI: def __init__(self, index_url): self.index_url = index_url - self.s = None - self.xsp = None + self.s = requests.Session() + self.s.headers["User-Agent"] = USER_AGENT + if sys.version_info >= (3, 8): + xsp_kwargs = {"headers": [("User-Agent", USER_AGENT)]} + else: + xsp_kwargs = {} + self.xsp = ServerProxy(self.index_url, **xsp_kwargs) self.pre = False self.newest = False self.all_versions = False self.errmsgs = [] + self.ctx_stack = ExitStack() + + def __enter__(self) -> "QyPI": + self.ctx_stack.enter_context(self.s) + self.ctx_stack.enter_context(self.xsp) + return self + + def __exit__(self, *_exc) -> None: + self.ctx_stack.close() def get(self, *path): - if self.s is None: - self.s = requests.Session() - self.s.headers["User-Agent"] = USER_AGENT return self.s.get(self.index_url.rstrip("/") + "/" + "/".join(path)) def get_package(self, package): @@ -76,8 +89,6 @@ def get_version(self, package, version): return r.json() def xmlrpc(self, method, *args, **kwargs): - if self.xsp is None: - self.xsp = ServerProxy(self.index_url) return getattr(self.xsp, method)(*args, **kwargs) def lookup_package(self, args): diff --git a/test/test_main.py b/test/test_main.py index 8bdf166..2fa9823 100644 --- a/test/test_main.py +++ b/test/test_main.py @@ -1,8 +1,10 @@ import json +import sys from traceback import format_exception from click.testing import CliRunner import pytest from qypi.__main__ import qypi +from qypi.api import USER_AGENT def show_result(r): @@ -13,7 +15,7 @@ def show_result(r): def test_list(mocker): - spinstance = mocker.Mock( + spinstance = mocker.MagicMock( **{ "list_packages.return_value": [ "foobar", @@ -30,12 +32,17 @@ def test_list(mocker): assert r.output == ( "foobar\n" "BarFoo\n" "quux\n" "Gnusto-Cleesh\n" "XYZZY_PLUGH\n" ) - spclass.assert_called_once_with("https://pypi.org/pypi") + if sys.version_info >= (3, 8): + spclass.assert_called_once_with( + "https://pypi.org/pypi", headers=[("User-Agent", USER_AGENT)] + ) + else: + spclass.assert_called_once_with("https://pypi.org/pypi") assert spinstance.method_calls == [mocker.call.list_packages()] def test_owner(mocker): - spinstance = mocker.Mock( + spinstance = mocker.MagicMock( **{ "package_roles.return_value": [ ["Owner", "luser"], @@ -60,12 +67,17 @@ def test_owner(mocker): " ]\n" "}\n" ) - spclass.assert_called_once_with("https://pypi.org/pypi") + if sys.version_info >= (3, 8): + spclass.assert_called_once_with( + "https://pypi.org/pypi", headers=[("User-Agent", USER_AGENT)] + ) + else: + spclass.assert_called_once_with("https://pypi.org/pypi") assert spinstance.method_calls == [mocker.call.package_roles("foobar")] def test_multiple_owner(mocker): - spinstance = mocker.Mock( + spinstance = mocker.MagicMock( **{ "package_roles.side_effect": [ [ @@ -106,7 +118,12 @@ def test_multiple_owner(mocker): " ]\n" "}\n" ) - spclass.assert_called_once_with("https://pypi.org/pypi") + if sys.version_info >= (3, 8): + spclass.assert_called_once_with( + "https://pypi.org/pypi", headers=[("User-Agent", USER_AGENT)] + ) + else: + spclass.assert_called_once_with("https://pypi.org/pypi") assert spinstance.method_calls == [ mocker.call.package_roles("foobar"), mocker.call.package_roles("Glarch"), @@ -114,7 +131,7 @@ def test_multiple_owner(mocker): def test_owned(mocker): - spinstance = mocker.Mock( + spinstance = mocker.MagicMock( **{ "user_packages.return_value": [ ["Owner", "foobar"], @@ -139,12 +156,17 @@ def test_owned(mocker): " ]\n" "}\n" ) - spclass.assert_called_once_with("https://pypi.org/pypi") + if sys.version_info >= (3, 8): + spclass.assert_called_once_with( + "https://pypi.org/pypi", headers=[("User-Agent", USER_AGENT)] + ) + else: + spclass.assert_called_once_with("https://pypi.org/pypi") assert spinstance.method_calls == [mocker.call.user_packages("luser")] def test_multiple_owned(mocker): - spinstance = mocker.Mock( + spinstance = mocker.MagicMock( **{ "user_packages.side_effect": [ [ @@ -185,7 +207,12 @@ def test_multiple_owned(mocker): " ]\n" "}\n" ) - spclass.assert_called_once_with("https://pypi.org/pypi") + if sys.version_info >= (3, 8): + spclass.assert_called_once_with( + "https://pypi.org/pypi", headers=[("User-Agent", USER_AGENT)] + ) + else: + spclass.assert_called_once_with("https://pypi.org/pypi") assert spinstance.method_calls == [ mocker.call.user_packages("luser"), mocker.call.user_packages("jsmith"), @@ -193,7 +220,7 @@ def test_multiple_owned(mocker): def test_search(mocker): - spinstance = mocker.Mock( + spinstance = mocker.MagicMock( **{ "search.return_value": [ { @@ -239,7 +266,12 @@ def test_search(mocker): " }\n" "]\n" ) - spclass.assert_called_once_with("https://pypi.org/pypi") + if sys.version_info >= (3, 8): + spclass.assert_called_once_with( + "https://pypi.org/pypi", headers=[("User-Agent", USER_AGENT)] + ) + else: + spclass.assert_called_once_with("https://pypi.org/pypi") assert spinstance.method_calls == [ mocker.call.search( {"description": ["term", "bar"], "keywords": ["foo"]}, @@ -249,7 +281,7 @@ def test_search(mocker): def test_browse(mocker): - spinstance = mocker.Mock( + spinstance = mocker.MagicMock( **{ "browse.return_value": [ ["foobar", "1.2.3"], @@ -295,14 +327,19 @@ def test_browse(mocker): " }\n" "]\n" ) - spclass.assert_called_once_with("https://pypi.org/pypi") + if sys.version_info >= (3, 8): + spclass.assert_called_once_with( + "https://pypi.org/pypi", headers=[("User-Agent", USER_AGENT)] + ) + else: + spclass.assert_called_once_with("https://pypi.org/pypi") assert spinstance.method_calls == [ mocker.call.browse(("Typing :: Typed", "Topic :: Utilities")) ] def test_browse_packages(mocker): - spinstance = mocker.Mock( + spinstance = mocker.MagicMock( **{ "browse.return_value": [ ["foobar", "1.2.3"], @@ -336,7 +373,12 @@ def test_browse_packages(mocker): " }\n" "]\n" ) - spclass.assert_called_once_with("https://pypi.org/pypi") + if sys.version_info >= (3, 8): + spclass.assert_called_once_with( + "https://pypi.org/pypi", headers=[("User-Agent", USER_AGENT)] + ) + else: + spclass.assert_called_once_with("https://pypi.org/pypi") assert spinstance.method_calls == [ mocker.call.browse(("Typing :: Typed", "Topic :: Utilities")) ] From 01cd07169d89ff3e5df8309bdbd47d16c0d3ec6f Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Sun, 13 Feb 2022 19:25:44 +0000 Subject: [PATCH 03/19] Rename a method --- src/qypi/api.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/qypi/api.py b/src/qypi/api.py index e573127..b4d9c4e 100644 --- a/src/qypi/api.py +++ b/src/qypi/api.py @@ -53,6 +53,7 @@ def get_package(self, package): return r.json() def get_latest_version(self, package): + # TODO: Eliminate pkg = self.get_package(package) releases = { (parse(rel), rel): first_upload(files) @@ -79,9 +80,9 @@ def get_latest_version(self, package): if pkg["info"]["version"] == latest: return pkg else: - return self.get_version(package, latest) + return self.get_package_version(package, latest) - def get_version(self, package, version): + def get_package_version(self, package, version): r = self.get(package, version, "json") if r.status_code == 404: raise QyPIError(f"{package}: version {version} not found") @@ -92,6 +93,7 @@ def xmlrpc(self, method, *args, **kwargs): return getattr(self.xsp, method)(*args, **kwargs) def lookup_package(self, args): + # TODO: Eliminate for name in args: try: yield self.get_package(name) @@ -103,7 +105,7 @@ def lookup_package_version(self, args): name, eq, version = spec.partition("=") try: if eq != "": - yield self.get_version(name, version.lstrip("=")) + yield self.get_package_version(name, version.lstrip("=")) elif self.all_versions: p = self.get_package(name) for v in sorted(p["releases"], key=parse): @@ -112,13 +114,14 @@ def lookup_package_version(self, args): yield p else: ### TODO: Can this call ever fail? - yield self.get_version(name, v) + yield self.get_package_version(name, v) else: yield self.get_latest_version(name) except QyPIError as e: self.errmsgs.append(str(e)) def cleanup(self, ctx): + # TODO: Eliminate if self.errmsgs: for msg in self.errmsgs: click.echo(ctx.command_path + ": " + msg, err=True) From 26837292e08a2fc02a8fc1d711824fe9dfdf4808 Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Sun, 13 Feb 2022 20:10:37 +0000 Subject: [PATCH 04/19] Add methods & classes for XML-RPC calls --- setup.cfg | 1 + src/qypi/__main__.py | 25 ++++------------ src/qypi/api.py | 71 ++++++++++++++++++++++++++++++++++++++++++++ src/qypi/util.py | 22 ++++++++++---- 4 files changed, 93 insertions(+), 26 deletions(-) diff --git a/setup.cfg b/setup.cfg index 091239f..3ad2249 100644 --- a/setup.cfg +++ b/setup.cfg @@ -48,6 +48,7 @@ python_requires = >=3.6 install_requires = click >= 8.0 packaging >= 16 + pydantic ~= 1.9 requests ~= 2.20 [options.packages.find] diff --git a/src/qypi/__main__.py b/src/qypi/__main__.py index 04426c5..fbf0dd4 100644 --- a/src/qypi/__main__.py +++ b/src/qypi/__main__.py @@ -173,7 +173,7 @@ def files(packages, trust_downloads): @click.pass_obj def listcmd(obj): """List all packages on PyPI""" - for pkg in obj.xmlrpc("list_packages"): + for pkg in obj.get_all_packages(): click.echo(pkg) @@ -210,7 +210,7 @@ def search(obj, terms, oper, packages): key = SEARCH_SYNONYMS.get(key, key) # ServerProxy can't handle defaultdicts, so we can't use those instead. spec.setdefault(key, []).append(value) - results = map(clean_pypi_dict, obj.xmlrpc("search", spec, oper)) + results = obj.search(spec, oper) if packages: results = squish_versions(results) click.echo(dumps(results)) @@ -236,10 +236,7 @@ def browse(obj, classifiers, file, packages): """ if file is not None: classifiers += tuple(map(str.strip, file)) - results = [ - {"name": name, "version": version or None} - for name, version in obj.xmlrpc("browse", classifiers) - ] + results = obj.browse(classifiers) if packages: results = squish_versions(results) click.echo(dumps(results)) @@ -252,13 +249,7 @@ def owner(obj, packages): """List package owners & maintainers""" with JSONMapper() as jmap: for pkg in packages: - jmap.append( - pkg, - [ - {"role": role, "user": user} - for role, user in obj.xmlrpc("package_roles", pkg) - ], - ) + jmap.append(pkg, [pr.json_dict() for pr in obj.get_package_roles(pkg)]) @qypi.command() @@ -268,13 +259,7 @@ def owned(obj, users): """List packages owned/maintained by a user""" with JSONMapper() as jmap: for u in users: - jmap.append( - u, - [ - {"role": role, "package": pkg} - for role, pkg in obj.xmlrpc("user_packages", u) - ], - ) + jmap.append(u, [ur.json_dict() for ur in obj.get_user_roles(u)]) if __name__ == "__main__": diff --git a/src/qypi/api.py b/src/qypi/api.py index b4d9c4e..0b952db 100644 --- a/src/qypi/api.py +++ b/src/qypi/api.py @@ -1,9 +1,13 @@ from contextlib import ExitStack +from enum import Enum +import json import platform import sys +from typing import Dict, List, Optional, Union, cast from xmlrpc.client import ServerProxy import click from packaging.version import parse +from pydantic import BaseModel, validator import requests from . import __url__, __version__ @@ -16,6 +20,45 @@ ) +class Role(Enum): + OWNER = "Owner" + MAINTAINER = "Maintainer" + + +class JSONableBase(BaseModel): + def json_dict(self) -> dict: + return cast(dict, json.loads(self.json())) + + +class PackageRole(JSONableBase): + user: str + role: Role + + +class UserRole(JSONableBase): + package: str + role: Role + + +class SearchResult(JSONableBase): + name: str + summary: Optional[str] + version: str + + @validator("summary") + @classmethod + def _nullify_summary(cls, v: Optional[str]) -> Optional[str]: + if v == "" or v == "UNKNOWN": + return None + else: + return v + + +class BrowseResult(JSONableBase): + name: str + version: str + + class QyPI: def __init__(self, index_url): self.index_url = index_url @@ -92,6 +135,34 @@ def get_package_version(self, package, version): def xmlrpc(self, method, *args, **kwargs): return getattr(self.xsp, method)(*args, **kwargs) + def get_all_packages(self) -> List[str]: + return cast(List[str], self.xmlrpc("list_packages")) + + def get_package_roles(self, package: str) -> List[PackageRole]: + return [ + PackageRole(role=role, user=user) + for role, user in self.xmlrpc("package_roles", package) + ] + + def get_user_roles(self, user: str) -> List[UserRole]: + return [ + UserRole(role=role, package=package) + for role, package in self.xmlrpc("user_packages", user) + ] + + def search( + self, spec: Dict[str, Union[str, List[str]]], operator: str = "and" + ) -> List[SearchResult]: + return [ + SearchResult.parse_obj(r) for r in self.xmlrpc("search", spec, operator) + ] + + def browse(self, classifiers: List[str]) -> List[BrowseResult]: + return [ + BrowseResult(name=name, version=version) + for name, version in self.xmlrpc("browse", classifiers) + ] + def lookup_package(self, args): # TODO: Eliminate for name in args: diff --git a/src/qypi/util.py b/src/qypi/util.py index d9f6d1b..bad58ce 100644 --- a/src/qypi/util.py +++ b/src/qypi/util.py @@ -1,10 +1,11 @@ from collections.abc import Iterator from itertools import groupby import json -from operator import itemgetter +from operator import attrgetter from textwrap import indent import click from packaging.version import parse +from .api import JSONableBase def obj_option(*args, **kwargs): @@ -63,10 +64,19 @@ def wrapper(f): ) +def json_default(x): + if isinstance(x, JSONableBase): + return x.json_dict() + else: + return x + + def dumps(obj): if isinstance(obj, Iterator): obj = list(obj) - return json.dumps(obj, sort_keys=True, indent=4, ensure_ascii=False) + return json.dumps( + obj, sort_keys=True, indent=4, ensure_ascii=False, default=json_default + ) def clean_pypi_dict(d): @@ -79,13 +89,13 @@ def clean_pypi_dict(d): def squish_versions(releases): """ - Given a list of `dict`s containing (at least) ``"name"`` and ``"version"`` - fields, return for each name the `dict` with the highest version. + Given a list of `SearchResult`\\s or `BrowseResult`\\s, return for each + name the result with the highest version. It is assumed that `dict`s with the same name are always adjacent. """ - for _, versions in groupby(releases, itemgetter("name")): - yield max(versions, key=lambda v: parse(v["version"])) + for _, versions in groupby(releases, attrgetter("name")): + yield max(versions, key=lambda v: parse(v.version)) class JSONLister: From 7f5852009a99def8723f2678654c716bbc7b6c14 Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Sun, 13 Feb 2022 23:29:51 +0000 Subject: [PATCH 05/19] Massive overhaul/rewrite --- CHANGELOG.md | 20 +- README.rst | 2 +- setup.cfg | 3 +- src/qypi/__init__.py | 2 +- src/qypi/__main__.py | 313 +++++++------ src/qypi/api.py | 450 +++++++++++++------ src/qypi/util.py | 120 +---- test/data/foobar.json | 39 +- test/data/has-prerel.json | 28 +- test/data/nullfields.json | 17 +- test/data/prerelease-only.json | 28 +- test/test_main.py | 785 +++++++++++---------------------- tox.ini | 2 +- 13 files changed, 860 insertions(+), 949 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 345f2d5..e7c8994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,24 @@ -v0.7.0 (in development) +v1.0.0 (in development) ----------------------- +- Package data returned from the JSON API is now internally represented by + [pydantic](https://github.com/samuelcolvin/pydantic)-based classes. As a + result, unknown fields returned by the JSON API are no longer output, and + there may be some errors interacting with non-Warehouse project indices that + do not define all of the same fields as Warehouse. [Please report any + instances of the latter.](https://github.com/jwodder/qypi/issues) +- The `info`, `readme`, `releases`, `files`, `owner`, and `owned` commands now + take exactly one positional argument, and the output formats of many of these + commands have been simplified for this case +- The `readme` command no longer takes an `--all-versions`/`-A` option +- The output from the `releases` command now includes an `is_yanked` field +- The `--packages` option to the `search` and `browse` commands is now named + `--projects` +- The output from the `owned` command has changed to use the more accurate + "project" instead of "package". +- Output timestamps now use `+00:00` as the timezone offset instead of `Z` +- Honor package yanking (PEP 592) - Support Python 3.10, 3.11, and 3.12 +- Drop support for Python 3.6 v0.6.0 (2021-05-31) ------------------- diff --git a/README.rst b/README.rst index 4fdfd5d..c9cd9b4 100644 --- a/README.rst +++ b/README.rst @@ -30,7 +30,7 @@ examples below). Installation ============ -``qypi`` requires Python 3.6 or higher. Just use version 6.0 or higher of `pip +``qypi`` requires Python 3.7 or higher. Just use version 6.0 or higher of `pip `_ for Python 3 (You have pip, right?) to install ``qypi`` and its dependencies:: diff --git a/setup.cfg b/setup.cfg index 3ad2249..c7b64c7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -20,7 +20,6 @@ keywords = classifiers = Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3 - Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 @@ -44,7 +43,7 @@ project_urls = packages = find: package_dir = =src -python_requires = >=3.6 +python_requires = >=3.7 install_requires = click >= 8.0 packaging >= 16 diff --git a/src/qypi/__init__.py b/src/qypi/__init__.py index 7f412ca..686cde4 100644 --- a/src/qypi/__init__.py +++ b/src/qypi/__init__.py @@ -9,7 +9,7 @@ information. """ -__version__ = "0.7.0.dev1" +__version__ = "1.0.0.dev1" __author__ = "John Thorvald Wodder II" __author_email__ = "qypi@varonathe.org" __license__ = "MIT" diff --git a/src/qypi/__main__.py b/src/qypi/__main__.py index fbf0dd4..61aed56 100644 --- a/src/qypi/__main__.py +++ b/src/qypi/__main__.py @@ -1,18 +1,9 @@ +from typing import List, Optional, Sequence, TextIO import click from packaging.version import parse from . import __version__ -from .api import QyPI, first_upload -from .util import ( - JSONLister, - JSONMapper, - clean_pypi_dict, - dumps, - package_args, - squish_versions, -) - -ENDPOINT = "https://pypi.org/pypi" -TRUST_DOWNLOADS = False +from .api import DEFAULT_ENDPOINT, QyPI, QyPIError +from .util import dumps, show_datetime, squish_versions SEARCH_SYNONYMS = { "homepage": "home_page", @@ -22,30 +13,45 @@ "keyword": "keywords", } +pre_opt = click.option( + "--pre/--no-pre", + default=None, + help="Show prerelease versions", + show_default=True, +) + +all_opt = click.option( + "-A", + "--all-versions/--latest-version", + default=False, + help="Show all versions/only the latest version when no version is" + " specified [default: latest]", +) + +sort_opt = click.option( + "--newest/--highest", + default=False, + help='Does "latest" mean "newest" or "highest"? [default: highest]', +) + @click.group(context_settings={"help_option_names": ["-h", "--help"]}) @click.option( "-i", "--index-url", - default=ENDPOINT, + default=DEFAULT_ENDPOINT, metavar="URL", - help="Use a different URL for PyPI", + help="Set PyPI API endpoint URL", show_default=True, ) @click.version_option(__version__, "-V", "--version", message="%(prog)s %(version)s") @click.pass_context -def qypi(ctx, index_url): +def main(ctx, index_url): """Query PyPI from the command line""" ctx.obj = ctx.with_resource(QyPI(index_url)) -@qypi.result_callback() -@click.pass_context -def cleanup(ctx, *_args, **_kwargs): - ctx.obj.cleanup(ctx) - - -@qypi.command() +@main.command() @click.option( "--description/--no-description", default=False, @@ -54,130 +60,165 @@ def cleanup(ctx, *_args, **_kwargs): ) @click.option( "--trust-downloads/--no-trust-downloads", - default=TRUST_DOWNLOADS, + default=False, help="Show download stats", show_default=True, ) -@package_args() -def info(packages, trust_downloads, description): +@all_opt +@sort_opt +@pre_opt +@click.argument("project") +@click.pass_obj +def info( + qypi: QyPI, + project: str, + trust_downloads: bool, + description: bool, + all_versions: bool, + newest: bool, + pre: Optional[bool], +) -> None: """ - Show package details. + Show project details. - Packages can be specified as either ``packagename`` to show the latest - version or as ``packagename==version`` to show the details for ``version``. + Projects can be specified as either ``projectname`` to show the latest + version or as ``projectname==version`` to show the details for ``version``. """ - with JSONLister() as jlist: - for pkg in packages: - info = clean_pypi_dict(pkg["info"]) - if not description: - info.pop("description", None) - if not trust_downloads: - info.pop("downloads", None) - info["url"] = info.pop("home_page", None) - info["release_date"] = first_upload(pkg["urls"]) - info["people"] = [] - for role in ("author", "maintainer"): - name = info.pop(role, None) - email = info.pop(role + "_email", None) - if name or email: - info["people"].append( - { - "name": name, - "email": email, - "role": role, - } + if all_versions: + try: + vs = qypi.get_all_requirement(project, yanked=False, prereleases=pre) + except QyPIError as e: + raise click.UsageError(str(e)) + click.echo( + dumps( + [ + v.qypi_json_dict( + description=description, trust_downloads=trust_downloads ) - if "package_url" in info and "project_url" not in info: - # Field was renamed between PyPI Legacy and Warehouse - info["project_url"] = info.pop("package_url") - jlist.append(info) + for v in vs + ] + ) + ) + else: + try: + v = qypi.get_requirement( + project, most_recent=newest, yanked=False, prereleases=pre + ) + except QyPIError as e: + raise click.UsageError(str(e)) + click.echo( + dumps( + v.qypi_json_dict( + description=description, trust_downloads=trust_downloads + ) + ) + ) -@qypi.command() -@package_args() -def readme(packages): +@main.command() +@sort_opt +@pre_opt +@click.argument("project") +@click.pass_obj +def readme(qypi: QyPI, project: str, newest: bool, pre: Optional[bool]) -> None: """ - View packages' long descriptions. + View projects' long descriptions. If stdout is a terminal, the descriptions are passed to a pager program (e.g., `less(1)`). - Packages can be specified as either ``packagename`` to show the latest - version or as ``packagename==version`` to show the long description for + Projects can be specified as either ``projectname`` to show the latest + version or as ``projectname==version`` to show the long description for ``version``. """ - for pkg in packages: - click.echo_via_pager(pkg["info"]["description"]) - - -@qypi.command() -@package_args(versioned=False) -def releases(packages): - """List released package versions""" - with JSONMapper() as jmap: - for pkg in packages: - try: - project_url = pkg["info"]["project_url"] - except KeyError: - project_url = pkg["info"]["package_url"] - if not project_url.endswith("/"): - project_url += "/" - jmap.append( - pkg["info"]["name"], - [ - { - "version": version, - "is_prerelease": parse(version).is_prerelease, - "release_date": first_upload(pkg["releases"][version]), - "release_url": project_url + version, - } - for version in sorted(pkg["releases"], key=parse) - ], - ) + v = qypi.get_requirement(project, most_recent=newest, yanked=False, prereleases=pre) + click.echo_via_pager(v.info.description) -@qypi.command() +@main.command() +@click.argument("project") +@click.pass_obj +def releases(qypi: QyPI, project: str) -> None: + """List released project versions""" + pkg = qypi.get_project(project) + data: List[dict] = [] + for v in sorted(pkg.versions, key=parse): + pv = pkg.get_version(v) + data.append( + { + "version": v, + "is_prerelease": parse(v).is_prerelease, + "is_yanked": pv.is_yanked, + "release_date": show_datetime(pv.upload_time), + "release_url": pv.info.release_url, + } + ) + click.echo(dumps(data)) + + +@main.command() @click.option( "--trust-downloads/--no-trust-downloads", - default=TRUST_DOWNLOADS, + default=False, help="Show download stats", show_default=True, ) -@package_args() -def files(packages, trust_downloads): +@all_opt +@sort_opt +@pre_opt +@click.argument("project") +@click.pass_obj +def files( + qypi: QyPI, + project: str, + trust_downloads: bool, + all_versions: bool, + newest: bool, + pre: Optional[bool], +) -> None: """ List files available for download. - Packages can be specified as either ``packagename`` to show the latest - version or as ``packagename==version`` to show the files available for + Projects can be specified as either ``projectname`` to show the latest + version or as ``projectname==version`` to show the files available for ``version``. """ - with JSONLister() as jlist: - for pkg in packages: - pkgfiles = pkg["urls"] - for pf in pkgfiles: - if not trust_downloads: - pf.pop("downloads", None) - pf.pop("path", None) - ### TODO: Change empty comment_text fields to None? - jlist.append( + if all_versions: + vs = qypi.get_all_requirement(project, yanked=False, prereleases=pre) + click.echo( + dumps( { - "name": pkg["info"]["name"], - "version": pkg["info"]["version"], - "files": pkgfiles, + v.info.version: [ + f.json_dict(trust_downloads=trust_downloads, exclude_unset=True) + for f in v.files + ] + for v in vs } ) + ) + else: + v = qypi.get_requirement( + project, most_recent=newest, yanked=False, prereleases=pre + ) + click.echo( + dumps( + [ + f.json_dict(trust_downloads=trust_downloads, exclude_unset=True) + for f in v.files + ] + ) + ) -@qypi.command("list") +@main.command("list") @click.pass_obj -def listcmd(obj): - """List all packages on PyPI""" - for pkg in obj.get_all_packages(): +def listcmd(qypi): + """List all projects on PyPI""" + for pkg in qypi.list_all_projects(): click.echo(pkg) -@qypi.command() +@main.command() @click.option( "--and", "oper", @@ -188,15 +229,15 @@ def listcmd(obj): @click.option("--or", "oper", flag_value="or", help="OR conditions together") @click.option( "-p/-r", - "--packages/--releases", + "--projects/--releases", default=False, - help="Show one result per package/per release" " [default: per release]", + help="Show one result per project/per release [default: per release]", ) @click.argument("terms", nargs=-1, required=True) @click.pass_obj -def search(obj, terms, oper, packages): +def search(qypi: QyPI, terms, oper, projects): """ - Search PyPI for packages or releases thereof. + Search PyPI for projects or releases thereof. Search terms may be specified as either ``field:value`` (e.g., ``summary:Django``) or just ``value`` to search long descriptions. @@ -210,25 +251,27 @@ def search(obj, terms, oper, packages): key = SEARCH_SYNONYMS.get(key, key) # ServerProxy can't handle defaultdicts, so we can't use those instead. spec.setdefault(key, []).append(value) - results = obj.search(spec, oper) - if packages: + results = qypi.search(spec, oper) + if projects: results = squish_versions(results) click.echo(dumps(results)) -@qypi.command() +@main.command() @click.option("-f", "--file", type=click.File("r")) @click.option( "-p/-r", - "--packages/--releases", + "--projects/--releases", default=False, - help="Show one result per package/per release" " [default: per release]", + help="Show one result per project/per release [default: per release]", ) @click.argument("classifiers", nargs=-1) @click.pass_obj -def browse(obj, classifiers, file, packages): +def browse( + qypi: QyPI, classifiers: Sequence[str], file: Optional[TextIO], projects: bool +) -> None: """ - List packages with given trove classifiers. + List projects with given trove classifiers. The list of classifiers may optionally be read from a file, one classifier per line. Any further classifiers listed on the command line will be added @@ -236,31 +279,27 @@ def browse(obj, classifiers, file, packages): """ if file is not None: classifiers += tuple(map(str.strip, file)) - results = obj.browse(classifiers) - if packages: + results = qypi.browse(classifiers) + if projects: results = squish_versions(results) click.echo(dumps(results)) -@qypi.command() -@click.argument("packages", nargs=-1) +@main.command() +@click.argument("project") @click.pass_obj -def owner(obj, packages): - """List package owners & maintainers""" - with JSONMapper() as jmap: - for pkg in packages: - jmap.append(pkg, [pr.json_dict() for pr in obj.get_package_roles(pkg)]) +def owner(qypi: QyPI, project: str) -> None: + """List project owners & maintainers""" + click.echo(dumps([pr.json_dict() for pr in qypi.get_project_roles(project)])) -@qypi.command() -@click.argument("users", nargs=-1) +@main.command() +@click.argument("user") @click.pass_obj -def owned(obj, users): - """List packages owned/maintained by a user""" - with JSONMapper() as jmap: - for u in users: - jmap.append(u, [ur.json_dict() for ur in obj.get_user_roles(u)]) +def owned(qypi: QyPI, user: str) -> None: + """List projects owned/maintained by a user""" + click.echo(dumps([ur.json_dict() for ur in qypi.get_user_roles(user)])) if __name__ == "__main__": - qypi() + main() diff --git a/src/qypi/api.py b/src/qypi/api.py index 0b952db..4850887 100644 --- a/src/qypi/api.py +++ b/src/qypi/api.py @@ -1,15 +1,22 @@ +from __future__ import annotations from contextlib import ExitStack +from datetime import datetime from enum import Enum import json +from operator import attrgetter import platform import sys -from typing import Dict, List, Optional, Union, cast +from typing import Any, Dict, List, Optional, Union, cast from xmlrpc.client import ServerProxy -import click +from packaging.requirements import Requirement +from packaging.specifiers import SpecifierSet from packaging.version import parse -from pydantic import BaseModel, validator +from pydantic import BaseModel, Field, validator import requests from . import __url__, __version__ +from .util import show_datetime + +DEFAULT_ENDPOINT = "https://pypi.org/pypi" USER_AGENT = "qypi/{} ({}) requests/{} {}/{}".format( __version__, @@ -20,47 +27,8 @@ ) -class Role(Enum): - OWNER = "Owner" - MAINTAINER = "Maintainer" - - -class JSONableBase(BaseModel): - def json_dict(self) -> dict: - return cast(dict, json.loads(self.json())) - - -class PackageRole(JSONableBase): - user: str - role: Role - - -class UserRole(JSONableBase): - package: str - role: Role - - -class SearchResult(JSONableBase): - name: str - summary: Optional[str] - version: str - - @validator("summary") - @classmethod - def _nullify_summary(cls, v: Optional[str]) -> Optional[str]: - if v == "" or v == "UNKNOWN": - return None - else: - return v - - -class BrowseResult(JSONableBase): - name: str - version: str - - class QyPI: - def __init__(self, index_url): + def __init__(self, index_url: str = DEFAULT_ENDPOINT) -> None: self.index_url = index_url self.s = requests.Session() self.s.headers["User-Agent"] = USER_AGENT @@ -69,13 +37,9 @@ def __init__(self, index_url): else: xsp_kwargs = {} self.xsp = ServerProxy(self.index_url, **xsp_kwargs) - self.pre = False - self.newest = False - self.all_versions = False - self.errmsgs = [] self.ctx_stack = ExitStack() - def __enter__(self) -> "QyPI": + def __enter__(self) -> QyPI: self.ctx_stack.enter_context(self.s) self.ctx_stack.enter_context(self.xsp) return self @@ -83,71 +47,65 @@ def __enter__(self) -> "QyPI": def __exit__(self, *_exc) -> None: self.ctx_stack.close() - def get(self, *path): - return self.s.get(self.index_url.rstrip("/") + "/" + "/".join(path)) - - def get_package(self, package): - r = self.get(package, "json") - # Unlike the XML-RPC API, the JSON API accepts package names regardless - # of normalization + def get_requirement( + self, + req: str, + most_recent: bool = False, + yanked: bool = False, + prereleases: Optional[bool] = None, + ) -> ProjectVersion: + reqobj = Requirement(req) + ### TODO: Warn if reqobj has non-None marker, extras, or url? + project = self.get_project(reqobj.name) + return project.get_version_by_spec( + reqobj.specifier, + most_recent=most_recent, + yanked=yanked, + prereleases=prereleases, + ) + + def get_all_requirements( + self, req: str, yanked: bool = False, prereleases: Optional[bool] = None + ) -> Optional[ProjectVersion]: + reqobj = Requirement(req) + ### TODO: Warn if reqobj has non-None marker, extras, or url? + project = self.get_project(reqobj.name) + return project.get_all_versions_by_spec( + reqobj.specifier, + yanked=yanked, + prereleases=prereleases, + ) + + def get_project(self, project: str) -> Project: + r = self.s.get(self.index_url.rstrip("/") + f"/{project}/json") if r.status_code == 404: - raise QyPIError(package + ": package not found") + raise QyPIError(f"{project}: project not found") r.raise_for_status() - return r.json() - - def get_latest_version(self, package): - # TODO: Eliminate - pkg = self.get_package(package) - releases = { - (parse(rel), rel): first_upload(files) - # The unparsed version string needs to be kept around because the - # alternative approach (stringifying the Version object once - # comparisons are done) can result in a different string (e.g., - # "2001.01.01" becomes "2001.1.1"), leading to a 404. - for rel, files in pkg["releases"].items() - } - candidates = releases.keys() - if not self.pre and any(not v[0].is_prerelease for v in candidates): - candidates = filter(lambda v: not v[0].is_prerelease, candidates) - if self.newest: - latest = max( - filter(releases.__getitem__, candidates), - key=releases.__getitem__, - default=None, - ) - else: - latest = max(candidates, default=None) - if latest is None: - raise QyPIError(package + ": no suitable versions available") - latest = latest[1] - if pkg["info"]["version"] == latest: - return pkg - else: - return self.get_package_version(package, latest) + return Project.from_response_json(client=self, data=r.json()) - def get_package_version(self, package, version): - r = self.get(package, version, "json") + def get_project_version(self, project: str, version: str) -> ProjectVersion: + r = self.s.get(self.index_url.rstrip("/") + f"/{project}/{version}/json") if r.status_code == 404: - raise QyPIError(f"{package}: version {version} not found") + raise QyPIError(f"{project}: version {version} not found") r.raise_for_status() - return r.json() + return ProjectVersion.from_response_json(r.json()) def xmlrpc(self, method, *args, **kwargs): return getattr(self.xsp, method)(*args, **kwargs) - def get_all_packages(self) -> List[str]: + def list_all_projects(self) -> List[str]: return cast(List[str], self.xmlrpc("list_packages")) - def get_package_roles(self, package: str) -> List[PackageRole]: + def get_project_roles(self, project: str) -> List[ProjectRole]: return [ - PackageRole(role=role, user=user) - for role, user in self.xmlrpc("package_roles", package) + ProjectRole(role=role, user=user) + for role, user in self.xmlrpc("package_roles", project) ] def get_user_roles(self, user: str) -> List[UserRole]: return [ - UserRole(role=role, package=package) - for role, package in self.xmlrpc("user_packages", user) + UserRole(role=role, project=project) + for role, project in self.xmlrpc("user_packages", user) ] def search( @@ -163,45 +121,269 @@ def browse(self, classifiers: List[str]) -> List[BrowseResult]: for name, version in self.xmlrpc("browse", classifiers) ] - def lookup_package(self, args): - # TODO: Eliminate - for name in args: - try: - yield self.get_package(name) - except QyPIError as e: - self.errmsgs.append(str(e)) - - def lookup_package_version(self, args): - for spec in args: - name, eq, version = spec.partition("=") - try: - if eq != "": - yield self.get_package_version(name, version.lstrip("=")) - elif self.all_versions: - p = self.get_package(name) - for v in sorted(p["releases"], key=parse): - if self.pre or not parse(v).is_prerelease: - if v == p["info"]["version"]: - yield p - else: - ### TODO: Can this call ever fail? - yield self.get_package_version(name, v) - else: - yield self.get_latest_version(name) - except QyPIError as e: - self.errmsgs.append(str(e)) - - def cleanup(self, ctx): - # TODO: Eliminate - if self.errmsgs: - for msg in self.errmsgs: - click.echo(ctx.command_path + ": " + msg, err=True) - ctx.exit(1) - class QyPIError(Exception): pass -def first_upload(files): - return min((f["upload_time_iso_8601"] for f in files), default=None) +class Role(Enum): + OWNER = "Owner" + MAINTAINER = "Maintainer" + + +class JSONableBase(BaseModel): + def json_dict(self, **kwargs: Any) -> dict: + return cast(dict, json.loads(self.json(**kwargs))) + + +class ProjectRole(JSONableBase): + user: str + role: Role + + +class UserRole(JSONableBase): + project: str + role: Role + + +class SearchResult(JSONableBase): + name: str + summary: Optional[str] + version: str + + @validator("summary") + @classmethod + def _nullify_summary(cls, v: Optional[str]) -> Optional[str]: + if v == "" or v == "UNKNOWN": + return None + else: + return v + + +class BrowseResult(JSONableBase): + name: str + version: str + + +class Downloads(JSONableBase): + last_day: int + last_month: int + last_week: int + + +class ProjectInfo(JSONableBase): + author: Optional[str] + author_email: Optional[str] + bugtrack_url: Optional[str] + classifiers: List[str] + description: Optional[str] + description_content_type: Optional[str] + docs_url: Optional[str] + download_url: Optional[str] + downloads: Downloads + home_page: Optional[str] + keywords: Optional[str] + license: Optional[str] + maintainer: Optional[str] + maintainer_email: Optional[str] + name: str + package_url: str + platform: Optional[str] + project_url: str + project_urls: Optional[Dict[str, str]] + release_url: str + requires_dist: Optional[List[str]] + requires_python: Optional[str] + summary: Optional[str] + version: str + yanked: bool + yanked_reason: Optional[str] + + @validator( + "author", + "author_email", + "bugtrack_url", + "description", + "description_content_type", + "docs_url", + "download_url", + "downloads", + "home_page", + "keywords", + "license", + "maintainer", + "maintainer_email", + "platform", + "requires_dist", + "requires_python", + "summary", + ) + @classmethod + def _nullify(cls, v: Optional[str]) -> Optional[str]: + if v == "" or v == "UNKNOWN": + return None + else: + return v + + +class ProjectFile(JSONableBase): + comment_text: Optional[str] # TODO: Nullify? + digests: Dict[str, str] + downloads: int + filename: str + has_sig: bool + md5_digest: str + packagetype: str + python_version: str + requires_python: Optional[str] + size: int + upload_time: datetime + upload_time_iso_8601: datetime + url: str + yanked: bool + yanked_reason: Optional[str] + + def json_dict(self, trust_downloads: bool = False, **kwargs: Any) -> dict: + if not trust_downloads: + kwargs.setdefault("exclude", set()).add("downloads") + return super().json_dict(**kwargs) + + +class Vulnerability(JSONableBase): + aliases: List[str] + details: str + fixed_in: List[str] + id: str + link: str + source: str + + +class ProjectVersion(JSONableBase): + info: ProjectInfo + files: List[ProjectFile] + vulnerabilities: List[Vulnerability] + + @classmethod + def from_response_json(cls, data: dict) -> ProjectVersion: + return cls( + info=data["info"], + files=data["urls"], + vulnerabilities=data.get("vulnerabilities", []), + ) + + @property + def name(self) -> str: + return self.info.name + + @property + def version(self) -> str: + return self.info.version + + @property + def upload_time(self) -> Optional[datetime]: + return min((f.upload_time_iso_8601 for f in self.files), default=None) + + @property + def is_yanked(self) -> bool: + return self.info.yanked + + def qypi_json_dict( + self, description: bool = False, trust_downloads: bool = False + ) -> dict: + info = self.info.json_dict(exclude_unset=True) + if not description: + info.pop("description", None) + if not trust_downloads: + info.pop("downloads", None) + info["url"] = info.pop("home_page", None) + info["release_date"] = show_datetime(self.upload_time) + info["people"] = [] + for role in ("author", "maintainer"): + name = info.pop(role, None) + email = info.pop(role + "_email", None) + if name or email: + info["people"].append( + { + "name": name, + "email": email, + "role": role, + } + ) + if "package_url" in info and "project_url" not in info: + # Field was renamed between PyPI Legacy and Warehouse + info["project_url"] = info.pop("package_url") + return info + + +class Project(JSONableBase): + client: QyPI = Field(exclude=True) + default_version: ProjectVersion + files: Dict[str, List[ProjectFile]] + version_cache: Dict[str, ProjectVersion] = Field( + default_factory=dict, exclude=True, repr=False + ) + + class Config: + arbitrary_types_allowed = True + + @classmethod + def from_response_json(cls, client: QyPI, data: dict) -> Project: + default_version = ProjectVersion.from_response_json(data) + return cls( + client=client, default_version=default_version, files=data["releases"] + ) + + @property + def name(self) -> str: + return self.default_version.name + + @property + def versions(self) -> List[str]: + return list(self.files.keys()) + + def get_version(self, version: str) -> ProjectVersion: + if version not in self.version_cache: + if version == self.default_version.version: + v = self.default_version + else: + v = self.client.get_project_version(self.name, version) + self.version_cache[version] = v + return self.version_cache[version] + + def get_version_by_spec( + self, + spec: Union[str, SpecifierSet], + most_recent: bool = False, + yanked: bool = False, + prereleases: Optional[bool] = None, + ) -> ProjectVersion: + if not isinstance(spec, SpecifierSet): + spec = SpecifierSet(spec) + vs = list(spec.filter(self.versions, prereleases=prereleases)) + if most_recent: + vobjs = [self.get_version(v) for v in vs] + if not yanked: + vobjs = [v for v in vobjs if not v.is_yanked] + vobjs_uploaded = [v for v in vobjs if v.upload_time is not None] + if vobjs_uploaded: + return max(vobjs_uploaded, key=attrgetter("upload_time")) + # else: Fallthrough to returning highest version + vs.sort(key=parse, reverse=True) + for v in vs: + if yanked or not self.get_version(v).is_yanked: + return self.get_version(v) + raise QyPIError(f"{self.name}: no matching versions found") + + def get_all_versions_by_spec( + self, + spec: Union[str, SpecifierSet], + yanked: bool = False, + prereleases: Optional[bool] = None, + ) -> List[ProjectVersion]: + if not isinstance(spec, SpecifierSet): + spec = SpecifierSet(spec) + vs = list(spec.filter(self.versions, prereleases=prereleases)) + vobjs = [self.get_version(v) for v in vs] + if not yanked: + vobjs = [v for v in vobjs if not v.is_yanked] + return vobjs diff --git a/src/qypi/util.py b/src/qypi/util.py index bad58ce..43231ec 100644 --- a/src/qypi/util.py +++ b/src/qypi/util.py @@ -1,70 +1,15 @@ from collections.abc import Iterator +from datetime import datetime from itertools import groupby import json from operator import attrgetter -from textwrap import indent -import click +from typing import Optional from packaging.version import parse -from .api import JSONableBase - - -def obj_option(*args, **kwargs): - """ - Like `click.option`, but sets an attribute on ``ctx.obj`` instead of - creating a parameter - """ - - def callback(ctx, param, value): - setattr(ctx.obj, param.name, value) - - return click.option(*args, callback=callback, expose_value=False, **kwargs) - - -pre_opt = obj_option( - "--pre/--no-pre", - default=False, - help="Show prerelease versions", - show_default=True, -) - -all_opt = obj_option( - "-A", - "--all-versions/--latest-version", - default=False, - help="Show all versions/only the latest version when no version is" - " specified [default: latest]", -) - -sort_opt = obj_option( - "--newest/--highest", - default=False, - help='Does "latest" mean "newest" or "highest"? [default: highest]', -) - - -def package_args(versioned=True): - if versioned: - - def callback(ctx, _param, value): - return ctx.obj.lookup_package_version(value) - - def wrapper(f): - return all_opt( - sort_opt( - pre_opt(click.argument("packages", nargs=-1, callback=callback)(f)) - ) - ) - - return wrapper - else: - return click.argument( - "packages", - nargs=-1, - callback=lambda ctx, _param, value: ctx.obj.lookup_package(value), - ) def json_default(x): + from .api import JSONableBase + if isinstance(x, JSONableBase): return x.json_dict() else: @@ -79,14 +24,6 @@ def dumps(obj): ) -def clean_pypi_dict(d): - return { - k: (None if v in ("", "UNKNOWN") else v) - for k, v in d.items() - if not k.startswith(("cheesecake", "_pypi")) - } - - def squish_versions(releases): """ Given a list of `SearchResult`\\s or `BrowseResult`\\s, return for each @@ -98,47 +35,8 @@ def squish_versions(releases): yield max(versions, key=lambda v: parse(v.version)) -class JSONLister: - def __init__(self): - self.first = True - - def __enter__(self): - click.echo("[", nl=False) - return self - - def __exit__(self, _exc_type, _exc_value, _traceback): - if not self.first: - click.echo() - click.echo("]") - return False - - def append(self, obj): - if self.first: - click.echo() - self.first = False - else: - click.echo(",") - click.echo(indent(dumps(obj), " " * 4), nl=False) - - -class JSONMapper: - def __init__(self): - self.first = True - - def __enter__(self): - click.echo("{", nl=False) - return self - - def __exit__(self, _exc_type, _exc_value, _traceback): - if not self.first: - click.echo() - click.echo("}") - return False - - def append(self, key, value): - if self.first: - click.echo() - self.first = False - else: - click.echo(",") - click.echo(indent(json.dumps(key) + ": " + dumps(value), " " * 4), nl=False) +def show_datetime(dt: Optional[datetime]) -> Optional[str]: + if dt is None: + return None + else: + return dt.isoformat() diff --git a/test/data/foobar.json b/test/data/foobar.json index e6b3a37..cf52b39 100644 --- a/test/data/foobar.json +++ b/test/data/foobar.json @@ -10,18 +10,21 @@ "maintainer_email": "dawn42@baldwin-alvarado.org", "home_page": "https://www.wilson-espinoza.com/explore/explore/post/", "package_url": "https://dummy.nil/pypi/foobar", + "project_url": "https://dummy.nil/pypi/foobar", "release_url": "https://dummy.nil/pypi/foobar/0.1.0", "downloads": { "last_day": 16, "last_week": 154, "last_month": 637 }, - "unknown_field": "passed through", + "unknown_field": "not passed through", "classifiers": [ "Topic :: Software Development :: Testing", "UNKNOWN" ], "platform": null, + "yanked": false, + "yanked_reason": null, "cheesecake_look-provide-gas": "HmHbSOlvwOIknMsxdkcQ", "cheesecake_threat-number": 7761, "_pypi_property-late": "xQnSZHqzpUXFuWEEuxZV", @@ -41,10 +44,12 @@ "downloads": 95, "size": 775, "comment_text": "", - "unknown_field": "passed through", + "unknown_field": "not passed through", "upload_time": "2013-01-18T18:53:56", "upload_time_iso_8601": "2013-01-18T18:53:56.265173Z", - "url": "https://files.dummyhosted.nil/packages/ac/62/c3a61adf1cc9b1e3ec63ba06706d3f7af3e1ef389b97523e2b529fbe5432/foobar-0.1.0-py2.py3-none-any.whl" + "url": "https://files.dummyhosted.nil/packages/ac/62/c3a61adf1cc9b1e3ec63ba06706d3f7af3e1ef389b97523e2b529fbe5432/foobar-0.1.0-py2.py3-none-any.whl", + "yanked": false, + "yanked_reason": null }, { "md5_digest": "2e93ea94a7f514f2d8f267dd90b47d37", @@ -59,10 +64,12 @@ "downloads": 104, "size": 743, "comment_text": "", - "unknown_field": "passed through", + "unknown_field": "not passed through", "upload_time": "2015-09-22T09:35:00", "upload_time_iso_8601": "2015-09-22T09:35:00.441072Z", - "url": "https://files.dummyhosted.nil/packages/8e/75/d58d32eb92ad0956d69925acb9828191f18cb14004cc6db76f7687b025a5/foobar-0.1.0.tar.gz" + "url": "https://files.dummyhosted.nil/packages/8e/75/d58d32eb92ad0956d69925acb9828191f18cb14004cc6db76f7687b025a5/foobar-0.1.0.tar.gz", + "yanked": false, + "yanked_reason": null } ] }, @@ -77,18 +84,21 @@ "maintainer_email": "maynardtim@hotmail.com", "home_page": "http://www.sanchez.net/index.htm", "package_url": "https://dummy.nil/pypi/foobar", + "project_url": "https://dummy.nil/pypi/foobar", "release_url": "https://dummy.nil/pypi/foobar/0.2.0", "downloads": { "last_day": 18, "last_week": 144, "last_month": 581 }, - "unknown_field": "passed through", + "unknown_field": "not passed through", "classifiers": [ "Topic :: Software Development :: Testing", "UNKNOWN" ], "platform": "Wood", + "yanked": false, + "yanked_reason": null, "cheesecake_woman-hold-hair": "DSBGtdRHISnyJfrGQizX", "cheesecake_which-early-tax": 9055, "_pypi_fight-time-rise": "PgLctIJSJoqxGGGFjdme", @@ -108,10 +118,12 @@ "downloads": 106, "size": 752, "comment_text": "", - "unknown_field": "passed through", + "unknown_field": "not passed through", "upload_time": "2017-02-04T12:34:05", "upload_time_iso_8601": "2017-02-04T12:34:05.766270Z", - "url": "https://files.dummyhosted.nil/packages/54/40/36eccb727704b5dabfda040e0eb23c29dbe26cf1a78cbeb24f33deb26b22/foobar-0.2.0-py2.py3-none-any.whl" + "url": "https://files.dummyhosted.nil/packages/54/40/36eccb727704b5dabfda040e0eb23c29dbe26cf1a78cbeb24f33deb26b22/foobar-0.2.0-py2.py3-none-any.whl", + "yanked": false, + "yanked_reason": null } ] }, @@ -126,18 +138,21 @@ "maintainer_email": "cspencer@paul-fisher.com", "home_page": "https://www.johnson.com/homepage.php", "package_url": "https://dummy.nil/pypi/foobar", + "project_url": "https://dummy.nil/pypi/foobar", "release_url": "https://dummy.nil/pypi/foobar/1.0.0", "downloads": { "last_day": 20, "last_week": 136, "last_month": 588 }, - "unknown_field": "passed through", + "unknown_field": "not passed through", "classifiers": [ "Topic :: Software Development :: Testing", "UNKNOWN" ], "platform": "Amiga", + "yanked": false, + "yanked_reason": null, "cheesecake_yourself-describe": "PVvGZKjzWEttoGekIqco", "cheesecake_act-provide-name": 4972, "_pypi_way-worry-bring": "wbsvtrUkNCZxpxEDrnFH", @@ -157,10 +172,12 @@ "downloads": 100, "size": 735, "comment_text": "", - "unknown_field": "passed through", + "unknown_field": "not passed through", "upload_time": "2019-02-01T09:17:59", "upload_time_iso_8601": "2019-02-01T09:17:59.172284Z", - "url": "https://files.dummyhosted.nil/packages/7f/97/e5ec19aed5d108c2f6c2fc6646d8247b1fadb49f0bf48e87a0fca8827696/foobar-1.0.0-py2.py3-none-any.whl" + "url": "https://files.dummyhosted.nil/packages/7f/97/e5ec19aed5d108c2f6c2fc6646d8247b1fadb49f0bf48e87a0fca8827696/foobar-1.0.0-py2.py3-none-any.whl", + "yanked": false, + "yanked_reason": null } ] } diff --git a/test/data/has-prerel.json b/test/data/has-prerel.json index a56d0ea..564687a 100644 --- a/test/data/has-prerel.json +++ b/test/data/has-prerel.json @@ -10,18 +10,21 @@ "maintainer_email": "estradakelly@hotmail.com", "home_page": "http://www.johnson.com/author.jsp", "package_url": "https://dummy.nil/pypi/has_prerel", + "project_url": "https://dummy.nil/pypi/has_prerel", "release_url": "https://dummy.nil/pypi/has_prerel/1.0.0", "downloads": { "last_day": 16, "last_week": 142, "last_month": 570 }, - "unknown_field": "passed through", + "unknown_field": "not passed through", "classifiers": [ "Topic :: Software Development :: Testing", "UNKNOWN" ], "platform": "Coleco", + "yanked": false, + "yanked_reason": null, "cheesecake_toward-little": "HVWAHFQnxPKrxLoWOzqx", "cheesecake_ball-staff-home": 1906, "_pypi_the-month-thank": "tZocMhoODznKtAxEVKbs", @@ -41,10 +44,12 @@ "downloads": 102, "size": 701, "comment_text": "", - "unknown_field": "passed through", + "unknown_field": "not passed through", "upload_time": "1970-04-21T22:33:29", "upload_time_iso_8601": "1970-04-21T22:33:29.915221Z", - "url": "https://files.dummyhosted.nil/packages/b7/be/340b13c29b8ad078c711693cd702e1581fff945ab4d533fe2ba5bfeedb8f/has_prerel-1.0.0.tar.gz" + "url": "https://files.dummyhosted.nil/packages/b7/be/340b13c29b8ad078c711693cd702e1581fff945ab4d533fe2ba5bfeedb8f/has_prerel-1.0.0.tar.gz", + "yanked": false, + "yanked_reason": null } ] }, @@ -59,18 +64,21 @@ "maintainer_email": "michael30@wilson-clark.com", "home_page": "http://reeves.com/register.htm", "package_url": "https://dummy.nil/pypi/has_prerel", + "project_url": "https://dummy.nil/pypi/has_prerel", "release_url": "https://dummy.nil/pypi/has_prerel/1.0.1a1", "downloads": { "last_day": 21, "last_week": 135, "last_month": 606 }, - "unknown_field": "passed through", + "unknown_field": "not passed through", "classifiers": [ "Topic :: Software Development :: Testing", "UNKNOWN" ], "platform": "GNU HURD", + "yanked": false, + "yanked_reason": null, "cheesecake_thought-life-more": "LnqmqPmRHoPaCmmnuCvr", "cheesecake_involve-late-hold": 1568, "_pypi_everyone-leg-show": "jHjJLOpBfzpitKAsxxmM", @@ -90,10 +98,12 @@ "downloads": 100, "size": 731, "comment_text": "", - "unknown_field": "passed through", + "unknown_field": "not passed through", "upload_time": "1976-08-10T20:15:23", "upload_time_iso_8601": "1976-08-10T20:15:23.166615Z", - "url": "https://files.dummyhosted.nil/packages/cc/6c/54b0ea444850e5b93c850a5a841791b1734a1a1cc135a1bc26d0349f0c48/has_prerel-1.0.1a1-py2.py3-none-any.whl" + "url": "https://files.dummyhosted.nil/packages/cc/6c/54b0ea444850e5b93c850a5a841791b1734a1a1cc135a1bc26d0349f0c48/has_prerel-1.0.1a1-py2.py3-none-any.whl", + "yanked": false, + "yanked_reason": null }, { "md5_digest": "7556a0c09be49ca29f937cc04340caca", @@ -108,10 +118,12 @@ "downloads": 94, "size": 767, "comment_text": "", - "unknown_field": "passed through", + "unknown_field": "not passed through", "upload_time": "2010-04-17T16:41:12", "upload_time_iso_8601": "2010-04-17T16:41:12.726119Z", - "url": "https://files.dummyhosted.nil/packages/66/98/7b07cecf05c1c6d36149689ceba068cd70345e77f5de597b0f9de10f41f0/has_prerel-1.0.1a1.tar.gz" + "url": "https://files.dummyhosted.nil/packages/66/98/7b07cecf05c1c6d36149689ceba068cd70345e77f5de597b0f9de10f41f0/has_prerel-1.0.1a1.tar.gz", + "yanked": false, + "yanked_reason": null } ] } diff --git a/test/data/nullfields.json b/test/data/nullfields.json index 6a8ca53..bf28cd5 100644 --- a/test/data/nullfields.json +++ b/test/data/nullfields.json @@ -8,6 +8,7 @@ "author_email": "barbara10@yahoo.com", "home_page": "https://bryant.com/wp-content/search/author/", "package_url": "https://dummy.nil/pypi/nullfields", + "project_url": "https://dummy.nil/pypi/nullfields", "release_url": "https://dummy.nil/pypi/nullfields/1.0.0", "downloads": { "last_day": 21, @@ -19,7 +20,9 @@ "Topic :: Software Development :: Testing", "UNKNOWN" ], - "platform": "" + "platform": "", + "yanked": false, + "yanked_reason": null }, "files": [ { @@ -35,10 +38,12 @@ "downloads": 96, "size": 750, "comment_text": "", - "unknown_field": "passed through", + "unknown_field": "not passed through", "upload_time": "2007-10-08T07:21:06", "upload_time_iso_8601": "2007-10-08T07:21:06.191703Z", - "url": "https://files.dummyhosted.nil/packages/5d/ec/a2ae6831e7419344c28971b4a4f4748424a0841e66ab2464451e63f19990/nullfields-1.0.0.tar.gz" + "url": "https://files.dummyhosted.nil/packages/5d/ec/a2ae6831e7419344c28971b4a4f4748424a0841e66ab2464451e63f19990/nullfields-1.0.0.tar.gz", + "yanked": false, + "yanked_reason": null }, { "md5_digest": "d4cd0d857311318c574d9a5713b4a955", @@ -53,10 +58,12 @@ "downloads": 93, "size": 730, "comment_text": "", - "unknown_field": "passed through", + "unknown_field": "not passed through", "upload_time": "2009-01-10T16:37:05", "upload_time_iso_8601": "2009-01-10T16:37:05.820291Z", - "url": "https://files.dummyhosted.nil/packages/88/29/3097d542abba807cf3d6ece379ec583f5e373e32e522cf524bad939cd93e/nullfields-1.0.0-py2.py3-none-any.whl" + "url": "https://files.dummyhosted.nil/packages/88/29/3097d542abba807cf3d6ece379ec583f5e373e32e522cf524bad939cd93e/nullfields-1.0.0-py2.py3-none-any.whl", + "yanked": false, + "yanked_reason": null } ] } diff --git a/test/data/prerelease-only.json b/test/data/prerelease-only.json index e4533af..a2aac36 100644 --- a/test/data/prerelease-only.json +++ b/test/data/prerelease-only.json @@ -10,18 +10,21 @@ "maintainer_email": "jamesduncan@robles.info", "home_page": "https://www.rose.com/app/tag/search/index.html", "package_url": "https://dummy.nil/pypi/Prerelease.Only", + "project_url": "https://dummy.nil/pypi/Prerelease.Only", "release_url": "https://dummy.nil/pypi/Prerelease.Only/0.1a1", "downloads": { "last_day": 13, "last_week": 142, "last_month": 619 }, - "unknown_field": "passed through", + "unknown_field": "not passed through", "classifiers": [ "Topic :: Software Development :: Testing", "UNKNOWN" ], "platform": "GNU HURD", + "yanked": false, + "yanked_reason": null, "cheesecake_address-someone": "DVdIlLbRMybCgCrmWEgx", "cheesecake_third-concern": 5197, "_pypi_final-front-outside": "PnjaRDXbrfNTDxSIHwpV", @@ -41,10 +44,12 @@ "downloads": 100, "size": 719, "comment_text": "", - "unknown_field": "passed through", + "unknown_field": "not passed through", "upload_time": "1978-03-09T22:33:19", "upload_time_iso_8601": "1978-03-09T22:33:19.415273Z", - "url": "https://files.dummyhosted.nil/packages/d5/ef/a5dcc4f8a797656a3897bc79fcd668d8931ec73ee311245a7a9db3ad17c6/Prerelease.Only-0.1a1.tar.gz" + "url": "https://files.dummyhosted.nil/packages/d5/ef/a5dcc4f8a797656a3897bc79fcd668d8931ec73ee311245a7a9db3ad17c6/Prerelease.Only-0.1a1.tar.gz", + "yanked": false, + "yanked_reason": null } ] }, @@ -59,18 +64,21 @@ "maintainer_email": "austinmccarty@johnson.com", "home_page": "http://www.wong-robbins.biz/explore/explore/categories/search/", "package_url": "https://dummy.nil/pypi/Prerelease.Only", + "project_url": "https://dummy.nil/pypi/Prerelease.Only", "release_url": "https://dummy.nil/pypi/Prerelease.Only/0.2a1", "downloads": { "last_day": 15, "last_week": 155, "last_month": 572 }, - "unknown_field": "passed through", + "unknown_field": "not passed through", "classifiers": [ "Topic :: Software Development :: Testing", "UNKNOWN" ], "platform": "Commodore 64", + "yanked": false, + "yanked_reason": null, "cheesecake_organization-from": "txSfkPTpUlkKtZLkcjxf", "cheesecake_we-continue-power": 3334, "_pypi_give-describe-cut": "oygFBCIbhmMgpfGZgJPI", @@ -90,10 +98,12 @@ "downloads": 91, "size": 779, "comment_text": "", - "unknown_field": "passed through", + "unknown_field": "not passed through", "upload_time": "2008-09-19T11:02:51", "upload_time_iso_8601": "2008-09-19T11:02:51.892301Z", - "url": "https://files.dummyhosted.nil/packages/35/24/32f4a4de4147316f0077dc720216c45c5ab05ae3ba548c0200769282aadf/Prerelease.Only-0.2a1-py2.py3-none-any.whl" + "url": "https://files.dummyhosted.nil/packages/35/24/32f4a4de4147316f0077dc720216c45c5ab05ae3ba548c0200769282aadf/Prerelease.Only-0.2a1-py2.py3-none-any.whl", + "yanked": false, + "yanked_reason": null }, { "md5_digest": "a2d9895128c749722bf0c981549edf1a", @@ -108,10 +118,12 @@ "downloads": 98, "size": 758, "comment_text": "", - "unknown_field": "passed through", + "unknown_field": "not passed through", "upload_time": "2019-05-01T12:14:25", "upload_time_iso_8601": "2019-05-01T12:14:25.173970Z", - "url": "https://files.dummyhosted.nil/packages/24/bf/e5f60a1a9aa2fe067598b9edad20dbd15624ec03a0c3cc547d879c3ac94e/Prerelease.Only-0.2a1.tar.gz" + "url": "https://files.dummyhosted.nil/packages/24/bf/e5f60a1a9aa2fe067598b9edad20dbd15624ec03a0c3cc547d879c3ac94e/Prerelease.Only-0.2a1.tar.gz", + "yanked": false, + "yanked_reason": null } ] } diff --git a/test/test_main.py b/test/test_main.py index 2fa9823..0afb857 100644 --- a/test/test_main.py +++ b/test/test_main.py @@ -1,9 +1,10 @@ import json import sys from traceback import format_exception +import click from click.testing import CliRunner import pytest -from qypi.__main__ import qypi +from qypi.__main__ import main from qypi.api import USER_AGENT @@ -27,11 +28,9 @@ def test_list(mocker): } ) spclass = mocker.patch("qypi.api.ServerProxy", return_value=spinstance) - r = CliRunner().invoke(qypi, ["list"]) + r = CliRunner().invoke(main, ["list"]) assert r.exit_code == 0, show_result(r) - assert r.output == ( - "foobar\n" "BarFoo\n" "quux\n" "Gnusto-Cleesh\n" "XYZZY_PLUGH\n" - ) + assert r.output == "foobar\nBarFoo\nquux\nGnusto-Cleesh\nXYZZY_PLUGH\n" if sys.version_info >= (3, 8): spclass.assert_called_once_with( "https://pypi.org/pypi", headers=[("User-Agent", USER_AGENT)] @@ -51,21 +50,19 @@ def test_owner(mocker): } ) spclass = mocker.patch("qypi.api.ServerProxy", return_value=spinstance) - r = CliRunner().invoke(qypi, ["owner", "foobar"]) + r = CliRunner().invoke(main, ["owner", "foobar"]) assert r.exit_code == 0, show_result(r) assert r.output == ( - "{\n" - ' "foobar": [\n' - " {\n" - ' "role": "Owner",\n' - ' "user": "luser"\n' - " },\n" - " {\n" - ' "role": "Maintainer",\n' - ' "user": "jsmith"\n' - " }\n" - " ]\n" - "}\n" + "[\n" + " {\n" + ' "role": "Owner",\n' + ' "user": "luser"\n' + " },\n" + " {\n" + ' "role": "Maintainer",\n' + ' "user": "jsmith"\n' + " }\n" + "]\n" ) if sys.version_info >= (3, 8): spclass.assert_called_once_with( @@ -76,60 +73,6 @@ def test_owner(mocker): assert spinstance.method_calls == [mocker.call.package_roles("foobar")] -def test_multiple_owner(mocker): - spinstance = mocker.MagicMock( - **{ - "package_roles.side_effect": [ - [ - ["Owner", "luser"], - ["Maintainer", "jsmith"], - ], - [ - ["Owner", "jsmith"], - ["Owner", "froody"], - ], - ], - } - ) - spclass = mocker.patch("qypi.api.ServerProxy", return_value=spinstance) - r = CliRunner().invoke(qypi, ["owner", "foobar", "Glarch"]) - assert r.exit_code == 0, show_result(r) - assert r.output == ( - "{\n" - ' "foobar": [\n' - " {\n" - ' "role": "Owner",\n' - ' "user": "luser"\n' - " },\n" - " {\n" - ' "role": "Maintainer",\n' - ' "user": "jsmith"\n' - " }\n" - " ],\n" - ' "Glarch": [\n' - " {\n" - ' "role": "Owner",\n' - ' "user": "jsmith"\n' - " },\n" - " {\n" - ' "role": "Owner",\n' - ' "user": "froody"\n' - " }\n" - " ]\n" - "}\n" - ) - if sys.version_info >= (3, 8): - spclass.assert_called_once_with( - "https://pypi.org/pypi", headers=[("User-Agent", USER_AGENT)] - ) - else: - spclass.assert_called_once_with("https://pypi.org/pypi") - assert spinstance.method_calls == [ - mocker.call.package_roles("foobar"), - mocker.call.package_roles("Glarch"), - ] - - def test_owned(mocker): spinstance = mocker.MagicMock( **{ @@ -140,21 +83,19 @@ def test_owned(mocker): } ) spclass = mocker.patch("qypi.api.ServerProxy", return_value=spinstance) - r = CliRunner().invoke(qypi, ["owned", "luser"]) + r = CliRunner().invoke(main, ["owned", "luser"]) assert r.exit_code == 0, show_result(r) assert r.output == ( - "{\n" - ' "luser": [\n' - " {\n" - ' "package": "foobar",\n' - ' "role": "Owner"\n' - " },\n" - " {\n" - ' "package": "quux",\n' - ' "role": "Maintainer"\n' - " }\n" - " ]\n" - "}\n" + "[\n" + " {\n" + ' "project": "foobar",\n' + ' "role": "Owner"\n' + " },\n" + " {\n" + ' "project": "quux",\n' + ' "role": "Maintainer"\n' + " }\n" + "]\n" ) if sys.version_info >= (3, 8): spclass.assert_called_once_with( @@ -165,60 +106,6 @@ def test_owned(mocker): assert spinstance.method_calls == [mocker.call.user_packages("luser")] -def test_multiple_owned(mocker): - spinstance = mocker.MagicMock( - **{ - "user_packages.side_effect": [ - [ - ["Owner", "foobar"], - ["Maintainer", "quux"], - ], - [ - ["Maintainer", "foobar"], - ["Owner", "Glarch"], - ], - ], - } - ) - spclass = mocker.patch("qypi.api.ServerProxy", return_value=spinstance) - r = CliRunner().invoke(qypi, ["owned", "luser", "jsmith"]) - assert r.exit_code == 0, show_result(r) - assert r.output == ( - "{\n" - ' "luser": [\n' - " {\n" - ' "package": "foobar",\n' - ' "role": "Owner"\n' - " },\n" - " {\n" - ' "package": "quux",\n' - ' "role": "Maintainer"\n' - " }\n" - " ],\n" - ' "jsmith": [\n' - " {\n" - ' "package": "foobar",\n' - ' "role": "Maintainer"\n' - " },\n" - " {\n" - ' "package": "Glarch",\n' - ' "role": "Owner"\n' - " }\n" - " ]\n" - "}\n" - ) - if sys.version_info >= (3, 8): - spclass.assert_called_once_with( - "https://pypi.org/pypi", headers=[("User-Agent", USER_AGENT)] - ) - else: - spclass.assert_called_once_with("https://pypi.org/pypi") - assert spinstance.method_calls == [ - mocker.call.user_packages("luser"), - mocker.call.user_packages("jsmith"), - ] - - def test_search(mocker): spinstance = mocker.MagicMock( **{ @@ -245,7 +132,7 @@ def test_search(mocker): } ) spclass = mocker.patch("qypi.api.ServerProxy", return_value=spinstance) - r = CliRunner().invoke(qypi, ["search", "term", "keyword:foo", "readme:bar"]) + r = CliRunner().invoke(main, ["search", "term", "keyword:foo", "readme:bar"]) assert r.exit_code == 0, show_result(r) assert r.output == ( "[\n" @@ -295,7 +182,7 @@ def test_browse(mocker): ) spclass = mocker.patch("qypi.api.ServerProxy", return_value=spinstance) r = CliRunner().invoke( - qypi, + main, ["browse", "Typing :: Typed", "Topic :: Utilities"], ) assert r.exit_code == 0, show_result(r) @@ -353,8 +240,8 @@ def test_browse_packages(mocker): ) spclass = mocker.patch("qypi.api.ServerProxy", return_value=spinstance) r = CliRunner().invoke( - qypi, - ["browse", "--packages", "Typing :: Typed", "Topic :: Utilities"], + main, + ["browse", "--projects", "Typing :: Typed", "Topic :: Utilities"], ) assert r.exit_code == 0, show_result(r) assert r.output == ( @@ -386,390 +273,239 @@ def test_browse_packages(mocker): @pytest.mark.usefixtures("mock_pypi_json") def test_info(): - r = CliRunner().invoke(qypi, ["info", "foobar"]) + r = CliRunner().invoke(main, ["info", "foobar"]) assert r.exit_code == 0, show_result(r) assert r.output == ( - "[\n" - " {\n" - ' "classifiers": [\n' - ' "Topic :: Software Development :: Testing",\n' - ' "UNKNOWN"\n' - " ],\n" - ' "name": "foobar",\n' - ' "people": [\n' - " {\n" - ' "email": "megan30@daniels.info",\n' - ' "name": "Brandon Perkins",\n' - ' "role": "author"\n' - " },\n" - " {\n" - ' "email": "cspencer@paul-fisher.com",\n' - ' "name": "Denise Adkins",\n' - ' "role": "maintainer"\n' - " }\n" - " ],\n" - ' "platform": "Amiga",\n' - ' "project_url": "https://dummy.nil/pypi/foobar",\n' - ' "release_date": "2019-02-01T09:17:59.172284Z",\n' - ' "release_url": "https://dummy.nil/pypi/foobar/1.0.0",\n' - ' "summary": "Including drive environment my it.",\n' - ' "unknown_field": "passed through",\n' - ' "url": "https://www.johnson.com/homepage.php",\n' - ' "version": "1.0.0"\n' - " }\n" - "]\n" + "{\n" + ' "classifiers": [\n' + ' "Topic :: Software Development :: Testing",\n' + ' "UNKNOWN"\n' + " ],\n" + ' "name": "foobar",\n' + ' "package_url": "https://dummy.nil/pypi/foobar",\n' + ' "people": [\n' + " {\n" + ' "email": "megan30@daniels.info",\n' + ' "name": "Brandon Perkins",\n' + ' "role": "author"\n' + " },\n" + " {\n" + ' "email": "cspencer@paul-fisher.com",\n' + ' "name": "Denise Adkins",\n' + ' "role": "maintainer"\n' + " }\n" + " ],\n" + ' "platform": "Amiga",\n' + ' "project_url": "https://dummy.nil/pypi/foobar",\n' + ' "release_date": "2019-02-01T09:17:59.172284+00:00",\n' + ' "release_url": "https://dummy.nil/pypi/foobar/1.0.0",\n' + ' "summary": "Including drive environment my it.",\n' + ' "url": "https://www.johnson.com/homepage.php",\n' + ' "version": "1.0.0",\n' + ' "yanked": false,\n' + ' "yanked_reason": null\n' + "}\n" ) @pytest.mark.usefixtures("mock_pypi_json") def test_info_explicit_latest_version(): - r = CliRunner().invoke(qypi, ["info", "foobar==1.0.0"]) + r = CliRunner().invoke(main, ["info", "foobar==1.0.0"]) assert r.exit_code == 0, show_result(r) assert r.output == ( - "[\n" - " {\n" - ' "classifiers": [\n' - ' "Topic :: Software Development :: Testing",\n' - ' "UNKNOWN"\n' - " ],\n" - ' "name": "foobar",\n' - ' "people": [\n' - " {\n" - ' "email": "megan30@daniels.info",\n' - ' "name": "Brandon Perkins",\n' - ' "role": "author"\n' - " },\n" - " {\n" - ' "email": "cspencer@paul-fisher.com",\n' - ' "name": "Denise Adkins",\n' - ' "role": "maintainer"\n' - " }\n" - " ],\n" - ' "platform": "Amiga",\n' - ' "project_url": "https://dummy.nil/pypi/foobar",\n' - ' "release_date": "2019-02-01T09:17:59.172284Z",\n' - ' "release_url": "https://dummy.nil/pypi/foobar/1.0.0",\n' - ' "summary": "Including drive environment my it.",\n' - ' "unknown_field": "passed through",\n' - ' "url": "https://www.johnson.com/homepage.php",\n' - ' "version": "1.0.0"\n' - " }\n" - "]\n" + "{\n" + ' "classifiers": [\n' + ' "Topic :: Software Development :: Testing",\n' + ' "UNKNOWN"\n' + " ],\n" + ' "name": "foobar",\n' + ' "package_url": "https://dummy.nil/pypi/foobar",\n' + ' "people": [\n' + " {\n" + ' "email": "megan30@daniels.info",\n' + ' "name": "Brandon Perkins",\n' + ' "role": "author"\n' + " },\n" + " {\n" + ' "email": "cspencer@paul-fisher.com",\n' + ' "name": "Denise Adkins",\n' + ' "role": "maintainer"\n' + " }\n" + " ],\n" + ' "platform": "Amiga",\n' + ' "project_url": "https://dummy.nil/pypi/foobar",\n' + ' "release_date": "2019-02-01T09:17:59.172284+00:00",\n' + ' "release_url": "https://dummy.nil/pypi/foobar/1.0.0",\n' + ' "summary": "Including drive environment my it.",\n' + ' "url": "https://www.johnson.com/homepage.php",\n' + ' "version": "1.0.0",\n' + ' "yanked": false,\n' + ' "yanked_reason": null\n' + "}\n" ) @pytest.mark.usefixtures("mock_pypi_json") def test_info_explicit_version(): - r = CliRunner().invoke(qypi, ["info", "foobar==0.2.0"]) + r = CliRunner().invoke(main, ["info", "foobar==0.2.0"]) assert r.exit_code == 0, show_result(r) assert r.output == ( - "[\n" - " {\n" - ' "classifiers": [\n' - ' "Topic :: Software Development :: Testing",\n' - ' "UNKNOWN"\n' - " ],\n" - ' "name": "foobar",\n' - ' "people": [\n' - " {\n" - ' "email": "danielstewart@frye.com",\n' - ' "name": "Sonya Johnson",\n' - ' "role": "author"\n' - " },\n" - " {\n" - ' "email": "maynardtim@hotmail.com",\n' - ' "name": "Stephen Romero",\n' - ' "role": "maintainer"\n' - " }\n" - " ],\n" - ' "platform": "Wood",\n' - ' "project_url": "https://dummy.nil/pypi/foobar",\n' - ' "release_date": "2017-02-04T12:34:05.766270Z",\n' - ' "release_url": "https://dummy.nil/pypi/foobar/0.2.0",\n' - ' "summary": "Water audience cut call.",\n' - ' "unknown_field": "passed through",\n' - ' "url": "http://www.sanchez.net/index.htm",\n' - ' "version": "0.2.0"\n' - " }\n" - "]\n" + "{\n" + ' "classifiers": [\n' + ' "Topic :: Software Development :: Testing",\n' + ' "UNKNOWN"\n' + " ],\n" + ' "name": "foobar",\n' + ' "package_url": "https://dummy.nil/pypi/foobar",\n' + ' "people": [\n' + " {\n" + ' "email": "danielstewart@frye.com",\n' + ' "name": "Sonya Johnson",\n' + ' "role": "author"\n' + " },\n" + " {\n" + ' "email": "maynardtim@hotmail.com",\n' + ' "name": "Stephen Romero",\n' + ' "role": "maintainer"\n' + " }\n" + " ],\n" + ' "platform": "Wood",\n' + ' "project_url": "https://dummy.nil/pypi/foobar",\n' + ' "release_date": "2017-02-04T12:34:05.766270+00:00",\n' + ' "release_url": "https://dummy.nil/pypi/foobar/0.2.0",\n' + ' "summary": "Water audience cut call.",\n' + ' "url": "http://www.sanchez.net/index.htm",\n' + ' "version": "0.2.0",\n' + ' "yanked": false,\n' + ' "yanked_reason": null\n' + "}\n" ) @pytest.mark.usefixtures("mock_pypi_json") def test_info_description(): - r = CliRunner().invoke(qypi, ["info", "--description", "foobar"]) - assert r.exit_code == 0, show_result(r) - assert r.output == ( - "[\n" - " {\n" - ' "classifiers": [\n' - ' "Topic :: Software Development :: Testing",\n' - ' "UNKNOWN"\n' - " ],\n" - ' "description": "foobar v1.0.0\\n\\nDream political close attorney sit cost inside. Seek hard can bad investment authority walk we. Sing range late use speech citizen.\\n\\nCan money issue claim onto really case. Fact garden along all book sister trip step.\\n\\nView table woman her production result. Fine allow prepare should traditional. Send cultural two care eye.\\n\\nGenerated with Faker",\n' - ' "name": "foobar",\n' - ' "people": [\n' - " {\n" - ' "email": "megan30@daniels.info",\n' - ' "name": "Brandon Perkins",\n' - ' "role": "author"\n' - " },\n" - " {\n" - ' "email": "cspencer@paul-fisher.com",\n' - ' "name": "Denise Adkins",\n' - ' "role": "maintainer"\n' - " }\n" - " ],\n" - ' "platform": "Amiga",\n' - ' "project_url": "https://dummy.nil/pypi/foobar",\n' - ' "release_date": "2019-02-01T09:17:59.172284Z",\n' - ' "release_url": "https://dummy.nil/pypi/foobar/1.0.0",\n' - ' "summary": "Including drive environment my it.",\n' - ' "unknown_field": "passed through",\n' - ' "url": "https://www.johnson.com/homepage.php",\n' - ' "version": "1.0.0"\n' - " }\n" - "]\n" - ) - - -@pytest.mark.usefixtures("mock_pypi_json") -def test_multiple_info(): - r = CliRunner().invoke(qypi, ["info", "has-prerel", "foobar"]) + r = CliRunner().invoke(main, ["info", "--description", "foobar"]) assert r.exit_code == 0, show_result(r) assert r.output == ( - "[\n" - " {\n" - ' "classifiers": [\n' - ' "Topic :: Software Development :: Testing",\n' - ' "UNKNOWN"\n' - " ],\n" - ' "name": "has_prerel",\n' - ' "people": [\n' - " {\n" - ' "email": "freed@hotmail.com",\n' - ' "name": "Samantha Gilbert",\n' - ' "role": "author"\n' - " },\n" - " {\n" - ' "email": "estradakelly@hotmail.com",\n' - ' "name": "Bradley Livingston",\n' - ' "role": "maintainer"\n' - " }\n" - " ],\n" - ' "platform": "Coleco",\n' - ' "project_url": "https://dummy.nil/pypi/has_prerel",\n' - ' "release_date": "1970-04-21T22:33:29.915221Z",\n' - ' "release_url": "https://dummy.nil/pypi/has_prerel/1.0.0",\n' - ' "summary": "Boy kid chance indeed resource explain.",\n' - ' "unknown_field": "passed through",\n' - ' "url": "http://www.johnson.com/author.jsp",\n' - ' "version": "1.0.0"\n' - " },\n" - " {\n" - ' "classifiers": [\n' - ' "Topic :: Software Development :: Testing",\n' - ' "UNKNOWN"\n' - " ],\n" - ' "name": "foobar",\n' - ' "people": [\n' - " {\n" - ' "email": "megan30@daniels.info",\n' - ' "name": "Brandon Perkins",\n' - ' "role": "author"\n' - " },\n" - " {\n" - ' "email": "cspencer@paul-fisher.com",\n' - ' "name": "Denise Adkins",\n' - ' "role": "maintainer"\n' - " }\n" - " ],\n" - ' "platform": "Amiga",\n' - ' "project_url": "https://dummy.nil/pypi/foobar",\n' - ' "release_date": "2019-02-01T09:17:59.172284Z",\n' - ' "release_url": "https://dummy.nil/pypi/foobar/1.0.0",\n' - ' "summary": "Including drive environment my it.",\n' - ' "unknown_field": "passed through",\n' - ' "url": "https://www.johnson.com/homepage.php",\n' - ' "version": "1.0.0"\n' - " }\n" - "]\n" - ) - - -@pytest.mark.usefixtures("mock_pypi_json") -def test_info_nonexistent(): - r = CliRunner().invoke(qypi, ["info", "does-not-exist", "foobar"]) - assert r.exit_code == 1, show_result(r) - assert r.output == ( - "[\n" - " {\n" - ' "classifiers": [\n' - ' "Topic :: Software Development :: Testing",\n' - ' "UNKNOWN"\n' - " ],\n" - ' "name": "foobar",\n' - ' "people": [\n' - " {\n" - ' "email": "megan30@daniels.info",\n' - ' "name": "Brandon Perkins",\n' - ' "role": "author"\n' - " },\n" - " {\n" - ' "email": "cspencer@paul-fisher.com",\n' - ' "name": "Denise Adkins",\n' - ' "role": "maintainer"\n' - " }\n" - " ],\n" - ' "platform": "Amiga",\n' - ' "project_url": "https://dummy.nil/pypi/foobar",\n' - ' "release_date": "2019-02-01T09:17:59.172284Z",\n' - ' "release_url": "https://dummy.nil/pypi/foobar/1.0.0",\n' - ' "summary": "Including drive environment my it.",\n' - ' "unknown_field": "passed through",\n' - ' "url": "https://www.johnson.com/homepage.php",\n' - ' "version": "1.0.0"\n' - " }\n" - "]\n" - "qypi: does-not-exist: package not found\n" + "{\n" + ' "classifiers": [\n' + ' "Topic :: Software Development :: Testing",\n' + ' "UNKNOWN"\n' + " ],\n" + ' "description": "foobar v1.0.0\\n\\nDream political close attorney sit cost inside. Seek hard can bad investment authority walk we. Sing range late use speech citizen.\\n\\nCan money issue claim onto really case. Fact garden along all book sister trip step.\\n\\nView table woman her production result. Fine allow prepare should traditional. Send cultural two care eye.\\n\\nGenerated with Faker",\n' + ' "name": "foobar",\n' + ' "package_url": "https://dummy.nil/pypi/foobar",\n' + ' "people": [\n' + " {\n" + ' "email": "megan30@daniels.info",\n' + ' "name": "Brandon Perkins",\n' + ' "role": "author"\n' + " },\n" + " {\n" + ' "email": "cspencer@paul-fisher.com",\n' + ' "name": "Denise Adkins",\n' + ' "role": "maintainer"\n' + " }\n" + " ],\n" + ' "platform": "Amiga",\n' + ' "project_url": "https://dummy.nil/pypi/foobar",\n' + ' "release_date": "2019-02-01T09:17:59.172284+00:00",\n' + ' "release_url": "https://dummy.nil/pypi/foobar/1.0.0",\n' + ' "summary": "Including drive environment my it.",\n' + ' "url": "https://www.johnson.com/homepage.php",\n' + ' "version": "1.0.0",\n' + ' "yanked": false,\n' + ' "yanked_reason": null\n' + "}\n" ) @pytest.mark.usefixtures("mock_pypi_json") -def test_info_nonexistent_split(): - r = CliRunner(mix_stderr=False).invoke(qypi, ["info", "does-not-exist", "foobar"]) - assert r.exit_code == 1, show_result(r) - assert r.stdout == ( - "[\n" - " {\n" - ' "classifiers": [\n' - ' "Topic :: Software Development :: Testing",\n' - ' "UNKNOWN"\n' - " ],\n" - ' "name": "foobar",\n' - ' "people": [\n' - " {\n" - ' "email": "megan30@daniels.info",\n' - ' "name": "Brandon Perkins",\n' - ' "role": "author"\n' - " },\n" - " {\n" - ' "email": "cspencer@paul-fisher.com",\n' - ' "name": "Denise Adkins",\n' - ' "role": "maintainer"\n' - " }\n" - " ],\n" - ' "platform": "Amiga",\n' - ' "project_url": "https://dummy.nil/pypi/foobar",\n' - ' "release_date": "2019-02-01T09:17:59.172284Z",\n' - ' "release_url": "https://dummy.nil/pypi/foobar/1.0.0",\n' - ' "summary": "Including drive environment my it.",\n' - ' "unknown_field": "passed through",\n' - ' "url": "https://www.johnson.com/homepage.php",\n' - ' "version": "1.0.0"\n' - " }\n" - "]\n" - ) - assert r.stderr == "qypi: does-not-exist: package not found\n" +@pytest.mark.parametrize("arg", ["does-not-exist", "does-not-exist==2.23.42"]) +def test_info_nonexistent(arg): + r = CliRunner().invoke(main, ["info", arg], standalone_mode=False) + assert r.exit_code != 0, show_result(r) + assert r.output == "" + assert isinstance(r.exception, click.UsageError) + assert str(r.exception) == "does-not-exist: project not found" @pytest.mark.usefixtures("mock_pypi_json") def test_info_nonexistent_version(): - r = CliRunner().invoke(qypi, ["info", "foobar==2.23.42"]) - assert r.exit_code == 1, show_result(r) - assert r.output == ("[]\n" "qypi: foobar: version 2.23.42 not found\n") - - -@pytest.mark.usefixtures("mock_pypi_json") -def test_info_nonexistent_version_split(): - r = CliRunner(mix_stderr=False).invoke(qypi, ["info", "foobar==2.23.42"]) - assert r.exit_code == 1, show_result(r) - assert r.stdout == "[]\n" - assert r.stderr == "qypi: foobar: version 2.23.42 not found\n" - - -@pytest.mark.usefixtures("mock_pypi_json") -def test_info_nonexistent_explicit_version(): - r = CliRunner().invoke(qypi, ["info", "does-not-exist==2.23.42"]) - assert r.exit_code == 1, show_result(r) - assert r.output == ("[]\n" "qypi: does-not-exist: version 2.23.42 not found\n") - - -@pytest.mark.usefixtures("mock_pypi_json") -def test_info_nonexistent_explicit_version_split(): - r = CliRunner(mix_stderr=False).invoke(qypi, ["info", "does-not-exist==2.23.42"]) - assert r.exit_code == 1, show_result(r) - assert r.stdout == "[]\n" - assert r.stderr == "qypi: does-not-exist: version 2.23.42 not found\n" + r = CliRunner().invoke(main, ["info", "foobar==2.23.42"], standalone_mode=False) + assert r.exit_code != 0, show_result(r) + assert r.output == "" + assert isinstance(r.exception, click.UsageError) + assert str(r.exception) == "foobar: no matching versions found" @pytest.mark.usefixtures("mock_pypi_json") def test_info_latest_is_prerelease(): - r = CliRunner().invoke(qypi, ["info", "has-prerel"]) + r = CliRunner().invoke(main, ["info", "has-prerel"]) assert r.exit_code == 0, show_result(r) data = json.loads(r.output) - assert data[0]["version"] == "1.0.0" + assert data["version"] == "1.0.0" @pytest.mark.usefixtures("mock_pypi_json") def test_info_latest_is_prerelease_pre(): - r = CliRunner().invoke(qypi, ["info", "--pre", "has-prerel"]) + r = CliRunner().invoke(main, ["info", "--pre", "has-prerel"]) assert r.exit_code == 0, show_result(r) data = json.loads(r.output) - assert data[0]["version"] == "1.0.1a1" + assert data["version"] == "1.0.1a1" @pytest.mark.usefixtures("mock_pypi_json") def test_info_explicit_prerelease(): - r = CliRunner().invoke(qypi, ["info", "has-prerel==1.0.1a1"]) + r = CliRunner().invoke(main, ["info", "has-prerel==1.0.1a1"]) assert r.exit_code == 0, show_result(r) data = json.loads(r.output) - assert data[0]["version"] == "1.0.1a1" + assert data["version"] == "1.0.1a1" @pytest.mark.usefixtures("mock_pypi_json") def test_info_all_are_prerelease(): - r = CliRunner().invoke(qypi, ["info", "prerelease-only"]) + r = CliRunner().invoke(main, ["info", "prerelease-only"]) assert r.exit_code == 0, show_result(r) data = json.loads(r.output) - assert data[0]["version"] == "0.2a1" + assert data["version"] == "0.2a1" @pytest.mark.usefixtures("mock_pypi_json") def test_info_nullfields(): - r = CliRunner().invoke(qypi, ["info", "nullfields"]) + r = CliRunner().invoke(main, ["info", "nullfields"]) assert r.exit_code == 0, show_result(r) assert r.output == ( - "[\n" - " {\n" - ' "classifiers": [\n' - ' "Topic :: Software Development :: Testing",\n' - ' "UNKNOWN"\n' - " ],\n" - ' "name": "nullfields",\n' - ' "people": [\n' - " {\n" - ' "email": "barbara10@yahoo.com",\n' - ' "name": "Philip Gonzalez",\n' - ' "role": "author"\n' - " }\n" - " ],\n" - ' "platform": null,\n' - ' "project_url": "https://dummy.nil/pypi/nullfields",\n' - ' "release_date": "2007-10-08T07:21:06.191703Z",\n' - ' "release_url": "https://dummy.nil/pypi/nullfields/1.0.0",\n' - ' "summary": "Film station choose short.",\n' - ' "unknown_field": null,\n' - ' "url": "https://bryant.com/wp-content/search/author/",\n' - ' "version": "1.0.0"\n' - " }\n" - "]\n" + "{\n" + ' "classifiers": [\n' + ' "Topic :: Software Development :: Testing",\n' + ' "UNKNOWN"\n' + " ],\n" + ' "name": "nullfields",\n' + ' "package_url": "https://dummy.nil/pypi/nullfields",\n' + ' "people": [\n' + " {\n" + ' "email": "barbara10@yahoo.com",\n' + ' "name": "Philip Gonzalez",\n' + ' "role": "author"\n' + " }\n" + " ],\n" + ' "platform": null,\n' + ' "project_url": "https://dummy.nil/pypi/nullfields",\n' + ' "release_date": "2007-10-08T07:21:06.191703+00:00",\n' + ' "release_url": "https://dummy.nil/pypi/nullfields/1.0.0",\n' + ' "summary": "Film station choose short.",\n' + ' "url": "https://bryant.com/wp-content/search/author/",\n' + ' "version": "1.0.0",\n' + ' "yanked": false,\n' + ' "yanked_reason": null\n' + "}\n" ) @pytest.mark.usefixtures("mock_pypi_json") def test_readme(): - r = CliRunner().invoke(qypi, ["readme", "foobar"]) + r = CliRunner().invoke(main, ["readme", "foobar"]) assert r.exit_code == 0, show_result(r) assert r.output == ( "foobar v1.0.0\n" @@ -786,7 +522,7 @@ def test_readme(): @pytest.mark.usefixtures("mock_pypi_json") def test_readme_explicit_version(): - r = CliRunner().invoke(qypi, ["readme", "foobar==0.2.0"]) + r = CliRunner().invoke(main, ["readme", "foobar==0.2.0"]) assert r.exit_code == 0, show_result(r) assert r.output == ( "foobar v0.2.0\n" @@ -803,32 +539,27 @@ def test_readme_explicit_version(): @pytest.mark.usefixtures("mock_pypi_json") def test_files(): - r = CliRunner().invoke(qypi, ["files", "foobar"]) + r = CliRunner().invoke(main, ["files", "foobar"]) assert r.exit_code == 0, show_result(r) assert r.output == ( "[\n" " {\n" - ' "files": [\n' - " {\n" - ' "comment_text": "",\n' - ' "digests": {\n' - ' "md5": "f92e8964922878760a07f783341a58ae",\n' - ' "sha256": "84750bd98e3f61441e4b86ab443ebae41e65557e2b071b5a8e22a7d61a48a59d"\n' - " },\n" - ' "filename": "foobar-1.0.0-py2.py3-none-any.whl",\n' - ' "has_sig": true,\n' - ' "md5_digest": "f92e8964922878760a07f783341a58ae",\n' - ' "packagetype": "bdist_wheel",\n' - ' "python_version": "py2.py3",\n' - ' "size": 735,\n' - ' "unknown_field": "passed through",\n' - ' "upload_time": "2019-02-01T09:17:59",\n' - ' "upload_time_iso_8601": "2019-02-01T09:17:59.172284Z",\n' - ' "url": "https://files.dummyhosted.nil/packages/7f/97/e5ec19aed5d108c2f6c2fc6646d8247b1fadb49f0bf48e87a0fca8827696/foobar-1.0.0-py2.py3-none-any.whl"\n' - " }\n" - " ],\n" - ' "name": "foobar",\n' - ' "version": "1.0.0"\n' + ' "comment_text": "",\n' + ' "digests": {\n' + ' "md5": "f92e8964922878760a07f783341a58ae",\n' + ' "sha256": "84750bd98e3f61441e4b86ab443ebae41e65557e2b071b5a8e22a7d61a48a59d"\n' + " },\n" + ' "filename": "foobar-1.0.0-py2.py3-none-any.whl",\n' + ' "has_sig": true,\n' + ' "md5_digest": "f92e8964922878760a07f783341a58ae",\n' + ' "packagetype": "bdist_wheel",\n' + ' "python_version": "py2.py3",\n' + ' "size": 735,\n' + ' "upload_time": "2019-02-01T09:17:59",\n' + ' "upload_time_iso_8601": "2019-02-01T09:17:59.172284+00:00",\n' + ' "url": "https://files.dummyhosted.nil/packages/7f/97/e5ec19aed5d108c2f6c2fc6646d8247b1fadb49f0bf48e87a0fca8827696/foobar-1.0.0-py2.py3-none-any.whl",\n' + ' "yanked": false,\n' + ' "yanked_reason": null\n' " }\n" "]\n" ) @@ -836,32 +567,27 @@ def test_files(): @pytest.mark.usefixtures("mock_pypi_json") def test_files_explicit_version(): - r = CliRunner().invoke(qypi, ["files", "foobar==0.2.0"]) + r = CliRunner().invoke(main, ["files", "foobar==0.2.0"]) assert r.exit_code == 0, show_result(r) assert r.output == ( "[\n" " {\n" - ' "files": [\n' - " {\n" - ' "comment_text": "",\n' - ' "digests": {\n' - ' "md5": "5ced02e62434eb5649276e6f12003009",\n' - ' "sha256": "f0862078b4f1af49f6b8c91153e9a7df88807900f9cf1b24287a901e515c824e"\n' - " },\n" - ' "filename": "foobar-0.2.0-py2.py3-none-any.whl",\n' - ' "has_sig": false,\n' - ' "md5_digest": "5ced02e62434eb5649276e6f12003009",\n' - ' "packagetype": "bdist_wheel",\n' - ' "python_version": "py2.py3",\n' - ' "size": 752,\n' - ' "unknown_field": "passed through",\n' - ' "upload_time": "2017-02-04T12:34:05",\n' - ' "upload_time_iso_8601": "2017-02-04T12:34:05.766270Z",\n' - ' "url": "https://files.dummyhosted.nil/packages/54/40/36eccb727704b5dabfda040e0eb23c29dbe26cf1a78cbeb24f33deb26b22/foobar-0.2.0-py2.py3-none-any.whl"\n' - " }\n" - " ],\n" - ' "name": "foobar",\n' - ' "version": "0.2.0"\n' + ' "comment_text": "",\n' + ' "digests": {\n' + ' "md5": "5ced02e62434eb5649276e6f12003009",\n' + ' "sha256": "f0862078b4f1af49f6b8c91153e9a7df88807900f9cf1b24287a901e515c824e"\n' + " },\n" + ' "filename": "foobar-0.2.0-py2.py3-none-any.whl",\n' + ' "has_sig": false,\n' + ' "md5_digest": "5ced02e62434eb5649276e6f12003009",\n' + ' "packagetype": "bdist_wheel",\n' + ' "python_version": "py2.py3",\n' + ' "size": 752,\n' + ' "upload_time": "2017-02-04T12:34:05",\n' + ' "upload_time_iso_8601": "2017-02-04T12:34:05.766270+00:00",\n' + ' "url": "https://files.dummyhosted.nil/packages/54/40/36eccb727704b5dabfda040e0eb23c29dbe26cf1a78cbeb24f33deb26b22/foobar-0.2.0-py2.py3-none-any.whl",\n' + ' "yanked": false,\n' + ' "yanked_reason": null\n' " }\n" "]\n" ) @@ -869,31 +595,32 @@ def test_files_explicit_version(): @pytest.mark.usefixtures("mock_pypi_json") def test_releases(): - r = CliRunner().invoke(qypi, ["releases", "foobar"]) + r = CliRunner().invoke(main, ["releases", "foobar"]) assert r.exit_code == 0, show_result(r) assert r.output == ( - "{\n" - ' "foobar": [\n' - " {\n" - ' "is_prerelease": false,\n' - ' "release_date": "2013-01-18T18:53:56.265173Z",\n' - ' "release_url": "https://dummy.nil/pypi/foobar/0.1.0",\n' - ' "version": "0.1.0"\n' - " },\n" - " {\n" - ' "is_prerelease": false,\n' - ' "release_date": "2017-02-04T12:34:05.766270Z",\n' - ' "release_url": "https://dummy.nil/pypi/foobar/0.2.0",\n' - ' "version": "0.2.0"\n' - " },\n" - " {\n" - ' "is_prerelease": false,\n' - ' "release_date": "2019-02-01T09:17:59.172284Z",\n' - ' "release_url": "https://dummy.nil/pypi/foobar/1.0.0",\n' - ' "version": "1.0.0"\n' - " }\n" - " ]\n" - "}\n" + "[\n" + " {\n" + ' "is_prerelease": false,\n' + ' "is_yanked": false,\n' + ' "release_date": "2013-01-18T18:53:56.265173+00:00",\n' + ' "release_url": "https://dummy.nil/pypi/foobar/0.1.0",\n' + ' "version": "0.1.0"\n' + " },\n" + " {\n" + ' "is_prerelease": false,\n' + ' "is_yanked": false,\n' + ' "release_date": "2017-02-04T12:34:05.766270+00:00",\n' + ' "release_url": "https://dummy.nil/pypi/foobar/0.2.0",\n' + ' "version": "0.2.0"\n' + " },\n" + " {\n" + ' "is_prerelease": false,\n' + ' "is_yanked": false,\n' + ' "release_date": "2019-02-01T09:17:59.172284+00:00",\n' + ' "release_url": "https://dummy.nil/pypi/foobar/1.0.0",\n' + ' "version": "1.0.0"\n' + " }\n" + "]\n" ) diff --git a/tox.ini b/tox.ini index 8ae3efe..8db2d17 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = lint,py36,py37,py38,py39,py310,py311,py312,pypy3 +envlist = lint,py37,py38,py39,py310,311,py312,pypy3 skip_missing_interpreters = True isolated_build = True minversion = 3.3.0 From 44f97a1f0c50a6d245a18f1fafd017f2aed0547e Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Sun, 13 Feb 2022 23:30:46 +0000 Subject: [PATCH 06/19] Set up type-checking --- .github/workflows/test.yml | 2 ++ setup.cfg | 17 +++++++++++++++++ src/qypi/py.typed | 0 tox.ini | 9 ++++++++- 4 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 src/qypi/py.typed diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 44427f5..14bcea9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,6 +33,8 @@ jobs: include: - python-version: '3.7' toxenv: lint + - python-version: '3.7' + toxenv: typing steps: - name: Check out repository uses: actions/checkout@v4 diff --git a/setup.cfg b/setup.cfg index c7b64c7..cf0c234 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,6 +34,7 @@ classifiers = Intended Audience :: Information Technology Topic :: Software Development :: Libraries :: Python Modules Topic :: System :: Software Distribution + Typing :: Typed project_urls = Source Code = https://github.com/jwodder/qypi @@ -56,3 +57,19 @@ where = src [options.entry_points] console_scripts = qypi = qypi.__main__:qypi + +[mypy] +allow_incomplete_defs = False +allow_untyped_defs = False +ignore_missing_imports = True +# : +no_implicit_optional = True +implicit_reexport = False +local_partial_types = True +pretty = True +show_error_codes = True +show_traceback = True +strict_equality = True +warn_redundant_casts = True +warn_return_any = True +warn_unreachable = True diff --git a/src/qypi/py.typed b/src/qypi/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tox.ini b/tox.ini index 8db2d17..89fca94 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = lint,py37,py38,py39,py310,311,py312,pypy3 +envlist = lint,typing,py37,py38,py39,py310,py311,py312,pypy3 skip_missing_interpreters = True isolated_build = True minversion = 3.3.0 @@ -23,6 +23,13 @@ deps = commands = flake8 src test +[testenv:typing] +deps = + mypy~=0.900 + {[testenv]deps} +commands = + mypy src test + [pytest] addopts = --cov=qypi --no-cov-on-fail filterwarnings = From 7a05b1523303a5a60c42a32cb2d09f147450571b Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Sun, 13 Feb 2022 23:55:26 +0000 Subject: [PATCH 07/19] Get code to type-check --- src/qypi/__main__.py | 43 ++++++++++++++++++++++------------- src/qypi/api.py | 8 +++---- src/qypi/util.py | 26 +++++---------------- test/conftest.py | 7 ++++-- test/test_main.py | 54 +++++++++++++++++++++++--------------------- tox.ini | 1 + 6 files changed, 71 insertions(+), 68 deletions(-) diff --git a/src/qypi/__main__.py b/src/qypi/__main__.py index 61aed56..4e0c16c 100644 --- a/src/qypi/__main__.py +++ b/src/qypi/__main__.py @@ -1,9 +1,12 @@ -from typing import List, Optional, Sequence, TextIO +from collections import defaultdict +from itertools import groupby +from operator import attrgetter +from typing import Dict, List, Optional, TextIO, Tuple import click from packaging.version import parse from . import __version__ from .api import DEFAULT_ENDPOINT, QyPI, QyPIError -from .util import dumps, show_datetime, squish_versions +from .util import dumps, show_datetime SEARCH_SYNONYMS = { "homepage": "home_page", @@ -46,7 +49,7 @@ ) @click.version_option(__version__, "-V", "--version", message="%(prog)s %(version)s") @click.pass_context -def main(ctx, index_url): +def main(ctx: click.Context, index_url: str) -> None: """Query PyPI from the command line""" ctx.obj = ctx.with_resource(QyPI(index_url)) @@ -86,7 +89,7 @@ def info( """ if all_versions: try: - vs = qypi.get_all_requirement(project, yanked=False, prereleases=pre) + vs = qypi.get_all_requirements(project, yanked=False, prereleases=pre) except QyPIError as e: raise click.UsageError(str(e)) click.echo( @@ -132,7 +135,10 @@ def readme(qypi: QyPI, project: str, newest: bool, pre: Optional[bool]) -> None: ``version``. """ v = qypi.get_requirement(project, most_recent=newest, yanked=False, prereleases=pre) - click.echo_via_pager(v.info.description) + if v.info.description is not None: + click.echo_via_pager(v.info.description) + else: + click.echo_via_pager("--- no description ---") @main.command() @@ -184,7 +190,7 @@ def files( ``version``. """ if all_versions: - vs = qypi.get_all_requirement(project, yanked=False, prereleases=pre) + vs = qypi.get_all_requirements(project, yanked=False, prereleases=pre) click.echo( dumps( { @@ -212,7 +218,7 @@ def files( @main.command("list") @click.pass_obj -def listcmd(qypi): +def listcmd(qypi: QyPI) -> None: """List all projects on PyPI""" for pkg in qypi.list_all_projects(): click.echo(pkg) @@ -235,25 +241,27 @@ def listcmd(qypi): ) @click.argument("terms", nargs=-1, required=True) @click.pass_obj -def search(qypi: QyPI, terms, oper, projects): +def search(qypi: QyPI, terms: Tuple[str], oper: str, projects: bool) -> None: """ Search PyPI for projects or releases thereof. Search terms may be specified as either ``field:value`` (e.g., ``summary:Django``) or just ``value`` to search long descriptions. """ - spec = {} + spec: Dict[str, List[str]] = defaultdict(list) for t in terms: key, colon, value = t.partition(":") if colon == "": key, value = "description", t else: key = SEARCH_SYNONYMS.get(key, key) - # ServerProxy can't handle defaultdicts, so we can't use those instead. - spec.setdefault(key, []).append(value) - results = qypi.search(spec, oper) + spec[key].append(value) + results = qypi.search(dict(spec), oper) if projects: - results = squish_versions(results) + results = [ + max(versions, key=lambda v: parse(v.version)) + for _, versions in groupby(results, attrgetter("name")) + ] click.echo(dumps(results)) @@ -268,7 +276,7 @@ def search(qypi: QyPI, terms, oper, projects): @click.argument("classifiers", nargs=-1) @click.pass_obj def browse( - qypi: QyPI, classifiers: Sequence[str], file: Optional[TextIO], projects: bool + qypi: QyPI, classifiers: Tuple[str, ...], file: Optional[TextIO], projects: bool ) -> None: """ List projects with given trove classifiers. @@ -279,9 +287,12 @@ def browse( """ if file is not None: classifiers += tuple(map(str.strip, file)) - results = qypi.browse(classifiers) + results = qypi.browse(list(classifiers)) if projects: - results = squish_versions(results) + results = [ + max(versions, key=lambda v: parse(v.version)) + for _, versions in groupby(results, attrgetter("name")) + ] click.echo(dumps(results)) diff --git a/src/qypi/api.py b/src/qypi/api.py index 4850887..8911ab2 100644 --- a/src/qypi/api.py +++ b/src/qypi/api.py @@ -36,7 +36,7 @@ def __init__(self, index_url: str = DEFAULT_ENDPOINT) -> None: xsp_kwargs = {"headers": [("User-Agent", USER_AGENT)]} else: xsp_kwargs = {} - self.xsp = ServerProxy(self.index_url, **xsp_kwargs) + self.xsp = ServerProxy(self.index_url, **xsp_kwargs) # type: ignore[arg-type] self.ctx_stack = ExitStack() def __enter__(self) -> QyPI: @@ -44,7 +44,7 @@ def __enter__(self) -> QyPI: self.ctx_stack.enter_context(self.xsp) return self - def __exit__(self, *_exc) -> None: + def __exit__(self, *_exc: Any) -> None: self.ctx_stack.close() def get_requirement( @@ -66,7 +66,7 @@ def get_requirement( def get_all_requirements( self, req: str, yanked: bool = False, prereleases: Optional[bool] = None - ) -> Optional[ProjectVersion]: + ) -> List[ProjectVersion]: reqobj = Requirement(req) ### TODO: Warn if reqobj has non-None marker, extras, or url? project = self.get_project(reqobj.name) @@ -90,7 +90,7 @@ def get_project_version(self, project: str, version: str) -> ProjectVersion: r.raise_for_status() return ProjectVersion.from_response_json(r.json()) - def xmlrpc(self, method, *args, **kwargs): + def xmlrpc(self, method: str, *args: Any, **kwargs: Any) -> Any: return getattr(self.xsp, method)(*args, **kwargs) def list_all_projects(self) -> List[str]: diff --git a/src/qypi/util.py b/src/qypi/util.py index 43231ec..8231f82 100644 --- a/src/qypi/util.py +++ b/src/qypi/util.py @@ -1,40 +1,26 @@ from collections.abc import Iterator from datetime import datetime -from itertools import groupby import json -from operator import attrgetter -from typing import Optional -from packaging.version import parse +from typing import Any, Optional -def json_default(x): +def json_default(x: Any) -> Any: from .api import JSONableBase - if isinstance(x, JSONableBase): + if isinstance(x, Iterator): + return list(x) + elif isinstance(x, JSONableBase): return x.json_dict() else: return x -def dumps(obj): - if isinstance(obj, Iterator): - obj = list(obj) +def dumps(obj: Any) -> str: return json.dumps( obj, sort_keys=True, indent=4, ensure_ascii=False, default=json_default ) -def squish_versions(releases): - """ - Given a list of `SearchResult`\\s or `BrowseResult`\\s, return for each - name the result with the highest version. - - It is assumed that `dict`s with the same name are always adjacent. - """ - for _, versions in groupby(releases, attrgetter("name")): - yield max(versions, key=lambda v: parse(v.version)) - - def show_datetime(dt: Optional[datetime]) -> Optional[str]: if dt is None: return None diff --git a/test/conftest.py b/test/conftest.py index 0155934..e929805 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -2,8 +2,10 @@ import json from pathlib import Path import re +from typing import Dict, Iterator, Tuple from packaging.utils import canonicalize_name import pytest +from requests import PreparedRequest import responses DATA_DIR = Path(__file__).with_name("data") @@ -12,7 +14,7 @@ @pytest.fixture -def mock_pypi_json(): +def mock_pypi_json() -> Iterator[responses.RequestsMock]: with responses.RequestsMock() as rsps: rsps.add_callback( responses.GET, @@ -23,7 +25,8 @@ def mock_pypi_json(): yield rsps -def mkresponse(r): +def mkresponse(r: PreparedRequest) -> Tuple[int, Dict[str, str], str]: + assert r.url is not None m = urlre.match(r.url) assert m package, version = m.groups() diff --git a/test/test_main.py b/test/test_main.py index 0afb857..60968ef 100644 --- a/test/test_main.py +++ b/test/test_main.py @@ -2,20 +2,22 @@ import sys from traceback import format_exception import click -from click.testing import CliRunner +from click.testing import CliRunner, Result import pytest +from pytest_mock import MockerFixture from qypi.__main__ import main from qypi.api import USER_AGENT -def show_result(r): +def show_result(r: Result) -> str: if r.exception is not None: + assert isinstance(r.exc_info, tuple) return "".join(format_exception(*r.exc_info)) else: return r.output -def test_list(mocker): +def test_list(mocker: MockerFixture) -> None: spinstance = mocker.MagicMock( **{ "list_packages.return_value": [ @@ -40,7 +42,7 @@ def test_list(mocker): assert spinstance.method_calls == [mocker.call.list_packages()] -def test_owner(mocker): +def test_owner(mocker: MockerFixture) -> None: spinstance = mocker.MagicMock( **{ "package_roles.return_value": [ @@ -73,7 +75,7 @@ def test_owner(mocker): assert spinstance.method_calls == [mocker.call.package_roles("foobar")] -def test_owned(mocker): +def test_owned(mocker: MockerFixture) -> None: spinstance = mocker.MagicMock( **{ "user_packages.return_value": [ @@ -106,7 +108,7 @@ def test_owned(mocker): assert spinstance.method_calls == [mocker.call.user_packages("luser")] -def test_search(mocker): +def test_search(mocker: MockerFixture) -> None: spinstance = mocker.MagicMock( **{ "search.return_value": [ @@ -167,7 +169,7 @@ def test_search(mocker): ] -def test_browse(mocker): +def test_browse(mocker: MockerFixture) -> None: spinstance = mocker.MagicMock( **{ "browse.return_value": [ @@ -221,11 +223,11 @@ def test_browse(mocker): else: spclass.assert_called_once_with("https://pypi.org/pypi") assert spinstance.method_calls == [ - mocker.call.browse(("Typing :: Typed", "Topic :: Utilities")) + mocker.call.browse(["Typing :: Typed", "Topic :: Utilities"]) ] -def test_browse_packages(mocker): +def test_browse_packages(mocker: MockerFixture) -> None: spinstance = mocker.MagicMock( **{ "browse.return_value": [ @@ -267,12 +269,12 @@ def test_browse_packages(mocker): else: spclass.assert_called_once_with("https://pypi.org/pypi") assert spinstance.method_calls == [ - mocker.call.browse(("Typing :: Typed", "Topic :: Utilities")) + mocker.call.browse(["Typing :: Typed", "Topic :: Utilities"]) ] @pytest.mark.usefixtures("mock_pypi_json") -def test_info(): +def test_info() -> None: r = CliRunner().invoke(main, ["info", "foobar"]) assert r.exit_code == 0, show_result(r) assert r.output == ( @@ -309,7 +311,7 @@ def test_info(): @pytest.mark.usefixtures("mock_pypi_json") -def test_info_explicit_latest_version(): +def test_info_explicit_latest_version() -> None: r = CliRunner().invoke(main, ["info", "foobar==1.0.0"]) assert r.exit_code == 0, show_result(r) assert r.output == ( @@ -346,7 +348,7 @@ def test_info_explicit_latest_version(): @pytest.mark.usefixtures("mock_pypi_json") -def test_info_explicit_version(): +def test_info_explicit_version() -> None: r = CliRunner().invoke(main, ["info", "foobar==0.2.0"]) assert r.exit_code == 0, show_result(r) assert r.output == ( @@ -383,7 +385,7 @@ def test_info_explicit_version(): @pytest.mark.usefixtures("mock_pypi_json") -def test_info_description(): +def test_info_description() -> None: r = CliRunner().invoke(main, ["info", "--description", "foobar"]) assert r.exit_code == 0, show_result(r) assert r.output == ( @@ -422,7 +424,7 @@ def test_info_description(): @pytest.mark.usefixtures("mock_pypi_json") @pytest.mark.parametrize("arg", ["does-not-exist", "does-not-exist==2.23.42"]) -def test_info_nonexistent(arg): +def test_info_nonexistent(arg: str) -> None: r = CliRunner().invoke(main, ["info", arg], standalone_mode=False) assert r.exit_code != 0, show_result(r) assert r.output == "" @@ -431,7 +433,7 @@ def test_info_nonexistent(arg): @pytest.mark.usefixtures("mock_pypi_json") -def test_info_nonexistent_version(): +def test_info_nonexistent_version() -> None: r = CliRunner().invoke(main, ["info", "foobar==2.23.42"], standalone_mode=False) assert r.exit_code != 0, show_result(r) assert r.output == "" @@ -440,7 +442,7 @@ def test_info_nonexistent_version(): @pytest.mark.usefixtures("mock_pypi_json") -def test_info_latest_is_prerelease(): +def test_info_latest_is_prerelease() -> None: r = CliRunner().invoke(main, ["info", "has-prerel"]) assert r.exit_code == 0, show_result(r) data = json.loads(r.output) @@ -448,7 +450,7 @@ def test_info_latest_is_prerelease(): @pytest.mark.usefixtures("mock_pypi_json") -def test_info_latest_is_prerelease_pre(): +def test_info_latest_is_prerelease_pre() -> None: r = CliRunner().invoke(main, ["info", "--pre", "has-prerel"]) assert r.exit_code == 0, show_result(r) data = json.loads(r.output) @@ -456,7 +458,7 @@ def test_info_latest_is_prerelease_pre(): @pytest.mark.usefixtures("mock_pypi_json") -def test_info_explicit_prerelease(): +def test_info_explicit_prerelease() -> None: r = CliRunner().invoke(main, ["info", "has-prerel==1.0.1a1"]) assert r.exit_code == 0, show_result(r) data = json.loads(r.output) @@ -464,7 +466,7 @@ def test_info_explicit_prerelease(): @pytest.mark.usefixtures("mock_pypi_json") -def test_info_all_are_prerelease(): +def test_info_all_are_prerelease() -> None: r = CliRunner().invoke(main, ["info", "prerelease-only"]) assert r.exit_code == 0, show_result(r) data = json.loads(r.output) @@ -472,7 +474,7 @@ def test_info_all_are_prerelease(): @pytest.mark.usefixtures("mock_pypi_json") -def test_info_nullfields(): +def test_info_nullfields() -> None: r = CliRunner().invoke(main, ["info", "nullfields"]) assert r.exit_code == 0, show_result(r) assert r.output == ( @@ -504,7 +506,7 @@ def test_info_nullfields(): @pytest.mark.usefixtures("mock_pypi_json") -def test_readme(): +def test_readme() -> None: r = CliRunner().invoke(main, ["readme", "foobar"]) assert r.exit_code == 0, show_result(r) assert r.output == ( @@ -521,7 +523,7 @@ def test_readme(): @pytest.mark.usefixtures("mock_pypi_json") -def test_readme_explicit_version(): +def test_readme_explicit_version() -> None: r = CliRunner().invoke(main, ["readme", "foobar==0.2.0"]) assert r.exit_code == 0, show_result(r) assert r.output == ( @@ -538,7 +540,7 @@ def test_readme_explicit_version(): @pytest.mark.usefixtures("mock_pypi_json") -def test_files(): +def test_files() -> None: r = CliRunner().invoke(main, ["files", "foobar"]) assert r.exit_code == 0, show_result(r) assert r.output == ( @@ -566,7 +568,7 @@ def test_files(): @pytest.mark.usefixtures("mock_pypi_json") -def test_files_explicit_version(): +def test_files_explicit_version() -> None: r = CliRunner().invoke(main, ["files", "foobar==0.2.0"]) assert r.exit_code == 0, show_result(r) assert r.output == ( @@ -594,7 +596,7 @@ def test_files_explicit_version(): @pytest.mark.usefixtures("mock_pypi_json") -def test_releases(): +def test_releases() -> None: r = CliRunner().invoke(main, ["releases", "foobar"]) assert r.exit_code == 0, show_result(r) assert r.output == ( diff --git a/tox.ini b/tox.ini index 89fca94..18a2298 100644 --- a/tox.ini +++ b/tox.ini @@ -26,6 +26,7 @@ commands = [testenv:typing] deps = mypy~=0.900 + types-requests {[testenv]deps} commands = mypy src test From 7a77d24430b5cb3c581258f1584edb0c4ceb5743 Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Mon, 14 Feb 2022 00:00:29 +0000 Subject: [PATCH 08/19] Add --yanked/--no-yanked options --- CHANGELOG.md | 6 +++++- src/qypi/__main__.py | 28 ++++++++++++++++++++++------ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7c8994..1dfdbdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,11 @@ v1.0.0 (in development) - The output from the `owned` command has changed to use the more accurate "project" instead of "package". - Output timestamps now use `+00:00` as the timezone offset instead of `Z` -- Honor package yanking (PEP 592) +- Honor yanking (PEP 592) + - Yanked versions are no longer shown by default + - The `info`, `readme`, and `files` commands now have + `--yanked`/`--no-yanked` options for controlling whether to display + yanked versions - Support Python 3.10, 3.11, and 3.12 - Drop support for Python 3.6 diff --git a/src/qypi/__main__.py b/src/qypi/__main__.py index 4e0c16c..4c653e9 100644 --- a/src/qypi/__main__.py +++ b/src/qypi/__main__.py @@ -23,6 +23,13 @@ show_default=True, ) +yanked_opt = click.option( + "--yanked/--no-yanked", + default=False, + help="Show yanked versions", + show_default=True, +) + all_opt = click.option( "-A", "--all-versions/--latest-version", @@ -70,6 +77,7 @@ def main(ctx: click.Context, index_url: str) -> None: @all_opt @sort_opt @pre_opt +@yanked_opt @click.argument("project") @click.pass_obj def info( @@ -80,6 +88,7 @@ def info( all_versions: bool, newest: bool, pre: Optional[bool], + yanked: bool, ) -> None: """ Show project details. @@ -89,7 +98,7 @@ def info( """ if all_versions: try: - vs = qypi.get_all_requirements(project, yanked=False, prereleases=pre) + vs = qypi.get_all_requirements(project, yanked=yanked, prereleases=pre) except QyPIError as e: raise click.UsageError(str(e)) click.echo( @@ -105,7 +114,7 @@ def info( else: try: v = qypi.get_requirement( - project, most_recent=newest, yanked=False, prereleases=pre + project, most_recent=newest, yanked=yanked, prereleases=pre ) except QyPIError as e: raise click.UsageError(str(e)) @@ -121,9 +130,12 @@ def info( @main.command() @sort_opt @pre_opt +@yanked_opt @click.argument("project") @click.pass_obj -def readme(qypi: QyPI, project: str, newest: bool, pre: Optional[bool]) -> None: +def readme( + qypi: QyPI, project: str, newest: bool, pre: Optional[bool], yanked: bool +) -> None: """ View projects' long descriptions. @@ -134,7 +146,9 @@ def readme(qypi: QyPI, project: str, newest: bool, pre: Optional[bool]) -> None: version or as ``projectname==version`` to show the long description for ``version``. """ - v = qypi.get_requirement(project, most_recent=newest, yanked=False, prereleases=pre) + v = qypi.get_requirement( + project, most_recent=newest, yanked=yanked, prereleases=pre + ) if v.info.description is not None: click.echo_via_pager(v.info.description) else: @@ -172,6 +186,7 @@ def releases(qypi: QyPI, project: str) -> None: @all_opt @sort_opt @pre_opt +@yanked_opt @click.argument("project") @click.pass_obj def files( @@ -181,6 +196,7 @@ def files( all_versions: bool, newest: bool, pre: Optional[bool], + yanked: bool, ) -> None: """ List files available for download. @@ -190,7 +206,7 @@ def files( ``version``. """ if all_versions: - vs = qypi.get_all_requirements(project, yanked=False, prereleases=pre) + vs = qypi.get_all_requirements(project, yanked=yanked, prereleases=pre) click.echo( dumps( { @@ -204,7 +220,7 @@ def files( ) else: v = qypi.get_requirement( - project, most_recent=newest, yanked=False, prereleases=pre + project, most_recent=newest, yanked=yanked, prereleases=pre ) click.echo( dumps( From 9396595ad8df39093fe16755ddd6f7f62df80ba1 Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Mon, 14 Feb 2022 00:07:31 +0000 Subject: [PATCH 09/19] Expand/update .gitignore --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 09ed583..9eaf843 100644 --- a/.gitignore +++ b/.gitignore @@ -2,11 +2,15 @@ *.egg-info/ *.pyc .cache/ -.coverage +.coverage* .eggs/ +.mypy_cache/ +.nox/ +.pytest_cache/ .tox/ __pycache__/ build/ dist/ +docs/.doctrees/ docs/_build/ venv/ From bc126857fe6fe067bce7fe10998fdec8b9fae57b Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Mon, 14 Feb 2022 00:48:32 +0000 Subject: [PATCH 10/19] Enable pydantic mypy plugin --- setup.cfg | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/setup.cfg b/setup.cfg index cf0c234..7b2f20e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -73,3 +73,8 @@ strict_equality = True warn_redundant_casts = True warn_return_any = True warn_unreachable = True +plugins = pydantic.mypy + +[pydantic-mypy] +init_forbid_extra = True +warn_untypes_fields = True From 5851b94c67a40601e783fb2df7973f86199ca33b Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Thu, 30 Jun 2022 21:27:42 +0000 Subject: [PATCH 11/19] Use as much future annotations as possible --- src/qypi/__main__.py | 11 ++++++----- src/qypi/api.py | 26 +++++++++++++------------- test/conftest.py | 5 +++-- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/qypi/__main__.py b/src/qypi/__main__.py index 4c653e9..ee26eb8 100644 --- a/src/qypi/__main__.py +++ b/src/qypi/__main__.py @@ -1,7 +1,8 @@ +from __future__ import annotations from collections import defaultdict from itertools import groupby from operator import attrgetter -from typing import Dict, List, Optional, TextIO, Tuple +from typing import Optional, TextIO import click from packaging.version import parse from . import __version__ @@ -161,7 +162,7 @@ def readme( def releases(qypi: QyPI, project: str) -> None: """List released project versions""" pkg = qypi.get_project(project) - data: List[dict] = [] + data: list[dict] = [] for v in sorted(pkg.versions, key=parse): pv = pkg.get_version(v) data.append( @@ -257,14 +258,14 @@ def listcmd(qypi: QyPI) -> None: ) @click.argument("terms", nargs=-1, required=True) @click.pass_obj -def search(qypi: QyPI, terms: Tuple[str], oper: str, projects: bool) -> None: +def search(qypi: QyPI, terms: tuple[str, ...], oper: str, projects: bool) -> None: """ Search PyPI for projects or releases thereof. Search terms may be specified as either ``field:value`` (e.g., ``summary:Django``) or just ``value`` to search long descriptions. """ - spec: Dict[str, List[str]] = defaultdict(list) + spec: dict[str, list[str]] = defaultdict(list) for t in terms: key, colon, value = t.partition(":") if colon == "": @@ -292,7 +293,7 @@ def search(qypi: QyPI, terms: Tuple[str], oper: str, projects: bool) -> None: @click.argument("classifiers", nargs=-1) @click.pass_obj def browse( - qypi: QyPI, classifiers: Tuple[str, ...], file: Optional[TextIO], projects: bool + qypi: QyPI, classifiers: tuple[str, ...], file: Optional[TextIO], projects: bool ) -> None: """ List projects with given trove classifiers. diff --git a/src/qypi/api.py b/src/qypi/api.py index 8911ab2..dc5c336 100644 --- a/src/qypi/api.py +++ b/src/qypi/api.py @@ -6,7 +6,7 @@ from operator import attrgetter import platform import sys -from typing import Any, Dict, List, Optional, Union, cast +from typing import Any, Dict, List, Optional, cast from xmlrpc.client import ServerProxy from packaging.requirements import Requirement from packaging.specifiers import SpecifierSet @@ -66,7 +66,7 @@ def get_requirement( def get_all_requirements( self, req: str, yanked: bool = False, prereleases: Optional[bool] = None - ) -> List[ProjectVersion]: + ) -> list[ProjectVersion]: reqobj = Requirement(req) ### TODO: Warn if reqobj has non-None marker, extras, or url? project = self.get_project(reqobj.name) @@ -93,29 +93,29 @@ def get_project_version(self, project: str, version: str) -> ProjectVersion: def xmlrpc(self, method: str, *args: Any, **kwargs: Any) -> Any: return getattr(self.xsp, method)(*args, **kwargs) - def list_all_projects(self) -> List[str]: - return cast(List[str], self.xmlrpc("list_packages")) + def list_all_projects(self) -> list[str]: + return cast("list[str]", self.xmlrpc("list_packages")) - def get_project_roles(self, project: str) -> List[ProjectRole]: + def get_project_roles(self, project: str) -> list[ProjectRole]: return [ ProjectRole(role=role, user=user) for role, user in self.xmlrpc("package_roles", project) ] - def get_user_roles(self, user: str) -> List[UserRole]: + def get_user_roles(self, user: str) -> list[UserRole]: return [ UserRole(role=role, project=project) for role, project in self.xmlrpc("user_packages", user) ] def search( - self, spec: Dict[str, Union[str, List[str]]], operator: str = "and" - ) -> List[SearchResult]: + self, spec: dict[str, str | list[str]], operator: str = "and" + ) -> list[SearchResult]: return [ SearchResult.parse_obj(r) for r in self.xmlrpc("search", spec, operator) ] - def browse(self, classifiers: List[str]) -> List[BrowseResult]: + def browse(self, classifiers: list[str]) -> list[BrowseResult]: return [ BrowseResult(name=name, version=version) for name, version in self.xmlrpc("browse", classifiers) @@ -338,7 +338,7 @@ def name(self) -> str: return self.default_version.name @property - def versions(self) -> List[str]: + def versions(self) -> list[str]: return list(self.files.keys()) def get_version(self, version: str) -> ProjectVersion: @@ -352,7 +352,7 @@ def get_version(self, version: str) -> ProjectVersion: def get_version_by_spec( self, - spec: Union[str, SpecifierSet], + spec: str | SpecifierSet, most_recent: bool = False, yanked: bool = False, prereleases: Optional[bool] = None, @@ -376,10 +376,10 @@ def get_version_by_spec( def get_all_versions_by_spec( self, - spec: Union[str, SpecifierSet], + spec: str | SpecifierSet, yanked: bool = False, prereleases: Optional[bool] = None, - ) -> List[ProjectVersion]: + ) -> list[ProjectVersion]: if not isinstance(spec, SpecifierSet): spec = SpecifierSet(spec) vs = list(spec.filter(self.versions, prereleases=prereleases)) diff --git a/test/conftest.py b/test/conftest.py index e929805..5b70b7a 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,8 +1,9 @@ +from __future__ import annotations from collections import OrderedDict +from collections.abc import Iterator import json from pathlib import Path import re -from typing import Dict, Iterator, Tuple from packaging.utils import canonicalize_name import pytest from requests import PreparedRequest @@ -25,7 +26,7 @@ def mock_pypi_json() -> Iterator[responses.RequestsMock]: yield rsps -def mkresponse(r: PreparedRequest) -> Tuple[int, Dict[str, str], str]: +def mkresponse(r: PreparedRequest) -> tuple[int, dict[str, str], str]: assert r.url is not None m = urlre.match(r.url) assert m From 7ebb73ef19b4239c2cb693772c30c692a4763a3b Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Sun, 16 Oct 2022 19:51:50 +0000 Subject: [PATCH 12/19] Boilerplate updates --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 18a2298..b52a97f 100644 --- a/tox.ini +++ b/tox.ini @@ -25,7 +25,7 @@ commands = [testenv:typing] deps = - mypy~=0.900 + mypy types-requests {[testenv]deps} commands = From c12eb2e0e50581b0c5b5da7964d244ff3a5e948e Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Sun, 16 Oct 2022 20:10:59 +0000 Subject: [PATCH 13/19] Fix --- src/qypi/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/qypi/api.py b/src/qypi/api.py index dc5c336..42c3b5c 100644 --- a/src/qypi/api.py +++ b/src/qypi/api.py @@ -32,6 +32,7 @@ def __init__(self, index_url: str = DEFAULT_ENDPOINT) -> None: self.index_url = index_url self.s = requests.Session() self.s.headers["User-Agent"] = USER_AGENT + xsp_kwargs: dict[str, Any] if sys.version_info >= (3, 8): xsp_kwargs = {"headers": [("User-Agent", USER_AGENT)]} else: From 0ded856ac4b3a15fd572f222fe63eaced26d81d5 Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Sat, 10 Dec 2022 18:50:53 +0000 Subject: [PATCH 14/19] Fix typing issue --- src/qypi/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/qypi/api.py b/src/qypi/api.py index 42c3b5c..3d70d72 100644 --- a/src/qypi/api.py +++ b/src/qypi/api.py @@ -360,7 +360,7 @@ def get_version_by_spec( ) -> ProjectVersion: if not isinstance(spec, SpecifierSet): spec = SpecifierSet(spec) - vs = list(spec.filter(self.versions, prereleases=prereleases)) + vs = list(map(str, spec.filter(self.versions, prereleases=prereleases))) if most_recent: vobjs = [self.get_version(v) for v in vs] if not yanked: @@ -383,7 +383,7 @@ def get_all_versions_by_spec( ) -> list[ProjectVersion]: if not isinstance(spec, SpecifierSet): spec = SpecifierSet(spec) - vs = list(spec.filter(self.versions, prereleases=prereleases)) + vs = list(map(str, spec.filter(self.versions, prereleases=prereleases))) vobjs = [self.get_version(v) for v in vs] if not yanked: vobjs = [v for v in vobjs if not v.is_yanked] From 3d42a6d9f107272c00df94077213c7fa33c08847 Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Wed, 27 Sep 2023 15:51:55 -0400 Subject: [PATCH 15/19] Update to Pydantic 2.0 --- setup.cfg | 2 +- src/qypi/__main__.py | 4 +-- src/qypi/api.py | 69 ++++++++++++++++++++++++-------------------- src/qypi/util.py | 16 +--------- tox.ini | 2 ++ 5 files changed, 43 insertions(+), 50 deletions(-) diff --git a/setup.cfg b/setup.cfg index 7b2f20e..05c96c6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -48,7 +48,7 @@ python_requires = >=3.7 install_requires = click >= 8.0 packaging >= 16 - pydantic ~= 1.9 + pydantic ~= 2.0 requests ~= 2.20 [options.packages.find] diff --git a/src/qypi/__main__.py b/src/qypi/__main__.py index ee26eb8..21aecd5 100644 --- a/src/qypi/__main__.py +++ b/src/qypi/__main__.py @@ -279,7 +279,7 @@ def search(qypi: QyPI, terms: tuple[str, ...], oper: str, projects: bool) -> Non max(versions, key=lambda v: parse(v.version)) for _, versions in groupby(results, attrgetter("name")) ] - click.echo(dumps(results)) + click.echo(dumps([r.json_dict() for r in results])) @main.command() @@ -310,7 +310,7 @@ def browse( max(versions, key=lambda v: parse(v.version)) for _, versions in groupby(results, attrgetter("name")) ] - click.echo(dumps(results)) + click.echo(dumps([r.json_dict() for r in results])) @main.command() diff --git a/src/qypi/api.py b/src/qypi/api.py index 3d70d72..765cc5b 100644 --- a/src/qypi/api.py +++ b/src/qypi/api.py @@ -2,7 +2,6 @@ from contextlib import ExitStack from datetime import datetime from enum import Enum -import json from operator import attrgetter import platform import sys @@ -11,7 +10,7 @@ from packaging.requirements import Requirement from packaging.specifiers import SpecifierSet from packaging.version import parse -from pydantic import BaseModel, Field, validator +from pydantic import BaseModel, Field, field_serializer, field_validator import requests from . import __url__, __version__ from .util import show_datetime @@ -113,7 +112,8 @@ def search( self, spec: dict[str, str | list[str]], operator: str = "and" ) -> list[SearchResult]: return [ - SearchResult.parse_obj(r) for r in self.xmlrpc("search", spec, operator) + SearchResult.model_validate(r) + for r in self.xmlrpc("search", spec, operator) ] def browse(self, classifiers: list[str]) -> list[BrowseResult]: @@ -134,7 +134,7 @@ class Role(Enum): class JSONableBase(BaseModel): def json_dict(self, **kwargs: Any) -> dict: - return cast(dict, json.loads(self.json(**kwargs))) + return self.model_dump(mode="json", **kwargs) class ProjectRole(JSONableBase): @@ -149,10 +149,10 @@ class UserRole(JSONableBase): class SearchResult(JSONableBase): name: str - summary: Optional[str] + summary: Optional[str] = None version: str - @validator("summary") + @field_validator("summary") @classmethod def _nullify_summary(cls, v: Optional[str]) -> Optional[str]: if v == "" or v == "UNKNOWN": @@ -173,34 +173,34 @@ class Downloads(JSONableBase): class ProjectInfo(JSONableBase): - author: Optional[str] - author_email: Optional[str] - bugtrack_url: Optional[str] + author: Optional[str] = None + author_email: Optional[str] = None + bugtrack_url: Optional[str] = None classifiers: List[str] - description: Optional[str] - description_content_type: Optional[str] - docs_url: Optional[str] - download_url: Optional[str] + description: Optional[str] = None + description_content_type: Optional[str] = None + docs_url: Optional[str] = None + download_url: Optional[str] = None downloads: Downloads - home_page: Optional[str] - keywords: Optional[str] - license: Optional[str] - maintainer: Optional[str] - maintainer_email: Optional[str] + home_page: Optional[str] = None + keywords: Optional[str] = None + license: Optional[str] = None + maintainer: Optional[str] = None + maintainer_email: Optional[str] = None name: str package_url: str - platform: Optional[str] + platform: Optional[str] = None project_url: str - project_urls: Optional[Dict[str, str]] + project_urls: Optional[Dict[str, str]] = None release_url: str - requires_dist: Optional[List[str]] - requires_python: Optional[str] - summary: Optional[str] + requires_dist: Optional[List[str]] = None + requires_python: Optional[str] = None + summary: Optional[str] = None version: str yanked: bool - yanked_reason: Optional[str] + yanked_reason: Optional[str] = None - @validator( + @field_validator( "author", "author_email", "bugtrack_url", @@ -228,7 +228,7 @@ def _nullify(cls, v: Optional[str]) -> Optional[str]: class ProjectFile(JSONableBase): - comment_text: Optional[str] # TODO: Nullify? + comment_text: Optional[str] = None # TODO: Nullify? digests: Dict[str, str] downloads: int filename: str @@ -236,13 +236,21 @@ class ProjectFile(JSONableBase): md5_digest: str packagetype: str python_version: str - requires_python: Optional[str] + requires_python: Optional[str] = None size: int upload_time: datetime upload_time_iso_8601: datetime url: str yanked: bool - yanked_reason: Optional[str] + yanked_reason: Optional[str] = None + + @field_serializer("upload_time", "upload_time_iso_8601") + def _serialize_dt(self, dt: datetime) -> str: + # For consistency with how `release_date` is serialized for + # `qypi_json_dict()`. Without this custom serializer, Pydantic would + # serialize UTC timestamps with "Z" at the end instead of the "+00:00" + # used by `datetime.isoformat()`. + return dt.isoformat() def json_dict(self, trust_downloads: bool = False, **kwargs: Any) -> dict: if not trust_downloads: @@ -316,7 +324,7 @@ def qypi_json_dict( return info -class Project(JSONableBase): +class Project(JSONableBase, arbitrary_types_allowed=True): client: QyPI = Field(exclude=True) default_version: ProjectVersion files: Dict[str, List[ProjectFile]] @@ -324,9 +332,6 @@ class Project(JSONableBase): default_factory=dict, exclude=True, repr=False ) - class Config: - arbitrary_types_allowed = True - @classmethod def from_response_json(cls, client: QyPI, data: dict) -> Project: default_version = ProjectVersion.from_response_json(data) diff --git a/src/qypi/util.py b/src/qypi/util.py index 8231f82..73926a9 100644 --- a/src/qypi/util.py +++ b/src/qypi/util.py @@ -1,24 +1,10 @@ -from collections.abc import Iterator from datetime import datetime import json from typing import Any, Optional -def json_default(x: Any) -> Any: - from .api import JSONableBase - - if isinstance(x, Iterator): - return list(x) - elif isinstance(x, JSONableBase): - return x.json_dict() - else: - return x - - def dumps(obj: Any) -> str: - return json.dumps( - obj, sort_keys=True, indent=4, ensure_ascii=False, default=json_default - ) + return json.dumps(obj, sort_keys=True, indent=4, ensure_ascii=False) def show_datetime(dt: Optional[datetime]) -> Optional[str]: diff --git a/tox.ini b/tox.ini index b52a97f..4df92e6 100644 --- a/tox.ini +++ b/tox.ini @@ -37,6 +37,8 @@ filterwarnings = error # ignore:can't resolve package from __spec__ or __package__, falling back on __name__ and __path__:ImportWarning + # + ignore:urllib3 v2.0 only supports OpenSSL [coverage:run] branch = True From 91805964c221ee243d94b91a32d0f5d7a2e9cdb3 Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Mon, 9 Oct 2023 16:17:57 -0400 Subject: [PATCH 16/19] Set `ignore_missing_imports = False` --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 05c96c6..9110ae2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -61,7 +61,7 @@ console_scripts = [mypy] allow_incomplete_defs = False allow_untyped_defs = False -ignore_missing_imports = True +ignore_missing_imports = False # : no_implicit_optional = True implicit_reexport = False From 3c5159d9aa09ecd1a9904ea89006d4d1327bffa3 Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Tue, 17 Oct 2023 15:25:16 -0400 Subject: [PATCH 17/19] Properly fill in __exit__ args --- src/qypi/api.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/qypi/api.py b/src/qypi/api.py index 765cc5b..0372489 100644 --- a/src/qypi/api.py +++ b/src/qypi/api.py @@ -5,6 +5,7 @@ from operator import attrgetter import platform import sys +from types import TracebackType from typing import Any, Dict, List, Optional, cast from xmlrpc.client import ServerProxy from packaging.requirements import Requirement @@ -44,7 +45,12 @@ def __enter__(self) -> QyPI: self.ctx_stack.enter_context(self.xsp) return self - def __exit__(self, *_exc: Any) -> None: + def __exit__( + self, + _exc_type: type[BaseException] | None, + _exc_val: BaseException | None, + _exc_tb: TracebackType | None, + ) -> None: self.ctx_stack.close() def get_requirement( From 82459245a6d2136cb0fb3a395c9c9866f568db9f Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Sat, 4 Nov 2023 18:55:35 +0000 Subject: [PATCH 18/19] Mark breaking changes in the CHANGELOG --- CHANGELOG.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dfdbdd..e758441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,15 +6,16 @@ v1.0.0 (in development) there may be some errors interacting with non-Warehouse project indices that do not define all of the same fields as Warehouse. [Please report any instances of the latter.](https://github.com/jwodder/qypi/issues) -- The `info`, `readme`, `releases`, `files`, `owner`, and `owned` commands now - take exactly one positional argument, and the output formats of many of these - commands have been simplified for this case -- The `readme` command no longer takes an `--all-versions`/`-A` option +- **Breaking**: The `info`, `readme`, `releases`, `files`, `owner`, and `owned` + commands now take exactly one positional argument, and the output formats of + many of these commands have been simplified for this case +- **Breaking**: The `readme` command no longer takes an `--all-versions`/`-A` + option - The output from the `releases` command now includes an `is_yanked` field -- The `--packages` option to the `search` and `browse` commands is now named - `--projects` -- The output from the `owned` command has changed to use the more accurate - "project" instead of "package". +- **Breaking**: The `--packages` option to the `search` and `browse` commands + is now named `--projects` +- **Breaking**: The output from the `owned` command has changed to use the more + accurate "project" instead of "package". - Output timestamps now use `+00:00` as the timezone offset instead of `Z` - Honor yanking (PEP 592) - Yanked versions are no longer shown by default From 089b7c62b429572008f3d28cce61c42db5b9231d Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Sat, 4 Nov 2023 19:31:49 +0000 Subject: [PATCH 19/19] Add some missing items to the changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e758441..774c562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ v1.0.0 (in development) - The `info`, `readme`, and `files` commands now have `--yanked`/`--no-yanked` options for controlling whether to display yanked versions +- Package arguments to `info`, `files`, and `readme` can now be requirements + specifiers that are used to filter the available versions +- Public library API added - Support Python 3.10, 3.11, and 3.12 - Drop support for Python 3.6