aboutsummaryrefslogtreecommitdiffstats
path: root/test_llamachat.py
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-09 15:14:14 +0200
committerDanilo M. <danix@danix.xyz>2026-08-09 15:14:14 +0200
commit009496882033fa9f01e0faaffe9d6d797650b621 (patch)
tree7a20b0fa43818e482dd6467e75c195006f644a69 /test_llamachat.py
parent05184385699325efa830a18c4b05380df79bc12c (diff)
downloadllamachat-009496882033fa9f01e0faaffe9d6d797650b621.tar.gz
llamachat-009496882033fa9f01e0faaffe9d6d797650b621.zip
fix: survive malformed provider config at startup
Eight config shapes, all valid TOML, crashed config.load() before any window existed, so the user got a traceback on a terminal a desktop launch does not have. Skip malformed providers instead, keeping the local path working, and say on stderr which one was dropped and why. The guards are isinstance tests rather than a try/except because the shapes raise three different exception types, and the local entry is checked the same way: [[providers.local]] is a truthy list that raised during the bare base_url merge, before the loop could skip it. A config that cannot load at all now reports the file and exits rather than half-starting on defaults the user never configured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176tzAW6H1i2Kz8vm2XXVGV
Diffstat (limited to 'test_llamachat.py')
-rwxr-xr-xtest_llamachat.py92
1 files changed, 92 insertions, 0 deletions
diff --git a/test_llamachat.py b/test_llamachat.py
index 0eea9e1..ed50995 100755
--- a/test_llamachat.py
+++ b/test_llamachat.py
@@ -1003,6 +1003,96 @@ def test_provider_parsing():
print("ok provider config parsing")
+def test_provider_malformed_shapes():
+ """Config shapes that are not tables are skipped, never raised on.
+
+ Every case here is valid TOML a hand-editing user can write, and every
+ one of them used to reach the GUI as a traceback before any window
+ existed. The invariant is that the local provider survives all of them.
+ """
+ import io
+ from contextlib import redirect_stderr
+
+ from llamachat import providers
+
+ # `providers` itself is not a table. Three shapes, and note they used to
+ # raise two different exception types, which is why the guard is an
+ # isinstance test and not a try/except.
+ for bad in ("oops", ["a"], 5):
+ fallen_back = providers.parse(
+ {"base_url": "http://localhost:8181", "providers": bad}
+ )
+ assert set(fallen_back) == {"local"}, bad
+ assert fallen_back["local"].base_url == "http://localhost:8181"
+
+ # A single entry that is not a table. The list case is the one that
+ # matters: [[providers.a]] is a plausible slip for [providers.a].
+ for bad in (5, "x", ["x"], [{"base_url": "http://y.example.org"}]):
+ mixed = providers.parse(
+ {"providers": {"local": {"base_url": "http://x.example.org"},
+ "a": bad}}
+ )
+ assert set(mixed) == {"local"}, bad
+
+ # A None entry, which TOML cannot produce but callers can, is still
+ # skipped rather than crashing.
+ assert providers.parse({"providers": {"a": None}}) == {}
+
+ # Each skip says which provider it dropped and why, so a user running
+ # from a terminal has something to act on.
+ err = io.StringIO()
+ with redirect_stderr(err):
+ providers.parse(
+ {
+ "providers": {
+ "listy": [{"base_url": "http://x.example.org"}],
+ "urlless": {"api_key": "env:SOME_VAR"},
+ "a:b": {"base_url": "http://y.example.org"},
+ }
+ }
+ )
+ messages = err.getvalue()
+ assert "listy" in messages and "urlless" in messages and "a:b" in messages
+ assert "base_url" in messages # the missing-URL case names what is missing
+ print("ok malformed provider shapes are skipped, not raised")
+
+
+def test_unusable_config_exits():
+ """A config that cannot load exits 1 with a message, not a traceback.
+
+ Runs the real entry point in a child process rather than calling main()
+ in-process. CONFIG_PATH is read from the environment at import time and
+ baked into load()'s default argument, so rebinding the constant after
+ import does nothing: only a fresh interpreter with a redirected
+ XDG_CONFIG_HOME actually moves the file the app reads.
+ """
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "llamachat" / "config.toml"
+ path.parent.mkdir(parents=True)
+ # A plain syntax error, the commonest way a hand-edited file breaks.
+ path.write_text('base_url = "http://localhost:8181"\nsocket = = ""\n')
+
+ env = dict(os.environ, XDG_CONFIG_HOME=tmp)
+ # --ping is the cheapest path that still loads the config, and it
+ # fails before it looks for a socket, so an instance actually running
+ # on this machine cannot turn this into a false pass.
+ proc = subprocess.run(
+ [sys.executable, "-m", "llamachat", "--ping"],
+ capture_output=True, text=True, env=env,
+ cwd=str(Path(__file__).resolve().parent),
+ )
+
+ assert proc.returncode == 1, proc.returncode
+ # A traceback here would mean the app died rather than reported.
+ assert "Traceback" not in proc.stderr, proc.stderr
+ # The path is what makes the message actionable: it says which file to fix.
+ assert str(path) in proc.stderr, proc.stderr
+ assert "unusable" in proc.stderr
+ # tomllib names the line, and that detail survives into the report.
+ assert "line" in proc.stderr, proc.stderr
+ print("ok unusable config exits without a traceback")
+
+
def test_model_ids_and_filtering():
"""Ids are provider:model, local stays bare, filters are substrings."""
from llamachat import providers
@@ -1986,6 +2076,8 @@ if __name__ == "__main__":
test_venv_discovery()
test_config_defaults()
test_provider_parsing()
+ test_provider_malformed_shapes()
+ test_unusable_config_exits()
test_model_ids_and_filtering()
test_key_resolution()
test_config_providers()