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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ 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.

Expand Down
5 changes: 5 additions & 0 deletions docs/api-stability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
7 changes: 7 additions & 0 deletions docs/local-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
24 changes: 20 additions & 4 deletions lib/python/base_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from ._dependencies import require_yaml
from .errors import ConfigurationError
from .paths import default_config_root, normalize_cli_name

__all__ = [
"BatteriesIncludedConfigLoader",
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down
1 change: 0 additions & 1 deletion lib/python/base_cli/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 20 additions & 7 deletions tests/test_batteries_included_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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",
)
Expand All @@ -145,14 +142,30 @@ 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)

_write_yaml(explicit, "environment: prod\nkeep_temp: maybe\n")
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)
Expand Down
Loading