diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f59e494..98f4b37 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bb80c4..6b4e752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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). diff --git a/docs/api-stability.md b/docs/api-stability.md index 8b4927b..2c3eb07 100644 --- a/docs/api-stability.md +++ b/docs/api-stability.md @@ -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). diff --git a/docs/local-config.md b/docs/local-config.md index bcc2abb..bba6aee 100644 --- a/docs/local-config.md +++ b/docs/local-config.md @@ -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 diff --git a/lib/python/base_cli/config.py b/lib/python/base_cli/config.py index 1808540..26db7a6 100644 --- a/lib/python/base_cli/config.py +++ b/lib/python/base_cli/config.py @@ -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", @@ -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 diff --git a/lib/python/base_cli/paths.py b/lib/python/base_cli/paths.py index 99299fd..ae10a31 100644 --- a/lib/python/base_cli/paths.py +++ b/lib/python/base_cli/paths.py @@ -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( @@ -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) + + 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 diff --git a/lib/python/base_cli/profile.py b/lib/python/base_cli/profile.py index 5211929..ffd2fe3 100644 --- a/lib/python/base_cli/profile.py +++ b/lib/python/base_cli/profile.py @@ -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__ = [ @@ -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, diff --git a/tests/full_validate.sh b/tests/full_validate.sh index 6138c9f..9dec3d8 100755 --- a/tests/full_validate.sh +++ b/tests/full_validate.sh @@ -25,7 +25,9 @@ require_commands() { } run_baseline() { + require_commands uv bash ./tests/validate.sh + uv lock --check } run_runtime() { diff --git a/tests/test_batteries_included_config.py b/tests/test_batteries_included_config.py index 17bc4d7..8854fd6 100644 --- a/tests/test_batteries_included_config.py +++ b/tests/test_batteries_included_config.py @@ -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"): diff --git a/tests/test_paths.py b/tests/test_paths.py index 46982a5..c25df7c 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -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, @@ -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"))