You've already forked httpie-cli
mirror of
https://github.com/httpie/cli.git
synced 2025-08-10 22:42:05 +02:00
Decouple parser definition from argparse (#1293)
This commit is contained in:
@@ -90,13 +90,19 @@ OUTPUT_OPTIONS = frozenset({
|
||||
})
|
||||
|
||||
# Pretty
|
||||
|
||||
|
||||
class PrettyOptions(enum.Enum):
|
||||
STDOUT_TTY_ONLY = enum.auto()
|
||||
|
||||
|
||||
PRETTY_MAP = {
|
||||
'all': ['format', 'colors'],
|
||||
'colors': ['colors'],
|
||||
'format': ['format'],
|
||||
'none': []
|
||||
}
|
||||
PRETTY_STDOUT_TTY_ONLY = object()
|
||||
PRETTY_STDOUT_TTY_ONLY = PrettyOptions.STDOUT_TTY_ONLY
|
||||
|
||||
|
||||
DEFAULT_FORMAT_OPTIONS = [
|
||||
|
File diff suppressed because it is too large
Load Diff
189
httpie/cli/options.py
Normal file
189
httpie/cli/options.py
Normal file
@@ -0,0 +1,189 @@
|
||||
import argparse
|
||||
import textwrap
|
||||
import typing
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from typing import Any, Optional, Dict, List, Type, TypeVar
|
||||
|
||||
from httpie.cli.argparser import HTTPieArgumentParser
|
||||
from httpie.cli.utils import LazyChoices
|
||||
|
||||
|
||||
class Qualifiers(Enum):
|
||||
OPTIONAL = auto()
|
||||
ZERO_OR_MORE = auto()
|
||||
SUPPRESS = auto()
|
||||
|
||||
|
||||
def map_qualifiers(
|
||||
configuration: Dict[str, Any], qualifier_map: Dict[Qualifiers, Any]
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
key: qualifier_map[value] if isinstance(value, Qualifiers) else value
|
||||
for key, value in configuration.items()
|
||||
}
|
||||
|
||||
|
||||
PARSER_SPEC_VERSION = '0.0.1a0'
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParserSpec:
|
||||
program: str
|
||||
description: Optional[str] = None
|
||||
epilog: Optional[str] = None
|
||||
groups: List['Group'] = field(default_factory=list)
|
||||
|
||||
def finalize(self) -> 'ParserSpec':
|
||||
if self.description:
|
||||
self.description = textwrap.dedent(self.description)
|
||||
if self.epilog:
|
||||
self.epilog = textwrap.dedent(self.epilog)
|
||||
for group in self.groups:
|
||||
group.finalize()
|
||||
return self
|
||||
|
||||
def add_group(self, name: str, **kwargs) -> 'Group':
|
||||
group = Group(name, **kwargs)
|
||||
self.groups.append(group)
|
||||
return group
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'name': self.program,
|
||||
'description': self.description,
|
||||
'groups': [group.serialize() for group in self.groups],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Group:
|
||||
name: str
|
||||
description: str = ''
|
||||
is_mutually_exclusive: bool = False
|
||||
arguments: List['Argument'] = field(default_factory=list)
|
||||
|
||||
def finalize(self) -> None:
|
||||
if self.description:
|
||||
self.description = textwrap.dedent(self.description)
|
||||
|
||||
def add_argument(self, *args, **kwargs):
|
||||
argument = Argument(list(args), kwargs.copy())
|
||||
self.arguments.append(argument)
|
||||
return argument
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'name': self.name,
|
||||
'description': self.description or None,
|
||||
'is_mutually_exclusive': self.is_mutually_exclusive,
|
||||
'args': [argument.serialize() for argument in self.arguments],
|
||||
}
|
||||
|
||||
|
||||
class Argument(typing.NamedTuple):
|
||||
aliases: List[str]
|
||||
configuration: Dict[str, Any]
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
configuration = self.configuration.copy()
|
||||
|
||||
# Unpack the dynamically computed choices, since we
|
||||
# will need to store the actual values somewhere.
|
||||
action = configuration.pop('action', None)
|
||||
if action == 'lazy_choices':
|
||||
choices = LazyChoices(self.aliases, **{'dest': None, **configuration})
|
||||
configuration['choices'] = list(choices.load())
|
||||
configuration['help'] = choices.help
|
||||
|
||||
result = {}
|
||||
if self.aliases:
|
||||
result['options'] = self.aliases.copy()
|
||||
else:
|
||||
result['options'] = [configuration['metavar']]
|
||||
result['is_positional'] = True
|
||||
|
||||
qualifiers = JSON_QUALIFIER_TO_OPTIONS[configuration.get('nargs', Qualifiers.SUPPRESS)]
|
||||
result.update(qualifiers)
|
||||
|
||||
help_msg = configuration.get('help')
|
||||
if help_msg and help_msg is not Qualifiers.SUPPRESS:
|
||||
result['description'] = help_msg.strip()
|
||||
|
||||
python_type = configuration.get('type')
|
||||
if python_type is not None:
|
||||
if hasattr(python_type, '__name__'):
|
||||
type_name = python_type.__name__
|
||||
else:
|
||||
type_name = type(python_type).__name__
|
||||
|
||||
result['python_type_name'] = type_name
|
||||
|
||||
result.update({
|
||||
key: value
|
||||
for key, value in configuration.items()
|
||||
if key in JSON_DIRECT_MIRROR_OPTIONS
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def __getattr__(self, attribute_name):
|
||||
if attribute_name in self.configuration:
|
||||
return self.configuration[attribute_name]
|
||||
else:
|
||||
raise AttributeError(attribute_name)
|
||||
|
||||
|
||||
ParserType = TypeVar('ParserType', bound=Type[argparse.ArgumentParser])
|
||||
|
||||
ARGPARSE_QUALIFIER_MAP = {
|
||||
Qualifiers.OPTIONAL: argparse.OPTIONAL,
|
||||
Qualifiers.SUPPRESS: argparse.SUPPRESS,
|
||||
Qualifiers.ZERO_OR_MORE: argparse.ZERO_OR_MORE,
|
||||
}
|
||||
|
||||
|
||||
def to_argparse(
|
||||
abstract_options: ParserSpec,
|
||||
parser_type: ParserType = HTTPieArgumentParser,
|
||||
) -> ParserType:
|
||||
concrete_parser = parser_type(
|
||||
prog=abstract_options.program,
|
||||
description=abstract_options.description,
|
||||
epilog=abstract_options.epilog,
|
||||
)
|
||||
concrete_parser.register('action', 'lazy_choices', LazyChoices)
|
||||
|
||||
for abstract_group in abstract_options.groups:
|
||||
concrete_group = concrete_parser.add_argument_group(
|
||||
title=abstract_group.name, description=abstract_group.description
|
||||
)
|
||||
if abstract_group.is_mutually_exclusive:
|
||||
concrete_group = concrete_group.add_mutually_exclusive_group(required=False)
|
||||
|
||||
for abstract_argument in abstract_group.arguments:
|
||||
concrete_group.add_argument(
|
||||
*abstract_argument.aliases,
|
||||
**map_qualifiers(
|
||||
abstract_argument.configuration, ARGPARSE_QUALIFIER_MAP
|
||||
)
|
||||
)
|
||||
|
||||
return concrete_parser
|
||||
|
||||
|
||||
JSON_DIRECT_MIRROR_OPTIONS = (
|
||||
'choices',
|
||||
'metavar'
|
||||
)
|
||||
|
||||
|
||||
JSON_QUALIFIER_TO_OPTIONS = {
|
||||
Qualifiers.OPTIONAL: {'is_optional': True},
|
||||
Qualifiers.ZERO_OR_MORE: {'is_optional': True, 'is_variadic': True},
|
||||
Qualifiers.SUPPRESS: {}
|
||||
}
|
||||
|
||||
|
||||
def to_data(abstract_options: ParserSpec) -> Dict[str, Any]:
|
||||
return {'version': PARSER_SPEC_VERSION, 'spec': abstract_options.serialize()}
|
@@ -17,9 +17,10 @@ from .context import Environment, Levels
|
||||
from .downloads import Downloader
|
||||
from .models import (
|
||||
RequestsMessageKind,
|
||||
OutputOptions,
|
||||
OutputOptions
|
||||
)
|
||||
from .output.writer import write_message, write_stream, MESSAGE_SEPARATOR_BYTES
|
||||
from .output.models import ProcessingOptions
|
||||
from .output.writer import write_message, write_stream, write_raw_data, MESSAGE_SEPARATOR_BYTES
|
||||
from .plugins.registry import plugin_manager
|
||||
from .status import ExitStatus, http_status_to_exit_status
|
||||
from .utils import unwrap_context
|
||||
@@ -169,6 +170,7 @@ def program(args: argparse.Namespace, env: Environment) -> ExitStatus:
|
||||
downloader = None
|
||||
initial_request: Optional[requests.PreparedRequest] = None
|
||||
final_response: Optional[requests.Response] = None
|
||||
processing_options = ProcessingOptions.from_raw_args(args)
|
||||
|
||||
def separate():
|
||||
getattr(env.stdout, 'buffer', env.stdout).write(MESSAGE_SEPARATOR_BYTES)
|
||||
@@ -183,12 +185,12 @@ def program(args: argparse.Namespace, env: Environment) -> ExitStatus:
|
||||
and chunk
|
||||
)
|
||||
if should_pipe_to_stdout:
|
||||
msg = requests.PreparedRequest()
|
||||
msg.is_body_upload_chunk = True
|
||||
msg.body = chunk
|
||||
msg.headers = initial_request.headers
|
||||
msg_output_options = OutputOptions.from_message(msg, body=True, headers=False)
|
||||
write_message(requests_message=msg, env=env, args=args, output_options=msg_output_options)
|
||||
return write_raw_data(
|
||||
env,
|
||||
chunk,
|
||||
processing_options=processing_options,
|
||||
headers=initial_request.headers
|
||||
)
|
||||
|
||||
try:
|
||||
if args.download:
|
||||
@@ -222,9 +224,14 @@ 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=Levels.WARNING)
|
||||
write_message(requests_message=message, env=env, args=args, output_options=output_options._replace(
|
||||
body=do_write_body
|
||||
))
|
||||
write_message(
|
||||
requests_message=message,
|
||||
env=env,
|
||||
output_options=output_options._replace(
|
||||
body=do_write_body
|
||||
),
|
||||
processing_options=processing_options
|
||||
)
|
||||
prev_with_body = output_options.body
|
||||
|
||||
# Cleanup
|
||||
|
@@ -45,6 +45,14 @@ COMMANDS = {
|
||||
},
|
||||
'cli': {
|
||||
'help': 'Manage HTTPie for Terminal',
|
||||
'export-args': [
|
||||
'Export available options for the CLI',
|
||||
{
|
||||
'flags': ['-f', '--format'],
|
||||
'choices': ['json'],
|
||||
'default': 'json'
|
||||
}
|
||||
],
|
||||
'sessions': {
|
||||
'help': 'Manage HTTPie sessions',
|
||||
'upgrade': [
|
||||
|
@@ -114,3 +114,28 @@ def cli_upgrade_all_sessions(env: Environment, args: argparse.Namespace) -> Exit
|
||||
session_name=session_name
|
||||
)
|
||||
return status
|
||||
|
||||
|
||||
FORMAT_TO_CONTENT_TYPE = {
|
||||
'json': 'application/json'
|
||||
}
|
||||
|
||||
|
||||
@task('export-args')
|
||||
def cli_export(env: Environment, args: argparse.Namespace) -> ExitStatus:
|
||||
import json
|
||||
from httpie.cli.definition import options
|
||||
from httpie.cli.options import to_data
|
||||
from httpie.output.writer import write_raw_data
|
||||
|
||||
if args.format == 'json':
|
||||
data = json.dumps(to_data(options))
|
||||
else:
|
||||
raise NotImplementedError(f'Unexpected format value: {args.format}')
|
||||
|
||||
write_raw_data(
|
||||
env,
|
||||
data,
|
||||
stream_kwargs={'mime_overwrite': FORMAT_TO_CONTENT_TYPE[args.format]},
|
||||
)
|
||||
return ExitStatus.SUCCESS
|
||||
|
44
httpie/output/models.py
Normal file
44
httpie/output/models.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import argparse
|
||||
from typing import Any, Dict, Union, List, NamedTuple, Optional
|
||||
|
||||
from httpie.context import Environment
|
||||
from httpie.cli.constants import PrettyOptions, PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY
|
||||
from httpie.cli.argtypes import PARSED_DEFAULT_FORMAT_OPTIONS
|
||||
from httpie.output.formatters.colors import AUTO_STYLE
|
||||
|
||||
|
||||
class ProcessingOptions(NamedTuple):
|
||||
"""Represents a set of stylistic options
|
||||
that are used when deciding which stream
|
||||
should be used."""
|
||||
|
||||
debug: bool = False
|
||||
traceback: bool = False
|
||||
|
||||
stream: bool = False
|
||||
style: str = AUTO_STYLE
|
||||
prettify: Union[List[str], PrettyOptions] = PRETTY_STDOUT_TTY_ONLY
|
||||
|
||||
response_mime: Optional[str] = None
|
||||
response_charset: Optional[str] = None
|
||||
|
||||
json: bool = False
|
||||
format_options: Dict[str, Any] = PARSED_DEFAULT_FORMAT_OPTIONS
|
||||
|
||||
def get_prettify(self, env: Environment) -> List[str]:
|
||||
if self.prettify is PRETTY_STDOUT_TTY_ONLY:
|
||||
return PRETTY_MAP['all' if env.stdout_isatty else 'none']
|
||||
else:
|
||||
return self.prettify
|
||||
|
||||
@classmethod
|
||||
def from_raw_args(cls, options: argparse.Namespace) -> 'ProcessingOptions':
|
||||
fetched_options = {
|
||||
option: getattr(options, option)
|
||||
for option in cls._fields
|
||||
}
|
||||
return cls(**fetched_options)
|
||||
|
||||
@property
|
||||
def show_traceback(self):
|
||||
return self.debug or self.traceback
|
@@ -34,7 +34,8 @@ class BaseStream(metaclass=ABCMeta):
|
||||
self,
|
||||
msg: HTTPMessage,
|
||||
output_options: OutputOptions,
|
||||
on_body_chunk_downloaded: Callable[[bytes], None] = None
|
||||
on_body_chunk_downloaded: Callable[[bytes], None] = None,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
:param msg: a :class:`models.HTTPMessage` subclass
|
||||
@@ -45,6 +46,7 @@ class BaseStream(metaclass=ABCMeta):
|
||||
self.msg = msg
|
||||
self.output_options = output_options
|
||||
self.on_body_chunk_downloaded = on_body_chunk_downloaded
|
||||
self.extra_options = kwargs
|
||||
|
||||
def get_headers(self) -> bytes:
|
||||
"""Return the headers' bytes."""
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import argparse
|
||||
import errno
|
||||
from typing import IO, TextIO, Tuple, Type, Union
|
||||
import requests
|
||||
from typing import Any, Dict, IO, Optional, TextIO, Tuple, Type, Union
|
||||
|
||||
from ..cli.dicts import HTTPHeadersDict
|
||||
from ..context import Environment
|
||||
@@ -10,8 +10,9 @@ from ..models import (
|
||||
HTTPMessage,
|
||||
RequestsMessage,
|
||||
RequestsMessageKind,
|
||||
OutputOptions
|
||||
OutputOptions,
|
||||
)
|
||||
from .models import ProcessingOptions
|
||||
from .processing import Conversion, Formatting
|
||||
from .streams import (
|
||||
BaseStream, BufferedPrettyStream, EncodedStream, PrettyStream, RawStream,
|
||||
@@ -25,30 +26,31 @@ MESSAGE_SEPARATOR_BYTES = MESSAGE_SEPARATOR.encode()
|
||||
def write_message(
|
||||
requests_message: RequestsMessage,
|
||||
env: Environment,
|
||||
args: argparse.Namespace,
|
||||
output_options: OutputOptions,
|
||||
processing_options: ProcessingOptions,
|
||||
extra_stream_kwargs: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
if not output_options.any():
|
||||
return
|
||||
write_stream_kwargs = {
|
||||
'stream': build_output_stream_for_message(
|
||||
args=args,
|
||||
env=env,
|
||||
requests_message=requests_message,
|
||||
output_options=output_options,
|
||||
processing_options=processing_options,
|
||||
extra_stream_kwargs=extra_stream_kwargs
|
||||
),
|
||||
# NOTE: `env.stdout` will in fact be `stderr` with `--download`
|
||||
'outfile': env.stdout,
|
||||
'flush': env.stdout_isatty or args.stream
|
||||
'flush': env.stdout_isatty or processing_options.stream
|
||||
}
|
||||
try:
|
||||
if env.is_windows and 'colors' in args.prettify:
|
||||
if env.is_windows and 'colors' in processing_options.get_prettify(env):
|
||||
write_stream_with_colors_win(**write_stream_kwargs)
|
||||
else:
|
||||
write_stream(**write_stream_kwargs)
|
||||
except OSError as e:
|
||||
show_traceback = args.debug or args.traceback
|
||||
if not show_traceback and e.errno == errno.EPIPE:
|
||||
if processing_options.show_traceback and e.errno == errno.EPIPE:
|
||||
# Ignore broken pipes unless --traceback.
|
||||
env.stderr.write('\n')
|
||||
else:
|
||||
@@ -94,11 +96,34 @@ def write_stream_with_colors_win(
|
||||
outfile.flush()
|
||||
|
||||
|
||||
def write_raw_data(
|
||||
env: Environment,
|
||||
data: Any,
|
||||
*,
|
||||
processing_options: Optional[ProcessingOptions] = None,
|
||||
headers: Optional[HTTPHeadersDict] = None,
|
||||
stream_kwargs: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
msg = requests.PreparedRequest()
|
||||
msg.is_body_upload_chunk = True
|
||||
msg.body = data
|
||||
msg.headers = headers or HTTPHeadersDict()
|
||||
msg_output_options = OutputOptions.from_message(msg, body=True, headers=False)
|
||||
return write_message(
|
||||
requests_message=msg,
|
||||
env=env,
|
||||
output_options=msg_output_options,
|
||||
processing_options=processing_options or ProcessingOptions(),
|
||||
extra_stream_kwargs=stream_kwargs
|
||||
)
|
||||
|
||||
|
||||
def build_output_stream_for_message(
|
||||
args: argparse.Namespace,
|
||||
env: Environment,
|
||||
requests_message: RequestsMessage,
|
||||
output_options: OutputOptions,
|
||||
processing_options: ProcessingOptions,
|
||||
extra_stream_kwargs: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
message_type = {
|
||||
RequestsMessageKind.REQUEST: HTTPRequest,
|
||||
@@ -106,10 +131,12 @@ def build_output_stream_for_message(
|
||||
}[output_options.kind]
|
||||
stream_class, stream_kwargs = get_stream_type_and_kwargs(
|
||||
env=env,
|
||||
args=args,
|
||||
processing_options=processing_options,
|
||||
message_type=message_type,
|
||||
headers=requests_message.headers
|
||||
)
|
||||
if extra_stream_kwargs:
|
||||
stream_kwargs.update(extra_stream_kwargs)
|
||||
yield from stream_class(
|
||||
msg=message_type(requests_message),
|
||||
output_options=output_options,
|
||||
@@ -124,20 +151,21 @@ def build_output_stream_for_message(
|
||||
|
||||
def get_stream_type_and_kwargs(
|
||||
env: Environment,
|
||||
args: argparse.Namespace,
|
||||
processing_options: ProcessingOptions,
|
||||
message_type: Type[HTTPMessage],
|
||||
headers: HTTPHeadersDict,
|
||||
) -> Tuple[Type['BaseStream'], dict]:
|
||||
"""Pick the right stream type and kwargs for it based on `env` and `args`.
|
||||
|
||||
"""
|
||||
is_stream = args.stream
|
||||
is_stream = processing_options.stream
|
||||
prettify_groups = processing_options.get_prettify(env)
|
||||
if not is_stream and message_type is HTTPResponse:
|
||||
# If this is a response, then check the headers for determining
|
||||
# auto-streaming.
|
||||
is_stream = headers.get('Content-Type') == 'text/event-stream'
|
||||
|
||||
if not env.stdout_isatty and not args.prettify:
|
||||
if not env.stdout_isatty and not prettify_groups:
|
||||
stream_class = RawStream
|
||||
stream_kwargs = {
|
||||
'chunk_size': (
|
||||
@@ -153,19 +181,19 @@ def get_stream_type_and_kwargs(
|
||||
}
|
||||
if message_type is HTTPResponse:
|
||||
stream_kwargs.update({
|
||||
'mime_overwrite': args.response_mime,
|
||||
'encoding_overwrite': args.response_charset,
|
||||
'mime_overwrite': processing_options.response_mime,
|
||||
'encoding_overwrite': processing_options.response_charset,
|
||||
})
|
||||
if args.prettify:
|
||||
if prettify_groups:
|
||||
stream_class = PrettyStream if is_stream else BufferedPrettyStream
|
||||
stream_kwargs.update({
|
||||
'conversion': Conversion(),
|
||||
'formatting': Formatting(
|
||||
env=env,
|
||||
groups=args.prettify,
|
||||
color_scheme=args.style,
|
||||
explicit_json=args.json,
|
||||
format_options=args.format_options,
|
||||
groups=prettify_groups,
|
||||
color_scheme=processing_options.style,
|
||||
explicit_json=processing_options.json,
|
||||
format_options=processing_options.format_options,
|
||||
)
|
||||
})
|
||||
|
||||
|
Reference in New Issue
Block a user