Repository: pypa/twine

Issue:
Support keyring.get_username_and_password
The next release of keyring will include a [get_username_and_password](https://github.com/jaraco/keyring/issues/350) API that allows backends to return both a username and password for a URL.

By updating twine to use this API, we can enable backends for private feeds to use other mechanisms besides simple username/passwords to authenticate users.

Discussion:

Patch:
diff --git a/twine/settings.py b/twine/settings.py
index 8e270fd..d4e3d1a 100644
--- a/twine/settings.py
+++ b/twine/settings.py
@@ -235,7 +235,11 @@ class Settings(object):
         )
 
     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,
diff --git a/twine/utils.py b/twine/utils.py
index 57eefb3..a4f6419 100644
--- a/twine/utils.py
+++ b/twine/utils.py
@@ -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
@@ -221,6 +238,13 @@ 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)
@@ -228,11 +252,18 @@ def password_from_keyring_or_prompt(system, username):
     )
 
 
-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',


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 520b78f..68d393d 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -229,6 +229,33 @@ def test_get_password_keyring_defers_to_prompt(monkeypatch):
     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):
     """
@@ -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'
@@ -261,6 +308,28 @@ def keyring_no_backends(monkeypatch):
     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'