Compare commits

...

69 Commits
3.0.1 ... 3.1.0

Author SHA1 Message Date
266c6375c6 Release prep for 3.1.0 (#1313) 2022-03-08 01:50:09 +03:00
77af4c7a5c Decouple parser definition from argparse (#1293) 2022-03-08 01:34:04 +03:00
7509dd4e6c Fix documentation styling errors. 2022-03-07 23:29:48 +03:00
f08c1bee17 Change error messages to use a better format. 2022-03-07 23:29:48 +03:00
59d9e928f8 Tweak 2022-03-07 23:29:48 +03:00
0a873172c9 Tweak SECURITY and add a Security policy section to docs 2022-03-07 23:29:48 +03:00
614866eeb2 Polish sessions docs 2022-03-07 23:29:48 +03:00
395914fb4d Apply suggestions from the review 2022-03-07 23:29:48 +03:00
65ab7d5caa Implement new style cookies 2022-03-07 23:29:48 +03:00
b5623ccc87 Fix the tests with the latest layout 2022-03-07 19:16:51 +03:00
ec203b1fac Tweak compact help 2022-03-07 19:16:51 +03:00
350abe3033 Make the naked invocation display a compacted help 2022-03-07 19:16:51 +03:00
9241a09360 Mention about interactive prompt on key passphrases 2022-03-07 16:09:07 +03:00
15013fd609 Implement support for private key passphrases 2022-03-07 16:09:07 +03:00
98688b2f2d Style fix on the changelog 2022-03-07 16:01:29 +03:00
5ac05e9514 Add changelog entry 2022-03-07 16:01:29 +03:00
5c98253377 Update httpie/uploads.py 2022-03-07 16:01:29 +03:00
b0f5b8ab26 Prevent data race happening between select.select and file.read() 2022-03-07 16:01:29 +03:00
55087a901e Introduce a mode to suppress all warnings (#1283) 2022-03-07 15:40:35 +03:00
c901e70463 Replaced unmaintained OAuth plugin with new httpie-oauth1 plugin. (#1302) 2022-03-03 08:31:06 -08:00
25bd817bb2 Fix displaying of status code without a status message. (#1301)
Co-authored-by: Jakub Roztocil <jakub@roztocil.co>
2022-03-03 08:28:04 -08:00
6f77e144e4 Bump actions/checkout from 2 to 3 (#1311)
Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 3.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-03 08:16:56 -08:00
6bf39e469f Bump actions/setup-python from 2 to 3 (#1307)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 2 to 3.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-01 17:17:41 +03:00
30cd862fc0 Update commands for Arch (#1306) 2022-03-01 00:57:23 +03:00
ad613f29d2 Add a changelog entry for the top-level array regulation 2022-02-25 12:51:34 +03:00
225dccb218 Regulate top-level arrays (#1292)
* Redesign the starting path

* Do not cast `:=[1,2,3]` to a top-level array
2022-02-08 15:18:40 -08:00
cafa11665b Disable additional repos 2022-02-08 12:49:33 +03:00
0a9d3d3c54 Fix the packit syntax 2022-02-08 12:49:33 +03:00
e306667436 Leave a note for the local spec 2022-02-08 12:49:33 +03:00
384d3869f6 Update the local copy fore 3.0.2 2022-02-08 12:49:33 +03:00
5fd48e3137 Use the lastest fedora spec in the packit 2022-02-08 12:49:33 +03:00
37ef670876 Update copyright year 2022-02-05 22:09:44 +03:00
46e782bf75 Point package to 3.0.2 2022-02-05 22:09:44 +03:00
42edb1eb76 Use 3.0.0 blog post as the changelog 2022-02-05 22:09:44 +03:00
d45f413f12 Make the version point to 3.0.3.dev0 (#1291) 2022-02-03 01:47:06 -08:00
f1ea486025 Fix escaping of integer indexes with multiple backslashes (#1288) 2022-02-01 02:10:55 -08:00
7abddfe350 Mark stdin warning related tests with requires_external_processes (#1289)
* Mark test_stdin_read_warning with requires_installation

* Mark stdin tests with requires_external_processes

Co-authored-by: Nilushan Costa <19643850+nilushancosta@users.noreply.github.com>
2022-02-01 01:52:07 -08:00
86ba995ad8 2022 (#1259) 2022-01-26 17:45:03 +03:00
c03f081a7e Finish off the naming 2022-01-26 12:51:10 +03:00
a7d8187b21 Proper naming for the release runs 2022-01-26 12:50:22 +03:00
fc383e9b78 Add names to the CI runners 2022-01-26 12:49:27 +03:00
770df02291 Add level parameter to the snap releaser (#1282) 2022-01-26 12:44:24 +03:00
f756cad58d Update CHANGELOG.md 2022-01-24 16:54:50 -08:00
fde64d578d Update CHANGELOG.md 2022-01-24 10:32:24 -08:00
c8404493e5 Update CHANGELOG.md 2022-01-24 10:32:07 -08:00
559134de0a Release 3.0.2 (#1281) 2022-01-24 21:20:17 +03:00
813e8864a1 Dont apply default options on the httpie command (#1280)
* Mark tests with requires_installation

* Dont apply default options on the httpie command

* lint
2022-01-24 10:13:47 -08:00
45fcd746d7 docs: format the benchmark docs 2022-01-24 18:20:03 +03:00
d5e3611e85 fix lint errors 2022-01-24 18:17:55 +03:00
378a1f513e Document the pyOpenSSL option 2022-01-24 18:17:55 +03:00
df6843b15a docs: add --{local, target}-{repo, branch} / format 2022-01-24 18:17:55 +03:00
640901146f docs: document the --fresh option 2022-01-24 18:17:55 +03:00
6b5d96da72 Describe the usage for benchmarks 2022-01-24 18:17:55 +03:00
97bd9c2a89 docs: add requirements 2022-01-24 18:17:55 +03:00
708608e1d4 docs: mention about the runners 2022-01-24 18:17:55 +03:00
d56a1f216e docs: give a brief description 2022-01-24 18:17:55 +03:00
738a6bea57 docs: fix the title to benchmarking infrastructure 2022-01-24 18:17:55 +03:00
ec521c461b docs: add initial benchmark docs 2022-01-24 18:17:55 +03:00
212000199e docs: fix the nested json example (#1278) 2022-01-24 18:00:54 +03:00
700dbeddb0 Typos 2022-01-24 01:51:53 +01:00
30a4d29f77 Update CHANGELOG.md 2022-01-23 15:15:16 -08:00
aedcad7e2a Update CHANGELOG.md 2022-01-23 15:14:31 -08:00
202f59e04a Tweak nested JSON docs 2022-01-23 18:36:18 +01:00
ba0c1ab258 Tweak auth docs 2022-01-23 17:24:29 +01:00
217cf8ddae Document auto-stream 2022-01-23 17:17:58 +01:00
859e442083 Docs 2022-01-23 16:59:07 +01:00
4e59bbfae6 Docs 2022-01-23 16:52:31 +01:00
caa8fb9058 Tweak response meta docs
- expand response meta section
- add examples
- interlink sections
2022-01-23 14:35:20 +01:00
2797b7244c Update cached brew formula 2022-01-23 14:11:09 +01:00
75 changed files with 3146 additions and 589 deletions

View File

@ -13,8 +13,8 @@ jobs:
if: github.event.label.name == 'benchmark'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: actions/checkout@v3
- uses: actions/setup-python@v3
with:
python-version: "3.9"

View File

@ -1,3 +1,5 @@
name: Code Style Check
on:
pull_request:
paths:
@ -11,8 +13,8 @@ jobs:
code-style:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: actions/checkout@v3
- uses: actions/setup-python@v3
with:
python-version: 3.9
- run: make venv

View File

@ -1,3 +1,5 @@
name: Coverage
on:
pull_request:
paths:
@ -10,8 +12,8 @@ jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: actions/checkout@v3
- uses: actions/setup-python@v3
with:
python-version: "3.10"
- run: make install

View File

@ -1,3 +1,5 @@
name: Check Markdown Style
on:
pull_request:
paths:
@ -8,7 +10,7 @@ jobs:
doc:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:

View File

@ -1,3 +1,5 @@
name: Deploy Documentation
on:
push:
branches:

View File

@ -1,3 +1,5 @@
name: Update & Install Docs
on:
push:
branches:
@ -13,8 +15,8 @@ jobs:
doc:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: actions/checkout@v3
- uses: actions/setup-python@v3
with:
python-version: 3.9
- run: make install

View File

@ -1,3 +1,5 @@
name: Release snap
on:
workflow_dispatch:
inputs:
@ -5,12 +7,16 @@ on:
description: "The branch, tag or SHA to release from"
required: true
default: "master"
level:
description: "Release level: stable, candidate, beta, edge"
required: true
default: "edge"
jobs:
snap:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
with:
ref: ${{ github.event.inputs.branch }}
- uses: snapcore/action-build@v1
@ -19,4 +25,4 @@ jobs:
with:
store_login: ${{ secrets.SNAP_STORE_LOGIN }}
snap: ${{ steps.build.outputs.snap }}
release: edge
release: ${{ github.event.inputs.level }}

View File

@ -1,3 +1,5 @@
name: Release on PyPI
on:
# Add a "Trigger" button to manually start the workflow.
workflow_dispatch:
@ -16,13 +18,13 @@ jobs:
new-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
with:
ref: ${{ github.event.inputs.branch }}
- name: PyPi configuration
- name: PyPI configuration
run: |
echo "[distutils]\nindex-servers=\n httpie\n\n[httpie]\nrepository = https://upload.pypi.org/legacy/\n" > $HOME/.pypirc
- uses: actions/setup-python@v2
- uses: actions/setup-python@v3
with:
python-version: 3.9
- run: make publish

View File

@ -1,3 +1,6 @@
name: Test Snap Package (Linux)
on:
pull_request:
paths:
@ -9,7 +12,7 @@ jobs:
snap:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Build
uses: snapcore/action-build@v1
id: snapcraft

View File

@ -1,3 +1,5 @@
name: Test Brew Package (MacOS)
on:
pull_request:
paths:
@ -9,7 +11,7 @@ jobs:
brew:
runs-on: macos-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Setup brew
run: |
brew developer on

View File

@ -1,3 +1,5 @@
name: Tests
on:
push:
branches:
@ -24,8 +26,8 @@ jobs:
pyopenssl: [0, 1]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: actions/checkout@v3
- uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Windows setup

View File

@ -3,16 +3,15 @@
specfile_path: httpie.spec
actions:
# get the current Fedora Rawhide specfile:
# post-upstream-clone: "wget https://src.fedoraproject.org/rpms/httpie/raw/rawhide/f/httpie.spec -O httpie.spec"
post-upstream-clone: "cp docs/packaging/linux-fedora/httpie.spec.txt httpie.spec"
post-upstream-clone: "wget https://src.fedoraproject.org/rpms/httpie/raw/rawhide/f/httpie.spec -O httpie.spec"
# Use this when the latest spec is not up-to-date.
# post-upstream-clone: "cp docs/packaging/linux-fedora/httpie.spec.txt httpie.spec"
jobs:
- job: copr_build
trigger: pull_request
metadata:
targets:
- fedora-all
additional_repos:
- "https://kojipkgs.fedoraproject.org/repos/f$releasever-build/latest/$basearch/"
- job: propose_downstream
trigger: release
metadata:

View File

@ -3,12 +3,34 @@
This document records all notable changes to [HTTPie](https://httpie.io).
This project adheres to [Semantic Versioning](https://semver.org/).
## [3.1.0](https://github.com/httpie/httpie/compare/3.0.2...3.1.0) (2022-03-08)
- **SECURITY** Fixed the [vulnerability](https://github.com/httpie/httpie/security/advisories/GHSA-9w4w-cpc8-h2fq) that caused exposure of cookies on redirects to third party hosts. ([#1312](https://github.com/httpie/httpie/pull/1312))
- Fixed escaping of integer indexes with multiple backslashes in the nested JSON builder. ([#1285](https://github.com/httpie/httpie/issues/1285))
- Fixed displaying of status code without a status message on non-`auto` themes. ([#1300](https://github.com/httpie/httpie/issues/1300))
- Fixed redundant issuance of stdin detection warnings on some rare cases due to underlying implementation. ([#1303](https://github.com/httpie/httpie/pull/1303))
- Fixed double `--quiet` so that it will now suppress all python level warnings. ([#1271](https://github.com/httpie/httpie/issues/1271))
- Added support for specifying certificate private key passphrases through `--cert-key-pass` and prompts. ([#946](https://github.com/httpie/httpie/issues/946))
- Added `httpie cli export-args` command for exposing the parser specification for the `http`/`https` commands. ([#1293](https://github.com/httpie/httpie/pull/1293))
- Improved regulation of top-level arrays. ([#1292](https://github.com/httpie/httpie/commit/225dccb2186f14f871695b6c4e0bfbcdb2e3aa28))
- Improved UI layout for standalone invocations. ([#1296](https://github.com/httpie/httpie/pull/1296))
## [3.0.2](https://github.com/httpie/httpie/compare/3.0.1...3.0.2) (2022-01-24)
[Whats new in HTTPie for Terminal 3.0 →](https://httpie.io/blog/httpie-3.0.0)
- Fixed usage of `httpie` when there is a presence of a config with `default_options`. ([#1280](https://github.com/httpie/httpie/pull/1280))
## [3.0.1](https://github.com/httpie/httpie/compare/3.0.0...3.0.1) (2022-01-23)
- Changed the value shown as time elapsed from time-to-read-headers to total exchange time ([#1277](https://github.com/httpie/httpie/issues/1277))
[Whats new in HTTPie for Terminal 3.0 →](https://httpie.io/blog/httpie-3.0.0)
- Changed the value shown as time elapsed from time-to-read-headers to total exchange time. ([#1277](https://github.com/httpie/httpie/issues/1277))
## [3.0.0](https://github.com/httpie/httpie/compare/2.6.0...3.0.0) (2022-01-21)
[Whats new in HTTPie for Terminal 3.0 →](https://httpie.io/blog/httpie-3.0.0)
- Dropped support for Python 3.6. ([#1177](https://github.com/httpie/httpie/issues/1177))
- Improved startup time by 40%. ([#1211](https://github.com/httpie/httpie/pull/1211))
- Added support for nested JSON syntax. ([#1169](https://github.com/httpie/httpie/issues/1169))
@ -32,7 +54,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## [2.6.0](https://github.com/httpie/httpie/compare/2.5.0...2.6.0) (2021-10-14)
[Whats new in HTTPie 2.6.0 →](https://httpie.io/blog/httpie-2.6.0)
[Whats new in HTTPie for Terminal 2.6.0 →](https://httpie.io/blog/httpie-2.6.0)
- Added support for formatting & coloring of JSON bodies preceded by non-JSON data (e.g., an XXSI prefix). ([#1130](https://github.com/httpie/httpie/issues/1130))
- Added charset auto-detection when `Content-Type` doesnt include it. ([#1110](https://github.com/httpie/httpie/issues/1110), [#1168](https://github.com/httpie/httpie/issues/1168))
@ -44,7 +66,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## [2.5.0](https://github.com/httpie/httpie/compare/2.4.0...2.5.0) (2021-09-06)
[Whats new in HTTPie 2.5.0 →](https://httpie.io/blog/httpie-2.5.0)
[Whats new in HTTPie for Terminal 2.5.0 →](https://httpie.io/blog/httpie-2.5.0)
- Added `--raw` to allow specifying the raw request body without extra processing as
an alternative to `stdin`. ([#534](https://github.com/httpie/httpie/issues/534))

View File

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

14
SECURITY.md Normal file
View File

@ -0,0 +1,14 @@
# Security policy
## Reporting a vulnerability
When you identify a vulnerability in HTTPie, please report it privately using one of the following channels:
- Email to [`security@httpie.io`](mailto:security@httpie.io)
- Report on [huntr.dev](https://huntr.dev/)
In addition to the description of the vulnerability, include the following information:
- A short reproducer to verify it (it can be a small HTTP server, shell script, docker image, etc.)
- Your deemed severity level of the vulnerability (`LOW`/`MEDIUM`/`HIGH`/`CRITICAL`)
- [CWE](https://cwe.mitre.org/) ID, if available.

View File

@ -205,12 +205,12 @@ Also works for other Arch-derived distributions like ArcoLinux, EndeavourOS, Art
```bash
# Install httpie
$ pacman -Sy httpie
$ pacman -Syu httpie
```
```bash
# Upgrade httpie
$ pacman -Syu httpie
$ pacman -Syu
```
### FreeBSD
@ -260,7 +260,7 @@ Verify that now you have the [current development version identifier](https://gi
```bash
$ http --version
# 3.0.0
# 3.X.X.dev0
```
## Usage
@ -681,7 +681,40 @@ Other JSON types, however, are not allowed with `--form` or `--multipart`.
"about": {
"mission": "Make APIs simple and intuitive",
"homepage": "httpie.io",
}
"stars": 54000
},
"apps": [
"Terminal",
"Desktop",
"Web",
"Mobile"
]
}
}
```
#### Introduction
Lets start with a simple example, and build a simple search query:
```bash
$ http --offline --print=B pie.dev/post \
category=tools \
search[type]=id \
search[id]:=1
```
In the example above, the `search[type]` is an instruction for creating an object called `search`, and setting the `type` field of it to the given value (`"id"`).
Also note that, just as the regular syntax, you can use the `:=` operator to directly pass raw JSON values (e.g, numbers in the case above).
```json
{
"category": "tools",
"search": {
"id": 1,
"type": "id"
}
}
```
@ -739,7 +772,7 @@ $ http --offline --print=B pie.dev/post \
category=tools \
search[type]=platforms \
search[platforms][]=Terminal \
search[platforms][1]=Desktop \
search[platforms][1]=Desktop \
search[platforms][3]=Mobile
```
@ -821,6 +854,47 @@ $ http PUT pie.dev/put \
You can also apply the nesting to the items by referencing their index:
```bash
http --offline --print=B pie.dev/post \
[0][type]=platform [0][name]=terminal \
[1][type]=platform [1][name]=desktop
```
```json
[
{
"type": "platform",
"name": "terminal"
},
{
"type": "platform",
"name": "desktop"
}
]
```
##### Escaping behavior
Nested JSON syntax uses the same [escaping rules](#escaping-rules) as
the terminal. There are 3 special characters, and 1 special token that you can escape.
If you want to send a bracket as is, escape it with a backslash (`\`):
```bash
$ http --offline --print=B pie.dev/post \
'foo\[bar\]:=1' \
'baz[\[]:=2' \
'baz[\]]:=3'
```
```json
{
"baz": {
"[": 2,
"]": 3
},
"foo[bar]": 1
}
```
If you want to send the literal backslash character (`\`), escape it with another backslash:
@ -1247,12 +1321,18 @@ https -A bearer -a token pie.dev/bearer
For example:
```bash
$ cat ~/.netrc
machine pie.dev
login httpie
password test
```
```bash
$ http pie.dev/basic-auth/httpie/test
HTTP/1.1 200 OK
[...]
```
This can be disabled with the `--ignore-netrc` option:
```bash
@ -1297,9 +1377,11 @@ Here are a few picks:
$ http --follow pie.dev/redirect/3
```
With `307 Temporary Redirect` and `308 Permanent Redirect`, the method and the body of the original request
are reused to perform the redirected request. Otherwise, a body-less `GET` request is performed.
### Showing intermediary redirect responses
If you wish to see the intermediary requests/responses,
then use the `--all` option:
@ -1407,6 +1489,21 @@ path of the key file with `--cert-key`:
(The actually available set of protocols may vary depending on your OpenSSL installation.)
```bash
# Specify the vulnerable SSL v3 protocol to talk to an outdated server:
$ http --ssl=ssl3 https://vulnerable.example.org
```
### SSL ciphers
You can specify the available ciphers with `--ciphers`.
It should be a string in the [OpenSSL cipher list format](https://www.openssl.org/docs/man1.1.0/man1/ciphers.html).
```bash
$ http --ciphers=ECDHE-RSA-AES128-GCM-SHA256 https://pie.dev/get
```
Note: these cipher strings do not change the negotiated version of SSL or TLS, they only affect the list of available cipher suites.
To see the default cipher string, run `http --help` and see the `--ciphers` section under SSL.
## Output options
@ -1442,7 +1539,7 @@ be printed via several options:
```bash
$ http --print=Hh PUT pie.dev/put hello=world
```
```
#### Response meta
@ -1453,13 +1550,13 @@ be printed via several options:
```bash
$ http --meta pie.dev/delay/1
### Verbose output
`--verbose` can often be useful for debugging the request and generating documentation examples:
```bash
$ http --verbose PUT pie.dev/put hello=world
PUT /put HTTP/1.1
```
```console
Elapsed time: 1.099171542s
```
The [extra verbose `-vv` output](#extra-verbose-output) includes the meta section by default. You can also show it in combination with other parts of the exchange via [`--print=m`](#what-parts-of-the-http-exchange-should-be-printed). For example, here we print it together with the response headers:
```bash
$ http --print=hm pie.dev/get
@ -1471,9 +1568,34 @@ $ http --print=Hh PUT pie.dev/put hello=world
```
Connection: keep-alive
Content-Type: application/json
Please note that it also includes time spent on formatting the output, which adds a small penalty. Also, if the body is not part of the output, [we dont spend time downloading it](#conditional-body-download).
If you [use `--style` with one of the Pie themes](#colors-and-formatting), youll see the time information color-coded (green/yellow/orange/red) based on how long the exchange took.
### Verbose output
`--verbose` can often be useful for debugging the request and generating documentation examples:
```bash
$ http --verbose PUT pie.dev/put hello=world
PUT /put HTTP/1.1
Accept: application/json, */*;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/json
Host: pie.dev
User-Agent: HTTPie/0.2.7dev
{
"hello": "world"
}
HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 477
Content-Type: application/json
Date: Sun, 05 Aug 2012 00:25:23 GMT
Server: gunicorn/0.13.4
{
@ -1505,9 +1627,9 @@ Server: gunicorn/0.13.4
```bash
# There will be no output, even in case of an unexpected response status code:
$ http -qq --check-status pie.dev/post enjoy='the silence without warnings'
$ http -qq --check-status pie.dev/post enjoy='the silence without warnings'
```
### Viewing intermediary requests/responses
To see all the HTTP communication, i.e. the final request/response as well as any possible intermediary requests/responses, use the `--all` option.
The intermediary HTTP communication include followed redirects (with `--follow`), the first unauthorized request when HTTP digest authentication is used (`--auth=digest`), etc.
@ -1927,6 +2049,8 @@ You can use the `--stream, -S` flag to make two things happen:
```bash
# Create a new session:
$ http --session=./session.json pie.dev/headers API-Token:123
```
```bash
# Inspect / edit the generated session file:
$ cat session.json
@ -2033,38 +2157,88 @@ $ http --session-read-only=./ro-session.json pie.dev/headers Custom-Header:orig-
$ http --session=./session.json pie.dev/cookies
```
},
```json
{
"password": null,
"cookies": {
"pie": "apple"
"username": null
}
}
```
To make a cookie domain _unbound_ (i.e., to make it available to all hosts, including throughout a cross-domain redirect chain), you can set the `domain` field to `null` in the session file:
```json
{
"cookies": [
{
"domain": null,
"name": "unbound-cookie",
"value": "send-me-to-any-host"
}
]
}
```
"cookies": {
```bash
"expires": null,
"path": "/",
"secure": false,
$ http --session=./session.json pie.dev/cookies
```
```json
}
{
"cookies": {
"unbound-cookie": "send-me-to-any-host"
}
}
```
}
```
### Cookie storage behavior
For example, a cookie set through the command line will overwrite a cookie of the same name stored in the session file.
There are three possible sources of persisted cookies within a session. They have the following storage priority: 1—response; 2—command line; 3—session file.
1. Receive a response with a `Set-Cookie` header:
```bash
$ http --session=./session.json pie.dev/cookie/set?foo=bar
```
2. Send a cookie specified on the command line as seen in [cookies](#cookies):
```bash
$ http --session=./session.json pie.dev/headers Cookie:foo=bar
Expired cookies are never stored.
If a cookie in a session file expires, it will be removed before sending a new request.
If the server expires an existing cookie, it will also be removed from the session file.
## Config
HTTPie uses a simple `config.json` file.
The file doesnt exist by default, but you can create it manually.
### Config file directory
```
3. Manually set cookie parameters in the session file:
```json
{
"cookies": {
"foo": {
"expires": null,
"path": "/",
"secure": false,
"value": "bar"
}
}
}
```
In summary:
- Cookies set via the CLI overwrite cookies of the same name inside session files.
- Server-sent `Set-Cookie` header cookies overwrite any pre-existing ones with the same name.
Cookie expiration handling:
- When the server expires an existing cookie, HTTPie removes it from the session file.
- When a cookie in a session file expires, HTTPie removes it before sending a new request.
### Upgrading sessions
HTTPie may introduce changes in the session file format. When HTTPie detects an obsolete format, it shows a warning. You can upgrade your session files using the following commands:
Upgrade all existing [named sessions](#named-sessions) inside the `sessions` subfolder of your [config directory](https://httpie.io/docs/cli/config-file-directory):
```bash
$ httpie cli sessions upgrade-all
Upgraded 'api_auth' @ 'pie.dev' to v3.1.0
@ -2073,16 +2247,53 @@ To set a cookie within a Session there are three options:
Upgrading individual sessions requires you to specify the session's hostname. That allows HTTPie to find the correct file in the case of name sessions. Additionally, it allows it to correctly bind cookies when upgrading with [`--bind-cookies`](#session-upgrade-options).
The config directory can be changed by setting the `$HTTPIE_CONFIG_DIR` environment variable:
Upgrade a single [named session](#named-sessions):
```bash
$ export HTTPIE_CONFIG_DIR=/tmp/httpie
$ http pie.dev/get
```
$ httpie cli sessions upgrade pie.dev api_auth
Upgraded 'api_auth' @ 'pie.dev' to v3.1.0
### Configurable options
Currently, HTTPie offers a single configurable option:
```
Upgrade a single [anonymous session](#anonymous-sessions) using a file path:
```bash
$ httpie cli sessions upgrade pie.dev ./session.json
Upgraded 'session.json' @ 'pie.dev' to v3.1.0
```
#### Session upgrade options
These flags are available for both `sessions upgrade` and `sessions upgrade-all`:
------------------|------------------------------------------
`--bind-cookies` | Bind all previously [unbound cookies](#host-based-cookie-policy) to the sessions host.
## Config
HTTPie uses a simple `config.json` file.
The file doesnt exist by default, but you can create it manually.
### Config file directory
To see the exact location for your installation, run `http --debug` and look for `config_dir` in the output.
The default location of the configuration file on most platforms is `$XDG_CONFIG_HOME/httpie/config.json` (defaulting to `~/.config/httpie/config.json`).
For backward compatibility, if the directory `~/.httpie` exists, the configuration file there will be used instead.
On Windows, the config file is located at `%APPDATA%\httpie\config.json`.
The config directory can be changed by setting the `$HTTPIE_CONFIG_DIR` environment variable:
```bash
$ export HTTPIE_CONFIG_DIR=/tmp/httpie
$ http pie.dev/get
```
### Configurable options
Currently, HTTPie offers a single configurable option:
#### `default_options`
An `Array` (by default empty) of default options that should be applied to every invocation of HTTPie.
@ -2095,7 +2306,7 @@ To see the exact location for your installation, run `http --debug` and look for
```json
{
{
"default_options": [
"--style=fruity"
]
}
@ -2134,7 +2345,7 @@ However, it is not recommended modifying the default behavior in a way that woul
*) echo 'Other Error!' ;;
esac
fi
fi
```
### Best practices
@ -2175,9 +2386,10 @@ And since theres neither data nor `EOF`, it will get stuck. So unless your
Available formats to export in include:
| format | Description |
#### `httpie plugins install`
|--------|---------------------------------------------------------------------------------------------------------------------------------------------------|
| `json` | Export the parser spec in JSON. The schema includes a top-level `version` parameter which should be interpreted in [semver](https://semver.org/). |
For installing plugins from [PyPI](https://pypi.org/) or from local paths, `httpie plugins install`
You can use any of these formats with `--format` parameter, but the default one is `json`.
```bash
$ httpie cli export-args | jq '"Program: " + .spec.name + ", Version: " + .version'
@ -2190,6 +2402,27 @@ For managing these plugins; starting with 3.0, we are offering a new plugin mana
In the past `pip` was used to install/uninstall plugins, but on some environments (e.g., brew installed
packages) it wasnt working properly. The new interface is a very simple overlay on top of `pip` to allow
plugin installations on every installation method.
By default, the plugins (and their missing dependencies) will be stored under the configuration directory,
but this can be modified through `plugins_dir` variable on the config.
#### `httpie plugins install`
For installing plugins from [PyPI](https://pypi.org/) or from local paths, `httpie plugins install`
can be used.
```bash
$ httpie plugins install httpie-plugin
Installing httpie-plugin...
Successfully installed httpie-plugin-1.0.2
```
> Tip: Generally HTTPie plugins start with `httpie-` prefix. Try searching for it on [PyPI](https://pypi.org/search/?q=httpie-)
> to find out all plugins from the community.
#### `httpie plugins list`
List all installed plugins.
```bash
@ -2320,6 +2553,10 @@ Helpers to convert from other client tools:
See [CONTRIBUTING](https://github.com/httpie/httpie/blob/master/CONTRIBUTING.md).
### Security policy
See [github.com/httpie/httpie/security/policy](https://github.com/httpie/httpie/security/policy).
### Change log
See [CHANGELOG](https://github.com/httpie/httpie/blob/master/CHANGELOG.md).

View File

@ -106,9 +106,9 @@ tools:
package: https://archlinux.org/packages/community/any/httpie/
commands:
install:
- pacman -Sy httpie
upgrade:
- pacman -Syu httpie
upgrade:
- pacman -Syu
pkg:
title: FreshPorts

View File

@ -3,19 +3,18 @@ class Httpie < Formula
desc "User-friendly cURL replacement (command-line HTTP client)"
homepage "https://httpie.io/"
url "https://files.pythonhosted.org/packages/64/ee/7b158899655231322f13ecd313d1a0546efe8b9e75167ec8b7fd9ddf7952/httpie-3.0.0.tar.gz"
sha256 "e719711aadf1ecd33278033b96dfef7f4e9e341d3a5d1f166785ac4b7fbdee29"
url "https://files.pythonhosted.org/packages/7b/f9/13070f19226b7db3641fb787df36bb715063abe1b8ca03fbaeca0f465d27/httpie-3.0.1.tar.gz"
sha256 "0e9bc93ebdcdd2d32ec24b8fa46cf7e4fde9eec7a6bd0c5d0ef224f25d7466b2"
license "BSD-3-Clause"
head "https://github.com/httpie/httpie.git", branch: "master"
bottle do
sha256 cellar: :any_skip_relocation, arm64_monterey: "83aab05ffbcd4c3baa6de6158d57ebdaa67c148bef8c872527d90bdaebff0504"
sha256 cellar: :any_skip_relocation, arm64_big_sur: "3c3a5c2458d0658e14b663495e115297c573aa3466d292f12d02c3ec13a24bdf"
sha256 cellar: :any_skip_relocation, monterey: "f860e7d3b77dca4928a2c5e10c4cbd50d792330dfb99f7d736ca0da9fb9dd0d0"
sha256 cellar: :any_skip_relocation, big_sur: "377b0643aa1f6d310ba4cfc70d66a94cc458213db8d134940d3b10a32defacf1"
sha256 cellar: :any_skip_relocation, catalina: "6d306c30f6f1d7a551d88415efe12b7c3f25d0602f3579dc632771a463f78fa5"
sha256 cellar: :any_skip_relocation, mojave: "f66b8cdff9cb7b44a84197c3e3d81d810f7ff8f2188998b977ccadfc7e2ec893"
sha256 cellar: :any_skip_relocation, x86_64_linux: "53f036b0114814c28982e8c022dcf494e7024de088641d7076fd73d12a45a0e9"
sha256 cellar: :any_skip_relocation, arm64_monterey: "9d285fcfb55ce8ed787d1b01966d51e6e07f7e77c44a204695a2d6eee9c8698d"
sha256 cellar: :any_skip_relocation, arm64_big_sur: "743a282b475e87a4eaf11e545f761aef1b8e4bfe49eaee47251d7629a35a8ced"
sha256 cellar: :any_skip_relocation, monterey: "5d63ea4f47b2028b2ba68abe12a4176934193e058edd869270221b41cc946c76"
sha256 cellar: :any_skip_relocation, big_sur: "5a53221a680a35d1aa00cbadde279dbe4f562d22ed207c15bd4221cb8c3180f1"
sha256 cellar: :any_skip_relocation, catalina: "5feadb6d76f55d6f9681682e221008c282dccf0e46ae22a959b4bad2efde204a"
sha256 cellar: :any_skip_relocation, x86_64_linux: "d530ddbec49588b0d481f156d35f7e5bb7d3b6427d203f04750e55cd3eecc303"
end
depends_on "python@3.10"
@ -36,8 +35,13 @@ class Httpie < Formula
end
resource "idna" do
url "https://files.pythonhosted.org/packages/cb/38/4c4d00ddfa48abe616d7e572e02a04273603db446975ab46bbcd36552005/idna-3.2.tar.gz"
sha256 "467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"
url "https://files.pythonhosted.org/packages/62/08/e3fc7c8161090f742f504f40b1bccbfc544d4a4e09eb774bf40aafce5436/idna-3.3.tar.gz"
sha256 "9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
end
resource "multidict" do
url "https://files.pythonhosted.org/packages/8e/7c/e12a69795b7b7d5071614af2c691c97fbf16a2a513c66ec52dd7d0a115bb/multidict-5.2.0.tar.gz"
sha256 "0dd1c93edb444b33ba2274b66f63def8a327d607c6c790772f448a53b6ea59ce"
end
resource "Pygments" do
@ -65,20 +69,14 @@ class Httpie < Formula
sha256 "0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"
end
resource "multidict" do
url "https://files.pythonhosted.org/packages/8e/7c/e12a69795b7b7d5071614af2c691c97fbf16a2a513c66ec52dd7d0a115bb/multidict-5.2.0.tar.gz"
sha256 "0dd1c93edb444b33ba2274b66f63def8a327d607c6c790772f448a53b6ea59ce"
end
def install
virtualenv_install_with_resources
end
test do
# shell_output() already checks the status code
shell_output("#{bin}/httpie -v")
shell_output("#{bin}/https -v")
shell_output("#{bin}/http -v")
assert_match version.to_s, shell_output("#{bin}/httpie --version")
assert_match version.to_s, shell_output("#{bin}/https --version")
assert_match version.to_s, shell_output("#{bin}/http --version")
raw_url = "https://raw.githubusercontent.com/Homebrew/homebrew-core/HEAD/Formula/httpie.rb"
assert_match "PYTHONPATH", shell_output("#{bin}/http --ignore-stdin #{raw_url}")

View File

@ -1,5 +1,5 @@
Name: httpie
Version: 2.6.0
Version: 3.0.2
Release: 1%{?dist}
Summary: A Curl-like tool for humans
@ -78,6 +78,21 @@ help2man %{buildroot}%{_bindir}/httpie > %{buildroot}%{_mandir}/man1/httpie.1
%changelog
* Mon Jan 24 2022 Miro Hrončok <mhroncok@redhat.com> - 3.0.2-1
- Update to 3.0.2
- Fixes: rhbz#2044572
* Mon Jan 24 2022 Miro Hrončok <mhroncok@redhat.com> - 3.0.1-1
- Update to 3.0.1
- Fixes: rhbz#2044058
* Fri Jan 21 2022 Miro Hrončok <mhroncok@redhat.com> - 3.0.0-1
- Update to 3.0.0
- Fixes: rhbz#2043680
* Thu Jan 20 2022 Fedora Release Engineering <releng@fedoraproject.org> - 2.6.0-2
- Rebuilt for https://fedoraproject.org/wiki/Fedora_36_Mass_Rebuild
* Fri Oct 15 2021 Miro Hrončok <mhroncok@redhat.com> - 2.6.0-1
- Update to 2.6.0
- Fixes: rhbz#2014022

View File

@ -2,7 +2,7 @@
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
<metadata>
<id>httpie</id>
<version>2.6.0</version>
<version>3.0.2</version>
<summary>Modern, user-friendly command-line HTTP client for the API era</summary>
<description>
HTTPie *aitch-tee-tee-pie* is a user-friendly command-line HTTP client for the API era.
@ -29,11 +29,11 @@ Main features:
<title>HTTPie</title>
<authors>HTTPie</authors>
<owners>jakubroztocil</owners>
<copyright>2012-2021 Jakub Roztocil</copyright>
<copyright>2012-2022 Jakub Roztocil</copyright>
<licenseUrl>https://raw.githubusercontent.com/httpie/httpie/master/LICENSE</licenseUrl>
<iconUrl>https://pie-assets.s3.eu-central-1.amazonaws.com/LogoIcons/GB.png</iconUrl>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<releaseNotes>See the [changelog](https://github.com/httpie/httpie/blob/2.6.0/CHANGELOG.md).</releaseNotes>
<releaseNotes>See the [changelog](https://httpie.io/blog/httpie-3.0.0).</releaseNotes>
<tags>httpie http https rest api client curl python ssl cli foss oss url</tags>
<projectUrl>https://httpie.io</projectUrl>
<packageSourceUrl>https://github.com/httpie/httpie/tree/master/docs/packaging/windows-chocolatey</packageSourceUrl>

View File

@ -0,0 +1,39 @@
# HTTPie Benchmarking Infrastructure
This directory includes the benchmarks we use for testing HTTPie's speed and the
infrastructure to automate this testing accross versions.
## Usage
Ensure the following requirements are satisfied:
- Python 3.7+
- `pyperf`
Then, run the `extras/benchmarks/run.py`:
```console
$ python extras/profiling/run.py
```
Without any options, this command will initially create an isolated environment
and install `httpie` from the latest commit. Then it will create a second
environment with the `master` of the current repository and run the benchmarks
on both of them. It will compare the results and print it as a markdown table:
| Benchmark | master | this_branch |
| -------------------------------------- | :----: | :------------------: |
| `http --version` (startup) | 201 ms | 174 ms: 1.16x faster |
| `http --offline pie.dev/get` (startup) | 200 ms | 174 ms: 1.15x faster |
| Geometric mean | (ref) | 1.10x faster |
If your `master` branch is not up-to-date, you can get a fresh clone by passing
`--fresh` option. This way, the benchmark runner will clone the `httpie/httpie`
repo from `GitHub` and use it as the baseline.
You can customize these branches by passing `--local-repo`/`--target-branch`,
and customize the repos by passing `--local-repo`/`--target-repo` (can either
take a URL or a path).
If you want to run a third enviroment with additional dependencies (such as
`pyOpenSSL`), you can pass `--complex`.

View File

@ -3,6 +3,6 @@ HTTPie: modern, user-friendly command-line HTTP client for the API era.
"""
__version__ = '3.0.1'
__version__ = '3.1.0'
__author__ = 'Jakub Roztocil'
__licence__ = 'BSD'

View File

@ -10,7 +10,8 @@ from urllib.parse import urlsplit
from requests.utils import get_netrc_auth
from .argtypes import (
AuthCredentials, KeyValueArgType, PARSED_DEFAULT_FORMAT_OPTIONS,
AuthCredentials, SSLCredentials, KeyValueArgType,
PARSED_DEFAULT_FORMAT_OPTIONS,
parse_auth,
parse_format_options,
)
@ -47,12 +48,39 @@ class HTTPieHelpFormatter(RawDescriptionHelpFormatter):
text = dedent(text).strip() + '\n\n'
return text.splitlines()
def add_usage(self, usage, actions, groups, prefix=None):
# Only display the positional arguments
displayed_actions = [
action
for action in actions
if not action.option_strings
]
_, exception, _ = sys.exc_info()
if (
isinstance(exception, argparse.ArgumentError)
and len(exception.args) >= 1
and isinstance(exception.args[0], argparse.Action)
):
# add_usage path is also taken when you pass an invalid option,
# e.g --style=invalid. If something like that happens, we want
# to include to action that caused to the invalid usage into
# the list of actions we are displaying.
displayed_actions.insert(0, exception.args[0])
super().add_usage(
usage,
displayed_actions,
groups,
prefix="usage:\n "
)
# TODO: refactor and design type-annotated data structures
# for raw args + parsed args and keep things immutable.
class BaseHTTPieArgumentParser(argparse.ArgumentParser):
def __init__(self, *args, formatter_class=HTTPieHelpFormatter, **kwargs):
super().__init__(*args, formatter_class=formatter_class, **kwargs)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.env = None
self.args = None
self.has_stdin_data = False
@ -115,9 +143,9 @@ class HTTPieArgumentParser(BaseHTTPieArgumentParser):
"""
def __init__(self, *args, **kwargs):
def __init__(self, *args, formatter_class=HTTPieHelpFormatter, **kwargs):
kwargs.setdefault('add_help', False)
super().__init__(*args, **kwargs)
super().__init__(*args, formatter_class=formatter_class, **kwargs)
# noinspection PyMethodOverriding
def parse_args(
@ -148,6 +176,7 @@ class HTTPieArgumentParser(BaseHTTPieArgumentParser):
self._parse_items()
self._process_url()
self._process_auth()
self._process_ssl_cert()
if self.args.raw is not None:
self._body_from_input(self.args.raw)
@ -230,9 +259,24 @@ class HTTPieArgumentParser(BaseHTTPieArgumentParser):
self.env.stdout_isatty = False
if self.args.quiet:
self.env.quiet = self.args.quiet
self.env.stderr = self.env.devnull
if not (self.args.output_file_specified and not self.args.download):
self.env.stdout = self.env.devnull
self.env.apply_warnings_filter()
def _process_ssl_cert(self):
from httpie.ssl_ import _is_key_file_encrypted
if self.args.cert_key_pass is None:
self.args.cert_key_pass = SSLCredentials(None)
if (
self.args.cert_key is not None
and self.args.cert_key_pass.value is None
and _is_key_file_encrypted(self.args.cert_key)
):
self.args.cert_key_pass.prompt_password(self.args.cert_key)
def _process_auth(self):
# TODO: refactor & simplify this method.
@ -512,3 +556,20 @@ class HTTPieArgumentParser(BaseHTTPieArgumentParser):
for options_group in format_options:
parsed_options = parse_format_options(options_group, defaults=parsed_options)
self.args.format_options = parsed_options
def error(self, message):
"""Prints a usage message incorporating the message to stderr and
exits."""
self.print_usage(sys.stderr)
self.exit(
2,
dedent(
f'''
error:
{message}
for more information:
run '{self.prog} --help' or visit https://httpie.io/docs/cli
'''
)
)

View File

@ -130,16 +130,11 @@ class KeyValueArgType:
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}: '
class PromptMixin:
def _prompt_password(self, prompt: str) -> str:
prompt_text = f'http: {prompt}: '
try:
self.value = self._getpass(prompt_text)
return self._getpass(prompt_text)
except (EOFError, KeyboardInterrupt):
sys.stderr.write('\n')
sys.exit(0)
@ -150,6 +145,26 @@ class AuthCredentials(KeyValueArg):
return getpass.getpass(str(prompt))
class SSLCredentials(PromptMixin):
"""Represents the passphrase for the certificate's key."""
def __init__(self, value: Optional[str]) -> None:
self.value = value
def prompt_password(self, key_file: str) -> None:
self.value = self._prompt_password(f'passphrase for {key_file}')
class AuthCredentials(KeyValueArg, PromptMixin):
"""Represents parsed credentials."""
def has_password(self) -> bool:
return self.value is not None
def prompt_password(self, host: str) -> None:
self.value = self._prompt_password(f'password for {self.key}@{host}:')
class AuthCredentialsArgType(KeyValueArgType):
"""A key-value arg type that parses credentials."""

View File

@ -90,13 +90,19 @@ OUTPUT_OPTIONS = frozenset({
})
# Pretty
class PrettyOptions(enum.Enum):
STDOUT_TTY_ONLY = enum.auto()
PRETTY_MAP = {
'all': ['format', 'colors'],
'colors': ['colors'],
'format': ['format'],
'none': []
}
PRETTY_STDOUT_TTY_ONLY = object()
PRETTY_STDOUT_TTY_ONLY = PrettyOptions.STDOUT_TTY_ONLY
DEFAULT_FORMAT_OPTIONS = [
@ -127,6 +133,7 @@ class RequestType(enum.Enum):
JSON = enum.auto()
EMPTY_STRING = ''
OPEN_BRACKET = '['
CLOSE_BRACKET = ']'
BACKSLASH = '\\'

File diff suppressed because it is too large Load Diff

View File

@ -82,3 +82,7 @@ class MultipartRequestDataDict(MultiValueOrderedDict):
class RequestFilesDict(RequestDataDict):
pass
class NestedJSONArray(list):
"""Denotes a top-level JSON array."""

View File

@ -9,7 +9,8 @@ from typing import (
Type,
Union,
)
from httpie.cli.constants import OPEN_BRACKET, CLOSE_BRACKET, BACKSLASH, HIGHLIGHTER
from httpie.cli.dicts import NestedJSONArray
from httpie.cli.constants import EMPTY_STRING, OPEN_BRACKET, CLOSE_BRACKET, BACKSLASH, HIGHLIGHTER
class HTTPieSyntaxError(ValueError):
@ -52,6 +53,7 @@ class TokenKind(Enum):
OPERATORS = {OPEN_BRACKET: TokenKind.LEFT_BRACKET, CLOSE_BRACKET: TokenKind.RIGHT_BRACKET}
SPECIAL_CHARS = OPERATORS.keys() | {BACKSLASH}
LITERAL_TOKENS = [TokenKind.TEXT, TokenKind.NUMBER]
class Token(NamedTuple):
@ -88,18 +90,18 @@ def tokenize(source: str) -> Iterator[Token]:
return None
value = ''.join(buffer)
for variation, kind in [
(int, TokenKind.NUMBER),
(check_escaped_int, TokenKind.TEXT),
]:
try:
value = variation(value)
except ValueError:
continue
else:
break
else:
kind = TokenKind.TEXT
kind = TokenKind.TEXT
if not backslashes:
for variation, kind in [
(int, TokenKind.NUMBER),
(check_escaped_int, TokenKind.TEXT),
]:
try:
value = variation(value)
except ValueError:
continue
else:
break
yield Token(
kind, value, start=cursor - (len(buffer) + backslashes), end=cursor
@ -171,8 +173,8 @@ class Path:
def parse(source: str) -> Iterator[Path]:
"""
start: literal? path*
start: root_path path*
root_path: (literal | index_path | append_path)
literal: TEXT | NUMBER
path:
@ -215,16 +217,47 @@ def parse(source: str) -> Iterator[Path]:
message = f'Expecting {suffix}'
raise HTTPieSyntaxError(source, token, message)
root = Path(PathAction.KEY, '', is_root=True)
if can_advance():
token = tokens[cursor]
if token.kind in {TokenKind.TEXT, TokenKind.NUMBER}:
token = expect(TokenKind.TEXT, TokenKind.NUMBER)
root.accessor = str(token.value)
root.tokens.append(token)
def parse_root():
tokens = []
if not can_advance():
return Path(
PathAction.KEY,
EMPTY_STRING,
is_root=True
)
yield root
# (literal | index_path | append_path)?
token = expect(*LITERAL_TOKENS, TokenKind.LEFT_BRACKET)
tokens.append(token)
if token.kind in LITERAL_TOKENS:
action = PathAction.KEY
value = str(token.value)
elif token.kind is TokenKind.LEFT_BRACKET:
token = expect(TokenKind.NUMBER, TokenKind.RIGHT_BRACKET)
tokens.append(token)
if token.kind is TokenKind.NUMBER:
action = PathAction.INDEX
value = token.value
tokens.append(expect(TokenKind.RIGHT_BRACKET))
elif token.kind is TokenKind.RIGHT_BRACKET:
action = PathAction.APPEND
value = None
else:
assert_cant_happen()
else:
assert_cant_happen()
return Path(
action,
value,
tokens=tokens,
is_root=True
)
yield parse_root()
# path*
while can_advance():
path_tokens = []
path_tokens.append(expect(TokenKind.LEFT_BRACKET))
@ -296,6 +329,10 @@ def interpret(context: Any, key: str, value: Any) -> Any:
assert_cant_happen()
for index, (path, next_path) in enumerate(zip(paths, paths[1:])):
# If there is no context yet, set it.
if cursor is None:
context = cursor = object_for(path.kind)
if path.kind is PathAction.KEY:
type_check(index, path, dict)
if next_path.kind is PathAction.SET:
@ -337,8 +374,19 @@ def interpret(context: Any, key: str, value: Any) -> Any:
return context
def wrap_with_dict(context):
if context is None:
return {}
elif isinstance(context, list):
return {EMPTY_STRING: NestedJSONArray(context)}
else:
assert isinstance(context, dict)
return context
def interpret_nested_json(pairs):
context = {}
context = None
for key, value in pairs:
interpret(context, key, value)
return context
context = interpret(context, key, value)
return wrap_with_dict(context)

189
httpie/cli/options.py Normal file
View File

@ -0,0 +1,189 @@
import argparse
import textwrap
import typing
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Any, Optional, Dict, List, Type, TypeVar
from httpie.cli.argparser import HTTPieArgumentParser
from httpie.cli.utils import LazyChoices
class Qualifiers(Enum):
OPTIONAL = auto()
ZERO_OR_MORE = auto()
SUPPRESS = auto()
def map_qualifiers(
configuration: Dict[str, Any], qualifier_map: Dict[Qualifiers, Any]
) -> Dict[str, Any]:
return {
key: qualifier_map[value] if isinstance(value, Qualifiers) else value
for key, value in configuration.items()
}
PARSER_SPEC_VERSION = '0.0.1a0'
@dataclass
class ParserSpec:
program: str
description: Optional[str] = None
epilog: Optional[str] = None
groups: List['Group'] = field(default_factory=list)
def finalize(self) -> 'ParserSpec':
if self.description:
self.description = textwrap.dedent(self.description)
if self.epilog:
self.epilog = textwrap.dedent(self.epilog)
for group in self.groups:
group.finalize()
return self
def add_group(self, name: str, **kwargs) -> 'Group':
group = Group(name, **kwargs)
self.groups.append(group)
return group
def serialize(self) -> Dict[str, Any]:
return {
'name': self.program,
'description': self.description,
'groups': [group.serialize() for group in self.groups],
}
@dataclass
class Group:
name: str
description: str = ''
is_mutually_exclusive: bool = False
arguments: List['Argument'] = field(default_factory=list)
def finalize(self) -> None:
if self.description:
self.description = textwrap.dedent(self.description)
def add_argument(self, *args, **kwargs):
argument = Argument(list(args), kwargs.copy())
self.arguments.append(argument)
return argument
def serialize(self) -> Dict[str, Any]:
return {
'name': self.name,
'description': self.description or None,
'is_mutually_exclusive': self.is_mutually_exclusive,
'args': [argument.serialize() for argument in self.arguments],
}
class Argument(typing.NamedTuple):
aliases: List[str]
configuration: Dict[str, Any]
def serialize(self) -> Dict[str, Any]:
configuration = self.configuration.copy()
# Unpack the dynamically computed choices, since we
# will need to store the actual values somewhere.
action = configuration.pop('action', None)
if action == 'lazy_choices':
choices = LazyChoices(self.aliases, **{'dest': None, **configuration})
configuration['choices'] = list(choices.load())
configuration['help'] = choices.help
result = {}
if self.aliases:
result['options'] = self.aliases.copy()
else:
result['options'] = [configuration['metavar']]
result['is_positional'] = True
qualifiers = JSON_QUALIFIER_TO_OPTIONS[configuration.get('nargs', Qualifiers.SUPPRESS)]
result.update(qualifiers)
help_msg = configuration.get('help')
if help_msg and help_msg is not Qualifiers.SUPPRESS:
result['description'] = help_msg.strip()
python_type = configuration.get('type')
if python_type is not None:
if hasattr(python_type, '__name__'):
type_name = python_type.__name__
else:
type_name = type(python_type).__name__
result['python_type_name'] = type_name
result.update({
key: value
for key, value in configuration.items()
if key in JSON_DIRECT_MIRROR_OPTIONS
})
return result
def __getattr__(self, attribute_name):
if attribute_name in self.configuration:
return self.configuration[attribute_name]
else:
raise AttributeError(attribute_name)
ParserType = TypeVar('ParserType', bound=Type[argparse.ArgumentParser])
ARGPARSE_QUALIFIER_MAP = {
Qualifiers.OPTIONAL: argparse.OPTIONAL,
Qualifiers.SUPPRESS: argparse.SUPPRESS,
Qualifiers.ZERO_OR_MORE: argparse.ZERO_OR_MORE,
}
def to_argparse(
abstract_options: ParserSpec,
parser_type: ParserType = HTTPieArgumentParser,
) -> ParserType:
concrete_parser = parser_type(
prog=abstract_options.program,
description=abstract_options.description,
epilog=abstract_options.epilog,
)
concrete_parser.register('action', 'lazy_choices', LazyChoices)
for abstract_group in abstract_options.groups:
concrete_group = concrete_parser.add_argument_group(
title=abstract_group.name, description=abstract_group.description
)
if abstract_group.is_mutually_exclusive:
concrete_group = concrete_group.add_mutually_exclusive_group(required=False)
for abstract_argument in abstract_group.arguments:
concrete_group.add_argument(
*abstract_argument.aliases,
**map_qualifiers(
abstract_argument.configuration, ARGPARSE_QUALIFIER_MAP
)
)
return concrete_parser
JSON_DIRECT_MIRROR_OPTIONS = (
'choices',
'metavar'
)
JSON_QUALIFIER_TO_OPTIONS = {
Qualifiers.OPTIONAL: {'is_optional': True},
Qualifiers.ZERO_OR_MORE: {'is_optional': True, 'is_variadic': True},
Qualifiers.SUPPRESS: {}
}
def to_data(abstract_options: ParserSpec) -> Dict[str, Any]:
return {'version': PARSER_SPEC_VERSION, 'spec': abstract_options.serialize()}

View File

@ -13,12 +13,13 @@ import urllib3
from . import __version__
from .adapters import HTTPieHTTPAdapter
from .context import Environment
from .cli.dicts import HTTPHeadersDict
from .cli.constants import EMPTY_STRING
from .cli.dicts import HTTPHeadersDict, NestedJSONArray
from .encoding import UTF8
from .models import RequestsMessage
from .plugins.registry import plugin_manager
from .sessions import get_httpie_session
from .ssl_ import AVAILABLE_SSL_VERSION_ARG_MAPPING, HTTPieHTTPSAdapter
from .ssl_ import AVAILABLE_SSL_VERSION_ARG_MAPPING, HTTPieCertificate, HTTPieHTTPSAdapter
from .uploads import (
compress_request, prepare_request_body,
get_multipart_data_and_content_type,
@ -43,6 +44,7 @@ def collect_messages(
httpie_session_headers = None
if args.session or args.session_read_only:
httpie_session = get_httpie_session(
env=env,
config_dir=env.config.directory,
session_name=args.session or args.session_read_only,
host=args.headers.get('Host'),
@ -129,10 +131,7 @@ def collect_messages(
if httpie_session:
if httpie_session.is_new() or not args.session_read_only:
httpie_session.cookies = requests_session.cookies
httpie_session.remove_cookies(
# TODO: take path & domain into account?
cookie['name'] for cookie in expired_cookies
)
httpie_session.remove_cookies(expired_cookies)
httpie_session.save()
@ -261,7 +260,14 @@ def make_send_kwargs_mergeable_from_env(args: argparse.Namespace) -> dict:
if args.cert:
cert = args.cert
if args.cert_key:
cert = cert, args.cert_key
# Having a client certificate key passphrase is not supported
# by requests. So we are using our own transportation structure
# which is compatible with their format (a tuple of minimum two
# items).
#
# See: https://github.com/psf/requests/issues/2519
cert = HTTPieCertificate(cert, args.cert_key, args.cert_key_pass.value)
return {
'proxies': {p.key: p.value for p in args.proxy},
'stream': True,
@ -280,7 +286,8 @@ def json_dict_to_request_body(data: Dict[str, Any]) -> str:
# item in the object, with an en empty key.
if len(data) == 1:
[(key, value)] = data.items()
if key == '' and isinstance(value, list):
if isinstance(value, NestedJSONArray):
assert key == EMPTY_STRING
data = value
if data:

View File

@ -1,7 +1,7 @@
import json
import os
from pathlib import Path
from typing import Union
from typing import Any, Dict, Union
from . import __version__
from .compat import is_windows
@ -62,6 +62,21 @@ class ConfigFileError(Exception):
pass
def read_raw_config(config_type: str, path: Path) -> Dict[str, Any]:
try:
with path.open(encoding=UTF8) as f:
try:
return json.load(f)
except ValueError as e:
raise ConfigFileError(
f'invalid {config_type} file: {e} [{path}]'
)
except FileNotFoundError:
pass
except OSError as e:
raise ConfigFileError(f'cannot read {config_type} file: {e}')
class BaseConfigDict(dict):
name = None
helpurl = None
@ -77,26 +92,25 @@ class BaseConfigDict(dict):
def is_new(self) -> bool:
return not self.path.exists()
def pre_process_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Hook for processing the incoming config data."""
return data
def post_process_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Hook for processing the outgoing config data."""
return data
def load(self):
config_type = type(self).__name__.lower()
try:
with self.path.open(encoding=UTF8) as f:
try:
data = json.load(f)
except ValueError as e:
raise ConfigFileError(
f'invalid {config_type} file: {e} [{self.path}]'
)
self.update(data)
except FileNotFoundError:
pass
except OSError as e:
raise ConfigFileError(f'cannot read {config_type} file: {e}')
data = read_raw_config(config_type, self.path)
if data is not None:
data = self.pre_process_data(data)
self.update(data)
def save(self):
self['__meta__'] = {
'httpie': __version__
}
def save(self, *, bump_version: bool = False):
self.setdefault('__meta__', {})
if bump_version or 'httpie' not in self['__meta__']:
self['__meta__']['httpie'] = __version__
if self.helpurl:
self['__meta__']['help'] = self.helpurl
@ -106,13 +120,19 @@ class BaseConfigDict(dict):
self.ensure_directory()
json_string = json.dumps(
obj=self,
obj=self.post_process_data(self),
indent=4,
sort_keys=True,
ensure_ascii=True,
)
self.path.write_text(json_string + '\n', encoding=UTF8)
@property
def version(self):
return self.get(
'__meta__', {}
).get('httpie', __version__)
class Config(BaseConfigDict):
FILENAME = 'config.json'

View File

@ -1,8 +1,10 @@
import sys
import os
import warnings
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator, IO, Optional
from enum import Enum
try:
@ -17,6 +19,17 @@ from .encoding import UTF8
from .utils import repr_dict
class Levels(str, Enum):
WARNING = 'warning'
ERROR = 'error'
DISPLAY_THRESHOLDS = {
Levels.WARNING: 2,
Levels.ERROR: float('inf'), # Never hide errors.
}
class Environment:
"""
Information about the execution context
@ -87,6 +100,8 @@ class Environment:
self.stdout_encoding = getattr(
actual_stdout, 'encoding', None) or UTF8
self.quiet = kwargs.pop('quiet', 0)
def __str__(self):
defaults = dict(type(self).__dict__)
actual = dict(defaults)
@ -134,6 +149,14 @@ class Environment:
self.stdout = original_stdout
self.stderr = original_stderr
def log_error(self, msg, level='error'):
assert level in ['error', 'warning']
self._orig_stderr.write(f'\n{self.program_name}: {level}: {msg}\n\n')
def log_error(self, msg: str, level: Levels = Levels.ERROR) -> None:
if self.stdout_isatty and self.quiet >= DISPLAY_THRESHOLDS[level]:
stderr = self.stderr # Not directly /dev/null, since stderr might be mocked
else:
stderr = self._orig_stderr
stderr.write(f'\n{self.program_name}: {level}: {msg}\n\n')
def apply_warnings_filter(self) -> None:
if self.quiet >= DISPLAY_THRESHOLDS[Levels.WARNING]:
warnings.simplefilter("ignore")

View File

@ -13,13 +13,14 @@ from . import __version__ as httpie_version
from .cli.constants import OUT_REQ_BODY
from .cli.nested_json import HTTPieSyntaxError
from .client import collect_messages
from .context import Environment
from .context import Environment, Levels
from .downloads import Downloader
from .models import (
RequestsMessageKind,
OutputOptions,
OutputOptions
)
from .output.writer import write_message, write_stream, MESSAGE_SEPARATOR_BYTES
from .output.models import ProcessingOptions
from .output.writer import write_message, write_stream, write_raw_data, MESSAGE_SEPARATOR_BYTES
from .plugins.registry import plugin_manager
from .status import ExitStatus, http_status_to_exit_status
from .utils import unwrap_context
@ -30,14 +31,15 @@ def raw_main(
parser: argparse.ArgumentParser,
main_program: Callable[[argparse.Namespace, Environment], ExitStatus],
args: List[Union[str, bytes]] = sys.argv,
env: Environment = Environment()
env: Environment = Environment(),
use_default_options: bool = True,
) -> ExitStatus:
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(env.config.plugins_dir)
if env.config.default_options:
if use_default_options and env.config.default_options:
args = env.config.default_options + args
include_debug_info = '--debug' in args
@ -168,6 +170,7 @@ def program(args: argparse.Namespace, env: Environment) -> ExitStatus:
downloader = None
initial_request: Optional[requests.PreparedRequest] = None
final_response: Optional[requests.Response] = None
processing_options = ProcessingOptions.from_raw_args(args)
def separate():
getattr(env.stdout, 'buffer', env.stdout).write(MESSAGE_SEPARATOR_BYTES)
@ -182,12 +185,12 @@ def program(args: argparse.Namespace, env: Environment) -> ExitStatus:
and chunk
)
if should_pipe_to_stdout:
msg = requests.PreparedRequest()
msg.is_body_upload_chunk = True
msg.body = chunk
msg.headers = initial_request.headers
msg_output_options = OutputOptions.from_message(msg, body=True, headers=False)
write_message(requests_message=msg, env=env, args=args, output_options=msg_output_options)
return write_raw_data(
env,
chunk,
processing_options=processing_options,
headers=initial_request.headers
)
try:
if args.download:
@ -220,10 +223,15 @@ def program(args: argparse.Namespace, env: Environment) -> ExitStatus:
if args.check_status or downloader:
exit_status = http_status_to_exit_status(http_status=message.status_code, follow=args.follow)
if exit_status != ExitStatus.SUCCESS and (not env.stdout_isatty or args.quiet == 1):
env.log_error(f'HTTP {message.raw.status} {message.raw.reason}', level='warning')
write_message(requests_message=message, env=env, args=args, output_options=output_options._replace(
body=do_write_body
))
env.log_error(f'HTTP {message.raw.status} {message.raw.reason}', level=Levels.WARNING)
write_message(
requests_message=message,
env=env,
output_options=output_options._replace(
body=do_write_body
),
processing_options=processing_options
)
prev_with_body = output_options.body
# Cleanup

View File

View File

@ -0,0 +1,103 @@
import argparse
from typing import Any, Type, List, Dict, TYPE_CHECKING
if TYPE_CHECKING:
from httpie.sessions import Session
INSECURE_COOKIE_JAR_WARNING = '''\
Outdated layout detected for the current session. Please consider updating it,
in order to not get affected by potential security problems.
For fixing the current session:
With binding all cookies to the current host (secure):
$ httpie cli sessions upgrade --bind-cookies {hostname} {session_id}
Without binding cookies (leaving them as is) (insecure):
$ httpie cli sessions upgrade {hostname} {session_id}
'''
INSECURE_COOKIE_JAR_WARNING_FOR_NAMED_SESSIONS = '''\
For fixing all named sessions:
With binding all cookies to the current host (secure):
$ httpie cli sessions upgrade-all --bind-cookies
Without binding cookies (leaving them as is) (insecure):
$ httpie cli sessions upgrade-all
'''
INSECURE_COOKIE_SECURITY_LINK = '\nSee https://pie.co/docs/security for more information.'
def pre_process(session: 'Session', cookies: Any) -> List[Dict[str, Any]]:
"""Load the given cookies to the cookie jar while maintaining
support for the old cookie layout."""
is_old_style = isinstance(cookies, dict)
if is_old_style:
normalized_cookies = [
{
'name': key,
**value
}
for key, value in cookies.items()
]
else:
normalized_cookies = cookies
should_issue_warning = is_old_style and any(
cookie.get('domain', '') == ''
for cookie in normalized_cookies
)
if should_issue_warning and not session.refactor_mode:
warning = INSECURE_COOKIE_JAR_WARNING.format(hostname=session.bound_host, session_id=session.session_id)
if not session.is_anonymous:
warning += INSECURE_COOKIE_JAR_WARNING_FOR_NAMED_SESSIONS
warning += INSECURE_COOKIE_SECURITY_LINK
session.env.log_error(
warning,
level='warning'
)
return normalized_cookies
def post_process(
normalized_cookies: List[Dict[str, Any]],
*,
original_type: Type[Any]
) -> Any:
"""Convert the cookies to their original format for
maximum compatibility."""
if issubclass(original_type, dict):
return {
cookie.pop('name'): cookie
for cookie in normalized_cookies
}
else:
return normalized_cookies
def fix_layout(session: 'Session', hostname: str, args: argparse.Namespace) -> None:
if not isinstance(session['cookies'], dict):
return None
session['cookies'] = [
{
'name': key,
**value
}
for key, value in session['cookies'].items()
]
for cookie in session.cookies:
if cookie.domain == '':
if args.bind_cookies:
cookie.domain = hostname
else:
cookie._rest['is_explicit_none'] = True

View File

@ -37,7 +37,8 @@ def main(args: List[Union[str, bytes]] = sys.argv, env: Environment = Environmen
parser=parser,
main_program=main_program,
args=args,
env=env
env=env,
use_default_options=False,
)
except argparse.ArgumentError:
program_args = args[1:]

View File

@ -2,6 +2,15 @@ from textwrap import dedent
from httpie.cli.argparser import HTTPieManagerArgumentParser
from httpie import __version__
CLI_SESSION_UPGRADE_FLAGS = [
{
'flags': ['--bind-cookies'],
'action': 'store_true',
'default': False,
'help': 'Bind domainless cookies to the host that session belongs.'
}
]
COMMANDS = {
'plugins': {
'help': 'Manage HTTPie plugins.',
@ -34,6 +43,42 @@ COMMANDS = {
'List all installed HTTPie plugins.'
],
},
'cli': {
'help': 'Manage HTTPie for Terminal',
'export-args': [
'Export available options for the CLI',
{
'flags': ['-f', '--format'],
'choices': ['json'],
'default': 'json'
}
],
'sessions': {
'help': 'Manage HTTPie sessions',
'upgrade': [
'Upgrade the given HTTPie session with the latest '
'layout. A list of changes between different session versions '
'can be found in the official documentation.',
{
'dest': 'hostname',
'metavar': 'HOSTNAME',
'help': 'The host this session belongs.'
},
{
'dest': 'session',
'metavar': 'SESSION_NAME_OR_PATH',
'help': 'The name or the path for the session that will be upgraded.'
},
*CLI_SESSION_UPGRADE_FLAGS
],
'upgrade-all': [
'Upgrade all named sessions with the latest layout. A list of '
'changes between different session versions can be found in the official '
'documentation.',
*CLI_SESSION_UPGRADE_FLAGS
],
}
}
}
@ -54,6 +99,8 @@ def generate_subparsers(root, parent_parser, definitions):
)
for command, properties in definitions.items():
is_subparser = isinstance(properties, dict)
properties = properties.copy()
descr = properties.pop('help', None) if is_subparser else properties.pop(0)
command_parser = actions.add_parser(command, description=descr)
command_parser.root = root
@ -62,7 +109,9 @@ def generate_subparsers(root, parent_parser, definitions):
continue
for argument in properties:
command_parser.add_argument(**argument)
argument = argument.copy()
flags = argument.pop('flags', [])
command_parser.add_argument(*flags, **argument)
parser = HTTPieManagerArgumentParser(

View File

@ -1,9 +1,11 @@
import argparse
from typing import Optional
from httpie.context import Environment
from httpie.manager.plugins import PluginInstaller
from httpie.status import ExitStatus
from httpie.manager.cli import missing_subcommand, parser
from httpie.manager.tasks import CLI_TASKS
MSG_COMMAND_CONFUSION = '''\
This command is only for managing HTTPie plugins.
@ -22,6 +24,13 @@ MSG_NAKED_INVOCATION = f'''\
'''.rstrip("\n").format(args='POST pie.dev/post hello=world')
def dispatch_cli_task(env: Environment, action: Optional[str], args: argparse.Namespace) -> ExitStatus:
if action is None:
parser.error(missing_subcommand('cli'))
return CLI_TASKS[action](env, args)
def program(args: argparse.Namespace, env: Environment) -> ExitStatus:
if args.action is None:
parser.error(MSG_NAKED_INVOCATION)
@ -29,5 +38,7 @@ def program(args: argparse.Namespace, env: Environment) -> ExitStatus:
if args.action == 'plugins':
plugins = PluginInstaller(env, debug=args.debug)
return plugins.run(args.plugins_action, args)
elif args.action == 'cli':
return dispatch_cli_task(env, args.cli_action, args)
return ExitStatus.SUCCESS

141
httpie/manager/tasks.py Normal file
View File

@ -0,0 +1,141 @@
import argparse
from typing import TypeVar, Callable, Tuple
from httpie.sessions import SESSIONS_DIR_NAME, get_httpie_session
from httpie.status import ExitStatus
from httpie.context import Environment
from httpie.legacy import cookie_format as legacy_cookies
from httpie.manager.cli import missing_subcommand, parser
T = TypeVar('T')
CLI_TASKS = {}
def task(name: str) -> Callable[[T], T]:
def wrapper(func: T) -> T:
CLI_TASKS[name] = func
return func
return wrapper
@task('sessions')
def cli_sessions(env: Environment, args: argparse.Namespace) -> ExitStatus:
action = args.cli_sessions_action
if action is None:
parser.error(missing_subcommand('cli', 'sessions'))
if action == 'upgrade':
return cli_upgrade_session(env, args)
elif action == 'upgrade-all':
return cli_upgrade_all_sessions(env, args)
else:
raise ValueError(f'Unexpected action: {action}')
def is_version_greater(version_1: str, version_2: str) -> bool:
# In an ideal scenerio, we would depend on `packaging` in order
# to offer PEP 440 compatible parsing. But since it might not be
# commonly available for outside packages, and since we are only
# going to parse HTTPie's own version it should be fine to compare
# this in a SemVer subset fashion.
def split_version(version: str) -> Tuple[int, ...]:
parts = []
for part in version.split('.')[:3]:
try:
parts.append(int(part))
except ValueError:
break
return tuple(parts)
return split_version(version_1) > split_version(version_2)
FIXERS_TO_VERSIONS = {
'3.1.0': legacy_cookies.fix_layout
}
def upgrade_session(env: Environment, args: argparse.Namespace, hostname: str, session_name: str):
session = get_httpie_session(
env=env,
config_dir=env.config.directory,
session_name=session_name,
host=hostname,
url=hostname,
refactor_mode=True
)
session_name = session.path.stem
if session.is_new():
env.log_error(f'{session_name!r} @ {hostname!r} does not exist.')
return ExitStatus.ERROR
fixers = [
fixer
for version, fixer in FIXERS_TO_VERSIONS.items()
if is_version_greater(version, session.version)
]
if len(fixers) == 0:
env.stdout.write(f'{session_name!r} @ {hostname!r} is already up to date.\n')
return ExitStatus.SUCCESS
for fixer in fixers:
fixer(session, hostname, args)
session.save(bump_version=True)
env.stdout.write(f'Upgraded {session_name!r} @ {hostname!r} to v{session.version}\n')
return ExitStatus.SUCCESS
def cli_upgrade_session(env: Environment, args: argparse.Namespace) -> ExitStatus:
return upgrade_session(
env,
args=args,
hostname=args.hostname,
session_name=args.session
)
def cli_upgrade_all_sessions(env: Environment, args: argparse.Namespace) -> ExitStatus:
session_dir_path = env.config_dir / SESSIONS_DIR_NAME
status = ExitStatus.SUCCESS
for host_path in session_dir_path.iterdir():
hostname = host_path.name
for session_path in host_path.glob("*.json"):
session_name = session_path.stem
status |= upgrade_session(
env,
args=args,
hostname=hostname,
session_name=session_name
)
return status
FORMAT_TO_CONTENT_TYPE = {
'json': 'application/json'
}
@task('export-args')
def cli_export(env: Environment, args: argparse.Namespace) -> ExitStatus:
import json
from httpie.cli.definition import options
from httpie.cli.options import to_data
from httpie.output.writer import write_raw_data
if args.format == 'json':
data = json.dumps(to_data(options))
else:
raise NotImplementedError(f'Unexpected format value: {args.format}')
write_raw_data(
env,
data,
stream_kwargs={'mime_overwrite': FORMAT_TO_CONTENT_TYPE[args.format]},
)
return ExitStatus.SUCCESS

View File

@ -2,7 +2,7 @@ import re
import pygments
from httpie.output.lexers.common import precise
RE_STATUS_LINE = re.compile(r'(\d{3})( +)(.+)')
RE_STATUS_LINE = re.compile(r'(\d{3})( +)?(.+)?')
STATUS_TYPES = {
'1': pygments.token.Number.HTTP.INFO,

44
httpie/output/models.py Normal file
View File

@ -0,0 +1,44 @@
import argparse
from typing import Any, Dict, Union, List, NamedTuple, Optional
from httpie.context import Environment
from httpie.cli.constants import PrettyOptions, PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY
from httpie.cli.argtypes import PARSED_DEFAULT_FORMAT_OPTIONS
from httpie.output.formatters.colors import AUTO_STYLE
class ProcessingOptions(NamedTuple):
"""Represents a set of stylistic options
that are used when deciding which stream
should be used."""
debug: bool = False
traceback: bool = False
stream: bool = False
style: str = AUTO_STYLE
prettify: Union[List[str], PrettyOptions] = PRETTY_STDOUT_TTY_ONLY
response_mime: Optional[str] = None
response_charset: Optional[str] = None
json: bool = False
format_options: Dict[str, Any] = PARSED_DEFAULT_FORMAT_OPTIONS
def get_prettify(self, env: Environment) -> List[str]:
if self.prettify is PRETTY_STDOUT_TTY_ONLY:
return PRETTY_MAP['all' if env.stdout_isatty else 'none']
else:
return self.prettify
@classmethod
def from_raw_args(cls, options: argparse.Namespace) -> 'ProcessingOptions':
fetched_options = {
option: getattr(options, option)
for option in cls._fields
}
return cls(**fetched_options)
@property
def show_traceback(self):
return self.debug or self.traceback

View File

@ -34,7 +34,8 @@ class BaseStream(metaclass=ABCMeta):
self,
msg: HTTPMessage,
output_options: OutputOptions,
on_body_chunk_downloaded: Callable[[bytes], None] = None
on_body_chunk_downloaded: Callable[[bytes], None] = None,
**kwargs
):
"""
:param msg: a :class:`models.HTTPMessage` subclass
@ -45,6 +46,7 @@ class BaseStream(metaclass=ABCMeta):
self.msg = msg
self.output_options = output_options
self.on_body_chunk_downloaded = on_body_chunk_downloaded
self.extra_options = kwargs
def get_headers(self) -> bytes:
"""Return the headers' bytes."""

View File

@ -1,6 +1,6 @@
import argparse
import errno
from typing import IO, TextIO, Tuple, Type, Union
import requests
from typing import Any, Dict, IO, Optional, TextIO, Tuple, Type, Union
from ..cli.dicts import HTTPHeadersDict
from ..context import Environment
@ -10,8 +10,9 @@ from ..models import (
HTTPMessage,
RequestsMessage,
RequestsMessageKind,
OutputOptions
OutputOptions,
)
from .models import ProcessingOptions
from .processing import Conversion, Formatting
from .streams import (
BaseStream, BufferedPrettyStream, EncodedStream, PrettyStream, RawStream,
@ -25,30 +26,31 @@ MESSAGE_SEPARATOR_BYTES = MESSAGE_SEPARATOR.encode()
def write_message(
requests_message: RequestsMessage,
env: Environment,
args: argparse.Namespace,
output_options: OutputOptions,
processing_options: ProcessingOptions,
extra_stream_kwargs: Optional[Dict[str, Any]] = None
):
if not output_options.any():
return
write_stream_kwargs = {
'stream': build_output_stream_for_message(
args=args,
env=env,
requests_message=requests_message,
output_options=output_options,
processing_options=processing_options,
extra_stream_kwargs=extra_stream_kwargs
),
# NOTE: `env.stdout` will in fact be `stderr` with `--download`
'outfile': env.stdout,
'flush': env.stdout_isatty or args.stream
'flush': env.stdout_isatty or processing_options.stream
}
try:
if env.is_windows and 'colors' in args.prettify:
if env.is_windows and 'colors' in processing_options.get_prettify(env):
write_stream_with_colors_win(**write_stream_kwargs)
else:
write_stream(**write_stream_kwargs)
except OSError as e:
show_traceback = args.debug or args.traceback
if not show_traceback and e.errno == errno.EPIPE:
if processing_options.show_traceback and e.errno == errno.EPIPE:
# Ignore broken pipes unless --traceback.
env.stderr.write('\n')
else:
@ -94,11 +96,34 @@ def write_stream_with_colors_win(
outfile.flush()
def write_raw_data(
env: Environment,
data: Any,
*,
processing_options: Optional[ProcessingOptions] = None,
headers: Optional[HTTPHeadersDict] = None,
stream_kwargs: Optional[Dict[str, Any]] = None
):
msg = requests.PreparedRequest()
msg.is_body_upload_chunk = True
msg.body = data
msg.headers = headers or HTTPHeadersDict()
msg_output_options = OutputOptions.from_message(msg, body=True, headers=False)
return write_message(
requests_message=msg,
env=env,
output_options=msg_output_options,
processing_options=processing_options or ProcessingOptions(),
extra_stream_kwargs=stream_kwargs
)
def build_output_stream_for_message(
args: argparse.Namespace,
env: Environment,
requests_message: RequestsMessage,
output_options: OutputOptions,
processing_options: ProcessingOptions,
extra_stream_kwargs: Optional[Dict[str, Any]] = None
):
message_type = {
RequestsMessageKind.REQUEST: HTTPRequest,
@ -106,10 +131,12 @@ def build_output_stream_for_message(
}[output_options.kind]
stream_class, stream_kwargs = get_stream_type_and_kwargs(
env=env,
args=args,
processing_options=processing_options,
message_type=message_type,
headers=requests_message.headers
)
if extra_stream_kwargs:
stream_kwargs.update(extra_stream_kwargs)
yield from stream_class(
msg=message_type(requests_message),
output_options=output_options,
@ -124,20 +151,21 @@ def build_output_stream_for_message(
def get_stream_type_and_kwargs(
env: Environment,
args: argparse.Namespace,
processing_options: ProcessingOptions,
message_type: Type[HTTPMessage],
headers: HTTPHeadersDict,
) -> Tuple[Type['BaseStream'], dict]:
"""Pick the right stream type and kwargs for it based on `env` and `args`.
"""
is_stream = args.stream
is_stream = processing_options.stream
prettify_groups = processing_options.get_prettify(env)
if not is_stream and message_type is HTTPResponse:
# If this is a response, then check the headers for determining
# auto-streaming.
is_stream = headers.get('Content-Type') == 'text/event-stream'
if not env.stdout_isatty and not args.prettify:
if not env.stdout_isatty and not prettify_groups:
stream_class = RawStream
stream_kwargs = {
'chunk_size': (
@ -153,19 +181,19 @@ def get_stream_type_and_kwargs(
}
if message_type is HTTPResponse:
stream_kwargs.update({
'mime_overwrite': args.response_mime,
'encoding_overwrite': args.response_charset,
'mime_overwrite': processing_options.response_mime,
'encoding_overwrite': processing_options.response_charset,
})
if args.prettify:
if prettify_groups:
stream_class = PrettyStream if is_stream else BufferedPrettyStream
stream_kwargs.update({
'conversion': Conversion(),
'formatting': Formatting(
env=env,
groups=args.prettify,
color_scheme=args.style,
explicit_json=args.json,
format_options=args.format_options,
groups=prettify_groups,
color_scheme=processing_options.style,
explicit_json=processing_options.json,
format_options=processing_options.format_options,
)
})

View File

@ -6,16 +6,19 @@ import os
import re
from http.cookies import SimpleCookie
from http.cookiejar import Cookie
from pathlib import Path
from typing import Iterable, Optional, Union
from urllib.parse import urlsplit
from typing import Any, Dict, List, Optional, Union
from requests.auth import AuthBase
from requests.cookies import RequestsCookieJar, create_cookie
from requests.cookies import RequestsCookieJar, remove_cookie_by_name
from .context import Environment
from .cli.dicts import HTTPHeadersDict
from .config import BaseConfigDict, DEFAULT_CONFIG_DIR
from .utils import url_as_host
from .plugins.registry import plugin_manager
from .legacy import cookie_format as legacy_cookies
SESSIONS_DIR_NAME = 'sessions'
@ -26,27 +29,72 @@ VALID_SESSION_NAME_PATTERN = re.compile('^[a-zA-Z0-9_.-]+$')
# <https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Requests>
SESSION_IGNORED_HEADER_PREFIXES = ['Content-', 'If-']
# Cookie related options
KEPT_COOKIE_OPTIONS = ['name', 'expires', 'path', 'value', 'domain', 'secure']
DEFAULT_COOKIE_PATH = '/'
def is_anonymous_session(session_name: str) -> bool:
return os.path.sep in session_name
def session_hostname_to_dirname(hostname: str, session_name: str) -> str:
# host:port => host_port
hostname = hostname.replace(':', '_')
return os.path.join(
SESSIONS_DIR_NAME,
hostname,
f'{session_name}.json'
)
def strip_port(hostname: str) -> str:
return hostname.split(':')[0]
def materialize_cookie(cookie: Cookie) -> Dict[str, Any]:
materialized_cookie = {
option: getattr(cookie, option)
for option in KEPT_COOKIE_OPTIONS
}
if (
cookie._rest.get('is_explicit_none')
and materialized_cookie['domain'] == ''
):
materialized_cookie['domain'] = None
return materialized_cookie
def get_httpie_session(
env: Environment,
config_dir: Path,
session_name: str,
host: Optional[str],
url: str,
*,
refactor_mode: bool = False
) -> 'Session':
if os.path.sep in session_name:
path = os.path.expanduser(session_name)
else:
hostname = host or urlsplit(url).netloc.split('@')[-1]
if not hostname:
# HACK/FIXME: httpie-unixsocket's URLs have no hostname.
hostname = 'localhost'
bound_hostname = host or url_as_host(url)
if not bound_hostname:
# HACK/FIXME: httpie-unixsocket's URLs have no hostname.
bound_hostname = 'localhost'
# host:port => host_port
hostname = hostname.replace(':', '_')
path = (
config_dir / SESSIONS_DIR_NAME / hostname / f'{session_name}.json'
)
session = Session(path)
if is_anonymous_session(session_name):
path = os.path.expanduser(session_name)
session_id = path
else:
path = config_dir / session_hostname_to_dirname(bound_hostname, session_name)
session_id = session_name
session = Session(
path,
env=env,
session_id=session_id,
bound_host=strip_port(bound_hostname),
refactor_mode=refactor_mode
)
session.load()
return session
@ -55,15 +103,61 @@ class Session(BaseConfigDict):
helpurl = 'https://httpie.io/docs#sessions'
about = 'HTTPie session file'
def __init__(self, path: Union[str, Path]):
def __init__(
self,
path: Union[str, Path],
env: Environment,
bound_host: str,
session_id: str,
refactor_mode: bool = False,
):
super().__init__(path=Path(path))
self['headers'] = {}
self['cookies'] = {}
self['cookies'] = []
self['auth'] = {
'type': None,
'username': None,
'password': None
}
self.env = env
self.cookie_jar = RequestsCookieJar()
self.session_id = session_id
self.bound_host = bound_host
self.refactor_mode = refactor_mode
def pre_process_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
cookies = data.get('cookies')
if cookies:
normalized_cookies = legacy_cookies.pre_process(self, cookies)
else:
normalized_cookies = []
for cookie in normalized_cookies:
domain = cookie.get('domain', '')
if domain is None:
# domain = None means explicitly lack of cookie, though
# requests requires domain to be a string so we'll cast it
# manually.
cookie['domain'] = ''
cookie['rest'] = {'is_explicit_none': True}
self.cookie_jar.set(**cookie)
return data
def post_process_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
cookies = data.get('cookies')
normalized_cookies = [
materialize_cookie(cookie)
for cookie in self.cookie_jar
]
data['cookies'] = legacy_cookies.post_process(
normalized_cookies,
original_type=type(cookies)
)
return data
def update_headers(self, request_headers: HTTPHeadersDict):
"""
@ -73,10 +167,10 @@ class Session(BaseConfigDict):
"""
headers = self.headers
for name, value in request_headers.copy().items():
if value is None:
continue # Ignore explicitly unset headers
original_value = value
if type(value) is not str:
value = value.decode()
@ -85,8 +179,15 @@ class Session(BaseConfigDict):
if name.lower() == 'cookie':
for cookie_name, morsel in SimpleCookie(value).items():
self['cookies'][cookie_name] = {'value': morsel.value}
del request_headers[name]
if not morsel['path']:
morsel['path'] = DEFAULT_COOKIE_PATH
self.cookie_jar.set(cookie_name, morsel)
all_cookie_headers = request_headers.getall(name)
if len(all_cookie_headers) > 1:
all_cookie_headers.remove(original_value)
else:
request_headers.popall(name)
continue
for prefix in SESSION_IGNORED_HEADER_PREFIXES:
@ -103,23 +204,21 @@ class Session(BaseConfigDict):
@property
def cookies(self) -> RequestsCookieJar:
jar = RequestsCookieJar()
for name, cookie_dict in self['cookies'].items():
jar.set_cookie(create_cookie(
name, cookie_dict.pop('value'), **cookie_dict))
jar.clear_expired_cookies()
return jar
self.cookie_jar.clear_expired_cookies()
return self.cookie_jar
@cookies.setter
def cookies(self, jar: RequestsCookieJar):
# <https://docs.python.org/3/library/cookielib.html#cookie-objects>
stored_attrs = ['value', 'path', 'secure', 'expires']
self['cookies'] = {}
for cookie in jar:
self['cookies'][cookie.name] = {
attname: getattr(cookie, attname)
for attname in stored_attrs
}
self.cookie_jar = jar
def remove_cookies(self, cookies: List[Dict[str, str]]):
for cookie in cookies:
remove_cookie_by_name(
self.cookie_jar,
cookie['name'],
domain=cookie.get('domain', None),
path=cookie.get('path', None)
)
@property
def auth(self) -> Optional[AuthBase]:
@ -155,7 +254,6 @@ class Session(BaseConfigDict):
assert {'type', 'raw_auth'} == auth.keys()
self['auth'] = auth
def remove_cookies(self, names: Iterable[str]):
for name in names:
if name in self['cookies']:
del self['cookies'][name]
@property
def is_anonymous(self):
return is_anonymous_session(self.session_id)

View File

@ -1,4 +1,5 @@
import ssl
from typing import NamedTuple, Optional
from httpie.adapters import HTTPAdapter
# noinspection PyPackageRequirements
@ -24,6 +25,17 @@ AVAILABLE_SSL_VERSION_ARG_MAPPING = {
}
class HTTPieCertificate(NamedTuple):
cert_file: Optional[str] = None
key_file: Optional[str] = None
key_password: Optional[str] = None
def to_raw_cert(self):
"""Synthesize a requests-compatible (2-item tuple of cert and key file)
object from HTTPie's internal representation of a certificate."""
return (self.cert_file, self.key_file)
class HTTPieHTTPSAdapter(HTTPAdapter):
def __init__(
self,
@ -47,6 +59,13 @@ class HTTPieHTTPSAdapter(HTTPAdapter):
kwargs['ssl_context'] = self._ssl_context
return super().proxy_manager_for(*args, **kwargs)
def cert_verify(self, conn, url, verify, cert):
if isinstance(cert, HTTPieCertificate):
conn.key_password = cert.key_password
cert = cert.to_raw_cert()
return super().cert_verify(conn, url, verify, cert)
@staticmethod
def _create_ssl_context(
verify: bool,
@ -61,3 +80,17 @@ class HTTPieHTTPSAdapter(HTTPAdapter):
# in `super().cert_verify()`.
cert_reqs=ssl.CERT_REQUIRED if verify else ssl.CERT_NONE
)
def _is_key_file_encrypted(key_file):
"""Detects if a key file is encrypted or not.
Copy of the internal urllib function (urllib3.util.ssl_)"""
with open(key_file, "r") as f:
for line in f:
# Look for Proc-Type: 4,ENCRYPTED
if "ENCRYPTED" in line:
return True
return False

View File

@ -2,6 +2,8 @@ import sys
import os
import zlib
import functools
import time
import threading
from typing import Any, Callable, IO, Iterable, Optional, Tuple, Union, TYPE_CHECKING
from urllib.parse import urlencode
@ -22,12 +24,20 @@ class ChunkedStream:
class ChunkedUploadStream(ChunkedStream):
def __init__(self, stream: Iterable, callback: Callable):
def __init__(
self,
stream: Iterable,
callback: Callable,
event: Optional[threading.Event] = None
) -> None:
self.callback = callback
self.stream = stream
self.event = event
def __iter__(self) -> Iterable[Union[str, bytes]]:
for chunk in self.stream:
if self.event:
self.event.set()
self.callback(chunk)
yield chunk
@ -35,12 +45,19 @@ class ChunkedUploadStream(ChunkedStream):
class ChunkedMultipartUploadStream(ChunkedStream):
chunk_size = 100 * 1024
def __init__(self, encoder: 'MultipartEncoder'):
def __init__(
self,
encoder: 'MultipartEncoder',
event: Optional[threading.Event] = None
) -> None:
self.encoder = encoder
self.event = event
def __iter__(self) -> Iterable[Union[str, bytes]]:
while True:
chunk = self.encoder.read(self.chunk_size)
if self.event:
self.event.set()
if not chunk:
break
yield chunk
@ -77,10 +94,10 @@ def is_stdin(file: IO) -> bool:
return file_no == sys.stdin.fileno()
READ_THRESHOLD = float(os.getenv("HTTPIE_STDIN_READ_WARN_THRESHOLD", 10.0))
READ_THRESHOLD = float(os.getenv('HTTPIE_STDIN_READ_WARN_THRESHOLD', 10.0))
def observe_stdin_for_data_thread(env: Environment, file: IO) -> None:
def observe_stdin_for_data_thread(env: Environment, file: IO, read_event: threading.Event) -> None:
# Windows unfortunately does not support select() operation
# on regular files, like stdin in our use case.
# https://docs.python.org/3/library/select.html#select.select
@ -92,12 +109,9 @@ def observe_stdin_for_data_thread(env: Environment, file: IO) -> None:
if READ_THRESHOLD == 0:
return None
import select
import threading
def worker():
can_read, _, _ = select.select([file], [], [], READ_THRESHOLD)
if not can_read:
def worker(event: threading.Event) -> None:
time.sleep(READ_THRESHOLD)
if not event.is_set():
env.stderr.write(
f'> warning: no stdin data read in {READ_THRESHOLD}s '
f'(perhaps you want to --ignore-stdin)\n'
@ -105,11 +119,28 @@ def observe_stdin_for_data_thread(env: Environment, file: IO) -> None:
)
thread = threading.Thread(
target=worker
target=worker,
args=(read_event,)
)
thread.start()
def _read_file_with_selectors(file: IO, read_event: threading.Event) -> bytes:
if is_windows or not is_stdin(file):
return as_bytes(file.read())
import select
# Try checking whether there is any incoming data for READ_THRESHOLD
# seconds. If there isn't anything in the given period, issue
# a warning about a misusage.
read_selectors, _, _ = select.select([file], [], [], READ_THRESHOLD)
if read_selectors:
read_event.set()
return as_bytes(file.read())
def _prepare_file_for_upload(
env: Environment,
file: Union[IO, 'MultipartEncoder'],
@ -117,9 +148,11 @@ def _prepare_file_for_upload(
chunked: bool = False,
content_length_header_value: Optional[int] = None,
) -> Union[bytes, IO, ChunkedStream]:
read_event = threading.Event()
if not super_len(file):
if is_stdin(file):
observe_stdin_for_data_thread(env, file)
observe_stdin_for_data_thread(env, file, read_event)
# Zero-length -> assume stdin.
if content_length_header_value is None and not chunked:
# Read the whole stdin to determine `Content-Length`.
@ -129,7 +162,7 @@ def _prepare_file_for_upload(
# something like --no-chunked.
# This would be backwards-incompatible so wait until v3.0.0.
#
file = as_bytes(file.read())
file = _read_file_with_selectors(file, read_event)
else:
file.read = _wrap_function_with_callback(
file.read,
@ -141,11 +174,13 @@ def _prepare_file_for_upload(
if isinstance(file, MultipartEncoder):
return ChunkedMultipartUploadStream(
encoder=file,
event=read_event,
)
else:
return ChunkedUploadStream(
stream=file,
callback=callback,
event=read_event
)
else:
return file

View File

@ -9,6 +9,7 @@ from collections import OrderedDict
from http.cookiejar import parse_ns_headers
from pathlib import Path
from pprint import pformat
from urllib.parse import urlsplit
from typing import Any, List, Optional, Tuple, Callable, Iterable, TypeVar
import requests.auth
@ -237,3 +238,7 @@ def unwrap_context(exc: Exception) -> Optional[Exception]:
return unwrap_context(context)
else:
return exc
def url_as_host(url: str) -> str:
return urlsplit(url).netloc.split('@')[-1]

7
pytest.ini Normal file
View File

@ -0,0 +1,7 @@
[pytest]
markers =
# If you want to run tests without a full HTTPie installation
# we advise you to disable the markers below, e.g:
# pytest -m 'not requires_installation and not requires_external_processes'
requires_installation
requires_external_processes

View File

@ -11,6 +11,7 @@ import httpie
tests_require = [
'pytest',
'pytest-httpbin>=0.0.6',
'pytest-lazy-fixture>=0.0.6',
'responses',
]
dev_require = [

View File

@ -0,0 +1,42 @@
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-128-CBC,93DA845817852FB576163AA829C232E9
VauvxiyC0lQbLJFoEGlnIIZO2/+b66DjTwSccqSdVg+Zaxvbc0jeVhS43SQ01ft0
hB/oISgJB/1I/oKbGwx07T9j78Q8G9AxQV6hzvozK5Etjew4RrvV4DYyOSzwZNQr
qB9S0qhBKyemA2vx4aH/8nazHh+zrRD3y0oMbuCHLxSGuqncNXIKCTYgMb8NUucJ
fEArYHijZ0iotoOEpP31JOUPCpKhEewQxzNK0HLws0lv6nl6fmBlkdi603qmsG5U
uinuiGodrh9SpCUc/A4OhVWKwoiQSxGnz+SiNaXyUByf9CR8RLPWqi5pTGHC8xrJ
uHI6Cw8ZfrJ2clYtuCWv6g6c4F7sz6eAJHqCZNnU32kKu3uH/9E/7Z8uH7JOVyFa
9DlBHCWHdyaHs8mY+/pDcxeMyWeC837sBelIBF1iEwU/sMw43HipZBNhrekMLAkx
y5HRYQDstTvk1Nvj8fKysYuhGCiF/V6PWYo5RaQszZLhS+uyFEBwa0ojYNZh4LyB
5uIdBaqtL9FD4RXqTYfN96eEyoYaUUY5KXqQMZkuZpotGYmH9OGMTVCgR7eU0a62
dgbQw4UCQd4YTNx1PyboH72oIi+Rqp2LEYEQSHP/dIUtBiA/kmWhgapZVGvfJ+fF
u9MPgPUDvH3oLVm4Mr+biLX/oUQVEup85q8++E2csDe2HoC4JdmJ0D9rZM2OqpYV
YZAPcPhx2pYnK5d6RvMFwtLPNfHxgYQXMVg6BFtu5GCxxqr+dhF7TGrN5s6AKC8U
bkVQIXwO8bYVTLj2Sb44fe+Xl1X/09yHnkZC0u/Kb2KvUm7Gnltn3tUmj7fGI0I6
aI6G3T1xc0jz9WhjdnM3uDYYI66GpgRgv81n7IkfRjclNArW4OStf30K4pXXjGeP
vgopPJ1yNpaM4QNbx3cqzP0eBy+Ss7aCXca4I3BzjXtuo9ZcEzGb+1FkS7ASEdex
cAroJOmm9KJ+3KOxsVs5fxXtQqzzeD8cdZeGV0eckJNfjWSBH2zyhaxwdlCvG1I9
dTvdd6q31FjlnUq9SvGEkfoy4myIUtt4DJQ4lSktvKQv9qepUjoX0k3xipgSmiPO
yxE+VdJdJ9/tDUf3psD01XLIss7hOX9aED3svN3uXB2ZVCSH6e2l4IrBMirdKNwR
fB4Yrul0qt9knmn11p2aWav055hb1Il5Tm8/WnaXkgtr20zP4RgR7P19mSjTBxUm
7iUIiWqU43Sx2LWsYpg7Lbj5XGLcvxv5WjYsE4Km0ltZCLKzMHfQ76qv4ZOQkHcR
9UevRmzU45095eASztedrYyxDNwU6YSdUcOYTP6383G9azbStlQY+w2Em++UoNoH
3eYj6KHKx+hkZOdc8PLaLg2f98jOiADpKYJTGnkKoLjTCfr9nzBeNxwRCQ4F4vO/
+tuRo3i1ODpJQbbZys9Mz+9PSwBH31UAib0+v0GYLDJN2rJcyGal/0DH5zON9Ogi
5bZQ9oS91p9K5hUAnHpd3zOzeX1lCoZnmtOI8wah79SVSpK1xoE6BAxAHfRiYiS3
1tDmkThJBOGXmkpLjtgNW3MqYKBnO3tRzrDDCjTKi5jFX/SD2FPpExOyA2+I0lrr
a9b+Sjbl1Z7B1yZmmTGMKB7prwK00LaF6yqKOhE+bx1yJAaWrbdPCD6vDmbq5YV6
87woIiA16Q2I1x77/Kg3TDO9LMDiwI5BFyjR+4Q5SvufIaxtsmTBuaBuPif+f4DT
MPQcfk5ozQIKY4qiSqMAOXAf2t+/UQROjgYvayRz0fOv2rV0vS4i9ELj/8Dn65Dq
7aQzLwM0psToGIVyzAV+hF3jeQP+Xu7VjtSxTJ+ajz7PeIXeBH/mwJKMk7hpRwGj
4fZ92S00Iat2kA6wn55u6EGewgcaQrN2zr75a9gvXQwMDmsjszq2uWWxxJg6pAPZ
rNqhM9tJ2UAJ1lLZzUDfhK4wU4pGWIhT+BmdDgJ40hI4b1WEmKSTxsj8AYNcVDRf
i2Ox1QhZQX9bH5kTOX373/6cALFR5DcU8qh2FJtf+3uiZHNloEeID//H2Gdoxz0Y
5CC/VDiIa4Gj4D+ATsLMgTDt4eUOinMeC1H6w+QBd9UvceqEvrgu+1WB8UCK/Hm/
7fZ0srsGg/WRqdSuO8/7998PEHgP8+wnTbxi9Y3EEbkaKUL6esJfeOjBibuGPyaf
2Y9QLcpVKaD7pmVeb97qExZZjEiID6QYmFUO8j0koS2fki0l+z8XEZ3JLZKa9XS+
uiMPQKg41j+9ZrGmwPNj7brjwA0cdSb4CLgxg4FwuwB660XaXpW3aRsiRryi0YcM
hn2l6b4JgBz8gUkFiTXQ8wRvAKDC1hUkUysqCAC+Yg3cWxlDZVeSeqVGr5jhHgN1
-----END RSA PRIVATE KEY-----

View File

@ -0,0 +1,26 @@
-----BEGIN CERTIFICATE-----
MIIEazCCAtOgAwIBAgIUIWojJySwCenyvcJrZY1aKOMQfTUwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMjAzMDExMzM5MzRaFw00OTA3
MTcxMzM5MzRaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggGiMA0GCSqGSIb3DQEB
AQUAA4IBjwAwggGKAoIBgQC0+8GOIhknLgLmWEQGgdDJffntbDfGdtG7JFUFfvuN
sibTHL4RPNhe+UrT/LR+JBfIfmdeb+NlmKzsTeR0+8PkX5ZjXMShf5icghVukK7G
OoQS7olQqlGzpIX76VqktVr4tFNXmMJeBO0NIaXHm0ASsoz3fIfDx9ttJETPs6lm
Wv/PUPemvtUgcbAb+kjz9QqcUV8B1xcCvQma6NSpxcmJHqAuI6HkdbDzyedKuuSi
M6yNFjh3EJjsufReQgkcfDsqh+RA3zQoIyPXLNqjzGD71z24jUtvIxb5ZNEtv0xp
5zCOCavuRNNyKGvvnlIeyup7bMe0QIds566miG49osVpPVvVmg+q+w2YYAE+7svb
nJp7NYn2tryRqsmvnASLVQD6T9wTWUa8w/tT1+ltnhfqbwDcVACzsw/U4FFwcfWw
5BnUcJacoDkj/3TCqgkA8XFe1/DVU8XCcsvEaoLzwHhHu2+QDpqal8rNouyTpFGA
/wioVBQGpksPZjl8lumsz3kCAwEAAaNTMFEwHQYDVR0OBBYEFGJhl1BPOXCVqRo3
U/ruuedvlDqsMB8GA1UdIwQYMBaAFGJhl1BPOXCVqRo3U/ruuedvlDqsMA8GA1Ud
EwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggGBAE9NtrE5qSHlK9DXdH6bPW6z
GO9uWBl3rJVtqVvPoH8RxJG/jtaD/Pnc3MkIxMoliCleNK6BdYVGV9u8x9W1jQo8
H+mnH3/Ise8ZY1zpblZJF0z9xs5sWW7qO8U06GmJWRSPn3LKEZjLsNmThhUW09wN
8EZX914zCWtzCrUTNg8Au1Dz9zA9ScfpCVPhKORTCnrpoTL6iXsPxmCx+5awmNLE
uh9kw4NScEyq33RTPosMpwSMlXGRuASltx/J7Rn0DNR0r1p0XzDS4CG1iDwXHlEF
MwsOvSahNyz5RInrU3cgN70tafoRIHScLYycnRml8dydxrDoFgdJk5sI4zgq24Sg
TktTq9ShrT4yQX+lrGS6eZQK/YZEBPD7BdTLYp3vlfYQMJ4Jz9SyQ8b9/9jIFVFS
dFfWiCqEuhTvGfptAzYX+K9OaegZnIk3X7R6O+YQ3oHCbLbnV3bpKlgNnOKBwa2X
kJ5GRp+rZOJ97yjrspKjpR5tNCiJnp7NnnA5VA6mfw==
-----END CERTIFICATE-----

View File

@ -4,7 +4,11 @@ import socket
import pytest
from pytest_httpbin import certs
from .utils import HTTPBIN_WITH_CHUNKED_SUPPORT_DOMAIN, HTTPBIN_WITH_CHUNKED_SUPPORT
from .utils import ( # noqa
HTTPBIN_WITH_CHUNKED_SUPPORT_DOMAIN,
HTTPBIN_WITH_CHUNKED_SUPPORT,
mock_env
)
from .utils.plugins_cli import ( # noqa
broken_plugin,
dummy_plugin,

View File

@ -1,6 +1,9 @@
"""Test data"""
import json
from pathlib import Path
from typing import Optional, Dict, Any
import httpie
from httpie.encoding import UTF8
from httpie.output.formatters.xml import pretty_xml, parse_xml
@ -19,10 +22,20 @@ FILE_PATH = FIXTURES_ROOT / 'test.txt'
JSON_FILE_PATH = FIXTURES_ROOT / 'test.json'
JSON_WITH_DUPE_KEYS_FILE_PATH = FIXTURES_ROOT / 'test_with_dupe_keys.json'
BIN_FILE_PATH = FIXTURES_ROOT / 'test.bin'
XML_FILES_PATH = FIXTURES_ROOT / 'xmldata'
XML_FILES_VALID = list((XML_FILES_PATH / 'valid').glob('*_raw.xml'))
XML_FILES_INVALID = list((XML_FILES_PATH / 'invalid').glob('*.xml'))
SESSION_FILES_PATH = FIXTURES_ROOT / 'session_data'
SESSION_FILES_OLD = sorted((SESSION_FILES_PATH / 'old').glob('*.json'))
SESSION_FILES_NEW = sorted((SESSION_FILES_PATH / 'new').glob('*.json'))
SESSION_VARIABLES = {
'__version__': httpie.__version__,
'__host__': 'null',
}
FILE_PATH_ARG = patharg(FILE_PATH)
BIN_FILE_PATH_ARG = patharg(BIN_FILE_PATH)
JSON_FILE_PATH_ARG = patharg(JSON_FILE_PATH)
@ -40,3 +53,14 @@ BIN_FILE_CONTENT = BIN_FILE_PATH.read_bytes()
UNICODE = FILE_CONTENT
XML_DATA_RAW = '<?xml version="1.0" encoding="utf-8"?><root><e>text</e></root>'
XML_DATA_FORMATTED = pretty_xml(parse_xml(XML_DATA_RAW))
def read_session_file(session_file: Path, *, extra_variables: Optional[Dict[str, str]] = None) -> Any:
with open(session_file) as stream:
data = stream.read()
session_vars = {**SESSION_VARIABLES, **(extra_variables or {})}
for variable, value in session_vars.items():
data = data.replace(variable, value)
return json.loads(data)

View File

@ -0,0 +1,31 @@
{
"__meta__": {
"about": "HTTPie session file",
"help": "https://httpie.io/docs#sessions",
"httpie": "__version__"
},
"auth": {
"password": null,
"type": null,
"username": null
},
"cookies": [
{
"domain": __host__,
"expires": null,
"name": "baz",
"path": "/",
"secure": false,
"value": "quux"
},
{
"domain": __host__,
"expires": null,
"name": "foo",
"path": "/",
"secure": false,
"value": "bar"
}
],
"headers": {}
}

View File

@ -0,0 +1,31 @@
{
"__meta__": {
"about": "HTTPie session file",
"help": "https://httpie.io/docs#sessions",
"httpie": "__version__"
},
"auth": {
"password": null,
"type": null,
"username": null
},
"cookies": [
{
"domain": __host__,
"expires": null,
"name": "baz",
"path": "/",
"secure": false,
"value": "quux"
},
{
"domain": __host__,
"expires": null,
"name": "foo",
"path": "/",
"secure": false,
"value": "bar"
}
],
"headers": {}
}

View File

@ -0,0 +1,33 @@
{
"__meta__": {
"about": "HTTPie session file",
"help": "https://httpie.io/docs#sessions",
"httpie": "__version__"
},
"auth": {
"raw_auth": "foo:bar",
"type": "basic"
},
"cookies": [
{
"domain": __host__,
"expires": null,
"name": "baz",
"path": "/",
"secure": false,
"value": "quux"
},
{
"domain": __host__,
"expires": null,
"name": "foo",
"path": "/",
"secure": false,
"value": "bar"
}
],
"headers": {
"X-Data": "value",
"X-Foo": "bar"
}
}

View File

@ -0,0 +1,14 @@
{
"__meta__": {
"about": "HTTPie session file",
"help": "https://httpie.io/docs#sessions",
"httpie": "__version__"
},
"auth": {
"password": null,
"type": null,
"username": null
},
"cookies": [],
"headers": {}
}

View File

@ -0,0 +1,14 @@
{
"__meta__": {
"about": "HTTPie session file",
"help": "https://httpie.io/docs#sessions",
"httpie": "__version__"
},
"auth": {
"password": null,
"type": null,
"username": null
},
"cookies": [],
"headers": {}
}

View File

@ -0,0 +1,27 @@
{
"__meta__": {
"about": "HTTPie session file",
"help": "https://httpie.io/docs#sessions",
"httpie": "3.0.2"
},
"auth": {
"password": null,
"type": null,
"username": null
},
"cookies": {
"baz": {
"expires": null,
"path": "/",
"secure": false,
"value": "quux"
},
"foo": {
"expires": null,
"path": "/",
"secure": false,
"value": "bar"
}
},
"headers": {}
}

View File

@ -0,0 +1,27 @@
{
"__meta__": {
"about": "HTTPie session file",
"help": "https://httpie.io/docs#sessions",
"httpie": "2.7.0.dev0"
},
"auth": {
"password": null,
"type": null,
"username": null
},
"cookies": {
"baz": {
"expires": null,
"path": "/",
"secure": false,
"value": "quux"
},
"foo": {
"expires": null,
"path": "/",
"secure": false,
"value": "bar"
}
},
"headers": {}
}

View File

@ -0,0 +1,29 @@
{
"__meta__": {
"about": "HTTPie session file",
"help": "https://httpie.io/docs#sessions",
"httpie": "3.0.2"
},
"auth": {
"raw_auth": "foo:bar",
"type": "basic"
},
"cookies": {
"baz": {
"expires": null,
"path": "/",
"secure": false,
"value": "quux"
},
"foo": {
"expires": null,
"path": "/",
"secure": false,
"value": "bar"
}
},
"headers": {
"X-Data": "value",
"X-Foo": "bar"
}
}

View File

@ -0,0 +1,14 @@
{
"__meta__": {
"about": "HTTPie session file",
"help": "https://httpie.io/docs#sessions",
"httpie": "3.0.2"
},
"auth": {
"password": null,
"type": null,
"username": null
},
"cookies": {},
"headers": {}
}

View File

@ -0,0 +1,14 @@
{
"__meta__": {
"about": "HTTPie session file",
"help": "https://httpie.io/docs#sessions",
"httpie": "3.0.2"
},
"auth": {
"password": null,
"type": null,
"username": null
},
"cookies": [],
"headers": {}
}

81
tests/test_cli_ui.py Normal file
View File

@ -0,0 +1,81 @@
import pytest
import shutil
import os
import sys
from tests.utils import http
if sys.version_info >= (3, 9):
REQUEST_ITEM_MSG = "[REQUEST_ITEM ...]"
else:
REQUEST_ITEM_MSG = "[REQUEST_ITEM [REQUEST_ITEM ...]]"
NAKED_HELP_MESSAGE = f"""\
usage:
http [METHOD] URL {REQUEST_ITEM_MSG}
error:
the following arguments are required: URL
for more information:
run 'http --help' or visit https://httpie.io/docs/cli
"""
NAKED_HELP_MESSAGE_PRETTY_WITH_NO_ARG = f"""\
usage:
http [--pretty {{all,colors,format,none}}] [METHOD] URL {REQUEST_ITEM_MSG}
error:
argument --pretty: expected one argument
for more information:
run 'http --help' or visit https://httpie.io/docs/cli
"""
NAKED_HELP_MESSAGE_PRETTY_WITH_INVALID_ARG = f"""\
usage:
http [--pretty {{all,colors,format,none}}] [METHOD] URL {REQUEST_ITEM_MSG}
error:
argument --pretty: invalid choice: '$invalid' (choose from 'all', 'colors', 'format', 'none')
for more information:
run 'http --help' or visit https://httpie.io/docs/cli
"""
PREDEFINED_TERMINAL_SIZE = (160, 80)
@pytest.fixture(scope="function")
def ignore_terminal_size(monkeypatch):
"""Some tests wrap/crop the output depending on the
size of the executed terminal, which might not be consistent
through all runs.
This fixture ensures every run uses the same exact configuration.
"""
def fake_terminal_size(*args, **kwargs):
return os.terminal_size(PREDEFINED_TERMINAL_SIZE)
# Setting COLUMNS as an env var is required for 3.8<
monkeypatch.setitem(os.environ, 'COLUMNS', str(PREDEFINED_TERMINAL_SIZE[0]))
monkeypatch.setattr(shutil, 'get_terminal_size', fake_terminal_size)
@pytest.mark.parametrize(
'args, expected_msg', [
([], NAKED_HELP_MESSAGE),
(['--pretty'], NAKED_HELP_MESSAGE_PRETTY_WITH_NO_ARG),
(['pie.dev', '--pretty'], NAKED_HELP_MESSAGE_PRETTY_WITH_NO_ARG),
(['--pretty', '$invalid'], NAKED_HELP_MESSAGE_PRETTY_WITH_INVALID_ARG),
]
)
def test_naked_invocation(ignore_terminal_size, args, expected_msg):
result = http(*args, tolerate_error_exit_status=True)
assert result.stderr == expected_msg

View File

@ -0,0 +1,262 @@
import pytest
from .utils import http
@pytest.fixture
def remote_httpbin(httpbin_with_chunked_support):
return httpbin_with_chunked_support
def _stringify(fixture):
return fixture + ''
@pytest.mark.parametrize('instance', [
pytest.lazy_fixture('httpbin'),
pytest.lazy_fixture('remote_httpbin'),
])
def test_explicit_user_set_cookie(httpbin, instance):
# User set cookies ARE NOT persisted within redirects
# when there is no session, even on the same domain.
r = http(
'--follow',
httpbin + '/redirect-to',
f'url=={_stringify(instance)}/cookies',
'Cookie:a=b'
)
assert r.json == {'cookies': {}}
@pytest.mark.parametrize('instance', [
pytest.lazy_fixture('httpbin'),
pytest.lazy_fixture('remote_httpbin'),
])
def test_explicit_user_set_cookie_in_session(tmp_path, httpbin, instance):
# User set cookies ARE persisted within redirects
# when there is A session, even on the same domain.
r = http(
'--follow',
'--session',
str(tmp_path / 'session.json'),
httpbin + '/redirect-to',
f'url=={_stringify(instance)}/cookies',
'Cookie:a=b'
)
assert r.json == {'cookies': {'a': 'b'}}
@pytest.mark.parametrize('instance', [
pytest.lazy_fixture('httpbin'),
pytest.lazy_fixture('remote_httpbin'),
])
def test_saved_user_set_cookie_in_session(tmp_path, httpbin, instance):
# User set cookies ARE persisted within redirects
# when there is A session, even on the same domain.
http(
'--follow',
'--session',
str(tmp_path / 'session.json'),
httpbin + '/get',
'Cookie:a=b'
)
r = http(
'--follow',
'--session',
str(tmp_path / 'session.json'),
httpbin + '/redirect-to',
f'url=={_stringify(instance)}/cookies',
)
assert r.json == {'cookies': {'a': 'b'}}
@pytest.mark.parametrize('instance', [
pytest.lazy_fixture('httpbin'),
pytest.lazy_fixture('remote_httpbin'),
])
@pytest.mark.parametrize('session', [True, False])
def test_explicit_user_set_headers(httpbin, tmp_path, instance, session):
# User set headers ARE persisted within redirects
# even on different domains domain with or without
# an active session.
session_args = []
if session:
session_args.extend([
'--session',
str(tmp_path / 'session.json')
])
r = http(
'--follow',
*session_args,
httpbin + '/redirect-to',
f'url=={_stringify(instance)}/get',
'X-Custom-Header:value'
)
assert 'X-Custom-Header' in r.json['headers']
@pytest.mark.parametrize('session', [True, False])
def test_server_set_cookie_on_redirect_same_domain(tmp_path, httpbin, session):
# Server set cookies ARE persisted on the same domain
# when they are forwarded.
session_args = []
if session:
session_args.extend([
'--session',
str(tmp_path / 'session.json')
])
r = http(
'--follow',
*session_args,
httpbin + '/cookies/set/a/b',
)
assert r.json['cookies'] == {'a': 'b'}
@pytest.mark.parametrize('session', [True, False])
def test_server_set_cookie_on_redirect_different_domain(tmp_path, http_server, httpbin, session):
# Server set cookies ARE persisted on different domains
# when they are forwarded.
session_args = []
if session:
session_args.extend([
'--session',
str(tmp_path / 'session.json')
])
r = http(
'--follow',
*session_args,
http_server + '/cookies/set-and-redirect',
f"X-Redirect-To:{httpbin + '/cookies'}",
'X-Cookies:a=b'
)
assert r.json['cookies'] == {'a': 'b'}
def test_saved_session_cookies_on_same_domain(tmp_path, httpbin):
# Saved session cookies ARE persisted when making a new
# request to the same domain.
http(
'--session',
str(tmp_path / 'session.json'),
httpbin + '/cookies/set/a/b'
)
r = http(
'--session',
str(tmp_path / 'session.json'),
httpbin + '/cookies'
)
assert r.json == {'cookies': {'a': 'b'}}
def test_saved_session_cookies_on_different_domain(tmp_path, httpbin, remote_httpbin):
# Saved session cookies ARE persisted when making a new
# request to a different domain.
http(
'--session',
str(tmp_path / 'session.json'),
httpbin + '/cookies/set/a/b'
)
r = http(
'--session',
str(tmp_path / 'session.json'),
remote_httpbin + '/cookies'
)
assert r.json == {'cookies': {}}
@pytest.mark.parametrize('initial_domain, first_request_domain, second_request_domain, expect_cookies', [
(
# Cookies are set by Domain A
# Initial domain is Domain A
# Redirected domain is Domain A
pytest.lazy_fixture('httpbin'),
pytest.lazy_fixture('httpbin'),
pytest.lazy_fixture('httpbin'),
True,
),
(
# Cookies are set by Domain A
# Initial domain is Domain B
# Redirected domain is Domain B
pytest.lazy_fixture('httpbin'),
pytest.lazy_fixture('remote_httpbin'),
pytest.lazy_fixture('remote_httpbin'),
False,
),
(
# Cookies are set by Domain A
# Initial domain is Domain A
# Redirected domain is Domain B
pytest.lazy_fixture('httpbin'),
pytest.lazy_fixture('httpbin'),
pytest.lazy_fixture('remote_httpbin'),
False,
),
(
# Cookies are set by Domain A
# Initial domain is Domain B
# Redirected domain is Domain A
pytest.lazy_fixture('httpbin'),
pytest.lazy_fixture('remote_httpbin'),
pytest.lazy_fixture('httpbin'),
True,
),
])
def test_saved_session_cookies_on_redirect(tmp_path, initial_domain, first_request_domain, second_request_domain, expect_cookies):
http(
'--session',
str(tmp_path / 'session.json'),
initial_domain + '/cookies/set/a/b'
)
r = http(
'--session',
str(tmp_path / 'session.json'),
'--follow',
first_request_domain + '/redirect-to',
f'url=={_stringify(second_request_domain)}/cookies'
)
if expect_cookies:
expected_data = {'cookies': {'a': 'b'}}
else:
expected_data = {'cookies': {}}
assert r.json == expected_data
def test_saved_session_cookie_pool(tmp_path, httpbin, remote_httpbin):
http(
'--session',
str(tmp_path / 'session.json'),
httpbin + '/cookies/set/a/b'
)
http(
'--session',
str(tmp_path / 'session.json'),
remote_httpbin + '/cookies/set/a/c'
)
http(
'--session',
str(tmp_path / 'session.json'),
remote_httpbin + '/cookies/set/b/d'
)
response = http(
'--session',
str(tmp_path / 'session.json'),
httpbin + '/cookies'
)
assert response.json['cookies'] == {'a': 'b'}
response = http(
'--session',
str(tmp_path / 'session.json'),
remote_httpbin + '/cookies'
)
assert response.json['cookies'] == {'a': 'c', 'b': 'd'}

138
tests/test_httpie_cli.py Normal file
View File

@ -0,0 +1,138 @@
import pytest
import shutil
import json
from httpie.sessions import SESSIONS_DIR_NAME
from httpie.status import ExitStatus
from httpie.cli.options import PARSER_SPEC_VERSION
from tests.utils import DUMMY_HOST, httpie
from tests.fixtures import SESSION_FILES_PATH, SESSION_FILES_NEW, SESSION_FILES_OLD, read_session_file
OLD_SESSION_FILES_PATH = SESSION_FILES_PATH / 'old'
@pytest.mark.requires_installation
def test_plugins_cli_error_message_without_args():
# No arguments
result = httpie(no_debug=True)
assert result.exit_status == ExitStatus.ERROR
assert 'usage: ' in result.stderr
assert 'specify one of these' in result.stderr
assert 'please use the http/https commands:' in result.stderr
@pytest.mark.parametrize(
'example',
[
'pie.dev/get',
'DELETE localhost:8000/delete',
'POST pie.dev/post header:value a=b header_2:value x:=1',
],
)
@pytest.mark.requires_installation
def test_plugins_cli_error_messages_with_example(example):
result = httpie(*example.split(), no_debug=True)
assert result.exit_status == ExitStatus.ERROR
assert 'usage: ' in result.stderr
assert f'http {example}' in result.stderr
assert f'https {example}' in result.stderr
@pytest.mark.parametrize(
'example',
[
'cli',
'plugins',
'cli foo',
'plugins unknown',
'plugins unknown.com A:B c=d',
'unknown.com UNPARSABLE????SYNTAX',
],
)
@pytest.mark.requires_installation
def test_plugins_cli_error_messages_invalid_example(example):
result = httpie(*example.split(), no_debug=True)
assert result.exit_status == ExitStatus.ERROR
assert 'usage: ' in result.stderr
assert f'http {example}' not in result.stderr
assert f'https {example}' not in result.stderr
HTTPIE_CLI_SESSIONS_UPGRADE_OPTIONS = [
(
# Default settings
[],
{'__host__': json.dumps(None)},
),
(
# When --bind-cookies is applied, the __host__ becomes DUMMY_URL.
['--bind-cookies'],
{'__host__': json.dumps(DUMMY_HOST)},
),
]
@pytest.mark.parametrize(
'old_session_file, new_session_file', zip(SESSION_FILES_OLD, SESSION_FILES_NEW)
)
@pytest.mark.parametrize(
'extra_args, extra_variables',
HTTPIE_CLI_SESSIONS_UPGRADE_OPTIONS,
)
def test_httpie_sessions_upgrade(tmp_path, old_session_file, new_session_file, extra_args, extra_variables):
session_path = tmp_path / 'session.json'
shutil.copyfile(old_session_file, session_path)
result = httpie(
'cli', 'sessions', 'upgrade', *extra_args, DUMMY_HOST, str(session_path)
)
assert result.exit_status == ExitStatus.SUCCESS
assert read_session_file(session_path) == read_session_file(
new_session_file, extra_variables=extra_variables
)
def test_httpie_sessions_upgrade_on_non_existent_file(tmp_path):
session_path = tmp_path / 'session.json'
result = httpie('cli', 'sessions', 'upgrade', DUMMY_HOST, str(session_path))
assert result.exit_status == ExitStatus.ERROR
assert 'does not exist' in result.stderr
@pytest.mark.parametrize(
'extra_args, extra_variables',
HTTPIE_CLI_SESSIONS_UPGRADE_OPTIONS,
)
def test_httpie_sessions_upgrade_all(tmp_path, mock_env, extra_args, extra_variables):
mock_env._create_temp_config_dir = False
mock_env.config_dir = tmp_path / "config"
session_dir = mock_env.config_dir / SESSIONS_DIR_NAME / DUMMY_HOST
session_dir.mkdir(parents=True)
for original_session_file in SESSION_FILES_OLD:
shutil.copy(original_session_file, session_dir)
result = httpie(
'cli', 'sessions', 'upgrade-all', *extra_args, env=mock_env
)
assert result.exit_status == ExitStatus.SUCCESS
for refactored_session_file, expected_session_file in zip(
sorted(session_dir.glob("*.json")),
SESSION_FILES_NEW
):
assert read_session_file(refactored_session_file) == read_session_file(
expected_session_file, extra_variables=extra_variables
)
@pytest.mark.parametrize(
'load_func, extra_options', [
(json.loads, []),
(json.loads, ['--format=json'])
]
)
def test_cli_export(load_func, extra_options):
response = httpie('cli', 'export-args', *extra_options)
assert response.exit_status == ExitStatus.SUCCESS
assert load_func(response)['version'] == PARSER_SPEC_VERSION

View File

@ -321,7 +321,7 @@ def test_complex_json_arguments_with_non_json(httpbin, request_type, value):
'foo[][key]=value',
'foo[2][key 2]=value 2',
r'foo[2][key \[]=value 3',
r'[nesting][under][!][empty][?][\\key]:=4',
r'bar[nesting][under][!][empty][?][\\key]:=4',
],
{
'foo': [
@ -329,7 +329,7 @@ def test_complex_json_arguments_with_non_json(httpbin, request_type, value):
2,
{'key': 'value', 'key 2': 'value 2', 'key [': 'value 3'},
],
'': {
'bar': {
'nesting': {'under': {'!': {'empty': {'?': {'\\key': 4}}}}}
},
},
@ -397,6 +397,58 @@ def test_complex_json_arguments_with_non_json(httpbin, request_type, value):
'2012': {'x': 2, '[3]': 4},
},
),
(
[
r'a[\0]:=0',
r'a[\\1]:=1',
r'a[\\\2]:=2',
r'a[\\\\\3]:=3',
r'a[-1\\]:=-1',
r'a[-2\\\\]:=-2',
r'a[\\-3\\\\]:=-3',
],
{
'a': {
'0': 0,
r'\1': 1,
r'\\2': 2,
r'\\\3': 3,
'-1\\': -1,
'-2\\\\': -2,
'\\-3\\\\': -3,
}
},
),
(
['[]:=0', '[]:=1', '[5]:=5', '[]:=6', '[9]:=9'],
[0, 1, None, None, None, 5, 6, None, None, 9],
),
(
['=empty', 'foo=bar', 'bar[baz][quux]=tuut'],
{'': 'empty', 'foo': 'bar', 'bar': {'baz': {'quux': 'tuut'}}},
),
(
[
r'\1=top level int',
r'\\1=escaped top level int',
r'\2[\3][\4]:=5',
],
{
'1': 'top level int',
'\\1': 'escaped top level int',
'2': {'3': {'4': 5}},
},
),
(
[':={"foo": {"bar": "baz"}}', 'top=val'],
{'': {'foo': {'bar': 'baz'}}, 'top': 'val'},
),
(
['[][a][b][]:=1', '[0][a][b][]:=2', '[][]:=2'],
[{'a': {'b': [1, 2]}}, [2]],
),
([':=[1,2,3]'], {'': [1, 2, 3]}),
([':=[1,2,3]', 'foo=bar'], {'': [1, 2, 3], 'foo': 'bar'}),
],
)
def test_nested_json_syntax(input_json, expected_json, httpbin):
@ -494,13 +546,36 @@ def test_nested_json_syntax(input_json, expected_json, httpbin):
['foo[\\1]:=2', 'foo[5]:=3'],
"HTTPie Type Error: Can't perform 'index' based access on 'foo' which has a type of 'object' but this operation requires a type of 'array'.\nfoo[5]\n ^^^",
),
(
['x=y', '[]:=2'],
"HTTPie Type Error: Can't perform 'append' based access on '' which has a type of 'object' but this operation requires a type of 'array'.",
),
(
['[]:=2', 'x=y'],
"HTTPie Type Error: Can't perform 'key' based access on '' which has a type of 'array' but this operation requires a type of 'object'.",
),
(
[':=[1,2,3]', '[]:=4'],
"HTTPie Type Error: Can't perform 'append' based access on '' which has a type of 'object' but this operation requires a type of 'array'.",
),
(
['[]:=4', ':=[1,2,3]'],
"HTTPie Type Error: Can't perform 'key' based access on '' which has a type of 'array' but this operation requires a type of 'object'.",
),
],
)
def test_nested_json_errors(input_json, expected_error, httpbin):
with pytest.raises(HTTPieSyntaxError) as exc:
http(httpbin + '/post', *input_json)
assert str(exc.value) == expected_error
exc_lines = str(exc.value).splitlines()
expected_lines = expected_error.splitlines()
if len(expected_lines) == 1:
# When the error offsets are not important, we'll just compare the actual
# error message.
exc_lines = exc_lines[:1]
assert expected_lines == exc_lines
def test_nested_json_sparse_array(httpbin_both):

View File

@ -5,6 +5,7 @@ from unittest import mock
import json
import os
import io
import warnings
from urllib.request import urlopen
import pytest
@ -17,10 +18,15 @@ from httpie.cli.argtypes import (
)
from httpie.cli.definition import parser
from httpie.encoding import UTF8
from httpie.output.formatters.colors import get_lexer, PIE_STYLE_NAMES
from httpie.output.formatters.colors import get_lexer, PIE_STYLE_NAMES, BUNDLED_STYLES
from httpie.status import ExitStatus
from .fixtures import XML_DATA_RAW, XML_DATA_FORMATTED
from .utils import COLOR, CRLF, HTTP_OK, MockEnvironment, http, DUMMY_URL
from .utils import COLOR, CRLF, HTTP_OK, MockEnvironment, http, DUMMY_URL, strip_colors
# For ensuring test reproducibility, avoid using the unsorted
# BUNDLED_STYLES set.
SORTED_BUNDLED_STYLES = sorted(BUNDLED_STYLES)
@pytest.mark.parametrize('stdout_isatty', [True, False])
@ -85,6 +91,31 @@ class TestQuietFlag:
)
assert 'http: warning: HTTP 500' in r.stderr
@mock.patch('httpie.core.program')
@pytest.mark.parametrize('flags, expected_warnings', [
([], 1),
(['-q'], 1),
(['-qq'], 0),
])
def test_quiet_on_python_warnings(self, test_patch, httpbin, flags, expected_warnings):
def warn_and_run(*args, **kwargs):
warnings.warn('warning!!')
return ExitStatus.SUCCESS
test_patch.side_effect = warn_and_run
with pytest.warns(None) as record:
http(*flags, httpbin + '/get')
assert len(record) == expected_warnings
def test_double_quiet_on_error(self, httpbin):
r = http(
'-qq', '--check-status', '$$$this.does.not.exist$$$',
tolerate_error_exit_status=True,
)
assert not r
assert 'Couldnt resolve the given hostname' in r.stderr
@pytest.mark.parametrize('quiet_flags', QUIET_SCENARIOS)
@mock.patch('httpie.cli.argtypes.AuthCredentials._getpass',
new=lambda self, prompt: 'password')
@ -234,6 +265,24 @@ def test_ensure_meta_is_colored(httpbin, style):
assert COLOR in r
@pytest.mark.parametrize('style', SORTED_BUNDLED_STYLES)
@pytest.mark.parametrize('msg', [
'',
' ',
' OK',
' OK ',
' CUSTOM ',
])
def test_ensure_status_code_is_shown_on_all_themes(http_server, style, msg):
env = MockEnvironment(colors=256)
r = http('--style', style,
http_server + '/status/msg',
'--raw', msg, env=env)
# Trailing space is stripped away.
assert 'HTTP/1.0 200' + msg.rstrip() in strip_colors(r)
class TestPrettyOptions:
"""Test the --pretty handling."""

View File

@ -0,0 +1,60 @@
from httpie.cli.options import ParserSpec, Qualifiers
def test_parser_serialization():
small_parser = ParserSpec("test_parser")
group_1 = small_parser.add_group("group_1")
group_1.add_argument("regular_arg", help="regular arg")
group_1.add_argument(
"variadic_arg",
metavar="META",
help=Qualifiers.SUPPRESS,
nargs=Qualifiers.ZERO_OR_MORE,
)
group_1.add_argument(
"-O",
"--opt-arg",
action="lazy_choices",
getter=lambda: ["opt_1", "opt_2"],
help_formatter=lambda state: ", ".join(state),
)
group_2 = small_parser.add_group("group_2")
group_2.add_argument("--typed", action="store_true", type=int)
definition = small_parser.finalize()
assert definition.serialize() == {
"name": "test_parser",
"description": None,
"groups": [
{
"name": "group_1",
"description": None,
"is_mutually_exclusive": False,
"args": [
{
"options": ["regular_arg"],
"description": "regular arg",
},
{
"options": ["variadic_arg"],
"is_optional": True,
"is_variadic": True,
"metavar": "META",
},
{
"options": ["-O", "--opt-arg"],
"description": "opt_1, opt_2",
"choices": ["opt_1", "opt_2"],
},
],
},
{
"name": "group_2",
"description": None,
"is_mutually_exclusive": False,
"args": [{"options": ["--typed"], "python_type_name": "int"}],
},
],
}

View File

@ -1,10 +1,10 @@
import pytest
from httpie.status import ExitStatus
from tests.utils import httpie
from tests.utils.plugins_cli import parse_listing
@pytest.mark.requires_installation
def test_plugins_installation(httpie_plugins_success, interface, dummy_plugin):
lines = httpie_plugins_success('install', dummy_plugin.path)
assert lines[0].startswith(
@ -14,6 +14,20 @@ def test_plugins_installation(httpie_plugins_success, interface, dummy_plugin):
assert interface.is_installed(dummy_plugin.name)
@pytest.mark.requires_installation
def test_plugin_installation_with_custom_config(httpie_plugins_success, interface, dummy_plugin):
interface.environment.config['default_options'] = ['--session-read-only', 'some-path.json', 'other', 'args']
interface.environment.config.save()
lines = httpie_plugins_success('install', dummy_plugin.path)
assert lines[0].startswith(
f'Installing {dummy_plugin.path}'
)
assert f'Successfully installed {dummy_plugin.name}-{dummy_plugin.version}' in lines
assert interface.is_installed(dummy_plugin.name)
@pytest.mark.requires_installation
def test_plugins_listing(httpie_plugins_success, interface, dummy_plugin):
httpie_plugins_success('install', dummy_plugin.path)
data = parse_listing(httpie_plugins_success('list'))
@ -23,6 +37,7 @@ def test_plugins_listing(httpie_plugins_success, interface, dummy_plugin):
}
@pytest.mark.requires_installation
def test_plugins_listing_multiple(interface, httpie_plugins_success, dummy_plugins):
paths = [plugin.path for plugin in dummy_plugins]
httpie_plugins_success('install', *paths)
@ -34,12 +49,14 @@ def test_plugins_listing_multiple(interface, httpie_plugins_success, dummy_plugi
}
@pytest.mark.requires_installation
def test_plugins_uninstall(interface, httpie_plugins_success, dummy_plugin):
httpie_plugins_success('install', dummy_plugin.path)
httpie_plugins_success('uninstall', dummy_plugin.name)
assert not interface.is_installed(dummy_plugin.name)
@pytest.mark.requires_installation
def test_plugins_listing_after_uninstall(interface, httpie_plugins_success, dummy_plugin):
httpie_plugins_success('install', dummy_plugin.path)
httpie_plugins_success('uninstall', dummy_plugin.name)
@ -48,6 +65,7 @@ def test_plugins_listing_after_uninstall(interface, httpie_plugins_success, dumm
assert len(data) == 0
@pytest.mark.requires_installation
def test_plugins_uninstall_specific(interface, httpie_plugins_success):
new_plugin_1 = interface.make_dummy_plugin()
new_plugin_2 = interface.make_dummy_plugin()
@ -61,6 +79,7 @@ def test_plugins_uninstall_specific(interface, httpie_plugins_success):
assert not interface.is_installed(target_plugin.name)
@pytest.mark.requires_installation
def test_plugins_installation_failed(httpie_plugins, interface):
plugin = interface.make_dummy_plugin(build=False)
result = httpie_plugins('install', plugin.path)
@ -69,6 +88,7 @@ def test_plugins_installation_failed(httpie_plugins, interface):
assert result.stderr.splitlines()[-1].strip().startswith("Can't install")
@pytest.mark.requires_installation
def test_plugins_uninstall_non_existent(httpie_plugins, interface):
plugin = interface.make_dummy_plugin(build=False)
result = httpie_plugins('uninstall', plugin.name)
@ -80,6 +100,7 @@ def test_plugins_uninstall_non_existent(httpie_plugins, interface):
)
@pytest.mark.requires_installation
def test_plugins_double_uninstall(httpie_plugins, httpie_plugins_success, dummy_plugin):
httpie_plugins_success("install", dummy_plugin.path)
httpie_plugins_success("uninstall", dummy_plugin.name)
@ -93,6 +114,7 @@ def test_plugins_double_uninstall(httpie_plugins, httpie_plugins_success, dummy_
)
@pytest.mark.requires_installation
def test_plugins_upgrade(httpie_plugins, httpie_plugins_success, dummy_plugin):
httpie_plugins_success("install", dummy_plugin.path)
@ -105,6 +127,7 @@ def test_plugins_upgrade(httpie_plugins, httpie_plugins_success, dummy_plugin):
assert data[dummy_plugin.name]['version'] == '2.0.0'
@pytest.mark.requires_installation
def test_broken_plugins(httpie_plugins, httpie_plugins_success, dummy_plugin, broken_plugin):
httpie_plugins_success("install", dummy_plugin.path, broken_plugin.path)
@ -125,42 +148,3 @@ def test_broken_plugins(httpie_plugins, httpie_plugins_success, dummy_plugin, br
# No warning now, since it is uninstalled.
data = parse_listing(httpie_plugins_success('list'))
assert len(data) == 1
def test_plugins_cli_error_message_without_args():
# No arguments
result = httpie(no_debug=True)
assert result.exit_status == ExitStatus.ERROR
assert 'usage: ' in result.stderr
assert 'specify one of these' in result.stderr
assert 'please use the http/https commands:' in result.stderr
@pytest.mark.parametrize(
'example', [
'pie.dev/get',
'DELETE localhost:8000/delete',
'POST pie.dev/post header:value a=b header_2:value x:=1'
]
)
def test_plugins_cli_error_messages_with_example(example):
result = httpie(*example.split(), no_debug=True)
assert result.exit_status == ExitStatus.ERROR
assert 'usage: ' in result.stderr
assert f'http {example}' in result.stderr
assert f'https {example}' in result.stderr
@pytest.mark.parametrize(
'example', [
'plugins unknown',
'plugins unknown.com A:B c=d',
'unknown.com UNPARSABLE????SYNTAX',
]
)
def test_plugins_cli_error_messages_invalid_example(example):
result = httpie(*example.split(), no_debug=True)
assert result.exit_status == ExitStatus.ERROR
assert 'usage: ' in result.stderr
assert f'http {example}' not in result.stderr
assert f'https {example}' not in result.stderr

View File

@ -1,12 +1,16 @@
import json
import os
import shutil
from contextlib import contextmanager
from datetime import datetime
from unittest import mock
from pathlib import Path
from typing import Iterator
import pytest
from .fixtures import FILE_PATH_ARG, UNICODE
from httpie.context import Environment
from httpie.encoding import UTF8
from httpie.plugins import AuthPlugin
from httpie.plugins.builtin import HTTPBasicAuth
@ -14,7 +18,7 @@ from httpie.plugins.registry import plugin_manager
from httpie.sessions import Session
from httpie.utils import get_expired_cookies
from .test_auth_plugins import basic_auth
from .utils import HTTP_OK, MockEnvironment, http, mk_config_dir
from .utils import DUMMY_HOST, HTTP_OK, MockEnvironment, http, mk_config_dir
from base64 import b64encode
@ -203,9 +207,9 @@ class TestSession(SessionTestBase):
"""
self.start_session(httpbin)
session_data = {
"headers": {
"cookie": "...",
"zzz": "..."
'headers': {
'cookie': '...',
'zzz': '...'
}
}
session_path = self.config_dir / 'session-data.json'
@ -307,7 +311,7 @@ class TestSession(SessionTestBase):
auth_type = 'test-prompted'
def get_auth(self, username=None, password=None):
basic_auth_header = "Basic " + b64encode(self.raw_auth.encode()).strip().decode('latin1')
basic_auth_header = 'Basic ' + b64encode(self.raw_auth.encode()).strip().decode('latin1')
return basic_auth(basic_auth_header)
plugin_manager.register(Plugin)
@ -359,7 +363,7 @@ class TestSession(SessionTestBase):
)
updated_session = json.loads(self.session_path.read_text(encoding=UTF8))
assert updated_session['auth']['type'] == 'test-saved'
assert updated_session['auth']['raw_auth'] == "user:password"
assert updated_session['auth']['raw_auth'] == 'user:password'
plugin_manager.unregister(Plugin)
@ -368,12 +372,12 @@ class TestExpiredCookies(CookieTestBase):
@pytest.mark.parametrize(
'initial_cookie, expired_cookie',
[
({'id': {'value': 123}}, 'id'),
({'id': {'value': 123}}, 'token')
({'id': {'value': 123}}, {'name': 'id'}),
({'id': {'value': 123}}, {'name': 'token'})
]
)
def test_removes_expired_cookies_from_session_obj(self, initial_cookie, expired_cookie, httpbin):
session = Session(self.config_dir)
def test_removes_expired_cookies_from_session_obj(self, initial_cookie, expired_cookie, httpbin, mock_env):
session = Session(self.config_dir, env=mock_env, session_id=None, bound_host=None)
session['cookies'] = initial_cookie
session.remove_cookies([expired_cookie])
assert expired_cookie not in session.cookies
@ -524,3 +528,165 @@ class TestCookieStorage(CookieTestBase):
updated_session = json.loads(self.session_path.read_text(encoding=UTF8))
assert updated_session['cookies']['cookie1']['value'] == expected
@pytest.fixture
def basic_session(httpbin, tmp_path):
session_path = tmp_path / 'session.json'
http(
'--session', str(session_path),
httpbin + '/get'
)
return session_path
@contextmanager
def open_session(path: Path, env: Environment, read_only: bool = False) -> Iterator[Session]:
session = Session(path, env, session_id='test', bound_host=DUMMY_HOST)
session.load()
yield session
if not read_only:
session.save()
@contextmanager
def open_raw_session(path: Path, read_only: bool = False) -> None:
with open(path) as stream:
raw_session = json.load(stream)
yield raw_session
if not read_only:
with open(path, 'w') as stream:
json.dump(raw_session, stream)
def read_stderr(env: Environment) -> bytes:
env.stderr.seek(0)
stderr_data = env.stderr.read()
if isinstance(stderr_data, str):
return stderr_data.encode()
else:
return stderr_data
def test_old_session_version_saved_as_is(basic_session, mock_env):
with open_session(basic_session, mock_env) as session:
session['__meta__'] = {'httpie': '0.0.1'}
with open_session(basic_session, mock_env, read_only=True) as session:
assert session['__meta__']['httpie'] == '0.0.1'
def test_old_session_cookie_layout_warning(basic_session, mock_env):
with open_session(basic_session, mock_env) as session:
# Use the old layout & set a cookie
session['cookies'] = {}
session.cookies.set('foo', 'bar')
assert read_stderr(mock_env) == b''
with open_session(basic_session, mock_env, read_only=True) as session:
assert b'Outdated layout detected' in read_stderr(mock_env)
@pytest.mark.parametrize('cookies, expect_warning', [
# Old-style cookie format
(
# Without 'domain' set
{'foo': {'value': 'bar'}},
True
),
(
# With 'domain' set to empty string
{'foo': {'value': 'bar', 'domain': ''}},
True
),
(
# With 'domain' set to null
{'foo': {'value': 'bar', 'domain': None}},
False,
),
(
# With 'domain' set to a URL
{'foo': {'value': 'bar', 'domain': DUMMY_HOST}},
False,
),
# New style cookie format
(
# Without 'domain' set
[{'name': 'foo', 'value': 'bar'}],
False
),
(
# With 'domain' set to empty string
[{'name': 'foo', 'value': 'bar', 'domain': ''}],
False
),
(
# With 'domain' set to null
[{'name': 'foo', 'value': 'bar', 'domain': None}],
False,
),
(
# With 'domain' set to a URL
[{'name': 'foo', 'value': 'bar', 'domain': DUMMY_HOST}],
False,
),
])
def test_cookie_security_warnings_on_raw_cookies(basic_session, mock_env, cookies, expect_warning):
with open_raw_session(basic_session) as raw_session:
raw_session['cookies'] = cookies
with open_session(basic_session, mock_env, read_only=True):
warning = b'Outdated layout detected'
stderr = read_stderr(mock_env)
if expect_warning:
assert warning in stderr
else:
assert warning not in stderr
def test_old_session_cookie_layout_loading(basic_session, httpbin, mock_env):
with open_session(basic_session, mock_env) as session:
# Use the old layout & set a cookie
session['cookies'] = {}
session.cookies.set('foo', 'bar')
response = http(
'--session', str(basic_session),
httpbin + '/cookies'
)
assert response.json['cookies'] == {'foo': 'bar'}
@pytest.mark.parametrize('layout_type', [
dict, list
])
def test_session_cookie_layout_preservance(basic_session, mock_env, layout_type):
with open_session(basic_session, mock_env) as session:
session['cookies'] = layout_type()
session.cookies.set('foo', 'bar')
session.save()
with open_session(basic_session, mock_env, read_only=True) as session:
assert isinstance(session['cookies'], layout_type)
@pytest.mark.parametrize('layout_type', [
dict, list
])
def test_session_cookie_layout_preservance_on_new_cookies(basic_session, httpbin, mock_env, layout_type):
with open_session(basic_session, mock_env) as session:
session['cookies'] = layout_type()
session.cookies.set('foo', 'bar')
session.save()
http(
'--session', str(basic_session),
httpbin + '/cookies/set/baz/quux'
)
with open_session(basic_session, mock_env, read_only=True) as session:
assert isinstance(session['cookies'], layout_type)

View File

@ -6,6 +6,8 @@ import pytest_httpbin.certs
import requests.exceptions
import urllib3
from unittest import mock
from httpie.ssl_ import AVAILABLE_SSL_VERSION_ARG_MAPPING, DEFAULT_SSL_CIPHERS
from httpie.status import ExitStatus
@ -32,6 +34,15 @@ CLIENT_CERT = str(CERTS_ROOT / 'client.crt')
CLIENT_KEY = str(CERTS_ROOT / 'client.key')
CLIENT_PEM = str(CERTS_ROOT / 'client.pem')
# In case of a regeneration, use the following commands
# in the PWD_TESTS_ROOT:
# $ openssl genrsa -aes128 -passout pass:password 3072 > client.pem
# $ openssl req -new -x509 -nodes -days 10000 -key client.pem > client.pem
PWD_TESTS_ROOT = CERTS_ROOT / 'password_protected'
PWD_CLIENT_PEM = str(PWD_TESTS_ROOT / 'client.pem')
PWD_CLIENT_KEY = str(PWD_TESTS_ROOT / 'client.key')
PWD_CLIENT_PASS = 'password'
PWD_CLIENT_INVALID_PASS = PWD_CLIENT_PASS + 'invalid'
# We test against a local httpbin instance which uses a self-signed cert.
# Requests without --verify=<CA_BUNDLE> will fail with a verification error.
@ -165,3 +176,37 @@ def test_pyopenssl_presence():
else:
assert urllib3.util.ssl_.IS_PYOPENSSL
assert urllib3.util.IS_PYOPENSSL
@mock.patch('httpie.cli.argtypes.SSLCredentials._prompt_password',
new=lambda self, prompt: PWD_CLIENT_PASS)
def test_password_protected_cert_prompt(httpbin_secure):
r = http(httpbin_secure + '/get',
'--cert', PWD_CLIENT_PEM,
'--cert-key', PWD_CLIENT_KEY)
assert HTTP_OK in r
@mock.patch('httpie.cli.argtypes.SSLCredentials._prompt_password',
new=lambda self, prompt: PWD_CLIENT_INVALID_PASS)
def test_password_protected_cert_prompt_invalid(httpbin_secure):
with pytest.raises(ssl_errors):
http(httpbin_secure + '/get',
'--cert', PWD_CLIENT_PEM,
'--cert-key', PWD_CLIENT_KEY)
def test_password_protected_cert_cli_arg(httpbin_secure):
r = http(httpbin_secure + '/get',
'--cert', PWD_CLIENT_PEM,
'--cert-key', PWD_CLIENT_KEY,
'--cert-key-pass', PWD_CLIENT_PASS)
assert HTTP_OK in r
def test_password_protected_cert_cli_arg_invalid(httpbin_secure):
with pytest.raises(ssl_errors):
http(httpbin_secure + '/get',
'--cert', PWD_CLIENT_PEM,
'--cert-key', PWD_CLIENT_KEY,
'--cert-key-pass', PWD_CLIENT_INVALID_PASS)

View File

@ -121,6 +121,7 @@ def stdin_processes(httpbin, *args):
@pytest.mark.parametrize("wait", (True, False))
@pytest.mark.requires_external_processes
@pytest.mark.skipif(is_windows, reason="Windows doesn't support select() calls into files")
def test_reading_from_stdin(httpbin, wait):
with stdin_processes(httpbin) as (process_1, process_2):
@ -138,6 +139,7 @@ def test_reading_from_stdin(httpbin, wait):
assert b'> warning: no stdin data read in 0.1s' not in errs
@pytest.mark.requires_external_processes
@pytest.mark.skipif(is_windows, reason="Windows doesn't support select() calls into files")
def test_stdin_read_warning(httpbin):
with stdin_processes(httpbin) as (process_1, process_2):
@ -153,6 +155,7 @@ def test_stdin_read_warning(httpbin):
assert b'> warning: no stdin data read in 0.1s' in errs
@pytest.mark.requires_external_processes
@pytest.mark.skipif(is_windows, reason="Windows doesn't support select() calls into files")
def test_stdin_read_warning_with_quiet(httpbin):
with stdin_processes(httpbin, "-qq") as (process_1, process_2):

View File

@ -5,6 +5,9 @@ import sys
import time
import json
import tempfile
import warnings
import pytest
from contextlib import suppress
from io import BytesIO
from pathlib import Path
from typing import Any, Optional, Union, List, Iterable
@ -15,6 +18,7 @@ import httpie.manager.__main__ as manager
from httpie.status import ExitStatus
from httpie.config import Config
from httpie.context import Environment
from httpie.utils import url_as_host
# pytest-httpbin currently does not support chunked requests:
@ -27,6 +31,8 @@ HTTPBIN_WITH_CHUNKED_SUPPORT = 'http://' + HTTPBIN_WITH_CHUNKED_SUPPORT_DOMAIN
TESTS_ROOT = Path(__file__).parent.parent
CRLF = '\r\n'
COLOR = '\x1b['
COLOR_RE = re.compile(r'\x1b\[\d+(;\d+)*?m', re.MULTILINE)
HTTP_OK = '200 OK'
# noinspection GrazieInspection
HTTP_OK_COLOR = (
@ -36,6 +42,11 @@ HTTP_OK_COLOR = (
)
DUMMY_URL = 'http://this-should.never-resolve' # Note: URL never fetched
DUMMY_HOST = url_as_host(DUMMY_URL)
def strip_colors(colorized_msg: str) -> str:
return COLOR_RE.sub('', colorized_msg)
def mk_config_dir() -> Path:
@ -90,6 +101,7 @@ class MockEnvironment(Environment):
def cleanup(self):
self.stdout.close()
self.stderr.close()
warnings.resetwarnings()
if self._delete_config_dir:
assert self._temp_dir in self.config_dir.parents
from shutil import rmtree
@ -179,6 +191,13 @@ class ExitStatusError(Exception):
pass
@pytest.fixture
def mock_env() -> MockEnvironment:
env = MockEnvironment(stdout_mode='')
yield env
env.cleanup()
def normalize_args(args: Iterable[Any]) -> List[str]:
return [str(arg) for arg in args]
@ -193,7 +212,7 @@ def httpie(
status.
"""
env = kwargs.setdefault('env', MockEnvironment())
env = kwargs.setdefault('env', MockEnvironment(stdout_mode=''))
cli_args = ['httpie']
if not kwargs.pop('no_debug', False):
cli_args.append('--debug')
@ -206,7 +225,16 @@ def httpie(
env.stdout.seek(0)
env.stderr.seek(0)
try:
response = StrCLIResponse(env.stdout.read())
output = env.stdout.read()
if isinstance(output, bytes):
with suppress(UnicodeDecodeError):
output = output.decode()
if isinstance(output, bytes):
response = BytesCLIResponse(output)
else:
response = StrCLIResponse(output)
response.stderr = env.stderr.read()
response.exit_status = exit_status
response.args = cli_args

View File

@ -18,14 +18,17 @@ class TestHandler(BaseHTTPRequestHandler):
return func
return inner
def do_GET(self):
def do_generic(self):
parse_result = urlparse(self.path)
func = self.handlers['GET'].get(parse_result.path)
func = self.handlers[self.command].get(parse_result.path)
if func is None:
return self.send_error(HTTPStatus.NOT_FOUND)
return func(self)
do_GET = do_generic
do_POST = do_generic
@TestHandler.handler('GET', '/headers')
def get_headers(handler):
@ -73,6 +76,28 @@ def random_encoding(handler):
handler.wfile.write('0\r\n\r\n'.encode('utf-8'))
@TestHandler.handler('POST', '/status/msg')
def status_custom_msg(handler):
content_len = int(handler.headers.get('content-length', 0))
post_body = handler.rfile.read(content_len).decode()
handler.send_response(200, post_body)
handler.end_headers()
@TestHandler.handler('GET', '/cookies/set-and-redirect')
def set_cookie_and_redirect(handler):
handler.send_response(302)
redirect_to = handler.headers.get('X-Redirect-To', '/headers')
handler.send_header('Location', redirect_to)
raw_cookies = handler.headers.get('X-Cookies', 'a=b')
for cookie in raw_cookies.split(', '):
handler.send_header('Set-Cookie', cookie)
handler.end_headers()
@pytest.fixture(scope="function")
def http_server():
"""A custom HTTP server implementation for our tests, that is