-
Notifications
You must be signed in to change notification settings - Fork 3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add an interface to allow calling system keyring
#11589
Conversation
3ddc9f6
to
b87ddb9
Compare
I'm not sure there's much to be tested here since we don't actually install I haven't added any code branches outside |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've made some general comments, but to be perfectly honest I don't use keyring at all, and I have little experience with what else might be needed here. So just to set expectations, I'm happy to review the code in general, but I'd want other maintainers to comment on whether this is an acceptable feature to add, and approve the implementation.
src/pip/_internal/network/auth.py
Outdated
@classmethod | ||
def set_password(cls, service_name: str, username: str, password: str) -> None: | ||
cmd = ["keyring", "set", service_name, username] | ||
input_ = password.encode() + b"\n" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can imagine encoding issues here, especially on Windows.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I've solved this using PYTHONIOENCODING
src/pip/_internal/network/auth.py
Outdated
res = subprocess.run(cmd) | ||
if res.returncode: | ||
raise RuntimeError(res.stderr) | ||
password = res.stdout.decode().strip("\n") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Encoding issues possible here. On Windows, at least, it's not necessarily true that keywring will write its output in the same encoding as the pip process' default encoding.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I've solved this using PYTHONIOENCODING
src/pip/_internal/network/auth.py
Outdated
) -> Optional[KeyRingCredential]: | ||
cmd = ["keyring", "get", self._quote(service_name), self._quote(username)] | ||
res = subprocess.run(cmd) | ||
cmd = ["keyring", "get", service_name, str(username)] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why str() round username? You don't account for the possibility of None
(allowed by the type signature) and str()
will do nothing if it's a string.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've avoided this by only supporting get_password
. I think this is the more correct thing to do as this is actually the function which is being called by the CLI.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've moved to mirroring keyring
's default implementation of get_credential
which just wraps get_password
src/pip/_internal/network/auth.py
Outdated
cmd = ["keyring", "get", self._quote(service_name), self._quote(username)] | ||
res = subprocess.run(cmd) | ||
cmd = ["keyring", "get", service_name, str(username)] | ||
res = subprocess.run(cmd, capture_output=True) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You need to support the --no-input
option here, so don't try to read stdin if the user supplied that flag.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think I understand this. I'm not reading from stdin
here.
Did you mean this line res = subprocess.run(cmd, input=input_)
? If so the reason I'm doing this is that the user has already supplied the password
through interaction. We're just saving the result here in a callback. Unfortunately, keyring
doesn't support passing the password in any other way as far as I can see.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, the keyring
command you run might try to read from stdin. The --no-input
flag is specifically to ensure that people can run pip without getting prompted for input, so you need to ensure that if --no-input
is specified, you stop the keyring subprocess from reading stdin - probably by passing stdin=subprocess.DEVNULL
to the call, assuming the keyring process works correctly if you do that.
I think we should test this. If the existing keyring code isn't tested, then I see that as a flaw in the original implementation, not a precedent we should follow... |
The existing code is tested but |
d57e834
to
43abcf0
Compare
It shouldn't need to ever so no reason to allow it and have to jiggle around the `--no-input` option in `pip`.
src/pip/_internal/network/auth.py
Outdated
|
||
try: | ||
import keyring | ||
except ImportError: | ||
keyring = None # type: ignore[assignment] | ||
keyring_path = shutil.which("keyring") | ||
if keyring_path is not None: | ||
keyring = KeyRingCli(keyring_path) # type: ignore[assignment] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of trying to fake keyring’s Python interface, it’d probably be easier if we introduce a common abstraction. We can have KeyRingCliProvider and KeyRingPythonProvider wrapping each implementation. This should also help get rid of the ImportError
block.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've acted on this feedback
src/pip/_internal/network/auth.py
Outdated
cmd, | ||
stdin=subprocess.DEVNULL, | ||
capture_output=True, | ||
env=dict(PYTHONIOENCODING="utf-8"), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe setting ENV will remove all other environment variables, including ones that the user might have set to control the keyring command (which might be the right thing to do - I don't know about that) and some essential system variables on Windows.
Rather than using env like this, you need to take a copy of os.environ
, modify it, and pass the modified copy into env
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've acted on this feedback
1ecade4
to
888c3b6
Compare
I'll need to overhaul the tests for this area of the code to fit in with the new abstraction. |
bd4eb67
to
8d9ea8b
Compare
I've added some tests by mocking |
src/pip/_internal/network/auth.py
Outdated
python_keyring = KeyRingPythonProvider() | ||
if python_keyring.is_available(): | ||
return python_keyring | ||
|
||
cli_keyring = KeyRingCliProvider() | ||
if cli_keyring.is_available(): | ||
return cli_keyring |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we only return a provider if the backend is available anyway, I wonder if it’d be easier to do something like
try:
import keyring
except ImportError:
keyring = None # type: ignore[assignment]
class KeyRingCliProvider:
def __init__(self, cmd: str) -> None:
self.keyring = cmd
def get_keyring_provider() -> Optional[KeyRingBaseProvider]:
if keyring is not None:
return KeyRingPythonProvider()
cli = shutil.which("keyring")
if cli:
return KeyRingCliProvider(cli)
return None
This avoids a few is_available
checks.
I also wonder whether it’d be a good idea to have a KeyRingNullProvider
that always fails; this should help localise the KEYRING_DISABLED
logic in get_keyring_provider
and eliminate some is None
checks.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think there is an advantage to delaying the import keyring
until get_keyring_provider
is called. It means that if the user has provided a password we won't bother trying to import keyring
(which from what I've heard can be very slow!).
I've implemented your idea of having a KeyRingNullProvider
, doing away with is_available
, and simplifying the KEYRING_DISABLED
logic. This necessitates removing the cache on get_keyring_provider
but that's probably fine.
I've reached the point now where this looks good to me, and I'm assuming that the way it uses the keyring client is OK (I have very limited knowledge of the keyring module). So I've approved it but I'll wait for @uranusjr's further review. |
Thanks very much, Paul. Appreciate your patience with me. I'm excited to be (potentially -- still early days) making my first contribution to |
It's been a pleasure 🙂 |
We didn't update the documentation -- @judahrand would you be interested in covering this in https://pip.pypa.io/en/stable/topics/authentication/#keyring-support? I guess that section would need to be modified to have subheaders for the two mechanisms and the header for subprocess calls can get a
at the start of it. |
I'll try to get to this in the next day or two. |
Awesome, thank you! ^.^ |
Closes #11588
This PR allows
pip
to use system widekeyring
installations which appear onPATH
.In order to avoid breaking peoples current systems this will still default to using
keyring
imported fromthe local environment. A different
keyring
installation will only be used if no localkeyring
is found.Edit: this may also partially address #8485 as it delays the import of
keyring