Skip to content

Instantly share code, notes, and snippets.

@tobiashochguertel
Created May 21, 2026 07:05
Show Gist options
  • Select an option

  • Save tobiashochguertel/cdb70a7c1b00aa31910ac9d40fac815c to your computer and use it in GitHub Desktop.

Select an option

Save tobiashochguertel/cdb70a7c1b00aa31910ac9d40fac815c to your computer and use it in GitHub Desktop.
Find config files by walking up parent directories (git-style discovery) with customizable path variant matrix
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "pydantic>=2.0",
# ]
# ///
"""
config-discover.py
Find a config file by walking up parent directories, like git discovers
.gitignore files. Uses discover_paths() to check multiple filename variants
(config.yaml, .config.yaml, config/config.yaml, .config/config.yaml, ...)
at each directory level.
Usage:
./config-discover.py # uses defaults
./config-discover.py mytool # custom basename
./config-discover.py mytool --ext json toml # custom extensions
RESOLVE_CONFIG_PATH=/custom/path ./config-discover.py # env override
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import Literal
from pydantic import BaseModel
# ── Path discovery ───────────────────────────────────────────────────────────
class DiscoveryConfig(BaseModel):
"""Controls which filename variants discover_paths() generates."""
search_cwd: bool = True
dot_prefixed: bool = True
dot_directory: bool = True
extensions: list[str] = ["yaml", "yml"]
directories: list[str] = ["config"]
def discover_paths(name: str, config: DiscoveryConfig | None = None) -> list[Path]:
"""Generate all possible file path variants for a config filename.
Args:
name: Base filename without extension (e.g., "mytool").
config: Overrides. See DiscoveryConfig for defaults.
Returns:
Deduplicated list of Path objects, cwd variants first.
"""
if config is None:
config = DiscoveryConfig()
dirs: list[str] = list(config.directories)
if config.search_cwd:
dirs.insert(0, "")
expanded_dirs: list[str] = []
for d in dirs:
expanded_dirs.append(d)
if d and config.dot_directory and not d.startswith("."):
expanded_dirs.append(f".{d}")
seen: set[str] = set()
paths: list[Path] = []
for ext in config.extensions:
for directory in expanded_dirs:
dir_prefix = f"{directory}/" if directory else ""
candidates: list[str] = [f"{dir_prefix}{name}.{ext}"]
if config.dot_prefixed and not directory:
candidates.append(f".{name}.{ext}")
for c in candidates:
if c not in seen:
seen.add(c)
paths.append(Path(c))
return paths
# ── Parent-walking resolver ─────────────────────────────────────────────────
def resolve_config(
name: str,
*,
env_var: str | None = None,
config: DiscoveryConfig | None = None,
start_dir: Path | None = None,
) -> Path:
"""Find a config file by walking up from start_dir toward root.
Like git discovering .gitignore: at each parent level, every
path variant from discover_paths() is checked. The first hit
is returned.
Args:
name: Base filename (e.g., "mytool").
env_var: Optional env var name for an explicit override path.
config: Overrides for the discovery matrix.
start_dir: Directory to start walking from (default: cwd).
Returns:
Path to the existing config file, or the default candidate
(first cwd variant) if nothing is found.
"""
if config is None:
config = DiscoveryConfig()
if env_var and (env_path := os.environ.get(env_var)):
return Path(env_path)
candidates = discover_paths(name, config)
start = start_dir or Path.cwd()
for parent in [start] + list(start.parents):
for candidate in candidates:
full = parent / candidate
if full.exists():
return full
return start / candidates[0]
# ── CLI ─────────────────────────────────────────────────────────────────────
def main() -> None:
name = sys.argv[1] if len(sys.argv) > 1 else "config"
found = resolve_config(
name,
env_var="RESOLVE_CONFIG_PATH",
)
print(f"Name: {name}")
print(f"Resolved: {found}")
print(f"Exists: {(found.exists())}")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment