mirror of
https://github.com/httpie/cli.git
synced 2025-08-11 12:23:53 +02:00
Compare commits
69 Commits
Author | SHA1 | Date | |
---|---|---|---|
266c6375c6 | |||
77af4c7a5c | |||
7509dd4e6c | |||
f08c1bee17 | |||
59d9e928f8 | |||
0a873172c9 | |||
614866eeb2 | |||
395914fb4d | |||
65ab7d5caa | |||
b5623ccc87 | |||
ec203b1fac | |||
350abe3033 | |||
9241a09360 | |||
15013fd609 | |||
98688b2f2d | |||
5ac05e9514 | |||
5c98253377 | |||
b0f5b8ab26 | |||
55087a901e | |||
c901e70463 | |||
25bd817bb2 | |||
6f77e144e4 | |||
6bf39e469f | |||
30cd862fc0 | |||
ad613f29d2 | |||
225dccb218 | |||
cafa11665b | |||
0a9d3d3c54 | |||
e306667436 | |||
384d3869f6 | |||
5fd48e3137 | |||
37ef670876 | |||
46e782bf75 | |||
42edb1eb76 | |||
d45f413f12 | |||
f1ea486025 | |||
7abddfe350 | |||
86ba995ad8 | |||
c03f081a7e | |||
a7d8187b21 | |||
fc383e9b78 | |||
770df02291 | |||
f756cad58d | |||
fde64d578d | |||
c8404493e5 | |||
559134de0a | |||
813e8864a1 | |||
45fcd746d7 | |||
d5e3611e85 | |||
378a1f513e | |||
df6843b15a | |||
640901146f | |||
6b5d96da72 | |||
97bd9c2a89 | |||
708608e1d4 | |||
d56a1f216e | |||
738a6bea57 | |||
ec521c461b | |||
212000199e | |||
700dbeddb0 | |||
30a4d29f77 | |||
aedcad7e2a | |||
202f59e04a | |||
ba0c1ab258 | |||
217cf8ddae | |||
859e442083 | |||
4e59bbfae6 | |||
caa8fb9058 | |||
2797b7244c |
4
.github/workflows/benchmark.yml
vendored
4
.github/workflows/benchmark.yml
vendored
@ -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"
|
||||
|
||||
|
6
.github/workflows/code-style.yml
vendored
6
.github/workflows/code-style.yml
vendored
@ -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
|
||||
|
6
.github/workflows/coverage.yml
vendored
6
.github/workflows/coverage.yml
vendored
@ -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
|
||||
|
4
.github/workflows/docs-check-markdown.yml
vendored
4
.github/workflows/docs-check-markdown.yml
vendored
@ -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:
|
||||
|
2
.github/workflows/docs-deploy.yml
vendored
2
.github/workflows/docs-deploy.yml
vendored
@ -1,3 +1,5 @@
|
||||
name: Deploy Documentation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
|
6
.github/workflows/docs-update-install.yml
vendored
6
.github/workflows/docs-update-install.yml
vendored
@ -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
|
||||
|
10
.github/workflows/release-snap.yml
vendored
10
.github/workflows/release-snap.yml
vendored
@ -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 }}
|
||||
|
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@ -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
|
||||
|
@ -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
|
||||
|
4
.github/workflows/test-package-mac-brew.yml
vendored
4
.github/workflows/test-package-mac-brew.yml
vendored
@ -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
|
||||
|
6
.github/workflows/tests.yml
vendored
6
.github/workflows/tests.yml
vendored
@ -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
|
||||
|
@ -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:
|
||||
|
28
CHANGELOG.md
28
CHANGELOG.md
@ -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)
|
||||
|
||||
[What’s 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))
|
||||
[What’s 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)
|
||||
|
||||
[What’s 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)
|
||||
|
||||
[What’s new in HTTPie 2.6.0 →](https://httpie.io/blog/httpie-2.6.0)
|
||||
[What’s 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` doesn’t 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)
|
||||
|
||||
[What’s new in HTTPie 2.5.0 →](https://httpie.io/blog/httpie-2.5.0)
|
||||
[What’s 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))
|
||||
|
2
LICENSE
2
LICENSE
@ -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
14
SECURITY.md
Normal 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.
|
315
docs/README.md
315
docs/README.md
@ -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
|
||||
|
||||
Let’s 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
|
||||
|
||||
@ -1454,12 +1551,12 @@ All the other [output options](#output-options) are under the hood just shortcut
|
||||
```bash
|
||||
$ http --meta pie.dev/delay/1
|
||||
```
|
||||
|
||||
|
||||
```console
|
||||
Elapsed time: 1.099171542s
|
||||
```
|
||||
|
||||
PUT /put HTTP/1.1
|
||||
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 don’t spend time downloading it](#conditional-body-download).
|
||||
|
||||
If you [use `--style` with one of the Pie themes](#colors-and-formatting), you’ll 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
|
||||
$ http --session=./session.json pie.dev/cookies
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"cookies": {
|
||||
"unbound-cookie": "send-me-to-any-host"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Cookie storage behavior
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
For example, a cookie set through the command line will overwrite a cookie of the same name stored in the session file.
|
||||
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):
|
||||
|
||||
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 doesn’t exist by default, but you can create it manually.
|
||||
|
||||
### Config file directory
|
||||
|
||||
```bash
|
||||
$ httpie cli sessions upgrade-all
|
||||
Upgraded 'api_auth' @ 'pie.dev' to v3.1.0
|
||||
@ -2076,13 +2250,50 @@ To set a cookie within a Session there are three options:
|
||||
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 session’s host.
|
||||
|
||||
## Config
|
||||
|
||||
HTTPie uses a simple `config.json` file.
|
||||
The file doesn’t 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 there’s neither data nor `EOF`, it will get stuck. So unless you’r
|
||||
|
||||
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 wasn’t 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).
|
||||
|
@ -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
|
||||
|
@ -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}")
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
39
extras/profiling/README.md
Normal file
39
extras/profiling/README.md
Normal 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`.
|
@ -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'
|
||||
|
@ -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
|
||||
'''
|
||||
)
|
||||
)
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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
@ -82,3 +82,7 @@ class MultipartRequestDataDict(MultiValueOrderedDict):
|
||||
|
||||
class RequestFilesDict(RequestDataDict):
|
||||
pass
|
||||
|
||||
|
||||
class NestedJSONArray(list):
|
||||
"""Denotes a top-level JSON array."""
|
||||
|
@ -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,6 +90,8 @@ def tokenize(source: str) -> Iterator[Token]:
|
||||
return None
|
||||
|
||||
value = ''.join(buffer)
|
||||
kind = TokenKind.TEXT
|
||||
if not backslashes:
|
||||
for variation, kind in [
|
||||
(int, TokenKind.NUMBER),
|
||||
(check_escaped_int, TokenKind.TEXT),
|
||||
@ -98,8 +102,6 @@ def tokenize(source: str) -> Iterator[Token]:
|
||||
continue
|
||||
else:
|
||||
break
|
||||
else:
|
||||
kind = TokenKind.TEXT
|
||||
|
||||
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 interpret_nested_json(pairs):
|
||||
context = {}
|
||||
for key, value in pairs:
|
||||
interpret(context, key, value)
|
||||
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 = None
|
||||
for key, value in pairs:
|
||||
context = interpret(context, key, value)
|
||||
|
||||
return wrap_with_dict(context)
|
||||
|
189
httpie/cli/options.py
Normal file
189
httpie/cli/options.py
Normal 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()}
|
@ -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:
|
||||
|
@ -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}]'
|
||||
)
|
||||
data = read_raw_config(config_type, self.path)
|
||||
if data is not None:
|
||||
data = self.pre_process_data(data)
|
||||
self.update(data)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as e:
|
||||
raise ConfigFileError(f'cannot read {config_type} file: {e}')
|
||||
|
||||
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'
|
||||
|
@ -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")
|
||||
|
@ -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(
|
||||
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
|
||||
|
0
httpie/legacy/__init__.py
Normal file
0
httpie/legacy/__init__.py
Normal file
103
httpie/legacy/cookie_format.py
Normal file
103
httpie/legacy/cookie_format.py
Normal 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
|
@ -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:]
|
||||
|
@ -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(
|
||||
|
@ -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
141
httpie/manager/tasks.py
Normal 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
|
@ -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
44
httpie/output/models.py
Normal 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
|
@ -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."""
|
||||
|
@ -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,
|
||||
)
|
||||
})
|
||||
|
||||
|
@ -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:
|
||||
bound_hostname = host or url_as_host(url)
|
||||
if not bound_hostname:
|
||||
# HACK/FIXME: httpie-unixsocket's URLs have no hostname.
|
||||
hostname = 'localhost'
|
||||
bound_hostname = 'localhost'
|
||||
|
||||
# host:port => host_port
|
||||
hostname = hostname.replace(':', '_')
|
||||
path = (
|
||||
config_dir / SESSIONS_DIR_NAME / hostname / f'{session_name}.json'
|
||||
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 = Session(path)
|
||||
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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
7
pytest.ini
Normal 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
|
1
setup.py
1
setup.py
@ -11,6 +11,7 @@ import httpie
|
||||
tests_require = [
|
||||
'pytest',
|
||||
'pytest-httpbin>=0.0.6',
|
||||
'pytest-lazy-fixture>=0.0.6',
|
||||
'responses',
|
||||
]
|
||||
dev_require = [
|
||||
|
42
tests/client_certs/password_protected/client.key
Normal file
42
tests/client_certs/password_protected/client.key
Normal 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-----
|
26
tests/client_certs/password_protected/client.pem
Normal file
26
tests/client_certs/password_protected/client.pem
Normal 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-----
|
@ -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,
|
||||
|
24
tests/fixtures/__init__.py
vendored
24
tests/fixtures/__init__.py
vendored
@ -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)
|
||||
|
31
tests/fixtures/session_data/new/cookies_dict.json
vendored
Normal file
31
tests/fixtures/session_data/new/cookies_dict.json
vendored
Normal 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": {}
|
||||
}
|
31
tests/fixtures/session_data/new/cookies_dict_dev_version.json
vendored
Normal file
31
tests/fixtures/session_data/new/cookies_dict_dev_version.json
vendored
Normal 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": {}
|
||||
}
|
33
tests/fixtures/session_data/new/cookies_dict_with_extras.json
vendored
Normal file
33
tests/fixtures/session_data/new/cookies_dict_with_extras.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
14
tests/fixtures/session_data/new/empty_cookies_dict.json
vendored
Normal file
14
tests/fixtures/session_data/new/empty_cookies_dict.json
vendored
Normal 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": {}
|
||||
}
|
14
tests/fixtures/session_data/new/empty_cookies_list.json
vendored
Normal file
14
tests/fixtures/session_data/new/empty_cookies_list.json
vendored
Normal 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": {}
|
||||
}
|
27
tests/fixtures/session_data/old/cookies_dict.json
vendored
Normal file
27
tests/fixtures/session_data/old/cookies_dict.json
vendored
Normal 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": {}
|
||||
}
|
27
tests/fixtures/session_data/old/cookies_dict_dev_version.json
vendored
Normal file
27
tests/fixtures/session_data/old/cookies_dict_dev_version.json
vendored
Normal 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": {}
|
||||
}
|
29
tests/fixtures/session_data/old/cookies_dict_with_extras.json
vendored
Normal file
29
tests/fixtures/session_data/old/cookies_dict_with_extras.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
14
tests/fixtures/session_data/old/empty_cookies_dict.json
vendored
Normal file
14
tests/fixtures/session_data/old/empty_cookies_dict.json
vendored
Normal 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": {}
|
||||
}
|
14
tests/fixtures/session_data/old/empty_cookies_list.json
vendored
Normal file
14
tests/fixtures/session_data/old/empty_cookies_list.json
vendored
Normal 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
81
tests/test_cli_ui.py
Normal 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
|
262
tests/test_cookie_on_redirects.py
Normal file
262
tests/test_cookie_on_redirects.py
Normal 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
138
tests/test_httpie_cli.py
Normal 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
|
@ -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):
|
||||
|
@ -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 'Couldn’t 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."""
|
||||
|
||||
|
60
tests/test_parser_schema.py
Normal file
60
tests/test_parser_schema.py
Normal 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"}],
|
||||
},
|
||||
],
|
||||
}
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user