aboutsummaryrefslogtreecommitdiffstats
path: root/test_llamachat.py
diff options
context:
space:
mode:
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()