diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cf0624..9fd11c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and versions are tracked in the repo-root `VERSION` file. ### Changed +- Select platform-aware cache roots (`XDG_CACHE_HOME`, macOS Caches, and + Windows `LOCALAPPDATA`) and normalize home-relative paths across separators. - Make `base_cli.App()` use the consumer-neutral profile by default. - Move manifest discovery, implicit configuration, owner-aware runtime layout, and history persistence out of the generic package. Consumers now provide diff --git a/README.md b/README.md index b75d888..9730f08 100644 --- a/README.md +++ b/README.md @@ -448,7 +448,10 @@ explicitly and return a clear usage error or actionable message. The generic profile uses the configured cache root and an application namespace to create per-run logs, caches, and temporary directories. Pass `cache_root` to `CliProfile.generic()` for deterministic placement in tests or -applications; otherwise the platform cache directory is used. The generic +applications; otherwise the platform cache directory is used. Linux and WSL2 +follow `XDG_CACHE_HOME` or `~/.cache`, macOS uses `~/Library/Caches`, and +Windows uses `%LOCALAPPDATA%` (falling back to `~/AppData/Local`). Set +`BASE_CLI_CACHE_DIR` to override the default on any platform. The generic profile does not prescribe a product-wide cache name or cleanup command. Each invocation is a run bundle containing private (`0600`) `run.json`, diff --git a/docs/cache-ownership-and-layout.md b/docs/cache-ownership-and-layout.md index 3be89fa..9a8e682 100644 --- a/docs/cache-ownership-and-layout.md +++ b/docs/cache-ownership-and-layout.md @@ -4,6 +4,18 @@ Runtime state is rooted at the cache root supplied to `CliProfile.generic()` or the platform cache directory. The generic profile places each application in a sanitized application namespace and does not impose a product-wide cache name. +When no explicit cache root is supplied, the generic profile follows these +platform conventions: + +- Linux and WSL2: `XDG_CACHE_HOME`, or `~/.cache` when it is unset; +- macOS: `~/Library/Caches`; and +- Windows: `%LOCALAPPDATA%`, with `~/AppData/Local` as a fallback. + +`BASE_CLI_CACHE_DIR` overrides these defaults on every platform. WSL2 is +supported when the process runs inside the Linux distribution; Windows-mounted +paths such as `/mnt/c` retain their own filesystem performance and permission +characteristics. + Consumer profiles may choose a different cache root or owner-aware layout when their application needs stronger isolation between projects or checkouts. diff --git a/lib/python/base_cli/history.py b/lib/python/base_cli/history.py index 61b1e1f..e5f502c 100644 --- a/lib/python/base_cli/history.py +++ b/lib/python/base_cli/history.py @@ -315,20 +315,24 @@ def redact_history_text(value: str) -> str: return compact_home_text(redact_text_value(value)) -def compact_optional_path(path: Path | None) -> str | None: +def compact_optional_path(path: Path | None, *, home: Path | str | None = None) -> str | None: if path is None: return None - return compact_path(path) + return compact_path(path, home=home) -def compact_path(path: Path) -> str: - return compact_home_text(str(path.expanduser().resolve(strict=False))) +def compact_path(path: Path, *, home: Path | str | None = None) -> str: + return compact_home_text(str(path.expanduser().resolve(strict=False)), home=home) -def compact_home_text(value: str) -> str: - home = str(Path.home().expanduser().resolve(strict=False)) - if value == home: +def compact_home_text(value: str, *, home: Path | str | None = None) -> str: + home_text = str(home) if home is not None else str(Path.home().expanduser().resolve(strict=False)) + normalized_value = value.replace("\\", "/") + normalized_home = home_text.replace("\\", "/").rstrip("/") + comparison_value = normalized_value.lower() if os.name == "nt" else normalized_value + comparison_home = normalized_home.lower() if os.name == "nt" else normalized_home + if comparison_value == comparison_home: return "~" - if value.startswith(f"{home}/"): - return f"~/{value[len(home) + 1:]}" + if comparison_value.startswith(f"{comparison_home}/"): + return f"~/{normalized_value[len(normalized_home) + 1:]}" return value diff --git a/lib/python/base_cli/paths.py b/lib/python/base_cli/paths.py index c801c8a..9d9967c 100644 --- a/lib/python/base_cli/paths.py +++ b/lib/python/base_cli/paths.py @@ -2,10 +2,12 @@ import contextlib import contextvars +import os import re +import sys import time import uuid -from collections.abc import Iterator +from collections.abc import Iterator, Mapping from pathlib import Path _WORKING_DIRECTORY_OVERRIDE: contextvars.ContextVar[Path | None] = contextvars.ContextVar( @@ -14,6 +16,38 @@ ) +def default_cache_root( + *, + environ: Mapping[str, str] | None = None, + home: Path | None = None, + platform_name: str | None = None, +) -> Path: + """Return the platform-default cache root for the generic profile. + + ``BASE_CLI_CACHE_DIR`` always wins so consumers and tests can provide an + explicit location. Linux follows ``XDG_CACHE_HOME`` when it is set, + macOS uses ``Library/Caches``, and Windows uses ``LOCALAPPDATA``. + """ + + environment = os.environ if environ is None else environ + configured = environment.get("BASE_CLI_CACHE_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" / "Caches" + if system.startswith("win"): + local_app_data = environment.get("LOCALAPPDATA") + return Path(local_app_data).expanduser() if local_app_data else root / "AppData" / "Local" + + xdg_cache_home = environment.get("XDG_CACHE_HOME") + if xdg_cache_home: + return Path(xdg_cache_home).expanduser() + return root / ".cache" + + 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 496a1f3..427e7a0 100644 --- a/lib/python/base_cli/profile.py +++ b/lib/python/base_cli/profile.py @@ -1,7 +1,5 @@ from __future__ import annotations -import os -import sys from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -10,7 +8,7 @@ from ._runtime import RuntimeLayout, runtime_layout from .config import load_yaml_file -from .paths import make_run_id +from .paths import default_cache_root, make_run_id @dataclass(frozen=True) @@ -124,7 +122,7 @@ def _generic_runtime_resolver( application_home: Path | None, ) -> RuntimeResolver: def resolve_runtime(cli_name: str, project: ProjectInfo | None) -> RuntimeBinding: - root = cache_root.expanduser().resolve() if cache_root is not None else _default_cache_root() + root = (cache_root if cache_root is not None else default_cache_root()).expanduser().resolve() run_id = make_run_id() project_root = project.root if project is not None else None project_name = project.name if project is not None else None @@ -147,13 +145,3 @@ def resolve_runtime(cli_name: str, project: ProjectInfo | None) -> RuntimeBindin ) return resolve_runtime - - -def _default_cache_root() -> Path: - configured = os.environ.get("BASE_CLI_CACHE_DIR") - if configured: - return Path(configured).expanduser().resolve() - root = Path.home() - if sys.platform == "darwin": - return root / "Library" / "Caches" - return root / ".cache" diff --git a/lib/python/base_cli/testing.py b/lib/python/base_cli/testing.py index 986eced..2740c14 100644 --- a/lib/python/base_cli/testing.py +++ b/lib/python/base_cli/testing.py @@ -33,6 +33,9 @@ def invoke( invoke_env = dict(env or {}) if home is not None: invoke_env.setdefault("HOME", str(home)) + invoke_env.setdefault("USERPROFILE", str(home)) + invoke_env.setdefault("LOCALAPPDATA", str(home / "AppData" / "Local")) + invoke_env.setdefault("XDG_CACHE_HOME", str(home / ".cache")) invoke_env.setdefault("BASE_CLI_CACHE_DIR", str(home / ".cache")) runner_kwargs = {} if "mix_stderr" in inspect.signature(CliRunner).parameters: diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..a59a70e --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from base_cli.history import compact_home_text +from base_cli.paths import default_cache_root + + +class DefaultCacheRootTests(unittest.TestCase): + def test_explicit_cache_override_wins_on_every_platform(self) -> None: + root = default_cache_root( + environ={ + "BASE_CLI_CACHE_DIR": "/custom/cache", + "LOCALAPPDATA": "/local/app-data", + "XDG_CACHE_HOME": "/xdg/cache", + }, + home=Path("/home/alice"), + platform_name="win32", + ) + + self.assertEqual(root, Path("/custom/cache")) + + def test_linux_prefers_xdg_cache_home(self) -> None: + root = default_cache_root( + environ={"XDG_CACHE_HOME": "/xdg/cache"}, + home=Path("/home/alice"), + platform_name="linux", + ) + + self.assertEqual(root, Path("/xdg/cache")) + + def test_linux_falls_back_to_home_cache(self) -> None: + root = default_cache_root( + environ={}, + home=Path("/home/alice"), + platform_name="linux", + ) + + self.assertEqual(root, Path("/home/alice/.cache")) + + def test_macos_uses_library_caches(self) -> None: + root = default_cache_root( + environ={"XDG_CACHE_HOME": "/xdg/cache"}, + home=Path("/Users/alice"), + platform_name="darwin", + ) + + self.assertEqual(root, Path("/Users/alice/Library/Caches")) + + def test_windows_prefers_local_app_data(self) -> None: + root = default_cache_root( + environ={"LOCALAPPDATA": r"C:\Users\alice\AppData\Local"}, + home=Path(r"C:\Users\alice"), + platform_name="win32", + ) + + self.assertEqual(root, Path(r"C:\Users\alice\AppData\Local")) + + def test_windows_falls_back_to_home_local_app_data(self) -> None: + root = default_cache_root( + environ={}, + home=Path(r"C:\Users\alice"), + platform_name="win32", + ) + + self.assertEqual(root, Path(r"C:\Users\alice") / "AppData" / "Local") + + +class HomePathCompactionTests(unittest.TestCase): + def test_compacts_home_paths_with_posix_separators(self) -> None: + self.assertEqual( + compact_home_text("/home/alice/project", home=Path("/home/alice")), + "~/project", + ) + + def test_compacts_home_paths_with_windows_separators(self) -> None: + self.assertEqual( + compact_home_text(r"C:\Users\Alice\project", home=r"C:\Users\Alice"), + "~/project", + ) + + def test_leaves_paths_outside_home_unchanged(self) -> None: + value = r"C:\Users\Bob\project" + + self.assertEqual(compact_home_text(value, home=r"C:\Users\Alice"), value)