Skip to content

Commit 29a4403

Browse files
committed
fix(keyring): make keyring unlock thread safe
When using `poetry install`, Poetry executor prefers to execute operations using a `concurrent.futures.ThreadPoolExecutor`. This can lead to multiple credential store unlock requests to be dispatched simultaneously. If the credential store is locked, and a user either intentionally or unintentionally dismisses the prompt to provide the store password; or the dbus messages fail to launch the prompter the Poetry user can experience, what can appear as a "hang". This in fact can be several threads competing with each other waiting for a dbus event indefinitely; this is a consequence of how Poetry uses keyring. This change introduces the following: - pre-flight check for installer commands that ensures keyring unlocks only once for the duration of the command - improved logging of keyring unlock event and/or failure - thread safe caching of `PasswordManager.<use_keyring|keyring>` This change does not address the following: - handling cases where dbus message fails or prompter is blocked - authentication of a locked keyring in a non-tty session
1 parent 8452907 commit 29a4403

File tree

6 files changed

+105
-25
lines changed

6 files changed

+105
-25
lines changed

src/poetry/console/commands/installer_command.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44

55
from poetry.console.commands.env_command import EnvCommand
66
from poetry.console.commands.group_command import GroupCommand
7+
from poetry.utils.password_manager import PoetryKeyring
78

89

910
if TYPE_CHECKING:
11+
from cleo.io.io import IO
12+
1013
from poetry.installation.installer import Installer
1114

1215

@@ -30,3 +33,7 @@ def installer(self) -> Installer:
3033

3134
def set_installer(self, installer: Installer) -> None:
3235
self._installer = installer
36+
37+
def execute(self, io: IO) -> int:
38+
PoetryKeyring.preflight_check(io)
39+
return super().execute(io)

src/poetry/utils/password_manager.py

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@
77
from contextlib import suppress
88
from typing import TYPE_CHECKING
99

10+
from poetry.config.config import Config
11+
from poetry.utils.threading import atomic_cached_property
12+
1013

1114
if TYPE_CHECKING:
12-
from keyring.backend import KeyringBackend
15+
import keyring.backend
16+
17+
from cleo.io.io import IO
1318

14-
from poetry.config.config import Config
1519

1620
logger = logging.getLogger(__name__)
1721

@@ -37,6 +41,35 @@ class PoetryKeyring:
3741
def __init__(self, namespace: str) -> None:
3842
self._namespace = namespace
3943

44+
@staticmethod
45+
def preflight_check(io: IO | None = None, config: Config | None = None) -> None:
46+
"""
47+
Performs a preflight check to determine the availability of the keyring service
48+
and logs the status if verbosity is enabled. This method is used to validate
49+
the configuration setup related to the keyring functionality.
50+
51+
:param io: An optional input/output handler used to log messages during the
52+
preflight check. If not provided, logging will be skipped.
53+
:param config: An optional configuration object. If not provided, a new
54+
configuration instance will be created using the default factory method.
55+
:return: None
56+
"""
57+
config = config or Config.create()
58+
59+
if config.get("keyring.enabled"):
60+
if io and io.is_verbose():
61+
io.write("Checking keyring availability: ")
62+
63+
message = "<fg=yellow;options=bold>Unavailable</>"
64+
65+
with suppress(RuntimeError, ValueError):
66+
if PoetryKeyring.is_available():
67+
message = "<fg=green;options=bold>Available</>"
68+
69+
if io and io.is_verbose():
70+
io.write(message)
71+
io.write_line("")
72+
4073
def get_credential(
4174
self, *names: str, username: str | None = None
4275
) -> HTTPAuthCredential:
@@ -70,9 +103,9 @@ def get_password(self, name: str, username: str) -> str | None:
70103

71104
try:
72105
return keyring.get_password(name, username or self._EMPTY_USERNAME_KEY)
73-
except (RuntimeError, keyring.errors.KeyringError):
106+
except (RuntimeError, keyring.errors.KeyringError) as e:
74107
raise PoetryKeyringError(
75-
f"Unable to retrieve the password for {name} from the key ring"
108+
f"Unable to retrieve the password for {name} from the key ring {e}"
76109
)
77110

78111
def set_password(self, name: str, username: str, password: str) -> None:
@@ -104,20 +137,22 @@ def get_entry_name(self, name: str) -> str:
104137
return f"{self._namespace}-{name}"
105138

106139
@classmethod
140+
@functools.cache
107141
def is_available(cls) -> bool:
108142
logger.debug("Checking if keyring is available")
109143
try:
110144
import keyring
111145
import keyring.backend
146+
import keyring.errors
112147
except ImportError as e:
113148
logger.debug("An error occurred while importing keyring: %s", e)
114149
return False
115150

116-
def backend_name(backend: KeyringBackend) -> str:
151+
def backend_name(backend: keyring.backend.KeyringBackend) -> str:
117152
name: str = backend.name
118153
return name.split(" ")[0]
119154

120-
def backend_is_valid(backend: KeyringBackend) -> bool:
155+
def backend_is_valid(backend: keyring.backend.KeyringBackend) -> bool:
121156
name = backend_name(backend)
122157
if name in ("chainer", "fail", "null"):
123158
logger.debug(f"Backend {backend.name!r} is not suitable")
@@ -138,20 +173,30 @@ def backend_is_valid(backend: KeyringBackend) -> bool:
138173
if valid_backend is None:
139174
logger.debug("No valid keyring backend was found")
140175
return False
141-
else:
142-
logger.debug(f"Using keyring backend {backend.name!r}")
143-
return True
176+
177+
logger.debug(f"Using keyring backend {backend.name!r}")
178+
179+
try:
180+
# unfortunately there is no clean of checking if keyring is unlocked
181+
keyring.get_password("python-poetry-check", "python-poetry")
182+
except (RuntimeError, keyring.errors.KeyringError):
183+
logger.debug(
184+
"Accessing keyring failed during availability check", exc_info=True
185+
)
186+
return False
187+
188+
return True
144189

145190

146191
class PasswordManager:
147192
def __init__(self, config: Config) -> None:
148193
self._config = config
149194

150-
@functools.cached_property
195+
@atomic_cached_property
151196
def use_keyring(self) -> bool:
152197
return self._config.get("keyring.enabled") and PoetryKeyring.is_available()
153198

154-
@functools.cached_property
199+
@atomic_cached_property
155200
def keyring(self) -> PoetryKeyring:
156201
if not self.use_keyring:
157202
raise PoetryKeyringError(

tests/conftest.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from tests.helpers import http_setup_redirect
4444
from tests.helpers import isolated_environment
4545
from tests.helpers import mock_clone
46+
from tests.helpers import set_keyring_backend
4647
from tests.helpers import switch_working_directory
4748
from tests.helpers import with_working_directory
4849

@@ -204,29 +205,29 @@ def dummy_keyring() -> DummyBackend:
204205

205206
@pytest.fixture()
206207
def with_simple_keyring(dummy_keyring: DummyBackend) -> None:
207-
keyring.set_keyring(dummy_keyring)
208+
set_keyring_backend(dummy_keyring)
208209

209210

210211
@pytest.fixture()
211212
def with_fail_keyring() -> None:
212-
keyring.set_keyring(FailKeyring()) # type: ignore[no-untyped-call]
213+
set_keyring_backend(FailKeyring()) # type: ignore[no-untyped-call]
213214

214215

215216
@pytest.fixture()
216217
def with_locked_keyring() -> None:
217-
keyring.set_keyring(LockedBackend()) # type: ignore[no-untyped-call]
218+
set_keyring_backend(LockedBackend()) # type: ignore[no-untyped-call]
218219

219220

220221
@pytest.fixture()
221222
def with_erroneous_keyring() -> None:
222-
keyring.set_keyring(ErroneousBackend()) # type: ignore[no-untyped-call]
223+
set_keyring_backend(ErroneousBackend()) # type: ignore[no-untyped-call]
223224

224225

225226
@pytest.fixture()
226227
def with_null_keyring() -> None:
227228
from keyring.backends.null import Keyring
228229

229-
keyring.set_keyring(Keyring()) # type: ignore[no-untyped-call]
230+
set_keyring_backend(Keyring()) # type: ignore[no-untyped-call]
230231

231232

232233
@pytest.fixture()
@@ -237,7 +238,7 @@ def with_chained_fail_keyring(mocker: MockerFixture) -> None:
237238
)
238239
from keyring.backends.chainer import ChainerBackend
239240

240-
keyring.set_keyring(ChainerBackend()) # type: ignore[no-untyped-call]
241+
set_keyring_backend(ChainerBackend()) # type: ignore[no-untyped-call]
241242

242243

243244
@pytest.fixture()
@@ -250,7 +251,7 @@ def with_chained_null_keyring(mocker: MockerFixture) -> None:
250251
)
251252
from keyring.backends.chainer import ChainerBackend
252253

253-
keyring.set_keyring(ChainerBackend()) # type: ignore[no-untyped-call]
254+
set_keyring_backend(ChainerBackend()) # type: ignore[no-untyped-call]
254255

255256

256257
@pytest.fixture
@@ -633,3 +634,8 @@ def handle(self) -> int:
633634
return MockCommand()
634635

635636
return _command_factory
637+
638+
639+
@pytest.fixture(autouse=True)
640+
def default_keyring(with_null_keyring: None) -> None:
641+
pass

tests/helpers.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from pathlib import Path
99
from typing import TYPE_CHECKING
1010

11+
import keyring
12+
1113
from poetry.core.packages.package import Package
1214
from poetry.core.packages.utils.link import Link
1315
from poetry.core.vcs.git import ParsedUrl
@@ -20,6 +22,7 @@
2022
from poetry.repositories import Repository
2123
from poetry.repositories.exceptions import PackageNotFoundError
2224
from poetry.utils._compat import metadata
25+
from poetry.utils.password_manager import PoetryKeyring
2326

2427

2528
if TYPE_CHECKING:
@@ -30,6 +33,7 @@
3033
import httpretty
3134

3235
from httpretty.core import HTTPrettyRequest
36+
from keyring.backend import KeyringBackend
3337
from poetry.core.constraints.version import Version
3438
from poetry.core.packages.dependency import Dependency
3539
from pytest_mock import MockerFixture
@@ -356,3 +360,9 @@ def with_working_directory(source: Path, target: Path | None = None) -> Iterator
356360

357361
with switch_working_directory(target or source, remove=use_copy) as path:
358362
yield path
363+
364+
365+
def set_keyring_backend(backend: KeyringBackend) -> None:
366+
"""Clears availability cache and sets the specified keyring backend."""
367+
PoetryKeyring.is_available.cache_clear()
368+
keyring.set_keyring(backend)

tests/utils/test_authenticator.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,14 @@
1818

1919
from poetry.utils.authenticator import Authenticator
2020
from poetry.utils.authenticator import RepositoryCertificateConfig
21+
from poetry.utils.password_manager import PoetryKeyring
2122

2223

2324
if TYPE_CHECKING:
2425
from pytest import LogCaptureFixture
2526
from pytest import MonkeyPatch
2627
from pytest_mock import MockerFixture
2728

28-
from poetry.utils.password_manager import PoetryKeyring
2929
from tests.conftest import Config
3030
from tests.conftest import DummyBackend
3131

@@ -96,29 +96,39 @@ def test_authenticator_ignores_locked_keyring(
9696
http: type[httpretty.httpretty],
9797
with_locked_keyring: None,
9898
caplog: LogCaptureFixture,
99+
mocker: MockerFixture,
99100
) -> None:
100101
caplog.set_level(logging.DEBUG, logger="poetry.utils.password_manager")
102+
spy_get_credential = mocker.spy(PoetryKeyring, "get_credential")
103+
spy_get_password = mocker.spy(PoetryKeyring, "get_password")
101104
authenticator = Authenticator()
102105
authenticator.request("get", "https://foo.bar/files/foo-0.1.0.tar.gz")
103106

104107
request = http.last_request()
105108
assert request.headers["Authorization"] is None
106-
assert "Keyring foo.bar is locked" in caplog.messages
109+
assert "Using keyring backend 'conftest LockedBackend'" in caplog.messages
110+
assert spy_get_credential.call_count == spy_get_password.call_count == 0
107111

108112

109113
def test_authenticator_ignores_failing_keyring(
110114
mock_remote: None,
111115
http: type[httpretty.httpretty],
112116
with_erroneous_keyring: None,
113117
caplog: LogCaptureFixture,
118+
mocker: MockerFixture,
114119
) -> None:
115120
caplog.set_level(logging.DEBUG, logger="poetry.utils.password_manager")
121+
spy_get_credential = mocker.spy(PoetryKeyring, "get_credential")
122+
spy_get_password = mocker.spy(PoetryKeyring, "get_password")
116123
authenticator = Authenticator()
117124
authenticator.request("get", "https://foo.bar/files/foo-0.1.0.tar.gz")
118125

119126
request = http.last_request()
120127
assert request.headers["Authorization"] is None
121-
assert "Accessing keyring foo.bar failed" in caplog.messages
128+
129+
assert "Using keyring backend 'conftest ErroneousBackend'" in caplog.messages
130+
assert "Accessing keyring failed during availability check" in caplog.messages
131+
assert spy_get_credential.call_count == spy_get_password.call_count == 0
122132

123133

124134
def test_authenticator_uses_password_only_credentials(

tests/utils/test_password_manager.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -285,16 +285,18 @@ def test_fail_keyring_should_be_unavailable(
285285
assert not key_ring.is_available()
286286

287287

288-
def test_locked_keyring_should_be_available(with_locked_keyring: None) -> None:
288+
def test_locked_keyring_should_not_be_available(with_locked_keyring: None) -> None:
289289
key_ring = PoetryKeyring("poetry")
290290

291-
assert key_ring.is_available()
291+
assert not key_ring.is_available()
292292

293293

294-
def test_erroneous_keyring_should_be_available(with_erroneous_keyring: None) -> None:
294+
def test_erroneous_keyring_should_not_be_available(
295+
with_erroneous_keyring: None,
296+
) -> None:
295297
key_ring = PoetryKeyring("poetry")
296298

297-
assert key_ring.is_available()
299+
assert not key_ring.is_available()
298300

299301

300302
def test_get_http_auth_from_environment_variables(

0 commit comments

Comments
 (0)