Skip to content

Commit

Permalink
Added pip style keyring password lookup as a fallback.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Darsstar committed May 22, 2021
1 parent 4ec09d4 commit e47c737
Show file tree
Hide file tree
Showing 3 changed files with 111 additions and 6 deletions.
14 changes: 14 additions & 0 deletions docs/docs/repositories.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 58 additions & 6 deletions poetry/utils/password_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
39 changes: 39 additions & 0 deletions tests/utils/test_password_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand All @@ -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]
Expand Down Expand Up @@ -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"})
Expand Down

0 comments on commit e47c737

Please sign in to comment.