From 10df6bd422aae0f8501193c9d8d5a72dc9562597 Mon Sep 17 00:00:00 2001 From: Max Koval Date: Fri, 24 Jul 2026 15:57:12 +0300 Subject: [PATCH] Add support for project-local config directories (#1229) Look for a `.httpie` directory containing `config.json` in the current working directory and its parents (nearest match wins), before falling back to the global config location. An explicitly set `$HTTPIE_CONFIG_DIR` still takes precedence. --- CHANGELOG.md | 4 ++++ docs/README.md | 16 ++++++++++++++ httpie/config.py | 47 +++++++++++++++++++++++++++++++++------ tests/test_config.py | 52 ++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 110 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0497ac3508..d469386621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ This document records all notable changes to [HTTPie](https://httpie.io). This project adheres to [Semantic Versioning](https://semver.org/). +## Unreleased + +- Added support for project-local config directories (`.httpie/config.json`), discovered recursively from the current working directory upwards. ([#1229](https://github.com/httpie/cli/issues/1229)) + ## [3.2.4](https://github.com/httpie/cli/compare/3.2.3...3.2.4) (2024-11-01) - Fix default certs loading and unpin `requests`. ([#1596](https://github.com/httpie/cli/issues/1596)) diff --git a/docs/README.md b/docs/README.md index 51dff51aec..93f7a288e0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2338,6 +2338,22 @@ $ export HTTPIE_CONFIG_DIR=/tmp/httpie $ http pie.dev/get ``` +### Project-local config directory + +HTTPie also looks for a project-local configuration: a `.httpie` directory containing a `config.json` file, located in the current working directory or any of its parent directories (similar to how `.npmrc` or `.git` are discovered). The nearest match wins, and it takes precedence over the global configuration: + +```bash +$ mkdir .httpie +$ echo '{"default_options": ["--auth=user:password"]}' > .httpie/config.json +$ http localhost:3000/api/endpoint # picks up the local config +``` + +This makes it possible to keep per-project defaults — such as authentication for a local development server — scoped to that project’s directory tree, without polluting the global config or affecting requests made elsewhere. + +A `.httpie` directory without a `config.json` file inside is ignored. The `$HTTPIE_CONFIG_DIR` environment variable, when set, takes precedence over the project-local discovery. + +Note that the active config directory also stores [sessions](#sessions), so named sessions created inside a project with a local config are scoped to that project. + ### Configurable options Currently, HTTPie offers a single configurable option: diff --git a/httpie/config.py b/httpie/config.py index 27bc0a784d..3727da6549 100644 --- a/httpie/config.py +++ b/httpie/config.py @@ -1,7 +1,7 @@ import json import os from pathlib import Path -from typing import Any, Dict, Union +from typing import Any, Dict, Optional, Union from . import __version__ from .compat import is_windows @@ -11,12 +11,39 @@ ENV_XDG_CONFIG_HOME = 'XDG_CONFIG_HOME' ENV_HTTPIE_CONFIG_DIR = 'HTTPIE_CONFIG_DIR' DEFAULT_CONFIG_DIRNAME = 'httpie' +DEFAULT_CONFIG_FILENAME = 'config.json' DEFAULT_RELATIVE_XDG_CONFIG_HOME = Path('.config') DEFAULT_RELATIVE_LEGACY_CONFIG_DIR = Path('.httpie') +DEFAULT_LOCAL_CONFIG_DIRNAME = '.httpie' DEFAULT_WINDOWS_CONFIG_DIR = Path( os.path.expandvars('%APPDATA%')) / DEFAULT_CONFIG_DIRNAME +def find_local_config_dir(start_dir: Optional[Path] = None) -> Optional[Path]: + """ + Return the path to a local (per-project) configuration directory, + or None if there isn’t one. + + Look for a `.httpie` directory containing a `config.json` file + in `start_dir` (defaults to the current working directory) and, + recursively, in its parents. The nearest match wins. + + The `config.json` file is required so that unrelated `.httpie` + directories don’t accidentally become the active configuration + directory. + + """ + try: + current_dir = (start_dir or Path.cwd()).resolve() + except OSError: + return None + for directory in (current_dir, *current_dir.parents): + candidate = directory / DEFAULT_LOCAL_CONFIG_DIRNAME + if (candidate / DEFAULT_CONFIG_FILENAME).is_file(): + return candidate + return None + + def get_default_config_dir() -> Path: """ Return the path to the httpie configuration directory. @@ -36,21 +63,27 @@ def get_default_config_dir() -> Path: if env_config_dir: return Path(env_config_dir) - # 2. Windows + # 2. local per-project directory (a `.httpie/config.json` found + # in the current working directory or one of its parents) + local_config_dir = find_local_config_dir() + if local_config_dir: + return local_config_dir + + # 3. Windows if is_windows: return DEFAULT_WINDOWS_CONFIG_DIR home_dir = Path.home() - # 3. legacy ~/.httpie + # 4. legacy ~/.httpie legacy_config_dir = home_dir / DEFAULT_RELATIVE_LEGACY_CONFIG_DIR if legacy_config_dir.exists(): return legacy_config_dir - # 4. XDG + # 5. XDG xdg_config_home_dir = os.environ.get( - ENV_XDG_CONFIG_HOME, # 4.1. explicit - home_dir / DEFAULT_RELATIVE_XDG_CONFIG_HOME # 4.2. default + ENV_XDG_CONFIG_HOME, # 5.1. explicit + home_dir / DEFAULT_RELATIVE_XDG_CONFIG_HOME # 5.2. default ) return Path(xdg_config_home_dir) / DEFAULT_CONFIG_DIRNAME @@ -135,7 +168,7 @@ def version(self): class Config(BaseConfigDict): - FILENAME = 'config.json' + FILENAME = DEFAULT_CONFIG_FILENAME DEFAULTS = { 'default_options': [] } diff --git a/tests/test_config.py b/tests/test_config.py index 1d2eea0750..81d1a7f02b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,9 +6,11 @@ from httpie.compat import is_windows from httpie.encoding import UTF8 from httpie.config import ( - Config, DEFAULT_CONFIG_DIRNAME, DEFAULT_RELATIVE_LEGACY_CONFIG_DIR, + Config, DEFAULT_CONFIG_DIRNAME, DEFAULT_CONFIG_FILENAME, + DEFAULT_LOCAL_CONFIG_DIRNAME, DEFAULT_RELATIVE_LEGACY_CONFIG_DIR, DEFAULT_RELATIVE_XDG_CONFIG_HOME, DEFAULT_WINDOWS_CONFIG_DIR, - ENV_HTTPIE_CONFIG_DIR, ENV_XDG_CONFIG_HOME, get_default_config_dir, + ENV_HTTPIE_CONFIG_DIR, ENV_XDG_CONFIG_HOME, find_local_config_dir, + get_default_config_dir, ) from .utils import HTTP_OK, MockEnvironment, http @@ -102,3 +104,49 @@ def test_custom_config_dir(monkeypatch: MonkeyPatch, tmp_path: Path): def test_windows_config_dir(monkeypatch: MonkeyPatch): monkeypatch.delenv(ENV_HTTPIE_CONFIG_DIR, raising=False) assert get_default_config_dir() == DEFAULT_WINDOWS_CONFIG_DIR + + +def make_local_config_dir(directory: Path) -> Path: + local_config_dir = directory / DEFAULT_LOCAL_CONFIG_DIRNAME + local_config_dir.mkdir(parents=True) + (local_config_dir / DEFAULT_CONFIG_FILENAME).write_text('{}', encoding=UTF8) + return local_config_dir + + +def test_local_config_dir_in_cwd(monkeypatch: MonkeyPatch, tmp_path: Path): + monkeypatch.delenv(ENV_HTTPIE_CONFIG_DIR, raising=False) + local_config_dir = make_local_config_dir(tmp_path) + monkeypatch.chdir(tmp_path) + assert get_default_config_dir() == local_config_dir + + +def test_local_config_dir_in_parent(monkeypatch: MonkeyPatch, tmp_path: Path): + monkeypatch.delenv(ENV_HTTPIE_CONFIG_DIR, raising=False) + local_config_dir = make_local_config_dir(tmp_path) + nested_dir = tmp_path / 'deeply' / 'nested' / 'directory' + nested_dir.mkdir(parents=True) + monkeypatch.chdir(nested_dir) + assert get_default_config_dir() == local_config_dir + + +def test_local_config_dir_nearest_match_wins(monkeypatch: MonkeyPatch, tmp_path: Path): + make_local_config_dir(tmp_path) + nested_dir = tmp_path / 'nested' + nested_dir.mkdir() + nested_config_dir = make_local_config_dir(nested_dir) + assert find_local_config_dir(start_dir=nested_dir) == nested_config_dir + + +def test_local_config_dir_requires_config_file(monkeypatch: MonkeyPatch, tmp_path: Path): + (tmp_path / DEFAULT_LOCAL_CONFIG_DIRNAME).mkdir() + assert find_local_config_dir(start_dir=tmp_path) is None + + +def test_local_config_dir_ignored_when_env_config_dir_set( + monkeypatch: MonkeyPatch, tmp_path: Path +): + make_local_config_dir(tmp_path) + monkeypatch.chdir(tmp_path) + httpie_config_dir = tmp_path / 'custom/directory' + monkeypatch.setenv(ENV_HTTPIE_CONFIG_DIR, str(httpie_config_dir)) + assert get_default_config_dir() == httpie_config_dir