Skip to content

Commit 5bfc70e

Browse files
sdispaterrobin92
authored andcommitted
Merge pull request python-poetry#4086 from Darsstar/pip-style-keyring-fallback
Added pip style keyring password lookup as a fallback.
1 parent 6864994 commit 5bfc70e

File tree

9 files changed

+227
-116
lines changed

9 files changed

+227
-116
lines changed

docs/repositories.md

+15
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,21 @@ If a system keyring is available and supported, the password is stored to and re
7474

7575
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).
7676

77+
{{% note %}}
78+
79+
Poetry will fallback to Pip style use of keyring so that backends like
80+
Microsoft's [artifacts-keyring](https://pypi.org/project/artifacts-keyring/) get a change to retrieve
81+
valid credentials. It will need to be properly installed into Poetry's virtualenv,
82+
preferrably by installing a plugin.
83+
84+
If you are letting Poetry manage your virtual environments you will want a virtualenv
85+
seeder installed in Poetry's virtualenv that installs the desired keyring backend
86+
during `poetry install`. To again use Azure DevOps as an example: [azure-devops-artifacts-helpers](https://pypi.org/project/azure-devops-artifacts-helpers/)
87+
provides such a seeder. This would of course best achieved by installing a Poetry plugin
88+
if it exists for you use case instead of doing it yourself.
89+
90+
{{% /note %}}
91+
7792
Alternatively, you can use environment variables to provide the credentials:
7893

7994
```bash

poetry/installation/executor.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
from poetry.utils.env import EnvCommandError
2424
from poetry.utils.helpers import safe_rmtree
2525

26-
from .authenticator import Authenticator
26+
from ..utils.authenticator import Authenticator
27+
from ..utils.pip import pip_install
2728
from .chef import Chef
2829
from .chooser import Chooser
2930
from .operations.install import Install

poetry/publishing/publisher.py

+6-5
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
from typing import Optional
44

55
from poetry.utils._compat import Path
6-
from poetry.utils.helpers import get_cert
7-
from poetry.utils.helpers import get_client_cert
86
from poetry.utils.password_manager import PasswordManager
97

8+
from ..utils.authenticator import Authenticator
9+
from ..utils.helpers import get_cert
10+
from ..utils.helpers import get_client_cert
1011
from .uploader import Uploader
1112

1213

@@ -23,7 +24,7 @@ def __init__(self, poetry, io):
2324
self._package = poetry.package
2425
self._io = io
2526
self._uploader = Uploader(poetry, io)
26-
self._password_manager = PasswordManager(poetry.config)
27+
self._authenticator = Authenticator(poetry.config, self._io)
2728

2829
@property
2930
def files(self):
@@ -51,13 +52,13 @@ def publish(
5152

5253
if not (username and password):
5354
# Check if we have a token first
54-
token = self._password_manager.get_pypi_token(repository_name)
55+
token = self._authenticator.get_pypi_token(repository_name)
5556
if token:
5657
logger.debug("Found an API token for {}.".format(repository_name))
5758
username = "__token__"
5859
password = token
5960
else:
60-
auth = self._password_manager.get_http_auth(repository_name)
61+
auth = self._authenticator.get_http_auth(repository_name)
6162
if auth:
6263
logger.debug(
6364
"Found authentication information for {}.".format(

poetry/repositories/legacy_repository.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727

2828
from ..config.config import Config
2929
from ..inspection.info import PackageInfo
30-
from ..installation.authenticator import Authenticator
30+
from ..utils.authenticator import Authenticator
3131
from .exceptions import PackageNotFound
3232
from .exceptions import RepositoryError
3333
from .pypi_repository import PyPiRepository

poetry/installation/authenticator.py renamed to poetry/utils/authenticator.py

+67-19
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import logging
22
import time
3+
import urllib
34

45
from typing import TYPE_CHECKING
6+
from typing import Any
7+
from typing import Dict
8+
from typing import Optional
9+
from typing import Tuple
510

611
import requests
712
import requests.auth
@@ -111,7 +116,7 @@ def get_credentials_for_url(
111116

112117
if credentials == (None, None):
113118
if "@" not in netloc:
114-
credentials = self._get_credentials_for_netloc_from_config(netloc)
119+
credentials = self._get_credentials_for_netloc(netloc)
115120
else:
116121
# Split from the right because that's how urllib.parse.urlsplit()
117122
# behaves if more than one @ is present (which can be checked using
@@ -136,30 +141,73 @@ def get_credentials_for_url(
136141

137142
return credentials[0], credentials[1]
138143

139-
def _get_credentials_for_netloc_from_config(
140-
self, netloc
141-
): # type: (str) -> Tuple[Optional[str], Optional[str]]
142-
credentials = (None, None)
144+
def get_pypi_token(self, name: str) -> str:
145+
return self._password_manager.get_pypi_token(name)
143146

144-
for repository_name in self._config.get("repositories", []):
145-
repository_config = self._config.get(
146-
"repositories.{}".format(repository_name)
147-
)
148-
if not repository_config:
149-
continue
147+
def get_http_auth(self, name: str) -> Optional[Dict[str, str]]:
148+
return self._get_http_auth(name, None)
150149

151-
url = repository_config.get("url")
150+
def _get_http_auth(
151+
self, name: str, netloc: Optional[str]
152+
) -> Optional[Dict[str, str]]:
153+
if name == "pypi":
154+
url = "https://upload.pypi.org/legacy/"
155+
else:
156+
url = self._config.get(f"repositories.{name}.url")
152157
if not url:
153-
continue
158+
return
159+
160+
parsed_url = urllib.parse.urlsplit(url)
154161

155-
parsed_url = urlparse.urlsplit(url)
162+
if netloc is None or netloc == parsed_url.netloc:
163+
auth = self._password_manager.get_http_auth(name)
156164

157-
if netloc == parsed_url.netloc:
158-
auth = self._password_manager.get_http_auth(repository_name)
165+
if auth is None or auth["password"] is None:
166+
username = auth["username"] if auth else None
167+
auth = self._get_credentials_for_netloc_from_keyring(
168+
url, parsed_url.netloc, username
169+
)
170+
171+
return auth
172+
173+
def _get_credentials_for_netloc(
174+
self, netloc: str
175+
) -> Tuple[Optional[str], Optional[str]]:
176+
credentials = (None, None)
159177

160-
if auth is None:
161-
continue
178+
for repository_name in self._config.get("repositories", []):
179+
auth = self._get_http_auth(repository_name, netloc)
180+
181+
if auth is None:
182+
continue
162183

163-
return auth["username"], auth["password"]
184+
return auth["username"], auth["password"]
164185

165186
return credentials
187+
188+
def _get_credentials_for_netloc_from_keyring(
189+
self, url: str, netloc: str, username: Optional[str]
190+
) -> Optional[Dict[str, str]]:
191+
import keyring
192+
193+
cred = keyring.get_credential(url, username)
194+
if cred is not None:
195+
return {
196+
"username": cred.username,
197+
"password": cred.password,
198+
}
199+
200+
cred = keyring.get_credential(netloc, username)
201+
if cred is not None:
202+
return {
203+
"username": cred.username,
204+
"password": cred.password,
205+
}
206+
207+
if username:
208+
return {
209+
"username": username,
210+
"password": None,
211+
}
212+
213+
return None

tests/conftest.py

+56
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import pytest
1212

1313
from cleo import CommandTester
14+
from keyring.backend import KeyringBackend
1415

1516
from poetry.config.config import Config as BaseConfig
1617
from poetry.config.dict_config_source import DictConfigSource
@@ -53,6 +54,61 @@ def all(self): # type: () -> Dict[str, Any]
5354
return super(Config, self).all()
5455

5556

57+
class DummyBackend(KeyringBackend):
58+
def __init__(self):
59+
self._passwords = {}
60+
61+
@classmethod
62+
def priority(cls):
63+
return 42
64+
65+
def set_password(self, service, username, password):
66+
self._passwords[service] = {username: password}
67+
68+
def get_password(self, service, username):
69+
return self._passwords.get(service, {}).get(username)
70+
71+
def get_credential(self, service, username):
72+
return self._passwords.get(service, {}).get(username)
73+
74+
def delete_password(self, service, username):
75+
if service in self._passwords and username in self._passwords[service]:
76+
del self._passwords[service][username]
77+
78+
79+
@pytest.fixture()
80+
def dummy_keyring():
81+
return DummyBackend()
82+
83+
84+
@pytest.fixture()
85+
def with_simple_keyring(dummy_keyring):
86+
import keyring
87+
88+
keyring.set_keyring(dummy_keyring)
89+
90+
91+
@pytest.fixture()
92+
def with_fail_keyring():
93+
import keyring
94+
95+
from keyring.backends.fail import Keyring
96+
97+
keyring.set_keyring(Keyring())
98+
99+
100+
@pytest.fixture()
101+
def with_chained_keyring(mocker):
102+
from keyring.backends.fail import Keyring
103+
104+
mocker.patch("keyring.backend.get_all_keyring", [Keyring()])
105+
import keyring
106+
107+
from keyring.backends.chainer import ChainerBackend
108+
109+
keyring.set_keyring(ChainerBackend())
110+
111+
56112
@pytest.fixture
57113
def config_source():
58114
source = DictConfigSource()

tests/test_factory.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -147,13 +147,13 @@ def test_create_poetry_with_multi_constraints_dependency():
147147
assert len(package.requires) == 2
148148

149149

150-
def test_poetry_with_default_source():
150+
def test_poetry_with_default_source(with_simple_keyring):
151151
poetry = Factory().create_poetry(fixtures_dir / "with_default_source")
152152

153153
assert 1 == len(poetry.pool.repositories)
154154

155155

156-
def test_poetry_with_non_default_source():
156+
def test_poetry_with_non_default_source(with_simple_keyring):
157157
poetry = Factory().create_poetry(fixtures_dir / "with_non_default_source")
158158

159159
assert len(poetry.pool.repositories) == 2
@@ -167,7 +167,7 @@ def test_poetry_with_non_default_source():
167167
assert isinstance(poetry.pool.repositories[1], PyPiRepository)
168168

169169

170-
def test_poetry_with_non_default_secondary_source():
170+
def test_poetry_with_non_default_secondary_source(with_simple_keyring):
171171
poetry = Factory().create_poetry(fixtures_dir / "with_non_default_secondary_source")
172172

173173
assert len(poetry.pool.repositories) == 2
@@ -183,7 +183,7 @@ def test_poetry_with_non_default_secondary_source():
183183
assert isinstance(repository, LegacyRepository)
184184

185185

186-
def test_poetry_with_non_default_multiple_secondary_sources():
186+
def test_poetry_with_non_default_multiple_secondary_sources(with_simple_keyring):
187187
poetry = Factory().create_poetry(
188188
fixtures_dir / "with_non_default_multiple_secondary_sources"
189189
)
@@ -205,7 +205,7 @@ def test_poetry_with_non_default_multiple_secondary_sources():
205205
assert isinstance(repository, LegacyRepository)
206206

207207

208-
def test_poetry_with_non_default_multiple_sources():
208+
def test_poetry_with_non_default_multiple_sources(with_simple_keyring):
209209
poetry = Factory().create_poetry(fixtures_dir / "with_non_default_multiple_sources")
210210

211211
assert len(poetry.pool.repositories) == 3
@@ -236,7 +236,7 @@ def test_poetry_with_no_default_source():
236236
assert isinstance(poetry.pool.repositories[0], PyPiRepository)
237237

238238

239-
def test_poetry_with_two_default_sources():
239+
def test_poetry_with_two_default_sources(with_simple_keyring):
240240
with pytest.raises(ValueError) as e:
241241
Factory().create_poetry(fixtures_dir / "with_two_default_sources")
242242

tests/installation/test_authenticator.py renamed to tests/utils/test_authenticator.py

+51-3
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,14 @@
55
import pytest
66
import requests
77

8-
from poetry.installation.authenticator import Authenticator
98
from poetry.io.null_io import NullIO
9+
from poetry.utils.authenticator import Authenticator
10+
11+
12+
class SimpleCredential:
13+
def __init__(self, username, password):
14+
self.username = username
15+
self.password = password
1016

1117

1218
@pytest.fixture()
@@ -50,7 +56,9 @@ def test_authenticator_uses_credentials_from_config_if_not_provided(
5056
assert "Basic YmFyOmJheg==" == request.headers["Authorization"]
5157

5258

53-
def test_authenticator_uses_username_only_credentials(config, mock_remote, http):
59+
def test_authenticator_uses_username_only_credentials(
60+
config, mock_remote, http, with_simple_keyring
61+
):
5462
config.merge(
5563
{
5664
"repositories": {"foo": {"url": "https://foo.bar/simple/"}},
@@ -83,7 +91,7 @@ def test_authenticator_uses_password_only_credentials(config, mock_remote, http)
8391

8492

8593
def test_authenticator_uses_empty_strings_as_default_password(
86-
config, mock_remote, http
94+
config, mock_remote, http, with_simple_keyring
8795
):
8896
config.merge(
8997
{
@@ -118,6 +126,46 @@ def test_authenticator_uses_empty_strings_as_default_username(
118126
assert "Basic OmJhcg==" == request.headers["Authorization"]
119127

120128

129+
def test_authenticator_falls_back_to_keyring_url(
130+
config, mock_remote, http, with_simple_keyring, dummy_keyring
131+
):
132+
config.merge(
133+
{
134+
"repositories": {"foo": {"url": "https://foo.bar/simple/"}},
135+
}
136+
)
137+
138+
dummy_keyring.set_password(
139+
"https://foo.bar/simple/", None, SimpleCredential(None, "bar")
140+
)
141+
142+
authenticator = Authenticator(config, NullIO())
143+
authenticator.request("get", "https://foo.bar/files/foo-0.1.0.tar.gz")
144+
145+
request = http.last_request()
146+
147+
assert "Basic OmJhcg==" == request.headers["Authorization"]
148+
149+
150+
def test_authenticator_falls_back_to_keyring_netloc(
151+
config, mock_remote, http, with_simple_keyring, dummy_keyring
152+
):
153+
config.merge(
154+
{
155+
"repositories": {"foo": {"url": "https://foo.bar/simple/"}},
156+
}
157+
)
158+
159+
dummy_keyring.set_password("foo.bar", None, SimpleCredential(None, "bar"))
160+
161+
authenticator = Authenticator(config, NullIO())
162+
authenticator.request("get", "https://foo.bar/files/foo-0.1.0.tar.gz")
163+
164+
request = http.last_request()
165+
166+
assert "Basic OmJhcg==" == request.headers["Authorization"]
167+
168+
121169
def test_authenticator_request_retries_on_exception(mocker, config, http):
122170
sleep = mocker.patch("time.sleep")
123171
sdist_uri = "https://foo.bar/files/{}/foo-0.1.0.tar.gz".format(str(uuid.uuid4()))

0 commit comments

Comments
 (0)