diff --git a/CHANGELOG.md b/CHANGELOG.md index e84fb84..cb11aae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ and versions are tracked in the repo-root `VERSION` file. - Add public typed runtime/profile/context contracts, generic attachment factories, isolated command-schema registries/codecs, and a strict consumer typing example. +- Add opt-in `CliProfile.batteries_included()` layered configuration with + platform-aware user paths, project and environment files, provenance, and + validated framework settings. ### Changed diff --git a/README.md b/README.md index b818307..d1da0c8 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,10 @@ command subtype in their return type. factories define the Click attachment boundary for adapters that compose or wrap an attached command. +`ConfigSnapshot` and `FrameworkConfig` provide the typed result boundary for +the opt-in batteries-included profile: consumer configuration, validated +lifecycle settings, and per-key provenance remain separate. + Command protocol schemas can be isolated per consumer with `CommandSchemaRegistry` and `CommandCodec`. The module-level registration and codec helpers remain compatible defaults backed by `RECORD_SCHEMAS`, but new @@ -647,6 +651,10 @@ Important fields include: - `ctx.log_file`: the run's shared `logs/primary.log`, or `None` when persistent logging is disabled. - `ctx.config`: merged configuration dictionary. +- `ctx.framework_config`: validated lifecycle settings supplied by the + batteries-included profile, or `None` for generic/custom dictionary loaders. +- `ctx.config_provenance`: winning source layer for each dotted configuration + key when a layered snapshot is used. - `ctx.application_context`: optional application state returned by an attachment's `context_factory`, or `None`. - `ctx.services`: optional services returned by an attachment's @@ -776,6 +784,21 @@ that need user files, project files, environment variables, or a merge precedence must implement those policies in `CliProfile.load_config` and `CliProfile.load_user_config`; `base_cli` does not define the value's fields. +Applications that want a standard opt-in policy can use +`CliProfile.batteries_included("tool")`. It discovers optional platform-aware +user (`config.yaml`), project (`.base-cli.yaml`), and environment +(`environments/.yaml`) layers before the explicit file. The precedence is +defaults → user → project → user environment → project environment → explicit +file → lifecycle command-line options. The selected environment comes from +`--environment`, then the explicit/project/user base files, and defaults to +`dev`. Mappings merge recursively; scalar and list values replace lower layers. + +The batteries-included profile validates the framework keys `environment`, +`log_level`, and `keep_temp` into `ctx.framework_config`, keeps consumer keys in +`ctx.config`, and records winning dotted-key sources in +`ctx.config_provenance`. Missing implicit files remain harmless; explicit +`--config` paths retain strict validation. + ## Project Discovery The generic profile does not discover projects or assume a manifest filename. diff --git a/docs/consumer-profiles.md b/docs/consumer-profiles.md index 68802a0..bfaab10 100644 --- a/docs/consumer-profiles.md +++ b/docs/consumer-profiles.md @@ -80,6 +80,46 @@ translate internal entry-point names into user-facing labels. The generic default only replaces underscores with hyphens; it does not know any product's command aliases. +## Batteries-included profile + +Applications that want conventional configuration discovery can opt in without +changing the generic defaults: + +```python +profile = base_cli.CliProfile.batteries_included("tool") +app = base_cli.App(name="tool", profile=profile) +``` + +The profile uses platform-aware user configuration roots (`XDG_CONFIG_HOME` or +`~/.config` on Linux, `~/Library/Application Support` on macOS, and `%APPDATA%` +on Windows). `BASE_CLI_CONFIG_DIR` overrides that root. User files live under +`//config.yaml`; a discovered project may provide +`.base-cli.yaml` and `environments/.yaml` files. All of these layers are +optional and their filenames can be customized by the profile factory. An +explicit `--config` path remains strict and must exist as a readable regular +file. + +Configuration precedence is deterministic, from lowest to highest: + +1. framework default (`environment: dev`); +2. user base configuration; +3. project base configuration; +4. user environment configuration; +5. project environment configuration; +6. explicit `--config` configuration; +7. command-line lifecycle options. + +The environment is selected by `--environment` when supplied. Otherwise the +explicit, project, or user base `environment` value is used, falling back to +`dev`. Mapping values merge recursively; scalar and list values replace the +lower-precedence value. `Context.config_provenance` records the winning source +for each dotted key. + +The reserved framework keys `environment`, `log_level`, and `keep_temp` are +validated into `Context.framework_config` and are excluded from the consumer +configuration dictionary. All other keys remain consumer-owned and are exposed +through `Context.config`. + ## Safe profile errors Plain exceptions from profile callbacks are treated as unexpected internal @@ -118,8 +158,8 @@ The following behaviors should not be added to generic lifecycle modules: - product-specific command lists or history schema; - assumptions about a downstream repository's directory layout. -The next migration step is to generalize the remaining context/config types -whose compatibility names still reflect one historical consumer. +The generic profile and typed `Context` are now the stable framework boundary; +consumer-specific conventions belong in an opt-in profile or adapter. The package rename is deliberately separate from this refactor. Names can be changed after the dependency boundary is stable. diff --git a/docs/local-config.md b/docs/local-config.md index abe63c0..207b919 100644 --- a/docs/local-config.md +++ b/docs/local-config.md @@ -13,3 +13,11 @@ 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. + +Applications that prefer conventional policy can opt into +`CliProfile.batteries_included("tool")`. It loads optional platform-aware user, +project, environment, and explicit YAML layers with documented precedence and +records the winning source for each key in `Context.config_provenance`. Its +reserved lifecycle keys are validated separately as `Context.framework_config`; +consumer-owned keys remain in `Context.config`. `CliProfile.generic()` remains +the convention-free default. diff --git a/lib/python/base_cli/__init__.py b/lib/python/base_cli/__init__.py index f1d5fd7..1cd183c 100644 --- a/lib/python/base_cli/__init__.py +++ b/lib/python/base_cli/__init__.py @@ -37,6 +37,7 @@ def _resolve_version() -> str: AttachmentContract, AttachmentServiceFactory, ) +from .config import BatteriesIncludedConfigLoader, ConfigSnapshot, FrameworkConfig from .app import ( App, argument, @@ -94,6 +95,7 @@ def _resolve_version() -> str: CliProfile, ConfigLoader, DisplayCommandResolver, + EnvironmentConfigLoader, HistoryDisplayResolver, HistoryWriter, ProjectDiscovery, @@ -112,10 +114,12 @@ def _resolve_version() -> str: "AttachmentContextFactory", "AttachmentContract", "AttachmentServiceFactory", + "BatteriesIncludedConfigLoader", "BOOLEAN", "ApplicationStateT", "CliProfile", "ConfigLoader", + "ConfigSnapshot", "CommandFilterNormalizer", "CommandCodec", "CommandProtocolError", @@ -125,8 +129,10 @@ def _resolve_version() -> str: "ConfigT", "DEFAULT_SCHEMA_REGISTRY", "DisplayCommandResolver", + "EnvironmentConfigLoader", "ExitCode", "FieldSpec", + "FrameworkConfig", "LIFECYCLE_META_KEY", "LifecycleOption", "LifecycleOptions", diff --git a/lib/python/base_cli/app.py b/lib/python/base_cli/app.py index d990ea8..7142cef 100644 --- a/lib/python/base_cli/app.py +++ b/lib/python/base_cli/app.py @@ -31,6 +31,7 @@ prune_log_files, ) from .attachment import AttachmentContract +from .config import ConfigSnapshot from .context import Context, recover_current_context, reset_current_context, set_current_context from .errors import ConfigurationError from .exit_codes import ExitCode @@ -1054,12 +1055,46 @@ def _create_context( explicit_config = Path(standard["config"]).expanduser() if standard.get("config") else None user_config = self.profile.load_user_config() workspace_root = self.profile.resolve_workspace_root(user_config) - config = self.profile.load_config(project, explicit_config) + requested_environment = standard.get("environment") + if ( + self.profile.load_config_for_environment is not None + and requested_environment is not None + ): + loaded_config = self.profile.load_config_for_environment( + project, + explicit_config, + str(requested_environment), + ) + else: + loaded_config = self.profile.load_config(project, explicit_config) - environment = standard.get("environment") or config.get("environment") or "dev" - debug = bool(standard.get("debug") or str(config.get("log_level", "")).lower() == "debug") + if isinstance(loaded_config, ConfigSnapshot): + config = loaded_config.config + framework_config = loaded_config.framework + config_provenance = loaded_config.provenance + else: + config = loaded_config + framework_config = None + config_provenance = {} + + environment = ( + standard.get("environment") + or (framework_config.environment if framework_config is not None else None) + or config.get("environment") + or "dev" + ) + log_level = ( + framework_config.log_level + if framework_config is not None + else str(config.get("log_level", "")).lower() + ) + debug = bool(standard.get("debug") or log_level == "debug") quiet = bool(standard.get("quiet")) - keep_temp = bool(standard.get("keep_temp") or config.get("keep_temp")) + keep_temp = bool( + standard.get("keep_temp") + or (framework_config.keep_temp if framework_config is not None else None) + or config.get("keep_temp") + ) _capture_effective_output_options( owner_app=self, debug=debug, @@ -1101,6 +1136,8 @@ def _create_context( temp_dir=layout.temp_dir, log_file=log_file, config=config, + framework_config=framework_config, + config_provenance=config_provenance, environment=environment, debug=debug, quiet=quiet, diff --git a/lib/python/base_cli/config.py b/lib/python/base_cli/config.py index ab38652..f77b289 100644 --- a/lib/python/base_cli/config.py +++ b/lib/python/base_cli/config.py @@ -1,18 +1,237 @@ from __future__ import annotations import stat +import re +from collections.abc import Mapping +from dataclasses import dataclass from pathlib import Path -from typing import Any +from types import MappingProxyType +from typing import Any, Final from ._dependencies import require_yaml from .errors import ConfigurationError __all__ = [ + "BatteriesIncludedConfigLoader", + "ConfigSnapshot", + "FrameworkConfig", + "DEFAULT_ENVIRONMENT", "load_yaml_file", ] +DEFAULT_ENVIRONMENT: Final = "dev" +_FRAMEWORK_KEYS = frozenset({"environment", "log_level", "keep_temp"}) +_LOG_LEVELS = frozenset({"debug", "info", "warning", "error", "critical"}) +_SAFE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*\Z") +_SAFE_FILENAME = re.compile(r"(?:[A-Za-z0-9][A-Za-z0-9_.-]*|\.[A-Za-z0-9][A-Za-z0-9_.-]*)\Z") + + +@dataclass(frozen=True) +class FrameworkConfig: + """Validated lifecycle settings separated from consumer configuration.""" + + environment: str = DEFAULT_ENVIRONMENT + log_level: str | None = None + keep_temp: bool = False + + +@dataclass(frozen=True) +class ConfigSnapshot: + """One deterministic layered configuration result. + + ``config`` contains only consumer-owned keys. Framework lifecycle settings + are validated and exposed separately through ``framework``. ``provenance`` + maps dotted configuration paths to the source layer that supplied them. + """ + + config: dict[str, Any] + framework: FrameworkConfig + provenance: Mapping[str, str] + + +def _validate_framework_config(values: Mapping[str, Any]) -> FrameworkConfig: + environment_value = values.get("environment", DEFAULT_ENVIRONMENT) + if not isinstance(environment_value, str) or not environment_value.strip(): + raise ConfigurationError("Config key 'environment' must be a non-empty string.") + environment = _validate_environment_name(environment_value) + + log_level_value = values.get("log_level") + if log_level_value is not None: + if not isinstance(log_level_value, str) or log_level_value.lower() not in _LOG_LEVELS: + supported = ", ".join(sorted(_LOG_LEVELS)) + raise ConfigurationError( + f"Config key 'log_level' must be one of: {supported}." + ) + log_level = log_level_value.lower() + else: + log_level = None + + keep_temp_value = values.get("keep_temp", False) + if not isinstance(keep_temp_value, bool): + raise ConfigurationError("Config key 'keep_temp' must be a boolean.") + + return FrameworkConfig( + environment=environment, + log_level=log_level, + keep_temp=keep_temp_value, + ) + + +def _validate_environment_name(value: object) -> str: + if not isinstance(value, str): + raise ConfigurationError("Config key 'environment' must be a string.") + normalized = value.strip() + if not normalized or _SAFE_NAME.fullmatch(normalized) is None: + raise ConfigurationError( + "Config key 'environment' must contain only letters, digits, '.', '_' or '-'." + ) + return normalized + + +def _leaf_provenance( + value: Any, + source: str, + prefix: str = "", +) -> dict[str, str]: + if isinstance(value, Mapping): + result: dict[str, str] = {} + for key, child in value.items(): + path = f"{prefix}.{key}" if prefix else str(key) + result.update(_leaf_provenance(child, source, path)) + return result or ({prefix: source} if prefix else {}) + return {prefix: source} if prefix else {} + + +def _merge_mapping( + target: dict[str, Any], + provenance: dict[str, str], + incoming: Mapping[str, Any], + source: str, +) -> None: + for key, value in incoming.items(): + if not isinstance(key, str): + raise ConfigurationError("Configuration keys must be strings.") + previous = target.get(key) + if isinstance(previous, Mapping) and isinstance(value, Mapping): + _merge_mapping(target[key], provenance, value, source) + provenance.update(_leaf_provenance(value, source, key)) + continue + for path in tuple(provenance): + if path == key or path.startswith(f"{key}."): + del provenance[path] + target[key] = dict(value) if isinstance(value, Mapping) else value + provenance.update(_leaf_provenance(value, source, key)) + + +class BatteriesIncludedConfigLoader: + """Load conventional user, project, environment, and explicit layers.""" + + def __init__( + self, + cli_name: str, + *, + user_config_dir: Path, + user_config_name: str = "config.yaml", + project_config_name: str = ".base-cli.yaml", + environment_dir_name: str = "environments", + ) -> None: + if _SAFE_FILENAME.fullmatch(user_config_name) is None: + raise ValueError("user_config_name must be a simple filename") + if _SAFE_FILENAME.fullmatch(project_config_name) is None: + raise ValueError("project_config_name must be a simple filename") + if _SAFE_NAME.fullmatch(environment_dir_name) is None: + raise ValueError("environment_dir_name must be a simple directory name") + self.cli_name = cli_name + self.user_config_dir = user_config_dir.expanduser() + self.user_config_name = user_config_name + self.project_config_name = project_config_name + self.environment_dir_name = environment_dir_name + + @property + def user_config_path(self) -> Path: + return self.user_config_dir / self.user_config_name + + def project_config_path(self, project_root: Path | None) -> Path | None: + if project_root is None: + return None + return project_root / self.project_config_name + + def _environment_paths( + self, + project_root: Path | None, + environment: str, + ) -> tuple[Path, Path | None]: + user_path = self.user_config_dir / self.environment_dir_name / f"{environment}.yaml" + project_path = ( + project_root / self.environment_dir_name / f"{environment}.yaml" + if project_root is not None + else None + ) + return user_path, project_path + + def load( + self, + project_root: Path | None, + explicit_path: Path | None, + *, + environment: str | None = None, + ) -> ConfigSnapshot: + user_values = load_yaml_file(self.user_config_path) + project_path = self.project_config_path(project_root) + project_values = load_yaml_file(project_path) if project_path is not None else {} + explicit_values = load_yaml_file(explicit_path, required=True) if explicit_path is not None else {} + + selected_environment = environment + if selected_environment is None: + candidate = explicit_values.get("environment") + if candidate is None: + candidate = project_values.get("environment", user_values.get("environment")) + selected_environment = candidate if candidate is not None else DEFAULT_ENVIRONMENT + selected_environment = _validate_environment_name(selected_environment) + + user_environment_path, project_environment_path = self._environment_paths( + project_root, + selected_environment, + ) + user_environment = load_yaml_file(user_environment_path) + project_environment = ( + load_yaml_file(project_environment_path) + if project_environment_path is not None + else {} + ) + + merged: dict[str, Any] = {} + provenance: dict[str, str] = {} + _merge_mapping(merged, provenance, {"environment": DEFAULT_ENVIRONMENT}, "default") + for source, values in ( + ("user", user_values), + ("project", project_values), + (f"user:environment:{selected_environment}", user_environment), + (f"project:environment:{selected_environment}", project_environment), + ("explicit", explicit_values), + ): + _merge_mapping(merged, provenance, values, source) + + framework_values = { + key: merged[key] + for key in _FRAMEWORK_KEYS + if key in merged + } + framework = _validate_framework_config(framework_values) + consumer_config = { + key: value + for key, value in merged.items() + if key not in _FRAMEWORK_KEYS + } + return ConfigSnapshot( + config=consumer_config, + framework=framework, + provenance=MappingProxyType(dict(provenance)), + ) + + def load_yaml_file(path: Path, *, required: bool = False) -> dict[str, Any]: """Load a YAML mapping, optionally requiring a regular file to exist. diff --git a/lib/python/base_cli/context.py b/lib/python/base_cli/context.py index ff95c4c..f840127 100644 --- a/lib/python/base_cli/context.py +++ b/lib/python/base_cli/context.py @@ -3,11 +3,13 @@ import contextvars import logging import os +from collections.abc import Mapping from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable, Generic, TypeVar from ._cleanup import remove_owned_temp_directory +from .config import FrameworkConfig _current_context: contextvars.ContextVar[Context[Any, Any, Any] | None] = contextvars.ContextVar( @@ -68,6 +70,8 @@ class Context(Generic[ConfigT, ApplicationStateT, ServicesT]): run_root: Path | None = None application_context: ApplicationStateT | None = field(default=None, repr=False, compare=False) services: ServicesT | None = field(default=None, repr=False, compare=False) + framework_config: FrameworkConfig | None = field(default=None, repr=False, compare=False) + config_provenance: Mapping[str, str] = field(default_factory=dict, repr=False, compare=False) _run_metadata_path: Path | None = field(default=None, init=False, repr=False, compare=False) _owns_temp_dir: bool = field(default=False, init=False, repr=False, compare=False) _owned_temp_identity: tuple[int, int] | None = field(default=None, init=False, repr=False, compare=False) diff --git a/lib/python/base_cli/paths.py b/lib/python/base_cli/paths.py index 9d9967c..3e55eac 100644 --- a/lib/python/base_cli/paths.py +++ b/lib/python/base_cli/paths.py @@ -48,6 +48,33 @@ def default_cache_root( return root / ".cache" +def default_config_root( + *, + environ: Mapping[str, str] | None = None, + home: Path | None = None, + platform_name: str | None = None, +) -> Path: + """Return the platform-default root for optional user configuration.""" + + environment = os.environ if environ is None else environ + configured = environment.get("BASE_CLI_CONFIG_DIR") + if configured: + return Path(configured).expanduser() + + root = home.expanduser() if home is not None else Path.home() + system = platform_name or sys.platform + if system == "darwin": + return root / "Library" / "Application Support" + if system.startswith("win"): + app_data = environment.get("APPDATA") + return Path(app_data).expanduser() if app_data else root / "AppData" / "Roaming" + + xdg_config_home = environment.get("XDG_CONFIG_HOME") + if xdg_config_home: + return Path(xdg_config_home).expanduser() + return root / ".config" + + def current_working_dir() -> Path: return _WORKING_DIRECTORY_OVERRIDE.get() or Path.cwd() diff --git a/lib/python/base_cli/profile.py b/lib/python/base_cli/profile.py index c3f961b..dc80edd 100644 --- a/lib/python/base_cli/profile.py +++ b/lib/python/base_cli/profile.py @@ -6,15 +6,22 @@ from typing import Any, Protocol, cast from ._runtime import runtime_layout -from .config import load_yaml_file +from .config import ( + BatteriesIncludedConfigLoader, + ConfigSnapshot, + load_yaml_file, +) from .context import Context -from .paths import default_cache_root, make_run_id +from .paths import default_cache_root, default_config_root, make_run_id, normalize_cli_name from .runtime import RuntimeLayout __all__ = [ "CliProfile", + "BatteriesIncludedConfigLoader", + "ConfigSnapshot", "ConfigLoader", "DisplayCommandResolver", + "EnvironmentConfigLoader", "HistoryDisplayResolver", "HistoryWriter", "ProjectDiscovery", @@ -72,7 +79,18 @@ def __call__( self, project: ProjectInfo | None, explicit_path: Path | None, - ) -> dict[str, Any]: ... + ) -> dict[str, Any] | ConfigSnapshot: ... + + +class EnvironmentConfigLoader(Protocol): + """Load configuration with an explicitly selected environment.""" + + def __call__( + self, + project: ProjectInfo | None, + explicit_path: Path | None, + environment: str, + ) -> dict[str, Any] | ConfigSnapshot: ... class RuntimeResolver(Protocol): @@ -147,6 +165,7 @@ class CliProfile: WorkspaceRootResolver, _no_workspace_root, ) + load_config_for_environment: EnvironmentConfigLoader | None = None @classmethod def generic( @@ -178,10 +197,99 @@ def generic( or cast(WorkspaceRootResolver, _no_workspace_root), ) + @classmethod + def batteries_included( + cls, + cli_name: str, + *, + cache_root: Path | None = None, + application_home: Path | None = None, + config_root: Path | None = None, + user_config_dir: Path | None = None, + user_config_name: str = "config.yaml", + project_config_name: str = ".base-cli.yaml", + environment_dir_name: str = "environments", + discover_project: ProjectDiscovery | None = None, + resolve_runtime: RuntimeResolver | None = None, + ) -> CliProfile: + """Create an opt-in profile with conventional layered YAML config. + + Layers are merged from lowest to highest precedence: defaults, user, + project, user environment, project environment, and explicit ``--config``. + The generic profile remains convention-free; this method is the explicit + adoption point for applications that want these conventions. + """ + normalized_name = normalize_cli_name(cli_name) + if not normalized_name: + raise ValueError("cli_name must contain a non-empty command name") + root = (config_root or default_config_root()).expanduser() + selected_user_dir = ( + user_config_dir.expanduser() + if user_config_dir is not None + else root / normalized_name + ) + loader = BatteriesIncludedConfigLoader( + normalized_name, + user_config_dir=selected_user_dir, + user_config_name=user_config_name, + project_config_name=project_config_name, + environment_dir_name=environment_dir_name, + ) + project_discovery = discover_project or _conventional_project_discovery(project_config_name) + + def load_user_config() -> object | None: + values = load_yaml_file(loader.user_config_path) + return values or None + + def load_config( + project: ProjectInfo | None, + explicit_path: Path | None, + ) -> ConfigSnapshot: + return loader.load( + project.root if project is not None else None, + explicit_path, + ) + + def load_config_for_environment( + project: ProjectInfo | None, + explicit_path: Path | None, + environment: str, + ) -> ConfigSnapshot: + return loader.load( + project.root if project is not None else None, + explicit_path, + environment=environment, + ) + + return cls( + discover_project=project_discovery, + load_user_config=load_user_config, + load_config=load_config, + load_config_for_environment=load_config_for_environment, + resolve_runtime=resolve_runtime + or _generic_runtime_resolver(cache_root, application_home), + ) + def _discover_no_project(_cwd: Path) -> ProjectInfo | None: return None +def _conventional_project_discovery(config_name: str) -> ProjectDiscovery: + def discover(cwd: Path) -> ProjectInfo | None: + current = cwd.expanduser().resolve() + for directory in (current, *current.parents): + candidate = directory / config_name + if candidate.is_file(): + return ProjectInfo( + root=directory, + manifest=candidate, + name=directory.name, + ) + return None + + return discover + + def _empty_user_config() -> None: return None diff --git a/tests/test_batteries_included_config.py b/tests/test_batteries_included_config.py new file mode 100644 index 0000000..3541227 --- /dev/null +++ b/tests/test_batteries_included_config.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +import base_cli +from base_cli.config import BatteriesIncludedConfigLoader, ConfigSnapshot +from base_cli.testing import invoke + + +def _write_yaml(path: Path, contents: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents, encoding="utf-8") + + +class BatteriesIncludedConfigTests(unittest.TestCase): + def test_layered_loader_merges_in_documented_order_and_records_provenance(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + user_dir = root / "user" / "tool" + project = root / "project" + explicit = root / "explicit.yaml" + _write_yaml( + user_dir / "config.yaml", + "environment: staging\nshared: user\nnested:\n user: true\n", + ) + _write_yaml( + project / ".base-cli.yaml", + "shared: project\nnested:\n project: true\n", + ) + _write_yaml( + user_dir / "environments" / "staging.yaml", + "shared: user-environment\nnested:\n user_environment: true\n", + ) + _write_yaml( + project / "environments" / "staging.yaml", + "shared: project-environment\nnested:\n project_environment: true\n", + ) + _write_yaml( + explicit, + "shared: explicit\nnested:\n explicit: true\n", + ) + + snapshot = BatteriesIncludedConfigLoader( + "tool", + user_config_dir=user_dir, + ).load(project, explicit) + + self.assertIsInstance(snapshot, ConfigSnapshot) + self.assertEqual(snapshot.framework.environment, "staging") + self.assertEqual(snapshot.config["shared"], "explicit") + self.assertEqual( + snapshot.config["nested"], + { + "user": True, + "project": True, + "user_environment": True, + "project_environment": True, + "explicit": True, + }, + ) + self.assertEqual(snapshot.provenance["shared"], "explicit") + self.assertEqual(snapshot.provenance["nested.user"], "user") + self.assertEqual(snapshot.provenance["nested.project_environment"], "project:environment:staging") + self.assertNotIn("environment", snapshot.config) + + def test_cli_environment_selects_environment_layer(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + user_dir = root / "user-config" / "tool" + _write_yaml(user_dir / "config.yaml", "environment: dev\n") + _write_yaml( + user_dir / "environments" / "prod.yaml", + "log_level: debug\nkeep_temp: true\nanswer: 42\n", + ) + seen: dict[str, object] = {} + app = base_cli.App( + name="tool", + profile=base_cli.CliProfile.batteries_included( + "tool", + user_config_dir=user_dir, + ), + log_to_file=False, + ) + + @app.command() + def main(ctx: base_cli.Context) -> None: + seen["environment"] = ctx.environment + seen["debug"] = ctx.debug + seen["keep_temp"] = ctx.keep_temp + seen["config"] = ctx.config + seen["provenance"] = dict(ctx.config_provenance) + + result = invoke(app, ["--environment", "prod"], home=root / "home") + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(seen["environment"], "prod") + self.assertTrue(seen["debug"]) + self.assertTrue(seen["keep_temp"]) + self.assertEqual(seen["config"], {"answer": 42}) + self.assertEqual(seen["provenance"]["answer"], "user:environment:prod") + + def test_missing_optional_layers_are_empty_but_explicit_paths_are_strict(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + loader = BatteriesIncludedConfigLoader( + "tool", + user_config_dir=root / "missing-user", + ) + snapshot = loader.load(None, None) + self.assertEqual(snapshot.config, {}) + self.assertEqual(snapshot.framework.environment, "dev") + with self.assertRaisesRegex(base_cli.ConfigurationError, "does not exist"): + loader.load(None, root / "missing-explicit.yaml") + + def test_environment_and_layer_names_cannot_escape_config_roots(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + loader = BatteriesIncludedConfigLoader("tool", user_config_dir=root / "user") + with self.assertRaisesRegex(base_cli.ConfigurationError, "environment"): + loader.load(None, None, environment="../secret") + with self.assertRaisesRegex(ValueError, "project_config_name"): + BatteriesIncludedConfigLoader( + "tool", + user_config_dir=root / "user", + project_config_name="../project.yaml", + ) + + def test_framework_settings_are_validated_and_separated(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + explicit = root / "config.yaml" + _write_yaml(explicit, "environment: prod\nlog_level: verbose\n") + loader = BatteriesIncludedConfigLoader("tool", user_config_dir=root / "user") + with self.assertRaisesRegex(base_cli.ConfigurationError, "log_level"): + loader.load(None, explicit) + + _write_yaml(explicit, "environment: prod\nkeep_temp: maybe\n") + with self.assertRaisesRegex(base_cli.ConfigurationError, "keep_temp"): + loader.load(None, explicit) + + def test_batteries_included_profile_discovers_project_config_upward(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + project = root / "project" + nested = project / "src" / "tool" + nested.mkdir(parents=True) + _write_yaml(project / ".base-cli.yaml", "answer: project\n") + seen: dict[str, object] = {} + app = base_cli.App( + name="tool", + profile=base_cli.CliProfile.batteries_included( + "tool", + user_config_dir=root / "user-config" / "tool", + ), + log_to_file=False, + ) + + @app.command() + def main(ctx: base_cli.Context) -> None: + seen["project_root"] = ctx.project_root + seen["config"] = ctx.config + + result = invoke(app, [], cwd=nested, home=root / "home") + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(seen["project_root"], project.resolve()) + self.assertEqual(seen["config"], {"answer": "project"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_paths.py b/tests/test_paths.py index a59a70e..5f120ff 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -4,7 +4,7 @@ from pathlib import Path from base_cli.history import compact_home_text -from base_cli.paths import default_cache_root +from base_cli.paths import default_cache_root, default_config_root class DefaultCacheRootTests(unittest.TestCase): @@ -67,6 +67,50 @@ def test_windows_falls_back_to_home_local_app_data(self) -> None: self.assertEqual(root, Path(r"C:\Users\alice") / "AppData" / "Local") +class DefaultConfigRootTests(unittest.TestCase): + def test_explicit_config_override_wins_on_every_platform(self) -> None: + root = default_config_root( + environ={ + "BASE_CLI_CONFIG_DIR": "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/custom/config", + "APPDATA": "/app-data", + "XDG_CONFIG_HOME": "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/xdg/config", + }, + home=Path("/home/alice"), + platform_name="win32", + ) + self.assertEqual(root, Path("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/custom/config")) + + def test_linux_prefers_xdg_config_home(self) -> None: + root = default_config_root( + environ={"XDG_CONFIG_HOME": "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/xdg/config"}, + home=Path("/home/alice"), + platform_name="linux", + ) + self.assertEqual(root, Path("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/xdg/config")) + + def test_linux_falls_back_to_home_config(self) -> None: + self.assertEqual( + default_config_root(environ={}, home=Path("/home/alice"), platform_name="linux"), + Path("/home/alice/.config"), + ) + + def test_macos_uses_application_support(self) -> None: + self.assertEqual( + default_config_root(environ={}, home=Path("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/Users/alice"), platform_name="darwin"), + Path("/Users/alice/Library/Application Support"), + ) + + def test_windows_prefers_app_data(self) -> None: + self.assertEqual( + default_config_root( + environ={"APPDATA": r"C:\Users\alice\AppData\Roaming"}, + home=Path(r"C:\Users\alice"), + platform_name="win32", + ), + Path(r"C:\Users\alice\AppData\Roaming"), + ) + + class HomePathCompactionTests(unittest.TestCase): def test_compacts_home_paths_with_posix_separators(self) -> None: self.assertEqual( diff --git a/tests/test_public_api.py b/tests/test_public_api.py index bb4af91..ef8d904 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -6,7 +6,7 @@ from unittest import mock import base_cli -from base_cli import attachment, command_filters, command_protocol, history, lifecycle_options +from base_cli import attachment, command_filters, command_protocol, config, history, lifecycle_options class PublicApiTests(unittest.TestCase): @@ -37,6 +37,8 @@ def test_facade_exports_supported_modules_functions_and_types(self) -> None: "ConfigurationError", "AttachmentAdapter", "AttachmentContract", + "BatteriesIncludedConfigLoader", + "ConfigSnapshot", "RuntimeLayout", "attach", "command_filters", @@ -76,6 +78,16 @@ def test_module_all_surfaces_are_explicit(self) -> None: "AttachmentServiceFactory", }, ) + self.assertEqual( + set(config.__all__), + { + "BatteriesIncludedConfigLoader", + "ConfigSnapshot", + "DEFAULT_ENVIRONMENT", + "FrameworkConfig", + "load_yaml_file", + }, + ) self.assertEqual( set(command_protocol.__all__), {