Skip to content

Commit 3ddc9f6

Browse files
committed
Add an interface to allow calling system keyring
1 parent 90f51db commit 3ddc9f6

File tree

1 file changed

+61
-4
lines changed

1 file changed

+61
-4
lines changed

src/pip/_internal/network/auth.py

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

7+
import shutil
8+
import subprocess
79
import urllib.parse
8-
from typing import Any, Dict, List, Optional, Tuple
10+
from typing import Any, Dict, List, Optional, Tuple, NamedTuple
911

1012
from pip._vendor.requests.auth import AuthBase, HTTPBasicAuth
1113
from pip._vendor.requests.models import Request, Response
@@ -23,11 +25,58 @@
2325

2426
logger = getLogger(__name__)
2527

26-
Credentials = Tuple[str, str, str]
28+
class Credentials(NamedTuple):
29+
service_name: str
30+
username: str
31+
password: str
32+
33+
34+
class KeyRingCredential(NamedTuple):
35+
username: str
36+
password: str
37+
38+
39+
class KeyRingCli:
40+
"""Mirror the parts of keyring's API which pip uses
41+
42+
Instead of calling the keyring package installed alongside pip
43+
we call keyring on the command line which will enable pip to
44+
use which ever installation of keyring is available first in
45+
PATH.
46+
"""
47+
@staticmethod
48+
def _quote(string: Optional[str]) -> str:
49+
return f'\'{string}\''
50+
51+
def get_credential(
52+
self, service_name: str, username: Optional[str]
53+
) -> Optional[KeyRingCredential]:
54+
cmd = ['keyring', 'get', self._quote(service_name), self._quote(username)]
55+
res = subprocess.run(cmd)
56+
if res.returncode:
57+
return None
58+
return KeyRingCredential(username=username, password=res.stdout)
59+
60+
def set_password(self, service_name: str, username: str, password: str) -> None:
61+
cmd = [
62+
'echo',
63+
self._quote(password),
64+
'|',
65+
'keyring',
66+
'set',
67+
self._quote(service_name),
68+
self._quote(username),
69+
]
70+
res = subprocess.run(cmd)
71+
if res.returncode:
72+
raise RuntimeError(res.stderr)
73+
return None
2774

2875
try:
2976
import keyring
3077
except ImportError:
78+
if shutil.which('keyring') is not None:
79+
keyring = KeyRingCli()
3180
keyring = None # type: ignore[assignment]
3281
except Exception as exc:
3382
logger.warning(
@@ -276,7 +325,11 @@ def handle_401(self, resp: Response, **kwargs: Any) -> Response:
276325

277326
# Prompt to save the password to keyring
278327
if save and self._should_save_password_to_keyring():
279-
self._credentials_to_save = (parsed.netloc, username, password)
328+
self._credentials_to_save = Credentials(
329+
service_name=parsed.netloc,
330+
username=username,
331+
password=password,
332+
)
280333

281334
# Consume content and release the original connection to allow our new
282335
# request to reuse the same one.
@@ -318,6 +371,10 @@ def save_credentials(self, resp: Response, **kwargs: Any) -> None:
318371
if creds and resp.status_code < 400:
319372
try:
320373
logger.info("Saving credentials to keyring")
321-
keyring.set_password(*creds)
374+
keyring.set_password(
375+
creds.service_name,
376+
creds.username,
377+
creds.password
378+
)
322379
except Exception:
323380
logger.exception("Failed to save credentials")

0 commit comments

Comments
 (0)