Skip to content

Commit e6f6025

Browse files
committed
refactor: improve windows error handling and add connection error messages in CLI
1 parent 21da06e commit e6f6025

3 files changed

Lines changed: 140 additions & 24 deletions

File tree

profine/cli/errors.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from __future__ import annotations
1313

1414
import os
15-
from pathlib import Path
1615

1716

1817
def format_user_error(exc: BaseException) -> tuple[str, int] | None:
@@ -49,11 +48,13 @@ def format_user_error(exc: BaseException) -> tuple[str, int] | None:
4948
2,
5049
)
5150

52-
# File not found — script path that doesn't exist
51+
# File not found — script path that doesn't exist.
52+
# Render exc.filename directly (no Path round-trip) so the displayed path
53+
# keeps the separators the user passed; this also keeps the cross-platform
54+
# test assertion (which uses forward slashes) stable on Windows.
5355
if isinstance(exc, FileNotFoundError):
54-
target = Path(exc.filename) if exc.filename else None
55-
if target:
56-
return (f"File not found: {target}", 2)
56+
if exc.filename:
57+
return (f"File not found: {exc.filename}", 2)
5758
return (f"File not found: {msg}", 2)
5859

5960
# Permission denied — output dir that's not writable
@@ -73,6 +74,20 @@ def format_user_error(exc: BaseException) -> tuple[str, int] | None:
7374
2,
7475
)
7576

77+
# OpenAI/httpx connection failure — typically `--provider local` with no
78+
# server listening. Surfaces as openai.APIConnectionError (which wraps
79+
# httpx.ConnectError) or a bare httpx.ConnectError.
80+
if name in ("APIConnectionError", "ConnectError", "ConnectionRefusedError") or (
81+
"connection error" in low or "connection refused" in low or "all connection attempts failed" in low
82+
):
83+
return (
84+
"Could not connect to the LLM endpoint. If you're using --provider local, "
85+
"make sure your OpenAI-compatible server (Ollama, vLLM, LM Studio, ...) is "
86+
"running and reachable. Default base URL is http://localhost:11434/v1 "
87+
"(Ollama); override with --base-url or PROFINE_LOCAL_BASE_URL.",
88+
2,
89+
)
90+
7691
# Malformed JSON from the LLM after retries — wraps LlmJsonParseError
7792
if name == "LlmJsonParseError":
7893
# Surface the saved debug-dump path if call_and_parse wrote one

profine/cli/main.py

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,33 +35,62 @@
3535
_LLM_COMMANDS = {"read", "profile", "interpret", "suggest", "edit", "benchmark", "run-all"}
3636

3737

38+
def _ensure_utf8_stdout() -> None:
39+
"""Reconfigure stdout/stderr to UTF-8 so non-ASCII help text (arrows,
40+
em-dashes) doesn't crash on Windows consoles defaulting to CP1252.
41+
"""
42+
for stream_name in ("stdout", "stderr"):
43+
stream = getattr(sys, stream_name, None)
44+
if stream and hasattr(stream, "reconfigure"):
45+
try:
46+
stream.reconfigure(encoding="utf-8", errors="replace")
47+
except Exception:
48+
pass
49+
50+
51+
def _add_shared(p: argparse.ArgumentParser, *, suppress: bool) -> None:
52+
"""Attach the shared flags to `p`.
53+
54+
`suppress=True` makes the argparse defaults SUPPRESS, so the action does
55+
not write to the namespace when the user didn't pass the flag. We use
56+
that for subparsers so that a flag set at the top level (e.g.
57+
`profine --provider local read x`) isn't clobbered when the subparser
58+
parses into its fresh namespace and copies attrs back. The top-level
59+
parser keeps real defaults so the value is always present after parse.
60+
"""
61+
NONE = argparse.SUPPRESS if suppress else None
62+
OUT = argparse.SUPPRESS if suppress else "profine_output"
63+
PROV = argparse.SUPPRESS if suppress else "openai"
64+
p.add_argument("--provider", default=PROV,
65+
choices=["openai", "anthropic", "local"],
66+
help="LLM provider: 'openai', 'anthropic', or 'local' (OpenAI-compatible local server)")
67+
p.add_argument("--api-key", default=NONE, help="API key override")
68+
p.add_argument("--model", default=NONE, help="Model name override (required for --provider local)")
69+
p.add_argument("--base-url", default=NONE,
70+
help="OpenAI-compatible endpoint URL (for --provider local; defaults to "
71+
"http://localhost:11434/v1 for Ollama). Env: PROFINE_LOCAL_BASE_URL")
72+
p.add_argument("--seed", type=int, default=NONE,
73+
help="Seed for the LLM provider (best-effort; OpenAI honors it, Anthropic "
74+
"ignores it and relies on temperature=0). Use to make optimization "
75+
"rankings reproducible across runs.")
76+
p.add_argument("--output", "-o", default=OUT, help="Output directory")
77+
p.add_argument("--prefs", default=NONE, help="Path to user preferences markdown")
78+
79+
3880
def build_parser() -> argparse.ArgumentParser:
39-
# Shared flags live in a parent parser so they work before OR after the
40-
# subcommand (e.g. `profine read train.py -o out` and
41-
# `profine -o out read train.py` both work).
42-
shared = argparse.ArgumentParser(add_help=False)
43-
shared.add_argument("--provider", default="openai",
44-
choices=["openai", "anthropic", "local"],
45-
help="LLM provider: 'openai', 'anthropic', or 'local' (OpenAI-compatible local server)")
46-
shared.add_argument("--api-key", default=None, help="API key override")
47-
shared.add_argument("--model", default=None, help="Model name override (required for --provider local)")
48-
shared.add_argument("--base-url", default=None,
49-
help="OpenAI-compatible endpoint URL (for --provider local; defaults to "
50-
"http://localhost:11434/v1 for Ollama). Env: PROFINE_LOCAL_BASE_URL")
51-
shared.add_argument("--seed", type=int, default=None,
52-
help="Seed for the LLM provider (best-effort; OpenAI honors it, Anthropic "
53-
"ignores it and relies on temperature=0). Use to make optimization "
54-
"rankings reproducible across runs.")
55-
shared.add_argument("--output", "-o", default="profine_output", help="Output directory")
56-
shared.add_argument("--prefs", default=None, help="Path to user preferences markdown")
81+
# Shared flags work before OR after the subcommand. Subparsers use
82+
# SUPPRESS defaults so they don't clobber values set at the top level.
83+
shared_suppress = argparse.ArgumentParser(add_help=False)
84+
_add_shared(shared_suppress, suppress=True)
5785

5886
parser = argparse.ArgumentParser(
5987
prog="profine",
6088
description="Agentic ML Training Optimizer",
61-
parents=[shared],
6289
)
90+
_add_shared(parser, suppress=False)
6391

6492
sub = parser.add_subparsers(dest="command", help="Tool to run")
93+
shared = shared_suppress
6594

6695
p_read = sub.add_parser("read", help="Read and analyze a training script", parents=[shared], conflict_handler="resolve")
6796
p_read.add_argument("script", help="Path to the training script")
@@ -128,6 +157,7 @@ def build_parser() -> argparse.ArgumentParser:
128157

129158

130159
def main(argv: list[str] | None = None) -> int:
160+
_ensure_utf8_stdout()
131161
try:
132162
# When profine is installed as a console script, find_dotenv()'s default
133163
# (usecwd=False) walks up from the entry-script's directory — e.g.

tests/test_cli_dispatch.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,77 @@ def test_format_user_error_llm_json_parse():
9999
assert "malformed JSON" in msg
100100

101101

102+
def test_format_user_error_connection_refused_builtin():
103+
# Bare ConnectionRefusedError (raised by httpx when local server is down)
104+
exc = ConnectionRefusedError(61, "Connection refused")
105+
formatted = format_user_error(exc)
106+
assert formatted is not None
107+
msg, code = formatted
108+
assert "--provider local" in msg
109+
assert "--base-url" in msg
110+
assert "localhost:11434" in msg
111+
assert code == 2
112+
113+
114+
def test_format_user_error_openai_api_connection_error():
115+
# Simulates openai.APIConnectionError without depending on the openai SDK
116+
class APIConnectionError(Exception):
117+
pass
118+
119+
exc = APIConnectionError("Connection error.")
120+
formatted = format_user_error(exc)
121+
assert formatted is not None
122+
msg, code = formatted
123+
assert "--base-url" in msg
124+
assert code == 2
125+
126+
127+
def test_format_user_error_httpx_connect_error():
128+
class ConnectError(Exception):
129+
pass
130+
131+
exc = ConnectError("All connection attempts failed")
132+
formatted = format_user_error(exc)
133+
assert formatted is not None
134+
msg, _ = formatted
135+
assert "OpenAI-compatible server" in msg
136+
137+
138+
def test_parser_provider_flag_before_subcommand_is_preserved():
139+
# Regression: subparsers used to clobber the top-level --provider value
140+
# back to the default 'openai' because parents=[shared] copied the
141+
# argparse defaults into subparser actions.
142+
parser = build_parser()
143+
args = parser.parse_args(
144+
["--provider", "local", "--model", "llama3.1:8b", "read", "train.py"]
145+
)
146+
assert args.provider == "local"
147+
assert args.model == "llama3.1:8b"
148+
149+
150+
def test_parser_provider_flag_after_subcommand_still_works():
151+
parser = build_parser()
152+
args = parser.parse_args(
153+
["read", "--provider", "local", "--model", "llama3.1:8b", "train.py"]
154+
)
155+
assert args.provider == "local"
156+
assert args.model == "llama3.1:8b"
157+
158+
159+
def test_parser_provider_defaults_when_unset():
160+
parser = build_parser()
161+
args = parser.parse_args(["read", "train.py"])
162+
assert args.provider == "openai"
163+
assert args.model is None
164+
assert args.output == "profine_output"
165+
166+
167+
def test_parser_output_flag_before_subcommand_is_preserved():
168+
parser = build_parser()
169+
args = parser.parse_args(["-o", "custom_out", "read", "train.py"])
170+
assert args.output == "custom_out"
171+
172+
102173
def test_format_user_error_unknown_returns_none():
103174
# Unrecognised exception types must signal "unknown" so the caller re-raises
104175
assert format_user_error(KeyError("random")) is None

0 commit comments

Comments
 (0)