|
35 | 35 | _LLM_COMMANDS = {"read", "profile", "interpret", "suggest", "edit", "benchmark", "run-all"} |
36 | 36 |
|
37 | 37 |
|
| 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 | + |
38 | 80 | 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) |
57 | 85 |
|
58 | 86 | parser = argparse.ArgumentParser( |
59 | 87 | prog="profine", |
60 | 88 | description="Agentic ML Training Optimizer", |
61 | | - parents=[shared], |
62 | 89 | ) |
| 90 | + _add_shared(parser, suppress=False) |
63 | 91 |
|
64 | 92 | sub = parser.add_subparsers(dest="command", help="Tool to run") |
| 93 | + shared = shared_suppress |
65 | 94 |
|
66 | 95 | p_read = sub.add_parser("read", help="Read and analyze a training script", parents=[shared], conflict_handler="resolve") |
67 | 96 | p_read.add_argument("script", help="Path to the training script") |
@@ -128,6 +157,7 @@ def build_parser() -> argparse.ArgumentParser: |
128 | 157 |
|
129 | 158 |
|
130 | 159 | def main(argv: list[str] | None = None) -> int: |
| 160 | + _ensure_utf8_stdout() |
131 | 161 | try: |
132 | 162 | # When profine is installed as a console script, find_dotenv()'s default |
133 | 163 | # (usecwd=False) walks up from the entry-script's directory — e.g. |
|
0 commit comments