Remove automatic config file creation to avoid concurrency issues.

Close #788
Close #812
This commit is contained in:
Jakub Roztocil 2019-12-02 17:43:16 +01:00
parent f0058eeaee
commit f202f338a4
11 changed files with 178 additions and 141 deletions

View File

@ -11,6 +11,7 @@ This project adheres to `Semantic Versioning <https://semver.org/>`_.
* Removed Python 2.7 support (`EOL Jan 2020 <https://www.python.org/doc/sunset-python-2/>`_). * Removed Python 2.7 support (`EOL Jan 2020 <https://www.python.org/doc/sunset-python-2/>`_).
* Removed the default 30-second connection ``--timeout`` limit. * Removed the default 30-second connection ``--timeout`` limit.
* Removed Pythons default limit of 100 response headers. * Removed Pythons default limit of 100 response headers.
* Removed automatic config file creation to avoid concurrency issues.
* Replaced the old collect-all-then-process handling of HTTP communication * Replaced the old collect-all-then-process handling of HTTP communication
with one-by-one processing of each HTTP request or response as they become with one-by-one processing of each HTTP request or response as they become
available. This means that you can see headers immediately, available. This means that you can see headers immediately,
@ -26,7 +27,6 @@ This project adheres to `Semantic Versioning <https://semver.org/>`_.
* Added ``tests/`` to the PyPi package for the convenience of * Added ``tests/`` to the PyPi package for the convenience of
downstream package maintainers. downstream package maintainers.
* Fixed an error when ``stdin`` was a closed fd. * Fixed an error when ``stdin`` was a closed fd.
* Fixed an error when the config directory was not writeable.
* Improved ``--debug`` output formatting. * Improved ``--debug`` output formatting.

View File

@ -1487,7 +1487,8 @@ To create or reuse a different session, simple specify a different name:
$ http --session=user2 -a user2:password example.org X-Bar:Foo $ http --session=user2 -a user2:password example.org X-Bar:Foo
Named sessions' data is stored in JSON files in the directory Named sessionss data is stored in JSON files in the the ``sessions``
subdirectory of the `config`_ directory:
``~/.httpie/sessions/<host>/<name>.json`` ``~/.httpie/sessions/<host>/<name>.json``
(``%APPDATA%\httpie\sessions\<host>\<name>.json`` on Windows). (``%APPDATA%\httpie\sessions\<host>\<name>.json`` on Windows).
@ -1517,46 +1518,61 @@ exchange once it is created, specify the session name via
Config Config
====== ======
HTTPie uses a simple JSON config file. HTTPie uses a simple ``config.json`` file. The file doesnt exist by default
but you can create it manually.
Config file directory
Config file location ---------------------
--------------------
The default location of the configuration file is ``~/.httpie/config.json`` The default location of the configuration file is ``~/.httpie/config.json``
(or ``%APPDATA%\httpie\config.json`` on Windows). The config directory (or ``%APPDATA%\httpie\config.json`` on Windows).
location can be changed by setting the ``HTTPIE_CONFIG_DIR``
environment variable. To view the exact location run ``http --debug``. The config directory can be changed by setting the ``$HTTPIE_CONFIG_DIR``
environment variable:
.. code-block:: bash
$ export HTTPIE_CONFIG_DIR=/tmp/httpie
$ http example.org
To view the exact location run ``http --debug``.
Configurable options Configurable options
-------------------- --------------------
The JSON file contains an object with the following keys: Currently HTTPie offers a single configurable option:
``default_options`` ``default_options``
~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~
An ``Array`` (by default empty) of default options that should be applied to An ``Array`` (by default empty) of default options that should be applied to
every invocation of HTTPie. every invocation of HTTPie.
For instance, you can use this option to change the default style and output For instance, you can use this config option to change your default color theme:
options: ``"default_options": ["--style=fruity", "--body"]`` Another useful
default option could be ``"--session=default"`` to make HTTPie always
use `sessions`_ (one named ``default`` will automatically be used).
Or you could change the implicit request content type from JSON to form by
adding ``--form`` to the list.
``__meta__`` .. code-block:: bash
~~~~~~~~~~~~
HTTPie automatically stores some of its metadata here. Please do not change. $ cat ~/.httpie/config.json
.. code-block:: json
{
"default_options": [
"--style=fruity"
]
}
Even though it is technically possible to include there any of HTTPies
options, it is not recommended to modify the default behaviour in a way
that would break your compatibility with the wider world as that can
generate a lot of confusion.
Un-setting previously specified options Un-setting previously specified options
--------------------------------------- ---------------------------------------

View File

@ -61,7 +61,6 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
def parse_args( def parse_args(
self, self,
env: Environment, env: Environment,
program_name='http',
args=None, args=None,
namespace=None namespace=None
) -> argparse.Namespace: ) -> argparse.Namespace:
@ -89,7 +88,7 @@ class HTTPieArgumentParser(argparse.ArgumentParser):
if self.has_stdin_data: if self.has_stdin_data:
self._body_from_file(self.env.stdin) self._body_from_file(self.env.stdin)
if not URL_SCHEME_RE.match(self.args.url): if not URL_SCHEME_RE.match(self.args.url):
if os.path.basename(program_name) == 'https': if os.path.basename(env.program_name) == 'https':
scheme = 'https://' scheme = 'https://'
else: else:
scheme = self.args.default_scheme + "://" scheme = self.args.default_scheme + "://"

View File

@ -35,7 +35,7 @@ DEFAULT_UA = f'HTTPie/{__version__}'
def collect_messages( def collect_messages(
args: argparse.Namespace, args: argparse.Namespace,
config_dir: Path config_dir: Path,
) -> Iterable[Union[requests.PreparedRequest, requests.Response]]: ) -> Iterable[Union[requests.PreparedRequest, requests.Response]]:
httpie_session = None httpie_session = None
httpie_session_headers = None httpie_session_headers = None

View File

@ -15,42 +15,43 @@ DEFAULT_CONFIG_DIR = Path(os.environ.get(
)) ))
class ConfigFileError(Exception):
pass
class BaseConfigDict(dict): class BaseConfigDict(dict):
name = None name = None
helpurl = None helpurl = None
about = None about = None
def _get_path(self) -> Path: def __init__(self, path: Path):
"""Return the config file path without side-effects.""" super().__init__()
raise NotImplementedError() self.path = path
def path(self) -> Path: def ensure_directory(self):
"""Return the config file path creating basedir, if needed."""
path = self._get_path()
try: try:
path.parent.mkdir(mode=0o700, parents=True) self.path.parent.mkdir(mode=0o700, parents=True)
except OSError as e: except OSError as e:
if e.errno != errno.EEXIST: if e.errno != errno.EEXIST:
raise raise
return path
def is_new(self) -> bool: def is_new(self) -> bool:
return not self._get_path().exists() return not self.path.exists()
def load(self): def load(self):
config_type = type(self).__name__.lower()
try: try:
with self.path().open('rt') as f: with self.path.open('rt') as f:
try: try:
data = json.load(f) data = json.load(f)
except ValueError as e: except ValueError as e:
raise ValueError( raise ConfigFileError(
'Invalid %s JSON: %s [%s]' % f'invalid {config_type} file: {e} [{self.path}]'
(type(self).__name__, str(e), self.path())
) )
self.update(data) self.update(data)
except IOError as e: except IOError as e:
if e.errno != errno.ENOENT: if e.errno != errno.ENOENT:
raise raise ConfigFileError(f'cannot read {config_type} file: {e}')
def save(self, fail_silently=False): def save(self, fail_silently=False):
self['__meta__'] = { self['__meta__'] = {
@ -62,9 +63,17 @@ class BaseConfigDict(dict):
if self.about: if self.about:
self['__meta__']['about'] = self.about self['__meta__']['about'] = self.about
self.ensure_directory()
try: try:
with self.path().open('w') as f: with self.path.open('w') as f:
json.dump(self, f, indent=4, sort_keys=True, ensure_ascii=True) json.dump(
obj=self,
fp=f,
indent=4,
sort_keys=True,
ensure_ascii=True,
)
f.write('\n') f.write('\n')
except IOError: except IOError:
if not fail_silently: if not fail_silently:
@ -72,27 +81,22 @@ class BaseConfigDict(dict):
def delete(self): def delete(self):
try: try:
self.path().unlink() self.path.unlink()
except OSError as e: except OSError as e:
if e.errno != errno.ENOENT: if e.errno != errno.ENOENT:
raise raise
class Config(BaseConfigDict): class Config(BaseConfigDict):
name = 'config' FILENAME = 'config.json'
helpurl = 'https://httpie.org/doc#config'
about = 'HTTPie configuration file'
DEFAULTS = { DEFAULTS = {
'default_options': [] 'default_options': []
} }
def __init__(self, directory: Union[str, Path] = DEFAULT_CONFIG_DIR): def __init__(self, directory: Union[str, Path] = DEFAULT_CONFIG_DIR):
super().__init__()
self.update(self.DEFAULTS)
self.directory = Path(directory) self.directory = Path(directory)
super().__init__(path=self.directory / self.FILENAME)
def _get_path(self) -> Path: self.update(self.DEFAULTS)
return self.directory / (self.name + '.json')
@property @property
def default_options(self) -> list: def default_options(self) -> list:

View File

@ -1,3 +1,4 @@
import os
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Union, IO, Optional from typing import Union, IO, Optional
@ -9,7 +10,7 @@ except ImportError:
curses = None # Compiled w/o curses curses = None # Compiled w/o curses
from httpie.compat import is_windows from httpie.compat import is_windows
from httpie.config import DEFAULT_CONFIG_DIR, Config from httpie.config import DEFAULT_CONFIG_DIR, Config, ConfigFileError
from httpie.utils import repr_dict from httpie.utils import repr_dict
@ -35,6 +36,7 @@ class Environment:
stderr: IO = sys.stderr stderr: IO = sys.stderr
stderr_isatty: bool = stderr.isatty() stderr_isatty: bool = stderr.isatty()
colors = 256 colors = 256
program_name: str = 'http'
if not is_windows: if not is_windows:
if curses: if curses:
try: try:
@ -79,16 +81,6 @@ class Environment:
self.stdout_encoding = getattr( self.stdout_encoding = getattr(
actual_stdout, 'encoding', None) or 'utf8' actual_stdout, 'encoding', None) or 'utf8'
@property
def config(self) -> Config:
if not hasattr(self, '_config'):
self._config = Config(directory=self.config_dir)
if self._config.is_new():
self._config.save(fail_silently=True)
else:
self._config.load()
return self._config
def __str__(self): def __str__(self):
defaults = dict(type(self).__dict__) defaults = dict(type(self).__dict__)
actual = dict(defaults) actual = dict(defaults)
@ -102,3 +94,21 @@ class Environment:
def __repr__(self): def __repr__(self):
return f'<{type(self).__name__} {self}>' return f'<{type(self).__name__} {self}>'
_config: Config = None
@property
def config(self) -> Config:
config = self._config
if not config:
self._config = config = Config(directory=self.config_dir)
if not config.is_new():
try:
config.load()
except ConfigFileError as e:
self.log_error(e, level='warning')
return config
def log_error(self, msg, level='error'):
assert level in ['error', 'warning']
self.stderr.write(f'\n{self.program_name}: {level}: {msg}\n\n')

View File

@ -1,25 +1,25 @@
import argparse import argparse
import os
import platform import platform
import sys import sys
from typing import Callable, List, Union from typing import List, Union
import requests import requests
from pygments import __version__ as pygments_version from pygments import __version__ as pygments_version
from requests import __version__ as requests_version from requests import __version__ as requests_version
from httpie import __version__ as httpie_version from httpie import __version__ as httpie_version
from httpie.status import ExitStatus, http_status_to_exit_status
from httpie.client import collect_messages from httpie.client import collect_messages
from httpie.context import Environment from httpie.context import Environment
from httpie.downloads import Downloader from httpie.downloads import Downloader
from httpie.output.writer import write_message, write_stream from httpie.output.writer import write_message, write_stream
from httpie.plugins import plugin_manager from httpie.plugins import plugin_manager
from httpie.status import ExitStatus, http_status_to_exit_status
def main( def main(
args: List[Union[str, bytes]] = sys.argv, args: List[Union[str, bytes]] = sys.argv,
env=Environment(), env=Environment(),
custom_log_error: Callable = None
) -> ExitStatus: ) -> ExitStatus:
""" """
The main function. The main function.
@ -30,22 +30,16 @@ def main(
Return exit status code. Return exit status code.
""" """
args = decode_raw_args(args, env.stdin_encoding)
program_name, *args = args program_name, *args = args
env.program_name = os.path.basename(program_name)
args = decode_raw_args(args, env.stdin_encoding)
plugin_manager.load_installed_plugins() plugin_manager.load_installed_plugins()
def log_error(msg, level='error'):
assert level in ['error', 'warning']
env.stderr.write(f'\n{program_name}: {level}: {msg}\n')
from httpie.cli.definition import parser from httpie.cli.definition import parser
if env.config.default_options: if env.config.default_options:
args = env.config.default_options + args args = env.config.default_options + args
if custom_log_error:
log_error = custom_log_error
include_debug_info = '--debug' in args include_debug_info = '--debug' in args
include_traceback = include_debug_info or '--traceback' in args include_traceback = include_debug_info or '--traceback' in args
@ -59,7 +53,6 @@ def main(
try: try:
parsed_args = parser.parse_args( parsed_args = parser.parse_args(
args=args, args=args,
program_name=program_name,
env=env, env=env,
) )
except KeyboardInterrupt: except KeyboardInterrupt:
@ -78,7 +71,6 @@ def main(
exit_status = program( exit_status = program(
args=parsed_args, args=parsed_args,
env=env, env=env,
log_error=log_error,
) )
except KeyboardInterrupt: except KeyboardInterrupt:
env.stderr.write('\n') env.stderr.write('\n')
@ -93,10 +85,10 @@ def main(
exit_status = ExitStatus.ERROR exit_status = ExitStatus.ERROR
except requests.Timeout: except requests.Timeout:
exit_status = ExitStatus.ERROR_TIMEOUT exit_status = ExitStatus.ERROR_TIMEOUT
log_error(f'Request timed out ({parsed_args.timeout}s).') env.log_error(f'Request timed out ({parsed_args.timeout}s).')
except requests.TooManyRedirects: except requests.TooManyRedirects:
exit_status = ExitStatus.ERROR_TOO_MANY_REDIRECTS exit_status = ExitStatus.ERROR_TOO_MANY_REDIRECTS
log_error( env.log_error(
f'Too many redirects' f'Too many redirects'
f' (--max-redirects=parsed_args.max_redirects).' f' (--max-redirects=parsed_args.max_redirects).'
) )
@ -110,7 +102,7 @@ def main(
f'{msg} while doing a {request.method}' f'{msg} while doing a {request.method}'
f' request to URL: {request.url}' f' request to URL: {request.url}'
) )
log_error(f'{type(e).__name__}: {msg}') env.log_error(f'{type(e).__name__}: {msg}')
if include_traceback: if include_traceback:
raise raise
exit_status = ExitStatus.ERROR exit_status = ExitStatus.ERROR
@ -121,7 +113,6 @@ def main(
def program( def program(
args: argparse.Namespace, args: argparse.Namespace,
env: Environment, env: Environment,
log_error: Callable
) -> ExitStatus: ) -> ExitStatus:
""" """
The main program without error handling. The main program without error handling.
@ -159,8 +150,9 @@ def program(
http_status=message.status_code, http_status=message.status_code,
follow=args.follow follow=args.follow
) )
if not env.stdout_isatty and exit_status != ExitStatus.SUCCESS: if (not env.stdout_isatty
log_error( and exit_status != ExitStatus.SUCCESS):
env.log_error(
f'HTTP {message.raw.status} {message.raw.reason}', f'HTTP {message.raw.status} {message.raw.reason}',
level='warning' level='warning'
) )
@ -179,10 +171,11 @@ def program(
downloader.finish() downloader.finish()
if downloader.interrupted: if downloader.interrupted:
exit_status = ExitStatus.ERROR exit_status = ExitStatus.ERROR
log_error('Incomplete download: size=%d; downloaded=%d' % ( env.log_error(
downloader.status.total_size, 'Incomplete download: size=%d; downloaded=%d' % (
downloader.status.downloaded downloader.status.total_size,
)) downloader.status.downloaded
))
return exit_status return exit_status
finally: finally:
@ -196,11 +189,11 @@ def program(
def print_debug_info(env: Environment): def print_debug_info(env: Environment):
env.stderr.writelines([ env.stderr.writelines([
'HTTPie %s\n' % httpie_version, f'HTTPie {httpie_version}\n',
'Requests %s\n' % requests_version, f'Requests {requests_version}\n',
'Pygments %s\n' % pygments_version, f'Pygments {pygments_version}\n',
'Python %s\n%s\n' % (sys.version, sys.executable), f'Python {sys.version}\n{sys.executable}\n',
'%s %s' % (platform.system(), platform.release()), f'{platform.system()} {platform.release()}',
]) ])
env.stderr.write('\n\n') env.stderr.write('\n\n')
env.stderr.write(repr(env)) env.stderr.write(repr(env))

View File

@ -1,4 +1,5 @@
"""Persistent, JSON-serialized sessions. """
Persistent, JSON-serialized sessions.
""" """
import os import os
@ -53,8 +54,7 @@ class Session(BaseConfigDict):
about = 'HTTPie session file' about = 'HTTPie session file'
def __init__(self, path: Union[str, Path]): def __init__(self, path: Union[str, Path]):
super().__init__() super().__init__(path=Path(path))
self._path = Path(path)
self['headers'] = {} self['headers'] = {}
self['cookies'] = {} self['cookies'] = {}
self['auth'] = { self['auth'] = {
@ -63,9 +63,6 @@ class Session(BaseConfigDict):
'password': None 'password': None
} }
def _get_path(self) -> Path:
return self._path
def update_headers(self, request_headers: RequestHeadersDict): def update_headers(self, request_headers: RequestHeadersDict):
""" """
Update the session headers with the request ones while ignoring Update the session headers with the request ones while ignoring

View File

@ -1,6 +1,5 @@
from httpie import __version__ from httpie.config import Config
from utils import MockEnvironment, http, HTTP_OK from utils import HTTP_OK, MockEnvironment, http
from httpie.context import Environment
def test_default_options(httpbin): def test_default_options(httpbin):
@ -8,15 +7,33 @@ def test_default_options(httpbin):
env.config['default_options'] = ['--form'] env.config['default_options'] = ['--form']
env.config.save() env.config.save()
r = http(httpbin.url + '/post', 'foo=bar', env=env) r = http(httpbin.url + '/post', 'foo=bar', env=env)
assert r.json['form'] == {"foo": "bar"} assert r.json['form'] == {
"foo": "bar"
}
def test_config_dir_not_writeable(httpbin): def test_config_file_not_valid(httpbin):
r = http(httpbin + '/get', env=MockEnvironment( env = MockEnvironment()
config_dir='/', env.create_temp_config_dir()
create_temp_config_dir=False, with (env.config_dir / Config.FILENAME).open('w') as f:
)) f.write('{invalid json}')
r = http(httpbin + '/get', env=env)
assert HTTP_OK in r assert HTTP_OK in r
assert 'http: warning' in r.stderr
assert 'invalid config file' in r.stderr
def test_config_file_not_inaccessible(httpbin):
env = MockEnvironment()
env.create_temp_config_dir()
config_path = env.config_dir / Config.FILENAME
assert not config_path.exists()
config_path.touch(0o000)
assert config_path.exists()
r = http(httpbin + '/get', env=env)
assert HTTP_OK in r
assert 'http: warning' in r.stderr
assert 'cannot read config file' in r.stderr
def test_default_options_overwrite(httpbin): def test_default_options_overwrite(httpbin):
@ -24,9 +41,6 @@ def test_default_options_overwrite(httpbin):
env.config['default_options'] = ['--form'] env.config['default_options'] = ['--form']
env.config.save() env.config.save()
r = http('--json', httpbin.url + '/post', 'foo=bar', env=env) r = http('--json', httpbin.url + '/post', 'foo=bar', env=env)
assert r.json['json'] == {"foo": "bar"} assert r.json['json'] == {
"foo": "bar"
}
def test_current_version():
version = MockEnvironment().config['__meta__']['httpie']
assert version == __version__

View File

@ -4,37 +4,30 @@ from requests import Request
from requests.exceptions import ConnectionError from requests.exceptions import ConnectionError
from httpie.status import ExitStatus from httpie.status import ExitStatus
from httpie.core import main
from utils import HTTP_OK, http from utils import HTTP_OK, http
error_msg = None @mock.patch('httpie.core.program')
def test_error(program):
exc = ConnectionError('Connection aborted')
exc.request = Request(method='GET', url='http://www.google.com')
program.side_effect = exc
r = http('www.google.com', tolerate_error_exit_status=True)
assert r.exit_status == ExitStatus.ERROR
assert (
'ConnectionError: '
'Connection aborted while doing a GET request to URL: '
'http://www.google.com'
) in r.stderr
@mock.patch('httpie.core.program') @mock.patch('httpie.core.program')
def test_error(get_response): def test_error_traceback(program):
def error(msg, *args, **kwargs):
global error_msg
error_msg = msg % args
exc = ConnectionError('Connection aborted') exc = ConnectionError('Connection aborted')
exc.request = Request(method='GET', url='http://www.google.com') exc.request = Request(method='GET', url='http://www.google.com')
get_response.side_effect = exc program.side_effect = exc
ret = main(['http', '--ignore-stdin', 'www.google.com'], custom_log_error=error)
assert ret == ExitStatus.ERROR
assert error_msg == (
'ConnectionError: '
'Connection aborted while doing a GET request to URL: '
'http://www.google.com')
@mock.patch('httpie.core.program')
def test_error_traceback(get_response):
exc = ConnectionError('Connection aborted')
exc.request = Request(method='GET', url='http://www.google.com')
get_response.side_effect = exc
with raises(ConnectionError): with raises(ConnectionError):
main(['http', '--ignore-stdin', '--traceback', 'www.google.com']) http('--traceback', 'www.google.com')
def test_max_headers_limit(httpbin_both): def test_max_headers_limit(httpbin_both):

View File

@ -6,7 +6,7 @@ import time
import json import json
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional, Union
from httpie.status import ExitStatus from httpie.status import ExitStatus
from httpie.config import Config from httpie.config import Config
@ -18,6 +18,7 @@ TESTS_ROOT = os.path.abspath(os.path.dirname(__file__))
CRLF = '\r\n' CRLF = '\r\n'
COLOR = '\x1b[' COLOR = '\x1b['
HTTP_OK = '200 OK' HTTP_OK = '200 OK'
# noinspection GrazieInspection
HTTP_OK_COLOR = ( HTTP_OK_COLOR = (
'HTTP\x1b[39m\x1b[38;5;245m/\x1b[39m\x1b' 'HTTP\x1b[39m\x1b[38;5;245m/\x1b[39m\x1b'
'[38;5;37m1.1\x1b[39m\x1b[38;5;245m \x1b[39m\x1b[38;5;37m200' '[38;5;37m1.1\x1b[39m\x1b[38;5;245m \x1b[39m\x1b[38;5;37m200'
@ -62,10 +63,13 @@ class MockEnvironment(Environment):
def config(self) -> Config: def config(self) -> Config:
if (self._create_temp_config_dir if (self._create_temp_config_dir
and self._temp_dir not in self.config_dir.parents): and self._temp_dir not in self.config_dir.parents):
self.config_dir = mk_config_dir() self.create_temp_config_dir()
self._delete_config_dir = True
return super().config return super().config
def create_temp_config_dir(self):
self.config_dir = mk_config_dir()
self._delete_config_dir = True
def cleanup(self): def cleanup(self):
self.stdout.close() self.stdout.close()
self.stderr.close() self.stderr.close()
@ -75,6 +79,7 @@ class MockEnvironment(Environment):
rmtree(self.config_dir) rmtree(self.config_dir)
def __del__(self): def __del__(self):
# noinspection PyBroadException
try: try:
self.cleanup() self.cleanup()
except Exception: except Exception:
@ -83,7 +88,7 @@ class MockEnvironment(Environment):
class BaseCLIResponse: class BaseCLIResponse:
""" """
Represents the result of simulated `$ http' invocation via `http()`. Represents the result of simulated `$ http' invocation via `http()`.
Holds and provides access to: Holds and provides access to:
@ -113,8 +118,8 @@ class StrCLIResponse(str, BaseCLIResponse):
@property @property
def json(self) -> Optional[dict]: def json(self) -> Optional[dict]:
""" """
Return deserialized JSON body, if one included in the output Return deserialized the request or response JSON body,
and is parsable. if one (and only one) included in the output and is parsable.
""" """
if not hasattr(self, '_json'): if not hasattr(self, '_json'):
@ -147,7 +152,12 @@ class ExitStatusError(Exception):
pass pass
def http(*args, program_name='http', **kwargs): def http(
*args,
program_name='http',
tolerate_error_exit_status=False,
**kwargs,
) -> Union[StrCLIResponse, BytesCLIResponse]:
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
""" """
Run HTTPie and capture stderr/out and exit status. Run HTTPie and capture stderr/out and exit status.
@ -188,7 +198,6 @@ def http(*args, program_name='http', **kwargs):
True True
""" """
tolerate_error_exit_status = kwargs.pop('tolerate_error_exit_status', False)
env = kwargs.get('env') env = kwargs.get('env')
if not env: if not env:
env = kwargs['env'] = MockEnvironment() env = kwargs['env'] = MockEnvironment()
@ -200,7 +209,8 @@ def http(*args, program_name='http', **kwargs):
args_with_config_defaults = args + env.config.default_options args_with_config_defaults = args + env.config.default_options
add_to_args = [] add_to_args = []
if '--debug' not in args_with_config_defaults: if '--debug' not in args_with_config_defaults:
if not tolerate_error_exit_status and '--traceback' not in args_with_config_defaults: if (not tolerate_error_exit_status
and '--traceback' not in args_with_config_defaults):
add_to_args.append('--traceback') add_to_args.append('--traceback')
if not any('--timeout' in arg for arg in args_with_config_defaults): if not any('--timeout' in arg for arg in args_with_config_defaults):
add_to_args.append('--timeout=3') add_to_args.append('--timeout=3')
@ -228,7 +238,8 @@ def http(*args, program_name='http', **kwargs):
sys.stderr.write(stderr.read()) sys.stderr.write(stderr.read())
raise raise
else: else:
if not tolerate_error_exit_status and exit_status != ExitStatus.SUCCESS: if (not tolerate_error_exit_status
and exit_status != ExitStatus.SUCCESS):
dump_stderr() dump_stderr()
raise ExitStatusError( raise ExitStatusError(
'httpie.core.main() unexpectedly returned' 'httpie.core.main() unexpectedly returned'