Skip to content

Commit 6d90fcb

Browse files
committed
Add support for pulling username from keyring subprocess provider
This fixes pypa#12543. Somewhat confusingly, when using using `PIP_KEYRING_PROVIDER=import`, pip was able to fetch both a username and a password from keyring, but when using `PIP_KEYRING_PROVIDER=subprocess`, it needed the username. I had to rework the existing tests quite a bit to fit with this new behavior, as it's now OK to ask for a username/password for an index even if you don't have a username. This is why `KeyringSubprocessResult` now subclasses `KeyringModuleV2`. While I was in here, I opted to remove the "fixtures" values from `KeyringModuleV2` by introducing this new `add_credential` contextmanager. IMO, they were hurting the readability of our tests: you had to jump around quite a bit to see what the contents of the keyring would be during our tests. It also forced all our tests to play nicely with the same fixtures values, which IMO was an unnecessary constraint. Note: per the discussion [here](pypa#12748 (comment)), I've opted not to implement any "feature detection" to see if the `keyring` subprocess supports `--mode=creds`. For the record, this feature was added in jaraco/keyring@7830a64, which landed in keyring v25.2.0.
1 parent 858a515 commit 6d90fcb

File tree

4 files changed

+200
-73
lines changed

4 files changed

+200
-73
lines changed

news/12543.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for pulling username from keyring subprocess provider

src/pip/_internal/network/auth.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
providing credentials in the context of network requests.
55
"""
66

7+
import json
78
import logging
89
import os
910
import shutil
@@ -115,23 +116,22 @@ def __init__(self, cmd: str) -> None:
115116
self.keyring = cmd
116117

117118
def get_auth_info(self, url: str, username: Optional[str]) -> Optional[AuthInfo]:
118-
# This is the default implementation of keyring.get_credential
119-
# https://github.com/jaraco/keyring/blob/97689324abcf01bd1793d49063e7ca01e03d7d07/keyring/backend.py#L134-L139
120-
if username is not None:
121-
password = self._get_password(url, username)
122-
if password is not None:
123-
return username, password
124-
return None
119+
return self._get_creds(url, username)
125120

126121
def save_auth_info(self, url: str, username: str, password: str) -> None:
127122
return self._set_password(url, username, password)
128123

129-
def _get_password(self, service_name: str, username: str) -> Optional[str]:
130-
"""Mirror the implementation of keyring.get_password using cli"""
124+
def _get_creds(
125+
self, service_name: str, username: Optional[str]
126+
) -> Optional[AuthInfo]:
127+
"""Mirror the implementation of keyring.get_credential using cli"""
131128
if self.keyring is None:
132129
return None
133130

134-
cmd = [self.keyring, "get", service_name, username]
131+
cmd = [self.keyring, "--mode=creds", "--output=json", "get", service_name]
132+
if username is not None:
133+
cmd.append(username)
134+
135135
env = os.environ.copy()
136136
env["PYTHONIOENCODING"] = "utf-8"
137137
res = subprocess.run(
@@ -142,7 +142,9 @@ def _get_password(self, service_name: str, username: str) -> Optional[str]:
142142
)
143143
if res.returncode:
144144
return None
145-
return res.stdout.decode("utf-8").strip(os.linesep)
145+
146+
data = json.loads(res.stdout.decode("utf-8"))
147+
return (data["username"], data["password"])
146148

147149
def _set_password(self, service_name: str, username: str, password: str) -> None:
148150
"""Mirror the implementation of keyring.set_password using cli"""

tests/functional/test_install_config.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -501,12 +501,7 @@ def set_password(self, url, username):
501501
"simple",
502502
)
503503

504-
function_name = (
505-
"get_credential"
506-
if keyring_provider_implementation == "import"
507-
else "get_password"
508-
)
509504
if auth_needed:
510-
assert function_name + " was called" in result.stderr
505+
assert "get_credential was called" in result.stderr
511506
else:
512-
assert function_name + " was called" not in result.stderr
507+
assert "get_credential was called" not in result.stderr

tests/unit/test_network_auth.py

Lines changed: 184 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import contextlib
12
import functools
3+
import json
24
import os
35
import subprocess
46
import sys
5-
from typing import Any, Dict, Iterable, List, Optional, Tuple
7+
from dataclasses import dataclass
8+
from typing import Any, Dict, Generator, Iterable, List, Optional, Tuple
69

710
import pytest
811

@@ -327,42 +330,98 @@ def _send(sent_req: MockRequest, **kwargs: Any) -> MockResponse:
327330
class KeyringModuleV2:
328331
"""Represents the current supported API of keyring"""
329332

333+
def __init__(self) -> None:
334+
self.saved_credential_by_username_by_system: dict[
335+
str, dict[str, KeyringModuleV2.Credential]
336+
] = {}
337+
338+
@dataclass
330339
class Credential:
331-
def __init__(self, username: str, password: str) -> None:
332-
self.username = username
333-
self.password = password
340+
username: str
341+
password: str
334342

335343
def get_password(self, system: str, username: str) -> None:
336344
pytest.fail("get_password should not ever be called")
337345

338-
def get_credential(self, system: str, username: str) -> Optional[Credential]:
339-
if system == "http://example.com/path2/":
340-
return self.Credential("username", "url")
341-
if system == "example.com":
342-
return self.Credential("username", "netloc")
343-
return None
346+
def get_credential(
347+
self, system: str, username: Optional[str]
348+
) -> Optional[Credential]:
349+
credential_by_username = self.saved_credential_by_username_by_system.get(
350+
system, {}
351+
)
352+
if username is None:
353+
# Just return the first cred we can find (if
354+
# there even are any for this service).
355+
credentials = list(credential_by_username.values())
356+
if len(credentials) == 0:
357+
return None
358+
359+
# Just pick the first one we can find.
360+
credential = credentials[0]
361+
return credential
362+
363+
return credential_by_username.get(username)
364+
365+
def set_password(self, system: str, username: str, password: str) -> None:
366+
if system not in self.saved_credential_by_username_by_system:
367+
self.saved_credential_by_username_by_system[system] = {}
368+
369+
credential_by_username = self.saved_credential_by_username_by_system[system]
370+
assert username not in credential_by_username
371+
credential_by_username[username] = self.Credential(username, password)
372+
373+
def delete_password(self, system: str, username: str) -> None:
374+
del self.saved_credential_by_username_by_system[system][username]
375+
376+
@contextlib.contextmanager
377+
def add_credential(
378+
self, system: str, username: str, password: str
379+
) -> Generator[None, None, None]:
380+
"""
381+
Context manager that adds the given credential to the keyring
382+
and yields. Once the yield is done, the credential is removed
383+
from the keyring.
384+
385+
This is re-entrant safe: it's ok for one thread to call this while in
386+
the middle of an existing invocation
387+
388+
This is probably not thread safe: it's not ok for multiple threads to
389+
simultaneously call this method on the exact same instance of KeyringModuleV2.
390+
"""
391+
self.set_password(system, username, password)
392+
try:
393+
yield
394+
finally:
395+
# No matter what happened, make sure we clean up after ourselves.
396+
self.delete_password(system, username)
344397

345398

346399
@pytest.mark.parametrize(
347400
"url, expect",
348401
[
349-
("http://example.com/path1", ("username", "netloc")),
350-
("http://example.com/path2/path3", ("username", "url")),
351-
("http://[email protected]/path2/path3", ("username", "url")),
402+
("http://example.com/path1", ("username", "hunter2")),
403+
("http://example.com/path2/path3", ("username", "hunter3")),
404+
("http://[email protected]/path2/path3", ("user2", None)),
352405
],
353406
)
354407
def test_keyring_get_credential(
355408
monkeypatch: pytest.MonkeyPatch, url: str, expect: Tuple[str, str]
356409
) -> None:
357-
monkeypatch.setitem(sys.modules, "keyring", KeyringModuleV2())
410+
keyring = KeyringModuleV2()
411+
monkeypatch.setitem(sys.modules, "keyring", keyring)
358412
auth = MultiDomainBasicAuth(
359413
index_urls=["http://example.com/path1", "http://example.com/path2"],
360414
keyring_provider="import",
361415
)
362416

363-
assert (
364-
auth._get_new_credentials(url, allow_netrc=False, allow_keyring=True) == expect
365-
)
417+
with (
418+
keyring.add_credential("example.com", "username", "hunter2"),
419+
keyring.add_credential("http://example.com/path2/", "username", "hunter3"),
420+
):
421+
assert (
422+
auth._get_new_credentials(url, allow_netrc=False, allow_keyring=True)
423+
== expect
424+
)
366425

367426

368427
class KeyringModuleBroken:
@@ -393,7 +452,7 @@ def test_broken_keyring_disables_keyring(monkeypatch: pytest.MonkeyPatch) -> Non
393452
assert keyring_broken._call_count == 1
394453

395454

396-
class KeyringSubprocessResult(KeyringModuleV1):
455+
class KeyringSubprocessResult(KeyringModuleV2):
397456
"""Represents the subprocess call to keyring"""
398457

399458
returncode = 0 # Default to zero retcode
@@ -408,33 +467,85 @@ def __call__(
408467
input: Optional[bytes] = None,
409468
check: Optional[bool] = None,
410469
) -> Any:
411-
if cmd[1] == "get":
412-
assert stdin == -3 # subprocess.DEVNULL
413-
assert stdout == subprocess.PIPE
414-
assert env["PYTHONIOENCODING"] == "utf-8"
415-
assert check is None
416-
417-
password = self.get_password(*cmd[2:])
418-
if password is None:
419-
# Expect non-zero returncode if no password present
420-
self.returncode = 1
421-
else:
422-
# Passwords are returned encoded with a newline appended
423-
self.returncode = 0
424-
self.stdout = (password + os.linesep).encode("utf-8")
425-
426-
if cmd[1] == "set":
427-
assert stdin is None
428-
assert stdout is None
429-
assert env["PYTHONIOENCODING"] == "utf-8"
430-
assert input is not None
431-
assert check
432-
433-
# Input from stdin is encoded
434-
self.set_password(cmd[2], cmd[3], input.decode("utf-8").strip(os.linesep))
470+
parsed_cmd = list(cmd)
471+
assert parsed_cmd.pop(0) == "keyring"
472+
subcommand = [
473+
arg
474+
for arg in parsed_cmd
475+
# Skip past all the --whatever options until we get to the subcommand.
476+
if not arg.startswith("--")
477+
][0]
478+
subcommand_func = {
479+
"get": self._get_subcommand,
480+
"set": self._set_subcommand,
481+
}[subcommand]
482+
483+
subcommand_func(
484+
parsed_cmd,
485+
env=env,
486+
stdin=stdin,
487+
stdout=stdout,
488+
input=input,
489+
check=check,
490+
)
435491

436492
return self
437493

494+
def _get_subcommand(
495+
self,
496+
cmd: List[str],
497+
*,
498+
env: Dict[str, str],
499+
stdin: Optional[Any] = None,
500+
stdout: Optional[Any] = None,
501+
input: Optional[bytes] = None,
502+
check: Optional[bool] = None,
503+
) -> None:
504+
assert cmd.pop(0) == "--mode=creds"
505+
assert cmd.pop(0) == "--output=json"
506+
assert stdin == -3 # subprocess.DEVNULL
507+
assert stdout == subprocess.PIPE
508+
assert env["PYTHONIOENCODING"] == "utf-8"
509+
assert check is None
510+
assert cmd.pop(0) == "get"
511+
512+
service = cmd.pop(0)
513+
username = cmd.pop(0) if len(cmd) > 0 else None
514+
creds = self.get_credential(service, username)
515+
if creds is None:
516+
# Expect non-zero returncode if no creds present
517+
self.returncode = 1
518+
else:
519+
# Passwords are returned encoded with a newline appended
520+
self.returncode = 0
521+
self.stdout = json.dumps(
522+
{
523+
"username": creds.username,
524+
"password": creds.password,
525+
}
526+
).encode("utf-8")
527+
528+
def _set_subcommand(
529+
self,
530+
cmd: List[str],
531+
*,
532+
env: Dict[str, str],
533+
stdin: Optional[Any] = None,
534+
stdout: Optional[Any] = None,
535+
input: Optional[bytes] = None,
536+
check: Optional[bool] = None,
537+
) -> None:
538+
assert cmd.pop(0) == "set"
539+
assert stdin is None
540+
assert stdout is None
541+
assert env["PYTHONIOENCODING"] == "utf-8"
542+
assert input is not None
543+
assert check
544+
545+
# Input from stdin is encoded
546+
system, username = cmd
547+
self.set_password(system, username, input.decode("utf-8").strip(os.linesep))
548+
438549
def check_returncode(self) -> None:
439550
if self.returncode:
440551
raise Exception()
@@ -443,31 +554,42 @@ def check_returncode(self) -> None:
443554
@pytest.mark.parametrize(
444555
"url, expect",
445556
[
446-
("http://example.com/path1", (None, None)),
447-
# path1 URLs will be resolved by netloc
448-
("http://[email protected]/path3", ("user", "user!netloc")),
449-
("http://[email protected]/path3", ("user2", "user2!netloc")),
450-
# path2 URLs will be resolved by index URL
451-
("http://example.com/path2/path3", (None, None)),
452-
("http://[email protected]/path2/path3", ("foo", "foo!url")),
557+
# It's not obvious, but this url ultimately resolves to index url
558+
# http://example.com/path2, so we get the creds for that index.
559+
("http://example.com/path1", ("saved-user1", "pw1")),
560+
("http://[email protected]/path2", ("saved-user1", "pw1")),
561+
("http://[email protected]/path2", ("saved-user2", "pw2")),
562+
("http://[email protected]/path2", ("new-user", None)),
563+
("http://example.com/path2/path3", ("saved-user1", "pw1")),
564+
("http://[email protected]/path2/path3", ("foo", None)),
453565
],
454566
)
455567
def test_keyring_cli_get_password(
456568
monkeypatch: pytest.MonkeyPatch,
457569
url: str,
458570
expect: Tuple[Optional[str], Optional[str]],
459571
) -> None:
572+
keyring_subprocess = KeyringSubprocessResult()
460573
monkeypatch.setattr(pip._internal.network.auth.shutil, "which", lambda x: "keyring")
461574
monkeypatch.setattr(
462-
pip._internal.network.auth.subprocess, "run", KeyringSubprocessResult()
575+
pip._internal.network.auth.subprocess, "run", keyring_subprocess
463576
)
464577
auth = MultiDomainBasicAuth(
465578
index_urls=["http://example.com/path2", "http://example.com/path3"],
466579
keyring_provider="subprocess",
467580
)
468581

469-
actual = auth._get_new_credentials(url, allow_netrc=False, allow_keyring=True)
470-
assert actual == expect
582+
with (
583+
keyring_subprocess.add_credential("example.com", "example", "!netloc"),
584+
keyring_subprocess.add_credential(
585+
"http://example.com/path2/", "saved-user1", "pw1"
586+
),
587+
keyring_subprocess.add_credential(
588+
"http://example.com/path2/", "saved-user2", "pw2"
589+
),
590+
):
591+
actual = auth._get_new_credentials(url, allow_netrc=False, allow_keyring=True)
592+
assert actual == expect
471593

472594

473595
@pytest.mark.parametrize(
@@ -492,13 +614,14 @@ def test_keyring_cli_set_password(
492614
creds: Tuple[str, str, bool],
493615
expect_save: bool,
494616
) -> None:
617+
expected_username, expected_password, save = creds
495618
monkeypatch.setattr(pip._internal.network.auth.shutil, "which", lambda x: "keyring")
496619
keyring = KeyringSubprocessResult()
497620
monkeypatch.setattr(pip._internal.network.auth.subprocess, "run", keyring)
498621
auth = MultiDomainBasicAuth(prompting=True, keyring_provider="subprocess")
499622
monkeypatch.setattr(auth, "_get_url_and_credentials", lambda u: (u, None, None))
500623
monkeypatch.setattr(auth, "_prompt_for_password", lambda *a: creds)
501-
if creds[2]:
624+
if save:
502625
# when _prompt_for_password indicates to save, we should save
503626
def should_save_password_to_keyring(*a: Any) -> bool:
504627
return True
@@ -535,6 +658,12 @@ def _send(sent_req: MockRequest, **kwargs: Any) -> MockResponse:
535658
auth.handle_401(resp)
536659

537660
if expect_save:
538-
assert keyring.saved_passwords == [("example.com", creds[0], creds[1])]
661+
assert keyring.saved_credential_by_username_by_system == {
662+
"example.com": {
663+
expected_username: KeyringModuleV2.Credential(
664+
expected_username, expected_password
665+
),
666+
},
667+
}
539668
else:
540-
assert keyring.saved_passwords == []
669+
assert keyring.saved_credential_by_username_by_system == {}

0 commit comments

Comments
 (0)