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
45 changes: 45 additions & 0 deletions docs/tickets/features/FR-00002-TD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Technical Document: FR-00002 — Add self-contained CLI to cache package

## Overview

The cache package currently has no CLI and its `CacheSettings` model only carries `namespace` and `strict` — it has no awareness of which storage backend to use or where it lives. Any consumer must manually resolve storage settings, create a backend, and wire it to `Cache`. This ticket introduces a `core-cache` CLI with a single `--config` flag that accepts a self-contained settings file covering both cache behaviour and the storage backend. The key design change is embedding `StorageSettings` directly inside `CacheSettings`, making the cache's configuration model fully self-contained.

## Approach

`CacheSettings` gains a `storage: StorageSettings` field (imported from `core_storage`). This means a single resolved `CacheSettings` instance carries everything needed to build a wired `Cache`: the namespace, the strict flag, and the full backend definition. No second resolver call, no second config file.

A `defaults.toml` is added to the cache package (currently missing) to establish the baseline layer. The cache `get_settings()` resolver gains a `config_file: Path | None` parameter — identical to what was added to storage in FR-00001 — which threads through to `LayerDiscovery.discover_layers(explicit_config=...)`.

The cache CLI callback resolves `CacheSettings` once, then wires:
```
backend = create_backend(cache_settings.storage)
store = Store(backend).namespace(cache_settings.namespace)
cache = Cache(store, strict=cache_settings.strict)
```

The resulting `Cache` is placed on the Typer context and consumed by each subcommand.

## Architecture / Design Decisions

- **Embed `StorageSettings` in `CacheSettings`, not a path pointer**: a path pointer (`storage_config: Path`) would require a second file and a second resolver call at runtime. Embedding the model keeps the config surface to a single file and a single resolved object.
- **No per-invocation backend override flags on the cache CLI**: the cache CLI is intentionally simpler than the storage CLI. Backend configuration belongs in the config file; the only CLI-level knob is which config file to use.
- **Reuse `LayerDiscovery` / `ConfigBuilder` from storage**: no duplication. The cache resolver passes its own `APP_NAME`, `ENV_PREFIX`, and `CacheSettings` model type into the same machinery.
- **`config_file` param mirrors FR-00001 exactly**: consistent API across both packages. When provided, project-root discovery is skipped; explicit config sits above XDG user config but below env vars.

## Implementation Plan

1. Add `storage: StorageSettings = Field(default_factory=StorageSettings)` to `CacheSettings` in `packages/cache/src/core_cache/settings/models.py`.
2. Create `packages/cache/src/core_cache/settings/defaults.toml` with SQLite backend defaults pointing to `~/.local/share/core-cache/cache.db`.
3. Add `config_file: Path | None = None` to `get_settings()` in `packages/cache/src/core_cache/settings/resolver.py`; pass it as `explicit_config` to `LayerDiscovery.discover_layers()`.
4. Create `packages/cache/src/core_cache/cli/__init__.py` with a Typer app, a `configure_cache` callback (single `--config`/`-c` option), and subcommands: `lookup`, `invalidate`, `clear`.
5. Register the `core-cache` entry point in `packages/cache/pyproject.toml`.

## Testing Strategy

- Unit tests for the updated `CacheSettings`: `storage` field present, defaults resolve to SQLite, embedded storage settings are validated by Pydantic.
- Unit tests for the updated `get_settings()`: `config_file` is applied, beats project-root, env vars still override it.
- CLI tests: `--config` with a valid file exits 0 and uses embedded storage settings; `--config` with a nonexistent path exits non-zero; omitting `--config` falls back to layered resolution.

## Risks / Unknowns

- None. The change is additive: `CacheSettings` gains a new field with a default, so existing library consumers that construct `CacheSettings()` directly are unaffected.
28 changes: 28 additions & 0 deletions docs/tickets/features/FR-00002-US.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# User Story: FR-00002 — Add self-contained CLI to cache package

## Story

As a developer using the core-cache package, I want a `core-cache` CLI with a single `--config` flag pointing to a cache-owned settings file so that I can operate the cache (lookup, invalidate, clear) without needing to manage storage configuration separately — everything the cache needs, including the backend path, lives in one file.

## Acceptance Criteria

- [ ] The `core-cache` CLI is available as an entry point after installing the package.
- [ ] The CLI accepts a `--config <path>` / `-c <path>` flag pointing to a TOML settings file.
- [ ] The settings file is self-contained: it holds both cache settings (`namespace`, `strict`) and storage backend settings (`backend.type`, `backend.sqlite.path`, etc.).
- [ ] `CacheSettings` embeds `StorageSettings` so the full configuration is expressed in a single model.
- [ ] A `defaults.toml` is shipped with the package providing sensible defaults (SQLite backend, `~/.local/share/core-cache/cache.db`).
- [ ] When `--config` is provided, project-root auto-discovery is skipped and the explicit file is used above the XDG user config (`~/.config/core-cache/settings.toml`).
- [ ] When `--config` is not provided, the existing layered resolution (package defaults → project root → XDG user config → env vars) is used.
- [ ] If the path passed to `--config` does not exist, the CLI exits with a non-zero code and a clear error message.
- [ ] All existing `Cache` / `CacheSettings` public API remains unchanged for library consumers.

## Out of Scope

- Supporting multiple `--config` files in a single invocation.
- Adding per-invocation backend override flags (e.g. `--backend`, `--sqlite-path`) to the cache CLI.
- A `core-cache start` / daemon command.

## Notes

- `StorageSettings` is imported from `core_storage` — no duplication of backend models.
- The layered settings infrastructure (`LayerDiscovery`, `ConfigBuilder`) is already shared from `core_storage`; this ticket follows the same pattern established by FR-00001.
4 changes: 4 additions & 0 deletions packages/cache/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ license = { text = "MIT" }
dependencies = [
"core-storage",
"pydantic>=2.0",
"typer>=0.9",
]

[project.scripts]
core-cache = "core_cache.cli:app"

[tool.uv.sources]
core-storage = { workspace = true }

Expand Down
54 changes: 54 additions & 0 deletions packages/cache/src/core_cache/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""CLI entry point for core-cache."""

from __future__ import annotations

from pathlib import Path

import typer

from core_cache.cache import Cache
from core_cache.cli.commands.clear import clear_cache
from core_cache.cli.commands.invalidate import invalidate_entry
from core_cache.cli.commands.lookup import lookup_entry
from core_cache.settings.resolver import get_settings
from core_storage.exceptions import StorageError
from core_storage.factory import create_backend
from core_storage.store import Store

app = typer.Typer(
help="core-cache — file-path-keyed cache CLI",
no_args_is_help=True,
)

app.command("lookup")(lookup_entry)
app.command("invalidate")(invalidate_entry)
app.command("clear")(clear_cache)


@app.callback()
def configure_cache(
ctx: typer.Context,
config: Path | None = typer.Option(
None,
"--config",
"-c",
help="Path to a settings.toml file. Overrides project-root and user config.",
exists=True,
file_okay=True,
dir_okay=False,
readable=True,
),
) -> None:
"""Resolve settings then build and wire the cache."""
ctx.ensure_object(dict)
try:
cache_settings = get_settings(config_file=config)
backend = create_backend(cache_settings.storage)
store = Store(backend).namespace(cache_settings.namespace)
ctx.obj = Cache(store, strict=cache_settings.strict)
except StorageError as storage_error:
typer.echo(f"Error: {storage_error}", err=True)
raise typer.Exit(code=1) from None


__all__ = ["app"]
1 change: 1 addition & 0 deletions packages/cache/src/core_cache/cli/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""CLI subcommands for core-cache."""
29 changes: 29 additions & 0 deletions packages/cache/src/core_cache/cli/commands/clear.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""CLI command: clear all entries from the cache namespace."""

from __future__ import annotations

import typer

from core_cache.cache import Cache
from core_storage.exceptions import StorageError


def clear_cache(
ctx: typer.Context,
yes: bool = typer.Option(
False,
"--yes",
"-y",
help="Skip confirmation prompt.",
),
) -> None:
"""Delete all entries in the cache namespace."""
cache: Cache = ctx.obj
if not yes:
typer.confirm("Clear all entries in the cache?", abort=True)
try:
deleted_count = cache.clear()
typer.echo(f"Cleared {deleted_count} entry/entries.")
except StorageError as storage_error:
typer.echo(f"Error: {storage_error}", err=True)
raise typer.Exit(code=1) from None
47 changes: 47 additions & 0 deletions packages/cache/src/core_cache/cli/commands/invalidate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""CLI command: invalidate a cache entry by file path and params."""

from __future__ import annotations

from pathlib import Path

import typer

from core_cache.cache import Cache
from core_cache.exceptions import CacheError


def invalidate_entry(
ctx: typer.Context,
path: Path = typer.Argument(..., help="Source file path to invalidate."),
param: list[str] = typer.Option(
[],
"--param",
"-p",
help="Generation parameter as key=value. Repeatable.",
),
) -> None:
"""Remove the cache entry for the given path and params."""
cache: Cache = ctx.obj
params = _parse_params(param)
try:
removed = cache.invalidate(path, params)
except CacheError as cache_error:
typer.echo(f"Error: {cache_error}", err=True)
raise typer.Exit(code=1) from None

if removed:
typer.echo(f"Invalidated: {path}")
else:
typer.echo(f"Not found: {path}", err=True)
raise typer.Exit(code=1)


def _parse_params(raw: list[str]) -> dict[str, object]:
params: dict[str, object] = {}
for item in raw:
if "=" not in item:
typer.echo(f"Error: param {item!r} must be in key=value format.", err=True)
raise typer.Exit(code=1)
key, _, value = item.partition("=")
params[key] = value
return params
48 changes: 48 additions & 0 deletions packages/cache/src/core_cache/cli/commands/lookup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""CLI command: look up a cache entry by file path and params."""

from __future__ import annotations

from pathlib import Path

import typer

from core_cache.cache import Cache
from core_cache.exceptions import CacheError
from core_cache.result import CacheHit


def lookup_entry(
ctx: typer.Context,
path: Path = typer.Argument(..., help="Source file path to look up."),
param: list[str] = typer.Option(
[],
"--param",
"-p",
help="Generation parameter as key=value. Repeatable.",
),
) -> None:
"""Check whether a file path is cached. Exits 0 on hit, 1 on miss."""
cache: Cache = ctx.obj
params = _parse_params(param)
try:
result = cache.lookup(path, params)
except CacheError as cache_error:
typer.echo(f"Error: {cache_error}", err=True)
raise typer.Exit(code=1) from None

if isinstance(result, CacheHit):
typer.echo(result.value.decode())
else:
typer.echo(f"Miss: {result.reason}", err=True)
raise typer.Exit(code=1)


def _parse_params(raw: list[str]) -> dict[str, object]:
params: dict[str, object] = {}
for item in raw:
if "=" not in item:
typer.echo(f"Error: param {item!r} must be in key=value format.", err=True)
raise typer.Exit(code=1)
key, _, value = item.partition("=")
params[key] = value
return params
20 changes: 20 additions & 0 deletions packages/cache/src/core_cache/settings/defaults.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# core-cache package defaults
# Lowest priority — overridden by project settings.toml, user settings.toml,
# environment variables, and CLI flags.

namespace = "core-cache"
strict = false

[storage.backend]
type = "sqlite"

[storage.backend.sqlite]
path = "~/.local/share/core-cache/cache.db"

[storage.backend.json]
path = "~/.local/share/core-cache/cache.json"

[storage.backend.redis]
host = "localhost"
port = 6379
db = 0
5 changes: 5 additions & 0 deletions packages/cache/src/core_cache/settings/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from pydantic import BaseModel, Field

from core_storage.settings.models import StorageSettings


class CacheSettings(BaseModel):
"""Settings for core_cache.
Expand All @@ -13,7 +15,10 @@ class CacheSettings(BaseModel):
this to avoid cross-generator collisions (e.g. "color-scheme-cache").
strict: When True, storage errors propagate instead of degrading to
CacheMiss. Default False (transparent degradation).
storage: Full storage backend configuration. Embedded so a single
config file is self-contained.
"""

namespace: str = Field(default="core-cache", min_length=1)
strict: bool = False
storage: StorageSettings = Field(default_factory=StorageSettings)
14 changes: 10 additions & 4 deletions packages/cache/src/core_cache/settings/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

from pathlib import Path
from typing import Any
from typing import Any, cast

from core_cache.settings.constants import APP_NAME, ENV_PREFIX
from core_cache.settings.models import CacheSettings
Expand All @@ -13,25 +13,31 @@

def get_settings(
project_root: Path | None = None,
config_file: Path | None = None,
cli_overrides: dict[str, Any] | None = None,
_environ: dict[str, str] | None = None,
) -> CacheSettings:
"""Return resolved CacheSettings by applying all layers in priority order.

Priority: package defaults < project settings.toml <
~/.config/core-cache/settings.toml < env vars < cli_overrides.
~/.config/core-cache/settings.toml < config_file <
env vars < cli_overrides.

Args:
project_root: Override the project root used to find ``settings.toml``.
Defaults to ``Path.cwd()``.
Defaults to ``Path.cwd()``. Ignored when ``config_file`` is provided.
config_file: Explicit path to a settings file (e.g. from ``--config``).
When provided, project-root auto-discovery is skipped and this file
is loaded above the XDG user config.
cli_overrides: Dotted-path overrides, e.g. ``{"namespace": "my-ns"}``.
_environ: Override ``os.environ`` (for testing).
"""
layers = LayerDiscovery.discover_layers(
app_name=APP_NAME,
project_root=project_root,
env_prefix=ENV_PREFIX,
explicit_config=config_file,
_environ=_environ,
)
validated = ConfigBuilder.build(CacheSettings, layers, cli_overrides=cli_overrides)
return validated # type: ignore[return-value]
return cast(CacheSettings, validated)
Loading
Loading