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
5 changes: 5 additions & 0 deletions solvent/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
147 changes: 147 additions & 0 deletions solvent/config_cmd.py
Original file line number Diff line number Diff line change
@@ -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 <key> print a single value
solvent config set <key> <value> 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle list-valued config fields before saving

When users set telegram_allow_from, _field_type_name returns list[str] | None, so this fallback persists the raw CLI string even though the config field is a list. Later apply_config treats it as a list and does ','.join(config.telegram_allow_from), which turns an ID like 123456789 into 1,2,3,4,5,6,7,8,9, so allowlist mode will not recognize the intended user. Please parse this field into a list or reject unsupported non-scalar types instead of saving a string.

Useful? React with 👍 / 👎.



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.", "<class '", "'"):
s = s.replace(prefix, "")
return s.rstrip("'>")


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")
Comment on lines +121 to +122

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept --json after get and show

When callers use the documented/scriptable form solvent config get model --json, argparse treats --json as an argument to the get subparser; because it is only registered on the parent parser here, the command exits with unrecognized arguments: --json. Scripts following the advertised get <key> --json usage have to know to put --json before the subcommand instead, so please add the option to the show/get subparsers or otherwise parse both positions.

Useful? React with 👍 / 👎.

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()
218 changes: 218 additions & 0 deletions tests/test_config_cmd.py
Original file line number Diff line number Diff line change
@@ -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
Loading