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
4 changes: 4 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ jobs:
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
version: "0.12.8"
- name: Install quality dependencies
run: python -m pip install ".[dev,typer,quality]"
- name: Validate repository baseline
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ and versions are tracked in the repo-root `VERSION` file.

- Continue compatibility hardening and adoption work for the next release.

### Changed

- Include `uv.lock` freshness in the authoritative local validation aggregate (#424).

## [0.5.0] - 2026-10-03

### Added
Expand All @@ -20,6 +24,8 @@ and versions are tracked in the repo-root `VERSION` file.

### Changed

- Derive default configuration directories from collision-resistant application
identity namespaces so distinct names cannot share a path (#425).
- Bound convenience-profile discovery and validate implicit project configuration trust; cap YAML input size (#385).
- Skip contended retention passes instead of blocking CLI invocations on housekeeping locks (#386).
- Reuse secure log lock descriptors and cache source paths per invocation; logging I/O failures stay inside logging (#381).
Expand Down
8 changes: 5 additions & 3 deletions docs/api-stability.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@ 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.
explicit `user_config_dir`, it determines a readable, filesystem-safe,
collision-resistant application namespace under the platform-default
configuration root. An explicit directory always takes precedence, so
applications that own path policy can omit the identity. Existing directories
from older normalization rules are not migrated automatically.

Platform tier details and the operating-system support test matrix are kept in
[`platform-support.md`](platform-support.md).
Expand Down
13 changes: 10 additions & 3 deletions docs/local-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,16 @@ 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.
`~/.config/tool` on Linux). An already-safe identity such as `MyTool`,
`Alpha-Tool`, or `acme.tools` keeps its spelling so existing configuration
directories continue to be read. Identities that need normalization, such as
`Alpha Tool` or a path-like value, use a readable filesystem-safe slug plus a
stable identity digest; this keeps them distinct from already-safe names.
Consumers with an existing configuration-root policy should pass
`user_config_dir` explicitly; that path remains authoritative. Existing
directories created by an older normalized policy are not merged, moved, or
deleted automatically; a consumer that needs migration must copy them under
its chosen policy.

## Trust of discovered project configuration

Expand Down
5 changes: 3 additions & 2 deletions lib/python/base_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

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

__all__ = [
"BatteriesIncludedConfigLoader",
Expand Down Expand Up @@ -213,7 +213,8 @@ def __init__(
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
assert cli_name is not None
user_config_dir = default_config_root() / config_namespace_component(cli_name)
self.cli_name = normalized_name
self.user_config_dir = user_config_dir.expanduser()
self.user_config_name = user_config_name
Expand Down
15 changes: 15 additions & 0 deletions lib/python/base_cli/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"base_cli_working_directory_override",
default=None,
)
_SAFE_CONFIG_NAMESPACE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z")


def default_cache_root(
Expand Down Expand Up @@ -125,6 +126,20 @@ def runtime_namespace_component(value: str, fallback: str = "application") -> st
return f"{readable}--{digest}"


def config_namespace_component(value: str, fallback: str = "application") -> str:
"""Return the isolated default-config namespace for an application identity.

Preserve an already-safe identity verbatim so existing user-config
directories remain readable after upgrade. Identities that need path
normalization use the runtime slug and stable digest policy, which keeps
transformed names distinct from their normalized counterparts.
"""

if _SAFE_CONFIG_NAMESPACE.fullmatch(value):
return value
return runtime_namespace_component(value, fallback=fallback)
Comment thread
codeforester marked this conversation as resolved.


def runtime_slug(value: str, fallback: str = "unnamed") -> str:
normalized = re.sub(r"[^a-zA-Z0-9._-]+", "-", value.strip()).strip(".-_").lower()
return normalized or fallback
Expand Down
6 changes: 4 additions & 2 deletions lib/python/base_cli/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
)
from .context import Context
from .history import display_command as _generic_history_display_command
from .paths import default_cache_root, default_config_root, make_run_id, normalize_cli_name
from .paths import config_namespace_component, default_cache_root, default_config_root, make_run_id, normalize_cli_name
from .runtime import RuntimeLayout

__all__ = [
Expand Down Expand Up @@ -231,7 +231,9 @@ def batteries_included(
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
selected_user_dir = (
user_config_dir.expanduser() if user_config_dir is not None else root / config_namespace_component(cli_name)
)
loader = BatteriesIncludedConfigLoader(
user_config_dir=selected_user_dir,
user_config_name=user_config_name,
Expand Down
2 changes: 2 additions & 0 deletions tests/full_validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ require_commands() {
}

run_baseline() {
require_commands uv
bash ./tests/validate.sh
uv lock --check
}

run_runtime() {
Expand Down
26 changes: 25 additions & 1 deletion tests/test_batteries_included_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,11 +259,35 @@ def test_cli_identity_namespaces_default_config_directory(self) -> None:
with patch.dict("os.environ", {"BASE_CLI_CONFIG_DIR": str(root)}, clear=False):
alpha = BatteriesIncludedConfigLoader("Alpha Tool")
beta = BatteriesIncludedConfigLoader("beta")
dotted = BatteriesIncludedConfigLoader("acme.tools")
deploy = BatteriesIncludedConfigLoader("acme.deploy")
mixed_case = BatteriesIncludedConfigLoader("MyTool")
hyphenated = BatteriesIncludedConfigLoader("Alpha-Tool")

self.assertEqual(alpha.cli_name, "Alpha-Tool")
self.assertEqual(alpha.user_config_dir, root / "Alpha-Tool")
self.assertNotEqual(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)
self.assertEqual(dotted.user_config_dir, root / "acme.tools")
self.assertEqual(deploy.user_config_dir, root / "acme.deploy")
self.assertEqual(mixed_case.user_config_dir, root / "MyTool")
self.assertEqual(hyphenated.user_config_dir, root / "Alpha-Tool")
self.assertNotEqual(dotted.user_config_dir, deploy.user_config_dir)

def test_explicit_user_config_directory_remains_authoritative(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
explicit = Path(tmpdir) / "shared"
loader = BatteriesIncludedConfigLoader("acme.tools", user_config_dir=explicit)

self.assertEqual(loader.user_config_dir, explicit)

def test_profile_uses_the_same_identity_namespace_policy(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
_write_yaml(root / "acme.tools" / "config.yaml", "answer: 42\n")
with patch.dict("os.environ", {"BASE_CLI_CONFIG_DIR": str(root)}, clear=False):
profile = base_cli.CliProfile.batteries_included("acme.tools")
self.assertEqual(profile.load_user_config(), {"answer": 42})

def test_loader_requires_identity_or_explicit_config_directory(self) -> None:
with self.assertRaisesRegex(ValueError, "either cli_name or user_config_dir"):
Expand Down
9 changes: 9 additions & 0 deletions tests/test_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from base_cli.history import compact_home_text
from base_cli.paths import (
config_namespace_component,
default_cache_root,
default_config_root,
normalize_explicit_cli_name,
Expand Down Expand Up @@ -154,3 +155,11 @@ def test_path_like_identity_cannot_escape_runtime_owner(self) -> None:
component = runtime_namespace_component("../../outside")
self.assertNotIn("/", component)
self.assertTrue(component.startswith("outside--"))

def test_config_namespace_preserves_dotted_identity_and_rejects_collisions(self) -> None:
self.assertEqual(config_namespace_component("acme.tools"), "acme.tools")
self.assertEqual(config_namespace_component("acme.deploy"), "acme.deploy")
self.assertEqual(config_namespace_component("MyTool"), "MyTool")
self.assertEqual(config_namespace_component("Alpha-Tool"), "Alpha-Tool")
self.assertNotEqual(config_namespace_component("Alpha Tool"), config_namespace_component("Alpha-Tool"))
self.assertNotIn("/", config_namespace_component("../../outside"))
Loading