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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
12 changes: 12 additions & 0 deletions docs/cache-ownership-and-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
22 changes: 13 additions & 9 deletions lib/python/base_cli/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
36 changes: 35 additions & 1 deletion lib/python/base_cli/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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()

Expand Down
16 changes: 2 additions & 14 deletions lib/python/base_cli/profile.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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"
3 changes: 3 additions & 0 deletions lib/python/base_cli/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
86 changes: 86 additions & 0 deletions tests/test_paths.py
Original file line number Diff line number Diff line change
@@ -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)