diff --git a/httpie/config.py b/httpie/config.py index 27bc0a784d..ced64341bb 100644 --- a/httpie/config.py +++ b/httpie/config.py @@ -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: @@ -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) diff --git a/httpie/core.py b/httpie/core.py index d0c26dcbcc..4d1d785383 100644 --- a/httpie/core.py +++ b/httpie/core.py @@ -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 ( @@ -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 @@ -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( diff --git a/tests/test_local_config.py b/tests/test_local_config.py new file mode 100644 index 0000000000..b06d799167 --- /dev/null +++ b/tests/test_local_config.py @@ -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