Compare commits

...

36 Commits
0.5.0 ... 0.7.0

Author SHA1 Message Date
9d2ac5d8ad 0.7.0 2013-09-24 20:07:48 +02:00
3e4e1c72a4 Merge branch 'master' of github.com:jkbr/httpie 2013-09-24 19:51:06 +02:00
29f6b6a2a9 Improved Content-Disposition parsing for --download mode
Closes #168.
2013-09-24 19:50:37 +02:00
26b2d408e7 Merge pull request #167 from matt-hickford/master
Fix plugins ImportError
2013-09-23 02:13:14 -07:00
b5f180a5ee Fix plugins ImportError described at https://github.com/jkbr/httpie/issues/166#issuecomment-24905910 2013-09-23 09:54:06 +01:00
354aaa94bd Improved .netrc example formatting. 2013-09-22 15:20:50 +02:00
2ad4059f92 Improved .netrc example formatting. 2013-09-22 15:19:59 +02:00
5a6b65ecc6 Added link to httpie-oauth. 2013-09-22 15:10:50 +02:00
2acb303552 Added support for auth plugins. 2013-09-21 23:46:15 +02:00
f7b703b4bf Added --ignore-stdin
Closes #150
2013-08-23 10:57:17 +02:00
00de49f4c3 Cleanup 2013-08-18 00:59:10 +02:00
67496162fa Improved --help output. 2013-08-10 11:56:19 +02:00
8378ad3624 Try to import argparse before adding it to reqs. 2013-08-01 09:07:33 +02:00
f87884dd8d README 2013-08-01 08:46:37 +02:00
b671ee35e7 Merge pull request #153 from lorin/patch-1
Augment cookie example in README for multiple cookies
2013-07-31 07:52:22 -07:00
69247066dc Augment cookie example in README for multiple cookies
This change updates the README to show how to pass multiple cookies.
2013-07-31 10:29:38 -04:00
383dba524a Print error when download is interrupted by server
Close #147
2013-07-07 17:00:03 +02:00
60f09776a5 httpless outputs also response headers by default 2013-06-03 12:28:04 +02:00
48719aa70e README 2013-06-03 12:22:34 +02:00
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
20 changed files with 808 additions and 403 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
.. ..
-------- --------
@ -418,7 +425,7 @@ To set custom headers you can use the ``Header:Value`` notation:
.. code-block:: bash .. code-block:: bash
$ http example.org User-Agent:Bacon/1.0 Cookie:valued-visitor=yes X-Foo:Bar Referer:http://httpie.org/ $ http example.org User-Agent:Bacon/1.0 'Cookie:valued-visitor=yes;foo=bar' X-Foo:Bar Referer:http://httpie.org/
.. code-block:: http .. code-block:: http
@ -426,7 +433,7 @@ To set custom headers you can use the ``Header:Value`` notation:
GET / HTTP/1.1 GET / HTTP/1.1
Accept: */* Accept: */*
Accept-Encoding: identity, deflate, compress, gzip Accept-Encoding: identity, deflate, compress, gzip
Cookie: valued-visitor=yes Cookie: valued-visitor=yes;foo=bar
Host: example.org Host: example.org
Referer: http://httpie.org/ Referer: http://httpie.org/
User-Agent: Bacon/1.0 User-Agent: Bacon/1.0
@ -451,8 +458,8 @@ Any of the default headers can be overwritten.
Authentication Authentication
============== ==============
The currently supported authentication schemes are Basic and Digest (more to The currently supported authentication schemes are Basic and Digest
come). There are two flags that control authentication: (see `auth plugins`_ for more). There are two flags that control authentication:
=================== ====================================================== =================== ======================================================
``--auth, -a`` Pass a ``username:password`` pair as ``--auth, -a`` Pass a ``username:password`` pair as
@ -494,19 +501,28 @@ With password prompt:
$ http -a username example.org $ http -a username example.org
Authorization information from your ``.netrc`` file is honored as well: Authorization information from your ``~/.netrc`` file is honored as well:
.. code-block:: bash .. code-block:: bash
$ cat .netrc $ cat ~/.netrc
machine httpbin.org machine httpbin.org
login httpie login httpie
password test password test
$ http httpbin.org/basic-auth/httpie/test $ http httpbin.org/basic-auth/httpie/test
HTTP/1.1 200 OK HTTP/1.1 200 OK
[...] [...]
------------
Auth Plugins
------------
* `httpie-ntlm <https://github.com/jkbr/httpie-ntlm>`_
* `httpie-oauth <https://github.com/jkbr/httpie-oauth>`_
======= =======
Proxies Proxies
======= =======
@ -702,7 +718,16 @@ On OS X, you can send the contents of the clipboard with ``pbpaste``:
Passing data through ``stdin`` cannot be combined with data fields specified Passing data through ``stdin`` cannot be combined with data fields specified
on the command line. on the command line:
.. code-block:: bash
$ echo 'data' | http POST example.org more=data # This is invalid
To prevent HTTPie from reading ``stdin`` data you can use the
``--ignore-stdin`` option.
------------------------- -------------------------
@ -744,6 +769,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:
@ -832,7 +858,7 @@ by adding the following to your ``~/.bash_profile``:
function httpless { function httpless {
# `httpless example.org' # `httpless example.org'
http --pretty=all "$@" | less -R; http --pretty=all --print=hb "$@" | less -R;
} }
@ -937,12 +963,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 +996,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`_.
@ -1026,14 +1073,18 @@ When using HTTPie from **shell scripts**, it can be handy to set the
``--check-status`` flag. It instructs HTTPie to exit with an error if the ``--check-status`` flag. It instructs HTTPie to exit with an error if the
HTTP status is one of ``3xx``, ``4xx``, or ``5xx``. The exit status will HTTP status is one of ``3xx``, ``4xx``, or ``5xx``. The exit status will
be ``3`` (unless ``--follow`` is set), ``4``, or ``5``, be ``3`` (unless ``--follow`` is set), ``4``, or ``5``,
respectively. Also, the ``--timeout`` option allows to overwrite the default respectively.
30s timeout:
The ``--ignore-stdin`` option prevents HTTPie from reading data from ``stdin``,
which is usually not desirable during non-interactive invocations.
Also, the ``--timeout`` option allows to overwrite the default 30s timeout:
.. code-block:: bash .. code-block:: bash
#!/bin/bash #!/bin/bash
if http --timeout=2.5 --check-status HEAD example.org/health &> /dev/null; then if http --check-status --ignore-stdin --timeout=2.5 HEAD example.org/health &> /dev/null; then
echo 'OK!' echo 'OK!'
else else
case $? in case $? in
@ -1162,7 +1213,18 @@ 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.8.0-dev`_
* `0.7.0`_ (2013-09-24)
* Added ``--ignore-stdin``.
* Added support for auth plugins.
* Improved ``Content-Disposition`` parsing for ``--download`` mode.
* `0.6.0`_ (2013-06-03)
* 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 +1334,9 @@ 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: https://github.com/jkbr/httpie/compare/0.6.0...0.7.0
.. _0.8.0-dev: https://github.com/jkbr/httpie/compare/0.7.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.7.0'
__licence__ = 'BSD' __licence__ = 'BSD'

View File

@ -3,86 +3,134 @@
NOTE: the CLI interface may change before reaching v1.0. NOTE: the CLI interface may change before reaching v1.0.
""" """
from argparse import FileType, OPTIONAL, ZERO_OR_MORE, SUPPRESS from textwrap import dedent, wrap
#noinspection PyCompatibility
from argparse import (RawDescriptionHelpFormatter, 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 .plugins.builtin import BuiltinAuthPlugin
from .plugins import plugin_manager
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, OUTPUT_OPTIONS_DEFAULT,
PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY, RegexValidator) PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY, SessionNameValidator)
def _(text): class HTTPieHelpFormatter(RawDescriptionHelpFormatter):
"""Normalize whitespace.""" """A nicer help formatter.
return ' '.join(text.strip().split())
Help for arguments can be indented and contain new lines.
It will be de-dented and arguments in the help
will be separated by a blank line for better readability.
"""
def __init__(self, max_help_position=6, *args, **kwargs):
# A smaller indent for args help.
kwargs['max_help_position'] = max_help_position
super(HTTPieHelpFormatter, self).__init__(*args, **kwargs)
def _split_lines(self, text, width):
text = dedent(text).strip() + '\n\n'
return text.splitlines()
parser = Parser( parser = Parser(
formatter_class=HTTPieHelpFormatter,
description='%s <http://httpie.org>' % __doc__.strip(), description='%s <http://httpie.org>' % __doc__.strip(),
epilog='For every --option there is a --no-option' epilog=dedent("""
' that reverts the option to its default value.\n\n' For every --OPTION there is also a --no-OPTION that reverts OPTION
'Suggestions and bug reports are greatly appreciated:\n' to its default value.
'https://github.com/jkbr/httpie/issues'
Suggestions and bug reports are greatly appreciated:
https://github.com/jkbr/httpie/issues
""")
) )
############################################################################### #######################################################################
# Positional arguments. # Positional arguments.
############################################################################### #######################################################################
positional = parser.add_argument_group( positional = parser.add_argument_group(
title='Positional arguments', title='Positional arguments',
description=_(''' description=dedent("""
These arguments come after any flags and in the These arguments come after any flags and in the order they are listed here.
order they are listed here. Only URL is required. Only URL is required.
''')
""")
) )
positional.add_argument( positional.add_argument(
'method', 'method',
metavar='METHOD', metavar='METHOD',
nargs=OPTIONAL, nargs=OPTIONAL,
default=None, default=None,
help=_(''' help="""
The HTTP method to be used for the request The HTTP method to be used for the request (GET, POST, PUT, DELETE, ...).
(GET, POST, PUT, DELETE, PATCH, ...).
If this argument is omitted, then HTTPie This argument can be omitted in which case HTTPie will use POST if there
will guess the HTTP method. If there is some is some data to be sent, otherwise GET:
data to be sent, then it will be POST, otherwise GET.
''') $ http example.org # => GET
$ http example.org hello=world # => POST
"""
) )
positional.add_argument( positional.add_argument(
'url', 'url',
metavar='URL', metavar='URL',
help=_(''' help="""
The protocol defaults to http:// if the The scheme defaults to 'http://' if the URL does not include one.
URL does not include one.
''') """
) )
positional.add_argument( positional.add_argument(
'items', 'items',
metavar='REQUEST ITEM', metavar='REQUEST ITEM',
nargs=ZERO_OR_MORE, nargs=ZERO_OR_MORE,
type=KeyValueArgType(*SEP_GROUP_ITEMS), type=KeyValueArgType(*SEP_GROUP_ITEMS),
help=_(''' help=r"""
A key-value pair whose type is defined by the Optional key-value pairs to be included in the request. The separator used
separator used. It can be an HTTP header (header:value), determines the type:
a data field to be used in the request body (field_name=value),
a raw JSON data field (field_name:=value), ':' HTTP headers:
a query parameter (name==value),
or a file field (field_name@/path/to/file). Referer:http://httpie.org Cookie:foo=bar User-Agent:bacon/1.0
You can use a backslash to escape a colliding
separator in the field name. '==' URL parameters to be appended to the request URI:
''')
search==httpie
'=' Data fields to be serialized into a JSON object (with --json, -j)
or form data (with --form, -f):
name=HTTPie language=Python description='CLI HTTP client'
'@' Form file fields (only with --form, -f):
cs@~/Documents/CV.pdf
':=' Non-string JSON data fields (only with --json, -j):
awesome:=true amount:=42 colors:='["red", "green", "blue"]'
You can use a backslash to escape a colliding separator in the field name:
field-name-with\:colon=value
"""
) )
############################################################################### #######################################################################
# Content type. # Content type.
############################################################################### #######################################################################
content_type = parser.add_argument_group( content_type = parser.add_argument_group(
title='Predefined content types', title='Predefined content types',
@ -92,29 +140,30 @@ content_type = parser.add_argument_group(
content_type.add_argument( content_type.add_argument(
'--json', '-j', '--json', '-j',
action='store_true', action='store_true',
help=_(''' help="""
(default) Data items from the command (default) Data items from the command line are serialized as a JSON object.
line are serialized as a JSON object. The Content-Type and Accept headers are set to application/json
The Content-Type and Accept headers (if not specified).
are set to application/json (if not specified).
''') """
) )
content_type.add_argument( content_type.add_argument(
'--form', '-f', '--form', '-f',
action='store_true', action='store_true',
help=_(''' help="""
Data items from the command line are serialized as form fields. Data items from the command line are serialized as form fields.
The Content-Type is set to application/x-www-form-urlencoded
(if not specified). The Content-Type is set to application/x-www-form-urlencoded (if not
The presence of any file fields results specified). The presence of any file fields results in a
in a multipart/form-data request. multipart/form-data request.
''')
"""
) )
############################################################################### #######################################################################
# Output processing # Output processing
############################################################################### #######################################################################
output_processing = parser.add_argument_group(title='Output processing') output_processing = parser.add_argument_group(title='Output processing')
@ -123,12 +172,12 @@ output_processing.add_argument(
dest='prettify', dest='prettify',
default=PRETTY_STDOUT_TTY_ONLY, default=PRETTY_STDOUT_TTY_ONLY,
choices=sorted(PRETTY_MAP.keys()), choices=sorted(PRETTY_MAP.keys()),
help=_(''' help="""
Controls output processing. The value can be "none" to not prettify Controls output processing. The value can be "none" to not prettify
the output (default for redirected output), "all" to apply both colors the output (default for redirected output), "all" to apply both colors
and formatting and formatting (default for terminal output), "colors", or "format".
(default for terminal output), "colors", or "format".
''') """
) )
output_processing.add_argument( output_processing.add_argument(
'--style', '-s', '--style', '-s',
@ -136,75 +185,97 @@ output_processing.add_argument(
metavar='STYLE', metavar='STYLE',
default=DEFAULT_STYLE, default=DEFAULT_STYLE,
choices=AVAILABLE_STYLES, choices=AVAILABLE_STYLES,
help=_(''' help="""
Output coloring style. One of %s. Defaults to "%s". Output coloring style (default is "{default}"). On of:
For this option to work properly, please make sure that the
$TERM environment variable is set to "xterm-256color" or similar {available}
(e.g., via `export TERM=xterm-256color' in your ~/.bashrc).
''') % (', '.join(sorted(AVAILABLE_STYLES)), DEFAULT_STYLE) For this option to work properly, please make sure that the $TERM
environment variable is set to "xterm-256color" or similar
(e.g., via `export TERM=xterm-256color' in your ~/.bashrc).
"""
.format(
default=DEFAULT_STYLE,
available='\n'.join(
'{0: >20}'.format(line.strip())
for line in
wrap(' '.join(sorted(AVAILABLE_STYLES)), 60)
),
)
) )
############################################################################### #######################################################################
# Output options # Output options
############################################################################### #######################################################################
output_options = parser.add_argument_group(title='Output options') output_options = parser.add_argument_group(title='Output options')
output_options.add_argument( output_options.add_argument(
'--print', '-p', '--print', '-p',
dest='output_options', dest='output_options',
metavar='WHAT', metavar='WHAT',
help=_(''' help="""
String specifying what the output should contain: String specifying what the output should contain:
"{request_headers}" stands for the request headers, and
"{request_body}" for the request body. '{req_head}' request headers
"{response_headers}" stands for the response headers and '{req_body}' request body
"{response_body}" for response the body. '{res_head}' response headers
The default behaviour is "hb" (i.e., the response '{res_body}' response body
headers and body is printed), if standard output is not redirected.
If the output is piped to another program or to a file, The default behaviour is '{default}' (i.e., the response headers and body
then only the body is printed by default. is printed), if standard output is not redirected. If the output is piped
'''.format(request_headers=OUT_REQ_HEAD, to another program or to a file, then only the response body is printed
request_body=OUT_REQ_BODY, by default.
response_headers=OUT_RESP_HEAD,
response_body=OUT_RESP_BODY,)) """
.format(
req_head=OUT_REQ_HEAD,
req_body=OUT_REQ_BODY,
res_head=OUT_RESP_HEAD,
res_body=OUT_RESP_BODY,
default=OUTPUT_OPTIONS_DEFAULT,
)
) )
output_options.add_argument( output_options.add_argument(
'--verbose', '-v', '--verbose', '-v',
dest='output_options', dest='output_options',
action='store_const', action='store_const',
const=''.join(OUTPUT_OPTIONS), const=''.join(OUTPUT_OPTIONS),
help=_(''' help="""
Print the whole request as well as the response. Print the whole request as well as the response. Shortcut for --print={0}.
Shortcut for --print={0}.
'''.format(''.join(OUTPUT_OPTIONS))) """
.format(''.join(OUTPUT_OPTIONS))
) )
output_options.add_argument( output_options.add_argument(
'--headers', '-h', '--headers', '-h',
dest='output_options', dest='output_options',
action='store_const', action='store_const',
const=OUT_RESP_HEAD, const=OUT_RESP_HEAD,
help=_(''' help="""
Print only the response headers. Print only the response headers. Shortcut for --print={0}.
Shortcut for --print={0}.
'''.format(OUT_RESP_HEAD)) """
.format(OUT_RESP_HEAD)
) )
output_options.add_argument( output_options.add_argument(
'--body', '-b', '--body', '-b',
dest='output_options', dest='output_options',
action='store_const', action='store_const',
const=OUT_RESP_BODY, const=OUT_RESP_BODY,
help=_(''' help="""
Print only the response body. Print only the response body. Shortcut for --print={0}.
Shortcut for --print={0}.
'''.format(OUT_RESP_BODY)) """
.format(OUT_RESP_BODY)
) )
output_options.add_argument( output_options.add_argument(
'--stream', '-S', '--stream', '-S',
action='store_true', action='store_true',
default=False, default=False,
help=_(''' help="""
Always stream the output by line, i.e., behave like `tail -f'. Always stream the output by line, i.e., behave like `tail -f'.
Without --stream and with --pretty (either set or implied), Without --stream and with --pretty (either set or implied),
@ -216,33 +287,31 @@ output_options.add_argument(
It is useful also without --pretty: It ensures that the output is flushed It is useful also without --pretty: It ensures that the output is flushed
more often and in smaller chunks. more often and in smaller chunks.
''') """
) )
output_processing.add_argument( output_options.add_argument(
'--output', '-o', '--output', '-o',
type=FileType('a+b'), type=FileType('a+b'),
dest='output_file', dest='output_file',
metavar='FILE', metavar='FILE',
help=_( help="""
''' Save output to FILE. If --download is set, then only the response body is
Save output to FILE. If --download is set, then only the response saved to the file. Other parts of the HTTP exchange are printed to stderr.
body is saved to the file. Other parts of the HTTP exchange are
printed to stderr. """
'''
)
) )
output_options.add_argument( output_options.add_argument(
'--download', '-d', '--download', '-d',
action='store_true', action='store_true',
default=False, default=False,
help=_(''' help="""
Do not print the response body to stdout. Rather, download it and store it Do not print the response body to stdout. Rather, download it and store it
in a file. The filename is guessed unless specified with --output in a file. The filename is guessed unless specified with --output
[filename]. This action is similar to the default behaviour of wget. [filename]. This action is similar to the default behaviour of wget.
''') """
) )
output_options.add_argument( output_options.add_argument(
@ -250,70 +319,102 @@ output_options.add_argument(
dest='download_resume', dest='download_resume',
action='store_true', action='store_true',
default=False, default=False,
help=_(''' help="""
Resume an interrupted download. Resume an interrupted download. Note that the --output option needs to be
The --output option needs to be specified as well. specified as well.
''')
"""
) )
###############################################################################
#######################################################################
# 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, help="""
'Session name contains invalid characters.' Create, or reuse and update a session. Within a session, custom headers,
), auth credential, as well as any cookies sent by the server persist between
help=_(''' requests.
Create, or reuse and update a session.
Within a session, custom headers, auth credential, as well as any Session files are stored in:
cookies sent by the server persist between requests.
Session files are stored in %s/<HOST>/<SESSION_NAME>.json. {session_dir}/<HOST>/<SESSION_NAME>.json.
''' % DEFAULT_SESSIONS_DIR)
"""
.format(session_dir=DEFAULT_SESSIONS_DIR)
) )
sessions.add_argument( sessions.add_argument(
'--session-read-only', '--session-read-only',
metavar='SESSION_NAME', metavar='SESSION_NAME_OR_PATH',
help=_(''' type=session_name_validator,
Create or read a session without updating it form the help="""
request/response exchange. Create or read a session without updating it form the request/response
''') exchange.
"""
) )
############################################################################### #######################################################################
# 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(
'--auth', '-a', '--auth', '-a',
metavar='USER[:PASS]', metavar='USER[:PASS]',
type=AuthCredentialsArgType(SEP_CREDENTIALS), type=AuthCredentialsArgType(SEP_CREDENTIALS),
help=_(''' help="""
If only the username is provided (-a username), If only the username is provided (-a username), HTTPie will prompt
HTTPie will prompt for the password. for the password.
'''),
""",
) )
_auth_plugins = plugin_manager.get_auth_plugins()
auth.add_argument( auth.add_argument(
'--auth-type', '--auth-type',
choices=['basic', 'digest'], choices=[plugin.auth_type for plugin in _auth_plugins],
default='basic', default=_auth_plugins[0].auth_type,
help=_(''' help="""
The authentication mechanism to be used. The authentication mechanism to be used. Defaults to "{default}".
Defaults to "basic".
''') {types}
"""
.format(default=_auth_plugins[0].auth_type, types='\n '.join(
'"{type}": {name}{package}{description}'.format(
type=plugin.auth_type,
name=plugin.name,
package=(
'' if issubclass(plugin, BuiltinAuthPlugin)
else ' (provided by %s)' % plugin.package_name
),
description=(
'' if not plugin.description else
'\n ' + ('\n '.join(wrap(plugin.description)))
)
)
for plugin in _auth_plugins
)),
) )
#######################################################################
# Network # Network
############################################# #######################################################################
network = parser.add_argument_group(title='Network') network = parser.add_argument_group(title='Network')
@ -323,31 +424,31 @@ network.add_argument(
action='append', action='append',
metavar='PROTOCOL:HOST', metavar='PROTOCOL:HOST',
type=KeyValueArgType(SEP_PROXY), type=KeyValueArgType(SEP_PROXY),
help=_(''' help="""
String mapping protocol to the URL of the proxy String mapping protocol to the URL of the proxy (e.g. http:foo.bar:3128).
(e.g. http:foo.bar:3128). You can specify multiple You can specify multiple proxies with different protocols.
proxies with different protocols.
''') """
) )
network.add_argument( network.add_argument(
'--follow', '--follow',
default=False, default=False,
action='store_true', action='store_true',
help=_(''' help="""
Set this flag if full redirects are allowed Set this flag if full redirects are allowed (e.g. re-POST-ing of data at
(e.g. re-POST-ing of data at new ``Location``) new Location).
''')
"""
) )
network.add_argument( network.add_argument(
'--verify', '--verify',
default='yes', default='yes',
help=_(''' help="""
Set to "no" to skip checking the host\'s SSL certificate. Set to "no" to skip checking the host's SSL certificate. You can also pass
You can also pass the path to a CA_BUNDLE the path to a CA_BUNDLE file for private certs. You can also set the
file for private certs. You can also set REQUESTS_CA_BUNDLE environment variable. Defaults to "yes".
the REQUESTS_CA_BUNDLE environment variable.
Defaults to "yes". """
''')
) )
network.add_argument( network.add_argument(
@ -355,63 +456,79 @@ network.add_argument(
type=float, type=float,
default=30, default=30,
metavar='SECONDS', metavar='SECONDS',
help=_(''' help="""
The connection timeout of the request in seconds. The connection timeout of the request in seconds. The default value is
The default value is 30 seconds. 30 seconds.
''')
"""
) )
network.add_argument( network.add_argument(
'--check-status', '--check-status',
default=False, default=False,
action='store_true', action='store_true',
help=_(''' help="""
By default, HTTPie exits with 0 when no network or other fatal By default, HTTPie exits with 0 when no network or other fatal errors
errors occur. occur. This flag instructs HTTPie to also check the HTTP status code and
exit with an error if the status indicates one.
This flag instructs HTTPie to also check the HTTP status code and When the server replies with a 4xx (Client Error) or 5xx (Server Error)
exit with an error if the status indicates one. status code, HTTPie exits with 4 or 5 respectively. If the response is a
3xx (Redirect) and --follow hasn't been set, then the exit status is 3.
Also an error message is written to stderr if stdout is redirected.
When the server replies with a 4xx (Client Error) or 5xx """
(Server Error) status code, HTTPie exits with 4 or 5 respectively.
If the response is a 3xx (Redirect) and --follow
hasn't been set, then the exit status is 3.
Also an error message is written to stderr if stdout is redirected.
''')
) )
############################################################################### #######################################################################
# Troubleshooting # Troubleshooting
############################################################################### #######################################################################
troubleshooting = parser.add_argument_group(title='Troubleshooting') troubleshooting = parser.add_argument_group(title='Troubleshooting')
troubleshooting.add_argument(
'--ignore-stdin',
action='store_true',
default=False,
help="""
Do not attempt to read stdin.
"""
)
troubleshooting.add_argument( troubleshooting.add_argument(
'--help', '--help',
action='help', action='help',
default=SUPPRESS, default=SUPPRESS,
help='Show this help message and exit' help="""
Show this help message and exit.
"""
) )
troubleshooting.add_argument( troubleshooting.add_argument(
'--version', '--version',
action='version', action='version',
version=__version__ version=__version__,
help="""
Show version and exit.
"""
) )
troubleshooting.add_argument( troubleshooting.add_argument(
'--traceback', '--traceback',
action='store_true', action='store_true',
default=False, default=False,
help='Prints exception traceback should one occur.' help="""
Prints exception traceback should one occur.
"""
) )
troubleshooting.add_argument( troubleshooting.add_argument(
'--debug', '--debug',
action='store_true', action='store_true',
default=False, default=False,
help=_(''' help="""
Prints exception traceback should one occur, and also other Prints exception traceback should one occur, and also other information
information that is useful for debugging HTTPie itself and that is useful for debugging HTTPie itself and for reporting bugs.
for bug reports.
''') """
) )

View File

@ -7,6 +7,7 @@ import requests.auth
from . import sessions from . import sessions
from . import __version__ from . import __version__
from .plugins import plugin_manager
FORM = 'application/x-www-form-urlencoded; charset=utf-8' FORM = 'application/x-www-form-urlencoded; charset=utf-8'
@ -14,6 +15,12 @@ JSON = 'application/json; charset=utf-8'
DEFAULT_UA = 'HTTPie/%s' % __version__ DEFAULT_UA = 'HTTPie/%s' % __version__
class HTTPie(object):
def __init__(self, env, plugin_manager):
pass
def get_response(args, config_dir): def get_response(args, config_dir):
"""Send the request and return a `request.Response`.""" """Send the request and return a `request.Response`."""
@ -27,9 +34,10 @@ def get_response(args, config_dir):
response = requests.request(**requests_kwargs) response = requests.request(**requests_kwargs)
else: else:
response = sessions.get_response( response = sessions.get_response(
args=args,
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),
) )
@ -69,10 +77,8 @@ def get_requests_kwargs(args):
credentials = None credentials = None
if args.auth: if args.auth:
credentials = { auth_plugin = plugin_manager.get_auth_plugin(args.auth_type)()
'basic': requests.auth.HTTPBasicAuth, credentials = auth_plugin.get_auth(args.auth.key, args.auth.value)
'digest': requests.auth.HTTPDigestAuth,
}[args.auth_type](args.auth.key, args.auth.value)
kwargs = { kwargs = {
'stream': True, 'stream': True,

View File

@ -12,7 +12,8 @@ from requests.compat import (
) )
try: try:
#noinspection PyUnresolvedReferences,PyCompatibility
from urllib.parse import urlsplit from urllib.parse import urlsplit
except ImportError: except ImportError:
#noinspection PyUnresolvedReferences,PyCompatibility
from urlparse import urlsplit from urlparse import urlsplit

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

@ -18,13 +18,13 @@ from httpie import __version__ as httpie_version
from requests import __version__ as requests_version from requests import __version__ as requests_version
from pygments import __version__ as pygments_version from pygments import __version__ as pygments_version
from .cli import parser
from .compat import str, is_py3 from .compat import str, is_py3
from .client import get_response from .client import get_response
from .downloads import Download from .downloads import Download
from .models import Environment from .models import Environment
from .output import build_output_stream, write, write_with_colors_win_py3 from .output import build_output_stream, write, write_with_colors_win_py3
from . import ExitStatus from . import ExitStatus
from .plugins import plugin_manager
def get_exit_status(http_status, follow=False): def get_exit_status(http_status, follow=False):
@ -58,6 +58,9 @@ def main(args=sys.argv[1:], env=Environment()):
Return exit status code. Return exit status code.
""" """
plugin_manager.load_installed_plugins()
from .cli import parser
if env.config.default_options: if env.config.default_options:
args = env.config.default_options + args args = env.config.default_options + args
@ -132,6 +135,10 @@ def main(args=sys.argv[1:], env=Environment()):
download.finish() download.finish()
if download.interrupted: if download.interrupted:
exit_status = ExitStatus.ERROR exit_status = ExitStatus.ERROR
error('Incomplete download: size=%d; downloaded=%d' % (
download.status.total_size,
download.status.downloaded
))
except IOError as e: except IOError as e:
if not traceback and e.errno == errno.EPIPE: if not traceback and e.errno == errno.EPIPE:

View File

@ -9,7 +9,8 @@ import re
import sys import sys
import mimetypes import mimetypes
import threading import threading
from time import time from time import sleep, time
from mailbox import Message
from .output import RawStream from .output import RawStream
from .models import HTTPResponse from .models import HTTPResponse
@ -104,11 +105,14 @@ def filename_from_content_disposition(content_disposition):
""" """
# attachment; filename=jkbr-httpie-0.4.1-20-g40bd8f6.tar.gz # attachment; filename=jkbr-httpie-0.4.1-20-g40bd8f6.tar.gz
match = re.search('filename=(\S+)', content_disposition)
if match and match.group(1): msg = Message('Content-Disposition: %s' % content_disposition)
fn = match.group(1).strip('."') filename = msg.get_filename()
if re.match('^[a-zA-Z0-9._-]+$', fn): if filename:
return fn # Basic sanitation.
filename = os.path.basename(filename).lstrip('.').strip()
if filename:
return filename
def filename_from_url(url, content_type): def filename_from_url(url, content_type):
@ -162,9 +166,9 @@ class Download(object):
self._resumed_from = 0 self._resumed_from = 0
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
) )
@ -197,7 +201,7 @@ class Download(object):
:return: RawStream, output_file :return: RawStream, output_file
""" """
assert not self._status.time_started assert not self.status.time_started
try: try:
total_size = int(response.headers['Content-Length']) total_size = int(response.headers['Content-Length'])
@ -232,7 +236,7 @@ class Download(object):
) )
self._output_file = open(get_unique_filename(fn), mode='a+b') self._output_file = open(get_unique_filename(fn), mode='a+b')
self._status.started( self.status.started(
resumed_from=self._resumed_from, resumed_from=self._resumed_from,
total_size=total_size total_size=total_size
) )
@ -241,7 +245,7 @@ class Download(object):
msg=HTTPResponse(response), msg=HTTPResponse(response),
with_headers=False, with_headers=False,
with_body=True, with_body=True,
on_body_chunk_downloaded=self._chunk_downloaded, on_body_chunk_downloaded=self.chunk_downloaded,
chunk_size=1024 * 8 chunk_size=1024 * 8
) )
@ -253,28 +257,27 @@ 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
def finish(self): def finish(self):
assert not self.finished assert not self.finished
self.finished = True self.finished = True
self._status.finished() self.status.finished()
def failed(self): def failed(self):
self.finish()
self._progress_reporter.stop() self._progress_reporter.stop()
@property @property
def interrupted(self): def interrupted(self):
return ( return (
self.finished self.finished
and self._status.total_size and self.status.total_size
and self._status.total_size != self._status.downloaded and self.status.total_size != self.status.downloaded
) )
def _chunk_downloaded(self, chunk): def chunk_downloaded(self, chunk):
""" """
A download progress callback. A download progress callback.
@ -283,7 +286,7 @@ class Download(object):
:type chunk: bytes :type chunk: bytes
""" """
self._status.chunk_downloaded(len(chunk)) self.status.chunk_downloaded(len(chunk))
class Status(object): class Status(object):
@ -317,7 +320,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 +333,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 +342,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 +386,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

@ -6,8 +6,9 @@ import sys
import re import re
import json import json
import mimetypes import mimetypes
import getpass from getpass import getpass
from io import BytesIO from io import BytesIO
#noinspection PyCompatibility
from argparse import ArgumentParser, ArgumentTypeError, ArgumentError from argparse import ArgumentParser, ArgumentTypeError, ArgumentError
try: try:
@ -20,6 +21,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'
@ -113,7 +115,7 @@ class Parser(ArgumentParser):
self._process_pretty_options() self._process_pretty_options()
self._guess_method() self._guess_method()
self._parse_items() self._parse_items()
if not env.stdin_isatty: if not self.args.ignore_stdin and not env.stdin_isatty:
self._body_from_file(self.env.stdin) self._body_from_file(self.env.stdin)
if not (self.args.url.startswith((HTTP, HTTPS))): if not (self.args.url.startswith((HTTP, HTTPS))):
# Default to 'https://' if invoked as `https args`. # Default to 'https://' if invoked as `https args`.
@ -182,6 +184,9 @@ class Parser(ArgumentParser):
if self.args.auth: if self.args.auth:
if not self.args.auth.has_password(): if not self.args.auth.has_password():
# Stdin already read (if not a tty) so it's save to prompt. # 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) self.args.auth.prompt_password(url.netloc)
elif url.username is not None: elif url.username is not None:
@ -239,7 +244,7 @@ class Parser(ArgumentParser):
if self.args.method is None: if self.args.method is None:
# Invoked as `http URL'. # Invoked as `http URL'.
assert not self.args.items assert not self.args.items
if not self.env.stdin_isatty: if not self.args.ignore_stdin and not self.env.stdin_isatty:
self.args.method = HTTP_POST self.args.method = HTTP_POST
else: else:
self.args.method = HTTP_GET self.args.method = HTTP_GET
@ -264,9 +269,12 @@ class Parser(ArgumentParser):
# Set the URL correctly # Set the URL correctly
self.args.url = self.args.method self.args.url = self.args.method
# Infer the method # Infer the method
has_data = not self.env.stdin_isatty or any( has_data = (
item.sep in SEP_GROUP_DATA_ITEMS (not self.args.ignore_stdin and
for item in self.args.items not self.env.stdin_isatty) or any(
item.sep in SEP_GROUP_DATA_ITEMS
for item in self.args.items
)
) )
self.args.method = HTTP_POST if has_data else HTTP_GET self.args.method = HTTP_POST if has_data else HTTP_GET
@ -373,24 +381,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
@ -425,8 +424,8 @@ class KeyValueArgType(object):
"""Tokenize `s`. There are only two token types - strings """Tokenize `s`. There are only two token types - strings
and escaped characters: and escaped characters:
>>> tokenize(r'foo\=bar\\baz') tokenize(r'foo\=bar\\baz')
['foo', Escaped('='), 'bar', Escaped('\\'), 'baz'] => ['foo', Escaped('='), 'bar', Escaped('\\'), 'baz']
""" """
tokens = [''] tokens = ['']

View File

@ -109,6 +109,7 @@ class HTTPResponse(HTTPMessage):
def iter_lines(self, chunk_size): def iter_lines(self, chunk_size):
return ((line, b'\n') for line in self._orig.iter_lines(chunk_size)) return ((line, b'\n') for line in self._orig.iter_lines(chunk_size))
#noinspection PyProtectedMember
@property @property
def headers(self): def headers(self):
original = self._orig.raw._original_response original = self._orig.raw._original_response

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

@ -0,0 +1,9 @@
from .base import AuthPlugin
from .manager import PluginManager
from .builtin import BasicAuthPlugin, DigestAuthPlugin
plugin_manager = PluginManager()
plugin_manager.register(BasicAuthPlugin)
plugin_manager.register(DigestAuthPlugin)

28
httpie/plugins/base.py Normal file
View File

@ -0,0 +1,28 @@
class AuthPlugin(object):
"""
Base auth plugin class.
See <https://github.com/jkbr/httpie-ntlm> for an example auth plugin.
"""
# The value that should be passed to --auth-type
# to use this auth plugin. Eg. "my-auth"
auth_type = None
# The name of the plugin, eg. "My auth".
name = None
# Optional short description. Will be be shown in the help
# under --auth-type.
description = None
# This be set automatically once the plugin has been loaded.
package_name = None
def get_auth(self, username, password):
"""
Return a ``requests.auth.AuthBase`` subclass instance.
"""
raise NotImplementedError()

26
httpie/plugins/builtin.py Normal file
View File

@ -0,0 +1,26 @@
import requests.auth
from .base import AuthPlugin
class BuiltinAuthPlugin(AuthPlugin):
package_name = '(builtin)'
class BasicAuthPlugin(BuiltinAuthPlugin):
name = 'Basic HTTP auth'
auth_type = 'basic'
def get_auth(self, username, password):
return requests.auth.HTTPBasicAuth(username, password)
class DigestAuthPlugin(BuiltinAuthPlugin):
name = 'Digest HTTP auth'
auth_type = 'digest'
def get_auth(self, username, password):
return requests.auth.HTTPDigestAuth(username, password)

35
httpie/plugins/manager.py Normal file
View File

@ -0,0 +1,35 @@
from pkg_resources import iter_entry_points
ENTRY_POINT_NAMES = [
'httpie.plugins.auth.v1'
]
class PluginManager(object):
def __init__(self):
self._plugins = []
def __iter__(self):
return iter(self._plugins)
def register(self, plugin):
self._plugins.append(plugin)
def get_auth_plugins(self):
return list(self._plugins)
def get_auth_plugin_mapping(self):
return dict((plugin.auth_type, plugin) for plugin in self)
def get_auth_plugin(self, auth_type):
return self.get_auth_plugin_mapping()[auth_type]
def load_installed_plugins(self):
for entry_point_name in ENTRY_POINT_NAMES:
for entry_point in iter_entry_points(entry_point_name):
plugin = entry_point.load()
plugin.package_name = entry_point.dist.key
self.register(entry_point.load())

View File

@ -3,52 +3,68 @@
""" """
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
from requests.auth import HTTPBasicAuth, HTTPDigestAuth
from .compat import urlsplit from .compat import urlsplit
from .config import BaseConfigDict, DEFAULT_CONFIG_DIR from .config import BaseConfigDict, DEFAULT_CONFIG_DIR
from httpie.plugins import plugin_manager
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, args,
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) if args.auth:
if auth: session.auth = {
session.auth = auth 'type': args.auth_type,
'username': args.auth.key,
'password': args.auth.value,
}
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 +75,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 +90,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 +127,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'] = {}
@ -163,15 +144,10 @@ class Session(BaseConfigDict):
auth = self.get('auth', None) auth = self.get('auth', None)
if not auth or not auth['type']: if not auth or not auth['type']:
return return
Auth = {'basic': HTTPBasicAuth, auth_plugin = plugin_manager.get_auth_plugin(auth['type'])()
'digest': HTTPDigestAuth}[auth['type']] return auth_plugin.get_auth(auth['username'], auth['password'])
return Auth(auth['username'], auth['password'])
@auth.setter @auth.setter
def auth(self, cred): def auth(self, auth):
self['auth'] = { assert set(['type', 'username', 'password']) == set(auth.keys())
'type': {HTTPBasicAuth: 'basic', self['auth'] = auth
HTTPDigestAuth: 'digest'}[type(cred)],
'username': cred.username,
'password': cred.password,
}

View File

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

View File

@ -1,6 +1,5 @@
import os import os
import sys import sys
import re
import codecs import codecs
from setuptools import setup from setuptools import setup
import httpie import httpie
@ -12,24 +11,23 @@ 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)): try:
# argparse has been added in Python 3.2 / 2.7 #noinspection PyUnresolvedReferences
import argparse
except ImportError:
requirements.append('argparse>=1.2.1') requirements.append('argparse>=1.2.1')
if 'win32' in str(sys.platform).lower(): if 'win32' in str(sys.platform).lower():
# Terminal colors for Windows # Terminal colors for Windows
requirements.append('colorama>=0.2.4') requirements.append('colorama>=0.2.4')
def long_description(): def long_description():
"""Pre-process the README so that PyPi can render it properly."""
with codecs.open('README.rst', encoding='utf8') as f: with codecs.open('README.rst', encoding='utf8') as f:
rst = f.read() return f.read()
code_block = '(:\n\n)?\.\. code-block::.*'
rst = re.sub(code_block, '::', rst)
return rst
setup( setup(
@ -42,7 +40,7 @@ setup(
author=httpie.__author__, author=httpie.__author__,
author_email='jakub@roztocil.name', author_email='jakub@roztocil.name',
license=httpie.__licence__, license=httpie.__licence__,
packages=['httpie'], packages=['httpie', 'httpie.plugins'],
entry_points={ entry_points={
'console_scripts': [ 'console_scripts': [
'http = httpie.__main__:main', 'http = httpie.__main__:main',

View File

@ -23,6 +23,7 @@ import subprocess
import os import os
import sys import sys
import json import json
#noinspection PyCompatibility
import argparse import argparse
import tempfile import tempfile
import unittest import unittest
@ -31,9 +32,11 @@ import time
from requests.structures import CaseInsensitiveDict from requests.structures import CaseInsensitiveDict
try: try:
#noinspection PyCompatibility
from urllib.request import urlopen from urllib.request import urlopen
except ImportError: except ImportError:
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
#noinspection PyCompatibility
from urllib2 import urlopen from urllib2 import urlopen
try: try:
from unittest import skipIf, skip from unittest import skipIf, skip
@ -110,7 +113,8 @@ with open(BIN_FILE_PATH, 'rb') as f:
def httpbin(path): def httpbin(path):
return HTTPBIN_URL + path url = HTTPBIN_URL + path
return url
def mk_config_dir(): def mk_config_dir():
@ -808,6 +812,7 @@ class RequestBodyFromFilePathTest(BaseTestCase):
# FIXME: *sometimes* fails on py33, the content-type is form. # FIXME: *sometimes* fails on py33, the content-type is form.
# https://github.com/jkbr/httpie/issues/140 # https://github.com/jkbr/httpie/issues/140
r = http( r = http(
'--verbose',
'POST', 'POST',
httpbin('/post'), httpbin('/post'),
'@' + FILE_PATH_ARG '@' + FILE_PATH_ARG
@ -1078,6 +1083,30 @@ class StreamTest(BaseTestCase):
self.assertIn(BIN_FILE_CONTENT, r) self.assertIn(BIN_FILE_CONTENT, r)
class IgnoreStdinTest(BaseTestCase):
def test_ignore_stdin(self):
with open(FILE_PATH) as f:
r = http(
'--ignore-stdin',
'--verbose',
httpbin('/get'),
env=TestEnvironment(stdin=f, stdin_isatty=False)
)
self.assertIn(OK, r)
self.assertIn('GET /get HTTP', r) # Don't default to POST.
self.assertNotIn(FILE_CONTENT, r) # Don't send stdin data.
def test_ignore_stdin_cannot_prompt_password(self):
r = http(
'--ignore-stdin',
'--auth=username-without-password',
httpbin('/get'),
)
self.assertEqual(r.exit_status, ExitStatus.ERROR)
self.assertIn('because --ignore-stdin', r.stderr)
class LineEndingsTest(BaseTestCase): class LineEndingsTest(BaseTestCase):
"""Test that CRLF is properly used in headers and """Test that CRLF is properly used in headers and
as the headers/body separator.""" as the headers/body separator."""
@ -1170,6 +1199,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 +1230,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': ''
@ -1226,6 +1259,7 @@ class ArgumentParserTestCase(unittest.TestCase):
self.parser.args.method = 'GET' self.parser.args.method = 'GET'
self.parser.args.url = 'http://example.com/' self.parser.args.url = 'http://example.com/'
self.parser.args.items = [] self.parser.args.items = []
self.parser.args.ignore_stdin = False
self.parser.env = TestEnvironment() self.parser.env = TestEnvironment()
@ -1241,6 +1275,7 @@ class ArgumentParserTestCase(unittest.TestCase):
self.parser.args.method = None self.parser.args.method = None
self.parser.args.url = 'http://example.com/' self.parser.args.url = 'http://example.com/'
self.parser.args.items = [] self.parser.args.items = []
self.parser.args.ignore_stdin = False
self.parser.env = TestEnvironment() self.parser.env = TestEnvironment()
self.parser._guess_method() self.parser._guess_method()
@ -1254,6 +1289,7 @@ class ArgumentParserTestCase(unittest.TestCase):
self.parser.args.method = 'http://example.com/' self.parser.args.method = 'http://example.com/'
self.parser.args.url = 'data=field' self.parser.args.url = 'data=field'
self.parser.args.items = [] self.parser.args.items = []
self.parser.args.ignore_stdin = False
self.parser.env = TestEnvironment() self.parser.env = TestEnvironment()
self.parser._guess_method() self.parser._guess_method()
@ -1269,6 +1305,7 @@ class ArgumentParserTestCase(unittest.TestCase):
self.parser.args.method = 'http://example.com/' self.parser.args.method = 'http://example.com/'
self.parser.args.url = 'test:header' self.parser.args.url = 'test:header'
self.parser.args.items = [] self.parser.args.items = []
self.parser.args.ignore_stdin = False
self.parser.env = TestEnvironment() self.parser.env = TestEnvironment()
@ -1289,6 +1326,7 @@ class ArgumentParserTestCase(unittest.TestCase):
input.KeyValue( input.KeyValue(
key='old_item', value='b', sep='=', orig='old_item=b') key='old_item', value='b', sep='=', orig='old_item=b')
] ]
self.parser.args.ignore_stdin = False
self.parser.env = TestEnvironment() self.parser.env = TestEnvironment()
@ -1370,6 +1408,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 +1502,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):
@ -1482,9 +1562,16 @@ class DownloadUtilsTest(BaseTestCase):
parse('attachment; filename=".hello-WORLD_123.txt"'), parse('attachment; filename=".hello-WORLD_123.txt"'),
'hello-WORLD_123.txt' 'hello-WORLD_123.txt'
) )
self.assertEqual(
parse('attachment; filename="white space.txt"'),
'white space.txt'
)
self.assertEqual(
parse(r'attachment; filename="\"quotes\".txt"'),
'"quotes".txt'
)
self.assertEqual(parse('attachment; filename=/etc/hosts'), 'hosts')
self.assertIsNone(parse('attachment; filename=')) self.assertIsNone(parse('attachment; filename='))
self.assertIsNone(parse('attachment; filename=/etc/hosts'))
self.assertIsNone(parse('attachment; filename=hello@world'))
def test_filename_from_url(self): def test_filename_from_url(self):
self.assertEqual(filename_from_url( self.assertEqual(filename_from_url(
@ -1559,22 +1646,34 @@ 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('/')))
download._chunk_downloaded(b'12345') time.sleep(1.1)
download.chunk_downloaded(b'12345')
download.finish() download.finish()
self.assertFalse(download.interrupted) self.assertFalse(download.interrupted)
def test_download_interrupted(self): def test_download_interrupted(self):
download = Download( download = Download(output_file=open(os.devnull, 'w'))
output_file=open(os.devnull, 'w')
)
download.start(Response( download.start(Response(
url=httpbin('/'), url=httpbin('/'),
headers={'Content-Length': 5} headers={'Content-Length': 5}
)) ))
download._chunk_downloaded(b'1234') download.chunk_downloaded(b'1234')
download.finish() download.finish()
self.assertTrue(download.interrupted) self.assertTrue(download.interrupted)