Skip to content

Commit b87ddb9

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

File tree

1 file changed

+60
-4
lines changed

1 file changed

+60
-4
lines changed

src/pip/_internal/network/auth.py

+60-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, NamedTuple, Optional, Tuple
911

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

2426
logger = getLogger(__name__)
2527

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

2878
try:
2979
import keyring
3080
except ImportError:
81+
if shutil.which("keyring") is not None:
82+
keyring = KeyRingCli()
3183
keyring = None # type: ignore[assignment]
3284
except Exception as exc:
3385
logger.warning(
@@ -276,7 +328,11 @@ def handle_401(self, resp: Response, **kwargs: Any) -> Response:
276328

277329
# Prompt to save the password to keyring
278330
if save and self._should_save_password_to_keyring():
279-
self._credentials_to_save = (parsed.netloc, username, password)
331+
self._credentials_to_save = Credentials(
332+
service_name=parsed.netloc,
333+
username=username,
334+
password=password,
335+
)
280336

281337
# Consume content and release the original connection to allow our new
282338
# request to reuse the same one.
@@ -318,6 +374,6 @@ def save_credentials(self, resp: Response, **kwargs: Any) -> None:
318374
if creds and resp.status_code < 400:
319375
try:
320376
logger.info("Saving credentials to keyring")
321-
keyring.set_password(*creds)
377+
keyring.set_password(creds.service_name, creds.username, creds.password)
322378
except Exception:
323379
logger.exception("Failed to save credentials")

0 commit comments

Comments
 (0)