Compare commits

...

512 Commits
0.9.3 ... 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
a7e5228712 Cleanup 2020-01-12 11:06:43 +01:00
5d628756ab Update Python version info 2020-01-12 11:00:25 +01:00
364edc4bd8 Ignore codecov upload failures 2020-01-12 10:57:14 +01:00
ce5ca6c480 Fix version 2020-01-12 10:55:45 +01:00
4b524e6a8c v2.0.0 2020-01-12 10:50:57 +01:00
e4a3ce8b9d Cleanup 2019-12-04 23:31:47 +01:00
348cc7d5c5 Fixes 2019-12-04 18:48:39 +01:00
ab3ea24630 Fixes 2019-12-04 18:34:26 +01:00
cd5116705c Set path in makefile 2019-12-04 18:24:53 +01:00
38bc578744 Fix tests 2019-12-04 18:09:51 +01:00
1bc54d4cb4 Create Python virtual environment (via venv) for tests & development, etc. 2019-12-04 17:49:07 +01:00
fe8b547cc7 Fix make target in CONTRIBUTING.rst (#819) 2019-12-04 13:54:00 +01:00
5aa9ed795e Switch to explicitly listed directories to search in for *.rst #820 2019-12-04 13:51:45 +01:00
c82d9b629f Merge remote-tracking branch 'origin/master' 2019-12-04 13:38:07 +01:00
e8b22d8b51 exclude site-packages from .rst file scanning (#820)
make test fails when finding .rst files from site packages installed
in the virtual environment
2019-12-04 13:37:57 +01:00
585cc0c039 Merge remote-tracking branch 'origin/master' 2019-12-04 13:37:46 +01:00
615d887513 run rst2pseudoxml.py with shell=true (#821)
makes rst2pseudoxml.py work properly on Windows

executes via a shell instead of not working
2019-12-04 13:33:13 +01:00
89faec994a Fix simple typo: downland -> download (#823)
Closes #822
2019-12-04 13:32:08 +01:00
490eeaa650 Cleanup 2019-12-03 19:09:09 +01:00
f1ab816ecd Tweak querystring parameters doc 2019-12-03 12:23:33 +01:00
6e2c31a5a9 Fix build 2019-12-02 20:57:21 +01:00
0608b5869f Upgrade setuptools 2019-12-02 20:47:09 +01:00
fcc3aaf873 Add Python 3.8 build 2019-12-02 20:39:07 +01:00
dcd6b63e45 withing -> within (#795) 2019-12-02 18:12:51 +01:00
ab2bda3ffe Fix typo (#808) 2019-12-02 18:04:52 +01:00
7390869cd6 Skip test_config_file_inaccessible on Windows 2019-12-02 17:59:44 +01:00
0af486d1b7 Ignore test cleanup rmtree errors (Win) 2019-12-02 17:53:29 +01:00
6cb822255d PEP8 2019-12-02 17:46:40 +01:00
f202f338a4 Remove automatic config file creation to avoid concurrency issues.
Close #788
Close #812
2019-12-02 17:43:16 +01:00
f0058eeaee cleanup 2019-12-02 10:42:33 +01:00
a23b636a63 Cleanup 2019-12-02 00:58:10 +01:00
fc497daf7d Update build.yml 2019-09-28 10:36:28 +02:00
b48ba74ce2 Update build.yml 2019-09-28 10:36:12 +02:00
9bae27354e Add main entry point tests 2019-09-18 11:57:27 +02:00
d9b3a16fa6 Make ExitStatus subclass IntEnum to allow direct int comparisons 2019-09-18 11:57:06 +02:00
f031b8cc8b Make codecov fail loudly 2019-09-18 11:17:33 +02:00
2dbafe27ed Fix codecov token 2019-09-18 11:16:19 +02:00
3affc245c4 Fix step order 2019-09-18 11:14:34 +02:00
85da430d16 codecov 2019-09-18 11:09:46 +02:00
a42b275ae2 Typing & cleanup 2019-09-17 09:21:49 +02:00
37fa67cd3c Runnable KeyValueArgType.tokenize doctest 2019-09-17 09:07:12 +02:00
0df4db7bb4 Cleanup 2019-09-16 13:28:01 +02:00
374c371ef1 Add httpie.status 2019-09-16 13:26:18 +02:00
64c81fc2ec Simplify 2019-09-10 14:40:34 +02:00
0252c2642e Build badge 2019-09-10 14:24:55 +02:00
b53ace480a Build on push via GitHub Actions 2019-09-10 14:18:06 +02:00
79b0f65fef Build on push via GitHub Actions 2019-09-10 14:14:39 +02:00
ed6156084f Remove .travis.yml 2019-09-10 14:14:08 +02:00
92fe452f92 worflow 2019-09-10 14:05:40 +02:00
0169151aa3 worflow 2019-09-10 14:02:11 +02:00
525449f044 worflow 2019-09-10 14:01:04 +02:00
3c4a5e7304 worflow 2019-09-10 14:00:28 +02:00
d9aadeef51 worflow 2019-09-10 13:59:43 +02:00
2bb54da368 worflow 2019-09-10 13:58:05 +02:00
3fa583e591 workflow 2019-09-10 13:39:59 +02:00
b7767b3c62 workflow 2019-09-10 13:36:02 +02:00
a5d9a839e5 coveralls token 2019-09-10 13:20:09 +02:00
2ffd8d9d9b workflow 2019-09-10 13:10:29 +02:00
7f80408945 Update pythonpackage.yml 2019-09-10 12:49:46 +02:00
3ec5c4a643 Update pythonpackage.yml 2019-09-10 12:46:34 +02:00
3909a436a9 Update pythonpackage.yml 2019-09-10 12:44:25 +02:00
a77f660ba7 Update pythonpackage.yml 2019-09-10 12:17:03 +02:00
548857f35a Update pythonpackage.yml 2019-09-10 11:56:55 +02:00
8741438484 Update CHANGELOG.rst 2019-09-09 09:36:22 +02:00
3176785a5f Create CODE_OF_CONDUCT.md 2019-09-04 13:38:56 +02:00
c8fd4c2d6e Move compression out of adapter 2019-09-04 00:00:03 +02:00
99f8a8c23d Typos 2019-09-03 22:37:59 +02:00
f866778421 CHANGELOG 2019-09-03 22:37:18 +02:00
5a4392076a Included tests in pypi package #182 2019-09-03 22:34:04 +02:00
bece3c77bb Add one-by-one processing of each HTTP request or response and --offline 2019-09-03 17:14:39 +02:00
c946b3d34f Cleanup 2019-09-02 14:38:23 +02:00
45e8e4e4ea Sessions 2019-09-01 21:15:39 +02:00
bd3208cf24 Refactor get_formatters_grouped 2019-09-01 11:45:47 +02:00
4dffac7a25 Refactor client 2019-09-01 11:38:14 +02:00
a34b3d9d87 Refactor PluginManager 2019-09-01 11:13:45 +02:00
30624e66ec Annotate formatters and processing 2019-08-31 19:13:36 +02:00
d603502960 Fix unregister annotation 2019-08-31 18:35:24 +02:00
09cd85918e CHANGELOG 2019-08-31 18:35:18 +02:00
b947d4826a Annotate plugins 2019-08-31 18:33:54 +02:00
e8ef5a783f Annotate and refactor streams.py 2019-08-31 18:21:10 +02:00
82a224a658 CHANGELOG 2019-08-31 18:00:34 +02:00
9da5c41704 Improve --debug output formatting 2019-08-31 18:00:03 +02:00
224519e0e2 Fix --ssl with --compress; refactor client 2019-08-31 17:52:56 +02:00
aba3b1ec01 Refactoring 2019-08-31 15:17:10 +02:00
466df77b6b CHANGELOG 2019-08-31 12:32:48 +02:00
3ea75a3577 Document $ALL_PROXY support (close #676)
It’s been supported by python-requests since v2.11.0 (2016-08-08)

d79024f246/HISTORY.md (2110-2016-08-08)
2019-08-31 12:31:32 +02:00
3e24827f4d Test that --ignore-netrc doesn't interfere with --auth 2019-08-31 12:14:44 +02:00
1dc67a6a38 Allow bypassing .netrc with --ignore-netrc (close #730) 2019-08-31 12:09:17 +02:00
a5713f7190 pep8 2019-08-30 21:26:51 +02:00
0f654388fc Python 3 annotations, super(), pathlib, etc. 2019-08-30 15:14:51 +02:00
63df735fef Update links to HTTPS 2019-08-30 10:07:01 +02:00
2579827418 Use set literal 2019-08-30 09:56:50 +02:00
9bd8b4e8f7 Don't fail if config dir not writeable (close #738) 2019-08-29 14:05:32 +02:00
d998013655 Merge branch 'mgsloan-allow-closed-stdin' 2019-08-29 13:40:00 +02:00
ced9212c1f Allow stdin to be a closed fd #791 2019-08-29 13:39:42 +02:00
07da8ea852 Merge branch 'allow-closed-stdin' of https://github.com/mgsloan/httpie into mgsloan-allow-closed-stdin 2019-08-29 13:26:28 +02:00
8e04a24b90 Reintroduce $ https command alias with https:// as default scheme
Close #608
2019-08-29 13:08:02 +02:00
8512a630f9 Use exact text from the three-clause BSD license (close #740)
As per https://opensource.org/licenses/BSD-3-Clause
2019-08-29 12:00:41 +02:00
2da2cec83c CHANGELOG 2019-08-29 11:51:49 +02:00
a4d8f1f22e Refactor --compress tests 2019-08-29 11:46:08 +02:00
5ec954c03d Add compressed requests (#739)
* Add optional compression of the request's content

This option allows compression of the files and/or data during uploading,

Examples:

    http --form --compress POST https://localhost/upload csv@./very-big.csv

    http -x -x POST https://localhost/upload foo=bar

    cat /var/log/system.log | http -x POST https://localhost/upload

Signed-off-by: Aleksandr Vinokurov <aleksandr.vin@gmail.com>

* Add tests for compression

Signed-off-by: Aleksandr Vinokurov <aleksandr.vin@gmail.com>

* Fix code style issues

Signed-off-by: Aleksandr Vinokurov <aleksandr.vin@gmail.com>

* Fix zlib compression api missuse in Python3

Signed-off-by: Aleksandr Vinokurov <aleksandr.vin@gmail.com>

* Remove tracing from compression logic

Signed-off-by: Aleksandr Vinokurov <aleksandr.vin@gmail.com>
2019-08-29 10:44:59 +02:00
2deaccf2d1 README 2019-08-29 10:21:13 +02:00
46c4f4e225 README 2019-08-29 10:20:45 +02:00
2d16494845 README 2019-08-29 10:16:39 +02:00
bb4f101c1e pep8 2019-08-29 10:09:56 +02:00
82081c889b Fix `--timeout=0` 2019-08-29 10:06:25 +02:00
05fc9c480a Remove the default 30-second connection timeout limit 2019-08-29 09:57:00 +02:00
e93de1fbe7 Make test_binary_suppresses_* deterministic 2019-08-29 09:46:17 +02:00
a969013bdd Disable default max headers limit and add --max-headers (closes #802) 2019-08-29 09:39:19 +02:00
65601f09b2 pip3 2019-08-29 09:28:57 +02:00
0f439a5dab Changelog 2019-08-29 09:01:27 +02:00
b3d2c1876e Python 2.7 support removal WIP 2019-08-29 08:53:56 +02:00
c297af0012 doc 2019-08-29 08:39:23 +02:00
f27b626a96 fix tests 2019-08-29 08:38:34 +02:00
c1d5a4a109 fix tests 2019-08-29 08:34:55 +02:00
db3016a602 Temporarily disable macOS stock Python Travis build
It's failing with InterpreterNotFound

https://travis-ci.org/jakubroztocil/httpie/jobs/578195789
2019-08-29 08:15:39 +02:00
4dd9dbd314 fix test_ssl_version II 2019-08-29 08:14:19 +02:00
29df4cd4f3 fix test_ssl_version on pypy 2019-08-29 08:05:31 +02:00
4d299a5531 Fix tests (work in progress) (#796)
* Add pyOpenSSL to dev requirements to fix tests on py2

* Add pyOpenSSL to the install requires

* Remove pyOpenSSL from install_requires
2019-08-29 07:59:18 +02:00
add6601009 Update homebrew formula for 1.0.3 (#801)
* Update brew formula for 1.0.3

This updates the Homebrew formula to:

* Install httpie 1.0.3
* Install the most up-to-date dependencies for httpie

* Add myself to AUTHORS
2019-08-29 07:38:46 +02:00
fa96041ec8 Update README.rst 2019-08-28 11:05:07 +02:00
3dccb2e325 Update CHANGELOG.rst 2019-08-26 17:42:47 +02:00
0a0de1755e Fix link 2019-08-26 12:47:31 +02:00
747be30d2e 1.0.3 2019-08-26 12:42:34 +02:00
88a9583f4c Update CHANGELOG.rst 2019-07-20 13:03:30 +02:00
c5ca9d248e Allow stdin to be a closed fd
Before this change, the following invocation would not work

```
$ http http://neverhttps.com <&-
```

The "<&-" at the end closes the stdin fd. Specifically, it would fail with

```
  ...
  File "/home/mgsloan/.local/lib/python3.6/site-packages/httpie/context.py", line 26, in Environment
    stdin_isatty = stdin.isatty()
AttributeError: 'NoneType' object has no attribute 'isatty'
```

This can occur when httpie is being programmatically invoked, and may
as well be supported.
2019-07-17 23:02:49 -06:00
fd6e87914c README 2019-06-24 12:36:08 +02:00
6dee49357d Fix comments 2019-06-24 12:29:42 +02:00
df36d6255d Changed the way the output filename is generated
When ``--download`` without ``--output`` results in a redirect,
now only the initial URL is considered, not the final one.
2019-06-24 12:20:09 +02:00
e92b831e6e Create FUNDING.yml 2019-06-23 12:05:24 +02:00
fd44f1af93 Updated Readme to fix a typo (#767) 2019-04-10 13:21:37 +02:00
b6309547d5 Add a bash here string example 2019-03-11 08:41:24 +01:00
3a46149de1 Fix several ResourceWarning: unclosed file (#741)
Signed-off-by: Mickaël Schoentgen <contact@tiger-222.fr>
2019-02-04 10:00:30 +01:00
b7c8bf0800 Add animation by @loranallensmith 2019-02-03 15:27:17 +01:00
69d010a11b Brew cleanup 2019-02-03 15:08:29 +01:00
42ff243400 Add make brew-test 2019-02-03 14:58:23 +01:00
933b438e5f Bump dependency versions #742 2019-02-03 14:26:05 +01:00
358342d1c9 Update LICENSE 2019-01-09 12:30:44 +01:00
c591a3810d 1.0.3-dev 2018-11-14 16:36:47 +01:00
0eba037037 v1.0.2
Close #729
2018-11-14 16:36:19 +01:00
3898129e9c Changelog 2018-11-14 16:22:00 +01:00
b88e88d2e3 Fix tests for installation with pyOpenSSL #729 2018-11-14 16:10:08 +01:00
d1407baf76 Add make pdf 2018-11-14 13:06:10 +01:00
d5032ca859 Fix changelog 2018-11-14 11:45:57 +01:00
f6a19cf552 Don't call external URLs from tests #729 2018-11-14 11:42:59 +01:00
74979f3b33 Brew 2018-11-06 11:37:33 +01:00
698eb51e60 Update screenshot 2018-11-03 18:08:43 +01:00
ae8030c930 Homebrew formula for v1.0.0 2018-11-02 17:18:04 +01:00
2e96d7ffbb Update CHANGELOG.rst 2018-11-02 16:28:17 +01:00
b5625e3d75 v1.0.0 2018-11-02 16:24:35 +01:00
932d3224f4 Cleanup 2018-11-02 16:23:17 +01:00
b596fedf13 exit 0 constant: OK => SUCCESS to avoid confusion w/ HTTP 200 OK 2018-11-02 16:07:39 +01:00
96444f3345 Changelog 2018-11-02 15:13:53 +01:00
89b66f1608 Merge remote-tracking branch 'origin/master' 2018-11-02 14:58:08 +01:00
a7d570916d #722: Add support for tls1.3 (#724)
* #722: Add support for tls1.3

* #722: Document the potential support for tls1.3
2018-11-02 14:57:53 +01:00
ab5a50cee8 Finish --style=auto for terminal ANSI colors and make it the default.
Previously (only in the development version), this was called 'preset'.
2018-11-02 14:53:05 +01:00
91961c6b51 Fixed some lines (#723) 2018-10-31 19:17:44 +01:00
256ea7d49d Add prog parameter to HTTPieArgumentParser (#715) 2018-10-30 18:41:56 +01:00
2cd6ea3050 Fix some broken documentation links (#703) 2018-09-07 19:10:04 +02:00
37dddf5bf7 Fix for broken Travis builds on macOS with Python 3.7 (#704) (#705) 2018-09-07 19:09:30 +02:00
e508c631f2 Fix Tox using different Python than expected on macOS (#688) (#706) 2018-09-07 19:08:37 +02:00
55530c8c6d fixed output for escaping rules (#700) 2018-09-03 20:04:18 +02:00
eb929cbc04 Travis CI: Add Python 3.7 on linux to the testing (#690)
* Travis CI: Add Python 3.7 on linux to the testing

`sudo: true` and `dist: xenial` are currently required https://github.com/travis-ci/travis-ci/issues/9069

* NEWEST_PYTHON=3.7
2018-07-25 14:02:00 +02:00
2490bb25ca Add v 0.9.9 CHANGELOG link 2018-07-22 17:58:52 +02:00
2038fa02e3 Mention v0.9.9 in CHANGELOG
#620
2018-07-22 17:57:52 +02:00
59d51ad513 Travis 2018-07-12 21:52:01 +02:00
61568f1def Travis 2018-07-12 21:46:00 +02:00
f93f4fa7c7 Travis CI Python versions; install fix 2018-07-12 21:33:12 +02:00
bf73b5701e Fix travis.yml syntax 2018-07-12 21:23:32 +02:00
7917f1b40c Build fixes and clean-up
* reflect Python 3.7 release
* fix `pycodestyle` errors
* update `pycodestyle` config
* move `pytest` and `pycodestyle` config to `setup.cfg`
* add `make pycodestyle`
* add `make coveralls`
* etc.
2018-07-12 21:16:16 +02:00
a50660cc70 Test --timeout with longer delay
test_timeout_exit_status fails on Python 2.7
https://travis-ci.org/jakubroztocil/httpie/jobs/390072675#L325
2018-07-12 00:39:31 +02:00
749b1e2aca Fix pytest configuration 2018-06-09 11:59:34 +02:00
137889a267 Doc improvements 2018-05-30 14:07:52 +02:00
c9c6f0fae5 Formatting 2018-05-30 14:02:46 +02:00
6fd1ea0e5a Section ordering 2018-05-30 13:56:35 +02:00
8f7676a2a9 Add Cookies section to the docs 2018-05-30 13:55:06 +02:00
87e661c5f1 Support using styles from Pygments plugins (#663)
`pygments.styles.STYLE_MAP` contains only styles built directly into
Pygments library. To list all available styles (including styles
registered by plugins), one should use `get_all_styles` generator.

For respective Pygments documentation, see:
http://pygments.org/docs/styles/#getting-a-list-of-available-styles
2018-04-14 15:25:59 -05:00
8ca333dda0 Use parentheses in describing sessions (#664)
It's a little more readable than using em dashes.
2018-04-11 20:04:02 -05:00
0f4dce98c7 Make default HTTP headers case-insensitive
Cloase #644
2018-02-22 12:52:57 +01:00
05547224ce Remove a Python 2.6 mention from extras_require 2017-12-28 18:33:31 +01:00
6301fee3d2 Upgrade to latest requests 2017-12-28 18:32:29 +01:00
a803e845a5 More robust urllib3 import 2017-12-28 18:32:12 +01:00
11be041e06 Rename TestEnvironment to MockEnvironment to avoid pytest warnings
Close #621
2017-12-28 18:17:48 +01:00
7f5fd130c5 Start using dict comprehensions 2017-12-28 18:15:17 +01:00
ec899d70b7 Removed Python 2.6 support
* Travis CI doesn't support it anymore.
* It had EOL more than 4 years ago
2017-12-28 18:03:37 +01:00
4d3b4fa0be Fix rst 2017-12-22 14:48:08 +01:00
27c557e983 Update README.rst
test
2017-12-22 14:40:48 +01:00
7f24f7d34c Delete appveyor.yml 2017-12-22 14:36:13 +01:00
4b61108005 Remove AppVeyor II. 2017-12-22 14:35:23 +01:00
8b189725fd Remove AppVeyor
@appveyor  https://help.appveyor.com/discussions/problems/10507-pip-install-fails-with-access-is-denied-error
2017-12-22 14:34:20 +01:00
1719ebded6 Fix README (#641) 2017-12-22 03:37:04 +01:00
c5d6a4ad8e OS X => macOS
Close #634
2017-12-17 19:45:46 +01:00
91e1fe2d0f appveyor fix attempt II. 2017-12-13 21:32:37 +01:00
ca7f41de53 appveyor fix attempt 2017-12-13 21:29:51 +01:00
46e24dd6b5 Use function as source of styles for Fish completion 2017-12-13 21:22:53 +01:00
803127e8c9 Remove duplicate option from Fish completion list 2017-12-13 21:22:53 +01:00
4c138959ea Merge pull request #633 from darshanime/version_number_fix
fix env version attribute
2017-12-13 21:18:38 +01:00
91a28973bd Merge pull request #631 from CrazyPython/patch-2
Fix Travis Build by removing 2.6
2017-12-13 21:16:05 +01:00
02b28093a8 Merge pull request #630 from CrazyPython/patch-1
Clarify error message
2017-12-13 21:15:43 +01:00
d64e7d8a6a Merge pull request #638 from gtback/update-contributing-rst
Update CONTRIBUTING.rst to include correct Makefile targets.
2017-12-13 21:14:31 +01:00
8841b8bf46 Update CONTRIBUTING.rst to include correct Makefile targets. 2017-12-07 04:39:32 +00:00
6472ca55e1 fix env version attribute 2017-11-18 19:01:26 +05:30
37c3307018 Remove 2.6 2017-11-14 09:18:10 -05:00
0aab796960 Clarify error message 2017-11-13 07:23:52 -05:00
95c33e31a2 Merge pull request #614 from watersalesman/master
List DNF as Fedora package manager in README
2017-10-04 12:09:07 -05:00
9af833da30 List DNF as Fedora package manager in README 2017-10-02 16:55:35 -04:00
dfe6245cd6 Update AppVeyor 2017-09-07 13:57:15 +02:00
555761f3cb Update copyright year 2017-09-06 01:42:16 +02:00
643735ef23 Fix Gitter link
Close #590
2017-09-06 01:14:56 +02:00
7a45f14542 Merge pull request #584 from scorphus/hotfix/new-requests
Support requests>=2.14.0
2017-07-20 07:54:08 +02:00
e993f83355 Merge pull request #589 from alappe/patch-1
Update README.rst, fix typo…
2017-07-20 07:53:30 +02:00
d726a4cd92 Merge pull request #591 from DavidOliver/patch-1
Fix sentence on overriding default timeout in readme
2017-07-20 07:52:57 +02:00
8d3f09497b Fix sentence on overriding default timeout in readme 2017-06-30 14:54:49 +02:00
31c78c2885 Update README.rst 2017-06-26 13:00:46 +02:00
9776a6dea0 Support requests>=2.14.0
From that release onwards, `cert_verify` raises `IOError` [1].

    1: https://github.com/kennethreitz/requests/commit/7d8b87c
2017-05-17 20:31:10 -03:00
f1d4861fae Merge pull request #568 from dsego/dsego/ansi-colors
Follow terminal ANSI color styles

Close #524
2017-03-12 22:44:05 +01:00
d99e1ff492 Fix link 2017-03-12 13:31:03 +01:00
a196d1d451 Travis cache: pip 2017-03-12 13:18:39 +01:00
02209c2db1 Oops, remove semicolons 2017-03-11 18:12:00 +01:00
9886f01f91 New style option that applies the terminal ANSI color scheme 2017-03-11 18:00:35 +01:00
a4f796fe69 Revert "Follow terminal ANSI color styles"
This reverts commit b0fde07cfd.
2017-03-11 16:58:50 +01:00
c948f98b05 Update links 2017-03-10 11:27:38 +01:00
b0fde07cfd Follow terminal ANSI color styles
Removes the default solarized color scheme and custom http lexer class.
2017-03-06 01:05:50 +01:00
f74670fac1 Update README.rst 2017-03-01 12:40:26 +01:00
7321b9fa4e Add --verify true/false tests and CHANGELOG 2017-02-17 00:56:07 +01:00
cf8d5eb3e8 Merge pull request #560 from hangtwenty/dummyproof-cli-param-verify
Add --verify=(true|false) as an alternative to (yes|no) and make the boolean value case-insensitive
2017-02-17 00:43:22 +01:00
64af72eb88 Turn --verify=False/True to --verify=no/yes
One way to address #559 -- https://github.com/jkbrzt/httpie/issues/559
-- instead of warning or throwing an error, just accept "True" and "False"
as synonyms of yes/no

(Updated to reflect feedback given at https://github.com/jkbrzt/httpie/pull/560 )
2017-02-13 18:30:55 -06:00
de38f86730 Merge pull request #558 from RobDesideri/patch-1
Update pip official website url
2017-02-09 21:13:47 +01:00
244ad15c92 Update pip official website url 2017-02-09 15:25:07 +01:00
586f45e634 Merge pull request #494 from keik/patch-1
Fix typo
2017-02-07 20:50:43 +01:00
b1b4743663 Merge pull request #555 from rootulp/patch-1
Gitter Badge: flat-square style
2017-02-07 20:50:01 +01:00
5600b4a2d3 Merge pull request #557 from robertbenjamin/fix-doc-typo
Update README.rst
2017-02-07 20:49:34 +01:00
9261167a1f Fix typo in the docs 2017-02-02 11:45:58 -08:00
519654e21b Gitter Badge: flat-square style
To match the other badges
2017-01-22 20:58:58 +00:00
4840499a43 Merge pull request #552 from duboviy/master
Add Python 3.6 support
2017-01-08 19:57:56 +01:00
ee6cdf4ab3 Update setup.py 2017-01-08 16:20:53 +02:00
98003f545d Update appveyor.yml 2017-01-08 16:19:26 +02:00
0046ed73c6 Update .travis.yml 2017-01-08 16:18:19 +02:00
66a6475064 Update tox.ini 2017-01-08 16:12:31 +02:00
97804802c0 Alternatives 2016-12-17 03:10:52 +01:00
c9296a9a45 Added link to httpcat 2016-12-17 03:06:48 +01:00
64a41c2601 README 2016-12-17 03:04:59 +01:00
0af6ae1be4 Fix PyPi README rendering
Close #540
2016-12-09 00:26:55 +01:00
d0fc10cf1a AWS / Amazon S3 auth plugin link 2016-12-08 21:48:38 +01:00
fe1d0b0a1e Doc 2016-12-08 21:48:18 +01:00
f133dbf22c Update README with new plugin repos location 2016-12-08 21:48:11 +01:00
9d93b07a9d Redme 2016-12-08 05:38:25 +01:00
761cdbf8be Update Homebrew formula 2016-12-08 05:25:50 +01:00
3a3aecca45 0.9.8 2016-12-08 05:22:20 +01:00
fb3a26586a Fix --auth-type help 2016-12-08 05:16:22 +01:00
cc9083f541 Keep the latest submitted Homebrew formula in extras/ for testing 2016-12-08 04:58:49 +01:00
9ae86f3b4f 0.9.7 2016-12-08 04:47:32 +01:00
3a6fd074a1 Added ExitStatus.PLUGIN_ERROR (7) 2016-12-08 04:42:17 +01:00
da59381b0b Fix PyPi link 2016-12-07 18:54:53 +01:00
6de2d6c2cb Docs 2016-12-07 06:20:01 +01:00
b9b033ed0c Docs 2016-12-07 06:00:51 +01:00
64d6363565 Docs 2016-12-07 05:59:27 +01:00
923b7acbe6 Docs 2016-12-07 05:56:53 +01:00
2efc0db8d4 Cleanup 2016-11-24 00:58:41 +01:00
2bf71af286 pep8 2016-11-23 23:36:46 +01:00
0b84180485 Fix Python 2.6 2016-11-23 23:20:52 +01:00
5a1bd4ba83 Cleanup 2016-11-23 23:15:18 +01:00
3f7ed35238 Add more plugin API tests 2016-11-23 23:09:45 +01:00
47fd392c74 Cleanup 2016-11-23 22:33:22 +01:00
54a63a810e Cleanup/docstring 2016-11-23 22:29:36 +01:00
a49774d3ab Extend auth plugin API
This extends the `AuthPlugin` API by the following attributes:

* `auth_require`: set to `False` to make `--auth, -a` optional
* `auth_parse`: set to `False` to disable `username:password` parsing
  (access the raw value passed to `-a` via `self.raw_auth`).
* `prompt_password`: set to`False` to disable password prompt when
   no password provided (only relevant when `auth_parse == True`)

 These changes should be 100% backwards-compatible.

 What needs more testing is auth support in sessions.

Close #433
Close #431
Close #378
Ping teracyhq/httpie-jwt-auth#3
2016-11-23 22:02:12 +01:00
b879d38b07 Test case for Host header removal (unimplemented feature) 2016-11-23 22:02:12 +01:00
0913e8b2ef Merge pull request #533 from kigawas/patch-1
Update README.rst
2016-10-28 18:11:55 +02:00
4fef4b9a75 Update README.rst
Change "you shell" to "your shell"
2016-10-28 12:02:21 +08:00
bfc23b1412 Changelog 2016-10-26 12:18:53 +02:00
6267f21f21 Clean-up 2016-10-26 11:58:47 +02:00
e9aba543b1 Changelog 2016-10-26 11:54:35 +02:00
9b23a4ac9a Exit with status 130 on CTRL-C
http://www.tldp.org/LDP/abs/html/exitcodes.html

 #531
2016-10-26 11:53:01 +02:00
b96eba336d Fixed test 2016-10-26 11:28:17 +02:00
48a6d234cb Need a main()
#531
2016-10-26 11:21:30 +02:00
c6f2b32e36 Stricter KeyboardInterrupt silencing
Relates to #531, but doesn't solve it completely.
2016-10-26 11:16:39 +02:00
64f6f69037 Add Twitter link 2016-09-17 15:58:05 +02:00
6bdfc7a071 Update config and session file help URLs 2016-09-12 10:57:30 +02:00
497a91711a README 2016-09-12 09:13:37 +02:00
f515ef72d0 README 2016-09-12 09:12:07 +02:00
22a2fddc79 README 2016-09-12 08:59:55 +02:00
1847eaa299 Updated config docs 2016-09-11 18:48:56 +02:00
e387c1d43e Updated config docs 2016-09-11 18:46:33 +02:00
fc6d89913f README 2016-09-11 11:39:03 +02:00
d584686744 README 2016-09-11 01:16:07 +02:00
b565be4318 CHANGELOG 2016-09-06 11:53:52 +01:00
87e44ae639 Handle curses-free Pythons 2016-09-06 11:50:56 +01:00
0d08732397 Merge pull request #516 from dongweiming/fix-496
Fix the handling of zero REQUEST_ITEM arguments 

Close  #496
2016-09-06 11:06:45 +01:00
c53a778f60 Fix Issue #496 2016-09-01 17:46:34 +08:00
5efc9010cc Update CHANGELOG.rst 2016-08-14 11:36:21 +02:00
08e883fcfe Merge pull request #503 from zquestz/patch-1
Updated README.rst to add Arch Linux install docs.
2016-08-14 04:09:50 +02:00
c4b309164f Updated README.rst to add Arch Linux install docs. 2016-08-13 19:08:37 -07:00
8e96238323 v0.9.6 2016-08-13 23:01:05 +02:00
8a9206eceb Fixed Makefile 2016-08-13 22:57:44 +02:00
8ac3c5961c Upgrade Pygments version 2016-08-13 22:57:33 +02:00
487c7a9221 v0.9.5 2016-08-13 22:51:42 +02:00
6d65668355 Strip request header values 2016-08-13 22:40:01 +02:00
3e5115e4a2 Merge pull request #501 from ii-v/master
Fixed spelling mistake in the AUTHORS.rst file
2016-08-11 08:37:41 +02:00
2b8b572f22 Merge pull request #1 from ii-v/ii-v-patch-1
Fixed spelling mistake `GitHib` to `GitHub`
2016-08-11 01:44:04 +02:00
af737fd338 Fixed spelling mistage GitHib to GitHub 2016-08-11 01:43:15 +02:00
ee375b6942 Merge pull request #493 from medecau/codestyle_environment
Codestyle environment
2016-07-29 23:17:00 +02:00
6b06d92a59 Fix typo 2016-07-27 09:54:26 +09:00
becb63de9a useful info 2016-07-26 21:59:34 +01:00
86c8abc485 force os to be linux (+1 squashed commit)
Squashed commits:
[444c56d] no vars for you (+1 squashed commit)
Squashed commits:
[c7d1bf9] added pycodestyle environment to travis config
2016-07-26 21:43:13 +01:00
8f6bee9196 codestyle fixes 2016-07-19 17:23:40 +01:00
9c2c058ae5 separate environment to test codestyle as proposed by @sigmavirus24 2016-07-19 17:23:18 +01:00
6238b59e72 Fix formatting 2016-07-08 15:05:43 +02:00
702c21aa91 Added related projects 2016-07-08 15:03:48 +02:00
aab5cd9da0 PEP8. clean-up 2016-07-04 20:30:55 +02:00
8c0f0b578c Clean-up 2016-07-02 18:44:02 +02:00
bb4881a873 Fixed README 2016-07-02 18:30:04 +02:00
3a1726b4ed Fixed README 2016-07-02 15:04:19 +02:00
e1fa57d228 Added -I as a shortcut for --ignore-stdin 2016-07-02 15:01:46 +02:00
bfc64bce21 Upgrade requests to 2.10.0 to enable optional SOCKS support
Closes #86
2016-07-02 14:58:34 +02:00
595dc51b2d Fish shell completion 2016-07-02 14:33:04 +02:00
83fa772247 Merge pull request #459 from dickeyxxx/fish-completion
added completions for fish shell
2016-07-02 14:31:06 +02:00
49a0fb6e0f More liberal default JSON Accept header
Closes #470
2016-07-02 14:18:36 +02:00
41e822ca2f Clean-up 2016-07-02 12:51:35 +02:00
1124d68946 Added --default-scheme <URL_SCHEME>
Closes #289
2016-07-02 12:47:02 +02:00
c3735d0422 Merge pull request #401 from lgarron/default-scheme
Add a --default-scheme argument.
2016-07-02 12:32:07 +02:00
364b91cbc4 Skip pypy3 tests on TravisCI 2016-07-02 12:03:52 +02:00
c8e06b55e1 Fix tests 2016-07-02 12:03:19 +02:00
5acbc904b7 Added the ability to unset headers
Closes #476
2016-07-02 11:50:30 +02:00
0c7c248dce Fix CHANGELOG 2016-07-02 11:17:38 +02:00
caf60cbc65 Typos 2016-07-02 11:11:06 +02:00
2b0e642842 Document preference for Python 3
Also mention that the Homebrew formula depends on Python 3 starting with HTTPie 0.9.4.
2016-07-02 11:07:46 +02:00
e25948f6a0 1.0.0-dev 2016-07-01 19:17:31 +02:00
b565b4628e v0.9.4 2016-07-01 19:02:34 +02:00
65081b2f12 Cleanup 2016-07-01 19:00:06 +02:00
963b2746f5 Be more liberal when detecting JSON in the formatter
Closes #485
2016-07-01 18:57:13 +02:00
098257c0be Rename --print-others to --history-print. 2016-07-01 18:49:27 +02:00
30eb0c2f26 Merge pull request #468 from Natim/master
Update readthedocs links.
2016-04-28 23:14:52 +08:00
9fbe745987 Update readthedocs links. 2016-04-28 12:28:20 +02:00
01a546eedd Merge pull request #463 from KyleAMathews/patch-1
Remove extra backtick.
2016-04-20 11:17:40 +08:00
eba6b63c55 Remove extra backtick. 2016-04-18 15:11:04 -07:00
ec245a1e80 added completions for fish shell 2016-04-06 11:28:03 -07:00
33eb9acd92 Updated README 2016-03-18 09:20:19 +08:00
293295cad6 Removed XML formatter
Closes #443
Closes #389
Closes #415
Closes #384
Closes #394
2016-03-18 09:16:39 +08:00
557911b606 Handle that os.pathconf is posix-only 2016-03-17 16:14:14 +08:00
5300b0b490 Fixed #451 - OSError: [Errno 36] File name too long 2016-03-17 15:58:01 +08:00
001bda1945 README 2016-03-17 15:00:50 +08:00
7c68d87c10 README 2016-03-10 14:27:33 +08:00
35a99fe04b Added test for -F shortcut 2016-03-09 21:58:34 +08:00
76e15b227c Added test_verbose_implies_all 2016-03-09 21:58:11 +08:00
8881ebf033 Changed the version icon label to include to word "stable" 2016-03-09 13:49:00 +08:00
25d1e8e418 Add Accept-Encoding: identity for --download
#423
2016-03-07 11:46:59 +08:00
7ce6eb148e Typo 2016-03-07 07:09:58 +08:00
6e1dbadff9 Replace --show-redirects with --all and add --print-others, -P
With --all, any intermediary requests/responses are shown (such as redirects
 or the initial unauthorized Digest auth request).

 The --print-others, -P option works like --print, -p, but only applies to
 intermediary requests/responses. The default behaviour is to inherit
 the value of -p.
2016-03-07 07:04:23 +08:00
a6ebc44a48 Run tests against both HTTP and HTTPS
Some of the tests now use the `httpbin_both` fixture from pytest-httpbin.
Also, made httpbin's CA trusted by default and added `httpbin_secure_untrusted`
fixture  to allow overriding that for particular tests.
2016-03-06 17:42:35 +08:00
5e03aeceb7 Make fruity default style one Windows
(again)
2016-03-06 08:33:40 +08:00
13ee9389aa Add link to contributors 2016-03-05 19:48:35 +08:00
bb49a1f979 Improved --debug output 2016-03-05 01:42:48 +08:00
4e574e6b8e Cleanup tests 2016-03-03 18:50:18 +08:00
529981af7a Fix CHANGELOG 2016-03-03 18:46:58 +08:00
6731cb881a README 2016-03-03 17:26:47 +08:00
f7d1b739e2 README 2016-03-03 17:24:46 +08:00
5bdf4a3bae Fixed test_rst_file_syntax error message 2016-03-03 17:22:12 +08:00
2d9414d34c Fixed README 2016-03-03 17:21:51 +08:00
20823c1702 Removed the "implicit_content_type" config option
If you used:

    "implicit_content_type": "form"

 You can achieve the the same result with:

     "default_options": ["--form"]

If you used:

    "implicit_content_type": "json"

 Then it's the default behaviour and it can be removed.

 In either case HTTPie will migrate your config file on the next invocation.
2016-03-03 17:14:39 +08:00
5dbd104c3b Nobody ain't got time for that 2016-03-03 17:09:34 +08:00
13a979ad11 Cleanup 2016-03-02 14:20:35 +08:00
4cfa143bfe Fixed coverage 2016-03-02 13:31:40 +08:00
d24f30d0af Cleanup 2016-03-02 13:31:23 +08:00
66e168b2af Improved failed test output 2016-03-02 13:16:41 +08:00
564670566c Fix coveralls integration 2016-03-02 12:25:19 +08:00
ecbbad816a Fix coveralls integration 2016-03-02 12:24:46 +08:00
0432694661 Changel 2016-03-02 12:24:24 +08:00
dc4da527db Added --ssl=<PROTOCOL_VERSION>
Closes #98
2016-03-02 12:12:05 +08:00
38e8ef14ec Run positive tests first
Trying to debug failing SSL tests on Travis - kevin1024/pytest-httpbin#32
2016-03-02 10:35:40 +08:00
c73dcaf63d CI 2016-03-02 10:08:20 +08:00
fb85509e91 CI 2016-03-02 09:58:50 +08:00
a2dca1e3bb CI 2016-03-02 09:44:39 +08:00
c2dae62af0 Appveyor: added Python 3.5 build 2016-03-02 09:37:58 +08:00
ae7008ee96 Appveyor 2016-03-02 09:33:43 +08:00
f6824f7ade Cleanup 2016-03-02 02:53:23 +08:00
7fd46e0b0d Cleanup 2016-03-02 01:02:11 +08:00
d4067fcb6d Added a short timeout for test requests 2016-03-02 00:31:00 +08:00
20f01709ea Mention URL escaping
Closes #311
2016-03-01 23:48:13 +08:00
56afd1adb9 Test suite cleanup 2016-03-01 23:22:50 +08:00
5e87a2d7e5 Cleanup 2016-03-01 23:13:45 +08:00
d30e28c2c7 Test suite improvements 2016-03-01 23:11:06 +08:00
0d2d24eac7 Copy 2016-03-01 21:37:26 +08:00
e2751e5fa3 Fixed args for Python 2.x 2016-03-01 21:28:10 +08:00
2a25d71aa4 Refactored main() into program() + main() 2016-03-01 21:10:54 +08:00
01ca7f0eb2 Ignore redirected stdout with --output, -o
This makes it easier to use HTTPie in cron jobs and scripts.

Closes #259
2016-03-01 20:24:50 +08:00
4f8d6c013b Fixed get_lexer() 2016-03-01 16:55:12 +08:00
e83e554ffb README 2016-03-01 16:50:30 +08:00
345f5a02a2 Fixed json absolute import 2016-03-01 16:39:50 +08:00
f96f0ef9ed JSON detection improvements 2016-03-01 16:22:54 +08:00
74e4d0b678 Added JSON detection when `--json, -j` is set
To correctly format JSON responses even when an incorrect ``Content-Type`` is returned.

Closes #92
Closes #349
Closes #368
2016-03-01 14:57:15 +08:00
0fc1f61f3d Fixed README 2016-03-01 00:45:54 +08:00
c50413a9c1 Added support section 2016-03-01 00:24:52 +08:00
9f8c452e7e Added gitter chat 2016-03-01 00:12:55 +08:00
776328c818 Added gitter chat 2016-03-01 00:08:07 +08:00
9312fabc01 Capitalization II 2016-02-29 22:03:08 +08:00
48ce934dfa Capitalization 2016-02-29 22:01:05 +08:00
3625bb6fa1 Updated travis badge title 2016-02-29 22:00:18 +08:00
a97f0d52f6 Travis 2016-02-29 17:20:50 +08:00
41b0286f37 Travis 2016-02-29 17:19:12 +08:00
fee54b04d8 Travis 2016-02-29 17:09:55 +08:00
73e0455896 Travis 2016-02-29 16:52:33 +08:00
3b217daddc Travis 2016-02-29 16:43:18 +08:00
e5e5d0ce6d Travis 2016-02-29 16:36:25 +08:00
f43e473de1 Travis 2016-02-29 16:35:09 +08:00
0a002ec554 Fix travis 2016-02-29 15:38:59 +08:00
576ee83d82 Fix travis 2016-02-29 15:35:56 +08:00
e42f7b8fc9 OSX build 2016-02-29 15:32:10 +08:00
b44e16ed0f Fix appveyor 2016-02-29 15:14:43 +08:00
ed08ab133e Refactoring 2016-02-29 15:00:17 +08:00
5408fb0fb9 Cleanup 2016-02-29 14:31:27 +08:00
e18b609ef7 Fixed --max-redirects 2016-02-29 14:21:25 +08:00
356e043651 Added --show-redirects and --max-redirects
Closes #157, #183, #188, #246
2016-02-29 14:12:09 +08:00
c6d4f6cdf6 Show redirects WIP 2016-02-29 14:07:08 +08:00
dc1371d4d6 Implemented --max-redirects option
Added argument to argparse, changed client so that it uses a new
requests Session() with the number of redirects and a single test to
show the setting works.
2016-02-29 14:07:08 +08:00
e2235e56dc Update CHANGELOG.rst 2016-02-29 02:38:09 +08:00
763935b77f Update CHANGELOG.rst 2016-02-29 02:37:36 +08:00
6435532f72 Updated CHANGELOG 2016-02-28 21:37:49 +08:00
11a37067e7 Document -A as a short name for --auth-type 2016-02-28 21:01:21 +08:00
25f0156502 Merge pull request #432 from hangtwenty/master
Add `-A` as short arg for `--auth-type`
2016-02-28 20:58:23 +08:00
0f8d04b4df More robust mime type parsing
Closes #344
2016-02-28 20:12:16 +08:00
e385ed6a99 Fix README 2016-02-28 19:32:19 +08:00
01fdab55e9 Explain how to send fieldnames and headers starting with '-'
Closes #355
2016-02-28 19:31:43 +08:00
1127557742 Cleanup 2016-02-28 19:15:35 +08:00
5898879395 Fixed --download with --session
Closes #412
2016-02-28 19:14:10 +08:00
8c33e5e3d3 Parser => HTTPieArgumentParser 2016-02-28 19:01:54 +08:00
10da7b63a3 Mention MacPorts installation method
Closes #395
2016-02-28 16:54:33 +08:00
df193a373f Updated tarball URL 2016-02-28 16:20:19 +08:00
c2f8c36952 Updated download example URLs 2016-02-28 16:19:18 +08:00
56f498c153 Detect Content Type of file uploaded in multipart/form-data request
Closes #271 #285 #398

This adds filename-based detection. It's still not possible to specify the
content type manually, though.
2016-02-28 15:49:01 +08:00
59e22b16b8 When possible, guess the content-type of the file being sent
Refined PR #285 by rasky to pass all tests
2016-02-28 15:47:43 +08:00
d32d6f29a9 Typo 2016-02-17 14:53:00 +08:00
274dddfb45 Changed the default color style back to solarized
Closes #440
2016-02-17 14:46:35 +08:00
deb7b747cc Small fix for Python 2.6 compatibility.
Relates to #430 / #432.
2016-01-22 18:46:36 -06:00
018e1f68de Merge remote-tracking branch 'upstream/master' 2016-01-22 18:40:53 -06:00
ac69d4311b add -A as short arg for --auth-type
Addresses #430
comes with unit test
2016-01-22 18:37:30 -06:00
c75c4fa2a6 Merge pull request #429 from pra85/patch-1
Update license year range to 2016
2016-01-18 11:26:27 -03:00
a6a79e92e4 Update license year range to 2016 2016-01-18 11:20:24 +05:30
ea76542150 Added guardian/httpie-hmac-auth 2016-01-15 14:07:41 -03:00
c6690e0182 Makefile improvements 2016-01-02 14:33:48 -03:00
c82c9f0ae4 Makefile improvements 2016-01-02 14:28:46 -03:00
84b81c00ea Fixed tox.ini and improved tests and CONTRIBUTING.txt 2016-01-02 14:07:00 -03:00
34c6958dc8 1.0.0-dev 2016-01-01 19:38:21 -03:00
6259b5dd3b Add a --default-scheme argument. 2015-10-28 15:06:04 -07:00
84 changed files with 6901 additions and 3217 deletions

View File

@ -1,4 +1,4 @@
# http://editorconfig.org # https://editorconfig.org
root = true root = true
[*] [*]

12
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,12 @@
# These are supported funding model platforms
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
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: https://paypal.me/roztocil

37
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,37 @@
name: Build
on: [push, pull_request]
jobs:
extras:
# Run coverage and extra tests only once
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/setup-python@v1
with:
python-version: 3.8
- run: pip install --upgrade pip
- run: make install
- run: make pycodestyle
- run: make test-cover
- run: make codecov-upload
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_REPO_TOKEN }}
- run: make test-dist
test:
# Run core HTTPie tests everywhere
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macOS-latest, windows-latest]
python-version: [3.6, 3.7, 3.8]
exclude:
- os: windows-latest
python-version: 3.8
steps:
- uses: actions/checkout@v1
- uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- run: python -m pip install --upgrade pip
- run: pip install --upgrade --editable .
- run: python setup.py test

151
.gitignore vendored
View File

@ -1,11 +1,142 @@
dist
httpie.egg-info
build
*.pyc
*.egg
.tox
README.html
.coverage
htmlcov
.idea
.DS_Store .DS_Store
.idea/
*.egg-info
.cache/
*.pyc
htmlcov
##############################################################################
# The below is GitHub template for Python project. gitignore.
# <https://github.com/github/gitignore/blob/master/Python.gitignore>
##############################################################################
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/

View File

@ -1,20 +0,0 @@
# https://travis-ci.org/jkbrzt/httpie
sudo: false
os:
- linux
# - osx
env:
global:
- NEWEST_PYTHON=3.4
language: python
python:
- 2.6
- 2.7
- pypy
- 3.3
- 3.4
- pypy3
script:
- make
after_success:
- if [[ $TRAVIS_PYTHON_VERSION == $NEWEST_PYTHON ]]; then pip install python-coveralls && coveralls; fi

View File

@ -2,12 +2,14 @@
HTTPie authors HTTPie authors
============== ==============
* `Jakub Roztocil <https://github.com/jkbrzt>`_ * `Jakub Roztocil <https://github.com/jakubroztocil>`_
Patches and ideas Patches and ideas
----------------- -----------------
`Complete list of contributors on GitHub <https://github.com/jakubroztocil/httpie/graphs/contributors>`_
* `Cláudia T. Delgado <https://github.com/claudiatd>`_ (logo) * `Cláudia T. Delgado <https://github.com/claudiatd>`_ (logo)
* `Hank Gay <https://github.com/gthank>`_ * `Hank Gay <https://github.com/gthank>`_
* `Jake Basile <https://github.com/jakebasile>`_ * `Jake Basile <https://github.com/jakebasile>`_
@ -34,3 +36,7 @@ Patches and ideas
* `Dennis Brakhane <https://github.com/brakhane>`_ * `Dennis Brakhane <https://github.com/brakhane>`_
* `Matt Layman <https://github.com/mblayman>`_ * `Matt Layman <https://github.com/mblayman>`_
* `Edward Yang <https://github.com/honorabrutroll>`_ * `Edward Yang <https://github.com/honorabrutroll>`_
* `Aleksandr Vinokurov <https://github.com/aleksandr-vin>`_
* `Jeff Byrnes <https://github.com/jeffbyrnes>`_

View File

@ -2,12 +2,181 @@
Change Log Change Log
========== ==========
This document records all notable changes to `HTTPie <http://httpie.org>`_. This document records all notable changes to `HTTPie <https://httpie.org>`_.
This project adheres to `Semantic Versioning <http://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)
-------------------------
* Removed Python 2.7 support (`EOL Jan 2020 <https://www.python.org/doc/sunset-python-2/>`_).
* Added ``--offline`` to allow building an HTTP request and printing it but not
actually sending it over the network.
* 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 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.
* Added ``--max-headers`` to allow setting the max header limit.
* Added ``--compress`` to allow request body compression.
* Added ``--ignore-netrc`` to allow bypassing credentials from ``.netrc``.
* Added ``https`` alias command with ``https://`` as the default scheme.
* Added ``$ALL_PROXY`` documentation.
* Added type annotations throughout the codebase.
* Added ``tests/`` to the PyPi package for the convenience of
downstream package maintainers.
* Fixed an error when ``stdin`` was a closed fd.
* Improved ``--debug`` output formatting.
`1.0.3`_ (2019-08-26)
---------------------
* Fixed CVE-2019-10751 — the way the output filename is generated for
``--download`` requests without ``--output`` resulting in a redirect has
been changed to only consider the initial URL as the base for the generated
filename, and not the final one. This fixes a potential security issue under
the following scenario:
1. A ``--download`` request with no explicit ``--output`` is made (e.g.,
``$ http -d example.org/file.txt``), instructing httpie to
`generate the output filename <https://httpie.org/doc#downloaded-filename>`_
from the ``Content-Disposition`` response header, or from the URL if the header
is not provided.
2. The server handling the request has been modified by an attacker and
instead of the expected response the URL returns a redirect to another
URL, e.g., ``attacker.example.org/.bash_profile``, whose response does
not provide a ``Content-Disposition`` header (i.e., the base for the
generated filename becomes ``.bash_profile`` instead of ``file.txt``).
3. Your current directory doesnt already contain ``.bash_profile``
(i.e., no unique suffix is added to the generated filename).
4. You dont notice the potentially unexpected output filename
as reported by httpie in the console output
(e.g., ``Downloading 100.00 B to ".bash_profile"``).
Reported by Raul Onitza and Giulio Comi.
`1.0.2`_ (2018-11-14)
-------------------------
* Fixed tests for installation with pyOpenSSL.
`1.0.1`_ (2018-11-14)
-------------------------
* Removed external URL calls from tests.
`1.0.0`_ (2018-11-02)
-------------------------
* Added ``--style=auto`` which follows the terminal ANSI color styles.
* Added support for selecting TLS 1.3 via ``--ssl=tls1.3``
(available once implemented in upstream libraries).
* Added ``true``/``false`` as valid values for ``--verify``
(in addition to ``yes``/``no``) and the boolean value is case-insensitive.
* Changed the default ``--style`` from ``solarized`` to ``auto`` (on Windows it stays ``fruity``).
* Fixed default headers being incorrectly case-sensitive.
* Removed Python 2.6 support.
`0.9.9`_ (2016-12-08)
---------------------
* Fixed README.
`0.9.8`_ (2016-12-08)
---------------------
* Extended auth plugin API.
* Added exit status code ``7`` for plugin errors.
* Added support for ``curses``-less Python installations.
* Fixed ``REQUEST_ITEM`` arg incorrectly being reported as required.
* Improved ``CTRL-C`` interrupt handling.
* Added the standard exit status code ``130`` for keyboard interrupts.
`0.9.6`_ (2016-08-13)
---------------------
* Added Python 3 as a dependency for Homebrew installations
to ensure some of the newer HTTP features work out of the box
for macOS users (starting with HTTPie 0.9.4.).
* Added the ability to unset a request header with ``Header:``, and send an
empty value with ``Header;``.
* Added ``--default-scheme <URL_SCHEME>`` to enable things like
``$ 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).
* 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``
to ``application/json, */*``.
* Changed the pre-processing of request HTTP headers so that any leading
and trailing whitespace is removed.
`0.9.4`_ (2016-07-01)
---------------------
* Added ``Content-Type`` of files uploaded in ``multipart/form-data`` requests
* Added ``--ssl=<PROTOCOL>`` to specify the desired SSL/TLS protocol version
to use for HTTPS requests.
* Added JSON detection with ``--json, -j`` to work around incorrect
``Content-Type``
* Added ``--all`` to show intermediate responses such as redirects (with ``--follow``)
* Added ``--history-print, -P WHAT`` to specify formatting of intermediate responses
* Added ``--max-redirects=N`` (default 30)
* Added ``-A`` as short name for ``--auth-type``
* Added ``-F`` as short name for ``--follow``
* Removed the ``implicit_content_type`` config option
(use ``"default_options": ["--form"]`` instead)
* Redirected ``stdout`` doesn't trigger an error anymore when ``--output FILE``
is set
* Changed the default ``--style`` back to ``solarized`` for better support
of light and dark terminals
* Improved ``--debug`` output
* Fixed ``--session`` when used with ``--download``
* Fixed ``--download`` to trim too long filenames before saving the file
* Fixed the handling of ``Content-Type`` with multiple ``+subtype`` parts
* Removed the XML formatter as the implementation suffered from multiple issues
`0.9.3`_ (2016-01-01) `0.9.3`_ (2016-01-01)
------------------------- ---------------------
* Changed the default color ``--style`` from ``solarized`` to ``monokai`` * Changed the default color ``--style`` from ``solarized`` to ``monokai``
* Added basic Bash autocomplete support (need to be installed manually) * Added basic Bash autocomplete support (need to be installed manually)
@ -30,8 +199,8 @@ This project adheres to `Semantic Versioning <http://semver.org/>`_.
--------------------- ---------------------
* Added support for Requests transport adapter plugins * Added support for Requests transport adapter plugins
(see `httpie-unixsocket <https://github.com/msabramo/httpie-unixsocket>`_ (see `httpie-unixsocket <https://github.com/httpie/httpie-unixsocket>`_
and `httpie-http2 <https://github.com/jkbrzt/httpie-http2>`_) and `httpie-http2 <https://github.com/httpie/httpie-http2>`_)
`0.9.0`_ (2015-01-31) `0.9.0`_ (2015-01-31)
@ -229,32 +398,57 @@ This project adheres to `Semantic Versioning <http://semver.org/>`_.
* Many improvements and bug fixes * Many improvements and bug fixes
`0.1`_ (2012-02-25) `0.1.0`_ (2012-02-25)
------------------- ---------------------
* Initial public release * Initial public release
.. _`0.1`: https://github.com/jkbrzt/httpie/commit/b966efa .. _`0.1.0`: https://github.com/jakubroztocil/httpie/commit/b966efa
.. _0.1.4: https://github.com/jkbrzt/httpie/compare/b966efa...0.1.4 .. _0.1.4: https://github.com/jakubroztocil/httpie/compare/b966efa...0.1.4
.. _0.1.5: https://github.com/jkbrzt/httpie/compare/0.1.4...0.1.5 .. _0.1.5: https://github.com/jakubroztocil/httpie/compare/0.1.4...0.1.5
.. _0.1.6: https://github.com/jkbrzt/httpie/compare/0.1.5...0.1.6 .. _0.1.6: https://github.com/jakubroztocil/httpie/compare/0.1.5...0.1.6
.. _0.2.0: https://github.com/jkbrzt/httpie/compare/0.1.6...0.2.0 .. _0.2.0: https://github.com/jakubroztocil/httpie/compare/0.1.6...0.2.0
.. _0.2.1: https://github.com/jkbrzt/httpie/compare/0.2.0...0.2.1 .. _0.2.1: https://github.com/jakubroztocil/httpie/compare/0.2.0...0.2.1
.. _0.2.2: https://github.com/jkbrzt/httpie/compare/0.2.1...0.2.2 .. _0.2.2: https://github.com/jakubroztocil/httpie/compare/0.2.1...0.2.2
.. _0.2.5: https://github.com/jkbrzt/httpie/compare/0.2.2...0.2.5 .. _0.2.5: https://github.com/jakubroztocil/httpie/compare/0.2.2...0.2.5
.. _0.2.6: https://github.com/jkbrzt/httpie/compare/0.2.5...0.2.6 .. _0.2.6: https://github.com/jakubroztocil/httpie/compare/0.2.5...0.2.6
.. _0.2.7: https://github.com/jkbrzt/httpie/compare/0.2.5...0.2.7 .. _0.2.7: https://github.com/jakubroztocil/httpie/compare/0.2.5...0.2.7
.. _0.3.0: https://github.com/jkbrzt/httpie/compare/0.2.7...0.3.0 .. _0.3.0: https://github.com/jakubroztocil/httpie/compare/0.2.7...0.3.0
.. _0.4.0: https://github.com/jkbrzt/httpie/compare/0.3.0...0.4.0 .. _0.4.0: https://github.com/jakubroztocil/httpie/compare/0.3.0...0.4.0
.. _0.4.1: https://github.com/jkbrzt/httpie/compare/0.4.0...0.4.1 .. _0.4.1: https://github.com/jakubroztocil/httpie/compare/0.4.0...0.4.1
.. _0.5.0: https://github.com/jkbrzt/httpie/compare/0.4.1...0.5.0 .. _0.5.0: https://github.com/jakubroztocil/httpie/compare/0.4.1...0.5.0
.. _0.5.1: https://github.com/jkbrzt/httpie/compare/0.5.0...0.5.1 .. _0.5.1: https://github.com/jakubroztocil/httpie/compare/0.5.0...0.5.1
.. _0.6.0: https://github.com/jkbrzt/httpie/compare/0.5.1...0.6.0 .. _0.6.0: https://github.com/jakubroztocil/httpie/compare/0.5.1...0.6.0
.. _0.7.1: https://github.com/jkbrzt/httpie/compare/0.6.0...0.7.1 .. _0.7.1: https://github.com/jakubroztocil/httpie/compare/0.6.0...0.7.1
.. _0.8.0: https://github.com/jkbrzt/httpie/compare/0.7.1...0.8.0 .. _0.8.0: https://github.com/jakubroztocil/httpie/compare/0.7.1...0.8.0
.. _0.9.0: https://github.com/jkbrzt/httpie/compare/0.8.0...0.9.0 .. _0.9.0: https://github.com/jakubroztocil/httpie/compare/0.8.0...0.9.0
.. _0.9.1: https://github.com/jkbrzt/httpie/compare/0.9.0...0.9.1 .. _0.9.1: https://github.com/jakubroztocil/httpie/compare/0.9.0...0.9.1
.. _0.9.2: https://github.com/jkbrzt/httpie/compare/0.9.1...0.9.2 .. _0.9.2: https://github.com/jakubroztocil/httpie/compare/0.9.1...0.9.2
.. _0.9.3: https://github.com/jkbrzt/httpie/compare/0.9.2...0.9.3 .. _0.9.3: https://github.com/jakubroztocil/httpie/compare/0.9.2...0.9.3
.. _1.0.0-dev: https://github.com/jkbrzt/httpie/compare/0.9.3...master .. _0.9.4: https://github.com/jakubroztocil/httpie/compare/0.9.3...0.9.4
.. _0.9.6: https://github.com/jakubroztocil/httpie/compare/0.9.4...0.9.6
.. _0.9.8: https://github.com/jakubroztocil/httpie/compare/0.9.6...0.9.8
.. _0.9.9: https://github.com/jakubroztocil/httpie/compare/0.9.8...0.9.9
.. _1.0.0: https://github.com/jakubroztocil/httpie/compare/0.9.9...1.0.0
.. _1.0.1: https://github.com/jakubroztocil/httpie/compare/1.0.0...1.0.1
.. _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: 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

76
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,76 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at jakub@roztocil.co. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq

View File

@ -1,12 +1,13 @@
######################
Contributing to HTTPie Contributing to HTTPie
###################### ######################
Bug reports and code and documentation patches are greatly appreciated. You can Bug reports and code and documentation patches are welcome. You can
also help by using the development version of HTTPie and reporting any bugs you help this project also by using the development version of HTTPie
might encounter. and by reporting any bugs you might encounter.
Bug Reports 1. Reporting bugs
=========== =================
**It's important that you provide the full command argument list **It's important that you provide the full command argument list
as well as the output of the failing command.** as well as the output of the failing command.**
@ -15,81 +16,168 @@ to your bug report, e.g.:
.. code-block:: bash .. code-block:: bash
$ http --debug [arguments that trigger the error] $ http --debug [COMPLETE ARGUMENT LIST THAT TRIGGERS THE ERROR]
[complete output] [COMPLETE OUTPUT]
Contributing Code and Documentation 2. Contributing Code and Docs
=================================== =============================
Before working on a new feature or a bug, please browse `existing issues`_ Before working on a new feature or a bug, please browse `existing issues`_
to see whether it has been previously discussed. If the change in question to see whether it has been previously discussed. If the change in question
is a bigger one, it's always good to discuss before your starting working on is a bigger one, it's always good to discuss before you start working on
it. it.
Development Environment Development Environment
----------------------- --------------------------------
Getting the code
****************
Go to https://github.com/jakubroztocil/httpie and fork the project repository.
.. code-block:: bash .. code-block:: bash
git clone https://github.com/<YOU>/httpie # Clone your fork
git clone git@github.com:<YOU>/httpie.git
# Enter the project directory
cd httpie cd httpie
# Create a branch for your changes
git checkout -b my_topical_branch git checkout -b my_topical_branch
# (Recommended: create a new virtualenv)
# Install dev. requirements and also HTTPie (in editable mode Setup
# so that the `http' command will point to your working copy): *****
The `Makefile`_ contains a bunch of tasks to get you started. Just run
the following command, which:
* Creates an isolated Python virtual environment inside ``./venv``
(via the standard library `venv`_ tool);
* installs all dependencies and also installs HTTPie
(in editable mode so that the ``http`` command will point to your
working copy).
* and runs tests (It is the same as running ``make install test``).
.. code-block:: bash
make make
Python virtual environment
**************************
Activate the Python virtual environment—created via the ``make install``
task during `setup`_—for your active shell session using the following command:
.. code-block:: bash
source venv/bin/activate
(If you use ``virtualenvwrapper``, you can also use ``workon httpie`` to
activate the environment — we have created a symlink for you. Its a bit of
a hack but it works™.)
You should now see ``(httpie)`` next to your shell prompt, and
the ``http`` should point to you development copy:
.. code-block::
(httpie) ~/Code/httpie $ which http
/Users/jakub/Code/httpie/venv/bin/http
(httpie) ~/Code/httpie $ http --version
2.0.0-dev
(Btw, you dont need to activate the virtual environment if you just want
run some of the ``make`` tasks. You can also invoke the development
version of HTTPie directly with ``./venv/bin/http`` without having to activate
the environment first. The same goes for ``./venv/bin/py.test``, etc.).
Making Changes Making Changes
-------------- --------------
Please make sure your changes conform to `Style Guide for Python Code`_ (PEP8). Please make sure your changes conform to `Style Guide for Python Code`_ (PEP8)
and that ``make pycodestyle`` passes.
Tests Testing & CI
----- ------------
Before opening a pull requests, please make sure the `test suite`_ passes Please add tests for any new features and bug fixes.
in all of the `supported Python environments`_. You should also **add tests
for any new features and bug fixes**. When you open a pull request,
`GitHub Actions <https://github.com/jakubroztocil/httpie/actions>`_
will automatically run HTTPies `test suite`_ against your code
so please make sure all checks pass.
Running tests locally
*********************
HTTPie uses the `pytest`_ runner. It also uses `Tox`_ which allows you to run
tests on multiple Python versions even when testing locally.
HTTPie uses `pytest`_ and `Tox`_.
.. code-block:: bash .. code-block:: bash
### Running all tests: # Run tests on the current Python interpreter with coverage.
# Current Python
make test make test
# Current Python with coverage # Run tests with coverage
make test-cover make test-cover
# All the supported and available Pythons via Tox # Run all tests in all of the supported and available Pythons via Tox
make test-tox make test-tox
### Running specific tests: # Test PEP8 compliance
make pycodestyle
# Current Python # Run extended tests — for code as well as .rst files syntax, packaging, etc.
pytest tests/test_uploads.py make test-all
# All Pythons
Running specific tests
**********************
After you have activated your virtual environment (see `setup`_), you
can run specific tests from the terminal:
.. code-block:: bash
# Run specific tests on the current Python
py.test tests/test_uploads.py
py.test tests/test_uploads.py::TestMultipartFormDataFileUpload
py.test tests/test_uploads.py::TestMultipartFormDataFileUpload::test_upload_ok
# Run specific tests on the on all Pythons via Tox
# (change to `tox -e py37' to limit Python version)
tox -- tests/test_uploads.py --verbose tox -- tests/test_uploads.py --verbose
tox -- tests/test_uploads.py::TestMultipartFormDataFileUpload --verbose
tox -- tests/test_uploads.py::TestMultipartFormDataFileUpload::test_upload_ok --verbose
-----
See `Makefile`_ for additional development utilities.
Don't forget to add yourself to `AUTHORS.rst`_. Finally, don't forget to add yourself to `AUTHORS`_!
.. _Tox: http://tox.testrun.org .. _Tox: http://tox.testrun.org
.. _supported Python environments: https://github.com/jkbrzt/httpie/blob/master/tox.ini .. _supported Python environments: https://github.com/jakubroztocil/httpie/blob/master/tox.ini
.. _existing issues: https://github.com/jkbrzt/httpie/issues?state=open .. _existing issues: https://github.com/jakubroztocil/httpie/issues?state=open
.. _AUTHORS.rst: https://github.com/jkbrzt/httpie/blob/master/AUTHORS.rst .. _AUTHORS: https://github.com/jakubroztocil/httpie/blob/master/AUTHORS.rst
.. _pytest: http://pytest.org/ .. _Makefile: https://github.com/jakubroztocil/httpie/blob/master/Makefile
.. _Style Guide for Python Code: http://python.org/dev/peps/pep-0008/ .. _venv: https://docs.python.org/3/library/venv.html
.. _test suite: https://github.com/jkbrzt/httpie/tree/master/tests .. _pytest: https://pytest.org/
.. _Style Guide for Python Code: https://python.org/dev/peps/pep-0008/
.. _test suite: https://github.com/jakubroztocil/httpie/tree/master/tests

10
LICENSE
View File

@ -1,4 +1,4 @@
Copyright © 2012-2015 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:
@ -10,14 +10,14 @@ modification, are permitted provided that the following conditions are met:
notice, this list of conditions and the following disclaimer in the notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution. documentation and/or other materials provided with the distribution.
3. Neither the name of The author nor the names of its contributors may 3. Neither the name of the copyright holder nor the names of its contributors
be used to endorse or promote products derived from this software may be used to endorse or promote products derived from this software
without specific prior written permission. without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE AUTHOR AND CONTRIBUTORS BE LIABLE FOR DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON

View File

@ -2,3 +2,6 @@ include LICENSE
include README.rst include README.rst
include CHANGELOG.rst include CHANGELOG.rst
include AUTHORS.rst include AUTHORS.rst
# <https://github.com/jakubroztocil/httpie/issues/182>
recursive-include tests/ *

215
Makefile
View File

@ -1,67 +1,194 @@
###############################################################################
# See ./CONTRIBUTING.rst
###############################################################################
ROOT_DIR:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
VERSION=$(shell grep __version__ httpie/__init__.py) VERSION=$(shell grep __version__ httpie/__init__.py)
REQUIREMENTS="requirements-dev.txt" REQUIREMENTS=requirements-dev.txt
TAG="\n\n\033[0;32m\#\#\# " H1="\n\n\033[0;32m\#\#\# "
END=" \#\#\# \033[0m\n" H1END=" \#\#\# \033[0m\n"
all: test
uninstall-httpie: # Only used to create our venv.
@echo $(TAG)Removing existing installation of HTTPie$(END) SYSTEM_PYTHON=python3
- pip uninstall --yes httpie >/dev/null
! which http VENV_ROOT=venv
VENV_BIN=$(VENV_ROOT)/bin
VENV_PIP=$(VENV_BIN)/pip3
VENV_PYTHON=$(VENV_BIN)/python
export PATH := $(VENV_BIN):$(PATH)
all: uninstall-httpie install test
install: venv
@echo $(H1)Installing dev requirements$(H1END)
$(VENV_PIP) install --upgrade -r $(REQUIREMENTS)
@echo $(H1)Installing HTTPie$(H1END)
$(VENV_PIP) install --upgrade --editable .
@echo @echo
uninstall-all: uninstall-httpie clean:
- pip uninstall --yes -r $(REQUIREMENTS) @echo $(H1)Cleaning up$(H1END)
rm -rf $(VENV_ROOT)
init: uninstall-httpie # Remove symlink for virtualenvwrapper, if weve created one.
@echo $(TAG)Installing dev requirements$(END) [ -n "$(WORKON_HOME)" -a -L "$(WORKON_HOME)/httpie" -a -f "$(WORKON_HOME)/httpie" ] && rm $(WORKON_HOME)/httpie || true
pip install --upgrade -r $(REQUIREMENTS) rm -rf .tox *.egg dist build .coverage .cache .pytest_cache httpie.egg-info
@echo $(TAG)Installing HTTPie$(END) find . -name '__pycache__' -delete -o -name '*.pyc' -delete
pip install --upgrade --editable .
@echo @echo
test: init
@echo $(TAG)Running tests in on current Python with coverage $(END) venv:
py.test --cov ./httpie --cov ./tests --doctest-modules --verbose ./httpie ./tests @echo $(H1)Creating a Python environment $(VENV_ROOT) $(H1END)
$(SYSTEM_PYTHON) -m venv --prompt httpie $(VENV_ROOT)
@echo
@echo done.
@echo
@echo To active it manually, run:
@echo
@echo " source $(VENV_BIN)/activate"
@echo
@echo '(learn more: https://docs.python.org/3/library/venv.html)'
@echo
@if [ -n "$(WORKON_HOME)" ]; then \
echo $(ROOT_DIR) > $(VENV_ROOT)/.project; \
if [ ! -d $(WORKON_HOME)/httpie -a ! -L $(WORKON_HOME)/httpie ]; then \
ln -s $(ROOT_DIR)/$(VENV_ROOT) $(WORKON_HOME)/httpie ; \
echo ''; \
echo 'Since you use virtualenvwrapper, we created a symlink'; \
echo 'so you can also use "workon httpie" to activate the venv.'; \
echo ''; \
fi; \
fi
###############################################################################
# Testing
###############################################################################
test:
@echo $(H1)Running tests$(HEADER_EXTRA)$(H1END)
$(VENV_BIN)/py.test $(COV) ./httpie $(COV) ./tests --doctest-modules --verbose ./httpie ./tests
@echo @echo
test-tox: init
@echo $(TAG)Running tests on all Pythons via Tox$(END) test-cover: COV=--cov
tox test-cover: HEADER_EXTRA=' (with coverage)'
test-cover: test
# test-all is meant to test everything — even this Makefile
test-all: clean install test test-tox test-dist pycodestyle
@echo @echo
test-dist: test-sdist test-bdist-wheel test-dist: test-sdist test-bdist-wheel
@echo @echo
test-sdist: clean uninstall-httpie
@echo $(TAG)Testing sdist build an installation$(END) test-tox: uninstall-httpie install
python setup.py sdist @echo $(H1)Running tests on all Pythons via Tox$(H1END)
pip install --force-reinstall --upgrade dist/*.gz $(VENV_BIN)/tox
which http
@echo @echo
test-bdist-wheel: clean uninstall-httpie
@echo $(TAG)Testing wheel build an installation$(END) test-sdist: clean venv
python setup.py bdist_wheel @echo $(H1)Testing sdist build an installation$(H1END)
pip install --force-reinstall --upgrade dist/*.whl $(VENV_PYTHON) setup.py sdist
which http $(VENV_PIP) install --force-reinstall --upgrade dist/*.gz
$(VENV_BIN)/http --version
@echo @echo
# This tests everything, even this Makefile.
test-all: uninstall-all clean init test test-tox test-dist
publish: test-all test-bdist-wheel: clean venv
@echo $(TAG)Testing wheel build an installation$(END) @echo $(H1)Testing wheel build an installation$(H1END)
$(VENV_PIP) install wheel
$(VENV_PYTHON) setup.py bdist_wheel
$(VENV_PIP) install --force-reinstall --upgrade dist/*.whl
$(VENV_BIN)/http --version
@echo
pycodestyle:
@echo $(H1)Running pycodestyle$(H1END)
@[ -f $(VENV_BIN)/pycodestyle ] || $(VENV_PIP) install pycodestyle
$(VENV_BIN)/pycodestyle httpie/ tests/ extras/ *.py
@echo
codecov-upload:
@echo $(H1)Running codecov$(H1END)
@[ -f $(VENV_BIN)/codecov ] || $(VENV_PIP) install codecov
# $(VENV_BIN)/codecov --required
$(VENV_BIN)/codecov
@echo
###############################################################################
# Publishing to PyPi
###############################################################################
publish: test-all publish-no-test
publish-no-test:
@echo $(H1)Testing wheel build an installation$(H1END)
@echo "$(VERSION)" @echo "$(VERSION)"
@echo "$(VERSION)" | grep -q "dev" && echo "!!!Not publishing dev version!!!" && exit 1 @echo "$(VERSION)" | grep -q "dev" && echo '!!!Not publishing dev version!!!' && exit 1 || echo ok
python setup.py register $(VENV_PYTHON) setup.py sdist bdist_wheel
python setup.py sdist upload $(VENV_BIN)/twine upload dist/*
python setup.py bdist_wheel upload
@echo @echo
clean:
@echo $(TAG)Cleaning up$(END)
rm -rf .tox *.egg dist build .coverage ###############################################################################
find . -name '__pycache__' -delete -print -o -name '*.pyc' -delete -print # Uninstalling
###############################################################################
uninstall-httpie:
@echo $(H1)Uninstalling httpie$(H1END)
- $(VENV_PIP) uninstall --yes httpie &2>/dev/null
@echo "Verifying…"
cd .. && ! $(VENV_PYTHON) -m httpie --version &2>/dev/null
@echo "Done"
@echo @echo
###############################################################################
# Docs
###############################################################################
pdf:
# NOTE: rst2pdf needs to be installed manually and against a Python 2
@echo "Converting README.rst to PDF…"
rst2pdf \
--strip-elements-with-class=no-pdf \
README.rst \
-o README.pdf
@echo "Done"
@echo
###############################################################################
# Homebrew
###############################################################################
brew-deps:
extras/brew-deps.py
brew-test:
- brew uninstall httpie
brew install --build-from-source ./extras/httpie.rb
brew test httpie
brew audit --strict httpie

1502
README.rst

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +0,0 @@
# https://ci.appveyor.com/project/jkbrzt/httpie
build: false
environment:
matrix:
- PYTHON: "C:/Python27"
- PYTHON: "C:/Python34"
init:
- "ECHO %PYTHON%"
- ps: "ls C:/Python*"
install:
# - ps: (new-object net.webclient).DownloadFile('https://bootstrap.pypa.io/get-pip.py', 'C:/get-pip.py')
# - "%PYTHON%/python.exe C:/get-pip.py"
- "%PYTHON%/Scripts/pip.exe install -U pip setuptools"
- "%PYTHON%/Scripts/pip.exe install -e ."
test_script:
- "%PYTHON%/Scripts/pip.exe --version"
- "%PYTHON%/Scripts/http.exe --debug"
- "%PYTHON%/python.exe setup.py test"

63
extras/brew-deps.py Executable file
View File

@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""
Generate Ruby code with URLs and file hashes for packages from PyPi
(i.e., httpie itself as well as its dependencies) to be included
in the Homebrew formula after a new release of HTTPie has been published
on PyPi.
<https://github.com/Homebrew/homebrew-core/blob/master/Formula/httpie.rb>
"""
import hashlib
import requests
PACKAGES = [
'httpie',
'Pygments',
'requests',
'certifi',
'urllib3',
'idna',
'chardet',
'PySocks',
]
def get_package_meta(package_name):
api_url = f'https://pypi.python.org/pypi/{package_name}/json'
resp = requests.get(api_url).json()
hasher = hashlib.sha256()
for release in resp['urls']:
download_url = release['url']
if download_url.endswith('.tar.gz'):
hasher.update(requests.get(download_url).content)
return {
'name': package_name,
'url': download_url,
'sha256': hasher.hexdigest(),
}
else:
raise RuntimeError(f'{package_name}: download not found: {resp}')
def main():
package_meta_map = {
package_name: get_package_meta(package_name)
for package_name in PACKAGES
}
httpie_meta = package_meta_map.pop('httpie')
print()
print(' url "{url}"'.format(url=httpie_meta['url']))
print(' sha256 "{sha256}"'.format(sha256=httpie_meta['sha256']))
print()
for dep_meta in package_meta_map.values():
print(' resource "{name}" do'.format(name=dep_meta['name']))
print(' url "{url}"'.format(url=dep_meta['url']))
print(' sha256 "{sha256}"'.format(sha256=dep_meta['sha256']))
print(' end')
print('')
if __name__ == '__main__':
main()

View File

@ -0,0 +1,59 @@
function __fish_httpie_auth_types
echo "basic"\t"Basic HTTP auth"
echo "digest"\t"Digest HTTP auth"
end
function __fish_httpie_styles
echo "autumn"
echo "borland"
echo "bw"
echo "colorful"
echo "default"
echo "emacs"
echo "friendly"
echo "fruity"
echo "igor"
echo "manni"
echo "monokai"
echo "murphy"
echo "native"
echo "paraiso-dark"
echo "paraiso-light"
echo "pastie"
echo "perldoc"
echo "rrt"
echo "solarized"
echo "tango"
echo "trac"
echo "vim"
echo "vs"
echo "xcode"
end
complete -x -c http -s s -l style -d 'Output coloring style (default is "monokai")' -A -a '(__fish_httpie_styles)'
complete -c http -s f -l form -d 'Data items from the command line are serialized as form fields'
complete -c http -s j -l json -d '(default) Data items from the command line are serialized as a JSON object'
complete -x -c http -l pretty -d 'Controls output processing' -a "all colors format none" -A
complete -x -c http -s p -l print -d 'String specifying what the output should contain'
complete -c http -s v -l verbose -d 'Print the whole request as well as the response'
complete -c http -s h -l headers -d 'Print only the response headers'
complete -c http -s b -l body -d 'Print only the response body'
complete -c http -s S -l stream -d 'Always stream the output by line'
complete -c http -s o -l output -d 'Save output to FILE'
complete -c http -s d -l download -d 'Do not print the response body to stdout'
complete -c http -s c -l continue -d 'Resume an interrupted download'
complete -x -c http -l session -d 'Create, or reuse and update a session'
complete -x -c http -s a -l auth -d 'If only the username is provided (-a username), HTTPie will prompt for the password'
complete -x -c http -l auth-type -d 'The authentication mechanism to be used' -a '(__fish_httpie_auth_types)' -A
complete -x -c http -l proxy -d 'String mapping protocol to the URL of the proxy'
complete -c http -l follow -d 'Allow full redirects'
complete -x -c http -l verify -d 'SSL cert verification'
complete -c http -l cert -d 'SSL cert'
complete -c http -l cert-key -d 'Private SSL cert key'
complete -x -c http -l timeout -d 'Connection timeout in seconds'
complete -c http -l check-status -d 'Error with non-200 HTTP status code'
complete -c http -l ignore-stdin -d 'Do not attempt to read stdin'
complete -c http -l help -d 'Show help'
complete -c http -l version -d 'Show version'
complete -c http -l traceback -d 'Prints exception traceback should one occur'
complete -c http -l debug -d 'Show debugging information'

68
extras/httpie.rb Normal file
View File

@ -0,0 +1,68 @@
# The latest Homebrew formula as submitted to Homebrew/homebrew-core.
# Only useful for testing until it gets accepted by homebrew maintainers.
# (It will need to be updated from the repo version before next release.)
#
# https://github.com/Homebrew/homebrew-core/blob/master/Formula/httpie.rb
#
class Httpie < Formula
include Language::Python::Virtualenv
desc "User-friendly cURL replacement (command-line HTTP client)"
homepage "https://httpie.org/"
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 "1fb33d9c85dc462c2549a03cf08670edad8014a5fdf0a7cb26493c64af40283d" => :catalina
sha256 "a22030f0b96c698c90265286ee80ffbb03079d1d008a80c0bdb3ea15a17d3fbb" => :mojave
sha256 "9f994ecf826efe53a3a49d1c3193e271629068d11306df55adeea2842a8afb8c" => :high_sierra
end
depends_on "python@3.8"
resource "Pygments" do
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/f5/4f/280162d4bd4d8aad241a21aecff7a6e46891b905a4341e7ab549ebaf7915/requests-2.23.0.tar.gz"
sha256 "b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"
end
resource "certifi" do
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/05/8c/40cd6949373e23081b3ea20d5594ae523e681b6f472e600fbc95ed046a36/urllib3-1.25.9.tar.gz"
sha256 "3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527"
end
resource "idna" do
url "https://files.pythonhosted.org/packages/cb/19/57503b5de719ee45e83472f339f617b0c01ad75cba44aba1e4c97c2b0abd/idna-2.9.tar.gz"
sha256 "7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"
end
resource "chardet" do
url "https://files.pythonhosted.org/packages/fc/bb/a5768c230f9ddb03acc9ef3f0d4a3cf93462473795d18e9535498c8f929d/chardet-3.0.4.tar.gz"
sha256 "84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"
end
resource "PySocks" do
url "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz"
sha256 "3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"
end
def install
virtualenv_install_with_resources
end
test do
raw_url = "https://raw.githubusercontent.com/Homebrew/homebrew-core/master/Formula/httpie.rb"
assert_match "PYTHONPATH", shell_output("#{bin}/http --ignore-stdin #{raw_url}")
end
end

BIN
httpie.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1019 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 681 KiB

View File

@ -2,18 +2,7 @@
HTTPie - a CLI, cURL-like tool for humans. HTTPie - a CLI, cURL-like tool for humans.
""" """
__version__ = '2.2.0'
__author__ = 'Jakub Roztocil' __author__ = 'Jakub Roztocil'
__version__ = '0.9.3'
__licence__ = 'BSD' __licence__ = 'BSD'
class ExitStatus:
"""Exit status code constants."""
OK = 0
ERROR = 1
ERROR_TIMEOUT = 2
# Used only when requested with --check-status:
ERROR_HTTP_3XX = 3
ERROR_HTTP_4XX = 4
ERROR_HTTP_5XX = 5

View File

@ -3,8 +3,18 @@
""" """
import sys import sys
from .core import main
def main():
try:
from .core import main
exit_status = main()
except KeyboardInterrupt:
from httpie.status import ExitStatus
exit_status = ExitStatus.ERROR_CTRL_C
sys.exit(exit_status.value)
if __name__ == '__main__': if __name__ == '__main__':
sys.exit(main()) main()

0
httpie/cli/__init__.py Normal file
View File

421
httpie/cli/argparser.py Normal file
View File

@ -0,0 +1,421 @@
import argparse
import errno
import os
import re
import sys
from argparse import RawDescriptionHelpFormatter
from textwrap import dedent
from urllib.parse import urlsplit
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 (
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,
OUTPUT_OPTIONS_DEFAULT_OFFLINE,
)
from httpie.cli.exceptions import ParseError
from httpie.cli.requestitems import RequestItems
from httpie.context import Environment
from httpie.plugins.registry import plugin_manager
from httpie.utils import ExplicitNullAuth, get_content_type
class HTTPieHelpFormatter(RawDescriptionHelpFormatter):
"""A nicer help formatter.
Help for arguments can be indented and contain new lines.
It will be de-dented and arguments in the help
will be separated by a blank line for better readability.
"""
def __init__(self, max_help_position=6, *args, **kwargs):
# A smaller indent for args help.
kwargs['max_help_position'] = max_help_position
super().__init__(*args, **kwargs)
def _split_lines(self, text, width):
text = dedent(text).strip() + '\n\n'
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`.
Handles all input (CLI args, file args, stdin), applies defaults,
and performs extra validation.
"""
def __init__(self, *args, formatter_class=HTTPieHelpFormatter, **kwargs):
kwargs['add_help'] = False
super().__init__(*args, formatter_class=formatter_class, **kwargs)
self.env = None
self.args = None
self.has_stdin_data = False
# noinspection PyMethodOverriding
def parse_args(
self,
env: Environment,
args=None,
namespace=None
) -> argparse.Namespace:
self.env = env
self.args, no_options = super().parse_known_args(args, namespace)
if self.args.debug:
self.args.traceback = True
self.has_stdin_data = (
self.env.stdin
and not self.args.ignore_stdin
and not self.env.stdin_isatty
)
# Arguments processing and environment setup.
self._apply_no_options(no_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()
if self.has_stdin_data:
self._body_from_file(self.env.stdin)
if not URL_SCHEME_RE.match(self.args.url):
if os.path.basename(env.program_name) == 'https':
scheme = 'https://'
else:
scheme = self.args.default_scheme + "://"
# See if we're using curl style shorthand for localhost (:3000/foo)
shorthand = re.match(r'^:(?!:)(\d*)(/?.*)$', self.args.url)
if shorthand:
port = shorthand.group(1)
rest = shorthand.group(2)
self.args.url = scheme + 'localhost'
if port:
self.args.url += ':' + port
self.args.url += rest
else:
self.args.url = scheme + self.args.url
self._process_auth()
return self.args
# noinspection PyShadowingBuiltins
def _print_message(self, message, file=None):
# Sneak in our stderr/stdout.
file = {
sys.stdout: self.env.stdout,
sys.stderr: self.env.stderr,
None: self.env.stderr
}.get(file, file)
if not hasattr(file, 'buffer') and isinstance(message, str):
message = message.encode(self.env.stdout_encoding)
super()._print_message(message, file)
def _setup_standard_streams(self):
"""
Modify `env.stdout` and `env.stdout_isatty` based on args, if needed.
"""
self.args.output_file_specified = bool(self.args.output_file)
if self.args.download:
# FIXME: Come up with a cleaner solution.
if not self.args.output_file and not self.env.stdout_isatty:
# Use stdout as the download output file.
self.args.output_file = self.env.stdout
# With `--download`, we write everything that would normally go to
# `stdout` to `stderr` instead. Let's replace the stream so that
# we don't have to use many `if`s throughout the codebase.
# The response body will be treated separately.
self.env.stdout = self.env.stderr
self.env.stdout_isatty = self.env.stderr_isatty
elif self.args.output_file:
# When not `--download`ing, then `--output` simply replaces
# `stdout`. The file is opened for appending, which isn't what
# we want in this case.
self.args.output_file.seek(0)
try:
self.args.output_file.truncate()
except IOError as e:
if e.errno == errno.EINVAL:
# E.g. /dev/null on Linux.
pass
else:
raise
self.env.stdout = self.args.output_file
self.env.stdout_isatty = False
def _process_auth(self):
# 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
url = urlsplit(self.args.url)
if self.args.auth is None and not auth_type_set:
if url.username is not None:
# Handle http://username:password@hostname/
username = url.username
password = url.password or ''
self.args.auth = AuthCredentials(
key=username,
value=password,
sep=SEPARATOR_CREDENTIALS,
orig=SEPARATOR_CREDENTIALS.join([username, password])
)
if self.args.auth is not None or auth_type_set:
if not self.args.auth_type:
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')
plugin.raw_auth = self.args.auth
self.args.auth_plugin = plugin
already_parsed = isinstance(self.args.auth, AuthCredentials)
if self.args.auth is None or not plugin.auth_parse:
self.args.auth = plugin.get_auth()
else:
if already_parsed:
# from the URL
credentials = self.args.auth
else:
credentials = parse_auth(self.args.auth)
if (not credentials.has_password()
and plugin.prompt_password):
if self.args.ignore_stdin:
# Non-tty stdin read by now
self.error(
'Unable to prompt for passwords because'
' --ignore-stdin is set.'
)
credentials.prompt_password(url.netloc)
self.args.auth = plugin.get_auth(
username=credentials.key,
password=credentials.value,
)
if not self.args.auth and self.args.ignore_netrc:
# Set a no-op auth to force requests to ignore .netrc
# <https://github.com/psf/requests/issues/2773#issuecomment-174312831>
self.args.auth = ExplicitNullAuth()
def _apply_no_options(self, no_options):
"""For every `--no-OPTION` in `no_options`, set `args.OPTION` to
its default value. This allows for un-setting of options, e.g.,
specified in config.
"""
invalid = []
for option in no_options:
if not option.startswith('--no-'):
invalid.append(option)
continue
# --no-option => --option
inverted = '--' + option[5:]
for action in self._actions:
if inverted in action.option_strings:
setattr(self.args, action.dest, action.default)
break
else:
invalid.append(option)
if invalid:
msg = 'unrecognized arguments: %s'
self.error(msg % ' '.join(invalid))
def _body_from_file(self, fd):
"""There can only be one source of request data.
Bytes are always read.
"""
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. '
'See https://httpie.org/doc#scripting for details.')
self.args.data = getattr(fd, 'buffer', fd).read()
def _guess_method(self):
"""Set `args.method` if not specified to either POST or GET
based on whether the request has data or not.
"""
if self.args.method is None:
# Invoked as `http URL'.
assert not self.args.request_items
if self.has_stdin_data:
self.args.method = HTTP_POST
else:
self.args.method = HTTP_GET
# FIXME: False positive, e.g., "localhost" matches but is a valid URL.
elif not re.match('^[a-zA-Z]+$', self.args.method):
# Invoked as `http URL item+'. The URL is now in `args.method`
# and the first ITEM is now incorrectly in `args.url`.
try:
# Parse the URL as an ITEM and store it as the first ITEM arg.
self.args.request_items.insert(0, KeyValueArgType(
*SEPARATOR_GROUP_ALL_ITEMS).__call__(self.args.url))
except argparse.ArgumentTypeError as e:
if self.args.traceback:
raise
self.error(e.args[0])
else:
# Set the URL correctly
self.args.url = self.args.method
# Infer the method
has_data = (
self.has_stdin_data
or any(
item.sep in SEPARATOR_GROUP_DATA_ITEMS
for item in self.args.request_items)
)
self.args.method = HTTP_POST if has_data else HTTP_GET
def _parse_items(self):
"""
Parse `args.request_items` into `args.headers`, `args.data`,
`args.params`, and `args.files`.
"""
try:
request_items = RequestItems.from_args(
request_item_args=self.args.request_items,
as_form=self.args.form,
)
except ParseError as e:
if self.args.traceback:
raise
self.error(e.args[0])
else:
self.args.headers = request_items.headers
self.args.data = request_items.data
self.args.files = request_items.files
self.args.params = request_items.params
if self.args.files and not self.args.form:
# `http url @/path/to/file`
file_fields = list(self.args.files.keys())
if file_fields != ['']:
self.error(
'Invalid file fields (perhaps you meant --form?): %s'
% ','.join(file_fields))
fn, fd, ct = self.args.files['']
self.args.files = {}
self._body_from_file(fd)
if 'Content-Type' not in self.args.headers:
content_type = get_content_type(fn)
if content_type:
self.args.headers['Content-Type'] = content_type
def _process_output_options(self):
"""Apply defaults to output options, or validate the provided ones.
The default output options are stdout-type-sensitive.
"""
def check_options(value, option):
unknown = set(value) - OUTPUT_OPTIONS
if unknown:
self.error('Unknown output options: {0}={1}'.format(
option,
','.join(unknown)
))
if self.args.verbose:
self.args.all = True
if self.args.output_options is None:
if self.args.verbose:
self.args.output_options = ''.join(OUTPUT_OPTIONS)
elif self.args.offline:
self.args.output_options = OUTPUT_OPTIONS_DEFAULT_OFFLINE
elif not self.env.stdout_isatty:
self.args.output_options = OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED
else:
self.args.output_options = OUTPUT_OPTIONS_DEFAULT
if self.args.output_options_history is None:
self.args.output_options_history = self.args.output_options
check_options(self.args.output_options, '--print')
check_options(self.args.output_options_history, '--history-print')
if self.args.download and OUT_RESP_BODY in self.args.output_options:
# Response body is always downloaded with --download and it goes
# through a different routine, so we remove it.
self.args.output_options = str(
set(self.args.output_options) - set(OUT_RESP_BODY))
def _process_pretty_options(self):
if self.args.prettify == PRETTY_STDOUT_TTY_ONLY:
self.args.prettify = PRETTY_MAP[
'all' if self.env.stdout_isatty else 'none']
elif (self.args.prettify and self.env.is_windows
and self.args.output_file):
self.error('Only terminal output can be colorized on Windows.')
else:
# noinspection PyTypeChecker
self.args.prettify = PRETTY_MAP[self.args.prettify]
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

250
httpie/cli/argtypes.py Normal file
View File

@ -0,0 +1,250 @@
import argparse
import getpass
import os
import sys
from copy import deepcopy
from typing import List, Optional, Union
from httpie.cli.constants import DEFAULT_FORMAT_OPTIONS, SEPARATOR_CREDENTIALS
from httpie.sessions import VALID_SESSION_NAME_PATTERN
class KeyValueArg:
"""Base key-value pair parsed from CLI."""
def __init__(self, key: str, value: Optional[str], sep: str, orig: str):
self.key = key
self.value = value
self.sep = sep
self.orig = orig
def __eq__(self, other: 'KeyValueArg'):
return self.__dict__ == other.__dict__
def __repr__(self):
return repr(self.__dict__)
class SessionNameValidator:
def __init__(self, error_message: str):
self.error_message = error_message
def __call__(self, value: str) -> str:
# Session name can be a path or just a name.
if (os.path.sep not in value
and not VALID_SESSION_NAME_PATTERN.search(value)):
raise argparse.ArgumentError(None, self.error_message)
return value
class Escaped(str):
"""Represents an escaped character."""
def __repr__(self):
return f"Escaped({repr(str(self))})"
class KeyValueArgType:
"""A key-value pair argument type used with `argparse`.
Parses a key-value arg and constructs a `KeyValueArg` instance.
Used for headers, form data, and other key-value pair types.
"""
key_value_class = KeyValueArg
def __init__(self, *separators: str):
self.separators = separators
self.special_characters = set('\\')
for separator in separators:
self.special_characters.update(separator)
def __call__(self, s: str) -> KeyValueArg:
"""Parse raw string arg and return `self.key_value_class` instance.
The best of `self.separators` is determined (first found, longest).
Back slash escaped characters aren't considered as separators
(or parts thereof). Literal back slash characters have to be escaped
as well (r'\\').
"""
tokens = self.tokenize(s)
# Sorting by length ensures that the longest one will be
# chosen as it will overwrite any shorter ones starting
# at the same position in the `found` dictionary.
separators = sorted(self.separators, key=len)
for i, token in enumerate(tokens):
if isinstance(token, Escaped):
continue
found = {}
for sep in separators:
pos = token.find(sep)
if pos != -1:
found[pos] = sep
if found:
# Starting first, longest separator found.
sep = found[min(found.keys())]
key, value = token.split(sep, 1)
# Any preceding tokens are part of the key.
key = ''.join(tokens[:i]) + key
# Any following tokens are part of the value.
value += ''.join(tokens[i + 1:])
break
else:
raise argparse.ArgumentTypeError(f'{s!r} is not a valid value')
return self.key_value_class(key=key, value=value, sep=sep, orig=s)
def tokenize(self, s: str) -> List[Union[str, Escaped]]:
r"""Tokenize the raw arg string
There are only two token types - strings and escaped characters:
>>> KeyValueArgType('=').tokenize(r'foo\=bar\\baz')
['foo', Escaped('='), 'bar', Escaped('\\'), 'baz']
"""
tokens = ['']
characters = iter(s)
for char in characters:
if char == '\\':
char = next(characters, '')
if char not in self.special_characters:
tokens[-1] += '\\' + char
else:
tokens.extend([Escaped(char), ''])
else:
tokens[-1] += char
return tokens
class AuthCredentials(KeyValueArg):
"""Represents parsed credentials."""
def has_password(self) -> bool:
return self.value is not None
def prompt_password(self, host: str):
prompt_text = f'http: password for {self.key}@{host}: '
try:
self.value = self._getpass(prompt_text)
except (EOFError, KeyboardInterrupt):
sys.stderr.write('\n')
sys.exit(0)
@staticmethod
def _getpass(prompt):
# To allow easy mocking.
return getpass.getpass(str(prompt))
class AuthCredentialsArgType(KeyValueArgType):
"""A key-value arg type that parses credentials."""
key_value_class = AuthCredentials
def __call__(self, s):
"""Parse credentials from `s`.
("username" or "username:password").
"""
try:
return super().__call__(s)
except argparse.ArgumentTypeError:
# No password provided, will prompt for it later.
return self.key_value_class(
key=s,
value=None,
sep=SEPARATOR_CREDENTIALS,
orig=s
)
parse_auth = AuthCredentialsArgType(SEPARATOR_CREDENTIALS)
def readable_file_arg(filename):
try:
with open(filename, 'rb'):
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

105
httpie/cli/constants.py Normal file
View File

@ -0,0 +1,105 @@
"""Parsing and processing of CLI input (args, auth credentials, files, stdin).
"""
import re
import ssl
# TODO: Use MultiDict for headers once added to `requests`.
# <https://github.com/jakubroztocil/httpie/issues/130>
# ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
# <https://tools.ietf.org/html/rfc3986#section-3.1>
URL_SCHEME_RE = re.compile(r'^[a-z][a-z0-9.+-]*://', re.IGNORECASE)
HTTP_POST = 'POST'
HTTP_GET = 'GET'
# Various separators used in args
SEPARATOR_HEADER = ':'
SEPARATOR_HEADER_EMPTY = ';'
SEPARATOR_CREDENTIALS = ':'
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 = '=='
# Separators that become request data
SEPARATOR_GROUP_DATA_ITEMS = frozenset({
SEPARATOR_DATA_STRING,
SEPARATOR_DATA_RAW_JSON,
SEPARATOR_FILE_UPLOAD,
SEPARATOR_DATA_EMBED_FILE_CONTENTS,
SEPARATOR_DATA_EMBED_RAW_JSON_FILE
})
# Separators for items whose value is a filename to be embedded
SEPARATOR_GROUP_DATA_EMBED_ITEMS = frozenset({
SEPARATOR_DATA_EMBED_FILE_CONTENTS,
SEPARATOR_DATA_EMBED_RAW_JSON_FILE,
})
# Separators for raw JSON items
SEPARATOR_GROUP_RAW_JSON_ITEMS = frozenset([
SEPARATOR_DATA_RAW_JSON,
SEPARATOR_DATA_EMBED_RAW_JSON_FILE,
])
# Separators allowed in ITEM arguments
SEPARATOR_GROUP_ALL_ITEMS = frozenset({
SEPARATOR_HEADER,
SEPARATOR_HEADER_EMPTY,
SEPARATOR_QUERY_PARAM,
SEPARATOR_DATA_STRING,
SEPARATOR_DATA_RAW_JSON,
SEPARATOR_FILE_UPLOAD,
SEPARATOR_DATA_EMBED_FILE_CONTENTS,
SEPARATOR_DATA_EMBED_RAW_JSON_FILE,
})
# Output options
OUT_REQ_HEAD = 'H'
OUT_REQ_BODY = 'B'
OUT_RESP_HEAD = 'h'
OUT_RESP_BODY = 'b'
OUTPUT_OPTIONS = frozenset({
OUT_REQ_HEAD,
OUT_REQ_BODY,
OUT_RESP_HEAD,
OUT_RESP_BODY
})
# Pretty
PRETTY_MAP = {
'all': ['format', 'colors'],
'colors': ['colors'],
'format': ['format'],
'none': []
}
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

View File

@ -1,78 +1,64 @@
"""CLI arguments definition. """
CLI arguments definition.
NOTE: the CLI interface may change before reaching v1.0.
""" """
from argparse import (FileType, OPTIONAL, SUPPRESS, ZERO_OR_MORE)
from textwrap import dedent, wrap from textwrap import dedent, wrap
# noinspection PyCompatibility
from argparse import (RawDescriptionHelpFormatter, FileType,
OPTIONAL, ZERO_OR_MORE, SUPPRESS)
from httpie import __doc__, __version__ from httpie import __doc__, __version__
from httpie.cli.argparser import HTTPieArgumentParser
from httpie.cli.argtypes import (
KeyValueArgType, SessionNameValidator,
readable_file_arg,
)
from httpie.cli.constants import (
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, SORTED_FORMAT_OPTIONS_STRING,
UNSORTED_FORMAT_OPTIONS_STRING,
)
from httpie.output.formatters.colors import (
AUTO_STYLE, AVAILABLE_STYLES, DEFAULT_STYLE,
)
from httpie.plugins.builtin import BuiltinAuthPlugin from httpie.plugins.builtin import BuiltinAuthPlugin
from httpie.plugins import plugin_manager from httpie.plugins.registry import plugin_manager
from httpie.sessions import DEFAULT_SESSIONS_DIR from httpie.sessions import DEFAULT_SESSIONS_DIR
from httpie.output.formatters.colors import AVAILABLE_STYLES, DEFAULT_STYLE from httpie.ssl import AVAILABLE_SSL_VERSION_ARG_MAPPING, DEFAULT_SSL_CIPHERS
from httpie.input import (Parser, AuthCredentialsArgType, KeyValueArgType,
SEP_PROXY, SEP_CREDENTIALS, SEP_GROUP_ALL_ITEMS,
OUT_REQ_HEAD, OUT_REQ_BODY, OUT_RESP_HEAD,
OUT_RESP_BODY, OUTPUT_OPTIONS,
OUTPUT_OPTIONS_DEFAULT, PRETTY_MAP,
PRETTY_STDOUT_TTY_ONLY, SessionNameValidator,
readable_file_arg)
class HTTPieHelpFormatter(RawDescriptionHelpFormatter): parser = HTTPieArgumentParser(
"""A nicer help formatter. prog='http',
description='%s <https://httpie.org>' % __doc__.strip(),
Help for arguments can be indented and contain new lines. epilog=dedent('''
It will be de-dented and arguments in the help
will be separated by a blank line for better readability.
"""
def __init__(self, max_help_position=6, *args, **kwargs):
# A smaller indent for args help.
kwargs['max_help_position'] = max_help_position
super(HTTPieHelpFormatter, self).__init__(*args, **kwargs)
def _split_lines(self, text, width):
text = dedent(text).strip() + '\n\n'
return text.splitlines()
parser = Parser(
formatter_class=HTTPieHelpFormatter,
description='%s <http://httpie.org>' % __doc__.strip(),
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.
Suggestions and bug reports are greatly appreciated: Suggestions and bug reports are greatly appreciated:
https://github.com/jkbrzt/httpie/issues https://github.com/jakubroztocil/httpie/issues
""") '''),
) )
####################################################################### #######################################################################
# Positional arguments. # Positional arguments.
####################################################################### #######################################################################
positional = parser.add_argument_group( positional = parser.add_argument_group(
title='Positional Arguments', title='Positional Arguments',
description=dedent(""" description=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(
'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
@ -81,27 +67,29 @@ 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(
'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 also use a shorthand for localhost You can also use a shorthand for localhost
$ 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(
'items', dest='request_items',
metavar='REQUEST_ITEM', metavar='REQUEST_ITEM',
nargs=ZERO_OR_MORE, nargs=ZERO_OR_MORE,
type=KeyValueArgType(*SEP_GROUP_ALL_ITEMS), default=None,
help=r""" type=KeyValueArgType(*SEPARATOR_GROUP_ALL_ITEMS),
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:
@ -124,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:
@ -138,10 +127,9 @@ positional.add_argument(
field-name-with\:colon=value field-name-with\:colon=value
""" '''
) )
####################################################################### #######################################################################
# Content type. # Content type.
####################################################################### #######################################################################
@ -154,26 +142,48 @@ 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.
""" '''
) )
#######################################################################
# Content processing.
#######################################################################
content_processing = parser.add_argument_group(
title='Content Processing Options',
description=None
)
content_processing.add_argument(
'--compress', '-x',
action='count',
default=0,
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.
'''
)
####################################################################### #######################################################################
# Output processing # Output processing
@ -186,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',
@ -199,24 +209,86 @@ 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} {available_styles}
For this option to work properly, please make sure that the $TERM The "{auto_style}" style follows your terminal's ANSI color styles.
environment variable is set to "xterm-256color" or similar
For non-{auto_style} styles to work properly, please make sure that the
$TERM environment variable is set to "xterm-256color" or similar
(e.g., via `export TERM=xterm-256color' in your ~/.bashrc). (e.g., via `export TERM=xterm-256color' in your ~/.bashrc).
""".format( '''.format(
default=DEFAULT_STYLE, default=DEFAULT_STYLE,
available='\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,
) )
) )
_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
@ -227,67 +299,82 @@ output_options.add_argument(
'--print', '-p', '--print', '-p',
dest='output_options', dest='output_options',
metavar='WHAT', metavar='WHAT',
help=""" help=f'''
String specifying what the output should contain: String specifying what the output should contain:
'{req_head}' request headers '{OUT_REQ_HEAD}' request headers
'{req_body}' request body '{OUT_REQ_BODY}' request body
'{res_head}' response headers '{OUT_RESP_HEAD}' response headers
'{res_body}' response body '{OUT_RESP_BODY}' response body
The default behaviour is '{default}' (i.e., the response headers and body The default behaviour is '{OUTPUT_OPTIONS_DEFAULT}' (i.e., the response
is printed), if standard output is not redirected. If the output is piped headers and body is printed), if standard output is not redirected.
to another program or to a file, then only the response body is printed If the output is piped to another program or to a file, then only the
by default. response body is printed by default.
""" '''
.format(
req_head=OUT_REQ_HEAD,
req_body=OUT_REQ_BODY,
res_head=OUT_RESP_HEAD,
res_body=OUT_RESP_BODY,
default=OUTPUT_OPTIONS_DEFAULT,
)
)
output_options.add_argument(
'--verbose', '-v',
dest='output_options',
action='store_const',
const=''.join(OUTPUT_OPTIONS),
help="""
Print the whole request as well as the response. Shortcut for --print={0}.
"""
.format(''.join(OUTPUT_OPTIONS))
) )
output_options.add_argument( output_options.add_argument(
'--headers', '-h', '--headers', '-h',
dest='output_options', dest='output_options',
action='store_const', action='store_const',
const=OUT_RESP_HEAD, const=OUT_RESP_HEAD,
help=""" help=f'''
Print only the response headers. Shortcut for --print={0}. Print only the response headers. Shortcut for --print={OUT_RESP_HEAD}.
""" '''
.format(OUT_RESP_HEAD)
) )
output_options.add_argument( output_options.add_argument(
'--body', '-b', '--body', '-b',
dest='output_options', dest='output_options',
action='store_const', action='store_const',
const=OUT_RESP_BODY, const=OUT_RESP_BODY,
help=""" help=f'''
Print only the response body. Shortcut for --print={0}. Print only the response body. Shortcut for --print={OUT_RESP_BODY}.
""" '''
.format(OUT_RESP_BODY)
) )
output_options.add_argument(
'--verbose', '-v',
dest='verbose',
action='store_true',
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))
)
output_options.add_argument(
'--all',
default=False,
action='store_true',
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='''
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( 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),
@ -299,18 +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. If --download is set, then only the response body is Save output to FILE instead of stdout. If --download is also set, then only
saved to the file. Other parts of the HTTP exchange are printed to stderr. the response body is saved to FILE. Other parts of the HTTP exchange are
printed to stderr.
""" '''
) )
@ -318,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(
@ -331,20 +419,19 @@ 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.
""" '''
) )
####################################################################### #######################################################################
# Sessions # Sessions
####################################################################### #######################################################################
sessions = parser.add_argument_group(title='Sessions')\ sessions = parser.add_argument_group(title='Sessions') \
.add_mutually_exclusive_group(required=False) .add_mutually_exclusive_group(required=False)
session_name_validator = SessionNameValidator( session_name_validator = SessionNameValidator(
'Session name contains invalid characters.' 'Session name contains invalid characters.'
@ -354,30 +441,28 @@ sessions.add_argument(
'--session', '--session',
metavar='SESSION_NAME_OR_PATH', metavar='SESSION_NAME_OR_PATH',
type=session_name_validator, type=session_name_validator,
help=""" 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.
Session files are stored in: Session files are stored in:
{session_dir}/<HOST>/<SESSION_NAME>.json. {DEFAULT_SESSIONS_DIR}/<HOST>/<SESSION_NAME>.json.
""" '''
.format(session_dir=DEFAULT_SESSIONS_DIR)
) )
sessions.add_argument( sessions.add_argument(
'--session-read-only', '--session-read-only',
metavar='SESSION_NAME_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.
""" '''
) )
####################################################################### #######################################################################
# Authentication # Authentication
####################################################################### #######################################################################
@ -386,27 +471,37 @@ sessions.add_argument(
auth = parser.add_argument_group(title='Authentication') auth = parser.add_argument_group(title='Authentication')
auth.add_argument( auth.add_argument(
'--auth', '-a', '--auth', '-a',
default=None,
metavar='USER[:PASS]', metavar='USER[:PASS]',
type=AuthCredentialsArgType(SEP_CREDENTIALS), help='''
help="""
If only the username is provided (-a username), HTTPie will prompt If only the username is provided (-a username), HTTPie will prompt
for the password. for the password.
""", ''',
) )
class _AuthTypeLazyChoices:
# Needed for plugin testing
def __contains__(self, item):
return item in plugin_manager.get_auth_plugin_mapping()
def __iter__(self):
return iter(sorted(plugin_manager.get_auth_plugin_mapping().keys()))
_auth_plugins = plugin_manager.get_auth_plugins() _auth_plugins = plugin_manager.get_auth_plugins()
auth.add_argument( auth.add_argument(
'--auth-type', '--auth-type', '-A',
choices=[plugin.auth_type for plugin in _auth_plugins], choices=_AuthTypeLazyChoices(),
default=_auth_plugins[0].auth_type, 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,
@ -422,7 +517,15 @@ auth.add_argument(
for plugin in _auth_plugins for plugin in _auth_plugins
)), )),
) )
auth.add_argument(
'--ignore-netrc',
default=False,
action='store_true',
help='''
Ignore credentials from .netrc.
''',
)
####################################################################### #######################################################################
# Network # Network
@ -430,79 +533,79 @@ auth.add_argument(
network = parser.add_argument_group(title='Network') network = parser.add_argument_group(title='Network')
network.add_argument(
'--offline',
default=False,
action='store_true',
help='''
Build the request and print it but dont actually send it.
'''
)
network.add_argument( network.add_argument(
'--proxy', '--proxy',
default=[], default=[],
action='append', action='append',
metavar='PROTOCOL:PROXY_URL', metavar='PROTOCOL:PROXY_URL',
type=KeyValueArgType(SEP_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. different protocols. The environment variables $ALL_PROXY, $HTTP_PROXY,
and $HTTPS_proxy are supported as well.
""" '''
) )
network.add_argument( network.add_argument(
'--follow', '--follow', '-F',
default=False, default=False,
action='store_true', action='store_true',
help=""" help='''
Set this flag if full redirects are allowed (e.g. re-POST-ing of data at Follow 30x Location redirects.
new Location).
""" '''
)
network.add_argument(
'--verify',
default='yes',
help="""
Set to "no" to skip checking the host's SSL certificate. You can also pass
the path to a CA_BUNDLE file for private certs. You can also set the
REQUESTS_CA_BUNDLE environment variable. Defaults to "yes".
"""
) )
network.add_argument( network.add_argument(
'--cert', '--max-redirects',
default=None, type=int,
type=readable_file_arg, default=30,
help=""" help='''
You can specify a local cert to use as client side SSL certificate. By default, requests have a limit of 30 redirects (works with --follow).
This file may either contain both private key and certificate or you may
specify --cert-key separately.
""" '''
) )
network.add_argument( network.add_argument(
'--cert-key', '--max-headers',
default=None, type=int,
type=readable_file_arg, default=0,
help=""" help='''
The private key to use with SSL. Only needed if --cert is given and the The maximum number of response headers to be read before giving up
certificate file does not contain the private key. (default 0, i.e., no limit).
""" '''
) )
network.add_argument( network.add_argument(
'--timeout', '--timeout',
type=float, type=float,
default=30, default=0,
metavar='SECONDS', metavar='SECONDS',
help=""" help='''
The connection timeout of the request in seconds. The default value is The connection timeout of the request in seconds.
30 seconds. The default value is 0, i.e., there is no timeout limit.
This is not a time limit on the entire response download;
rather, an error is reported if the server has not issued a response for
timeout seconds (more precisely, if no bytes have been received on
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.
@ -512,9 +615,79 @@ 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.
'''
) )
#######################################################################
# SSL
#######################################################################
ssl = parser.add_argument_group(title='SSL')
ssl.add_argument(
'--verify',
default='yes',
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(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='''
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='''
The private key to use with SSL. Only needed if --cert is given and the
certificate file does not contain the private key.
'''
)
####################################################################### #######################################################################
# Troubleshooting # Troubleshooting
@ -523,48 +696,56 @@ network.add_argument(
troubleshooting = parser.add_argument_group(title='Troubleshooting') troubleshooting = parser.add_argument_group(title='Troubleshooting')
troubleshooting.add_argument( troubleshooting.add_argument(
'--ignore-stdin', '--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 exception traceback should one occur. Prints the exception traceback should one occur.
""" '''
)
troubleshooting.add_argument(
'--default-scheme',
default="http",
help='''
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 exception traceback should one occur, and also other information Prints the exception traceback should one occur, as well as other
that is useful for debugging HTTPie itself and for reporting bugs. information useful for debugging HTTPie itself and for reporting bugs.
""" '''
) )

53
httpie/cli/dicts.py Normal file
View File

@ -0,0 +1,53 @@
from collections import OrderedDict
from requests.structures import CaseInsensitiveDict
class RequestHeadersDict(CaseInsensitiveDict):
"""
Headers are case-insensitive and multiple values are currently not supported.
"""
class RequestJSONDataDict(OrderedDict):
pass
class MultiValueOrderedDict(OrderedDict):
"""Multi-value dict for URL parameters and form data."""
def __setitem__(self, key, value):
"""
If `key` is assigned more than once, `self[key]` holds a
`list` of all the values.
This allows having multiple fields with the same name in form
data and URL params.
"""
assert not isinstance(value, list)
if key not in self:
super().__setitem__(key, value)
else:
if not isinstance(self[key], list):
super().__setitem__(key, [self[key]])
self[key].append(value)
class RequestQueryParamsDict(MultiValueOrderedDict):
pass
class RequestDataDict(MultiValueOrderedDict):
def items(self):
for key, values in super(MultiValueOrderedDict, self).items():
if not isinstance(values, list):
values = [values]
for value in values:
yield key, value
class RequestFilesDict(RequestDataDict):
pass

2
httpie/cli/exceptions.py Normal file
View File

@ -0,0 +1,2 @@
class ParseError(Exception):
pass

153
httpie/cli/requestitems.py Normal file
View File

@ -0,0 +1,153 @@
import os
from io import BytesIO
from typing import Callable, Dict, IO, List, Optional, Tuple, Union
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_FILE_UPLOAD_TYPE, SEPARATOR_HEADER, SEPARATOR_HEADER_EMPTY,
SEPARATOR_QUERY_PARAM,
)
from httpie.cli.dicts import (
RequestDataDict, RequestFilesDict, RequestHeadersDict, RequestJSONDataDict,
RequestQueryParamsDict,
)
from httpie.cli.exceptions import ParseError
from httpie.utils import (get_content_type, load_json_preserve_order)
class RequestItems:
def __init__(self, as_form=False):
self.headers = RequestHeadersDict()
self.data = RequestDataDict() if as_form else RequestJSONDataDict()
self.files = RequestFilesDict()
self.params = RequestQueryParamsDict()
@classmethod
def from_args(
cls,
request_item_args: List[KeyValueArg],
as_form=False,
) -> 'RequestItems':
instance = cls(as_form=as_form)
rules: Dict[str, Tuple[Callable, dict]] = {
SEPARATOR_HEADER: (
process_header_arg,
instance.headers,
),
SEPARATOR_HEADER_EMPTY: (
process_empty_header_arg,
instance.headers,
),
SEPARATOR_QUERY_PARAM: (
process_query_param_arg,
instance.params,
),
SEPARATOR_FILE_UPLOAD: (
process_file_upload_arg,
instance.files,
),
SEPARATOR_DATA_STRING: (
process_data_item_arg,
instance.data,
),
SEPARATOR_DATA_EMBED_FILE_CONTENTS: (
process_data_embed_file_contents_arg,
instance.data,
),
SEPARATOR_DATA_RAW_JSON: (
process_data_raw_json_embed_arg,
instance.data,
),
SEPARATOR_DATA_EMBED_RAW_JSON_FILE: (
process_data_embed_raw_json_file_arg,
instance.data,
),
}
for arg in request_item_args:
processor_func, target_dict = rules[arg.sep]
target_dict[arg.key] = processor_func(arg)
return instance
JSONType = Union[str, bool, int, list, dict]
def process_header_arg(arg: KeyValueArg) -> Optional[str]:
return arg.value or None
def process_empty_header_arg(arg: KeyValueArg) -> str:
if arg.value:
raise ParseError(
'Invalid item "%s" '
'(to specify an empty header use `Header;`)'
% arg.orig
)
return arg.value
def process_query_param_arg(arg: KeyValueArg) -> str:
return arg.value
def process_file_upload_arg(arg: KeyValueArg) -> Tuple[str, IO, str]:
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()
except IOError as e:
raise ParseError('"%s": %s' % (arg.orig, e))
return (
os.path.basename(filename),
BytesIO(contents),
mime_type or get_content_type(filename),
)
def process_data_item_arg(arg: KeyValueArg) -> str:
return arg.value
def process_data_embed_file_contents_arg(arg: KeyValueArg) -> str:
return load_text_file(arg)
def process_data_embed_raw_json_file_arg(arg: KeyValueArg) -> JSONType:
contents = load_text_file(arg)
value = load_json(arg, contents)
return value
def process_data_raw_json_embed_arg(arg: KeyValueArg) -> JSONType:
value = load_json(arg, arg.value)
return value
def load_text_file(item: KeyValueArg) -> str:
path = item.value
try:
with open(os.path.expanduser(path), 'rb') as f:
return f.read().decode()
except IOError as e:
raise ParseError('"%s": %s' % (item.orig, e))
except UnicodeDecodeError:
raise ParseError(
'"%s": cannot embed the content of "%s",'
' not a UTF8 or ASCII-encoded text file'
% (item.orig, item.value)
)
def load_json(arg: KeyValueArg, contents: str) -> JSONType:
try:
return load_json_preserve_order(contents)
except ValueError as e:
raise ParseError('"%s": %s' % (arg.orig, e))

View File

@ -1,99 +1,259 @@
import argparse
import http.client
import json import json
import sys import sys
from pprint import pformat import zlib
from contextlib import contextmanager
from pathlib import Path
from typing import Iterable, Union
from urllib.parse import urlparse, urlunparse
import requests import requests
from requests.packages import urllib3 # noinspection PyPackageRequirements
import urllib3
from httpie import sessions
from httpie import __version__ from httpie import __version__
from httpie.compat import str 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.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}, */*;q=0.5'
DEFAULT_UA = f'HTTPie/{__version__}'
try: def collect_messages(
# https://urllib3.readthedocs.org/en/latest/security.html args: argparse.Namespace,
urllib3.disable_warnings() config_dir: Path,
except AttributeError: ) -> Iterable[Union[requests.PreparedRequest, requests.Response]]:
# In some rare cases, the user may have an old version of the requests httpie_session = None
# or urllib3, and there is no method called "disable_warnings." In these httpie_session_headers = None
# cases, we don't need to call the method. if args.session or args.session_read_only:
# They may get some noisy output but execution shouldn't die. Move on. httpie_session = get_httpie_session(
pass config_dir=config_dir,
session_name=args.session or args.session_read_only,
host=args.headers.get('Host'),
url=args.url,
)
httpie_session_headers = httpie_session.headers
request_kwargs = make_request_kwargs(
args=args,
base_headers=httpie_session_headers,
)
send_kwargs = make_send_kwargs(args)
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:
httpie_session.update_headers(request_kwargs['headers'])
requests_session.cookies = httpie_session.cookies
if args.auth_plugin:
# Save auth from CLI to HTTPie session.
httpie_session.auth = {
'type': args.auth_plugin.auth_type,
'raw_auth': args.auth_plugin.raw_auth,
}
elif httpie_session.auth:
# Apply auth from HTTPie session
request_kwargs['auth'] = httpie_session.auth
if args.debug:
# TODO: reflect the split between request and send kwargs.
dump_request(request_kwargs)
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:
send_kwargs_merged = requests_session.merge_environment_settings(
url=prepared_request.url,
**send_kwargs_mergeable_from_env,
)
with max_headers(args.max_headers):
response = requests_session.send(
request=prepared_request,
**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:
raise requests.TooManyRedirects
if args.follow:
prepared_request = response.next
if args.all:
yield response
continue
yield response
break
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()
FORM = 'application/x-www-form-urlencoded; charset=utf-8' # noinspection PyProtectedMember
JSON = 'application/json' @contextmanager
DEFAULT_UA = 'HTTPie/%s' % __version__ 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:
yield
finally:
http.client._MAXHEADERS = orig
def get_requests_session(): def compress_body(request: requests.PreparedRequest, always: bool):
deflater = zlib.compressobj()
body_bytes = (
request.body
if isinstance(request.body, bytes)
else request.body.encode()
)
deflated_data = deflater.compress(body_bytes)
deflated_data += deflater.flush()
is_economical = len(deflated_data) < len(body_bytes)
if is_economical or always:
request.body = deflated_data
request.headers['Content-Encoding'] = 'deflate'
request.headers['Content-Length'] = str(len(deflated_data))
def build_requests_session(
verify: bool,
ssl_version: str = None,
ciphers: str = None,
) -> requests.Session:
requests_session = requests.Session() requests_session = requests.Session()
for cls in plugin_manager.get_transport_plugins():
transport_plugin = cls() # Install our adapter.
requests_session.mount(prefix=transport_plugin.prefix, https_adapter = HTTPieHTTPSAdapter(
adapter=transport_plugin.get_adapter()) ciphers=ciphers,
verify=verify,
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():
transport_plugin = plugin_cls()
requests_session.mount(
prefix=transport_plugin.prefix,
adapter=transport_plugin.get_adapter(),
)
return requests_session return requests_session
def get_response(args, config_dir): def dump_request(kwargs: dict):
"""Send the request and return a `request.Response`.""" sys.stderr.write(
f'\n>>> requests.request(**{repr_dict(kwargs)})\n\n')
requests_session = get_requests_session()
if not args.session and not args.session_read_only:
kwargs = get_requests_kwargs(args)
if args.debug:
dump_request(kwargs)
response = requests_session.request(**kwargs)
else:
response = sessions.get_response(
requests_session=requests_session,
args=args,
config_dir=config_dir,
session_name=args.session or args.session_read_only,
read_only=bool(args.session_read_only),
)
return response
def dump_request(kwargs): def finalize_headers(headers: RequestHeadersDict) -> RequestHeadersDict:
sys.stderr.write('\n>>> requests.request(**%s)\n\n' final_headers = RequestHeadersDict()
% pformat(kwargs)) for name, value in headers.items():
if value is not None:
# “leading or trailing LWS MAY be removed without
# changing the semantics of the field value”
# <https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html>
# Also, requests raises `InvalidHeader` for leading spaces.
value = value.strip()
if isinstance(value, str):
# See <https://github.com/jakubroztocil/httpie/issues/212>
value = value.encode('utf8')
final_headers[name] = value
return final_headers
def encode_headers(headers): def make_default_headers(args: argparse.Namespace) -> RequestHeadersDict:
# This allows for unicode headers which is non-standard but practical. default_headers = RequestHeadersDict({
# See: https://github.com/jkbrzt/httpie/issues/212
return dict(
(name, value.encode('utf8') if isinstance(value, str) else value)
for name, value in headers.items()
)
def get_default_headers(args):
default_headers = {
'User-Agent': DEFAULT_UA 'User-Agent': DEFAULT_UA
} })
auto_json = args.data and not args.form auto_json = args.data and not args.form
# FIXME: Accept is set to JSON with `http url @./file.txt`.
if args.json or auto_json: if args.json or auto_json:
default_headers['Accept'] = 'application/json' default_headers['Accept'] = JSON_ACCEPT
if args.json or (auto_json and args.data): if args.json or (auto_json and args.data):
default_headers['Content-Type'] = JSON default_headers['Content-Type'] = JSON_CONTENT_TYPE
elif args.form and not args.files: elif args.form and not args.files:
# If sending files, `requests` will set # If sending files, `requests` will set
# the `Content-Type` for us. # the `Content-Type` for us.
default_headers['Content-Type'] = FORM default_headers['Content-Type'] = FORM_CONTENT_TYPE
return default_headers return default_headers
def get_requests_kwargs(args, base_headers=None): def make_send_kwargs(args: argparse.Namespace) -> dict:
kwargs = {
'timeout': args.timeout or None,
'allow_redirects': False,
}
return kwargs
def make_send_kwargs_mergeable_from_env(args: argparse.Namespace) -> dict:
cert = None
if args.cert:
cert = args.cert
if args.cert_key:
cert = cert, args.cert_key
kwargs = {
'proxies': {p.key: p.value for p in args.proxy},
'stream': True,
'verify': {
'yes': True,
'true': True,
'no': False,
'false': False,
}.get(args.verify.lower(), args.verify),
'cert': cert,
}
return kwargs
def make_request_kwargs(
args: argparse.Namespace,
base_headers: RequestHeadersDict = None
) -> dict:
""" """
Translate our `args` into `requests.request` keyword arguments. Translate our `args` into `requests.Request` keyword arguments.
""" """
# Serialize JSON data, if needed. # Serialize JSON data, if needed.
@ -108,40 +268,47 @@ def get_requests_kwargs(args, base_headers=None):
data = '' data = ''
# Finalize headers. # Finalize headers.
headers = get_default_headers(args) headers = make_default_headers(args)
if base_headers: if base_headers:
headers.update(base_headers) headers.update(base_headers)
headers.update(args.headers) headers.update(args.headers)
headers = encode_headers(headers) headers = finalize_headers(headers)
credentials = None
if args.auth:
auth_plugin = plugin_manager.get_auth_plugin(args.auth_type)()
credentials = auth_plugin.get_auth(args.auth.key, args.auth.value)
cert = None
if args.cert:
cert = args.cert
if args.cert_key:
cert = cert, args.cert_key
kwargs = { kwargs = {
'stream': True,
'method': args.method.lower(), 'method': args.method.lower(),
'url': args.url, 'url': args.url,
'headers': headers, 'headers': headers,
'data': data, 'data': data,
'verify': { 'auth': args.auth,
'yes': True,
'no': False
}.get(args.verify, args.verify),
'cert': cert,
'timeout': args.timeout,
'auth': credentials,
'proxies': dict((p.key, p.value) for p in args.proxy),
'files': args.files,
'allow_redirects': args.follow,
'params': args.params, 'params': args.params,
'files': args.files,
} }
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

@ -1,173 +1,4 @@
"""
Python 2.6, 2.7, and 3.x compatibility.
"""
import sys import sys
is_py2 = sys.version_info[0] == 2
is_py26 = sys.version_info[:2] == (2, 6)
is_py27 = sys.version_info[:2] == (2, 7)
is_py3 = sys.version_info[0] == 3
is_pypy = 'pypy' in sys.version.lower()
is_windows = 'win32' in str(sys.platform).lower() is_windows = 'win32' in str(sys.platform).lower()
if is_py2:
bytes = str
str = unicode
elif is_py3:
str = str
bytes = bytes
try: # pragma: no cover
# noinspection PyUnresolvedReferences,PyCompatibility
from urllib.parse import urlsplit
except ImportError: # pragma: no cover
# noinspection PyUnresolvedReferences,PyCompatibility
from urlparse import urlsplit
try: # pragma: no cover
# noinspection PyCompatibility
from urllib.request import urlopen
except ImportError: # pragma: no cover
# noinspection PyCompatibility
from urllib2 import urlopen
try: # pragma: no cover
from collections import OrderedDict
except ImportError: # pragma: no cover
# Python 2.6 OrderedDict class, needed for headers, parameters, etc .###
# <https://pypi.python.org/pypi/ordereddict/1.1>
# noinspection PyCompatibility
from UserDict import DictMixin
# noinspection PyShadowingBuiltins
class OrderedDict(dict, DictMixin):
# Copyright (c) 2009 Raymond Hettinger
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE.
# noinspection PyMissingConstructor
def __init__(self, *args, **kwds):
if len(args) > 1:
raise TypeError('expected at most 1 arguments, got %d'
% len(args))
try:
self.__end
except AttributeError:
self.clear()
self.update(*args, **kwds)
def clear(self):
self.__end = end = []
# noinspection PyUnusedLocal
end += [None, end, end] # sentinel node for doubly linked list
self.__map = {} # key --> [key, prev, next]
dict.clear(self)
def __setitem__(self, key, value):
if key not in self:
end = self.__end
curr = end[1]
curr[2] = end[1] = self.__map[key] = [key, curr, end]
dict.__setitem__(self, key, value)
def __delitem__(self, key):
dict.__delitem__(self, key)
key, prev, next = self.__map.pop(key)
prev[2] = next
next[1] = prev
def __iter__(self):
end = self.__end
curr = end[2]
while curr is not end:
yield curr[0]
curr = curr[2]
def __reversed__(self):
end = self.__end
curr = end[1]
while curr is not end:
yield curr[0]
curr = curr[1]
def popitem(self, last=True):
if not self:
raise KeyError('dictionary is empty')
if last:
key = reversed(self).next()
else:
key = iter(self).next()
value = self.pop(key)
return key, value
def __reduce__(self):
items = [[k, self[k]] for k in self]
tmp = self.__map, self.__end
del self.__map, self.__end
inst_dict = vars(self).copy()
self.__map, self.__end = tmp
if inst_dict:
return self.__class__, (items,), inst_dict
return self.__class__, (items,)
def keys(self):
return list(self)
setdefault = DictMixin.setdefault
update = DictMixin.update
pop = DictMixin.pop
values = DictMixin.values
items = DictMixin.items
iterkeys = DictMixin.iterkeys
itervalues = DictMixin.itervalues
iteritems = DictMixin.iteritems
def __repr__(self):
if not self:
return '%s()' % (self.__class__.__name__,)
return '%s(%r)' % (self.__class__.__name__, self.items())
def copy(self):
return self.__class__(self)
# noinspection PyMethodOverriding
@classmethod
def fromkeys(cls, iterable, value=None):
d = cls()
for key in iterable:
d[key] = value
return d
def __eq__(self, other):
if isinstance(other, OrderedDict):
if len(self) != len(other):
return False
for p, q in zip(self.items(), other.items()):
if p != q:
return False
return True
return dict.__eq__(self, other)
def __ne__(self, other):
return not self == other

View File

@ -1,61 +1,102 @@
import os
import json
import errno import errno
import json
import os
from pathlib import Path
from typing import Union
from httpie import __version__ from httpie import __version__
from httpie.compat import is_windows from httpie.compat import is_windows
DEFAULT_CONFIG_DIR = 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):
pass
class BaseConfigDict(dict): class BaseConfigDict(dict):
name = None name = None
helpurl = None helpurl = None
about = None about = None
def __getattr__(self, item): def __init__(self, path: Path):
return self[item] super().__init__()
self.path = path
def _get_path(self): def ensure_directory(self):
"""Return the config file path without side-effects."""
raise NotImplementedError()
@property
def path(self):
"""Return the config file path creating basedir, if needed."""
path = self._get_path()
try: try:
os.makedirs(os.path.dirname(path), mode=0o700) self.path.parent.mkdir(mode=0o700, parents=True)
except OSError as e: except OSError as e:
if e.errno != errno.EEXIST: if e.errno != errno.EEXIST:
raise raise
return path
def is_new(self): def is_new(self) -> bool:
return not os.path.exists(self._get_path()) return not self.path.exists()
def load(self): def load(self):
config_type = type(self).__name__.lower()
try: try:
with open(self.path, 'rt') as f: with self.path.open('rt') as f:
try: try:
data = json.load(f) data = json.load(f)
except ValueError as e: except ValueError as e:
raise ValueError( raise ConfigFileError(
'Invalid %s JSON: %s [%s]' % f'invalid {config_type} file: {e} [{self.path}]'
(type(self).__name__, str(e), self.path)
) )
self.update(data) self.update(data)
except IOError as e: except IOError as e:
if e.errno != errno.ENOENT: if e.errno != errno.ENOENT:
raise raise ConfigFileError(f'cannot read {config_type} file: {e}')
def save(self): def save(self, fail_silently=False):
self['__meta__'] = { self['__meta__'] = {
'httpie': __version__ 'httpie': __version__
} }
@ -65,33 +106,41 @@ class BaseConfigDict(dict):
if self.about: if self.about:
self['__meta__']['about'] = self.about self['__meta__']['about'] = self.about
with open(self.path, 'w') as f: self.ensure_directory()
json.dump(self, f, indent=4, sort_keys=True, ensure_ascii=True)
f.write('\n') try:
with self.path.open('w') as f:
json.dump(
obj=self,
fp=f,
indent=4,
sort_keys=True,
ensure_ascii=True,
)
f.write('\n')
except IOError:
if not fail_silently:
raise
def delete(self): def delete(self):
try: try:
os.unlink(self.path) self.path.unlink()
except OSError as e: except OSError as e:
if e.errno != errno.ENOENT: if e.errno != errno.ENOENT:
raise raise
class Config(BaseConfigDict): class Config(BaseConfigDict):
FILENAME = 'config.json'
name = 'config'
helpurl = 'https://github.com/jkbrzt/httpie#config'
about = 'HTTPie configuration file'
DEFAULTS = { DEFAULTS = {
'implicit_content_type': 'json',
'default_options': [] 'default_options': []
} }
def __init__(self, directory=DEFAULT_CONFIG_DIR): def __init__(self, directory: Union[str, Path] = DEFAULT_CONFIG_DIR):
super(Config, self).__init__() self.directory = Path(directory)
super().__init__(path=self.directory / self.FILENAME)
self.update(self.DEFAULTS) self.update(self.DEFAULTS)
self.directory = directory
def _get_path(self): @property
return os.path.join(self.directory, self.name + '.json') def default_options(self) -> list:
return self['default_options']

View File

@ -1,10 +1,20 @@
import sys import sys
from pathlib import Path
from typing import IO, Optional
try:
import curses
except ImportError:
curses = None # Compiled w/o curses
from httpie.compat import is_windows from httpie.compat import is_windows
from httpie.config import DEFAULT_CONFIG_DIR, Config from httpie.config import DEFAULT_CONFIG_DIR, Config, ConfigFileError
from httpie.utils import repr_dict
class Environment(object): class Environment:
""" """
Information about the execution context Information about the execution context
(standard streams, config directory, etc). (standard streams, config directory, etc).
@ -14,29 +24,25 @@ class Environment(object):
is used by the test suite to simulate various scenarios. is used by the test suite to simulate various scenarios.
""" """
is_windows = is_windows is_windows: bool = is_windows
config_dir = DEFAULT_CONFIG_DIR config_dir: Path = DEFAULT_CONFIG_DIR
stdin = sys.stdin stdin: Optional[IO] = sys.stdin # `None` when closed fd (#791)
stdin_isatty = stdin.isatty() stdin_isatty: bool = stdin.isatty() if stdin else False
stdin_encoding = None stdin_encoding: str = None
stdout = sys.stdout stdout: IO = sys.stdout
stdout_isatty = stdout.isatty() stdout_isatty: bool = stdout.isatty()
stdout_encoding = None stdout_encoding: str = None
stderr = sys.stderr stderr: IO = sys.stderr
stderr_isatty = stderr.isatty() stderr_isatty: bool = stderr.isatty()
colors = 256 colors = 256
program_name: str = 'http'
if not is_windows: if not is_windows:
import curses if curses:
try:
curses.setupterm()
try: try:
curses.setupterm()
colors = curses.tigetnum('colors') colors = curses.tigetnum('colors')
except TypeError: except curses.error:
# pypy3 (2.4.0) pass
colors = curses.tigetnum(b'colors')
except curses.error:
pass
del curses
else: else:
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
import colorama.initialise import colorama.initialise
@ -60,7 +66,7 @@ class Environment(object):
self.__dict__.update(**kwargs) self.__dict__.update(**kwargs)
# Keyword arguments > stream.encoding > default utf8 # Keyword arguments > stream.encoding > default utf8
if self.stdin_encoding is None: if self.stdin and self.stdin_encoding is None:
self.stdin_encoding = getattr( self.stdin_encoding = getattr(
self.stdin, 'encoding', None) or 'utf8' self.stdin, 'encoding', None) or 'utf8'
if self.stdout_encoding is None: if self.stdout_encoding is None:
@ -69,16 +75,39 @@ class Environment(object):
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
from colorama import AnsiToWin32 from colorama import AnsiToWin32
if isinstance(self.stdout, AnsiToWin32): if isinstance(self.stdout, AnsiToWin32):
# noinspection PyUnresolvedReferences
actual_stdout = self.stdout.wrapped actual_stdout = self.stdout.wrapped
self.stdout_encoding = getattr( self.stdout_encoding = getattr(
actual_stdout, 'encoding', None) or 'utf8' actual_stdout, 'encoding', None) or 'utf8'
def __str__(self):
defaults = dict(type(self).__dict__)
actual = dict(defaults)
actual.update(self.__dict__)
actual['config'] = self.config
return repr_dict({
key: value
for key, value in actual.items()
if not key.startswith('_')
})
def __repr__(self):
return f'<{type(self).__name__} {self}>'
_config: Config = None
@property @property
def config(self): def config(self) -> Config:
if not hasattr(self, '_config'): config = self._config
self._config = Config(directory=self.config_dir) if not config:
if self._config.is_new(): self._config = config = Config(directory=self.config_dir)
self._config.save() if not config.is_new():
else: try:
self._config.load() config.load()
return self._config except ConfigFileError as e:
self.log_error(e, level='warning')
return config
def log_error(self, msg, level='error'):
assert level in ['error', 'warning']
self.stderr.write(f'\n{self.program_name}: {level}: {msg}\n\n')

View File

@ -1,62 +1,212 @@
"""This module provides the main functionality of HTTPie. import argparse
import os
Invocation flow: import platform
1. Read, validate and process the input (args, `stdin`).
2. Create and send a request.
3. Stream, and possibly process and format, the parts
of the request-response exchange selected by output options.
4. Simultaneously write to `stdout`
5. Exit.
"""
import sys import sys
import errno from typing import List, Union
import requests import requests
from requests import __version__ as requests_version
from pygments import __version__ as pygments_version from pygments import __version__ as pygments_version
from requests import __version__ as requests_version
from httpie import __version__ as httpie_version, ExitStatus from httpie import __version__ as httpie_version
from httpie.compat import str, bytes, is_py3 from httpie.client import collect_messages
from httpie.client import get_response
from httpie.downloads import Download
from httpie.context import Environment from httpie.context import Environment
from httpie.plugins import plugin_manager from httpie.downloads import Downloader
from httpie.output.streams import ( from httpie.output.writer import write_message, write_stream
build_output_stream, from httpie.plugins.registry import plugin_manager
write, write_with_colors_win_py3 from httpie.status import ExitStatus, http_status_to_exit_status
)
def get_exit_status(http_status, follow=False): # noinspection PyDefaultArgument
"""Translate HTTP status code to exit status code.""" def main(
if 300 <= http_status <= 399 and not follow: args: List[Union[str, bytes]] = sys.argv,
# Redirect env=Environment(),
return ExitStatus.ERROR_HTTP_3XX ) -> ExitStatus:
elif 400 <= http_status <= 499:
# Client Error
return ExitStatus.ERROR_HTTP_4XX
elif 500 <= http_status <= 599:
# Server Error
return ExitStatus.ERROR_HTTP_5XX
else:
return ExitStatus.OK
def print_debug_info(env):
env.stderr.writelines([
'HTTPie %s\n' % httpie_version,
'HTTPie data: %s\n' % env.config.directory,
'Requests %s\n' % requests_version,
'Pygments %s\n' % pygments_version,
'Python %s %s\n' % (sys.version, sys.platform)
])
def decode_args(args, stdin_encoding):
""" """
Convert all bytes ags to str The main function.
Pre-process args, handle some special types of invocations,
and run the main program with error handling.
Return exit status code.
"""
program_name, *args = args
env.program_name = os.path.basename(program_name)
args = decode_raw_args(args, env.stdin_encoding)
plugin_manager.load_installed_plugins()
from httpie.cli.definition import parser
if env.config.default_options:
args = env.config.default_options + args
include_debug_info = '--debug' in args
include_traceback = include_debug_info or '--traceback' in args
if include_debug_info:
print_debug_info(env)
if args == ['--debug']:
return ExitStatus.SUCCESS
exit_status = ExitStatus.SUCCESS
try:
parsed_args = parser.parse_args(
args=args,
env=env,
)
except KeyboardInterrupt:
env.stderr.write('\n')
if include_traceback:
raise
exit_status = ExitStatus.ERROR_CTRL_C
except SystemExit as e:
if e.code != ExitStatus.SUCCESS:
env.stderr.write('\n')
if include_traceback:
raise
exit_status = ExitStatus.ERROR
else:
try:
exit_status = program(
args=parsed_args,
env=env,
)
except KeyboardInterrupt:
env.stderr.write('\n')
if include_traceback:
raise
exit_status = ExitStatus.ERROR_CTRL_C
except SystemExit as e:
if e.code != ExitStatus.SUCCESS:
env.stderr.write('\n')
if include_traceback:
raise
exit_status = ExitStatus.ERROR
except requests.Timeout:
exit_status = ExitStatus.ERROR_TIMEOUT
env.log_error(f'Request timed out ({parsed_args.timeout}s).')
except requests.TooManyRedirects:
exit_status = ExitStatus.ERROR_TOO_MANY_REDIRECTS
env.log_error(
f'Too many redirects'
f' (--max-redirects={parsed_args.max_redirects}).'
)
except Exception as e:
# TODO: Further distinction between expected and unexpected errors.
msg = str(e)
if hasattr(e, 'request'):
request = e.request
if hasattr(request, 'url'):
msg = (
f'{msg} while doing a {request.method}'
f' request to URL: {request.url}'
)
env.log_error(f'{type(e).__name__}: {msg}')
if include_traceback:
raise
exit_status = ExitStatus.ERROR
return exit_status
def program(
args: argparse.Namespace,
env: Environment,
) -> ExitStatus:
"""
The main program without error handling.
"""
exit_status = ExitStatus.SUCCESS
downloader = None
try:
if args.download:
args.follow = True # --download implies --follow.
downloader = Downloader(
output_file=args.output_file,
progress_file=env.stderr,
resume=args.download_resume
)
downloader.pre_request(args.headers)
initial_request = None
final_response = None
for message in collect_messages(args, env.config.directory):
write_message(
requests_message=message,
env=env,
args=args,
)
if isinstance(message, requests.PreparedRequest):
if not initial_request:
initial_request = message
else:
final_response = message
if args.check_status or downloader:
exit_status = http_status_to_exit_status(
http_status=message.status_code,
follow=args.follow
)
if (not env.stdout_isatty
and exit_status != ExitStatus.SUCCESS):
env.log_error(
f'HTTP {message.raw.status} {message.raw.reason}',
level='warning'
)
if downloader and exit_status == ExitStatus.SUCCESS:
# Last response body download.
download_stream, download_to = downloader.start(
initial_url=initial_request.url,
final_response=final_response,
)
write_stream(
stream=download_stream,
outfile=download_to,
flush=False,
)
downloader.finish()
if downloader.interrupted:
exit_status = ExitStatus.ERROR
env.log_error(
'Incomplete download: size=%d; downloaded=%d' % (
downloader.status.total_size,
downloader.status.downloaded
))
return exit_status
finally:
if downloader and not downloader.finished:
downloader.failed()
if (not isinstance(args, list) and args.output_file
and args.output_file_specified):
args.output_file.close()
def print_debug_info(env: Environment):
env.stderr.writelines([
f'HTTPie {httpie_version}\n',
f'Requests {requests_version}\n',
f'Pygments {pygments_version}\n',
f'Python {sys.version}\n{sys.executable}\n',
f'{platform.system()} {platform.release()}',
])
env.stderr.write('\n\n')
env.stderr.write(repr(env))
env.stderr.write('\n')
def decode_raw_args(
args: List[Union[str, bytes]],
stdin_encoding: str
) -> List[str]:
"""
Convert all bytes args to str
by decoding them using stdin encoding. by decoding them using stdin encoding.
""" """
@ -65,138 +215,3 @@ def decode_args(args, stdin_encoding):
if type(arg) == bytes else arg if type(arg) == bytes else arg
for arg in args for arg in args
] ]
def main(args=sys.argv[1:], env=Environment(), error=None):
"""Run the main program and write the output to ``env.stdout``.
Return exit status code.
"""
args = decode_args(args, env.stdin_encoding)
plugin_manager.load_installed_plugins()
from httpie.cli import parser
if env.config.default_options:
args = env.config.default_options + args
def _error(msg, *args, **kwargs):
msg = msg % args
level = kwargs.get('level', 'error')
env.stderr.write('\nhttp: %s: %s\n' % (level, msg))
if error is None:
error = _error
debug = '--debug' in args
traceback = debug or '--traceback' in args
exit_status = ExitStatus.OK
if debug:
print_debug_info(env)
if args == ['--debug']:
return exit_status
download = None
try:
args = parser.parse_args(args=args, env=env)
if args.download:
args.follow = True # --download implies --follow.
download = Download(
output_file=args.output_file,
progress_file=env.stderr,
resume=args.download_resume
)
download.pre_request(args.headers)
response = get_response(args, config_dir=env.config.directory)
if args.check_status or download:
exit_status = get_exit_status(
http_status=response.status_code,
follow=args.follow
)
if not env.stdout_isatty and exit_status != ExitStatus.OK:
error('HTTP %s %s',
response.raw.status,
response.raw.reason,
level='warning')
write_kwargs = {
'stream': build_output_stream(
args, env, response.request, response),
# This will in fact be `stderr` with `--download`
'outfile': env.stdout,
'flush': env.stdout_isatty or args.stream
}
try:
if env.is_windows and is_py3 and 'colors' in args.prettify:
write_with_colors_win_py3(**write_kwargs)
else:
write(**write_kwargs)
if download and exit_status == ExitStatus.OK:
# Response body download.
download_stream, download_to = download.start(response)
write(
stream=download_stream,
outfile=download_to,
flush=False,
)
download.finish()
if download.interrupted:
exit_status = ExitStatus.ERROR
error('Incomplete download: size=%d; downloaded=%d' % (
download.status.total_size,
download.status.downloaded
))
except IOError as e:
if not traceback and e.errno == errno.EPIPE:
# Ignore broken pipes unless --traceback.
env.stderr.write('\n')
else:
raise
except KeyboardInterrupt:
if traceback:
raise
env.stderr.write('\n')
exit_status = ExitStatus.ERROR
except SystemExit as e:
if e.code != ExitStatus.OK:
if traceback:
raise
env.stderr.write('\n')
exit_status = ExitStatus.ERROR
except requests.Timeout:
exit_status = ExitStatus.ERROR_TIMEOUT
error('Request timed out (%ss).', args.timeout)
except Exception as e:
# TODO: Better distinction between expected and unexpected errors.
# Network errors vs. bugs, etc.
if traceback:
raise
msg = str(e)
if hasattr(e, 'request'):
request = e.request
if hasattr(request, 'url'):
msg += ' while doing %s request to URL: %s' % (
request.method, request.url)
error('%s: %s', type(e).__name__, msg)
exit_status = ExitStatus.ERROR
finally:
if download and not download.finished:
download.failed()
return exit_status

View File

@ -4,23 +4,27 @@ Download mode implementation.
""" """
from __future__ import division from __future__ import division
import errno
import mimetypes
import os import os
import re import re
import sys import sys
import mimetypes
import threading import threading
from time import sleep, time
from mailbox import Message from mailbox import Message
from time import sleep, time
from typing import IO, Optional, Tuple
from urllib.parse import urlsplit
import requests
from httpie.output.streams import RawStream
from httpie.models import HTTPResponse from httpie.models import HTTPResponse
from httpie.output.streams import RawStream
from httpie.utils import humanize_bytes from httpie.utils import humanize_bytes
from httpie.compat import urlsplit
PARTIAL_CONTENT = 206 PARTIAL_CONTENT = 206
CLEAR_LINE = '\r\033[K' CLEAR_LINE = '\r\033[K'
PROGRESS = ( PROGRESS = (
'{percentage: 6.2f} %' '{percentage: 6.2f} %'
@ -37,11 +41,11 @@ class ContentRangeError(ValueError):
pass pass
def parse_content_range(content_range, resumed_from): def parse_content_range(content_range: str, resumed_from: int) -> int:
""" """
Parse and validate Content-Range header. Parse and validate Content-Range header.
<http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html> <https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html>
:param content_range: the value of a Content-Range response header :param content_range: the value of a Content-Range response header
eg. "bytes 21010-47021/47022" eg. "bytes 21010-47021/47022"
@ -53,8 +57,8 @@ def parse_content_range(content_range, resumed_from):
raise ContentRangeError('Missing Content-Range') raise ContentRangeError('Missing Content-Range')
pattern = ( pattern = (
'^bytes (?P<first_byte_pos>\d+)-(?P<last_byte_pos>\d+)' r'^bytes (?P<first_byte_pos>\d+)-(?P<last_byte_pos>\d+)'
'/(\*|(?P<instance_length>\d+))$' r'/(\*|(?P<instance_length>\d+))$'
) )
match = re.match(pattern, content_range) match = re.match(pattern, content_range)
@ -78,8 +82,8 @@ def parse_content_range(content_range, resumed_from):
# byte-content-range- spec MUST ignore it and any content # byte-content-range- spec MUST ignore it and any content
# transferred along with it." # transferred along with it."
if (first_byte_pos >= last_byte_pos if (first_byte_pos >= last_byte_pos
or (instance_length is not None or (instance_length is not None
and instance_length <= last_byte_pos)): and instance_length <= last_byte_pos)):
raise ContentRangeError( raise ContentRangeError(
'Invalid Content-Range returned: %r' % content_range) 'Invalid Content-Range returned: %r' % content_range)
@ -96,7 +100,9 @@ def parse_content_range(content_range, resumed_from):
return last_byte_pos + 1 return last_byte_pos + 1
def filename_from_content_disposition(content_disposition): def filename_from_content_disposition(
content_disposition: str
) -> Optional[str]:
""" """
Extract and validate filename from a Content-Disposition header. Extract and validate filename from a Content-Disposition header.
@ -104,7 +110,7 @@ def filename_from_content_disposition(content_disposition):
:return: the filename if present and valid, otherwise `None` :return: the filename if present and valid, otherwise `None`
""" """
# attachment; filename=jkbrzt-httpie-0.4.1-20-g40bd8f6.tar.gz # attachment; filename=jakubroztocil-httpie-0.4.1-20-g40bd8f6.tar.gz
msg = Message('Content-Disposition: %s' % content_disposition) msg = Message('Content-Disposition: %s' % content_disposition)
filename = msg.get_filename() filename = msg.get_filename()
@ -115,7 +121,7 @@ def filename_from_content_disposition(content_disposition):
return filename return filename
def filename_from_url(url, content_type): def filename_from_url(url: str, content_type: Optional[str]) -> str:
fn = urlsplit(url).path.rstrip('/') fn = urlsplit(url).path.rstrip('/')
fn = os.path.basename(fn) if fn else 'index' fn = os.path.basename(fn) if fn else 'index'
if '.' not in fn and content_type: if '.' not in fn and content_type:
@ -135,23 +141,61 @@ def filename_from_url(url, content_type):
return fn return fn
def get_unique_filename(filename, exists=os.path.exists): def trim_filename(filename: str, max_len: int) -> str:
if len(filename) > max_len:
trim_by = len(filename) - max_len
name, ext = os.path.splitext(filename)
if trim_by >= len(name):
filename = filename[:-trim_by]
else:
filename = name[:-trim_by] + ext
return filename
def get_filename_max_length(directory: str) -> int:
max_len = 255
try:
pathconf = os.pathconf
except AttributeError:
pass # non-posix
else:
try:
max_len = pathconf(directory, 'PC_NAME_MAX')
except OSError as e:
if e.errno != errno.EINVAL:
raise
return max_len
def trim_filename_if_needed(filename: str, directory='.', extra=0) -> str:
max_len = get_filename_max_length(directory) - extra
if len(filename) > max_len:
filename = trim_filename(filename, max_len)
return filename
def get_unique_filename(filename: str, exists=os.path.exists) -> str:
attempt = 0 attempt = 0
while True: while True:
suffix = '-' + str(attempt) if attempt > 0 else '' suffix = '-' + str(attempt) if attempt > 0 else ''
if not exists(filename + suffix): try_filename = trim_filename_if_needed(filename, extra=len(suffix))
return filename + suffix try_filename += suffix
if not exists(try_filename):
return try_filename
attempt += 1 attempt += 1
class Download(object): class Downloader:
def __init__(self, output_file=None, def __init__(
resume=False, progress_file=sys.stderr): self,
output_file: IO = None,
resume: bool = False,
progress_file: IO = sys.stderr
):
""" """
: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.
@ -159,27 +203,24 @@ class Download(object):
:param progress_file: Where to report download progress. :param progress_file: Where to report download progress.
""" """
self.finished = False
self.status = DownloadStatus()
self._output_file = output_file self._output_file = output_file
self._resume = resume self._resume = resume
self._resumed_from = 0 self._resumed_from = 0
self.finished = False
self.status = Status()
self._progress_reporter = ProgressReporterThread( self._progress_reporter = ProgressReporterThread(
status=self.status, status=self.status,
output=progress_file output=progress_file
) )
def pre_request(self, request_headers): def pre_request(self, request_headers: dict):
"""Called just before the HTTP request is sent. """Called just before the HTTP request is sent.
Might alter `request_headers`. Might alter `request_headers`.
:type request_headers: dict
""" """
# Disable content encoding so that we can resume, etc. # Ask the server not to encode the content so that we can resume, etc.
request_headers['Accept-Encoding'] = None request_headers['Accept-Encoding'] = 'identity'
if self._resume: if self._resume:
bytes_have = os.path.getsize(self._output_file.name) bytes_have = os.path.getsize(self._output_file.name)
if bytes_have: if bytes_have:
@ -188,28 +229,40 @@ class Download(object):
request_headers['Range'] = 'bytes=%d-' % bytes_have request_headers['Range'] = 'bytes=%d-' % bytes_have
self._resumed_from = bytes_have self._resumed_from = bytes_have
def start(self, response): def start(
self,
initial_url: str,
final_response: requests.Response
) -> Tuple[RawStream, IO]:
""" """
Initiate and return a stream for `response` body with progress Initiate and return a stream for `response` body with progress
callback attached. Can be called only once. callback attached. Can be called only once.
:param response: Initiated response object with headers already fetched :param initial_url: The original requested URL
:type response: requests.models.Response :param final_response: Initiated response object with headers already fetched
:return: RawStream, output_file :return: RawStream, output_file
""" """
assert not self.status.time_started assert not self.status.time_started
# FIXME: some servers still might sent Content-Encoding: gzip
# <https://github.com/jakubroztocil/httpie/issues/423>
try: try:
total_size = int(response.headers['Content-Length']) total_size = int(final_response.headers['Content-Length'])
except (KeyError, ValueError, TypeError): except (KeyError, ValueError, TypeError):
total_size = None total_size = None
if self._output_file: if not self._output_file:
if self._resume and response.status_code == PARTIAL_CONTENT: self._output_file = self._get_output_file_from_response(
initial_url=initial_url,
final_response=final_response,
)
else:
# `--output, -o` provided
if self._resume and final_response.status_code == PARTIAL_CONTENT:
total_size = parse_content_range( total_size = parse_content_range(
response.headers.get('Content-Range'), final_response.headers.get('Content-Range'),
self._resumed_from self._resumed_from
) )
@ -220,19 +273,6 @@ class Download(object):
self._output_file.truncate() self._output_file.truncate()
except IOError: except IOError:
pass # stdout pass # stdout
else:
# TODO: Should the filename be taken from response.history[0].url?
# Output file not specified. Pick a name that doesn't exist yet.
filename = None
if 'Content-Disposition' in response.headers:
filename = filename_from_content_disposition(
response.headers['Content-Disposition'])
if not filename:
filename = filename_from_url(
url=response.url,
content_type=response.headers.get('Content-Type'),
)
self._output_file = open(get_unique_filename(filename), mode='a+b')
self.status.started( self.status.started(
resumed_from=self._resumed_from, resumed_from=self._resumed_from,
@ -240,7 +280,7 @@ class Download(object):
) )
stream = RawStream( stream = RawStream(
msg=HTTPResponse(response), msg=HTTPResponse(final_response),
with_headers=False, with_headers=False,
with_body=True, with_body=True,
on_body_chunk_downloaded=self.chunk_downloaded, on_body_chunk_downloaded=self.chunk_downloaded,
@ -268,27 +308,44 @@ class Download(object):
self._progress_reporter.stop() self._progress_reporter.stop()
@property @property
def interrupted(self): def interrupted(self) -> bool:
return ( return (
self.finished self.finished
and self.status.total_size and self.status.total_size
and self.status.total_size != self.status.downloaded and self.status.total_size != self.status.downloaded
) )
def chunk_downloaded(self, chunk): def chunk_downloaded(self, chunk: bytes):
""" """
A download progress callback. A download progress callback.
: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))
@staticmethod
def _get_output_file_from_response(
initial_url: str,
final_response: requests.Response,
) -> IO:
# Output file not specified. Pick a name that doesn't exist yet.
filename = None
if 'Content-Disposition' in final_response.headers:
filename = filename_from_content_disposition(
final_response.headers['Content-Disposition'])
if not filename:
filename = filename_from_url(
url=initial_url,
content_type=final_response.headers.get('Content-Type'),
)
unique_filename = get_unique_filename(filename)
return open(unique_filename, mode='a+b')
class Status(object):
"""Holds details about the downland status.""" class DownloadStatus:
"""Holds details about the download status."""
def __init__(self): def __init__(self):
self.downloaded = 0 self.downloaded = 0
@ -299,8 +356,7 @@ class Status(object):
def started(self, resumed_from=0, total_size=None): def started(self, resumed_from=0, total_size=None):
assert self.time_started is None assert self.time_started is None
if total_size is not None: self.total_size = total_size
self.total_size = total_size
self.downloaded = self.resumed_from = resumed_from self.downloaded = self.resumed_from = resumed_from
self.time_started = time() self.time_started = time()
@ -325,13 +381,15 @@ class ProgressReporterThread(threading.Thread):
Uses threading to periodically update the status (speed, ETA, etc.). Uses threading to periodically update the status (speed, ETA, etc.).
""" """
def __init__(self, status, output, tick=.1, update_interval=1):
"""
:type status: Status def __init__(
:type output: file self,
""" status: DownloadStatus,
super(ProgressReporterThread, self).__init__() output: IO,
tick=.1,
update_interval=1
):
super().__init__()
self.status = status self.status = status
self.output = output self.output = output
self._tick = tick self._tick = tick
@ -410,8 +468,8 @@ class ProgressReporterThread(threading.Thread):
else 0) else 0)
def sum_up(self): def sum_up(self):
actually_downloaded = (self.status.downloaded actually_downloaded = (
- self.status.resumed_from) self.status.downloaded - self.status.resumed_from)
time_taken = self.status.time_finished - self.status.time_started time_taken = self.status.time_finished - self.status.time_started
self.output.write(CLEAR_LINE) self.output.write(CLEAR_LINE)

View File

@ -1,680 +0,0 @@
"""Parsing and processing of CLI input (args, auth credentials, files, stdin).
"""
import os
import sys
import re
import errno
import mimetypes
import getpass
from io import BytesIO
from collections import namedtuple, Iterable
# noinspection PyCompatibility
from argparse import ArgumentParser, ArgumentTypeError, ArgumentError
# TODO: Use MultiDict for headers once added to `requests`.
# https://github.com/jkbrzt/httpie/issues/130
from requests.structures import CaseInsensitiveDict
from httpie.compat import OrderedDict, urlsplit, str, is_pypy, is_py27
from httpie.sessions import VALID_SESSION_NAME_PATTERN
from httpie.utils import load_json_preserve_order
# ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
# <http://tools.ietf.org/html/rfc3986#section-3.1>
URL_SCHEME_RE = re.compile(r'^[a-z][a-z0-9.+-]*://', re.IGNORECASE)
HTTP_POST = 'POST'
HTTP_GET = 'GET'
HTTP = 'http://'
HTTPS = 'https://'
# Various separators used in args
SEP_HEADERS = ':'
SEP_CREDENTIALS = ':'
SEP_PROXY = ':'
SEP_DATA = '='
SEP_DATA_RAW_JSON = ':='
SEP_FILES = '@'
SEP_DATA_EMBED_FILE = '=@'
SEP_DATA_EMBED_RAW_JSON_FILE = ':=@'
SEP_QUERY = '=='
# Separators that become request data
SEP_GROUP_DATA_ITEMS = frozenset([
SEP_DATA,
SEP_DATA_RAW_JSON,
SEP_FILES,
SEP_DATA_EMBED_FILE,
SEP_DATA_EMBED_RAW_JSON_FILE
])
# Separators for items whose value is a filename to be embedded
SEP_GROUP_DATA_EMBED_ITEMS = frozenset([
SEP_DATA_EMBED_FILE,
SEP_DATA_EMBED_RAW_JSON_FILE,
])
# Separators for raw JSON items
SEP_GROUP_RAW_JSON_ITEMS = frozenset([
SEP_DATA_RAW_JSON,
SEP_DATA_EMBED_RAW_JSON_FILE,
])
# Separators allowed in ITEM arguments
SEP_GROUP_ALL_ITEMS = frozenset([
SEP_HEADERS,
SEP_QUERY,
SEP_DATA,
SEP_DATA_RAW_JSON,
SEP_FILES,
SEP_DATA_EMBED_FILE,
SEP_DATA_EMBED_RAW_JSON_FILE,
])
# Output options
OUT_REQ_HEAD = 'H'
OUT_REQ_BODY = 'B'
OUT_RESP_HEAD = 'h'
OUT_RESP_BODY = 'b'
OUTPUT_OPTIONS = frozenset([
OUT_REQ_HEAD,
OUT_REQ_BODY,
OUT_RESP_HEAD,
OUT_RESP_BODY
])
# Pretty
PRETTY_MAP = {
'all': ['format', 'colors'],
'colors': ['colors'],
'format': ['format'],
'none': []
}
PRETTY_STDOUT_TTY_ONLY = object()
# Defaults
OUTPUT_OPTIONS_DEFAULT = OUT_RESP_HEAD + OUT_RESP_BODY
OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED = OUT_RESP_BODY
class Parser(ArgumentParser):
"""Adds additional logic to `argparse.ArgumentParser`.
Handles all input (CLI args, file args, stdin), applies defaults,
and performs extra validation.
"""
def __init__(self, *args, **kwargs):
kwargs['add_help'] = False
super(Parser, self).__init__(*args, **kwargs)
# noinspection PyMethodOverriding
def parse_args(self, env, args=None, namespace=None):
self.env = env
self.args, no_options = super(Parser, self)\
.parse_known_args(args, namespace)
if self.args.debug:
self.args.traceback = True
# Arguments processing and environment setup.
self._apply_no_options(no_options)
self._apply_config()
self._validate_download_options()
self._setup_standard_streams()
self._process_output_options()
self._process_pretty_options()
self._guess_method()
self._parse_items()
if not self.args.ignore_stdin and not env.stdin_isatty:
self._body_from_file(self.env.stdin)
if not URL_SCHEME_RE.match(self.args.url):
scheme = HTTP
# See if we're using curl style shorthand for localhost (:3000/foo)
shorthand = re.match(r'^:(?!:)(\d*)(/?.*)$', self.args.url)
if shorthand:
port = shorthand.group(1)
rest = shorthand.group(2)
self.args.url = scheme + 'localhost'
if port:
self.args.url += ':' + port
self.args.url += rest
else:
self.args.url = scheme + self.args.url
self._process_auth()
return self.args
# noinspection PyShadowingBuiltins
def _print_message(self, message, file=None):
# Sneak in our stderr/stdout.
file = {
sys.stdout: self.env.stdout,
sys.stderr: self.env.stderr,
None: self.env.stderr
}.get(file, file)
if not hasattr(file, 'buffer') and isinstance(message, str):
message = message.encode(self.env.stdout_encoding)
super(Parser, self)._print_message(message, file)
def _setup_standard_streams(self):
"""
Modify `env.stdout` and `env.stdout_isatty` based on args, if needed.
"""
if not self.env.stdout_isatty and self.args.output_file:
self.error('Cannot use --output, -o with redirected output.')
if self.args.download:
# FIXME: Come up with a cleaner solution.
if not self.env.stdout_isatty:
# Use stdout as the download output file.
self.args.output_file = self.env.stdout
# With `--download`, we write everything that would normally go to
# `stdout` to `stderr` instead. Let's replace the stream so that
# we don't have to use many `if`s throughout the codebase.
# The response body will be treated separately.
self.env.stdout = self.env.stderr
self.env.stdout_isatty = self.env.stderr_isatty
elif self.args.output_file:
# When not `--download`ing, then `--output` simply replaces
# `stdout`. The file is opened for appending, which isn't what
# we want in this case.
self.args.output_file.seek(0)
try:
self.args.output_file.truncate()
except IOError as e:
if e.errno == errno.EINVAL:
# E.g. /dev/null on Linux.
pass
else:
raise
self.env.stdout = self.args.output_file
self.env.stdout_isatty = False
def _apply_config(self):
if (not self.args.json
and self.env.config.implicit_content_type == 'form'):
self.args.form = True
def _process_auth(self):
"""
If only a username provided via --auth, then ask for a password.
Or, take credentials from the URL, if provided.
"""
url = urlsplit(self.args.url)
if self.args.auth:
if not self.args.auth.has_password():
# Stdin already read (if not a tty) so it's save to prompt.
if self.args.ignore_stdin:
self.error('Unable to prompt for passwords because'
' --ignore-stdin is set.')
self.args.auth.prompt_password(url.netloc)
elif url.username is not None:
# Handle http://username:password@hostname/
username = url.username
password = url.password or ''
self.args.auth = AuthCredentials(
key=username,
value=password,
sep=SEP_CREDENTIALS,
orig=SEP_CREDENTIALS.join([username, password])
)
def _apply_no_options(self, no_options):
"""For every `--no-OPTION` in `no_options`, set `args.OPTION` to
its default value. This allows for un-setting of options, e.g.,
specified in config.
"""
invalid = []
for option in no_options:
if not option.startswith('--no-'):
invalid.append(option)
continue
# --no-option => --option
inverted = '--' + option[5:]
for action in self._actions:
if inverted in action.option_strings:
setattr(self.args, action.dest, action.default)
break
else:
invalid.append(option)
if invalid:
msg = 'unrecognized arguments: %s'
self.error(msg % ' '.join(invalid))
def _body_from_file(self, fd):
"""There can only be one source of request data.
Bytes are always read.
"""
if self.args.data:
self.error('Request body (from stdin or a file) and request '
'data (key=value) cannot be mixed.')
self.args.data = getattr(fd, 'buffer', fd).read()
def _guess_method(self):
"""Set `args.method` if not specified to either POST or GET
based on whether the request has data or not.
"""
if self.args.method is None:
# Invoked as `http URL'.
assert not self.args.items
if not self.args.ignore_stdin and not self.env.stdin_isatty:
self.args.method = HTTP_POST
else:
self.args.method = HTTP_GET
# FIXME: False positive, e.g., "localhost" matches but is a valid URL.
elif not re.match('^[a-zA-Z]+$', self.args.method):
# Invoked as `http URL item+'. The URL is now in `args.method`
# and the first ITEM is now incorrectly in `args.url`.
try:
# Parse the URL as an ITEM and store it as the first ITEM arg.
self.args.items.insert(0, KeyValueArgType(
*SEP_GROUP_ALL_ITEMS).__call__(self.args.url))
except ArgumentTypeError as e:
if self.args.traceback:
raise
self.error(e.args[0])
else:
# Set the URL correctly
self.args.url = self.args.method
# Infer the method
has_data = (
(not self.args.ignore_stdin and not self.env.stdin_isatty)
or any(item.sep in SEP_GROUP_DATA_ITEMS
for item in self.args.items)
)
self.args.method = HTTP_POST if has_data else HTTP_GET
def _parse_items(self):
"""Parse `args.items` into `args.headers`, `args.data`, `args.params`,
and `args.files`.
"""
try:
items = parse_items(
items=self.args.items,
data_class=ParamsDict if self.args.form else OrderedDict
)
except ParseError as e:
if self.args.traceback:
raise
self.error(e.args[0])
else:
self.args.headers = items.headers
self.args.data = items.data
self.args.files = items.files
self.args.params = items.params
if self.args.files and not self.args.form:
# `http url @/path/to/file`
file_fields = list(self.args.files.keys())
if file_fields != ['']:
self.error(
'Invalid file fields (perhaps you meant --form?): %s'
% ','.join(file_fields))
fn, fd = self.args.files['']
self.args.files = {}
self._body_from_file(fd)
if 'Content-Type' not in self.args.headers:
mime, encoding = mimetypes.guess_type(fn, strict=False)
if mime:
content_type = mime
if encoding:
content_type = '%s; charset=%s' % (mime, encoding)
self.args.headers['Content-Type'] = content_type
def _process_output_options(self):
"""Apply defaults to output options, or validate the provided ones.
The default output options are stdout-type-sensitive.
"""
if not self.args.output_options:
self.args.output_options = (
OUTPUT_OPTIONS_DEFAULT
if self.env.stdout_isatty
else OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED
)
unknown_output_options = set(self.args.output_options) - OUTPUT_OPTIONS
if unknown_output_options:
self.error(
'Unknown output options: %s' % ','.join(unknown_output_options)
)
if self.args.download and OUT_RESP_BODY in self.args.output_options:
# Response body is always downloaded with --download and it goes
# through a different routine, so we remove it.
self.args.output_options = str(
set(self.args.output_options) - set(OUT_RESP_BODY))
def _process_pretty_options(self):
if self.args.prettify == PRETTY_STDOUT_TTY_ONLY:
self.args.prettify = PRETTY_MAP[
'all' if self.env.stdout_isatty else 'none']
elif (self.args.prettify and self.env.is_windows and
self.args.output_file):
self.error('Only terminal output can be colorized on Windows.')
else:
# noinspection PyTypeChecker
self.args.prettify = PRETTY_MAP[self.args.prettify]
def _validate_download_options(self):
if not self.args.download:
if self.args.download_resume:
self.error('--continue only works with --download')
if self.args.download_resume and not (
self.args.download and self.args.output_file):
self.error('--continue requires --output to be specified')
class ParseError(Exception):
pass
class KeyValue(object):
"""Base key-value pair parsed from CLI."""
def __init__(self, key, value, sep, orig):
self.key = key
self.value = value
self.sep = sep
self.orig = orig
def __eq__(self, other):
return self.__dict__ == other.__dict__
def __repr__(self):
return repr(self.__dict__)
class SessionNameValidator(object):
def __init__(self, error_message):
self.error_message = error_message
def __call__(self, value):
# Session name can be a path or just a name.
if (os.path.sep not in value
and not VALID_SESSION_NAME_PATTERN.search(value)):
raise ArgumentError(None, self.error_message)
return value
class KeyValueArgType(object):
"""A key-value pair argument type used with `argparse`.
Parses a key-value arg and constructs a `KeyValue` instance.
Used for headers, form data, and other key-value pair types.
"""
key_value_class = KeyValue
def __init__(self, *separators):
self.separators = separators
self.special_characters = set('\\')
for separator in separators:
self.special_characters.update(separator)
def __call__(self, string):
"""Parse `string` and return `self.key_value_class()` instance.
The best of `self.separators` is determined (first found, longest).
Back slash escaped characters aren't considered as separators
(or parts thereof). Literal back slash characters have to be escaped
as well (r'\\').
"""
class Escaped(str):
"""Represents an escaped character."""
def tokenize(string):
"""Tokenize `string`. There are only two token types - strings
and escaped characters:
tokenize(r'foo\=bar\\baz')
=> ['foo', Escaped('='), 'bar', Escaped('\\'), 'baz']
"""
tokens = ['']
characters = iter(string)
for char in characters:
if char == '\\':
char = next(characters, '')
if char not in self.special_characters:
tokens[-1] += '\\' + char
else:
tokens.extend([Escaped(char), ''])
else:
tokens[-1] += char
return tokens
tokens = tokenize(string)
# Sorting by length ensures that the longest one will be
# chosen as it will overwrite any shorter ones starting
# at the same position in the `found` dictionary.
separators = sorted(self.separators, key=len)
for i, token in enumerate(tokens):
if isinstance(token, Escaped):
continue
found = {}
for sep in separators:
pos = token.find(sep)
if pos != -1:
found[pos] = sep
if found:
# Starting first, longest separator found.
sep = found[min(found.keys())]
key, value = token.split(sep, 1)
# Any preceding tokens are part of the key.
key = ''.join(tokens[:i]) + key
# Any following tokens are part of the value.
value += ''.join(tokens[i + 1:])
break
else:
raise ArgumentTypeError(
u'"%s" is not a valid value' % string)
return self.key_value_class(
key=key, value=value, sep=sep, orig=string)
class AuthCredentials(KeyValue):
"""Represents parsed credentials."""
def _getpass(self, prompt):
# To allow mocking.
return getpass.getpass(str(prompt))
def has_password(self):
return self.value is not None
def prompt_password(self, host):
try:
self.value = self._getpass(
'http: password for %s@%s: ' % (self.key, host))
except (EOFError, KeyboardInterrupt):
sys.stderr.write('\n')
sys.exit(0)
class AuthCredentialsArgType(KeyValueArgType):
"""A key-value arg type that parses credentials."""
key_value_class = AuthCredentials
def __call__(self, string):
"""Parse credentials from `string`.
("username" or "username:password").
"""
try:
return super(AuthCredentialsArgType, self).__call__(string)
except ArgumentTypeError:
# No password provided, will prompt for it later.
return self.key_value_class(
key=string,
value=None,
sep=SEP_CREDENTIALS,
orig=string
)
class RequestItemsDict(OrderedDict):
"""Multi-value dict for URL parameters and form data."""
if is_pypy and is_py27:
# Manually set keys when initialized with an iterable as PyPy
# doesn't call __setitem__ in such case (pypy3 does).
def __init__(self, *args, **kwargs):
if len(args) == 1 and isinstance(args[0], Iterable):
super(RequestItemsDict, self).__init__(**kwargs)
for k, v in args[0]:
self[k] = v
else:
super(RequestItemsDict, self).__init__(*args, **kwargs)
# noinspection PyMethodOverriding
def __setitem__(self, key, value):
""" If `key` is assigned more than once, `self[key]` holds a
`list` of all the values.
This allows having multiple fields with the same name in form
data and URL params.
"""
assert not isinstance(value, list)
if key not in self:
super(RequestItemsDict, self).__setitem__(key, value)
else:
if not isinstance(self[key], list):
super(RequestItemsDict, self).__setitem__(key, [self[key]])
self[key].append(value)
class ParamsDict(RequestItemsDict):
pass
class DataDict(RequestItemsDict):
def items(self):
for key, values in super(RequestItemsDict, self).items():
if not isinstance(values, list):
values = [values]
for value in values:
yield key, value
RequestItems = namedtuple('RequestItems',
['headers', 'data', 'files', 'params'])
def parse_items(items,
headers_class=CaseInsensitiveDict,
data_class=OrderedDict,
files_class=DataDict,
params_class=ParamsDict):
"""Parse `KeyValue` `items` into `data`, `headers`, `files`,
and `params`.
"""
headers = []
data = []
files = []
params = []
for item in items:
value = item.value
if item.sep == SEP_HEADERS:
target = headers
elif item.sep == SEP_QUERY:
target = params
elif item.sep == SEP_FILES:
try:
with open(os.path.expanduser(value), 'rb') as f:
value = (os.path.basename(value),
BytesIO(f.read()))
except IOError as e:
raise ParseError('"%s": %s' % (item.orig, e))
target = files
elif item.sep in SEP_GROUP_DATA_ITEMS:
if item.sep in SEP_GROUP_DATA_EMBED_ITEMS:
try:
with open(os.path.expanduser(value), 'rb') as f:
value = f.read().decode('utf8')
except IOError as e:
raise ParseError('"%s": %s' % (item.orig, e))
except UnicodeDecodeError:
raise ParseError(
'"%s": cannot embed the content of "%s",'
' not a UTF8 or ASCII-encoded text file'
% (item.orig, item.value)
)
if item.sep in SEP_GROUP_RAW_JSON_ITEMS:
try:
value = load_json_preserve_order(value)
except ValueError as e:
raise ParseError('"%s": %s' % (item.orig, e))
target = data
else:
raise TypeError(item)
target.append((item.key, value))
return RequestItems(headers_class(headers),
data_class(data),
files_class(files),
params_class(params))
def readable_file_arg(filename):
try:
open(filename, 'rb')
except IOError as ex:
raise ArgumentTypeError('%s: %s' % (filename, ex.args[1]))
return filename

View File

@ -1,37 +1,38 @@
from httpie.compat import urlsplit, str from typing import Iterable, Optional
from urllib.parse import urlsplit
class HTTPMessage(object): class HTTPMessage:
"""Abstract class for HTTP messages.""" """Abstract class for HTTP messages."""
def __init__(self, orig): def __init__(self, orig):
self._orig = orig self._orig = orig
def iter_body(self, chunk_size): def iter_body(self, chunk_size: int) -> Iterable[bytes]:
"""Return an iterator over the body.""" """Return an iterator over the body."""
raise NotImplementedError() raise NotImplementedError()
def iter_lines(self, chunk_size): def iter_lines(self, chunk_size: int) -> Iterable[bytes]:
"""Return an iterator over the body yielding (`line`, `line_feed`).""" """Return an iterator over the body yielding (`line`, `line_feed`)."""
raise NotImplementedError() raise NotImplementedError()
@property @property
def headers(self): def headers(self) -> str:
"""Return a `str` with the message's headers.""" """Return a `str` with the message's headers."""
raise NotImplementedError() raise NotImplementedError()
@property @property
def encoding(self): def encoding(self) -> Optional[str]:
"""Return a `str` with the message's encoding, if known.""" """Return a `str` with the message's encoding, if known."""
raise NotImplementedError() raise NotImplementedError()
@property @property
def body(self): def body(self) -> bytes:
"""Return a `bytes` with the message's body.""" """Return a `bytes` with the message's body."""
raise NotImplementedError() raise NotImplementedError()
@property @property
def content_type(self): def content_type(self) -> str:
"""Return the message content type.""" """Return the message content type."""
ct = self._orig.headers.get('Content-Type', '') ct = self._orig.headers.get('Content-Type', '')
if not isinstance(ct, str): if not isinstance(ct, str):
@ -48,7 +49,7 @@ class HTTPResponse(HTTPMessage):
def iter_lines(self, chunk_size): def iter_lines(self, chunk_size):
return ((line, b'\n') for line in self._orig.iter_lines(chunk_size)) return ((line, b'\n') for line in self._orig.iter_lines(chunk_size))
#noinspection PyProtectedMember # noinspection PyProtectedMember
@property @property
def headers(self): def headers(self):
original = self._orig.raw._original_response original = self._orig.raw._original_response
@ -60,11 +61,7 @@ class HTTPResponse(HTTPMessage):
20: '2', 20: '2',
}[original.version] }[original.version]
status_line = 'HTTP/{version} {status} {reason}'.format( status_line = f'HTTP/{version} {original.status} {original.reason}'
version=version,
status=original.status,
reason=original.reason
)
headers = [status_line] headers = [status_line]
try: try:
# `original.msg` is a `http.client.HTTPMessage` on Python 3 # `original.msg` is a `http.client.HTTPMessage` on Python 3

View File

@ -1,20 +1,36 @@
from __future__ import absolute_import
import json
from typing import Optional, Type
import pygments.lexer import pygments.lexer
import pygments.token
import pygments.styles
import pygments.lexers import pygments.lexers
import pygments.style import pygments.style
import pygments.styles
import pygments.token
from pygments.formatters.terminal import TerminalFormatter from pygments.formatters.terminal import TerminalFormatter
from pygments.formatters.terminal256 import Terminal256Formatter from pygments.formatters.terminal256 import Terminal256Formatter
from pygments.lexer import Lexer
from pygments.lexers.special import TextLexer
from pygments.lexers.text import HttpLexer as PygmentsHttpLexer
from pygments.util import ClassNotFound from pygments.util import ClassNotFound
from httpie.compat import is_windows
from httpie.context import Environment
from httpie.plugins import FormatterPlugin from httpie.plugins import FormatterPlugin
# Colors on Windows via colorama don't look that AUTO_STYLE = 'auto' # Follows terminal ANSI color styles
# great and fruity seems to give the best result there. DEFAULT_STYLE = AUTO_STYLE
AVAILABLE_STYLES = set(pygments.styles.STYLE_MAP.keys()) SOLARIZED_STYLE = 'solarized' # Bundled here
AVAILABLE_STYLES.add('solarized') if is_windows:
DEFAULT_STYLE = 'monokai' # Colors on Windows via colorama don't look that
# great and fruity seems to give the best result there.
DEFAULT_STYLE = 'fruity'
AVAILABLE_STYLES = set(pygments.styles.get_all_styles())
AVAILABLE_STYLES.add(SOLARIZED_STYLE)
AVAILABLE_STYLES.add(AUTO_STYLE)
class ColorFormatter(FormatterPlugin): class ColorFormatter(FormatterPlugin):
@ -27,58 +43,93 @@ class ColorFormatter(FormatterPlugin):
""" """
group_name = 'colors' group_name = 'colors'
def __init__(self, env, color_scheme=DEFAULT_STYLE, **kwargs): def __init__(
super(ColorFormatter, self).__init__(**kwargs) self,
env: Environment,
explicit_json=False,
color_scheme=DEFAULT_STYLE,
**kwargs
):
super().__init__(**kwargs)
if not env.colors: if not env.colors:
self.enabled = False self.enabled = False
return return
# Cache to speed things up when we process streamed body by line. use_auto_style = color_scheme == AUTO_STYLE
self.lexer_cache = {} has_256_colors = env.colors == 256
if use_auto_style or not has_256_colors:
try: http_lexer = PygmentsHttpLexer()
style_class = pygments.styles.get_style_by_name(color_scheme) formatter = TerminalFormatter()
except ClassNotFound:
style_class = Solarized256Style
if env.colors == 256:
fmt_class = Terminal256Formatter
else: else:
fmt_class = TerminalFormatter http_lexer = SimplifiedHTTPLexer()
self.formatter = fmt_class(style=style_class) formatter = Terminal256Formatter(
style=self.get_style_class(color_scheme)
)
def format_headers(self, headers): self.explicit_json = explicit_json # --json
return pygments.highlight(headers, HTTPLexer(), self.formatter).strip() self.formatter = formatter
self.http_lexer = http_lexer
def format_body(self, body, mime): def format_headers(self, headers: str) -> str:
lexer = self.get_lexer(mime) return pygments.highlight(
code=headers,
lexer=self.http_lexer,
formatter=self.formatter,
).strip()
def format_body(self, body: str, mime: str) -> str:
lexer = self.get_lexer_for_body(mime, body)
if lexer: if lexer:
body = pygments.highlight(body, lexer, self.formatter) body = pygments.highlight(
code=body,
lexer=lexer,
formatter=self.formatter,
)
return body.strip() return body.strip()
def get_lexer(self, mime): def get_lexer_for_body(
if mime in self.lexer_cache: self, mime: str,
return self.lexer_cache[mime] body: str
self.lexer_cache[mime] = get_lexer(mime) ) -> Optional[Type[Lexer]]:
return self.lexer_cache[mime] return get_lexer(
mime=mime,
explicit_json=self.explicit_json,
body=body,
)
@staticmethod
def get_style_class(color_scheme: str) -> Type[pygments.style.Style]:
try:
return pygments.styles.get_style_by_name(color_scheme)
except ClassNotFound:
return Solarized256Style
def get_lexer(mime): def get_lexer(
mime: str,
explicit_json=False,
body=''
) -> Optional[Type[Lexer]]:
# Build candidate mime type and lexer names.
mime_types, lexer_names = [mime], [] mime_types, lexer_names = [mime], []
type_, subtype = mime.split('/') type_, subtype = mime.split('/', 1)
if '+' not in subtype: if '+' not in subtype:
lexer_names.append(subtype) lexer_names.append(subtype)
else: else:
subtype_name, subtype_suffix = subtype.split('+') subtype_name, subtype_suffix = subtype.split('+', 1)
lexer_names.extend([subtype_name, subtype_suffix]) lexer_names.extend([subtype_name, subtype_suffix])
mime_types.extend([ mime_types.extend([
'%s/%s' % (type_, subtype_name), '%s/%s' % (type_, subtype_name),
'%s/%s' % (type_, subtype_suffix) '%s/%s' % (type_, subtype_suffix)
]) ])
# as a last resort, if no lexer feels responsible, and
# As a last resort, if no lexer feels responsible, and
# the subtype contains 'json', take the JSON lexer # the subtype contains 'json', take the JSON lexer
if 'json' in subtype: if 'json' in subtype:
lexer_names.append('json') lexer_names.append('json')
# Try to resolve the right lexer.
lexer = None lexer = None
for mime_type in mime_types: for mime_type in mime_types:
try: try:
@ -92,10 +143,20 @@ def get_lexer(mime):
lexer = pygments.lexers.get_lexer_by_name(name) lexer = pygments.lexers.get_lexer_by_name(name)
except ClassNotFound: except ClassNotFound:
pass pass
if explicit_json and body and (not lexer or isinstance(lexer, TextLexer)):
# JSON response with an incorrect Content-Type?
try:
json.loads(body) # FIXME: the body also gets parsed in json.py
except ValueError:
pass # Nope
else:
lexer = pygments.lexers.get_lexer_by_name('json')
return lexer return lexer
class HTTPLexer(pygments.lexer.RegexLexer): class SimplifiedHTTPLexer(pygments.lexer.RegexLexer):
"""Simplified HTTP lexer for Pygments. """Simplified HTTP lexer for Pygments.
It only operates on headers and provides a stronger contrast between It only operates on headers and provides a stronger contrast between
@ -143,8 +204,6 @@ class HTTPLexer(pygments.lexer.RegexLexer):
} }
# TODO: As Solarized is not the default theme any longer, it should be removed
# or bundled directly with Pygments so that we don't need to support it.
class Solarized256Style(pygments.style.Style): class Solarized256Style(pygments.style.Style):
""" """
solarized256 solarized256

View File

@ -3,7 +3,11 @@ from httpie.plugins import FormatterPlugin
class HeadersFormatter(FormatterPlugin): class HeadersFormatter(FormatterPlugin):
def format_headers(self, headers): 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 Sorts headers by name while retaining relative
order of multiple headers with the same name. order of multiple headers with the same name.

View File

@ -4,23 +4,31 @@ import json
from httpie.plugins import FormatterPlugin from httpie.plugins import FormatterPlugin
DEFAULT_INDENT = 4
class JSONFormatter(FormatterPlugin): class JSONFormatter(FormatterPlugin):
def format_body(self, body, mime): def __init__(self, **kwargs):
if 'json' in mime: super().__init__(**kwargs)
self.enabled = self.format_options['json']['format']
def format_body(self, body: str, mime: str) -> str:
maybe_json = [
'json',
'javascript',
'text',
]
if (self.kwargs['explicit_json']
or any(token in mime for token in maybe_json)):
try: try:
obj = json.loads(body) obj = json.loads(body)
except ValueError: except ValueError:
# Invalid JSON, ignore. pass # Invalid JSON, ignore.
pass
else: else:
# Indent, sort keys by name, and avoid # Indent, sort keys by name, and avoid
# unicode escapes to improve readability. # unicode escapes to improve readability.
body = json.dumps(obj, body = json.dumps(
sort_keys=True, obj=obj,
ensure_ascii=False, sort_keys=self.format_options['json']['sort_keys'],
indent=DEFAULT_INDENT) ensure_ascii=False,
indent=self.format_options['json']['indent']
)
return body return body

View File

@ -1,61 +0,0 @@
from __future__ import absolute_import
import re
from xml.etree import ElementTree
from httpie.plugins import FormatterPlugin
DECLARATION_RE = re.compile('<\?xml[^\n]+?\?>', flags=re.I)
DOCTYPE_RE = re.compile('<!DOCTYPE[^\n]+?>', flags=re.I)
DEFAULT_INDENT = 4
def indent(elem, indent_text=' ' * DEFAULT_INDENT):
"""
In-place prettyprint formatter
C.f. http://effbot.org/zone/element-lib.htm#prettyprint
"""
def _indent(elem, level=0):
i = "\n" + level * indent_text
if len(elem):
if not elem.text or not elem.text.strip():
elem.text = i + indent_text
if not elem.tail or not elem.tail.strip():
elem.tail = i
for elem in elem:
_indent(elem, level + 1)
if not elem.tail or not elem.tail.strip():
elem.tail = i
else:
if level and (not elem.tail or not elem.tail.strip()):
elem.tail = i
return _indent(elem)
class XMLFormatter(FormatterPlugin):
# TODO: tests
def format_body(self, body, mime):
if 'xml' in mime:
# FIXME: orig NS names get forgotten during the conversion, etc.
try:
root = ElementTree.fromstring(body.encode('utf8'))
except ElementTree.ParseError:
# Ignore invalid XML errors (skips attempting to pretty print)
pass
else:
indent(root)
# Use the original declaration
declaration = DECLARATION_RE.match(body)
doctype = DOCTYPE_RE.match(body)
body = ElementTree.tostring(root, encoding='utf-8')\
.decode('utf8')
if doctype:
body = '%s\n%s' % (doctype.group(0), body)
if declaration:
body = '%s\n%s' % (declaration.group(0), body)
return body

View File

@ -1,6 +1,8 @@
import re import re
from typing import Optional, List
from httpie.plugins import plugin_manager from httpie.plugins import ConverterPlugin
from httpie.plugins.registry import plugin_manager
from httpie.context import Environment from httpie.context import Environment
@ -11,19 +13,20 @@ def is_valid_mime(mime):
return mime and MIME_RE.match(mime) return mime and MIME_RE.match(mime)
class Conversion(object): class Conversion:
def get_converter(self, mime): @staticmethod
def get_converter(mime: str) -> Optional[ConverterPlugin]:
if is_valid_mime(mime): if is_valid_mime(mime):
for converter_class in plugin_manager.get_converters(): for converter_class in plugin_manager.get_converters():
if converter_class.supports(mime): if converter_class.supports(mime):
return converter_class(mime) return converter_class(mime)
class Formatting(object): class Formatting:
"""A delegate class that invokes the actual processors.""" """A delegate class that invokes the actual processors."""
def __init__(self, groups, env=Environment(), **kwargs): def __init__(self, groups: List[str], env=Environment(), **kwargs):
""" """
:param groups: names of processor groups to be applied :param groups: names of processor groups to be applied
:param env: Environment :param env: Environment
@ -38,12 +41,12 @@ class Formatting(object):
if p.enabled: if p.enabled:
self.enabled_plugins.append(p) self.enabled_plugins.append(p)
def format_headers(self, headers): def format_headers(self, headers: str) -> str:
for p in self.enabled_plugins: for p in self.enabled_plugins:
headers = p.format_headers(headers) headers = p.format_headers(headers)
return headers return headers
def format_body(self, content, mime): def format_body(self, content: str, mime: str) -> str:
if is_valid_mime(mime): if is_valid_mime(mime):
for p in self.enabled_plugins: for p in self.enabled_plugins:
content = p.format_body(content, mime) content = p.format_body(content, mime)

View File

@ -1,12 +1,9 @@
from itertools import chain from itertools import chain
from functools import partial from typing import Callable, Iterable, Union
from httpie.compat import str
from httpie.context import Environment from httpie.context import Environment
from httpie.models import HTTPRequest, HTTPResponse from httpie.models import HTTPMessage
from httpie.input import (OUT_REQ_BODY, OUT_REQ_HEAD, from httpie.output.processing import Conversion, Formatting
OUT_RESP_HEAD, OUT_RESP_BODY)
from httpie.output.processing import Formatting, Conversion
BINARY_SUPPRESSED_NOTICE = ( BINARY_SUPPRESSED_NOTICE = (
@ -24,108 +21,16 @@ class BinarySuppressedError(Exception):
message = BINARY_SUPPRESSED_NOTICE message = BINARY_SUPPRESSED_NOTICE
def write(stream, outfile, flush): class BaseStream:
"""Write the output stream."""
try:
# Writing bytes so we use the buffer interface (Python 3).
buf = outfile.buffer
except AttributeError:
buf = outfile
for chunk in stream:
buf.write(chunk)
if flush:
outfile.flush()
def write_with_colors_win_py3(stream, outfile, flush):
"""Like `write`, but colorized chunks are written as text
directly to `outfile` to ensure it gets processed by colorama.
Applies only to Windows with Python 3 and colorized terminal output.
"""
color = b'\x1b['
encoding = outfile.encoding
for chunk in stream:
if color in chunk:
outfile.write(chunk.decode(encoding))
else:
outfile.buffer.write(chunk)
if flush:
outfile.flush()
def build_output_stream(args, env, request, response):
"""Build and return a chain of iterators over the `request`-`response`
exchange each of which yields `bytes` chunks.
"""
req_h = OUT_REQ_HEAD in args.output_options
req_b = OUT_REQ_BODY in args.output_options
resp_h = OUT_RESP_HEAD in args.output_options
resp_b = OUT_RESP_BODY in args.output_options
req = req_h or req_b
resp = resp_h or resp_b
output = []
Stream = get_stream_type(env, args)
if req:
output.append(Stream(
msg=HTTPRequest(request),
with_headers=req_h,
with_body=req_b))
if req_b and resp:
# Request/Response separator.
output.append([b'\n\n'])
if resp:
output.append(Stream(
msg=HTTPResponse(response),
with_headers=resp_h,
with_body=resp_b))
if env.stdout_isatty and resp_b:
# Ensure a blank line after the response body.
# For terminal output only.
output.append([b'\n\n'])
return chain(*output)
def get_stream_type(env, args):
"""Pick the right stream type based on `env` and `args`.
Wrap it in a partial with the type-specific args so that
we don't need to think what stream we are dealing with.
"""
if not env.stdout_isatty and not args.prettify:
Stream = partial(
RawStream,
chunk_size=RawStream.CHUNK_SIZE_BY_LINE
if args.stream
else RawStream.CHUNK_SIZE
)
elif args.prettify:
Stream = partial(
PrettyStream if args.stream else BufferedPrettyStream,
env=env,
conversion=Conversion(),
formatting=Formatting(env=env, groups=args.prettify,
color_scheme=args.style),
)
else:
Stream = partial(EncodedStream, env=env)
return Stream
class BaseStream(object):
"""Base HTTP message output stream class.""" """Base HTTP message output stream class."""
def __init__(self, msg, with_headers=True, with_body=True, def __init__(
on_body_chunk_downloaded=None): self,
msg: HTTPMessage,
with_headers=True,
with_body=True,
on_body_chunk_downloaded: Callable[[bytes], None] = None
):
""" """
:param msg: a :class:`models.HTTPMessage` subclass :param msg: a :class:`models.HTTPMessage` subclass
:param with_headers: if `True`, headers will be included :param with_headers: if `True`, headers will be included
@ -138,15 +43,15 @@ class BaseStream(object):
self.with_body = with_body self.with_body = with_body
self.on_body_chunk_downloaded = on_body_chunk_downloaded self.on_body_chunk_downloaded = on_body_chunk_downloaded
def get_headers(self): def get_headers(self) -> bytes:
"""Return the headers' bytes.""" """Return the headers' bytes."""
return self.msg.headers.encode('utf8') return self.msg.headers.encode('utf8')
def iter_body(self): def iter_body(self) -> Iterable[bytes]:
"""Return an iterator over the message body.""" """Return an iterator over the message body."""
raise NotImplementedError() raise NotImplementedError()
def __iter__(self): def __iter__(self) -> Iterable[bytes]:
"""Return an iterator over `self.msg`.""" """Return an iterator over `self.msg`."""
if self.with_headers: if self.with_headers:
yield self.get_headers() yield self.get_headers()
@ -171,10 +76,10 @@ class RawStream(BaseStream):
CHUNK_SIZE_BY_LINE = 1 CHUNK_SIZE_BY_LINE = 1
def __init__(self, chunk_size=CHUNK_SIZE, **kwargs): def __init__(self, chunk_size=CHUNK_SIZE, **kwargs):
super(RawStream, self).__init__(**kwargs) super().__init__(**kwargs)
self.chunk_size = chunk_size self.chunk_size = chunk_size
def iter_body(self): def iter_body(self) -> Iterable[bytes]:
return self.msg.iter_body(self.chunk_size) return self.msg.iter_body(self.chunk_size)
@ -189,26 +94,20 @@ class EncodedStream(BaseStream):
CHUNK_SIZE = 1 CHUNK_SIZE = 1
def __init__(self, env=Environment(), **kwargs): def __init__(self, env=Environment(), **kwargs):
super().__init__(**kwargs)
super(EncodedStream, self).__init__(**kwargs)
if env.stdout_isatty: if env.stdout_isatty:
# Use the encoding supported by the terminal. # Use the encoding supported by the terminal.
output_encoding = env.stdout_encoding output_encoding = env.stdout_encoding
else: else:
# Preserve the message encoding. # Preserve the message encoding.
output_encoding = self.msg.encoding output_encoding = self.msg.encoding
# Default to utf8 when unsure. # Default to utf8 when unsure.
self.output_encoding = output_encoding or 'utf8' self.output_encoding = output_encoding or 'utf8'
def iter_body(self): def iter_body(self) -> Iterable[bytes]:
for line, lf in self.msg.iter_lines(self.CHUNK_SIZE): for line, lf in self.msg.iter_lines(self.CHUNK_SIZE):
if b'\0' in line: if b'\0' in line:
raise BinarySuppressedError() raise BinarySuppressedError()
yield line.decode(self.msg.encoding) \ yield line.decode(self.msg.encoding) \
.encode(self.output_encoding, 'replace') + lf .encode(self.output_encoding, 'replace') + lf
@ -224,17 +123,21 @@ class PrettyStream(EncodedStream):
CHUNK_SIZE = 1 CHUNK_SIZE = 1
def __init__(self, conversion, formatting, **kwargs): def __init__(
super(PrettyStream, self).__init__(**kwargs) self, conversion: Conversion,
formatting: Formatting,
**kwargs,
):
super().__init__(**kwargs)
self.formatting = formatting self.formatting = formatting
self.conversion = conversion self.conversion = conversion
self.mime = self.msg.content_type.split(';')[0] self.mime = self.msg.content_type.split(';')[0]
def get_headers(self): def get_headers(self) -> bytes:
return self.formatting.format_headers( return self.formatting.format_headers(
self.msg.headers).encode(self.output_encoding) self.msg.headers).encode(self.output_encoding)
def iter_body(self): def iter_body(self) -> Iterable[bytes]:
first_chunk = True first_chunk = True
iter_lines = self.msg.iter_lines(self.CHUNK_SIZE) iter_lines = self.msg.iter_lines(self.CHUNK_SIZE)
for line, lf in iter_lines: for line, lf in iter_lines:
@ -255,7 +158,7 @@ class PrettyStream(EncodedStream):
yield self.process_body(line) + lf yield self.process_body(line) + lf
first_chunk = False first_chunk = False
def process_body(self, chunk): def process_body(self, chunk: Union[str, bytes]) -> bytes:
if not isinstance(chunk, str): if not isinstance(chunk, str):
# Text when a converter has been used, # Text when a converter has been used,
# otherwise it will always be bytes. # otherwise it will always be bytes.
@ -274,7 +177,7 @@ class BufferedPrettyStream(PrettyStream):
CHUNK_SIZE = 1024 * 10 CHUNK_SIZE = 1024 * 10
def iter_body(self): def iter_body(self) -> Iterable[bytes]:
# Read the whole body before prettifying it, # Read the whole body before prettifying it,
# but bail out immediately if the body is binary. # but bail out immediately if the body is binary.
converter = None converter = None

164
httpie/output/writer.py Normal file
View File

@ -0,0 +1,164 @@
import argparse
import errno
from typing import Union, IO, TextIO, Tuple, Type
import requests
from httpie.context import Environment
from httpie.models import HTTPRequest, HTTPResponse
from httpie.output.processing import Conversion, Formatting
from httpie.output.streams import (
RawStream, PrettyStream,
BufferedPrettyStream, EncodedStream,
BaseStream,
)
from httpie.cli.constants import (
OUT_REQ_BODY, OUT_REQ_HEAD, OUT_RESP_BODY, OUT_RESP_HEAD,
)
def write_message(
requests_message: Union[requests.PreparedRequest, requests.Response],
env: Environment,
args: argparse.Namespace,
):
output_options_by_message_type = {
requests.PreparedRequest: {
'with_headers': OUT_REQ_HEAD in args.output_options,
'with_body': OUT_REQ_BODY in args.output_options,
},
requests.Response: {
'with_headers': OUT_RESP_HEAD in args.output_options,
'with_body': OUT_RESP_BODY in args.output_options,
},
}
output_options = output_options_by_message_type[type(requests_message)]
if not any(output_options.values()):
return
write_stream_kwargs = {
'stream': build_output_stream_for_message(
args=args,
env=env,
requests_message=requests_message,
**output_options,
),
# NOTE: `env.stdout` will in fact be `stderr` with `--download`
'outfile': env.stdout,
'flush': env.stdout_isatty or args.stream
}
try:
if env.is_windows and 'colors' in args.prettify:
write_stream_with_colors_win_py3(**write_stream_kwargs)
else:
write_stream(**write_stream_kwargs)
except IOError as e:
show_traceback = args.debug or args.traceback
if not show_traceback and e.errno == errno.EPIPE:
# Ignore broken pipes unless --traceback.
env.stderr.write('\n')
else:
raise
def write_stream(
stream: BaseStream,
outfile: Union[IO, TextIO],
flush: bool
):
"""Write the output stream."""
try:
# Writing bytes so we use the buffer interface (Python 3).
buf = outfile.buffer
except AttributeError:
buf = outfile
for chunk in stream:
buf.write(chunk)
if flush:
outfile.flush()
def write_stream_with_colors_win_py3(
stream: 'BaseStream',
outfile: TextIO,
flush: bool
):
"""Like `write`, but colorized chunks are written as text
directly to `outfile` to ensure it gets processed by colorama.
Applies only to Windows with Python 3 and colorized terminal output.
"""
color = b'\x1b['
encoding = outfile.encoding
for chunk in stream:
if color in chunk:
outfile.write(chunk.decode(encoding))
else:
outfile.buffer.write(chunk)
if flush:
outfile.flush()
def build_output_stream_for_message(
args: argparse.Namespace,
env: Environment,
requests_message: Union[requests.PreparedRequest, requests.Response],
with_headers: bool,
with_body: bool,
):
stream_class, stream_kwargs = get_stream_type_and_kwargs(
env=env,
args=args,
)
message_class = {
requests.PreparedRequest: HTTPRequest,
requests.Response: HTTPResponse,
}[type(requests_message)]
yield from stream_class(
msg=message_class(requests_message),
with_headers=with_headers,
with_body=with_body,
**stream_kwargs,
)
if env.stdout_isatty and with_body:
# Ensure a blank line after the response body.
# For terminal output only.
yield b'\n\n'
def get_stream_type_and_kwargs(
env: Environment,
args: argparse.Namespace
) -> Tuple[Type['BaseStream'], dict]:
"""Pick the right stream type and kwargs for it based on `env` and `args`.
"""
if not env.stdout_isatty and not args.prettify:
stream_class = RawStream
stream_kwargs = {
'chunk_size': (
RawStream.CHUNK_SIZE_BY_LINE
if args.stream
else RawStream.CHUNK_SIZE
)
}
elif args.prettify:
stream_class = PrettyStream if args.stream else BufferedPrettyStream
stream_kwargs = {
'env': env,
'conversion': Conversion(),
'formatting': Formatting(
env=env,
groups=args.prettify,
color_scheme=args.style,
explicit_json=args.json,
format_options=args.format_options,
)
}
else:
stream_class = EncodedStream
stream_kwargs = {
'env': env
}
return stream_class, stream_kwargs

View File

@ -1,24 +1,9 @@
""" """
WARNING: The plugin API is still work in progress and will WARNING: The plugin API is still work in progress and will
probably be completely reworked by v1.0.0. probably be completely reworked in the future.
""" """
from httpie.plugins.base import ( 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.xml import XMLFormatter
from httpie.output.formatters.colors import ColorFormatter
plugin_manager = PluginManager()
plugin_manager.register(BasicAuthPlugin,
DigestAuthPlugin)
plugin_manager.register(HeadersFormatter,
JSONFormatter,
XMLFormatter,
ColorFormatter)

View File

@ -1,9 +1,9 @@
class BasePlugin(object): 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,15 +15,52 @@ class AuthPlugin(BasePlugin):
""" """
Base auth plugin class. Base auth plugin class.
See <https://github.com/jkbrzt/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`
""" """
# The value that should be passed to --auth-type # The value that should be passed to --auth-type
# to use this auth plugin. Eg. "my-auth" # to use this auth plugin. Eg. "my-auth"
auth_type = None auth_type = None
def get_auth(self, username, password): # Set to `False` to make it possible to invoke this auth
# plugin without requiring the user to specify credentials
# through `--auth, -a`.
auth_require = True
# By default the `-a` argument is parsed for `username:password`.
# 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. If the credentials came from a netrc file,
# then this is `None`.
raw_auth = None
def get_auth(self, username=None, password=None):
""" """
If `auth_parse` is set to `True`, then `username`
and `password` contain the parsed credentials.
Use `self.raw_auth` to access the raw value passed through
`--auth, -a`.
Return a ``requests.auth.AuthBase`` subclass instance. Return a ``requests.auth.AuthBase`` subclass instance.
""" """
@ -32,8 +69,13 @@ class AuthPlugin(BasePlugin):
class TransportPlugin(BasePlugin): class TransportPlugin(BasePlugin):
""" """
Requests transport adapter docs:
http://docs.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>
""" """
@ -49,7 +91,15 @@ class TransportPlugin(BasePlugin):
raise NotImplementedError() raise NotImplementedError()
class ConverterPlugin(object): 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
@ -62,19 +112,25 @@ class ConverterPlugin(object):
raise NotImplementedError raise NotImplementedError
class FormatterPlugin(object): class FormatterPlugin(BasePlugin):
"""
Possibly formats response body & headers for prettified terminal display.
"""
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): def format_headers(self, headers: str) -> str:
"""Return processed `headers` """Return processed `headers`
:param headers: The headers as text. :param headers: The headers as text.
@ -82,7 +138,7 @@ class FormatterPlugin(object):
""" """
return headers return headers
def format_body(self, content, mime): def format_body(self, content: str, mime: str) -> str:
"""Return processed `content`. """Return processed `content`.
:param mime: E.g., 'application/atom+xml'. :param mime: E.g., 'application/atom+xml'.

View File

@ -5,44 +5,54 @@ import requests.auth
from httpie.plugins.base import AuthPlugin from httpie.plugins.base import AuthPlugin
# noinspection PyAbstractClass
class BuiltinAuthPlugin(AuthPlugin): class BuiltinAuthPlugin(AuthPlugin):
package_name = '(builtin)' package_name = '(builtin)'
class HTTPBasicAuth(requests.auth.HTTPBasicAuth): class HTTPBasicAuth(requests.auth.HTTPBasicAuth):
def __call__(self, r): def __call__(
self,
request: requests.PreparedRequest
) -> requests.PreparedRequest:
""" """
Override username/password serialization to allow unicode. Override username/password serialization to allow unicode.
See https://github.com/jkbrzt/httpie/issues/212 See https://github.com/jakubroztocil/httpie/issues/212
""" """
r.headers['Authorization'] = type(self).make_header( # noinspection PyTypeChecker
request.headers['Authorization'] = type(self).make_header(
self.username, self.password).encode('latin1') self.username, self.password).encode('latin1')
return r return request
@staticmethod @staticmethod
def make_header(username, password): def make_header(username: str, password: str) -> str:
credentials = u'%s:%s' % (username, password) credentials = u'%s:%s' % (username, password)
token = b64encode(credentials.encode('utf8')).strip().decode('latin1') token = b64encode(credentials.encode('utf8')).strip().decode('latin1')
return 'Basic %s' % token return 'Basic %s' % token
class BasicAuthPlugin(BuiltinAuthPlugin): class BasicAuthPlugin(BuiltinAuthPlugin):
name = 'Basic HTTP auth' name = 'Basic HTTP auth'
auth_type = 'basic' auth_type = 'basic'
netrc_parse = True
def get_auth(self, username, password): # noinspection PyMethodOverriding
def get_auth(self, username: str, password: str) -> HTTPBasicAuth:
return HTTPBasicAuth(username, password) return HTTPBasicAuth(username, password)
class DigestAuthPlugin(BuiltinAuthPlugin): class DigestAuthPlugin(BuiltinAuthPlugin):
name = 'Digest HTTP auth' name = 'Digest HTTP auth'
auth_type = 'digest' auth_type = 'digest'
netrc_parse = True
def get_auth(self, username, password): # noinspection PyMethodOverriding
def get_auth(
self,
username: str,
password: str
) -> requests.auth.HTTPDigestAuth:
return requests.auth.HTTPDigestAuth(username, password) return requests.auth.HTTPDigestAuth(username, password)

View File

@ -1,7 +1,11 @@
from itertools import groupby from itertools import groupby
from operator import attrgetter
from typing import Dict, List, Type
from pkg_resources import iter_entry_points from pkg_resources import iter_entry_points
from httpie.plugins import AuthPlugin, FormatterPlugin, ConverterPlugin
from httpie.plugins.base import TransportPlugin from httpie.plugins import AuthPlugin, ConverterPlugin, FormatterPlugin
from httpie.plugins.base import BasePlugin, TransportPlugin
ENTRY_POINT_NAMES = [ ENTRY_POINT_NAMES = [
@ -12,17 +16,17 @@ ENTRY_POINT_NAMES = [
] ]
class PluginManager(object): class PluginManager(list):
def __init__(self): def register(self, *plugins: Type[BasePlugin]):
self._plugins = []
def __iter__(self):
return iter(self._plugins)
def register(self, *plugins):
for plugin in plugins: for plugin in plugins:
self._plugins.append(plugin) self.append(plugin)
def unregister(self, plugin: Type[BasePlugin]):
self.remove(plugin)
def filter(self, by_type=Type[BasePlugin]):
return [plugin for plugin in self if issubclass(plugin, by_type)]
def load_installed_plugins(self): def load_installed_plugins(self):
for entry_point_name in ENTRY_POINT_NAMES: for entry_point_name in ENTRY_POINT_NAMES:
@ -32,34 +36,34 @@ class PluginManager(object):
self.register(entry_point.load()) self.register(entry_point.load())
# Auth # Auth
def get_auth_plugins(self): def get_auth_plugins(self) -> List[Type[AuthPlugin]]:
return [plugin for plugin in self if issubclass(plugin, AuthPlugin)] return self.filter(AuthPlugin)
def get_auth_plugin_mapping(self): def get_auth_plugin_mapping(self) -> Dict[str, Type[AuthPlugin]]:
return dict((plugin.auth_type, plugin) return {
for plugin in self.get_auth_plugins()) plugin.auth_type: plugin for plugin in self.get_auth_plugins()
}
def get_auth_plugin(self, auth_type): def get_auth_plugin(self, auth_type: str) -> Type[AuthPlugin]:
return self.get_auth_plugin_mapping()[auth_type] return self.get_auth_plugin_mapping()[auth_type]
# Output processing # Output processing
def get_formatters(self): def get_formatters(self) -> List[Type[FormatterPlugin]]:
return [plugin for plugin in self return self.filter(FormatterPlugin)
if issubclass(plugin, FormatterPlugin)]
def get_formatters_grouped(self): def get_formatters_grouped(self) -> Dict[str, List[Type[FormatterPlugin]]]:
groups = {} return {
for group_name, group in groupby( group_name: list(group)
self.get_formatters(), for group_name, group
key=lambda p: getattr(p, 'group_name', 'format')): in groupby(self.get_formatters(), key=attrgetter('group_name'))
groups[group_name] = list(group) }
return groups
def get_converters(self): def get_converters(self) -> List[Type[ConverterPlugin]]:
return [plugin for plugin in self return self.filter(ConverterPlugin)
if issubclass(plugin, ConverterPlugin)]
# Adapters # Adapters
def get_transport_plugins(self): def get_transport_plugins(self) -> List[Type[TransportPlugin]]:
return [plugin for plugin in self return self.filter(TransportPlugin)
if issubclass(plugin, TransportPlugin)]
def __repr__(self):
return f'<PluginManager: {list(self)}>'

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

@ -1,86 +1,60 @@
"""Persistent, JSON-serialized sessions. """
Persistent, JSON-serialized sessions.
""" """
import re
import os import os
import re
from pathlib import Path
from typing import Iterable, Optional, Union
from urllib.parse import urlsplit
from requests.auth import AuthBase
from requests.cookies import RequestsCookieJar, create_cookie from requests.cookies import RequestsCookieJar, create_cookie
from httpie.compat import urlsplit 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'
DEFAULT_SESSIONS_DIR = os.path.join(DEFAULT_CONFIG_DIR, SESSIONS_DIR_NAME) DEFAULT_SESSIONS_DIR = DEFAULT_CONFIG_DIR / SESSIONS_DIR_NAME
VALID_SESSION_NAME_PATTERN = re.compile('^[a-zA-Z0-9_.-]+$') VALID_SESSION_NAME_PATTERN = re.compile('^[a-zA-Z0-9_.-]+$')
# Request headers starting with these prefixes won't be stored in sessions. # Request headers starting with these prefixes won't be stored in sessions.
# They are specific to each request. # They are specific to each request.
# http://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Requests # <https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Requests>
SESSION_IGNORED_HEADER_PREFIXES = ['Content-', 'If-'] SESSION_IGNORED_HEADER_PREFIXES = ['Content-', 'If-']
def get_response(requests_session, session_name, def get_httpie_session(
config_dir, args, read_only=False): config_dir: Path,
"""Like `client.get_response`, but applies permanent session_name: str,
aspects of the session to the request. host: Optional[str],
url: str,
""" ) -> 'Session':
from .client import get_requests_kwargs, dump_request
if os.path.sep in session_name: if os.path.sep in session_name:
path = os.path.expanduser(session_name) path = os.path.expanduser(session_name)
else: else:
hostname = (args.headers.get('Host', None) hostname = host or urlsplit(url).netloc.split('@')[-1]
or urlsplit(args.url).netloc.split('@')[-1])
if not hostname: if not hostname:
# HACK/FIXME: httpie-unixsocket's URLs have no hostname. # HACK/FIXME: httpie-unixsocket's URLs have no hostname.
hostname = 'localhost' hostname = 'localhost'
# host:port => host_port # host:port => host_port
hostname = hostname.replace(':', '_') hostname = hostname.replace(':', '_')
path = os.path.join(config_dir, path = (
SESSIONS_DIR_NAME, config_dir / SESSIONS_DIR_NAME / hostname / f'{session_name}.json'
hostname, )
session_name + '.json')
session = Session(path) session = Session(path)
session.load() session.load()
return session
kwargs = get_requests_kwargs(args, base_headers=session.headers)
if args.debug:
dump_request(kwargs)
session.update_headers(kwargs['headers'])
if args.auth:
session.auth = {
'type': args.auth_type,
'username': args.auth.key,
'password': args.auth.value,
}
elif session.auth:
kwargs['auth'] = session.auth
requests_session.cookies = session.cookies
try:
response = requests_session.request(**kwargs)
except Exception:
raise
else:
# Existing sessions with `read_only=True` don't get updated.
if session.is_new() or not read_only:
session.cookies = requests_session.cookies
session.save()
return response
class Session(BaseConfigDict): class Session(BaseConfigDict):
helpurl = 'https://github.com/jkbrzt/httpie#sessions' helpurl = 'https://httpie.org/doc#sessions'
about = 'HTTPie session file' about = 'HTTPie session file'
def __init__(self, path, *args, **kwargs): def __init__(self, path: Union[str, Path]):
super(Session, self).__init__(*args, **kwargs) super().__init__(path=Path(path))
self._path = path
self['headers'] = {} self['headers'] = {}
self['cookies'] = {} self['cookies'] = {}
self['auth'] = { self['auth'] = {
@ -89,18 +63,18 @@ class Session(BaseConfigDict):
'password': None 'password': None
} }
def _get_path(self): def update_headers(self, request_headers: RequestHeadersDict):
return self._path
def update_headers(self, request_headers):
""" """
Update the session headers with the request ones while ignoring Update the session headers with the request ones while ignoring
certain name prefixes. certain name prefixes.
:type request_headers: dict
""" """
headers = self.headers
for name, value in request_headers.items(): for name, value in request_headers.items():
if value is None:
continue # Ignore explicitly unset headers
value = value.decode('utf8') value = value.decode('utf8')
if name == 'User-Agent' and value.startswith('HTTPie/'): if name == 'User-Agent' and value.startswith('HTTPie/'):
continue continue
@ -109,14 +83,16 @@ class Session(BaseConfigDict):
if name.lower().startswith(prefix.lower()): if name.lower().startswith(prefix.lower()):
break break
else: else:
self['headers'][name] = value headers[name] = value
self['headers'] = dict(headers)
@property @property
def headers(self): def headers(self) -> RequestHeadersDict:
return self['headers'] return RequestHeadersDict(self['headers'])
@property @property
def cookies(self): def cookies(self) -> RequestsCookieJar:
jar = RequestsCookieJar() jar = RequestsCookieJar()
for name, cookie_dict in self['cookies'].items(): for name, cookie_dict in self['cookies'].items():
jar.set_cookie(create_cookie( jar.set_cookie(create_cookie(
@ -125,28 +101,51 @@ class Session(BaseConfigDict):
return jar return jar
@cookies.setter @cookies.setter
def cookies(self, jar): def cookies(self, jar: RequestsCookieJar):
""" # <https://docs.python.org/2/library/cookielib.html#cookie-objects>
:type jar: CookieJar
"""
# http://docs.python.org/2/library/cookielib.html#cookie-objects
stored_attrs = ['value', 'path', 'secure', 'expires'] stored_attrs = ['value', 'path', 'secure', 'expires']
self['cookies'] = {} self['cookies'] = {}
for cookie in jar: for cookie in jar:
self['cookies'][cookie.name] = dict( self['cookies'][cookie.name] = {
(attname, getattr(cookie, attname)) attname: getattr(cookie, attname)
for attname in stored_attrs for attname in stored_attrs
) }
@property @property
def auth(self): def auth(self) -> Optional[AuthBase]:
auth = self.get('auth', None) auth = self.get('auth', None)
if not auth or not auth['type']: if not auth or not auth['type']:
return return
auth_plugin = plugin_manager.get_auth_plugin(auth['type'])()
return auth_plugin.get_auth(auth['username'], auth['password']) plugin = plugin_manager.get_auth_plugin(auth['type'])()
credentials = {'username': None, 'password': None}
try:
# New style
plugin.raw_auth = auth['raw_auth']
except KeyError:
# Old style
credentials = {
'username': auth['username'],
'password': auth['password'],
}
else:
if plugin.auth_parse:
from httpie.cli.argtypes import parse_auth
parsed = parse_auth(plugin.raw_auth)
credentials = {
'username': parsed.key,
'password': parsed.value,
}
return plugin.get_auth(**credentials)
@auth.setter @auth.setter
def auth(self, auth): def auth(self, auth: dict):
assert set(['type', 'username', 'password']) == set(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
)

40
httpie/status.py Normal file
View File

@ -0,0 +1,40 @@
from enum import IntEnum, unique
@unique
class ExitStatus(IntEnum):
"""Program exit status code constants."""
SUCCESS = 0
ERROR = 1
ERROR_TIMEOUT = 2
# See --check-status
ERROR_HTTP_3XX = 3
ERROR_HTTP_4XX = 4
ERROR_HTTP_5XX = 5
ERROR_TOO_MANY_REDIRECTS = 6
PLUGIN_ERROR = 7
# 128+2 SIGINT
# <http://www.tldp.org/LDP/abs/html/exitcodes.html>
ERROR_CTRL_C = 130
def http_status_to_exit_status(http_status: int, follow=False) -> ExitStatus:
"""
Translate HTTP status code to exit status code.
(Relevant only when invoked with --check-status or --download.)
"""
if 300 <= http_status <= 399 and not follow:
# Redirect
return ExitStatus.ERROR_HTTP_3XX
elif 400 <= http_status <= 499:
# Client Error
return ExitStatus.ERROR_HTTP_4XX
elif 500 <= http_status <= 599:
# Server Error
return ExitStatus.ERROR_HTTP_5XX
else:
return ExitStatus.SUCCESS

View File

@ -1,19 +1,28 @@
from __future__ import division from __future__ import division
import json
from httpie.compat import is_py26, OrderedDict 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
def load_json_preserve_order(s): def load_json_preserve_order(s):
if is_py26:
return json.loads(s)
return json.loads(s, object_pairs_hook=OrderedDict) return json.loads(s, object_pairs_hook=OrderedDict)
def repr_dict(d: dict) -> str:
return pformat(d)
def humanize_bytes(n, precision=2): def humanize_bytes(n, precision=2):
# Author: Doug Latornell # Author: Doug Latornell
# Licence: MIT # Licence: MIT
# URL: http://code.activestate.com/recipes/577081/ # URL: https://code.activestate.com/recipes/577081/
"""Return a humanized string representation of a number of bytes. """Return a humanized string representation of a number of bytes.
Assumes `from __future__ import division`. Assumes `from __future__ import division`.
@ -54,3 +63,51 @@ def humanize_bytes(n, precision=2):
# noinspection PyUnboundLocalVariable # noinspection PyUnboundLocalVariable
return '%.*f %s' % (precision, n / factor, suffix) return '%.*f %s' % (precision, n / factor, suffix)
class ExplicitNullAuth(requests.auth.AuthBase):
"""Forces requests to ignore the ``.netrc``.
<https://github.com/psf/requests/issues/2773#issuecomment-174312831>
"""
def __call__(self, r):
return r
def get_content_type(filename):
"""
Return the content type for ``filename`` in format appropriate
for Content-Type headers, or ``None`` if the file type is unknown
to ``mimetypes``.
"""
mime, encoding = mimetypes.guess_type(filename, strict=False)
if mime:
content_type = mime
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,3 +0,0 @@
[pytest]
addopts = --tb=native
norecursedirs = tests/fixtures

View File

@ -5,3 +5,5 @@ pytest-cov
pytest-httpbin>=0.0.6 pytest-httpbin>=0.0.6
docutils docutils
wheel wheel
pycodestyle
twine

View File

@ -1,2 +1,19 @@
[wheel] [wheel]
universal = 1
[tool:pytest]
# <https://docs.pytest.org/en/latest/customize.html>
norecursedirs = tests/fixtures
addopts = --tb=native
[pycodestyle]
# <http://pycodestyle.pycqa.org/en/latest/intro.html#configuration>
exclude = .git,.idea,__pycache__,build,dist,.tox,.pytest_cache,*.egg-info
# <http://pycodestyle.pycqa.org/en/latest/intro.html#error-codes>
# E241 - multiple spaces after ,
# E501 - line too long
# W503 - line break before binary operator
ignore = E241,E501,W503

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',
@ -35,32 +36,27 @@ tests_require = [
install_requires = [ install_requires = [
'requests>=2.3.0', 'requests>=2.22.0',
'Pygments>=1.5' '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 = {
# http://wheel.readthedocs.org/en/latest/#defining-conditional-dependencies # https://wheel.readthedocs.io/en/latest/#defining-conditional-dependencies
':python_version == "2.6"' ':sys_platform == "win32"': install_requires_win_only,
' or python_version == "3.0"'
' or python_version == "3.1" ': ['argparse>=1.2.1'],
':sys_platform == "win32"': ['colorama>=0.2.4'],
} }
@ -68,13 +64,14 @@ def long_description():
with codecs.open('README.rst', encoding='utf8') as f: with codecs.open('README.rst', encoding='utf8') as f:
return f.read() return f.read()
setup( setup(
name='httpie', name='httpie',
version=httpie.__version__, version=httpie.__version__,
description=httpie.__doc__.strip(), description=httpie.__doc__.strip(),
long_description=long_description(), long_description=long_description(),
url='http://httpie.org/', url='https://httpie.org/',
download_url='https://github.com/jkbrzt/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__,
@ -82,8 +79,10 @@ setup(
entry_points={ entry_points={
'console_scripts': [ 'console_scripts': [
'http = httpie.__main__:main', 'http = 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,
@ -91,14 +90,7 @@ setup(
classifiers=[ classifiers=[
'Development Status :: 5 - Production/Stable', 'Development Status :: 5 - Production/Stable',
'Programming Language :: Python', 'Programming Language :: Python',
'Programming Language :: Python :: 2', 'Programming Language :: Python :: 3 :: Only',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.1',
'Programming Language :: Python :: 3.2',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Environment :: Console', 'Environment :: Console',
'Intended Audience :: Developers', 'Intended Audience :: Developers',
'Intended Audience :: System Administrators', 'Intended Audience :: System Administrators',
@ -110,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

@ -5,4 +5,4 @@ HTTPie Test Suite
Please see `CONTRIBUTING`_. Please see `CONTRIBUTING`_.
.. _CONTRIBUTING: https://github.com/jkbrzt/httpie/blob/master/CONTRIBUTING.rst .. _CONTRIBUTING: https://github.com/jakubroztocil/httpie/blob/master/CONTRIBUTING.rst

24
tests/conftest.py Normal file
View File

@ -0,0 +1,24 @@
import pytest
from pytest_httpbin import certs
@pytest.fixture(scope='function', autouse=True)
def httpbin_add_ca_bundle(monkeypatch):
"""
Make pytest-httpbin's CA trusted by default.
(Same as `httpbin_ca_bundle`, just auto-used.).
"""
monkeypatch.setenv('REQUESTS_CA_BUNDLE', certs.where())
@pytest.fixture(scope='function')
def httpbin_secure_untrusted(monkeypatch, httpbin_secure):
"""
Like the `httpbin_secure` fixture, but without the
make-CA-trusted-by-default.
"""
monkeypatch.delenv('REQUESTS_CA_BUNDLE')
return httpbin_secure

View File

@ -12,7 +12,7 @@ def patharg(path):
return path.replace('\\', '\\\\\\') return path.replace('\\', '\\\\\\')
FIXTURES_ROOT = path.join(path.abspath(path.dirname(__file__)), 'fixtures') FIXTURES_ROOT = path.join(path.abspath(path.dirname(__file__)))
FILE_PATH = path.join(FIXTURES_ROOT, 'test.txt') FILE_PATH = path.join(FIXTURES_ROOT, 'test.txt')
JSON_FILE_PATH = path.join(FIXTURES_ROOT, 'test.json') JSON_FILE_PATH = path.join(FIXTURES_ROOT, 'test.json')
BIN_FILE_PATH = path.join(FIXTURES_ROOT, 'test.bin') BIN_FILE_PATH = path.join(FIXTURES_ROOT, 'test.bin')

View File

@ -1,61 +1,142 @@
"""HTTP authentication-related tests.""" """HTTP authentication-related tests."""
import requests import mock
import pytest import pytest
from utils import http, add_auth, HTTP_OK, TestEnvironment from httpie.plugins.builtin import HTTPBasicAuth
import httpie.input from httpie.status import ExitStatus
import httpie.cli from httpie.utils import ExplicitNullAuth
from utils import http, add_auth, HTTP_OK, MockEnvironment
import httpie.cli.constants
import httpie.cli.definition
class TestAuth: def test_basic_auth(httpbin_both):
def test_basic_auth(self, httpbin): r = http('--auth=user:password',
r = http('--auth=user:password', 'GET', httpbin_both + '/basic-auth/user/password')
'GET', httpbin.url + '/basic-auth/user/password') assert HTTP_OK in r
assert r.json == {'authenticated': True, 'user': 'user'}
@pytest.mark.parametrize('argument_name', ['--auth-type', '-A'])
def test_digest_auth(httpbin_both, argument_name):
r = http(argument_name + '=digest', '--auth=user:password',
'GET', httpbin_both.url + '/digest-auth/auth/user/password')
assert HTTP_OK in r
assert r.json == {'authenticated': True, 'user': 'user'}
@mock.patch('httpie.cli.argtypes.AuthCredentials._getpass',
new=lambda self, prompt: 'password')
def test_password_prompt(httpbin):
r = http('--auth', 'user',
'GET', httpbin.url + '/basic-auth/user/password')
assert HTTP_OK in r
assert r.json == {'authenticated': True, 'user': 'user'}
def test_credentials_in_url(httpbin_both):
url = add_auth(httpbin_both.url + '/basic-auth/user/password',
auth='user:password')
r = http('GET', url)
assert HTTP_OK in r
assert r.json == {'authenticated': True, 'user': 'user'}
def test_credentials_in_url_auth_flag_has_priority(httpbin_both):
"""When credentials are passed in URL and via -a at the same time,
then the ones from -a are used."""
url = add_auth(httpbin_both.url + '/basic-auth/user/password',
auth='user:wrong')
r = http('--auth=user:password', 'GET', url)
assert HTTP_OK in r
assert r.json == {'authenticated': True, 'user': 'user'}
@pytest.mark.parametrize('url', [
'username@example.org',
'username:@example.org',
])
def test_only_username_in_url(url):
"""
https://github.com/jakubroztocil/httpie/issues/242
"""
args = httpie.cli.definition.parser.parse_args(args=[url], env=MockEnvironment())
assert args.auth
assert args.auth.username == 'username'
assert args.auth.password == ''
def test_missing_auth(httpbin):
r = http(
'--auth-type=basic',
'GET',
httpbin + '/basic-auth/user/password',
tolerate_error_exit_status=True
)
assert HTTP_OK not in r
assert '--auth required' in r.stderr
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')
assert get_netrc_auth.call_count == 1
assert HTTP_OK in r assert HTTP_OK in r
assert r.json == {'authenticated': True, 'user': 'user'}
@pytest.mark.skipif(
requests.__version__ == '0.13.6',
reason='Redirects with prefetch=False are broken in Requests 0.13.6')
def test_digest_auth(self, httpbin):
r = http('--auth-type=digest', '--auth=user:password',
'GET', httpbin.url + '/digest-auth/auth/user/password')
assert HTTP_OK in r
assert r.json == {'authenticated': True, 'user': 'user'}
def test_password_prompt(self, httpbin): def test_ignore_netrc(httpbin_both):
httpie.input.AuthCredentials._getpass = lambda self, prompt: 'password' with mock.patch('httpie.cli.argparser.get_netrc_auth') as get_netrc_auth:
r = http('--auth', 'user', get_netrc_auth.return_value = ('httpie', 'password')
'GET', httpbin.url + '/basic-auth/user/password') r = http('--ignore-netrc', httpbin_both + '/basic-auth/httpie/password')
assert HTTP_OK in r assert get_netrc_auth.call_count == 0
assert r.json == {'authenticated': True, 'user': 'user'} assert 'HTTP/1.1 401 UNAUTHORIZED' in r
def test_credentials_in_url(self, httpbin):
url = add_auth(httpbin.url + '/basic-auth/user/password',
auth='user:password')
r = http('GET', url)
assert HTTP_OK in r
assert r.json == {'authenticated': True, 'user': 'user'}
def test_credentials_in_url_auth_flag_has_priority(self, httpbin): def test_ignore_netrc_together_with_auth():
"""When credentials are passed in URL and via -a at the same time, args = httpie.cli.definition.parser.parse_args(
then the ones from -a are used.""" args=['--ignore-netrc', '--auth=username:password', 'example.org'],
url = add_auth(httpbin.url + '/basic-auth/user/password', env=MockEnvironment(),
auth='user:wrong') )
r = http('--auth=user:password', 'GET', url) assert isinstance(args.auth, HTTPBasicAuth)
assert HTTP_OK in r
assert r.json == {'authenticated': True, 'user': 'user'}
@pytest.mark.parametrize('url', [
'username@example.org',
'username:@example.org',
])
def test_only_username_in_url(self, url):
"""
https://github.com/jkbrzt/httpie/issues/242
""" def test_ignore_netrc_with_auth_type_resulting_in_missing_auth(httpbin):
args = httpie.cli.parser.parse_args(args=[url], env=TestEnvironment()) with mock.patch('httpie.cli.argparser.get_netrc_auth') as get_netrc_auth:
assert args.auth get_netrc_auth.return_value = ('httpie', 'password')
assert args.auth.key == 'username' r = http(
assert args.auth.value == '' '--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)

135
tests/test_auth_plugins.py Normal file
View File

@ -0,0 +1,135 @@
from mock import mock
from httpie.cli.constants import SEPARATOR_CREDENTIALS
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'
PASSWORD = 'password'
# Basic auth encoded `USERNAME` and `PASSWORD`
# noinspection SpellCheckingInspection
BASIC_AUTH_HEADER_VALUE = 'Basic dXNlcjpwYXNzd29yZA=='
BASIC_AUTH_URL = '/basic-auth/{0}/{1}'.format(USERNAME, PASSWORD)
AUTH_OK = {'authenticated': True, 'user': USERNAME}
def basic_auth(header=BASIC_AUTH_HEADER_VALUE):
def inner(r):
r.headers['Authorization'] = header
return r
return inner
def test_auth_plugin_parse_auth_false(httpbin):
class Plugin(AuthPlugin):
auth_type = 'test-parse-false'
auth_parse = False
def get_auth(self, username=None, password=None):
assert username is None
assert password is None
assert self.raw_auth == BASIC_AUTH_HEADER_VALUE
return basic_auth(self.raw_auth)
plugin_manager.register(Plugin)
try:
r = http(
httpbin + BASIC_AUTH_URL,
'--auth-type',
Plugin.auth_type,
'--auth',
BASIC_AUTH_HEADER_VALUE,
)
assert HTTP_OK in r
assert r.json == AUTH_OK
finally:
plugin_manager.unregister(Plugin)
def test_auth_plugin_require_auth_false(httpbin):
class Plugin(AuthPlugin):
auth_type = 'test-require-false'
auth_require = False
def get_auth(self, username=None, password=None):
assert self.raw_auth is None
assert username is None
assert password is None
return basic_auth()
plugin_manager.register(Plugin)
try:
r = http(
httpbin + BASIC_AUTH_URL,
'--auth-type',
Plugin.auth_type,
)
assert HTTP_OK in r
assert r.json == AUTH_OK
finally:
plugin_manager.unregister(Plugin)
def test_auth_plugin_require_auth_false_and_auth_provided(httpbin):
class Plugin(AuthPlugin):
auth_type = 'test-require-false-yet-provided'
auth_require = False
def get_auth(self, username=None, password=None):
assert self.raw_auth == USERNAME + SEPARATOR_CREDENTIALS + PASSWORD
assert username == USERNAME
assert password == PASSWORD
return basic_auth()
plugin_manager.register(Plugin)
try:
r = http(
httpbin + BASIC_AUTH_URL,
'--auth-type',
Plugin.auth_type,
'--auth',
USERNAME + SEPARATOR_CREDENTIALS + PASSWORD,
)
assert HTTP_OK in r
assert r.json == AUTH_OK
finally:
plugin_manager.unregister(Plugin)
@mock.patch('httpie.cli.argtypes.AuthCredentials._getpass',
new=lambda self, prompt: 'UNEXPECTED_PROMPT_RESPONSE')
def test_auth_plugin_prompt_password_false(httpbin):
class Plugin(AuthPlugin):
auth_type = 'test-prompt-false'
prompt_password = False
def get_auth(self, username=None, password=None):
assert self.raw_auth == USERNAME
assert username == USERNAME
assert password is None
return basic_auth()
plugin_manager.register(Plugin)
try:
r = http(
httpbin + BASIC_AUTH_URL,
'--auth-type',
Plugin.auth_type,
'--auth',
USERNAME,
)
assert HTTP_OK in r
assert r.json == AUTH_OK
finally:
plugin_manager.unregister(Plugin)

View File

@ -1,14 +1,16 @@
"""Tests for dealing with binary request and response data.""" """Tests for dealing with binary request and response data."""
from httpie.compat import urlopen import requests
from httpie.output.streams import BINARY_SUPPRESSED_NOTICE
from utils import TestEnvironment, http
from fixtures import BIN_FILE_PATH, BIN_FILE_CONTENT, BIN_FILE_PATH_ARG from fixtures import BIN_FILE_PATH, BIN_FILE_CONTENT, BIN_FILE_PATH_ARG
from httpie.output.streams import BINARY_SUPPRESSED_NOTICE
from utils import MockEnvironment, http
class TestBinaryRequestData: class TestBinaryRequestData:
def test_binary_stdin(self, httpbin): def test_binary_stdin(self, httpbin):
with open(BIN_FILE_PATH, 'rb') as stdin: with open(BIN_FILE_PATH, 'rb') as stdin:
env = TestEnvironment( env = MockEnvironment(
stdin=stdin, stdin=stdin,
stdin_isatty=False, stdin_isatty=False,
stdout_isatty=False stdout_isatty=False
@ -17,38 +19,32 @@ class TestBinaryRequestData:
assert r == BIN_FILE_CONTENT assert r == BIN_FILE_CONTENT
def test_binary_file_path(self, httpbin): def test_binary_file_path(self, httpbin):
env = TestEnvironment(stdin_isatty=True, stdout_isatty=False) env = MockEnvironment(stdin_isatty=True, stdout_isatty=False)
r = http('--print=B', 'POST', httpbin.url + '/post', r = http('--print=B', 'POST', httpbin.url + '/post',
'@' + BIN_FILE_PATH_ARG, env=env, ) '@' + BIN_FILE_PATH_ARG, env=env, )
assert r == BIN_FILE_CONTENT assert r == BIN_FILE_CONTENT
def test_binary_file_form(self, httpbin): def test_binary_file_form(self, httpbin):
env = TestEnvironment(stdin_isatty=True, stdout_isatty=False) env = MockEnvironment(stdin_isatty=True, stdout_isatty=False)
r = http('--print=B', '--form', 'POST', httpbin.url + '/post', r = http('--print=B', '--form', 'POST', httpbin.url + '/post',
'test@' + BIN_FILE_PATH_ARG, env=env) 'test@' + BIN_FILE_PATH_ARG, env=env)
assert bytes(BIN_FILE_CONTENT) in bytes(r) assert bytes(BIN_FILE_CONTENT) in bytes(r)
class TestBinaryResponseData: class TestBinaryResponseData:
url = 'http://www.google.com/favicon.ico'
@property def test_binary_suppresses_when_terminal(self, httpbin):
def bindata(self): r = http('GET', httpbin + '/bytes/1024?seed=1')
if not hasattr(self, '_bindata'):
self._bindata = urlopen(self.url).read()
return self._bindata
def test_binary_suppresses_when_terminal(self):
r = http('GET', self.url)
assert BINARY_SUPPRESSED_NOTICE.decode() in r assert BINARY_SUPPRESSED_NOTICE.decode() in r
def test_binary_suppresses_when_not_terminal_but_pretty(self): def test_binary_suppresses_when_not_terminal_but_pretty(self, httpbin):
env = TestEnvironment(stdin_isatty=True, stdout_isatty=False) env = MockEnvironment(stdin_isatty=True, stdout_isatty=False)
r = http('--pretty=all', 'GET', self.url, r = http('--pretty=all', 'GET', httpbin + '/bytes/1024?seed=1', env=env)
env=env)
assert BINARY_SUPPRESSED_NOTICE.decode() in r assert BINARY_SUPPRESSED_NOTICE.decode() in r
def test_binary_included_and_correct_when_suitable(self): def test_binary_included_and_correct_when_suitable(self, httpbin):
env = TestEnvironment(stdin_isatty=True, stdout_isatty=False) env = MockEnvironment(stdin_isatty=True, stdout_isatty=False)
r = http('GET', self.url, env=env) url = httpbin + '/bytes/1024?seed=1'
assert r == self.bindata r = http('GET', url, env=env)
expected = requests.get(url).content
assert r == expected

View File

@ -1,42 +1,42 @@
"""CLI argument parsing related tests.""" """CLI argument parsing related tests."""
import json
# noinspection PyCompatibility
import argparse import argparse
import json
import pytest import pytest
from requests.exceptions import InvalidSchema from requests.exceptions import InvalidSchema
from httpie import input import httpie.cli.argparser
from httpie.input import KeyValue, KeyValueArgType, DataDict
from httpie import ExitStatus
from httpie.cli import parser
from utils import TestEnvironment, http, HTTP_OK
from fixtures import ( from fixtures import (
FILE_PATH_ARG, JSON_FILE_PATH_ARG, FILE_CONTENT, FILE_PATH, FILE_PATH_ARG, JSON_FILE_CONTENT,
JSON_FILE_CONTENT, FILE_CONTENT, FILE_PATH JSON_FILE_PATH_ARG,
) )
from httpie.status import ExitStatus
from httpie.cli import constants
from httpie.cli.definition import parser
from httpie.cli.argtypes import KeyValueArg, KeyValueArgType
from httpie.cli.requestitems import RequestItems
from utils import HTTP_OK, MockEnvironment, http
class TestItemParsing: class TestItemParsing:
key_value_arg = KeyValueArgType(*constants.SEPARATOR_GROUP_ALL_ITEMS)
key_value = KeyValueArgType(*input.SEP_GROUP_ALL_ITEMS)
def test_invalid_items(self): def test_invalid_items(self):
items = ['no-separator'] items = ['no-separator']
for item in items: for item in items:
pytest.raises(argparse.ArgumentTypeError, self.key_value, item) pytest.raises(argparse.ArgumentTypeError, self.key_value_arg, item)
def test_escape_separator(self): def test_escape_separator(self):
items = input.parse_items([ items = RequestItems.from_args([
# headers # headers
self.key_value(r'foo\:bar:baz'), self.key_value_arg(r'foo\:bar:baz'),
self.key_value(r'jack\@jill:hill'), self.key_value_arg(r'jack\@jill:hill'),
# data # data
self.key_value(r'baz\=bar=foo'), self.key_value_arg(r'baz\=bar=foo'),
# files # files
self.key_value(r'bar\@baz@%s' % FILE_PATH_ARG), self.key_value_arg(r'bar\@baz@%s' % FILE_PATH_ARG),
]) ])
# `requests.structures.CaseInsensitiveDict` => `dict` # `requests.structures.CaseInsensitiveDict` => `dict`
headers = dict(items.headers._store.values()) headers = dict(items.headers._store.values())
@ -45,45 +45,55 @@ class TestItemParsing:
'foo:bar': 'baz', 'foo:bar': 'baz',
'jack@jill': 'hill', 'jack@jill': 'hill',
} }
assert items.data == {'baz=bar': 'foo'} assert items.data == {
'baz=bar': 'foo'
}
assert 'bar@baz' in items.files assert 'bar@baz' in items.files
@pytest.mark.parametrize(('string', 'key', 'sep', 'value'), [ @pytest.mark.parametrize(('string', 'key', 'sep', 'value'), [
('path=c:\windows', 'path', '=', 'c:\windows'), ('path=c:\\windows', 'path', '=', 'c:\\windows'),
('path=c:\windows\\', 'path', '=', 'c:\windows\\'), ('path=c:\\windows\\', 'path', '=', 'c:\\windows\\'),
('path\==c:\windows', 'path=', '=', 'c:\windows'), ('path\\==c:\\windows', 'path=', '=', 'c:\\windows'),
]) ])
def test_backslash_before_non_special_character_does_not_escape( def test_backslash_before_non_special_character_does_not_escape(
self, string, key, sep, value): self, string, key, sep, value
expected = KeyValue(orig=string, key=key, sep=sep, value=value) ):
actual = self.key_value(string) expected = KeyValueArg(orig=string, key=key, sep=sep, value=value)
actual = self.key_value_arg(string)
assert actual == expected assert actual == expected
def test_escape_longsep(self): def test_escape_longsep(self):
items = input.parse_items([ items = RequestItems.from_args([
self.key_value(r'bob\:==foo'), self.key_value_arg(r'bob\:==foo'),
]) ])
assert items.params == {'bob:': 'foo'} assert items.params == {
'bob:': 'foo'
}
def test_valid_items(self): def test_valid_items(self):
items = input.parse_items([ items = RequestItems.from_args([
self.key_value('string=value'), self.key_value_arg('string=value'),
self.key_value('header:value'), self.key_value_arg('Header:value'),
self.key_value('list:=["a", 1, {}, false]'), self.key_value_arg('Unset-Header:'),
self.key_value('obj:={"a": "b"}'), self.key_value_arg('Empty-Header;'),
self.key_value('eh:'), self.key_value_arg('list:=["a", 1, {}, false]'),
self.key_value('ed='), self.key_value_arg('obj:={"a": "b"}'),
self.key_value('bool:=true'), self.key_value_arg('ed='),
self.key_value('file@' + FILE_PATH_ARG), self.key_value_arg('bool:=true'),
self.key_value('query==value'), self.key_value_arg('file@' + FILE_PATH_ARG),
self.key_value('string-embed=@' + FILE_PATH_ARG), self.key_value_arg('query==value'),
self.key_value('raw-json-embed:=@' + JSON_FILE_PATH_ARG), self.key_value_arg('string-embed=@' + FILE_PATH_ARG),
self.key_value_arg('raw-json-embed:=@' + JSON_FILE_PATH_ARG),
]) ])
# Parsed headers # Parsed headers
# `requests.structures.CaseInsensitiveDict` => `dict` # `requests.structures.CaseInsensitiveDict` => `dict`
headers = dict(items.headers._store.values()) headers = dict(items.headers._store.values())
assert headers == {'header': 'value', 'eh': ''} assert headers == {
'Header': 'value',
'Unset-Header': None,
'Empty-Header': ''
}
# Parsed data # Parsed data
raw_json_embed = items.data.pop('raw-json-embed') raw_json_embed = items.data.pop('raw-json-embed')
@ -94,30 +104,36 @@ class TestItemParsing:
"string": "value", "string": "value",
"bool": True, "bool": True,
"list": ["a", 1, {}, False], "list": ["a", 1, {}, False],
"obj": {"a": "b"}, "obj": {
"a": "b"
},
"string-embed": FILE_CONTENT, "string-embed": FILE_CONTENT,
} }
# Parsed query string parameters # Parsed query string parameters
assert items.params == {'query': 'value'} assert items.params == {
'query': 'value'
}
# Parsed file fields # Parsed file fields
assert 'file' in items.files assert 'file' in items.files
assert (items.files['file'][1].read().strip().decode('utf8') assert (items.files['file'][1].read().strip().
== FILE_CONTENT) decode('utf8') == FILE_CONTENT)
def test_multiple_file_fields_with_same_field_name(self): def test_multiple_file_fields_with_same_field_name(self):
items = input.parse_items([ items = RequestItems.from_args([
self.key_value('file_field@' + FILE_PATH_ARG), self.key_value_arg('file_field@' + FILE_PATH_ARG),
self.key_value('file_field@' + FILE_PATH_ARG), self.key_value_arg('file_field@' + FILE_PATH_ARG),
]) ])
assert len(items.files['file_field']) == 2 assert len(items.files['file_field']) == 2
def test_multiple_text_fields_with_same_field_name(self): def test_multiple_text_fields_with_same_field_name(self):
items = input.parse_items( items = RequestItems.from_args(
[self.key_value('text_field=a'), request_item_args=[
self.key_value('text_field=b')], self.key_value_arg('text_field=a'),
data_class=DataDict self.key_value_arg('text_field=b')
],
as_form=True,
) )
assert items.data['text_field'] == ['a', 'b'] assert items.data['text_field'] == ['a', 'b']
assert list(items.data.items()) == [ assert list(items.data.items()) == [
@ -154,46 +170,46 @@ class TestQuerystring:
assert '"url": "%s"' % url in r assert '"url": "%s"' % url in r
class TestURLshorthand: class TestLocalhostShorthand:
def test_expand_localhost_shorthand(self): def test_expand_localhost_shorthand(self):
args = parser.parse_args(args=[':'], env=TestEnvironment()) args = parser.parse_args(args=[':'], env=MockEnvironment())
assert args.url == 'http://localhost' assert args.url == 'http://localhost'
def test_expand_localhost_shorthand_with_slash(self): def test_expand_localhost_shorthand_with_slash(self):
args = parser.parse_args(args=[':/'], env=TestEnvironment()) args = parser.parse_args(args=[':/'], env=MockEnvironment())
assert args.url == 'http://localhost/' assert args.url == 'http://localhost/'
def test_expand_localhost_shorthand_with_port(self): def test_expand_localhost_shorthand_with_port(self):
args = parser.parse_args(args=[':3000'], env=TestEnvironment()) args = parser.parse_args(args=[':3000'], env=MockEnvironment())
assert args.url == 'http://localhost:3000' assert args.url == 'http://localhost:3000'
def test_expand_localhost_shorthand_with_path(self): def test_expand_localhost_shorthand_with_path(self):
args = parser.parse_args(args=[':/path'], env=TestEnvironment()) args = parser.parse_args(args=[':/path'], env=MockEnvironment())
assert args.url == 'http://localhost/path' assert args.url == 'http://localhost/path'
def test_expand_localhost_shorthand_with_port_and_slash(self): def test_expand_localhost_shorthand_with_port_and_slash(self):
args = parser.parse_args(args=[':3000/'], env=TestEnvironment()) args = parser.parse_args(args=[':3000/'], env=MockEnvironment())
assert args.url == 'http://localhost:3000/' assert args.url == 'http://localhost:3000/'
def test_expand_localhost_shorthand_with_port_and_path(self): def test_expand_localhost_shorthand_with_port_and_path(self):
args = parser.parse_args(args=[':3000/path'], env=TestEnvironment()) args = parser.parse_args(args=[':3000/path'], env=MockEnvironment())
assert args.url == 'http://localhost:3000/path' assert args.url == 'http://localhost:3000/path'
def test_dont_expand_shorthand_ipv6_as_shorthand(self): def test_dont_expand_shorthand_ipv6_as_shorthand(self):
args = parser.parse_args(args=['::1'], env=TestEnvironment()) args = parser.parse_args(args=['::1'], env=MockEnvironment())
assert args.url == 'http://::1' assert args.url == 'http://::1'
def test_dont_expand_longer_ipv6_as_shorthand(self): def test_dont_expand_longer_ipv6_as_shorthand(self):
args = parser.parse_args( args = parser.parse_args(
args=['::ffff:c000:0280'], args=['::ffff:c000:0280'],
env=TestEnvironment() env=MockEnvironment()
) )
assert args.url == 'http://::ffff:c000:0280' assert args.url == 'http://::ffff:c000:0280'
def test_dont_expand_full_ipv6_as_shorthand(self): def test_dont_expand_full_ipv6_as_shorthand(self):
args = parser.parse_args( args = parser.parse_args(
args=['0000:0000:0000:0000:0000:0000:0000:0001'], args=['0000:0000:0000:0000:0000:0000:0000:0001'],
env=TestEnvironment() env=MockEnvironment()
) )
assert args.url == 'http://0000:0000:0000:0000:0000:0000:0000:0001' assert args.url == 'http://0000:0000:0000:0000:0000:0000:0000:0001'
@ -201,92 +217,80 @@ class TestURLshorthand:
class TestArgumentParser: class TestArgumentParser:
def setup_method(self, method): def setup_method(self, method):
self.parser = input.Parser() self.parser = httpie.cli.argparser.HTTPieArgumentParser()
def test_guess_when_method_set_and_valid(self): def test_guess_when_method_set_and_valid(self):
self.parser.args = argparse.Namespace() self.parser.args = argparse.Namespace()
self.parser.args.method = 'GET' self.parser.args.method = 'GET'
self.parser.args.url = 'http://example.com/' self.parser.args.url = 'http://example.com/'
self.parser.args.items = [] self.parser.args.request_items = []
self.parser.args.ignore_stdin = False self.parser.args.ignore_stdin = False
self.parser.env = MockEnvironment()
self.parser.env = TestEnvironment()
self.parser._guess_method() self.parser._guess_method()
assert self.parser.args.method == 'GET' assert self.parser.args.method == 'GET'
assert self.parser.args.url == 'http://example.com/' assert self.parser.args.url == 'http://example.com/'
assert self.parser.args.items == [] assert self.parser.args.request_items == []
def test_guess_when_method_not_set(self): def test_guess_when_method_not_set(self):
self.parser.args = argparse.Namespace() self.parser.args = argparse.Namespace()
self.parser.args.method = None self.parser.args.method = None
self.parser.args.url = 'http://example.com/' self.parser.args.url = 'http://example.com/'
self.parser.args.items = [] self.parser.args.request_items = []
self.parser.args.ignore_stdin = False self.parser.args.ignore_stdin = False
self.parser.env = TestEnvironment() self.parser.env = MockEnvironment()
self.parser._guess_method() self.parser._guess_method()
assert self.parser.args.method == 'GET' assert self.parser.args.method == 'GET'
assert self.parser.args.url == 'http://example.com/' assert self.parser.args.url == 'http://example.com/'
assert self.parser.args.items == [] assert self.parser.args.request_items == []
def test_guess_when_method_set_but_invalid_and_data_field(self): def test_guess_when_method_set_but_invalid_and_data_field(self):
self.parser.args = argparse.Namespace() self.parser.args = argparse.Namespace()
self.parser.args.method = 'http://example.com/' self.parser.args.method = 'http://example.com/'
self.parser.args.url = 'data=field' self.parser.args.url = 'data=field'
self.parser.args.items = [] self.parser.args.request_items = []
self.parser.args.ignore_stdin = False self.parser.args.ignore_stdin = False
self.parser.env = TestEnvironment() self.parser.env = MockEnvironment()
self.parser._guess_method() self.parser._guess_method()
assert self.parser.args.method == 'POST' assert self.parser.args.method == 'POST'
assert self.parser.args.url == 'http://example.com/' assert self.parser.args.url == 'http://example.com/'
assert self.parser.args.items == [ assert self.parser.args.request_items == [
KeyValue(key='data', KeyValueArg(key='data',
value='field', value='field',
sep='=', sep='=',
orig='data=field') orig='data=field')
] ]
def test_guess_when_method_set_but_invalid_and_header_field(self): def test_guess_when_method_set_but_invalid_and_header_field(self):
self.parser.args = argparse.Namespace() self.parser.args = argparse.Namespace()
self.parser.args.method = 'http://example.com/' self.parser.args.method = 'http://example.com/'
self.parser.args.url = 'test:header' self.parser.args.url = 'test:header'
self.parser.args.items = [] self.parser.args.request_items = []
self.parser.args.ignore_stdin = False self.parser.args.ignore_stdin = False
self.parser.env = MockEnvironment()
self.parser.env = TestEnvironment()
self.parser._guess_method() self.parser._guess_method()
assert self.parser.args.method == 'GET' assert self.parser.args.method == 'GET'
assert self.parser.args.url == 'http://example.com/' assert self.parser.args.url == 'http://example.com/'
assert self.parser.args.items, [ assert self.parser.args.request_items, [
KeyValue(key='test', KeyValueArg(key='test',
value='header', value='header',
sep=':', sep=':',
orig='test:header') orig='test:header')
] ]
def test_guess_when_method_set_but_invalid_and_item_exists(self): def test_guess_when_method_set_but_invalid_and_item_exists(self):
self.parser.args = argparse.Namespace() self.parser.args = argparse.Namespace()
self.parser.args.method = 'http://example.com/' self.parser.args.method = 'http://example.com/'
self.parser.args.url = 'new_item=a' self.parser.args.url = 'new_item=a'
self.parser.args.items = [ self.parser.args.request_items = [
KeyValue( KeyValueArg(
key='old_item', value='b', sep='=', orig='old_item=b') key='old_item', value='b', sep='=', orig='old_item=b')
] ]
self.parser.args.ignore_stdin = False self.parser.args.ignore_stdin = False
self.parser.env = MockEnvironment()
self.parser.env = TestEnvironment()
self.parser._guess_method() self.parser._guess_method()
assert self.parser.args.request_items, [
assert self.parser.args.items, [ KeyValueArg(key='new_item', value='a', sep='=', orig='new_item=a'),
KeyValue(key='new_item', value='a', sep='=', orig='new_item=a'), KeyValueArg(
KeyValue(
key='old_item', value='b', sep='=', orig='old_item=b'), key='old_item', value='b', sep='=', orig='old_item=b'),
] ]
@ -299,17 +303,17 @@ class TestNoOptions:
def test_invalid_no_options(self, httpbin): def test_invalid_no_options(self, httpbin):
r = http('--no-war', 'GET', httpbin.url + '/get', r = http('--no-war', 'GET', httpbin.url + '/get',
error_exit_ok=True) tolerate_error_exit_status=True)
assert r.exit_status == 1 assert r.exit_status == ExitStatus.ERROR
assert 'unrecognized arguments: --no-war' in r.stderr assert 'unrecognized arguments: --no-war' in r.stderr
assert 'GET /get HTTP/1.1' not in r assert 'GET /get HTTP/1.1' not in r
class TestIgnoreStdin: class TestStdin:
def test_ignore_stdin(self, httpbin): def test_ignore_stdin(self, httpbin):
with open(FILE_PATH) as f: with open(FILE_PATH) as f:
env = TestEnvironment(stdin=f, stdin_isatty=False) env = MockEnvironment(stdin=f, stdin_isatty=False)
r = http('--ignore-stdin', '--verbose', httpbin.url + '/get', r = http('--ignore-stdin', '--verbose', httpbin.url + '/get',
env=env) env=env)
assert HTTP_OK in r assert HTTP_OK in r
@ -318,15 +322,33 @@ class TestIgnoreStdin:
def test_ignore_stdin_cannot_prompt_password(self, httpbin): def test_ignore_stdin_cannot_prompt_password(self, httpbin):
r = http('--ignore-stdin', '--auth=no-password', httpbin.url + '/get', r = http('--ignore-stdin', '--auth=no-password', httpbin.url + '/get',
error_exit_ok=True) tolerate_error_exit_status=True)
assert r.exit_status == ExitStatus.ERROR assert r.exit_status == ExitStatus.ERROR
assert 'because --ignore-stdin' in r.stderr assert 'because --ignore-stdin' in r.stderr
def test_stdin_closed(self, httpbin):
r = http(httpbin + '/get', env=MockEnvironment(stdin=None))
assert HTTP_OK in r
class TestSchemes: class TestSchemes:
def test_custom_scheme(self): def test_invalid_custom_scheme(self):
# InvalidSchema is expected because HTTPie # InvalidSchema is expected because HTTPie
# shouldn't touch a formally valid scheme. # shouldn't touch a formally valid scheme.
with pytest.raises(InvalidSchema): with pytest.raises(InvalidSchema):
http('foo+bar-BAZ.123://bah') http('foo+bar-BAZ.123://bah')
def test_invalid_scheme_via_via_default_scheme(self):
# InvalidSchema is expected because HTTPie
# shouldn't touch a formally valid scheme.
with pytest.raises(InvalidSchema):
http('bah', '--default=scheme=foo+bar-BAZ.123')
def test_default_scheme_option(self, httpbin_secure):
url = '{0}:{1}'.format(httpbin_secure.host, httpbin_secure.port)
assert HTTP_OK in http(url, '--default-scheme=https')
def test_scheme_when_invoked_as_https(self, httpbin_secure):
url = '{0}:{1}'.format(httpbin_secure.host, httpbin_secure.port)
assert HTTP_OK in http(url, program_name='https')

110
tests/test_compress.py Normal file
View File

@ -0,0 +1,110 @@
"""
We test against httpbin which doesn't return the request data in a
consistent way:
1. Non-form requests: the `data` field contains base64 encoded version of
our zlib-encoded request data.
2. Form requests: `form` contains a messed up version of the data.
"""
import base64
import zlib
from fixtures import FILE_PATH, FILE_CONTENT
from utils import http, HTTP_OK, MockEnvironment
def assert_decompressed_equal(base64_compressed_data, expected_str):
compressed_data = base64.b64decode(
base64_compressed_data.split(',', 1)[1])
data = zlib.decompress(compressed_data)
actual_str = data.decode()
# FIXME: contains a trailing linebreak with an uploaded file
actual_str = actual_str.rstrip()
assert actual_str == expected_str
def test_compress_skip_negative_ratio(httpbin_both):
r = http(
'--compress',
httpbin_both + '/post',
'foo=bar',
)
assert HTTP_OK in r
assert 'Content-Encoding' not in r.json['headers']
assert r.json['json'] == {'foo': 'bar'}
def test_compress_force_with_negative_ratio(httpbin_both):
r = http(
'--compress',
'--compress',
httpbin_both + '/post',
'foo=bar',
)
assert HTTP_OK in r
assert r.json['headers']['Content-Encoding'] == 'deflate'
assert_decompressed_equal(r.json['data'], '{"foo": "bar"}')
def test_compress_json(httpbin_both):
r = http(
'--compress',
'--compress',
httpbin_both + '/post',
'foo=bar',
)
assert HTTP_OK in r
assert r.json['headers']['Content-Encoding'] == 'deflate'
assert_decompressed_equal(r.json['data'], '{"foo": "bar"}')
assert r.json['json'] is None
def test_compress_form(httpbin_both):
r = http(
'--form',
'--compress',
'--compress',
httpbin_both + '/post',
'foo=bar',
)
assert HTTP_OK in r
assert r.json['headers']['Content-Encoding'] == 'deflate'
assert r.json['data'] == ""
assert '"foo": "bar"' not in r
def test_compress_stdin(httpbin_both):
with open(FILE_PATH) as f:
env = MockEnvironment(stdin=f, stdin_isatty=False)
r = http(
'--compress',
'--compress',
'PATCH',
httpbin_both + '/patch',
env=env,
)
assert HTTP_OK in r
assert r.json['headers']['Content-Encoding'] == 'deflate'
assert_decompressed_equal(r.json['data'], FILE_CONTENT.strip())
assert not r.json['json']
def test_compress_file(httpbin_both):
r = http(
'--form',
'--compress',
'--compress',
'PUT',
httpbin_both + '/put',
'file@' + FILE_PATH,
)
assert HTTP_OK in r
assert r.json['headers']['Content-Encoding'] == 'deflate'
assert r.json['headers']['Content-Type'].startswith(
'multipart/form-data; boundary=')
assert r.json['files'] == {}
assert FILE_CONTENT not in r

104
tests/test_config.py Normal file
View File

@ -0,0 +1,104 @@
from pathlib import Path
import pytest
from _pytest.monkeypatch import MonkeyPatch
from httpie.compat import is_windows
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
def test_default_options(httpbin):
env = MockEnvironment()
env.config['default_options'] = ['--form']
env.config.save()
r = http(httpbin.url + '/post', 'foo=bar', env=env)
assert r.json['form'] == {
"foo": "bar"
}
def test_config_file_not_valid(httpbin):
env = MockEnvironment()
env.create_temp_config_dir()
with (env.config_dir / Config.FILENAME).open('w') as f:
f.write('{invalid json}')
r = http(httpbin + '/get', env=env)
assert HTTP_OK in r
assert 'http: warning' in r.stderr
assert 'invalid config file' in r.stderr
@pytest.mark.skipif(is_windows, reason='cannot chmod 000 on Windows')
def test_config_file_inaccessible(httpbin):
env = MockEnvironment()
env.create_temp_config_dir()
config_path = env.config_dir / Config.FILENAME
assert not config_path.exists()
config_path.touch(0o000)
assert config_path.exists()
r = http(httpbin + '/get', env=env)
assert HTTP_OK in r
assert 'http: warning' in r.stderr
assert 'cannot read config file' in r.stderr
def test_default_options_overwrite(httpbin):
env = MockEnvironment()
env.config['default_options'] = ['--form']
env.config.save()
r = http('--json', httpbin.url + '/post', 'foo=bar', env=env)
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

@ -2,10 +2,27 @@
Tests for the provided defaults regarding HTTP method, and --json vs. --form. Tests for the provided defaults regarding HTTP method, and --json vs. --form.
""" """
from utils import TestEnvironment, http, HTTP_OK, no_content_type from httpie.client import JSON_ACCEPT
from utils import MockEnvironment, http, HTTP_OK
from fixtures import FILE_PATH from fixtures import FILE_PATH
def test_default_headers_case_insensitive(httpbin):
"""
<https://github.com/jakubroztocil/httpie/issues/644>
"""
r = http(
'--debug',
'--print=H',
httpbin.url + '/post',
'CONTENT-TYPE:application/json-patch+json',
'a=b',
)
assert 'CONTENT-TYPE: application/json-patch+json' 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')
@ -28,25 +45,25 @@ class TestImplicitHTTPMethod:
def test_implicit_POST_stdin(self, httpbin): def test_implicit_POST_stdin(self, httpbin):
with open(FILE_PATH) as f: with open(FILE_PATH) as f:
env = TestEnvironment(stdin_isatty=False, stdin=f) env = MockEnvironment(stdin_isatty=False, stdin=f)
r = http('--form', httpbin.url + '/post', env=env) r = http('--form', httpbin.url + '/post', env=env)
assert HTTP_OK in r assert HTTP_OK in r
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.
""" """
def test_GET_no_data_no_auto_headers(self, httpbin): def test_GET_no_data_no_auto_headers(self, httpbin):
# https://github.com/jkbrzt/httpie/issues/62 # https://github.com/jakubroztocil/httpie/issues/62
r = http('GET', httpbin.url + '/headers') r = http('GET', httpbin.url + '/headers')
assert HTTP_OK in r assert HTTP_OK in r
assert r.json['headers']['Accept'] == '*/*' assert r.json['headers']['Accept'] == '*/*'
assert no_content_type(r.json['headers']) assert 'Content-Type' not in r.json['headers']
def test_POST_no_data_no_auto_headers(self, httpbin): def test_POST_no_data_no_auto_headers(self, httpbin):
# JSON headers shouldn't be automatically set for POST with no data. # JSON headers shouldn't be automatically set for POST with no data.
@ -58,22 +75,22 @@ class TestAutoContentTypeAndAcceptHeaders:
def test_POST_with_data_auto_JSON_headers(self, httpbin): def test_POST_with_data_auto_JSON_headers(self, httpbin):
r = http('POST', httpbin.url + '/post', 'a=b') r = http('POST', httpbin.url + '/post', 'a=b')
assert HTTP_OK in r assert HTTP_OK in r
assert '"Accept": "application/json"' in r assert r.json['headers']['Accept'] == JSON_ACCEPT
assert '"Content-Type": "application/json' in r assert r.json['headers']['Content-Type'] == 'application/json'
def test_GET_with_data_auto_JSON_headers(self, httpbin): def test_GET_with_data_auto_JSON_headers(self, httpbin):
# JSON headers should automatically be set also for GET with data. # JSON headers should automatically be set also for GET with data.
r = http('POST', httpbin.url + '/post', 'a=b') r = http('POST', httpbin.url + '/post', 'a=b')
assert HTTP_OK in r assert HTTP_OK in r
assert '"Accept": "application/json"' in r, r assert r.json['headers']['Accept'] == JSON_ACCEPT
assert '"Content-Type": "application/json' in r 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'] == 'application/json' assert r.json['headers']['Accept'] == JSON_ACCEPT
# Make sure Content-Type gets set even with no data. # Make sure Content-Type gets set even with no data.
# https://github.com/jkbrzt/httpie/issues/137 # https://github.com/jakubroztocil/httpie/issues/137
assert 'application/json' in r.json['headers']['Content-Type'] assert 'application/json' in r.json['headers']['Content-Type']
def test_GET_explicit_JSON_explicit_headers(self, httpbin): def test_GET_explicit_JSON_explicit_headers(self, httpbin):
@ -96,11 +113,11 @@ class TestAutoContentTypeAndAcceptHeaders:
assert '"Content-Type": "application/xml"' in r assert '"Content-Type": "application/xml"' in r
def test_print_only_body_when_stdout_redirected_by_default(self, httpbin): def test_print_only_body_when_stdout_redirected_by_default(self, httpbin):
env = TestEnvironment(stdin_isatty=True, stdout_isatty=False) env = MockEnvironment(stdin_isatty=True, stdout_isatty=False)
r = http('GET', httpbin.url + '/get', env=env) r = http('GET', httpbin.url + '/get', env=env)
assert 'HTTP/' not in r assert 'HTTP/' not in r
def test_print_overridable_when_stdout_redirected(self, httpbin): def test_print_overridable_when_stdout_redirected(self, httpbin):
env = TestEnvironment(stdin_isatty=True, stdout_isatty=False) env = MockEnvironment(stdin_isatty=True, stdout_isatty=False)
r = http('--print=h', 'GET', httpbin.url + '/get', env=env) r = http('--print=h', 'GET', httpbin.url + '/get', env=env)
assert HTTP_OK in r assert HTTP_OK in r

View File

@ -1,15 +1,23 @@
import os import os
import fnmatch
import subprocess import subprocess
from glob import glob
from pathlib import Path
import pytest import pytest
from utils import TESTS_ROOT from utils import TESTS_ROOT
SOURCE_DIRECTORIES = [
'extras',
'httpie',
'tests',
]
def has_docutils(): def has_docutils():
try: try:
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences,PyPackageRequirements
import docutils import docutils
return True return True
except ImportError: except ImportError:
@ -17,23 +25,41 @@ def has_docutils():
def rst_filenames(): def rst_filenames():
for root, dirnames, filenames in os.walk(os.path.dirname(TESTS_ROOT)): cwd = os.getcwd()
if '.tox' not in root: os.chdir(TESTS_ROOT.parent)
for filename in fnmatch.filter(filenames, '*.rst'): try:
yield os.path.join(root, filename) yield from glob('*.rst')
for directory in SOURCE_DIRECTORIES:
yield from glob(f'{directory}/**/*.rst', recursive=True)
finally:
os.chdir(cwd)
filenames = list(rst_filenames()) filenames = list(sorted(rst_filenames()))
assert filenames assert filenames
@pytest.mark.skipif(not has_docutils(), reason='docutils not installed') # HACK: hardcoded paths, venv should be irrelevant, etc.
# TODO: replaces the process with Python code
VENV_BIN = Path(__file__).parent.parent / 'venv/bin'
VENV_PYTHON = VENV_BIN / 'python'
VENV_RST2PSEUDOXML = VENV_BIN / 'rst2pseudoxml.py'
@pytest.mark.skipif(not os.path.exists(VENV_RST2PSEUDOXML), reason='docutils not installed')
@pytest.mark.parametrize('filename', filenames) @pytest.mark.parametrize('filename', filenames)
def test_rst_file_syntax(filename): def test_rst_file_syntax(filename):
p = subprocess.Popen( p = subprocess.Popen(
['rst2pseudoxml.py', '--report=1', '--exit-status=1', filename], [
VENV_PYTHON,
VENV_RST2PSEUDOXML,
'--report=1',
'--exit-status=1',
filename,
],
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
stdout=subprocess.PIPE stdout=subprocess.PIPE,
shell=True,
) )
err = p.communicate()[1] err = p.communicate()[1]
assert p.returncode == 0, err assert p.returncode == 0, err.decode('utf8')

View File

@ -1,18 +1,20 @@
import os import os
import tempfile
import time import time
from urllib.request import urlopen
import pytest import pytest
import mock
from requests.structures import CaseInsensitiveDict from requests.structures import CaseInsensitiveDict
from httpie.compat import urlopen
from httpie.downloads import ( from httpie.downloads import (
parse_content_range, filename_from_content_disposition, filename_from_url, parse_content_range, filename_from_content_disposition, filename_from_url,
get_unique_filename, ContentRangeError, Download, get_unique_filename, ContentRangeError, Downloader,
) )
from utils import http, TestEnvironment from utils import http, MockEnvironment
class Response(object): class Response:
# noinspection PyDefaultArgument # noinspection PyDefaultArgument
def __init__(self, url, headers={}, status_code=200): def __init__(self, url, headers={}, status_code=200):
self.url = url self.url = url
@ -21,6 +23,7 @@ class Response(object):
class TestDownloadUtils: class TestDownloadUtils:
def test_Content_Range_parsing(self): def test_Content_Range_parsing(self):
parse = parse_content_range parse = parse_content_range
@ -74,7 +77,31 @@ class TestDownloadUtils:
content_type='x-foo/bar' content_type='x-foo/bar'
) )
def test_unique_filename(self): @pytest.mark.parametrize(
'orig_name, unique_on_attempt, expected',
[
# Simple
('foo.bar', 0, 'foo.bar'),
('foo.bar', 1, 'foo.bar-1'),
('foo.bar', 10, 'foo.bar-10'),
# Trim
('A' * 20, 0, 'A' * 10),
('A' * 20, 1, 'A' * 8 + '-1'),
('A' * 20, 10, 'A' * 7 + '-10'),
# Trim before ext
('A' * 20 + '.txt', 0, 'A' * 6 + '.txt'),
('A' * 20 + '.txt', 1, 'A' * 4 + '.txt-1'),
# Trim at the end
('foo.' + 'A' * 20, 0, 'foo.' + 'A' * 6),
('foo.' + 'A' * 20, 1, 'foo.' + 'A' * 4 + '-1'),
('foo.' + 'A' * 20, 10, 'foo.' + 'A' * 3 + '-10'),
]
)
@mock.patch('httpie.downloads.get_filename_max_length')
def test_unique_filename(self, get_filename_max_length,
orig_name, unique_on_attempt,
expected):
def attempts(unique_on_attempt=0): def attempts(unique_on_attempt=0):
# noinspection PyUnresolvedReferences,PyUnusedLocal # noinspection PyUnresolvedReferences,PyUnusedLocal
def exists(filename): def exists(filename):
@ -86,54 +113,79 @@ class TestDownloadUtils:
exists.attempt = 0 exists.attempt = 0
return exists return exists
assert 'foo.bar' == get_unique_filename('foo.bar', attempts(0)) get_filename_max_length.return_value = 10
assert 'foo.bar-1' == get_unique_filename('foo.bar', attempts(1))
assert 'foo.bar-10' == get_unique_filename('foo.bar', attempts(10)) actual = get_unique_filename(orig_name, attempts(unique_on_attempt))
assert expected == actual
class TestDownloads: class TestDownloads:
# TODO: more tests # TODO: more tests
def test_actual_download(self, httpbin): def test_actual_download(self, httpbin_both, httpbin):
url = httpbin.url + '/robots.txt' robots_txt = '/robots.txt'
body = urlopen(url).read().decode() body = urlopen(httpbin + robots_txt).read().decode()
env = TestEnvironment(stdin_isatty=True, stdout_isatty=False) env = MockEnvironment(stdin_isatty=True, stdout_isatty=False)
r = http('--download', url, env=env) r = http('--download', httpbin_both.url + robots_txt, env=env)
assert 'Downloading' in r.stderr assert 'Downloading' in r.stderr
assert '[K' in r.stderr assert '[K' in r.stderr
assert 'Done' in r.stderr assert 'Done' in r.stderr
assert body == r assert body == r
def test_download_with_Content_Length(self, httpbin): def test_download_with_Content_Length(self, httpbin_both):
devnull = open(os.devnull, 'w') with open(os.devnull, 'w') as devnull:
download = Download(output_file=devnull, progress_file=devnull) downloader = Downloader(output_file=devnull, progress_file=devnull)
download.start(Response( downloader.start(
url=httpbin.url + '/', initial_url='/',
headers={'Content-Length': 10} final_response=Response(
)) url=httpbin_both.url + '/',
time.sleep(1.1) headers={'Content-Length': 10}
download.chunk_downloaded(b'12345') )
time.sleep(1.1) )
download.chunk_downloaded(b'12345') time.sleep(1.1)
download.finish() downloader.chunk_downloaded(b'12345')
assert not download.interrupted time.sleep(1.1)
downloader.chunk_downloaded(b'12345')
downloader.finish()
assert not downloader.interrupted
downloader._progress_reporter.join()
def test_download_no_Content_Length(self, httpbin): def test_download_no_Content_Length(self, httpbin_both):
devnull = open(os.devnull, 'w') with open(os.devnull, 'w') as devnull:
download = Download(output_file=devnull, progress_file=devnull) downloader = Downloader(output_file=devnull, progress_file=devnull)
download.start(Response(url=httpbin.url + '/')) downloader.start(
time.sleep(1.1) final_response=Response(url=httpbin_both.url + '/'),
download.chunk_downloaded(b'12345') initial_url='/'
download.finish() )
assert not download.interrupted time.sleep(1.1)
downloader.chunk_downloaded(b'12345')
downloader.finish()
assert not downloader.interrupted
downloader._progress_reporter.join()
def test_download_interrupted(self, httpbin): def test_download_interrupted(self, httpbin_both):
devnull = open(os.devnull, 'w') with open(os.devnull, 'w') as devnull:
download = Download(output_file=devnull, progress_file=devnull) downloader = Downloader(output_file=devnull, progress_file=devnull)
download.start(Response( downloader.start(
url=httpbin.url + '/', final_response=Response(
headers={'Content-Length': 5} url=httpbin_both.url + '/',
)) headers={'Content-Length': 5}
download.chunk_downloaded(b'1234') ),
download.finish() initial_url='/'
assert download.interrupted )
downloader.chunk_downloaded(b'1234')
downloader.finish()
assert downloader.interrupted
downloader._progress_reporter.join()
def test_download_with_redirect_original_url_used_for_filename(self, httpbin):
# Redirect from `/redirect/1` to `/get`.
expected_filename = '1.json'
orig_cwd = os.getcwd()
os.chdir(tempfile.mkdtemp(prefix='httpie_download_test_'))
try:
assert os.listdir('.') == []
http('--download', httpbin.url + '/redirect/1')
assert os.listdir('.') == [expected_filename]
finally:
os.chdir(orig_cwd)

View File

@ -1,48 +1,41 @@
import mock import mock
from pytest import raises from pytest import raises
from requests import Request, Timeout from requests import Request
from requests.exceptions import ConnectionError from requests.exceptions import ConnectionError
from httpie.core import main from httpie.status import ExitStatus
from utils import HTTP_OK, http
error_msg = None
@mock.patch('httpie.core.get_response') @mock.patch('httpie.core.program')
def test_error(get_response): def test_error(program):
def error(msg, *args, **kwargs):
global error_msg
error_msg = msg % args
exc = ConnectionError('Connection aborted') exc = ConnectionError('Connection aborted')
exc.request = Request(method='GET', url='http://www.google.com') exc.request = Request(method='GET', url='http://www.google.com')
get_response.side_effect = exc program.side_effect = exc
ret = main(['--ignore-stdin', 'www.google.com'], error=error) r = http('www.google.com', tolerate_error_exit_status=True)
assert ret == 1 assert r.exit_status == ExitStatus.ERROR
assert error_msg == ( error_msg = (
'ConnectionError: ' 'ConnectionError: '
'Connection aborted while doing GET request to URL: ' 'Connection aborted while doing a GET request to URL: '
'http://www.google.com') 'http://www.google.com'
)
assert error_msg in r.stderr
@mock.patch('httpie.core.get_response') @mock.patch('httpie.core.program')
def test_error_traceback(get_response): def test_error_traceback(program):
exc = ConnectionError('Connection aborted') exc = ConnectionError('Connection aborted')
exc.request = Request(method='GET', url='http://www.google.com') exc.request = Request(method='GET', url='http://www.google.com')
get_response.side_effect = exc program.side_effect = exc
with raises(ConnectionError): with raises(ConnectionError):
ret = main(['--ignore-stdin', '--traceback', 'www.google.com']) http('--traceback', 'www.google.com')
@mock.patch('httpie.core.get_response') def test_max_headers_limit(httpbin_both):
def test_timeout(get_response): with raises(ConnectionError) as e:
def error(msg, *args, **kwargs): http('--max-headers=1', httpbin_both + '/get')
global error_msg assert 'got more than 1 headers' in str(e.value)
error_msg = msg % args
exc = Timeout('Request timed out')
exc.request = Request(method='GET', url='http://www.google.com') def test_max_headers_no_limit(httpbin_both):
get_response.side_effect = exc assert HTTP_OK in http('--max-headers=0', httpbin_both + '/get')
ret = main(['--ignore-stdin', 'www.google.com'], error=error)
assert ret == 2
assert error_msg == 'Request timed out (30s).'

View File

@ -1,63 +1,74 @@
import requests import mock
import pytest
from httpie import ExitStatus from httpie.status import ExitStatus
from utils import TestEnvironment, http, HTTP_OK from utils import MockEnvironment, http, HTTP_OK
class TestExitStatus: def test_keyboard_interrupt_during_arg_parsing_exit_status(httpbin):
def test_ok_response_exits_0(self, httpbin): with mock.patch('httpie.cli.definition.parser.parse_args',
r = http('GET', httpbin.url + '/status/200') side_effect=KeyboardInterrupt()):
assert HTTP_OK in r r = http('GET', httpbin.url + '/get', tolerate_error_exit_status=True)
assert r.exit_status == ExitStatus.OK assert r.exit_status == ExitStatus.ERROR_CTRL_C
def test_error_response_exits_0_without_check_status(self, httpbin):
r = http('GET', httpbin.url + '/status/500')
assert '500 INTERNAL SERVER ERRO' in r
assert r.exit_status == ExitStatus.OK
assert not r.stderr
@pytest.mark.skipif( def test_keyboard_interrupt_in_program_exit_status(httpbin):
tuple(map(int, requests.__version__.split('.'))) < (2, 3, 0), with mock.patch('httpie.core.program',
reason='timeout broken in requests prior v2.3.0 (#185)' side_effect=KeyboardInterrupt()):
) r = http('GET', httpbin.url + '/get', tolerate_error_exit_status=True)
def test_timeout_exit_status(self, httpbin): assert r.exit_status == ExitStatus.ERROR_CTRL_C
r = http('--timeout=0.5', 'GET', httpbin.url + '/delay/1',
error_exit_ok=True)
assert r.exit_status == ExitStatus.ERROR_TIMEOUT
def test_3xx_check_status_exits_3_and_stderr_when_stdout_redirected( def test_ok_response_exits_0(httpbin):
self, httpbin): r = http('GET', httpbin.url + '/get')
env = TestEnvironment(stdout_isatty=False) assert HTTP_OK in r
r = http('--check-status', '--headers', assert r.exit_status == ExitStatus.SUCCESS
'GET', httpbin.url + '/status/301',
env=env, error_exit_ok=True)
assert '301 MOVED PERMANENTLY' in r
assert r.exit_status == ExitStatus.ERROR_HTTP_3XX
assert '301 moved permanently' in r.stderr.lower()
@pytest.mark.skipif(
requests.__version__ == '0.13.6',
reason='Redirects with prefetch=False are broken in Requests 0.13.6')
def test_3xx_check_status_redirects_allowed_exits_0(self, httpbin):
r = http('--check-status', '--follow',
'GET', httpbin.url + '/status/301',
error_exit_ok=True)
# The redirect will be followed so 200 is expected.
assert HTTP_OK in r
assert r.exit_status == ExitStatus.OK
def test_4xx_check_status_exits_4(self, httpbin): def test_error_response_exits_0_without_check_status(httpbin):
r = http('--check-status', 'GET', httpbin.url + '/status/401', r = http('GET', httpbin.url + '/status/500')
error_exit_ok=True) assert '500 INTERNAL SERVER ERROR' in r
assert '401 UNAUTHORIZED' in r assert r.exit_status == ExitStatus.SUCCESS
assert r.exit_status == ExitStatus.ERROR_HTTP_4XX assert not r.stderr
# Also stderr should be empty since stdout isn't redirected.
assert not r.stderr
def test_5xx_check_status_exits_5(self, httpbin):
r = http('--check-status', 'GET', httpbin.url + '/status/500', def test_timeout_exit_status(httpbin):
error_exit_ok=True)
assert '500 INTERNAL SERVER ERROR' in r r = http('--timeout=0.01', 'GET', httpbin.url + '/delay/0.5',
assert r.exit_status == ExitStatus.ERROR_HTTP_5XX tolerate_error_exit_status=True)
assert r.exit_status == ExitStatus.ERROR_TIMEOUT
def test_3xx_check_status_exits_3_and_stderr_when_stdout_redirected(
httpbin):
env = MockEnvironment(stdout_isatty=False)
r = http('--check-status', '--headers',
'GET', httpbin.url + '/status/301',
env=env, tolerate_error_exit_status=True)
assert '301 MOVED PERMANENTLY' in r
assert r.exit_status == ExitStatus.ERROR_HTTP_3XX
assert '301 moved permanently' in r.stderr.lower()
def test_3xx_check_status_redirects_allowed_exits_0(httpbin):
r = http('--check-status', '--follow',
'GET', httpbin.url + '/status/301',
tolerate_error_exit_status=True)
# The redirect will be followed so 200 is expected.
assert HTTP_OK in r
assert r.exit_status == ExitStatus.SUCCESS
def test_4xx_check_status_exits_4(httpbin):
r = http('--check-status', 'GET', httpbin.url + '/status/401',
tolerate_error_exit_status=True)
assert '401 UNAUTHORIZED' in r
assert r.exit_status == ExitStatus.ERROR_HTTP_4XX
# Also stderr should be empty since stdout isn't redirected.
assert not r.stderr
def test_5xx_check_status_exits_5(httpbin):
r = http('--check-status', 'GET', httpbin.url + '/status/500',
tolerate_error_exit_status=True)
assert '500 INTERNAL SERVER ERROR' in r
assert r.exit_status == ExitStatus.ERROR_HTTP_5XX

View File

@ -1,79 +1,201 @@
"""High-level tests.""" """High-level tests."""
import io
from unittest import mock
import pytest import pytest
from utils import TestEnvironment, http, HTTP_OK
import httpie.__main__
from httpie.context import Environment
from httpie.status import ExitStatus
from httpie.cli.exceptions import ParseError
from utils import MockEnvironment, http, HTTP_OK
from fixtures import FILE_PATH, FILE_CONTENT from fixtures import FILE_PATH, FILE_CONTENT
import httpie import httpie
from httpie.compat import is_py26
class TestHTTPie: def test_main_entry_point():
# Patch stdin to bypass pytest capture
with mock.patch.object(Environment, 'stdin', io.StringIO()):
with pytest.raises(SystemExit) as e:
httpie.__main__.main()
assert e.value.code == ExitStatus.ERROR
def test_debug(self):
r = http('--debug')
assert r.exit_status == httpie.ExitStatus.OK
assert 'HTTPie %s' % httpie.__version__ in r.stderr
assert 'HTTPie data:' in r.stderr
def test_help(self): @mock.patch('httpie.core.main')
r = http('--help', error_exit_ok=True) def test_main_entry_point_keyboard_interrupt(main):
assert r.exit_status == httpie.ExitStatus.OK main.side_effect = KeyboardInterrupt()
assert 'https://github.com/jkbrzt/httpie/issues' in r with mock.patch.object(Environment, 'stdin', io.StringIO()):
with pytest.raises(SystemExit) as e:
httpie.__main__.main()
assert e.value.code == ExitStatus.ERROR_CTRL_C
def test_version(self):
r = http('--version', error_exit_ok=True)
assert r.exit_status == httpie.ExitStatus.OK
# FIXME: py3 has version in stdout, py2 in stderr
assert httpie.__version__ == r.stderr.strip() + r.strip()
def test_GET(self, httpbin): def test_debug():
r = http('GET', httpbin.url + '/get') r = http('--debug')
assert HTTP_OK in r assert r.exit_status == ExitStatus.SUCCESS
assert 'HTTPie %s' % httpie.__version__ in r.stderr
def test_DELETE(self, httpbin):
r = http('DELETE', httpbin.url + '/delete')
assert HTTP_OK in r
def test_PUT(self, httpbin): def test_help():
r = http('PUT', httpbin.url + '/put', 'foo=bar') r = http('--help', tolerate_error_exit_status=True)
assert HTTP_OK in r assert r.exit_status == ExitStatus.SUCCESS
assert r.json['json']['foo'] == 'bar' assert 'https://github.com/jakubroztocil/httpie/issues' in r
def test_POST_JSON_data(self, httpbin):
r = http('POST', httpbin.url + '/post', 'foo=bar')
assert HTTP_OK in r
assert r.json['json']['foo'] == 'bar'
def test_POST_form(self, httpbin): def test_version():
r = http('--form', 'POST', httpbin.url + '/post', 'foo=bar') r = http('--version', tolerate_error_exit_status=True)
assert HTTP_OK in r assert r.exit_status == ExitStatus.SUCCESS
assert '"foo": "bar"' in r # FIXME: py3 has version in stdout, py2 in stderr
assert httpie.__version__ == r.strip()
def test_POST_form_multiple_values(self, httpbin):
r = http('--form', 'POST', httpbin.url + '/post', 'foo=bar', 'foo=baz')
assert HTTP_OK in r
assert r.json['form'] == {'foo': ['bar', 'baz']}
def test_POST_stdin(self, httpbin): def test_GET(httpbin_both):
with open(FILE_PATH) as f: r = http('GET', httpbin_both + '/get')
env = TestEnvironment(stdin=f, stdin_isatty=False) assert HTTP_OK in r
r = http('--form', 'POST', httpbin.url + '/post', env=env)
assert HTTP_OK in r
assert FILE_CONTENT in r
def test_headers(self, httpbin):
r = http('GET', httpbin.url + '/headers', 'Foo:bar')
assert HTTP_OK in r
assert '"User-Agent": "HTTPie' in r, r
assert '"Foo": "bar"' in r
@pytest.mark.skipif( def test_path_dot_normalization():
is_py26, r = http(
reason='the `object_pairs_hook` arg for `json.loads()` is Py>2.6 only' '--offline',
'example.org/../../etc/password',
'param==value'
) )
def test_json_input_preserve_order(self, httpbin): assert 'GET /etc/password?param=value' in r
r = http('PATCH', httpbin.url + '/patch',
'order:={"map":{"1":"first","2":"second"}}')
assert HTTP_OK in r def test_path_as_is():
assert r.json['data'] == \ r = http(
'{"order": {"map": {"1": "first", "2": "second"}}}' '--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
def test_PUT(httpbin_both):
r = http('PUT', httpbin_both + '/put', 'foo=bar')
assert HTTP_OK in r
assert r.json['json']['foo'] == 'bar'
def test_POST_JSON_data(httpbin_both):
r = http('POST', httpbin_both + '/post', 'foo=bar')
assert HTTP_OK in r
assert r.json['json']['foo'] == 'bar'
def test_POST_form(httpbin_both):
r = http('--form', 'POST', httpbin_both + '/post', 'foo=bar')
assert HTTP_OK in r
assert '"foo": "bar"' in r
def test_POST_form_multiple_values(httpbin_both):
r = http('--form', 'POST', httpbin_both + '/post', 'foo=bar', 'foo=baz')
assert HTTP_OK in r
assert r.json['form'] == {'foo': ['bar', 'baz']}
def test_POST_stdin(httpbin_both):
with open(FILE_PATH) as f:
env = MockEnvironment(stdin=f, stdin_isatty=False)
r = http('--form', 'POST', httpbin_both + '/post', env=env)
assert HTTP_OK in r
assert FILE_CONTENT in r
def test_POST_file(httpbin_both):
r = http('--form', 'POST', httpbin_both + '/post', 'file@' + FILE_PATH)
assert HTTP_OK 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):
r = http('GET', httpbin_both + '/headers', 'Foo:bar')
assert HTTP_OK in r
assert '"User-Agent": "HTTPie' in r, r
assert '"Foo": "bar"' in r
def test_headers_unset(httpbin_both):
r = http('GET', httpbin_both + '/headers')
assert 'Accept' in r.json['headers'] # default Accept present
r = http('GET', httpbin_both + '/headers', 'Accept:')
assert 'Accept' not in r.json['headers'] # default Accept unset
@pytest.mark.skip('unimplemented')
def test_unset_host_header(httpbin_both):
r = http('GET', httpbin_both + '/headers')
assert 'Host' in r.json['headers'] # default Host present
r = http('GET', httpbin_both + '/headers', 'Host:')
assert 'Host' not in r.json['headers'] # default Host unset
def test_headers_empty_value(httpbin_both):
r = http('GET', httpbin_both + '/headers')
assert r.json['headers']['Accept'] # default Accept has value
r = http('GET', httpbin_both + '/headers', 'Accept;')
assert r.json['headers']['Accept'] == '' # Accept has no value
def test_headers_empty_value_with_value_gives_error(httpbin):
with pytest.raises(ParseError):
http('GET', httpbin + '/headers', 'Accept;SYNTAX_ERROR')
def test_json_input_preserve_order(httpbin_both):
r = http('PATCH', httpbin_both + '/patch',
'order:={"map":{"1":"first","2":"second"}}')
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,8 +1,35 @@
import argparse
import json
import os
from tempfile import gettempdir
from urllib.request import urlopen
import pytest import pytest
from utils import TestEnvironment, http, HTTP_OK, COLOR, CRLF from httpie.cli.argtypes import (
from httpie 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])
def test_output_option(httpbin, stdout_isatty):
output_filename = os.path.join(gettempdir(), test_output_option.__name__)
url = httpbin + '/robots.txt'
r = http('--output', output_filename, url,
env=MockEnvironment(stdout_isatty=stdout_isatty))
assert r == ''
expected_body = urlopen(url).read().decode()
with open(output_filename, 'r') as f:
actual_body = f.read()
assert actual_body == expected_body
class TestVerboseFlag: class TestVerboseFlag:
@ -13,7 +40,7 @@ class TestVerboseFlag:
assert r.count('__test__') == 2 assert r.count('__test__') == 2
def test_verbose_form(self, httpbin): def test_verbose_form(self, httpbin):
# https://github.com/jkbrzt/httpie/issues/53 # https://github.com/jakubroztocil/httpie/issues/53
r = http('--verbose', '--form', 'POST', httpbin.url + '/post', r = http('--verbose', '--form', 'POST', httpbin.url + '/post',
'A=B', 'C=D') 'A=B', 'C=D')
assert HTTP_OK in r assert HTTP_OK in r
@ -25,35 +52,48 @@ class TestVerboseFlag:
assert HTTP_OK in r assert HTTP_OK in r
assert '"baz": "bar"' in r assert '"baz": "bar"' in r
def test_verbose_implies_all(self, httpbin):
r = http('--verbose', '--follow', httpbin + '/redirect/1')
assert 'GET /redirect/1 HTTP/1.1' in r
assert 'HTTP/1.1 302 FOUND' in r
assert 'GET /get HTTP/1.1' in r
assert HTTP_OK in r
class TestColors: class TestColors:
@pytest.mark.parametrize('mime', [ @pytest.mark.parametrize(
'application/json', argnames=['mime', 'explicit_json', 'body', 'expected_lexer_name'],
'application/json+foo', argvalues=[
'application/foo+json', ('application/json', False, None, 'JSON'),
'application/json-foo', ('application/json+foo', False, None, 'JSON'),
'application/x-json', ('application/foo+json', False, None, 'JSON'),
'foo/json', ('application/json-foo', False, None, 'JSON'),
'foo/json+bar', ('application/x-json', False, None, 'JSON'),
'foo/bar+json', ('foo/json', False, None, 'JSON'),
'foo/json-foo', ('foo/json+bar', False, None, 'JSON'),
'foo/x-json', ('foo/bar+json', False, None, 'JSON'),
]) ('foo/json-foo', False, None, 'JSON'),
def test_get_lexer(self, mime): ('foo/x-json', False, None, 'JSON'),
lexer = get_lexer(mime) ('application/vnd.comverge.grid+hal+json', False, None, 'JSON'),
('text/plain', True, '{}', 'JSON'),
('text/plain', True, 'foo', 'Text only'),
]
)
def test_get_lexer(self, mime, explicit_json, body, expected_lexer_name):
lexer = get_lexer(mime, body=body, explicit_json=explicit_json)
assert lexer is not None assert lexer is not None
assert lexer.name == 'JSON' assert lexer.name == expected_lexer_name
def test_get_lexer_not_found(self): def test_get_lexer_not_found(self):
assert get_lexer('xxx/yyy') is None assert get_lexer('xxx/yyy') is None
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 = TestEnvironment(colors=256) env = MockEnvironment(colors=256)
r = http('GET', httpbin.url + '/get', env=env) r = http('GET', httpbin.url + '/get', env=env)
assert COLOR in r assert COLOR in r
@ -62,7 +102,7 @@ class TestPrettyOptions:
assert COLOR not in r assert COLOR not in r
def test_force_pretty(self, httpbin): def test_force_pretty(self, httpbin):
env = TestEnvironment(stdout_isatty=False, colors=256) env = MockEnvironment(stdout_isatty=False, colors=256)
r = http('--pretty=all', 'GET', httpbin.url + '/get', env=env, ) r = http('--pretty=all', 'GET', httpbin.url + '/get', env=env, )
assert COLOR in r assert COLOR in r
@ -75,13 +115,13 @@ class TestPrettyOptions:
match any lexer. match any lexer.
""" """
env = TestEnvironment(colors=256) env = MockEnvironment(colors=256)
r = http('--print=B', '--pretty=all', httpbin.url + '/post', r = http('--print=B', '--pretty=all', httpbin.url + '/post',
'Content-Type:text/foo+json', 'a=b', env=env) 'Content-Type:text/foo+json', 'a=b', env=env)
assert COLOR in r assert COLOR in r
def test_colors_option(self, httpbin): def test_colors_option(self, httpbin):
env = TestEnvironment(colors=256) env = MockEnvironment(colors=256)
r = http('--print=B', '--pretty=colors', r = http('--print=B', '--pretty=colors',
'GET', httpbin.url + '/get', 'a=b', 'GET', httpbin.url + '/get', 'a=b',
env=env) env=env)
@ -90,7 +130,7 @@ class TestPrettyOptions:
assert COLOR in r assert COLOR in r
def test_format_option(self, httpbin): def test_format_option(self, httpbin):
env = TestEnvironment(colors=256) env = MockEnvironment(colors=256)
r = http('--print=B', '--pretty=format', r = http('--print=B', '--pretty=format',
'GET', httpbin.url + '/get', 'a=b', 'GET', httpbin.url + '/get', 'a=b',
env=env) env=env)
@ -105,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:
@ -128,7 +169,7 @@ class TestLineEndings:
def test_CRLF_formatted_response(self, httpbin): def test_CRLF_formatted_response(self, httpbin):
r = http('--pretty=format', 'GET', httpbin.url + '/get') r = http('--pretty=format', 'GET', httpbin.url + '/get')
assert r.exit_status == ExitStatus.OK assert r.exit_status == ExitStatus.SUCCESS
self._validate_crlf(r) self._validate_crlf(r)
def test_CRLF_ugly_request(self, httpbin): def test_CRLF_ugly_request(self, httpbin):
@ -138,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

52
tests/test_redirects.py Normal file
View File

@ -0,0 +1,52 @@
"""High-level tests."""
import pytest
from httpie.status import ExitStatus
from utils import http, HTTP_OK
def test_follow_all_redirects_shown(httpbin):
r = http('--follow', '--all', httpbin.url + '/redirect/2')
assert r.count('HTTP/1.1') == 3
assert r.count('HTTP/1.1 302 FOUND', 2)
assert HTTP_OK in r
@pytest.mark.parametrize('follow_flag', ['--follow', '-F'])
def test_follow_without_all_redirects_hidden(httpbin, follow_flag):
r = http(follow_flag, httpbin.url + '/redirect/2')
assert r.count('HTTP/1.1') == 1
assert HTTP_OK in r
def test_follow_all_output_options_used_for_redirects(httpbin):
r = http('--check-status',
'--follow',
'--all',
'--print=H',
httpbin.url + '/redirect/2')
assert r.count('GET /') == 3
assert HTTP_OK not in r
#
# def test_follow_redirect_output_options(httpbin):
# r = http('--check-status',
# '--follow',
# '--all',
# '--print=h',
# '--history-print=H',
# httpbin.url + '/redirect/2')
# assert r.count('GET /') == 2
# assert 'HTTP/1.1 302 FOUND' not in r
# assert HTTP_OK in r
#
def test_max_redirects(httpbin):
r = http(
'--max-redirects=1',
'--follow',
httpbin.url + '/redirect/3',
tolerate_error_exit_status=True,
)
assert r.exit_status == ExitStatus.ERROR_TOO_MANY_REDIRECTS

View File

@ -7,7 +7,7 @@ from httpie.compat import is_windows
def test_Host_header_overwrite(httpbin): def test_Host_header_overwrite(httpbin):
""" """
https://github.com/jkbrzt/httpie/issues/235 https://github.com/jakubroztocil/httpie/issues/235
""" """
host = 'httpbin.org' host = 'httpbin.org'
@ -21,7 +21,7 @@ def test_Host_header_overwrite(httpbin):
@pytest.mark.skipif(is_windows, reason='Unix-only') @pytest.mark.skipif(is_windows, reason='Unix-only')
def test_output_devnull(httpbin): def test_output_devnull(httpbin):
""" """
https://github.com/jkbrzt/httpie/issues/252 https://github.com/jakubroztocil/httpie/issues/252
""" """
http('--output=/dev/null', httpbin + '/get') http('--output=/dev/null', httpbin + '/get')

View File

@ -1,17 +1,20 @@
# 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
import pytest import pytest
from httpie.plugins.builtin import HTTPBasicAuth
from utils import TestEnvironment, mk_config_dir, http, HTTP_OK, \
no_content_type
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(object): class SessionTestBase:
def start_session(self, httpbin): def start_session(self, httpbin):
"""Create and reuse a unique config dir for each test.""" """Create and reuse a unique config dir for each test."""
@ -24,12 +27,12 @@ class SessionTestBase(object):
""" """
Return an environment. Return an environment.
Each environment created withing a test method Each environment created within a test method
will share the same config_dir. It is necessary will share the same config_dir. It is necessary
for session files being reused. for session files being reused.
""" """
return TestEnvironment(config_dir=self.config_dir) return MockEnvironment(config_dir=self.config_dir)
class TestSessionFlow(SessionTestBase): class TestSessionFlow(SessionTestBase):
@ -44,11 +47,16 @@ class TestSessionFlow(SessionTestBase):
authorization, and response cookies. authorization, and response cookies.
""" """
super(TestSessionFlow, self).start_session(httpbin) super().start_session(httpbin)
r1 = http('--follow', '--session=test', '--auth=username:password', r1 = http(
'GET', httpbin.url + '/cookies/set?hello=world', '--follow',
'Hello:World', '--session=test',
env=self.env()) '--auth=username:password',
'GET',
httpbin.url + '/cookies/set?hello=world',
'Hello:World',
env=self.env()
)
assert HTTP_OK in r1 assert HTTP_OK in r1
def test_session_created_and_reused(self, httpbin): def test_session_created_and_reused(self, httpbin):
@ -81,8 +89,8 @@ class TestSessionFlow(SessionTestBase):
assert HTTP_OK in r4 assert HTTP_OK in r4
assert r4.json['headers']['Hello'] == 'World2' assert r4.json['headers']['Hello'] == 'World2'
assert r4.json['headers']['Cookie'] == 'hello=world2' assert r4.json['headers']['Cookie'] == 'hello=world2'
assert (r2.json['headers']['Authorization'] != assert (r2.json['headers']['Authorization']
r4.json['headers']['Authorization']) != r4.json['headers']['Authorization'])
def test_session_read_only(self, httpbin): def test_session_read_only(self, httpbin):
self.start_session(httpbin) self.start_session(httpbin)
@ -125,25 +133,21 @@ class TestSession(SessionTestBase):
r2 = http('--session=test', 'GET', httpbin.url + '/get', r2 = http('--session=test', 'GET', httpbin.url + '/get',
env=self.env()) env=self.env())
assert HTTP_OK in r2 assert HTTP_OK in r2
assert no_content_type(r2.json['headers']) assert 'Content-Type' not in r2.json['headers']
assert 'If-Unmodified-Since' not in r2.json['headers'] assert 'If-Unmodified-Since' not in r2.json['headers']
def test_session_by_path(self, httpbin): def test_session_by_path(self, httpbin):
self.start_session(httpbin) self.start_session(httpbin)
session_path = os.path.join(self.config_dir, 'session-by-path.json') session_path = self.config_dir / 'session-by-path.json'
r1 = http('--session=' + session_path, 'GET', httpbin.url + '/get', r1 = http('--session', str(session_path), 'GET', httpbin.url + '/get',
'Foo:Bar', env=self.env()) 'Foo:Bar', env=self.env())
assert HTTP_OK in r1 assert HTTP_OK in r1
r2 = http('--session=' + session_path, 'GET', httpbin.url + '/get', r2 = http('--session', str(session_path), 'GET', httpbin.url + '/get',
env=self.env()) env=self.env())
assert HTTP_OK in r2 assert HTTP_OK in r2
assert r2.json['headers']['Foo'] == 'Bar' assert r2.json['headers']['Foo'] == 'Bar'
@pytest.mark.skipif(
sys.version_info >= (3,),
reason="This test fails intermittently on Python 3 - "
"see https://github.com/jkbrzt/httpie/issues/282")
def test_session_unicode(self, httpbin): def test_session_unicode(self, httpbin):
self.start_session(httpbin) self.start_session(httpbin)
@ -164,7 +168,7 @@ class TestSession(SessionTestBase):
def test_session_default_header_value_overwritten(self, httpbin): def test_session_default_header_value_overwritten(self, httpbin):
self.start_session(httpbin) self.start_session(httpbin)
# https://github.com/jkbrzt/httpie/issues/180 # https://github.com/jakubroztocil/httpie/issues/180
r1 = http('--session=test', r1 = http('--session=test',
httpbin.url + '/headers', 'User-Agent:custom', httpbin.url + '/headers', 'User-Agent:custom',
env=self.env()) env=self.env())
@ -174,3 +178,102 @@ class TestSession(SessionTestBase):
r2 = http('--session=test', httpbin.url + '/headers', env=self.env()) r2 = http('--session=test', httpbin.url + '/headers', env=self.env())
assert HTTP_OK in r2 assert HTTP_OK in r2
assert r2.json['headers']['User-Agent'] == 'custom' assert r2.json['headers']['User-Agent'] == 'custom'
def test_download_in_session(self, httpbin):
# https://github.com/jakubroztocil/httpie/issues/412
self.start_session(httpbin)
cwd = os.getcwd()
os.chdir(gettempdir())
try:
http('--session=test', '--download',
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,79 +1,136 @@
import os
import pytest import pytest
import pytest_httpbin.certs import pytest_httpbin.certs
from requests.exceptions import SSLError import requests.exceptions
from httpie import ExitStatus from httpie.ssl import AVAILABLE_SSL_VERSION_ARG_MAPPING, DEFAULT_SSL_CIPHERS
from utils import http, HTTP_OK, TESTS_ROOT from httpie.status import ExitStatus
from utils import HTTP_OK, TESTS_ROOT, http
CLIENT_CERT = os.path.join(TESTS_ROOT, 'client_certs', 'client.crt') try:
CLIENT_KEY = os.path.join(TESTS_ROOT, 'client_certs', 'client.key') # Handle OpenSSL errors, if installed.
CLIENT_PEM = os.path.join(TESTS_ROOT, 'client_certs', 'client.pem') # See <https://github.com/jakubroztocil/httpie/issues/729>
# noinspection PyUnresolvedReferences
import OpenSSL.SSL
ssl_errors = (
requests.exceptions.SSLError,
OpenSSL.SSL.Error,
)
except ImportError:
ssl_errors = (
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')
# 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.
# See: https://github.com/kevin1024/pytest-httpbin#https-support # See: https://github.com/kevin1024/pytest-httpbin#https-support
CA_BUNDLE = pytest_httpbin.certs.where() CA_BUNDLE = pytest_httpbin.certs.where()
class TestClientSSLCertHandling(object): @pytest.mark.parametrize('ssl_version',
AVAILABLE_SSL_VERSION_ARG_MAPPING.keys())
def test_ssl_version(httpbin_secure, ssl_version):
try:
r = http(
'--ssl', ssl_version,
httpbin_secure + '/get'
)
assert HTTP_OK in r
except ssl_errors as e:
if ssl_version == 'ssl3':
# pytest-httpbin doesn't support ssl3
pass
else:
raise
def test_cert_file_not_found(self, httpbin_secure):
r = http(httpbin_secure + '/get',
'--verify', CA_BUNDLE,
'--cert', '/__not_found__',
error_exit_ok=True)
assert r.exit_status == ExitStatus.ERROR
assert 'No such file or directory' in r.stderr
def test_cert_file_invalid(self, httpbin_secure): class TestClientCert:
with pytest.raises(SSLError):
http(httpbin_secure + '/get',
'--verify', CA_BUNDLE,
'--cert', __file__)
def test_cert_ok_but_missing_key(self, httpbin_secure):
with pytest.raises(SSLError):
http(httpbin_secure + '/get',
'--verify', CA_BUNDLE,
'--cert', CLIENT_CERT)
def test_cert_and_key(self, httpbin_secure): def test_cert_and_key(self, httpbin_secure):
r = http(httpbin_secure + '/get', r = http(httpbin_secure + '/get',
'--verify', CA_BUNDLE,
'--cert', CLIENT_CERT, '--cert', CLIENT_CERT,
'--cert-key', CLIENT_KEY) '--cert-key', CLIENT_KEY)
assert HTTP_OK in r assert HTTP_OK in r
def test_cert_pem(self, httpbin_secure): def test_cert_pem(self, httpbin_secure):
r = http(httpbin_secure + '/get', r = http(httpbin_secure + '/get',
'--verify', CA_BUNDLE,
'--cert', CLIENT_PEM) '--cert', CLIENT_PEM)
assert HTTP_OK in r assert HTTP_OK in r
def test_cert_file_not_found(self, httpbin_secure):
r = http(httpbin_secure + '/get',
'--cert', '/__not_found__',
tolerate_error_exit_status=True)
assert r.exit_status == ExitStatus.ERROR
assert 'No such file or directory' in r.stderr
class TestServerSSLCertHandling(object): def test_cert_file_invalid(self, httpbin_secure):
with pytest.raises(ssl_errors):
http(httpbin_secure + '/get',
'--cert', __file__)
def test_self_signed_server_cert_by_default_raises_ssl_error( def test_cert_ok_but_missing_key(self, httpbin_secure):
self, httpbin_secure): with pytest.raises(ssl_errors):
with pytest.raises(SSLError): http(httpbin_secure + '/get',
http(httpbin_secure.url + '/get') '--cert', CLIENT_CERT)
class TestServerCert:
def test_verify_no_OK(self, httpbin_secure): def test_verify_no_OK(self, httpbin_secure):
r = http(httpbin_secure.url + '/get', '--verify=no') r = http(httpbin_secure.url + '/get', '--verify=no')
assert HTTP_OK in r assert HTTP_OK in r
def test_verify_custom_ca_bundle_path( @pytest.mark.parametrize('verify_value', ['false', 'fALse'])
self, httpbin_secure): def test_verify_false_OK(self, httpbin_secure, verify_value):
r = http(httpbin_secure.url + '/get', '--verify', CA_BUNDLE) r = http(httpbin_secure.url + '/get', '--verify', verify_value)
assert HTTP_OK in r assert HTTP_OK in r
def test_verify_custom_ca_bundle_path(
self, httpbin_secure_untrusted
):
r = http(httpbin_secure_untrusted + '/get', '--verify', CA_BUNDLE)
assert HTTP_OK in r
def test_self_signed_server_cert_by_default_raises_ssl_error(
self,
httpbin_secure_untrusted
):
with pytest.raises(ssl_errors):
http(httpbin_secure_untrusted.url + '/get')
def test_verify_custom_ca_bundle_invalid_path(self, httpbin_secure): def test_verify_custom_ca_bundle_invalid_path(self, httpbin_secure):
with pytest.raises(SSLError): # since 2.14.0 requests raises IOError
with pytest.raises(ssl_errors + (IOError,)):
http(httpbin_secure.url + '/get', '--verify', '/__not_found__') http(httpbin_secure.url + '/get', '--verify', '/__not_found__')
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(SSLError): 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

@ -2,41 +2,43 @@ import pytest
from httpie.compat import is_windows from httpie.compat import is_windows
from httpie.output.streams import BINARY_SUPPRESSED_NOTICE from httpie.output.streams import BINARY_SUPPRESSED_NOTICE
from utils import http, TestEnvironment from utils import http, MockEnvironment
from fixtures import BIN_FILE_CONTENT, BIN_FILE_PATH from fixtures import BIN_FILE_CONTENT, BIN_FILE_PATH
class TestStream: # GET because httpbin 500s with binary POST body.
# GET because httpbin 500s with binary POST body.
@pytest.mark.skipif(is_windows,
reason='Pretty redirect not supported under Windows')
def test_pretty_redirected_stream(self, httpbin):
"""Test that --stream works with prettified redirected output."""
with open(BIN_FILE_PATH, 'rb') as f:
env = TestEnvironment(colors=256, stdin=f,
stdin_isatty=False,
stdout_isatty=False)
r = http('--verbose', '--pretty=all', '--stream', 'GET',
httpbin.url + '/get', env=env)
assert BINARY_SUPPRESSED_NOTICE.decode() in r
def test_encoded_stream(self, httpbin): @pytest.mark.skipif(is_windows,
"""Test that --stream works with non-prettified reason='Pretty redirect not supported under Windows')
redirected terminal output.""" def test_pretty_redirected_stream(httpbin):
with open(BIN_FILE_PATH, 'rb') as f: """Test that --stream works with prettified redirected output."""
env = TestEnvironment(stdin=f, stdin_isatty=False) with open(BIN_FILE_PATH, 'rb') as f:
r = http('--pretty=none', '--stream', '--verbose', 'GET', env = MockEnvironment(colors=256, stdin=f,
httpbin.url + '/get', env=env) stdin_isatty=False,
assert BINARY_SUPPRESSED_NOTICE.decode() in r stdout_isatty=False)
r = http('--verbose', '--pretty=all', '--stream', 'GET',
httpbin.url + '/get', env=env)
assert BINARY_SUPPRESSED_NOTICE.decode() in r
def test_redirected_stream(self, httpbin):
"""Test that --stream works with non-prettified def test_encoded_stream(httpbin):
redirected terminal output.""" """Test that --stream works with non-prettified
with open(BIN_FILE_PATH, 'rb') as f: redirected terminal output."""
env = TestEnvironment(stdout_isatty=False, with open(BIN_FILE_PATH, 'rb') as f:
stdin_isatty=False, env = MockEnvironment(stdin=f, stdin_isatty=False)
stdin=f) r = http('--pretty=none', '--stream', '--verbose', 'GET',
r = http('--pretty=none', '--stream', '--verbose', 'GET', httpbin.url + '/get', env=env)
httpbin.url + '/get', env=env) assert BINARY_SUPPRESSED_NOTICE.decode() in r
assert BIN_FILE_CONTENT in r
def test_redirected_stream(httpbin):
"""Test that --stream works with non-prettified
redirected terminal output."""
with open(BIN_FILE_PATH, 'rb') as f:
env = MockEnvironment(stdout_isatty=False,
stdin_isatty=False,
stdin=f)
r = http('--pretty=none', '--stream', '--verbose', 'GET',
httpbin.url + '/get', env=env)
assert BIN_FILE_CONTENT in r

View File

@ -7,81 +7,91 @@ from utils import http, HTTP_OK
from fixtures import UNICODE from fixtures import UNICODE
class TestUnicode: def test_unicode_headers(httpbin):
# httpbin doesn't interpret utf8 headers
r = http(httpbin.url + '/headers', u'Test:%s' % UNICODE)
assert HTTP_OK in r
def test_unicode_headers(self, httpbin):
# httpbin doesn't interpret utf8 headers
r = http(httpbin.url + '/headers', u'Test:%s' % UNICODE)
assert HTTP_OK in r
def test_unicode_headers_verbose(self, httpbin): def test_unicode_headers_verbose(httpbin):
# httpbin doesn't interpret utf8 headers # httpbin doesn't interpret utf8 headers
r = http('--verbose', httpbin.url + '/headers', u'Test:%s' % UNICODE) r = http('--verbose', httpbin.url + '/headers', u'Test:%s' % UNICODE)
assert HTTP_OK in r assert HTTP_OK in r
assert UNICODE in r assert UNICODE in r
def test_unicode_form_item(self, httpbin):
r = http('--form', 'POST', httpbin.url + '/post', u'test=%s' % UNICODE)
assert HTTP_OK in r
assert r.json['form'] == {'test': UNICODE}
def test_unicode_form_item_verbose(self, httpbin): def test_unicode_form_item(httpbin):
r = http('--verbose', '--form', r = http('--form', 'POST', httpbin.url + '/post', u'test=%s' % UNICODE)
'POST', httpbin.url + '/post', u'test=%s' % UNICODE) assert HTTP_OK in r
assert HTTP_OK in r assert r.json['form'] == {'test': UNICODE}
assert UNICODE in r
def test_unicode_json_item(self, httpbin):
r = http('--json', 'POST', httpbin.url + '/post', u'test=%s' % UNICODE)
assert HTTP_OK in r
assert r.json['json'] == {'test': UNICODE}
def test_unicode_json_item_verbose(self, httpbin): def test_unicode_form_item_verbose(httpbin):
r = http('--verbose', '--json', r = http('--verbose', '--form',
'POST', httpbin.url + '/post', u'test=%s' % UNICODE) 'POST', httpbin.url + '/post', u'test=%s' % UNICODE)
assert HTTP_OK in r assert HTTP_OK in r
assert UNICODE in r assert UNICODE in r
def test_unicode_raw_json_item(self, httpbin):
r = http('--json', 'POST', httpbin.url + '/post',
u'test:={ "%s" : [ "%s" ] }' % (UNICODE, UNICODE))
assert HTTP_OK in r
assert r.json['json'] == {'test': {UNICODE: [UNICODE]}}
def test_unicode_raw_json_item_verbose(self, httpbin): def test_unicode_json_item(httpbin):
r = http('--json', 'POST', httpbin.url + '/post', r = http('--json', 'POST', httpbin.url + '/post', u'test=%s' % UNICODE)
u'test:={ "%s" : [ "%s" ] }' % (UNICODE, UNICODE)) assert HTTP_OK in r
assert HTTP_OK in r assert r.json['json'] == {'test': UNICODE}
assert r.json['json'] == {'test': {UNICODE: [UNICODE]}}
def test_unicode_url_query_arg_item(self, httpbin):
r = http(httpbin.url + '/get', u'test==%s' % UNICODE)
assert HTTP_OK in r
assert r.json['args'] == {'test': UNICODE}, r
def test_unicode_url_query_arg_item_verbose(self, httpbin): def test_unicode_json_item_verbose(httpbin):
r = http('--verbose', httpbin.url + '/get', u'test==%s' % UNICODE) r = http('--verbose', '--json',
assert HTTP_OK in r 'POST', httpbin.url + '/post', u'test=%s' % UNICODE)
assert UNICODE in r assert HTTP_OK in r
assert UNICODE in r
def test_unicode_url(self, httpbin):
r = http(httpbin.url + u'/get?test=' + UNICODE)
assert HTTP_OK in r
assert r.json['args'] == {'test': UNICODE}
# def test_unicode_url_verbose(self): def test_unicode_raw_json_item(httpbin):
# r = http(httpbin.url + '--verbose', u'/get?test=' + UNICODE) r = http('--json', 'POST', httpbin.url + '/post',
# assert HTTP_OK in r u'test:={ "%s" : [ "%s" ] }' % (UNICODE, UNICODE))
assert HTTP_OK in r
assert r.json['json'] == {'test': {UNICODE: [UNICODE]}}
def test_unicode_basic_auth(self, httpbin):
# it doesn't really authenticate us because httpbin
# doesn't interpret the utf8-encoded auth
http('--verbose', '--auth', u'test:%s' % UNICODE,
httpbin.url + u'/basic-auth/test/' + UNICODE)
def test_unicode_digest_auth(self, httpbin): def test_unicode_raw_json_item_verbose(httpbin):
# it doesn't really authenticate us because httpbin r = http('--json', 'POST', httpbin.url + '/post',
# doesn't interpret the utf8-encoded auth u'test:={ "%s" : [ "%s" ] }' % (UNICODE, UNICODE))
http('--auth-type=digest', assert HTTP_OK in r
'--auth', u'test:%s' % UNICODE, assert r.json['json'] == {'test': {UNICODE: [UNICODE]}}
httpbin.url + u'/digest-auth/auth/test/' + UNICODE)
def test_unicode_url_query_arg_item(httpbin):
r = http(httpbin.url + '/get', u'test==%s' % UNICODE)
assert HTTP_OK in r
assert r.json['args'] == {'test': UNICODE}, r
def test_unicode_url_query_arg_item_verbose(httpbin):
r = http('--verbose', httpbin.url + '/get', u'test==%s' % UNICODE)
assert HTTP_OK in r
assert UNICODE in r
def test_unicode_url(httpbin):
r = http(httpbin.url + u'/get?test=' + UNICODE)
assert HTTP_OK in r
assert r.json['args'] == {'test': UNICODE}
# def test_unicode_url_verbose(self):
# r = http(httpbin.url + '--verbose', u'/get?test=' + UNICODE)
# assert HTTP_OK in r
def test_unicode_basic_auth(httpbin):
# it doesn't really authenticate us because httpbin
# doesn't interpret the utf8-encoded auth
http('--verbose', '--auth', u'test:%s' % UNICODE,
httpbin.url + u'/basic-auth/test/' + UNICODE)
def test_unicode_digest_auth(httpbin):
# it doesn't really authenticate us because httpbin
# doesn't interpret the utf8-encoded auth
http('--auth-type=digest',
'--auth', u'test:%s' % UNICODE,
httpbin.url + u'/digest-auth/auth/test/' + UNICODE)

View File

@ -2,8 +2,9 @@ import os
import pytest import pytest
from httpie.input import ParseError from httpie.cli.exceptions import ParseError
from utils import TestEnvironment, http, HTTP_OK from httpie.status import ExitStatus
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,24 +17,36 @@ 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
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
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:
@ -60,14 +73,20 @@ class TestRequestBodyFromFilePath:
def test_request_body_from_file_by_path_no_field_name_allowed( def test_request_body_from_file_by_path_no_field_name_allowed(
self, httpbin): self, httpbin):
env = TestEnvironment(stdin_isatty=True) env = MockEnvironment(stdin_isatty=True)
r = http('POST', httpbin.url + '/post', 'field-name@' + FILE_PATH_ARG, r = http('POST', httpbin.url + '/post', 'field-name@' + FILE_PATH_ARG,
env=env, error_exit_ok=True) env=env, tolerate_error_exit_status=True)
assert 'perhaps you meant --form?' in r.stderr assert 'perhaps you meant --form?' in r.stderr
def test_request_body_from_file_by_path_no_data_items_allowed( def test_request_body_from_file_by_path_no_data_items_allowed(
self, httpbin): self, httpbin):
env = TestEnvironment(stdin_isatty=False) env = MockEnvironment(stdin_isatty=False)
r = http('POST', httpbin.url + '/post', '@' + FILE_PATH_ARG, 'foo=bar', r = http(
env=env, error_exit_ok=True) 'POST',
httpbin.url + '/post',
'@' + FILE_PATH_ARG, 'foo=bar',
env=env,
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

@ -4,7 +4,7 @@ import tempfile
import pytest import pytest
from httpie.context import Environment from httpie.context import Environment
from utils import TestEnvironment, http from utils import MockEnvironment, http
from httpie.compat import is_windows from httpie.compat import is_windows
@ -20,10 +20,12 @@ class TestWindowsOnly:
class TestFakeWindows: class TestFakeWindows:
def test_output_file_pretty_not_allowed_on_windows(self, httpbin): def test_output_file_pretty_not_allowed_on_windows(self, httpbin):
env = TestEnvironment(is_windows=True) env = MockEnvironment(is_windows=True)
output_file = os.path.join( output_file = os.path.join(
tempfile.gettempdir(), '__httpie_test_output__') tempfile.gettempdir(),
self.test_output_file_pretty_not_allowed_on_windows.__name__
)
r = http('--output', output_file, r = http('--output', output_file,
'--pretty=all', 'GET', httpbin.url + '/get', '--pretty=all', 'GET', httpbin.url + '/get',
env=env, error_exit_ok=True) env=env, tolerate_error_exit_status=True)
assert 'Only terminal output can be colorized on Windows' in r.stderr assert 'Only terminal output can be colorized on Windows' in r.stderr

View File

@ -1,26 +1,24 @@
# coding=utf-8 # coding=utf-8
"""Utilities used by HTTPie tests. """Utilities for HTTPie test suite."""
"""
import os import os
import sys import sys
import time import time
import json import json
import shutil
import tempfile import tempfile
from pathlib import Path
from typing import Optional, Union
import httpie from httpie.status import ExitStatus
from httpie.config import Config
from httpie.context import Environment from httpie.context import Environment
from httpie.core import main from httpie.core import main
from httpie.compat import bytes, str
TESTS_ROOT = os.path.abspath(os.path.dirname(__file__))
TESTS_ROOT = Path(__file__).parent
CRLF = '\r\n' CRLF = '\r\n'
COLOR = '\x1b[' COLOR = '\x1b['
HTTP_OK = '200 OK' HTTP_OK = '200 OK'
# noinspection GrazieInspection
HTTP_OK_COLOR = ( HTTP_OK_COLOR = (
'HTTP\x1b[39m\x1b[38;5;245m/\x1b[39m\x1b' 'HTTP\x1b[39m\x1b[38;5;245m/\x1b[39m\x1b'
'[38;5;37m1.1\x1b[39m\x1b[38;5;245m \x1b[39m\x1b[38;5;37m200' '[38;5;37m1.1\x1b[39m\x1b[38;5;245m \x1b[39m\x1b[38;5;37m200'
@ -28,14 +26,9 @@ HTTP_OK_COLOR = (
) )
def no_content_type(headers): def mk_config_dir() -> Path:
return ( dirname = tempfile.mkdtemp(prefix='httpie_config_')
'Content-Type' not in headers return Path(dirname)
# We need to do also this because of this issue:
# <https://github.com/kevin1024/pytest-httpbin/issues/5>
# TODO: remove this function once the issue is if fixed
or headers['Content-Type'] == 'text/plain'
)
def add_auth(url, auth): def add_auth(url, auth):
@ -43,142 +36,59 @@ def add_auth(url, auth):
return proto + '://' + auth + '@' + rest return proto + '://' + auth + '@' + rest
class TestEnvironment(Environment): class MockEnvironment(Environment):
""" """Environment subclass with reasonable defaults for testing."""
Environment subclass with reasonable defaults suitable for testing.
"""
colors = 0 colors = 0
stdin_isatty = True, stdin_isatty = True,
stdout_isatty = True stdout_isatty = True
is_windows = False is_windows = False
_shutil = shutil # needed by __del__ (would get gc'd) def __init__(self, create_temp_config_dir=True, **kwargs):
def __init__(self, **kwargs):
if 'stdout' not in kwargs: if 'stdout' not in kwargs:
kwargs['stdout'] = tempfile.TemporaryFile('w+b') kwargs['stdout'] = tempfile.TemporaryFile(
mode='w+b',
prefix='httpie_stdout'
)
if 'stderr' not in kwargs: if 'stderr' not in kwargs:
kwargs['stderr'] = tempfile.TemporaryFile('w+t') kwargs['stderr'] = tempfile.TemporaryFile(
mode='w+t',
prefix='httpie_stderr'
)
super().__init__(**kwargs)
self._create_temp_config_dir = create_temp_config_dir
self._delete_config_dir = False
self._temp_dir = Path(tempfile.gettempdir())
self.delete_config_dir = False @property
if 'config_dir' not in kwargs: def config(self) -> Config:
kwargs['config_dir'] = mk_config_dir() if (self._create_temp_config_dir
self.delete_config_dir = True and self._temp_dir not in self.config_dir.parents):
self.create_temp_config_dir()
return super().config
super(TestEnvironment, self).__init__(**kwargs) def create_temp_config_dir(self):
self.config_dir = mk_config_dir()
self._delete_config_dir = True
def cleanup(self):
self.stdout.close()
self.stderr.close()
if self._delete_config_dir:
assert self._temp_dir in self.config_dir.parents
from shutil import rmtree
rmtree(self.config_dir, ignore_errors=True)
def __del__(self): def __del__(self):
if self.delete_config_dir: # noinspection PyBroadException
self._shutil.rmtree(self.config_dir)
def http(*args, **kwargs):
"""
Run HTTPie and capture stderr/out and exit status.
Invoke `httpie.core.main()` with `args` and `kwargs`,
and return a `CLIResponse` subclass instance.
The return value is either a `StrCLIResponse`, or `BytesCLIResponse`
if unable to decode the output.
The response has the following attributes:
`stdout` is represented by the instance itself (print r)
`stderr`: text written to stderr
`exit_status`: the exit status
`json`: decoded JSON (if possible) or `None`
Exceptions are propagated.
If you pass ``error_exit_ok=True``, then error exit statuses
won't result into an exception.
Example:
$ http --auth=user:password GET httpbin.org/basic-auth/user/password
>>> r = http('-a', 'user:pw', 'httpbin.org/basic-auth/user/pw')
>>> type(r) == StrCLIResponse
True
>>> r.exit_status
0
>>> r.stderr
''
>>> 'HTTP/1.1 200 OK' in r
True
>>> r.json == {'authenticated': True, 'user': 'user'}
True
"""
error_exit_ok = kwargs.pop('error_exit_ok', False)
env = kwargs.get('env')
if not env:
env = kwargs['env'] = TestEnvironment()
stdout = env.stdout
stderr = env.stderr
args = list(args)
if '--debug' not in args and '--traceback' not in args:
args = ['--traceback'] + args
def dump_stderr():
stderr.seek(0)
sys.stderr.write(stderr.read())
try:
try: try:
exit_status = main(args=args, **kwargs) self.cleanup()
if '--download' in args:
# Let the progress reporter thread finish.
time.sleep(.5)
except SystemExit:
if error_exit_ok:
exit_status = httpie.ExitStatus.ERROR
else:
dump_stderr()
raise
except Exception: except Exception:
stderr.seek(0) pass
sys.stderr.write(stderr.read())
raise
else:
if exit_status != httpie.ExitStatus.OK and not error_exit_ok:
dump_stderr()
raise Exception('Unexpected exit status: %s', exit_status)
stdout.seek(0)
stderr.seek(0)
output = stdout.read()
try:
output = output.decode('utf8')
except UnicodeDecodeError:
# noinspection PyArgumentList
r = BytesCLIResponse(output)
else:
# noinspection PyArgumentList
r = StrCLIResponse(output)
r.stderr = stderr.read()
r.exit_status = exit_status
if r.exit_status != httpie.ExitStatus.OK:
sys.stderr.write(r.stderr)
return r
finally:
stdout.close()
stderr.close()
class BaseCLIResponse(object): class BaseCLIResponse:
""" """
Represents the result of simulated `$ http' invocation via `http()`. Represents the result of simulated `$ http' invocation via `http()`.
Holds and provides access to: Holds and provides access to:
@ -187,9 +97,9 @@ class BaseCLIResponse(object):
- exit_status output: print(self.exit_status) - exit_status output: print(self.exit_status)
""" """
stderr = None stderr: str = None
json = None json: dict = None
exit_status = None exit_status: ExitStatus = None
class BytesCLIResponse(bytes, BaseCLIResponse): class BytesCLIResponse(bytes, BaseCLIResponse):
@ -206,10 +116,10 @@ class BytesCLIResponse(bytes, BaseCLIResponse):
class StrCLIResponse(str, BaseCLIResponse): class StrCLIResponse(str, BaseCLIResponse):
@property @property
def json(self): def json(self) -> Optional[dict]:
""" """
Return deserialized JSON body, if one included in the output Return deserialized the request or response JSON body,
and is parseable. if one (and only one) included in the output and is parsable.
""" """
if not hasattr(self, '_json'): if not hasattr(self, '_json'):
@ -231,11 +141,130 @@ class StrCLIResponse(str, BaseCLIResponse):
pass pass
else: else:
try: try:
# noinspection PyAttributeOutsideInit
self._json = json.loads(j) self._json = json.loads(j)
except ValueError: except ValueError:
pass pass
return self._json return self._json
def mk_config_dir(): class ExitStatusError(Exception):
return tempfile.mkdtemp(prefix='httpie_test_config_dir_') pass
def http(
*args,
program_name='http',
tolerate_error_exit_status=False,
**kwargs,
) -> Union[StrCLIResponse, BytesCLIResponse]:
# noinspection PyUnresolvedReferences
"""
Run HTTPie and capture stderr/out and exit status.
Invoke `httpie.core.main()` with `args` and `kwargs`,
and return a `CLIResponse` subclass instance.
The return value is either a `StrCLIResponse`, or `BytesCLIResponse`
if unable to decode the output.
The response has the following attributes:
`stdout` is represented by the instance itself (print r)
`stderr`: text written to stderr
`exit_status`: the exit status
`json`: decoded JSON (if possible) or `None`
Exceptions are propagated.
If you pass ``tolerate_error_exit_status=True``, then error exit statuses
won't result into an exception.
Example:
$ http --auth=user:password GET httpbin.org/basic-auth/user/password
>>> httpbin = getfixture('httpbin')
>>> r = http('-a', 'user:pw', httpbin.url + '/basic-auth/user/pw')
>>> type(r) == StrCLIResponse
True
>>> r.exit_status
<ExitStatus.SUCCESS: 0>
>>> r.stderr
''
>>> 'HTTP/1.1 200 OK' in r
True
>>> r.json == {'authenticated': True, 'user': 'user'}
True
"""
env = kwargs.get('env')
if not env:
env = kwargs['env'] = MockEnvironment()
stdout = env.stdout
stderr = env.stderr
args = list(args)
args_with_config_defaults = args + env.config.default_options
add_to_args = []
if '--debug' not in args_with_config_defaults:
if (not tolerate_error_exit_status
and '--traceback' not in args_with_config_defaults):
add_to_args.append('--traceback')
if not any('--timeout' in arg for arg in args_with_config_defaults):
add_to_args.append('--timeout=3')
complete_args = [program_name, *add_to_args, *args]
# print(' '.join(complete_args))
def dump_stderr():
stderr.seek(0)
sys.stderr.write(stderr.read())
try:
try:
exit_status = main(args=complete_args, **kwargs)
if '--download' in args:
# Let the progress reporter thread finish.
time.sleep(.5)
except SystemExit:
if tolerate_error_exit_status:
exit_status = ExitStatus.ERROR
else:
dump_stderr()
raise
except Exception:
stderr.seek(0)
sys.stderr.write(stderr.read())
raise
else:
if (not tolerate_error_exit_status
and exit_status != ExitStatus.SUCCESS):
dump_stderr()
raise ExitStatusError(
'httpie.core.main() unexpectedly returned'
f' a non-zero exit status: {exit_status}'
)
stdout.seek(0)
stderr.seek(0)
output = stdout.read()
try:
output = output.decode('utf8')
except UnicodeDecodeError:
r = BytesCLIResponse(output)
else:
r = StrCLIResponse(output)
r.stderr = stderr.read()
r.exit_status = exit_status
if r.exit_status != ExitStatus.SUCCESS:
sys.stderr.write(r.stderr)
return r
finally:
stdout.close()
stderr.close()
env.cleanup()

17
tox.ini
View File

@ -1,18 +1,23 @@
# Tox (http://tox.testrun.org/) is a tool for running tests # Tox (http://tox.testrun.org/) is a tool for running tests
# in multiple virtualenvs. # in multiple virtualenvs. See ./CONTRIBUTING.rst
# Run:
# $ pip install -r requirements-dev.txt
# $ tox
[tox] [tox]
envlist = py26, py27, py34, pypy # pypy3 currently fails because of a Flask issue
envlist = py37
[testenv] [testenv]
deps = deps =
mock
pytest pytest
pytest-httpbin>=0.0.6 pytest-httpbin>=0.0.6
commands = commands =
py.test --verbose --doctest-modules --basetemp={envtmpdir} {posargs:./tests ./httpie} # NOTE: the order of the directories in posargs seems to matter.
# When changed, then many ImportMismatchError exceptions occurrs.
py.test \
--verbose \
--doctest-modules \
{posargs:./httpie ./tests}