From e47c73734130f307ffd221a3b4ccd5a4e22ba883 Mon Sep 17 00:00:00 2001 From: Dos Moonen Date: Sat, 22 May 2021 11:30:42 +0200 Subject: [PATCH] Added pip style keyring password lookup as a fallback. This way Poetry gives keyring backends like Microsoft's `artifacts-keyring` or Google's `keyrings.google-artifactregistry-auth` a change to retrieve the credentials. Since Microsoft Azure DevOps Personal Access Tokens will expire after some time the `artifacts-keyring` implementation will generate a new one for you to counteract that. --- docs/docs/repositories.md | 14 ++++++ poetry/utils/password_manager.py | 64 +++++++++++++++++++++++++--- tests/utils/test_password_manager.py | 39 +++++++++++++++++ 3 files changed, 111 insertions(+), 6 deletions(-) diff --git a/docs/docs/repositories.md b/docs/docs/repositories.md index 952e91f15d4..7d09c2935d0 100644 --- a/docs/docs/repositories.md +++ b/docs/docs/repositories.md @@ -63,6 +63,20 @@ If a system keyring is available and supported, the password is stored to and re Keyring support is enabled using the [keyring library](https://pypi.org/project/keyring/). For more information on supported backends refer to the [library documentation](https://keyring.readthedocs.io/en/latest/?badge=latest). +!!!note + + ``` + Poetry will fallback to Pip style use of keyring so that backends like + Microsoft's [artifacts-keyring](https://pypi.org/project/artifacts-keyring/). It will need to be properly + installed into Poetry's virtualenv, preferrably by installing a plugin. + + If you are letting Poetry manage your virtual environments you will want a virtualenv + seeder installed in Poetry's virtualenv that installs the desired keyring backend + during `poetry install`. To again use Azure DecOps as an example: [azure-devops-artifacts-helpers](https://pypi.org/project/azure-devops-artifacts-helpers/) + provides such a seeder. This would of course best achieved by installing a Poetry plugin + if it exists for you use case instead of doing it yourself. + ``` + Alternatively, you can use environment variables to provide the credentials: ```bash diff --git a/poetry/utils/password_manager.py b/poetry/utils/password_manager.py index e5eb05eab51..c9f371cce76 100644 --- a/poetry/utils/password_manager.py +++ b/poetry/utils/password_manager.py @@ -47,6 +47,48 @@ def get_password(self, name: str, username: str) -> Optional[str]: f"Unable to retrieve the password for {name} from the key ring" ) + def get_credential(self, url: str, username: str) -> Optional[Dict[str, str]]: + # Based on how Pip does things + if not self.is_available(): + return + + import urllib.parse + + import keyring + import keyring.errors + + try: + netloc = urllib.parse.urlparse(url).netloc + + try: + get_credential = keyring.get_credential + except AttributeError: + pass + else: + cred = get_credential(url, username) + if cred is not None: + return cred.username, cred.password + + cred = get_credential(netloc, username) + if cred is not None: + return cred.username, cred.password + + return None + + if username: + password = keyring.get_password(url, username) + if password: + return username, password + + password = keyring.get_password(netloc, username) + if password: + return username, password + + except (RuntimeError, keyring.errors.KeyringError): + raise KeyRingError( + f"Unable to retrieve the password for {url} from the key ring" + ) + def set_password(self, name: str, username: str, password: str) -> None: if not self.is_available(): return @@ -156,19 +198,29 @@ def delete_pypi_token(self, name: str) -> None: def get_http_auth(self, name: str) -> Optional[Dict[str, str]]: auth = self._config.get(f"http-basic.{name}") if not auth: + # maybe environment variables are set username = self._config.get(f"http-basic.{name}.username") password = self._config.get(f"http-basic.{name}.password") - if not username and not password: - return None else: username, password = auth["username"], auth.get("password") if password is None: password = self.keyring.get_password(name, username) - return { - "username": username, - "password": password, - } + if not password: + # nothing found the poetry way + # let's give pip style keyring backends a go + # like `artifacts-keyring` for Azure DevOps + # or `keyrings.google-artifactregistry-auth` + url = self._config.get(f"repositories.{name}.url") + cred = self.keyring.get_credential(url, username) + if cred: + username, password = cred + + if username and password: + return { + "username": username, + "password": password, + } def set_http_password(self, name: str, username: str, password: str) -> None: auth = {"username": username} diff --git a/tests/utils/test_password_manager.py b/tests/utils/test_password_manager.py index 31d5812ce96..2c2e9b238f3 100644 --- a/tests/utils/test_password_manager.py +++ b/tests/utils/test_password_manager.py @@ -9,6 +9,12 @@ from poetry.utils.password_manager import PasswordManager +class SimpleCredential: + def __init__(self, username, password): + self.username = username + self.password = password + + class DummyBackend(KeyringBackend): def __init__(self): self._passwords = {} @@ -23,6 +29,9 @@ def set_password(self, service, username, password): def get_password(self, service, username): return self._passwords.get(service, {}).get(username) + def get_credential(self, service, username): + return self._passwords.get(service, {}).get(username) + def delete_password(self, service, username): if service in self._passwords and username in self._passwords[service]: del self._passwords[service][username] @@ -86,6 +95,36 @@ def test_get_http_auth(config, mock_available_backend, backend): assert "baz" == auth["password"] +def test_get_http_auth_pip_fallback_url(config, mock_available_backend, backend): + backend.set_password( + "https://poetry.test/pypi/simple", None, SimpleCredential("bar", "baz") + ) + config.auth_config_source.add_property( + "repositories.foo.url", "https://poetry.test/pypi/simple" + ) + manager = PasswordManager(config) + + assert manager.keyring.is_available() + auth = manager.get_http_auth("foo") + + assert "bar" == auth["username"] + assert "baz" == auth["password"] + + +def test_get_http_auth_pip_fallback_netloc(config, mock_available_backend, backend): + backend.set_password("poetry.test", None, SimpleCredential("bar", "baz")) + config.auth_config_source.add_property( + "repositories.foo.url", "https://poetry.test/pypi/simple" + ) + manager = PasswordManager(config) + + assert manager.keyring.is_available() + auth = manager.get_http_auth("foo") + + assert "bar" == auth["username"] + assert "baz" == auth["password"] + + def test_delete_http_password(config, mock_available_backend, backend): backend.set_password("poetry-repository-foo", "bar", "baz") config.auth_config_source.add_property("http-basic.foo", {"username": "bar"})