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/.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/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 345f2d5..774c562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,32 @@ -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) +- **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 +- **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 + - 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 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/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 diff --git a/setup.cfg b/setup.cfg index f82997e..9110ae2 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 @@ -35,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 @@ -44,10 +44,11 @@ project_urls = packages = find: package_dir = =src -python_requires = >=3.6 +python_requires = >=3.7 install_requires = - click ~= 8.0 + click >= 8.0 packaging >= 16 + pydantic ~= 2.0 requests ~= 2.20 [options.packages.find] @@ -56,3 +57,24 @@ where = src [options.entry_points] console_scripts = qypi = qypi.__main__:qypi + +[mypy] +allow_incomplete_defs = False +allow_untyped_defs = False +ignore_missing_imports = False +# : +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 +plugins = pydantic.mypy + +[pydantic-mypy] +init_forbid_extra = True +warn_untypes_fields = True 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 45e7bb6..21aecd5 100644 --- a/src/qypi/__main__.py +++ b/src/qypi/__main__.py @@ -1,18 +1,13 @@ +from __future__ import annotations +from collections import defaultdict +from itertools import groupby +from operator import attrgetter +from typing import Optional, 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 SEARCH_SYNONYMS = { "homepage": "home_page", @@ -22,30 +17,52 @@ "keyword": "keywords", } +pre_opt = click.option( + "--pre/--no-pre", + default=None, + help="Show prerelease versions", + 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", + 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: click.Context, index_url: str) -> None: """Query PyPI from the command line""" - ctx.obj = QyPI(index_url) - - -@qypi.result_callback() -@click.pass_context -def cleanup(ctx, *_args, **_kwargs): - ctx.obj.cleanup(ctx) + ctx.obj = ctx.with_resource(QyPI(index_url)) -@qypi.command() +@main.command() @click.option( "--description/--no-description", default=False, @@ -54,130 +71,177 @@ 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 +@yanked_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], + yanked: 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_requirements(project, yanked=yanked, 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=yanked, 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 +@yanked_opt +@click.argument("project") +@click.pass_obj +def readme( + qypi: QyPI, project: str, newest: bool, pre: Optional[bool], yanked: 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=yanked, prereleases=pre + ) + if v.info.description is not None: + click.echo_via_pager(v.info.description) + else: + click.echo_via_pager("--- no 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 +@yanked_opt +@click.argument("project") +@click.pass_obj +def files( + qypi: QyPI, + project: str, + trust_downloads: bool, + all_versions: bool, + newest: bool, + pre: Optional[bool], + yanked: 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_requirements(project, yanked=yanked, 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=yanked, 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.xmlrpc("list_packages"): +def listcmd(qypi: QyPI) -> None: + """List all projects on PyPI""" + for pkg in qypi.list_all_projects(): click.echo(pkg) -@qypi.command() +@main.command() @click.option( "--and", "oper", @@ -188,47 +252,51 @@ 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: tuple[str, ...], oper: str, projects: bool) -> None: """ - 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. """ - 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 = map(clean_pypi_dict, obj.xmlrpc("search", spec, oper)) - if packages: - results = squish_versions(results) - click.echo(dumps(results)) + spec[key].append(value) + results = qypi.search(dict(spec), oper) + if projects: + results = [ + max(versions, key=lambda v: parse(v.version)) + for _, versions in groupby(results, attrgetter("name")) + ] + click.echo(dumps([r.json_dict() for r in 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: tuple[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,46 +304,30 @@ 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) - ] - if packages: - results = squish_versions(results) - click.echo(dumps(results)) + results = qypi.browse(list(classifiers)) + if projects: + results = [ + max(versions, key=lambda v: parse(v.version)) + for _, versions in groupby(results, attrgetter("name")) + ] + click.echo(dumps([r.json_dict() for r in 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, - [ - {"role": role, "user": user} - for role, user in obj.xmlrpc("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, - [ - {"role": role, "package": pkg} - for role, pkg in obj.xmlrpc("user_packages", 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 4b04f38..0372489 100644 --- a/src/qypi/api.py +++ b/src/qypi/api.py @@ -1,9 +1,22 @@ +from __future__ import annotations +from contextlib import ExitStack +from datetime import datetime +from enum import Enum +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 -import click +from packaging.requirements import Requirement +from packaging.specifiers import SpecifierSet from packaging.version import parse +from pydantic import BaseModel, Field, field_serializer, 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__, @@ -15,108 +28,374 @@ class QyPI: - def __init__(self, index_url): + def __init__(self, index_url: str = DEFAULT_ENDPOINT) -> None: self.index_url = index_url - self.s = None - self.xsp = None - self.pre = False - self.newest = False - self.all_versions = False - self.errmsgs = [] - - 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): - r = self.get(package, "json") - # Unlike the XML-RPC API, the JSON API accepts package names regardless - # of normalization + 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: + xsp_kwargs = {} + self.xsp = ServerProxy(self.index_url, **xsp_kwargs) # type: ignore[arg-type] + 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_type: type[BaseException] | None, + _exc_val: BaseException | None, + _exc_tb: TracebackType | None, + ) -> None: + self.ctx_stack.close() + + 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 + ) -> list[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): - 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_version(package, latest) + return Project.from_response_json(client=self, data=r.json()) - def get_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): - if self.xsp is None: - self.xsp = ServerProxy(self.index_url) + def xmlrpc(self, method: str, *args: Any, **kwargs: Any) -> Any: return getattr(self.xsp, method)(*args, **kwargs) - def lookup_package(self, args): - 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_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_version(name, v) - else: - yield self.get_latest_version(name) - except QyPIError as e: - self.errmsgs.append(str(e)) - - def cleanup(self, ctx): - if self.errmsgs: - for msg in self.errmsgs: - click.echo(ctx.command_path + ": " + msg, err=True) - ctx.exit(1) + def list_all_projects(self) -> list[str]: + return cast("list[str]", self.xmlrpc("list_packages")) + + 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]: + return [ + UserRole(role=role, project=project) + for role, project in self.xmlrpc("user_packages", user) + ] + + def search( + self, spec: dict[str, str | list[str]], operator: str = "and" + ) -> list[SearchResult]: + return [ + SearchResult.model_validate(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) + ] 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 self.model_dump(mode="json", **kwargs) + + +class ProjectRole(JSONableBase): + user: str + role: Role + + +class UserRole(JSONableBase): + project: str + role: Role + + +class SearchResult(JSONableBase): + name: str + summary: Optional[str] = None + version: str + + @field_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] = None + author_email: Optional[str] = None + bugtrack_url: Optional[str] = None + classifiers: List[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] = 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] = None + project_url: str + project_urls: Optional[Dict[str, str]] = None + release_url: str + requires_dist: Optional[List[str]] = None + requires_python: Optional[str] = None + summary: Optional[str] = None + version: str + yanked: bool + yanked_reason: Optional[str] = None + + @field_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] = None # 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] = None + size: int + upload_time: datetime + upload_time_iso_8601: datetime + url: str + yanked: bool + 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: + 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, arbitrary_types_allowed=True): + 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 + ) + + @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: str | SpecifierSet, + most_recent: bool = False, + yanked: bool = False, + prereleases: Optional[bool] = None, + ) -> ProjectVersion: + if not isinstance(spec, SpecifierSet): + spec = SpecifierSet(spec) + 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: + 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: str | SpecifierSet, + yanked: bool = False, + prereleases: Optional[bool] = None, + ) -> list[ProjectVersion]: + if not isinstance(spec, SpecifierSet): + spec = SpecifierSet(spec) + 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] + return vobjs diff --git a/src/qypi/py.typed b/src/qypi/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/qypi/util.py b/src/qypi/util.py index d9f6d1b..73926a9 100644 --- a/src/qypi/util.py +++ b/src/qypi/util.py @@ -1,134 +1,14 @@ -from collections.abc import Iterator -from itertools import groupby +from datetime import datetime import json -from operator import itemgetter -from textwrap import indent -import click -from packaging.version import parse +from typing import Any, Optional -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 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) -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 `dict`s containing (at least) ``"name"`` and ``"version"`` - fields, return for each name the `dict` 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"])) - - -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/conftest.py b/test/conftest.py index 0155934..5b70b7a 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,9 +1,12 @@ +from __future__ import annotations from collections import OrderedDict +from collections.abc import Iterator import json from pathlib import Path import re from packaging.utils import canonicalize_name import pytest +from requests import PreparedRequest import responses DATA_DIR = Path(__file__).with_name("data") @@ -12,7 +15,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 +26,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/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 8bdf166..60968ef 100644 --- a/test/test_main.py +++ b/test/test_main.py @@ -1,19 +1,24 @@ import json +import sys from traceback import format_exception -from click.testing import CliRunner +import click +from click.testing import CliRunner, Result import pytest -from qypi.__main__ import qypi +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): - spinstance = mocker.Mock( +def test_list(mocker: MockerFixture) -> None: + spinstance = mocker.MagicMock( **{ "list_packages.return_value": [ "foobar", @@ -25,17 +30,20 @@ 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" - ) - spclass.assert_called_once_with("https://pypi.org/pypi") + 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)] + ) + 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( +def test_owner(mocker: MockerFixture) -> None: + spinstance = mocker.MagicMock( **{ "package_roles.return_value": [ ["Owner", "luser"], @@ -44,77 +52,31 @@ 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" ) - 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( - **{ - "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" - ) - 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.Mock( +def test_owned(mocker: MockerFixture) -> None: + spinstance = mocker.MagicMock( **{ "user_packages.return_value": [ ["Owner", "foobar"], @@ -123,77 +85,31 @@ 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" ) - 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( - **{ - "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" - ) - 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.Mock( +def test_search(mocker: MockerFixture) -> None: + spinstance = mocker.MagicMock( **{ "search.return_value": [ { @@ -218,7 +134,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" @@ -239,7 +155,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"]}, @@ -248,8 +169,8 @@ def test_search(mocker): ] -def test_browse(mocker): - spinstance = mocker.Mock( +def test_browse(mocker: MockerFixture) -> None: + spinstance = mocker.MagicMock( **{ "browse.return_value": [ ["foobar", "1.2.3"], @@ -263,7 +184,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) @@ -295,14 +216,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")) + mocker.call.browse(["Typing :: Typed", "Topic :: Utilities"]) ] -def test_browse_packages(mocker): - spinstance = mocker.Mock( +def test_browse_packages(mocker: MockerFixture) -> None: + spinstance = mocker.MagicMock( **{ "browse.return_value": [ ["foobar", "1.2.3"], @@ -316,8 +242,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 == ( @@ -336,398 +262,252 @@ 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")) + mocker.call.browse(["Typing :: Typed", "Topic :: Utilities"]) ] @pytest.mark.usefixtures("mock_pypi_json") -def test_info(): - r = CliRunner().invoke(qypi, ["info", "foobar"]) +def test_info() -> None: + 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" - ) - - -@pytest.mark.usefixtures("mock_pypi_json") -def test_info_explicit_latest_version(): - r = CliRunner().invoke(qypi, ["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"]) +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 == ( - "[\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": "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_description(): - r = CliRunner().invoke(qypi, ["info", "--description", "foobar"]) +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 == ( - "[\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" + "{\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_multiple_info(): - r = CliRunner().invoke(qypi, ["info", "has-prerel", "foobar"]) +def test_info_description() -> None: + 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" - ) - - -@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" + "{\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" ) - assert r.stderr == "qypi: does-not-exist: package not found\n" - - -@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.parametrize("arg", ["does-not-exist", "does-not-exist==2.23.42"]) +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 == "" + 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_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" +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 == "" + 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"]) +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) - 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"]) +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) - 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"]) +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) - 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"]) +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) - 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"]) +def test_info_nullfields() -> None: + 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"]) +def test_readme() -> None: + r = CliRunner().invoke(main, ["readme", "foobar"]) assert r.exit_code == 0, show_result(r) assert r.output == ( "foobar v1.0.0\n" @@ -743,8 +523,8 @@ def test_readme(): @pytest.mark.usefixtures("mock_pypi_json") -def test_readme_explicit_version(): - r = CliRunner().invoke(qypi, ["readme", "foobar==0.2.0"]) +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 == ( "foobar v0.2.0\n" @@ -760,98 +540,89 @@ def test_readme_explicit_version(): @pytest.mark.usefixtures("mock_pypi_json") -def test_files(): - r = CliRunner().invoke(qypi, ["files", "foobar"]) +def test_files() -> None: + 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" ) @pytest.mark.usefixtures("mock_pypi_json") -def test_files_explicit_version(): - r = CliRunner().invoke(qypi, ["files", "foobar==0.2.0"]) +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 == ( "[\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" ) @pytest.mark.usefixtures("mock_pypi_json") -def test_releases(): - r = CliRunner().invoke(qypi, ["releases", "foobar"]) +def test_releases() -> None: + 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..4df92e6 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = lint,py36,py37,py38,py39,py310,py311,py312,pypy3 +envlist = lint,typing,py37,py38,py39,py310,py311,py312,pypy3 skip_missing_interpreters = True isolated_build = True minversion = 3.3.0 @@ -23,12 +23,22 @@ deps = commands = flake8 src test +[testenv:typing] +deps = + mypy + types-requests + {[testenv]deps} +commands = + mypy src test + [pytest] addopts = --cov=qypi --no-cov-on-fail 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