Skip to content

Commit 3a1b45f

Browse files
authored
Merge pull request #4086 from Darsstar/pip-style-keyring-fallback
Added pip style keyring password lookup as a fallback.
2 parents b54f2dc + 6db85a6 commit 3a1b45f

File tree

9 files changed

+221
-115
lines changed

9 files changed

+221
-115
lines changed

docs/repositories.md

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

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

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

8095
```bash

poetry/installation/executor.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424
from poetry.utils.helpers import safe_rmtree
2525
from poetry.utils.pip import pip_editable_install
2626

27+
from ..utils.authenticator import Authenticator
2728
from ..utils.pip import pip_install
28-
from .authenticator import Authenticator
2929
from .chef import Chef
3030
from .chooser import Chooser
3131
from .operations.install import Install

poetry/publishing/publisher.py

+6-7
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@
66
from typing import Optional
77
from typing import Union
88

9-
from poetry.utils.helpers import get_cert
10-
from poetry.utils.helpers import get_client_cert
11-
from poetry.utils.password_manager import PasswordManager
12-
9+
from ..utils.authenticator import Authenticator
10+
from ..utils.helpers import get_cert
11+
from ..utils.helpers import get_client_cert
1312
from .uploader import Uploader
1413

1514

@@ -32,7 +31,7 @@ def __init__(self, poetry: "Poetry", io: Union["BufferedIO", "ConsoleIO"]) -> No
3231
self._package = poetry.package
3332
self._io = io
3433
self._uploader = Uploader(poetry, io)
35-
self._password_manager = PasswordManager(poetry.config)
34+
self._authenticator = Authenticator(poetry.config, self._io)
3635

3736
@property
3837
def files(self) -> List[Path]:
@@ -58,13 +57,13 @@ def publish(
5857

5958
if not (username and password):
6059
# Check if we have a token first
61-
token = self._password_manager.get_pypi_token(repository_name)
60+
token = self._authenticator.get_pypi_token(repository_name)
6261
if token:
6362
logger.debug(f"Found an API token for {repository_name}.")
6463
username = "__token__"
6564
password = token
6665
else:
67-
auth = self._password_manager.get_http_auth(repository_name)
66+
auth = self._authenticator.get_http_auth(repository_name)
6867
if auth:
6968
logger.debug(
7069
"Found authentication information for {}.".format(

poetry/repositories/legacy_repository.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434

3535
from ..config.config import Config
3636
from ..inspection.info import PackageInfo
37-
from ..installation.authenticator import Authenticator
37+
from ..utils.authenticator import Authenticator
3838
from .exceptions import PackageNotFound
3939
from .exceptions import RepositoryError
4040
from .pypi_repository import PyPiRepository

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

+62-16
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from typing import TYPE_CHECKING
66
from typing import Any
7+
from typing import Dict
78
from typing import Optional
89
from typing import Tuple
910

@@ -108,7 +109,7 @@ def get_credentials_for_url(self, url: str) -> Tuple[Optional[str], Optional[str
108109

109110
if credentials == (None, None):
110111
if "@" not in netloc:
111-
credentials = self._get_credentials_for_netloc_from_config(netloc)
112+
credentials = self._get_credentials_for_netloc(netloc)
112113
else:
113114
# Split from the right because that's how urllib.parse.urlsplit()
114115
# behaves if more than one @ is present (which can be checked using
@@ -133,28 +134,73 @@ def get_credentials_for_url(self, url: str) -> Tuple[Optional[str], Optional[str
133134

134135
return credentials[0], credentials[1]
135136

136-
def _get_credentials_for_netloc_from_config(
137+
def get_pypi_token(self, name: str) -> str:
138+
return self._password_manager.get_pypi_token(name)
139+
140+
def get_http_auth(self, name: str) -> Optional[Dict[str, str]]:
141+
return self._get_http_auth(name, None)
142+
143+
def _get_http_auth(
144+
self, name: str, netloc: Optional[str]
145+
) -> Optional[Dict[str, str]]:
146+
if name == "pypi":
147+
url = "https://upload.pypi.org/legacy/"
148+
else:
149+
url = self._config.get(f"repositories.{name}.url")
150+
if not url:
151+
return
152+
153+
parsed_url = urllib.parse.urlsplit(url)
154+
155+
if netloc is None or netloc == parsed_url.netloc:
156+
auth = self._password_manager.get_http_auth(name)
157+
158+
if auth is None or auth["password"] is None:
159+
username = auth["username"] if auth else None
160+
auth = self._get_credentials_for_netloc_from_keyring(
161+
url, parsed_url.netloc, username
162+
)
163+
164+
return auth
165+
166+
def _get_credentials_for_netloc(
137167
self, netloc: str
138168
) -> Tuple[Optional[str], Optional[str]]:
139169
credentials = (None, None)
140170

141171
for repository_name in self._config.get("repositories", []):
142-
repository_config = self._config.get(f"repositories.{repository_name}")
143-
if not repository_config:
144-
continue
172+
auth = self._get_http_auth(repository_name, netloc)
145173

146-
url = repository_config.get("url")
147-
if not url:
174+
if auth is None:
148175
continue
149176

150-
parsed_url = urllib.parse.urlsplit(url)
151-
152-
if netloc == parsed_url.netloc:
153-
auth = self._password_manager.get_http_auth(repository_name)
154-
155-
if auth is None:
156-
continue
157-
158-
return auth["username"], auth["password"]
177+
return auth["username"], auth["password"]
159178

160179
return credentials
180+
181+
def _get_credentials_for_netloc_from_keyring(
182+
self, url: str, netloc: str, username: Optional[str]
183+
) -> Optional[Dict[str, str]]:
184+
import keyring
185+
186+
cred = keyring.get_credential(url, username)
187+
if cred is not None:
188+
return {
189+
"username": cred.username,
190+
"password": cred.password,
191+
}
192+
193+
cred = keyring.get_credential(netloc, username)
194+
if cred is not None:
195+
return {
196+
"username": cred.username,
197+
"password": cred.password,
198+
}
199+
200+
if username:
201+
return {
202+
"username": username,
203+
"password": None,
204+
}
205+
206+
return None

tests/conftest.py

+56
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import pytest
1313

1414
from cleo.testers.command_tester import CommandTester
15+
from keyring.backend import KeyringBackend
1516

1617
from poetry.config.config import Config as BaseConfig
1718
from poetry.config.dict_config_source import DictConfigSource
@@ -53,6 +54,61 @@ def all(self) -> 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_cache_dir(tmp_dir):
58114
path = Path(tmp_dir) / ".cache" / "pypoetry"

tests/test_factory.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -156,13 +156,13 @@ def test_create_poetry_with_multi_constraints_dependency():
156156
assert len(package.requires) == 2
157157

158158

159-
def test_poetry_with_default_source():
159+
def test_poetry_with_default_source(with_simple_keyring):
160160
poetry = Factory().create_poetry(fixtures_dir / "with_default_source")
161161

162162
assert 1 == len(poetry.pool.repositories)
163163

164164

165-
def test_poetry_with_non_default_source():
165+
def test_poetry_with_non_default_source(with_simple_keyring):
166166
poetry = Factory().create_poetry(fixtures_dir / "with_non_default_source")
167167

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

178178

179-
def test_poetry_with_non_default_secondary_source():
179+
def test_poetry_with_non_default_secondary_source(with_simple_keyring):
180180
poetry = Factory().create_poetry(fixtures_dir / "with_non_default_secondary_source")
181181

182182
assert len(poetry.pool.repositories) == 2
@@ -192,7 +192,7 @@ def test_poetry_with_non_default_secondary_source():
192192
assert isinstance(repository, LegacyRepository)
193193

194194

195-
def test_poetry_with_non_default_multiple_secondary_sources():
195+
def test_poetry_with_non_default_multiple_secondary_sources(with_simple_keyring):
196196
poetry = Factory().create_poetry(
197197
fixtures_dir / "with_non_default_multiple_secondary_sources"
198198
)
@@ -214,7 +214,7 @@ def test_poetry_with_non_default_multiple_secondary_sources():
214214
assert isinstance(repository, LegacyRepository)
215215

216216

217-
def test_poetry_with_non_default_multiple_sources():
217+
def test_poetry_with_non_default_multiple_sources(with_simple_keyring):
218218
poetry = Factory().create_poetry(fixtures_dir / "with_non_default_multiple_sources")
219219

220220
assert len(poetry.pool.repositories) == 3
@@ -245,7 +245,7 @@ def test_poetry_with_no_default_source():
245245
assert isinstance(poetry.pool.repositories[0], PyPiRepository)
246246

247247

248-
def test_poetry_with_two_default_sources():
248+
def test_poetry_with_two_default_sources(with_simple_keyring):
249249
with pytest.raises(ValueError) as e:
250250
Factory().create_poetry(fixtures_dir / "with_two_default_sources")
251251

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

+51-3
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@
77

88
from cleo.io.null_io import NullIO
99

10-
from poetry.installation.authenticator import Authenticator
10+
from poetry.utils.authenticator import Authenticator
11+
12+
13+
class SimpleCredential:
14+
def __init__(self, username, password):
15+
self.username = username
16+
self.password = password
1117

1218

1319
@pytest.fixture()
@@ -52,7 +58,9 @@ def test_authenticator_uses_credentials_from_config_if_not_provided(
5258
assert "Basic YmFyOmJheg==" == request.headers["Authorization"]
5359

5460

55-
def test_authenticator_uses_username_only_credentials(config, mock_remote, http):
61+
def test_authenticator_uses_username_only_credentials(
62+
config, mock_remote, http, with_simple_keyring
63+
):
5664
config.merge(
5765
{
5866
"repositories": {"foo": {"url": "https://foo.bar/simple/"}},
@@ -85,7 +93,7 @@ def test_authenticator_uses_password_only_credentials(config, mock_remote, http)
8593

8694

8795
def test_authenticator_uses_empty_strings_as_default_password(
88-
config, mock_remote, http
96+
config, mock_remote, http, with_simple_keyring
8997
):
9098
config.merge(
9199
{
@@ -120,6 +128,46 @@ def test_authenticator_uses_empty_strings_as_default_username(
120128
assert "Basic OmJhcg==" == request.headers["Authorization"]
121129

122130

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

0 commit comments

Comments
 (0)