forked from extern/httpie-cli
Compare commits
24 Commits
fix-initia
...
dependabot
Author | SHA1 | Date | |
---|---|---|---|
af5e9897c9 | |||
47e9b99ba1 | |||
265841f866 | |||
b16392fbb9 | |||
e73c3e6c24 | |||
f0563deb7f | |||
4894b4c0fc | |||
3a123c4125 | |||
621042a048 | |||
0689b55e1d | |||
a7321d8ac4 | |||
d9a73cd8eb | |||
930cd9081a | |||
3549ee8342 | |||
810bb1c77b | |||
767f3c3a19 | |||
1236793272 | |||
c3a2f87dd2 | |||
1121d695a8 | |||
5794a070e1 | |||
4736a16698 | |||
3ad408add7 | |||
91cdb22a4b | |||
c995fd9b24 |
4
.github/workflows/autogenerated-files.yml
vendored
4
.github/workflows/autogenerated-files.yml
vendored
@ -12,7 +12,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-python@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.9
|
||||
|
||||
@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@v4
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
commit-message: "[automated] Update auto-generated files"
|
||||
title: "[automated] Update auto-generated files"
|
||||
|
2
.github/workflows/benchmark.yml
vendored
2
.github/workflows/benchmark.yml
vendored
@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.9"
|
||||
|
||||
|
2
.github/workflows/code-style.yml
vendored
2
.github/workflows/code-style.yml
vendored
@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.9
|
||||
- run: make venv
|
||||
|
2
.github/workflows/coverage.yml
vendored
2
.github/workflows/coverage.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- run: make install
|
||||
|
2
.github/workflows/release-brew.yml
vendored
2
.github/workflows/release-brew.yml
vendored
@ -18,7 +18,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
|
||||
- uses: mislav/bump-homebrew-formula-action@v1
|
||||
- uses: mislav/bump-homebrew-formula-action@v2
|
||||
with:
|
||||
formula-name: httpie
|
||||
tag-name: ${{ github.events.inputs.branch }}
|
||||
|
@ -24,7 +24,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
|
||||
- uses: actions/setup-python@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.9
|
||||
|
||||
|
2
.github/workflows/release-pypi.yml
vendored
2
.github/workflows/release-pypi.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
|
||||
- uses: actions/setup-python@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.9
|
||||
|
||||
|
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v5
|
||||
- uses: actions/stale@v8
|
||||
with:
|
||||
close-pr-message: 'Thanks for the pull request, but since it was stale for more than a 30 days we are closing it. If you want to work back on it, feel free to re-open it or create a new one.'
|
||||
stale-pr-label: 'stale'
|
||||
|
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@ -30,7 +30,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Windows setup
|
||||
|
@ -154,7 +154,7 @@ with the master branch of your repository (or a fresh checkout of HTTPie master,
|
||||
`--fresh`) and report the results back.
|
||||
|
||||
```bash
|
||||
$ python extras/benchmarks/run.py
|
||||
$ python extras/profiling/run.py
|
||||
```
|
||||
|
||||
The benchmarks can also be run on the CI. Since it is a long process, it requires manual
|
||||
|
2
Makefile
2
Makefile
@ -34,7 +34,7 @@ default: list-tasks
|
||||
list-tasks:
|
||||
@echo Available tasks:
|
||||
@echo ----------------
|
||||
@$(MAKE) -pRrq -f $(lastword $(MAKEFILE_LIST)) : 2>/dev/null | awk -v RS= -F: '/^# File/,/^# Finished Make data base/ {if ($$1 !~ "^[#.]") {print $$1}}' | sort | egrep -v -e '^[^[:alnum:]]' -e '^$@$$'
|
||||
@$(MAKE) -pRrq -f $(lastword $(MAKEFILE_LIST)) : 2>/dev/null | awk -v RS= -F: '/^# File/,/^# Finished Make data base/ {if ($$1 !~ "^[#.]") {print $$1}}' | sort | grep -E -v -e '^[^[:alnum:]]' -e '^$@$$'
|
||||
@echo
|
||||
|
||||
|
||||
|
52
README.md
52
README.md
@ -1,10 +1,30 @@
|
||||
<br/>
|
||||
<a href="https://httpie.io" target="blank_">
|
||||
<img height="100" alt="HTTPie" src="https://raw.githubusercontent.com/httpie/httpie/master/docs/httpie-logo.svg" />
|
||||
</a>
|
||||
<br/>
|
||||
<h2 align="center">
|
||||
<a href="https://httpie.io" target="blank_">
|
||||
<img height="100" alt="HTTPie" src="https://raw.githubusercontent.com/httpie/httpie/master/docs/httpie-logo.svg" />
|
||||
</a>
|
||||
<br>
|
||||
HTTPie for Terminal: human-friendly CLI HTTP client for the API era
|
||||
</h2>
|
||||
|
||||
# HTTPie: human-friendly CLI HTTP client for the API era
|
||||
<div align="center">
|
||||
|
||||
[](https://httpie.io/product)
|
||||
[](https://httpie.io/app)
|
||||
[](https://httpie.io/cli)
|
||||
[](https://twitter.com/httpie)
|
||||
[](https://httpie.io/discord)
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://httpie.org/docs/cli)
|
||||
[](https://pypi.python.org/pypi/httpie)
|
||||
[](https://github.com/httpie/httpie/actions)
|
||||
[](https://codecov.io/gh/httpie/httpie)
|
||||
|
||||
</div>
|
||||
|
||||
HTTPie (pronounced _aitch-tee-tee-pie_) is a command-line HTTP client.
|
||||
Its goal is to make CLI interaction with web services as human-friendly as possible.
|
||||
@ -12,16 +32,16 @@ HTTPie is designed for testing, debugging, and generally interacting with APIs &
|
||||
The `http` & `https` commands allow for creating and sending arbitrary HTTP requests.
|
||||
They use simple and natural syntax and provide formatted and colorized output.
|
||||
|
||||
[](https://httpie.org/docs)
|
||||
[](https://pypi.python.org/pypi/httpie)
|
||||
[](https://github.com/httpie/httpie/actions)
|
||||
[](https://codecov.io/gh/httpie/httpie)
|
||||
[](https://twitter.com/httpie)
|
||||
[](https://httpie.io/discord)
|
||||
<div align="center">
|
||||
|
||||
<img src="https://raw.githubusercontent.com/httpie/httpie/master/docs/httpie-animation.gif" alt="HTTPie in action" width="100%"/>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
## We lost 54k GitHub stars
|
||||
|
||||
Please note we recently accidentally made this repo private for a moment, and GitHub deleted our community that took a decade to build. Read the full story here: https://httpie.io/blog/stardust
|
||||
@ -53,25 +73,25 @@ Please note we recently accidentally made this repo private for a moment, and Gi
|
||||
Hello World:
|
||||
|
||||
```bash
|
||||
$ https httpie.io/hello
|
||||
https httpie.io/hello
|
||||
```
|
||||
|
||||
Custom [HTTP method](https://httpie.io/docs#http-method), [HTTP headers](https://httpie.io/docs#http-headers) and [JSON](https://httpie.io/docs#json) data:
|
||||
|
||||
```bash
|
||||
$ http PUT pie.dev/put X-API-Token:123 name=John
|
||||
http PUT pie.dev/put X-API-Token:123 name=John
|
||||
```
|
||||
|
||||
Build and print a request without sending it using [offline mode](https://httpie.io/docs#offline-mode):
|
||||
|
||||
```bash
|
||||
$ http --offline pie.dev/post hello=offline
|
||||
http --offline pie.dev/post hello=offline
|
||||
```
|
||||
|
||||
Use [GitHub API](https://developer.github.com/v3/issues/comments/#create-a-comment) to post a comment on an [Issue](https://github.com/httpie/httpie/issues/83) with [authentication](https://httpie.io/docs#authentication):
|
||||
|
||||
```bash
|
||||
$ http -a USERNAME POST https://api.github.com/repos/httpie/httpie/issues/83/comments body='HTTPie is awesome! :heart:'
|
||||
http -a USERNAME POST https://api.github.com/repos/httpie/httpie/issues/83/comments body='HTTPie is awesome! :heart:'
|
||||
```
|
||||
|
||||
[See more examples →](https://httpie.io/docs#examples)
|
||||
|
@ -1453,7 +1453,8 @@ $ http --proxy=http:http://user:pass@10.10.1.10:3128 example.org
|
||||
## HTTPS
|
||||
|
||||
### Server SSL certificate verification
|
||||
To skip the host’s SSL certificate verification, you can pass `--verify=no` (default is `yes`):
|
||||
|
||||
To skip the host’s SSL certificate verification, you can pass `--verify=no` (default is `yes`):
|
||||
|
||||
```bash
|
||||
$ http --verify=no https://pie.dev/get
|
||||
@ -2394,12 +2395,9 @@ fi
|
||||
You can check whether a new update is available for your system by running `httpie cli check-updates`:
|
||||
|
||||
```bash-termible
|
||||
You can check whether a new update is available for your system by running `httpie cli check-updates`:
|
||||
|
||||
```bash-termible
|
||||
$ httpie cli check-updates
|
||||
$ httpie cli check-updates
|
||||
```
|
||||
|
||||
|
||||
#### `httpie cli export-args`
|
||||
|
||||
`httpie cli export-args` command can expose the parser specification of `http`/`https` commands
|
||||
@ -2553,7 +2551,7 @@ HTTPie has the following community channels:
|
||||
|
||||
See [github.com/httpie/httpie/security/policy](https://github.com/httpie/httpie/security/policy).
|
||||
|
||||
### Change log
|
||||
### Change log
|
||||
|
||||
See [CHANGELOG](https://github.com/httpie/httpie/blob/master/CHANGELOG.md).
|
||||
|
||||
|
@ -1,3 +1,3 @@
|
||||
Here we maintain a database of contributors, from which we generate credits on release blog posts and social medias.
|
||||
Here we maintain a database of contributors, from which we generate credits on release blog posts and social media.
|
||||
|
||||
For the HTTPie blog see: <https://httpie.io/blog>.
|
||||
|
@ -55,7 +55,7 @@ def build_docs_structure(database: Database):
|
||||
tree = database[KEY_DOC_STRUCTURE]
|
||||
structure = []
|
||||
for platform, tools_ids in tree.items():
|
||||
assert platform.isalnum(), f'{platform=} must be alpha-numeric for generated links to work'
|
||||
assert platform.isalnum(), f'{platform=} must be alphanumeric for generated links to work'
|
||||
platform_tools = [tools[tool_id] for tool_id in tools_ids]
|
||||
structure.append((platform, platform_tools))
|
||||
return structure
|
||||
|
@ -10,7 +10,7 @@ Ensure the following requirements are satisfied:
|
||||
- Python 3.7+
|
||||
- `pyperf`
|
||||
|
||||
Then, run the `extras/benchmarks/run.py`:
|
||||
Then, run the `extras/profiling/run.py`:
|
||||
|
||||
```console
|
||||
$ python extras/profiling/run.py
|
||||
|
@ -9,11 +9,11 @@ timings.
|
||||
|
||||
The benchmarks are run through 'pyperf', which allows to
|
||||
do get very precise results. For micro-benchmarks like startup,
|
||||
please run `pyperf system tune` to get even more acurrate results.
|
||||
please run `pyperf system tune` to get even more accurate results.
|
||||
|
||||
Examples:
|
||||
|
||||
# Run everything as usual, the default is that we do 3 warmup runs
|
||||
# Run everything as usual, the default is that we do 3 warm-up runs
|
||||
# and 5 actual runs.
|
||||
$ python extras/profiling/benchmarks.py
|
||||
|
||||
@ -188,7 +188,7 @@ DownloadRunner('download', '`http --download :/big_file.txt` (3GB)', '3G')
|
||||
def main() -> None:
|
||||
# PyPerf will bring it's own argument parser, so configure the script.
|
||||
# The somewhat fast and also precise enough configuration is this. We run
|
||||
# benchmarks 3 times to warmup (e.g especially for download benchmark, this
|
||||
# benchmarks 3 times to warm up (e.g especially for download benchmark, this
|
||||
# is important). And then 5 actual runs where we record.
|
||||
sys.argv.extend(
|
||||
['--worker', '--loops=1', '--warmup=3', '--values=5', '--processes=2']
|
||||
|
@ -19,19 +19,19 @@ which would include additional dependencies like pyOpenSSL.
|
||||
Examples:
|
||||
|
||||
# Run everything as usual, and compare last commit with master
|
||||
$ python extras/benchmarks/run.py
|
||||
$ python extras/profiling/run.py
|
||||
|
||||
# Include complex environments
|
||||
$ python extras/benchmarks/run.py --complex
|
||||
$ python extras/profiling/run.py --complex
|
||||
|
||||
# Compare against a fresh copy
|
||||
$ python extras/benchmarks/run.py --fresh
|
||||
$ python extras/profiling/run.py --fresh
|
||||
|
||||
# Compare against a custom branch of a custom repo
|
||||
$ python extras/benchmarks/run.py --target-repo my_repo --target-branch my_branch
|
||||
$ python extras/profiling/run.py --target-repo my_repo --target-branch my_branch
|
||||
|
||||
# Debug changes made on this script (only run benchmarks once)
|
||||
$ python extras/benchmarks/run.py --debug
|
||||
$ python extras/profiling/run.py --debug
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
|
@ -9,7 +9,7 @@ from httpie.cli.options import ParserSpec
|
||||
from httpie.manager.cli import options as manager_options
|
||||
from httpie.output.ui.rich_help import OptionsHighlighter, to_usage
|
||||
from httpie.output.ui.rich_utils import render_as_string
|
||||
from httpie.utils import split
|
||||
from httpie.utils import split_iterable
|
||||
|
||||
|
||||
# Escape certain characters so they are rendered properly on
|
||||
|
@ -92,7 +92,3 @@ class MultipartRequestDataDict(MultiValueOrderedDict):
|
||||
|
||||
class RequestFilesDict(RequestDataDict):
|
||||
pass
|
||||
|
||||
|
||||
class NestedJSONArray(list):
|
||||
"""Denotes a top-level JSON array."""
|
||||
|
@ -1,404 +0,0 @@
|
||||
from enum import Enum, auto
|
||||
from typing import (
|
||||
Any,
|
||||
Iterator,
|
||||
NamedTuple,
|
||||
Optional,
|
||||
List,
|
||||
NoReturn,
|
||||
Type,
|
||||
Union,
|
||||
)
|
||||
from .dicts import NestedJSONArray
|
||||
|
||||
|
||||
EMPTY_STRING = ''
|
||||
HIGHLIGHTER = '^'
|
||||
OPEN_BRACKET = '['
|
||||
CLOSE_BRACKET = ']'
|
||||
BACKSLASH = '\\'
|
||||
|
||||
|
||||
class HTTPieSyntaxError(ValueError):
|
||||
def __init__(
|
||||
self,
|
||||
source: str,
|
||||
token: Optional['Token'],
|
||||
message: str,
|
||||
message_kind: str = 'Syntax',
|
||||
) -> None:
|
||||
self.source = source
|
||||
self.token = token
|
||||
self.message = message
|
||||
self.message_kind = message_kind
|
||||
|
||||
def __str__(self):
|
||||
lines = [f'HTTPie {self.message_kind} Error: {self.message}']
|
||||
if self.token is not None:
|
||||
lines.append(self.source)
|
||||
lines.append(
|
||||
' ' * self.token.start
|
||||
+ HIGHLIGHTER * (self.token.end - self.token.start)
|
||||
)
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
class TokenKind(Enum):
|
||||
TEXT = auto()
|
||||
NUMBER = auto()
|
||||
LEFT_BRACKET = auto()
|
||||
RIGHT_BRACKET = auto()
|
||||
|
||||
def to_name(self) -> str:
|
||||
for key, value in OPERATORS.items():
|
||||
if value is self:
|
||||
return repr(key)
|
||||
else:
|
||||
return 'a ' + self.name.lower()
|
||||
|
||||
|
||||
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):
|
||||
kind: TokenKind
|
||||
value: Union[str, int]
|
||||
start: int
|
||||
end: int
|
||||
|
||||
|
||||
def assert_cant_happen() -> NoReturn:
|
||||
raise ValueError('Unexpected value')
|
||||
|
||||
|
||||
def check_escaped_int(value: str) -> str:
|
||||
if not value.startswith(BACKSLASH):
|
||||
raise ValueError('Not an escaped int')
|
||||
|
||||
try:
|
||||
int(value[1:])
|
||||
except ValueError as exc:
|
||||
raise ValueError('Not an escaped int') from exc
|
||||
else:
|
||||
return value[1:]
|
||||
|
||||
|
||||
def tokenize(source: str) -> Iterator[Token]:
|
||||
cursor = 0
|
||||
backslashes = 0
|
||||
buffer = []
|
||||
|
||||
def send_buffer() -> Iterator[Token]:
|
||||
nonlocal backslashes
|
||||
if not buffer:
|
||||
return None
|
||||
|
||||
value = ''.join(buffer)
|
||||
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
|
||||
)
|
||||
buffer.clear()
|
||||
backslashes = 0
|
||||
|
||||
def can_advance() -> bool:
|
||||
return cursor < len(source)
|
||||
|
||||
while can_advance():
|
||||
index = source[cursor]
|
||||
if index in OPERATORS:
|
||||
yield from send_buffer()
|
||||
yield Token(OPERATORS[index], index, cursor, cursor + 1)
|
||||
elif index == BACKSLASH and can_advance():
|
||||
if source[cursor + 1] in SPECIAL_CHARS:
|
||||
backslashes += 1
|
||||
else:
|
||||
buffer.append(index)
|
||||
|
||||
buffer.append(source[cursor + 1])
|
||||
cursor += 1
|
||||
else:
|
||||
buffer.append(index)
|
||||
|
||||
cursor += 1
|
||||
|
||||
yield from send_buffer()
|
||||
|
||||
|
||||
class PathAction(Enum):
|
||||
KEY = auto()
|
||||
INDEX = auto()
|
||||
APPEND = auto()
|
||||
|
||||
# Pseudo action, used by the interpreter
|
||||
SET = auto()
|
||||
|
||||
def to_string(self) -> str:
|
||||
return self.name.lower()
|
||||
|
||||
|
||||
class Path:
|
||||
def __init__(
|
||||
self,
|
||||
kind: PathAction,
|
||||
accessor: Optional[Union[str, int]] = None,
|
||||
tokens: Optional[List[Token]] = None,
|
||||
is_root: bool = False,
|
||||
):
|
||||
self.kind = kind
|
||||
self.accessor = accessor
|
||||
self.tokens = tokens or []
|
||||
self.is_root = is_root
|
||||
|
||||
def reconstruct(self) -> str:
|
||||
if self.kind is PathAction.KEY:
|
||||
if self.is_root:
|
||||
return str(self.accessor)
|
||||
return OPEN_BRACKET + self.accessor + CLOSE_BRACKET
|
||||
elif self.kind is PathAction.INDEX:
|
||||
return OPEN_BRACKET + str(self.accessor) + CLOSE_BRACKET
|
||||
elif self.kind is PathAction.APPEND:
|
||||
return OPEN_BRACKET + CLOSE_BRACKET
|
||||
else:
|
||||
assert_cant_happen()
|
||||
|
||||
|
||||
def parse(source: str) -> Iterator[Path]:
|
||||
"""
|
||||
start: root_path path*
|
||||
root_path: (literal | index_path | append_path)
|
||||
literal: TEXT | NUMBER
|
||||
|
||||
path:
|
||||
key_path
|
||||
| index_path
|
||||
| append_path
|
||||
key_path: LEFT_BRACKET TEXT RIGHT_BRACKET
|
||||
index_path: LEFT_BRACKET NUMBER RIGHT_BRACKET
|
||||
append_path: LEFT_BRACKET RIGHT_BRACKET
|
||||
"""
|
||||
|
||||
tokens = list(tokenize(source))
|
||||
cursor = 0
|
||||
|
||||
def can_advance():
|
||||
return cursor < len(tokens)
|
||||
|
||||
def expect(*kinds):
|
||||
nonlocal cursor
|
||||
|
||||
assert len(kinds) > 0
|
||||
if can_advance():
|
||||
token = tokens[cursor]
|
||||
cursor += 1
|
||||
if token.kind in kinds:
|
||||
return token
|
||||
elif tokens:
|
||||
token = tokens[-1]._replace(
|
||||
start=tokens[-1].end + 0, end=tokens[-1].end + 1
|
||||
)
|
||||
else:
|
||||
token = None
|
||||
|
||||
if len(kinds) == 1:
|
||||
suffix = kinds[0].to_name()
|
||||
else:
|
||||
suffix = ', '.join(kind.to_name() for kind in kinds[:-1])
|
||||
suffix += ' or ' + kinds[-1].to_name()
|
||||
|
||||
message = f'Expecting {suffix}'
|
||||
raise HTTPieSyntaxError(source, token, message)
|
||||
|
||||
def parse_root():
|
||||
tokens = []
|
||||
if not can_advance():
|
||||
return Path(
|
||||
PathAction.KEY,
|
||||
EMPTY_STRING,
|
||||
is_root=True
|
||||
)
|
||||
|
||||
# (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))
|
||||
|
||||
token = expect(
|
||||
TokenKind.TEXT, TokenKind.NUMBER, TokenKind.RIGHT_BRACKET
|
||||
)
|
||||
path_tokens.append(token)
|
||||
if token.kind is TokenKind.RIGHT_BRACKET:
|
||||
path = Path(PathAction.APPEND, tokens=path_tokens)
|
||||
elif token.kind is TokenKind.TEXT:
|
||||
path = Path(PathAction.KEY, token.value, tokens=path_tokens)
|
||||
path_tokens.append(expect(TokenKind.RIGHT_BRACKET))
|
||||
elif token.kind is TokenKind.NUMBER:
|
||||
path = Path(PathAction.INDEX, token.value, tokens=path_tokens)
|
||||
path_tokens.append(expect(TokenKind.RIGHT_BRACKET))
|
||||
else:
|
||||
assert_cant_happen()
|
||||
yield path
|
||||
|
||||
|
||||
JSON_TYPE_MAPPING = {
|
||||
dict: 'object',
|
||||
list: 'array',
|
||||
int: 'number',
|
||||
float: 'number',
|
||||
str: 'string',
|
||||
}
|
||||
|
||||
|
||||
def interpret(context: Any, key: str, value: Any) -> Any:
|
||||
cursor = context
|
||||
|
||||
paths = list(parse(key))
|
||||
paths.append(Path(PathAction.SET, value))
|
||||
|
||||
def type_check(index: int, path: Path, expected_type: Type[Any]) -> None:
|
||||
if not isinstance(cursor, expected_type):
|
||||
if path.tokens:
|
||||
pseudo_token = Token(
|
||||
None, None, path.tokens[0].start, path.tokens[-1].end
|
||||
)
|
||||
else:
|
||||
pseudo_token = None
|
||||
|
||||
cursor_type = JSON_TYPE_MAPPING.get(
|
||||
type(cursor), type(cursor).__name__
|
||||
)
|
||||
required_type = JSON_TYPE_MAPPING[expected_type]
|
||||
|
||||
message = f"Can't perform {path.kind.to_string()!r} based access on "
|
||||
message += repr(
|
||||
''.join(path.reconstruct() for path in paths[:index])
|
||||
)
|
||||
message += (
|
||||
f' which has a type of {cursor_type!r} but this operation'
|
||||
)
|
||||
message += f' requires a type of {required_type!r}.'
|
||||
raise HTTPieSyntaxError(
|
||||
key, pseudo_token, message, message_kind='Type'
|
||||
)
|
||||
|
||||
def object_for(kind: str) -> Any:
|
||||
if kind is PathAction.KEY:
|
||||
return {}
|
||||
elif kind in {PathAction.INDEX, PathAction.APPEND}:
|
||||
return []
|
||||
else:
|
||||
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:
|
||||
cursor[path.accessor] = next_path.accessor
|
||||
break
|
||||
|
||||
cursor = cursor.setdefault(
|
||||
path.accessor, object_for(next_path.kind)
|
||||
)
|
||||
elif path.kind is PathAction.INDEX:
|
||||
type_check(index, path, list)
|
||||
if path.accessor < 0:
|
||||
raise HTTPieSyntaxError(
|
||||
key,
|
||||
path.tokens[1],
|
||||
'Negative indexes are not supported.',
|
||||
message_kind='Value',
|
||||
)
|
||||
cursor.extend([None] * (path.accessor - len(cursor) + 1))
|
||||
if next_path.kind is PathAction.SET:
|
||||
cursor[path.accessor] = next_path.accessor
|
||||
break
|
||||
|
||||
if cursor[path.accessor] is None:
|
||||
cursor[path.accessor] = object_for(next_path.kind)
|
||||
|
||||
cursor = cursor[path.accessor]
|
||||
elif path.kind is PathAction.APPEND:
|
||||
type_check(index, path, list)
|
||||
if next_path.kind is PathAction.SET:
|
||||
cursor.append(next_path.accessor)
|
||||
break
|
||||
|
||||
cursor.append(object_for(next_path.kind))
|
||||
cursor = cursor[-1]
|
||||
else:
|
||||
assert_cant_happen()
|
||||
|
||||
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 = None
|
||||
for key, value in pairs:
|
||||
context = interpret(context, key, value)
|
||||
|
||||
return wrap_with_dict(context)
|
20
httpie/cli/nested_json/__init__.py
Normal file
20
httpie/cli/nested_json/__init__.py
Normal file
@ -0,0 +1,20 @@
|
||||
"""
|
||||
A library for parsing the HTTPie nested JSON key syntax and constructing the resulting objects.
|
||||
|
||||
<https://httpie.io/docs/cli/nested-json>
|
||||
|
||||
It has no dependencies.
|
||||
|
||||
"""
|
||||
from .interpret import interpret_nested_json, unwrap_top_level_list_if_needed
|
||||
from .errors import NestedJSONSyntaxError
|
||||
from .tokens import EMPTY_STRING, NestedJSONArray
|
||||
|
||||
|
||||
__all__ = [
|
||||
'interpret_nested_json',
|
||||
'unwrap_top_level_list_if_needed',
|
||||
'EMPTY_STRING',
|
||||
'NestedJSONArray',
|
||||
'NestedJSONSyntaxError'
|
||||
]
|
27
httpie/cli/nested_json/errors.py
Normal file
27
httpie/cli/nested_json/errors.py
Normal file
@ -0,0 +1,27 @@
|
||||
from typing import Optional
|
||||
|
||||
from .tokens import Token, HIGHLIGHTER
|
||||
|
||||
|
||||
class NestedJSONSyntaxError(ValueError):
|
||||
def __init__(
|
||||
self,
|
||||
source: str,
|
||||
token: Optional[Token],
|
||||
message: str,
|
||||
message_kind: str = 'Syntax',
|
||||
) -> None:
|
||||
self.source = source
|
||||
self.token = token
|
||||
self.message = message
|
||||
self.message_kind = message_kind
|
||||
|
||||
def __str__(self):
|
||||
lines = [f'HTTPie {self.message_kind} Error: {self.message}']
|
||||
if self.token is not None:
|
||||
lines.append(self.source)
|
||||
lines.append(
|
||||
' ' * self.token.start
|
||||
+ HIGHLIGHTER * (self.token.end - self.token.start)
|
||||
)
|
||||
return '\n'.join(lines)
|
129
httpie/cli/nested_json/interpret.py
Normal file
129
httpie/cli/nested_json/interpret.py
Normal file
@ -0,0 +1,129 @@
|
||||
from typing import Type, Union, Any, Iterable, Tuple
|
||||
|
||||
from .parse import parse, assert_cant_happen
|
||||
from .errors import NestedJSONSyntaxError
|
||||
from .tokens import EMPTY_STRING, TokenKind, Token, PathAction, Path, NestedJSONArray
|
||||
|
||||
|
||||
__all__ = [
|
||||
'interpret_nested_json',
|
||||
'unwrap_top_level_list_if_needed',
|
||||
]
|
||||
|
||||
JSONType = Type[Union[dict, list, int, float, str]]
|
||||
JSON_TYPE_MAPPING = {
|
||||
dict: 'object',
|
||||
list: 'array',
|
||||
int: 'number',
|
||||
float: 'number',
|
||||
str: 'string',
|
||||
}
|
||||
|
||||
|
||||
def interpret_nested_json(pairs: Iterable[Tuple[str, str]]) -> dict:
|
||||
context = None
|
||||
for key, value in pairs:
|
||||
context = interpret(context, key, value)
|
||||
return wrap_with_dict(context)
|
||||
|
||||
|
||||
def interpret(context: Any, key: str, value: Any) -> Any:
|
||||
cursor = context
|
||||
paths = list(parse(key))
|
||||
paths.append(Path(PathAction.SET, value))
|
||||
|
||||
# noinspection PyShadowingNames
|
||||
def type_check(index: int, path: Path, expected_type: JSONType):
|
||||
if not isinstance(cursor, expected_type):
|
||||
if path.tokens:
|
||||
pseudo_token = Token(
|
||||
kind=TokenKind.PSEUDO,
|
||||
value='',
|
||||
start=path.tokens[0].start,
|
||||
end=path.tokens[-1].end,
|
||||
)
|
||||
else:
|
||||
pseudo_token = None
|
||||
cursor_type = JSON_TYPE_MAPPING.get(type(cursor), type(cursor).__name__)
|
||||
required_type = JSON_TYPE_MAPPING[expected_type]
|
||||
message = f'Cannot perform {path.kind.to_string()!r} based access on '
|
||||
message += repr(''.join(path.reconstruct() for path in paths[:index]))
|
||||
message += f' which has a type of {cursor_type!r} but this operation'
|
||||
message += f' requires a type of {required_type!r}.'
|
||||
raise NestedJSONSyntaxError(
|
||||
source=key,
|
||||
token=pseudo_token,
|
||||
message=message,
|
||||
message_kind='Type',
|
||||
)
|
||||
|
||||
def object_for(kind: PathAction) -> Any:
|
||||
if kind is PathAction.KEY:
|
||||
return {}
|
||||
elif kind in {PathAction.INDEX, PathAction.APPEND}:
|
||||
return []
|
||||
else:
|
||||
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:
|
||||
cursor[path.accessor] = next_path.accessor
|
||||
break
|
||||
cursor = cursor.setdefault(path.accessor, object_for(next_path.kind))
|
||||
elif path.kind is PathAction.INDEX:
|
||||
type_check(index, path, list)
|
||||
if path.accessor < 0:
|
||||
raise NestedJSONSyntaxError(
|
||||
source=key,
|
||||
token=path.tokens[1],
|
||||
message='Negative indexes are not supported.',
|
||||
message_kind='Value',
|
||||
)
|
||||
cursor.extend([None] * (path.accessor - len(cursor) + 1))
|
||||
if next_path.kind is PathAction.SET:
|
||||
cursor[path.accessor] = next_path.accessor
|
||||
break
|
||||
if cursor[path.accessor] is None:
|
||||
cursor[path.accessor] = object_for(next_path.kind)
|
||||
cursor = cursor[path.accessor]
|
||||
elif path.kind is PathAction.APPEND:
|
||||
type_check(index, path, list)
|
||||
if next_path.kind is PathAction.SET:
|
||||
cursor.append(next_path.accessor)
|
||||
break
|
||||
cursor.append(object_for(next_path.kind))
|
||||
cursor = cursor[-1]
|
||||
else:
|
||||
assert_cant_happen()
|
||||
|
||||
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 unwrap_top_level_list_if_needed(data: dict):
|
||||
"""
|
||||
Propagate the top-level list, if that’s what we got.
|
||||
|
||||
"""
|
||||
if len(data) == 1:
|
||||
key, value = list(data.items())[0]
|
||||
if isinstance(value, NestedJSONArray):
|
||||
assert key == EMPTY_STRING
|
||||
return value
|
||||
return data
|
193
httpie/cli/nested_json/parse.py
Normal file
193
httpie/cli/nested_json/parse.py
Normal file
@ -0,0 +1,193 @@
|
||||
from typing import Iterator
|
||||
|
||||
from .errors import NestedJSONSyntaxError
|
||||
from .tokens import (
|
||||
EMPTY_STRING,
|
||||
BACKSLASH,
|
||||
TokenKind,
|
||||
OPERATORS,
|
||||
SPECIAL_CHARS,
|
||||
LITERAL_TOKENS,
|
||||
Token,
|
||||
PathAction,
|
||||
Path,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'parse',
|
||||
'assert_cant_happen',
|
||||
]
|
||||
|
||||
|
||||
def parse(source: str) -> Iterator[Path]:
|
||||
"""
|
||||
start: root_path path*
|
||||
root_path: (literal | index_path | append_path)
|
||||
literal: TEXT | NUMBER
|
||||
|
||||
path:
|
||||
key_path
|
||||
| index_path
|
||||
| append_path
|
||||
key_path: LEFT_BRACKET TEXT RIGHT_BRACKET
|
||||
index_path: LEFT_BRACKET NUMBER RIGHT_BRACKET
|
||||
append_path: LEFT_BRACKET RIGHT_BRACKET
|
||||
|
||||
"""
|
||||
|
||||
tokens = list(tokenize(source))
|
||||
cursor = 0
|
||||
|
||||
def can_advance():
|
||||
return cursor < len(tokens)
|
||||
|
||||
# noinspection PyShadowingNames
|
||||
def expect(*kinds):
|
||||
nonlocal cursor
|
||||
assert kinds
|
||||
if can_advance():
|
||||
token = tokens[cursor]
|
||||
cursor += 1
|
||||
if token.kind in kinds:
|
||||
return token
|
||||
elif tokens:
|
||||
token = tokens[-1]._replace(
|
||||
start=tokens[-1].end + 0,
|
||||
end=tokens[-1].end + 1,
|
||||
)
|
||||
else:
|
||||
token = None
|
||||
if len(kinds) == 1:
|
||||
suffix = kinds[0].to_name()
|
||||
else:
|
||||
suffix = ', '.join(kind.to_name() for kind in kinds[:-1])
|
||||
suffix += ' or ' + kinds[-1].to_name()
|
||||
message = f'Expecting {suffix}'
|
||||
raise NestedJSONSyntaxError(source, token, message)
|
||||
|
||||
# noinspection PyShadowingNames
|
||||
def parse_root():
|
||||
tokens = []
|
||||
if not can_advance():
|
||||
return Path(
|
||||
kind=PathAction.KEY,
|
||||
accessor=EMPTY_STRING,
|
||||
is_root=True
|
||||
)
|
||||
# (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()
|
||||
# noinspection PyUnboundLocalVariable
|
||||
return Path(
|
||||
kind=action,
|
||||
accessor=value,
|
||||
tokens=tokens,
|
||||
is_root=True
|
||||
)
|
||||
|
||||
yield parse_root()
|
||||
|
||||
# path*
|
||||
while can_advance():
|
||||
path_tokens = [expect(TokenKind.LEFT_BRACKET)]
|
||||
token = expect(TokenKind.TEXT, TokenKind.NUMBER, TokenKind.RIGHT_BRACKET)
|
||||
path_tokens.append(token)
|
||||
if token.kind is TokenKind.RIGHT_BRACKET:
|
||||
path = Path(PathAction.APPEND, tokens=path_tokens)
|
||||
elif token.kind is TokenKind.TEXT:
|
||||
path = Path(PathAction.KEY, token.value, tokens=path_tokens)
|
||||
path_tokens.append(expect(TokenKind.RIGHT_BRACKET))
|
||||
elif token.kind is TokenKind.NUMBER:
|
||||
path = Path(PathAction.INDEX, token.value, tokens=path_tokens)
|
||||
path_tokens.append(expect(TokenKind.RIGHT_BRACKET))
|
||||
else:
|
||||
assert_cant_happen()
|
||||
# noinspection PyUnboundLocalVariable
|
||||
yield path
|
||||
|
||||
|
||||
def tokenize(source: str) -> Iterator[Token]:
|
||||
cursor = 0
|
||||
backslashes = 0
|
||||
buffer = []
|
||||
|
||||
def send_buffer() -> Iterator[Token]:
|
||||
nonlocal backslashes
|
||||
if not buffer:
|
||||
return None
|
||||
|
||||
value = ''.join(buffer)
|
||||
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=kind,
|
||||
value=value,
|
||||
start=cursor - (len(buffer) + backslashes),
|
||||
end=cursor,
|
||||
)
|
||||
buffer.clear()
|
||||
backslashes = 0
|
||||
|
||||
def can_advance() -> bool:
|
||||
return cursor < len(source)
|
||||
|
||||
while can_advance():
|
||||
index = source[cursor]
|
||||
if index in OPERATORS:
|
||||
yield from send_buffer()
|
||||
yield Token(OPERATORS[index], index, cursor, cursor + 1)
|
||||
elif index == BACKSLASH and can_advance():
|
||||
if source[cursor + 1] in SPECIAL_CHARS:
|
||||
backslashes += 1
|
||||
else:
|
||||
buffer.append(index)
|
||||
buffer.append(source[cursor + 1])
|
||||
cursor += 1
|
||||
else:
|
||||
buffer.append(index)
|
||||
cursor += 1
|
||||
|
||||
yield from send_buffer()
|
||||
|
||||
|
||||
def check_escaped_int(value: str) -> str:
|
||||
if not value.startswith(BACKSLASH):
|
||||
raise ValueError('Not an escaped int')
|
||||
try:
|
||||
int(value[1:])
|
||||
except ValueError as exc:
|
||||
raise ValueError('Not an escaped int') from exc
|
||||
else:
|
||||
return value[1:]
|
||||
|
||||
|
||||
def assert_cant_happen():
|
||||
raise ValueError('Unexpected value')
|
80
httpie/cli/nested_json/tokens.py
Normal file
80
httpie/cli/nested_json/tokens.py
Normal file
@ -0,0 +1,80 @@
|
||||
from enum import Enum, auto
|
||||
from typing import NamedTuple, Union, Optional, List
|
||||
|
||||
EMPTY_STRING = ''
|
||||
HIGHLIGHTER = '^'
|
||||
OPEN_BRACKET = '['
|
||||
CLOSE_BRACKET = ']'
|
||||
BACKSLASH = '\\'
|
||||
|
||||
|
||||
class TokenKind(Enum):
|
||||
TEXT = auto()
|
||||
NUMBER = auto()
|
||||
LEFT_BRACKET = auto()
|
||||
RIGHT_BRACKET = auto()
|
||||
PSEUDO = auto() # Not a real token, use when representing location only.
|
||||
|
||||
def to_name(self) -> str:
|
||||
for key, value in OPERATORS.items():
|
||||
if value is self:
|
||||
return repr(key)
|
||||
else:
|
||||
return 'a ' + self.name.lower()
|
||||
|
||||
|
||||
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):
|
||||
kind: TokenKind
|
||||
value: Union[str, int]
|
||||
start: int
|
||||
end: int
|
||||
|
||||
|
||||
class PathAction(Enum):
|
||||
KEY = auto()
|
||||
INDEX = auto()
|
||||
APPEND = auto()
|
||||
# Pseudo action, used by the interpreter
|
||||
SET = auto()
|
||||
|
||||
def to_string(self) -> str:
|
||||
return self.name.lower()
|
||||
|
||||
|
||||
class Path:
|
||||
def __init__(
|
||||
self,
|
||||
kind: PathAction,
|
||||
accessor: Optional[Union[str, int]] = None,
|
||||
tokens: Optional[List[Token]] = None,
|
||||
is_root: bool = False,
|
||||
):
|
||||
self.kind = kind
|
||||
self.accessor = accessor
|
||||
self.tokens = tokens or []
|
||||
self.is_root = is_root
|
||||
|
||||
def reconstruct(self) -> str:
|
||||
if self.kind is PathAction.KEY:
|
||||
if self.is_root:
|
||||
return str(self.accessor)
|
||||
return OPEN_BRACKET + self.accessor + CLOSE_BRACKET
|
||||
elif self.kind is PathAction.INDEX:
|
||||
return OPEN_BRACKET + str(self.accessor) + CLOSE_BRACKET
|
||||
elif self.kind is PathAction.APPEND:
|
||||
return OPEN_BRACKET + CLOSE_BRACKET
|
||||
|
||||
|
||||
class NestedJSONArray(list):
|
||||
"""Denotes a top-level JSON array."""
|
@ -18,7 +18,7 @@ from .dicts import (
|
||||
)
|
||||
from .exceptions import ParseError
|
||||
from .nested_json import interpret_nested_json
|
||||
from ..utils import get_content_type, load_json_preserve_order_and_dupe_keys, split
|
||||
from ..utils import get_content_type, load_json_preserve_order_and_dupe_keys, split_iterable
|
||||
|
||||
|
||||
class RequestItems:
|
||||
@ -78,25 +78,28 @@ class RequestItems:
|
||||
instance.data,
|
||||
),
|
||||
SEPARATOR_DATA_RAW_JSON: (
|
||||
json_only(instance, process_data_raw_json_embed_arg),
|
||||
convert_json_value_to_form_if_needed(
|
||||
in_json_mode=instance.is_json,
|
||||
processor=process_data_raw_json_embed_arg
|
||||
),
|
||||
instance.data,
|
||||
),
|
||||
SEPARATOR_DATA_EMBED_RAW_JSON_FILE: (
|
||||
json_only(instance, process_data_embed_raw_json_file_arg),
|
||||
convert_json_value_to_form_if_needed(
|
||||
in_json_mode=instance.is_json,
|
||||
processor=process_data_embed_raw_json_file_arg,
|
||||
),
|
||||
instance.data,
|
||||
),
|
||||
}
|
||||
|
||||
if instance.is_json:
|
||||
json_item_args, request_item_args = split(
|
||||
request_item_args,
|
||||
lambda arg: arg.sep in SEPARATOR_GROUP_NESTED_JSON_ITEMS
|
||||
json_item_args, request_item_args = split_iterable(
|
||||
iterable=request_item_args,
|
||||
key=lambda arg: arg.sep in SEPARATOR_GROUP_NESTED_JSON_ITEMS
|
||||
)
|
||||
if json_item_args:
|
||||
pairs = [
|
||||
(arg.key, rules[arg.sep][0](arg))
|
||||
for arg in json_item_args
|
||||
]
|
||||
pairs = [(arg.key, rules[arg.sep][0](arg)) for arg in json_item_args]
|
||||
processor_func, target_dict = rules[SEPARATOR_GROUP_NESTED_JSON_ITEMS]
|
||||
value = processor_func(pairs)
|
||||
target_dict.update(value)
|
||||
@ -159,6 +162,30 @@ def process_file_upload_arg(arg: KeyValueArg) -> Tuple[str, IO, str]:
|
||||
)
|
||||
|
||||
|
||||
def convert_json_value_to_form_if_needed(in_json_mode: bool, processor: Callable[[KeyValueArg], JSONType]) -> Callable[[], str]:
|
||||
"""
|
||||
We allow primitive values to be passed to forms via JSON key/value syntax.
|
||||
|
||||
But complex values lead to an error because there’s no clear way to serialize them.
|
||||
|
||||
"""
|
||||
if in_json_mode:
|
||||
return processor
|
||||
|
||||
@functools.wraps(processor)
|
||||
def wrapper(*args, **kwargs) -> str:
|
||||
try:
|
||||
output = processor(*args, **kwargs)
|
||||
except ParseError:
|
||||
output = None
|
||||
if isinstance(output, (str, int, float)):
|
||||
return str(output)
|
||||
else:
|
||||
raise ParseError('Cannot use complex JSON value types with --form/--multipart.')
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def process_data_item_arg(arg: KeyValueArg) -> str:
|
||||
return arg.value
|
||||
|
||||
@ -167,29 +194,6 @@ def process_data_embed_file_contents_arg(arg: KeyValueArg) -> str:
|
||||
return load_text_file(arg)
|
||||
|
||||
|
||||
def json_only(items: RequestItems, func: Callable[[KeyValueArg], JSONType]) -> str:
|
||||
if items.is_json:
|
||||
return func
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs) -> str:
|
||||
try:
|
||||
ret = func(*args, **kwargs)
|
||||
except ParseError:
|
||||
ret = None
|
||||
|
||||
# If it is a basic type, then allow it
|
||||
if isinstance(ret, (str, int, float)):
|
||||
return str(ret)
|
||||
else:
|
||||
raise ParseError(
|
||||
'Can\'t use complex JSON value types with '
|
||||
'--form/--multipart.'
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def process_data_embed_raw_json_file_arg(arg: KeyValueArg) -> JSONType:
|
||||
contents = load_text_file(arg)
|
||||
value = load_json(arg, contents)
|
||||
|
@ -10,12 +10,13 @@ from urllib.parse import urlparse, urlunparse
|
||||
import requests
|
||||
# noinspection PyPackageRequirements
|
||||
import urllib3
|
||||
|
||||
from . import __version__
|
||||
from .adapters import HTTPieHTTPAdapter
|
||||
from .context import Environment
|
||||
from .cli.constants import HTTP_OPTIONS
|
||||
from .cli.nested_json import EMPTY_STRING
|
||||
from .cli.dicts import HTTPHeadersDict, NestedJSONArray
|
||||
from .cli.dicts import HTTPHeadersDict
|
||||
from .cli.nested_json import unwrap_top_level_list_if_needed
|
||||
from .context import Environment
|
||||
from .encoding import UTF8
|
||||
from .models import RequestsMessage
|
||||
from .plugins.registry import plugin_manager
|
||||
@ -306,21 +307,13 @@ def make_send_kwargs_mergeable_from_env(args: argparse.Namespace) -> dict:
|
||||
|
||||
|
||||
def json_dict_to_request_body(data: Dict[str, Any]) -> str:
|
||||
# Propagate the top-level list if there is only one
|
||||
# item in the object, with an en empty key.
|
||||
if len(data) == 1:
|
||||
[(key, value)] = data.items()
|
||||
if isinstance(value, NestedJSONArray):
|
||||
assert key == EMPTY_STRING
|
||||
data = value
|
||||
|
||||
data = unwrap_top_level_list_if_needed(data)
|
||||
if data:
|
||||
data = json.dumps(data)
|
||||
else:
|
||||
# We need to set data to an empty string to prevent requests
|
||||
# from assigning an empty list to `response.request.data`.
|
||||
data = ''
|
||||
|
||||
return data
|
||||
|
||||
|
||||
|
@ -11,7 +11,7 @@ from requests import __version__ as requests_version
|
||||
|
||||
from . import __version__ as httpie_version
|
||||
from .cli.constants import OUT_REQ_BODY
|
||||
from .cli.nested_json import HTTPieSyntaxError
|
||||
from .cli.nested_json import NestedJSONSyntaxError
|
||||
from .client import collect_messages
|
||||
from .context import Environment, LogLevel
|
||||
from .downloads import Downloader
|
||||
@ -78,7 +78,7 @@ def raw_main(
|
||||
args=args,
|
||||
env=env,
|
||||
)
|
||||
except HTTPieSyntaxError as exc:
|
||||
except NestedJSONSyntaxError as exc:
|
||||
env.stderr.write(str(exc) + "\n")
|
||||
if include_traceback:
|
||||
raise
|
||||
|
@ -52,7 +52,6 @@ def program():
|
||||
try:
|
||||
exit_status = main()
|
||||
except KeyboardInterrupt:
|
||||
from httpie.status import ExitStatus
|
||||
exit_status = ExitStatus.ERROR_CTRL_C
|
||||
|
||||
return exit_status
|
||||
|
@ -43,7 +43,6 @@ def _discover_system_pip() -> List[str]:
|
||||
|
||||
|
||||
def _run_pip_subprocess(pip_executable: List[str], args: List[str]) -> bytes:
|
||||
import subprocess
|
||||
|
||||
cmd = [*pip_executable, *args]
|
||||
try:
|
||||
|
@ -16,7 +16,6 @@ from .cli.constants import (
|
||||
from .compat import cached_property
|
||||
from .utils import split_cookies, parse_content_type_header
|
||||
|
||||
|
||||
ELAPSED_TIME_LABEL = 'Elapsed time'
|
||||
|
||||
|
||||
@ -67,27 +66,10 @@ class HTTPResponse(HTTPMessage):
|
||||
def iter_lines(self, chunk_size):
|
||||
return ((line, b'\n') for line in self._orig.iter_lines(chunk_size))
|
||||
|
||||
# noinspection PyProtectedMember
|
||||
@property
|
||||
def headers(self):
|
||||
try:
|
||||
raw = self._orig.raw
|
||||
if getattr(raw, '_original_response', None):
|
||||
raw_version = raw._original_response.version
|
||||
else:
|
||||
raw_version = raw.version
|
||||
except AttributeError:
|
||||
# Assume HTTP/1.1
|
||||
raw_version = 11
|
||||
version = {
|
||||
9: '0.9',
|
||||
10: '1.0',
|
||||
11: '1.1',
|
||||
20: '2.0',
|
||||
}[raw_version]
|
||||
|
||||
original = self._orig
|
||||
status_line = f'HTTP/{version} {original.status_code} {original.reason}'
|
||||
status_line = f'HTTP/{self.version} {original.status_code} {original.reason}'
|
||||
headers = [status_line]
|
||||
headers.extend(
|
||||
': '.join(header)
|
||||
@ -117,6 +99,32 @@ class HTTPResponse(HTTPMessage):
|
||||
for key, value in data.items()
|
||||
)
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
"""
|
||||
Return the HTTP version used by the server, e.g. '1.1'.
|
||||
|
||||
Assume HTTP/1.1 if version is not available.
|
||||
|
||||
"""
|
||||
mapping = {
|
||||
9: '0.9',
|
||||
10: '1.0',
|
||||
11: '1.1',
|
||||
20: '2.0',
|
||||
}
|
||||
fallback = 11
|
||||
version = None
|
||||
try:
|
||||
raw = self._orig.raw
|
||||
if getattr(raw, '_original_response', None):
|
||||
version = raw._original_response.version
|
||||
else:
|
||||
version = raw.version
|
||||
except AttributeError:
|
||||
pass
|
||||
return mapping[version or fallback]
|
||||
|
||||
|
||||
class HTTPRequest(HTTPMessage):
|
||||
"""A :class:`requests.models.Request` wrapper."""
|
||||
|
@ -24,7 +24,7 @@ def enable_highlighter(
|
||||
console: Console,
|
||||
highlighter: Highlighter,
|
||||
) -> Iterator[Console]:
|
||||
"""Enable a higlighter temporarily."""
|
||||
"""Enable a highlighter temporarily."""
|
||||
|
||||
original_highlighter = console.highlighter
|
||||
try:
|
||||
|
@ -245,7 +245,7 @@ def get_site_paths(path: Path) -> Iterable[Path]:
|
||||
yield as_site(path)
|
||||
|
||||
|
||||
def split(iterable: Iterable[T], key: Callable[[T], bool]) -> Tuple[List[T], List[T]]:
|
||||
def split_iterable(iterable: Iterable[T], key: Callable[[T], bool]) -> Tuple[List[T], List[T]]:
|
||||
left, right = [], []
|
||||
for item in iterable:
|
||||
if key(item):
|
||||
|
@ -5,7 +5,7 @@ import responses
|
||||
|
||||
from httpie.cli.constants import PRETTY_MAP
|
||||
from httpie.cli.exceptions import ParseError
|
||||
from httpie.cli.nested_json import HTTPieSyntaxError
|
||||
from httpie.cli.nested_json import NestedJSONSyntaxError
|
||||
from httpie.output.formatters.colors import ColorFormatter
|
||||
from httpie.utils import JsonDictPreservingDuplicateKeys
|
||||
|
||||
@ -157,7 +157,7 @@ def test_complex_json_arguments_with_non_json(httpbin, request_type, value):
|
||||
f'option:={json.dumps(value)}',
|
||||
)
|
||||
|
||||
cm.match("Can't use complex JSON value types")
|
||||
cm.match('Cannot use complex JSON value types')
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@ -508,23 +508,23 @@ def test_nested_json_syntax(input_json, expected_json, httpbin):
|
||||
),
|
||||
(
|
||||
['foo=1', 'foo[key]:=2'],
|
||||
"HTTPie Type Error: Can't perform 'key' based access on 'foo' which has a type of 'string' but this operation requires a type of 'object'.\nfoo[key]\n ^^^^^",
|
||||
"HTTPie Type Error: Cannot perform 'key' based access on 'foo' which has a type of 'string' but this operation requires a type of 'object'.\nfoo[key]\n ^^^^^",
|
||||
),
|
||||
(
|
||||
['foo=1', 'foo[0]:=2'],
|
||||
"HTTPie Type Error: Can't perform 'index' based access on 'foo' which has a type of 'string' but this operation requires a type of 'array'.\nfoo[0]\n ^^^",
|
||||
"HTTPie Type Error: Cannot perform 'index' based access on 'foo' which has a type of 'string' but this operation requires a type of 'array'.\nfoo[0]\n ^^^",
|
||||
),
|
||||
(
|
||||
['foo=1', 'foo[]:=2'],
|
||||
"HTTPie Type Error: Can't perform 'append' based access on 'foo' which has a type of 'string' but this operation requires a type of 'array'.\nfoo[]\n ^^",
|
||||
"HTTPie Type Error: Cannot perform 'append' based access on 'foo' which has a type of 'string' but this operation requires a type of 'array'.\nfoo[]\n ^^",
|
||||
),
|
||||
(
|
||||
['data[key]=value', 'data[key 2]=value 2', 'data[0]=value'],
|
||||
"HTTPie Type Error: Can't perform 'index' based access on 'data' which has a type of 'object' but this operation requires a type of 'array'.\ndata[0]\n ^^^",
|
||||
"HTTPie Type Error: Cannot perform 'index' based access on 'data' which has a type of 'object' but this operation requires a type of 'array'.\ndata[0]\n ^^^",
|
||||
),
|
||||
(
|
||||
['data[key]=value', 'data[key 2]=value 2', 'data[]=value'],
|
||||
"HTTPie Type Error: Can't perform 'append' based access on 'data' which has a type of 'object' but this operation requires a type of 'array'.\ndata[]\n ^^",
|
||||
"HTTPie Type Error: Cannot perform 'append' based access on 'data' which has a type of 'object' but this operation requires a type of 'array'.\ndata[]\n ^^",
|
||||
),
|
||||
(
|
||||
[
|
||||
@ -532,7 +532,7 @@ def test_nested_json_syntax(input_json, expected_json, httpbin):
|
||||
'foo[bar][baz][5][]:=4',
|
||||
'foo[bar][baz][key][]:=5',
|
||||
],
|
||||
"HTTPie Type Error: Can't perform 'key' based access on 'foo[bar][baz]' which has a type of 'array' but this operation requires a type of 'object'.\nfoo[bar][baz][key][]\n ^^^^^",
|
||||
"HTTPie Type Error: Cannot perform 'key' based access on 'foo[bar][baz]' which has a type of 'array' but this operation requires a type of 'object'.\nfoo[bar][baz][key][]\n ^^^^^",
|
||||
),
|
||||
(
|
||||
['foo[-10]:=[1,2]'],
|
||||
@ -540,32 +540,32 @@ def test_nested_json_syntax(input_json, expected_json, httpbin):
|
||||
),
|
||||
(
|
||||
['foo[0]:=1', 'foo[]:=2', 'foo[\\2]:=3'],
|
||||
"HTTPie Type Error: Can't perform 'key' based access on 'foo' which has a type of 'array' but this operation requires a type of 'object'.\nfoo[\\2]\n ^^^^",
|
||||
"HTTPie Type Error: Cannot perform 'key' based access on 'foo' which has a type of 'array' but this operation requires a type of 'object'.\nfoo[\\2]\n ^^^^",
|
||||
),
|
||||
(
|
||||
['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 ^^^",
|
||||
"HTTPie Type Error: Cannot 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'.",
|
||||
"HTTPie Type Error: Cannot 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'.",
|
||||
"HTTPie Type Error: Cannot 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'.",
|
||||
"HTTPie Type Error: Cannot 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'.",
|
||||
"HTTPie Type Error: Cannot 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:
|
||||
with pytest.raises(NestedJSONSyntaxError) as exc:
|
||||
http(httpbin + '/post', *input_json)
|
||||
|
||||
exc_lines = str(exc.value).splitlines()
|
||||
|
Reference in New Issue
Block a user