-
Notifications
You must be signed in to change notification settings - Fork 1
add: solvent config — view and edit local configuration #44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When callers use the documented/scriptable form 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() | ||
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When users set
telegram_allow_from,_field_type_namereturnslist[str] | None, so this fallback persists the raw CLI string even though the config field is a list. Laterapply_configtreats it as a list and does','.join(config.telegram_allow_from), which turns an ID like123456789into1,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 👍 / 👎.