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
47 changes: 47 additions & 0 deletions .github/workflows/smoke-test-storage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Smoke Tests (storage)

on:
push:
branches: ["**"]
paths:
- "packages/storage/**"
- ".github/workflows/smoke-test-storage.yml"
workflow_dispatch:
inputs:
verbose:
description: 'Verbose test output'
required: false
default: false
type: boolean

jobs:
smoke-test:
name: Smoke Tests (storage)
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install uv
run: pip install uv

- name: Install dependencies
run: uv sync --dev --all-packages

- name: Add venv to PATH
run: echo "$(pwd)/.venv/bin" >> $GITHUB_PATH

- name: Run smoke tests (standard mode)
if: inputs.verbose == false && github.event_name != 'push'
run: bash packages/storage/tests/smoke/run-smoke-tests.sh

- name: Run smoke tests (verbose mode)
if: inputs.verbose == true || github.event_name == 'push'
run: bash packages/storage/tests/smoke/run-smoke-tests.sh --verbose
33 changes: 33 additions & 0 deletions docs/tickets/features/FR-00001-TD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Technical Document: FR-00001 — Add --config flag to storage CLI

## Overview

The core-storage CLI currently resolves configuration through a layered system: package defaults → `cwd/settings.toml` → `~/.config/core-storage/settings.toml` → env vars → CLI flags. The project-root layer is implicit and CWD-dependent, making it fragile when the user is not in the expected directory. This ticket adds a `--config <path>` flag that lets the user point explicitly at a settings file, removing the CWD dependency and giving a single, predictable way to switch configurations.

## Approach

A new `explicit_config` parameter is added to `LayerDiscovery.discover_layers()`. When set, it inserts the file as a named layer (`"explicit-config"`) immediately above the XDG user config, and skips project-root auto-discovery entirely. This preserves the full layer priority order: explicit config overrides user config, but env vars and CLI flags still override explicit config.

The change is threaded through `get_settings()` as a `config_file` parameter and exposed at the CLI via a Typer `--config`/`-c` option with `exists=True`, which delegates the "file not found" validation to Typer before any backend setup runs.

## Architecture / Design Decisions

- **Priority slot**: explicit config sits above user config but below env vars and CLI flags. This means per-invocation overrides (env vars, flags) always win, which is consistent with the existing precedence model.
- **Skip project-root when explicit config is given**: combining both would be surprising — if you pass `--config`, you are declaring your config source explicitly and auto-discovery adds noise.
- **`exists=True` on the Typer option**: offloads path validation to Typer, keeping `configure_backend` free of manual file checks and producing a consistent error message format.

## Implementation Plan

1. Add `explicit_config: Path | None = None` to `LayerDiscovery.discover_layers()`: skip project-root step when set, append an `"explicit-config"` `LayerSource` after user-config.
2. Add `config_file: Path | None = None` to `get_settings()` and pass it as `explicit_config` to `LayerDiscovery`.
3. Add `--config`/`-c` Typer option to the `configure_backend` CLI callback with `exists=True`; pass resolved path to `get_settings(config_file=...)`.

## Testing Strategy

- Unit tests for `LayerDiscovery`: explicit config appears in layers, project-root is skipped, explicit config sits above user-config by index.
- Unit tests for `get_settings()`: config file is applied, config file beats project-root, CLI overrides beat config file.
- CLI tests: `--config` with a valid file exits 0 and uses the settings from that file; `--config` with a nonexistent path exits non-zero.

## Risks / Unknowns

- None. The change is additive and all existing behaviour is preserved when `--config` is absent.
23 changes: 23 additions & 0 deletions docs/tickets/features/FR-00001-US.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# User Story: FR-00001 — Add --config flag to storage CLI

## Story

As a developer using the core-storage CLI, I want to pass an explicit settings file path via a `--config` flag so that I can switch between storage configurations without depending on my current working directory or modifying global user settings.

## Acceptance Criteria

- [ ] The CLI accepts a `--config <path>` / `-c <path>` flag that points to a settings TOML file.
- [ ] When `--config` is provided, the specified file is loaded as a configuration layer above the XDG user config (`~/.config/core-storage/settings.toml`).
- [ ] When `--config` is provided, project-root auto-discovery (`cwd/settings.toml`) is skipped.
- [ ] Environment variables and other CLI flags (e.g. `--backend`, `--sqlite-path`) still override values from `--config`.
- [ ] If the path passed to `--config` does not exist, the CLI exits with a non-zero code and a clear error message before attempting to connect to any backend.
- [ ] All existing CLI behaviour is unchanged when `--config` is not provided.

## Out of Scope

- Adding a `--config` flag to the cache CLI (no CLI exists yet for that package).
- Supporting multiple `--config` files in a single invocation.

## Notes

- Related to the broader question of how cache and storage share configuration at the CLI level.
15 changes: 14 additions & 1 deletion packages/storage/src/core_storage/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@
@app.callback()
def configure_backend(
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,
),
backend_type: str | None = typer.Option(
None,
"--backend",
Expand Down Expand Up @@ -98,7 +108,10 @@ def configure_backend(
cli_overrides["backend.redis.password"] = redis_password

try:
storage_settings = get_settings(cli_overrides=cli_overrides or None)
storage_settings = get_settings(
config_file=config,
cli_overrides=cli_overrides or None,
)
ctx.obj = Store(create_backend(storage_settings))
except StorageError as storage_error:
typer.echo(f"Error: {storage_error}", err=True)
Expand Down
45 changes: 31 additions & 14 deletions packages/storage/src/core_storage/settings/layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def discover_layers(
app_name: str,
project_root: Path | None = None,
env_prefix: str | None = None,
explicit_config: Path | None = None,
_environ: dict[str, str] | None = None,
) -> list[LayerSource]:
"""Return all applicable layers ordered lowest to highest priority.
Expand All @@ -60,9 +61,14 @@ def discover_layers(
app_name: Used to construct the XDG config path.
project_root: Explicit project root for the project-level
``settings.toml``. Falls back to ``Path.cwd()``.
Ignored when ``explicit_config`` is provided.
env_prefix: When provided, environment variables matching
``<prefix>SECTION__FIELD`` are injected as the
highest-priority layer.
explicit_config: Path to a settings file passed explicitly (e.g.
via ``--config``). When provided, project-root
auto-discovery is skipped and this file is loaded
above the XDG user config.
_environ: Override ``os.environ`` for testing.
"""
discovered_layers: list[LayerSource] = []
Expand All @@ -77,20 +83,21 @@ def discover_layers(
)
)

# 2. Project root settings.toml
try:
root = project_root if project_root is not None else Path.cwd()
project_settings_path = root / "settings.toml"
if project_settings_path.exists():
discovered_layers.append(
LayerSource(
name="project-root",
filepath=project_settings_path,
is_namespaced=True,
# 2. Project root settings.toml (skipped when explicit_config is given)
if explicit_config is None:
try:
root = project_root if project_root is not None else Path.cwd()
project_settings_path = root / "settings.toml"
if project_settings_path.exists():
discovered_layers.append(
LayerSource(
name="project-root",
filepath=project_settings_path,
is_namespaced=True,
)
)
)
except OSError:
pass # cwd inaccessible (e.g. deleted in container) — skip
except OSError:
pass # cwd inaccessible (e.g. deleted in container) — skip

# 3. XDG user config
user_settings_path = get_user_settings_file(app_name)
Expand All @@ -103,7 +110,17 @@ def discover_layers(
)
)

# 4. Environment variables
# 4. Explicit config (--config flag) — overrides user config
if explicit_config is not None:
discovered_layers.append(
LayerSource(
name="explicit-config",
filepath=explicit_config,
is_namespaced=True,
)
)

# 5. Environment variables
if env_prefix:
env_data = parse_env_vars(prefix=env_prefix, environ=_environ)
if env_data:
Expand Down
9 changes: 8 additions & 1 deletion packages/storage/src/core_storage/settings/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,23 @@

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,
) -> StorageSettings:
"""Return resolved settings by applying all layers in priority order.

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

Args:
project_root: Override the project root used to find
``settings.toml``. 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 from CLI flags, e.g.
``{"backend.type": "redis"}``.
_environ: Override ``os.environ`` (for testing).
Expand All @@ -32,6 +38,7 @@ def get_settings(
app_name=APP_NAME,
project_root=project_root,
env_prefix=ENV_PREFIX,
explicit_config=config_file,
_environ=_environ,
)
validated_settings = ConfigBuilder.build(
Expand Down
138 changes: 138 additions & 0 deletions packages/storage/tests/smoke/run-smoke-tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
#!/usr/bin/env bash
# Smoke tests for core-storage CLI — validates the --config flag end-to-end.
# Usage: bash tests/smoke/run-smoke-tests.sh [--verbose]

set -euo pipefail

VERBOSE=false
for arg in "$@"; do
[[ "$arg" == "--verbose" ]] && VERBOSE=true
done

PASS=0
FAIL=0
TMPDIR_ROOT=$(mktemp -d)
trap 'rm -rf "$TMPDIR_ROOT"' EXIT

log() { [[ "$VERBOSE" == true ]] && echo "$@" || true; }
pass() { echo " ✓ $1"; PASS=$((PASS + 1)); }
fail() { echo " ✗ $1" >&2; FAIL=$((FAIL + 1)); }

run_test() {
local description="$1"; shift
log " running: core-storage $*"
if "$@" > /dev/null 2>&1; then
pass "$description"
else
fail "$description"
fi
}

run_test_expect_fail() {
local description="$1"; shift
log " running (expect failure): core-storage $*"
if ! "$@" > /dev/null 2>&1; then
pass "$description"
else
fail "$description"
fi
}

# ---------------------------------------------------------------------------

echo "=== Smoke Tests: core-storage ==="
log ""

# --- Baseline ---
echo "--- Baseline ---"
run_test "--help exits 0" core-storage --help

# --- --config flag: basic loading ---
echo ""
echo "--- --config flag ---"

CONFIG="$TMPDIR_ROOT/custom.toml"
JSON_STORE="$TMPDIR_ROOT/store.json"
cat > "$CONFIG" <<TOML
[backend]
type = "json"

[backend.json]
path = "$JSON_STORE"
TOML

run_test "--config loads settings file" \
core-storage --config "$CONFIG" list ns

run_test "--config: set and get round-trip" bash -c "
core-storage --config '$CONFIG' set ns key1 value1 &&
result=\$(core-storage --config '$CONFIG' get ns key1) &&
[[ \"\$result\" == 'value1' ]]
"

run_test "--config: exists returns 0 for known key" \
core-storage --config "$CONFIG" exists ns key1

run_test "--config: list shows key" bash -c "
core-storage --config '$CONFIG' list ns | grep -q key1
"

run_test "--config: delete removes key" bash -c "
core-storage --config '$CONFIG' delete ns key1 &&
! core-storage --config '$CONFIG' exists ns key1
"

run_test "--config: clear empties namespace" bash -c "
core-storage --config '$CONFIG' set ns a 1 &&
core-storage --config '$CONFIG' set ns b 2 &&
core-storage --config '$CONFIG' clear --yes ns &&
! core-storage --config '$CONFIG' exists ns a &&
! core-storage --config '$CONFIG' exists ns b
"

# --- --config flag overrides project-root settings.toml ---
echo ""
echo "--- --config overrides project-root ---"

PROJECT_DIR="$TMPDIR_ROOT/project"
mkdir -p "$PROJECT_DIR"
PROJECT_STORE="$TMPDIR_ROOT/project-store.sqlite"
cat > "$PROJECT_DIR/settings.toml" <<TOML
[backend]
type = "sqlite"

[backend.sqlite]
path = "$PROJECT_STORE"
TOML

EXPLICIT_STORE="$TMPDIR_ROOT/explicit-store.json"
EXPLICIT_CONFIG="$TMPDIR_ROOT/explicit.toml"
cat > "$EXPLICIT_CONFIG" <<TOML
[backend]
type = "json"

[backend.json]
path = "$EXPLICIT_STORE"
TOML

run_test "--config skips project-root settings.toml (uses json not sqlite)" bash -c "
cd '$PROJECT_DIR' &&
core-storage --config '$EXPLICIT_CONFIG' list ns > /dev/null &&
[[ ! -f '$PROJECT_STORE' ]]
"

# --- invalid --config path is rejected ---
echo ""
echo "--- --config validation ---"

run_test_expect_fail "nonexistent --config path exits nonzero" \
core-storage --config /nonexistent/path.toml list ns

# ---------------------------------------------------------------------------

echo ""
echo "Results: ${PASS} passed, ${FAIL} failed"

if [[ "$FAIL" -gt 0 ]]; then
exit 1
fi
Loading
Loading