From 8e7f241932812ee6da260a10b4ed302aa79ad973 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:13:27 +0530 Subject: [PATCH 1/2] fix: give config loader identity a documented role --- CHANGELOG.md | 3 +++ docs/api-reference.md | 4 ++-- docs/api-stability.md | 5 +++++ docs/local-config.md | 7 +++++++ lib/python/base_cli/config.py | 24 ++++++++++++++++++---- lib/python/base_cli/profile.py | 1 - tests/test_batteries_included_config.py | 27 ++++++++++++++++++------- 7 files changed, 57 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38484e1..152772f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ and versions are tracked in the repo-root `VERSION` file. ### Fixed +- Give `BatteriesIncludedConfigLoader.cli_name` a documented identity role by + using it to derive an isolated default configuration directory when no + explicit directory is supplied. - Keep plain consumer configuration mappings opaque so only validated `ConfigSnapshot.framework` values control lifecycle behavior. - Preserve `KeyboardInterrupt`, `SystemExit`, and `GeneratorExit` across diff --git a/docs/api-reference.md b/docs/api-reference.md index 91763c6..1a30c92 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -116,9 +116,9 @@ base_cli.TyperAdapter(...) ### `BatteriesIncludedConfigLoader` **Kind:** class -**Signature:** `BatteriesIncludedConfigLoader(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'` +**Signature:** `BatteriesIncludedConfigLoader(cli_name: 'str | 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') -> 'None'` -**Behavior:** Load conventional user, project, environment, and explicit layers. +**Behavior:** Load conventional user, project, environment, and explicit layers. When `user_config_dir` is omitted, the optional `cli_name` is normalized and used to namespace the platform-default user configuration directory. Provide an explicit `user_config_dir` to retain consumer-owned path policy. At least one of these two arguments is required. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. diff --git a/docs/api-stability.md b/docs/api-stability.md index 6a36637..8211b09 100644 --- a/docs/api-stability.md +++ b/docs/api-stability.md @@ -60,6 +60,11 @@ dependency matrix is maintained in [`dependency-support.md`](dependency-support. A future minor release may drop an end-of-life Python or dependency window with a migration note. +`BatteriesIncludedConfigLoader.cli_name` is optional. When supplied without an +explicit `user_config_dir`, it determines the normalized application namespace +under the platform-default configuration root. An explicit directory always +takes precedence, so applications that own path policy can omit the identity. + Platform tier details and the operating-system support test matrix are kept in [`platform-support.md`](platform-support.md). diff --git a/docs/local-config.md b/docs/local-config.md index af852ab..3f51f61 100644 --- a/docs/local-config.md +++ b/docs/local-config.md @@ -22,3 +22,10 @@ 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. + +For direct use, `BatteriesIncludedConfigLoader` accepts an optional `cli_name`. +When no `user_config_dir` is supplied, that identity selects an isolated +directory below the platform's default config root (for example, +`~/.config/tool` on Linux). Consumers with an existing configuration-root +policy should pass `user_config_dir` explicitly; the identity is then metadata +only. diff --git a/lib/python/base_cli/config.py b/lib/python/base_cli/config.py index b3ef74a..c928b1e 100644 --- a/lib/python/base_cli/config.py +++ b/lib/python/base_cli/config.py @@ -10,6 +10,7 @@ from ._dependencies import require_yaml from .errors import ConfigurationError +from .paths import default_config_root, normalize_cli_name __all__ = [ "BatteriesIncludedConfigLoader", @@ -123,13 +124,18 @@ def _merge_mapping( class BatteriesIncludedConfigLoader: - """Load conventional user, project, environment, and explicit layers.""" + """Load conventional user, project, environment, and explicit layers. + + ``cli_name`` is an optional identity used to derive the platform-default + user configuration directory when ``user_config_dir`` is omitted. Pass an + explicit directory when the consumer owns its configuration-root policy. + """ def __init__( self, - cli_name: str, + cli_name: str | None = None, *, - user_config_dir: Path, + user_config_dir: Path | None = None, user_config_name: str = "config.yaml", project_config_name: str = ".base-cli.yaml", environment_dir_name: str = "environments", @@ -140,7 +146,17 @@ def __init__( 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 + if cli_name is not None: + normalized_name = normalize_cli_name(cli_name) + if not normalized_name: + raise ValueError("cli_name must contain a non-empty command name") + else: + normalized_name = None + if user_config_dir is None: + if normalized_name is None: + raise ValueError("either cli_name or user_config_dir must be provided") + user_config_dir = default_config_root() / normalized_name + self.cli_name = normalized_name self.user_config_dir = user_config_dir.expanduser() self.user_config_name = user_config_name self.project_config_name = project_config_name diff --git a/lib/python/base_cli/profile.py b/lib/python/base_cli/profile.py index 1128640..bb1a467 100644 --- a/lib/python/base_cli/profile.py +++ b/lib/python/base_cli/profile.py @@ -217,7 +217,6 @@ def batteries_included( 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, diff --git a/tests/test_batteries_included_config.py b/tests/test_batteries_included_config.py index 829a6e3..200a025 100644 --- a/tests/test_batteries_included_config.py +++ b/tests/test_batteries_included_config.py @@ -3,6 +3,7 @@ import tempfile import unittest from pathlib import Path +from unittest.mock import patch import base_cli from base_cli.config import BatteriesIncludedConfigLoader, ConfigSnapshot, _merge_mapping @@ -117,10 +118,7 @@ def main(ctx: base_cli.Context) -> None: 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", - ) + loader = BatteriesIncludedConfigLoader(user_config_dir=root / "missing-user") snapshot = loader.load(None, None) self.assertEqual(snapshot.config, {}) self.assertEqual(snapshot.framework.environment, "dev") @@ -130,12 +128,11 @@ def test_missing_optional_layers_are_empty_but_explicit_paths_are_strict(self) - 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") + loader = BatteriesIncludedConfigLoader(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", ) @@ -145,7 +142,7 @@ def test_framework_settings_are_validated_and_separated(self) -> None: root = Path(tmpdir) explicit = root / "config.yaml" _write_yaml(explicit, "environment: prod\nlog_level: verbose\n") - loader = BatteriesIncludedConfigLoader("tool", user_config_dir=root / "user") + loader = BatteriesIncludedConfigLoader(user_config_dir=root / "user") with self.assertRaisesRegex(base_cli.ConfigurationError, "log_level"): loader.load(None, explicit) @@ -153,6 +150,22 @@ def test_framework_settings_are_validated_and_separated(self) -> None: with self.assertRaisesRegex(base_cli.ConfigurationError, "keep_temp"): loader.load(None, explicit) + def test_cli_identity_namespaces_default_config_directory(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + with patch.dict("os.environ", {"BASE_CLI_CONFIG_DIR": str(root)}, clear=False): + alpha = BatteriesIncludedConfigLoader("Alpha Tool") + beta = BatteriesIncludedConfigLoader("beta") + + self.assertEqual(alpha.cli_name, "Alpha-Tool") + self.assertEqual(alpha.user_config_dir, root / "Alpha-Tool") + self.assertEqual(beta.user_config_dir, root / "beta") + self.assertNotEqual(alpha.user_config_dir, beta.user_config_dir) + + def test_loader_requires_identity_or_explicit_config_directory(self) -> None: + with self.assertRaisesRegex(ValueError, "either cli_name or user_config_dir"): + BatteriesIncludedConfigLoader() + def test_batteries_included_profile_discovers_project_config_upward(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) From 8b63bc767d9eb23824831053db28dedd6ef6ef74 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:38:52 +0530 Subject: [PATCH 2/2] docs: regenerate public API reference --- docs/api-reference.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 1a30c92..fc6abc8 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -118,7 +118,7 @@ base_cli.TyperAdapter(...) **Kind:** class **Signature:** `BatteriesIncludedConfigLoader(cli_name: 'str | 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') -> 'None'` -**Behavior:** Load conventional user, project, environment, and explicit layers. When `user_config_dir` is omitted, the optional `cli_name` is normalized and used to namespace the platform-default user configuration directory. Provide an explicit `user_config_dir` to retain consumer-owned path policy. At least one of these two arguments is required. +**Behavior:** Load conventional user, project, environment, and explicit layers. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release.