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 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 HTTPie also supports persistent sessions, where custom headers (except for the
ones starting with ``Content-`` or ``If-``), authorization, and cookies ones starting with ``Content-`` or ``If-``), authorization, and cookies
(manually specified or sent by the server) persist between requests (manually specified or sent by the server) persist between requests
to the same host. to the same host.
Create a new session named ``user1``: --------------
Named Sessions
--------------
Create a new session named ``user1`` for ``example.org``:
.. code-block:: bash .. 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 once it is created, specify the session name via
``--session-read-only=SESSION_NAME`` instead. ``--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`` ``~/.httpie/sessions/<host>/<name>.json``
(``%APPDATA%\httpie\sessions\<host>\<name>.json`` on Windows). (``%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, **Warning:** All session data, including credentials, cookie data,
and custom headers are stored in plain text. 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`_. See also `Config`_.
@ -1164,6 +1184,8 @@ Changelog
*You can click a version name to see a diff with the previous one.* *You can click a version name to see a diff with the previous one.*
* `0.6.0-dev`_ * `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) * `0.5.1`_ (2013-05-13)
* ``Content-*`` and ``If-*`` request headers are not stored in sessions * ``Content-*`` and ``If-*`` request headers are not stored in sessions
anymore as they are request-specific. 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 __doc__
from . import __version__ from . import __version__
from .sessions import DEFAULT_SESSIONS_DIR, Session from .sessions import DEFAULT_SESSIONS_DIR
from .output import AVAILABLE_STYLES, DEFAULT_STYLE from .output import AVAILABLE_STYLES, DEFAULT_STYLE
from .input import (Parser, AuthCredentialsArgType, KeyValueArgType, from .input import (Parser, AuthCredentialsArgType, KeyValueArgType,
SEP_PROXY, SEP_CREDENTIALS, SEP_GROUP_ITEMS, SEP_PROXY, SEP_CREDENTIALS, SEP_GROUP_ITEMS,
OUT_REQ_HEAD, OUT_REQ_BODY, OUT_RESP_HEAD, OUT_REQ_HEAD, OUT_REQ_BODY, OUT_RESP_HEAD,
OUT_RESP_BODY, OUTPUT_OPTIONS, OUT_RESP_BODY, OUTPUT_OPTIONS,
PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY, RegexValidator) PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY, SessionNameValidator)
def _(text): def _(text):
@ -256,19 +256,21 @@ output_options.add_argument(
''') ''')
) )
############################################################################### ###############################################################################
# Sessions # Sessions
############################################################################### ###############################################################################
sessions = parser.add_argument_group(title='Sessions')\ sessions = parser.add_argument_group(title='Sessions')\
.add_mutually_exclusive_group(required=False) .add_mutually_exclusive_group(required=False)
session_name_validator = SessionNameValidator(
'Session name contains invalid characters.')
sessions.add_argument( sessions.add_argument(
'--session', '--session',
metavar='SESSION_NAME', metavar='SESSION_NAME_OR_PATH',
type=RegexValidator( type=session_name_validator,
Session.VALID_NAME_PATTERN,
'Session name contains invalid characters.'
),
help=_(''' help=_('''
Create, or reuse and update a session. Create, or reuse and update a session.
Within a session, custom headers, auth credential, as well as any Within a session, custom headers, auth credential, as well as any
@ -278,7 +280,8 @@ sessions.add_argument(
) )
sessions.add_argument( sessions.add_argument(
'--session-read-only', '--session-read-only',
metavar='SESSION_NAME', metavar='SESSION_NAME_OR_PATH',
type=session_name_validator,
help=_(''' help=_('''
Create or read a session without updating it form the Create or read a session without updating it form the
request/response exchange. request/response exchange.
@ -289,6 +292,7 @@ sessions.add_argument(
############################################################################### ###############################################################################
# Authentication # Authentication
############################################################################### ###############################################################################
# ``requests.request`` keyword arguments. # ``requests.request`` keyword arguments.
auth = parser.add_argument_group(title='Authentication') auth = parser.add_argument_group(title='Authentication')
auth.add_argument( auth.add_argument(
@ -312,8 +316,9 @@ auth.add_argument(
) )
###############################################################################
# Network # Network
############################################# ###############################################################################
network = parser.add_argument_group(title='Network') network = parser.add_argument_group(title='Network')

View File

@ -28,7 +28,7 @@ def get_response(args, config_dir):
else: else:
response = sessions.get_response( response = sessions.get_response(
config_dir=config_dir, 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, requests_kwargs=requests_kwargs,
read_only=bool(args.session_read_only), read_only=bool(args.session_read_only),
) )

View File

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

View File

@ -20,6 +20,7 @@ except ImportError:
from requests.structures import CaseInsensitiveDict from requests.structures import CaseInsensitiveDict
from .compat import urlsplit, str from .compat import urlsplit, str
from .sessions import VALID_SESSION_NAME_PATTERN
HTTP_POST = 'POST' HTTP_POST = 'POST'
@ -373,24 +374,15 @@ class KeyValue(object):
return self.__dict__ == other.__dict__ return self.__dict__ == other.__dict__
def session_name_arg_type(name): class SessionNameValidator(object):
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
def __init__(self, error_message):
class RegexValidator(object):
def __init__(self, pattern, error_message):
self.pattern = re.compile(pattern)
self.error_message = error_message self.error_message = error_message
def __call__(self, value): 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) raise ArgumentError(None, self.error_message)
return value return value

View File

@ -3,9 +3,6 @@
""" """
import re import re
import os import os
import glob
import errno
import shutil
import requests import requests
from requests.cookies import RequestsCookieJar, create_cookie from requests.cookies import RequestsCookieJar, create_cookie
@ -17,26 +14,36 @@ from .config import BaseConfigDict, DEFAULT_CONFIG_DIR
SESSIONS_DIR_NAME = 'sessions' SESSIONS_DIR_NAME = 'sessions'
DEFAULT_SESSIONS_DIR = os.path.join(DEFAULT_CONFIG_DIR, SESSIONS_DIR_NAME) 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. # Request headers starting with these prefixes won't be stored in sessions.
# They are specific to each request. # They are specific to each request.
# http://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Requests # http://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Requests
SESSION_IGNORED_HEADER_PREFIXES = ['Content-', 'If-'] 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 """Like `client.get_response`, but applies permanent
aspects of the session to the request. aspects of the session to the request.
""" """
sessions_dir = os.path.join(config_dir, SESSIONS_DIR_NAME) if os.path.sep in session_name:
host = Host( path = os.path.expanduser(session_name)
root_dir=sessions_dir, else:
name=requests_kwargs['headers'].get('Host', None) hostname = (
or urlsplit(requests_kwargs['url']).netloc.split('@')[-1] 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() session.load()
# Merge request and session headers to get final headers for this request. # 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 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): class Session(BaseConfigDict):
helpurl = 'https://github.com/jkbr/httpie#sessions'
help = 'https://github.com/jkbr/httpie#sessions'
about = 'HTTPie session file' about = 'HTTPie session file'
VALID_NAME_PATTERN = re.compile('^[a-zA-Z0-9_.-]+$') def __init__(self, path, *args, **kwargs):
def __init__(self, host, name, *args, **kwargs):
assert self.VALID_NAME_PATTERN.match(name)
super(Session, self).__init__(*args, **kwargs) super(Session, self).__init__(*args, **kwargs)
self.host = host self._path = path
self.name = name
self['headers'] = {} self['headers'] = {}
self['cookies'] = {} self['cookies'] = {}
self['auth'] = { self['auth'] = {
@ -139,13 +90,8 @@ class Session(BaseConfigDict):
'password': None 'password': None
} }
@property def _get_path(self):
def directory(self): return self._path
return self.host.path
@property
def verbose_name(self):
return '%s %s %s' % (self.host.name, self.name, self.path)
def update_headers(self, request_headers): def update_headers(self, request_headers):
""" """

View File

@ -1464,6 +1464,28 @@ class SessionTest(BaseTestCase):
# Should be the same as before r2. # Should be the same as before r2.
self.assertDictEqual(r1.json, r3.json) 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): class DownloadUtilsTest(BaseTestCase):