Source code for tlc_plugin_sdk.shared.config_store

# Copyright 2026 3LC Inc.
# SPDX-License-Identifier: Apache-2.0
"""Generic on-disk store for a plugin's saved job configs.

A "config" here is a **reusable job parameterization** a user names and re-runs
from the plugin UI (the "New config" / config-bar feature driven by
:func:`tlc_plugin_sdk.shared.config_ui.config_ui_script`) — NOT the
service/host settings (those live in ``persistent_settings`` / ``settings.json``).

Each plugin keeps its own ``@dataclass`` config schema and hands the *type* to
:class:`PluginConfigStore`, which owns the JSON-on-disk CRUD. The config
dataclass must carry the common envelope fields the store manages:

- ``id: str``         — assigned on first save
- ``created: str``    — ISO timestamp, assigned on first save; list order key
- ``last_run: str | None`` — bumped by :meth:`update_last_run`

(``name: str`` is conventional for the UI but not required by the store.)

Configs live under ``~/.3lc-plugin-configs/<plugin-id>/``. Pass ``legacy_dir``
to lazily migrate a pre-standardization location on first construction.
"""

from __future__ import annotations

import json
import logging
import shutil
import uuid
from dataclasses import asdict, is_dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Generic, TypeVar

logger = logging.getLogger(__name__)

# Standardized root for all plugins' saved job configs.
CONFIG_ROOT = Path.home() / ".3lc-plugin-configs"

T = TypeVar("T")


[docs] class PluginConfigStore(Generic[T]): """Persist a plugin's saved job configs as JSON files, one per config. Args: config_cls: The plugin's config ``@dataclass`` (must have ``id`` / ``created`` / ``last_run`` fields). Instances are (de)serialized via :func:`dataclasses.asdict` and ``config_cls(**known_fields)``. plugin_id: The plugin's manifest id; configs live under ``~/.3lc-plugin-configs/<plugin_id>/``. legacy_dir: Optional back-compat directory. If the standardized directory has no configs yet and ``legacy_dir`` holds some, they are moved on construction (one-time, idempotent). """ def __init__(self, config_cls: type[T], plugin_id: str, *, legacy_dir: Path | str | None = None) -> None: if not is_dataclass(config_cls): msg = f"PluginConfigStore requires a dataclass config type, got {config_cls!r}" raise TypeError(msg) self._cls = config_cls self._dir = CONFIG_ROOT / plugin_id self._dir.mkdir(parents=True, exist_ok=True) if legacy_dir is not None: self._migrate_legacy(Path(legacy_dir)) # ── CRUD ─────────────────────────────────────────────────────────────
[docs] def list_configs(self) -> list[T]: """Return all saved configs, newest first (by ``created``).""" configs: list[T] = [] for f in sorted(self._dir.glob("*.json")): cfg = self._read(f) if cfg is not None: configs.append(cfg) configs.sort(key=lambda c: getattr(c, "created", "") or "", reverse=True) return configs
[docs] def get_config(self, config_id: str) -> T | None: """Load a config by id, or None if missing/unreadable.""" return self._read(self._path(config_id))
[docs] def save_config(self, config: T) -> T: """Save a config, assigning ``id`` and ``created`` on first save.""" if not getattr(config, "id", ""): config.id = str(uuid.uuid4()) # type: ignore[attr-defined] if not getattr(config, "created", ""): config.created = datetime.now(timezone.utc).isoformat() # type: ignore[attr-defined] with open(self._path(config.id), "w") as f: # type: ignore[attr-defined] json.dump(asdict(config), f, indent=2) # type: ignore[call-overload] return config
[docs] def delete_config(self, config_id: str) -> bool: """Delete a config. Returns True if it existed.""" path = self._path(config_id) if path.exists(): path.unlink() return True return False
[docs] def update_last_run(self, config_id: str) -> None: """Stamp ``last_run`` with the current time, if the config exists.""" config = self.get_config(config_id) if config is not None: config.last_run = datetime.now(timezone.utc).isoformat() # type: ignore[attr-defined] self.save_config(config)
# ── internals ──────────────────────────────────────────────────────── def _path(self, config_id: str) -> Path: return self._dir / f"{config_id}.json" def _read(self, path: Path) -> T | None: """Load + deserialize one config file, tolerating drift/corruption. Unknown keys (older/newer schema) are dropped and missing fields fall back to dataclass defaults, so a config schema can evolve without invalidating saved files. """ if not path.exists(): return None try: with open(path) as f: data = json.load(f) known = {k: v for k, v in data.items() if k in self._cls.__dataclass_fields__} # type: ignore[attr-defined] return self._cls(**known) except (json.JSONDecodeError, OSError, TypeError): return None def _migrate_legacy(self, legacy_dir: Path) -> None: """Move configs from a pre-standardization dir into the new location once.""" if not legacy_dir.is_dir() or any(self._dir.glob("*.json")): return moved = 0 for f in legacy_dir.glob("*.json"): dest = self._dir / f.name if not dest.exists(): shutil.move(str(f), str(dest)) moved += 1 if moved: logger.info("Migrated %d config(s) from %s%s", moved, legacy_dir, self._dir)