diff --git a/README.rst b/README.rst index 4b82f7d1..79a68ba7 100644 --- a/README.rst +++ b/README.rst @@ -506,31 +506,30 @@ path. The path can also be configured via the environment variable Sessions ======== +*This is an experimental feature.* + HTTPie supports named sessions, where several options and cookies sent by the server persist between requests: .. code-block:: bash - http --session=user1 --auth=user1:password example.org + http --session=user1 --auth=user1:password example.org X-Foo:Bar Now you can always refer to the session by passing ``--session=user1``, -and the credentials and cookies will be reused: +and the credentials, custom headers and cookies will be reused: .. code-block:: bash - http --session=user1 GET example.org - - -Since sessions are named, you can switch between multiple sessions: - -.. code-block:: bash - - http --session=user2 --auth=user2:password example.org + http --session=user1 example.org Note that session cookies respect the cookie domain and path. -Session data are stored in ``~/.httpie/sessions/.pickle``. + +Session data are stored in ``~/.httpie/sessions/.json``. + +You can view and manipulate existing sessions via the ``httpie`` management +command, see ``httpie --help``. ============== diff --git a/httpie/cli.py b/httpie/cli.py index d3b05769..30ae8446 100644 --- a/httpie/cli.py +++ b/httpie/cli.py @@ -224,11 +224,10 @@ misc.add_argument( '--session', metavar='SESSION_NAME', help=_(''' Create or reuse a session. - Withing a session, values of --auth, --timeout, - --verify, --proxies, headers, as well as any - cookies sent by the server are persistent between requests. + Withing a session, custom headers, auth credential, as well as any + cookies sent by the server persist between requests. You can use the `httpie' management command to manipulate - and inspect existing sessions. See `httpie session'. + and inspect existing sessions. See `httpie --help'. ''') ) diff --git a/httpie/client.py b/httpie/client.py index e36931ea..d783a7bd 100644 --- a/httpie/client.py +++ b/httpie/client.py @@ -4,12 +4,15 @@ from pprint import pformat import requests import requests.auth +from requests.defaults import defaults -from .import sessions +from . import sessions +from . import __version__ FORM = 'application/x-www-form-urlencoded; charset=utf-8' JSON = 'application/json; charset=utf-8' +DEFAULT_UA = 'HTTPie/%s' % __version__ def get_response(args): @@ -29,25 +32,24 @@ def get_response(args): def get_requests_kwargs(args): """Send the request and return a `request.Response`.""" + base_headers = defaults['base_headers'].copy() + base_headers['User-Agent'] = DEFAULT_UA + auto_json = args.data and not args.form if args.json or auto_json: - if 'Content-Type' not in args.headers and args.data: - args.headers['Content-Type'] = JSON - - if 'Accept' not in args.headers: - # Default Accept to JSON as well. - args.headers['Accept'] = 'application/json' + base_headers['Accept'] = 'application/json' + if args.data: + base_headers['Content-Type'] = JSON if isinstance(args.data, dict): # If not empty, serialize the data `dict` parsed from arguments. # Otherwise set it to `None` avoid sending "{}". args.data = json.dumps(args.data) if args.data else None - elif args.form: - if not args.files and 'Content-Type' not in args.headers: + elif args.form and not args.files: # If sending files, `requests` will set # the `Content-Type` for us. - args.headers['Content-Type'] = FORM + base_headers['Content-Type'] = FORM credentials = None if args.auth: @@ -71,7 +73,10 @@ def get_requests_kwargs(args): 'proxies': dict((p.key, p.value) for p in args.proxy), 'files': args.files, 'allow_redirects': args.allow_redirects, - 'params': args.params + 'params': args.params, + 'config': { + 'base_headers': base_headers + } } return kwargs diff --git a/httpie/input.py b/httpie/input.py index 7c03c997..ca17b2f7 100644 --- a/httpie/input.py +++ b/httpie/input.py @@ -18,8 +18,6 @@ except ImportError: from requests.structures import CaseInsensitiveDict from requests.compat import str, urlparse -from . import __version__ - HTTP_POST = 'POST' HTTP_GET = 'GET' @@ -79,7 +77,6 @@ PRETTY_STDOUT_TTY_ONLY = object() # Defaults OUTPUT_OPTIONS_DEFAULT = OUT_RESP_HEAD + OUT_RESP_BODY OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED = OUT_RESP_BODY -DEFAULT_UA = 'HTTPie/%s' % __version__ class Parser(argparse.ArgumentParser): @@ -193,7 +190,6 @@ class Parser(argparse.ArgumentParser): """ args.headers = CaseInsensitiveDict() - args.headers['User-Agent'] = DEFAULT_UA args.data = ParamDict() if args.form else OrderedDict() args.files = OrderedDict() args.params = ParamDict() diff --git a/httpie/manage.py b/httpie/manage.py new file mode 100644 index 00000000..a5fa833d --- /dev/null +++ b/httpie/manage.py @@ -0,0 +1,30 @@ +""" +Provides the `httpie' management command. + +Note that the main `http' command points to `httpie.__main__.main()`. + +""" +import argparse + +from . import sessions +from . import __version__ + + +parser = argparse.ArgumentParser( + description='The HTTPie management command.', + version=__version__ +) +subparsers = parser.add_subparsers() + + +# Only sessions as of now. +sessions.add_actions(subparsers) + + +def main(): + args = parser.parse_args() + args.action(args) + + +if __name__ == '__main__': + main() diff --git a/httpie/output.py b/httpie/output.py index ca432e89..55a4442b 100644 --- a/httpie/output.py +++ b/httpie/output.py @@ -135,7 +135,7 @@ def make_stream(env, args): elif args.prettify: Stream = partial( PrettyStream if args.stream else BufferedPrettyStream, - processor=OutputProcessor(env, groups=args.prettify, + processor=OutputProcessor(env=env, groups=args.prettify, pygments_style=args.style), env=env) else: @@ -343,7 +343,7 @@ class BaseProcessor(object): enabled = True - def __init__(self, env, **kwargs): + def __init__(self, env=Environment(), **kwargs): """ :param env: an class:`Environment` instance :param kwargs: additional keyword argument that some @@ -406,7 +406,8 @@ class PygmentsProcessor(BaseProcessor): return try: - style = get_style_by_name(self.kwargs['pygments_style']) + style = get_style_by_name( + self.kwargs.get('pygments_style', DEFAULT_STYLE)) except ClassNotFound: style = Solarized256Style @@ -460,7 +461,7 @@ class OutputProcessor(object): ] } - def __init__(self, env, groups, **kwargs): + def __init__(self, groups, env=Environment(), **kwargs): """ :param env: a :class:`models.Environment` instance :param groups: the groups of processors to be applied diff --git a/httpie/sessions.py b/httpie/sessions.py index a1997f45..ad81262b 100644 --- a/httpie/sessions.py +++ b/httpie/sessions.py @@ -1,68 +1,211 @@ -import os -import pickle -import errno -from requests import Session +"""Persistent, JSON-serialized sessions. +""" +import os +import sys +import json +import glob +import errno +import codecs +import subprocess + +from requests import Session as RSession +from requests.cookies import RequestsCookieJar, create_cookie +from requests.auth import HTTPBasicAuth, HTTPDigestAuth + +from . import __version__ from .config import CONFIG_DIR +from .output import PygmentsProcessor SESSIONS_DIR = os.path.join(CONFIG_DIR, 'sessions') def get_response(name, request_kwargs): - session = load(name) - session_kwargs, request_kwargs = split_kwargs(request_kwargs) - headers = session_kwargs.pop('headers', None) - if headers: - session.headers.update(headers) - session.__dict__.update(session_kwargs) + + session = Session.load(name) + + # Update session headers with the request headers. + session['headers'].update(request_kwargs.get('headers', {})) + + auth = request_kwargs.get('auth', None) + if auth: + session.auth = auth + elif session.auth: + request_kwargs['auth'] = session.auth + + + # Use the merged headers for the request + request_kwargs['headers'] = session['headers'] + + rsession = RSession(cookies=session.cookies) try: - response = session.request(**request_kwargs) + response = rsession.request(**request_kwargs) except Exception: raise else: - save(session, name) + session.cookies = rsession.cookies + session.save() return response -def split_kwargs(requests_kwargs): - session = {} - request = {} - session_attrs = [ - 'auth', 'timeout', - 'verify', 'proxies', - 'params' - ] +class Session(dict): - for k, v in requests_kwargs.items(): - if v is not None: - if k in session_attrs: - session[k] = v - else: - request[k] = v - return session, request + def __init__(self, name, *args, **kwargs): + super(Session, self).__init__(*args, **kwargs) + self.name = name + self.setdefault('cookies', {}) + self.setdefault('headers', {}) + + @property + def path(self): + return type(self).get_path(self.name) + + @property + def cookies(self): + jar = RequestsCookieJar() + for name, cookie_dict in self['cookies'].items(): + cookie = create_cookie( + name, cookie_dict.pop('value'), **cookie_dict) + jar.set_cookie(cookie) + jar.clear_expired_cookies() + return jar + + @cookies.setter + def cookies(self, jar): + exclude = [ + '_rest', 'name', 'port_specified', + 'domain_specified', 'domain_initial_dot', + 'path_specified' + ] + self['cookies'] = {} + for host in jar._cookies.values(): + for path in host.values(): + for name, cookie in path.items(): + cookie_dict = {} + for k, v in cookie.__dict__.items(): + if k not in exclude: + cookie_dict[k] = v + self['cookies'][name] = cookie_dict + + @property + def auth(self): + auth = self.get('auth', None) + if not auth: + return None + Auth = {'basic': HTTPBasicAuth, + 'digest': HTTPDigestAuth}[auth['type']] + return Auth(auth['username'], auth['password']) -def get_path(name): - try: - os.makedirs(SESSIONS_DIR, mode=0o700) - except OSError as e: - if e.errno != errno.EEXIST: - raise + @auth.setter + def auth(self, cred): + self['auth'] = { + 'type': {HTTPBasicAuth: 'basic', + HTTPDigestAuth: 'digest'}[type(cred)], + 'username': cred.username, + 'password': cred.password, + } - return os.path.join(SESSIONS_DIR, name + '.pickle') + def save(self): + self['__version__'] = __version__ + with open(self.path, 'wb') as f: + json.dump(self, f, indent=4, sort_keys=True, ensure_ascii=True) + f.write(b'\n') + + @classmethod + def load(cls, name): + try: + with open(cls.get_path(name), 'rt') as f: + try: + data = json.load(f) + except ValueError as e: + raise ValueError('Invalid session: %s [%s]' % + (e.message, f.name)) + + return cls(name, data) + except IOError as e: + if e.errno != errno.ENOENT: + raise + return cls(name) + + @classmethod + def get_path(cls, name): + try: + os.makedirs(SESSIONS_DIR, mode=0o700) + except OSError as e: + if e.errno != errno.EEXIST: + raise + + return os.path.join(SESSIONS_DIR, name + '.json') -def load(name): - try: - with open(get_path(name), 'rb') as f: - return pickle.load(f) - except IOError as e: - if e.errno != errno.ENOENT: - raise - return Session() +def show_action(args): + if not args.name: + for fn in sorted(glob.glob1(SESSIONS_DIR, '*.json')): + print(os.path.splitext(fn)[0]) + return + + path = Session.get_path(args.name) + if not os.path.exists(path): + sys.stderr.write('Session "%s" does not exist [%s].\n' + % (args.name, path)) + sys.exit(1) + + with codecs.open(path, encoding='utf8') as f: + print(path + ':\n') + print(PygmentsProcessor().process_body( + f.read(), 'application/json', 'json')) + print('') -def save(session, name): - with open(get_path(name), 'wb') as f: - pickle.dump(session, f) +def delete_action(args): + if not args.name: + for path in glob.glob(os.path.join(SESSIONS_DIR, '*.json')): + os.unlink(path) + return + path = Session.get_path(args.name) + if not os.path.exists(path): + sys.stderr.write('Session "%s" does not exist [%s].\n' + % (args.name, path)) + sys.exit(1) + else: + os.unlink(path) + + +def edit_action(args): + editor = os.environ.get('EDITOR', None) + if not editor: + sys.stderr.write( + 'You need to configure the environment variable EDITOR.\n') + sys.exit(1) + command = editor.split() + command.append(Session.get_path(args.name)) + subprocess.call(command) + + +def add_actions(subparsers): + + # Show + show = subparsers.add_parser('session-show', help='list or show sessions') + show.set_defaults(action=show_action) + show.add_argument('name', nargs='?', + help='When omitted, HTTPie prints a list of existing sessions.' + ' When specified, the session data is printed.') + + # Edit + edit = subparsers.add_parser('session-edit', help='edit a session in $EDITOR') + edit.set_defaults(action=edit_action) + edit.add_argument('name') + + # Delete + delete = subparsers.add_parser('session-delete', help='delete a session') + delete.set_defaults(action=delete_action) + delete_group = delete.add_mutually_exclusive_group(required=True) + delete_group.add_argument( + '--all', action='store_true', + help='Delete all sessions from %s' % SESSIONS_DIR) + delete_group.add_argument( + 'name', nargs='?', + help='The name of the session to be deleted. ' \ + 'To see a list existing sessions, run `httpie sessions show\'.') diff --git a/setup.py b/setup.py index 780b07c7..305e2415 100644 --- a/setup.py +++ b/setup.py @@ -47,6 +47,7 @@ setup( entry_points={ 'console_scripts': [ 'http = httpie.__main__:main', + 'httpie = httpie.manage:main', ], }, install_requires=requirements,