From b87ddb95e8748fbc41927f592887526fca857b89 Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Wed, 9 Nov 2022 19:32:36 +0000 Subject: [PATCH 01/19] Add an interface to allow calling system `keyring` --- src/pip/_internal/network/auth.py | 64 +++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/src/pip/_internal/network/auth.py b/src/pip/_internal/network/auth.py index ca42798bd95..5107c7c1317 100644 --- a/src/pip/_internal/network/auth.py +++ b/src/pip/_internal/network/auth.py @@ -4,8 +4,10 @@ providing credentials in the context of network requests. """ +import shutil +import subprocess import urllib.parse -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, NamedTuple, Optional, Tuple from pip._vendor.requests.auth import AuthBase, HTTPBasicAuth from pip._vendor.requests.models import Request, Response @@ -23,11 +25,61 @@ logger = getLogger(__name__) -Credentials = Tuple[str, str, str] + +class Credentials(NamedTuple): + service_name: str + username: str + password: str + + +class KeyRingCredential(NamedTuple): + username: str + password: str + + +class KeyRingCli: + """Mirror the parts of keyring's API which pip uses + + Instead of calling the keyring package installed alongside pip + we call keyring on the command line which will enable pip to + use which ever installation of keyring is available first in + PATH. + """ + + @staticmethod + def _quote(string: Optional[str]) -> str: + return f"'{string}'" + + def get_credential( + self, service_name: str, username: Optional[str] + ) -> Optional[KeyRingCredential]: + cmd = ["keyring", "get", self._quote(service_name), self._quote(username)] + res = subprocess.run(cmd) + if res.returncode: + return None + return KeyRingCredential(username=username, password=res.stdout) + + def set_password(self, service_name: str, username: str, password: str) -> None: + cmd = [ + "echo", + self._quote(password), + "|", + "keyring", + "set", + self._quote(service_name), + self._quote(username), + ] + res = subprocess.run(cmd) + if res.returncode: + raise RuntimeError(res.stderr) + return None + try: import keyring except ImportError: + if shutil.which("keyring") is not None: + keyring = KeyRingCli() keyring = None # type: ignore[assignment] except Exception as exc: logger.warning( @@ -276,7 +328,11 @@ def handle_401(self, resp: Response, **kwargs: Any) -> Response: # Prompt to save the password to keyring if save and self._should_save_password_to_keyring(): - self._credentials_to_save = (parsed.netloc, username, password) + self._credentials_to_save = Credentials( + service_name=parsed.netloc, + username=username, + password=password, + ) # Consume content and release the original connection to allow our new # request to reuse the same one. @@ -318,6 +374,6 @@ def save_credentials(self, resp: Response, **kwargs: Any) -> None: if creds and resp.status_code < 400: try: logger.info("Saving credentials to keyring") - keyring.set_password(*creds) + keyring.set_password(creds.service_name, creds.username, creds.password) except Exception: logger.exception("Failed to save credentials") From edc588c48f4c87c6d5ee1398698a08ebb3316a7b Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Wed, 9 Nov 2022 19:40:59 +0000 Subject: [PATCH 02/19] Add news --- news/11589.feature.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 news/11589.feature.rst diff --git a/news/11589.feature.rst b/news/11589.feature.rst new file mode 100644 index 00000000000..d01a564b631 --- /dev/null +++ b/news/11589.feature.rst @@ -0,0 +1,2 @@ +Enable the use of ``keyring`` found on ``PATH``. This allows ``keyring`` +installed using ``pipx`` to be used by ``pip``. From 4cbae5b1a016924fb62e0b2c7b812430fc62edf0 Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Wed, 9 Nov 2022 20:28:59 +0000 Subject: [PATCH 03/19] Improve cli interface --- src/pip/_internal/network/auth.py | 35 ++++++++++++------------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/src/pip/_internal/network/auth.py b/src/pip/_internal/network/auth.py index 5107c7c1317..0164a46170b 100644 --- a/src/pip/_internal/network/auth.py +++ b/src/pip/_internal/network/auth.py @@ -33,7 +33,7 @@ class Credentials(NamedTuple): class KeyRingCredential(NamedTuple): - username: str + username: Optional[str] password: str @@ -46,30 +46,21 @@ class KeyRingCli: PATH. """ - @staticmethod - def _quote(string: Optional[str]) -> str: - return f"'{string}'" - + @classmethod def get_credential( - self, service_name: str, username: Optional[str] + cls, service_name: str, username: Optional[str] ) -> Optional[KeyRingCredential]: - cmd = ["keyring", "get", self._quote(service_name), self._quote(username)] - res = subprocess.run(cmd) + cmd = ["keyring", "get", service_name, str(username)] + res = subprocess.run(cmd, capture_output=True) if res.returncode: return None - return KeyRingCredential(username=username, password=res.stdout) - - def set_password(self, service_name: str, username: str, password: str) -> None: - cmd = [ - "echo", - self._quote(password), - "|", - "keyring", - "set", - self._quote(service_name), - self._quote(username), - ] - res = subprocess.run(cmd) + password = res.stdout.decode().strip("\n") + return KeyRingCredential(username=username, password=password) + + @classmethod + def set_password(cls, service_name: str, username: str, password: str) -> None: + cmd = ["keyring", "set", service_name, username] + res = subprocess.run(cmd, input=password.encode() + b"\n", capture_output=True) if res.returncode: raise RuntimeError(res.stderr) return None @@ -79,7 +70,7 @@ def set_password(self, service_name: str, username: str, password: str) -> None: import keyring except ImportError: if shutil.which("keyring") is not None: - keyring = KeyRingCli() + keyring = KeyRingCli # type: ignore[assignment] keyring = None # type: ignore[assignment] except Exception as exc: logger.warning( From efa7f2bf7d8672af0de11cd1104e933164e3eabb Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Wed, 9 Nov 2022 20:38:10 +0000 Subject: [PATCH 04/19] Raise better exception --- src/pip/_internal/network/auth.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pip/_internal/network/auth.py b/src/pip/_internal/network/auth.py index 0164a46170b..ed8c54cf03e 100644 --- a/src/pip/_internal/network/auth.py +++ b/src/pip/_internal/network/auth.py @@ -61,8 +61,7 @@ def get_credential( def set_password(cls, service_name: str, username: str, password: str) -> None: cmd = ["keyring", "set", service_name, username] res = subprocess.run(cmd, input=password.encode() + b"\n", capture_output=True) - if res.returncode: - raise RuntimeError(res.stderr) + res.check_returncode() return None From 7e9310245dbc1c36a7babf32a8df51ced4e3f947 Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Wed, 9 Nov 2022 20:39:06 +0000 Subject: [PATCH 05/19] Don't capture output --- src/pip/_internal/network/auth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pip/_internal/network/auth.py b/src/pip/_internal/network/auth.py index ed8c54cf03e..358ef11b232 100644 --- a/src/pip/_internal/network/auth.py +++ b/src/pip/_internal/network/auth.py @@ -60,7 +60,8 @@ def get_credential( @classmethod def set_password(cls, service_name: str, username: str, password: str) -> None: cmd = ["keyring", "set", service_name, username] - res = subprocess.run(cmd, input=password.encode() + b"\n", capture_output=True) + input_ = password.encode() + b"\n" + res = subprocess.run(cmd, input=input_) res.check_returncode() return None From 6ec0af5258d56618dd7cc9ced077d9d12bfb8687 Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Wed, 9 Nov 2022 22:31:37 +0000 Subject: [PATCH 06/19] Handle IO encoding --- src/pip/_internal/network/auth.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/pip/_internal/network/auth.py b/src/pip/_internal/network/auth.py index 358ef11b232..bae2ce6f563 100644 --- a/src/pip/_internal/network/auth.py +++ b/src/pip/_internal/network/auth.py @@ -51,17 +51,19 @@ def get_credential( cls, service_name: str, username: Optional[str] ) -> Optional[KeyRingCredential]: cmd = ["keyring", "get", service_name, str(username)] - res = subprocess.run(cmd, capture_output=True) + res = subprocess.run( + cmd, capture_output=True, env=dict(PYTHONIOENCODING="utf-8") + ) if res.returncode: return None - password = res.stdout.decode().strip("\n") + password = res.stdout.decode("utf-8").strip("\n") return KeyRingCredential(username=username, password=password) @classmethod def set_password(cls, service_name: str, username: str, password: str) -> None: cmd = ["keyring", "set", service_name, username] - input_ = password.encode() + b"\n" - res = subprocess.run(cmd, input=input_) + input_ = password.encode("utf-8") + b"\n" + res = subprocess.run(cmd, input=input_, env=dict(PYTHONIOENCODING="utf-8")) res.check_returncode() return None From f5c96b14a0c9e9c274f6555a50f93fd2e5e35a35 Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Wed, 9 Nov 2022 22:43:13 +0000 Subject: [PATCH 07/19] Switch to defining `get_password` --- src/pip/_internal/network/auth.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/pip/_internal/network/auth.py b/src/pip/_internal/network/auth.py index bae2ce6f563..0993fc112a4 100644 --- a/src/pip/_internal/network/auth.py +++ b/src/pip/_internal/network/auth.py @@ -32,11 +32,6 @@ class Credentials(NamedTuple): password: str -class KeyRingCredential(NamedTuple): - username: Optional[str] - password: str - - class KeyRingCli: """Mirror the parts of keyring's API which pip uses @@ -47,17 +42,14 @@ class KeyRingCli: """ @classmethod - def get_credential( - cls, service_name: str, username: Optional[str] - ) -> Optional[KeyRingCredential]: - cmd = ["keyring", "get", service_name, str(username)] + def get_password(cls, service_name: str, username: str) -> Optional[str]: + cmd = ["keyring", "get", service_name, username] res = subprocess.run( cmd, capture_output=True, env=dict(PYTHONIOENCODING="utf-8") ) if res.returncode: return None - password = res.stdout.decode("utf-8").strip("\n") - return KeyRingCredential(username=username, password=password) + return res.stdout.decode("utf-8").strip("\n") @classmethod def set_password(cls, service_name: str, username: str, password: str) -> None: From 43abcf01b155a86276727a0a5900f54dc9e74ae6 Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Wed, 9 Nov 2022 22:51:24 +0000 Subject: [PATCH 08/19] Set `keyring` correctly --- src/pip/_internal/network/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pip/_internal/network/auth.py b/src/pip/_internal/network/auth.py index 0993fc112a4..6f011e1aa65 100644 --- a/src/pip/_internal/network/auth.py +++ b/src/pip/_internal/network/auth.py @@ -63,9 +63,9 @@ def set_password(cls, service_name: str, username: str, password: str) -> None: try: import keyring except ImportError: + keyring = None # type: ignore[assignment] if shutil.which("keyring") is not None: keyring = KeyRingCli # type: ignore[assignment] - keyring = None # type: ignore[assignment] except Exception as exc: logger.warning( "Keyring is skipped due to an exception: %s", From 5137ce26b6580ab4186519549c1ec6e07859b109 Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Wed, 9 Nov 2022 23:13:21 +0000 Subject: [PATCH 09/19] Use full `keyring` path --- src/pip/_internal/network/auth.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/pip/_internal/network/auth.py b/src/pip/_internal/network/auth.py index 6f011e1aa65..e13a4450209 100644 --- a/src/pip/_internal/network/auth.py +++ b/src/pip/_internal/network/auth.py @@ -41,9 +41,11 @@ class KeyRingCli: PATH. """ - @classmethod - def get_password(cls, service_name: str, username: str) -> Optional[str]: - cmd = ["keyring", "get", service_name, username] + def __init__(self, keyring: str) -> None: + self.keyring = keyring + + def get_password(self, service_name: str, username: str) -> Optional[str]: + cmd = [self.keyring, "get", service_name, username] res = subprocess.run( cmd, capture_output=True, env=dict(PYTHONIOENCODING="utf-8") ) @@ -51,9 +53,8 @@ def get_password(cls, service_name: str, username: str) -> Optional[str]: return None return res.stdout.decode("utf-8").strip("\n") - @classmethod - def set_password(cls, service_name: str, username: str, password: str) -> None: - cmd = ["keyring", "set", service_name, username] + def set_password(self, service_name: str, username: str, password: str) -> None: + cmd = [self.keyring, "set", service_name, username] input_ = password.encode("utf-8") + b"\n" res = subprocess.run(cmd, input=input_, env=dict(PYTHONIOENCODING="utf-8")) res.check_returncode() @@ -64,8 +65,9 @@ def set_password(cls, service_name: str, username: str, password: str) -> None: import keyring except ImportError: keyring = None # type: ignore[assignment] - if shutil.which("keyring") is not None: - keyring = KeyRingCli # type: ignore[assignment] + keyring_path = shutil.which("keyring") + if keyring_path is not None: + keyring = KeyRingCli(keyring_path) # type: ignore[assignment] except Exception as exc: logger.warning( "Keyring is skipped due to an exception: %s", From 4fc2008d04cb3600c7eb9d603aa6a34f4cf4ddba Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Wed, 9 Nov 2022 23:32:38 +0000 Subject: [PATCH 10/19] Prevent `keyring` from ever reading from `stdin` It shouldn't need to ever so no reason to allow it and have to jiggle around the `--no-input` option in `pip`. --- src/pip/_internal/network/auth.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pip/_internal/network/auth.py b/src/pip/_internal/network/auth.py index e13a4450209..bb223c6a231 100644 --- a/src/pip/_internal/network/auth.py +++ b/src/pip/_internal/network/auth.py @@ -47,7 +47,10 @@ def __init__(self, keyring: str) -> None: def get_password(self, service_name: str, username: str) -> Optional[str]: cmd = [self.keyring, "get", service_name, username] res = subprocess.run( - cmd, capture_output=True, env=dict(PYTHONIOENCODING="utf-8") + cmd, + stdin=subprocess.DEVNULL, + capture_output=True, + env=dict(PYTHONIOENCODING="utf-8"), ) if res.returncode: return None From 888c3b6c543a59559fd25cf8c95f6db1c2365819 Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Thu, 10 Nov 2022 10:31:38 +0000 Subject: [PATCH 11/19] Abstract provider interface to `keyring` --- src/pip/_internal/network/auth.py | 181 +++++++++++++++++++++--------- 1 file changed, 126 insertions(+), 55 deletions(-) diff --git a/src/pip/_internal/network/auth.py b/src/pip/_internal/network/auth.py index bb223c6a231..72e300bd138 100644 --- a/src/pip/_internal/network/auth.py +++ b/src/pip/_internal/network/auth.py @@ -4,10 +4,12 @@ providing credentials in the context of network requests. """ +import os import shutil import subprocess import urllib.parse -from typing import Any, Dict, List, NamedTuple, Optional, Tuple +from abc import ABC, abstractmethod +from typing import Any, Dict, List, NamedTuple, Optional, Tuple, Type from pip._vendor.requests.auth import AuthBase, HTTPBasicAuth from pip._vendor.requests.models import Request, Response @@ -27,13 +29,71 @@ class Credentials(NamedTuple): - service_name: str + url: str username: str password: str -class KeyRingCli: - """Mirror the parts of keyring's API which pip uses +class KeyRingBaseProvider(ABC): + """Keyring base provider interface""" + + @classmethod + @abstractmethod + def is_available(cls) -> bool: + ... + + @classmethod + @abstractmethod + def get_auth_info(cls, url: str, username: Optional[str]) -> Optional[AuthInfo]: + ... + + @classmethod + @abstractmethod + def save_auth_info(cls, url: str, username: str, password: str) -> None: + ... + + +class KeyRingPythonProvider(KeyRingBaseProvider): + """Keyring interface which uses locally imported `keyring`""" + + try: + import keyring + except ImportError: + keyring = None # type: ignore[assignment] + + @classmethod + def is_available(cls) -> bool: + return cls.keyring is not None + + @classmethod + def get_auth_info(cls, url: str, username: Optional[str]) -> Optional[AuthInfo]: + if cls.is_available is False: + return None + + # Support keyring's get_credential interface which supports getting + # credentials without a username. This is only available for + # keyring>=15.2.0. + if hasattr(cls.keyring, "get_credential"): + logger.debug("Getting credentials from keyring for %s", url) + cred = cls.keyring.get_credential(url, username) + if cred is not None: + return cred.username, cred.password + return None + + if username is not None: + logger.debug("Getting password from keyring for %s", url) + password = cls.keyring.get_password(url, username) + if password: + return username, password + return None + + @classmethod + def save_auth_info(cls, url: str, username: str, password: str) -> None: + cls.keyring.set_password(url, username, password) + + +class KeyRingCliProvider(KeyRingBaseProvider): + """Provider which uses `keyring` cli Instead of calling the keyring package installed alongside pip we call keyring on the command line which will enable pip to @@ -41,75 +101,85 @@ class KeyRingCli: PATH. """ - def __init__(self, keyring: str) -> None: - self.keyring = keyring + keyring = shutil.which("keyring") + + @classmethod + def is_available(cls) -> bool: + return cls.keyring is not None - def get_password(self, service_name: str, username: str) -> Optional[str]: - cmd = [self.keyring, "get", service_name, username] + @classmethod + def get_auth_info(cls, url: str, username: Optional[str]) -> Optional[AuthInfo]: + if cls.is_available is False: + return None + + # This is the default implementation of keyring.get_credential + # https://github.com/jaraco/keyring/blob/97689324abcf01bd1793d49063e7ca01e03d7d07/keyring/backend.py#L134-L139 + if username is not None: + password = cls._get_password(url, username) + if password is not None: + return username, password + return None + + @classmethod + def save_auth_info(cls, url: str, username: str, password: str) -> None: + if not cls.is_available: + raise RuntimeError("keyring is not available") + return cls._set_password(url, username, password) + + @classmethod + def _get_password(cls, service_name: str, username: str) -> Optional[str]: + """Mirror the implemenation of keyring.get_password using cli""" + if cls.keyring is None: + return None + + cmd = [cls.keyring, "get", service_name, username] + env = os.environ + env["PYTHONIOENCODING"] = "utf-8" res = subprocess.run( cmd, stdin=subprocess.DEVNULL, capture_output=True, - env=dict(PYTHONIOENCODING="utf-8"), + env=env, ) if res.returncode: return None return res.stdout.decode("utf-8").strip("\n") - def set_password(self, service_name: str, username: str, password: str) -> None: - cmd = [self.keyring, "set", service_name, username] + @classmethod + def _set_password(cls, service_name: str, username: str, password: str) -> None: + """Mirror the implemenation of keyring.set_password using cli""" + if cls.keyring is None: + return None + + cmd = [cls.keyring, "set", service_name, username] input_ = password.encode("utf-8") + b"\n" - res = subprocess.run(cmd, input=input_, env=dict(PYTHONIOENCODING="utf-8")) + env = os.environ + env["PYTHONIOENCODING"] = "utf-8" + res = subprocess.run(cmd, input=input_, env=env) res.check_returncode() return None -try: - import keyring -except ImportError: - keyring = None # type: ignore[assignment] - keyring_path = shutil.which("keyring") - if keyring_path is not None: - keyring = KeyRingCli(keyring_path) # type: ignore[assignment] -except Exception as exc: - logger.warning( - "Keyring is skipped due to an exception: %s", - str(exc), - ) - keyring = None # type: ignore[assignment] +def get_keyring_provider() -> Optional[Type[KeyRingBaseProvider]]: + if KeyRingPythonProvider.is_available(): + return KeyRingPythonProvider + if KeyRingCliProvider.is_available(): + return KeyRingCliProvider + return None def get_keyring_auth(url: Optional[str], username: Optional[str]) -> Optional[AuthInfo]: """Return the tuple auth for a given url from keyring.""" - global keyring - if not url or not keyring: + # Do nothing if no url was provided + if not url: return None - try: - try: - get_credential = keyring.get_credential - except AttributeError: - pass - else: - logger.debug("Getting credentials from keyring for %s", url) - cred = get_credential(url, username) - if cred is not None: - return cred.username, cred.password - return None - - if username: - logger.debug("Getting password from keyring for %s", url) - password = keyring.get_password(url, username) - if password: - return username, password + keyring = get_keyring_provider() + # Do nothin if keyring is not available + if keyring is None: + return None - except Exception as exc: - logger.warning( - "Keyring is skipped due to an exception: %s", - str(exc), - ) - keyring = None # type: ignore[assignment] - return None + return keyring.get_auth_info(url, username) class MultiDomainBasicAuth(AuthBase): @@ -283,7 +353,7 @@ def _prompt_for_password( # Factored out to allow for easy patching in tests def _should_save_password_to_keyring(self) -> bool: - if not keyring: + if get_keyring_provider() is None: return False return ask("Save credentials to keyring [y/N]: ", ["y", "n"]) == "y" @@ -319,7 +389,7 @@ def handle_401(self, resp: Response, **kwargs: Any) -> Response: # Prompt to save the password to keyring if save and self._should_save_password_to_keyring(): self._credentials_to_save = Credentials( - service_name=parsed.netloc, + url=parsed.netloc, username=username, password=password, ) @@ -355,15 +425,16 @@ def warn_on_401(self, resp: Response, **kwargs: Any) -> None: def save_credentials(self, resp: Response, **kwargs: Any) -> None: """Response callback to save credentials on success.""" + keyring = get_keyring_provider() assert keyring is not None, "should never reach here without keyring" if not keyring: - return + return None creds = self._credentials_to_save self._credentials_to_save = None if creds and resp.status_code < 400: try: logger.info("Saving credentials to keyring") - keyring.set_password(creds.service_name, creds.username, creds.password) + keyring.save_auth_info(creds.url, creds.username, creds.password) except Exception: logger.exception("Failed to save credentials") From 996d4fad95c2df44181dfc7eb2f811014ce82572 Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Thu, 10 Nov 2022 10:36:17 +0000 Subject: [PATCH 12/19] Take copy of `os.environ` rather than editing --- src/pip/_internal/network/auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pip/_internal/network/auth.py b/src/pip/_internal/network/auth.py index 72e300bd138..249b3a465de 100644 --- a/src/pip/_internal/network/auth.py +++ b/src/pip/_internal/network/auth.py @@ -133,7 +133,7 @@ def _get_password(cls, service_name: str, username: str) -> Optional[str]: return None cmd = [cls.keyring, "get", service_name, username] - env = os.environ + env = os.environ.copy() env["PYTHONIOENCODING"] = "utf-8" res = subprocess.run( cmd, @@ -153,7 +153,7 @@ def _set_password(cls, service_name: str, username: str, password: str) -> None: cmd = [cls.keyring, "set", service_name, username] input_ = password.encode("utf-8") + b"\n" - env = os.environ + env = os.environ.copy() env["PYTHONIOENCODING"] = "utf-8" res = subprocess.run(cmd, input=input_, env=env) res.check_returncode() From 4f8a6137a1ec625910e40e3ea768e38d36ec923e Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Thu, 10 Nov 2022 10:44:25 +0000 Subject: [PATCH 13/19] Import `keyring` lazily --- src/pip/_internal/network/auth.py | 95 +++++++++++++++---------------- 1 file changed, 47 insertions(+), 48 deletions(-) diff --git a/src/pip/_internal/network/auth.py b/src/pip/_internal/network/auth.py index 249b3a465de..bda61534705 100644 --- a/src/pip/_internal/network/auth.py +++ b/src/pip/_internal/network/auth.py @@ -4,12 +4,13 @@ providing credentials in the context of network requests. """ +import functools import os import shutil import subprocess import urllib.parse from abc import ABC, abstractmethod -from typing import Any, Dict, List, NamedTuple, Optional, Tuple, Type +from typing import Any, Dict, List, NamedTuple, Optional, Tuple from pip._vendor.requests.auth import AuthBase, HTTPBasicAuth from pip._vendor.requests.models import Request, Response @@ -37,59 +38,56 @@ class Credentials(NamedTuple): class KeyRingBaseProvider(ABC): """Keyring base provider interface""" - @classmethod @abstractmethod - def is_available(cls) -> bool: + def is_available(self) -> bool: ... - @classmethod @abstractmethod - def get_auth_info(cls, url: str, username: Optional[str]) -> Optional[AuthInfo]: + def get_auth_info(self, url: str, username: Optional[str]) -> Optional[AuthInfo]: ... - @classmethod @abstractmethod - def save_auth_info(cls, url: str, username: str, password: str) -> None: + def save_auth_info(self, url: str, username: str, password: str) -> None: ... class KeyRingPythonProvider(KeyRingBaseProvider): """Keyring interface which uses locally imported `keyring`""" - try: - import keyring - except ImportError: - keyring = None # type: ignore[assignment] + def __init__(self) -> None: + try: + import keyring + except ImportError: + keyring = None # type: ignore[assignment] - @classmethod - def is_available(cls) -> bool: - return cls.keyring is not None + self.keyring = keyring - @classmethod - def get_auth_info(cls, url: str, username: Optional[str]) -> Optional[AuthInfo]: - if cls.is_available is False: + def is_available(self) -> bool: + return self.keyring is not None + + def get_auth_info(self, url: str, username: Optional[str]) -> Optional[AuthInfo]: + if self.is_available is False: return None # Support keyring's get_credential interface which supports getting # credentials without a username. This is only available for # keyring>=15.2.0. - if hasattr(cls.keyring, "get_credential"): + if hasattr(self.keyring, "get_credential"): logger.debug("Getting credentials from keyring for %s", url) - cred = cls.keyring.get_credential(url, username) + cred = self.keyring.get_credential(url, username) if cred is not None: return cred.username, cred.password return None if username is not None: logger.debug("Getting password from keyring for %s", url) - password = cls.keyring.get_password(url, username) + password = self.keyring.get_password(url, username) if password: return username, password return None - @classmethod - def save_auth_info(cls, url: str, username: str, password: str) -> None: - cls.keyring.set_password(url, username, password) + def save_auth_info(self, url: str, username: str, password: str) -> None: + self.keyring.set_password(url, username, password) class KeyRingCliProvider(KeyRingBaseProvider): @@ -101,38 +99,35 @@ class KeyRingCliProvider(KeyRingBaseProvider): PATH. """ - keyring = shutil.which("keyring") + def __init__(self) -> None: + self.keyring = shutil.which("keyring") - @classmethod - def is_available(cls) -> bool: - return cls.keyring is not None + def is_available(self) -> bool: + return self.keyring is not None - @classmethod - def get_auth_info(cls, url: str, username: Optional[str]) -> Optional[AuthInfo]: - if cls.is_available is False: + def get_auth_info(self, url: str, username: Optional[str]) -> Optional[AuthInfo]: + if self.is_available is False: return None # This is the default implementation of keyring.get_credential # https://github.com/jaraco/keyring/blob/97689324abcf01bd1793d49063e7ca01e03d7d07/keyring/backend.py#L134-L139 if username is not None: - password = cls._get_password(url, username) + password = self._get_password(url, username) if password is not None: return username, password return None - @classmethod - def save_auth_info(cls, url: str, username: str, password: str) -> None: - if not cls.is_available: + def save_auth_info(self, url: str, username: str, password: str) -> None: + if not self.is_available: raise RuntimeError("keyring is not available") - return cls._set_password(url, username, password) + return self._set_password(url, username, password) - @classmethod - def _get_password(cls, service_name: str, username: str) -> Optional[str]: + def _get_password(self, service_name: str, username: str) -> Optional[str]: """Mirror the implemenation of keyring.get_password using cli""" - if cls.keyring is None: + if self.keyring is None: return None - cmd = [cls.keyring, "get", service_name, username] + cmd = [self.keyring, "get", service_name, username] env = os.environ.copy() env["PYTHONIOENCODING"] = "utf-8" res = subprocess.run( @@ -145,13 +140,12 @@ def _get_password(cls, service_name: str, username: str) -> Optional[str]: return None return res.stdout.decode("utf-8").strip("\n") - @classmethod - def _set_password(cls, service_name: str, username: str, password: str) -> None: + def _set_password(self, service_name: str, username: str, password: str) -> None: """Mirror the implemenation of keyring.set_password using cli""" - if cls.keyring is None: + if self.keyring is None: return None - cmd = [cls.keyring, "set", service_name, username] + cmd = [self.keyring, "set", service_name, username] input_ = password.encode("utf-8") + b"\n" env = os.environ.copy() env["PYTHONIOENCODING"] = "utf-8" @@ -160,11 +154,16 @@ def _set_password(cls, service_name: str, username: str, password: str) -> None: return None -def get_keyring_provider() -> Optional[Type[KeyRingBaseProvider]]: - if KeyRingPythonProvider.is_available(): - return KeyRingPythonProvider - if KeyRingCliProvider.is_available(): - return KeyRingCliProvider +@functools.lru_cache(maxsize=1) +def get_keyring_provider() -> Optional[KeyRingBaseProvider]: + python_keyring = KeyRingPythonProvider() + if python_keyring.is_available(): + return python_keyring + + cli_keyring = KeyRingCliProvider() + if cli_keyring.is_available(): + return cli_keyring + return None From 3a15e010916f3fd40a37b4458ad9a35696241f0b Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Thu, 10 Nov 2022 19:15:07 +0000 Subject: [PATCH 14/19] Get the tests passing again --- src/pip/_internal/network/auth.py | 17 ++++++++++++++--- tests/unit/test_network_auth.py | 25 +++++++++++++++++-------- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/pip/_internal/network/auth.py b/src/pip/_internal/network/auth.py index bda61534705..99fd9977c75 100644 --- a/src/pip/_internal/network/auth.py +++ b/src/pip/_internal/network/auth.py @@ -28,6 +28,8 @@ logger = getLogger(__name__) +KEYRING_DISABLED = False + class Credentials(NamedTuple): url: str @@ -174,11 +176,20 @@ def get_keyring_auth(url: Optional[str], username: Optional[str]) -> Optional[Au return None keyring = get_keyring_provider() - # Do nothin if keyring is not available - if keyring is None: + # Do nothing if keyring is not available + global KEYRING_DISABLED + if keyring is None or KEYRING_DISABLED: return None - return keyring.get_auth_info(url, username) + try: + return keyring.get_auth_info(url, username) + except Exception as exc: + logger.warning( + "Keyring is skipped due to an exception: %s", + str(exc), + ) + KEYRING_DISABLED = True + return None class MultiDomainBasicAuth(AuthBase): diff --git a/tests/unit/test_network_auth.py b/tests/unit/test_network_auth.py index 5c0e5746281..03d39c452a2 100644 --- a/tests/unit/test_network_auth.py +++ b/tests/unit/test_network_auth.py @@ -1,5 +1,6 @@ import functools -from typing import Any, List, Optional, Tuple +import sys +from typing import Any, Iterable, List, Optional, Tuple import pytest @@ -8,6 +9,14 @@ from tests.lib.requests_mocks import MockConnection, MockRequest, MockResponse +@pytest.fixture(scope="function", autouse=True) +def reset_keyring() -> Iterable[None]: + yield None + # Reset the state of the module between tests + pip._internal.network.auth.KEYRING_DISABLED = False + pip._internal.network.auth.get_keyring_provider.cache_clear() + + @pytest.mark.parametrize( ["input_url", "url", "username", "password"], [ @@ -138,7 +147,7 @@ def test_keyring_get_password( expect: Tuple[Optional[str], Optional[str]], ) -> None: keyring = KeyringModuleV1() - monkeypatch.setattr("pip._internal.network.auth.keyring", keyring) + monkeypatch.setitem(sys.modules, "keyring", keyring) # type: ignore[misc] auth = MultiDomainBasicAuth(index_urls=["http://example.com/path2"]) actual = auth._get_new_credentials(url, allow_netrc=False, allow_keyring=True) @@ -147,7 +156,7 @@ def test_keyring_get_password( def test_keyring_get_password_after_prompt(monkeypatch: pytest.MonkeyPatch) -> None: keyring = KeyringModuleV1() - monkeypatch.setattr("pip._internal.network.auth.keyring", keyring) + monkeypatch.setitem(sys.modules, "keyring", keyring) # type: ignore[misc] auth = MultiDomainBasicAuth() def ask_input(prompt: str) -> str: @@ -163,7 +172,7 @@ def test_keyring_get_password_after_prompt_when_none( monkeypatch: pytest.MonkeyPatch, ) -> None: keyring = KeyringModuleV1() - monkeypatch.setattr("pip._internal.network.auth.keyring", keyring) + monkeypatch.setitem(sys.modules, "keyring", keyring) # type: ignore[misc] auth = MultiDomainBasicAuth() def ask_input(prompt: str) -> str: @@ -184,7 +193,7 @@ def test_keyring_get_password_username_in_index( monkeypatch: pytest.MonkeyPatch, ) -> None: keyring = KeyringModuleV1() - monkeypatch.setattr("pip._internal.network.auth.keyring", keyring) + monkeypatch.setitem(sys.modules, "keyring", keyring) # type: ignore[misc] auth = MultiDomainBasicAuth(index_urls=["http://user@example.com/path2"]) get = functools.partial( auth._get_new_credentials, allow_netrc=False, allow_keyring=True @@ -217,7 +226,7 @@ def test_keyring_set_password( expect_save: bool, ) -> None: keyring = KeyringModuleV1() - monkeypatch.setattr("pip._internal.network.auth.keyring", keyring) + monkeypatch.setitem(sys.modules, "keyring", keyring) # type: ignore[misc] auth = MultiDomainBasicAuth(prompting=True) monkeypatch.setattr(auth, "_get_url_and_credentials", lambda u: (u, None, None)) monkeypatch.setattr(auth, "_prompt_for_password", lambda *a: creds) @@ -293,7 +302,7 @@ def get_credential(self, system: str, username: str) -> Optional[Credential]: def test_keyring_get_credential( monkeypatch: pytest.MonkeyPatch, url: str, expect: str ) -> None: - monkeypatch.setattr(pip._internal.network.auth, "keyring", KeyringModuleV2()) + monkeypatch.setitem(sys.modules, "keyring", KeyringModuleV2()) # type: ignore[misc] auth = MultiDomainBasicAuth(index_urls=["http://example.com/path2"]) assert ( @@ -314,7 +323,7 @@ def get_credential(self, system: str, username: str) -> None: def test_broken_keyring_disables_keyring(monkeypatch: pytest.MonkeyPatch) -> None: keyring_broken = KeyringModuleBroken() - monkeypatch.setattr(pip._internal.network.auth, "keyring", keyring_broken) + monkeypatch.setitem(sys.modules, "keyring", keyring_broken) # type: ignore[misc] auth = MultiDomainBasicAuth(index_urls=["http://example.com/"]) From 8d9ea8b62f91fb98ee13b7a370274deffdbe4956 Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Thu, 10 Nov 2022 20:01:52 +0000 Subject: [PATCH 15/19] Add tests for new code paths --- tests/unit/test_network_auth.py | 142 +++++++++++++++++++++++++++++++- 1 file changed, 141 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_network_auth.py b/tests/unit/test_network_auth.py index 03d39c452a2..56c17d11f02 100644 --- a/tests/unit/test_network_auth.py +++ b/tests/unit/test_network_auth.py @@ -1,6 +1,6 @@ import functools import sys -from typing import Any, Iterable, List, Optional, Tuple +from typing import Any, Dict, Iterable, List, Optional, Tuple import pytest @@ -334,3 +334,143 @@ def test_broken_keyring_disables_keyring(monkeypatch: pytest.MonkeyPatch) -> Non url, allow_netrc=False, allow_keyring=True ) == (None, None) assert keyring_broken._call_count == 1 + + +class KeyringSubprocessResult(KeyringModuleV1): + """Represents the subprocess call to keyring""" + + returncode = 0 # Default to zero retcode + + def __call__( + self, + cmd: List[str], + *, + env: Dict[str, str], + stdin: Optional[Any] = None, + capture_output: Optional[bool] = None, + input: Optional[bytes] = None, + ) -> Any: + if cmd[1] == "get": + assert stdin == -3 # subprocess.DEVNULL + assert capture_output is True + assert env["PYTHONIOENCODING"] == "utf-8" + + password = self.get_password(*cmd[2:]) + if password is None: + # Expect non-zero returncode if no password present + self.returncode = 1 + else: + # Passwords are returned encoded with a newline appended + self.stdout = password.encode("utf-8") + b"\n" + + if cmd[1] == "set": + assert stdin is None + assert capture_output is None + assert env["PYTHONIOENCODING"] == "utf-8" + assert input is not None + + # Input from stdin is encoded + self.set_password(cmd[2], cmd[3], input.decode("utf-8").strip("\n")) + + return self + + def check_returncode(self) -> None: + if self.returncode: + raise Exception() + + +@pytest.mark.parametrize( + "url, expect", + ( + ("http://example.com/path1", (None, None)), + # path1 URLs will be resolved by netloc + ("http://user@example.com/path1", ("user", "user!netloc")), + ("http://user2@example.com/path1", ("user2", "user2!netloc")), + # path2 URLs will be resolved by index URL + ("http://example.com/path2/path3", (None, None)), + ("http://foo@example.com/path2/path3", ("foo", "foo!url")), + ), +) +def test_keyring_cli_get_password( + monkeypatch: pytest.MonkeyPatch, + url: str, + expect: Tuple[Optional[str], Optional[str]], +) -> None: + monkeypatch.setattr(pip._internal.network.auth.shutil, "which", lambda x: "keyring") + monkeypatch.setattr( + pip._internal.network.auth.subprocess, "run", KeyringSubprocessResult() + ) + auth = MultiDomainBasicAuth(index_urls=["http://example.com/path2"]) + + actual = auth._get_new_credentials(url, allow_netrc=False, allow_keyring=True) + assert actual == expect + + +@pytest.mark.parametrize( + "response_status, creds, expect_save", + ( + (403, ("user", "pass", True), False), + ( + 200, + ("user", "pass", True), + True, + ), + ( + 200, + ("user", "pass", False), + False, + ), + ), +) +def test_keyring_cli_set_password( + monkeypatch: pytest.MonkeyPatch, + response_status: int, + creds: Tuple[str, str, bool], + expect_save: bool, +) -> None: + monkeypatch.setattr(pip._internal.network.auth.shutil, "which", lambda x: "keyring") + keyring = KeyringSubprocessResult() + monkeypatch.setattr(pip._internal.network.auth.subprocess, "run", keyring) + auth = MultiDomainBasicAuth(prompting=True) + monkeypatch.setattr(auth, "_get_url_and_credentials", lambda u: (u, None, None)) + monkeypatch.setattr(auth, "_prompt_for_password", lambda *a: creds) + if creds[2]: + # when _prompt_for_password indicates to save, we should save + def should_save_password_to_keyring(*a: Any) -> bool: + return True + + else: + # when _prompt_for_password indicates not to save, we should + # never call this function + def should_save_password_to_keyring(*a: Any) -> bool: + assert False, "_should_save_password_to_keyring should not be called" + + monkeypatch.setattr( + auth, "_should_save_password_to_keyring", should_save_password_to_keyring + ) + + req = MockRequest("https://example.com") + resp = MockResponse(b"") + resp.url = req.url + connection = MockConnection() + + def _send(sent_req: MockRequest, **kwargs: Any) -> MockResponse: + assert sent_req is req + assert "Authorization" in sent_req.headers + r = MockResponse(b"") + r.status_code = response_status + return r + + # https://github.com/python/mypy/issues/2427 + connection._send = _send # type: ignore[assignment] + + resp.request = req + resp.status_code = 401 + resp.connection = connection + + auth.handle_401(resp) + + if expect_save: + assert keyring.saved_passwords == [("example.com", creds[0], creds[1])] + else: + assert keyring.saved_passwords == [] From c04222fe47698b49ba33618c91c3bdb6d419cff0 Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Thu, 10 Nov 2022 21:25:26 +0000 Subject: [PATCH 16/19] Simplify provider interface --- src/pip/_internal/network/auth.py | 77 +++++++++++++------------------ tests/unit/test_network_auth.py | 1 - 2 files changed, 31 insertions(+), 47 deletions(-) diff --git a/src/pip/_internal/network/auth.py b/src/pip/_internal/network/auth.py index 99fd9977c75..3e2e54da227 100644 --- a/src/pip/_internal/network/auth.py +++ b/src/pip/_internal/network/auth.py @@ -4,12 +4,12 @@ providing credentials in the context of network requests. """ -import functools import os import shutil import subprocess import urllib.parse from abc import ABC, abstractmethod +from types import ModuleType from typing import Any, Dict, List, NamedTuple, Optional, Tuple from pip._vendor.requests.auth import AuthBase, HTTPBasicAuth @@ -40,10 +40,6 @@ class Credentials(NamedTuple): class KeyRingBaseProvider(ABC): """Keyring base provider interface""" - @abstractmethod - def is_available(self) -> bool: - ... - @abstractmethod def get_auth_info(self, url: str, username: Optional[str]) -> Optional[AuthInfo]: ... @@ -53,24 +49,23 @@ def save_auth_info(self, url: str, username: str, password: str) -> None: ... -class KeyRingPythonProvider(KeyRingBaseProvider): - """Keyring interface which uses locally imported `keyring`""" +class KeyRingNullProvider(KeyRingBaseProvider): + """Keyring null provider""" - def __init__(self) -> None: - try: - import keyring - except ImportError: - keyring = None # type: ignore[assignment] + def get_auth_info(self, url: str, username: Optional[str]) -> Optional[AuthInfo]: + return None - self.keyring = keyring + def save_auth_info(self, url: str, username: str, password: str) -> None: + return None - def is_available(self) -> bool: - return self.keyring is not None - def get_auth_info(self, url: str, username: Optional[str]) -> Optional[AuthInfo]: - if self.is_available is False: - return None +class KeyRingPythonProvider(KeyRingBaseProvider): + """Keyring interface which uses locally imported `keyring`""" + def __init__(self, module: ModuleType) -> None: + self.keyring = module + + def get_auth_info(self, url: str, username: Optional[str]) -> Optional[AuthInfo]: # Support keyring's get_credential interface which supports getting # credentials without a username. This is only available for # keyring>=15.2.0. @@ -101,16 +96,10 @@ class KeyRingCliProvider(KeyRingBaseProvider): PATH. """ - def __init__(self) -> None: - self.keyring = shutil.which("keyring") - - def is_available(self) -> bool: - return self.keyring is not None + def __init__(self, cmd: str) -> None: + self.keyring = cmd def get_auth_info(self, url: str, username: Optional[str]) -> Optional[AuthInfo]: - if self.is_available is False: - return None - # This is the default implementation of keyring.get_credential # https://github.com/jaraco/keyring/blob/97689324abcf01bd1793d49063e7ca01e03d7d07/keyring/backend.py#L134-L139 if username is not None: @@ -120,8 +109,6 @@ def get_auth_info(self, url: str, username: Optional[str]) -> Optional[AuthInfo] return None def save_auth_info(self, url: str, username: str, password: str) -> None: - if not self.is_available: - raise RuntimeError("keyring is not available") return self._set_password(url, username, password) def _get_password(self, service_name: str, username: str) -> Optional[str]: @@ -156,17 +143,19 @@ def _set_password(self, service_name: str, username: str, password: str) -> None return None -@functools.lru_cache(maxsize=1) -def get_keyring_provider() -> Optional[KeyRingBaseProvider]: - python_keyring = KeyRingPythonProvider() - if python_keyring.is_available(): - return python_keyring - - cli_keyring = KeyRingCliProvider() - if cli_keyring.is_available(): - return cli_keyring +def get_keyring_provider() -> KeyRingBaseProvider: + # keyring has previously failed and been disabled + if not KEYRING_DISABLED: + try: + import keyring - return None + return KeyRingPythonProvider(keyring) + except ImportError: + pass + cli = shutil.which("keyring") + if cli: + return KeyRingCliProvider(cli) + return KeyRingNullProvider() def get_keyring_auth(url: Optional[str], username: Optional[str]) -> Optional[AuthInfo]: @@ -176,11 +165,6 @@ def get_keyring_auth(url: Optional[str], username: Optional[str]) -> Optional[Au return None keyring = get_keyring_provider() - # Do nothing if keyring is not available - global KEYRING_DISABLED - if keyring is None or KEYRING_DISABLED: - return None - try: return keyring.get_auth_info(url, username) except Exception as exc: @@ -188,6 +172,7 @@ def get_keyring_auth(url: Optional[str], username: Optional[str]) -> Optional[Au "Keyring is skipped due to an exception: %s", str(exc), ) + global KEYRING_DISABLED KEYRING_DISABLED = True return None @@ -436,9 +421,9 @@ def warn_on_401(self, resp: Response, **kwargs: Any) -> None: def save_credentials(self, resp: Response, **kwargs: Any) -> None: """Response callback to save credentials on success.""" keyring = get_keyring_provider() - assert keyring is not None, "should never reach here without keyring" - if not keyring: - return None + assert not isinstance( + keyring, KeyRingNullProvider + ), "should never reach here without keyring" creds = self._credentials_to_save self._credentials_to_save = None diff --git a/tests/unit/test_network_auth.py b/tests/unit/test_network_auth.py index 56c17d11f02..625a20a48f5 100644 --- a/tests/unit/test_network_auth.py +++ b/tests/unit/test_network_auth.py @@ -14,7 +14,6 @@ def reset_keyring() -> Iterable[None]: yield None # Reset the state of the module between tests pip._internal.network.auth.KEYRING_DISABLED = False - pip._internal.network.auth.get_keyring_provider.cache_clear() @pytest.mark.parametrize( From e6e42de0e6086a8814ebfc24d5abda35ad3fba29 Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Thu, 10 Nov 2022 21:34:50 +0000 Subject: [PATCH 17/19] Move `keyring` import --- src/pip/_internal/network/auth.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/pip/_internal/network/auth.py b/src/pip/_internal/network/auth.py index 3e2e54da227..b84a9eb1f9d 100644 --- a/src/pip/_internal/network/auth.py +++ b/src/pip/_internal/network/auth.py @@ -9,7 +9,6 @@ import subprocess import urllib.parse from abc import ABC, abstractmethod -from types import ModuleType from typing import Any, Dict, List, NamedTuple, Optional, Tuple from pip._vendor.requests.auth import AuthBase, HTTPBasicAuth @@ -62,8 +61,10 @@ def save_auth_info(self, url: str, username: str, password: str) -> None: class KeyRingPythonProvider(KeyRingBaseProvider): """Keyring interface which uses locally imported `keyring`""" - def __init__(self, module: ModuleType) -> None: - self.keyring = module + def __init__(self) -> None: + import keyring + + self.keyring = keyring def get_auth_info(self, url: str, username: Optional[str]) -> Optional[AuthInfo]: # Support keyring's get_credential interface which supports getting @@ -147,9 +148,7 @@ def get_keyring_provider() -> KeyRingBaseProvider: # keyring has previously failed and been disabled if not KEYRING_DISABLED: try: - import keyring - - return KeyRingPythonProvider(keyring) + return KeyRingPythonProvider() except ImportError: pass cli = shutil.which("keyring") From 14a3d9388eb9afb46637567348cae6d71f06c3a2 Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Thu, 10 Nov 2022 21:42:47 +0000 Subject: [PATCH 18/19] Don't silently fallback to cli --- src/pip/_internal/network/auth.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/pip/_internal/network/auth.py b/src/pip/_internal/network/auth.py index b84a9eb1f9d..8ea9040b1b0 100644 --- a/src/pip/_internal/network/auth.py +++ b/src/pip/_internal/network/auth.py @@ -147,13 +147,26 @@ def _set_password(self, service_name: str, username: str, password: str) -> None def get_keyring_provider() -> KeyRingBaseProvider: # keyring has previously failed and been disabled if not KEYRING_DISABLED: + # Default to trying to use Python provider try: return KeyRingPythonProvider() except ImportError: pass + except Exception as exc: + # In the event of an unexpected exception + # we shouldn't fallback silently to the + # CliProvider + logger.warning( + "Keyring is skipped due to an exception: %s", + str(exc), + ) + return KeyRingNullProvider() + + # Fallback to Cli Provider if `keyring` isn't installed cli = shutil.which("keyring") if cli: return KeyRingCliProvider(cli) + return KeyRingNullProvider() From 623ac5d77dec4c9e4e8d99582bad913c1b0f0b6f Mon Sep 17 00:00:00 2001 From: Judah Rand <17158624+judahrand@users.noreply.github.com> Date: Thu, 10 Nov 2022 21:48:50 +0000 Subject: [PATCH 19/19] Do fallback but issue a warning --- src/pip/_internal/network/auth.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/pip/_internal/network/auth.py b/src/pip/_internal/network/auth.py index 8ea9040b1b0..241ddc53a9c 100644 --- a/src/pip/_internal/network/auth.py +++ b/src/pip/_internal/network/auth.py @@ -154,13 +154,12 @@ def get_keyring_provider() -> KeyRingBaseProvider: pass except Exception as exc: # In the event of an unexpected exception - # we shouldn't fallback silently to the - # CliProvider + # we should warn the user logger.warning( - "Keyring is skipped due to an exception: %s", + "Installed copy of keyring fails with exception %s, " + "trying to find a keyring executable as a fallback", str(exc), ) - return KeyRingNullProvider() # Fallback to Cli Provider if `keyring` isn't installed cli = shutil.which("keyring")