Compare commits

...

69 Commits
2.0.0 ... 2.2.0

Author SHA1 Message Date
5945845420 v2.2.0 2020-06-18 22:20:12 +02:00
3ee5b49256 Update README.rst 2020-06-18 10:58:13 +02:00
bb024757b6 Clarify config docs 2020-06-16 13:33:14 +02:00
d35864e79d Cleanup 2020-06-16 13:01:48 +02:00
8a106781be Add --sorted
Also add --no-(sorted|unsorted) to allow the documented resetting to default via --no-<option>.
2020-06-16 12:54:50 +02:00
23dd80563f Cleanup 2020-06-16 12:25:46 +02:00
2bab69d9fb Fix default value 2020-06-16 12:24:03 +02:00
826489950d Added --unsorted
It acts as a shortcut for --format-options=json.sort_keys:false,headers.sort:false

#128
2020-06-16 12:20:13 +02:00
b86598886e Added netrc support for auth plugins.
Enabled for --auth-type=basic and digest, 3rd parties may opt in.

This closes #718, closes #719, closes #852, and also closes #934
2020-06-16 11:05:00 +02:00
c240162cab Added a test that verifies .netrc is honored when only --auth-type is passed 2020-06-16 10:07:41 +02:00
26e29612f2 Update CHANGELOG.rst 2020-06-15 23:08:09 +02:00
37200eb055 Cleanup 2020-06-15 23:02:16 +02:00
9c68d7dd87 Remove expired cookies (#929)
* added a test for expiring cookies

* updated tests

* set up util for extracting expired cookies from response header

* Revert "updated tests"

This reverts commit a4eb5c4498.

* Revert "Revert "updated tests""

This reverts commit d242e21bce.

* added more functionality to get-expired-cookies

* add 'clear expired cookies' from session.json files

* refactored get_expired_cookies

* fixed formatting issues

* ensured key exists in cookie_header dict

* fixed linting errors

* removed unused import

* Added tests for get_expired_cookies util

* Added additional test for get_expired_cookies

* added remove_expired_cookies method directly to sessions class

* extracted logic to clear cookies to sessions.py

* refactored utils

* added tests to check expired cookies being removed from session obj

* added type annotations for methods

* Refactored test_sessions

* Seperated out expiry related tests into own class

* Refactored get_expired_cookies in utils

* Refactored remove cookie methods

* fixed linting errors

* fixed indentation and also pluralized test class name

* removed inheritance from SessionTestbase class

* Moved related test to TestExpiredCookies class

Co-authored-by: kbanc <katherine.bancoft@gmail.com>
2020-06-15 22:28:04 +02:00
7ee519ef46 Update CHANGELOG 2020-06-08 18:02:04 +02:00
c4627cc882 Custom file upload MIME type (#927)
* Support curl-like syntax for custom MIME type for files

In order to specify a custom MIME type for file uploads, a syntax
similar to that used by cURL is used so that

http -F test_file@/path/to/file.bin;type=application/zip https://...

forwards the user-provided file type if provided, otherwise falling
back to the usual guesswork out of the file extension.
2020-06-08 17:59:41 +02:00
492687b0da Add stable docs link icon 2020-05-28 14:30:56 +02:00
caeef2fb7c Use : instead of = in `--format-options 2020-05-28 14:24:15 +02:00
aae596d472 Improve --format-options error messages 2020-05-27 16:19:32 +02:00
cb51faec51 pep8 2020-05-27 16:12:31 +02:00
c2a0cef76e Add --format-options to allow disabling sorting, etc.
#128
2020-05-27 16:01:17 +02:00
493e98c833 Update CHANGELOG 2020-05-26 10:15:33 +02:00
ca02e51420 Improve plugin API docs 2020-05-26 10:07:53 +02:00
cd085cbc0d Refactor built-in plugin registry to avoid circular imports
Fix #925
2020-05-26 10:07:34 +02:00
27d57ce773 Cleanup 2020-05-23 20:30:25 +02:00
4c4efff56a Pass cert_reqs to context 2020-05-23 20:19:16 +02:00
a53505f26e Fix SSL context 2020-05-23 15:01:33 +02:00
165dc36f8d Add examples 2020-05-23 13:38:28 +02:00
5df3a91619 Add examples 2020-05-23 13:37:47 +02:00
7dbceafc01 Add docs for the https command alias 2020-05-23 13:34:59 +02:00
d62d6a77d1 Add support for --ciphers (#870) 2020-05-23 13:26:06 +02:00
0a81facccf Str env vars 2020-05-23 12:14:09 +02:00
3e20ade645 Cleanup & refactor XDG_CONFIG_HOME support 2020-05-23 12:12:35 +02:00
0c47094109 Update CHANGELOG.rst 2020-05-22 12:38:42 +02:00
defe4bc76d Fix issue links 2020-05-21 16:03:40 +02:00
afee6a7970 Added changelog entry for $XDG_CONFIG_HOME support 2020-05-21 15:59:03 +02:00
7b676dd583 Update ~/.httpie references to ~/.config/httpie 2020-05-21 15:56:53 +02:00
5af0874ed3 Support (part of) the XDG Base Directory Specification (#920)
On Unix-like systems, the configuration file now lives in
$XDG_CONFIG_HOME/httpie/ by default, not ~/.httpie/ (the behaviour on
Windows is unchanged). The previous location is still checked, in order
to support existing installations.

Searching $XDG_CONFIG_DIRS is still not supported.

Fixes #145; supersedes #436.
2020-05-21 15:50:00 +02:00
e11a2d1346 Update FUNDING.yml 2020-05-13 21:55:24 +02:00
b2044fc18d Update README.rst 2020-04-24 12:15:19 +02:00
d9a2d665ad Fix typo (#898) 2020-04-20 17:46:43 +02:00
e83e275dff Fix spelling of “GitHub” (#899) 2020-04-20 17:45:51 +02:00
4a99495466 Update CHANGELOG.rst 2020-04-18 20:44:40 +02:00
495f67229a Fix brew formula 2020-04-18 13:39:17 +02:00
45b9bae3dc Update brew formula 2020-04-18 13:24:25 +02:00
774ff148cd 2.2.0-dev 2020-04-18 12:57:56 +02:00
70a78249c1 2.1.0
#488 #840 #895
2020-04-18 12:54:40 +02:00
fc85988368 Change default JSON Accept to application/json, */*;q=0.5
See #488
2020-04-18 12:03:38 +02:00
83bd8059de accept wip 2020-04-18 12:03:37 +02:00
3af5f1f305 Add an --offline example 2020-04-16 11:47:56 +02:00
4351650691 Ignore --download with --offline 2020-04-16 11:41:12 +02:00
770976a66e Add --path-as-is docs 2020-04-16 11:29:58 +02:00
29b692d597 Add --offline mode docs 2020-04-16 11:29:33 +02:00
8936d1b71e Add tests for --offline 2020-04-16 11:28:21 +02:00
4f32b76223 Readme WIP 2020-04-15 18:07:43 +02:00
c9d770017e Fix 'Too many redirects' error message formatting 2020-04-15 17:43:08 +02:00
cdf691c212 Change default JSON Accept to application/json, */*;q=0.5
Close #488
2020-04-13 22:12:06 +02:00
684a4708d7 Add --path-as-is
Close #895
2020-04-13 20:18:56 +02:00
Mio
5754e33a75 Removed duplicate type annotation. (#888) 2020-04-13 18:15:48 +02:00
14fe7dbb27 apt (#890)
Co-authored-by: Doug Beney <contact@dougie.io>
2020-04-13 18:15:16 +02:00
3a6ac7d126 Remove unused imports 2020-04-13 17:37:27 +02:00
e9080e6b22 Build on PRs as well 2020-04-13 17:24:18 +02:00
c73858b9c3 Update examples 2020-03-27 10:03:30 +01:00
7340b2b64d Update --download doc 2020-03-27 10:03:30 +01:00
8d246415fd 2020 2020-03-22 12:29:01 +01:00
381dd4f619 Actually fixed --form file upload w/ redirected stdin error handling
#840
2020-01-23 15:56:29 +01:00
e6bad645ed Fixed --form file upload mixed with redirected stdin error handling.
Close #840
2020-01-23 15:55:00 +01:00
6e9cd139a6 Clean up Python-version related PyPI classifiers (#841)
- Removes 'Programming Language :: Python :: 3.5' per the
  README, which specifies 'Python version 3.6 or greater is required.'
- Adds 'Programming Language :: Python :: 3 :: Only' in place
2020-01-23 15:05:07 +01:00
deee2dffd0 Update CHANGELOG.rst 2020-01-13 14:50:58 +01:00
c3be722188 Update brew formula 2020-01-12 11:44:58 +01:00
42 changed files with 1566 additions and 400 deletions

2
.github/FUNDING.yml vendored
View File

@ -1,6 +1,6 @@
# These are supported funding model platforms # These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] github: jakubroztocil # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username ko_fi: # Replace with a single Ko-fi username

View File

@ -1,5 +1,5 @@
name: Build name: Build
on: [push] on: [push, pull_request]
jobs: jobs:
extras: extras:
# Run coverage and extra tests only once # Run coverage and extra tests only once

2
.gitignore vendored
View File

@ -7,7 +7,7 @@ htmlcov
############################################################################## ##############################################################################
# The bellow is GitHub template for Python project. gitignore. # The below is GitHub template for Python project. gitignore.
# <https://github.com/github/gitignore/blob/master/Python.gitignore> # <https://github.com/github/gitignore/blob/master/Python.gitignore>
############################################################################## ##############################################################################

View File

@ -6,6 +6,32 @@ This document records all notable changes to `HTTPie <https://httpie.org>`_.
This project adheres to `Semantic Versioning <https://semver.org/>`_. This project adheres to `Semantic Versioning <https://semver.org/>`_.
`2.2.0`_ (2020-06-18)
-------------------------
* Added support for custom content types for uploaded files (`#668`_).
* Added support for ``$XDG_CONFIG_HOME`` (`#920`_).
* Added support for ``Set-Cookie``-triggered cookie expiration (`#853`_).
* Added ``--format-options`` to allow disabling sorting, etc. (`#128`_)
* Added ``--sorted`` and ``--unsorted`` shortcuts for (un)setting all sorting-related ``--format-options``. (`#128`_)
* Added ``--ciphers`` to allow configuring OpenSSL ciphers (`#870`_).
* Added ``netrc`` support for auth plugins. Enabled for ``--auth-type=basic``
and ``digest``, 3rd parties may opt in (`#718`_, `#719`_, `#852`_, `#934`_).
* Fixed built-in plugins-related circular imports (`#925`_).
`2.1.0`_ (2020-04-18)
---------------------
* Added ``--path-as-is`` to bypass dot segment (``/../`` or ``/./``)
URL squashing (`#895`_).
* Changed the default ``Accept`` header value for JSON requests from
``application/json, */*`` to ``application/json, */*;q=0.5``
to clearly indicate preference (`#488`_).
* Fixed ``--form`` file upload mixed with redirected ``stdin`` error handling
(`#840`_).
`2.0.0`_ (2020-01-12) `2.0.0`_ (2020-01-12)
------------------------- -------------------------
* Removed Python 2.7 support (`EOL Jan 2020 <https://www.python.org/doc/sunset-python-2/>`_). * Removed Python 2.7 support (`EOL Jan 2020 <https://www.python.org/doc/sunset-python-2/>`_).
@ -14,7 +40,7 @@ This project adheres to `Semantic Versioning <https://semver.org/>`_.
* Replaced the old collect-all-then-process handling of HTTP communication * Replaced the old collect-all-then-process handling of HTTP communication
with one-by-one processing of each HTTP request or response as they become with one-by-one processing of each HTTP request or response as they become
available. This means that you can see headers immediately, available. This means that you can see headers immediately,
see what is being send even when the request fails, etc. see what is being sent even if the request fails, etc.
* Removed automatic config file creation to avoid concurrency issues. * Removed automatic config file creation to avoid concurrency issues.
* Removed the default 30-second connection ``--timeout`` limit. * Removed the default 30-second connection ``--timeout`` limit.
* Removed Pythons default limit of 100 response headers. * Removed Pythons default limit of 100 response headers.
@ -113,7 +139,7 @@ This project adheres to `Semantic Versioning <https://semver.org/>`_.
``$ alias https='http --default-scheme=https``. ``$ alias https='http --default-scheme=https``.
* Added ``-I`` as a shortcut for ``--ignore-stdin``. * Added ``-I`` as a shortcut for ``--ignore-stdin``.
* Added fish shell completion (located in ``extras/httpie-completion.fish`` * Added fish shell completion (located in ``extras/httpie-completion.fish``
in the Github repo). in the GitHub repo).
* Updated ``requests`` to 2.10.0 so that SOCKS support can be added via * Updated ``requests`` to 2.10.0 so that SOCKS support can be added via
``pip install requests[socks]``. ``pip install requests[socks]``.
* Changed the default JSON ``Accept`` header from ``application/json`` * Changed the default JSON ``Accept`` header from ``application/json``
@ -409,4 +435,20 @@ This project adheres to `Semantic Versioning <https://semver.org/>`_.
.. _1.0.2: https://github.com/jakubroztocil/httpie/compare/1.0.1...1.0.2 .. _1.0.2: https://github.com/jakubroztocil/httpie/compare/1.0.1...1.0.2
.. _1.0.3: https://github.com/jakubroztocil/httpie/compare/1.0.2...1.0.3 .. _1.0.3: https://github.com/jakubroztocil/httpie/compare/1.0.2...1.0.3
.. _2.0.0: https://github.com/jakubroztocil/httpie/compare/1.0.3...2.0.0 .. _2.0.0: https://github.com/jakubroztocil/httpie/compare/1.0.3...2.0.0
.. _2.1.0-dev: https://github.com/jakubroztocil/httpie/compare/2.0.0...master .. _2.1.0: https://github.com/jakubroztocil/httpie/compare/2.0.0...2.1.0
.. _2.2.0: https://github.com/jakubroztocil/httpie/compare/2.1.0...2.2.0
.. _#128: https://github.com/jakubroztocil/httpie/issues/128
.. _#488: https://github.com/jakubroztocil/httpie/issues/488
.. _#668: https://github.com/jakubroztocil/httpie/issues/668
.. _#718: https://github.com/jakubroztocil/httpie/issues/718
.. _#719: https://github.com/jakubroztocil/httpie/issues/719
.. _#840: https://github.com/jakubroztocil/httpie/issues/840
.. _#853: https://github.com/jakubroztocil/httpie/issues/853
.. _#852: https://github.com/jakubroztocil/httpie/issues/852
.. _#870: https://github.com/jakubroztocil/httpie/issues/870
.. _#895: https://github.com/jakubroztocil/httpie/issues/895
.. _#920: https://github.com/jakubroztocil/httpie/issues/920
.. _#925: https://github.com/jakubroztocil/httpie/issues/925
.. _#934: https://github.com/jakubroztocil/httpie/issues/934

View File

@ -115,7 +115,7 @@ Testing & CI
Please add tests for any new features and bug fixes. Please add tests for any new features and bug fixes.
When you open a pull request, When you open a pull request,
`Github Actions <https://github.com/jakubroztocil/httpie/actions>`_ `GitHub Actions <https://github.com/jakubroztocil/httpie/actions>`_
will automatically run HTTPies `test suite`_ against your code will automatically run HTTPies `test suite`_ against your code
so please make sure all checks pass. so please make sure all checks pass.

View File

@ -1,4 +1,4 @@
Copyright © 2012-2019 Jakub Roztocil <jakub@roztocil.co> Copyright © 2012-2020 Jakub Roztocil <jakub@roztocil.co>
Redistribution and use in source and binary forms, with or without Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met: modification, are permitted provided that the following conditions are met:

File diff suppressed because it is too large Load Diff

View File

@ -9,42 +9,42 @@ class Httpie < Formula
desc "User-friendly cURL replacement (command-line HTTP client)" desc "User-friendly cURL replacement (command-line HTTP client)"
homepage "https://httpie.org/" homepage "https://httpie.org/"
url "https://files.pythonhosted.org/packages/d5/a4/ab61c1dbfdef33c7b7f5f7df0d79eb5cd55a106601a4acc17f983f320b4a/httpie-1.0.3.tar.gz" url "https://files.pythonhosted.org/packages/e2/79/da6aec7b4356e8b325561b987c940e5b1e4de1200a5c3db7c57a97d61ca1/httpie-2.1.0.tar.gz"
sha256 "6d1b6e21da7d3ec030ae95536d4032c1129bdaf9de4adc72c596b87e5f646e80" sha256 "a76f1c72e83bd03cde3478c5f345d5570fdb2967ed19d68d09518088640b9e8e"
head "https://github.com/jakubroztocil/httpie.git" head "https://github.com/jakubroztocil/httpie.git"
bottle do bottle do
cellar :any_skip_relocation cellar :any_skip_relocation
sha256 "158258be68ac93de13860be2bef02da6fd8b68aa24b2e6609bcff1ec3f93e7a0" => :mojave sha256 "1fb33d9c85dc462c2549a03cf08670edad8014a5fdf0a7cb26493c64af40283d" => :catalina
sha256 "54352116b6fa2c3bd65f26136fdcb57986dbff8a52de5febf7aea59c126d29e1" => :high_sierra sha256 "a22030f0b96c698c90265286ee80ffbb03079d1d008a80c0bdb3ea15a17d3fbb" => :mojave
sha256 "9cce71768fe388808e11b26d651b44a6b54219f5406845b4273b5099f5c1f76f" => :sierra sha256 "9f994ecf826efe53a3a49d1c3193e271629068d11306df55adeea2842a8afb8c" => :high_sierra
end end
depends_on "python" depends_on "python@3.8"
resource "Pygments" do resource "Pygments" do
url "https://files.pythonhosted.org/packages/7e/ae/26808275fc76bf2832deb10d3a3ed3107bc4de01b85dcccbe525f2cd6d1e/Pygments-2.4.2.tar.gz" url "https://files.pythonhosted.org/packages/6e/4d/4d2fe93a35dfba417311a4ff627489a947b01dc0cc377a3673c00cf7e4b2/Pygments-2.6.1.tar.gz"
sha256 "881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297" sha256 "647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44"
end end
resource "requests" do resource "requests" do
url "https://files.pythonhosted.org/packages/01/62/ddcf76d1d19885e8579acb1b1df26a852b03472c0e46d2b959a714c90608/requests-2.22.0.tar.gz" url "https://files.pythonhosted.org/packages/f5/4f/280162d4bd4d8aad241a21aecff7a6e46891b905a4341e7ab549ebaf7915/requests-2.23.0.tar.gz"
sha256 "11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4" sha256 "b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"
end end
resource "certifi" do resource "certifi" do
url "https://files.pythonhosted.org/packages/c5/67/5d0548226bcc34468e23a0333978f0e23d28d0b3f0c71a151aef9c3f7680/certifi-2019.6.16.tar.gz" url "https://files.pythonhosted.org/packages/b8/e2/a3a86a67c3fc8249ed305fc7b7d290ebe5e4d46ad45573884761ef4dea7b/certifi-2020.4.5.1.tar.gz"
sha256 "945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695" sha256 "51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"
end end
resource "urllib3" do resource "urllib3" do
url "https://files.pythonhosted.org/packages/4c/13/2386233f7ee40aa8444b47f7463338f3cbdf00c316627558784e3f542f07/urllib3-1.25.3.tar.gz" url "https://files.pythonhosted.org/packages/05/8c/40cd6949373e23081b3ea20d5594ae523e681b6f472e600fbc95ed046a36/urllib3-1.25.9.tar.gz"
sha256 "dbe59173209418ae49d485b87d1681aefa36252ee85884c31346debd19463232" sha256 "3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527"
end end
resource "idna" do resource "idna" do
url "https://files.pythonhosted.org/packages/ad/13/eb56951b6f7950cadb579ca166e448ba77f9d24efc03edd7e55fa57d04b7/idna-2.8.tar.gz" url "https://files.pythonhosted.org/packages/cb/19/57503b5de719ee45e83472f339f617b0c01ad75cba44aba1e4c97c2b0abd/idna-2.9.tar.gz"
sha256 "c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407" sha256 "7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"
end end
resource "chardet" do resource "chardet" do
@ -53,8 +53,8 @@ class Httpie < Formula
end end
resource "PySocks" do resource "PySocks" do
url "https://files.pythonhosted.org/packages/15/ab/35824cfdee1aac662e3298275fa1e6cbedb52126d1785f8977959b769ccf/PySocks-1.7.0.tar.gz" url "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz"
sha256 "d9031ea45fdfacbe59a99273e9f0448ddb33c1580fe3831c1b09557c5718977c" sha256 "3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"
end end
def install def install

View File

@ -3,6 +3,6 @@ HTTPie - a CLI, cURL-like tool for humans.
""" """
__version__ = '2.0.0' __version__ = '2.2.0'
__author__ = 'Jakub Roztocil' __author__ = 'Jakub Roztocil'
__licence__ = 'BSD' __licence__ = 'BSD'

View File

@ -7,9 +7,16 @@ from argparse import RawDescriptionHelpFormatter
from textwrap import dedent from textwrap import dedent
from urllib.parse import urlsplit from urllib.parse import urlsplit
from httpie.cli.argtypes import AuthCredentials, KeyValueArgType, parse_auth from requests.utils import get_netrc_auth
from httpie.cli.argtypes import (
AuthCredentials, KeyValueArgType, PARSED_DEFAULT_FORMAT_OPTIONS,
parse_auth,
parse_format_options,
)
from httpie.cli.constants import ( from httpie.cli.constants import (
HTTP_GET, HTTP_POST, OUTPUT_OPTIONS, OUTPUT_OPTIONS_DEFAULT, DEFAULT_FORMAT_OPTIONS, HTTP_GET, HTTP_POST, OUTPUT_OPTIONS,
OUTPUT_OPTIONS_DEFAULT,
OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED, OUT_RESP_BODY, PRETTY_MAP, OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED, OUT_RESP_BODY, PRETTY_MAP,
PRETTY_STDOUT_TTY_ONLY, SEPARATOR_CREDENTIALS, SEPARATOR_GROUP_ALL_ITEMS, PRETTY_STDOUT_TTY_ONLY, SEPARATOR_CREDENTIALS, SEPARATOR_GROUP_ALL_ITEMS,
SEPARATOR_GROUP_DATA_ITEMS, URL_SCHEME_RE, SEPARATOR_GROUP_DATA_ITEMS, URL_SCHEME_RE,
@ -18,7 +25,7 @@ from httpie.cli.constants import (
from httpie.cli.exceptions import ParseError from httpie.cli.exceptions import ParseError
from httpie.cli.requestitems import RequestItems from httpie.cli.requestitems import RequestItems
from httpie.context import Environment from httpie.context import Environment
from httpie.plugins import plugin_manager from httpie.plugins.registry import plugin_manager
from httpie.utils import ExplicitNullAuth, get_content_type from httpie.utils import ExplicitNullAuth, get_content_type
@ -42,6 +49,8 @@ class HTTPieHelpFormatter(RawDescriptionHelpFormatter):
return text.splitlines() return text.splitlines()
# TODO: refactor and design type-annotated data structures
# for raw args + parsed args and keep things immutable.
class HTTPieArgumentParser(argparse.ArgumentParser): class HTTPieArgumentParser(argparse.ArgumentParser):
"""Adds additional logic to `argparse.ArgumentParser`. """Adds additional logic to `argparse.ArgumentParser`.
@ -78,10 +87,11 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
# Arguments processing and environment setup. # Arguments processing and environment setup.
self._apply_no_options(no_options) self._apply_no_options(no_options)
self._validate_download_options() self._process_download_options()
self._setup_standard_streams() self._setup_standard_streams()
self._process_output_options() self._process_output_options()
self._process_pretty_options() self._process_pretty_options()
self._process_format_options()
self._guess_method() self._guess_method()
self._parse_items() self._parse_items()
@ -154,7 +164,7 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
self.env.stdout_isatty = False self.env.stdout_isatty = False
def _process_auth(self): def _process_auth(self):
# TODO: refactor # TODO: refactor & simplify this method.
self.args.auth_plugin = None self.args.auth_plugin = None
default_auth_plugin = plugin_manager.get_auth_plugins()[0] default_auth_plugin = plugin_manager.get_auth_plugins()[0]
auth_type_set = self.args.auth_type is not None auth_type_set = self.args.auth_type is not None
@ -177,6 +187,19 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
self.args.auth_type = default_auth_plugin.auth_type self.args.auth_type = default_auth_plugin.auth_type
plugin = plugin_manager.get_auth_plugin(self.args.auth_type)() plugin = plugin_manager.get_auth_plugin(self.args.auth_type)()
if (not self.args.ignore_netrc
and self.args.auth is None
and plugin.netrc_parse):
# Only host needed, so its OK URL not finalized.
netrc_credentials = get_netrc_auth(self.args.url)
if netrc_credentials:
self.args.auth = AuthCredentials(
key=netrc_credentials[0],
value=netrc_credentials[1],
sep=SEPARATOR_CREDENTIALS,
orig=SEPARATOR_CREDENTIALS.join(netrc_credentials)
)
if plugin.auth_require and self.args.auth is None: if plugin.auth_require and self.args.auth is None:
self.error('--auth required') self.error('--auth required')
@ -243,10 +266,11 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
Bytes are always read. Bytes are always read.
""" """
if self.args.data: if self.args.data or self.args.files:
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. Pass ' 'data (key=value) cannot be mixed. Pass '
'--ignore-stdin to let key/value take priority.') '--ignore-stdin to let key/value take priority. '
'See https://httpie.org/doc#scripting for details.')
self.args.data = getattr(fd, 'buffer', fd).read() self.args.data = getattr(fd, 'buffer', fd).read()
def _guess_method(self): def _guess_method(self):
@ -378,10 +402,20 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
# noinspection PyTypeChecker # noinspection PyTypeChecker
self.args.prettify = PRETTY_MAP[self.args.prettify] self.args.prettify = PRETTY_MAP[self.args.prettify]
def _validate_download_options(self): def _process_download_options(self):
if self.args.offline:
self.args.download = False
self.args.download_resume = False
return
if not self.args.download: if not self.args.download:
if self.args.download_resume: if self.args.download_resume:
self.error('--continue only works with --download') self.error('--continue only works with --download')
if self.args.download_resume and not ( if self.args.download_resume and not (
self.args.download and self.args.output_file): self.args.download and self.args.output_file):
self.error('--continue requires --output to be specified') self.error('--continue requires --output to be specified')
def _process_format_options(self):
parsed_options = PARSED_DEFAULT_FORMAT_OPTIONS
for options_group in self.args.format_options or []:
parsed_options = parse_format_options(options_group, defaults=parsed_options)
self.args.format_options = parsed_options

View File

@ -2,9 +2,10 @@ import argparse
import getpass import getpass
import os import os
import sys import sys
from typing import Union, List, Optional from copy import deepcopy
from typing import List, Optional, Union
from httpie.cli.constants import SEPARATOR_CREDENTIALS from httpie.cli.constants import DEFAULT_FORMAT_OPTIONS, SEPARATOR_CREDENTIALS
from httpie.sessions import VALID_SESSION_NAME_PATTERN from httpie.sessions import VALID_SESSION_NAME_PATTERN
@ -181,3 +182,69 @@ def readable_file_arg(filename):
return filename return filename
except IOError as ex: except IOError as ex:
raise argparse.ArgumentTypeError(f'{filename}: {ex.args[1]}') raise argparse.ArgumentTypeError(f'{filename}: {ex.args[1]}')
def parse_format_options(s: str, defaults: Optional[dict]) -> dict:
"""
Parse `s` and update `defaults` with the parsed values.
>>> parse_format_options(
... defaults={'json': {'indent': 4, 'sort_keys': True}},
... s='json.indent:2,json.sort_keys:False',
... )
{'json': {'indent': 2, 'sort_keys': False}}
"""
value_map = {
'true': True,
'false': False,
}
options = deepcopy(defaults or {})
for option in s.split(','):
try:
path, value = option.lower().split(':')
section, key = path.split('.')
except ValueError:
raise argparse.ArgumentTypeError(f'invalid option {option!r}')
if value in value_map:
parsed_value = value_map[value]
else:
if value.isnumeric():
parsed_value = int(value)
else:
parsed_value = value
if defaults is None:
options.setdefault(section, {})
else:
try:
default_value = defaults[section][key]
except KeyError:
raise argparse.ArgumentTypeError(
f'invalid key {path!r}')
default_type, parsed_type = type(default_value), type(parsed_value)
if parsed_type is not default_type:
raise argparse.ArgumentTypeError(
'invalid value'
f' {value!r} in {option!r}'
f' (expected {default_type.__name__}'
f' got {parsed_type.__name__})'
)
options[section][key] = parsed_value
return options
PARSED_DEFAULT_FORMAT_OPTIONS = parse_format_options(
s=','.join(DEFAULT_FORMAT_OPTIONS),
defaults=None,
)
class UnsortedAction(argparse.Action):
def __call__(self, *args, **kwargs):
return 1

View File

@ -24,6 +24,7 @@ SEPARATOR_PROXY = ':'
SEPARATOR_DATA_STRING = '=' SEPARATOR_DATA_STRING = '='
SEPARATOR_DATA_RAW_JSON = ':=' SEPARATOR_DATA_RAW_JSON = ':='
SEPARATOR_FILE_UPLOAD = '@' SEPARATOR_FILE_UPLOAD = '@'
SEPARATOR_FILE_UPLOAD_TYPE = ';type=' # in already parsed file upload path only
SEPARATOR_DATA_EMBED_FILE_CONTENTS = '=@' SEPARATOR_DATA_EMBED_FILE_CONTENTS = '=@'
SEPARATOR_DATA_EMBED_RAW_JSON_FILE = ':=@' SEPARATOR_DATA_EMBED_RAW_JSON_FILE = ':=@'
SEPARATOR_QUERY_PARAM = '==' SEPARATOR_QUERY_PARAM = '=='
@ -83,21 +84,22 @@ PRETTY_MAP = {
} }
PRETTY_STDOUT_TTY_ONLY = object() PRETTY_STDOUT_TTY_ONLY = object()
DEFAULT_FORMAT_OPTIONS = [
'headers.sort:true',
'json.format:true',
'json.indent:4',
'json.sort_keys:true',
]
SORTED_FORMAT_OPTIONS = [
'headers.sort:true',
'json.sort_keys:true',
]
SORTED_FORMAT_OPTIONS_STRING = ','.join(SORTED_FORMAT_OPTIONS)
UNSORTED_FORMAT_OPTIONS_STRING = ','.join(
option.replace('true', 'false') for option in SORTED_FORMAT_OPTIONS)
# Defaults # Defaults
OUTPUT_OPTIONS_DEFAULT = OUT_RESP_HEAD + OUT_RESP_BODY OUTPUT_OPTIONS_DEFAULT = OUT_RESP_HEAD + OUT_RESP_BODY
OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED = OUT_RESP_BODY OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED = OUT_RESP_BODY
OUTPUT_OPTIONS_DEFAULT_OFFLINE = OUT_REQ_HEAD + OUT_REQ_BODY OUTPUT_OPTIONS_DEFAULT_OFFLINE = OUT_REQ_HEAD + OUT_REQ_BODY
SSL_VERSION_ARG_MAPPING = {
'ssl2.3': 'PROTOCOL_SSLv23',
'ssl3': 'PROTOCOL_SSLv3',
'tls1': 'PROTOCOL_TLSv1',
'tls1.1': 'PROTOCOL_TLSv1_1',
'tls1.2': 'PROTOCOL_TLSv1_2',
'tls1.3': 'PROTOCOL_TLSv1_3',
}
SSL_VERSION_ARG_MAPPING = {
cli_arg: getattr(ssl, ssl_constant)
for cli_arg, ssl_constant in SSL_VERSION_ARG_MAPPING.items()
if hasattr(ssl, ssl_constant)
}

View File

@ -8,25 +8,29 @@ from textwrap import dedent, wrap
from httpie import __doc__, __version__ from httpie import __doc__, __version__
from httpie.cli.argparser import HTTPieArgumentParser from httpie.cli.argparser import HTTPieArgumentParser
from httpie.cli.argtypes import ( from httpie.cli.argtypes import (
KeyValueArgType, SessionNameValidator, readable_file_arg, KeyValueArgType, SessionNameValidator,
readable_file_arg,
) )
from httpie.cli.constants import ( from httpie.cli.constants import (
OUTPUT_OPTIONS, OUTPUT_OPTIONS_DEFAULT, OUT_REQ_BODY, OUT_REQ_HEAD, DEFAULT_FORMAT_OPTIONS, OUTPUT_OPTIONS,
OUTPUT_OPTIONS_DEFAULT, OUT_REQ_BODY, OUT_REQ_HEAD,
OUT_RESP_BODY, OUT_RESP_HEAD, PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY, OUT_RESP_BODY, OUT_RESP_HEAD, PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY,
SEPARATOR_GROUP_ALL_ITEMS, SEPARATOR_PROXY, SSL_VERSION_ARG_MAPPING, SEPARATOR_GROUP_ALL_ITEMS, SEPARATOR_PROXY, SORTED_FORMAT_OPTIONS_STRING,
UNSORTED_FORMAT_OPTIONS_STRING,
) )
from httpie.output.formatters.colors import ( from httpie.output.formatters.colors import (
AUTO_STYLE, AVAILABLE_STYLES, DEFAULT_STYLE, AUTO_STYLE, AVAILABLE_STYLES, DEFAULT_STYLE,
) )
from httpie.plugins import plugin_manager
from httpie.plugins.builtin import BuiltinAuthPlugin from httpie.plugins.builtin import BuiltinAuthPlugin
from httpie.plugins.registry import plugin_manager
from httpie.sessions import DEFAULT_SESSIONS_DIR from httpie.sessions import DEFAULT_SESSIONS_DIR
from httpie.ssl import AVAILABLE_SSL_VERSION_ARG_MAPPING, DEFAULT_SSL_CIPHERS
parser = HTTPieArgumentParser( parser = HTTPieArgumentParser(
prog='http', prog='http',
description='%s <https://httpie.org>' % __doc__.strip(), description='%s <https://httpie.org>' % __doc__.strip(),
epilog=dedent(""" epilog=dedent('''
For every --OPTION there is also a --no-OPTION that reverts OPTION For every --OPTION there is also a --no-OPTION that reverts OPTION
to its default value. to its default value.
@ -34,7 +38,7 @@ parser = HTTPieArgumentParser(
https://github.com/jakubroztocil/httpie/issues https://github.com/jakubroztocil/httpie/issues
"""), '''),
) )
####################################################################### #######################################################################
@ -43,18 +47,18 @@ parser = HTTPieArgumentParser(
positional = parser.add_argument_group( positional = parser.add_argument_group(
title='Positional Arguments', title='Positional Arguments',
description=dedent(""" description=dedent('''
These arguments come after any flags and in the order they are listed here. These arguments come after any flags and in the order they are listed here.
Only URL is required. Only URL is required.
""") ''')
) )
positional.add_argument( positional.add_argument(
dest='method', dest='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, ...).
This argument can be omitted in which case HTTPie will use POST if there This argument can be omitted in which case HTTPie will use POST if there
@ -63,12 +67,12 @@ positional.add_argument(
$ http example.org # => GET $ http example.org # => GET
$ http example.org hello=world # => POST $ http example.org hello=world # => POST
""" '''
) )
positional.add_argument( positional.add_argument(
dest='url', dest='url',
metavar='URL', metavar='URL',
help=""" help='''
The scheme defaults to 'http://' if the URL does not include one. The scheme defaults to 'http://' if the URL does not include one.
(You can override this with: --default-scheme=https) (You can override this with: --default-scheme=https)
@ -77,7 +81,7 @@ positional.add_argument(
$ http :3000 # => http://localhost:3000 $ http :3000 # => http://localhost:3000
$ http :/foo # => http://localhost/foo $ http :/foo # => http://localhost/foo
""" '''
) )
positional.add_argument( positional.add_argument(
dest='request_items', dest='request_items',
@ -85,7 +89,7 @@ positional.add_argument(
nargs=ZERO_OR_MORE, nargs=ZERO_OR_MORE,
default=None, default=None,
type=KeyValueArgType(*SEPARATOR_GROUP_ALL_ITEMS), type=KeyValueArgType(*SEPARATOR_GROUP_ALL_ITEMS),
help=r""" help=r'''
Optional key-value pairs to be included in the request. The separator used Optional key-value pairs to be included in the request. The separator used
determines the type: determines the type:
@ -108,7 +112,8 @@ positional.add_argument(
'@' Form file fields (only with --form, -f): '@' Form file fields (only with --form, -f):
cs@~/Documents/CV.pdf cv@~/Documents/CV.pdf
cv@'~/Documents/CV.pdf;type=application/pdf'
'=@' A data field like '=', but takes a file path and embeds its content: '=@' A data field like '=', but takes a file path and embeds its content:
@ -122,7 +127,7 @@ positional.add_argument(
field-name-with\:colon=value field-name-with\:colon=value
""" '''
) )
####################################################################### #######################################################################
@ -137,24 +142,24 @@ 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 line are serialized as a JSON object. (default) Data items from the command line are serialized as a JSON object.
The Content-Type and Accept headers are set to application/json The Content-Type and Accept headers are set to application/json
(if not specified). (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 The Content-Type is set to application/x-www-form-urlencoded (if not
specified). The presence of any file fields results in a specified). The presence of any file fields results in a
multipart/form-data request. multipart/form-data request.
""" '''
) )
####################################################################### #######################################################################
@ -170,14 +175,14 @@ content_processing.add_argument(
'--compress', '-x', '--compress', '-x',
action='count', action='count',
default=0, default=0,
help=""" help='''
Content compressed (encoded) with Deflate algorithm. Content compressed (encoded) with Deflate algorithm.
The Content-Encoding header is set to deflate. The Content-Encoding header is set to deflate.
Compression is skipped if it appears that compression ratio is Compression is skipped if it appears that compression ratio is
negative. Compression can be forced by repeating the argument. negative. Compression can be forced by repeating the argument.
""" '''
) )
####################################################################### #######################################################################
@ -191,12 +196,12 @@ output_processing.add_argument(
dest='prettify', dest='prettify',
default=PRETTY_STDOUT_TTY_ONLY, default=PRETTY_STDOUT_TTY_ONLY,
choices=sorted(PRETTY_MAP.keys()), choices=sorted(PRETTY_MAP.keys()),
help=""" help='''
Controls output processing. The value can be "none" to not prettify Controls output processing. The value can be "none" to not prettify
the output (default for redirected output), "all" to apply both colors the output (default for redirected output), "all" to apply both colors
and formatting (default for terminal output), "colors", or "format". and formatting (default for terminal output), "colors", or "format".
""" '''
) )
output_processing.add_argument( output_processing.add_argument(
'--style', '-s', '--style', '-s',
@ -204,10 +209,10 @@ output_processing.add_argument(
metavar='STYLE', metavar='STYLE',
default=DEFAULT_STYLE, default=DEFAULT_STYLE,
choices=AVAILABLE_STYLES, choices=AVAILABLE_STYLES,
help=""" help='''
Output coloring style (default is "{default}"). One of: Output coloring style (default is "{default}"). It can be One of:
{available_styles} {available_styles}
The "{auto_style}" style follows your terminal's ANSI color styles. The "{auto_style}" style follows your terminal's ANSI color styles.
@ -215,15 +220,75 @@ output_processing.add_argument(
$TERM environment variable is set to "xterm-256color" or similar $TERM environment variable is set to "xterm-256color" or similar
(e.g., via `export TERM=xterm-256color' in your ~/.bashrc). (e.g., via `export TERM=xterm-256color' in your ~/.bashrc).
""".format( '''.format(
default=DEFAULT_STYLE, default=DEFAULT_STYLE,
available_styles='\n'.join( available_styles='\n'.join(
'{0}{1}'.format(8 * ' ', line.strip()) '{0}{1}'.format(8 * ' ', line.strip())
for line in wrap(', '.join(sorted(AVAILABLE_STYLES)), 60) for line in wrap(', '.join(sorted(AVAILABLE_STYLES)), 60)
).rstrip(), ).strip(),
auto_style=AUTO_STYLE, auto_style=AUTO_STYLE,
) )
) )
_sorted_kwargs = {
'action': 'append_const',
'const': SORTED_FORMAT_OPTIONS_STRING,
'dest': 'format_options'
}
_unsorted_kwargs = {
'action': 'append_const',
'const': UNSORTED_FORMAT_OPTIONS_STRING,
'dest': 'format_options'
}
# The closest approx. of the documented resetting to default via --no-<option>.
# We hide them from the doc because they act only as low-level aliases here.
output_processing.add_argument('--no-unsorted', **_sorted_kwargs, help=SUPPRESS)
output_processing.add_argument('--no-sorted', **_unsorted_kwargs, help=SUPPRESS)
output_processing.add_argument(
'--unsorted',
**_unsorted_kwargs,
help=f'''
Disables all sorting while formatting output. It is a shortcut for:
--format-options={UNSORTED_FORMAT_OPTIONS_STRING}
'''
)
output_processing.add_argument(
'--sorted',
**_sorted_kwargs,
help=f'''
Re-enables all sorting options while formatting output. It is a shortcut for:
--format-options={SORTED_FORMAT_OPTIONS_STRING}
'''
)
output_processing.add_argument(
'--format-options',
action='append',
help='''
Controls output formatting. Only relevant when formatting is enabled
through (explicit or implied) --pretty=all or --pretty=format.
The following are the default options:
{option_list}
You may use this option multiple times, as well as specify multiple
comma-separated options at the same time. For example, this modifies the
settings to disable the sorting of JSON keys, and sets the indent size to 2:
--format-options json.sort_keys:false,json.indent:2
This is something you will typically put into your config file.
'''.format(
option_list='\n'.join(
(8 * ' ') + option for option in DEFAULT_FORMAT_OPTIONS).strip()
)
)
####################################################################### #######################################################################
# Output options # Output options
@ -234,7 +299,7 @@ output_options.add_argument(
'--print', '-p', '--print', '-p',
dest='output_options', dest='output_options',
metavar='WHAT', metavar='WHAT',
help=f""" help=f'''
String specifying what the output should contain: String specifying what the output should contain:
'{OUT_REQ_HEAD}' request headers '{OUT_REQ_HEAD}' request headers
@ -247,69 +312,69 @@ output_options.add_argument(
If the output is piped to another program or to a file, then only the If the output is piped to another program or to a file, then only the
response body is printed by default. response body is printed by default.
""" '''
) )
output_options.add_argument( output_options.add_argument(
'--headers', '-h', '--headers', '-h',
dest='output_options', dest='output_options',
action='store_const', action='store_const',
const=OUT_RESP_HEAD, const=OUT_RESP_HEAD,
help=f""" help=f'''
Print only the response headers. Shortcut for --print={OUT_RESP_HEAD}. Print only the response headers. Shortcut for --print={OUT_RESP_HEAD}.
""" '''
) )
output_options.add_argument( output_options.add_argument(
'--body', '-b', '--body', '-b',
dest='output_options', dest='output_options',
action='store_const', action='store_const',
const=OUT_RESP_BODY, const=OUT_RESP_BODY,
help=f""" help=f'''
Print only the response body. Shortcut for --print={OUT_RESP_BODY}. Print only the response body. Shortcut for --print={OUT_RESP_BODY}.
""" '''
) )
output_options.add_argument( output_options.add_argument(
'--verbose', '-v', '--verbose', '-v',
dest='verbose', dest='verbose',
action='store_true', action='store_true',
help=""" help='''
Verbose output. Print the whole request as well as the response. Also print Verbose output. Print the whole request as well as the response. Also print
any intermediary requests/responses (such as redirects). any intermediary requests/responses (such as redirects).
It's a shortcut for: --all --print={0} It's a shortcut for: --all --print={0}
""".format(''.join(OUTPUT_OPTIONS)) '''.format(''.join(OUTPUT_OPTIONS))
) )
output_options.add_argument( output_options.add_argument(
'--all', '--all',
default=False, default=False,
action='store_true', action='store_true',
help=""" help='''
By default, only the final request/response is shown. Use this flag to show By default, only the final request/response is shown. Use this flag to show
any intermediary requests/responses as well. Intermediary requests include any intermediary requests/responses as well. Intermediary requests include
followed redirects (with --follow), the first unauthorized request when followed redirects (with --follow), the first unauthorized request when
Digest auth is used (--auth=digest), etc. Digest auth is used (--auth=digest), etc.
""" '''
) )
output_options.add_argument( output_options.add_argument(
'--history-print', '-P', '--history-print', '-P',
dest='output_options_history', dest='output_options_history',
metavar='WHAT', metavar='WHAT',
help=""" help='''
The same as --print, -p but applies only to intermediary requests/responses The same as --print, -p but applies only to intermediary requests/responses
(such as redirects) when their inclusion is enabled with --all. If this (such as redirects) when their inclusion is enabled with --all. If this
options is not specified, then they are formatted the same way as the final options is not specified, then they are formatted the same way as the final
response. response.
""" '''
) )
output_options.add_argument( output_options.add_argument(
'--stream', '-S', '--stream', '-S',
action='store_true', action='store_true',
default=False, default=False,
help=""" help='''
Always stream the output by line, i.e., behave like `tail -f'. Always stream the output by line, i.e., behave like `tail -f'.
Without --stream and with --pretty (either set or implied), Without --stream and with --pretty (either set or implied),
@ -321,19 +386,19 @@ 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_options.add_argument(
'--output', '-o', '--output', '-o',
type=FileType('a+b'), type=FileType('a+b'),
dest='output_file', dest='output_file',
metavar='FILE', metavar='FILE',
help=""" help='''
Save output to FILE instead of stdout. If --download is also set, then only Save output to FILE instead of stdout. If --download is also set, then only
the response body is saved to FILE. Other parts of the HTTP exchange are the response body is saved to FILE. Other parts of the HTTP exchange are
printed to stderr. printed to stderr.
""" '''
) )
@ -341,12 +406,12 @@ output_options.add_argument(
'--download', '-d', '--download', '-d',
action='store_true', action='store_true',
default=False, default=False,
help=""" help='''
Do not print the response body to stdout. Rather, download it and store it Do not print the response body to stdout. Rather, download it and store it
in a file. The filename is guessed unless specified with --output in a file. The filename is guessed unless specified with --output
[filename]. This action is similar to the default behaviour of wget. [filename]. This action is similar to the default behaviour of wget.
""" '''
) )
output_options.add_argument( output_options.add_argument(
@ -354,11 +419,11 @@ output_options.add_argument(
dest='download_resume', dest='download_resume',
action='store_true', action='store_true',
default=False, default=False,
help=""" help='''
Resume an interrupted download. Note that the --output option needs to be Resume an interrupted download. Note that the --output option needs to be
specified as well. specified as well.
""" '''
) )
####################################################################### #######################################################################
@ -376,7 +441,7 @@ sessions.add_argument(
'--session', '--session',
metavar='SESSION_NAME_OR_PATH', metavar='SESSION_NAME_OR_PATH',
type=session_name_validator, type=session_name_validator,
help=f""" help=f'''
Create, or reuse and update a session. Within a session, custom headers, Create, or reuse and update a session. Within a session, custom headers,
auth credential, as well as any cookies sent by the server persist between auth credential, as well as any cookies sent by the server persist between
requests. requests.
@ -385,17 +450,17 @@ sessions.add_argument(
{DEFAULT_SESSIONS_DIR}/<HOST>/<SESSION_NAME>.json. {DEFAULT_SESSIONS_DIR}/<HOST>/<SESSION_NAME>.json.
""" '''
) )
sessions.add_argument( sessions.add_argument(
'--session-read-only', '--session-read-only',
metavar='SESSION_NAME_OR_PATH', metavar='SESSION_NAME_OR_PATH',
type=session_name_validator, type=session_name_validator,
help=""" help='''
Create or read a session without updating it form the request/response Create or read a session without updating it form the request/response
exchange. exchange.
""" '''
) )
####################################################################### #######################################################################
@ -408,11 +473,11 @@ auth.add_argument(
'--auth', '-a', '--auth', '-a',
default=None, default=None,
metavar='USER[:PASS]', metavar='USER[:PASS]',
help=""" help='''
If only the username is provided (-a username), HTTPie will prompt If only the username is provided (-a username), HTTPie will prompt
for the password. for the password.
""", ''',
) )
@ -431,12 +496,12 @@ auth.add_argument(
'--auth-type', '-A', '--auth-type', '-A',
choices=_AuthTypeLazyChoices(), choices=_AuthTypeLazyChoices(),
default=None, default=None,
help=""" help='''
The authentication mechanism to be used. Defaults to "{default}". The authentication mechanism to be used. Defaults to "{default}".
{types} {types}
""".format(default=_auth_plugins[0].auth_type, types='\n '.join( '''.format(default=_auth_plugins[0].auth_type, types='\n '.join(
'"{type}": {name}{package}{description}'.format( '"{type}": {name}{package}{description}'.format(
type=plugin.auth_type, type=plugin.auth_type,
name=plugin.name, name=plugin.name,
@ -456,10 +521,10 @@ auth.add_argument(
'--ignore-netrc', '--ignore-netrc',
default=False, default=False,
action='store_true', action='store_true',
help=""" help='''
Ignore credentials from .netrc. Ignore credentials from .netrc.
""", ''',
) )
####################################################################### #######################################################################
@ -472,9 +537,9 @@ network.add_argument(
'--offline', '--offline',
default=False, default=False,
action='store_true', action='store_true',
help=""" help='''
Build the request and print it but dont actually send it. Build the request and print it but dont actually send it.
""" '''
) )
network.add_argument( network.add_argument(
'--proxy', '--proxy',
@ -482,43 +547,43 @@ network.add_argument(
action='append', action='append',
metavar='PROTOCOL:PROXY_URL', metavar='PROTOCOL:PROXY_URL',
type=KeyValueArgType(SEPARATOR_PROXY), type=KeyValueArgType(SEPARATOR_PROXY),
help=""" help='''
String mapping protocol to the URL of the proxy String mapping protocol to the URL of the proxy
(e.g. http:http://foo.bar:3128). You can specify multiple proxies with (e.g. http:http://foo.bar:3128). You can specify multiple proxies with
different protocols. The environment variables $ALL_PROXY, $HTTP_PROXY, different protocols. The environment variables $ALL_PROXY, $HTTP_PROXY,
and $HTTPS_proxy are supported as well. and $HTTPS_proxy are supported as well.
""" '''
) )
network.add_argument( network.add_argument(
'--follow', '-F', '--follow', '-F',
default=False, default=False,
action='store_true', action='store_true',
help=""" help='''
Follow 30x Location redirects. Follow 30x Location redirects.
""" '''
) )
network.add_argument( network.add_argument(
'--max-redirects', '--max-redirects',
type=int, type=int,
default=30, default=30,
help=""" help='''
By default, requests have a limit of 30 redirects (works with --follow). By default, requests have a limit of 30 redirects (works with --follow).
""" '''
) )
network.add_argument( network.add_argument(
'--max-headers', '--max-headers',
type=int, type=int,
default=0, default=0,
help=""" help='''
The maximum number of response headers to be read before giving up The maximum number of response headers to be read before giving up
(default 0, i.e., no limit). (default 0, i.e., no limit).
""" '''
) )
network.add_argument( network.add_argument(
@ -526,7 +591,7 @@ network.add_argument(
type=float, type=float,
default=0, default=0,
metavar='SECONDS', metavar='SECONDS',
help=""" help='''
The connection timeout of the request in seconds. The connection timeout of the request in seconds.
The default value is 0, i.e., there is no timeout limit. The default value is 0, i.e., there is no timeout limit.
This is not a time limit on the entire response download; This is not a time limit on the entire response download;
@ -534,13 +599,13 @@ network.add_argument(
timeout seconds (more precisely, if no bytes have been received on timeout seconds (more precisely, if no bytes have been received on
the underlying socket for timeout seconds). the underlying socket for timeout seconds).
""" '''
) )
network.add_argument( network.add_argument(
'--check-status', '--check-status',
default=False, default=False,
action='store_true', action='store_true',
help=""" help='''
By default, HTTPie exits with 0 when no network or other fatal errors 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 occur. This flag instructs HTTPie to also check the HTTP status code and
exit with an error if the status indicates one. exit with an error if the status indicates one.
@ -550,7 +615,16 @@ network.add_argument(
3xx (Redirect) and --follow hasn't been set, then the exit status is 3. 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. Also an error message is written to stderr if stdout is redirected.
""" '''
)
network.add_argument(
'--path-as-is',
default=False,
action='store_true',
help='''
Bypass dot segment (/../ or /./) URL squashing.
'''
) )
####################################################################### #######################################################################
@ -561,47 +635,58 @@ ssl = parser.add_argument_group(title='SSL')
ssl.add_argument( ssl.add_argument(
'--verify', '--verify',
default='yes', default='yes',
help=""" help='''
Set to "no" (or "false") to skip checking the host's SSL certificate. Set to "no" (or "false") to skip checking the host's SSL certificate.
Defaults to "yes" ("true"). You can also pass the path to a CA_BUNDLE file Defaults to "yes" ("true"). You can also pass the path to a CA_BUNDLE file
for private certs. (Or you can set the REQUESTS_CA_BUNDLE environment for private certs. (Or you can set the REQUESTS_CA_BUNDLE environment
variable instead.) variable instead.)
""" '''
) )
ssl.add_argument( ssl.add_argument(
'--ssl', # TODO: Maybe something more general, such as --secure-protocol? '--ssl', # TODO: Maybe something more general, such as --secure-protocol?
dest='ssl_version', dest='ssl_version',
choices=list(sorted(SSL_VERSION_ARG_MAPPING.keys())), choices=list(sorted(AVAILABLE_SSL_VERSION_ARG_MAPPING.keys())),
help=""" help='''
The desired protocol version to use. This will default to The desired protocol version to use. This will default to
SSL v2.3 which will negotiate the highest protocol that both SSL v2.3 which will negotiate the highest protocol that both
the server and your installation of OpenSSL support. Available protocols the server and your installation of OpenSSL support. Available protocols
may vary depending on OpenSSL installation (only the supported ones may vary depending on OpenSSL installation (only the supported ones
are shown here). are shown here).
""" '''
)
ssl.add_argument(
'--ciphers',
help=f'''
A string in the OpenSSL cipher list format. By default, the following
is used:
{DEFAULT_SSL_CIPHERS}
'''
) )
ssl.add_argument( ssl.add_argument(
'--cert', '--cert',
default=None, default=None,
type=readable_file_arg, type=readable_file_arg,
help=""" help='''
You can specify a local cert to use as client side SSL certificate. You can specify a local cert to use as client side SSL certificate.
This file may either contain both private key and certificate or you may This file may either contain both private key and certificate or you may
specify --cert-key separately. specify --cert-key separately.
""" '''
) )
ssl.add_argument( ssl.add_argument(
'--cert-key', '--cert-key',
default=None, default=None,
type=readable_file_arg, type=readable_file_arg,
help=""" help='''
The private key to use with SSL. Only needed if --cert is given and the The private key to use with SSL. Only needed if --cert is given and the
certificate file does not contain the private key. certificate file does not contain the private key.
""" '''
) )
####################################################################### #######################################################################
@ -614,53 +699,53 @@ troubleshooting.add_argument(
'--ignore-stdin', '-I', '--ignore-stdin', '-I',
action='store_true', action='store_true',
default=False, default=False,
help=""" help='''
Do not attempt to read stdin. Do not attempt to read stdin.
""" '''
) )
troubleshooting.add_argument( troubleshooting.add_argument(
'--help', '--help',
action='help', action='help',
default=SUPPRESS, default=SUPPRESS,
help=""" help='''
Show this help message and exit. Show this help message and exit.
""" '''
) )
troubleshooting.add_argument( troubleshooting.add_argument(
'--version', '--version',
action='version', action='version',
version=__version__, version=__version__,
help=""" help='''
Show version and exit. Show version and exit.
""" '''
) )
troubleshooting.add_argument( troubleshooting.add_argument(
'--traceback', '--traceback',
action='store_true', action='store_true',
default=False, default=False,
help=""" help='''
Prints the exception traceback should one occur. Prints the exception traceback should one occur.
""" '''
) )
troubleshooting.add_argument( troubleshooting.add_argument(
'--default-scheme', '--default-scheme',
default="http", default="http",
help=""" help='''
The default scheme to use if not specified in the URL. The default scheme to use if not specified in the URL.
""" '''
) )
troubleshooting.add_argument( troubleshooting.add_argument(
'--debug', '--debug',
action='store_true', action='store_true',
default=False, default=False,
help=""" help='''
Prints the exception traceback should one occur, as well as other Prints the exception traceback should one occur, as well as other
information useful for debugging HTTPie itself and for reporting bugs. information useful for debugging HTTPie itself and for reporting bugs.
""" '''
) )

View File

@ -6,7 +6,8 @@ from httpie.cli.argtypes import KeyValueArg
from httpie.cli.constants import ( from httpie.cli.constants import (
SEPARATOR_DATA_EMBED_FILE_CONTENTS, SEPARATOR_DATA_EMBED_RAW_JSON_FILE, SEPARATOR_DATA_EMBED_FILE_CONTENTS, SEPARATOR_DATA_EMBED_RAW_JSON_FILE,
SEPARATOR_DATA_RAW_JSON, SEPARATOR_DATA_STRING, SEPARATOR_FILE_UPLOAD, SEPARATOR_DATA_RAW_JSON, SEPARATOR_DATA_STRING, SEPARATOR_FILE_UPLOAD,
SEPARATOR_HEADER, SEPARATOR_HEADER_EMPTY, SEPARATOR_QUERY_PARAM, SEPARATOR_FILE_UPLOAD_TYPE, SEPARATOR_HEADER, SEPARATOR_HEADER_EMPTY,
SEPARATOR_QUERY_PARAM,
) )
from httpie.cli.dicts import ( from httpie.cli.dicts import (
RequestDataDict, RequestFilesDict, RequestHeadersDict, RequestJSONDataDict, RequestDataDict, RequestFilesDict, RequestHeadersDict, RequestJSONDataDict,
@ -95,7 +96,10 @@ def process_query_param_arg(arg: KeyValueArg) -> str:
def process_file_upload_arg(arg: KeyValueArg) -> Tuple[str, IO, str]: def process_file_upload_arg(arg: KeyValueArg) -> Tuple[str, IO, str]:
filename = arg.value parts = arg.value.split(SEPARATOR_FILE_UPLOAD_TYPE)
filename = parts[0]
mime_type = parts[1] if len(parts) > 1 else None
try: try:
with open(os.path.expanduser(filename), 'rb') as f: with open(os.path.expanduser(filename), 'rb') as f:
contents = f.read() contents = f.read()
@ -104,7 +108,7 @@ def process_file_upload_arg(arg: KeyValueArg) -> Tuple[str, IO, str]:
return ( return (
os.path.basename(filename), os.path.basename(filename),
BytesIO(contents), BytesIO(contents),
get_content_type(filename), mime_type or get_content_type(filename),
) )

View File

@ -6,30 +6,24 @@ import zlib
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Iterable, Union from typing import Iterable, Union
from urllib.parse import urlparse, urlunparse
import requests import requests
from requests.adapters import HTTPAdapter # noinspection PyPackageRequirements
import urllib3
from httpie import __version__ from httpie import __version__
from httpie.cli.constants import SSL_VERSION_ARG_MAPPING
from httpie.cli.dicts import RequestHeadersDict from httpie.cli.dicts import RequestHeadersDict
from httpie.plugins import plugin_manager from httpie.plugins.registry import plugin_manager
from httpie.sessions import get_httpie_session from httpie.sessions import get_httpie_session
from httpie.utils import repr_dict from httpie.ssl import AVAILABLE_SSL_VERSION_ARG_MAPPING, HTTPieHTTPSAdapter
from httpie.utils import get_expired_cookies, repr_dict
try:
# noinspection PyPackageRequirements
import urllib3
# <https://urllib3.readthedocs.io/en/latest/security.html>
urllib3.disable_warnings()
except (ImportError, AttributeError):
pass
urllib3.disable_warnings()
FORM_CONTENT_TYPE = 'application/x-www-form-urlencoded; charset=utf-8' FORM_CONTENT_TYPE = 'application/x-www-form-urlencoded; charset=utf-8'
JSON_CONTENT_TYPE = 'application/json' JSON_CONTENT_TYPE = 'application/json'
JSON_ACCEPT = f'{JSON_CONTENT_TYPE}, */*' JSON_ACCEPT = f'{JSON_CONTENT_TYPE}, */*;q=0.5'
DEFAULT_UA = f'HTTPie/{__version__}' DEFAULT_UA = f'HTTPie/{__version__}'
@ -56,6 +50,8 @@ def collect_messages(
send_kwargs_mergeable_from_env = make_send_kwargs_mergeable_from_env(args) send_kwargs_mergeable_from_env = make_send_kwargs_mergeable_from_env(args)
requests_session = build_requests_session( requests_session = build_requests_session(
ssl_version=args.ssl_version, ssl_version=args.ssl_version,
ciphers=args.ciphers,
verify=bool(send_kwargs_mergeable_from_env['verify'])
) )
if httpie_session: if httpie_session:
@ -77,9 +73,15 @@ def collect_messages(
request = requests.Request(**request_kwargs) request = requests.Request(**request_kwargs)
prepared_request = requests_session.prepare_request(request) prepared_request = requests_session.prepare_request(request)
if args.path_as_is:
prepared_request.url = ensure_path_as_is(
orig_url=args.url,
prepped_url=prepared_request.url,
)
if args.compress and prepared_request.body: if args.compress and prepared_request.body:
compress_body(prepared_request, always=args.compress > 1) compress_body(prepared_request, always=args.compress > 1)
response_count = 0 response_count = 0
expired_cookies = []
while prepared_request: while prepared_request:
yield prepared_request yield prepared_request
if not args.offline: if not args.offline:
@ -93,6 +95,12 @@ def collect_messages(
**send_kwargs_merged, **send_kwargs_merged,
**send_kwargs, **send_kwargs,
) )
# noinspection PyProtectedMember
expired_cookies += get_expired_cookies(
headers=response.raw._original_response.msg._headers
)
response_count += 1 response_count += 1
if response.next: if response.next:
if args.max_redirects and response_count == args.max_redirects: if args.max_redirects and response_count == args.max_redirects:
@ -108,6 +116,10 @@ def collect_messages(
if httpie_session: if httpie_session:
if httpie_session.is_new() or not args.session_read_only: if httpie_session.is_new() or not args.session_read_only:
httpie_session.cookies = requests_session.cookies httpie_session.cookies = requests_session.cookies
httpie_session.remove_cookies(
# TODO: take path & domain into account?
cookie['name'] for cookie in expired_cookies
)
httpie_session.save() httpie_session.save()
@ -115,6 +127,7 @@ def collect_messages(
@contextmanager @contextmanager
def max_headers(limit): def max_headers(limit):
# <https://github.com/jakubroztocil/httpie/issues/802> # <https://github.com/jakubroztocil/httpie/issues/802>
# noinspection PyUnresolvedReferences
orig = http.client._MAXHEADERS orig = http.client._MAXHEADERS
http.client._MAXHEADERS = limit or float('Inf') http.client._MAXHEADERS = limit or float('Inf')
try: try:
@ -139,29 +152,23 @@ def compress_body(request: requests.PreparedRequest, always: bool):
request.headers['Content-Length'] = str(len(deflated_data)) request.headers['Content-Length'] = str(len(deflated_data))
class HTTPieHTTPSAdapter(HTTPAdapter):
def __init__(self, ssl_version=None, **kwargs):
self._ssl_version = ssl_version
super().__init__(**kwargs)
def init_poolmanager(self, *args, **kwargs):
kwargs['ssl_version'] = self._ssl_version
super().init_poolmanager(*args, **kwargs)
def build_requests_session( def build_requests_session(
verify: bool,
ssl_version: str = None, ssl_version: str = None,
ciphers: str = None,
) -> requests.Session: ) -> requests.Session:
requests_session = requests.Session() requests_session = requests.Session()
# Install our adapter. # Install our adapter.
requests_session.mount('https://', HTTPieHTTPSAdapter( https_adapter = HTTPieHTTPSAdapter(
ciphers=ciphers,
verify=verify,
ssl_version=( ssl_version=(
SSL_VERSION_ARG_MAPPING[ssl_version] AVAILABLE_SSL_VERSION_ARG_MAPPING[ssl_version]
if ssl_version else None if ssl_version else None
) ),
)) )
requests_session.mount('https://', https_adapter)
# Install adapters from plugins. # Install adapters from plugins.
for plugin_cls in plugin_manager.get_transport_plugins(): for plugin_cls in plugin_manager.get_transport_plugins():
@ -278,3 +285,30 @@ def make_request_kwargs(
} }
return kwargs return kwargs
def ensure_path_as_is(orig_url: str, prepped_url: str) -> str:
"""
Handle `--path-as-is` by replacing the path component of the prepared
URL with the path component from the original URL. Other parts stay
untouched because other (welcome) processing on the URL might have
taken place.
<https://github.com/jakubroztocil/httpie/issues/895>
<https://ec.haxx.se/http/http-basics#path-as-is>
<https://curl.haxx.se/libcurl/c/CURLOPT_PATH_AS_IS.html>
>>> ensure_path_as_is('http://foo/../', 'http://foo/?foo=bar')
'http://foo/../?foo=bar'
"""
parsed_orig, parsed_prepped = urlparse(orig_url), urlparse(prepped_url)
final_dict = {
# noinspection PyProtectedMember
**parsed_prepped._asdict(),
'path': parsed_orig.path,
}
final_url = urlunparse(tuple(final_dict.values()))
return final_url

View File

@ -8,11 +8,54 @@ from httpie import __version__
from httpie.compat import is_windows from httpie.compat import is_windows
DEFAULT_CONFIG_DIR = Path(os.environ.get( ENV_XDG_CONFIG_HOME = 'XDG_CONFIG_HOME'
'HTTPIE_CONFIG_DIR', ENV_HTTPIE_CONFIG_DIR = 'HTTPIE_CONFIG_DIR'
os.path.expanduser('~/.httpie') if not is_windows else DEFAULT_CONFIG_DIRNAME = 'httpie'
os.path.expandvars(r'%APPDATA%\\httpie') DEFAULT_RELATIVE_XDG_CONFIG_HOME = Path('.config')
)) DEFAULT_RELATIVE_LEGACY_CONFIG_DIR = Path('.httpie')
DEFAULT_WINDOWS_CONFIG_DIR = Path(
os.path.expandvars('%APPDATA%')) / DEFAULT_CONFIG_DIRNAME
def get_default_config_dir() -> Path:
"""
Return the path to the httpie configuration directory.
This directory isn't guaranteed to exist, and nor are any of its
ancestors (only the legacy ~/.httpie, if returned, is guaranteed to exist).
XDG Base Directory Specification support:
<https://wiki.archlinux.org/index.php/XDG_Base_Directory>
$XDG_CONFIG_HOME is supported; $XDG_CONFIG_DIRS is not
"""
# 1. explicitly set through env
env_config_dir = os.environ.get(ENV_HTTPIE_CONFIG_DIR)
if env_config_dir:
return Path(env_config_dir)
# 2. Windows
if is_windows:
return DEFAULT_WINDOWS_CONFIG_DIR
home_dir = Path.home()
# 3. legacy ~/.httpie
legacy_config_dir = home_dir / DEFAULT_RELATIVE_LEGACY_CONFIG_DIR
if legacy_config_dir.exists():
return legacy_config_dir
# 4. XDG
xdg_config_home_dir = os.environ.get(
ENV_XDG_CONFIG_HOME, # 4.1. explicit
home_dir / DEFAULT_RELATIVE_XDG_CONFIG_HOME # 4.2. default
)
return Path(xdg_config_home_dir) / DEFAULT_CONFIG_DIRNAME
DEFAULT_CONFIG_DIR = get_default_config_dir()
class ConfigFileError(Exception): class ConfigFileError(Exception):

View File

@ -1,7 +1,6 @@
import os
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Union, IO, Optional from typing import IO, Optional
try: try:

View File

@ -13,10 +13,11 @@ from httpie.client import collect_messages
from httpie.context import Environment from httpie.context import Environment
from httpie.downloads import Downloader from httpie.downloads import Downloader
from httpie.output.writer import write_message, write_stream from httpie.output.writer import write_message, write_stream
from httpie.plugins import plugin_manager from httpie.plugins.registry import plugin_manager
from httpie.status import ExitStatus, http_status_to_exit_status from httpie.status import ExitStatus, http_status_to_exit_status
# noinspection PyDefaultArgument
def main( def main(
args: List[Union[str, bytes]] = sys.argv, args: List[Union[str, bytes]] = sys.argv,
env=Environment(), env=Environment(),
@ -90,7 +91,7 @@ def main(
exit_status = ExitStatus.ERROR_TOO_MANY_REDIRECTS exit_status = ExitStatus.ERROR_TOO_MANY_REDIRECTS
env.log_error( env.log_error(
f'Too many redirects' f'Too many redirects'
f' (--max-redirects=parsed_args.max_redirects).' f' (--max-redirects={parsed_args.max_redirects}).'
) )
except Exception as e: except Exception as e:
# TODO: Further distinction between expected and unexpected errors. # TODO: Further distinction between expected and unexpected errors.

View File

@ -196,7 +196,6 @@ class Downloader:
""" """
:param resume: Should the download resume if partial download :param resume: Should the download resume if partial download
already exists. already exists.
:type resume: bool
:param output_file: The file to store response body in. If not :param output_file: The file to store response body in. If not
provided, it will be guessed from the response. provided, it will be guessed from the response.
@ -322,7 +321,6 @@ class Downloader:
:param chunk: A chunk of response body data that has just :param chunk: A chunk of response body data that has just
been downloaded and written to the output. been downloaded and written to the output.
:type chunk: bytes
""" """
self.status.chunk_downloaded(len(chunk)) self.status.chunk_downloaded(len(chunk))

View File

@ -3,6 +3,10 @@ from httpie.plugins import FormatterPlugin
class HeadersFormatter(FormatterPlugin): class HeadersFormatter(FormatterPlugin):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.enabled = self.format_options['headers']['sort']
def format_headers(self, headers: str) -> str: def format_headers(self, headers: str) -> str:
""" """
Sorts headers by name while retaining relative Sorts headers by name while retaining relative

View File

@ -4,11 +4,12 @@ import json
from httpie.plugins import FormatterPlugin from httpie.plugins import FormatterPlugin
DEFAULT_INDENT = 4
class JSONFormatter(FormatterPlugin): class JSONFormatter(FormatterPlugin):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.enabled = self.format_options['json']['format']
def format_body(self, body: str, mime: str) -> str: def format_body(self, body: str, mime: str) -> str:
maybe_json = [ maybe_json = [
'json', 'json',
@ -26,8 +27,8 @@ class JSONFormatter(FormatterPlugin):
# unicode escapes to improve readability. # unicode escapes to improve readability.
body = json.dumps( body = json.dumps(
obj=obj, obj=obj,
sort_keys=True, sort_keys=self.format_options['json']['sort_keys'],
ensure_ascii=False, ensure_ascii=False,
indent=DEFAULT_INDENT indent=self.format_options['json']['indent']
) )
return body return body

View File

@ -1,7 +1,8 @@
import re import re
from typing import Optional, List from typing import Optional, List
from httpie.plugins import plugin_manager, ConverterPlugin from httpie.plugins import ConverterPlugin
from httpie.plugins.registry import plugin_manager
from httpie.context import Environment from httpie.context import Environment

View File

@ -152,6 +152,7 @@ def get_stream_type_and_kwargs(
groups=args.prettify, groups=args.prettify,
color_scheme=args.style, color_scheme=args.style,
explicit_json=args.json, explicit_json=args.json,
format_options=args.format_options,
) )
} }
else: else:

View File

@ -7,18 +7,3 @@ from httpie.plugins.base import (
AuthPlugin, FormatterPlugin, AuthPlugin, FormatterPlugin,
ConverterPlugin, TransportPlugin ConverterPlugin, TransportPlugin
) )
from httpie.plugins.manager import PluginManager
from httpie.plugins.builtin import BasicAuthPlugin, DigestAuthPlugin
from httpie.output.formatters.headers import HeadersFormatter
from httpie.output.formatters.json import JSONFormatter
from httpie.output.formatters.colors import ColorFormatter
plugin_manager = PluginManager()
plugin_manager.register(
BasicAuthPlugin,
DigestAuthPlugin,
HeadersFormatter,
JSONFormatter,
ColorFormatter,
)

View File

@ -3,7 +3,7 @@ class BasePlugin:
# The name of the plugin, eg. "My auth". # The name of the plugin, eg. "My auth".
name = None name = None
# Optional short description. Will be be shown in the help # Optional short description. It will be shown in the help
# under --auth-type. # under --auth-type.
description = None description = None
@ -15,7 +15,9 @@ class AuthPlugin(BasePlugin):
""" """
Base auth plugin class. Base auth plugin class.
See <https://github.com/httpie/httpie-ntlm> for an example auth plugin. See httpie-ntlm for an example auth plugin:
<https://github.com/httpie/httpie-ntlm>
See also `test_auth_plugins.py` See also `test_auth_plugins.py`
@ -33,13 +35,22 @@ class AuthPlugin(BasePlugin):
# Set this to `False` to disable the parsing and error handling. # Set this to `False` to disable the parsing and error handling.
auth_parse = True auth_parse = True
# Set to `True` to make it possible for this auth
# plugin to acquire credentials from the users netrc file(s).
# It is used as a fallback when the credentials are not provided explicitly
# through `--auth, -a`. Enabling this will allow skipping `--auth, -a`
# even when `auth_require` is set `True` (provided that netrc provides
# credential for a given host).
netrc_parse = False
# If both `auth_parse` and `prompt_password` are set to `True`, # If both `auth_parse` and `prompt_password` are set to `True`,
# and the value of `-a` lacks the password part, # and the value of `-a` lacks the password part,
# then the user will be prompted to type the password in. # then the user will be prompted to type the password in.
prompt_password = True prompt_password = True
# Will be set to the raw value of `-a` (if provided) before # Will be set to the raw value of `-a` (if provided) before
# `get_auth()` gets called. # `get_auth()` gets called. If the credentials came from a netrc file,
# then this is `None`.
raw_auth = None raw_auth = None
def get_auth(self, username=None, password=None): def get_auth(self, username=None, password=None):
@ -58,8 +69,13 @@ class AuthPlugin(BasePlugin):
class TransportPlugin(BasePlugin): class TransportPlugin(BasePlugin):
""" """
Requests transport adapter docs:
https://2.python-requests.org/en/latest/user/advanced/#transport-adapters <https://requests.readthedocs.io/en/latest/user/advanced/#transport-adapters>
See httpie-unixsocket for an example transport plugin:
<https://github.com/httpie/httpie-unixsocket>
""" """
@ -76,6 +92,14 @@ class TransportPlugin(BasePlugin):
class ConverterPlugin(BasePlugin): class ConverterPlugin(BasePlugin):
"""
Possibly converts response data for prettified terminal display.
See httpie-msgpack for an example converter plugin:
<https://github.com/rasky/httpie-msgpack>.
"""
def __init__(self, mime): def __init__(self, mime):
self.mime = mime self.mime = mime
@ -89,17 +113,22 @@ class ConverterPlugin(BasePlugin):
class FormatterPlugin(BasePlugin): class FormatterPlugin(BasePlugin):
"""
Possibly formats response body & headers for prettified terminal display.
"""
group_name = 'format' group_name = 'format'
def __init__(self, **kwargs): def __init__(self, **kwargs):
""" """
:param env: an class:`Environment` instance :param env: an class:`Environment` instance
:param kwargs: additional keyword argument that some :param kwargs: additional keyword argument that some
processor might require. formatters might require.
""" """
self.enabled = True self.enabled = True
self.kwargs = kwargs self.kwargs = kwargs
self.format_options = kwargs['format_options']
def format_headers(self, headers: str) -> str: def format_headers(self, headers: str) -> str:
"""Return processed `headers` """Return processed `headers`

View File

@ -22,6 +22,7 @@ class HTTPBasicAuth(requests.auth.HTTPBasicAuth):
See https://github.com/jakubroztocil/httpie/issues/212 See https://github.com/jakubroztocil/httpie/issues/212
""" """
# noinspection PyTypeChecker
request.headers['Authorization'] = type(self).make_header( request.headers['Authorization'] = type(self).make_header(
self.username, self.password).encode('latin1') self.username, self.password).encode('latin1')
return request return request
@ -36,6 +37,7 @@ class HTTPBasicAuth(requests.auth.HTTPBasicAuth):
class BasicAuthPlugin(BuiltinAuthPlugin): class BasicAuthPlugin(BuiltinAuthPlugin):
name = 'Basic HTTP auth' name = 'Basic HTTP auth'
auth_type = 'basic' auth_type = 'basic'
netrc_parse = True
# noinspection PyMethodOverriding # noinspection PyMethodOverriding
def get_auth(self, username: str, password: str) -> HTTPBasicAuth: def get_auth(self, username: str, password: str) -> HTTPBasicAuth:
@ -43,9 +45,9 @@ class BasicAuthPlugin(BuiltinAuthPlugin):
class DigestAuthPlugin(BuiltinAuthPlugin): class DigestAuthPlugin(BuiltinAuthPlugin):
name = 'Digest HTTP auth' name = 'Digest HTTP auth'
auth_type = 'digest' auth_type = 'digest'
netrc_parse = True
# noinspection PyMethodOverriding # noinspection PyMethodOverriding
def get_auth( def get_auth(

View File

@ -0,0 +1,18 @@
from httpie.plugins.manager import PluginManager
from httpie.plugins.builtin import BasicAuthPlugin, DigestAuthPlugin
from httpie.output.formatters.headers import HeadersFormatter
from httpie.output.formatters.json import JSONFormatter
from httpie.output.formatters.colors import ColorFormatter
plugin_manager = PluginManager()
# Register all built-in plugins.
plugin_manager.register(
BasicAuthPlugin,
DigestAuthPlugin,
HeadersFormatter,
JSONFormatter,
ColorFormatter,
)

View File

@ -5,7 +5,7 @@ Persistent, JSON-serialized sessions.
import os import os
import re import re
from pathlib import Path from pathlib import Path
from typing import Optional, Union from typing import Iterable, Optional, Union
from urllib.parse import urlsplit from urllib.parse import urlsplit
from requests.auth import AuthBase from requests.auth import AuthBase
@ -13,7 +13,7 @@ from requests.cookies import RequestsCookieJar, create_cookie
from httpie.cli.dicts import RequestHeadersDict from httpie.cli.dicts import RequestHeadersDict
from httpie.config import BaseConfigDict, DEFAULT_CONFIG_DIR from httpie.config import BaseConfigDict, DEFAULT_CONFIG_DIR
from httpie.plugins import plugin_manager from httpie.plugins.registry import plugin_manager
SESSIONS_DIR_NAME = 'sessions' SESSIONS_DIR_NAME = 'sessions'
@ -144,3 +144,8 @@ class Session(BaseConfigDict):
def auth(self, auth: dict): def auth(self, auth: dict):
assert {'type', 'raw_auth'} == auth.keys() assert {'type', 'raw_auth'} == auth.keys()
self['auth'] = auth self['auth'] = auth
def remove_cookies(self, names: Iterable[str]):
for name in names:
if name in self['cookies']:
del self['cookies'][name]

63
httpie/ssl.py Normal file
View File

@ -0,0 +1,63 @@
import ssl
from requests.adapters import HTTPAdapter
# noinspection PyPackageRequirements
from urllib3.util.ssl_ import (
DEFAULT_CIPHERS, create_urllib3_context,
resolve_ssl_version,
)
DEFAULT_SSL_CIPHERS = DEFAULT_CIPHERS
SSL_VERSION_ARG_MAPPING = {
'ssl2.3': 'PROTOCOL_SSLv23',
'ssl3': 'PROTOCOL_SSLv3',
'tls1': 'PROTOCOL_TLSv1',
'tls1.1': 'PROTOCOL_TLSv1_1',
'tls1.2': 'PROTOCOL_TLSv1_2',
'tls1.3': 'PROTOCOL_TLSv1_3',
}
AVAILABLE_SSL_VERSION_ARG_MAPPING = {
arg: getattr(ssl, constant_name)
for arg, constant_name in SSL_VERSION_ARG_MAPPING.items()
if hasattr(ssl, constant_name)
}
class HTTPieHTTPSAdapter(HTTPAdapter):
def __init__(
self,
verify: bool,
ssl_version: str = None,
ciphers: str = None,
**kwargs
):
self._ssl_context = self._create_ssl_context(
verify=verify,
ssl_version=ssl_version,
ciphers=ciphers,
)
super().__init__(**kwargs)
def init_poolmanager(self, *args, **kwargs):
kwargs['ssl_context'] = self._ssl_context
return super().init_poolmanager(*args, **kwargs)
def proxy_manager_for(self, *args, **kwargs):
kwargs['ssl_context'] = self._ssl_context
return super().proxy_manager_for(*args, **kwargs)
@staticmethod
def _create_ssl_context(
verify: bool,
ssl_version: str = None,
ciphers: str = None,
) -> ssl.SSLContext:
return create_urllib3_context(
ciphers=ciphers,
ssl_version=resolve_ssl_version(ssl_version),
# Since we are using a custom SSL context, we need to pass this
# here manually, even though its also passed to the connection
# in `super().cert_verify()`.
cert_reqs=ssl.CERT_REQUIRED if verify else ssl.CERT_NONE
)

View File

@ -1,8 +1,12 @@
from __future__ import division from __future__ import division
import json import json
import mimetypes import mimetypes
import time
from collections import OrderedDict from collections import OrderedDict
from http.cookiejar import parse_ns_headers
from pprint import pformat from pprint import pformat
from typing import List, Tuple
import requests.auth import requests.auth
@ -83,3 +87,27 @@ def get_content_type(filename):
if encoding: if encoding:
content_type = '%s; charset=%s' % (mime, encoding) content_type = '%s; charset=%s' % (mime, encoding)
return content_type return content_type
def get_expired_cookies(
headers: List[Tuple[str, str]],
now: float = None
) -> List[dict]:
now = now or time.time()
attr_sets: List[Tuple[str, str]] = parse_ns_headers(
value for name, value in headers
if name.lower() == 'set-cookie'
)
cookies = [
# The first attr name is the cookie name.
dict(attrs[1:], name=attrs[0][0])
for attrs in attr_sets
]
return [
{
'name': cookie['name'],
'path': cookie.get('path', '/')
}
for cookie in cookies
if cookie.get('expires', float('Inf')) <= now
]

View File

@ -1,10 +1,10 @@
[wheel] [wheel]
universal = 1
[tool:pytest] [tool:pytest]
# <https://docs.pytest.org/en/latest/customize.html> # <https://docs.pytest.org/en/latest/customize.html>
norecursedirs = tests/fixtures norecursedirs = tests/fixtures
addopts = --tb=native
[pycodestyle] [pycodestyle]

View File

@ -10,8 +10,11 @@ import httpie
class PyTest(TestCommand): class PyTest(TestCommand):
# `$ python setup.py test' simply installs minimal requirements """
# and runs the tests with no fancy stuff like parallel execution. Running `$ python setup.py test' simply installs minimal requirements
and runs the tests with no fancy stuff like parallel execution.
"""
def finalize_options(self): def finalize_options(self):
TestCommand.finalize_options(self) TestCommand.finalize_options(self)
self.test_args = [ self.test_args = [
@ -26,8 +29,6 @@ class PyTest(TestCommand):
tests_require = [ tests_require = [
# Pytest needs to come last.
# https://bitbucket.org/pypa/setuptools/issue/196/
'pytest-httpbin', 'pytest-httpbin',
'pytest', 'pytest',
'mock', 'mock',
@ -38,28 +39,24 @@ install_requires = [
'requests>=2.22.0', 'requests>=2.22.0',
'Pygments>=2.5.2', 'Pygments>=2.5.2',
] ]
install_requires_win_only = [
'colorama>=0.2.4',
]
# Conditional dependencies: # Conditional dependencies:
# sdist # sdist
if 'bdist_wheel' not in sys.argv: if 'bdist_wheel' not in sys.argv:
try:
# noinspection PyUnresolvedReferences
import argparse
except ImportError:
install_requires.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
install_requires.append('colorama>=0.2.4') install_requires.extend(install_requires_win_only)
# bdist_wheel # bdist_wheel
extras_require = { extras_require = {
# https://wheel.readthedocs.io/en/latest/#defining-conditional-dependencies # https://wheel.readthedocs.io/en/latest/#defining-conditional-dependencies
'python_version == "3.0" or python_version == "3.1"': ['argparse>=1.2.1'], ':sys_platform == "win32"': install_requires_win_only,
':sys_platform == "win32"': ['colorama>=0.2.4'],
} }
@ -74,7 +71,7 @@ setup(
description=httpie.__doc__.strip(), description=httpie.__doc__.strip(),
long_description=long_description(), long_description=long_description(),
url='https://httpie.org/', url='https://httpie.org/',
download_url='https://github.com/jakubroztocil/httpie', download_url=f'https://github.com/jakubroztocil/httpie/archive/{httpie.__version__}.tar.gz',
author=httpie.__author__, author=httpie.__author__,
author_email='jakub@roztocil.co', author_email='jakub@roztocil.co',
license=httpie.__licence__, license=httpie.__licence__,
@ -85,6 +82,7 @@ setup(
'https = httpie.__main__:main', 'https = httpie.__main__:main',
], ],
}, },
python_requires='>=3.6',
extras_require=extras_require, extras_require=extras_require,
install_requires=install_requires, install_requires=install_requires,
tests_require=tests_require, tests_require=tests_require,
@ -92,9 +90,7 @@ setup(
classifiers=[ classifiers=[
'Development Status :: 5 - Production/Stable', 'Development Status :: 5 - Production/Stable',
'Programming Language :: Python', 'Programming Language :: Python',
'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3 :: Only',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Environment :: Console', 'Environment :: Console',
'Intended Audience :: Developers', 'Intended Audience :: Developers',
'Intended Audience :: System Administrators', 'Intended Audience :: System Administrators',
@ -106,4 +102,11 @@ setup(
'Topic :: Text Processing', 'Topic :: Text Processing',
'Topic :: Utilities' 'Topic :: Utilities'
], ],
project_urls={
'Documentation': 'https://httpie.org/docs',
'Source': 'https://github.com/jakubroztocil/httpie',
'Online Demo': 'https://httpie.org/run',
'Donate': 'https://httpie.org/donate',
'Twitter': 'https://twitter.com/clihttp',
},
) )

View File

@ -3,6 +3,7 @@ import mock
import pytest import pytest
from httpie.plugins.builtin import HTTPBasicAuth from httpie.plugins.builtin import HTTPBasicAuth
from httpie.status import ExitStatus
from httpie.utils import ExplicitNullAuth from httpie.utils import ExplicitNullAuth
from utils import http, add_auth, HTTP_OK, MockEnvironment from utils import http, add_auth, HTTP_OK, MockEnvironment
import httpie.cli.constants import httpie.cli.constants
@ -78,6 +79,8 @@ def test_missing_auth(httpbin):
def test_netrc(httpbin_both): def test_netrc(httpbin_both):
# This one gets handled by requests (no --auth, --auth-type present),
# thats why we patch inside `requests.sessions`.
with mock.patch('requests.sessions.get_netrc_auth') as get_netrc_auth: with mock.patch('requests.sessions.get_netrc_auth') as get_netrc_auth:
get_netrc_auth.return_value = ('httpie', 'password') get_netrc_auth.return_value = ('httpie', 'password')
r = http(httpbin_both + '/basic-auth/httpie/password') r = http(httpbin_both + '/basic-auth/httpie/password')
@ -86,24 +89,54 @@ def test_netrc(httpbin_both):
def test_ignore_netrc(httpbin_both): def test_ignore_netrc(httpbin_both):
with mock.patch('requests.sessions.get_netrc_auth') as get_netrc_auth: with mock.patch('httpie.cli.argparser.get_netrc_auth') as get_netrc_auth:
get_netrc_auth.return_value = ('httpie', 'password') get_netrc_auth.return_value = ('httpie', 'password')
r = http('--ignore-netrc', httpbin_both + '/basic-auth/httpie/password') r = http('--ignore-netrc', httpbin_both + '/basic-auth/httpie/password')
assert get_netrc_auth.call_count == 0 assert get_netrc_auth.call_count == 0
assert 'HTTP/1.1 401 UNAUTHORIZED' in r assert 'HTTP/1.1 401 UNAUTHORIZED' in r
def test_ignore_netrc_null_auth():
args = httpie.cli.definition.parser.parse_args(
args=['--ignore-netrc', 'example.org'],
env=MockEnvironment(),
)
assert isinstance(args.auth, ExplicitNullAuth)
def test_ignore_netrc_together_with_auth(): def test_ignore_netrc_together_with_auth():
args = httpie.cli.definition.parser.parse_args( args = httpie.cli.definition.parser.parse_args(
args=['--ignore-netrc', '--auth=username:password', 'example.org'], args=['--ignore-netrc', '--auth=username:password', 'example.org'],
env=MockEnvironment(), env=MockEnvironment(),
) )
assert isinstance(args.auth, HTTPBasicAuth) assert isinstance(args.auth, HTTPBasicAuth)
def test_ignore_netrc_with_auth_type_resulting_in_missing_auth(httpbin):
with mock.patch('httpie.cli.argparser.get_netrc_auth') as get_netrc_auth:
get_netrc_auth.return_value = ('httpie', 'password')
r = http(
'--ignore-netrc',
'--auth-type=basic',
httpbin + '/basic-auth/httpie/password',
tolerate_error_exit_status=True,
)
assert get_netrc_auth.call_count == 0
assert r.exit_status == ExitStatus.ERROR
assert '--auth required' in r.stderr
@pytest.mark.parametrize(
argnames=['auth_type', 'endpoint'],
argvalues=[
('basic', '/basic-auth/httpie/password'),
('digest', '/digest-auth/auth/httpie/password'),
],
)
def test_auth_plugin_netrc_parse(auth_type, endpoint, httpbin):
# Test
with mock.patch('httpie.cli.argparser.get_netrc_auth') as get_netrc_auth:
get_netrc_auth.return_value = ('httpie', 'password')
r = http('--auth-type', auth_type, httpbin + endpoint)
assert get_netrc_auth.call_count == 1
assert HTTP_OK in r
def test_ignore_netrc_null_auth():
args = httpie.cli.definition.parser.parse_args(
args=['--ignore-netrc', 'example.org'],
env=MockEnvironment(),
)
assert isinstance(args.auth, ExplicitNullAuth)

View File

@ -1,9 +1,11 @@
from mock import mock from mock import mock
from httpie.cli.constants import SEPARATOR_CREDENTIALS from httpie.cli.constants import SEPARATOR_CREDENTIALS
from httpie.plugins import AuthPlugin, plugin_manager from httpie.plugins import AuthPlugin
from httpie.plugins.registry import plugin_manager
from utils import http, HTTP_OK from utils import http, HTTP_OK
# TODO: run all these tests in session mode as well # TODO: run all these tests in session mode as well
USERNAME = 'user' USERNAME = 'user'

View File

@ -1,7 +1,14 @@
from pathlib import Path
import pytest import pytest
from _pytest.monkeypatch import MonkeyPatch
from httpie.compat import is_windows from httpie.compat import is_windows
from httpie.config import Config from httpie.config import (
Config, DEFAULT_CONFIG_DIRNAME, DEFAULT_RELATIVE_LEGACY_CONFIG_DIR,
DEFAULT_RELATIVE_XDG_CONFIG_HOME, DEFAULT_WINDOWS_CONFIG_DIR,
ENV_HTTPIE_CONFIG_DIR, ENV_XDG_CONFIG_HOME, get_default_config_dir,
)
from utils import HTTP_OK, MockEnvironment, http from utils import HTTP_OK, MockEnvironment, http
@ -48,3 +55,50 @@ def test_default_options_overwrite(httpbin):
assert r.json['json'] == { assert r.json['json'] == {
"foo": "bar" "foo": "bar"
} }
@pytest.mark.skipif(is_windows, reason='XDG_CONFIG_HOME needs *nix')
def test_explicit_xdg_config_home(monkeypatch: MonkeyPatch, tmp_path: Path):
home_dir = tmp_path
monkeypatch.delenv(ENV_HTTPIE_CONFIG_DIR, raising=False)
monkeypatch.setenv('HOME', str(home_dir))
custom_xdg_config_home = home_dir / 'custom_xdg_config_home'
monkeypatch.setenv(ENV_XDG_CONFIG_HOME, str(custom_xdg_config_home))
expected_config_dir = custom_xdg_config_home / DEFAULT_CONFIG_DIRNAME
assert get_default_config_dir() == expected_config_dir
@pytest.mark.skipif(is_windows, reason='XDG_CONFIG_HOME needs *nix')
def test_default_xdg_config_home(monkeypatch: MonkeyPatch, tmp_path: Path):
home_dir = tmp_path
monkeypatch.delenv(ENV_HTTPIE_CONFIG_DIR, raising=False)
monkeypatch.delenv(ENV_XDG_CONFIG_HOME, raising=False)
monkeypatch.setenv('HOME', str(home_dir))
expected_config_dir = (
home_dir
/ DEFAULT_RELATIVE_XDG_CONFIG_HOME
/ DEFAULT_CONFIG_DIRNAME
)
assert get_default_config_dir() == expected_config_dir
@pytest.mark.skipif(is_windows, reason='legacy config dir needs *nix')
def test_legacy_config_dir(monkeypatch: MonkeyPatch, tmp_path: Path):
home_dir = tmp_path
monkeypatch.delenv(ENV_HTTPIE_CONFIG_DIR, raising=False)
monkeypatch.setenv('HOME', str(home_dir))
legacy_config_dir = home_dir / DEFAULT_RELATIVE_LEGACY_CONFIG_DIR
legacy_config_dir.mkdir()
assert get_default_config_dir() == legacy_config_dir
def test_custom_config_dir(monkeypatch: MonkeyPatch, tmp_path: Path):
httpie_config_dir = tmp_path / 'custom/directory'
monkeypatch.setenv(ENV_HTTPIE_CONFIG_DIR, str(httpie_config_dir))
assert get_default_config_dir() == httpie_config_dir
@pytest.mark.skipif(not is_windows, reason='windows-only')
def test_windows_config_dir(monkeypatch: MonkeyPatch):
monkeypatch.delenv(ENV_HTTPIE_CONFIG_DIR, raising=False)
assert get_default_config_dir() == DEFAULT_WINDOWS_CONFIG_DIR

View File

@ -22,6 +22,7 @@ def test_default_headers_case_insensitive(httpbin):
assert 'Content-Type' not in r assert 'Content-Type' not in r
# noinspection PyPep8Naming
class TestImplicitHTTPMethod: class TestImplicitHTTPMethod:
def test_implicit_GET(self, httpbin): def test_implicit_GET(self, httpbin):
r = http(httpbin.url + '/get') r = http(httpbin.url + '/get')
@ -51,9 +52,9 @@ class TestImplicitHTTPMethod:
class TestAutoContentTypeAndAcceptHeaders: class TestAutoContentTypeAndAcceptHeaders:
""" """
Test that Accept and Content-Type correctly defaults to JSON, Test that `Accept` and `Content-Type` correctly default to JSON,
but can still be overridden. The same with Content-Type when --form but can still be overridden. The same with Content-Type when `--form`
-f is used. `-f` is used.
""" """
@ -84,7 +85,7 @@ class TestAutoContentTypeAndAcceptHeaders:
assert r.json['headers']['Accept'] == JSON_ACCEPT assert r.json['headers']['Accept'] == JSON_ACCEPT
assert r.json['headers']['Content-Type'] == 'application/json' assert r.json['headers']['Content-Type'] == 'application/json'
def test_POST_explicit_JSON_auto_JSON_accept(self, httpbin): def test_POST_explicit_JSON_JSON_ACCEPT(self, httpbin):
r = http('--json', 'POST', httpbin.url + '/post') r = http('--json', 'POST', httpbin.url + '/post')
assert HTTP_OK in r assert HTTP_OK in r
assert r.json['headers']['Accept'] == JSON_ACCEPT assert r.json['headers']['Accept'] == JSON_ACCEPT

View File

@ -55,6 +55,25 @@ def test_GET(httpbin_both):
assert HTTP_OK in r assert HTTP_OK in r
def test_path_dot_normalization():
r = http(
'--offline',
'example.org/../../etc/password',
'param==value'
)
assert 'GET /etc/password?param=value' in r
def test_path_as_is():
r = http(
'--offline',
'--path-as-is',
'example.org/../../etc/password',
'param==value'
)
assert 'GET /../../etc/password?param=value' in r
def test_DELETE(httpbin_both): def test_DELETE(httpbin_both):
r = http('DELETE', httpbin_both + '/delete') r = http('DELETE', httpbin_both + '/delete')
assert HTTP_OK in r assert HTTP_OK in r
@ -98,6 +117,27 @@ def test_POST_file(httpbin_both):
assert FILE_CONTENT in r assert FILE_CONTENT in r
def test_form_POST_file_redirected_stdin(httpbin):
"""
<https://github.com/jakubroztocil/httpie/issues/840>
"""
with open(FILE_PATH) as f:
r = http(
'--form',
'POST',
httpbin + '/post',
'file@' + FILE_PATH,
tolerate_error_exit_status=True,
env=MockEnvironment(
stdin=f,
stdin_isatty=False,
),
)
assert r.exit_status == ExitStatus.ERROR
assert 'cannot be mixed' in r.stderr
def test_headers(httpbin_both): def test_headers(httpbin_both):
r = http('GET', httpbin_both + '/headers', 'Foo:bar') r = http('GET', httpbin_both + '/headers', 'Foo:bar')
assert HTTP_OK in r assert HTTP_OK in r
@ -141,3 +181,21 @@ def test_json_input_preserve_order(httpbin_both):
assert HTTP_OK in r assert HTTP_OK in r
assert r.json['data'] == \ assert r.json['data'] == \
'{"order": {"map": {"1": "first", "2": "second"}}}' '{"order": {"map": {"1": "first", "2": "second"}}}'
def test_offline():
r = http(
'--offline',
'https://this-should.never-resolve/foo',
)
assert 'GET /foo' in r
def test_offline_download():
"""Absence of response should be handled gracefully with --download"""
r = http(
'--offline',
'--download',
'https://this-should.never-resolve/foo',
)
assert 'GET /foo' in r

View File

@ -1,12 +1,19 @@
import argparse
import json
import os import os
from tempfile import gettempdir from tempfile import gettempdir
from urllib.request import urlopen from urllib.request import urlopen
import pytest import pytest
from utils import MockEnvironment, http, HTTP_OK, COLOR, CRLF from httpie.cli.argtypes import (
from httpie.status import ExitStatus PARSED_DEFAULT_FORMAT_OPTIONS,
parse_format_options,
)
from httpie.cli.definition import parser
from httpie.output.formatters.colors import get_lexer from httpie.output.formatters.colors import get_lexer
from httpie.status import ExitStatus
from utils import COLOR, CRLF, HTTP_OK, MockEnvironment, http
@pytest.mark.parametrize('stdout_isatty', [True, False]) @pytest.mark.parametrize('stdout_isatty', [True, False])
@ -58,19 +65,19 @@ class TestColors:
@pytest.mark.parametrize( @pytest.mark.parametrize(
argnames=['mime', 'explicit_json', 'body', 'expected_lexer_name'], argnames=['mime', 'explicit_json', 'body', 'expected_lexer_name'],
argvalues=[ argvalues=[
('application/json', False, None, 'JSON'), ('application/json', False, None, 'JSON'),
('application/json+foo', False, None, 'JSON'), ('application/json+foo', False, None, 'JSON'),
('application/foo+json', False, None, 'JSON'), ('application/foo+json', False, None, 'JSON'),
('application/json-foo', False, None, 'JSON'), ('application/json-foo', False, None, 'JSON'),
('application/x-json', False, None, 'JSON'), ('application/x-json', False, None, 'JSON'),
('foo/json', False, None, 'JSON'), ('foo/json', False, None, 'JSON'),
('foo/json+bar', False, None, 'JSON'), ('foo/json+bar', False, None, 'JSON'),
('foo/bar+json', False, None, 'JSON'), ('foo/bar+json', False, None, 'JSON'),
('foo/json-foo', False, None, 'JSON'), ('foo/json-foo', False, None, 'JSON'),
('foo/x-json', False, None, 'JSON'), ('foo/x-json', False, None, 'JSON'),
('application/vnd.comverge.grid+hal+json', False, None, 'JSON'), ('application/vnd.comverge.grid+hal+json', False, None, 'JSON'),
('text/plain', True, '{}', 'JSON'), ('text/plain', True, '{}', 'JSON'),
('text/plain', True, 'foo', 'Text only'), ('text/plain', True, 'foo', 'Text only'),
] ]
) )
def test_get_lexer(self, mime, explicit_json, body, expected_lexer_name): def test_get_lexer(self, mime, explicit_json, body, expected_lexer_name):
@ -83,7 +90,7 @@ class TestColors:
class TestPrettyOptions: class TestPrettyOptions:
"""Test the --pretty flag handling.""" """Test the --pretty handling."""
def test_pretty_enabled_by_default(self, httpbin): def test_pretty_enabled_by_default(self, httpbin):
env = MockEnvironment(colors=256) env = MockEnvironment(colors=256)
@ -138,6 +145,7 @@ class TestLineEndings:
and as the headers/body separator. and as the headers/body separator.
""" """
def _validate_crlf(self, msg): def _validate_crlf(self, msg):
lines = iter(msg.splitlines(True)) lines = iter(msg.splitlines(True))
for header in lines: for header in lines:
@ -171,3 +179,199 @@ class TestLineEndings:
def test_CRLF_formatted_request(self, httpbin): def test_CRLF_formatted_request(self, httpbin):
r = http('--pretty=format', '--print=HB', 'GET', httpbin.url + '/get') r = http('--pretty=format', '--print=HB', 'GET', httpbin.url + '/get')
self._validate_crlf(r) self._validate_crlf(r)
class TestFormatOptions:
def test_header_formatting_options(self):
def get_headers(sort):
return http(
'--offline', '--print=H',
'--format-options', 'headers.sort:' + sort,
'example.org', 'ZZZ:foo', 'XXX:foo',
)
r_sorted = get_headers('true')
r_unsorted = get_headers('false')
assert r_sorted != r_unsorted
assert f'XXX: foo{CRLF}ZZZ: foo' in r_sorted
assert f'ZZZ: foo{CRLF}XXX: foo' in r_unsorted
@pytest.mark.parametrize(
argnames=['options', 'expected_json'],
argvalues=[
# @formatter:off
(
'json.sort_keys:true,json.indent:4',
json.dumps({'a': 0, 'b': 0}, indent=4),
),
(
'json.sort_keys:false,json.indent:2',
json.dumps({'b': 0, 'a': 0}, indent=2),
),
(
'json.format:false',
json.dumps({'b': 0, 'a': 0}),
),
# @formatter:on
]
)
def test_json_formatting_options(self, options: str, expected_json: str):
r = http(
'--offline', '--print=B',
'--format-options', options,
'example.org', 'b:=0', 'a:=0',
)
assert expected_json in r
@pytest.mark.parametrize(
argnames=['defaults', 'options_string', 'expected'],
argvalues=[
# @formatter:off
({'foo': {'bar': 1}}, 'foo.bar:2', {'foo': {'bar': 2}}),
({'foo': {'bar': True}}, 'foo.bar:false', {'foo': {'bar': False}}),
({'foo': {'bar': 'a'}}, 'foo.bar:b', {'foo': {'bar': 'b'}}),
# @formatter:on
]
)
def test_parse_format_options(self, defaults, options_string, expected):
actual = parse_format_options(s=options_string, defaults=defaults)
assert expected == actual
@pytest.mark.parametrize(
argnames=['options_string', 'expected_error'],
argvalues=[
('foo:2', 'invalid option'),
('foo.baz:2', 'invalid key'),
('foo.bar:false', 'expected int got bool'),
]
)
def test_parse_format_options_errors(self, options_string, expected_error):
defaults = {
'foo': {
'bar': 1
}
}
with pytest.raises(argparse.ArgumentTypeError, match=expected_error):
parse_format_options(s=options_string, defaults=defaults)
@pytest.mark.parametrize(
argnames=['args', 'expected_format_options'],
argvalues=[
(
[
'--format-options',
'headers.sort:false,json.sort_keys:false',
'--format-options=json.indent:10'
],
{
'headers': {
'sort': False
},
'json': {
'sort_keys': False,
'indent': 10,
'format': True
},
}
),
(
[
'--unsorted'
],
{
'headers': {
'sort': False
},
'json': {
'sort_keys': False,
'indent': 4,
'format': True
},
}
),
(
[
'--format-options=headers.sort:true',
'--unsorted',
'--format-options=headers.sort:true',
],
{
'headers': {
'sort': True
},
'json': {
'sort_keys': False,
'indent': 4,
'format': True
},
}
),
(
[
'--no-format-options', # --no-<option> anywhere resets
'--format-options=headers.sort:true',
'--unsorted',
'--format-options=headers.sort:true',
],
PARSED_DEFAULT_FORMAT_OPTIONS,
),
(
[
'--format-options=json.indent:2',
'--unsorted',
'--no-unsorted',
],
{
'headers': {
'sort': True
},
'json': {
'sort_keys': True,
'indent': 2,
'format': True
},
}
),
(
[
'--format-options=json.indent:2',
'--unsorted',
'--sorted',
],
{
'headers': {
'sort': True
},
'json': {
'sort_keys': True,
'indent': 2,
'format': True
},
}
),
(
[
'--format-options=json.indent:2',
'--sorted',
'--no-sorted',
'--no-unsorted',
],
{
'headers': {
'sort': True
},
'json': {
'sort_keys': True,
'indent': 2,
'format': True
},
}
),
],
)
def test_format_options_accumulation(self, args, expected_format_options):
parsed_args = parser.parse_args(
args=[*args, 'example.org'],
env=MockEnvironment(),
)
assert parsed_args.format_options == expected_format_options

View File

@ -1,14 +1,17 @@
# coding=utf-8 # coding=utf-8
import json
import os import os
import shutil import shutil
import sys from datetime import datetime
from tempfile import gettempdir from tempfile import gettempdir
import pytest import pytest
from httpie.plugins.builtin import HTTPBasicAuth
from utils import MockEnvironment, mk_config_dir, http, HTTP_OK
from fixtures import UNICODE from fixtures import UNICODE
from httpie.plugins.builtin import HTTPBasicAuth
from httpie.sessions import Session
from httpie.utils import get_expired_cookies
from utils import MockEnvironment, mk_config_dir, http, HTTP_OK
class SessionTestBase: class SessionTestBase:
@ -186,3 +189,91 @@ class TestSession(SessionTestBase):
httpbin.url + '/get', env=self.env()) httpbin.url + '/get', env=self.env())
finally: finally:
os.chdir(cwd) os.chdir(cwd)
class TestExpiredCookies:
def setup_method(self, method):
self.config_dir = mk_config_dir()
def teardown_method(self, method):
shutil.rmtree(self.config_dir)
@pytest.mark.parametrize(
argnames=['initial_cookie', 'expired_cookie'],
argvalues=[
({'id': {'value': 123}}, 'id'),
({'id': {'value': 123}}, 'token')
]
)
def test_removes_expired_cookies_from_session_obj(self, initial_cookie, expired_cookie, httpbin):
session = Session(self.config_dir)
session['cookies'] = initial_cookie
session.remove_cookies([expired_cookie])
assert expired_cookie not in session.cookies
def test_expired_cookies(self, httpbin):
orig_session = {
'cookies': {
'to_expire': {
'value': 'foo'
},
'to_stay': {
'value': 'foo'
},
}
}
session_path = self.config_dir / 'test-session.json'
session_path.write_text(json.dumps(orig_session))
r = http(
'--session', str(session_path),
'--print=H',
httpbin.url + '/cookies/delete?to_expire',
)
assert 'Cookie: to_expire=foo; to_stay=foo' in r
updated_session = json.loads(session_path.read_text())
assert 'to_stay' in updated_session['cookies']
assert 'to_expire' not in updated_session['cookies']
@pytest.mark.parametrize(
argnames=['headers', 'now', 'expected_expired'],
argvalues=[
(
[
('Set-Cookie', 'hello=world; Path=/; Expires=Thu, 01-Jan-1970 00:00:00 GMT; HttpOnly'),
('Connection', 'keep-alive')
],
None,
[
{
'name': 'hello',
'path': '/'
}
]
),
(
[
('Set-Cookie', 'hello=world; Path=/; Expires=Thu, 01-Jan-1970 00:00:00 GMT; HttpOnly'),
('Set-Cookie', 'pea=pod; Path=/ab; Expires=Thu, 01-Jan-1970 00:00:00 GMT; HttpOnly'),
('Connection', 'keep-alive')
],
None,
[
{'name': 'hello', 'path': '/'},
{'name': 'pea', 'path': '/ab'}
]
),
(
[
('Set-Cookie', 'hello=world; Path=/; Expires=Fri, 12 Jun 2020 12:28:55 GMT; HttpOnly'),
('Connection', 'keep-alive')
],
datetime(2020, 6, 11).timestamp(),
[]
)
]
)
def test_get_expired_cookies_manages_multiple_cookie_headers(self, headers, now, expected_expired):
assert get_expired_cookies(headers, now=now) == expected_expired

View File

@ -1,11 +1,9 @@
import os
import pytest import pytest
import pytest_httpbin.certs import pytest_httpbin.certs
import requests.exceptions import requests.exceptions
from httpie.ssl import AVAILABLE_SSL_VERSION_ARG_MAPPING, DEFAULT_SSL_CIPHERS
from httpie.status import ExitStatus from httpie.status import ExitStatus
from httpie.cli.constants import SSL_VERSION_ARG_MAPPING
from utils import HTTP_OK, TESTS_ROOT, http from utils import HTTP_OK, TESTS_ROOT, http
@ -23,10 +21,12 @@ except ImportError:
requests.exceptions.SSLError, requests.exceptions.SSLError,
) )
CERTS_ROOT = TESTS_ROOT / 'client_certs'
CLIENT_CERT = str(CERTS_ROOT / 'client.crt')
CLIENT_KEY = str(CERTS_ROOT / 'client.key')
CLIENT_PEM = str(CERTS_ROOT / 'client.pem')
CLIENT_CERT = os.path.join(TESTS_ROOT, 'client_certs', 'client.crt')
CLIENT_KEY = os.path.join(TESTS_ROOT, 'client_certs', 'client.key')
CLIENT_PEM = os.path.join(TESTS_ROOT, 'client_certs', 'client.pem')
# FIXME: # FIXME:
# We test against a local httpbin instance which uses a self-signed cert. # We test against a local httpbin instance which uses a self-signed cert.
# Requests without --verify=<CA_BUNDLE> will fail with a verification error. # Requests without --verify=<CA_BUNDLE> will fail with a verification error.
@ -34,7 +34,8 @@ CLIENT_PEM = os.path.join(TESTS_ROOT, 'client_certs', 'client.pem')
CA_BUNDLE = pytest_httpbin.certs.where() CA_BUNDLE = pytest_httpbin.certs.where()
@pytest.mark.parametrize('ssl_version', SSL_VERSION_ARG_MAPPING.keys()) @pytest.mark.parametrize('ssl_version',
AVAILABLE_SSL_VERSION_ARG_MAPPING.keys())
def test_ssl_version(httpbin_secure, ssl_version): def test_ssl_version(httpbin_secure, ssl_version):
try: try:
r = http( r = http(
@ -113,3 +114,23 @@ class TestServerCert:
def test_verify_custom_ca_bundle_invalid_bundle(self, httpbin_secure): def test_verify_custom_ca_bundle_invalid_bundle(self, httpbin_secure):
with pytest.raises(ssl_errors): with pytest.raises(ssl_errors):
http(httpbin_secure.url + '/get', '--verify', __file__) http(httpbin_secure.url + '/get', '--verify', __file__)
def test_ciphers(httpbin_secure):
r = http(
httpbin_secure.url + '/get',
'--ciphers',
DEFAULT_SSL_CIPHERS,
)
assert HTTP_OK in r
def test_ciphers_none_can_be_selected(httpbin_secure):
r = http(
httpbin_secure.url + '/get',
'--ciphers',
'__FOO__',
tolerate_error_exit_status=True,
)
assert r.exit_status == ExitStatus.ERROR
assert 'No cipher can be selected.' in r.stderr

View File

@ -3,6 +3,7 @@ import os
import pytest import pytest
from httpie.cli.exceptions import ParseError from httpie.cli.exceptions import ParseError
from httpie.status import ExitStatus
from utils import MockEnvironment, http, HTTP_OK from utils import MockEnvironment, http, HTTP_OK
from fixtures import FILE_PATH_ARG, FILE_PATH, FILE_CONTENT from fixtures import FILE_PATH_ARG, FILE_PATH, FILE_CONTENT
@ -16,27 +17,37 @@ class TestMultipartFormDataFileUpload:
def test_upload_ok(self, httpbin): def test_upload_ok(self, httpbin):
r = http('--form', '--verbose', 'POST', httpbin.url + '/post', r = http('--form', '--verbose', 'POST', httpbin.url + '/post',
'test-file@%s' % FILE_PATH_ARG, 'foo=bar') f'test-file@{FILE_PATH_ARG}', 'foo=bar')
assert HTTP_OK in r assert HTTP_OK in r
assert 'Content-Disposition: form-data; name="foo"' in r assert 'Content-Disposition: form-data; name="foo"' in r
assert 'Content-Disposition: form-data; name="test-file";' \ assert 'Content-Disposition: form-data; name="test-file";' \
' filename="%s"' % os.path.basename(FILE_PATH) in r f' filename="{os.path.basename(FILE_PATH)}"' in r
assert FILE_CONTENT in r assert FILE_CONTENT in r
assert '"foo": "bar"' in r assert '"foo": "bar"' in r
assert 'Content-Type: text/plain' in r assert 'Content-Type: text/plain' in r
def test_upload_multiple_fields_with_the_same_name(self, httpbin): def test_upload_multiple_fields_with_the_same_name(self, httpbin):
r = http('--form', '--verbose', 'POST', httpbin.url + '/post', r = http('--form', '--verbose', 'POST', httpbin.url + '/post',
'test-file@%s' % FILE_PATH_ARG, f'test-file@{FILE_PATH_ARG}',
'test-file@%s' % FILE_PATH_ARG) f'test-file@{FILE_PATH_ARG}')
assert HTTP_OK in r assert HTTP_OK in r
assert r.count('Content-Disposition: form-data; name="test-file";' assert r.count('Content-Disposition: form-data; name="test-file";'
' filename="%s"' % os.path.basename(FILE_PATH)) == 2 f' filename="{os.path.basename(FILE_PATH)}"') == 2
# Should be 4, but is 3 because httpbin # Should be 4, but is 3 because httpbin
# doesn't seem to support filed field lists # doesn't seem to support filed field lists
assert r.count(FILE_CONTENT) in [3, 4] assert r.count(FILE_CONTENT) in [3, 4]
assert r.count('Content-Type: text/plain') == 2 assert r.count('Content-Type: text/plain') == 2
def test_upload_custom_content_type(self, httpbin):
r = http('--form', '--verbose', 'POST', httpbin.url + '/post',
f'test-file@{FILE_PATH_ARG};type=image/vnd.microsoft.icon')
assert HTTP_OK in r
# Content type is stripped from the filename
assert 'Content-Disposition: form-data; name="test-file";' \
f' filename="{os.path.basename(FILE_PATH)}"' in r
assert FILE_CONTENT in r
assert 'Content-Type: image/vnd.microsoft.icon' in r
class TestRequestBodyFromFilePath: class TestRequestBodyFromFilePath:
""" """
@ -77,4 +88,5 @@ class TestRequestBodyFromFilePath:
env=env, env=env,
tolerate_error_exit_status=True, tolerate_error_exit_status=True,
) )
assert r.exit_status == ExitStatus.ERROR
assert 'cannot be mixed' in r.stderr assert 'cannot be mixed' in r.stderr

View File

@ -216,6 +216,7 @@ def http(
add_to_args.append('--timeout=3') add_to_args.append('--timeout=3')
complete_args = [program_name, *add_to_args, *args] complete_args = [program_name, *add_to_args, *args]
# print(' '.join(complete_args))
def dump_stderr(): def dump_stderr():
stderr.seek(0) stderr.seek(0)