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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
16 changes: 16 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
47 changes: 40 additions & 7 deletions httpie/config.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -135,7 +168,7 @@ def version(self):


class Config(BaseConfigDict):
FILENAME = 'config.json'
FILENAME = DEFAULT_CONFIG_FILENAME
DEFAULTS = {
'default_options': []
}
Expand Down
52 changes: 50 additions & 2 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Loading