Skip to content

Commit 1b9cc0f

Browse files
authored
Merge pull request #11589 from judahrand/keyring-cli
Closes #11588
2 parents 90f51db + 623ac5d commit 1b9cc0f

File tree

3 files changed

+314
-41
lines changed

3 files changed

+314
-41
lines changed

news/11589.feature.rst

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Enable the use of ``keyring`` found on ``PATH``. This allows ``keyring``
2+
installed using ``pipx`` to be used by ``pip``.

src/pip/_internal/network/auth.py

+156-33
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@
44
providing credentials in the context of network requests.
55
"""
66

7+
import os
8+
import shutil
9+
import subprocess
710
import urllib.parse
8-
from typing import Any, Dict, List, Optional, Tuple
11+
from abc import ABC, abstractmethod
12+
from typing import Any, Dict, List, NamedTuple, Optional, Tuple
913

1014
from pip._vendor.requests.auth import AuthBase, HTTPBasicAuth
1115
from pip._vendor.requests.models import Request, Response
@@ -23,51 +27,165 @@
2327

2428
logger = getLogger(__name__)
2529

26-
Credentials = Tuple[str, str, str]
30+
KEYRING_DISABLED = False
2731

28-
try:
29-
import keyring
30-
except ImportError:
31-
keyring = None # type: ignore[assignment]
32-
except Exception as exc:
33-
logger.warning(
34-
"Keyring is skipped due to an exception: %s",
35-
str(exc),
36-
)
37-
keyring = None # type: ignore[assignment]
3832

33+
class Credentials(NamedTuple):
34+
url: str
35+
username: str
36+
password: str
3937

40-
def get_keyring_auth(url: Optional[str], username: Optional[str]) -> Optional[AuthInfo]:
41-
"""Return the tuple auth for a given url from keyring."""
42-
global keyring
43-
if not url or not keyring:
38+
39+
class KeyRingBaseProvider(ABC):
40+
"""Keyring base provider interface"""
41+
42+
@abstractmethod
43+
def get_auth_info(self, url: str, username: Optional[str]) -> Optional[AuthInfo]:
44+
...
45+
46+
@abstractmethod
47+
def save_auth_info(self, url: str, username: str, password: str) -> None:
48+
...
49+
50+
51+
class KeyRingNullProvider(KeyRingBaseProvider):
52+
"""Keyring null provider"""
53+
54+
def get_auth_info(self, url: str, username: Optional[str]) -> Optional[AuthInfo]:
4455
return None
4556

46-
try:
47-
try:
48-
get_credential = keyring.get_credential
49-
except AttributeError:
50-
pass
51-
else:
57+
def save_auth_info(self, url: str, username: str, password: str) -> None:
58+
return None
59+
60+
61+
class KeyRingPythonProvider(KeyRingBaseProvider):
62+
"""Keyring interface which uses locally imported `keyring`"""
63+
64+
def __init__(self) -> None:
65+
import keyring
66+
67+
self.keyring = keyring
68+
69+
def get_auth_info(self, url: str, username: Optional[str]) -> Optional[AuthInfo]:
70+
# Support keyring's get_credential interface which supports getting
71+
# credentials without a username. This is only available for
72+
# keyring>=15.2.0.
73+
if hasattr(self.keyring, "get_credential"):
5274
logger.debug("Getting credentials from keyring for %s", url)
53-
cred = get_credential(url, username)
75+
cred = self.keyring.get_credential(url, username)
5476
if cred is not None:
5577
return cred.username, cred.password
5678
return None
5779

58-
if username:
80+
if username is not None:
5981
logger.debug("Getting password from keyring for %s", url)
60-
password = keyring.get_password(url, username)
82+
password = self.keyring.get_password(url, username)
6183
if password:
6284
return username, password
85+
return None
86+
87+
def save_auth_info(self, url: str, username: str, password: str) -> None:
88+
self.keyring.set_password(url, username, password)
89+
90+
91+
class KeyRingCliProvider(KeyRingBaseProvider):
92+
"""Provider which uses `keyring` cli
93+
94+
Instead of calling the keyring package installed alongside pip
95+
we call keyring on the command line which will enable pip to
96+
use which ever installation of keyring is available first in
97+
PATH.
98+
"""
99+
100+
def __init__(self, cmd: str) -> None:
101+
self.keyring = cmd
102+
103+
def get_auth_info(self, url: str, username: Optional[str]) -> Optional[AuthInfo]:
104+
# This is the default implementation of keyring.get_credential
105+
# https://github.com/jaraco/keyring/blob/97689324abcf01bd1793d49063e7ca01e03d7d07/keyring/backend.py#L134-L139
106+
if username is not None:
107+
password = self._get_password(url, username)
108+
if password is not None:
109+
return username, password
110+
return None
63111

112+
def save_auth_info(self, url: str, username: str, password: str) -> None:
113+
return self._set_password(url, username, password)
114+
115+
def _get_password(self, service_name: str, username: str) -> Optional[str]:
116+
"""Mirror the implemenation of keyring.get_password using cli"""
117+
if self.keyring is None:
118+
return None
119+
120+
cmd = [self.keyring, "get", service_name, username]
121+
env = os.environ.copy()
122+
env["PYTHONIOENCODING"] = "utf-8"
123+
res = subprocess.run(
124+
cmd,
125+
stdin=subprocess.DEVNULL,
126+
capture_output=True,
127+
env=env,
128+
)
129+
if res.returncode:
130+
return None
131+
return res.stdout.decode("utf-8").strip("\n")
132+
133+
def _set_password(self, service_name: str, username: str, password: str) -> None:
134+
"""Mirror the implemenation of keyring.set_password using cli"""
135+
if self.keyring is None:
136+
return None
137+
138+
cmd = [self.keyring, "set", service_name, username]
139+
input_ = password.encode("utf-8") + b"\n"
140+
env = os.environ.copy()
141+
env["PYTHONIOENCODING"] = "utf-8"
142+
res = subprocess.run(cmd, input=input_, env=env)
143+
res.check_returncode()
144+
return None
145+
146+
147+
def get_keyring_provider() -> KeyRingBaseProvider:
148+
# keyring has previously failed and been disabled
149+
if not KEYRING_DISABLED:
150+
# Default to trying to use Python provider
151+
try:
152+
return KeyRingPythonProvider()
153+
except ImportError:
154+
pass
155+
except Exception as exc:
156+
# In the event of an unexpected exception
157+
# we should warn the user
158+
logger.warning(
159+
"Installed copy of keyring fails with exception %s, "
160+
"trying to find a keyring executable as a fallback",
161+
str(exc),
162+
)
163+
164+
# Fallback to Cli Provider if `keyring` isn't installed
165+
cli = shutil.which("keyring")
166+
if cli:
167+
return KeyRingCliProvider(cli)
168+
169+
return KeyRingNullProvider()
170+
171+
172+
def get_keyring_auth(url: Optional[str], username: Optional[str]) -> Optional[AuthInfo]:
173+
"""Return the tuple auth for a given url from keyring."""
174+
# Do nothing if no url was provided
175+
if not url:
176+
return None
177+
178+
keyring = get_keyring_provider()
179+
try:
180+
return keyring.get_auth_info(url, username)
64181
except Exception as exc:
65182
logger.warning(
66183
"Keyring is skipped due to an exception: %s",
67184
str(exc),
68185
)
69-
keyring = None # type: ignore[assignment]
70-
return None
186+
global KEYRING_DISABLED
187+
KEYRING_DISABLED = True
188+
return None
71189

72190

73191
class MultiDomainBasicAuth(AuthBase):
@@ -241,7 +359,7 @@ def _prompt_for_password(
241359

242360
# Factored out to allow for easy patching in tests
243361
def _should_save_password_to_keyring(self) -> bool:
244-
if not keyring:
362+
if get_keyring_provider() is None:
245363
return False
246364
return ask("Save credentials to keyring [y/N]: ", ["y", "n"]) == "y"
247365

@@ -276,7 +394,11 @@ def handle_401(self, resp: Response, **kwargs: Any) -> Response:
276394

277395
# Prompt to save the password to keyring
278396
if save and self._should_save_password_to_keyring():
279-
self._credentials_to_save = (parsed.netloc, username, password)
397+
self._credentials_to_save = Credentials(
398+
url=parsed.netloc,
399+
username=username,
400+
password=password,
401+
)
280402

281403
# Consume content and release the original connection to allow our new
282404
# request to reuse the same one.
@@ -309,15 +431,16 @@ def warn_on_401(self, resp: Response, **kwargs: Any) -> None:
309431

310432
def save_credentials(self, resp: Response, **kwargs: Any) -> None:
311433
"""Response callback to save credentials on success."""
312-
assert keyring is not None, "should never reach here without keyring"
313-
if not keyring:
314-
return
434+
keyring = get_keyring_provider()
435+
assert not isinstance(
436+
keyring, KeyRingNullProvider
437+
), "should never reach here without keyring"
315438

316439
creds = self._credentials_to_save
317440
self._credentials_to_save = None
318441
if creds and resp.status_code < 400:
319442
try:
320443
logger.info("Saving credentials to keyring")
321-
keyring.set_password(*creds)
444+
keyring.save_auth_info(creds.url, creds.username, creds.password)
322445
except Exception:
323446
logger.exception("Failed to save credentials")

0 commit comments

Comments
 (0)