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
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
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username

View File

@ -1,5 +1,5 @@
name: Build
on: [push]
on: [push, pull_request]
jobs:
extras:
# 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>
##############################################################################

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/>`_.
`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)
-------------------------
* 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
with one-by-one processing of each HTTP request or response as they become
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 the default 30-second connection ``--timeout`` limit.
* 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``.
* Added ``-I`` as a shortcut for ``--ignore-stdin``.
* 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
``pip install requests[socks]``.
* 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.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.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.
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
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
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)"
homepage "https://httpie.org/"
url "https://files.pythonhosted.org/packages/d5/a4/ab61c1dbfdef33c7b7f5f7df0d79eb5cd55a106601a4acc17f983f320b4a/httpie-1.0.3.tar.gz"
sha256 "6d1b6e21da7d3ec030ae95536d4032c1129bdaf9de4adc72c596b87e5f646e80"
url "https://files.pythonhosted.org/packages/e2/79/da6aec7b4356e8b325561b987c940e5b1e4de1200a5c3db7c57a97d61ca1/httpie-2.1.0.tar.gz"
sha256 "a76f1c72e83bd03cde3478c5f345d5570fdb2967ed19d68d09518088640b9e8e"
head "https://github.com/jakubroztocil/httpie.git"
bottle do
cellar :any_skip_relocation
sha256 "158258be68ac93de13860be2bef02da6fd8b68aa24b2e6609bcff1ec3f93e7a0" => :mojave
sha256 "54352116b6fa2c3bd65f26136fdcb57986dbff8a52de5febf7aea59c126d29e1" => :high_sierra
sha256 "9cce71768fe388808e11b26d651b44a6b54219f5406845b4273b5099f5c1f76f" => :sierra
sha256 "1fb33d9c85dc462c2549a03cf08670edad8014a5fdf0a7cb26493c64af40283d" => :catalina
sha256 "a22030f0b96c698c90265286ee80ffbb03079d1d008a80c0bdb3ea15a17d3fbb" => :mojave
sha256 "9f994ecf826efe53a3a49d1c3193e271629068d11306df55adeea2842a8afb8c" => :high_sierra
end
depends_on "python"
depends_on "python@3.8"
resource "Pygments" do
url "https://files.pythonhosted.org/packages/7e/ae/26808275fc76bf2832deb10d3a3ed3107bc4de01b85dcccbe525f2cd6d1e/Pygments-2.4.2.tar.gz"
sha256 "881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297"
url "https://files.pythonhosted.org/packages/6e/4d/4d2fe93a35dfba417311a4ff627489a947b01dc0cc377a3673c00cf7e4b2/Pygments-2.6.1.tar.gz"
sha256 "647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44"
end
resource "requests" do
url "https://files.pythonhosted.org/packages/01/62/ddcf76d1d19885e8579acb1b1df26a852b03472c0e46d2b959a714c90608/requests-2.22.0.tar.gz"
sha256 "11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4"
url "https://files.pythonhosted.org/packages/f5/4f/280162d4bd4d8aad241a21aecff7a6e46891b905a4341e7ab549ebaf7915/requests-2.23.0.tar.gz"
sha256 "b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"
end
resource "certifi" do
url "https://files.pythonhosted.org/packages/c5/67/5d0548226bcc34468e23a0333978f0e23d28d0b3f0c71a151aef9c3f7680/certifi-2019.6.16.tar.gz"
sha256 "945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695"
url "https://files.pythonhosted.org/packages/b8/e2/a3a86a67c3fc8249ed305fc7b7d290ebe5e4d46ad45573884761ef4dea7b/certifi-2020.4.5.1.tar.gz"
sha256 "51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"
end
resource "urllib3" do
url "https://files.pythonhosted.org/packages/4c/13/2386233f7ee40aa8444b47f7463338f3cbdf00c316627558784e3f542f07/urllib3-1.25.3.tar.gz"
sha256 "dbe59173209418ae49d485b87d1681aefa36252ee85884c31346debd19463232"
url "https://files.pythonhosted.org/packages/05/8c/40cd6949373e23081b3ea20d5594ae523e681b6f472e600fbc95ed046a36/urllib3-1.25.9.tar.gz"
sha256 "3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527"
end
resource "idna" do
url "https://files.pythonhosted.org/packages/ad/13/eb56951b6f7950cadb579ca166e448ba77f9d24efc03edd7e55fa57d04b7/idna-2.8.tar.gz"
sha256 "c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407"
url "https://files.pythonhosted.org/packages/cb/19/57503b5de719ee45e83472f339f617b0c01ad75cba44aba1e4c97c2b0abd/idna-2.9.tar.gz"
sha256 "7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"
end
resource "chardet" do
@ -53,8 +53,8 @@ class Httpie < Formula
end
resource "PySocks" do
url "https://files.pythonhosted.org/packages/15/ab/35824cfdee1aac662e3298275fa1e6cbedb52126d1785f8977959b769ccf/PySocks-1.7.0.tar.gz"
sha256 "d9031ea45fdfacbe59a99273e9f0448ddb33c1580fe3831c1b09557c5718977c"
url "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz"
sha256 "3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"
end
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'
__licence__ = 'BSD'

View File

@ -7,9 +7,16 @@ from argparse import RawDescriptionHelpFormatter
from textwrap import dedent
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 (
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,
PRETTY_STDOUT_TTY_ONLY, SEPARATOR_CREDENTIALS, SEPARATOR_GROUP_ALL_ITEMS,
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.requestitems import RequestItems
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
@ -42,6 +49,8 @@ class HTTPieHelpFormatter(RawDescriptionHelpFormatter):
return text.splitlines()
# TODO: refactor and design type-annotated data structures
# for raw args + parsed args and keep things immutable.
class HTTPieArgumentParser(argparse.ArgumentParser):
"""Adds additional logic to `argparse.ArgumentParser`.
@ -78,10 +87,11 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
# Arguments processing and environment setup.
self._apply_no_options(no_options)
self._validate_download_options()
self._process_download_options()
self._setup_standard_streams()
self._process_output_options()
self._process_pretty_options()
self._process_format_options()
self._guess_method()
self._parse_items()
@ -154,7 +164,7 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
self.env.stdout_isatty = False
def _process_auth(self):
# TODO: refactor
# TODO: refactor & simplify this method.
self.args.auth_plugin = None
default_auth_plugin = plugin_manager.get_auth_plugins()[0]
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
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:
self.error('--auth required')
@ -243,10 +266,11 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
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 '
'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()
def _guess_method(self):
@ -378,10 +402,20 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
# noinspection PyTypeChecker
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 self.args.download_resume:
self.error('--continue only works with --download')
if self.args.download_resume and not (
self.args.download and self.args.output_file):
self.error('--continue requires --output to be specified')
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 os
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
@ -181,3 +182,69 @@ def readable_file_arg(filename):
return filename
except IOError as ex:
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_RAW_JSON = ':='
SEPARATOR_FILE_UPLOAD = '@'
SEPARATOR_FILE_UPLOAD_TYPE = ';type=' # in already parsed file upload path only
SEPARATOR_DATA_EMBED_FILE_CONTENTS = '=@'
SEPARATOR_DATA_EMBED_RAW_JSON_FILE = ':=@'
SEPARATOR_QUERY_PARAM = '=='
@ -83,21 +84,22 @@ PRETTY_MAP = {
}
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
OUTPUT_OPTIONS_DEFAULT = OUT_RESP_HEAD + OUT_RESP_BODY
OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED = OUT_RESP_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.cli.argparser import HTTPieArgumentParser
from httpie.cli.argtypes import (
KeyValueArgType, SessionNameValidator, readable_file_arg,
KeyValueArgType, SessionNameValidator,
readable_file_arg,
)
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,
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 (
AUTO_STYLE, AVAILABLE_STYLES, DEFAULT_STYLE,
)
from httpie.plugins import plugin_manager
from httpie.plugins.builtin import BuiltinAuthPlugin
from httpie.plugins.registry import plugin_manager
from httpie.sessions import DEFAULT_SESSIONS_DIR
from httpie.ssl import AVAILABLE_SSL_VERSION_ARG_MAPPING, DEFAULT_SSL_CIPHERS
parser = HTTPieArgumentParser(
prog='http',
description='%s <https://httpie.org>' % __doc__.strip(),
epilog=dedent("""
epilog=dedent('''
For every --OPTION there is also a --no-OPTION that reverts OPTION
to its default value.
@ -34,7 +38,7 @@ parser = HTTPieArgumentParser(
https://github.com/jakubroztocil/httpie/issues
"""),
'''),
)
#######################################################################
@ -43,18 +47,18 @@ parser = HTTPieArgumentParser(
positional = parser.add_argument_group(
title='Positional Arguments',
description=dedent("""
description=dedent('''
These arguments come after any flags and in the order they are listed here.
Only URL is required.
""")
''')
)
positional.add_argument(
dest='method',
metavar='METHOD',
nargs=OPTIONAL,
default=None,
help="""
help='''
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
@ -63,12 +67,12 @@ positional.add_argument(
$ http example.org # => GET
$ http example.org hello=world # => POST
"""
'''
)
positional.add_argument(
dest='url',
metavar='URL',
help="""
help='''
The scheme defaults to 'http://' if the URL does not include one.
(You can override this with: --default-scheme=https)
@ -77,7 +81,7 @@ positional.add_argument(
$ http :3000 # => http://localhost:3000
$ http :/foo # => http://localhost/foo
"""
'''
)
positional.add_argument(
dest='request_items',
@ -85,7 +89,7 @@ positional.add_argument(
nargs=ZERO_OR_MORE,
default=None,
type=KeyValueArgType(*SEPARATOR_GROUP_ALL_ITEMS),
help=r"""
help=r'''
Optional key-value pairs to be included in the request. The separator used
determines the type:
@ -108,7 +112,8 @@ positional.add_argument(
'@' 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:
@ -122,7 +127,7 @@ positional.add_argument(
field-name-with\:colon=value
"""
'''
)
#######################################################################
@ -137,24 +142,24 @@ content_type = parser.add_argument_group(
content_type.add_argument(
'--json', '-j',
action='store_true',
help="""
help='''
(default) Data items from the command line are serialized as a JSON object.
The Content-Type and Accept headers are set to application/json
(if not specified).
"""
'''
)
content_type.add_argument(
'--form', '-f',
action='store_true',
help="""
help='''
Data items from the command line are serialized as form fields.
The Content-Type is set to application/x-www-form-urlencoded (if not
specified). The presence of any file fields results in a
multipart/form-data request.
"""
'''
)
#######################################################################
@ -170,14 +175,14 @@ content_processing.add_argument(
'--compress', '-x',
action='count',
default=0,
help="""
help='''
Content compressed (encoded) with Deflate algorithm.
The Content-Encoding header is set to deflate.
Compression is skipped if it appears that compression ratio is
negative. Compression can be forced by repeating the argument.
"""
'''
)
#######################################################################
@ -191,12 +196,12 @@ output_processing.add_argument(
dest='prettify',
default=PRETTY_STDOUT_TTY_ONLY,
choices=sorted(PRETTY_MAP.keys()),
help="""
help='''
Controls output processing. The value can be "none" to not prettify
the output (default for redirected output), "all" to apply both colors
and formatting (default for terminal output), "colors", or "format".
"""
'''
)
output_processing.add_argument(
'--style', '-s',
@ -204,10 +209,10 @@ output_processing.add_argument(
metavar='STYLE',
default=DEFAULT_STYLE,
choices=AVAILABLE_STYLES,
help="""
Output coloring style (default is "{default}"). One of:
help='''
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.
@ -215,15 +220,75 @@ output_processing.add_argument(
$TERM environment variable is set to "xterm-256color" or similar
(e.g., via `export TERM=xterm-256color' in your ~/.bashrc).
""".format(
'''.format(
default=DEFAULT_STYLE,
available_styles='\n'.join(
'{0}{1}'.format(8 * ' ', line.strip())
for line in wrap(', '.join(sorted(AVAILABLE_STYLES)), 60)
).rstrip(),
).strip(),
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
@ -234,7 +299,7 @@ output_options.add_argument(
'--print', '-p',
dest='output_options',
metavar='WHAT',
help=f"""
help=f'''
String specifying what the output should contain:
'{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
response body is printed by default.
"""
'''
)
output_options.add_argument(
'--headers', '-h',
dest='output_options',
action='store_const',
const=OUT_RESP_HEAD,
help=f"""
help=f'''
Print only the response headers. Shortcut for --print={OUT_RESP_HEAD}.
"""
'''
)
output_options.add_argument(
'--body', '-b',
dest='output_options',
action='store_const',
const=OUT_RESP_BODY,
help=f"""
help=f'''
Print only the response body. Shortcut for --print={OUT_RESP_BODY}.
"""
'''
)
output_options.add_argument(
'--verbose', '-v',
dest='verbose',
action='store_true',
help="""
help='''
Verbose output. Print the whole request as well as the response. Also print
any intermediary requests/responses (such as redirects).
It's a shortcut for: --all --print={0}
""".format(''.join(OUTPUT_OPTIONS))
'''.format(''.join(OUTPUT_OPTIONS))
)
output_options.add_argument(
'--all',
default=False,
action='store_true',
help="""
help='''
By default, only the final request/response is shown. Use this flag to show
any intermediary requests/responses as well. Intermediary requests include
followed redirects (with --follow), the first unauthorized request when
Digest auth is used (--auth=digest), etc.
"""
'''
)
output_options.add_argument(
'--history-print', '-P',
dest='output_options_history',
metavar='WHAT',
help="""
help='''
The same as --print, -p but applies only to intermediary requests/responses
(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
response.
"""
'''
)
output_options.add_argument(
'--stream', '-S',
action='store_true',
default=False,
help="""
help='''
Always stream the output by line, i.e., behave like `tail -f'.
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
more often and in smaller chunks.
"""
'''
)
output_options.add_argument(
'--output', '-o',
type=FileType('a+b'),
dest='output_file',
metavar='FILE',
help="""
help='''
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
printed to stderr.
"""
'''
)
@ -341,12 +406,12 @@ output_options.add_argument(
'--download', '-d',
action='store_true',
default=False,
help="""
help='''
Do not print the response body to stdout. Rather, download it and store it
in a file. The filename is guessed unless specified with --output
[filename]. This action is similar to the default behaviour of wget.
"""
'''
)
output_options.add_argument(
@ -354,11 +419,11 @@ output_options.add_argument(
dest='download_resume',
action='store_true',
default=False,
help="""
help='''
Resume an interrupted download. Note that the --output option needs to be
specified as well.
"""
'''
)
#######################################################################
@ -376,7 +441,7 @@ sessions.add_argument(
'--session',
metavar='SESSION_NAME_OR_PATH',
type=session_name_validator,
help=f"""
help=f'''
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
requests.
@ -385,17 +450,17 @@ sessions.add_argument(
{DEFAULT_SESSIONS_DIR}/<HOST>/<SESSION_NAME>.json.
"""
'''
)
sessions.add_argument(
'--session-read-only',
metavar='SESSION_NAME_OR_PATH',
type=session_name_validator,
help="""
help='''
Create or read a session without updating it form the request/response
exchange.
"""
'''
)
#######################################################################
@ -408,11 +473,11 @@ auth.add_argument(
'--auth', '-a',
default=None,
metavar='USER[:PASS]',
help="""
help='''
If only the username is provided (-a username), HTTPie will prompt
for the password.
""",
''',
)
@ -431,12 +496,12 @@ auth.add_argument(
'--auth-type', '-A',
choices=_AuthTypeLazyChoices(),
default=None,
help="""
help='''
The authentication mechanism to be used. Defaults to "{default}".
{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=plugin.auth_type,
name=plugin.name,
@ -456,10 +521,10 @@ auth.add_argument(
'--ignore-netrc',
default=False,
action='store_true',
help="""
help='''
Ignore credentials from .netrc.
""",
''',
)
#######################################################################
@ -472,9 +537,9 @@ network.add_argument(
'--offline',
default=False,
action='store_true',
help="""
help='''
Build the request and print it but dont actually send it.
"""
'''
)
network.add_argument(
'--proxy',
@ -482,43 +547,43 @@ network.add_argument(
action='append',
metavar='PROTOCOL:PROXY_URL',
type=KeyValueArgType(SEPARATOR_PROXY),
help="""
help='''
String mapping protocol to the URL of the proxy
(e.g. http:http://foo.bar:3128). You can specify multiple proxies with
different protocols. The environment variables $ALL_PROXY, $HTTP_PROXY,
and $HTTPS_proxy are supported as well.
"""
'''
)
network.add_argument(
'--follow', '-F',
default=False,
action='store_true',
help="""
help='''
Follow 30x Location redirects.
"""
'''
)
network.add_argument(
'--max-redirects',
type=int,
default=30,
help="""
help='''
By default, requests have a limit of 30 redirects (works with --follow).
"""
'''
)
network.add_argument(
'--max-headers',
type=int,
default=0,
help="""
help='''
The maximum number of response headers to be read before giving up
(default 0, i.e., no limit).
"""
'''
)
network.add_argument(
@ -526,7 +591,7 @@ network.add_argument(
type=float,
default=0,
metavar='SECONDS',
help="""
help='''
The connection timeout of the request in seconds.
The default value is 0, i.e., there is no timeout limit.
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
the underlying socket for timeout seconds).
"""
'''
)
network.add_argument(
'--check-status',
default=False,
action='store_true',
help="""
help='''
By default, HTTPie exits with 0 when no network or other fatal errors
occur. This flag instructs HTTPie to also check the HTTP status code and
exit with an error if the status indicates one.
@ -550,7 +615,16 @@ network.add_argument(
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.
"""
'''
)
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(
'--verify',
default='yes',
help="""
help='''
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
for private certs. (Or you can set the REQUESTS_CA_BUNDLE environment
variable instead.)
"""
'''
)
ssl.add_argument(
'--ssl', # TODO: Maybe something more general, such as --secure-protocol?
dest='ssl_version',
choices=list(sorted(SSL_VERSION_ARG_MAPPING.keys())),
help="""
choices=list(sorted(AVAILABLE_SSL_VERSION_ARG_MAPPING.keys())),
help='''
The desired protocol version to use. This will default to
SSL v2.3 which will negotiate the highest protocol that both
the server and your installation of OpenSSL support. Available protocols
may vary depending on OpenSSL installation (only the supported ones
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(
'--cert',
default=None,
type=readable_file_arg,
help="""
help='''
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
specify --cert-key separately.
"""
'''
)
ssl.add_argument(
'--cert-key',
default=None,
type=readable_file_arg,
help="""
help='''
The private key to use with SSL. Only needed if --cert is given and the
certificate file does not contain the private key.
"""
'''
)
#######################################################################
@ -614,53 +699,53 @@ troubleshooting.add_argument(
'--ignore-stdin', '-I',
action='store_true',
default=False,
help="""
help='''
Do not attempt to read stdin.
"""
'''
)
troubleshooting.add_argument(
'--help',
action='help',
default=SUPPRESS,
help="""
help='''
Show this help message and exit.
"""
'''
)
troubleshooting.add_argument(
'--version',
action='version',
version=__version__,
help="""
help='''
Show version and exit.
"""
'''
)
troubleshooting.add_argument(
'--traceback',
action='store_true',
default=False,
help="""
help='''
Prints the exception traceback should one occur.
"""
'''
)
troubleshooting.add_argument(
'--default-scheme',
default="http",
help="""
help='''
The default scheme to use if not specified in the URL.
"""
'''
)
troubleshooting.add_argument(
'--debug',
action='store_true',
default=False,
help="""
help='''
Prints the exception traceback should one occur, as well as other
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 (
SEPARATOR_DATA_EMBED_FILE_CONTENTS, SEPARATOR_DATA_EMBED_RAW_JSON_FILE,
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 (
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]:
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:
with open(os.path.expanduser(filename), 'rb') as f:
contents = f.read()
@ -104,7 +108,7 @@ def process_file_upload_arg(arg: KeyValueArg) -> Tuple[str, IO, str]:
return (
os.path.basename(filename),
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 pathlib import Path
from typing import Iterable, Union
from urllib.parse import urlparse, urlunparse
import requests
from requests.adapters import HTTPAdapter
# noinspection PyPackageRequirements
import urllib3
from httpie import __version__
from httpie.cli.constants import SSL_VERSION_ARG_MAPPING
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.utils import repr_dict
try:
# noinspection PyPackageRequirements
import urllib3
# <https://urllib3.readthedocs.io/en/latest/security.html>
urllib3.disable_warnings()
except (ImportError, AttributeError):
pass
from httpie.ssl import AVAILABLE_SSL_VERSION_ARG_MAPPING, HTTPieHTTPSAdapter
from httpie.utils import get_expired_cookies, repr_dict
urllib3.disable_warnings()
FORM_CONTENT_TYPE = 'application/x-www-form-urlencoded; charset=utf-8'
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__}'
@ -56,6 +50,8 @@ def collect_messages(
send_kwargs_mergeable_from_env = make_send_kwargs_mergeable_from_env(args)
requests_session = build_requests_session(
ssl_version=args.ssl_version,
ciphers=args.ciphers,
verify=bool(send_kwargs_mergeable_from_env['verify'])
)
if httpie_session:
@ -77,9 +73,15 @@ def collect_messages(
request = requests.Request(**request_kwargs)
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:
compress_body(prepared_request, always=args.compress > 1)
response_count = 0
expired_cookies = []
while prepared_request:
yield prepared_request
if not args.offline:
@ -93,6 +95,12 @@ def collect_messages(
**send_kwargs_merged,
**send_kwargs,
)
# noinspection PyProtectedMember
expired_cookies += get_expired_cookies(
headers=response.raw._original_response.msg._headers
)
response_count += 1
if response.next:
if args.max_redirects and response_count == args.max_redirects:
@ -108,6 +116,10 @@ def collect_messages(
if httpie_session:
if httpie_session.is_new() or not args.session_read_only:
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()
@ -115,6 +127,7 @@ def collect_messages(
@contextmanager
def max_headers(limit):
# <https://github.com/jakubroztocil/httpie/issues/802>
# noinspection PyUnresolvedReferences
orig = http.client._MAXHEADERS
http.client._MAXHEADERS = limit or float('Inf')
try:
@ -139,29 +152,23 @@ def compress_body(request: requests.PreparedRequest, always: bool):
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(
verify: bool,
ssl_version: str = None,
ciphers: str = None,
) -> requests.Session:
requests_session = requests.Session()
# Install our adapter.
requests_session.mount('https://', HTTPieHTTPSAdapter(
https_adapter = HTTPieHTTPSAdapter(
ciphers=ciphers,
verify=verify,
ssl_version=(
SSL_VERSION_ARG_MAPPING[ssl_version]
AVAILABLE_SSL_VERSION_ARG_MAPPING[ssl_version]
if ssl_version else None
)
))
),
)
requests_session.mount('https://', https_adapter)
# Install adapters from plugins.
for plugin_cls in plugin_manager.get_transport_plugins():
@ -278,3 +285,30 @@ def make_request_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
DEFAULT_CONFIG_DIR = Path(os.environ.get(
'HTTPIE_CONFIG_DIR',
os.path.expanduser('~/.httpie') if not is_windows else
os.path.expandvars(r'%APPDATA%\\httpie')
))
ENV_XDG_CONFIG_HOME = 'XDG_CONFIG_HOME'
ENV_HTTPIE_CONFIG_DIR = 'HTTPIE_CONFIG_DIR'
DEFAULT_CONFIG_DIRNAME = '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):

View File

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

View File

@ -13,10 +13,11 @@ from httpie.client import collect_messages
from httpie.context import Environment
from httpie.downloads import Downloader
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
# noinspection PyDefaultArgument
def main(
args: List[Union[str, bytes]] = sys.argv,
env=Environment(),
@ -90,7 +91,7 @@ def main(
exit_status = ExitStatus.ERROR_TOO_MANY_REDIRECTS
env.log_error(
f'Too many redirects'
f' (--max-redirects=parsed_args.max_redirects).'
f' (--max-redirects={parsed_args.max_redirects}).'
)
except Exception as e:
# 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
already exists.
:type resume: bool
:param output_file: The file to store response body in. If not
provided, it will be guessed from the response.
@ -322,7 +321,6 @@ class Downloader:
:param chunk: A chunk of response body data that has just
been downloaded and written to the output.
:type chunk: bytes
"""
self.status.chunk_downloaded(len(chunk))

View File

@ -3,6 +3,10 @@ from httpie.plugins import 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:
"""
Sorts headers by name while retaining relative

View File

@ -4,11 +4,12 @@ import json
from httpie.plugins import FormatterPlugin
DEFAULT_INDENT = 4
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:
maybe_json = [
'json',
@ -26,8 +27,8 @@ class JSONFormatter(FormatterPlugin):
# unicode escapes to improve readability.
body = json.dumps(
obj=obj,
sort_keys=True,
sort_keys=self.format_options['json']['sort_keys'],
ensure_ascii=False,
indent=DEFAULT_INDENT
indent=self.format_options['json']['indent']
)
return body

View File

@ -1,7 +1,8 @@
import re
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

View File

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

View File

@ -7,18 +7,3 @@ from httpie.plugins.base import (
AuthPlugin, FormatterPlugin,
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".
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.
description = None
@ -15,7 +15,9 @@ class AuthPlugin(BasePlugin):
"""
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`
@ -33,13 +35,22 @@ class AuthPlugin(BasePlugin):
# Set this to `False` to disable the parsing and error handling.
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`,
# and the value of `-a` lacks the password part,
# then the user will be prompted to type the password in.
prompt_password = True
# 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
def get_auth(self, username=None, password=None):
@ -58,8 +69,13 @@ class AuthPlugin(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):
"""
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):
self.mime = mime
@ -89,17 +113,22 @@ class ConverterPlugin(BasePlugin):
class FormatterPlugin(BasePlugin):
"""
Possibly formats response body & headers for prettified terminal display.
"""
group_name = 'format'
def __init__(self, **kwargs):
"""
:param env: an class:`Environment` instance
:param kwargs: additional keyword argument that some
processor might require.
formatters might require.
"""
self.enabled = True
self.kwargs = kwargs
self.format_options = kwargs['format_options']
def format_headers(self, headers: str) -> str:
"""Return processed `headers`

View File

@ -22,6 +22,7 @@ class HTTPBasicAuth(requests.auth.HTTPBasicAuth):
See https://github.com/jakubroztocil/httpie/issues/212
"""
# noinspection PyTypeChecker
request.headers['Authorization'] = type(self).make_header(
self.username, self.password).encode('latin1')
return request
@ -36,6 +37,7 @@ class HTTPBasicAuth(requests.auth.HTTPBasicAuth):
class BasicAuthPlugin(BuiltinAuthPlugin):
name = 'Basic HTTP auth'
auth_type = 'basic'
netrc_parse = True
# noinspection PyMethodOverriding
def get_auth(self, username: str, password: str) -> HTTPBasicAuth:
@ -43,9 +45,9 @@ class BasicAuthPlugin(BuiltinAuthPlugin):
class DigestAuthPlugin(BuiltinAuthPlugin):
name = 'Digest HTTP auth'
auth_type = 'digest'
netrc_parse = True
# noinspection PyMethodOverriding
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 re
from pathlib import Path
from typing import Optional, Union
from typing import Iterable, Optional, Union
from urllib.parse import urlsplit
from requests.auth import AuthBase
@ -13,7 +13,7 @@ from requests.cookies import RequestsCookieJar, create_cookie
from httpie.cli.dicts import RequestHeadersDict
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'
@ -144,3 +144,8 @@ class Session(BaseConfigDict):
def auth(self, auth: dict):
assert {'type', 'raw_auth'} == auth.keys()
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
import json
import mimetypes
import time
from collections import OrderedDict
from http.cookiejar import parse_ns_headers
from pprint import pformat
from typing import List, Tuple
import requests.auth
@ -83,3 +87,27 @@ def get_content_type(filename):
if encoding:
content_type = '%s; charset=%s' % (mime, encoding)
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]
universal = 1
[tool:pytest]
# <https://docs.pytest.org/en/latest/customize.html>
norecursedirs = tests/fixtures
addopts = --tb=native
[pycodestyle]

View File

@ -10,8 +10,11 @@ import httpie
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):
TestCommand.finalize_options(self)
self.test_args = [
@ -26,8 +29,6 @@ class PyTest(TestCommand):
tests_require = [
# Pytest needs to come last.
# https://bitbucket.org/pypa/setuptools/issue/196/
'pytest-httpbin',
'pytest',
'mock',
@ -38,28 +39,24 @@ install_requires = [
'requests>=2.22.0',
'Pygments>=2.5.2',
]
install_requires_win_only = [
'colorama>=0.2.4',
]
# Conditional dependencies:
# sdist
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():
# Terminal colors for Windows
install_requires.append('colorama>=0.2.4')
install_requires.extend(install_requires_win_only)
# bdist_wheel
extras_require = {
# 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"': ['colorama>=0.2.4'],
':sys_platform == "win32"': install_requires_win_only,
}
@ -74,7 +71,7 @@ setup(
description=httpie.__doc__.strip(),
long_description=long_description(),
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_email='jakub@roztocil.co',
license=httpie.__licence__,
@ -85,6 +82,7 @@ setup(
'https = httpie.__main__:main',
],
},
python_requires='>=3.6',
extras_require=extras_require,
install_requires=install_requires,
tests_require=tests_require,
@ -92,9 +90,7 @@ setup(
classifiers=[
'Development Status :: 5 - Production/Stable',
'Programming Language :: Python',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3 :: Only',
'Environment :: Console',
'Intended Audience :: Developers',
'Intended Audience :: System Administrators',
@ -106,4 +102,11 @@ setup(
'Topic :: Text Processing',
'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
from httpie.plugins.builtin import HTTPBasicAuth
from httpie.status import ExitStatus
from httpie.utils import ExplicitNullAuth
from utils import http, add_auth, HTTP_OK, MockEnvironment
import httpie.cli.constants
@ -78,6 +79,8 @@ def test_missing_auth(httpbin):
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:
get_netrc_auth.return_value = ('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):
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')
r = http('--ignore-netrc', httpbin_both + '/basic-auth/httpie/password')
assert get_netrc_auth.call_count == 0
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():
args = httpie.cli.definition.parser.parse_args(
args=['--ignore-netrc', '--auth=username:password', 'example.org'],
env=MockEnvironment(),
)
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 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
# TODO: run all these tests in session mode as well
USERNAME = 'user'

View File

@ -1,7 +1,14 @@
from pathlib import Path
import pytest
from _pytest.monkeypatch import MonkeyPatch
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
@ -48,3 +55,50 @@ def test_default_options_overwrite(httpbin):
assert r.json['json'] == {
"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
# noinspection PyPep8Naming
class TestImplicitHTTPMethod:
def test_implicit_GET(self, httpbin):
r = http(httpbin.url + '/get')
@ -51,9 +52,9 @@ class TestImplicitHTTPMethod:
class TestAutoContentTypeAndAcceptHeaders:
"""
Test that Accept and Content-Type correctly defaults to JSON,
but can still be overridden. The same with Content-Type when --form
-f is used.
Test that `Accept` and `Content-Type` correctly default to JSON,
but can still be overridden. The same with Content-Type when `--form`
`-f` is used.
"""
@ -84,7 +85,7 @@ class TestAutoContentTypeAndAcceptHeaders:
assert r.json['headers']['Accept'] == JSON_ACCEPT
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')
assert HTTP_OK in r
assert r.json['headers']['Accept'] == JSON_ACCEPT

View File

@ -55,6 +55,25 @@ def test_GET(httpbin_both):
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):
r = http('DELETE', httpbin_both + '/delete')
assert HTTP_OK in r
@ -98,6 +117,27 @@ def test_POST_file(httpbin_both):
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):
r = http('GET', httpbin_both + '/headers', 'Foo:bar')
assert HTTP_OK in r
@ -141,3 +181,21 @@ def test_json_input_preserve_order(httpbin_both):
assert HTTP_OK in r
assert r.json['data'] == \
'{"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
from tempfile import gettempdir
from urllib.request import urlopen
import pytest
from utils import MockEnvironment, http, HTTP_OK, COLOR, CRLF
from httpie.status import ExitStatus
from httpie.cli.argtypes import (
PARSED_DEFAULT_FORMAT_OPTIONS,
parse_format_options,
)
from httpie.cli.definition import parser
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])
@ -58,19 +65,19 @@ class TestColors:
@pytest.mark.parametrize(
argnames=['mime', 'explicit_json', 'body', 'expected_lexer_name'],
argvalues=[
('application/json', False, None, 'JSON'),
('application/json', False, None, 'JSON'),
('application/json+foo', False, None, 'JSON'),
('application/foo+json', False, None, 'JSON'),
('application/json-foo', False, None, 'JSON'),
('application/x-json', False, None, 'JSON'),
('foo/json', False, None, 'JSON'),
('foo/json+bar', False, None, 'JSON'),
('foo/bar+json', False, None, 'JSON'),
('foo/json-foo', False, None, 'JSON'),
('foo/x-json', False, None, 'JSON'),
('application/x-json', False, None, 'JSON'),
('foo/json', False, None, 'JSON'),
('foo/json+bar', False, None, 'JSON'),
('foo/bar+json', False, None, 'JSON'),
('foo/json-foo', False, None, 'JSON'),
('foo/x-json', False, None, 'JSON'),
('application/vnd.comverge.grid+hal+json', False, None, 'JSON'),
('text/plain', True, '{}', 'JSON'),
('text/plain', True, 'foo', 'Text only'),
('text/plain', True, '{}', 'JSON'),
('text/plain', True, 'foo', 'Text only'),
]
)
def test_get_lexer(self, mime, explicit_json, body, expected_lexer_name):
@ -83,7 +90,7 @@ class TestColors:
class TestPrettyOptions:
"""Test the --pretty flag handling."""
"""Test the --pretty handling."""
def test_pretty_enabled_by_default(self, httpbin):
env = MockEnvironment(colors=256)
@ -138,6 +145,7 @@ class TestLineEndings:
and as the headers/body separator.
"""
def _validate_crlf(self, msg):
lines = iter(msg.splitlines(True))
for header in lines:
@ -171,3 +179,199 @@ class TestLineEndings:
def test_CRLF_formatted_request(self, httpbin):
r = http('--pretty=format', '--print=HB', 'GET', httpbin.url + '/get')
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
import json
import os
import shutil
import sys
from datetime import datetime
from tempfile import gettempdir
import pytest
from httpie.plugins.builtin import HTTPBasicAuth
from utils import MockEnvironment, mk_config_dir, http, HTTP_OK
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:
@ -186,3 +189,91 @@ class TestSession(SessionTestBase):
httpbin.url + '/get', env=self.env())
finally:
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_httpbin.certs
import requests.exceptions
from httpie.ssl import AVAILABLE_SSL_VERSION_ARG_MAPPING, DEFAULT_SSL_CIPHERS
from httpie.status import ExitStatus
from httpie.cli.constants import SSL_VERSION_ARG_MAPPING
from utils import HTTP_OK, TESTS_ROOT, http
@ -23,10 +21,12 @@ except ImportError:
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:
# We test against a local httpbin instance which uses a self-signed cert.
# 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()
@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):
try:
r = http(
@ -113,3 +114,23 @@ class TestServerCert:
def test_verify_custom_ca_bundle_invalid_bundle(self, httpbin_secure):
with pytest.raises(ssl_errors):
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
from httpie.cli.exceptions import ParseError
from httpie.status import ExitStatus
from utils import MockEnvironment, http, HTTP_OK
from fixtures import FILE_PATH_ARG, FILE_PATH, FILE_CONTENT
@ -16,27 +17,37 @@ class TestMultipartFormDataFileUpload:
def test_upload_ok(self, httpbin):
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 'Content-Disposition: form-data; name="foo"' in r
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 '"foo": "bar"' in r
assert 'Content-Type: text/plain' in r
def test_upload_multiple_fields_with_the_same_name(self, httpbin):
r = http('--form', '--verbose', 'POST', httpbin.url + '/post',
'test-file@%s' % FILE_PATH_ARG,
'test-file@%s' % FILE_PATH_ARG)
f'test-file@{FILE_PATH_ARG}',
f'test-file@{FILE_PATH_ARG}')
assert HTTP_OK in r
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
# doesn't seem to support filed field lists
assert r.count(FILE_CONTENT) in [3, 4]
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:
"""
@ -77,4 +88,5 @@ class TestRequestBodyFromFilePath:
env=env,
tolerate_error_exit_status=True,
)
assert r.exit_status == ExitStatus.ERROR
assert 'cannot be mixed' in r.stderr

View File

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