You've already forked httpie-cli
mirror of
https://github.com/httpie/cli.git
synced 2025-08-10 22:42:05 +02:00
Implement basic metrics layout & total elapsed time (#1250)
* Initial metadata processing * Dynamic coloring and other stuff * Use -vv / --meta * More testing * Cleanup * Tweek message Co-authored-by: Jakub Roztocil <jakub@roztocil.co>
This commit is contained in:
@@ -14,6 +14,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
|
||||
- Added support for _receiving_ multiple HTTP headers lines with the same name. ([#1207](https://github.com/httpie/httpie/issues/1207))
|
||||
- Added support for basic JSON types on `--form`/`--multipart` when using JSON only operators (`:=`/`:=@`). ([#1212](https://github.com/httpie/httpie/issues/1212))
|
||||
- Added support for automatically enabling `--stream` when `Content-Type` is `text/event-stream`. ([#376](https://github.com/httpie/httpie/issues/376))
|
||||
- Added support for displaying the total elapsed time throguh `--meta`/`-vv` or `--print=m`. ([#243](https://github.com/httpie/httpie/issues/243))
|
||||
- Added new `pie-dark`/`pie-light` (and `pie`) styles that match with [HTTPie for Web and Desktop](https://httpie.io/product). ([#1237](https://github.com/httpie/httpie/issues/1237))
|
||||
- Added support for better error handling on DNS failures. ([#1248](https://github.com/httpie/httpie/issues/1248))
|
||||
- Broken plugins will no longer crash the whole application. ([#1204](https://github.com/httpie/httpie/issues/1204))
|
||||
|
@@ -1497,13 +1497,15 @@ By default, HTTPie only outputs the final response and the whole response
|
||||
Content-Length: 477
|
||||
Content-Type: application/json
|
||||
Date: Sun, 05 Aug 2012 00:25:23 GMT
|
||||
[…]
|
||||
}
|
||||
```
|
||||
|
||||
### Quiet output
|
||||
|
||||
`--quiet` redirects all output that would otherwise go to `stdout` and `stderr` to `/dev/null` (except for errors and warnings).
|
||||
Server: gunicorn/0.13.4
|
||||
|
||||
{
|
||||
[…]
|
||||
}
|
||||
```
|
||||
|
||||
#### Verbosity Level: 2
|
||||
|
||||
If you run HTTPie with `-vv` or `--verbose --verbose`, then it would also display the response metadata.
|
||||
|
||||
```bash
|
||||
@@ -1516,6 +1518,7 @@ It accepts a string of characters each of which represents a specific part of th
|
||||
`--quiet` redirects all output that would otherwise go to `stdout` and `stderr` to `/dev/null` (except for errors and warnings).
|
||||
This doesn’t affect output to a file via `--output` or `--download`.
|
||||
|
||||
```bash
|
||||
# There will be no output:
|
||||
$ http --quiet pie.dev/post enjoy='the silence'
|
||||
```
|
||||
@@ -1552,6 +1555,15 @@ Server: gunicorn/0.13.4
|
||||
As an optimization, the response body is downloaded from the server only if it’s part of the output.
|
||||
This is similar to performing a `HEAD` request, except that it applies to any HTTP method you use.
|
||||
|
||||
Let’s say that there is an API that returns the whole resource when it is updated, but you are only interested in the response headers to see the status code after an update:
|
||||
|
||||
```bash
|
||||
$ http --headers PATCH pie.dev/patch name='New Name'
|
||||
```
|
||||
|
||||
Since you are only printing the HTTP headers here, the connection to the server is closed as soon as all the response headers have been received.
|
||||
Therefore, bandwidth and time isn’t wasted downloading the body which you don’t care about.
|
||||
The response headers are downloaded always, even if they are not part of the output
|
||||
|
||||
## Raw request body
|
||||
|
||||
|
@@ -15,7 +15,7 @@ from .argtypes import (
|
||||
parse_format_options,
|
||||
)
|
||||
from .constants import (
|
||||
HTTP_GET, HTTP_POST, OUTPUT_OPTIONS, OUTPUT_OPTIONS_DEFAULT,
|
||||
HTTP_GET, HTTP_POST, BASE_OUTPUT_OPTIONS, OUTPUT_OPTIONS, OUTPUT_OPTIONS_DEFAULT,
|
||||
OUTPUT_OPTIONS_DEFAULT_OFFLINE, OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED,
|
||||
OUT_RESP_BODY, PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY, RequestType,
|
||||
SEPARATOR_CREDENTIALS,
|
||||
@@ -456,8 +456,10 @@ class HTTPieArgumentParser(BaseHTTPieArgumentParser):
|
||||
self.args.all = True
|
||||
|
||||
if self.args.output_options is None:
|
||||
if self.args.verbose:
|
||||
if self.args.verbose >= 2:
|
||||
self.args.output_options = ''.join(OUTPUT_OPTIONS)
|
||||
elif self.args.verbose == 1:
|
||||
self.args.output_options = ''.join(BASE_OUTPUT_OPTIONS)
|
||||
elif self.args.offline:
|
||||
self.args.output_options = OUTPUT_OPTIONS_DEFAULT_OFFLINE
|
||||
elif not self.env.stdout_isatty:
|
||||
|
@@ -73,12 +73,18 @@ OUT_REQ_HEAD = 'H'
|
||||
OUT_REQ_BODY = 'B'
|
||||
OUT_RESP_HEAD = 'h'
|
||||
OUT_RESP_BODY = 'b'
|
||||
OUT_RESP_META = 'm'
|
||||
|
||||
OUTPUT_OPTIONS = frozenset({
|
||||
BASE_OUTPUT_OPTIONS = frozenset({
|
||||
OUT_REQ_HEAD,
|
||||
OUT_REQ_BODY,
|
||||
OUT_RESP_HEAD,
|
||||
OUT_RESP_BODY
|
||||
OUT_RESP_BODY,
|
||||
})
|
||||
|
||||
OUTPUT_OPTIONS = frozenset({
|
||||
*BASE_OUTPUT_OPTIONS,
|
||||
OUT_RESP_META,
|
||||
})
|
||||
|
||||
# Pretty
|
||||
|
@@ -14,7 +14,7 @@ from .argtypes import (
|
||||
from .constants import (
|
||||
DEFAULT_FORMAT_OPTIONS, OUTPUT_OPTIONS,
|
||||
OUTPUT_OPTIONS_DEFAULT, OUT_REQ_BODY, OUT_REQ_HEAD,
|
||||
OUT_RESP_BODY, OUT_RESP_HEAD, PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY,
|
||||
OUT_RESP_BODY, OUT_RESP_HEAD, OUT_RESP_META, PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY,
|
||||
RequestType, SEPARATOR_GROUP_ALL_ITEMS, SEPARATOR_PROXY,
|
||||
SORTED_FORMAT_OPTIONS_STRING,
|
||||
UNSORTED_FORMAT_OPTIONS_STRING,
|
||||
@@ -401,6 +401,16 @@ output_options.add_argument(
|
||||
|
||||
'''
|
||||
)
|
||||
output_options.add_argument(
|
||||
'--meta', '-m',
|
||||
dest='output_options',
|
||||
action='store_const',
|
||||
const=OUT_RESP_META,
|
||||
help=f'''
|
||||
Print only the response metadata. Shortcut for --print={OUT_RESP_META}.
|
||||
|
||||
'''
|
||||
)
|
||||
output_options.add_argument(
|
||||
'--body', '-b',
|
||||
dest='output_options',
|
||||
@@ -415,7 +425,8 @@ output_options.add_argument(
|
||||
output_options.add_argument(
|
||||
'--verbose', '-v',
|
||||
dest='verbose',
|
||||
action='store_true',
|
||||
action='count',
|
||||
default=0,
|
||||
help=f'''
|
||||
Verbose output. Print the whole request as well as the response. Also print
|
||||
any intermediary requests/responses (such as redirects).
|
||||
|
@@ -3,21 +3,20 @@ import os
|
||||
import platform
|
||||
import sys
|
||||
import socket
|
||||
from typing import List, Optional, Tuple, Union, Callable
|
||||
from typing import List, Optional, Union, Callable
|
||||
|
||||
import requests
|
||||
from pygments import __version__ as pygments_version
|
||||
from requests import __version__ as requests_version
|
||||
|
||||
from . import __version__ as httpie_version
|
||||
from .cli.constants import OUT_REQ_BODY, OUT_REQ_HEAD, OUT_RESP_BODY, OUT_RESP_HEAD
|
||||
from .cli.constants import OUT_REQ_BODY
|
||||
from .client import collect_messages
|
||||
from .context import Environment
|
||||
from .downloads import Downloader
|
||||
from .models import (
|
||||
RequestsMessage,
|
||||
RequestsMessageKind,
|
||||
infer_requests_message_kind
|
||||
OutputOptions,
|
||||
)
|
||||
from .output.writer import write_message, write_stream, MESSAGE_SEPARATOR_BYTES
|
||||
from .plugins.registry import plugin_manager
|
||||
@@ -112,9 +111,9 @@ def raw_main(
|
||||
original_exc = unwrap_context(exc)
|
||||
if isinstance(original_exc, socket.gaierror):
|
||||
if original_exc.errno == socket.EAI_AGAIN:
|
||||
annotation = '\nCouldn\'t connect to a DNS server. Perhaps check your connection and try again.'
|
||||
annotation = '\nCouldn’t connect to a DNS server. Please check your connection and try again.'
|
||||
elif original_exc.errno == socket.EAI_NONAME:
|
||||
annotation = '\nCouldn\'t resolve the given hostname. Perhaps check it and try again.'
|
||||
annotation = '\nCouldn’t resolve the given hostname. Please check the URL and try again.'
|
||||
propagated_exc = original_exc
|
||||
else:
|
||||
propagated_exc = exc
|
||||
@@ -153,22 +152,6 @@ def main(
|
||||
)
|
||||
|
||||
|
||||
def get_output_options(
|
||||
args: argparse.Namespace,
|
||||
message: RequestsMessage
|
||||
) -> Tuple[bool, bool]:
|
||||
return {
|
||||
RequestsMessageKind.REQUEST: (
|
||||
OUT_REQ_HEAD in args.output_options,
|
||||
OUT_REQ_BODY in args.output_options,
|
||||
),
|
||||
RequestsMessageKind.RESPONSE: (
|
||||
OUT_RESP_HEAD in args.output_options,
|
||||
OUT_RESP_BODY in args.output_options,
|
||||
),
|
||||
}[infer_requests_message_kind(message)]
|
||||
|
||||
|
||||
def program(args: argparse.Namespace, env: Environment) -> ExitStatus:
|
||||
"""
|
||||
The main program without error handling.
|
||||
@@ -197,7 +180,8 @@ def program(args: argparse.Namespace, env: Environment) -> ExitStatus:
|
||||
msg.is_body_upload_chunk = True
|
||||
msg.body = chunk
|
||||
msg.headers = initial_request.headers
|
||||
write_message(requests_message=msg, env=env, args=args, with_body=True, with_headers=False)
|
||||
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)
|
||||
|
||||
try:
|
||||
if args.download:
|
||||
@@ -211,17 +195,17 @@ def program(args: argparse.Namespace, env: Environment) -> ExitStatus:
|
||||
|
||||
# Process messages as they’re generated
|
||||
for message in messages:
|
||||
is_request = isinstance(message, requests.PreparedRequest)
|
||||
with_headers, with_body = get_output_options(args=args, message=message)
|
||||
do_write_body = with_body
|
||||
if prev_with_body and (with_headers or with_body) and (force_separator or not env.stdout_isatty):
|
||||
output_options = OutputOptions.from_message(message, args.output_options)
|
||||
|
||||
do_write_body = output_options.body
|
||||
if prev_with_body and output_options.any() and (force_separator or not env.stdout_isatty):
|
||||
# Separate after a previous message with body, if needed. See test_tokens.py.
|
||||
separate()
|
||||
force_separator = False
|
||||
if is_request:
|
||||
if output_options.kind is RequestsMessageKind.REQUEST:
|
||||
if not initial_request:
|
||||
initial_request = message
|
||||
if with_body:
|
||||
if output_options.body:
|
||||
is_streamed_upload = not isinstance(message.body, (str, bytes))
|
||||
do_write_body = not is_streamed_upload
|
||||
force_separator = is_streamed_upload and env.stdout_isatty
|
||||
@@ -231,9 +215,10 @@ def program(args: argparse.Namespace, env: Environment) -> ExitStatus:
|
||||
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, with_headers=with_headers,
|
||||
with_body=do_write_body)
|
||||
prev_with_body = with_body
|
||||
write_message(requests_message=message, env=env, args=args, output_options=output_options._replace(
|
||||
body=do_write_body
|
||||
))
|
||||
prev_with_body = output_options.body
|
||||
|
||||
# Cleanup
|
||||
if force_separator:
|
||||
|
@@ -14,7 +14,7 @@ from urllib.parse import urlsplit
|
||||
|
||||
import requests
|
||||
|
||||
from .models import HTTPResponse
|
||||
from .models import HTTPResponse, OutputOptions
|
||||
from .output.streams import RawStream
|
||||
from .utils import humanize_bytes
|
||||
|
||||
@@ -266,10 +266,10 @@ class Downloader:
|
||||
total_size=total_size
|
||||
)
|
||||
|
||||
output_options = OutputOptions.from_message(final_response, headers=False, body=True)
|
||||
stream = RawStream(
|
||||
msg=HTTPResponse(final_response),
|
||||
with_headers=False,
|
||||
with_body=True,
|
||||
output_options=output_options,
|
||||
on_body_chunk_downloaded=self.chunk_downloaded,
|
||||
)
|
||||
|
||||
|
@@ -1,11 +1,18 @@
|
||||
import requests
|
||||
|
||||
from enum import Enum, auto
|
||||
from typing import Iterable, Union
|
||||
from typing import Iterable, Union, NamedTuple
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .utils import split_cookies, parse_content_type_header
|
||||
from .cli.constants import (
|
||||
OUT_REQ_BODY,
|
||||
OUT_REQ_HEAD,
|
||||
OUT_RESP_BODY,
|
||||
OUT_RESP_HEAD,
|
||||
OUT_RESP_META
|
||||
)
|
||||
from .compat import cached_property
|
||||
from .utils import split_cookies, parse_content_type_header
|
||||
|
||||
|
||||
class HTTPMessage:
|
||||
@@ -27,6 +34,11 @@ class HTTPMessage:
|
||||
"""Return a `str` with the message's headers."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def metadata(self) -> str:
|
||||
"""Return metadata about the current message."""
|
||||
raise NotImplementedError
|
||||
|
||||
@cached_property
|
||||
def encoding(self) -> str:
|
||||
ct, params = parse_content_type_header(self.content_type)
|
||||
@@ -81,6 +93,15 @@ class HTTPResponse(HTTPMessage):
|
||||
)
|
||||
return '\r\n'.join(headers)
|
||||
|
||||
@property
|
||||
def metadata(self) -> str:
|
||||
data = {}
|
||||
data['Elapsed time'] = str(self._orig.elapsed.total_seconds()) + 's'
|
||||
return '\n'.join(
|
||||
f'{key}: {value}'
|
||||
for key, value in data.items()
|
||||
)
|
||||
|
||||
|
||||
class HTTPRequest(HTTPMessage):
|
||||
"""A :class:`requests.models.Request` wrapper."""
|
||||
@@ -138,3 +159,50 @@ def infer_requests_message_kind(message: RequestsMessage) -> RequestsMessageKind
|
||||
return RequestsMessageKind.RESPONSE
|
||||
else:
|
||||
raise TypeError(f"Unexpected message type: {type(message).__name__}")
|
||||
|
||||
|
||||
OPTION_TO_PARAM = {
|
||||
RequestsMessageKind.REQUEST: {
|
||||
'headers': OUT_REQ_HEAD,
|
||||
'body': OUT_REQ_BODY,
|
||||
},
|
||||
RequestsMessageKind.RESPONSE: {
|
||||
'headers': OUT_RESP_HEAD,
|
||||
'body': OUT_RESP_BODY,
|
||||
'meta': OUT_RESP_META
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class OutputOptions(NamedTuple):
|
||||
kind: RequestsMessageKind
|
||||
headers: bool
|
||||
body: bool
|
||||
meta: bool = False
|
||||
|
||||
def any(self):
|
||||
return (
|
||||
self.headers
|
||||
or self.body
|
||||
or self.meta
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_message(
|
||||
cls,
|
||||
message: RequestsMessage,
|
||||
raw_args: str = '',
|
||||
**kwargs
|
||||
):
|
||||
kind = infer_requests_message_kind(message)
|
||||
|
||||
options = {
|
||||
option: param in raw_args
|
||||
for option, param in OPTION_TO_PARAM[kind].items()
|
||||
}
|
||||
options.update(kwargs)
|
||||
|
||||
return cls(
|
||||
kind=kind,
|
||||
**options
|
||||
)
|
||||
|
@@ -16,6 +16,7 @@ from pygments.lexers.text import HttpLexer as PygmentsHttpLexer
|
||||
from pygments.util import ClassNotFound
|
||||
|
||||
from ..lexers.json import EnhancedJsonLexer
|
||||
from ..lexers.metadata import MetadataLexer
|
||||
from ..ui.palette import SHADE_NAMES, get_color
|
||||
from ...compat import is_windows
|
||||
from ...context import Environment
|
||||
@@ -50,6 +51,7 @@ class ColorFormatter(FormatterPlugin):
|
||||
|
||||
"""
|
||||
group_name = 'colors'
|
||||
metadata_lexer = MetadataLexer()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -68,9 +70,8 @@ class ColorFormatter(FormatterPlugin):
|
||||
has_256_colors = env.colors == 256
|
||||
if use_auto_style or not has_256_colors:
|
||||
http_lexer = PygmentsHttpLexer()
|
||||
formatter = TerminalFormatter()
|
||||
body_formatter = formatter
|
||||
header_formatter = formatter
|
||||
body_formatter = header_formatter = TerminalFormatter()
|
||||
precise = False
|
||||
else:
|
||||
from ..lexers.http import SimplifiedHTTPLexer
|
||||
header_formatter, body_formatter, precise = self.get_formatters(color_scheme)
|
||||
@@ -80,6 +81,7 @@ class ColorFormatter(FormatterPlugin):
|
||||
self.header_formatter = header_formatter
|
||||
self.body_formatter = body_formatter
|
||||
self.http_lexer = http_lexer
|
||||
self.metadata_lexer = MetadataLexer(precise=precise)
|
||||
|
||||
def format_headers(self, headers: str) -> str:
|
||||
return pygments.highlight(
|
||||
@@ -98,6 +100,13 @@ class ColorFormatter(FormatterPlugin):
|
||||
)
|
||||
return body
|
||||
|
||||
def format_metadata(self, metadata: str) -> str:
|
||||
return pygments.highlight(
|
||||
code=metadata,
|
||||
lexer=self.metadata_lexer,
|
||||
formatter=self.header_formatter,
|
||||
).strip()
|
||||
|
||||
def get_lexer_for_body(
|
||||
self, mime: str,
|
||||
body: str
|
||||
@@ -288,6 +297,13 @@ PIE_HEADER_STYLE = {
|
||||
pygments.token.Number.HTTP.REDIRECT: 'bold yellow',
|
||||
pygments.token.Number.HTTP.CLIENT_ERR: 'bold orange',
|
||||
pygments.token.Number.HTTP.SERVER_ERR: 'bold red',
|
||||
|
||||
# Metadata
|
||||
pygments.token.Name.Decorator: 'grey',
|
||||
pygments.token.Number.SPEED.FAST: 'bold green',
|
||||
pygments.token.Number.SPEED.AVG: 'bold yellow',
|
||||
pygments.token.Number.SPEED.SLOW: 'bold orange',
|
||||
pygments.token.Number.SPEED.VERY_SLOW: 'bold red',
|
||||
}
|
||||
|
||||
PIE_BODY_STYLE = {
|
||||
|
12
httpie/output/lexers/common.py
Normal file
12
httpie/output/lexers/common.py
Normal file
@@ -0,0 +1,12 @@
|
||||
def precise(lexer, precise_token, parent_token):
|
||||
# Due to a pygments bug*, custom tokens will look bad
|
||||
# on outside styles. Until it is fixed on upstream, we'll
|
||||
# convey whether the client is using pie style or not
|
||||
# through precise option and return more precise tokens
|
||||
# depending on it's value.
|
||||
#
|
||||
# [0]: https://github.com/pygments/pygments/issues/1986
|
||||
if precise_token is None or not lexer.options.get("precise"):
|
||||
return parent_token
|
||||
else:
|
||||
return precise_token
|
@@ -1,6 +1,6 @@
|
||||
import re
|
||||
import pygments
|
||||
|
||||
from httpie.output.lexers.common import precise
|
||||
|
||||
RE_STATUS_LINE = re.compile(r'(\d{3})( +)(.+)')
|
||||
|
||||
@@ -22,20 +22,6 @@ RESPONSE_TYPES = {
|
||||
}
|
||||
|
||||
|
||||
def precise(lexer, precise_token, parent_token):
|
||||
# Due to a pygments bug*, custom tokens will look bad
|
||||
# on outside styles. Until it is fixed on upstream, we'll
|
||||
# convey whether the client is using pie style or not
|
||||
# through precise option and return more precise tokens
|
||||
# depending on it's value.
|
||||
#
|
||||
# [0]: https://github.com/pygments/pygments/issues/1986
|
||||
if precise_token is None or not lexer.options.get("precise"):
|
||||
return parent_token
|
||||
else:
|
||||
return precise_token
|
||||
|
||||
|
||||
def http_response_type(lexer, match, ctx):
|
||||
status_match = RE_STATUS_LINE.match(match.group())
|
||||
if status_match is None:
|
||||
|
57
httpie/output/lexers/metadata.py
Normal file
57
httpie/output/lexers/metadata.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import pygments
|
||||
from httpie.output.lexers.common import precise
|
||||
|
||||
SPEED_TOKENS = {
|
||||
0.45: pygments.token.Number.SPEED.FAST,
|
||||
1.00: pygments.token.Number.SPEED.AVG,
|
||||
2.50: pygments.token.Number.SPEED.SLOW,
|
||||
}
|
||||
|
||||
|
||||
def speed_based_token(lexer, match, ctx):
|
||||
try:
|
||||
value = float(match.group())
|
||||
except ValueError:
|
||||
return pygments.token.Number
|
||||
|
||||
for limit, token in SPEED_TOKENS.items():
|
||||
if value <= limit:
|
||||
break
|
||||
else:
|
||||
token = pygments.token.Number.SPEED.VERY_SLOW
|
||||
|
||||
response_type = precise(
|
||||
lexer,
|
||||
token,
|
||||
pygments.token.Number
|
||||
)
|
||||
yield match.start(), response_type, match.group()
|
||||
|
||||
|
||||
class MetadataLexer(pygments.lexer.RegexLexer):
|
||||
"""Simple HTTPie metadata lexer."""
|
||||
|
||||
tokens = {
|
||||
'root': [
|
||||
(
|
||||
r'(Elapsed time)( *)(:)( *)(\d+\.\d+)(s)', pygments.lexer.bygroups(
|
||||
pygments.token.Name.Decorator, # Name
|
||||
pygments.token.Text,
|
||||
pygments.token.Operator, # Colon
|
||||
pygments.token.Text,
|
||||
speed_based_token,
|
||||
pygments.token.Name.Builtin # Value
|
||||
)
|
||||
),
|
||||
# Generic item
|
||||
(
|
||||
r'(.*?)( *)(:)( *)(.+)', pygments.lexer.bygroups(
|
||||
pygments.token.Name.Decorator, # Name
|
||||
pygments.token.Text,
|
||||
pygments.token.Operator, # Colon
|
||||
pygments.token.Text,
|
||||
pygments.token.Text # Value
|
||||
)
|
||||
),
|
||||
]
|
||||
}
|
@@ -51,3 +51,8 @@ class Formatting:
|
||||
for p in self.enabled_plugins:
|
||||
content = p.format_body(content, mime)
|
||||
return content
|
||||
|
||||
def format_metadata(self, metadata: str) -> str:
|
||||
for p in self.enabled_plugins:
|
||||
metadata = p.format_metadata(metadata)
|
||||
return metadata
|
||||
|
@@ -5,7 +5,7 @@ from typing import Callable, Iterable, Optional, Union
|
||||
from .processing import Conversion, Formatting
|
||||
from ..context import Environment
|
||||
from ..encoding import smart_decode, smart_encode, UTF8
|
||||
from ..models import HTTPMessage
|
||||
from ..models import HTTPMessage, OutputOptions
|
||||
from ..utils import parse_content_type_header
|
||||
|
||||
|
||||
@@ -33,47 +33,58 @@ class BaseStream(metaclass=ABCMeta):
|
||||
def __init__(
|
||||
self,
|
||||
msg: HTTPMessage,
|
||||
with_headers=True,
|
||||
with_body=True,
|
||||
output_options: OutputOptions,
|
||||
on_body_chunk_downloaded: Callable[[bytes], None] = None
|
||||
):
|
||||
"""
|
||||
:param msg: a :class:`models.HTTPMessage` subclass
|
||||
:param with_headers: if `True`, headers will be included
|
||||
:param with_body: if `True`, body will be included
|
||||
|
||||
:param output_options: a :class:`OutputOptions` instance to represent
|
||||
which parts of the message is printed.
|
||||
"""
|
||||
assert with_headers or with_body
|
||||
assert output_options.any()
|
||||
self.msg = msg
|
||||
self.with_headers = with_headers
|
||||
self.with_body = with_body
|
||||
self.output_options = output_options
|
||||
self.on_body_chunk_downloaded = on_body_chunk_downloaded
|
||||
|
||||
def get_headers(self) -> bytes:
|
||||
"""Return the headers' bytes."""
|
||||
return self.msg.headers.encode()
|
||||
|
||||
def get_metadata(self) -> bytes:
|
||||
"""Return the message metadata."""
|
||||
return self.msg.metadata.encode()
|
||||
|
||||
@abstractmethod
|
||||
def iter_body(self) -> Iterable[bytes]:
|
||||
"""Return an iterator over the message body."""
|
||||
|
||||
def __iter__(self) -> Iterable[bytes]:
|
||||
"""Return an iterator over `self.msg`."""
|
||||
if self.with_headers:
|
||||
if self.output_options.headers:
|
||||
yield self.get_headers()
|
||||
yield b'\r\n\r\n'
|
||||
|
||||
if self.with_body:
|
||||
if self.output_options.body:
|
||||
try:
|
||||
for chunk in self.iter_body():
|
||||
yield chunk
|
||||
if self.on_body_chunk_downloaded:
|
||||
self.on_body_chunk_downloaded(chunk)
|
||||
except DataSuppressedError as e:
|
||||
if self.with_headers:
|
||||
if self.output_options.headers:
|
||||
yield b'\n'
|
||||
yield e.message
|
||||
|
||||
if self.output_options.meta:
|
||||
mixed = self.output_options.headers or self.output_options.body
|
||||
|
||||
if mixed:
|
||||
yield b'\n\n'
|
||||
|
||||
yield self.get_metadata()
|
||||
if not mixed:
|
||||
yield b'\n'
|
||||
|
||||
|
||||
class RawStream(BaseStream):
|
||||
"""The message is streamed in chunks with no processing."""
|
||||
@@ -181,6 +192,10 @@ class PrettyStream(EncodedStream):
|
||||
return self.formatting.format_headers(
|
||||
self.msg.headers).encode(self.output_encoding)
|
||||
|
||||
def get_metadata(self) -> bytes:
|
||||
return self.formatting.format_metadata(
|
||||
self.msg.metadata).encode(self.output_encoding)
|
||||
|
||||
def iter_body(self) -> Iterable[bytes]:
|
||||
first_chunk = True
|
||||
iter_lines = self.msg.iter_lines(self.CHUNK_SIZE)
|
||||
|
@@ -10,7 +10,7 @@ from ..models import (
|
||||
HTTPMessage,
|
||||
RequestsMessage,
|
||||
RequestsMessageKind,
|
||||
infer_requests_message_kind
|
||||
OutputOptions
|
||||
)
|
||||
from .processing import Conversion, Formatting
|
||||
from .streams import (
|
||||
@@ -26,18 +26,16 @@ def write_message(
|
||||
requests_message: RequestsMessage,
|
||||
env: Environment,
|
||||
args: argparse.Namespace,
|
||||
with_headers=False,
|
||||
with_body=False,
|
||||
output_options: OutputOptions,
|
||||
):
|
||||
if not (with_body or with_headers):
|
||||
if not output_options.any():
|
||||
return
|
||||
write_stream_kwargs = {
|
||||
'stream': build_output_stream_for_message(
|
||||
args=args,
|
||||
env=env,
|
||||
requests_message=requests_message,
|
||||
with_body=with_body,
|
||||
with_headers=with_headers,
|
||||
output_options=output_options,
|
||||
),
|
||||
# NOTE: `env.stdout` will in fact be `stderr` with `--download`
|
||||
'outfile': env.stdout,
|
||||
@@ -100,13 +98,12 @@ def build_output_stream_for_message(
|
||||
args: argparse.Namespace,
|
||||
env: Environment,
|
||||
requests_message: RequestsMessage,
|
||||
with_headers: bool,
|
||||
with_body: bool,
|
||||
output_options: OutputOptions,
|
||||
):
|
||||
message_type = {
|
||||
RequestsMessageKind.REQUEST: HTTPRequest,
|
||||
RequestsMessageKind.RESPONSE: HTTPResponse,
|
||||
}[infer_requests_message_kind(requests_message)]
|
||||
}[output_options.kind]
|
||||
stream_class, stream_kwargs = get_stream_type_and_kwargs(
|
||||
env=env,
|
||||
args=args,
|
||||
@@ -115,11 +112,10 @@ def build_output_stream_for_message(
|
||||
)
|
||||
yield from stream_class(
|
||||
msg=message_type(requests_message),
|
||||
with_headers=with_headers,
|
||||
with_body=with_body,
|
||||
output_options=output_options,
|
||||
**stream_kwargs,
|
||||
)
|
||||
if (env.stdout_isatty and with_body
|
||||
if (env.stdout_isatty and output_options.body
|
||||
and not getattr(requests_message, 'is_body_upload_chunk', False)):
|
||||
# Ensure a blank line after the response body.
|
||||
# For terminal output only.
|
||||
|
@@ -155,3 +155,11 @@ class FormatterPlugin(BasePlugin):
|
||||
|
||||
"""
|
||||
return content
|
||||
|
||||
def format_metadata(self, metadata: str) -> str:
|
||||
"""Return processed `metadata`.
|
||||
|
||||
:param metadata: The metadata as text.
|
||||
|
||||
"""
|
||||
return metadata
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import requests
|
||||
from unittest import mock
|
||||
from urllib.request import urlopen
|
||||
|
||||
@@ -14,7 +15,7 @@ from httpie.downloads import (
|
||||
from .utils import http, MockEnvironment
|
||||
|
||||
|
||||
class Response:
|
||||
class Response(requests.Response):
|
||||
# noinspection PyDefaultArgument
|
||||
def __init__(self, url, headers={}, status_code=200):
|
||||
self.url = url
|
||||
|
7
tests/test_meta.py
Normal file
7
tests/test_meta.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .utils import http
|
||||
|
||||
|
||||
def test_meta_elapsed_time(httpbin, monkeypatch):
|
||||
r = http('--meta', httpbin + '/get')
|
||||
for line in r.splitlines():
|
||||
assert 'Elapsed time' in r
|
@@ -17,7 +17,7 @@ from httpie.cli.argtypes import (
|
||||
)
|
||||
from httpie.cli.definition import parser
|
||||
from httpie.encoding import UTF8
|
||||
from httpie.output.formatters.colors import get_lexer
|
||||
from httpie.output.formatters.colors import PIE_STYLES, get_lexer
|
||||
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
|
||||
@@ -227,6 +227,13 @@ def test_ensure_contents_colored(httpbin, endpoint):
|
||||
assert COLOR in r
|
||||
|
||||
|
||||
@pytest.mark.parametrize('style', PIE_STYLES.keys())
|
||||
def test_ensure_meta_is_colored(httpbin, style):
|
||||
env = MockEnvironment(colors=256)
|
||||
r = http('--meta', '--style', style, 'GET', httpbin + '/get', env=env)
|
||||
assert COLOR in r
|
||||
|
||||
|
||||
class TestPrettyOptions:
|
||||
"""Test the --pretty handling."""
|
||||
|
||||
|
@@ -101,3 +101,18 @@ def test_verbose_chunked(httpbin_with_chunked_support):
|
||||
def test_request_headers_response_body(httpbin):
|
||||
r = http('--print=Hb', httpbin + '/get')
|
||||
assert_output_matches(r, ExpectSequence.TERMINAL_REQUEST)
|
||||
|
||||
|
||||
def test_request_single_verbose(httpbin):
|
||||
r = http('-v', httpbin + '/post', 'hello=world')
|
||||
assert_output_matches(r, ExpectSequence.TERMINAL_EXCHANGE)
|
||||
|
||||
|
||||
def test_request_double_verbose(httpbin):
|
||||
r = http('-vv', httpbin + '/post', 'hello=world')
|
||||
assert_output_matches(r, ExpectSequence.TERMINAL_EXCHANGE_META)
|
||||
|
||||
|
||||
def test_request_meta(httpbin):
|
||||
r = http('--meta', httpbin + '/get')
|
||||
assert_output_matches(r, [Expect.RESPONSE_META])
|
||||
|
@@ -7,6 +7,7 @@ from ...utils import CRLF
|
||||
|
||||
|
||||
SEPARATOR_RE = re.compile(f'^{MESSAGE_SEPARATOR}')
|
||||
KEY_VALUE_RE = re.compile(r'[\n]*((.*?):(.+)[\n]?)+[\n]*')
|
||||
|
||||
|
||||
def make_headers_re(message_type: Expect):
|
||||
@@ -43,6 +44,7 @@ BODY_ENDINGS = [
|
||||
TOKEN_REGEX_MAP = {
|
||||
Expect.REQUEST_HEADERS: make_headers_re(Expect.REQUEST_HEADERS),
|
||||
Expect.RESPONSE_HEADERS: make_headers_re(Expect.RESPONSE_HEADERS),
|
||||
Expect.RESPONSE_META: KEY_VALUE_RE,
|
||||
Expect.SEPARATOR: SEPARATOR_RE,
|
||||
}
|
||||
|
||||
|
@@ -107,6 +107,29 @@ def test_assert_output_matches_headers_with_body_and_separator():
|
||||
)
|
||||
|
||||
|
||||
def test_assert_output_matches_response_meta():
|
||||
assert_output_matches(
|
||||
(
|
||||
'Key: Value\n'
|
||||
'Elapsed Time: 3.3s'
|
||||
),
|
||||
[Expect.RESPONSE_META]
|
||||
)
|
||||
|
||||
|
||||
def test_assert_output_matches_whole_response():
|
||||
assert_output_matches(
|
||||
(
|
||||
f'HTTP/1.1{CRLF}'
|
||||
f'AAA:BBB{CRLF}'
|
||||
f'{CRLF}'
|
||||
f'CCC{MESSAGE_SEPARATOR}'
|
||||
'Elapsed Time: 3.3s'
|
||||
),
|
||||
[Expect.RESPONSE_HEADERS, Expect.BODY, Expect.RESPONSE_META]
|
||||
)
|
||||
|
||||
|
||||
def test_assert_output_matches_multiple_messages():
|
||||
assert_output_matches(
|
||||
(
|
||||
|
@@ -8,6 +8,7 @@ class Expect(Enum):
|
||||
"""
|
||||
REQUEST_HEADERS = auto()
|
||||
RESPONSE_HEADERS = auto()
|
||||
RESPONSE_META = auto()
|
||||
BODY = auto()
|
||||
SEPARATOR = auto()
|
||||
|
||||
@@ -45,6 +46,10 @@ class ExpectSequence:
|
||||
*TERMINAL_REQUEST,
|
||||
*TERMINAL_RESPONSE,
|
||||
]
|
||||
TERMINAL_EXCHANGE_META = [
|
||||
*TERMINAL_EXCHANGE,
|
||||
Expect.RESPONSE_META
|
||||
]
|
||||
TERMINAL_BODY = [
|
||||
RAW_BODY,
|
||||
Expect.SEPARATOR
|
||||
|
Reference in New Issue
Block a user