Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions httpie/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@

ENV_XDG_CONFIG_HOME = 'XDG_CONFIG_HOME'
ENV_HTTPIE_CONFIG_DIR = 'HTTPIE_CONFIG_DIR'
ENV_HTTPIE_LOCAL_CONFIG = 'HTTPIE_LOCAL_CONFIG'
DEFAULT_CONFIG_DIRNAME = 'httpie'
DEFAULT_RELATIVE_XDG_CONFIG_HOME = Path('.config')
DEFAULT_RELATIVE_LEGACY_CONFIG_DIR = Path('.httpie')
DEFAULT_WINDOWS_CONFIG_DIR = Path(
os.path.expandvars('%APPDATA%')) / DEFAULT_CONFIG_DIRNAME
LOCAL_CONFIG_FILENAME = '.httpie'
LOCAL_CONFIG_KEYS = ('default_options', 'headers', 'query')


def get_default_config_dir() -> Path:
Expand Down Expand Up @@ -170,3 +173,75 @@ def developer_mode(self) -> bool:
we usually ignore."""

return self.get('developer_mode')


class LocalConfig(dict):
"""A per-CWD `.httpie` config layered on top of the user config.

Recognised keys: `default_options` (list of CLI args),
`headers` (object) and `query` (object). Unknown keys are ignored.
"""

def __init__(self, path: Path, data: Dict[str, Any]):
super().__init__()
self.path = path
for key in LOCAL_CONFIG_KEYS:
if key in data:
self[key] = data[key]

@property
def default_options(self) -> list:
value = self.get('default_options', [])
return value if isinstance(value, list) else []

@property
def headers(self) -> Dict[str, str]:
value = self.get('headers', {})
return value if isinstance(value, dict) else {}

@property
def query(self) -> Dict[str, str]:
value = self.get('query', {})
return value if isinstance(value, dict) else {}

def is_empty(self) -> bool:
return not (self.default_options or self.headers or self.query)

def apply_to_parsed_args(self, args) -> None:
"""Fill in headers/query from local config, never overriding CLI values."""
if self.headers and hasattr(args, 'headers'):
for key, value in self.headers.items():
if key not in args.headers:
args.headers.add(key, value)
if self.query and hasattr(args, 'params'):
for key, value in self.query.items():
if key not in args.params:
args.params[key] = value


def get_local_config_path() -> Path:
"""Resolve the path the local config would live at (may not exist)."""
override = os.environ.get(ENV_HTTPIE_LOCAL_CONFIG)
if override:
return Path(override)
return Path.cwd() / LOCAL_CONFIG_FILENAME


def load_local_config() -> Union['LocalConfig', None]:
"""Return the local config from CWD, or None if absent/unreadable.

Raises ConfigFileError on invalid JSON so callers can surface it.
"""
try:
path = get_local_config_path()
except (OSError, FileNotFoundError):
# CWD was deleted out from under us.
return None
data = read_raw_config('local config', path)
if data is None:
return None
if not isinstance(data, dict):
raise ConfigFileError(
f'invalid local config file: top-level value must be an object [{path}]'
)
return LocalConfig(path=path, data=data)
25 changes: 24 additions & 1 deletion httpie/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .cli.constants import OUT_REQ_BODY
from .cli.nested_json import NestedJSONSyntaxError
from .client import collect_messages
from .config import ConfigFileError, LocalConfig, load_local_config
from .context import Environment, LogLevel
from .downloads import Downloader
from .models import (
Expand Down Expand Up @@ -45,8 +46,28 @@ def raw_main(

plugin_manager.load_installed_plugins(env.config.plugins_dir)

local_config: Optional[LocalConfig] = None
if use_default_options:
try:
local_config = load_local_config()
except ConfigFileError as e:
env.log_error(str(e), level=LogLevel.WARNING)
if local_config and not local_config.is_empty():
env.log_error(
f'loaded local config {local_config.path}'
f' ({len(local_config.default_options)} options,'
f' {len(local_config.headers)} headers,'
f' {len(local_config.query)} query)',
level=LogLevel.INFO,
)

prepend: List[str] = []
if use_default_options and env.config.default_options:
args = env.config.default_options + args
prepend.extend(env.config.default_options)
if local_config and local_config.default_options:
prepend.extend(local_config.default_options)
if prepend:
args = prepend + args

include_debug_info = '--debug' in args
include_traceback = include_debug_info or '--traceback' in args
Expand Down Expand Up @@ -95,6 +116,8 @@ def handle_generic_error(e, annotation=None):
raise
exit_status = ExitStatus.ERROR
else:
if local_config:
local_config.apply_to_parsed_args(parsed_args)
check_updates(env)
try:
exit_status = main_program(
Expand Down
173 changes: 173 additions & 0 deletions tests/test_local_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import json
from pathlib import Path

import pytest
from _pytest.monkeypatch import MonkeyPatch

from httpie.config import (
ENV_HTTPIE_LOCAL_CONFIG, LOCAL_CONFIG_FILENAME, LocalConfig,
get_local_config_path, load_local_config,
)
from .utils import MockEnvironment, http

URL = 'http://example.com/path'


@pytest.fixture
def local_config(monkeypatch: MonkeyPatch, tmp_path: Path):
"""Yield a callable that writes a local config and points HTTPIE_LOCAL_CONFIG at it."""
path = tmp_path / LOCAL_CONFIG_FILENAME

def write(data):
if isinstance(data, str):
path.write_text(data)
else:
path.write_text(json.dumps(data))
monkeypatch.setenv(ENV_HTTPIE_LOCAL_CONFIG, str(path))
return path

monkeypatch.delenv(ENV_HTTPIE_LOCAL_CONFIG, raising=False)
yield write


# --- Path resolution -------------------------------------------------------

def test_local_config_path_env_override(monkeypatch, tmp_path):
custom = tmp_path / 'somewhere/.httpie'
monkeypatch.setenv(ENV_HTTPIE_LOCAL_CONFIG, str(custom))
assert get_local_config_path() == custom


def test_local_config_path_defaults_to_cwd(monkeypatch, tmp_path):
monkeypatch.delenv(ENV_HTTPIE_LOCAL_CONFIG, raising=False)
monkeypatch.chdir(tmp_path)
assert get_local_config_path() == tmp_path / LOCAL_CONFIG_FILENAME


# --- Loader ----------------------------------------------------------------

def test_load_returns_none_when_absent(monkeypatch, tmp_path):
monkeypatch.setenv(ENV_HTTPIE_LOCAL_CONFIG, str(tmp_path / 'missing'))
assert load_local_config() is None


def test_load_returns_typed_object(monkeypatch, tmp_path):
path = tmp_path / LOCAL_CONFIG_FILENAME
path.write_text(json.dumps({
'default_options': ['--form'],
'headers': {'X-A': '1'},
'query': {'q': 'v'},
'mystery': 'ignored',
}))
monkeypatch.setenv(ENV_HTTPIE_LOCAL_CONFIG, str(path))
cfg = load_local_config()
assert isinstance(cfg, LocalConfig)
assert cfg.default_options == ['--form']
assert cfg.headers == {'X-A': '1'}
assert cfg.query == {'q': 'v'}
assert 'mystery' not in cfg
assert not cfg.is_empty()


def test_load_empty_object_is_empty(monkeypatch, tmp_path):
path = tmp_path / LOCAL_CONFIG_FILENAME
path.write_text('{}')
monkeypatch.setenv(ENV_HTTPIE_LOCAL_CONFIG, str(path))
cfg = load_local_config()
assert cfg.is_empty()


# --- Integration via --offline --------------------------------------------

def test_no_local_config_is_noop(monkeypatch, tmp_path):
monkeypatch.setenv(ENV_HTTPIE_LOCAL_CONFIG, str(tmp_path / 'missing'))
r = http('--offline', URL, env=MockEnvironment())
assert 'loaded local config' not in r.stderr
assert 'X-' not in r # no surprise headers


def test_default_options_from_local(local_config):
local_config({'default_options': ['--form']})
r = http('--offline', 'POST', URL, 'foo=bar', env=MockEnvironment())
assert 'Content-Type: application/x-www-form-urlencoded' in r
assert 'foo=bar' in r


def test_cli_overrides_local_default_options(local_config):
local_config({'default_options': ['--form']})
r = http('--offline', '--json', 'POST', URL, 'foo=bar', env=MockEnvironment())
assert 'Content-Type: application/json' in r


def test_local_overrides_user_default_options(local_config):
env = MockEnvironment()
env.config['default_options'] = ['--form']
env.config.save()
local_config({'default_options': ['--json']})
r = http('--offline', 'POST', URL, 'foo=bar', env=env)
assert 'Content-Type: application/json' in r


def test_header_from_local(local_config):
local_config({'headers': {'X-Custom': 'from-local'}})
r = http('--offline', URL, env=MockEnvironment())
assert 'X-Custom: from-local' in r


def test_cli_overrides_local_header(local_config):
local_config({'headers': {'X-Custom': 'from-local'}})
r = http('--offline', URL, 'X-Custom:from-cli', env=MockEnvironment())
assert 'X-Custom: from-cli' in r
assert 'from-local' not in r


def test_local_header_case_insensitive_against_cli(local_config):
local_config({'headers': {'X-Custom': 'from-local'}})
r = http('--offline', URL, 'x-custom:from-cli', env=MockEnvironment())
assert 'from-cli' in r
assert 'from-local' not in r


def test_query_from_local(local_config):
local_config({'query': {'api_version': '2'}})
r = http('--offline', URL, env=MockEnvironment())
assert 'GET /path?api_version=2' in r


def test_cli_overrides_local_query(local_config):
local_config({'query': {'api_version': '2'}})
r = http('--offline', URL, 'api_version==3', env=MockEnvironment())
assert 'api_version=3' in r
assert 'api_version=2' not in r


def test_stderr_notice_when_loaded(local_config):
local_config({'headers': {'X-Custom': 'x'}})
r = http('--offline', URL, env=MockEnvironment())
assert 'loaded local config' in r.stderr
assert '1 headers' in r.stderr


def test_no_stderr_notice_when_empty(local_config):
local_config({})
r = http('--offline', URL, env=MockEnvironment())
assert 'loaded local config' not in r.stderr


def test_invalid_json_warns_and_continues(local_config):
local_config('{not valid json')
r = http('--offline', URL, env=MockEnvironment())
assert 'warning' in r.stderr
assert 'invalid local config file' in r.stderr


def test_non_object_top_level_warns(local_config):
local_config([1, 2, 3])
r = http('--offline', URL, env=MockEnvironment())
assert 'warning' in r.stderr


def test_unknown_keys_ignored(local_config):
local_config({'headers': {'X-Custom': 'x'}, 'mystery': 'value'})
r = http('--offline', URL, env=MockEnvironment())
assert 'X-Custom: x' in r
Loading