diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 14e1752d..c919a4b7 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -9,6 +9,7 @@ This project adheres to `Semantic Versioning `_.
`1.0.0-dev`_ (Unreleased)
-------------------------
+* Extended auth plugin API.
* Added support for ``curses``-less Python installations.
* Fixed ``REQUEST_ITEM`` arg incorrectly being reported as required.
* Improved ``CTRL-C`` interrupt handling.
diff --git a/httpie/cli.py b/httpie/cli.py
index e78b59f2..05c4ac0c 100644
--- a/httpie/cli.py
+++ b/httpie/cli.py
@@ -5,22 +5,25 @@ NOTE: the CLI interface may change before reaching v1.0.
"""
from textwrap import dedent, wrap
# noinspection PyCompatibility
-from argparse import (RawDescriptionHelpFormatter, FileType,
- OPTIONAL, ZERO_OR_MORE, SUPPRESS)
+from argparse import (
+ RawDescriptionHelpFormatter, FileType,
+ OPTIONAL, ZERO_OR_MORE, SUPPRESS
+)
from httpie import __doc__, __version__
from httpie.plugins.builtin import BuiltinAuthPlugin
from httpie.plugins import plugin_manager
from httpie.sessions import DEFAULT_SESSIONS_DIR
from httpie.output.formatters.colors import AVAILABLE_STYLES, DEFAULT_STYLE
-from httpie.input import (HTTPieArgumentParser,
- AuthCredentialsArgType, KeyValueArgType,
- SEP_PROXY, SEP_CREDENTIALS, SEP_GROUP_ALL_ITEMS,
- OUT_REQ_HEAD, OUT_REQ_BODY, OUT_RESP_HEAD,
- OUT_RESP_BODY, OUTPUT_OPTIONS,
- OUTPUT_OPTIONS_DEFAULT, PRETTY_MAP,
- PRETTY_STDOUT_TTY_ONLY, SessionNameValidator,
- readable_file_arg, SSL_VERSION_ARG_MAPPING)
+from httpie.input import (
+ HTTPieArgumentParser, KeyValueArgType,
+ SEP_PROXY, SEP_GROUP_ALL_ITEMS,
+ OUT_REQ_HEAD, OUT_REQ_BODY, OUT_RESP_HEAD,
+ OUT_RESP_BODY, OUTPUT_OPTIONS,
+ OUTPUT_OPTIONS_DEFAULT, PRETTY_MAP,
+ PRETTY_STDOUT_TTY_ONLY, SessionNameValidator,
+ readable_file_arg, SSL_VERSION_ARG_MAPPING
+)
class HTTPieHelpFormatter(RawDescriptionHelpFormatter):
@@ -414,8 +417,8 @@ sessions.add_argument(
auth = parser.add_argument_group(title='Authentication')
auth.add_argument(
'--auth', '-a',
+ default=None,
metavar='USER[:PASS]',
- type=AuthCredentialsArgType(SEP_CREDENTIALS),
help="""
If only the username is provided (-a username), HTTPie will prompt
for the password.
@@ -423,10 +426,21 @@ auth.add_argument(
""",
)
+
+class _AuthTypeLazyChoices(object):
+ # Needed for plugin testing
+
+ def __contains__(self, item):
+ return item in plugin_manager.get_auth_plugin_mapping()
+
+ def __iter__(self):
+ return iter(plugin_manager.get_auth_plugins())
+
+
_auth_plugins = plugin_manager.get_auth_plugins()
auth.add_argument(
'--auth-type', '-A',
- choices=[plugin.auth_type for plugin in _auth_plugins],
+ choices=_AuthTypeLazyChoices(),
default=_auth_plugins[0].auth_type,
help="""
The authentication mechanism to be used. Defaults to "{default}".
diff --git a/httpie/client.py b/httpie/client.py
index fb0fc62c..894a40d7 100644
--- a/httpie/client.py
+++ b/httpie/client.py
@@ -145,11 +145,6 @@ def get_requests_kwargs(args, base_headers=None):
headers.update(args.headers)
headers = finalize_headers(headers)
- credentials = None
- if args.auth:
- auth_plugin = plugin_manager.get_auth_plugin(args.auth_type)()
- credentials = auth_plugin.get_auth(args.auth.key, args.auth.value)
-
cert = None
if args.cert:
cert = args.cert
@@ -168,7 +163,7 @@ def get_requests_kwargs(args, base_headers=None):
}.get(args.verify, args.verify),
'cert': cert,
'timeout': args.timeout,
- 'auth': credentials,
+ 'auth': args.auth,
'proxies': dict((p.key, p.value) for p in args.proxy),
'files': args.files,
'allow_redirects': args.follow,
diff --git a/httpie/input.py b/httpie/input.py
index 99a4849c..a323e4ee 100644
--- a/httpie/input.py
+++ b/httpie/input.py
@@ -15,6 +15,7 @@ from argparse import ArgumentParser, ArgumentTypeError, ArgumentError
# TODO: Use MultiDict for headers once added to `requests`.
# https://github.com/jkbrzt/httpie/issues/130
+from httpie.plugins import plugin_manager
from requests.structures import CaseInsensitiveDict
from httpie.compat import OrderedDict, urlsplit, str, is_pypy, is_py27
@@ -214,31 +215,56 @@ class HTTPieArgumentParser(ArgumentParser):
self.env.stdout_isatty = False
def _process_auth(self):
- """
- If only a username provided via --auth, then ask for a password.
- Or, take credentials from the URL, if provided.
-
- """
+ # TODO: refactor
+ self.args.auth_plugin = None
+ default_auth_plugin = plugin_manager.get_auth_plugins()[0]
+ auth_type_set = self.args.auth_type != default_auth_plugin.auth_type
url = urlsplit(self.args.url)
- if self.args.auth:
- if not self.args.auth.has_password():
- # Stdin already read (if not a tty) so it's save to prompt.
- if self.args.ignore_stdin:
- self.error('Unable to prompt for passwords because'
- ' --ignore-stdin is set.')
- self.args.auth.prompt_password(url.netloc)
+ if self.args.auth is None and not auth_type_set:
+ if url.username is not None:
+ # Handle http://username:password@hostname/
+ username = url.username
+ password = url.password or ''
+ self.args.auth = AuthCredentials(
+ key=username,
+ value=password,
+ sep=SEP_CREDENTIALS,
+ orig=SEP_CREDENTIALS.join([username, password])
+ )
- elif url.username is not None:
- # Handle http://username:password@hostname/
- username = url.username
- password = url.password or ''
- self.args.auth = AuthCredentials(
- key=username,
- value=password,
- sep=SEP_CREDENTIALS,
- orig=SEP_CREDENTIALS.join([username, password])
- )
+ if self.args.auth is not None or auth_type_set:
+ plugin = plugin_manager.get_auth_plugin(self.args.auth_type)()
+
+ if plugin.auth_require and self.args.auth is None:
+ self.error('--auth required')
+
+ plugin.raw_auth = self.args.auth
+ self.args.auth_plugin = plugin
+ already_parsed = isinstance(self.args.auth, AuthCredentials)
+
+ if self.args.auth is None or not plugin.auth_parse:
+ self.args.auth = plugin.get_auth()
+ else:
+ if already_parsed:
+ # from the URL
+ credentials = self.args.auth
+ else:
+ credentials = parse_auth(self.args.auth)
+
+ if (not credentials.has_password()
+ and plugin.prompt_password):
+ if self.args.ignore_stdin:
+ # Non-tty stdin read by now
+ self.error(
+ 'Unable to prompt for passwords because'
+ ' --ignore-stdin is set.'
+ )
+ credentials.prompt_password(url.netloc)
+ self.args.auth = plugin.get_auth(
+ username=credentials.key,
+ password=credentials.value,
+ )
def _apply_no_options(self, no_options):
"""For every `--no-OPTION` in `no_options`, set `args.OPTION` to
@@ -578,6 +604,9 @@ class AuthCredentialsArgType(KeyValueArgType):
)
+parse_auth = AuthCredentialsArgType(SEP_CREDENTIALS)
+
+
class RequestItemsDict(OrderedDict):
"""Multi-value dict for URL parameters and form data."""
diff --git a/httpie/plugins/base.py b/httpie/plugins/base.py
index ff6e6a20..3129a80c 100644
--- a/httpie/plugins/base.py
+++ b/httpie/plugins/base.py
@@ -17,13 +17,36 @@ class AuthPlugin(BasePlugin):
See for an example auth plugin.
+ See also `test_auth_plugins.py`
+
"""
# The value that should be passed to --auth-type
# to use this auth plugin. Eg. "my-auth"
auth_type = None
- def get_auth(self, username, password):
+ # Set to `False` to make it possible to invoke this auth
+ # plugin without requiring the user to specify credentials
+ # through `--auth, -a`.
+ auth_require = True
+
+ # By default the `-a` argument is parsed for `username:password`.
+ # Set this to `False` to disable the parsing and error handling.
+ auth_parse = True
+
+ # If both `auth_parse` and `prompt_password` are set to `True`,
+ # and the value of `-a` lacks the password part,
+ # then the user will be prompted to type the password in.
+ prompt_password = True
+
+ # Will be set to the raw value of `-a` (if provided) before
+ # `get_auth()` gets called.
+ raw_auth = None
+
+ def get_auth(self, username=None, password=None):
"""
+ If `auth_parse` is set to `True`, then `username`
+ and `password` contain the parsed credentials.
+
Return a ``requests.auth.AuthBase`` subclass instance.
"""
diff --git a/httpie/plugins/builtin.py b/httpie/plugins/builtin.py
index 0e3aa26d..6b9640d1 100644
--- a/httpie/plugins/builtin.py
+++ b/httpie/plugins/builtin.py
@@ -36,6 +36,7 @@ class BasicAuthPlugin(BuiltinAuthPlugin):
name = 'Basic HTTP auth'
auth_type = 'basic'
+ # noinspection PyMethodOverriding
def get_auth(self, username, password):
return HTTPBasicAuth(username, password)
@@ -45,5 +46,6 @@ class DigestAuthPlugin(BuiltinAuthPlugin):
name = 'Digest HTTP auth'
auth_type = 'digest'
+ # noinspection PyMethodOverriding
def get_auth(self, username, password):
return requests.auth.HTTPDigestAuth(username, password)
diff --git a/httpie/plugins/manager.py b/httpie/plugins/manager.py
index 239d32ae..f7a2a063 100644
--- a/httpie/plugins/manager.py
+++ b/httpie/plugins/manager.py
@@ -24,6 +24,9 @@ class PluginManager(object):
for plugin in plugins:
self._plugins.append(plugin)
+ def unregister(self, plugin):
+ self._plugins.remove(plugin)
+
def load_installed_plugins(self):
for entry_point_name in ENTRY_POINT_NAMES:
for entry_point in iter_entry_points(entry_point_name):
diff --git a/httpie/sessions.py b/httpie/sessions.py
index 249b78ba..ff3969be 100644
--- a/httpie/sessions.py
+++ b/httpie/sessions.py
@@ -51,11 +51,10 @@ def get_response(requests_session, session_name,
dump_request(kwargs)
session.update_headers(kwargs['headers'])
- if args.auth:
+ if args.auth_plugin:
session.auth = {
- 'type': args.auth_type,
- 'username': args.auth.key,
- 'password': args.auth.value,
+ 'type': args.auth_plugin.auth_type,
+ 'raw_auth': args.auth_plugin.raw_auth,
}
elif session.auth:
kwargs['auth'] = session.auth
@@ -147,10 +146,31 @@ class Session(BaseConfigDict):
auth = self.get('auth', None)
if not auth or not auth['type']:
return
- auth_plugin = plugin_manager.get_auth_plugin(auth['type'])()
- return auth_plugin.get_auth(auth['username'], auth['password'])
+
+ plugin = plugin_manager.get_auth_plugin(auth['type'])()
+
+ credentials = {'username': None, 'password': None}
+ try:
+ # New style
+ plugin.raw_auth = auth['raw_auth']
+ except KeyError:
+ # Old style
+ credentials = {
+ 'username': auth['username'],
+ 'password': auth['password'],
+ }
+ else:
+ if plugin.auth_parse:
+ from httpie.input import parse_auth
+ parsed = parse_auth(plugin.raw_auth)
+ credentials = {
+ 'username': parsed.key,
+ 'password': parsed.value,
+ }
+
+ return plugin.get_auth(**credentials)
@auth.setter
def auth(self, auth):
- assert set(['type', 'username', 'password']) == set(auth.keys())
+ assert set(['type', 'raw_auth']) == set(auth.keys())
self['auth'] = auth
diff --git a/tests/test_auth.py b/tests/test_auth.py
index 1d7395b6..c279e1cd 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -60,5 +60,5 @@ def test_only_username_in_url(url):
"""
args = httpie.cli.parser.parse_args(args=[url], env=TestEnvironment())
assert args.auth
- assert args.auth.key == 'username'
- assert args.auth.value == ''
+ assert args.auth.username == 'username'
+ assert args.auth.password == ''
diff --git a/tests/test_auth_plugins.py b/tests/test_auth_plugins.py
new file mode 100644
index 00000000..233c599b
--- /dev/null
+++ b/tests/test_auth_plugins.py
@@ -0,0 +1,89 @@
+from utils import http, HTTP_OK
+from httpie.plugins import AuthPlugin, plugin_manager
+
+# TODO: run all these tests in session mode as well
+
+# Basic auth encoded 'username' and 'password'
+BASIC_AUTH_HEADER_VALUE = 'Basic dXNlcm5hbWU6cGFzc3dvcmQ='
+BASIC_AUTH_URL = '/basic-auth/username/password'
+
+
+def dummy_auth(auth_header=BASIC_AUTH_HEADER_VALUE):
+
+ def inner(r):
+ r.headers['Authorization'] = auth_header
+ return r
+
+ return inner
+
+
+def test_auth_plugin_parse_false(httpbin):
+
+ class ParseFalseAuthPlugin(AuthPlugin):
+ auth_type = 'parse-false'
+ auth_parse = False
+
+ def get_auth(self, username=None, password=None):
+ assert username is None
+ assert password is None
+ assert self.raw_auth == BASIC_AUTH_HEADER_VALUE
+ return dummy_auth(self.raw_auth)
+
+ plugin_manager.register(ParseFalseAuthPlugin)
+ try:
+ r = http(
+ httpbin + BASIC_AUTH_URL,
+ '--auth-type', 'parse-false',
+ '--auth', BASIC_AUTH_HEADER_VALUE
+ )
+ assert HTTP_OK in r
+ finally:
+ plugin_manager.unregister(ParseFalseAuthPlugin)
+
+
+def test_auth_plugin_require_false(httpbin):
+
+ class RequireFalseAuthPlugin(AuthPlugin):
+ auth_type = 'require-false'
+ auth_require = False
+
+ def get_auth(self, username=None, password=None):
+ assert self.raw_auth is None
+ assert username is None
+ assert password is None
+ return dummy_auth()
+
+ plugin_manager.register(RequireFalseAuthPlugin)
+ try:
+ r = http(
+ httpbin + BASIC_AUTH_URL,
+ '--auth-type', 'require-false',
+ )
+ assert HTTP_OK in r
+ finally:
+ plugin_manager.unregister(RequireFalseAuthPlugin)
+
+
+def test_auth_plugin_prompt_false(httpbin):
+
+ class PromptFalseAuthPlugin(AuthPlugin):
+ auth_type = 'prompt-false'
+ prompt_password = False
+
+ def get_auth(self, username=None, password=None):
+ assert self.raw_auth == 'username:'
+ assert username == 'username'
+ assert password == ''
+ return dummy_auth()
+
+ plugin_manager.register(PromptFalseAuthPlugin)
+
+ try:
+ r = http(
+ httpbin + BASIC_AUTH_URL,
+ '--auth-type', 'prompt-false',
+ '--auth', 'username:'
+ )
+ assert HTTP_OK in r
+ finally:
+ plugin_manager.unregister(PromptFalseAuthPlugin)