Skip to content

Commit b23d14f

Browse files
committed
Refactor MultiDomainBasicAuth
I was starting to work on #12750, but got hung up on some strange OOP patterns going on here. In particular, I found it odd that there are 2 different ways we set some knobs (`prompting` and `keyring_provider`) and for instances of `MultiDomainBasicAuth`: 1. In tests, we do it by passing these knobs as constructor parameters to `MultiDomainBasicAuth`. 2. In the real cli codepath, we do it by mutating `session.auth` (an instance of `MultiDomainBasicAuth`) after it's constructed. I believe that 2) is the reason for the careful cache management logic you see me tearing out in the PR. Basically: when a `MultiDomainBasicAuth` is constructed, it's possible that the keyring provider will change after the fact, so we can't do any useful caching in our constructor. In this PR, I reworked all of this to behave like more normal OOP: I've tightened up the API of `MultiDomainBasicAuth` so that these knobs are only passed in as constructor parameters, and the other instance attributes are now "private" (underscore-prefixed) values that you're not supposed to read or write to (except for some tests that look at these attributes to verify things are working). This change allowed me to get rid of all the careful cache management code we had, and sets me up to implement #12750 as I'm going to be adding yet another knob (`keyring_executable`). This new pattern will allow me to validate that `keyring_executable` is compatible with `keyring_provider` in `MultiDomainAuthSettings`'s constructor (it doesn't make any sense to specify a path to an executable if you're using the import provider, for example). With the previous code pattern, there was just no good place to validate that. You'll notice I did end up touching a couple of tests: - `test_collector.py`: Logging is slightly different now, as we now immediately go fetch the keyring provider, rather than lazily. I could rework this to preserve the old behavior, but I don't think it's an important difference, it only affects tests that are trying to assert on every single log message. - `test_network_auth.py`: Nothing significant here. Just removing the now-unnecessary `reset_keyring` logic, and updating `.password` to `._password` to reflect the new api.
1 parent 390cb42 commit b23d14f

File tree

6 files changed

+57
-62
lines changed

6 files changed

+57
-62
lines changed

news/d11595a3-3f64-4c8a-9e79-4a811802cafa.trivial.rst

Whitespace-only changes.

src/pip/_internal/cli/index_command.py

+7-6
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def _build_session(
8686
retries: Optional[int] = None,
8787
timeout: Optional[int] = None,
8888
) -> "PipSession":
89-
from pip._internal.network.session import PipSession
89+
from pip._internal.network.session import MultiDomainAuthSettings, PipSession
9090

9191
cache_dir = options.cache_dir
9292
assert not cache_dir or os.path.isabs(cache_dir)
@@ -100,8 +100,13 @@ def _build_session(
100100
cache=os.path.join(cache_dir, "http-v2") if cache_dir else None,
101101
retries=retries if retries is not None else options.retries,
102102
trusted_hosts=options.trusted_hosts,
103-
index_urls=self._get_index_urls(options),
104103
ssl_context=ssl_context,
104+
multi_domain_auth_settings=MultiDomainAuthSettings(
105+
index_urls=self._get_index_urls(options),
106+
# Determine if we can prompt the user for authentication or not
107+
prompting=not options.no_input,
108+
keyring_provider=options.keyring_provider,
109+
),
105110
)
106111

107112
# Handle custom ca-bundles from the user
@@ -124,10 +129,6 @@ def _build_session(
124129
}
125130
session.trust_env = False
126131

127-
# Determine if we can prompt the user for authentication or not
128-
session.auth.prompting = not options.no_input
129-
session.auth.keyring_provider = options.keyring_provider
130-
131132
return session
132133

133134

src/pip/_internal/network/auth.py

+25-40
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
import sysconfig
1212
import urllib.parse
1313
from abc import ABC, abstractmethod
14-
from functools import lru_cache
1514
from os.path import commonprefix
1615
from pathlib import Path
1716
from typing import Any, Dict, List, NamedTuple, Optional, Tuple
@@ -32,8 +31,6 @@
3231

3332
logger = getLogger(__name__)
3433

35-
KEYRING_DISABLED = False
36-
3734

3835
class Credentials(NamedTuple):
3936
url: str
@@ -201,13 +198,9 @@ def PATH_as_shutil_which_determines_it() -> str:
201198
return path
202199

203200

204-
@lru_cache(maxsize=None)
205201
def get_keyring_provider(provider: str) -> KeyRingBaseProvider:
206202
logger.verbose("Keyring provider requested: %s", provider)
207203

208-
# keyring has previously failed and been disabled
209-
if KEYRING_DISABLED:
210-
provider = "disabled"
211204
if provider in ["import", "auto"]:
212205
try:
213206
impl = KeyRingPythonProvider()
@@ -239,35 +232,27 @@ def __init__(
239232
index_urls: Optional[List[str]] = None,
240233
keyring_provider: str = "auto",
241234
) -> None:
242-
self.prompting = prompting
243-
self.index_urls = index_urls
244-
self.keyring_provider = keyring_provider # type: ignore[assignment]
245-
self.passwords: Dict[str, AuthInfo] = {}
235+
self._prompting = prompting
236+
self._index_urls = index_urls
237+
self._keyring_provider_name = keyring_provider
238+
self._keyring_provider = get_keyring_provider(self._keyring_provider_name)
239+
self._passwords: Dict[str, AuthInfo] = {}
246240
# When the user is prompted to enter credentials and keyring is
247241
# available, we will offer to save them. If the user accepts,
248242
# this value is set to the credentials they entered. After the
249243
# request authenticates, the caller should call
250244
# ``save_credentials`` to save these.
251245
self._credentials_to_save: Optional[Credentials] = None
252246

253-
@property
254-
def keyring_provider(self) -> KeyRingBaseProvider:
255-
return get_keyring_provider(self._keyring_provider)
256-
257-
@keyring_provider.setter
258-
def keyring_provider(self, provider: str) -> None:
259-
# The free function get_keyring_provider has been decorated with
260-
# functools.cache. If an exception occurs in get_keyring_auth that
261-
# cache will be cleared and keyring disabled, take that into account
262-
# if you want to remove this indirection.
263-
self._keyring_provider = provider
264-
265247
@property
266248
def use_keyring(self) -> bool:
267249
# We won't use keyring when --no-input is passed unless
268250
# a specific provider is requested because it might require
269251
# user interaction
270-
return self.prompting or self._keyring_provider not in ["auto", "disabled"]
252+
return self._prompting or self._keyring_provider_name not in [
253+
"auto",
254+
"disabled",
255+
]
271256

272257
def _get_keyring_auth(
273258
self,
@@ -280,7 +265,7 @@ def _get_keyring_auth(
280265
return None
281266

282267
try:
283-
return self.keyring_provider.get_auth_info(url, username)
268+
return self._keyring_provider.get_auth_info(url, username)
284269
except Exception as exc:
285270
# Log the full exception (with stacktrace) at debug, so it'll only
286271
# show up when running in verbose mode.
@@ -290,9 +275,9 @@ def _get_keyring_auth(
290275
"Keyring is skipped due to an exception: %s",
291276
str(exc),
292277
)
293-
global KEYRING_DISABLED
294-
KEYRING_DISABLED = True
295-
get_keyring_provider.cache_clear()
278+
# Disable keyring.
279+
self._keyring_provider_name = "disabled"
280+
self._keyring_provider = get_keyring_provider(self._keyring_provider_name)
296281
return None
297282

298283
def _get_index_url(self, url: str) -> Optional[str]:
@@ -308,15 +293,15 @@ def _get_index_url(self, url: str) -> Optional[str]:
308293
Returns None if no matching index was found, or if --no-index
309294
was specified by the user.
310295
"""
311-
if not url or not self.index_urls:
296+
if not url or not self._index_urls:
312297
return None
313298

314299
url = remove_auth_from_url(url).rstrip("/") + "/"
315300
parsed_url = urllib.parse.urlsplit(url)
316301

317302
candidates = []
318303

319-
for index in self.index_urls:
304+
for index in self._index_urls:
320305
index = index.rstrip("/") + "/"
321306
parsed_index = urllib.parse.urlsplit(remove_auth_from_url(index))
322307
if parsed_url == parsed_index:
@@ -421,8 +406,8 @@ def _get_url_and_credentials(
421406
# Do this if either the username or the password is missing.
422407
# This accounts for the situation in which the user has specified
423408
# the username in the index url, but the password comes from keyring.
424-
if (username is None or password is None) and netloc in self.passwords:
425-
un, pw = self.passwords[netloc]
409+
if (username is None or password is None) and netloc in self._passwords:
410+
un, pw = self._passwords[netloc]
426411
# It is possible that the cached credentials are for a different username,
427412
# in which case the cache should be ignored.
428413
if username is None or username == un:
@@ -437,7 +422,7 @@ def _get_url_and_credentials(
437422
password = password or ""
438423

439424
# Store any acquired credentials.
440-
self.passwords[netloc] = (username, password)
425+
self._passwords[netloc] = (username, password)
441426

442427
assert (
443428
# Credentials were found
@@ -468,7 +453,7 @@ def __call__(self, req: Request) -> Request:
468453
def _prompt_for_password(
469454
self, netloc: str
470455
) -> Tuple[Optional[str], Optional[str], bool]:
471-
username = ask_input(f"User for {netloc}: ") if self.prompting else None
456+
username = ask_input(f"User for {netloc}: ") if self._prompting else None
472457
if not username:
473458
return None, None, False
474459
if self.use_keyring:
@@ -481,9 +466,9 @@ def _prompt_for_password(
481466
# Factored out to allow for easy patching in tests
482467
def _should_save_password_to_keyring(self) -> bool:
483468
if (
484-
not self.prompting
469+
not self._prompting
485470
or not self.use_keyring
486-
or not self.keyring_provider.has_keyring
471+
or not self._keyring_provider.has_keyring
487472
):
488473
return False
489474
return ask("Save credentials to keyring [y/N]: ", ["y", "n"]) == "y"
@@ -505,7 +490,7 @@ def handle_401(self, resp: Response, **kwargs: Any) -> Response:
505490
)
506491

507492
# We are not able to prompt the user so simply return the response
508-
if not self.prompting and not username and not password:
493+
if not self._prompting and not username and not password:
509494
return resp
510495

511496
parsed = urllib.parse.urlparse(resp.url)
@@ -518,7 +503,7 @@ def handle_401(self, resp: Response, **kwargs: Any) -> Response:
518503
# Store the new username and password to use for future requests
519504
self._credentials_to_save = None
520505
if username is not None and password is not None:
521-
self.passwords[parsed.netloc] = (username, password)
506+
self._passwords[parsed.netloc] = (username, password)
522507

523508
# Prompt to save the password to keyring
524509
if save and self._should_save_password_to_keyring():
@@ -562,15 +547,15 @@ def warn_on_401(self, resp: Response, **kwargs: Any) -> None:
562547
def save_credentials(self, resp: Response, **kwargs: Any) -> None:
563548
"""Response callback to save credentials on success."""
564549
assert (
565-
self.keyring_provider.has_keyring
550+
self._keyring_provider.has_keyring
566551
), "should never reach here without keyring"
567552

568553
creds = self._credentials_to_save
569554
self._credentials_to_save = None
570555
if creds and resp.status_code < 400:
571556
try:
572557
logger.info("Saving credentials to keyring")
573-
self.keyring_provider.save_auth_info(
558+
self._keyring_provider.save_auth_info(
574559
creds.url, creds.username, creds.password
575560
)
576561
except Exception:

src/pip/_internal/network/session.py

+16-2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import sys
1717
import urllib.parse
1818
import warnings
19+
from dataclasses import dataclass
1920
from typing import (
2021
TYPE_CHECKING,
2122
Any,
@@ -317,6 +318,13 @@ def cert_verify(
317318
super().cert_verify(conn=conn, url=url, verify=False, cert=cert)
318319

319320

321+
@dataclass(frozen=True)
322+
class MultiDomainAuthSettings:
323+
prompting: bool = True
324+
index_urls: Optional[List[str]] = None
325+
keyring_provider: str = "auto"
326+
327+
320328
class PipSession(requests.Session):
321329
timeout: Optional[int] = None
322330

@@ -326,8 +334,8 @@ def __init__(
326334
retries: int = 0,
327335
cache: Optional[str] = None,
328336
trusted_hosts: Sequence[str] = (),
329-
index_urls: Optional[List[str]] = None,
330337
ssl_context: Optional["SSLContext"] = None,
338+
multi_domain_auth_settings: Optional[MultiDomainAuthSettings] = None,
331339
**kwargs: Any,
332340
) -> None:
333341
"""
@@ -344,7 +352,13 @@ def __init__(
344352
self.headers["User-Agent"] = user_agent()
345353

346354
# Attach our Authentication handler to the session
347-
self.auth = MultiDomainBasicAuth(index_urls=index_urls)
355+
if multi_domain_auth_settings is None:
356+
multi_domain_auth_settings = MultiDomainAuthSettings()
357+
self.auth = MultiDomainBasicAuth(
358+
prompting=multi_domain_auth_settings.prompting,
359+
index_urls=multi_domain_auth_settings.index_urls,
360+
keyring_provider=multi_domain_auth_settings.keyring_provider,
361+
)
348362

349363
# Create our urllib3.Retry instance which will allow us to customize
350364
# how we handle retries.

tests/unit/test_collector.py

+3
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
_ensure_quoted_url,
3636
)
3737
from pip._internal.network.session import PipSession
38+
from pip._internal.utils._log import VERBOSE
3839
from tests.lib import (
3940
TestData,
4041
make_test_link_collector,
@@ -1064,6 +1065,8 @@ def test_collect_file_sources(
10641065
* https://pypi.org/simple/singlemodule/"""
10651066
)
10661067
assert caplog.record_tuples == [
1068+
("pip._internal.network.auth", VERBOSE, "Keyring provider requested: auto"),
1069+
("pip._internal.network.auth", VERBOSE, "Keyring provider set: disabled"),
10671070
("pip._internal.index.collector", logging.DEBUG, expected_message),
10681071
]
10691072

tests/unit/test_network_auth.py

+6-14
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import os
33
import subprocess
44
import sys
5-
from typing import Any, Dict, Iterable, List, Optional, Tuple
5+
from typing import Any, Dict, List, Optional, Tuple
66

77
import pytest
88

@@ -11,14 +11,6 @@
1111
from tests.lib.requests_mocks import MockConnection, MockRequest, MockResponse
1212

1313

14-
@pytest.fixture(autouse=True)
15-
def reset_keyring() -> Iterable[None]:
16-
yield None
17-
# Reset the state of the module between tests
18-
pip._internal.network.auth.KEYRING_DISABLED = False
19-
pip._internal.network.auth.get_keyring_provider.cache_clear()
20-
21-
2214
@pytest.mark.parametrize(
2315
"input_url, url, username, password",
2416
[
@@ -61,13 +53,13 @@ def test_get_credentials_parses_correctly(
6153
(username is None and password is None)
6254
or
6355
# Credentials were found and "cached" appropriately
64-
auth.passwords["example.com"] == (username, password)
56+
auth._passwords["example.com"] == (username, password)
6557
)
6658

6759

6860
def test_get_credentials_not_to_uses_cached_credentials() -> None:
6961
auth = MultiDomainBasicAuth()
70-
auth.passwords["example.com"] = ("user", "pass")
62+
auth._passwords["example.com"] = ("user", "pass")
7163

7264
got = auth._get_url_and_credentials("http://foo:[email protected]/path")
7365
expected = ("http://example.com/path", "foo", "bar")
@@ -76,7 +68,7 @@ def test_get_credentials_not_to_uses_cached_credentials() -> None:
7668

7769
def test_get_credentials_not_to_uses_cached_credentials_only_username() -> None:
7870
auth = MultiDomainBasicAuth()
79-
auth.passwords["example.com"] = ("user", "pass")
71+
auth._passwords["example.com"] = ("user", "pass")
8072

8173
got = auth._get_url_and_credentials("http://[email protected]/path")
8274
expected = ("http://example.com/path", "foo", "")
@@ -85,7 +77,7 @@ def test_get_credentials_not_to_uses_cached_credentials_only_username() -> None:
8577

8678
def test_get_credentials_uses_cached_credentials() -> None:
8779
auth = MultiDomainBasicAuth()
88-
auth.passwords["example.com"] = ("user", "pass")
80+
auth._passwords["example.com"] = ("user", "pass")
8981

9082
got = auth._get_url_and_credentials("http://example.com/path")
9183
expected = ("http://example.com/path", "user", "pass")
@@ -94,7 +86,7 @@ def test_get_credentials_uses_cached_credentials() -> None:
9486

9587
def test_get_credentials_uses_cached_credentials_only_username() -> None:
9688
auth = MultiDomainBasicAuth()
97-
auth.passwords["example.com"] = ("user", "pass")
89+
auth._passwords["example.com"] = ("user", "pass")
9890

9991
got = auth._get_url_and_credentials("http://[email protected]/path")
10092
expected = ("http://example.com/path", "user", "pass")

0 commit comments

Comments
 (0)