Created
May 21, 2026 07:05
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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