|
4 | 4 | providing credentials in the context of network requests.
|
5 | 5 | """
|
6 | 6 |
|
| 7 | +import os |
7 | 8 | import shutil
|
8 | 9 | import subprocess
|
9 | 10 | import urllib.parse
|
10 |
| -from typing import Any, Dict, List, NamedTuple, Optional, Tuple |
| 11 | +from abc import ABC, abstractmethod |
| 12 | +from typing import Any, Dict, List, NamedTuple, Optional, Tuple, Type |
11 | 13 |
|
12 | 14 | from pip._vendor.requests.auth import AuthBase, HTTPBasicAuth
|
13 | 15 | from pip._vendor.requests.models import Request, Response
|
|
27 | 29 |
|
28 | 30 |
|
29 | 31 | class Credentials(NamedTuple):
|
30 |
| - service_name: str |
| 32 | + url: str |
31 | 33 | username: str
|
32 | 34 | password: str
|
33 | 35 |
|
34 | 36 |
|
35 |
| -class KeyRingCli: |
36 |
| - """Mirror the parts of keyring's API which pip uses |
| 37 | +class KeyRingBaseProvider(ABC): |
| 38 | + """Keyring base provider interface""" |
| 39 | + |
| 40 | + @classmethod |
| 41 | + @abstractmethod |
| 42 | + def is_available(cls) -> bool: |
| 43 | + ... |
| 44 | + |
| 45 | + @classmethod |
| 46 | + @abstractmethod |
| 47 | + def get_auth_info(cls, url: str, username: Optional[str]) -> Optional[AuthInfo]: |
| 48 | + ... |
| 49 | + |
| 50 | + @classmethod |
| 51 | + @abstractmethod |
| 52 | + def save_auth_info(cls, url: str, username: str, password: str) -> None: |
| 53 | + ... |
| 54 | + |
| 55 | + |
| 56 | +class KeyRingPythonProvider(KeyRingBaseProvider): |
| 57 | + """Keyring interface which uses locally imported `keyring`""" |
| 58 | + |
| 59 | + try: |
| 60 | + import keyring |
| 61 | + |
| 62 | + keyring = keyring |
| 63 | + except ImportError: |
| 64 | + keyring = None # type: ignore[assignment] |
| 65 | + |
| 66 | + @classmethod |
| 67 | + def is_available(cls) -> bool: |
| 68 | + return cls.keyring is not None |
| 69 | + |
| 70 | + @classmethod |
| 71 | + def get_auth_info(cls, url: str, username: Optional[str]) -> Optional[AuthInfo]: |
| 72 | + if cls.is_available is False: |
| 73 | + return None |
| 74 | + |
| 75 | + # Support keyring's get_credential interface which supports getting |
| 76 | + # credentials without a username. This is only available for |
| 77 | + # keyring>=15.2.0. |
| 78 | + if hasattr(cls.keyring, "get_credential"): |
| 79 | + logger.debug("Getting credentials from keyring for %s", url) |
| 80 | + cred = cls.keyring.get_credential(url, username) |
| 81 | + if cred is not None: |
| 82 | + return cred.username, cred.password |
| 83 | + return None |
| 84 | + |
| 85 | + if username is not None: |
| 86 | + logger.debug("Getting password from keyring for %s", url) |
| 87 | + password = cls.keyring.get_password(url, username) |
| 88 | + if password: |
| 89 | + return username, password |
| 90 | + return None |
| 91 | + |
| 92 | + @classmethod |
| 93 | + def save_auth_info(cls, url: str, username: str, password: str) -> None: |
| 94 | + cls.keyring.set_password(url, username, password) |
| 95 | + |
| 96 | + |
| 97 | +class KeyRingCliProvider(KeyRingBaseProvider): |
| 98 | + """Provider which uses `keyring` cli |
37 | 99 |
|
38 | 100 | Instead of calling the keyring package installed alongside pip
|
39 | 101 | we call keyring on the command line which will enable pip to
|
40 | 102 | use which ever installation of keyring is available first in
|
41 | 103 | PATH.
|
42 | 104 | """
|
43 | 105 |
|
44 |
| - def __init__(self, keyring: str) -> None: |
45 |
| - self.keyring = keyring |
| 106 | + keyring = shutil.which("keyring") |
| 107 | + |
| 108 | + @classmethod |
| 109 | + def is_available(cls) -> bool: |
| 110 | + return cls.keyring is not None |
46 | 111 |
|
47 |
| - def get_password(self, service_name: str, username: str) -> Optional[str]: |
48 |
| - cmd = [self.keyring, "get", service_name, username] |
| 112 | + @classmethod |
| 113 | + def get_auth_info(cls, url: str, username: Optional[str]) -> Optional[AuthInfo]: |
| 114 | + if cls.is_available is False: |
| 115 | + return None |
| 116 | + |
| 117 | + # This is the default implementation of keyring.get_credential |
| 118 | + # https://github.com/jaraco/keyring/blob/97689324abcf01bd1793d49063e7ca01e03d7d07/keyring/backend.py#L134-L139 |
| 119 | + if username is not None: |
| 120 | + password = cls._get_password(url, username) |
| 121 | + if password is not None: |
| 122 | + return username, password |
| 123 | + return None |
| 124 | + |
| 125 | + @classmethod |
| 126 | + def save_auth_info(cls, url: str, username: str, password: str) -> None: |
| 127 | + if not cls.is_available: |
| 128 | + raise RuntimeError("keyring is not available") |
| 129 | + return cls._set_password(url, username, password) |
| 130 | + |
| 131 | + @classmethod |
| 132 | + def _get_password(cls, service_name: str, username: str) -> Optional[str]: |
| 133 | + """Mirror the implemenation of keyring.get_password using cli""" |
| 134 | + if cls.keyring is None: |
| 135 | + return None |
| 136 | + |
| 137 | + cmd = [cls.keyring, "get", service_name, username] |
| 138 | + env = os.environ |
| 139 | + env["PYTHONIOENCODING"] = "utf-8" |
49 | 140 | res = subprocess.run(
|
50 | 141 | cmd,
|
51 | 142 | stdin=subprocess.DEVNULL,
|
52 | 143 | capture_output=True,
|
53 |
| - env=dict(PYTHONIOENCODING="utf-8"), |
| 144 | + env=env, |
54 | 145 | )
|
55 | 146 | if res.returncode:
|
56 | 147 | return None
|
57 | 148 | return res.stdout.decode("utf-8").strip("\n")
|
58 | 149 |
|
59 |
| - def set_password(self, service_name: str, username: str, password: str) -> None: |
60 |
| - cmd = [self.keyring, "set", service_name, username] |
| 150 | + @classmethod |
| 151 | + def _set_password(cls, service_name: str, username: str, password: str) -> None: |
| 152 | + """Mirror the implemenation of keyring.set_password using cli""" |
| 153 | + if cls.keyring is None: |
| 154 | + return None |
| 155 | + |
| 156 | + cmd = [cls.keyring, "set", service_name, username] |
61 | 157 | input_ = password.encode("utf-8") + b"\n"
|
62 |
| - res = subprocess.run(cmd, input=input_, env=dict(PYTHONIOENCODING="utf-8")) |
| 158 | + env = os.environ |
| 159 | + env["PYTHONIOENCODING"] = "utf-8" |
| 160 | + res = subprocess.run(cmd, input=input_, env=env) |
63 | 161 | res.check_returncode()
|
64 | 162 | return None
|
65 | 163 |
|
66 | 164 |
|
67 |
| -try: |
68 |
| - import keyring |
69 |
| -except ImportError: |
70 |
| - keyring = None # type: ignore[assignment] |
71 |
| - keyring_path = shutil.which("keyring") |
72 |
| - if keyring_path is not None: |
73 |
| - keyring = KeyRingCli(keyring_path) # type: ignore[assignment] |
74 |
| -except Exception as exc: |
75 |
| - logger.warning( |
76 |
| - "Keyring is skipped due to an exception: %s", |
77 |
| - str(exc), |
78 |
| - ) |
79 |
| - keyring = None # type: ignore[assignment] |
| 165 | +def get_keyring_provider() -> Optional[Type[KeyRingBaseProvider]]: |
| 166 | + if KeyRingPythonProvider.is_available(): |
| 167 | + return KeyRingPythonProvider |
| 168 | + if KeyRingCliProvider.is_available(): |
| 169 | + return KeyRingCliProvider |
| 170 | + return None |
80 | 171 |
|
81 | 172 |
|
82 | 173 | def get_keyring_auth(url: Optional[str], username: Optional[str]) -> Optional[AuthInfo]:
|
83 | 174 | """Return the tuple auth for a given url from keyring."""
|
84 |
| - global keyring |
85 |
| - if not url or not keyring: |
| 175 | + # Do nothing if no url was provided |
| 176 | + if not url: |
86 | 177 | return None
|
87 | 178 |
|
88 |
| - try: |
89 |
| - try: |
90 |
| - get_credential = keyring.get_credential |
91 |
| - except AttributeError: |
92 |
| - pass |
93 |
| - else: |
94 |
| - logger.debug("Getting credentials from keyring for %s", url) |
95 |
| - cred = get_credential(url, username) |
96 |
| - if cred is not None: |
97 |
| - return cred.username, cred.password |
98 |
| - return None |
99 |
| - |
100 |
| - if username: |
101 |
| - logger.debug("Getting password from keyring for %s", url) |
102 |
| - password = keyring.get_password(url, username) |
103 |
| - if password: |
104 |
| - return username, password |
| 179 | + keyring = get_keyring_provider() |
| 180 | + # Do nothin if keyring is not available |
| 181 | + if keyring is None: |
| 182 | + return None |
105 | 183 |
|
106 |
| - except Exception as exc: |
107 |
| - logger.warning( |
108 |
| - "Keyring is skipped due to an exception: %s", |
109 |
| - str(exc), |
110 |
| - ) |
111 |
| - keyring = None # type: ignore[assignment] |
112 |
| - return None |
| 184 | + return keyring.get_auth_info(url, username) |
113 | 185 |
|
114 | 186 |
|
115 | 187 | class MultiDomainBasicAuth(AuthBase):
|
@@ -283,7 +355,7 @@ def _prompt_for_password(
|
283 | 355 |
|
284 | 356 | # Factored out to allow for easy patching in tests
|
285 | 357 | def _should_save_password_to_keyring(self) -> bool:
|
286 |
| - if not keyring: |
| 358 | + if get_keyring_provider() is None: |
287 | 359 | return False
|
288 | 360 | return ask("Save credentials to keyring [y/N]: ", ["y", "n"]) == "y"
|
289 | 361 |
|
@@ -319,7 +391,7 @@ def handle_401(self, resp: Response, **kwargs: Any) -> Response:
|
319 | 391 | # Prompt to save the password to keyring
|
320 | 392 | if save and self._should_save_password_to_keyring():
|
321 | 393 | self._credentials_to_save = Credentials(
|
322 |
| - service_name=parsed.netloc, |
| 394 | + url=parsed.netloc, |
323 | 395 | username=username,
|
324 | 396 | password=password,
|
325 | 397 | )
|
@@ -355,15 +427,16 @@ def warn_on_401(self, resp: Response, **kwargs: Any) -> None:
|
355 | 427 |
|
356 | 428 | def save_credentials(self, resp: Response, **kwargs: Any) -> None:
|
357 | 429 | """Response callback to save credentials on success."""
|
| 430 | + keyring = get_keyring_provider() |
358 | 431 | assert keyring is not None, "should never reach here without keyring"
|
359 | 432 | if not keyring:
|
360 |
| - return |
| 433 | + return None |
361 | 434 |
|
362 | 435 | creds = self._credentials_to_save
|
363 | 436 | self._credentials_to_save = None
|
364 | 437 | if creds and resp.status_code < 400:
|
365 | 438 | try:
|
366 | 439 | logger.info("Saving credentials to keyring")
|
367 |
| - keyring.set_password(creds.service_name, creds.username, creds.password) |
| 440 | + keyring.save_auth_info(creds.url, creds.username, creds.password) |
368 | 441 | except Exception:
|
369 | 442 | logger.exception("Failed to save credentials")
|
0 commit comments