Skip to content
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

Fixes #418 Support keyring.get_credential #419

Merged
merged 9 commits into from
Oct 30, 2018
Merged
69 changes: 69 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,33 @@ def get_password(system, user):
assert pw == 'entered pw'


def test_get_username_and_password_keyring_overrides_prompt(monkeypatch):
import collections
Credential = collections.namedtuple('Credential', 'username password')

class MockKeyring:
@staticmethod
def get_credential(system, user):
return Credential(
'real_user',
'real_user@{system} sekure pa55word'.format(**locals())
)

@staticmethod
def get_password(system, user):
cred = MockKeyring.get_credential(system, user)
if user != cred.username:
raise RuntimeError("unexpected username")
return cred.password

monkeypatch.setitem(sys.modules, 'keyring', MockKeyring)

user = utils.get_username('system', None, {})
assert user == 'real_user'
pw = utils.get_password('system', user, None, {})
assert pw == 'real_user@system sekure pa55word'


@pytest.fixture
def keyring_missing(monkeypatch):
"""
Expand All @@ -237,11 +264,31 @@ def keyring_missing(monkeypatch):
monkeypatch.delitem(sys.modules, 'keyring', raising=False)


@pytest.fixture
def keyring_missing_get_credentials(monkeypatch):
"""
Simulate older versions of keyring that do not have the
'get_credentials' API.
"""
monkeypatch.delattr('keyring.backends.KeyringBackend',
'get_credential', raising=False)


@pytest.fixture
def entered_username(monkeypatch):
monkeypatch.setattr(utils, 'input_func', lambda prompt: 'entered user')


@pytest.fixture
def entered_password(monkeypatch):
monkeypatch.setattr(utils, 'password_prompt', lambda prompt: 'entered pw')


def test_get_username_keyring_missing_get_credentials_prompts(
entered_username, keyring_missing_get_credentials):
assert utils.get_username('system', None, {}) == 'entered user'


def test_get_password_keyring_missing_prompts(
entered_password, keyring_missing):
assert utils.get_password('system', 'user', None, {}) == 'entered pw'
Expand All @@ -261,6 +308,28 @@ def get_password(system, username):
monkeypatch.setitem(sys.modules, 'keyring', FailKeyring())


@pytest.fixture
def keyring_no_backends_get_credential(monkeypatch):
"""
Simulate that keyring has no available backends. When keyring
has no backends for the system, the backend will be a
fail.Keyring, which raises RuntimeError on get_password.
"""
class FailKeyring(object):
@staticmethod
def get_credential(system, username):
raise RuntimeError("fail!")
monkeypatch.setitem(sys.modules, 'keyring', FailKeyring())


def test_get_username_runtime_error_suppressed(
entered_username, keyring_no_backends_get_credential, recwarn):
assert utils.get_username('system', None, {}) == 'entered user'
assert len(recwarn) == 1
warning = recwarn.pop(UserWarning)
assert 'fail!' in str(warning)


def test_get_password_runtime_error_suppressed(
entered_password, keyring_no_backends, recwarn):
assert utils.get_password('system', 'user', None, {}) == 'entered pw'
Expand Down
6 changes: 5 additions & 1 deletion twine/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,11 @@ def _handle_repository_options(self, repository_name, repository_url):
)

def _handle_authentication(self, username, password):
self.username = utils.get_username(username, self.repository_config)
self.username = utils.get_username(
self.repository_config['repository'],
username,
self.repository_config
)
self.password = utils.get_password(
self.repository_config['repository'],
self.username,
Expand Down
41 changes: 36 additions & 5 deletions twine/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,23 @@ def get_userpass_value(cli_value, config, key, prompt_strategy=None):
return None


def get_username_from_keyring(system):
if 'keyring' not in sys.modules:
return

try:
getter = sys.modules['keyring'].get_credential
except AttributeError:
return None

try:
creds = getter(system, None)
if creds:
return creds.username
except Exception as exc:
warnings.warn(str(exc))


def password_prompt(prompt_text): # Always expects unicode for our own sanity
prompt = prompt_text
# Workaround for https://github.com/pypa/twine/issues/116
Expand All @@ -221,18 +238,32 @@ def get_password_from_keyring(system, username):
warnings.warn(str(exc))


def username_from_keyring_or_prompt(system):
return (
get_username_from_keyring(system)
or input_func('Enter your username: ')
)


def password_from_keyring_or_prompt(system, username):
return (
get_password_from_keyring(system, username)
or password_prompt('Enter your password: ')
)


get_username = functools.partial(
get_userpass_value,
key='username',
prompt_strategy=functools.partial(input_func, 'Enter your username: '),
)
def get_username(system, cli_value, config):
return get_userpass_value(
cli_value,
config,
key='username',
prompt_strategy=functools.partial(
username_from_keyring_or_prompt,
system,
),
)


get_cacert = functools.partial(
get_userpass_value,
key='ca_cert',
Expand Down