forked from extern/httpie-cli
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:
parent
e0e03f3237
commit
f3b500119c
@ -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))
|
||||
|
@ -1498,10 +1498,12 @@ message is printed (headers as well as the body). You can control what should
|
||||
be printed via several options:
|
||||
|
||||
| Option | What is printed |
|
||||
| --------------: | -------------------------------------------------------------------------------------------------- |
|
||||
| -------------------------: | -------------------------------------------------------------------------------------------------- |
|
||||
| `--headers, -h` | Only the response headers are printed |
|
||||
| `--body, -b` | Only the response body is printed |
|
||||
| `--meta, -m` | Only the response metadata is printed (various metrics like total elapsed time) |
|
||||
| `--verbose, -v` | Print the whole HTTP exchange (request and response). This option also enables `--all` (see below) |
|
||||
| `--verbose --verbose, -vv` | Just like `-v`, but also include the response metadata. |
|
||||
| `--print, -p` | Selects parts of the HTTP exchange |
|
||||
| `--quiet, -q` | Don't print anything to `stdout` and `stderr` |
|
||||
|
||||
@ -1516,6 +1518,7 @@ It accepts a string of characters each of which represents a specific part of th
|
||||
| `B` | request body |
|
||||
| `h` | response headers |
|
||||
| `b` | response body |
|
||||
| `m` | response meta |
|
||||
|
||||
Print request and response headers:
|
||||
|
||||
@ -1552,6 +1555,15 @@ 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
|
||||
# Just like the above, but with additional columns like the total elapsed time
|
||||
$ http -vv pie.dev/get
|
||||
```
|
||||
|
||||
### Quiet output
|
||||
|
||||
`--quiet` redirects all output that would otherwise go to `stdout` and `stderr` to `/dev/null` (except for errors and warnings).
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user