mirror of
https://github.com/httpie/cli.git
synced 2025-08-10 12:17:46 +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.
|
345
docs/README.md
345
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
|
||||
|
||||
@ -1453,13 +1550,13 @@ be printed via several options:
|
||||
|
||||
```bash
|
||||
$ http --meta pie.dev/delay/1
|
||||
### Verbose output
|
||||
|
||||
`--verbose` can often be useful for debugging the request and generating documentation examples:
|
||||
|
||||
```bash
|
||||
$ http --verbose PUT pie.dev/put hello=world
|
||||
PUT /put HTTP/1.1
|
||||
```
|
||||
|
||||
```console
|
||||
Elapsed time: 1.099171542s
|
||||
```
|
||||
|
||||
The [extra verbose `-vv` output](#extra-verbose-output) includes the meta section by default. You can also show it in combination with other parts of the exchange via [`--print=m`](#what-parts-of-the-http-exchange-should-be-printed). For example, here we print it together with the response headers:
|
||||
|
||||
```bash
|
||||
$ http --print=hm pie.dev/get
|
||||
@ -1471,9 +1568,34 @@ $ http --print=Hh PUT pie.dev/put hello=world
|
||||
|
||||
```
|
||||
|
||||
Connection: keep-alive
|
||||
|
||||
Content-Type: application/json
|
||||
Please note that it also includes time spent on formatting the output, which adds a small penalty. Also, if the body is not part of the output, [we 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
|
||||
"expires": null,
|
||||
"path": "/",
|
||||
"secure": false,
|
||||
$ http --session=./session.json pie.dev/cookies
|
||||
```
|
||||
|
||||
```json
|
||||
}
|
||||
{
|
||||
"cookies": {
|
||||
"unbound-cookie": "send-me-to-any-host"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
### Cookie storage behavior
|
||||
|
||||
For example, a cookie set through the command line will overwrite a cookie of the same name stored in the session file.
|
||||
There are three possible sources of persisted cookies within a session. They have the following storage priority: 1—response; 2—command line; 3—session file.
|
||||
|
||||
1. Receive a response with a `Set-Cookie` header:
|
||||
|
||||
```bash
|
||||
$ http --session=./session.json pie.dev/cookie/set?foo=bar
|
||||
```
|
||||
|
||||
2. Send a cookie specified on the command line as seen in [cookies](#cookies):
|
||||
|
||||
```bash
|
||||
$ http --session=./session.json pie.dev/headers Cookie:foo=bar
|
||||
|
||||
Expired cookies are never stored.
|
||||
If a cookie in a session file expires, it will be removed before sending a new request.
|
||||
If the server expires an existing cookie, it will also be removed from the session file.
|
||||
|
||||
## Config
|
||||
|
||||
HTTPie uses a simple `config.json` file.
|
||||
The file doesn’t exist by default, but you can create it manually.
|
||||
|
||||
### Config file directory
|
||||
|
||||
```
|
||||
|
||||
3. Manually set cookie parameters in the session file:
|
||||
|
||||
```json
|
||||
{
|
||||
"cookies": {
|
||||
"foo": {
|
||||
"expires": null,
|
||||
"path": "/",
|
||||
"secure": false,
|
||||
"value": "bar"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In summary:
|
||||
|
||||
- Cookies set via the CLI overwrite cookies of the same name inside session files.
|
||||
- Server-sent `Set-Cookie` header cookies overwrite any pre-existing ones with the same name.
|
||||
|
||||
Cookie expiration handling:
|
||||
|
||||
- When the server expires an existing cookie, HTTPie removes it from the session file.
|
||||
- When a cookie in a session file expires, HTTPie removes it before sending a new request.
|
||||
|
||||
### Upgrading sessions
|
||||
|
||||
HTTPie may introduce changes in the session file format. When HTTPie detects an obsolete format, it shows a warning. You can upgrade your session files using the following commands:
|
||||
|
||||
Upgrade all existing [named sessions](#named-sessions) inside the `sessions` subfolder of your [config directory](https://httpie.io/docs/cli/config-file-directory):
|
||||
|
||||
```bash
|
||||
$ httpie cli sessions upgrade-all
|
||||
Upgraded 'api_auth' @ 'pie.dev' to v3.1.0
|
||||
@ -2073,16 +2247,53 @@ To set a cookie within a Session there are three options:
|
||||
|
||||
Upgrading individual sessions requires you to specify the session's hostname. That allows HTTPie to find the correct file in the case of name sessions. Additionally, it allows it to correctly bind cookies when upgrading with [`--bind-cookies`](#session-upgrade-options).
|
||||
|
||||
The config directory can be changed by setting the `$HTTPIE_CONFIG_DIR` environment variable:
|
||||
|
||||
Upgrade a single [named session](#named-sessions):
|
||||
|
||||
```bash
|
||||
$ export HTTPIE_CONFIG_DIR=/tmp/httpie
|
||||
$ http pie.dev/get
|
||||
```
|
||||
$ httpie cli sessions upgrade pie.dev api_auth
|
||||
Upgraded 'api_auth' @ 'pie.dev' to v3.1.0
|
||||
### Configurable options
|
||||
|
||||
Currently, HTTPie offers a single configurable option:
|
||||
```
|
||||
|
||||
Upgrade a single [anonymous session](#anonymous-sessions) using a file path:
|
||||
|
||||
```bash
|
||||
$ httpie cli sessions upgrade pie.dev ./session.json
|
||||
Upgraded 'session.json' @ 'pie.dev' to v3.1.0
|
||||
```
|
||||
|
||||
#### Session upgrade options
|
||||
|
||||
These flags are available for both `sessions upgrade` and `sessions upgrade-all`:
|
||||
|
||||
------------------|------------------------------------------
|
||||
`--bind-cookies` | Bind all previously [unbound cookies](#host-based-cookie-policy) to the 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,18 +90,18 @@ def tokenize(source: str) -> Iterator[Token]:
|
||||
return None
|
||||
|
||||
value = ''.join(buffer)
|
||||
for variation, kind in [
|
||||
(int, TokenKind.NUMBER),
|
||||
(check_escaped_int, TokenKind.TEXT),
|
||||
]:
|
||||
try:
|
||||
value = variation(value)
|
||||
except ValueError:
|
||||
continue
|
||||
else:
|
||||
break
|
||||
else:
|
||||
kind = TokenKind.TEXT
|
||||
kind = TokenKind.TEXT
|
||||
if not backslashes:
|
||||
for variation, kind in [
|
||||
(int, TokenKind.NUMBER),
|
||||
(check_escaped_int, TokenKind.TEXT),
|
||||
]:
|
||||
try:
|
||||
value = variation(value)
|
||||
except ValueError:
|
||||
continue
|
||||
else:
|
||||
break
|
||||
|
||||
yield Token(
|
||||
kind, value, start=cursor - (len(buffer) + backslashes), end=cursor
|
||||
@ -171,8 +173,8 @@ class Path:
|
||||
|
||||
def parse(source: str) -> Iterator[Path]:
|
||||
"""
|
||||
start: literal? path*
|
||||
|
||||
start: root_path path*
|
||||
root_path: (literal | index_path | append_path)
|
||||
literal: TEXT | NUMBER
|
||||
|
||||
path:
|
||||
@ -215,16 +217,47 @@ def parse(source: str) -> Iterator[Path]:
|
||||
message = f'Expecting {suffix}'
|
||||
raise HTTPieSyntaxError(source, token, message)
|
||||
|
||||
root = Path(PathAction.KEY, '', is_root=True)
|
||||
if can_advance():
|
||||
token = tokens[cursor]
|
||||
if token.kind in {TokenKind.TEXT, TokenKind.NUMBER}:
|
||||
token = expect(TokenKind.TEXT, TokenKind.NUMBER)
|
||||
root.accessor = str(token.value)
|
||||
root.tokens.append(token)
|
||||
def parse_root():
|
||||
tokens = []
|
||||
if not can_advance():
|
||||
return Path(
|
||||
PathAction.KEY,
|
||||
EMPTY_STRING,
|
||||
is_root=True
|
||||
)
|
||||
|
||||
yield root
|
||||
# (literal | index_path | append_path)?
|
||||
token = expect(*LITERAL_TOKENS, TokenKind.LEFT_BRACKET)
|
||||
tokens.append(token)
|
||||
|
||||
if token.kind in LITERAL_TOKENS:
|
||||
action = PathAction.KEY
|
||||
value = str(token.value)
|
||||
elif token.kind is TokenKind.LEFT_BRACKET:
|
||||
token = expect(TokenKind.NUMBER, TokenKind.RIGHT_BRACKET)
|
||||
tokens.append(token)
|
||||
if token.kind is TokenKind.NUMBER:
|
||||
action = PathAction.INDEX
|
||||
value = token.value
|
||||
tokens.append(expect(TokenKind.RIGHT_BRACKET))
|
||||
elif token.kind is TokenKind.RIGHT_BRACKET:
|
||||
action = PathAction.APPEND
|
||||
value = None
|
||||
else:
|
||||
assert_cant_happen()
|
||||
else:
|
||||
assert_cant_happen()
|
||||
|
||||
return Path(
|
||||
action,
|
||||
value,
|
||||
tokens=tokens,
|
||||
is_root=True
|
||||
)
|
||||
|
||||
yield parse_root()
|
||||
|
||||
# path*
|
||||
while can_advance():
|
||||
path_tokens = []
|
||||
path_tokens.append(expect(TokenKind.LEFT_BRACKET))
|
||||
@ -296,6 +329,10 @@ def interpret(context: Any, key: str, value: Any) -> Any:
|
||||
assert_cant_happen()
|
||||
|
||||
for index, (path, next_path) in enumerate(zip(paths, paths[1:])):
|
||||
# If there is no context yet, set it.
|
||||
if cursor is None:
|
||||
context = cursor = object_for(path.kind)
|
||||
|
||||
if path.kind is PathAction.KEY:
|
||||
type_check(index, path, dict)
|
||||
if next_path.kind is PathAction.SET:
|
||||
@ -337,8 +374,19 @@ def interpret(context: Any, key: str, value: Any) -> Any:
|
||||
return context
|
||||
|
||||
|
||||
def wrap_with_dict(context):
|
||||
if context is None:
|
||||
return {}
|
||||
elif isinstance(context, list):
|
||||
return {EMPTY_STRING: NestedJSONArray(context)}
|
||||
else:
|
||||
assert isinstance(context, dict)
|
||||
return context
|
||||
|
||||
|
||||
def interpret_nested_json(pairs):
|
||||
context = {}
|
||||
context = None
|
||||
for key, value in pairs:
|
||||
interpret(context, key, value)
|
||||
return context
|
||||
context = interpret(context, key, value)
|
||||
|
||||
return wrap_with_dict(context)
|
||||
|
189
httpie/cli/options.py
Normal file
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}]'
|
||||
)
|
||||
self.update(data)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as e:
|
||||
raise ConfigFileError(f'cannot read {config_type} file: {e}')
|
||||
data = read_raw_config(config_type, self.path)
|
||||
if data is not None:
|
||||
data = self.pre_process_data(data)
|
||||
self.update(data)
|
||||
|
||||
def save(self):
|
||||
self['__meta__'] = {
|
||||
'httpie': __version__
|
||||
}
|
||||
def save(self, *, bump_version: bool = False):
|
||||
self.setdefault('__meta__', {})
|
||||
if bump_version or 'httpie' not in self['__meta__']:
|
||||
self['__meta__']['httpie'] = __version__
|
||||
if self.helpurl:
|
||||
self['__meta__']['help'] = self.helpurl
|
||||
|
||||
@ -106,13 +120,19 @@ class BaseConfigDict(dict):
|
||||
self.ensure_directory()
|
||||
|
||||
json_string = json.dumps(
|
||||
obj=self,
|
||||
obj=self.post_process_data(self),
|
||||
indent=4,
|
||||
sort_keys=True,
|
||||
ensure_ascii=True,
|
||||
)
|
||||
self.path.write_text(json_string + '\n', encoding=UTF8)
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
return self.get(
|
||||
'__meta__', {}
|
||||
).get('httpie', __version__)
|
||||
|
||||
|
||||
class Config(BaseConfigDict):
|
||||
FILENAME = 'config.json'
|
||||
|
@ -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(
|
||||
body=do_write_body
|
||||
))
|
||||
env.log_error(f'HTTP {message.raw.status} {message.raw.reason}', level=Levels.WARNING)
|
||||
write_message(
|
||||
requests_message=message,
|
||||
env=env,
|
||||
output_options=output_options._replace(
|
||||
body=do_write_body
|
||||
),
|
||||
processing_options=processing_options
|
||||
)
|
||||
prev_with_body = output_options.body
|
||||
|
||||
# Cleanup
|
||||
|
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:
|
||||
# HACK/FIXME: httpie-unixsocket's URLs have no hostname.
|
||||
hostname = 'localhost'
|
||||
bound_hostname = host or url_as_host(url)
|
||||
if not bound_hostname:
|
||||
# HACK/FIXME: httpie-unixsocket's URLs have no hostname.
|
||||
bound_hostname = 'localhost'
|
||||
|
||||
# host:port => host_port
|
||||
hostname = hostname.replace(':', '_')
|
||||
path = (
|
||||
config_dir / SESSIONS_DIR_NAME / hostname / f'{session_name}.json'
|
||||
)
|
||||
session = Session(path)
|
||||
if is_anonymous_session(session_name):
|
||||
path = os.path.expanduser(session_name)
|
||||
session_id = path
|
||||
else:
|
||||
path = config_dir / session_hostname_to_dirname(bound_hostname, session_name)
|
||||
session_id = session_name
|
||||
|
||||
session = Session(
|
||||
path,
|
||||
env=env,
|
||||
session_id=session_id,
|
||||
bound_host=strip_port(bound_hostname),
|
||||
refactor_mode=refactor_mode
|
||||
)
|
||||
session.load()
|
||||
return session
|
||||
|
||||
@ -55,15 +103,61 @@ class Session(BaseConfigDict):
|
||||
helpurl = 'https://httpie.io/docs#sessions'
|
||||
about = 'HTTPie session file'
|
||||
|
||||
def __init__(self, path: Union[str, Path]):
|
||||
def __init__(
|
||||
self,
|
||||
path: Union[str, Path],
|
||||
env: Environment,
|
||||
bound_host: str,
|
||||
session_id: str,
|
||||
refactor_mode: bool = False,
|
||||
):
|
||||
super().__init__(path=Path(path))
|
||||
self['headers'] = {}
|
||||
self['cookies'] = {}
|
||||
self['cookies'] = []
|
||||
self['auth'] = {
|
||||
'type': None,
|
||||
'username': None,
|
||||
'password': None
|
||||
}
|
||||
self.env = env
|
||||
self.cookie_jar = RequestsCookieJar()
|
||||
self.session_id = session_id
|
||||
self.bound_host = bound_host
|
||||
self.refactor_mode = refactor_mode
|
||||
|
||||
def pre_process_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
cookies = data.get('cookies')
|
||||
if cookies:
|
||||
normalized_cookies = legacy_cookies.pre_process(self, cookies)
|
||||
else:
|
||||
normalized_cookies = []
|
||||
|
||||
for cookie in normalized_cookies:
|
||||
domain = cookie.get('domain', '')
|
||||
if domain is None:
|
||||
# domain = None means explicitly lack of cookie, though
|
||||
# requests requires domain to be a string so we'll cast it
|
||||
# manually.
|
||||
cookie['domain'] = ''
|
||||
cookie['rest'] = {'is_explicit_none': True}
|
||||
|
||||
self.cookie_jar.set(**cookie)
|
||||
|
||||
return data
|
||||
|
||||
def post_process_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
cookies = data.get('cookies')
|
||||
|
||||
normalized_cookies = [
|
||||
materialize_cookie(cookie)
|
||||
for cookie in self.cookie_jar
|
||||
]
|
||||
data['cookies'] = legacy_cookies.post_process(
|
||||
normalized_cookies,
|
||||
original_type=type(cookies)
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def update_headers(self, request_headers: HTTPHeadersDict):
|
||||
"""
|
||||
@ -73,10 +167,10 @@ class Session(BaseConfigDict):
|
||||
"""
|
||||
headers = self.headers
|
||||
for name, value in request_headers.copy().items():
|
||||
|
||||
if value is None:
|
||||
continue # Ignore explicitly unset headers
|
||||
|
||||
original_value = value
|
||||
if type(value) is not str:
|
||||
value = value.decode()
|
||||
|
||||
@ -85,8 +179,15 @@ class Session(BaseConfigDict):
|
||||
|
||||
if name.lower() == 'cookie':
|
||||
for cookie_name, morsel in SimpleCookie(value).items():
|
||||
self['cookies'][cookie_name] = {'value': morsel.value}
|
||||
del request_headers[name]
|
||||
if not morsel['path']:
|
||||
morsel['path'] = DEFAULT_COOKIE_PATH
|
||||
self.cookie_jar.set(cookie_name, morsel)
|
||||
|
||||
all_cookie_headers = request_headers.getall(name)
|
||||
if len(all_cookie_headers) > 1:
|
||||
all_cookie_headers.remove(original_value)
|
||||
else:
|
||||
request_headers.popall(name)
|
||||
continue
|
||||
|
||||
for prefix in SESSION_IGNORED_HEADER_PREFIXES:
|
||||
@ -103,23 +204,21 @@ class Session(BaseConfigDict):
|
||||
|
||||
@property
|
||||
def cookies(self) -> RequestsCookieJar:
|
||||
jar = RequestsCookieJar()
|
||||
for name, cookie_dict in self['cookies'].items():
|
||||
jar.set_cookie(create_cookie(
|
||||
name, cookie_dict.pop('value'), **cookie_dict))
|
||||
jar.clear_expired_cookies()
|
||||
return jar
|
||||
self.cookie_jar.clear_expired_cookies()
|
||||
return self.cookie_jar
|
||||
|
||||
@cookies.setter
|
||||
def cookies(self, jar: RequestsCookieJar):
|
||||
# <https://docs.python.org/3/library/cookielib.html#cookie-objects>
|
||||
stored_attrs = ['value', 'path', 'secure', 'expires']
|
||||
self['cookies'] = {}
|
||||
for cookie in jar:
|
||||
self['cookies'][cookie.name] = {
|
||||
attname: getattr(cookie, attname)
|
||||
for attname in stored_attrs
|
||||
}
|
||||
self.cookie_jar = jar
|
||||
|
||||
def remove_cookies(self, cookies: List[Dict[str, str]]):
|
||||
for cookie in cookies:
|
||||
remove_cookie_by_name(
|
||||
self.cookie_jar,
|
||||
cookie['name'],
|
||||
domain=cookie.get('domain', None),
|
||||
path=cookie.get('path', None)
|
||||
)
|
||||
|
||||
@property
|
||||
def auth(self) -> Optional[AuthBase]:
|
||||
@ -155,7 +254,6 @@ class Session(BaseConfigDict):
|
||||
assert {'type', 'raw_auth'} == auth.keys()
|
||||
self['auth'] = auth
|
||||
|
||||
def remove_cookies(self, names: Iterable[str]):
|
||||
for name in names:
|
||||
if name in self['cookies']:
|
||||
del self['cookies'][name]
|
||||
@property
|
||||
def is_anonymous(self):
|
||||
return is_anonymous_session(self.session_id)
|
||||
|
@ -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