|
4 | 4 | providing credentials in the context of network requests.
|
5 | 5 | """
|
6 | 6 |
|
| 7 | +import shutil |
| 8 | +import subprocess |
7 | 9 | import urllib.parse
|
8 |
| -from typing import Any, Dict, List, Optional, Tuple |
| 10 | +from typing import Any, Dict, List, NamedTuple, Optional, Tuple |
9 | 11 |
|
10 | 12 | from pip._vendor.requests.auth import AuthBase, HTTPBasicAuth
|
11 | 13 | from pip._vendor.requests.models import Request, Response
|
|
23 | 25 |
|
24 | 26 | logger = getLogger(__name__)
|
25 | 27 |
|
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 | + |
27 | 77 |
|
28 | 78 | try:
|
29 | 79 | import keyring
|
30 | 80 | except ImportError:
|
| 81 | + if shutil.which("keyring") is not None: |
| 82 | + keyring = KeyRingCli() |
31 | 83 | keyring = None # type: ignore[assignment]
|
32 | 84 | except Exception as exc:
|
33 | 85 | logger.warning(
|
@@ -276,7 +328,11 @@ def handle_401(self, resp: Response, **kwargs: Any) -> Response:
|
276 | 328 |
|
277 | 329 | # Prompt to save the password to keyring
|
278 | 330 | 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 | + ) |
280 | 336 |
|
281 | 337 | # Consume content and release the original connection to allow our new
|
282 | 338 | # request to reuse the same one.
|
@@ -318,6 +374,6 @@ def save_credentials(self, resp: Response, **kwargs: Any) -> None:
|
318 | 374 | if creds and resp.status_code < 400:
|
319 | 375 | try:
|
320 | 376 | logger.info("Saving credentials to keyring")
|
321 |
| - keyring.set_password(*creds) |
| 377 | + keyring.set_password(creds.service_name, creds.username, creds.password) |
322 | 378 | except Exception:
|
323 | 379 | logger.exception("Failed to save credentials")
|
0 commit comments