Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-unearth for openSUSE:Factory checked in at 2023-06-16 16:55:01 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-unearth (Old) and /work/SRC/openSUSE:Factory/.python-unearth.new.15902 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-unearth" Fri Jun 16 16:55:01 2023 rev:4 rq:1093373 version:0.9.1 Changes: -------- --- /work/SRC/openSUSE:Factory/python-unearth/python-unearth.changes 2022-12-16 17:52:09.324184002 +0100 +++ /work/SRC/openSUSE:Factory/.python-unearth.new.15902/python-unearth.changes 2023-06-16 16:56:11.890186688 +0200 @@ -1,0 +2,18 @@ +Thu Jun 15 12:46:34 UTC 2023 - Andreas Schneider <a...@cryptomilk.org> + +- Update to version 0.9.1 + * cli: Exposing requires-python, platform and abi interface for cli tools. - + by @frostming in #52 (b9935) + * Evaluation issue when the requirement has no version specifier Close #50 - + by @frostming in #50 (0a813) + * Typo on json response field - by @frostming (3e767) + * Update pdm.lock - by @github-actions[bot] and frostming in #44 (e4572) + * Update pdm.lock - by @github-actions[bot] in #45 (a98b9) + * Update pdm.lock - by @github-actions[bot] in #47 (e0ea7) + * Update pdm.lock - by @github-actions[bot] and frostming in #49 (fc994) + * Allow to order the index urls and find links together for PackageFinder - + by @frostming in #43 (dbe85) + * Allow to set prefer_binary for individual packages. +- Use sle15_python_module_pythons + +------------------------------------------------------------------- Old: ---- unearth-0.7.0.tar.gz New: ---- unearth-0.9.1.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-unearth.spec ++++++ --- /var/tmp/diff_new_pack.6t6G93/_old 2023-06-16 16:56:12.458190039 +0200 +++ /var/tmp/diff_new_pack.6t6G93/_new 2023-06-16 16:56:12.466190086 +0200 @@ -1,7 +1,7 @@ # # spec file for package python-unearth # -# Copyright (c) 2022 SUSE LLC +# Copyright (c) 2023 SUSE LLC # # All modifications and additions to the file contributed by third parties # remain the property of their copyright owners, unless otherwise agreed @@ -16,8 +16,9 @@ # +%{?sle15_python_module_pythons} Name: python-unearth -Version: 0.7.0 +Version: 0.9.1 Release: 0 Summary: A utility to fetch and download python packages License: MIT ++++++ unearth-0.7.0.tar.gz -> unearth-0.9.1.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.7.0/PKG-INFO new/unearth-0.9.1/PKG-INFO --- old/unearth-0.7.0/PKG-INFO 2022-12-12 06:10:18.483614400 +0100 +++ new/unearth-0.9.1/PKG-INFO 1970-01-01 01:00:00.000000000 +0100 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: unearth -Version: 0.7.0 +Version: 0.9.1 Summary: A utility to fetch and download python packages License: MIT Author-email: Frost Ming <m...@frostming.com> @@ -65,7 +65,7 @@ ```python >>> from unearth import PackageFinder ->>> finder = PackageFinder(index_urls=['https://pypi.org/simple/']) +>>> finder = PackageFinder(index_urls=["https://pypi.org/simple/"]) >>> result = finder.find_best_match("flask>=2") >>> result.best_candidate Package(name='flask', version='2.1.2', link=<Link https://files.pythonhosted.org/packages/ba/76/e9580e494eaf6f09710b0f3b9000c9c0363e44af5390be32bb0394165853/Flask-2.1.2-py3-none-any.whl#sha256=fad5b446feb0d6db6aec0c3184d16a8c1f6c3e464b511649c8918a9be100b4fe (from https://pypi.org/simple/flask)>) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.7.0/README.md new/unearth-0.9.1/README.md --- old/unearth-0.7.0/README.md 2022-12-12 06:10:02.951712000 +0100 +++ new/unearth-0.9.1/README.md 2023-05-15 09:34:42.539511400 +0200 @@ -43,7 +43,7 @@ ```python >>> from unearth import PackageFinder ->>> finder = PackageFinder(index_urls=['https://pypi.org/simple/']) +>>> finder = PackageFinder(index_urls=["https://pypi.org/simple/"]) >>> result = finder.find_best_match("flask>=2") >>> result.best_candidate Package(name='flask', version='2.1.2', link=<Link https://files.pythonhosted.org/packages/ba/76/e9580e494eaf6f09710b0f3b9000c9c0363e44af5390be32bb0394165853/Flask-2.1.2-py3-none-any.whl#sha256=fad5b446feb0d6db6aec0c3184d16a8c1f6c3e464b511649c8918a9be100b4fe (from https://pypi.org/simple/flask)>) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.7.0/pyproject.toml new/unearth-0.9.1/pyproject.toml --- old/unearth-0.7.0/pyproject.toml 2022-12-12 06:10:02.955712000 +0100 +++ new/unearth-0.9.1/pyproject.toml 2023-05-15 09:34:42.539511400 +0200 @@ -29,7 +29,7 @@ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3 :: Only", ] -version = "0.7.0" +version = "0.9.1" [project.license] text = "MIT" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.7.0/src/unearth/__init__.py new/unearth-0.9.1/src/unearth/__init__.py --- old/unearth-0.7.0/src/unearth/__init__.py 2022-12-12 06:10:02.955712000 +0100 +++ new/unearth-0.9.1/src/unearth/__init__.py 2023-05-15 09:34:42.539511400 +0200 @@ -7,12 +7,13 @@ """ from unearth.errors import HashMismatchError, UnpackError, URLError, VCSBackendError from unearth.evaluator import Package, TargetPython -from unearth.finder import BestMatch, PackageFinder +from unearth.finder import BestMatch, PackageFinder, Source from unearth.link import Link from unearth.vcs import vcs_support __all__ = [ "Link", + "Source", "Package", "URLError", "BestMatch", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.7.0/src/unearth/__main__.py new/unearth-0.9.1/src/unearth/__main__.py --- old/unearth-0.7.0/src/unearth/__main__.py 2022-12-12 06:10:02.955712000 +0100 +++ new/unearth-0.9.1/src/unearth/__main__.py 2023-05-15 09:34:42.539511400 +0200 @@ -8,10 +8,10 @@ import sys import tempfile from dataclasses import dataclass -from typing import cast from packaging.requirements import Requirement +from unearth.evaluator import TargetPython from unearth.finder import PackageFinder from unearth.link import Link from unearth.utils import splitext @@ -24,12 +24,16 @@ index_urls: list[str] find_links: list[str] trusted_hosts: list[str] - no_binary: list[str] - only_binary: list[str] + no_binary: bool + only_binary: bool prefer_binary: bool all: bool link_only: bool download: str | None + py_ver: tuple[int, ...] | None + abis: list[str] | None + impl: str | None + platforms: list[str] | None def _setup_logger(verbosity: bool) -> None: @@ -42,6 +46,14 @@ logger.addHandler(handler) +def comma_split(arg: str) -> list[str]: + return arg.split(",") + + +def to_py_ver(arg: str) -> tuple[int, ...]: + return tuple(int(i) for i in arg.split(".") if i.isdigit()) + + def cli_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Find and download packages from a PEP 508 requirement string.", @@ -57,8 +69,8 @@ parser.add_argument( "--index-url", "-i", - dest="index_urls", metavar="URL", + dest="index_urls", action="append", help="(Multiple)(PEP 503)Simple Index URLs.", ) @@ -79,17 +91,13 @@ ) parser.add_argument( "--no-binary", - action="append", - metavar="PACKAGE", - help="(Multiple)Specify package names to exclude binary results, " - "or `:all:` to exclude all binary results.", + action="store_true", + help="Exclude binary packages from the results.", ) parser.add_argument( "--only-binary", - action="append", - metavar="PACKAGE", - help="(Multiple)Specify package names to only allow binary results, " - "or `:all:` to enforce binary results for all packages.", + action="store_true", + help="Only include binary packages in the results.", ) parser.add_argument( "--prefer-binary", @@ -113,10 +121,34 @@ metavar="DIR", help="Download the package(s) to DIR.", ) + group = parser.add_argument_group("Target Python options") + group.add_argument( + "--python-version", + "--py", + dest="py_ver", + type=to_py_ver, + help="Target Python version. e.g. 3.11.0", + ) + group.add_argument( + "--abis", type=comma_split, help="Comma-separated list of ABIs. e.g. cp39,cp310" + ) + group.add_argument( + "--implementation", + "--impl", + dest="impl", + help="Python implementation. e.g. cp,pp,jy,ip", + ) + group.add_argument( + "--platforms", + type=comma_split, + help="Comma-separated list of platforms. e.g. win_amd64,linux_x86_64", + ) return parser def get_dest_for_package(dest: str, link: Link) -> str: + if link.is_wheel: + return dest filename = link.filename.rsplit("@", 1)[0] fn, _ = splitext(filename) return os.path.join(dest, fn) @@ -124,18 +156,21 @@ def cli(argv: list[str] | None = None) -> None: parser = cli_parser() - args = cast(CLIArgs, parser.parse_args(argv)) + args = CLIArgs(**vars(parser.parse_args(argv))) _setup_logger(args.verbose) + name = args.requirement.name + target_python = TargetPython(args.py_ver, args.abis, args.impl, args.platforms) finder = PackageFinder( - index_urls=args.index_urls or ["https://pypi.org/simple"], + index_urls=args.index_urls or ["https://pypi.org/simple/"], find_links=args.find_links or [], trusted_hosts=args.trusted_hosts or [], - no_binary=args.no_binary or [], - only_binary=args.only_binary or [], - prefer_binary=args.prefer_binary, + target_python=target_python, + no_binary=[name] if args.no_binary else [], + only_binary=[name] if args.only_binary else [], + prefer_binary=[name] if args.prefer_binary else [], verbosity=int(args.verbose), ) - matches = finder.find_matches(args.requirement) + matches = list(finder.find_matches(args.requirement)) if not matches: print("No matches are found.", file=sys.stderr) sys.exit(1) @@ -143,15 +178,13 @@ matches = matches[:1] result = [] - with tempfile.TemporaryDirectory("unearth-download-") as tempdir: + if args.download: + os.makedirs(args.download, exist_ok=True) + with tempfile.TemporaryDirectory("unearth-download-") as download_dir: for match in matches: data = match.as_json() if args.download is not None: - download_dir, dest = tempdir, get_dest_for_package( - args.download, match.link - ) - if match.link.is_wheel: - download_dir = args.download + dest = get_dest_for_package(args.download, match.link) data["local_path"] = finder.download_and_unpack( match.link, dest, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.7.0/src/unearth/auth.py new/unearth-0.9.1/src/unearth/auth.py --- old/unearth-0.7.0/src/unearth/auth.py 2022-12-12 06:10:02.955712000 +0100 +++ new/unearth-0.9.1/src/unearth/auth.py 2023-05-15 09:34:42.539511400 +0200 @@ -1,7 +1,11 @@ from __future__ import annotations +import abc import getpass import logging +import os +import shutil +import subprocess from typing import Any, Iterable, Optional, Tuple, cast from urllib.parse import urlparse @@ -12,48 +16,134 @@ from unearth.utils import split_auth_from_netloc, split_auth_from_url -try: - import keyring # type: ignore -except ModuleNotFoundError: - keyring = None # type: ignore[assignment] +KEYRING_DISABLED = False AuthInfo = Tuple[str, str] MaybeAuth = Optional[Tuple[str, Optional[str]]] logger = logging.getLogger(__name__) -def get_keyring_auth(url: str | None, username: str | None) -> AuthInfo | None: - """Return the tuple auth for a given url from keyring.""" - global keyring - if not url or not keyring: - return None +class KeyringBaseProvider(metaclass=abc.ABCMeta): + """Base class for keyring providers.""" - try: - try: - get_credential = keyring.get_credential - except AttributeError: - pass - else: - logger.debug("Getting credentials from keyring for %s", url) - cred = get_credential(url, username) + @abc.abstractmethod + def get_auth_info(self, url: str, username: str | None) -> AuthInfo | None: + """Return the password for the given url and username. + The username can be None. + """ + ... + + @abc.abstractmethod + def save_auth_info(self, url: str, username: str, password: str) -> None: + """Set the password for the given url and username.""" + ... + + +class KeyringModuleProvider(KeyringBaseProvider): + """Keyring provider that uses the keyring module.""" + + def __init__(self) -> None: + import keyring # type: ignore + + self.keyring = keyring + + def get_auth_info(self, url: str, username: str | None) -> AuthInfo | None: + if hasattr(self.keyring, "get_credential"): + logger.debug("Getting credentials from keyring for url: %s", url) + cred = self.keyring.get_credential(url, username) if cred is not None: return cred.username, cred.password - return None - if not username: + if username is None: username = "__token__" - logger.debug("Getting password from keyring for %s@%s", username, url) - password = keyring.get_password(url, username) + logger.debug("Getting password from keyring for: %s@%s", username, url) + password = self.keyring.get_password(url, username) if password: return username, password + return None + + def save_auth_info(self, url: str, username: str, password: str) -> None: + self.keyring.set_password(url, username, password) + + +class KeyringCliProvider(KeyringBaseProvider): + def __init__(self, cmd: str) -> None: + self.keyring = cmd + + def get_auth_info(self, url: str, username: str | None) -> AuthInfo | None: + if username is not None: + logger.debug("Getting password from keyring CLI for %s@%s", username, url) + password = self._get_password(url, username) + if password is not None: + return username, password + return None + + def save_auth_info(self, url: str, username: str, password: str) -> None: + return self._set_password(url, username, password) + + def _get_password(self, service_name: str, username: str) -> str | None: + """Mirror the implementation of keyring.get_password using cli""" + cmd = [self.keyring, "get", service_name, username] + env = dict(os.environ, PYTHONIOENCODING="utf-8") + res = subprocess.run( + cmd, stdin=subprocess.DEVNULL, capture_output=True, env=env + ) + if res.returncode: + return None + return res.stdout.decode("utf-8").strip(os.linesep) + def _set_password(self, service_name: str, username: str, password: str) -> None: + """Mirror the implementation of keyring.set_password using cli""" + if self.keyring is None: + return None + + cmd = [self.keyring, "set", service_name, username] + input_ = (password + os.linesep).encode("utf-8") + env = dict(os.environ, PYTHONIOENCODING="utf-8") + subprocess.run(cmd, input=input_, env=env, check=True) + return None + + +def get_keyring_provider() -> KeyringBaseProvider | None: + """Return the keyring provider to use.""" + if KEYRING_DISABLED: + return None + + try: + return KeyringModuleProvider() + except ImportError: + pass + except Exception as exc: + logger.warning( + "Importing keyring failed: %s, trying to find a keyring executable.", + exc, + ) + + keyring = shutil.which("keyring") + if keyring is not None: + return KeyringCliProvider(keyring) + + return None + + +def get_keyring_auth(url: str | None, username: str | None) -> AuthInfo | None: + """Return the tuple auth for a given url from keyring.""" + if not url: + return None + + keyring = get_keyring_provider() + if keyring is None: + return None + try: + return keyring.get_auth_info(url, username) except Exception as exc: logger.warning( "Keyring is skipped due to an exception: %s", str(exc), ) - keyring = None # type: ignore[assignment] - return None + global KEYRING_DISABLED + KEYRING_DISABLED = True + return None class MultiDomainBasicAuth(AuthBase): @@ -190,7 +280,7 @@ # Factored out to allow for easy patching in tests def _should_save_password_to_keyring(self) -> bool: - if not keyring: + if get_keyring_provider() is None: return False return input("Save credentials to keyring [y/N]: ") == "y" @@ -260,15 +350,14 @@ def save_credentials(self, resp: Response, **kwargs: Any) -> None: """Response callback to save credentials on success.""" + keyring = get_keyring_provider() assert keyring is not None, "should never reach here without keyring" - if not keyring: - return creds = self._credentials_to_save self._credentials_to_save = None if creds and resp.status_code < 400: try: logger.info("Saving credentials to keyring") - keyring.set_password(*creds) + keyring.save_auth_info(*creds) except Exception: logger.exception("Failed to save credentials") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.7.0/src/unearth/collector.py new/unearth-0.9.1/src/unearth/collector.py --- old/unearth-0.7.0/src/unearth/collector.py 2022-12-12 06:10:02.955712000 +0100 +++ new/unearth-0.9.1/src/unearth/collector.py 2023-05-15 09:34:42.539511400 +0200 @@ -90,7 +90,7 @@ requires_python: str | None = file.get("requires-python") yank_reason: str | None = file.get("yanked") or None dist_info_metadata: bool | dict[str, str] | None = file.get( - "dist-info-metadata" + "data-dist-info-metadata" ) hashes: dict[str, str] | None = file.get("hashes") yield Link( diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.7.0/src/unearth/evaluator.py new/unearth-0.9.1/src/unearth/evaluator.py --- old/unearth-0.7.0/src/unearth/evaluator.py 2022-12-12 06:10:02.955712000 +0100 +++ new/unearth-0.9.1/src/unearth/evaluator.py 2023-05-15 09:34:42.539511400 +0200 @@ -9,7 +9,7 @@ from urllib.parse import urlencode import packaging.requirements -from packaging.specifiers import SpecifierSet +from packaging.specifiers import InvalidSpecifier, SpecifierSet from packaging.tags import Tag from packaging.utils import ( InvalidWheelFilename, @@ -150,7 +150,13 @@ if not self.ignore_compatibility and link.requires_python: py_ver = self.target_python.py_ver or sys.version_info[:2] py_version = ".".join(str(v) for v in py_ver) - if not SpecifierSet(link.requires_python).contains(py_version, True): + try: + requires_python = SpecifierSet(link.requires_python) + except InvalidSpecifier: + raise LinkMismatchError( + f"Invalid requires-python: {link.requires_python}" + ) + if not requires_python.contains(py_version, True): raise LinkMismatchError( "The target python version({}) doesn't match " "the requires-python specifier {}".format( @@ -249,7 +255,7 @@ ) self._check_hashes(link) except LinkMismatchError as e: - logger.debug("Skip link %s: %s", link, e) + logger.debug("Skipping link %s: %s", link, e) return None return Package(name=self.package_name, version=version, link=link) @@ -272,18 +278,17 @@ if requirement.name: if canonicalize_name(package.name) != canonicalize_name(requirement.name): logger.debug( - "Skip package %s: name doesn't match %s", package, requirement.name + "Skipping package %s: name doesn't match %s", package, requirement.name ) return False - if requirement.specifier and package.version: - if not requirement.specifier.contains( - package.version, prereleases=allow_prereleases - ): - logger.debug( - "Skip package %s: version doesn't match %s", - package, - requirement.specifier, - ) - return False + if package.version and not requirement.specifier.contains( + package.version, prereleases=allow_prereleases + ): + logger.debug( + "Skipping package %s: version doesn't match %s", + package, + requirement.specifier, + ) + return False return True diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.7.0/src/unearth/finder.py new/unearth-0.9.1/src/unearth/finder.py --- old/unearth-0.7.0/src/unearth/finder.py 2022-12-12 06:10:02.955712000 +0100 +++ new/unearth-0.9.1/src/unearth/finder.py 2023-05-15 09:34:42.539511400 +0200 @@ -3,10 +3,11 @@ import atexit import functools +import itertools import os import pathlib from tempfile import TemporaryDirectory -from typing import Iterable, NamedTuple +from typing import TYPE_CHECKING, Iterable, NamedTuple, Sequence from urllib.parse import urljoin import packaging.requirements @@ -25,7 +26,17 @@ from unearth.link import Link from unearth.preparer import unpack_link from unearth.session import PyPISession -from unearth.utils import split_auth_from_url +from unearth.utils import LazySequence + +if TYPE_CHECKING: + from typing import TypedDict + + class Source(TypedDict): + url: str + type: str + +else: + Source = dict class BestMatch(NamedTuple): @@ -34,9 +45,9 @@ #: The best matching package, or None if no match was found. best: Package | None #: The applicable packages, excluding those with unmatching versions. - applicable: list[Package] + applicable: Sequence[Package] #: All candidates found for the requirement. - candidates: list[Package] + candidates: Sequence[Package] class PackageFinder: @@ -45,16 +56,16 @@ Args: session (PyPISession|None): The session to use for the finder. If not provided, a temporary session will be created. - index_urls: (Iterable[str]): The urls of the index pages. - find_links: (Iterable[str]): The urls or paths of the find links. + index_urls (Iterable[str]): The index URLs to search for packages. + find_links (Iterable[str]): The links to search for packages. trusted_hosts: (Iterable[str]): The trusted hosts. target_python (TargetPython): The links must match the target Python ignore_compatibility (bool): Whether to ignore the compatibility check no_binary (Iterable[str]): The names of the packages to disallow wheels only_binary (Iterable[str]): The names of the packages to disallow non-wheels - prefer_binary (bool): Whether to prefer binary packages even if - newer sdist pacakges exist. + prefer_binary (Iterable[str]): The names of the packages to prefer binary + distributions even if newer sdist pacakges exist. respect_source_order (bool): If True, packages from the source coming earlier are more preferred, even if they have lower versions. verbosity (int): The verbosity level. @@ -63,6 +74,7 @@ def __init__( self, session: PyPISession | None = None, + *, index_urls: Iterable[str] = (), find_links: Iterable[str] = (), trusted_hosts: Iterable[str] = (), @@ -70,33 +82,57 @@ ignore_compatibility: bool = False, no_binary: Iterable[str] = (), only_binary: Iterable[str] = (), - prefer_binary: bool = False, + prefer_binary: Iterable[str] = (), respect_source_order: bool = False, verbosity: int = 0, ) -> None: - self.index_urls = list(index_urls) - self.find_links = list(find_links) + self.sources: list[Source] = [] + for url in index_urls: + self.add_index_url(url) + for url in find_links: + self.add_find_links(url) self.target_python = target_python or TargetPython() self.ignore_compatibility = ignore_compatibility self.no_binary = [canonicalize_name(name) for name in no_binary] self.only_binary = [canonicalize_name(name) for name in only_binary] - self.prefer_binary = prefer_binary - if session is None: - session = PyPISession( - index_urls=self.index_urls, trusted_hosts=trusted_hosts - ) - atexit.register(session.close) - self.session = session + self.prefer_binary = [canonicalize_name(name) for name in prefer_binary] + self.trusted_hosts = trusted_hosts + self._session = session self.respect_source_order = respect_source_order self.verbosity = verbosity self._tag_priorities = { tag: i for i, tag in enumerate(self.target_python.supported_tags()) } - # Index pages are preferred over find links. - self._source_order = [ - split_auth_from_url(url)[1] for url in (self.index_urls + self.find_links) - ] + + @property + def session(self) -> PyPISession: + if self._session is None: + index_urls = [ + source["url"] for source in self.sources if source["type"] == "index" + ] + session = PyPISession( + index_urls=index_urls, trusted_hosts=self.trusted_hosts + ) + atexit.register(session.close) + self._session = session + return self._session + + def add_index_url(self, url: str) -> None: + """Add an index URL to the finder search scope. + + Args: + url (str): The index URL to add. + """ + self.sources.append({"url": url, "type": "index"}) + + def add_find_links(self, url: str) -> None: + """Add a find links URL to the finder search scope. + + Args: + url (str): The find links URL to add. + """ + self.sources.append({"url": url, "type": "find_links"}) def build_evaluator( self, @@ -140,7 +176,9 @@ def _build_find_link(self, find_link: str) -> Link: if os.path.exists(find_link): return Link.from_path(os.path.abspath(find_link)) - return Link(find_link) + elif "://" in find_link: + return Link(find_link) + raise ValueError(f"Invalid find link or non-existing path: {find_link}") def _evaluate_links( self, links: Iterable[Link], evaluator: Evaluator @@ -163,32 +201,21 @@ def _sort_key(self, package: Package) -> tuple: """The key for sort, package with the largest value is the most preferred.""" link = package.link - pri = len(self._tag_priorities) + pri = len(self._tag_priorities) + 1 # default priority for sdist. build_tag: BuildTag = () prefer_binary = False if link.is_wheel: *_, build_tag, file_tags = parse_wheel_filename(link.filename) pri = min( - (self._tag_priorities.get(tag, pri) for tag in file_tags), default=pri + (self._tag_priorities.get(tag, pri - 1) for tag in file_tags), + default=pri - 1, ) - if self.prefer_binary: + if canonicalize_name(package.name) in self.prefer_binary: prefer_binary = True - comes_from = package.link.comes_from - source_index = len(self._source_order) - if comes_from is not None and self.respect_source_order: - source_index = next( - ( - i - for i, url in enumerate(self._source_order) - if comes_from.startswith(url) - ), - source_index, - ) return ( -int(link.is_yanked), int(prefer_binary), - -source_index, parse_version(package.version) if package.version is not None else 0, -pri, build_tag, @@ -208,26 +235,39 @@ hashes (dict[str, list[str]]|None): The hashes to filter on. Returns: - Iterable[Package]: The packages with the given name + Iterable[Package]: The packages with the given name, sorted by best match. """ evaluator = self.build_evaluator(package_name, allow_yanked, hashes) - for index_url in self.index_urls: - package_link = self._build_index_page_link(index_url, package_name) - yield from self._evaluate_links( - collect_links_from_location(self.session, package_link), evaluator - ) - for find_link in self.find_links: - link = self._build_find_link(find_link) - yield from self._evaluate_links( - collect_links_from_location(self.session, link, expand=True), evaluator - ) + + def find_one_source(source: Source) -> Iterable[Package]: + if source["type"] == "index": + link = self._build_index_page_link(source["url"], package_name) + result = self._evaluate_links( + collect_links_from_location(self.session, link), evaluator + ) + else: + link = self._build_find_link(source["url"]) + result = self._evaluate_links( + collect_links_from_location(self.session, link, expand=True), + evaluator, + ) + if self.respect_source_order: + # Sort the result within the individual source. + return sorted(result, key=self._sort_key, reverse=True) + return result + + all_packages = itertools.chain.from_iterable(map(find_one_source, self.sources)) + if self.respect_source_order: + return all_packages + # Otherwise, sort the result across all sources. + return sorted(all_packages, key=self._sort_key, reverse=True) def find_all_packages( self, package_name: str, allow_yanked: bool = False, hashes: dict[str, list[str]] | None = None, - ) -> list[Package]: + ) -> Sequence[Package]: """Find all packages with the given package name, best match first. Args: @@ -236,13 +276,9 @@ hashes (dict[str, list[str]]|None): The hashes to filter on. Returns: - list[Package]: The packages list sorted by best match + Sequence[Package]: The packages list sorted by best match """ - return sorted( - self._find_packages(package_name, allow_yanked, hashes), - key=self._sort_key, - reverse=True, - ) + return LazySequence(self._find_packages(package_name, allow_yanked, hashes)) def _find_packages_from_requirement( self, @@ -263,7 +299,7 @@ allow_yanked: bool | None = None, allow_prereleases: bool | None = None, hashes: dict[str, list[str]] | None = None, - ) -> list[Package]: + ) -> Sequence[Package]: """Find all packages matching the given requirement, best match first. Args: @@ -276,18 +312,16 @@ hashes (dict[str, list[str]]|None): The hashes to filter on. Returns: - list[Package]: The packages list sorted by best match + Sequence[Package]: The packages sorted by best match """ if isinstance(requirement, str): requirement = packaging.requirements.Requirement(requirement) - return sorted( + return LazySequence( self._evaluate_packages( self._find_packages_from_requirement(requirement, allow_yanked, hashes), requirement, allow_prereleases, - ), - key=self._sort_key, - reverse=True, + ) ) def find_best_match( @@ -313,13 +347,14 @@ """ if isinstance(requirement, str): requirement = packaging.requirements.Requirement(requirement) - candidates = list( - self._find_packages_from_requirement(requirement, allow_yanked, hashes) + packages = self._find_packages_from_requirement( + requirement, allow_yanked, hashes ) - applicable_candidates = list( - self._evaluate_packages(candidates, requirement, allow_prereleases) + candidates = LazySequence(packages) + applicable_candidates = LazySequence( + self._evaluate_packages(packages, requirement, allow_prereleases) ) - best_match = max(applicable_candidates, key=self._sort_key, default=None) + best_match = next(iter(applicable_candidates), None) return BestMatch(best_match, applicable_candidates, candidates) def download_and_unpack( diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.7.0/src/unearth/session.py new/unearth-0.9.1/src/unearth/session.py --- old/unearth-0.7.0/src/unearth/session.py 2022-12-12 06:10:02.955712000 +0100 +++ new/unearth-0.9.1/src/unearth/session.py 2023-05-15 09:34:42.539511400 +0200 @@ -193,7 +193,7 @@ return True logger.warning( - "Skip %s for not being trusted, please add it to `trusted_hosts` list", + "Skipping %s for not being trusted, please add it to `trusted_hosts` list", location.redacted, ) return False diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.7.0/src/unearth/utils.py new/unearth-0.9.1/src/unearth/utils.py --- old/unearth-0.7.0/src/unearth/utils.py 2022-12-12 06:10:02.955712000 +0100 +++ new/unearth-0.9.1/src/unearth/utils.py 2023-05-15 09:34:42.539511400 +0200 @@ -2,10 +2,12 @@ from __future__ import annotations import functools +import itertools import os import sys import urllib.parse as parse from pathlib import Path +from typing import Iterable, Iterator, Sequence, TypeVar from urllib.request import pathname2url, url2pathname WINDOWS = sys.platform == "win32" @@ -126,8 +128,8 @@ auth, has_auth, host = netloc.rpartition("@") if not has_auth: return None, host - user, _, password = auth.partition(":") - return (parse.unquote(user), parse.unquote(password) if password else None), host + user, has_pass, password = auth.partition(":") + return (parse.unquote(user), parse.unquote(password) if has_pass else None), host @functools.lru_cache(maxsize=128) @@ -182,3 +184,36 @@ return f"{int_size / 1000.0:.1f} kB" else: return f"{int(int_size)} bytes" + + +T = TypeVar("T", covariant=True) + + +class LazySequence(Sequence[T]): + """A sequence that is lazily evaluated.""" + + def __init__(self, data: Iterable[T]) -> None: + self._inner = data + + def __iter__(self) -> Iterator[T]: + self._inner, this = itertools.tee(self._inner) + return this + + def __len__(self) -> int: + i = 0 + for _ in self: + i += 1 + return i + + def __bool__(self) -> bool: + for _ in self: + return True + return False + + def __getitem__(self, index: int) -> T: # type: ignore[override] + if index < 0: + raise IndexError("Negative indices are not supported") + for i, item in enumerate(self): + if i == index: + return item + raise IndexError("Index out of range") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.7.0/src/unearth/vcs/git.py new/unearth-0.9.1/src/unearth/vcs/git.py --- old/unearth-0.7.0/src/unearth/vcs/git.py 2022-12-12 06:10:02.955712000 +0100 +++ new/unearth-0.9.1/src/unearth/vcs/git.py 2023-05-15 09:34:42.539511400 +0200 @@ -35,8 +35,10 @@ ) -> None: rev_display = f" (revision: {rev})" if rev else "" logger.info("Cloning %s%s to %s", url, rev_display, display_path(location)) + env = None if self.verbosity <= 0: flags: tuple[str, ...] = ("--quiet",) + env = {"GIT_TERMINAL_PROMPT": "0"} elif self.verbosity == 1: flags = () else: @@ -47,9 +49,10 @@ # Speeds up cloning by functioning without a complete copy of repository self.run_command( ["clone", "--filter=blob:none", *flags, url, str(location)], + extra_env=env, ) else: - self.run_command(["clone", *flags, url, str(location)]) + self.run_command(["clone", *flags, url, str(location)], extra_env=env) if rev is not None: self.run_command(["checkout", "-q", rev], cwd=location) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.7.0/tests/conftest.py new/unearth-0.9.1/tests/conftest.py --- old/unearth-0.7.0/tests/conftest.py 2022-12-12 06:10:02.955712000 +0100 +++ new/unearth-0.9.1/tests/conftest.py 2023-05-15 09:34:42.539511400 +0200 @@ -1,4 +1,5 @@ """Configuration for the pytest test suite.""" +import os from ssl import SSLContext from unittest import mock @@ -71,7 +72,9 @@ @pytest.fixture() def pypi_auth(pypi): def check_auth(auth): - return auth.username == "test" and auth.password == "password" + return auth.username == os.getenv( + "PYPI_USER", "test" + ) and auth.password == os.getenv("PYPI_PASSWORD", "password") def unauthenticated(): message = {"message": "Unauthenticated"} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.7.0/tests/test_evaluator.py new/unearth-0.9.1/tests/test_evaluator.py --- old/unearth-0.7.0/tests/test_evaluator.py 2022-12-12 06:10:02.955712000 +0100 +++ new/unearth-0.9.1/tests/test_evaluator.py 2023-05-15 09:34:42.543511400 +0200 @@ -249,6 +249,7 @@ ("8.0.0a0", ">=8.0.0dev0", None, True), ("8.0.0dev0", ">=7", None, False), ("8.0.0dev0", ">=7", True, True), + ("8.0.0a0", "", None, False), ("8.0.0a0", ">=8.0.0dev0", False, False), ], ) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.7.0/tests/test_finder.py new/unearth-0.9.1/tests/test_finder.py --- old/unearth-0.7.0/tests/test_finder.py 2022-12-12 06:10:02.955712000 +0100 +++ new/unearth-0.9.1/tests/test_finder.py 2023-05-15 09:34:42.543511400 +0200 @@ -6,6 +6,8 @@ pytestmark = pytest.mark.usefixtures("pypi", "content_type") +DEFAULT_INDEX_URL = "https://pypi.org/simple/" + @pytest.mark.parametrize( "target_python,filename", @@ -34,15 +36,15 @@ ) def test_find_most_matching_wheel(session, target_python, filename): finder = PackageFinder( - session, index_urls=["https://pypi.org/simple"], target_python=target_python + session=session, index_urls=[DEFAULT_INDEX_URL], target_python=target_python ) assert finder.find_best_match("black").best.link.filename == filename def test_find_package_with_format_control(session): finder = PackageFinder( - session, - index_urls=["https://pypi.org/simple"], + session=session, + index_urls=[DEFAULT_INDEX_URL], target_python=TargetPython( (3, 9), abis=["cp39"], impl="cp", platforms=["win_amd64"] ), @@ -58,8 +60,8 @@ def test_find_package_no_binary_for_all(session): finder = PackageFinder( - session, - index_urls=["https://pypi.org/simple"], + session=session, + index_urls=[DEFAULT_INDEX_URL], target_python=TargetPython( (3, 9), abis=["cp39"], impl="cp", platforms=["win_amd64"] ), @@ -71,12 +73,12 @@ def test_find_package_prefer_binary(session): finder = PackageFinder( - session, - index_urls=["https://pypi.org/simple"], + session=session, + index_urls=[DEFAULT_INDEX_URL], target_python=TargetPython( (3, 9), abis=["cp39"], impl="cp", platforms=["win_amd64"] ), - prefer_binary=True, + prefer_binary=["first"], ) assert ( finder.find_best_match("first").best.link.filename @@ -86,8 +88,8 @@ def test_find_package_with_hash_allowance(session): finder = PackageFinder( - session, - index_urls=["https://pypi.org/simple"], + session=session, + index_urls=[DEFAULT_INDEX_URL], target_python=TargetPython( (3, 9), abis=["cp39"], impl="cp", platforms=["win_amd64"] ), @@ -108,8 +110,8 @@ @pytest.mark.parametrize("ignore_compat", [True, False]) def test_find_package_ignoring_compatibility(session, ignore_compat): finder = PackageFinder( - session, - index_urls=["https://pypi.org/simple"], + session=session, + index_urls=[DEFAULT_INDEX_URL], target_python=TargetPython( (3, 9), abis=["cp39"], impl="cp", platforms=["win_amd64"] ), @@ -121,8 +123,8 @@ def test_find_package_with_version_specifier(session): finder = PackageFinder( - session, - index_urls=["https://pypi.org/simple"], + session=session, + index_urls=[DEFAULT_INDEX_URL], ignore_compatibility=True, ) matches = finder.find_matches("black==22.3.0") @@ -134,8 +136,8 @@ def test_find_package_allowing_prereleases(session): finder = PackageFinder( - session, - index_urls=["https://pypi.org/simple"], + session=session, + index_urls=[DEFAULT_INDEX_URL], ignore_compatibility=True, ) matches = finder.find_matches("black<22.3.0", allow_prereleases=True) @@ -152,8 +154,8 @@ def test_find_requirement_with_link(session): finder = PackageFinder( - session, - index_urls=["https://pypi.org/simple"], + session=session, + index_urls=[DEFAULT_INDEX_URL], ignore_compatibility=True, ) req = "first @ https://pypi.org/files/first-2.0.2.tar.gz" @@ -166,11 +168,9 @@ def test_find_requirement_preference(session, fixtures_dir): find_link = Link.from_path(fixtures_dir / "findlinks/index.html") finder = PackageFinder( - session, - index_urls=["https://pypi.org/simple"], - find_links=[find_link.normalized], - ignore_compatibility=True, + session=session, index_urls=[DEFAULT_INDEX_URL], ignore_compatibility=True ) + finder.add_find_links(find_link.normalized) best = finder.find_best_match("first").best assert best.link.filename == "first-2.0.3-py2.py3-none-any.whl" assert best.link.comes_from == find_link.normalized @@ -179,12 +179,12 @@ def test_find_requirement_preference_respect_source_order(session, fixtures_dir): find_link = Link.from_path(fixtures_dir / "findlinks/index.html") finder = PackageFinder( - session, - index_urls=["https://pypi.org/simple"], - find_links=[find_link.normalized], + session=session, + index_urls=[DEFAULT_INDEX_URL], ignore_compatibility=True, respect_source_order=True, ) + finder.add_find_links(find_link.normalized) best = finder.find_best_match("first").best assert best.link.filename == "first-2.0.2.tar.gz" assert best.link.comes_from == "https://pypi.org/simple/first/" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.7.0/tests/test_session.py new/unearth-0.9.1/tests/test_session.py --- old/unearth-0.7.0/tests/test_session.py 2022-12-12 06:10:02.955712000 +0100 +++ new/unearth-0.9.1/tests/test_session.py 2023-05-15 09:34:42.543511400 +0200 @@ -57,7 +57,17 @@ assert not any(r.status_code == 401 for r in resp.history) -def test_session_auth_from_prompting(pypi_auth, session, monkeypatch): +def test_session_auth_with_empty_password(pypi_auth, session, monkeypatch): + monkeypatch.setenv("PYPI_PASSWORD", "") + session.auth = MultiDomainBasicAuth( + prompting=False, index_urls=["https://test:@pypi.org/simple"] + ) + resp = session.get("https://pypi.org/simple/click") + assert resp.status_code == 200 + assert not any(r.status_code == 401 for r in resp.history) + + +def test_session_auth_from_prompting(pypi_auth, session): with mock.patch.object( MultiDomainBasicAuth, "_prompt_for_password", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.7.0/tests/test_utils.py new/unearth-0.9.1/tests/test_utils.py --- old/unearth-0.7.0/tests/test_utils.py 1970-01-01 01:00:00.000000000 +0100 +++ new/unearth-0.9.1/tests/test_utils.py 2023-05-15 09:34:42.543511400 +0200 @@ -0,0 +1,25 @@ +from unittest import mock + +from unearth.utils import LazySequence + + +def test_lazy_sequence(): + func = mock.Mock() + + def gen(size): + for i in range(size): + func() + yield i + + seq = LazySequence(gen(5)) + assert bool(seq) is True + assert func.call_count == 1 + assert seq[0] == 0 + assert func.call_count == 1 + assert seq[1] == 1 + assert func.call_count == 2 + assert 3 in seq + assert func.call_count == 4 + assert len(seq) == 5 + assert list(seq) == [0, 1, 2, 3, 4] + assert func.call_count == 5