Compare commits

...

17 Commits
0.5.0 ... 0.6.0

Author SHA1 Message Date
809a461a26 v0.6.0 2013-06-03 12:19:43 +02:00
c3d550e930 Fixed headers tests; Require requests>=1.2.3. 2013-06-02 20:47:29 +02:00
172df162b3 Added XML formatting to CHANGELOG. 2013-06-02 20:27:58 +02:00
1bad62ab0e Handle unicode when formatting XML. 2013-06-02 20:25:36 +02:00
8d302f91f9 Merge branch 'master' of git://github.com/jargonjustin/httpie into jargonjustin-master 2013-06-02 20:14:51 +02:00
63b61bc811 Add custom Host example. 2013-05-20 15:31:02 +02:00
5af88756a6 Fixed download ETA for Python 2.6. 2013-05-14 12:49:29 +02:00
7f624e61b5 Use Thread instead of Timer for progress reporting. 2013-05-14 12:49:03 +02:00
6e848b3203 cleanup 2013-05-14 12:14:08 +02:00
8e112a6948 test_download_no_Content_Length 2013-05-13 15:35:12 +02:00
87c59ae561 Added anonymous sessions (--session=/file/path.json). 2013-05-13 14:47:44 +02:00
76eebeac2a 0.6.0-dev 2013-05-13 12:42:16 +02:00
5b9cbcb530 v0.5.1 2013-05-13 12:40:25 +02:00
8ad33d5f6a Changelog 2013-05-13 12:20:54 +02:00
86ac4cdb7b Changelog 2013-05-13 12:20:28 +02:00
e09b74021c Ignore Content-* and If-* request headers.
Those headers are not stored in sessions anymore.

Closes #141.
2013-05-13 11:54:49 +02:00
2e57c080fd Pretty print XML 2012-12-17 13:21:38 -08:00
13 changed files with 263 additions and 157 deletions

View File

@ -28,3 +28,4 @@ Patches and ideas
* `Tomek Wójcik <https://github.com/tomekwojcik>`_ * `Tomek Wójcik <https://github.com/tomekwojcik>`_
* `Davey Shafik <https://github.com/dshafik>`_ * `Davey Shafik <https://github.com/dshafik>`_
* `cido <https://github.com/cido>`_ * `cido <https://github.com/cido>`_
* `Justin Bonnar <https://github.com/jargonjustin>`_

View File

@ -181,6 +181,13 @@ between requests to the same host:
$ http --session=logged-in httpbin.org/headers $ http --session=logged-in httpbin.org/headers
Set a custom ``Host`` header to work around missing DNS records:
.. code-block:: bash
$ http localhost:8000 Host:example.com
.. ..
-------- --------
@ -744,6 +751,7 @@ Also, the following formatting is applied:
* HTTP headers are sorted by name. * HTTP headers are sorted by name.
* JSON data is indented, sorted by keys, and unicode escapes are converted * JSON data is indented, sorted by keys, and unicode escapes are converted
to the characters they represent. to the characters they represent.
* XML data is indented for better readability.
One of these options can be used to control output processing: One of these options can be used to control output processing:
@ -937,12 +945,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, authorization, HTTPie also supports persistent sessions, where custom headers (except for the
and cookies (manually specified or sent by the server) persist between ones starting with ``Content-`` or ``If-``), authorization, and cookies
requests to the same host. (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 .. code-block:: bash
@ -965,14 +978,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 sessions to be re-used 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`_.
@ -1162,7 +1191,14 @@ 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.7.0-dev`_
* `0.6.0`_
* XML data is now formatted.
* ``--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.
* `0.5.0`_ (2013-04-27) * `0.5.0`_ (2013-04-27)
* Added a `download mode`_ via ``--download``. * Added a `download mode`_ via ``--download``.
* Bugfixes. * Bugfixes.
@ -1272,6 +1308,8 @@ Changelog
.. _0.4.0: https://github.com/jkbr/httpie/compare/0.3.0...0.4.0 .. _0.4.0: https://github.com/jkbr/httpie/compare/0.3.0...0.4.0
.. _0.4.1: https://github.com/jkbr/httpie/compare/0.4.0...0.4.1 .. _0.4.1: https://github.com/jkbr/httpie/compare/0.4.0...0.4.1
.. _0.5.0: https://github.com/jkbr/httpie/compare/0.4.1...0.5.0 .. _0.5.0: https://github.com/jkbr/httpie/compare/0.4.1...0.5.0
.. _0.6.0-dev: https://github.com/jkbr/httpie/compare/0.5.0...master .. _0.5.1: https://github.com/jkbr/httpie/compare/0.5.0...0.5.1
.. _0.6.0: https://github.com/jkbr/httpie/compare/0.5.1...0.6.0
.. _0.7.0-dev: https://github.com/jkbr/httpie/compare/0.6.0...master
.. _AUTHORS.rst: https://github.com/jkbr/httpie/blob/master/AUTHORS.rst .. _AUTHORS.rst: https://github.com/jkbr/httpie/blob/master/AUTHORS.rst
.. _LICENSE: https://github.com/jkbr/httpie/blob/master/LICENSE .. _LICENSE: https://github.com/jkbr/httpie/blob/master/LICENSE

View File

@ -3,7 +3,7 @@ HTTPie - a CLI, cURL-like tool for humans.
""" """
__author__ = 'Jakub Roztocil' __author__ = 'Jakub Roztocil'
__version__ = '0.5.0' __version__ = '0.6.0'
__licence__ = 'BSD' __licence__ = 'BSD'

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,8 +28,8 @@ 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,
request_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

@ -9,7 +9,7 @@ import re
import sys import sys
import mimetypes import mimetypes
import threading import threading
from time import time from time import sleep, time
from .output import RawStream from .output import RawStream
from .models import HTTPResponse from .models import HTTPResponse
@ -163,7 +163,7 @@ class Download(object):
self.finished = False self.finished = False
self._status = Status() self._status = Status()
self._progress_reporter = ProgressReporter( self._progress_reporter = ProgressReporterThread(
status=self._status, status=self._status,
output=progress_file output=progress_file
) )
@ -253,7 +253,7 @@ class Download(object):
self._output_file.name self._output_file.name
) )
) )
self._progress_reporter.report() self._progress_reporter.start()
return stream, self._output_file return stream, self._output_file
@ -263,7 +263,6 @@ class Download(object):
self._status.finished() self._status.finished()
def failed(self): def failed(self):
self.finish()
self._progress_reporter.stop() self._progress_reporter.stop()
@property @property
@ -317,7 +316,7 @@ class Status(object):
self.time_finished = time() self.time_finished = time()
class ProgressReporter(object): class ProgressReporterThread(threading.Thread):
""" """
Reports download progress based on its status. Reports download progress based on its status.
@ -330,6 +329,7 @@ class ProgressReporter(object):
:type status: Status :type status: Status
:type output: file :type output: file
""" """
super(ProgressReporterThread, self).__init__()
self.status = status self.status = status
self.output = output self.output = output
self._tick = tick self._tick = tick
@ -338,20 +338,20 @@ class ProgressReporter(object):
self._status_line = '' self._status_line = ''
self._prev_bytes = 0 self._prev_bytes = 0
self._prev_time = time() self._prev_time = time()
self._stop = False self._should_stop = threading.Event()
def stop(self): def stop(self):
"""Stop reporting on next tick.""" """Stop reporting on next tick."""
self._stop = True self._should_stop.set()
def run(self):
while not self._should_stop.is_set():
if self.status.has_finished:
self.sum_up()
break
def report(self):
if self._stop:
return
if self.status.has_finished:
self.sum_up()
else:
self.report_speed() self.report_speed()
threading.Timer(self._tick, self.report).start() sleep(self._tick)
def report_speed(self): def report_speed(self):
@ -382,7 +382,7 @@ class ProgressReporter(object):
s = int((self.status.total_size - downloaded) / speed) s = int((self.status.total_size - downloaded) / speed)
h, s = divmod(s, 60 * 60) h, s = divmod(s, 60 * 60)
m, s = divmod(s, 60) m, s = divmod(s, 60)
eta = '{}:{:0>2}:{:0>2}'.format(h, m, s) eta = '{0}:{1:0>2}:{2:0>2}'.format(h, m, s)
self._status_line = PROGRESS.format( self._status_line = PROGRESS.format(
percentage=percentage, percentage=percentage,

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

@ -2,6 +2,7 @@
""" """
import json import json
import xml.dom.minidom
from functools import partial from functools import partial
from itertools import chain from itertools import chain
@ -20,6 +21,9 @@ from .input import (OUT_REQ_BODY, OUT_REQ_HEAD,
OUT_RESP_HEAD, OUT_RESP_BODY) OUT_RESP_HEAD, OUT_RESP_BODY)
# The default number of spaces to indent when pretty printing
DEFAULT_INDENT = 4
# Colors on Windows via colorama don't look that # Colors on Windows via colorama don't look that
# great and fruity seems to give the best result there. # great and fruity seems to give the best result there.
AVAILABLE_STYLES = set(STYLE_MAP.keys()) AVAILABLE_STYLES = set(STYLE_MAP.keys())
@ -264,8 +268,9 @@ class PrettyStream(EncodedStream):
def _process_body(self, chunk): def _process_body(self, chunk):
return (self.processor return (self.processor
.process_body( .process_body(
chunk.decode(self.msg.encoding, 'replace'), content=chunk.decode(self.msg.encoding, 'replace'),
self.msg.content_type) content_type=self.msg.content_type,
encoding=self.msg.encoding)
.encode(self.output_encoding, 'replace')) .encode(self.output_encoding, 'replace'))
@ -367,12 +372,13 @@ class BaseProcessor(object):
""" """
return headers return headers
def process_body(self, content, content_type, subtype): def process_body(self, content, content_type, subtype, encoding):
"""Return processed `content`. """Return processed `content`.
:param content: The body content as text :param content: The body content as text
:param content_type: Full content type, e.g., 'application/atom+xml'. :param content_type: Full content type, e.g., 'application/atom+xml'.
:param subtype: E.g. 'xml'. :param subtype: E.g. 'xml'.
:param encoding: The original content encoding.
""" """
return content return content
@ -381,7 +387,7 @@ class BaseProcessor(object):
class JSONProcessor(BaseProcessor): class JSONProcessor(BaseProcessor):
"""JSON body processor.""" """JSON body processor."""
def process_body(self, content, content_type, subtype): def process_body(self, content, content_type, subtype, encoding):
if subtype == 'json': if subtype == 'json':
try: try:
# Indent the JSON data, sort keys by name, and # Indent the JSON data, sort keys by name, and
@ -389,13 +395,29 @@ class JSONProcessor(BaseProcessor):
content = json.dumps(json.loads(content), content = json.dumps(json.loads(content),
sort_keys=True, sort_keys=True,
ensure_ascii=False, ensure_ascii=False,
indent=4) indent=DEFAULT_INDENT)
except ValueError: except ValueError:
# Invalid JSON but we don't care. # Invalid JSON but we don't care.
pass pass
return content return content
class XMLProcessor(BaseProcessor):
"""XML body processor."""
# TODO: tests
def process_body(self, content, content_type, subtype, encoding):
if subtype == 'xml':
try:
# Pretty print the XML
doc = xml.dom.minidom.parseString(content.encode(encoding))
content = doc.toprettyxml(indent=' ' * DEFAULT_INDENT)
except xml.parsers.expat.ExpatError:
# Ignore invalid XML errors (skips attempting to pretty print)
pass
return content
class PygmentsProcessor(BaseProcessor): class PygmentsProcessor(BaseProcessor):
"""A processor that applies syntax-highlighting using Pygments """A processor that applies syntax-highlighting using Pygments
to the headers, and to the body as well if its content type is recognized. to the headers, and to the body as well if its content type is recognized.
@ -427,7 +449,7 @@ class PygmentsProcessor(BaseProcessor):
return pygments.highlight( return pygments.highlight(
headers, HTTPLexer(), self.formatter).strip() headers, HTTPLexer(), self.formatter).strip()
def process_body(self, content, content_type, subtype): def process_body(self, content, content_type, subtype, encoding):
try: try:
lexer = self.lexers_by_type.get(content_type) lexer = self.lexers_by_type.get(content_type)
if not lexer: if not lexer:
@ -460,7 +482,8 @@ class OutputProcessor(object):
installed_processors = { installed_processors = {
'format': [ 'format': [
HeadersProcessor, HeadersProcessor,
JSONProcessor JSONProcessor,
XMLProcessor
], ],
'colors': [ 'colors': [
PygmentsProcessor PygmentsProcessor
@ -486,13 +509,18 @@ class OutputProcessor(object):
headers = processor.process_headers(headers) headers = processor.process_headers(headers)
return headers return headers
def process_body(self, content, content_type): def process_body(self, content, content_type, encoding):
# e.g., 'application/atom+xml' # e.g., 'application/atom+xml'
content_type = content_type.split(';')[0] content_type = content_type.split(';')[0]
# e.g., 'xml' # e.g., 'xml'
subtype = content_type.split('/')[-1].split('+')[-1] subtype = content_type.split('/')[-1].split('+')[-1]
for processor in self.processors: for processor in self.processors:
content = processor.process_body(content, content_type, subtype) content = processor.process_body(
content,
content_type,
subtype,
encoding
)
return content return content

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,38 +14,53 @@ 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.
# 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, request_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=request_kwargs['headers'].get('Host', None) hostname = (
or urlsplit(request_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()
# Update session headers with the request headers. request_headers = requests_kwargs.get('headers', {})
session['headers'].update(request_kwargs.get('headers', {})) requests_kwargs['headers'] = dict(session.headers, **request_headers)
# Use the merged headers for the request session.update_headers(request_headers)
request_kwargs['headers'] = session['headers']
auth = request_kwargs.get('auth', None) auth = requests_kwargs.get('auth', None)
if auth: if auth:
session.auth = auth session.auth = auth
elif session.auth: elif session.auth:
request_kwargs['auth'] = session.auth requests_kwargs['auth'] = session.auth
requests_session = requests.Session() requests_session = requests.Session()
requests_session.cookies = session.cookies requests_session.cookies = session.cookies
try: try:
response = requests_session.request(**request_kwargs) response = requests_session.request(**requests_kwargs)
except Exception: except Exception:
raise raise
else: else:
@ -59,69 +71,13 @@ def get_response(name, request_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'] = {
@ -130,13 +86,31 @@ class Session(BaseConfigDict):
'password': None 'password': None
} }
@property def _get_path(self):
def directory(self): return self._path
return self.host.path
def update_headers(self, request_headers):
"""
Update the session headers with the request ones while ignoring
certain name prefixes.
:type request_headers: dict
"""
for name, value in request_headers.items():
if name == 'User-Agent' and value.startswith('HTTPie/'):
continue
for prefix in SESSION_IGNORED_HEADER_PREFIXES:
if name.lower().startswith(prefix.lower()):
break
else:
self['headers'][name] = value
@property @property
def verbose_name(self): def headers(self):
return '%s %s %s' % (self.host.name, self.name, self.path) return self['headers']
@property @property
def cookies(self): def cookies(self):
@ -149,6 +123,9 @@ class Session(BaseConfigDict):
@cookies.setter @cookies.setter
def cookies(self, jar): def cookies(self, jar):
"""
:type jar: CookieJar
"""
# http://docs.python.org/2/library/cookielib.html#cookie-objects # http://docs.python.org/2/library/cookielib.html#cookie-objects
stored_attrs = ['value', 'path', 'secure', 'expires'] stored_attrs = ['value', 'path', 'secure', 'expires']
self['cookies'] = {} self['cookies'] = {}

View File

@ -1,3 +1,3 @@
tox tox
httpbin git+git://github.com/kennethreitz/httpbin.git@7c96875e87a448f08fb1981e85eb79e77d592d98
docutils docutils

View File

@ -12,7 +12,7 @@ if sys.argv[-1] == 'test':
requirements = [ requirements = [
'requests>=1.0.4', 'requests>=1.2.3',
'Pygments>=1.5' 'Pygments>=1.5'
] ]
if sys.version_info[:2] in ((2, 6), (3, 1)): if sys.version_info[:2] in ((2, 6), (3, 1)):

View File

@ -1170,6 +1170,8 @@ class ItemParsingTest(BaseTestCase):
# files # files
self.key_value_type('bar\\@baz@%s' % FILE_PATH_ARG) self.key_value_type('bar\\@baz@%s' % FILE_PATH_ARG)
]) ])
# `requests.structures.CaseInsensitiveDict` => `dict`
headers = dict(headers._store.values())
self.assertDictEqual(headers, { self.assertDictEqual(headers, {
'foo:bar': 'baz', 'foo:bar': 'baz',
'jack@jill': 'hill', 'jack@jill': 'hill',
@ -1199,6 +1201,8 @@ class ItemParsingTest(BaseTestCase):
self.key_value_type('test-file@%s' % FILE_PATH_ARG), self.key_value_type('test-file@%s' % FILE_PATH_ARG),
self.key_value_type('query==value'), self.key_value_type('query==value'),
]) ])
# `requests.structures.CaseInsensitiveDict` => `dict`
headers = dict(headers._store.values())
self.assertDictEqual(headers, { self.assertDictEqual(headers, {
'header': 'value', 'header': 'value',
'eh': '' 'eh': ''
@ -1370,6 +1374,26 @@ class SessionTest(BaseTestCase):
self.assertEqual(r.json['headers']['Cookie'], 'hello=world') self.assertEqual(r.json['headers']['Cookie'], 'hello=world')
self.assertIn('Basic ', r.json['headers']['Authorization']) self.assertIn('Basic ', r.json['headers']['Authorization'])
def test_session_ignored_header_prefixes(self):
r = http(
'--session=test',
'GET',
httpbin('/get'),
'Content-Type: text/plain',
'If-Unmodified-Since: Sat, 29 Oct 1994 19:43:31 GMT',
env=self.env
)
self.assertIn(OK, r)
r2 = http(
'--session=test',
'GET',
httpbin('/get')
)
self.assertIn(OK, r2)
self.assertNotIn('Content-Type', r2.json['headers'])
self.assertNotIn('If-Unmodified-Since', r2.json['headers'])
def test_session_update(self): def test_session_update(self):
# Get a response to a request from the original session. # Get a response to a request from the original session.
r1 = http( r1 = http(
@ -1444,6 +1468,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):
@ -1559,9 +1605,23 @@ class DownloadTest(BaseTestCase):
self.assertIn('Done', r.stderr) self.assertIn('Done', r.stderr)
self.assertEqual(body, r) self.assertEqual(body, r)
def test_download_with_Content_Length(self):
download = Download(output_file=open(os.devnull, 'w'))
download.start(Response(
url=httpbin('/'),
headers={'Content-Length': 10}
))
time.sleep(1.1)
download._chunk_downloaded(b'12345')
time.sleep(1.1)
download._chunk_downloaded(b'12345')
download.finish()
self.assertFalse(download.interrupted)
def test_download_no_Content_Length(self): def test_download_no_Content_Length(self):
download = Download(output_file=open(os.devnull, 'w')) download = Download(output_file=open(os.devnull, 'w'))
download.start(Response(url=httpbin('/'))) download.start(Response(url=httpbin('/')))
time.sleep(1.1)
download._chunk_downloaded(b'12345') download._chunk_downloaded(b'12345')
download.finish() download.finish()
self.assertFalse(download.interrupted) self.assertFalse(download.interrupted)