diff --git a/solvent/__main__.py b/solvent/__main__.py index cd0c63d..351b713 100644 --- a/solvent/__main__.py +++ b/solvent/__main__.py @@ -15,6 +15,7 @@ upgrade check for newer version on PyPI; --check exits 1 if outdated jobs list/show/retry/cancel jobs (jobs --help for sub-commands) logs tail the structured event log; -f to follow, --job/--stage to filter + config show/get/set/reset local configuration values serve webhooks + job API + hosted briefs worker resume incomplete jobs, process the queue telegram long-poll the Telegram bot @@ -65,6 +66,10 @@ def main(): sys.argv.pop(1) from .logs import main as logs_main logs_main() + elif len(sys.argv) > 1 and sys.argv[1] == "config": + sys.argv.pop(1) + from .config_cmd import main as config_main + config_main() elif len(sys.argv) > 1 and sys.argv[1] == "serve": sys.argv.pop(1) from .server import main as serve_main diff --git a/solvent/config_cmd.py b/solvent/config_cmd.py new file mode 100644 index 0000000..9d36183 --- /dev/null +++ b/solvent/config_cmd.py @@ -0,0 +1,147 @@ +""" +config_cmd.py — CLI for viewing and editing SOLVENT configuration. + +Usage: + solvent config show all current config values + solvent config get print a single value + solvent config set update a value and save + solvent config reset restore all defaults + solvent config --json output as JSON +""" + +from __future__ import annotations + +import json +import sys +from dataclasses import fields +from typing import Any + + +def _load_or_default(): + from .config import SolventConfig, load_config + return load_config() or SolventConfig() + + +def _coerce(field_type: str, value: str) -> Any: + """Convert a CLI string value to the appropriate Python type.""" + if field_type in ("bool",): + if value.lower() in ("true", "1", "yes", "on"): + return True + if value.lower() in ("false", "0", "no", "off"): + return False + raise ValueError(f"expected true/false, got {value!r}") + if field_type == "int": + return int(value) + if field_type == "float": + return float(value) + return value # str and anything else + + +def _field_type_name(f) -> str: + """Return a simplified type name string for a dataclass field.""" + t = f.type + if hasattr(t, "__name__"): + return t.__name__ + s = str(t) + for prefix in ("typing.", "") + + +def cmd_show(*, as_json: bool = False) -> None: + cfg = _load_or_default() + data = cfg.to_dict() + if as_json: + print(json.dumps(data, indent=2)) + return + col = max(len(k) for k in data) + 2 + for k, v in data.items(): + print(f" {k:<{col}} {v}") + + +def cmd_get(key: str, *, as_json: bool = False) -> int: + cfg = _load_or_default() + data = cfg.to_dict() + if key not in data: + print(f"[solvent config] unknown key: {key!r}", file=sys.stderr) + return 1 + val = data[key] + if as_json: + print(json.dumps(val)) + else: + print(val) + return 0 + + +def cmd_set(key: str, value: str) -> int: + from .config import SolventConfig, load_config, save_config + + cfg = load_config() or SolventConfig() + known_fields = {f.name: f for f in fields(SolventConfig)} + if key not in known_fields: + print(f"[solvent config] unknown key: {key!r}", file=sys.stderr) + print(f" Valid keys: {', '.join(sorted(known_fields))}", file=sys.stderr) + return 1 + + f = known_fields[key] + type_name = _field_type_name(f) + try: + coerced = _coerce(type_name, value) + except (ValueError, TypeError) as e: + print(f"[solvent config] bad value for {key!r} ({type_name}): {e}", file=sys.stderr) + return 1 + + setattr(cfg, key, coerced) + try: + cfg.validate() + except ValueError as e: + print(f"[solvent config] validation error: {e}", file=sys.stderr) + return 1 + + save_config(cfg) + print(f" {key} = {coerced}") + return 0 + + +def cmd_reset() -> int: + from .config import SolventConfig, save_config + cfg = SolventConfig() + save_config(cfg) + print("[solvent config] reset to defaults") + return 0 + + +def main() -> None: + import argparse + + p = argparse.ArgumentParser( + description="View and edit SOLVENT configuration.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p.add_argument("--json", dest="as_json", action="store_true", + help="output as JSON") + sub = p.add_subparsers(dest="cmd") + + sub.add_parser("show", help="show all config values (default)") + g = sub.add_parser("get", help="get a single config value") + g.add_argument("key") + s = sub.add_parser("set", help="set a config value") + s.add_argument("key") + s.add_argument("value") + sub.add_parser("reset", help="reset all values to defaults") + + args = p.parse_args() + + if args.cmd is None or args.cmd == "show": + cmd_show(as_json=args.as_json) + sys.exit(0) + elif args.cmd == "get": + sys.exit(cmd_get(args.key, as_json=args.as_json)) + elif args.cmd == "set": + sys.exit(cmd_set(args.key, args.value)) + elif args.cmd == "reset": + sys.exit(cmd_reset()) + + +if __name__ == "__main__": + main() diff --git a/tests/test_config_cmd.py b/tests/test_config_cmd.py new file mode 100644 index 0000000..f805aa8 --- /dev/null +++ b/tests/test_config_cmd.py @@ -0,0 +1,218 @@ +"""Tests for solvent/config_cmd.py.""" + +from __future__ import annotations + +import json +import sys +from io import StringIO +from pathlib import Path +from unittest import mock + +import pytest + +from solvent.config import CONFIG_PATH, SolventConfig, save_config +from solvent.config_cmd import cmd_get, cmd_reset, cmd_set, cmd_show, _coerce + + +# --------------------------------------------------------------------------- +# _coerce +# --------------------------------------------------------------------------- + +def test_coerce_bool_true(): + assert _coerce("bool", "true") is True + assert _coerce("bool", "1") is True + assert _coerce("bool", "yes") is True + + +def test_coerce_bool_false(): + assert _coerce("bool", "false") is False + assert _coerce("bool", "0") is False + assert _coerce("bool", "no") is False + + +def test_coerce_bool_invalid(): + with pytest.raises(ValueError): + _coerce("bool", "maybe") + + +def test_coerce_int(): + assert _coerce("int", "42") == 42 + + +def test_coerce_float(): + assert _coerce("float", "3.14") == pytest.approx(3.14) + + +def test_coerce_str(): + assert _coerce("str", "hello") == "hello" + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def isolate_config(tmp_path, monkeypatch): + """Redirect config reads/writes to tmp_path.""" + cfg_dir = tmp_path / ".solvent" + cfg_dir.mkdir() + cfg_path = cfg_dir / "config.json" + monkeypatch.setattr("solvent.config.CONFIG_DIR", cfg_dir) + monkeypatch.setattr("solvent.config.CONFIG_PATH", cfg_path) + monkeypatch.setattr("solvent.config_cmd.CONFIG_PATH", cfg_path, raising=False) + yield cfg_path + + +# --------------------------------------------------------------------------- +# cmd_show +# --------------------------------------------------------------------------- + +def test_cmd_show_prints_keys(capsys): + cmd_show() + captured = capsys.readouterr() + assert "model" in captured.out + assert "rate_burst_limit" in captured.out + + +def test_cmd_show_json(capsys): + cmd_show(as_json=True) + captured = capsys.readouterr() + data = json.loads(captured.out) + assert "model" in data + assert "rate_burst_limit" in data + + +def test_cmd_show_reflects_saved_config(isolate_config, capsys): + cfg = SolventConfig(model="nemotron") + save_config(cfg) + cmd_show() + captured = capsys.readouterr() + assert "nemotron" in captured.out + + +# --------------------------------------------------------------------------- +# cmd_get +# --------------------------------------------------------------------------- + +def test_cmd_get_known_key(capsys): + rc = cmd_get("model") + assert rc == 0 + captured = capsys.readouterr() + assert "offline" in captured.out + + +def test_cmd_get_unknown_key(capsys): + rc = cmd_get("nonexistent_key") + assert rc == 1 + captured = capsys.readouterr() + assert "unknown key" in captured.err + + +def test_cmd_get_json_mode(capsys): + rc = cmd_get("rate_burst_limit", as_json=True) + assert rc == 0 + captured = capsys.readouterr() + val = json.loads(captured.out) + assert isinstance(val, int) + + +# --------------------------------------------------------------------------- +# cmd_set +# --------------------------------------------------------------------------- + +def test_cmd_set_string_field(isolate_config, capsys): + rc = cmd_set("model", "nemotron") + assert rc == 0 + from solvent.config import load_config + cfg = load_config() + assert cfg.model == "nemotron" + + +def test_cmd_set_bool_field(isolate_config): + rc = cmd_set("stripe_test_mode", "true") + assert rc == 0 + from solvent.config import load_config + cfg = load_config() + assert cfg.stripe_test_mode is True + + +def test_cmd_set_int_field(isolate_config): + rc = cmd_set("rate_burst_limit", "10") + assert rc == 0 + from solvent.config import load_config + cfg = load_config() + assert cfg.rate_burst_limit == 10 + + +def test_cmd_set_unknown_key_returns_1(capsys): + rc = cmd_set("bogus_key", "value") + assert rc == 1 + captured = capsys.readouterr() + assert "unknown key" in captured.err + + +def test_cmd_set_invalid_value_returns_1(capsys): + rc = cmd_set("rate_burst_limit", "not_a_number") + assert rc == 1 + + +def test_cmd_set_validation_failure_returns_1(capsys): + rc = cmd_set("model", "turbo-gpt-99") + assert rc == 1 + captured = capsys.readouterr() + assert "validation" in captured.err.lower() or "invalid" in captured.err.lower() + + +# --------------------------------------------------------------------------- +# cmd_reset +# --------------------------------------------------------------------------- + +def test_cmd_reset_restores_defaults(isolate_config, capsys): + cmd_set("model", "nemotron") + rc = cmd_reset() + assert rc == 0 + from solvent.config import load_config + cfg = load_config() + assert cfg.model == "offline" + captured = capsys.readouterr() + assert "reset" in captured.out.lower() + + +# --------------------------------------------------------------------------- +# CLI main dispatch +# --------------------------------------------------------------------------- + +def test_main_show(isolate_config, capsys): + with mock.patch("sys.argv", ["solvent", "show"]): + with pytest.raises(SystemExit) as exc: + from solvent.config_cmd import main + main() + assert exc.value.code == 0 + captured = capsys.readouterr() + assert "model" in captured.out + + +def test_main_no_subcommand_shows_all(isolate_config, capsys): + with mock.patch("sys.argv", ["solvent"]): + with pytest.raises(SystemExit) as exc: + from solvent.config_cmd import main + main() + assert exc.value.code == 0 + captured = capsys.readouterr() + assert "model" in captured.out + + +def test_main_set_and_get(isolate_config, capsys): + with mock.patch("sys.argv", ["solvent", "set", "model", "nemotron"]): + with pytest.raises(SystemExit) as exc: + from solvent.config_cmd import main + main() + assert exc.value.code == 0 + + with mock.patch("sys.argv", ["solvent", "get", "model"]): + with pytest.raises(SystemExit) as exc: + from solvent.config_cmd import main + main() + assert exc.value.code == 0 + captured = capsys.readouterr() + assert "nemotron" in captured.out