diff --git a/keyring/__init__.py b/keyring/__init__.py index eadd788e..5e84842e 100644 --- a/keyring/__init__.py +++ b/keyring/__init__.py @@ -1,9 +1,9 @@ from __future__ import absolute_import from .core import (set_keyring, get_keyring, set_password, get_password, - delete_password) + delete_password, get_username_and_password) __all__ = ( 'set_keyring', 'get_keyring', 'set_password', 'get_password', - 'delete_password', + 'delete_password', 'get_username_and_password' ) diff --git a/keyring/backend.py b/keyring/backend.py index 72099440..78be80b3 100644 --- a/keyring/backend.py +++ b/keyring/backend.py @@ -108,6 +108,23 @@ def delete_password(self, service, username): """ raise errors.PasswordDeleteError("reason") + # for backward-compatibility, don't require a backend to implement + # get_username_and_password + # @abc.abstractmethod + def get_username_and_password(self, service, username): + """Gets the username and password for the service. + Returns (username, password) + + The *username* argument is optional and may be omitted by + the caller or ignored by the backend. Callers must use the + returned username. + """ + # The default implementation requires a username here. + if username is not None: + password = self.get_password(service, username) + if password is not None: + return username, password + return None, None class Crypter: """Base class providing encryption and decryption diff --git a/keyring/backends/Windows.py b/keyring/backends/Windows.py index 3d7c862d..e6888e8f 100644 --- a/keyring/backends/Windows.py +++ b/keyring/backends/Windows.py @@ -127,6 +127,20 @@ def _delete_password(self, target): TargetName=target, ) + def get_username_and_password(self, service, username): + res = None + # get the credentials associated with the provided username + if username: + res = self._get_password(self._compound_name(username, service)) + # get any first password under the service name + if not res: + res = self._get_password(service) + if not res: + return None, None + username = res['UserName'] + blob = res['CredentialBlob'] + return username, blob.decode('utf-16') + class OldPywinError: """ diff --git a/keyring/core.py b/keyring/core.py index fd5ba84d..0135695e 100644 --- a/keyring/core.py +++ b/keyring/core.py @@ -70,6 +70,24 @@ def delete_password(service_name, username): _keyring_backend.delete_password(service_name, username) +def get_username_and_password(service_name, username): + """Get username and password from the specified service. + """ + try: + call = _keyring_backend.get_username_and_password + except AttributeError: + pass + else: + return call(service_name, username) + + # The fallback behavior requires a username. + if username is not None: + password = _keyring_backend.get_password(service_name, username) + if password is not None: + return username, password + return None, None + + def recommended(backend): return backend.priority >= 1 diff --git a/keyring/tests/test_backend.py b/keyring/tests/test_backend.py index 5eeec144..b757356b 100644 --- a/keyring/tests/test_backend.py +++ b/keyring/tests/test_backend.py @@ -134,3 +134,16 @@ def test_different_user(self): assert keyring.get_password('service1', 'user2') == 'password2' self.set_password('service2', 'user3', 'password3') assert keyring.get_password('service1', 'user1') == 'password1' + + def test_username_and_password(self): + keyring = self.keyring + assert keyring.get_username_and_password('service1', None) == (None, None) + self.set_password('service1', 'user1', 'password1') + self.set_password('service1', 'user2', 'password2') + # Using get_username_and_password may produce any of these results + candidates = frozenset(( + ('user1', 'password1'), + ('user2', 'password2'), + )) + assert keyring.get_username_and_password('service1', None) in candidates + assert keyring.get_username_and_password('service1', 'user2') in candidates