Added anonymous sessions (--session=/file/path.json).

This commit is contained in:
Jakub Roztocil 2013-05-13 14:47:44 +02:00
parent 76eebeac2a
commit 87c59ae561
7 changed files with 113 additions and 121 deletions

View File

@ -937,13 +937,17 @@ Streamed output by small chunks alá ``tail -f``:
Sessions
========
By default, every request is completely independent of the previous ones.
By default, every request is completely independent of any previous ones.
HTTPie also supports persistent sessions, where custom headers (except for the
ones starting with ``Content-`` or ``If-``), authorization, and cookies
(manually specified or sent by the server) persist between requests
to the same host.
Create a new session named ``user1``:
--------------
Named Sessions
--------------
Create a new session named ``user1`` for ``example.org``:
.. code-block:: bash
@ -966,14 +970,30 @@ To use a session without updating it from the request/response exchange
once it is created, specify the session name via
``--session-read-only=SESSION_NAME`` instead.
Session data are stored in JSON files in the directory
Named sessions' data is stored in JSON files in the directory
``~/.httpie/sessions/<host>/<name>.json``
(``%APPDATA%\httpie\sessions\<host>\<name>.json`` on Windows).
------------------
Anonymous Sessions
------------------
Instead of a name, you can also directly specify a path to a session file. This
allows for re-using session across multiple hosts:
.. code-block:: bash
$ http --session=/tmp/session.json example.org
$ http --session=/tmp/session.json admin.example.org
$ http --session=~/.httpie/sessions/another.example.org/test.json example.org
$ http --session-read-only=/tmp/session.json example.org
**Warning:** All session data, including credentials, cookie data,
and custom headers are stored in plain text.
Session files can also be created and edited manually in a text editor.
Note that session files can also be created and edited manually in a text
editor; they are plain JSON.
See also `Config`_.
@ -1164,6 +1184,8 @@ Changelog
*You can click a version name to see a diff with the previous one.*
* `0.6.0-dev`_
* ``--session`` and ``--session-read-only`` now also accept paths to
session files (eg. ``http --session=/tmp/session.json example.org``).
* `0.5.1`_ (2013-05-13)
* ``Content-*`` and ``If-*`` request headers are not stored in sessions
anymore as they are request-specific.

View File

@ -7,13 +7,13 @@ from argparse import FileType, OPTIONAL, ZERO_OR_MORE, SUPPRESS
from . import __doc__
from . import __version__
from .sessions import DEFAULT_SESSIONS_DIR, Session
from .sessions import DEFAULT_SESSIONS_DIR
from .output import AVAILABLE_STYLES, DEFAULT_STYLE
from .input import (Parser, AuthCredentialsArgType, KeyValueArgType,
SEP_PROXY, SEP_CREDENTIALS, SEP_GROUP_ITEMS,
OUT_REQ_HEAD, OUT_REQ_BODY, OUT_RESP_HEAD,
OUT_RESP_BODY, OUTPUT_OPTIONS,
PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY, RegexValidator)
PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY, SessionNameValidator)
def _(text):
@ -256,19 +256,21 @@ output_options.add_argument(
''')
)
###############################################################################
# Sessions
###############################################################################
sessions = parser.add_argument_group(title='Sessions')\
.add_mutually_exclusive_group(required=False)
session_name_validator = SessionNameValidator(
'Session name contains invalid characters.')
sessions.add_argument(
'--session',
metavar='SESSION_NAME',
type=RegexValidator(
Session.VALID_NAME_PATTERN,
'Session name contains invalid characters.'
),
metavar='SESSION_NAME_OR_PATH',
type=session_name_validator,
help=_('''
Create, or reuse and update a session.
Within a session, custom headers, auth credential, as well as any
@ -278,7 +280,8 @@ sessions.add_argument(
)
sessions.add_argument(
'--session-read-only',
metavar='SESSION_NAME',
metavar='SESSION_NAME_OR_PATH',
type=session_name_validator,
help=_('''
Create or read a session without updating it form the
request/response exchange.
@ -289,6 +292,7 @@ sessions.add_argument(
###############################################################################
# Authentication
###############################################################################
# ``requests.request`` keyword arguments.
auth = parser.add_argument_group(title='Authentication')
auth.add_argument(
@ -312,8 +316,9 @@ auth.add_argument(
)
###############################################################################
# Network
#############################################
###############################################################################
network = parser.add_argument_group(title='Network')

View File

@ -28,7 +28,7 @@ def get_response(args, config_dir):
else:
response = sessions.get_response(
config_dir=config_dir,
name=args.session or args.session_read_only,
session_name=args.session or args.session_read_only,
requests_kwargs=requests_kwargs,
read_only=bool(args.session_read_only),
)

View File

@ -16,9 +16,8 @@ DEFAULT_CONFIG_DIR = os.environ.get(
class BaseConfigDict(dict):
name = None
help = None
helpurl = None
about = None
directory = DEFAULT_CONFIG_DIR
def __init__(self, directory=None, *args, **kwargs):
@ -29,18 +28,24 @@ class BaseConfigDict(dict):
def __getattr__(self, item):
return self[item]
@property
def path(self):
try:
os.makedirs(self.directory, mode=0o700)
except OSError as e:
if e.errno != errno.EEXIST:
raise
def _get_path(self):
"""Return the config file path without side-effects."""
return os.path.join(self.directory, self.name + '.json')
@property
def path(self):
"""Return the config file path creating basedir, if needed."""
path = self._get_path()
try:
os.makedirs(os.path.dirname(path), mode=0o700)
except OSError as e:
if e.errno != errno.EEXIST:
raise
return path
@property
def is_new(self):
return not os.path.exists(self.path)
return not os.path.exists(self._get_path())
def load(self):
try:
@ -61,8 +66,8 @@ class BaseConfigDict(dict):
self['__meta__'] = {
'httpie': __version__
}
if self.help:
self['__meta__']['help'] = self.help
if self.helpurl:
self['__meta__']['help'] = self.helpurl
if self.about:
self['__meta__']['about'] = self.about
@ -82,7 +87,7 @@ class BaseConfigDict(dict):
class Config(BaseConfigDict):
name = 'config'
help = 'https://github.com/jkbr/httpie#config'
helpurl = 'https://github.com/jkbr/httpie#config'
about = 'HTTPie configuration file'
DEFAULTS = {

View File

@ -20,6 +20,7 @@ except ImportError:
from requests.structures import CaseInsensitiveDict
from .compat import urlsplit, str
from .sessions import VALID_SESSION_NAME_PATTERN
HTTP_POST = 'POST'
@ -373,24 +374,15 @@ class KeyValue(object):
return self.__dict__ == other.__dict__
def session_name_arg_type(name):
from .sessions import Session
if not Session.is_valid_name(name):
raise ArgumentTypeError(
'special characters and spaces are not'
' allowed in session names: "%s"'
% name)
return name
class SessionNameValidator(object):
class RegexValidator(object):
def __init__(self, pattern, error_message):
self.pattern = re.compile(pattern)
def __init__(self, error_message):
self.error_message = error_message
def __call__(self, value):
if not self.pattern.search(value):
# Session name can be a path or just a name.
if (os.path.sep not in value
and not VALID_SESSION_NAME_PATTERN.search(value)):
raise ArgumentError(None, self.error_message)
return value

View File

@ -3,9 +3,6 @@
"""
import re
import os
import glob
import errno
import shutil
import requests
from requests.cookies import RequestsCookieJar, create_cookie
@ -17,26 +14,36 @@ from .config import BaseConfigDict, DEFAULT_CONFIG_DIR
SESSIONS_DIR_NAME = 'sessions'
DEFAULT_SESSIONS_DIR = os.path.join(DEFAULT_CONFIG_DIR, SESSIONS_DIR_NAME)
VALID_SESSION_NAME_PATTERN = re.compile('^[a-zA-Z0-9_.-]+$')
# Request headers starting with these prefixes won't be stored in sessions.
# They are specific to each request.
# http://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Requests
SESSION_IGNORED_HEADER_PREFIXES = ['Content-', 'If-']
def get_response(name, requests_kwargs, config_dir, read_only=False):
def get_response(session_name, requests_kwargs, config_dir, read_only=False):
"""Like `client.get_response`, but applies permanent
aspects of the session to the request.
"""
sessions_dir = os.path.join(config_dir, SESSIONS_DIR_NAME)
host = Host(
root_dir=sessions_dir,
name=requests_kwargs['headers'].get('Host', None)
if os.path.sep in session_name:
path = os.path.expanduser(session_name)
else:
hostname = (
requests_kwargs['headers'].get('Host', None)
or urlsplit(requests_kwargs['url']).netloc.split('@')[-1]
)
session = Session(host, name)
assert re.match('^[a-zA-Z0-9_.:-]+$', hostname)
# host:port => host_port
hostname = hostname.replace(':', '_')
path = os.path.join(config_dir,
SESSIONS_DIR_NAME,
hostname,
session_name + '.json')
session = Session(path)
session.load()
# Merge request and session headers to get final headers for this request.
@ -68,69 +75,13 @@ def get_response(name, requests_kwargs, config_dir, read_only=False):
return response
class Host(object):
"""A host is a per-host directory on the disk containing sessions files."""
VALID_NAME_PATTERN = re.compile('^[a-zA-Z0-9_.:-]+$')
def __init__(self, name, root_dir=DEFAULT_SESSIONS_DIR):
assert self.VALID_NAME_PATTERN.match(name)
self.name = name
self.root_dir = root_dir
def __iter__(self):
"""Return an iterator yielding `Session` instances."""
for fn in sorted(glob.glob1(self.path, '*.json')):
session_name = os.path.splitext(fn)[0]
yield Session(host=self, name=session_name)
@staticmethod
def _quote_name(name):
"""host:port => host_port"""
return name.replace(':', '_')
@staticmethod
def _unquote_name(name):
"""host_port => host:port"""
return re.sub(r'_(\d+)$', r':\1', name)
@classmethod
def all(cls, root_dir=DEFAULT_SESSIONS_DIR):
"""Return a generator yielding a host at a time."""
for name in sorted(glob.glob1(root_dir, '*')):
if os.path.isdir(os.path.join(root_dir, name)):
yield Host(cls._unquote_name(name), root_dir=root_dir)
@property
def verbose_name(self):
return '%s %s' % (self.name, self.path)
def delete(self):
shutil.rmtree(self.path)
@property
def path(self):
path = os.path.join(self.root_dir, self._quote_name(self.name))
try:
os.makedirs(path, mode=0o700)
except OSError as e:
if e.errno != errno.EEXIST:
raise
return path
class Session(BaseConfigDict):
help = 'https://github.com/jkbr/httpie#sessions'
helpurl = 'https://github.com/jkbr/httpie#sessions'
about = 'HTTPie session file'
VALID_NAME_PATTERN = re.compile('^[a-zA-Z0-9_.-]+$')
def __init__(self, host, name, *args, **kwargs):
assert self.VALID_NAME_PATTERN.match(name)
def __init__(self, path, *args, **kwargs):
super(Session, self).__init__(*args, **kwargs)
self.host = host
self.name = name
self._path = path
self['headers'] = {}
self['cookies'] = {}
self['auth'] = {
@ -139,13 +90,8 @@ class Session(BaseConfigDict):
'password': None
}
@property
def directory(self):
return self.host.path
@property
def verbose_name(self):
return '%s %s %s' % (self.host.name, self.name, self.path)
def _get_path(self):
return self._path
def update_headers(self, request_headers):
"""

View File

@ -1464,6 +1464,28 @@ class SessionTest(BaseTestCase):
# Should be the same as before r2.
self.assertDictEqual(r1.json, r3.json)
def test_session_by_path(self):
session_path = os.path.join(self.config_dir, 'session-by-path.json')
r1 = http(
'--session=' + session_path,
'GET',
httpbin('/get'),
'Foo:Bar',
env=self.env
)
self.assertIn(OK, r1)
r2 = http(
'--session=' + session_path,
'GET',
httpbin('/get'),
env=self.env
)
self.assertIn(OK, r2)
self.assertEqual(r2.json['headers']['Foo'], 'Bar')
class DownloadUtilsTest(BaseTestCase):