diff --git a/CHANGES.md b/CHANGES.md index 0be0d72ce..338ec953e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,31 @@ # Release Notes +## 2.10.0 + +This release adds support for injecting requirements into the isolated +Pip PEXes Pex uses to resolve distributions. The motivating use case +for this is to use the feature Pip 23.1 introduced for forcing +`--keyring-provider import`. + +Pex already supported using a combination of the following to force +non-interactive use of the keyring: +1. A `keyring` script installation that was on the `PATH` +2. A `--pip-version` 23.1 or newer. +3. Specifying `--use-pip-config` to pass `--keyring-provider subprocess` + to Pip. + +You could not force `--keyring-provider import` though, since the Pips +Pex uses are themselves hermetic PEXes without access to extra +installed keyring requirements elsewhere on the system. With +`--extra-pip-requirement` you can now do this with the primary benefit +over `--keyring-provider subprocess` being that you do not need to add +the username to index URLs. This is ultimately because the keyring CLI +requires username whereas the API does not; but see +https://pip.pypa.io/en/stable/topics/authentication/#keyring-support for +more information. + +* Add support for `--extra-pip-requirement`. (#2461) + ## 2.9.0 This release adds support for Pip 24.1.2. diff --git a/pex/cli/commands/lock.py b/pex/cli/commands/lock.py index 4b630f023..4b7fbd370 100644 --- a/pex/cli/commands/lock.py +++ b/pex/cli/commands/lock.py @@ -1668,6 +1668,7 @@ def _sync(self): max_parallel_jobs=pip_configuration.max_jobs, pip_version=pip_configuration.version, use_pip_config=pip_configuration.use_pip_config, + extra_pip_requirements=pip_configuration.extra_requirements, result_type=InstallableType.INSTALLED_WHEEL_CHROOT, ) ) diff --git a/pex/pip/installation.py b/pex/pip/installation.py index f63175c9a..0ef28406e 100644 --- a/pex/pip/installation.py +++ b/pex/pip/installation.py @@ -3,12 +3,13 @@ from __future__ import absolute_import +import hashlib import os from textwrap import dedent from pex import pex_warnings, third_party from pex.atomic_directory import atomic_directory -from pex.common import safe_mkdtemp +from pex.common import pluralize, safe_mkdtemp from pex.dist_metadata import Requirement from pex.interpreter import PythonInterpreter from pex.orderedset import OrderedSet @@ -26,7 +27,7 @@ from pex.venv.virtualenv import InstallationChoice, Virtualenv if TYPE_CHECKING: - from typing import Callable, Dict, Iterator, Optional, Union + from typing import Callable, Dict, Iterator, Optional, Tuple, Union import attr # vendor:skip else: @@ -36,13 +37,14 @@ def _pip_installation( version, # type: PipVersionValue iter_distribution_locations, # type: Callable[[], Iterator[str]] + fingerprint, # type: str interpreter=None, # type: Optional[PythonInterpreter] ): # type: (...) -> Pip pip_root = os.path.join(ENV.PEX_ROOT, "pip", str(version)) path = os.path.join(pip_root, "pip.pex") pip_interpreter = interpreter or PythonInterpreter.get() - pip_pex_path = os.path.join(path, isolated().pex_hash) + pip_pex_path = os.path.join(path, isolated().pex_hash, fingerprint) with atomic_directory(pip_pex_path) as chroot: if not chroot.is_finalized(): from pex.pex_builder import PEXBuilder @@ -77,15 +79,63 @@ def _pip_installation( return Pip(pip=pip_venv, version=version, pip_cache=pip_cache) -def _vendored_installation(interpreter=None): - # type: (Optional[PythonInterpreter]) -> Pip +def _fingerprint(requirements): + # type: (Tuple[Requirement, ...]) -> str + if not requirements: + return "no-extra-requirements" + return hashlib.sha1("\n".join(sorted(map(str, requirements))).encode("utf-8")).hexdigest() + + +def _vendored_installation( + interpreter=None, # type: Optional[PythonInterpreter] + resolver=None, # type: Optional[Resolver] + extra_requirements=(), # type: Tuple[Requirement, ...] +): + # type: (...) -> Pip + + def expose_vendored(): + # type: () -> Iterator[str] + return third_party.expose(("pip", "setuptools"), interpreter=interpreter) + + if not extra_requirements: + return _pip_installation( + version=PipVersion.VENDORED, + iter_distribution_locations=expose_vendored, + interpreter=interpreter, + fingerprint=_fingerprint(extra_requirements), + ) + + if not resolver: + raise ValueError( + "A resolver is required to install extra {requirements} for vendored Pip: " + "{extra_requirements}".format( + requirements=pluralize(extra_requirements, "requirement"), + extra_requirements=" ".join(map(str, extra_requirements)), + ) + ) + + # This indirection works around MyPy type inference failing to see that + # `iter_distribution_locations` is only successfully defined when resolve is not None. + extra_requirement_resolver = resolver + + def iter_distribution_locations(): + # type: () -> Iterator[str] + for location in expose_vendored(): + yield location + + for resolved_distribution in extra_requirement_resolver.resolve_requirements( + requirements=tuple(map(str, extra_requirements)), + targets=Targets.from_target(LocalInterpreter.create(interpreter)), + pip_version=PipVersion.VENDORED, + extra_resolver_requirements=(), + ).distributions: + yield resolved_distribution.distribution.location return _pip_installation( version=PipVersion.VENDORED, - iter_distribution_locations=lambda: third_party.expose( - ("pip", "setuptools"), interpreter=interpreter - ), + iter_distribution_locations=iter_distribution_locations, interpreter=interpreter, + fingerprint=_fingerprint(extra_requirements), ) @@ -102,7 +152,7 @@ def bootstrap_pip(): venv = Virtualenv.create( venv_dir=os.path.join(chroot, "pip"), interpreter=interpreter, - install_pip=InstallationChoice.UPGRADED, + install_pip=InstallationChoice.YES, ) for req in version.requirements: @@ -118,6 +168,7 @@ def _resolved_installation( version, # type: PipVersionValue resolver=None, # type: Optional[Resolver] interpreter=None, # type: Optional[PythonInterpreter] + extra_requirements=(), # type: Tuple[Requirement, ...] ): # type: (...) -> Pip targets = Targets.from_target(LocalInterpreter.create(interpreter)) @@ -130,25 +181,31 @@ def _resolved_installation( warn=False, ) ) - if bootstrap_pip_version is not PipVersion.VENDORED: + if bootstrap_pip_version is not PipVersion.VENDORED and not extra_requirements: return _pip_installation( version=version, iter_distribution_locations=_bootstrap_pip(version, interpreter=interpreter), interpreter=interpreter, + fingerprint=_fingerprint(extra_requirements), ) - if resolver is None: + requirements = list(version.requirements) + requirements.extend(map(str, extra_requirements)) + if not resolver: raise ValueError( - "A resolver is required to install {requirement}".format( - requirement=version.requirement + "A resolver is required to install {requirements} for Pip {version}: {reqs}".format( + requirements=pluralize(requirements, "requirement"), + version=version, + reqs=" ".join(map(str, extra_requirements)), ) ) def resolve_distribution_locations(): for resolved_distribution in resolver.resolve_requirements( - requirements=version.requirements, + requirements=requirements, targets=targets, - pip_version=PipVersion.VENDORED, + pip_version=bootstrap_pip_version, + extra_resolver_requirements=(), ).distributions: yield resolved_distribution.distribution.location @@ -156,6 +213,7 @@ def resolve_distribution_locations(): version=version, iter_distribution_locations=resolve_distribution_locations, interpreter=interpreter, + fingerprint=_fingerprint(extra_requirements), ) @@ -163,6 +221,7 @@ def resolve_distribution_locations(): class PipInstallation(object): interpreter = attr.ib() # type: PythonInterpreter version = attr.ib() # type: PipVersionValue + extra_requirements = attr.ib() # type: Tuple[Requirement, ...] def check_python_applies(self): # type: () -> None @@ -257,6 +316,7 @@ def get_pip( interpreter=None, version=None, # type: Optional[PipVersionValue] resolver=None, # type: Optional[Resolver] + extra_requirements=(), # type: Tuple[Requirement, ...] ): # type: (...) -> Pip """Returns a lazily instantiated global Pip object that is safe for un-coordinated use.""" @@ -280,15 +340,23 @@ def get_pip( installation = PipInstallation( interpreter=interpreter or PythonInterpreter.get(), version=calculated_version, + extra_requirements=extra_requirements, ) pip = _PIP.get(installation) if pip is None: installation.check_python_applies() if installation.version is PipVersion.VENDORED: - pip = _vendored_installation(interpreter=interpreter) + pip = _vendored_installation( + interpreter=interpreter, + resolver=resolver, + extra_requirements=installation.extra_requirements, + ) else: pip = _resolved_installation( - version=installation.version, resolver=resolver, interpreter=interpreter + version=installation.version, + resolver=resolver, + interpreter=interpreter, + extra_requirements=installation.extra_requirements, ) _PIP[installation] = pip return pip diff --git a/pex/pip/tool.py b/pex/pip/tool.py index b757faffc..1bb7273e3 100644 --- a/pex/pip/tool.py +++ b/pex/pip/tool.py @@ -19,6 +19,7 @@ from pex.common import safe_mkdir, safe_mkdtemp from pex.compatibility import get_stderr_bytes_buffer, shlex_quote, urlparse from pex.dependency_configuration import DependencyConfiguration +from pex.dist_metadata import Requirement from pex.interpreter import PythonInterpreter from pex.jobs import Job from pex.network_configuration import NetworkConfiguration @@ -148,6 +149,7 @@ def create( network_configuration=None, # type: Optional[NetworkConfiguration] password_entries=(), # type: Iterable[PasswordEntry] use_pip_config=False, # type: bool + extra_pip_requirements=(), # type: Tuple[Requirement, ...] ): # type: (...) -> PackageIndexConfiguration resolver_version = resolver_version or ResolverVersion.default(pip_version) @@ -169,6 +171,7 @@ def create( network_configuration=network_configuration, use_pip_config=use_pip_config ), use_pip_config=use_pip_config, + extra_pip_requirements=extra_pip_requirements, password_entries=password_entries, ) @@ -181,6 +184,7 @@ def __init__( use_pip_config, # type: bool password_entries=(), # type: Iterable[PasswordEntry] pip_version=None, # type: Optional[PipVersionValue] + extra_pip_requirements=(), # type: Tuple[Requirement, ...] ): # type: (...) -> None self.resolver_version = resolver_version # type: ResolverVersion.Value @@ -190,6 +194,7 @@ def __init__( self.use_pip_config = use_pip_config # type: bool self.password_entries = password_entries # type: Iterable[PasswordEntry] self.pip_version = pip_version # type: Optional[PipVersionValue] + self.extra_pip_requirements = extra_pip_requirements # type: Tuple[Requirement, ...] if TYPE_CHECKING: diff --git a/pex/pip/version.py b/pex/pip/version.py index 793f89fd7..ba8fe3f91 100644 --- a/pex/pip/version.py +++ b/pex/pip/version.py @@ -3,6 +3,7 @@ from __future__ import absolute_import +import functools import os import sys from textwrap import dedent @@ -18,6 +19,7 @@ from typing import Iterable, Optional, Tuple, Union +@functools.total_ordering class PipVersionValue(Enum.Value): @classmethod def overridden(cls): @@ -87,6 +89,11 @@ def requires_python_applies(self, target=None): source=Requirement.parse(self.requirement), ) + def __lt__(self, other): + if not isinstance(other, PipVersionValue): + return NotImplemented + return self.version < other.version + class LatestPipVersion(object): def __get__(self, obj, objtype=None): diff --git a/pex/resolve/configured_resolve.py b/pex/resolve/configured_resolve.py index 390649a9c..76790b7f7 100644 --- a/pex/resolve/configured_resolve.py +++ b/pex/resolve/configured_resolve.py @@ -59,6 +59,7 @@ def resolve( max_parallel_jobs=pip_configuration.max_jobs, pip_version=lock.pip_version, use_pip_config=pip_configuration.use_pip_config, + extra_pip_requirements=pip_configuration.extra_requirements, result_type=result_type, dependency_configuration=dependency_configuration, ) @@ -103,6 +104,7 @@ def resolve( pip_version=resolver_configuration.version, resolver=ConfiguredResolver(pip_configuration=resolver_configuration), use_pip_config=resolver_configuration.use_pip_config, + extra_pip_requirements=resolver_configuration.extra_requirements, result_type=result_type, dependency_configuration=dependency_configuration, ) diff --git a/pex/resolve/configured_resolver.py b/pex/resolve/configured_resolver.py index 06d730240..6329bf5d3 100644 --- a/pex/resolve/configured_resolver.py +++ b/pex/resolve/configured_resolver.py @@ -4,6 +4,7 @@ from __future__ import absolute_import from pex import resolver +from pex.dist_metadata import Requirement from pex.pep_427 import InstallableType from pex.pip.version import PipVersion, PipVersionValue from pex.resolve import lock_resolver @@ -15,7 +16,7 @@ from pex.typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Iterable, Optional + from typing import Iterable, Optional, Tuple import attr # vendor:skip else: @@ -70,6 +71,7 @@ def resolve_lock( max_parallel_jobs=self.pip_configuration.max_jobs, pip_version=pip_version or self.pip_configuration.version, use_pip_config=self.pip_configuration.use_pip_config, + extra_pip_requirements=self.pip_configuration.extra_requirements, result_type=result_type, ) ) @@ -80,6 +82,7 @@ def resolve_requirements( targets=Targets(), # type: Targets pip_version=None, # type: Optional[PipVersionValue] transitive=None, # type: Optional[bool] + extra_resolver_requirements=None, # type: Optional[Tuple[Requirement, ...]] result_type=InstallableType.INSTALLED_WHEEL_CHROOT, # type: InstallableType.Value ): # type: (...) -> ResolveResult @@ -100,5 +103,8 @@ def resolve_requirements( pip_version=pip_version or self.pip_configuration.version, resolver=self, use_pip_config=self.pip_configuration.use_pip_config, + extra_pip_requirements=extra_resolver_requirements + if extra_resolver_requirements is not None + else self.pip_configuration.extra_requirements, result_type=result_type, ) diff --git a/pex/resolve/downloads.py b/pex/resolve/downloads.py index 207bf3651..edf8b2085 100644 --- a/pex/resolve/downloads.py +++ b/pex/resolve/downloads.py @@ -65,6 +65,7 @@ def _pip(self): interpreter=self.target.get_interpreter(), version=self.package_index_configuration.pip_version, resolver=self.resolver, + extra_requirements=self.package_index_configuration.extra_pip_requirements, ) @staticmethod diff --git a/pex/resolve/lock_resolver.py b/pex/resolve/lock_resolver.py index 35149c8b8..f84bce95b 100644 --- a/pex/resolve/lock_resolver.py +++ b/pex/resolve/lock_resolver.py @@ -15,6 +15,7 @@ from pex.common import pluralize from pex.compatibility import cpu_count from pex.dependency_configuration import DependencyConfiguration +from pex.dist_metadata import Requirement from pex.network_configuration import NetworkConfiguration from pex.orderedset import OrderedSet from pex.pep_427 import InstallableType @@ -92,6 +93,7 @@ def __init__( pip_version=None, # type: Optional[PipVersionValue] resolver=None, # type: Optional[Resolver] use_pip_config=False, # type: bool + extra_pip_requirements=(), # type: Tuple[Requirement, ...] ): super(VCSArtifactDownloadManager, self).__init__( pex_root=pex_root, file_lock_style=file_lock_style @@ -113,6 +115,7 @@ def __init__( self._pip_version = pip_version self._resolver = resolver self._use_pip_config = use_pip_config + self._extra_pip_requirements = extra_pip_requirements def save( self, @@ -138,6 +141,7 @@ def save( pip_version=self._pip_version, resolver=self._resolver, use_pip_config=self._use_pip_config, + extra_pip_requirements=self._extra_pip_requirements, ) if len(downloaded_vcs.local_distributions) != 1: return Error( @@ -251,6 +255,7 @@ def resolve_from_lock( max_parallel_jobs=None, # type: Optional[int] pip_version=None, # type: Optional[PipVersionValue] use_pip_config=False, # type: bool + extra_pip_requirements=(), # type: Tuple[Requirement, ...] result_type=InstallableType.INSTALLED_WHEEL_CHROOT, # type: InstallableType.Value dependency_configuration=DependencyConfiguration(), # type: DependencyConfiguration ): @@ -304,6 +309,7 @@ def resolve_from_lock( network_configuration=network_configuration, password_entries=PasswordDatabase.from_netrc().append(password_entries).entries, use_pip_config=use_pip_config, + extra_pip_requirements=extra_pip_requirements, ), max_parallel_jobs=max_parallel_jobs, ), @@ -324,6 +330,7 @@ def resolve_from_lock( pip_version=pip_version, resolver=resolver, use_pip_config=use_pip_config, + extra_pip_requirements=extra_pip_requirements, ) for resolved_subset in subset_result.subsets } @@ -441,6 +448,7 @@ def resolve_from_lock( network_configuration=network_configuration, password_entries=PasswordDatabase.from_netrc().append(password_entries).entries, use_pip_config=use_pip_config, + extra_pip_requirements=extra_pip_requirements, ), compile=compile, build_configuration=build_configuration, diff --git a/pex/resolve/lockfile/create.py b/pex/resolve/lockfile/create.py index 3c976c247..e3a0c53d6 100644 --- a/pex/resolve/lockfile/create.py +++ b/pex/resolve/lockfile/create.py @@ -368,10 +368,13 @@ def create( network_configuration=network_configuration, find_links=pip_configuration.repos_configuration.find_links, indexes=pip_configuration.repos_configuration.indexes, - password_entries=PasswordDatabase.from_netrc() - .append(pip_configuration.repos_configuration.password_entries) - .entries, + password_entries=( + PasswordDatabase.from_netrc() + .append(pip_configuration.repos_configuration.password_entries) + .entries + ), use_pip_config=pip_configuration.use_pip_config, + extra_pip_requirements=pip_configuration.extra_requirements, ) configured_resolver = ConfiguredResolver(pip_configuration=pip_configuration) @@ -419,6 +422,7 @@ def create( pip_version=pip_configuration.version, resolver=configured_resolver, use_pip_config=pip_configuration.use_pip_config, + extra_pip_requirements=pip_configuration.extra_requirements, dependency_configuration=dependency_configuration, ) except resolvers.ResolveError as e: @@ -476,6 +480,7 @@ def create( max_parallel_jobs=pip_configuration.max_jobs, pip_version=pip_configuration.version, use_pip_config=pip_configuration.use_pip_config, + extra_pip_requirements=pip_configuration.extra_requirements, ) ) diff --git a/pex/resolve/resolver_configuration.py b/pex/resolve/resolver_configuration.py index 3f507646f..83badd4fb 100644 --- a/pex/resolve/resolver_configuration.py +++ b/pex/resolve/resolver_configuration.py @@ -190,6 +190,7 @@ class PipConfiguration(object): resolver_version = attr.ib(default=None) # type: Optional[ResolverVersion.Value] allow_version_fallback = attr.ib(default=True) # type: bool use_pip_config = attr.ib(default=False) # type: bool + extra_requirements = attr.ib(default=()) # type Tuple[Requirement, ...] @attr.s(frozen=True) diff --git a/pex/resolve/resolver_options.py b/pex/resolve/resolver_options.py index f946b4f12..b869994b0 100644 --- a/pex/resolve/resolver_options.py +++ b/pex/resolve/resolver_options.py @@ -8,6 +8,7 @@ from pex import pex_warnings from pex.argparse import HandleBoolAction +from pex.dist_metadata import Requirement from pex.fetcher import initialize_ssl_context from pex.network_configuration import NetworkConfiguration from pex.orderedset import OrderedSet @@ -112,6 +113,21 @@ def register( "fast indicating the problematic selected interpreters." ), ) + parser.add_argument( + "--extra-pip-requirement", + dest="extra_pip_requirements", + type=Requirement.parse, + default=list(default_resolver_configuration.extra_requirements), + action="append", + help=( + "Add this extra requirement to the Pip PEX uses by Pex to resolve distributions. " + "Notably, this can be used to install keyring and keyring plugins for Pip to use. " + "There is obviously a bootstrap issue here if your only available index is secured; " + "so you may need to use an additional --find-links repo or --index that is not " + "secured in order to bootstrap keyring. " + "See: https://pip.pypa.io/en/stable/topics/authentication/#keyring-support" + ), + ) register_use_pip_config(parser) register_repos_options(parser) @@ -543,6 +559,7 @@ def create_pip_configuration(options): resolver_version=resolver_version, allow_version_fallback=options.allow_pip_version_fallback, use_pip_config=get_use_pip_config_value(options), + extra_requirements=tuple(options.extra_pip_requirements), ) diff --git a/pex/resolve/resolvers.py b/pex/resolve/resolvers.py index 2817e1ae8..a2e12ebdb 100644 --- a/pex/resolve/resolvers.py +++ b/pex/resolve/resolvers.py @@ -105,12 +105,14 @@ def resolve_lock( # type: (...) -> ResolveResult raise NotImplementedError() + @abstractmethod def resolve_requirements( self, requirements, # type: Iterable[str] targets=Targets(), # type: Targets pip_version=None, # type: Optional[PipVersionValue] transitive=None, # type: Optional[bool] + extra_resolver_requirements=None, # type: Optional[Tuple[Requirement, ...]] result_type=InstallableType.INSTALLED_WHEEL_CHROOT, # type: InstallableType.Value ): # type: (...) -> ResolveResult diff --git a/pex/resolver.py b/pex/resolver.py index 69add4929..8281d7cb3 100644 --- a/pex/resolver.py +++ b/pex/resolver.py @@ -143,6 +143,11 @@ def _spawn_download( interpreter=target.get_interpreter(), version=self.pip_version, resolver=self.resolver, + extra_requirements=( + self.package_index_configuration.extra_pip_requirements + if self.package_index_configuration + else () + ), ).spawn_download_distributions( download_dir=download_dir, requirements=self.requirements, @@ -548,6 +553,11 @@ def _spawn_wheel_build( interpreter=build_request.target.get_interpreter(), version=self._pip_version, resolver=self._resolver, + extra_requirements=( + self._package_index_configuration.extra_pip_requirements + if self._package_index_configuration is not None + else () + ), ).spawn_build_wheels( distributions=[build_request.source_path], wheel_dir=build_result.build_dir, @@ -1034,6 +1044,7 @@ def resolve( pip_version=None, # type: Optional[PipVersionValue] resolver=None, # type: Optional[Resolver] use_pip_config=False, # type: bool + extra_pip_requirements=(), # type: Tuple[Requirement, ...] result_type=InstallableType.INSTALLED_WHEEL_CHROOT, # type: InstallableType.Value dependency_configuration=DependencyConfiguration(), # type: DependencyConfiguration ): @@ -1118,6 +1129,7 @@ def resolve( network_configuration=network_configuration, password_entries=password_entries, use_pip_config=use_pip_config, + extra_pip_requirements=extra_pip_requirements, ) build_requests, download_results = _download_internal( targets=targets, @@ -1267,6 +1279,7 @@ def download( pip_version=None, # type: Optional[PipVersionValue] resolver=None, # type: Optional[Resolver] use_pip_config=False, # type: bool + extra_pip_requirements=(), # type: Tuple[Requirement, ...] dependency_configuration=DependencyConfiguration(), # type: DependencyConfiguration ): # type: (...) -> Downloaded @@ -1312,6 +1325,7 @@ def download( network_configuration=network_configuration, password_entries=password_entries, use_pip_config=use_pip_config, + extra_pip_requirements=extra_pip_requirements, ) build_requests, download_results = _download_internal( targets=targets, diff --git a/pex/venv/virtualenv.py b/pex/venv/virtualenv.py index dfed841b6..3253e8c13 100644 --- a/pex/venv/virtualenv.py +++ b/pex/venv/virtualenv.py @@ -37,7 +37,7 @@ from pex.version import __version__ if TYPE_CHECKING: - from typing import DefaultDict, Iterator, Optional, Tuple, Type, Union + from typing import DefaultDict, Iterable, Iterator, Optional, Tuple, Type, Union logger = logging.getLogger(__name__) @@ -181,6 +181,7 @@ def create( install_pip=InstallationChoice.NO, # type: InstallationChoice.Value install_setuptools=InstallationChoice.NO, # type: InstallationChoice.Value install_wheel=InstallationChoice.NO, # type: InstallationChoice.Value + other_installs=(), # type: Iterable[str] ): # type: (...) -> Virtualenv @@ -227,11 +228,12 @@ def create( # they install Pip for all Pythons older than 3.12. installations.pop("setuptools") - project_installs = [ + project_installs = OrderedSet( project for project, installation_choice in installations.items() if installation_choice is InstallationChoice.YES - ] + ) + project_installs.update(other_installs) if project_installs and install_pip is InstallationChoice.NO: raise ValueError( "Installation of Pip is required in order to install {projects}.".format( @@ -320,7 +322,9 @@ def create( args=["-m", "pip", "install", "-U"] + project_upgrades, env=env ) if project_installs: - venv.interpreter.execute(args=["-m", "pip", "install"] + project_installs, env=env) + venv.interpreter.execute( + args=["-m", "pip", "install"] + list(project_installs), env=env + ) return venv @classmethod @@ -334,6 +338,7 @@ def create_atomic( install_pip=InstallationChoice.NO, # type: InstallationChoice.Value install_setuptools=InstallationChoice.NO, # type: InstallationChoice.Value install_wheel=InstallationChoice.NO, # type: InstallationChoice.Value + other_installs=(), # type: Iterable[str] ): # type: (...) -> Virtualenv virtualenv = cls.create( @@ -345,6 +350,7 @@ def create_atomic( install_pip=install_pip, install_setuptools=install_setuptools, install_wheel=install_wheel, + other_installs=other_installs, ) for script in virtualenv._rewrite_base_scripts(real_venv_dir=venv_dir.target_dir): TRACER.log("Re-writing {}".format(script)) diff --git a/pex/version.py b/pex/version.py index a6389c6cb..403f9092a 100644 --- a/pex/version.py +++ b/pex/version.py @@ -1,4 +1,4 @@ # Copyright 2015 Pex project contributors. # Licensed under the Apache License, Version 2.0 (see LICENSE). -__version__ = "2.9.0" +__version__ = "2.10.0" diff --git a/testing/__init__.py b/testing/__init__.py index 0cbd279d1..e020a9bb9 100644 --- a/testing/__init__.py +++ b/testing/__init__.py @@ -4,6 +4,7 @@ from __future__ import absolute_import, print_function import contextlib +import glob import itertools import os import platform @@ -217,12 +218,14 @@ def bdist(self): interpreter=self._interpreter, verify=self._verify, ).wait() - dists = os.listdir(self._wheel_dir) + dists = glob.glob(os.path.join(self._wheel_dir, "*.whl")) if len(dists) == 0: raise self.BuildFailure("No distributions were produced!") if len(dists) > 1: - raise self.BuildFailure("Ambiguous source distributions found: %s" % (" ".join(dists))) - return os.path.join(self._wheel_dir, dists[0]) + raise self.BuildFailure( + "Ambiguous wheel distributions found: {dists}".format(dists=" ".join(dists)) + ) + return dists[0] @contextlib.contextmanager diff --git a/testing/mitmproxy.py b/testing/mitmproxy.py new file mode 100644 index 000000000..4d370f7f8 --- /dev/null +++ b/testing/mitmproxy.py @@ -0,0 +1,138 @@ +from __future__ import absolute_import + +import json +import logging +import os +import subprocess +from contextlib import contextmanager +from textwrap import dedent + +from pex.atomic_directory import atomic_directory +from pex.common import safe_rmtree +from pex.interpreter import PythonInterpreter +from pex.typing import TYPE_CHECKING +from pex.venv.virtualenv import InvalidVirtualenvError, Virtualenv +from testing import PEX_TEST_DEV_ROOT, PY310, data, ensure_python_interpreter + +if TYPE_CHECKING: + from typing import Iterable, Iterator, Optional, Tuple + + import attr # vendor:skip +else: + from pex.third_party import attr + + +logger = logging.getLogger(__name__) + + +MITMPROXY_DIR = os.path.join(PEX_TEST_DEV_ROOT, "mitmproxy") + + +def _ensure_mitmproxy_venv(): + # type: () -> Virtualenv + + venv_dir = os.path.join(MITMPROXY_DIR, "venv") + try: + return Virtualenv(venv_dir=venv_dir) + except InvalidVirtualenvError as e: + logger.warning(str(e)) + safe_rmtree(venv_dir) + with atomic_directory(venv_dir) as atomic_venvdir: + if not atomic_venvdir.is_finalized(): + logger.info("Installing mitmproxy...") + mitmproxy_lock = data.path("locks", "mitmproxy.lock.json") + python = ensure_python_interpreter(PY310) + Virtualenv.create_atomic( + venv_dir=atomic_venvdir, + interpreter=PythonInterpreter.from_binary(python), + force=True, + ) + subprocess.check_call( + args=[ + python, + "-m", + "pex.cli", + "venv", + "create", + "--lock", + mitmproxy_lock, + "-d", + atomic_venvdir.work_dir, + ] + ) + return Virtualenv(venv_dir=venv_dir) + + +@attr.s(frozen=True) +class Proxy(object): + @classmethod + def configured(cls, config_dir): + # type: (str) -> Proxy + + mitmdump_venv = _ensure_mitmproxy_venv() + + confdir = os.path.join(config_dir, "confdir") + messages = os.path.join(config_dir, "messages") + addon = os.path.join(config_dir, "addon.py") + with open(addon, "w") as fp: + fp.write( + dedent( + """\ + import json + + from mitmproxy import ctx + + + def running() -> None: + port = ctx.master.addons.get("proxyserver").listen_addrs()[0][1] + with open({msg_channel!r}, "w") as fp: + json.dump({{"port": port}}, fp) + """.format( + msg_channel=messages + ) + ) + ) + return cls(mitmdump_venv=mitmdump_venv, confdir=confdir, messages=messages, addon=addon) + + mitmdump_venv = attr.ib() # type Virtualenv + confdir = attr.ib() # type: str + messages = attr.ib() # type: str + addon = attr.ib() # type: str + + @contextmanager + def reverse( + self, + targets, # type: Iterable[str] + proxy_auth=None, # type: Optional[str] + ): + # type: (...) -> Iterator[Tuple[int, str]] + os.mkfifo(self.messages) + args = [ + self.mitmdump_venv.interpreter.binary, + self.mitmdump_venv.bin_path("mitmdump"), + "--set", + "confdir={confdir}".format(confdir=self.confdir), + "-p", + "0", + "-s", + self.addon, + ] + if proxy_auth: + args.extend(["--proxyauth", proxy_auth]) + for target in targets: + args.extend(["--mode", "reverse:{target}".format(target=target)]) + proxy_process = subprocess.Popen(args) + try: + with open(self.messages, "r") as fp: + data = json.load(fp) + yield data["port"], os.path.join(self.confdir, "mitmproxy-ca.pem") + finally: + proxy_process.kill() + os.unlink(self.messages) + + @contextmanager + def run(self, proxy_auth=None): + # type: (Optional[str]) -> Iterator[Tuple[int, str]] + + with self.reverse(targets=(), proxy_auth=proxy_auth) as (port, cert): + yield port, cert diff --git a/tests/integration/cli/commands/test_lock_sync.py b/tests/integration/cli/commands/test_lock_sync.py index 8e86e1b99..673b87a80 100644 --- a/tests/integration/cli/commands/test_lock_sync.py +++ b/tests/integration/cli/commands/test_lock_sync.py @@ -290,7 +290,7 @@ def test_sync_implicit_lock_create_venv_create_run( venv_dir = os.path.join(str(tmpdir), "venv") run_sync( *(repo_args + ["cowsay==5.0", "--lock", lock, "--venv", venv_dir, "--", "cowsay", "Moo!"]) - ).assert_success(expected_output_re=r".*| Moo! |.*", re_flags=re.DOTALL) + ).assert_success(expected_output_re=r".*\| Moo! \|.*", re_flags=re.DOTALL) assert_lock_matches_venv( lock=lock, path_mappings=path_mappings, venv=venv_dir, expected_pins=[pin("cowsay", "5.0")] ) @@ -867,7 +867,7 @@ def test_sync_venv_run( ] ) ) - result.assert_success(expected_output_re=r".*| A New Moo! |.*", re_flags=re.DOTALL) + result.assert_success(expected_output_re=r".*\| A New Moo! \|.*", re_flags=re.DOTALL) # N.B.: Since the venv now matches the lock, this means Pip and its dist dependencies were # nuked, confirming the default --no-retain-pip mode. @@ -1017,11 +1017,10 @@ def test_sync_venv_run_no_retain_pip_preinstalled( lock, "--", venv.bin_path("cowsay"), - "-t", "Moo Two!", ] ) - ).assert_success(expected_output_re=r".*| Moo Two! |.*", re_flags=re.DOTALL) + ).assert_success(expected_output_re=r".*\| Moo Two! \|.*", re_flags=re.DOTALL) assert_lock_matches_venv( lock=lock, venv=venv, path_mappings=path_mappings, expected_pins=[pin("cowsay", "5.0")] ) @@ -1060,7 +1059,7 @@ def test_sync_venv_run_retain_pip_preinstalled( "A New Moo!", ] ) - ).assert_success(expected_output_re=r".*| A New Moo! |.*", re_flags=re.DOTALL) + ).assert_success(expected_output_re=r".*\| A New Moo! \|.*", re_flags=re.DOTALL) assert_lock(lock, path_mappings, expected_pins=[pin("cowsay", "6.0")]) assert_venv(venv_dir, expected_pins=[pin("cowsay", "6.0"), pip_pin]) @@ -1097,7 +1096,7 @@ def test_sync_venv_run_retain_pip_no_pip_preinstalled( "Moo!", ] ) - ).assert_success(expected_output_re=r".*| Moo! |.*", re_flags=re.DOTALL) + ).assert_success(expected_output_re=r".*\| Moo! \|.*", re_flags=re.DOTALL) lock_file = assert_lock_matches_venv( lock=lock, path_mappings=path_mappings, venv=venv_dir, expected_pins=[pin("cowsay", "5.0")] ) @@ -1117,7 +1116,7 @@ def test_sync_venv_run_retain_pip_no_pip_preinstalled( ] ) ).assert_success( - expected_output_re=r".*| Moo Two! |.*", + expected_output_re=r".*\| Moo Two! \|.*", expected_error_re=r".*No updates for lock generated by {platform}\..*".format( platform=re.escape(str(lock_file.locked_resolves[0].platform_tag)) ), @@ -1185,7 +1184,7 @@ def test_sync_venv_run_retain_user_pip( "A New Moo!", ] ) - ).assert_success(expected_output_re=r".*| A New Moo! |.*", re_flags=re.DOTALL) + ).assert_success(expected_output_re=r".*\| A New Moo! \|.*", re_flags=re.DOTALL) user_pip = find_distribution("pip", search_path=venv.sys_path, rescan=True) assert user_pip is not None diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 0dc22f995..c6d6e8b50 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -4,11 +4,8 @@ from __future__ import absolute_import import glob -import json import os import subprocess -from contextlib import contextmanager -from textwrap import dedent import pytest @@ -18,9 +15,10 @@ from pex.typing import TYPE_CHECKING from pex.venv.virtualenv import Virtualenv from testing import PY310, data, ensure_python_interpreter, make_env, run_pex_command +from testing.mitmproxy import Proxy if TYPE_CHECKING: - from typing import Any, Callable, ContextManager, Iterator, Optional, Tuple + from typing import Any, Callable, Iterator @pytest.fixture(scope="session") @@ -119,61 +117,11 @@ def mitmdump_venv(shared_integration_test_tmpdir): @pytest.fixture -def run_proxy( - mitmdump_venv, # type: Virtualenv - tmpdir, # type: Any -): - # type: (...) -> Callable[[Optional[str]], ContextManager[Tuple[int, str]]] - confdir = os.path.join(str(tmpdir), "confdir") - messages = os.path.join(str(tmpdir), "messages") - addon = os.path.join(str(tmpdir), "addon.py") - with open(addon, "w") as fp: - fp.write( - dedent( - """\ - import json - - from mitmproxy import ctx - - - def running() -> None: - port = ctx.master.addons.get("proxyserver").listen_addrs()[0][1] - with open({msg_channel!r}, "w") as fp: - json.dump({{"port": port}}, fp) - """.format( - msg_channel=messages - ) - ) - ) - - @contextmanager - def _run_proxy( - proxy_auth=None, # type: Optional[str] - ): - # type: (...) -> Iterator[Tuple[int, str]] - os.mkfifo(messages) - args = [ - mitmdump_venv.interpreter.binary, - mitmdump_venv.bin_path("mitmdump"), - "--set", - "confdir={confdir}".format(confdir=confdir), - "-p", - "0", - "-s", - addon, - ] - if proxy_auth: - args.extend(["--proxyauth", proxy_auth]) - proxy_process = subprocess.Popen(args) - try: - with open(messages, "r") as fp: - data = json.load(fp) - yield data["port"], os.path.join(confdir, "mitmproxy-ca.pem") - finally: - proxy_process.kill() - os.unlink(messages) - - return _run_proxy +def proxy(tmpdir): + # type: (Any) -> Proxy + config_dir = os.path.join(str(tmpdir), "mitmdump-cfg") + os.mkdir(config_dir) + return Proxy.configured(config_dir=config_dir) @pytest.fixture diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index e6c323972..15372960e 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -51,10 +51,11 @@ run_simple_pex_test, temporary_content, ) +from testing.mitmproxy import Proxy from testing.pep_427 import get_installable_type_flag if TYPE_CHECKING: - from typing import Any, Callable, ContextManager, Iterator, List, Optional, Text, Tuple + from typing import Any, Callable, Iterator, List, Optional, Text, Tuple def test_pex_execute(): @@ -1533,8 +1534,8 @@ def test_tmpdir_file(tmp_workdir): ) -def test_requirements_network_configuration(run_proxy, tmp_workdir): - # type: (Callable[[Optional[str]], ContextManager[Tuple[int, str]]], str) -> None +def test_requirements_network_configuration(proxy, tmp_workdir): + # type: (Proxy, str) -> None def req( contents, # type: str line_no, # type: int @@ -1551,7 +1552,7 @@ def req( ) proxy_auth = "jake:jones" - with run_proxy(proxy_auth) as (port, ca_cert): + with proxy.run(proxy_auth) as (port, ca_cert): reqs = parse_requirement_file( EXAMPLE_PYTHON_REQUIREMENTS_URL, fetcher=URLFetcher( diff --git a/tests/integration/test_issue_1537.py b/tests/integration/test_issue_1537.py index 3cfa8f6bb..9991f4b67 100644 --- a/tests/integration/test_issue_1537.py +++ b/tests/integration/test_issue_1537.py @@ -1,26 +1,29 @@ # Copyright 2021 Pex project contributors. # Licensed under the Apache License, Version 2.0 (see LICENSE). +from __future__ import absolute_import + import os.path import shutil import subprocess from pex.typing import TYPE_CHECKING from testing import run_pex_command +from testing.mitmproxy import Proxy if TYPE_CHECKING: - from typing import Any, Callable, ContextManager, Tuple + from typing import Any def test_rel_cert_path( - run_proxy, # type: Callable[[], ContextManager[Tuple[int, str]]] + proxy, # type: Proxy tmpdir, # type: Any ): # type: (...) -> None pex_file = os.path.join(str(tmpdir), "pex") workdir = os.path.join(str(tmpdir), "workdir") os.mkdir(workdir) - with run_proxy() as (port, ca_cert): + with proxy.run() as (port, ca_cert): shutil.copy(ca_cert, os.path.join(workdir, "cert")) run_pex_command( args=[ diff --git a/tests/integration/test_keyring_support.py b/tests/integration/test_keyring_support.py new file mode 100644 index 000000000..ec3bd0c4e --- /dev/null +++ b/tests/integration/test_keyring_support.py @@ -0,0 +1,332 @@ +# Copyright 2024 Pex project contributors. +# Licensed under the Apache License, Version 2.0 (see LICENSE). + +from __future__ import absolute_import, print_function + +import glob +import os +import re +import shutil +from textwrap import dedent + +import pytest + +from pex.atomic_directory import atomic_directory +from pex.common import safe_open +from pex.compatibility import urlparse +from pex.dist_metadata import DistMetadata +from pex.pep_503 import ProjectName +from pex.pip.installation import get_pip +from pex.pip.version import PipVersion, PipVersionValue +from pex.resolve import resolver_configuration +from pex.resolve.configured_resolver import ConfiguredResolver +from pex.typing import TYPE_CHECKING +from pex.venv.virtualenv import InstallationChoice, Virtualenv +from testing import PY_VER, WheelBuilder, make_env, run_pex_command +from testing.mitmproxy import Proxy + +if TYPE_CHECKING: + from typing import Any, Iterable, Mapping + + import attr # vendor:skip +else: + from pex.third_party import attr + + +@attr.s(frozen=True) +class KeyringBackend(object): + username = attr.ib() # type: str + password = attr.ib() # type: str + wheel = attr.ib() # type: str + project_name = attr.ib(init=False) # type: ProjectName + + def __attrs_post_init__(self): + # type: () -> None + object.__setattr__(self, "project_name", DistMetadata.load(self.wheel).project_name) + + @property + def basic_auth(self): + # type: () -> str + return "{username}:{password}".format(username=self.username, password=self.password) + + @property + def requirement(self): + # type: () -> str + return "{project_name} @ file://{wheel}".format( + project_name=self.project_name, wheel=self.wheel + ) + + +@pytest.fixture(scope="session") +def keyring_backend(shared_integration_test_tmpdir): + # type: (str) -> KeyringBackend + + username = "jake" + password = "jones" + + project_dir = os.path.join(shared_integration_test_tmpdir, "keyring-backend") + with atomic_directory(project_dir) as atomic_projectdir: + if not atomic_projectdir.is_finalized(): + with safe_open( + os.path.join(atomic_projectdir.work_dir, "pex_test_backend.py"), "w" + ) as fp: + fp.write( + dedent( + """\ + from keyring.backend import KeyringBackend + from keyring import credentials, errors + + + USERNAME = {username!r} + PASSWORD = {password!r} + + + class PexTestBackend(KeyringBackend): + priority = 9 + + def get_password(self, service, username): + return PASSWORD + + def get_credential(self, service, username=None): + return credentials.SimpleCredential(username or USERNAME, PASSWORD) + + def set_password(service, username, password): + raise errors.PasswordSetError("Unsupported.") + + def delete_password(service, username): + raise errors.PasswordDeleteError("Unsupported.") + """ + ).format(username=username, password=password) + ) + + with safe_open(os.path.join(atomic_projectdir.work_dir, "setup.py"), "w") as fp: + fp.write( + dedent( + """\ + from setuptools import setup + + + setup( + name="pex_test_backend", + version="0.1.0", + entry_points={ + "keyring.backends": [ + "pex_test_backend = pex_test_backend", + ], + }, + install_requires=["keyring==24.1.1"], + py_modules=["pex_test_backend"], + ) + """ + ) + ) + + WheelBuilder( + source_dir=atomic_projectdir.work_dir, wheel_dir=atomic_projectdir.work_dir + ).bdist() + + wheels = glob.glob(os.path.join(project_dir, "*.whl")) + assert 1 == len(wheels) + return KeyringBackend(username=username, password=password, wheel=wheels[0]) + + +@attr.s(frozen=True) +class KeyringVenv(object): + backend = attr.ib() # type: KeyringBackend + path_element = attr.ib() # type: str + + +@pytest.fixture(scope="session") +def keyring_venv( + shared_integration_test_tmpdir, # type: str + keyring_backend, # type: KeyringBackend +): + # type: (...) -> KeyringVenv + + keyring_venv_dir = os.path.join(shared_integration_test_tmpdir, "keyring") + with atomic_directory(keyring_venv_dir) as atomic_venvdir: + if not atomic_venvdir.is_finalized(): + Virtualenv.create_atomic( + venv_dir=atomic_venvdir, + install_pip=InstallationChoice.YES, + other_installs=[keyring_backend.requirement], + ) + return KeyringVenv(backend=keyring_backend, path_element=Virtualenv(keyring_venv_dir).bin_dir) + + +@pytest.fixture +def index_url_info(): + # type: () -> urlparse.ParseResult + index = os.environ.get("PIP_INDEX_URL", resolver_configuration.PYPI) + return urlparse.urlparse(index) + + +@pytest.fixture +def index_reverse_proxy_target(index_url_info): + # type: (urlparse.ParseResult) -> str + return str(index_url_info._replace(path="", params="", query="", fragment="").geturl()) + + +@pytest.fixture +def devpi_clean_env(): + # type: () -> Mapping[str, Any] + + # These will be set when tests are run with --devpi, and we want to unset them to + # ensure our Pex command line config above is what is used. + return dict( + _PEX_USE_PIP_CONFIG=None, + PIP_INDEX_URL=None, + PIP_TRUSTED_HOST=None, + ) + + +skip_if_required_keyring_version_not_supported = pytest.mark.skipif( + PY_VER < (3, 7), reason="The keyring distribution used for this test requires Python `>=3.7`." +) + +keyring_provider_pip_versions = pytest.mark.parametrize( + "pip_version", + [ + pytest.param(pip_version, id=str(pip_version)) + for pip_version in PipVersion.values() + # The Pip `--keyring-provider` option is only available starting in Pip 23.1. + if pip_version >= PipVersion.v23_1 and pip_version.requires_python_applies() + ], +) + + +def download_pip_requirements( + download_dir, # type: str + pip_version, # type: PipVersionValue + extra_requirements=(), # type: Iterable[str] +): + # type: (...) -> None + requirements = list(pip_version.requirements) + requirements.extend(extra_requirements) + get_pip(resolver=ConfiguredResolver.version(pip_version)).spawn_download_distributions( + download_dir=download_dir, requirements=requirements + ).wait() + + +@skip_if_required_keyring_version_not_supported +@keyring_provider_pip_versions +def test_subprocess_provider( + proxy, # type: Proxy + pip_version, # type: PipVersionValue + keyring_venv, # type: KeyringVenv + index_url_info, # type: urlparse.ParseResult + index_reverse_proxy_target, # type: str + devpi_clean_env, # type: Mapping[str, Any] + tmpdir, # type: Any +): + # type: (...) -> None + + # N.B.: Pip subprocess keyring support presents a catch-22 for Pex unless there is a + # non-authenticated source to resolve a non-vendored --pip-version from in the 1st place (since + # the Pip subprocess keyring backend is only supported in non-vendored versions of Pip); so we + # use a find-links repo pre-populated with the Pip version we need; just as a user would have + # to. + find_links = os.path.join(str(tmpdir), "find-links") + download_pip_requirements(download_dir=find_links, pip_version=pip_version) + + with proxy.reverse( + targets=[index_reverse_proxy_target], proxy_auth=keyring_venv.backend.basic_auth + ) as (port, _): + pex_root = os.path.join(str(tmpdir), "pex-root") + proxied_index = str( + index_url_info._replace( + scheme="http", + netloc="{username}@localhost:{port}".format( + username=keyring_venv.backend.username, port=port + ), + ).geturl() + ) + run_pex_command( + args=[ + "--pex-root", + pex_root, + "--runtime-pex-root", + pex_root, + "--no-pypi", + "--index", + proxied_index, + "--find-links", + find_links, + "--pip-version", + str(pip_version), + "--use-pip-config", + "cowsay==5.0", + "-c", + "cowsay", + "--", + "Subprocess Auth!", + ], + env=make_env( + PIP_KEYRING_PROVIDER="subprocess", + PATH=os.pathsep.join( + (keyring_venv.path_element, os.environ.get("PATH", os.defpath)) + ), + **devpi_clean_env + ), + ).assert_success(expected_output_re=r"^.*\| Subprocess Auth! \|.*$", re_flags=re.DOTALL) + + +@skip_if_required_keyring_version_not_supported +@keyring_provider_pip_versions +def test_import_provider( + proxy, # type: Proxy + pip_version, # type: PipVersionValue + keyring_venv, # type: KeyringVenv + index_url_info, # type: urlparse.ParseResult + index_reverse_proxy_target, # type: str + devpi_clean_env, # type: Mapping[str, Any] + tmpdir, # type: Any +): + # type: (...) -> None + + # N.B.: Pip keyring support presents a catch-22 unless there is a non-authenticated source to + # resolve the keyring distributions from in the 1st place; so we use a find-links repo + # pre-populated with the keyring dependencies we need to authenticate; just as a user would + # have to. + find_links = os.path.join(str(tmpdir), "find-links") + download_pip_requirements( + download_dir=find_links, + pip_version=pip_version, + extra_requirements=[keyring_venv.backend.wheel], + ) + shutil.copy(keyring_venv.backend.wheel, find_links) + + with proxy.reverse( + targets=[index_reverse_proxy_target], proxy_auth=keyring_venv.backend.basic_auth + ) as (port, _): + pex_root = os.path.join(str(tmpdir), "pex-root") + proxied_index = str( + index_url_info._replace( + scheme="http", + netloc="localhost:{port}".format(port=port), + ).geturl() + ) + run_pex_command( + args=[ + "--pex-root", + pex_root, + "--runtime-pex-root", + pex_root, + "--no-pypi", + "--index", + proxied_index, + "--find-links", + find_links, + "--extra-pip-requirement", + str(keyring_venv.backend.project_name), + "--pip-version", + str(pip_version), + "--use-pip-config", + "cowsay==5.0", + "-c", + "cowsay", + "--", + "Import Auth!", + ], + env=make_env(PIP_KEYRING_PROVIDER="import", **devpi_clean_env), + ).assert_success(expected_output_re=r"^.*\| Import Auth! \|.*$", re_flags=re.DOTALL) diff --git a/tests/test_pip.py b/tests/test_pip.py index 4faf14df4..0bc0bbe15 100644 --- a/tests/test_pip.py +++ b/tests/test_pip.py @@ -323,6 +323,7 @@ def test_pip_pex_interpreter_venv_hash_issue_1885( installation = PipInstallation( interpreter=current_interpreter, version=PipVersion.DEFAULT, + extra_requirements=(), ) _PIP.pop(installation, None) binary = current_interpreter.binary