Compare commits

..

1 Commits
0.7.1 ... 0.4.0

Author SHA1 Message Date
d4f2daca56 v0.4.0 2013-02-22 13:52:05 +01:00
23 changed files with 608 additions and 1873 deletions

View File

@ -28,4 +28,3 @@ 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

@ -2,6 +2,13 @@
HTTPie: a CLI, cURL-like tool for humans HTTPie: a CLI, cURL-like tool for humans
**************************************** ****************************************
.. image:: https://raw.github.com/claudiatd/httpie-artwork/master/images/httpie_logo_simple.png
:alt: HTTPie logo
:width: 469
:height: 331
:align: center
v0.4.0-alpha (`stable version`_)
HTTPie is a **command line HTTP client**. Its goal is to make CLI interaction HTTPie is a **command line HTTP client**. Its goal is to make CLI interaction
with web services as **human-friendly** as possible. It provides a with web services as **human-friendly** as possible. It provides a
@ -17,13 +24,6 @@ for **testing, debugging**, and generally **interacting** with HTTP servers.
:align: center :align: center
------
.. image:: https://raw.github.com/claudiatd/httpie-artwork/master/images/httpie_logo_simple.png
:alt: HTTPie logo
:align: center
HTTPie is written in Python, and under the hood it uses the excellent HTTPie is written in Python, and under the hood it uses the excellent
`Requests`_ and `Pygments`_ libraries. `Requests`_ and `Pygments`_ libraries.
@ -37,6 +37,7 @@ HTTPie is written in Python, and under the hood it uses the excellent
:backlinks: none :backlinks: none
============= =============
Main Features Main Features
============= =============
@ -49,7 +50,6 @@ Main Features
* Arbitrary request data * Arbitrary request data
* Custom headers * Custom headers
* Persistent sessions * Persistent sessions
* Wget-like downloads
* Python 2.6, 2.7 and 3.x support * Python 2.6, 2.7 and 3.x support
* Linux, Mac OS X and Windows support * Linux, Mac OS X and Windows support
* Documentation * Documentation
@ -143,9 +143,7 @@ See the request that is being sent using one of the `output options`_:
$ http -v example.org $ http -v example.org
Use `Github API`_ to post a comment on an Use `Github API`_ to post a comment on an issue with `authentication`_:
`issue <https://github.com/jkbr/httpie/issues/83>`_
with `authentication`_:
.. code-block:: bash .. code-block:: bash
@ -165,13 +163,6 @@ Download a file and save it via `redirected output`_:
$ http example.org/file > file $ http example.org/file > file
Download a file ``wget`` style:
.. code-block:: bash
$ http --download example.org/file
Use named `sessions`_ to make certain aspects or the communication persistent Use named `sessions`_ to make certain aspects or the communication persistent
between requests to the same host: between requests to the same host:
@ -181,13 +172,6 @@ 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
.. ..
-------- --------
@ -266,8 +250,8 @@ their type is distinguished only by the separator used:
| | The ``==`` separator is used | | | The ``==`` separator is used |
+-----------------------+-----------------------------------------------------+ +-----------------------+-----------------------------------------------------+
| Data Fields | Request data fields to be serialized as a JSON | | Data Fields | Request data fields to be serialized as a JSON |
| ``field=value`` | object (default), or to be form encoded | | ``field=value`` | object (default), or to be form encoded (``--form`` |
| | (``--form, -f``). | | | / ``-f``). |
+-----------------------+-----------------------------------------------------+ +-----------------------+-----------------------------------------------------+
| Raw JSON fields | Useful when sending JSON and one or | | Raw JSON fields | Useful when sending JSON and one or |
| ``field:=json`` | more fields need to be a ``Boolean``, ``Number``, | | ``field:=json`` | more fields need to be a ``Boolean``, ``Number``, |
@ -275,7 +259,7 @@ their type is distinguished only by the separator used:
| | ``meals:='["ham","spam"]'`` or ``pies:=[1,2,3]`` | | | ``meals:='["ham","spam"]'`` or ``pies:=[1,2,3]`` |
| | (note the quotes). | | | (note the quotes). |
+-----------------------+-----------------------------------------------------+ +-----------------------+-----------------------------------------------------+
| Files | Only available with ``--form, -f``. | | Files | Only available with ``-f`` / ``--form``. |
| ``field@/dir/file`` | For example ``screenshot@~/Pictures/img.png``. | | ``field@/dir/file`` | For example ``screenshot@~/Pictures/img.png``. |
| | The presence of a file field results | | | The presence of a file field results |
| | in a ``multipart/form-data`` request. | | | in a ``multipart/form-data`` request. |
@ -306,7 +290,7 @@ both of which can be overwritten:
``Accept`` ``application/json`` ``Accept`` ``application/json``
================ ======================================= ================ =======================================
You can use ``--json, -j`` to explicitly set ``Accept`` You can use ``--json`` / ``-j`` to explicitly set ``Accept``
to ``application/json`` regardless of whether you are sending data to ``application/json`` regardless of whether you are sending data
(it's a shortcut for setting the header via the usual header notation (it's a shortcut for setting the header via the usual header notation
``http url Accept:application/json``). ``http url Accept:application/json``).
@ -324,6 +308,7 @@ Simple example:
Accept-Encoding: identity, deflate, compress, gzip Accept-Encoding: identity, deflate, compress, gzip
Content-Type: application/json; charset=utf-8 Content-Type: application/json; charset=utf-8
Host: example.org Host: example.org
User-Agent: HTTPie/0.2.7dev
{ {
"name": "John", "name": "John",
@ -345,6 +330,7 @@ into the resulting object:
Accept: application/json Accept: application/json
Content-Type: application/json; charset=utf-8 Content-Type: application/json; charset=utf-8
Host: api.example.com Host: api.example.com
User-Agent: HTTPie/0.2.7dev
{ {
"age": 29, "age": 29,
@ -369,7 +355,7 @@ Forms
===== =====
Submitting forms is very similar to sending `JSON`_ requests. Often the only Submitting forms is very similar to sending `JSON`_ requests. Often the only
difference is in adding the ``--form, -f`` option, which ensures that difference is in adding the ``--form`` / ``-f`` option, which ensures that
data fields are serialized as, and ``Content-Type`` is set to, data fields are serialized as, and ``Content-Type`` is set to,
``application/x-www-form-urlencoded; charset=utf-8``. ``application/x-www-form-urlencoded; charset=utf-8``.
@ -389,6 +375,7 @@ Regular Forms
.. code-block:: http .. code-block:: http
POST /person/1 HTTP/1.1 POST /person/1 HTTP/1.1
User-Agent: HTTPie/0.2.7dev
Content-Type: application/x-www-form-urlencoded; charset=utf-8 Content-Type: application/x-www-form-urlencoded; charset=utf-8
name=John+Smith&email=john%40example.org name=John+Smith&email=john%40example.org
@ -425,7 +412,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;foo=bar' X-Foo:Bar Referer:http://httpie.org/ $ http example.org User-Agent:Bacon/1.0 Cookie:valued-visitor=yes X-Foo:Bar Referer:http://httpie.org/
.. code-block:: http .. code-block:: http
@ -433,7 +420,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;foo=bar Cookie: valued-visitor=yes
Host: example.org Host: example.org
Referer: http://httpie.org/ Referer: http://httpie.org/
User-Agent: Bacon/1.0 User-Agent: Bacon/1.0
@ -458,8 +445,8 @@ Any of the default headers can be overwritten.
Authentication Authentication
============== ==============
The currently supported authentication schemes are Basic and Digest The currently supported authentication schemes are Basic and Digest (more to
(see `auth plugins`_ for more). There are two flags that control authentication: come). There are two flags that control authentication:
=================== ====================================================== =================== ======================================================
``--auth, -a`` Pass a ``username:password`` pair as ``--auth, -a`` Pass a ``username:password`` pair as
@ -476,7 +463,7 @@ The currently supported authentication schemes are Basic and Digest
``basic`` so it can often be omitted. ``basic`` so it can often be omitted.
=================== ====================================================== =================== ======================================================
Authorization information from ``.netrc`` is honored as well.
Basic auth: Basic auth:
@ -501,38 +488,15 @@ With password prompt:
$ http -a username example.org $ http -a username example.org
Authorization information from your ``~/.netrc`` file is honored as well:
.. code-block:: bash
$ cat ~/.netrc
machine httpbin.org
login httpie
password test
$ http httpbin.org/basic-auth/httpie/test
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
======= =======
You can specify proxies to be used through the ``--proxy`` argument for each You can specify proxies to be used through the ``--proxy`` argument:
protocol (which is included in the value in case of redirects across protocols):
.. code-block:: bash .. code-block:: bash
$ http --proxy=http:10.10.1.10:3128 --proxy=https:10.10.1.10:1080 example.org $ http --proxy=http:10.10.1.10:3128 --https:10.10.1.10:1080 example.org
With Basic authentication: With Basic authentication:
@ -611,7 +575,7 @@ documentation examples:
} }
All the other options are just a shortcut for ``--print, -p``. All the other options are just a shortcut for ``--print`` / ``-p``.
It accepts a string of characters each of which represents a specific part of It accepts a string of characters each of which represents a specific part of
the HTTP exchange: the HTTP exchange:
@ -718,16 +682,7 @@ 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.
------------------------- -------------------------
@ -769,7 +724,6 @@ 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:
@ -858,70 +812,10 @@ by adding the following to your ``~/.bash_profile``:
function httpless { function httpless {
# `httpless example.org' # `httpless example.org'
http --pretty=all --print=hb "$@" | less -R; http --pretty=all "$@" | less -R;
} }
=============
Download Mode
=============
HTTPie features a download mode in which it acts similarly to ``wget``.
When enabled using the ``--download, -d`` flag, response headers are printed to
the terminal (``stderr``), and a progress bar is shown while the response body
is being saved to a file.
.. code-block:: bash
$ http --download https://github.com/jkbr/httpie/tarball/master
.. code-block:: http
HTTP/1.1 200 OK
Connection: keep-alive
Content-Disposition: attachment; filename=jkbr-httpie-0.4.1-33-gfc4f70a.tar.gz
Content-Length: 505530
Content-Type: application/x-gzip
Server: GitHub.com
Vary: Accept-Encoding
Downloading 494.89 kB to "jkbr-httpie-0.4.1-33-gfc4f70a.tar.gz"
/ 21.01% 104.00 kB 47.55 kB/s 0:00:08 ETA
If not provided via ``--output, -o``, the output filename will be determined
from ``Content-Disposition`` (if available), or from the URL and
``Content-Type``. If the guessed filename already exists, HTTPie adds a unique
suffix to it.
You can also redirect the response body to another program while the response
headers and progress are still shown in the terminal:
.. code-block:: bash
$ http -d https://github.com/jkbr/httpie/tarball/master | tar zxf -
If ``--output, -o`` is specified, you can resume a partial download using the
``--continue, -c`` option. This only works with servers that support
``Range`` requests and ``206 Partial Content`` responses. If the server doesn't
support that, the whole file will simply be downloaded:
.. code-block:: bash
$ http -dco file.zip example.org/file
Other notes:
* The ``--download`` option only changes how the response body is treated.
* You can still set custom headers, use sessions, ``--verbose, -v``, etc.
* ``--download`` always implies ``--follow`` (redirects are followed).
* HTTPie exits with status code ``1`` (error) if the body hasn't been fully
downloaded.
* ``Accept-Encoding`` cannot be set with ``--download``.
================== ==================
Streamed Responses Streamed Responses
================== ==================
@ -959,21 +853,18 @@ Streamed output by small chunks alá ``tail -f``:
$ http --stream -f -a YOUR-TWITTER-NAME https://stream.twitter.com/1/statuses/filter.json track=Apple \ $ http --stream -f -a YOUR-TWITTER-NAME https://stream.twitter.com/1/statuses/filter.json track=Apple \
| while read tweet; do echo "$tweet" | http POST example.org/tweets ; done | while read tweet; do echo "$tweet" | http POST example.org/tweets ; done
======== ========
Sessions Sessions
======== ========
By default, every request is completely independent of any previous ones. By default, every request is completely independent of the previous ones.
HTTPie also supports persistent sessions, where custom headers (except for the HTTPie also supports persistent sessions, where custom headers, authorization,
ones starting with ``Content-`` or ``If-``), authorization, and cookies and cookies (manually specified or sent by the server) persist between
(manually specified or sent by the server) persist between requests requests to the same host.
to the same host.
-------------- Create a new session named ``user1``:
Named Sessions
--------------
Create a new session named ``user1`` for ``example.org``:
.. code-block:: bash .. code-block:: bash
@ -996,30 +887,18 @@ 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.
Named sessions' data is stored in JSON files in the directory Session data are 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.
Note that session files can also be created and edited manually in a text Session files can also be created or edited with a text editor.
editor; they are plain JSON.
.. code-block:: bash
$ httpie session edit example.org user1
See also `Config`_. See also `Config`_.
@ -1073,18 +952,14 @@ 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. respectively. Also, the ``--timeout`` option allows to overwrite the default
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 --check-status --ignore-stdin --timeout=2.5 HEAD example.org/health &> /dev/null; then if http --timeout=2.5 --check-status HEAD example.org/health &> /dev/null; then
echo 'OK!' echo 'OK!'
else else
case $? in case $? in
@ -1213,26 +1088,8 @@ Changelog
*You can click a version name to see a diff with the previous one.* *You can click a version name to see a diff with the previous one.*
* `0.8.0-dev`_ * `0.5.0-alpha`_
* `0.7.1`_ (2013-09-24) * `0.4.0`_
* Added ``--ignore-stdin``.
* Added support for auth plugins.
* Improved ``--help`` output.
* Improved ``Content-Disposition`` parsing for ``--download`` mode.
* Update to Requests 2.0.0
* `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)
* Added a `download mode`_ via ``--download``.
* Bugfixes.
* `0.4.1`_ (2013-02-26)
* Fixed ``setup.py``.
* `0.4.0`_ (2013-02-22)
* Python 3.3 compatibility. * Python 3.3 compatibility.
* Requests >= v1.0.4 compatibility. * Requests >= v1.0.4 compatibility.
* Added support for credentials in URL. * Added support for credentials in URL.
@ -1254,7 +1111,7 @@ Changelog
``--ugly`` has bee removed in favor of ``--pretty=none``. ``--ugly`` has bee removed in favor of ``--pretty=none``.
* `0.2.7`_ (2012-08-07) * `0.2.7`_ (2012-08-07)
* Compatibility with Requests 0.13.6. * Compatibility with Requests 0.13.6.
* Streamed terminal output. ``--stream, -S`` can be used to enable * Streamed terminal output. ``--stream`` / ``-S`` can be used to enable
streaming also with ``--pretty`` and to ensure a more frequent output streaming also with ``--pretty`` and to ensure a more frequent output
flushing. flushing.
* Support for efficient large file downloads. * Support for efficient large file downloads.
@ -1334,11 +1191,8 @@ Changelog
.. _0.2.7: https://github.com/jkbr/httpie/compare/0.2.5...0.2.7 .. _0.2.7: https://github.com/jkbr/httpie/compare/0.2.5...0.2.7
.. _0.3.0: https://github.com/jkbr/httpie/compare/0.2.7...0.3.0 .. _0.3.0: https://github.com/jkbr/httpie/compare/0.2.7...0.3.0
.. _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.5.0-alpha: https://github.com/jkbr/httpie/compare/0.4.0...master
.. _0.5.0: https://github.com/jkbr/httpie/compare/0.4.1...0.5.0 .. _stable version: https://github.com/jkbr/httpie/tree/0.3.0#readme
.. _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.1: https://github.com/jkbr/httpie/compare/0.6.0...0.7.1
.. _0.8.0-dev: https://github.com/jkbr/httpie/compare/0.7.1...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.7.1' __version__ = '0.4.0'
__licence__ = 'BSD' __licence__ = 'BSD'

View File

@ -1,136 +1,88 @@
"""CLI arguments definition. """CLI arguments definition.
NOTE: the CLI interface may change before reaching v1.0. NOTE: the CLI interface may change before reaching v1.0.
TODO: make the options config friendly, i.e., no mutually exclusive groups to
allow options overwriting.
""" """
from textwrap import dedent, wrap from argparse import FileType, OPTIONAL, ZERO_OR_MORE, SUPPRESS
#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 .plugins.builtin import BuiltinAuthPlugin from .compat import is_windows
from .plugins import plugin_manager 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, OUTPUT_OPTIONS_DEFAULT, OUT_RESP_BODY, OUTPUT_OPTIONS,
PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY, SessionNameValidator) PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY, RegexValidator)
class HTTPieHelpFormatter(RawDescriptionHelpFormatter): def _(text):
"""A nicer help formatter. """Normalize whitespace."""
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=dedent(""" epilog='For every --option there is a --no-option'
For every --OPTION there is also a --no-OPTION that reverts OPTION ' that reverts the option to its default value.\n\n'
to its default value. 'Suggestions and bug reports are greatly appreciated:\n'
'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=dedent(""" description=_('''
These arguments come after any flags and in the order they are listed here. These arguments come after any flags and in the
Only URL is required. order they are listed here. 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 (GET, POST, PUT, DELETE, ...). The HTTP method to be used for the request
(GET, POST, PUT, DELETE, PATCH, ...).
This argument can be omitted in which case HTTPie will use POST if there If this argument is omitted, then HTTPie
is some data to be sent, otherwise GET: will guess the HTTP method. If there is some
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=r""" help=_('''
Optional key-value pairs to be included in the request. The separator used A key-value pair whose type is defined by the
determines the type: separator used. It can be an HTTP header (header:value),
a data field to be used in the request body (field_name=value),
':' HTTP headers: a raw JSON data field (field_name:=value),
a query parameter (name==value),
Referer:http://httpie.org Cookie:foo=bar User-Agent:bacon/1.0 or a file field (field_name@/path/to/file).
You can use a backslash to escape a colliding
'==' URL parameters to be appended to the request URI: separator in the field name.
''')
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',
@ -138,144 +90,117 @@ 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')
output_processing.add_argument( output_processing.add_argument(
'--pretty', '--output', '-o', type=FileType('w+b'),
dest='prettify', metavar='FILE',
default=PRETTY_STDOUT_TTY_ONLY, help=SUPPRESS if not is_windows else _(
choices=sorted(PRETTY_MAP.keys()), '''
help=""" Save output to FILE.
Controls output processing. The value can be "none" to not prettify This option is a replacement for piping output to FILE,
the output (default for redirected output), "all" to apply both colors which would on Windows result in corrupted data
and formatting (default for terminal output), "colors", or "format". being saved.
""" '''
)
output_processing.add_argument(
'--style', '-s',
dest='style',
metavar='STYLE',
default=DEFAULT_STYLE,
choices=AVAILABLE_STYLES,
help="""
Output coloring style (default is "{default}"). On of:
{available}
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_processing.add_argument(
'--pretty', dest='prettify', default=PRETTY_STDOUT_TTY_ONLY,
choices=sorted(PRETTY_MAP.keys()),
help=_('''
Controls output processing. The value can be "none" to not prettify
the output (default for redirected output), "all" to apply both colors
and formatting
(default for terminal output), "colors", or "format".
''')
)
output_processing.add_argument(
'--style', '-s', dest='style', default=DEFAULT_STYLE, metavar='STYLE',
choices=AVAILABLE_STYLES,
help=_('''
Output coloring style. One of %s. Defaults to "%s".
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).
''') % (', '.join(sorted(AVAILABLE_STYLES)), DEFAULT_STYLE)
)
####################################################################### ###############################################################################
# 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', metavar='WHAT',
dest='output_options', help=_('''
metavar='WHAT', String specifying what the output should contain:
help=""" "{request_headers}" stands for the request headers, and
String specifying what the output should contain: "{request_body}" for the request body.
"{response_headers}" stands for the response headers and
'{req_head}' request headers "{response_body}" for response the body.
'{req_body}' request body The default behaviour is "hb" (i.e., the response
'{res_head}' response headers headers and body is printed), if standard output is not redirected.
'{res_body}' response body If the output is piped to another program or to a file,
then only the body is printed by default.
The default behaviour is '{default}' (i.e., the response headers and body '''.format(request_headers=OUT_REQ_HEAD,
is printed), if standard output is not redirected. If the output is piped request_body=OUT_REQ_BODY,
to another program or to a file, then only the response body is printed response_headers=OUT_RESP_HEAD,
by default. 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', const=''.join(OUTPUT_OPTIONS),
action='store_const', help=_('''
const=''.join(OUTPUT_OPTIONS), Print the whole request as well as the response.
help=""" Shortcut for --print={0}.
Print the whole request as well as the response. 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', const=OUT_RESP_HEAD,
action='store_const', help=_('''
const=OUT_RESP_HEAD, Print only the response headers.
help=""" Shortcut for --print={0}.
Print only the response headers. 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', const=OUT_RESP_BODY,
action='store_const', help=_('''
const=OUT_RESP_BODY, Print only the response body.
help=""" Shortcut for --print={0}.
Print only the response body. 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', default=False,
action='store_true', help=_('''
default=False,
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),
@ -287,248 +212,141 @@ 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_options.add_argument(
'--output', '-o',
type=FileType('a+b'),
dest='output_file',
metavar='FILE',
help="""
Save output to FILE. If --download is set, then only the response body is
saved to the file. Other parts of the HTTP exchange are printed to stderr.
"""
)
output_options.add_argument(
'--download', '-d',
action='store_true',
default=False,
help="""
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
[filename]. This action is similar to the default behaviour of wget.
"""
)
output_options.add_argument(
'--continue', '-c',
dest='download_resume',
action='store_true',
default=False,
help="""
Resume an interrupted download. Note that the --output option needs to be
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', type=RegexValidator(
metavar='SESSION_NAME_OR_PATH', Session.VALID_NAME_PATTERN,
type=session_name_validator, 'Session name contains invalid characters.'
help=""" ),
Create, or reuse and update a session. Within a session, custom headers, help=_('''
auth credential, as well as any cookies sent by the server persist between Create, or reuse and update a session.
requests. Within a session, custom headers, auth credential, as well as any
cookies sent by the server persist between requests.
Session files are stored in: Session files are stored in %s/<HOST>/<SESSION_NAME>.json.
''' % DEFAULT_SESSIONS_DIR)
{session_dir}/<HOST>/<SESSION_NAME>.json.
"""
.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), HTTPie will prompt If only the username is provided (-a username),
for the password. HTTPie will prompt for the password.
'''),
""",
) )
_auth_plugins = plugin_manager.get_auth_plugins()
auth.add_argument( auth.add_argument(
'--auth-type', '--auth-type', choices=['basic', 'digest'], default='basic',
choices=[plugin.auth_type for plugin in _auth_plugins], help=_('''
default=_auth_plugins[0].auth_type, The authentication mechanism to be used.
help=""" Defaults to "basic".
The authentication mechanism to be used. Defaults to "{default}". ''')
{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')
network.add_argument( network.add_argument(
'--proxy', '--proxy', default=[], action='append', metavar='PROTOCOL:HOST',
default=[],
action='append',
metavar='PROTOCOL:HOST',
type=KeyValueArgType(SEP_PROXY), type=KeyValueArgType(SEP_PROXY),
help=""" help=_('''
String mapping protocol to the URL of the proxy (e.g. http:foo.bar:3128). String mapping protocol to the URL of the proxy
You can specify multiple proxies with different protocols. (e.g. http:foo.bar:3128). You can specify multiple
proxies with different protocols.
""" ''')
) )
network.add_argument( network.add_argument(
'--follow', '--follow', default=False, action='store_true',
default=False, help=_('''
action='store_true', Set this flag if full redirects are allowed
help=""" (e.g. re-POST-ing of data at new ``Location``)
Set this flag if full redirects are allowed (e.g. re-POST-ing of data at ''')
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(
'--timeout', '--timeout', type=float, default=30, metavar='SECONDS',
type=float, help=_('''
default=30, The connection timeout of the request in seconds.
metavar='SECONDS', The default value is 30 seconds.
help=""" ''')
The connection timeout of the request in seconds. The default value is
30 seconds.
"""
) )
network.add_argument( network.add_argument(
'--check-status', '--check-status', default=False, action='store_true',
default=False, help=_('''
action='store_true', By default, HTTPie exits with 0 when no network or other fatal
help=""" errors occur.
By default, HTTPie exits with 0 when no network or other fatal errors
occur. This flag instructs HTTPie to also check the HTTP status code and
exit with an error if the status indicates one.
When the server replies with a 4xx (Client Error) or 5xx (Server Error) This flag instructs HTTPie to also check the HTTP status code and
status code, HTTPie exits with 4 or 5 respectively. If the response is a exit with an error if the status indicates one.
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', version=__version__)
action='version', troubleshooting.add_argument(
version=__version__, '--traceback', action='store_true', default=False,
help=""" help='Prints exception traceback should one occur.'
Show version and exit.
"""
) )
troubleshooting.add_argument( troubleshooting.add_argument(
'--traceback', '--debug', action='store_true', default=False,
action='store_true', help=_('''
default=False, Prints exception traceback should one occur, and also other
help=""" information that is useful for debugging HTTPie itself and
Prints exception traceback should one occur. for bug reports.
''')
"""
)
troubleshooting.add_argument(
'--debug',
action='store_true',
default=False,
help="""
Prints exception traceback should one occur, and also other information
that is useful for debugging HTTPie itself and for reporting bugs.
"""
) )

View File

@ -3,10 +3,10 @@ import sys
from pprint import pformat from pprint import pformat
import requests import requests
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'
@ -27,10 +27,9 @@ 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,
session_name=args.session or args.session_read_only, name=args.session or args.session_read_only,
requests_kwargs=requests_kwargs, request_kwargs=requests_kwargs,
read_only=bool(args.session_read_only), read_only=bool(args.session_read_only),
) )
@ -45,10 +44,9 @@ def get_requests_kwargs(args):
} }
auto_json = args.data and not args.form auto_json = args.data and not args.form
# FIXME: Accept is set to JSON with `http url @./file.txt`.
if args.json or auto_json: if args.json or auto_json:
implicit_headers['Accept'] = 'application/json' implicit_headers['Accept'] = 'application/json'
if args.json or (auto_json and args.data): if args.data:
implicit_headers['Content-Type'] = JSON implicit_headers['Content-Type'] = JSON
if isinstance(args.data, dict): if isinstance(args.data, dict):
@ -70,8 +68,10 @@ def get_requests_kwargs(args):
credentials = None credentials = None
if args.auth: if args.auth:
auth_plugin = plugin_manager.get_auth_plugin(args.auth_type)() credentials = {
credentials = auth_plugin.get_auth(args.auth.key, args.auth.value) 'basic': requests.auth.HTTPBasicAuth,
'digest': requests.auth.HTTPDigestAuth,
}[args.auth_type](args.auth.key, args.auth.value)
kwargs = { kwargs = {
'stream': True, 'stream': True,

View File

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

View File

@ -18,13 +18,12 @@ 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 .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_p3k
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,16 +57,13 @@ 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
def error(msg, *args, **kwargs): def error(msg, *args, **kwargs):
msg = msg % args msg = msg % args
level = kwargs.get('level', 'error') level = kwargs.get('level', 'error')
env.stderr.write('\nhttp: %s: %s\n' % (level, msg)) env.stderr.write('http: %s: %s\n' % (level, msg))
debug = '--debug' in args debug = '--debug' in args
traceback = debug or '--traceback' in args traceback = debug or '--traceback' in args
@ -78,28 +74,13 @@ def main(args=sys.argv[1:], env=Environment()):
if args == ['--debug']: if args == ['--debug']:
return exit_status return exit_status
download = None
try: try:
args = parser.parse_args(args=args, env=env) args = parser.parse_args(args=args, env=env)
if args.download:
args.follow = True # --download implies --follow.
download = Download(
output_file=args.output_file,
progress_file=env.stderr,
resume=args.download_resume
)
download.pre_request(args.headers)
response = get_response(args, config_dir=env.config.directory) response = get_response(args, config_dir=env.config.directory)
if args.check_status or download: if args.check_status:
exit_status = get_exit_status(response.status_code, args.follow)
exit_status = get_exit_status(
http_status=response.status_code,
follow=args.follow
)
if not env.stdout_isatty and exit_status != ExitStatus.OK: if not env.stdout_isatty and exit_status != ExitStatus.OK:
error('HTTP %s %s', error('HTTP %s %s',
@ -108,44 +89,25 @@ def main(args=sys.argv[1:], env=Environment()):
level='warning') level='warning')
write_kwargs = { write_kwargs = {
'stream': build_output_stream( 'stream': build_output_stream(args, env,
args, env, response.request, response), response.request,
response),
# This will in fact be `stderr` with `--download`
'outfile': env.stdout, 'outfile': env.stdout,
'flush': env.stdout_isatty or args.stream 'flush': env.stdout_isatty or args.stream
} }
try: try:
if env.is_windows and is_py3 and 'colors' in args.prettify: if env.is_windows and is_py3 and 'colors' in args.prettify:
write_with_colors_win_py3(**write_kwargs) write_with_colors_win_p3k(**write_kwargs)
else: else:
write(**write_kwargs) write(**write_kwargs)
if download and exit_status == ExitStatus.OK:
# Response body download.
download_stream, download_to = download.start(response)
write(
stream=download_stream,
outfile=download_to,
flush=False,
)
download.finish()
if download.interrupted:
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:
# Ignore broken pipes unless --traceback. # Ignore broken pipes unless --traceback.
env.stderr.write('\n') env.stderr.write('\n')
else: else:
raise raise
except (KeyboardInterrupt, SystemExit): except (KeyboardInterrupt, SystemExit):
if traceback: if traceback:
raise raise
@ -164,8 +126,4 @@ def main(args=sys.argv[1:], env=Environment()):
error('%s: %s', type(e).__name__, str(e)) error('%s: %s', type(e).__name__, str(e))
exit_status = ExitStatus.ERROR exit_status = ExitStatus.ERROR
finally:
if download and not download.finished:
download.failed()
return exit_status return exit_status

View File

@ -1,427 +0,0 @@
# coding=utf-8
"""
Download mode implementation.
"""
from __future__ import division
import os
import re
import sys
import mimetypes
import threading
from time import sleep, time
from mailbox import Message
from .output import RawStream
from .models import HTTPResponse
from .utils import humanize_bytes
from .compat import urlsplit
PARTIAL_CONTENT = 206
CLEAR_LINE = '\r\033[K'
PROGRESS = (
'{percentage: 6.2f} %'
' {downloaded: >10}'
' {speed: >10}/s'
' {eta: >8} ETA'
)
PROGRESS_NO_CONTENT_LENGTH = '{downloaded: >10} {speed: >10}/s'
SUMMARY = 'Done. {downloaded} in {time:0.5f}s ({speed}/s)\n'
SPINNER = '|/-\\'
class ContentRangeError(ValueError):
pass
def parse_content_range(content_range, resumed_from):
"""
Parse and validate Content-Range header.
<http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html>
:param content_range: the value of a Content-Range response header
eg. "bytes 21010-47021/47022"
:param resumed_from: first byte pos. from the Range request header
:return: total size of the response body when fully downloaded.
"""
if content_range is None:
raise ContentRangeError('Missing Content-Range')
pattern = (
'^bytes (?P<first_byte_pos>\d+)-(?P<last_byte_pos>\d+)'
'/(\*|(?P<instance_length>\d+))$'
)
match = re.match(pattern, content_range)
if not match:
raise ContentRangeError(
'Invalid Content-Range format %r' % content_range)
content_range_dict = match.groupdict()
first_byte_pos = int(content_range_dict['first_byte_pos'])
last_byte_pos = int(content_range_dict['last_byte_pos'])
instance_length = (
int(content_range_dict['instance_length'])
if content_range_dict['instance_length']
else None
)
# "A byte-content-range-spec with a byte-range-resp-spec whose
# last- byte-pos value is less than its first-byte-pos value,
# or whose instance-length value is less than or equal to its
# last-byte-pos value, is invalid. The recipient of an invalid
# byte-content-range- spec MUST ignore it and any content
# transferred along with it."
if (first_byte_pos >= last_byte_pos
or (instance_length is not None
and instance_length <= last_byte_pos)):
raise ContentRangeError(
'Invalid Content-Range returned: %r' % content_range)
if (first_byte_pos != resumed_from
or (instance_length is not None
and last_byte_pos + 1 != instance_length)):
# Not what we asked for.
raise ContentRangeError(
'Unexpected Content-Range returned (%r)'
' for the requested Range ("bytes=%d-")'
% (content_range, resumed_from)
)
return last_byte_pos + 1
def filename_from_content_disposition(content_disposition):
"""
Extract and validate filename from a Content-Disposition header.
:param content_disposition: Content-Disposition value
:return: the filename if present and valid, otherwise `None`
"""
# attachment; filename=jkbr-httpie-0.4.1-20-g40bd8f6.tar.gz
msg = Message('Content-Disposition: %s' % content_disposition)
filename = msg.get_filename()
if filename:
# Basic sanitation.
filename = os.path.basename(filename).lstrip('.').strip()
if filename:
return filename
def filename_from_url(url, content_type):
fn = urlsplit(url).path.rstrip('/')
fn = os.path.basename(fn) if fn else 'index'
if '.' not in fn and content_type:
content_type = content_type.split(';')[0]
if content_type == 'text/plain':
# mimetypes returns '.ksh'
ext = '.txt'
else:
ext = mimetypes.guess_extension(content_type)
if ext == '.htm': # Python 3
ext = '.html'
if ext:
fn += ext
return fn
def get_unique_filename(fn, exists=os.path.exists):
attempt = 0
while True:
suffix = '-' + str(attempt) if attempt > 0 else ''
if not exists(fn + suffix):
return fn + suffix
attempt += 1
class Download(object):
def __init__(self, output_file=None,
resume=False, progress_file=sys.stderr):
"""
:param resume: Should the download resume if partial download
already exists.
:type resume: bool
:param output_file: The file to store response body in. If not
provided, it will be guessed from the response.
:type output_file: file
:param progress_file: Where to report download progress.
:type progress_file: file
"""
self._output_file = output_file
self._resume = resume
self._resumed_from = 0
self.finished = False
self.status = Status()
self._progress_reporter = ProgressReporterThread(
status=self.status,
output=progress_file
)
def pre_request(self, request_headers):
"""Called just before the HTTP request is sent.
Might alter `request_headers`.
:type request_headers: dict
"""
# Disable content encoding so that we can resume, etc.
request_headers['Accept-Encoding'] = None
if self._resume:
bytes_have = os.path.getsize(self._output_file.name)
if bytes_have:
# Set ``Range`` header to resume the download
# TODO: Use "If-Range: mtime" to make sure it's fresh?
request_headers['Range'] = 'bytes=%d-' % bytes_have
self._resumed_from = bytes_have
def start(self, response):
"""
Initiate and return a stream for `response` body with progress
callback attached. Can be called only once.
:param response: Initiated response object with headers already fetched
:type response: requests.models.Response
:return: RawStream, output_file
"""
assert not self.status.time_started
try:
total_size = int(response.headers['Content-Length'])
except (KeyError, ValueError, TypeError):
total_size = None
if self._output_file:
if self._resume and response.status_code == PARTIAL_CONTENT:
total_size = parse_content_range(
response.headers.get('Content-Range'),
self._resumed_from
)
else:
self._resumed_from = 0
try:
self._output_file.seek(0)
self._output_file.truncate()
except IOError:
pass # stdout
else:
# TODO: Should the filename be taken from response.history[0].url?
# Output file not specified. Pick a name that doesn't exist yet.
fn = None
if 'Content-Disposition' in response.headers:
fn = filename_from_content_disposition(
response.headers['Content-Disposition'])
if not fn:
fn = filename_from_url(
url=response.url,
content_type=response.headers.get('Content-Type'),
)
self._output_file = open(get_unique_filename(fn), mode='a+b')
self.status.started(
resumed_from=self._resumed_from,
total_size=total_size
)
stream = RawStream(
msg=HTTPResponse(response),
with_headers=False,
with_body=True,
on_body_chunk_downloaded=self.chunk_downloaded,
chunk_size=1024 * 8
)
self._progress_reporter.output.write(
'Downloading %sto "%s"\n' % (
(humanize_bytes(total_size) + ' '
if total_size is not None
else ''),
self._output_file.name
)
)
self._progress_reporter.start()
return stream, self._output_file
def finish(self):
assert not self.finished
self.finished = True
self.status.finished()
def failed(self):
self._progress_reporter.stop()
@property
def interrupted(self):
return (
self.finished
and self.status.total_size
and self.status.total_size != self.status.downloaded
)
def chunk_downloaded(self, chunk):
"""
A download progress callback.
:param chunk: A chunk of response body data that has just
been downloaded and written to the output.
:type chunk: bytes
"""
self.status.chunk_downloaded(len(chunk))
class Status(object):
"""Holds details about the downland status."""
def __init__(self):
self.downloaded = 0
self.total_size = None
self.resumed_from = 0
self.time_started = None
self.time_finished = None
def started(self, resumed_from=0, total_size=None):
assert self.time_started is None
if total_size is not None:
self.total_size = total_size
self.downloaded = self.resumed_from = resumed_from
self.time_started = time()
def chunk_downloaded(self, size):
assert self.time_finished is None
self.downloaded += size
@property
def has_finished(self):
return self.time_finished is not None
def finished(self):
assert self.time_started is not None
assert self.time_finished is None
self.time_finished = time()
class ProgressReporterThread(threading.Thread):
"""
Reports download progress based on its status.
Uses threading to periodically update the status (speed, ETA, etc.).
"""
def __init__(self, status, output, tick=.1, update_interval=1):
"""
:type status: Status
:type output: file
"""
super(ProgressReporterThread, self).__init__()
self.status = status
self.output = output
self._tick = tick
self._update_interval = update_interval
self._spinner_pos = 0
self._status_line = ''
self._prev_bytes = 0
self._prev_time = time()
self._should_stop = threading.Event()
def stop(self):
"""Stop reporting on next tick."""
self._should_stop.set()
def run(self):
while not self._should_stop.is_set():
if self.status.has_finished:
self.sum_up()
break
self.report_speed()
sleep(self._tick)
def report_speed(self):
now = time()
if now - self._prev_time >= self._update_interval:
downloaded = self.status.downloaded
try:
speed = ((downloaded - self._prev_bytes)
/ (now - self._prev_time))
except ZeroDivisionError:
speed = 0
if not self.status.total_size:
self._status_line = PROGRESS_NO_CONTENT_LENGTH.format(
downloaded=humanize_bytes(downloaded),
speed=humanize_bytes(speed),
)
else:
try:
percentage = downloaded / self.status.total_size * 100
except ZeroDivisionError:
percentage = 0
if not speed:
eta = '-:--:--'
else:
s = int((self.status.total_size - downloaded) / speed)
h, s = divmod(s, 60 * 60)
m, s = divmod(s, 60)
eta = '{0}:{1:0>2}:{2:0>2}'.format(h, m, s)
self._status_line = PROGRESS.format(
percentage=percentage,
downloaded=humanize_bytes(downloaded),
speed=humanize_bytes(speed),
eta=eta,
)
self._prev_time = now
self._prev_bytes = downloaded
self.output.write(
CLEAR_LINE
+ ' '
+ SPINNER[self._spinner_pos]
+ ' '
+ self._status_line
)
self.output.flush()
self._spinner_pos = (self._spinner_pos + 1
if self._spinner_pos + 1 != len(SPINNER)
else 0)
def sum_up(self):
actually_downloaded = (self.status.downloaded
- self.status.resumed_from)
time_taken = self.status.time_finished - self.status.time_started
self.output.write(CLEAR_LINE)
self.output.write(SUMMARY.format(
downloaded=humanize_bytes(actually_downloaded),
total=(self.status.total_size
and humanize_bytes(self.status.total_size)),
speed=humanize_bytes(actually_downloaded / time_taken),
time=time_taken,
))
self.output.flush()

View File

@ -6,9 +6,8 @@ import sys
import re import re
import json import json
import mimetypes import mimetypes
from getpass import 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:
@ -21,7 +20,6 @@ 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'
@ -100,106 +98,57 @@ class Parser(ArgumentParser):
def parse_args(self, env, args=None, namespace=None): def parse_args(self, env, args=None, namespace=None):
self.env = env self.env = env
self.args, no_options = super(Parser, self)\
.parse_known_args(args, namespace)
if self.args.debug: args, no_options = super(Parser, self).parse_known_args(args,
self.args.traceback = True namespace)
# Arguments processing and environment setup. self._apply_no_options(args, no_options)
self._apply_no_options(no_options)
self._apply_config()
self._validate_download_options()
self._setup_standard_streams()
self._process_output_options()
self._process_pretty_options()
self._guess_method()
self._parse_items()
if not self.args.ignore_stdin and not env.stdin_isatty:
self._body_from_file(self.env.stdin)
if not (self.args.url.startswith((HTTP, HTTPS))):
# Default to 'https://' if invoked as `https args`.
scheme = HTTPS if self.env.progname == 'https' else HTTP
self.args.url = scheme + self.args.url
self._process_auth()
return self.args if not args.json and env.config.implicit_content_type == 'form':
args.form = True
# noinspection PyShadowingBuiltins if args.debug:
def _print_message(self, message, file=None): args.traceback = True
# Sneak in our stderr/stdout.
file = {
sys.stdout: self.env.stdout,
sys.stderr: self.env.stderr,
None: self.env.stderr
}.get(file, file)
super(Parser, self)._print_message(message, file) if args.output:
env.stdout = args.output
env.stdout_isatty = False
def _setup_standard_streams(self): self._process_output_options(args, env)
""" self._process_pretty_options(args, env)
Modify `env.stdout` and `env.stdout_isatty` based on args, if needed. self._guess_method(args, env)
self._parse_items(args)
""" if not env.stdin_isatty:
if not self.env.stdout_isatty and self.args.output_file: self._body_from_file(args, env.stdin)
self.error('Cannot use --output, -o with redirected output.')
# FIXME: Come up with a cleaner solution. if not (args.url.startswith(HTTP) or args.url.startswith(HTTPS)):
if self.args.download: scheme = HTTPS if env.progname == 'https' else HTTP
args.url = scheme + args.url
if not self.env.stdout_isatty: self._process_auth(args)
# Use stdout as tge download output file.
self.args.output_file = self.env.stdout
# With `--download`, we write everything that would normally go to return args
# `stdout` to `stderr` instead. Let's replace the stream so that
# we don't have to use many `if`s throughout the codebase.
# The response body will be treated separately.
self.env.stdout = self.env.stderr
self.env.stdout_isatty = self.env.stderr_isatty
elif self.args.output_file: def _process_auth(self, args):
# When not `--download`ing, then `--output` simply replaces url = urlsplit(args.url)
# `stdout`. The file is opened for appending, which isn't what
# we want in this case.
self.args.output_file.seek(0)
self.args.output_file.truncate()
self.env.stdout = self.args.output_file if args.auth:
self.env.stdout_isatty = False if not args.auth.has_password():
def _apply_config(self):
if (not self.args.json
and self.env.config.implicit_content_type == 'form'):
self.args.form = True
def _process_auth(self):
"""
If only a username provided via --auth, then ask for a password.
Or, take credentials from the URL, if provided.
"""
url = urlsplit(self.args.url)
if self.args.auth:
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: args.auth.prompt_password(url.netloc)
self.error('Unable to prompt for passwords because'
' --ignore-stdin is set.')
self.args.auth.prompt_password(url.netloc)
elif url.username is not None: elif url.username is not None:
# Handle http://username:password@hostname/ # Handle http://username:password@hostname/
username, password = url.username, url.password username, password = url.username, url.password
self.args.auth = AuthCredentials( args.auth = AuthCredentials(
key=username, key=username,
value=password, value=password,
sep=SEP_CREDENTIALS, sep=SEP_CREDENTIALS,
orig=SEP_CREDENTIALS.join([username, password]) orig=SEP_CREDENTIALS.join([username, password])
) )
def _apply_no_options(self, no_options): def _apply_no_options(self, args, no_options):
"""For every `--no-OPTION` in `no_options`, set `args.OPTION` to """For every `--no-OPTION` in `no_options`, set `args.OPTION` to
its default value. This allows for un-setting of options, e.g., its default value. This allows for un-setting of options, e.g.,
specified in config. specified in config.
@ -216,7 +165,7 @@ class Parser(ArgumentParser):
inverted = '--' + option[5:] inverted = '--' + option[5:]
for action in self._actions: for action in self._actions:
if inverted in action.option_strings: if inverted in action.option_strings:
setattr(self.args, action.dest, action.default) setattr(args, action.dest, action.default)
break break
else: else:
invalid.append(option) invalid.append(option)
@ -225,143 +174,123 @@ class Parser(ArgumentParser):
msg = 'unrecognized arguments: %s' msg = 'unrecognized arguments: %s'
self.error(msg % ' '.join(invalid)) self.error(msg % ' '.join(invalid))
def _body_from_file(self, fd): def _print_message(self, message, file=None):
# Sneak in our stderr/stdout.
file = {
sys.stdout: self.env.stdout,
sys.stderr: self.env.stderr,
None: self.env.stderr
}.get(file, file)
super(Parser, self)._print_message(message, file)
def _body_from_file(self, args, fd):
"""There can only be one source of request data. """There can only be one source of request data.
Bytes are always read. Bytes are always read.
""" """
if self.args.data: if args.data:
self.error('Request body (from stdin or a file) and request ' self.error('Request body (from stdin or a file) and request '
'data (key=value) cannot be mixed.') 'data (key=value) cannot be mixed.')
self.args.data = getattr(fd, 'buffer', fd).read() args.data = getattr(fd, 'buffer', fd).read()
def _guess_method(self): def _guess_method(self, args, env):
"""Set `args.method` if not specified to either POST or GET """Set `args.method` if not specified to either POST or GET
based on whether the request has data or not. based on whether the request has data or not.
""" """
if self.args.method is None: if args.method is None:
# Invoked as `http URL'. # Invoked as `http URL'.
assert not self.args.items assert not args.items
if not self.args.ignore_stdin and not self.env.stdin_isatty: if not env.stdin_isatty:
self.args.method = HTTP_POST args.method = HTTP_POST
else: else:
self.args.method = HTTP_GET args.method = HTTP_GET
# FIXME: False positive, e.g., "localhost" matches but is a valid URL. # FIXME: False positive, e.g., "localhost" matches but is a valid URL.
elif not re.match('^[a-zA-Z]+$', self.args.method): elif not re.match('^[a-zA-Z]+$', args.method):
# Invoked as `http URL item+'. The URL is now in `args.method` # Invoked as `http URL item+'. The URL is now in `args.method`
# and the first ITEM is now incorrectly in `args.url`. # and the first ITEM is now incorrectly in `args.url`.
try: try:
# Parse the URL as an ITEM and store it as the first ITEM arg. # Parse the URL as an ITEM and store it as the first ITEM arg.
self.args.items.insert( args.items.insert(
0, 0, KeyValueArgType(*SEP_GROUP_ITEMS).__call__(args.url))
KeyValueArgType(*SEP_GROUP_ITEMS).__call__(self.args.url)
)
except ArgumentTypeError as e: except ArgumentTypeError as e:
if self.args.traceback: if args.traceback:
raise raise
self.error(e.message) self.error(e.message)
else: else:
# Set the URL correctly # Set the URL correctly
self.args.url = self.args.method args.url = args.method
# Infer the method # Infer the method
has_data = ( has_data = not env.stdin_isatty or any(
(not self.args.ignore_stdin and item.sep in SEP_GROUP_DATA_ITEMS for item in args.items)
not self.env.stdin_isatty) or any( args.method = HTTP_POST if has_data else HTTP_GET
item.sep in SEP_GROUP_DATA_ITEMS
for item in self.args.items
)
)
self.args.method = HTTP_POST if has_data else HTTP_GET
def _parse_items(self): def _parse_items(self, args):
"""Parse `args.items` into `args.headers`, `args.data`, `args.params`, """Parse `args.items` into `args.headers`, `args.data`,
and `args.files`. `args.`, and `args.files`.
""" """
self.args.headers = CaseInsensitiveDict() args.headers = CaseInsensitiveDict()
self.args.data = ParamDict() if self.args.form else OrderedDict() args.data = ParamDict() if args.form else OrderedDict()
self.args.files = OrderedDict() args.files = OrderedDict()
self.args.params = ParamDict() args.params = ParamDict()
try: try:
parse_items(items=self.args.items, parse_items(items=args.items,
headers=self.args.headers, headers=args.headers,
data=self.args.data, data=args.data,
files=self.args.files, files=args.files,
params=self.args.params) params=args.params)
except ParseError as e: except ParseError as e:
if self.args.traceback: if args.traceback:
raise raise
self.error(e.message) self.error(e.message)
if self.args.files and not self.args.form: if args.files and not args.form:
# `http url @/path/to/file` # `http url @/path/to/file`
file_fields = list(self.args.files.keys()) file_fields = list(args.files.keys())
if file_fields != ['']: if file_fields != ['']:
self.error( self.error(
'Invalid file fields (perhaps you meant --form?): %s' 'Invalid file fields (perhaps you meant --form?): %s'
% ','.join(file_fields)) % ','.join(file_fields))
fn, fd = self.args.files[''] fn, fd = args.files['']
self.args.files = {} args.files = {}
self._body_from_file(args, fd)
self._body_from_file(fd) if 'Content-Type' not in args.headers:
if 'Content-Type' not in self.args.headers:
mime, encoding = mimetypes.guess_type(fn, strict=False) mime, encoding = mimetypes.guess_type(fn, strict=False)
if mime: if mime:
content_type = mime content_type = mime
if encoding: if encoding:
content_type = '%s; charset=%s' % (mime, encoding) content_type = '%s; charset=%s' % (mime, encoding)
self.args.headers['Content-Type'] = content_type args.headers['Content-Type'] = content_type
def _process_output_options(self): def _process_output_options(self, args, env):
"""Apply defaults to output options, or validate the provided ones. """Apply defaults to output options or validate the provided ones.
The default output options are stdout-type-sensitive. The default output options are stdout-type-sensitive.
""" """
if not self.args.output_options: if not args.output_options:
self.args.output_options = ( args.output_options = (OUTPUT_OPTIONS_DEFAULT if env.stdout_isatty
OUTPUT_OPTIONS_DEFAULT else OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED)
if self.env.stdout_isatty
else OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED
)
unknown_output_options = set(self.args.output_options) - OUTPUT_OPTIONS unknown = set(args.output_options) - OUTPUT_OPTIONS
if unknown_output_options: if unknown:
self.error( self.error('Unknown output options: %s' % ','.join(unknown))
'Unknown output options: %s' % ','.join(unknown_output_options)
)
if self.args.download and OUT_RESP_BODY in self.args.output_options: def _process_pretty_options(self, args, env):
# Response body is always downloaded with --download and it goes if args.prettify == PRETTY_STDOUT_TTY_ONLY:
# through a different routine, so we remove it. args.prettify = PRETTY_MAP['all' if env.stdout_isatty else 'none']
self.args.output_options = str( elif args.prettify and env.is_windows:
set(self.args.output_options) - set(OUT_RESP_BODY))
def _process_pretty_options(self):
if self.args.prettify == PRETTY_STDOUT_TTY_ONLY:
self.args.prettify = PRETTY_MAP[
'all' if self.env.stdout_isatty else 'none']
elif self.args.prettify and self.env.is_windows:
self.error('Only terminal output can be colorized on Windows.') self.error('Only terminal output can be colorized on Windows.')
else: else:
# noinspection PyTypeChecker args.prettify = PRETTY_MAP[args.prettify]
self.args.prettify = PRETTY_MAP[self.args.prettify]
def _validate_download_options(self):
if not self.args.download:
if self.args.download_resume:
self.error('--continue only works with --download')
if self.args.download_resume and not (
self.args.download and self.args.output_file):
self.error('--continue requires --output to be specified')
class ParseError(Exception): class ParseError(Exception):
@ -381,15 +310,34 @@ class KeyValue(object):
return self.__dict__ == other.__dict__ return self.__dict__ == other.__dict__
class SessionNameValidator(object): def session_name_arg_type(name):
from .sessions import Session
if not Session.is_valid_name(name):
raise ArgumentTypeError(
'special characters and spaces are not'
' allowed in session names: "%s"'
% name)
return name
def __init__(self, error_message):
def host_name_arg_type(name):
from .sessions import Host
if not Host.is_valid_name(name):
raise ArgumentTypeError(
'special characters and spaces are not'
' allowed in host names: "%s"'
% name)
return name
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):
# Session name can be a path or just a name. if not self.pattern.search(value):
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
@ -424,8 +372,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

@ -19,26 +19,22 @@ class Environment(object):
if progname not in ['http', 'https']: if progname not in ['http', 'https']:
progname = 'http' progname = 'http'
stdin_isatty = sys.stdin.isatty()
stdin = sys.stdin
stdout_isatty = sys.stdout.isatty()
config_dir = DEFAULT_CONFIG_DIR config_dir = DEFAULT_CONFIG_DIR
# Can be set to 0 to disable colors completely. if stdout_isatty and is_windows:
colors = 256 if '256color' in os.environ.get('TERM', '') else 88
stdin = sys.stdin
stdin_isatty = sys.stdin.isatty()
stdout_isatty = sys.stdout.isatty()
stderr_isatty = sys.stderr.isatty()
if is_windows:
# noinspection PyUnresolvedReferences
from colorama.initialise import wrap_stream from colorama.initialise import wrap_stream
stdout = wrap_stream(sys.stdout, convert=None, stdout = wrap_stream(sys.stdout, convert=None,
strip=None, autoreset=True, wrap=True) strip=None, autoreset=True, wrap=True)
stderr = wrap_stream(sys.stderr, convert=None,
strip=None, autoreset=True, wrap=True)
else: else:
stdout = sys.stdout stdout = sys.stdout
stderr = sys.stderr stderr = sys.stderr
# Can be set to 0 to disable colors completely.
colors = 256 if '256color' in os.environ.get('TERM', '') else 88
def __init__(self, **kwargs): def __init__(self, **kwargs):
assert all(hasattr(type(self), attr) assert all(hasattr(type(self), attr)
@ -88,7 +84,10 @@ class HTTPMessage(object):
@property @property
def content_type(self): def content_type(self):
"""Return the message content type.""" """Return the message content type."""
return self._orig.headers.get('Content-Type', '') ct = self._orig.headers.get('Content-Type', '')
if isinstance(ct, bytes):
ct = ct.decode()
return ct
class HTTPResponse(HTTPMessage): class HTTPResponse(HTTPMessage):
@ -100,7 +99,6 @@ 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,7 +2,6 @@
""" """
import json import json
import xml.dom.minidom
from functools import partial from functools import partial
from itertools import chain from itertools import chain
@ -21,9 +20,6 @@ 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())
@ -65,7 +61,7 @@ def write(stream, outfile, flush):
outfile.flush() outfile.flush()
def write_with_colors_win_py3(stream, outfile, flush): def write_with_colors_win_p3k(stream, outfile, flush):
"""Like `write`, but colorized chunks are written as text """Like `write`, but colorized chunks are written as text
directly to `outfile` to ensure it gets processed by colorama. directly to `outfile` to ensure it gets processed by colorama.
Applies only to Windows with Python 3 and colorized terminal output. Applies only to Windows with Python 3 and colorized terminal output.
@ -151,8 +147,7 @@ def get_stream_type(env, args):
class BaseStream(object): class BaseStream(object):
"""Base HTTP message output stream class.""" """Base HTTP message output stream class."""
def __init__(self, msg, with_headers=True, with_body=True, def __init__(self, msg, with_headers=True, with_body=True):
on_body_chunk_downloaded=None):
""" """
:param msg: a :class:`models.HTTPMessage` subclass :param msg: a :class:`models.HTTPMessage` subclass
:param with_headers: if `True`, headers will be included :param with_headers: if `True`, headers will be included
@ -163,7 +158,6 @@ class BaseStream(object):
self.msg = msg self.msg = msg
self.with_headers = with_headers self.with_headers = with_headers
self.with_body = with_body self.with_body = with_body
self.on_body_chunk_downloaded = on_body_chunk_downloaded
def _get_headers(self): def _get_headers(self):
"""Return the headers' bytes.""" """Return the headers' bytes."""
@ -183,8 +177,6 @@ class BaseStream(object):
try: try:
for chunk in self._iter_body(): for chunk in self._iter_body():
yield chunk yield chunk
if self.on_body_chunk_downloaded:
self.on_body_chunk_downloaded(chunk)
except BinarySuppressedError as e: except BinarySuppressedError as e:
if self.with_headers: if self.with_headers:
yield b'\n' yield b'\n'
@ -195,7 +187,7 @@ class RawStream(BaseStream):
"""The message is streamed in chunks with no processing.""" """The message is streamed in chunks with no processing."""
CHUNK_SIZE = 1024 * 100 CHUNK_SIZE = 1024 * 100
CHUNK_SIZE_BY_LINE = 1 CHUNK_SIZE_BY_LINE = 1024 * 5
def __init__(self, chunk_size=CHUNK_SIZE, **kwargs): def __init__(self, chunk_size=CHUNK_SIZE, **kwargs):
super(RawStream, self).__init__(**kwargs) super(RawStream, self).__init__(**kwargs)
@ -213,7 +205,7 @@ class EncodedStream(BaseStream):
is suppressed. The body is always streamed by line. is suppressed. The body is always streamed by line.
""" """
CHUNK_SIZE = 1 CHUNK_SIZE = 1024 * 5
def __init__(self, env=Environment(), **kwargs): def __init__(self, env=Environment(), **kwargs):
@ -249,7 +241,7 @@ class PrettyStream(EncodedStream):
""" """
CHUNK_SIZE = 1 CHUNK_SIZE = 1024 * 5
def __init__(self, processor, **kwargs): def __init__(self, processor, **kwargs):
super(PrettyStream, self).__init__(**kwargs) super(PrettyStream, self).__init__(**kwargs)
@ -268,9 +260,8 @@ class PrettyStream(EncodedStream):
def _process_body(self, chunk): def _process_body(self, chunk):
return (self.processor return (self.processor
.process_body( .process_body(
content=chunk.decode(self.msg.encoding, 'replace'), chunk.decode(self.msg.encoding, 'replace'),
content_type=self.msg.content_type, self.msg.content_type)
encoding=self.msg.encoding)
.encode(self.output_encoding, 'replace')) .encode(self.output_encoding, 'replace'))
@ -372,13 +363,12 @@ class BaseProcessor(object):
""" """
return headers return headers
def process_body(self, content, content_type, subtype, encoding): def process_body(self, content, content_type, subtype):
"""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
@ -387,7 +377,7 @@ class BaseProcessor(object):
class JSONProcessor(BaseProcessor): class JSONProcessor(BaseProcessor):
"""JSON body processor.""" """JSON body processor."""
def process_body(self, content, content_type, subtype, encoding): def process_body(self, content, content_type, subtype):
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
@ -395,29 +385,13 @@ 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=DEFAULT_INDENT) indent=4)
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.
@ -449,7 +423,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, encoding): def process_body(self, content, content_type, subtype):
try: try:
lexer = self.lexers_by_type.get(content_type) lexer = self.lexers_by_type.get(content_type)
if not lexer: if not lexer:
@ -482,8 +456,7 @@ class OutputProcessor(object):
installed_processors = { installed_processors = {
'format': [ 'format': [
HeadersProcessor, HeadersProcessor,
JSONProcessor, JSONProcessor
XMLProcessor
], ],
'colors': [ 'colors': [
PygmentsProcessor PygmentsProcessor
@ -509,18 +482,13 @@ class OutputProcessor(object):
headers = processor.process_headers(headers) headers = processor.process_headers(headers)
return headers return headers
def process_body(self, content, content_type, encoding): def process_body(self, content, content_type):
# 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 = processor.process_body(content, content_type, subtype)
content,
content_type,
subtype,
encoding
)
return content return content

View File

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

View File

@ -1,28 +0,0 @@
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()

View File

@ -1,26 +0,0 @@
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)

View File

@ -1,35 +0,0 @@
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,68 +3,52 @@
""" """
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(session_name, requests_kwargs, config_dir, args, def get_response(name, request_kwargs, config_dir, read_only=False):
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.
""" """
if os.path.sep in session_name: sessions_dir = os.path.join(config_dir, SESSIONS_DIR_NAME)
path = os.path.expanduser(session_name) host = Host(
else: root_dir=sessions_dir,
hostname = ( name=request_kwargs['headers'].get('Host', None)
requests_kwargs['headers'].get('Host', None) or urlsplit(request_kwargs['url']).netloc.split('@')[-1]
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()
request_headers = requests_kwargs.get('headers', {}) # Update session headers with the request headers.
requests_kwargs['headers'] = dict(session.headers, **request_headers) session['headers'].update(request_kwargs.get('headers', {}))
session.update_headers(request_headers) # Use the merged headers for the request
request_kwargs['headers'] = session['headers']
if args.auth: auth = request_kwargs.get('auth', None)
session.auth = { if auth:
'type': args.auth_type, session.auth = auth
'username': args.auth.key,
'password': args.auth.value,
}
elif session.auth: elif session.auth:
requests_kwargs['auth'] = session.auth request_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(**requests_kwargs) response = requests_session.request(**request_kwargs)
except Exception: except Exception:
raise raise
else: else:
@ -75,13 +59,69 @@ def get_response(session_name, requests_kwargs, config_dir, args,
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'
def __init__(self, path, *args, **kwargs): VALID_NAME_PATTERN = re.compile('^[a-zA-Z0-9_.-]+$')
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._path = path self.host = host
self.name = name
self['headers'] = {} self['headers'] = {}
self['cookies'] = {} self['cookies'] = {}
self['auth'] = { self['auth'] = {
@ -90,31 +130,13 @@ class Session(BaseConfigDict):
'password': None 'password': None
} }
def _get_path(self): @property
return self._path def directory(self):
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 headers(self): def verbose_name(self):
return self['headers'] return '%s %s %s' % (self.host.name, self.name, self.path)
@property @property
def cookies(self): def cookies(self):
@ -127,27 +149,31 @@ 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'] = {}
for cookie in jar: for host in jar._cookies.values():
self['cookies'][cookie.name] = dict( for path in host.values():
(attname, getattr(cookie, attname)) for name, cookie in path.items():
for attname in stored_attrs self['cookies'][name] = dict(
) (attname, getattr(cookie, attname))
for attname in stored_attrs
)
@property @property
def auth(self): def auth(self):
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_plugin = plugin_manager.get_auth_plugin(auth['type'])() Auth = {'basic': HTTPBasicAuth,
return auth_plugin.get_auth(auth['username'], auth['password']) 'digest': HTTPDigestAuth}[auth['type']]
return Auth(auth['username'], auth['password'])
@auth.setter @auth.setter
def auth(self, auth): def auth(self, cred):
assert set(['type', 'username', 'password']) == set(auth.keys()) self['auth'] = {
self['auth'] = auth 'type': {HTTPBasicAuth: 'basic',
HTTPDigestAuth: 'digest'}[type(cred)],
'username': cred.username,
'password': cred.password,
}

View File

@ -1,46 +0,0 @@
from __future__ import division
def humanize_bytes(n, precision=2):
# Author: Doug Latornell
# Licence: MIT
# URL: http://code.activestate.com/recipes/577081/
"""Return a humanized string representation of a number of bytes.
Assumes `from __future__ import division`.
>>> humanize_bytes(1)
'1 byte'
>>> humanize_bytes(1024)
'1.0 kB'
>>> humanize_bytes(1024 * 123)
'123.0 kB'
>>> humanize_bytes(1024 * 12342)
'12.1 MB'
>>> humanize_bytes(1024 * 12342, 2)
'12.05 MB'
>>> humanize_bytes(1024 * 1234, 2)
'1.21 MB'
>>> humanize_bytes(1024 * 1234 * 1111, 2)
'1.31 GB'
>>> humanize_bytes(1024 * 1234 * 1111, 1)
'1.3 GB'
"""
abbrevs = [
(1 << 50, 'PB'),
(1 << 40, 'TB'),
(1 << 30, 'GB'),
(1 << 20, 'MB'),
(1 << 10, 'kB'),
(1, 'B')
]
if n == 1:
return '1 B'
for factor, suffix in abbrevs:
if n >= factor:
break
return '%.*f %s' % (precision, n / factor, suffix)

View File

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

1
requirements.txt Normal file
View File

@ -0,0 +1 @@
#

View File

@ -1,5 +1,6 @@
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
@ -11,23 +12,24 @@ if sys.argv[-1] == 'test':
requirements = [ requirements = [
'requests>=2.0.0', 'requests>=1.0.4',
'Pygments>=1.5' 'Pygments>=1.5'
] ]
try: if sys.version_info[:2] in ((2, 6), (3, 1)):
#noinspection PyUnresolvedReferences # argparse has been added in Python 3.2 / 2.7
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:
return f.read() rst = f.read()
code_block = '(:\n\n)?\.\. code-block::.*'
rst = re.sub(code_block, '::', rst)
return rst
setup( setup(
@ -40,10 +42,11 @@ 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', 'httpie.plugins'], packages=['httpie'],
entry_points={ entry_points={
'console_scripts': [ 'console_scripts': [
'http = httpie.__main__:main', 'http = httpie.__main__:main',
'httpie = httpie.manage:main',
], ],
}, },
install_requires=requirements, install_requires=requirements,

View File

@ -23,27 +23,20 @@ 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
import shutil import shutil
import time
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 PyCompatibility
from urllib2 import urlopen from urllib2 import urlopen
try: try:
from unittest import skipIf, skip from unittest import skipIf, skip
except ImportError: except ImportError:
skip = lambda msg: lambda self: None skip = lambda msg: lambda self: None
# noinspection PyUnusedLocal
def skipIf(cond, reason): def skipIf(cond, reason):
def decorator(test_method): def decorator(test_method):
if cond: if cond:
@ -69,14 +62,6 @@ from httpie.core import main
from httpie.output import BINARY_SUPPRESSED_NOTICE from httpie.output import BINARY_SUPPRESSED_NOTICE
from httpie.input import ParseError from httpie.input import ParseError
from httpie.compat import is_windows, is_py26, bytes, str from httpie.compat import is_windows, is_py26, bytes, str
from httpie.downloads import (
parse_content_range,
filename_from_content_disposition,
filename_from_url,
get_unique_filename,
ContentRangeError,
Download,
)
CRLF = '\r\n' CRLF = '\r\n'
@ -113,8 +98,7 @@ with open(BIN_FILE_PATH, 'rb') as f:
def httpbin(path): def httpbin(path):
url = HTTPBIN_URL + path return HTTPBIN_URL + path
return url
def mk_config_dir(): def mk_config_dir():
@ -196,25 +180,20 @@ def http(*args, **kwargs):
if not env: if not env:
env = kwargs['env'] = TestEnvironment() env = kwargs['env'] = TestEnvironment()
stdout = env.stdout
stderr = env.stderr
try: try:
try: try:
exit_status = main(args=['--traceback'] + list(args), **kwargs) exit_status = main(args=['--traceback'] + list(args), **kwargs)
if '--download' in args:
# Let the progress reporter thread finish.
time.sleep(.5)
except Exception: except Exception:
sys.stderr.write(stderr.read()) sys.stderr.write(env.stderr.read())
raise raise
except SystemExit: except SystemExit:
exit_status = ExitStatus.ERROR exit_status = ExitStatus.ERROR
stdout.seek(0) env.stdout.seek(0)
stderr.seek(0) env.stderr.seek(0)
output = stdout.read() output = env.stdout.read()
try: try:
r = StrResponse(output.decode('utf8')) r = StrResponse(output.decode('utf8'))
except UnicodeDecodeError: except UnicodeDecodeError:
@ -236,14 +215,14 @@ def http(*args, **kwargs):
except ValueError: except ValueError:
pass pass
r.stderr = stderr.read() r.stderr = env.stderr.read()
r.exit_status = exit_status r.exit_status = exit_status
return r return r
finally: finally:
stdout.close() env.stdout.close()
stderr.close() env.stderr.close()
class BaseTestCase(unittest.TestCase): class BaseTestCase(unittest.TestCase):
@ -261,15 +240,11 @@ class BaseTestCase(unittest.TestCase):
self.assertEqual(set(d1.keys()), set(d2.keys()), msg) self.assertEqual(set(d1.keys()), set(d2.keys()), msg)
self.assertEqual(sorted(d1.values()), sorted(d2.values()), msg) self.assertEqual(sorted(d1.values()), sorted(d2.values()), msg)
def assertIsNone(self, obj, msg=None):
self.assertEqual(obj, None, msg=msg)
################################################################# #################################################################
# High-level tests using httpbin. # High-level tests using httpbin.
################################################################# #################################################################
class HTTPieTest(BaseTestCase): class HTTPieTest(BaseTestCase):
def test_GET(self): def test_GET(self):
@ -293,7 +268,7 @@ class HTTPieTest(BaseTestCase):
'foo=bar' 'foo=bar'
) )
self.assertIn(OK, r) self.assertIn(OK, r)
self.assertIn(r'\"foo\": \"bar\"', r) self.assertIn('"foo": "bar"', r)
def test_POST_JSON_data(self): def test_POST_JSON_data(self):
r = http( r = http(
@ -302,7 +277,7 @@ class HTTPieTest(BaseTestCase):
'foo=bar' 'foo=bar'
) )
self.assertIn(OK, r) self.assertIn(OK, r)
self.assertIn(r'\"foo\": \"bar\"', r) self.assertIn('"foo": "bar"', r)
def test_POST_form(self): def test_POST_form(self):
r = http( r = http(
@ -461,9 +436,7 @@ class AutoContentTypeAndAcceptHeadersTest(BaseTestCase):
) )
self.assertIn(OK, r) self.assertIn(OK, r)
self.assertEqual(r.json['headers']['Accept'], 'application/json') self.assertEqual(r.json['headers']['Accept'], 'application/json')
# Make sure Content-Type gets set even with no data. self.assertFalse(r.json['headers'].get('Content-Type'))
# https://github.com/jkbr/httpie/issues/137
self.assertIn('application/json', r.json['headers']['Content-Type'])
def test_GET_explicit_JSON_explicit_headers(self): def test_GET_explicit_JSON_explicit_headers(self):
r = http( r = http(
@ -546,7 +519,7 @@ class ImplicitHTTPMethodTest(BaseTestCase):
'hello=world' 'hello=world'
) )
self.assertIn(OK, r) self.assertIn(OK, r)
self.assertIn(r'\"hello\": \"world\"', r) self.assertIn('"hello": "world"', r)
def test_implicit_POST_form(self): def test_implicit_POST_form(self):
r = http( r = http(
@ -685,8 +658,8 @@ class VerboseFlagTest(BaseTestCase):
'baz=bar' 'baz=bar'
) )
self.assertIn(OK, r) self.assertIn(OK, r)
self.assertIn('"baz": "bar"', r) # request #noinspection PyUnresolvedReferences
self.assertIn(r'\"baz\": \"bar\"', r) # response self.assertEqual(r.count('"baz": "bar"'), 2)
class MultipartFormDataFileUploadTest(BaseTestCase): class MultipartFormDataFileUploadTest(BaseTestCase):
@ -810,7 +783,6 @@ class RequestBodyFromFilePathTest(BaseTestCase):
""" """
def test_request_body_from_file_by_path(self): def test_request_body_from_file_by_path(self):
r = http( r = http(
'--verbose',
'POST', 'POST',
httpbin('/post'), httpbin('/post'),
'@' + FILE_PATH_ARG '@' + FILE_PATH_ARG
@ -873,8 +845,8 @@ class AuthTest(BaseTestCase):
httpbin('/digest-auth/auth/user/password') httpbin('/digest-auth/auth/user/password')
) )
self.assertIn(OK, r) self.assertIn(OK, r)
self.assertIn(r'"authenticated": true', r) self.assertIn('"authenticated": true', r)
self.assertIn(r'"user": "user"', r) self.assertIn('"user": "user"', r)
def test_password_prompt(self): def test_password_prompt(self):
@ -1079,30 +1051,6 @@ 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."""
@ -1195,8 +1143,6 @@ 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',
@ -1226,8 +1172,6 @@ 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': ''
@ -1251,84 +1195,71 @@ class ArgumentParserTestCase(unittest.TestCase):
self.parser = input.Parser() self.parser = input.Parser()
def test_guess_when_method_set_and_valid(self): def test_guess_when_method_set_and_valid(self):
self.parser.args = argparse.Namespace() args = argparse.Namespace()
self.parser.args.method = 'GET' args.method = 'GET'
self.parser.args.url = 'http://example.com/' args.url = 'http://example.com/'
self.parser.args.items = [] args.items = []
self.parser.args.ignore_stdin = False
self.parser.env = TestEnvironment() self.parser._guess_method(args, TestEnvironment())
self.parser._guess_method() self.assertEqual(args.method, 'GET')
self.assertEqual(args.url, 'http://example.com/')
self.assertEqual(self.parser.args.method, 'GET') self.assertEqual(args.items, [])
self.assertEqual(self.parser.args.url, 'http://example.com/')
self.assertEqual(self.parser.args.items, [])
def test_guess_when_method_not_set(self): def test_guess_when_method_not_set(self):
args = argparse.Namespace()
args.method = None
args.url = 'http://example.com/'
args.items = []
self.parser.args = argparse.Namespace() self.parser._guess_method(args, TestEnvironment())
self.parser.args.method = None
self.parser.args.url = 'http://example.com/'
self.parser.args.items = []
self.parser.args.ignore_stdin = False
self.parser.env = TestEnvironment()
self.parser._guess_method() self.assertEqual(args.method, 'GET')
self.assertEqual(args.url, 'http://example.com/')
self.assertEqual(self.parser.args.method, 'GET') self.assertEqual(args.items, [])
self.assertEqual(self.parser.args.url, 'http://example.com/')
self.assertEqual(self.parser.args.items, [])
def test_guess_when_method_set_but_invalid_and_data_field(self): def test_guess_when_method_set_but_invalid_and_data_field(self):
self.parser.args = argparse.Namespace() args = argparse.Namespace()
self.parser.args.method = 'http://example.com/' args.method = 'http://example.com/'
self.parser.args.url = 'data=field' args.url = 'data=field'
self.parser.args.items = [] args.items = []
self.parser.args.ignore_stdin = False
self.parser.env = TestEnvironment()
self.parser._guess_method()
self.assertEqual(self.parser.args.method, 'POST') self.parser._guess_method(args, TestEnvironment())
self.assertEqual(self.parser.args.url, 'http://example.com/')
self.assertEqual(args.method, 'POST')
self.assertEqual(args.url, 'http://example.com/')
self.assertEqual( self.assertEqual(
self.parser.args.items, args.items,
[input.KeyValue( [input.KeyValue(
key='data', value='field', sep='=', orig='data=field')]) key='data', value='field', sep='=', orig='data=field')])
def test_guess_when_method_set_but_invalid_and_header_field(self): def test_guess_when_method_set_but_invalid_and_header_field(self):
self.parser.args = argparse.Namespace() args = argparse.Namespace()
self.parser.args.method = 'http://example.com/' args.method = 'http://example.com/'
self.parser.args.url = 'test:header' args.url = 'test:header'
self.parser.args.items = [] args.items = []
self.parser.args.ignore_stdin = False
self.parser.env = TestEnvironment() self.parser._guess_method(args, TestEnvironment())
self.parser._guess_method() self.assertEqual(args.method, 'GET')
self.assertEqual(args.url, 'http://example.com/')
self.assertEqual(self.parser.args.method, 'GET')
self.assertEqual(self.parser.args.url, 'http://example.com/')
self.assertEqual( self.assertEqual(
self.parser.args.items, args.items,
[input.KeyValue( [input.KeyValue(
key='test', value='header', sep=':', orig='test:header')]) key='test', value='header', sep=':', orig='test:header')])
def test_guess_when_method_set_but_invalid_and_item_exists(self): def test_guess_when_method_set_but_invalid_and_item_exists(self):
self.parser.args = argparse.Namespace() args = argparse.Namespace()
self.parser.args.method = 'http://example.com/' args.method = 'http://example.com/'
self.parser.args.url = 'new_item=a' args.url = 'new_item=a'
self.parser.args.items = [ args.items = [
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._guess_method(args, TestEnvironment())
self.parser._guess_method() self.assertEqual(args.items, [
self.assertEqual(self.parser.args.items, [
input.KeyValue( input.KeyValue(
key='new_item', value='a', sep='=', orig='new_item=a'), key='new_item', value='a', sep='=', orig='new_item=a'),
input.KeyValue( input.KeyValue(
@ -1404,26 +1335,6 @@ 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(
@ -1498,181 +1409,6 @@ 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):
def test_Content_Range_parsing(self):
parse = parse_content_range
self.assertEqual(parse('bytes 100-199/200', 100), 200)
self.assertEqual(parse('bytes 100-199/*', 100), 200)
# missing
self.assertRaises(ContentRangeError, parse, None, 100)
# syntax error
self.assertRaises(ContentRangeError, parse, 'beers 100-199/*', 100)
# unexpected range
self.assertRaises(ContentRangeError, parse, 'bytes 100-199/*', 99)
# invalid instance-length
self.assertRaises(ContentRangeError, parse, 'bytes 100-199/199', 100)
# invalid byte-range-resp-spec
self.assertRaises(ContentRangeError, parse, 'bytes 100-99/199', 100)
# invalid byte-range-resp-spec
self.assertRaises(ContentRangeError, parse, 'bytes 100-100/*', 100)
def test_Content_Disposition_parsing(self):
parse = filename_from_content_disposition
self.assertEqual(
parse('attachment; filename=hello-WORLD_123.txt'),
'hello-WORLD_123.txt'
)
self.assertEqual(
parse('attachment; filename=".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='))
def test_filename_from_url(self):
self.assertEqual(filename_from_url(
url='http://example.org/foo',
content_type='text/plain'
), 'foo.txt')
self.assertEqual(filename_from_url(
url='http://example.org/foo',
content_type='text/html; charset=utf8'
), 'foo.html')
self.assertEqual(filename_from_url(
url='http://example.org/foo',
content_type=None
), 'foo')
self.assertEqual(filename_from_url(
url='http://example.org/foo',
content_type='x-foo/bar'
), 'foo')
def test_unique_filename(self):
def make_exists(unique_on_attempt=0):
# noinspection PyUnresolvedReferences,PyUnusedLocal
def exists(filename):
if exists.attempt == unique_on_attempt:
return False
exists.attempt += 1
return True
exists.attempt = 0
return exists
self.assertEqual(
get_unique_filename('foo.bar', exists=make_exists()),
'foo.bar'
)
self.assertEqual(
get_unique_filename('foo.bar', exists=make_exists(1)),
'foo.bar-1'
)
self.assertEqual(
get_unique_filename('foo.bar', exists=make_exists(10)),
'foo.bar-10'
)
class Response(object):
# noinspection PyDefaultArgument
def __init__(self, url, headers={}, status_code=200):
self.url = url
self.headers = CaseInsensitiveDict(headers)
self.status_code = status_code
# noinspection PyTypeChecker
class DownloadTest(BaseTestCase):
# TODO: more tests
def test_actual_download(self):
url = httpbin('/robots.txt')
body = urlopen(url).read().decode()
r = http(
'--download',
url,
env=TestEnvironment(
stdin_isatty=True,
stdout_isatty=False
)
)
self.assertIn('Downloading', r.stderr)
self.assertIn('[K', r.stderr)
self.assertIn('Done', r.stderr)
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):
download = Download(output_file=open(os.devnull, 'w'))
download.start(Response(url=httpbin('/')))
time.sleep(1.1)
download.chunk_downloaded(b'12345')
download.finish()
self.assertFalse(download.interrupted)
def test_download_interrupted(self):
download = Download(output_file=open(os.devnull, 'w'))
download.start(Response(
url=httpbin('/'),
headers={'Content-Length': 5}
))
download.chunk_downloaded(b'1234')
download.finish()
self.assertTrue(download.interrupted)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()

View File

@ -11,3 +11,9 @@ commands = {envpython} setup.py test
[testenv:py26] [testenv:py26]
deps = argparse deps = argparse
[testenv:py30]
deps = argparse
[testenv:py31]
deps = argparse