Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ and versions are tracked in the repo-root `VERSION` file.

### Fixed

- Reject missing, unreadable, and non-regular explicit `--config` paths before
profile or runtime startup while preserving optional profile-owned files.
- Make `App.name` authoritative for single-command identity, reject duplicate
or post-materialization registrations deterministically, stabilize inferred
names across Click releases, and make module-level `@command()` functions
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,12 @@ lifecycle after the profile's configuration is loaded; for example,
`--environment prod` overrides `environment: dev` from an explicit
configuration file.

An explicit `--config` value must identify an existing, readable regular file;
invalid paths are rejected as usage errors before profile loading or runtime
state begins. Invalid YAML reports that the file `contains invalid YAML`, while
a non-mapping document reports that it `must contain a YAML mapping`. Each error
includes the selected path. Quoted home-relative paths such as
`--config "~/tool.yml"` are expanded before validation.

`ctx.config` exposes the dictionary returned by the profile. `ctx.user_config`
exposes the opaque user-configuration value returned by the profile. Consumers
Expand Down
6 changes: 6 additions & 0 deletions docs/local-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,11 @@ Standalone applications can accept an explicit `--config` file through the
generic profile, or provide their own `load_user_config` and `load_config`
callbacks for application-owned configuration sources.

Command-line `--config` values are strict: the path is expanded and must be an
existing, readable regular file before profile and runtime startup. By contrast,
`base_cli.config.load_yaml_file(path)` keeps its optional-file behavior for
profile-discovered configuration; pass `required=True` when a consumer-owned
call site represents an explicit user request.

The consumer owns the configuration schema, merge semantics, and operational
choice of whether to back up or synchronize its machine-local files.
32 changes: 31 additions & 1 deletion lib/python/base_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import functools
import logging
import os
import stat
import sys
import time
import traceback
Expand Down Expand Up @@ -925,7 +926,11 @@ def decorator(func: Callable[..., Any]):
def _decorate_standard_options(click: Any, func: Callable[..., Any], version: str | None):
func = click.option("--log-file", type=click.Path(dir_okay=False), help="Override the persistent log file.")(func)
func = click.option("--keep-temp", is_flag=True, default=None, help="Preserve this run's temp directory.")(func)
func = click.option("--config", type=click.Path(dir_okay=False), help="Load an additional config file.")(func)
func = click.option(
"--config",
type=_explicit_config_path_type(click),
help="Load an additional config file.",
)(func)
func = click.option("--environment", help="Set the CLI environment.")(func)
func = click.option(
"--debug",
Expand All @@ -945,6 +950,31 @@ def _decorate_standard_options(click: Any, func: Callable[..., Any], version: st
return func


def _explicit_config_path_type(click: Any) -> Any:
class ExplicitConfigPath(click.Path):
def convert(self, value: Any, param: Any, ctx: Any) -> Path:
try:
expanded = Path(value).expanduser()
except (RuntimeError, TypeError, ValueError) as exc:
self.fail(f"Path {value!r} could not be expanded: {exc}", param, ctx)

converted = super().convert(expanded, param, ctx)
try:
mode = converted.stat().st_mode
except OSError:
self.fail(f"Path {str(expanded)!r} does not exist.", param, ctx)
if not stat.S_ISREG(mode):
self.fail(f"Path {str(expanded)!r} is not a regular file.", param, ctx)
return converted

return ExplicitConfigPath(
exists=True,
dir_okay=False,
readable=True,
path_type=Path,
)


def _pop_standard_options(kwargs: dict[str, Any]) -> dict[str, Any]:
standard = {}
for key in _STANDARD_OPTION_KEYS:
Expand Down
26 changes: 24 additions & 2 deletions lib/python/base_cli/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import stat
from pathlib import Path
from typing import Any

Expand All @@ -12,16 +13,37 @@
]


def load_yaml_file(path: Path) -> dict[str, Any]:
if not path.is_file():
def load_yaml_file(path: Path, *, required: bool = False) -> dict[str, Any]:
"""Load a YAML mapping, optionally requiring a regular file to exist.

Missing files remain an empty mapping by default so consumer profiles can
use this helper for optional implicit configuration. Callers handling an
explicitly requested file must set ``required=True``.
"""
if required:
try:
mode = path.stat().st_mode
except FileNotFoundError as exc:
raise ConfigurationError(f"Config file '{path}' does not exist.") from exc
except OSError as exc:
raise ConfigurationError(f"Unable to read config file '{path}': {exc}") from exc
if not stat.S_ISREG(mode):
raise ConfigurationError(f"Config path '{path}' is not a regular file.")
elif not path.is_file():
return {}

yaml = require_yaml("PyYAML is required to load the explicit CLI configuration file.")

try:
contents = path.read_text(encoding="utf-8")
except FileNotFoundError as exc:
if required:
raise ConfigurationError(f"Config file '{path}' does not exist.") from exc
return {}
except OSError as exc:
raise ConfigurationError(f"Unable to read config file '{path}': {exc}") from exc
except UnicodeDecodeError as exc:
raise ConfigurationError(f"Unable to read config file '{path}': {exc}") from exc
try:
data = yaml.safe_load(contents)
except yaml.YAMLError as exc:
Expand Down
2 changes: 1 addition & 1 deletion lib/python/base_cli/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ def _empty_user_config() -> None:


def _load_explicit_config(_project: ProjectInfo | None, explicit: Path | None) -> dict[str, Any]:
return load_yaml_file(explicit) if explicit is not None else {}
return load_yaml_file(explicit, required=True) if explicit is not None else {}


def _generic_runtime_resolver(
Expand Down
Loading