|
4 | 4 | providing credentials in the context of network requests.
|
5 | 5 | """
|
6 | 6 |
|
| 7 | +import os |
| 8 | +import shutil |
| 9 | +import subprocess |
7 | 10 | 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 |
9 | 13 |
|
10 | 14 | from pip._vendor.requests.auth import AuthBase, HTTPBasicAuth
|
11 | 15 | from pip._vendor.requests.models import Request, Response
|
|
23 | 27 |
|
24 | 28 | logger = getLogger(__name__)
|
25 | 29 |
|
26 |
| -Credentials = Tuple[str, str, str] |
| 30 | +KEYRING_DISABLED = False |
27 | 31 |
|
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] |
38 | 32 |
|
| 33 | +class Credentials(NamedTuple): |
| 34 | + url: str |
| 35 | + username: str |
| 36 | + password: str |
39 | 37 |
|
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]: |
44 | 55 | return None
|
45 | 56 |
|
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"): |
52 | 74 | logger.debug("Getting credentials from keyring for %s", url)
|
53 |
| - cred = get_credential(url, username) |
| 75 | + cred = self.keyring.get_credential(url, username) |
54 | 76 | if cred is not None:
|
55 | 77 | return cred.username, cred.password
|
56 | 78 | return None
|
57 | 79 |
|
58 |
| - if username: |
| 80 | + if username is not None: |
59 | 81 | logger.debug("Getting password from keyring for %s", url)
|
60 |
| - password = keyring.get_password(url, username) |
| 82 | + password = self.keyring.get_password(url, username) |
61 | 83 | if password:
|
62 | 84 | 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 |
63 | 111 |
|
| 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) |
64 | 181 | except Exception as exc:
|
65 | 182 | logger.warning(
|
66 | 183 | "Keyring is skipped due to an exception: %s",
|
67 | 184 | str(exc),
|
68 | 185 | )
|
69 |
| - keyring = None # type: ignore[assignment] |
70 |
| - return None |
| 186 | + global KEYRING_DISABLED |
| 187 | + KEYRING_DISABLED = True |
| 188 | + return None |
71 | 189 |
|
72 | 190 |
|
73 | 191 | class MultiDomainBasicAuth(AuthBase):
|
@@ -241,7 +359,7 @@ def _prompt_for_password(
|
241 | 359 |
|
242 | 360 | # Factored out to allow for easy patching in tests
|
243 | 361 | def _should_save_password_to_keyring(self) -> bool:
|
244 |
| - if not keyring: |
| 362 | + if get_keyring_provider() is None: |
245 | 363 | return False
|
246 | 364 | return ask("Save credentials to keyring [y/N]: ", ["y", "n"]) == "y"
|
247 | 365 |
|
@@ -276,7 +394,11 @@ def handle_401(self, resp: Response, **kwargs: Any) -> Response:
|
276 | 394 |
|
277 | 395 | # Prompt to save the password to keyring
|
278 | 396 | 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 | + ) |
280 | 402 |
|
281 | 403 | # Consume content and release the original connection to allow our new
|
282 | 404 | # request to reuse the same one.
|
@@ -309,15 +431,16 @@ def warn_on_401(self, resp: Response, **kwargs: Any) -> None:
|
309 | 431 |
|
310 | 432 | def save_credentials(self, resp: Response, **kwargs: Any) -> None:
|
311 | 433 | """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" |
315 | 438 |
|
316 | 439 | creds = self._credentials_to_save
|
317 | 440 | self._credentials_to_save = None
|
318 | 441 | if creds and resp.status_code < 400:
|
319 | 442 | try:
|
320 | 443 | logger.info("Saving credentials to keyring")
|
321 |
| - keyring.set_password(*creds) |
| 444 | + keyring.save_auth_info(creds.url, creds.username, creds.password) |
322 | 445 | except Exception:
|
323 | 446 | logger.exception("Failed to save credentials")
|
0 commit comments