Skip to content

Commit bd782bf

Browse files
author
Dos Moonen
committed
Make it possible to request a keyring provider: disabled, import or subprocess
1 parent 7fd045c commit bd782bf

File tree

8 files changed

+279
-54
lines changed

8 files changed

+279
-54
lines changed

docs/html/topics/authentication.md

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,17 +66,79 @@ man pages][netrc-docs].
6666
## Keyring Support
6767

6868
pip supports loading credentials stored in your keyring using the
69-
{pypi}`keyring` library.
69+
{pypi}`keyring` library which can be enabled py passing `--keyring-provider`
70+
with a value of `import`, `subprocess`. Or `disabled` if you configured either
71+
of the first two to be used via `pip config` and want to turn it off again.
72+
73+
### Configuring Pip
74+
Passing this as a command line argument will work, but is not how the majority
75+
of this feature's users will use it. They instead will want to overwrite the
76+
default of `disabled` in the global, user of site configuration file:
77+
```bash
78+
$ pip config set --global global.keyring-provider subprocess
79+
80+
# A different user on the same system which has PYTHONPATH configured and and
81+
# wanting to use keyring installed that way could then run
82+
$ pip config set --user global.keyring-provider import
83+
84+
# For a specific virtual environment you might want to use disable it again
85+
# because you will only be using PyPI and the private repo (and mirror)
86+
# requires 2FA with a keycard and a pincode
87+
$ pip config set --site global.index https://pypi.org/simple
88+
$ pip config set --site global.keyring-provider disabled
89+
90+
# configuring it via environment variable is also possible
91+
$ export PIP_KEYRING_PROVIDER=disabled
92+
```
93+
94+
### Installing and using the keyring python module
95+
96+
Setting it to `import` tries to communicate with `keyring` by importing it
97+
and using its Python api.
7098

7199
```bash
72-
$ pip install keyring # install keyring from PyPI
100+
# install keyring from PyPI
101+
$ pip install keyring --index-url https://pypi.org/simple
73102
$ echo "your-password" | keyring set pypi.company.com your-username
74-
$ pip install your-package --index-url https://pypi.company.com/
103+
$ pip install your-package --keyring-provider import --index-url https://pypi.company.com/
104+
```
105+
106+
### Installing and using the keyring cli application
107+
108+
Setting it to `subprocess` will look for a `keyring` executable on the PATH
109+
if one can be found that is different from the `keyring` installation `import`
110+
would be using.
111+
112+
The cli requires a username, therefor you MUST put a username in the url.
113+
See the example below or the basic HTTP authentication section at the top of
114+
this page.
115+
116+
```bash
117+
# install keyring from PyPI using pipx, which we assume if installed properly
118+
# you can also create a venv somewhere and add it to the PATH yourself instead
119+
$ pipx install keyring --index-url https://pypi.org/simple
120+
121+
# install the keyring backend for Azure DevOps for example
122+
$ VssSessionToken is the username you MUST use for this backend
123+
$ pipx inject keyring artifacts-keyring --index-url https://pypi.org/simple
124+
125+
# or the one for Google Artifact Registry
126+
$ pipx inject keyring keyrings.google-artifactregistry-auth --index-url https://pypi.org/simple
127+
$ gcloud auth login
128+
129+
$ pip install your-package --keyring-provider subprocess --index-url https://[email protected]/
75130
```
76131

132+
### Here be dragons
133+
77134
Pip is conservative and does not query keyring at all when `--no-input` is used
78135
because the keyring might require user interaction such as prompting the user
79-
on the console. You can force keyring usage by passing `--force-keyring` or one
136+
on the console. Third party tools frequently call Pip for you and do indeed pass
137+
`--no-input` as they are well-behaved and don't have much information to work
138+
with. (Keyring does have an api to request a backend that does not require user
139+
input.) You have more information about your system, however!
140+
141+
You can force keyring usage by passing `--force-keyring` or one
80142
of the following:
81143

82144
```bash

news/8719.feature.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Add ``--keyring-provider`` flag. ``keyring`` support is now disabled by default
2+
due to the default value for this flag being ``default``. To enable previous
3+
importing behaviour use ``import``. For the new CLI subprocess behaviour use
4+
``subprocess``. See the Authentication page in the documentation for more info.

src/pip/_internal/cli/cmdoptions.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,19 @@ class PipOption(Option):
244244
help="Disable prompting for input.",
245245
)
246246

247+
keyring_provider: Callable[..., Option] = partial(
248+
Option,
249+
"--keyring-provider",
250+
dest="keyring_provider",
251+
choices=["disabled", "import", "subprocess"],
252+
default="disabled",
253+
help=(
254+
"Enable the credential lookup via the keyring library if user input is allowed."
255+
" Specify which mechanism to use [disabled, import, subprocess]."
256+
" (default: disabled)"
257+
),
258+
)
259+
247260
force_keyring: Callable[..., Option] = partial(
248261
Option,
249262
"--force-keyring",
@@ -253,7 +266,8 @@ class PipOption(Option):
253266
help=(
254267
"Always query the keyring, regardless of pip's --no-input option. Note"
255268
" that this may cause problems if the keyring expects to be able to"
256-
" prompt the user interactively and no interactive user is available."
269+
" prompt the user interactively and no interactive user is available"
270+
" or is unable to observe the communication channel."
257271
),
258272
)
259273

@@ -1032,6 +1046,7 @@ def check_list_path_option(options: Values) -> None:
10321046
quiet,
10331047
log,
10341048
no_input,
1049+
keyring_provider,
10351050
force_keyring,
10361051
proxy,
10371052
retries,

src/pip/_internal/cli/req_command.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ def _build_session(
151151

152152
# Determine if we can prompt the user for authentication or not
153153
session.auth.prompting = not options.no_input
154+
session.auth.keyring_provider = options.keyring_provider
154155
# We won't use keyring when --no-input is passed unless
155156
# --force-keyring is passed as well because it might require
156157
# user interaction

src/pip/_internal/network/auth.py

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@
33
Contains interface (MultiDomainBasicAuth) and associated glue code for
44
providing credentials in the context of network requests.
55
"""
6-
6+
import functools
77
import os
88
import shutil
99
import subprocess
10+
import sys
11+
import sysconfig
12+
import typing
1013
import urllib.parse
1114
from abc import ABC, abstractmethod
15+
from functools import lru_cache
16+
from pathlib import Path
1217
from typing import Any, Dict, List, NamedTuple, Optional, Tuple
1318

1419
from pip._vendor.requests.auth import AuthBase, HTTPBasicAuth
@@ -123,7 +128,8 @@ def _get_password(self, service_name: str, username: str) -> Optional[str]:
123128
res = subprocess.run(
124129
cmd,
125130
stdin=subprocess.DEVNULL,
126-
capture_output=True,
131+
stdout=subprocess.PIPE,
132+
stderr=sys.stderr,
127133
env=env,
128134
)
129135
if res.returncode:
@@ -144,12 +150,19 @@ def _set_password(self, service_name: str, username: str, password: str) -> None
144150
return None
145151

146152

147-
def get_keyring_provider() -> KeyRingBaseProvider:
153+
@lru_cache(maxsize=None)
154+
def get_keyring_provider(provider: str) -> KeyRingBaseProvider:
155+
logger.verbose("Keyring provider requested: %s", provider)
156+
148157
# keyring has previously failed and been disabled
149-
if not KEYRING_DISABLED:
150-
# Default to trying to use Python provider
158+
if KEYRING_DISABLED or provider == "disabled":
159+
pass
160+
elif provider == "import":
151161
try:
152-
return KeyRingPythonProvider()
162+
try:
163+
return KeyRingPythonProvider()
164+
finally:
165+
logger.verbose("Keyring provider set: import")
153166
except ImportError:
154167
pass
155168
except Exception as exc:
@@ -160,22 +173,54 @@ def get_keyring_provider() -> KeyRingBaseProvider:
160173
"trying to find a keyring executable as a fallback",
161174
str(exc),
162175
)
163-
164-
# Fallback to Cli Provider if `keyring` isn't installed
176+
elif provider == "subprocess":
165177
cli = shutil.which("keyring")
178+
if cli and cli.startswith(sysconfig.get_path("scripts")):
179+
# all code within this function is stolen from shutil.which implementation
180+
@typing.no_type_check
181+
def PATH_as_shutil_which_determines_it() -> str:
182+
path = os.environ.get("PATH", None)
183+
if path is None:
184+
try:
185+
path = os.confstr("CS_PATH")
186+
except (AttributeError, ValueError):
187+
# os.confstr() or CS_PATH is not available
188+
path = os.defpath
189+
# bpo-35755: Don't use os.defpath if the PATH environment variable is
190+
# set to an empty string
191+
192+
return path
193+
194+
scripts = Path(sysconfig.get_path("scripts")).resolve()
195+
196+
paths = []
197+
for path in PATH_as_shutil_which_determines_it().split(os.pathsep):
198+
p = Path(path)
199+
if p.exists() and not p.resolve().samefile(scripts):
200+
paths.append(path)
201+
202+
path = os.pathsep.join(paths)
203+
204+
cli = shutil.which("keyring", path=path)
205+
166206
if cli:
207+
logger.verbose("Keyring provider set: subprocess with executable %s", cli)
167208
return KeyRingCliProvider(cli)
209+
else:
210+
logger.verbose("Unknown keyring provider requested: %s", provider)
168211

212+
logger.verbose("Keyring provider set: disabled")
169213
return KeyRingNullProvider()
170214

171215

172-
def get_keyring_auth(url: Optional[str], username: Optional[str]) -> Optional[AuthInfo]:
216+
def get_keyring_auth(
217+
keyring: KeyRingBaseProvider, url: Optional[str], username: Optional[str]
218+
) -> Optional[AuthInfo]:
173219
"""Return the tuple auth for a given url from keyring."""
174220
# Do nothing if no url was provided
175221
if not url:
176222
return None
177223

178-
keyring = get_keyring_provider()
179224
try:
180225
return keyring.get_auth_info(url, username)
181226
except Exception as exc:
@@ -185,6 +230,7 @@ def get_keyring_auth(url: Optional[str], username: Optional[str]) -> Optional[Au
185230
)
186231
global KEYRING_DISABLED
187232
KEYRING_DISABLED = True
233+
get_keyring_provider.cache_clear()
188234
return None
189235

190236

@@ -193,10 +239,12 @@ def __init__(
193239
self,
194240
prompting: bool = True,
195241
index_urls: Optional[List[str]] = None,
242+
keyring_provider: str = "disabled",
196243
use_keyring: bool = True,
197244
) -> None:
198245
self.prompting = prompting
199246
self.index_urls = index_urls
247+
self.keyring_provider = keyring_provider # type: ignore[assignment]
200248
self.use_keyring = use_keyring
201249
self.passwords: Dict[str, AuthInfo] = {}
202250
# When the user is prompted to enter credentials and keyring is
@@ -206,6 +254,18 @@ def __init__(
206254
# ``save_credentials`` to save these.
207255
self._credentials_to_save: Optional[Credentials] = None
208256

257+
@property
258+
def keyring_provider(self) -> KeyRingBaseProvider:
259+
return self._keyring_provider()
260+
261+
@keyring_provider.setter
262+
def keyring_provider(self, provider: str) -> None:
263+
# The free function get_keyring_provider has been decorated with
264+
# functools.cache. If an exception occurs in get_keyring_auth that
265+
# cache will be cleared and keyring disabled, take that into account
266+
# if you want to remove this indirection.
267+
self._keyring_provider = functools.partial(get_keyring_provider, provider)
268+
209269
def _get_index_url(self, url: str) -> Optional[str]:
210270
"""Return the original index URL matching the requested URL.
211271
@@ -275,8 +335,8 @@ def _get_new_credentials(
275335
# The index url is more specific than the netloc, so try it first
276336
# fmt: off
277337
kr_auth = (
278-
get_keyring_auth(index_url, username) or
279-
get_keyring_auth(netloc, username)
338+
get_keyring_auth(self.keyring_provider, index_url, username) or
339+
get_keyring_auth(self.keyring_provider, netloc, username)
280340
)
281341
# fmt: on
282342
if kr_auth:
@@ -356,15 +416,15 @@ def _prompt_for_password(
356416
username = ask_input(f"User for {netloc}: ") if self.prompting else None
357417
if not username:
358418
return None, None, False
359-
auth = get_keyring_auth(netloc, username)
419+
auth = get_keyring_auth(self.keyring_provider, netloc, username)
360420
if auth and auth[0] is not None and auth[1] is not None:
361421
return auth[0], auth[1], False
362422
password = ask_password("Password: ")
363423
return username, password, True
364424

365425
# Factored out to allow for easy patching in tests
366426
def _should_save_password_to_keyring(self) -> bool:
367-
if not self.prompting or get_keyring_provider() is None:
427+
if not self.prompting or isinstance(self.keyring_provider, KeyRingNullProvider):
368428
return False
369429
return ask("Save credentials to keyring [y/N]: ", ["y", "n"]) == "y"
370430

@@ -439,16 +499,17 @@ def warn_on_401(self, resp: Response, **kwargs: Any) -> None:
439499

440500
def save_credentials(self, resp: Response, **kwargs: Any) -> None:
441501
"""Response callback to save credentials on success."""
442-
keyring = get_keyring_provider()
443502
assert not isinstance(
444-
keyring, KeyRingNullProvider
503+
self.keyring_provider, KeyRingNullProvider
445504
), "should never reach here without keyring"
446505

447506
creds = self._credentials_to_save
448507
self._credentials_to_save = None
449508
if creds and resp.status_code < 400:
450509
try:
451510
logger.info("Saving credentials to keyring")
452-
keyring.save_auth_info(creds.url, creds.username, creds.password)
511+
self.keyring_provider.save_auth_info(
512+
creds.url, creds.username, creds.password
513+
)
453514
except Exception:
454515
logger.exception("Failed to save credentials")

tests/conftest.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from pathlib import Path
1111
from typing import (
1212
TYPE_CHECKING,
13+
AnyStr,
1314
Callable,
1415
Dict,
1516
Iterable,
@@ -507,7 +508,10 @@ def with_wheel(virtualenv: VirtualEnvironment, wheel_install: Path) -> None:
507508

508509
class ScriptFactory(Protocol):
509510
def __call__(
510-
self, tmpdir: Path, virtualenv: Optional[VirtualEnvironment] = None
511+
self,
512+
tmpdir: Path,
513+
virtualenv: Optional[VirtualEnvironment] = None,
514+
environ: Optional[Dict[AnyStr, AnyStr]] = None,
511515
) -> PipTestEnvironment:
512516
...
513517

@@ -521,7 +525,11 @@ def script_factory(
521525
def factory(
522526
tmpdir: Path,
523527
virtualenv: Optional[VirtualEnvironment] = None,
528+
environ: Optional[Dict[AnyStr, AnyStr]] = None,
524529
) -> PipTestEnvironment:
530+
kwargs = {}
531+
if environ:
532+
kwargs["environ"] = environ
525533
if virtualenv is None:
526534
virtualenv = virtualenv_factory(tmpdir.joinpath("venv"))
527535
return PipTestEnvironment(
@@ -541,6 +549,7 @@ def factory(
541549
pip_expect_warning=deprecated_python,
542550
# Tell the Test Environment if we want to run pip via a zipapp
543551
zipapp=zipapp,
552+
**kwargs,
544553
)
545554

546555
return factory

0 commit comments

Comments
 (0)