| """scripts/_lib/cli.py — shared scaffolding for the `odysseus-*` CLIs. |
| |
| Each top-level CLI imports a few helpers from here so they don't |
| have to redefine the same `_quiet_logs` / `_emit` / `_fail` / |
| parents-parser pattern. Usage: |
| |
| from scripts._lib.cli import quiet_logs, emit, fail, common_parser, run |
| |
| quiet_logs() |
| try: |
| from core.database import SessionLocal, Note # or whatever |
| quiet_logs() |
| except ModuleNotFoundError as e: |
| fail(f"{e}\\nhint: run from repo root with venv active.", code=2) |
| |
| def cmd_list(args): |
| ... |
| |
| def build_parser(): |
| p = common_parser("odysseus-foo", "Description.") |
| sub = p.add_subparsers(dest="cmd", required=True) |
| pl = sub.add_parser("list", parents=[p._common_parents[0]]) |
| pl.set_defaults(func=cmd_list) |
| return p |
| |
| if __name__ == "__main__": |
| sys.exit(run(build_parser())) |
| |
| The `--pretty` flag, repo-root-on-sys.path, and clean exit on |
| KeyboardInterrupt / unexpected exception are all handled centrally. |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import logging |
| import os |
| import sys |
| from pathlib import Path |
|
|
| |
| |
| REPO_ROOT = Path(__file__).resolve().parent.parent.parent |
| if str(REPO_ROOT) not in sys.path: |
| sys.path.insert(0, str(REPO_ROOT)) |
|
|
|
|
| def quiet_logs() -> None: |
| """Force the root logger down to WARNING (overridable via |
| LOG_LEVEL=...). Call once before importing app modules and again |
| *after* — some submodules call `logging.basicConfig` during their |
| own import and re-raise the level to INFO.""" |
| level_name = os.environ.get("LOG_LEVEL", "WARNING").upper() |
| level = getattr(logging, level_name, logging.WARNING) |
| root = logging.getLogger() |
| root.setLevel(level) |
| for handler in root.handlers: |
| handler.setLevel(level) |
|
|
|
|
| def emit(obj, args) -> None: |
| """Write JSON to stdout. Pretty-print if `--pretty` was passed or |
| stdout is a TTY. Uses `default=str` so SQLAlchemy datetimes etc. |
| serialize cleanly.""" |
| pretty = getattr(args, "pretty", False) or sys.stdout.isatty() |
| json.dump( |
| obj, sys.stdout, |
| indent=2 if pretty else None, |
| default=str, |
| ensure_ascii=False, |
| ) |
| sys.stdout.write("\n") |
|
|
|
|
| def fail(msg: str, code: int = 1) -> "None": |
| """Print an error to stderr and exit non-zero. Doesn't return.""" |
| sys.stderr.write(f"error: {msg}\n") |
| sys.exit(code) |
|
|
|
|
| VERSION = "0.1.0" |
|
|
|
|
| def common_parser(prog: str, description: str = "") -> argparse.ArgumentParser: |
| """Return a top-level parser with `--pretty` and `--version` already |
| wired up, and a stashed `_common_parents` list each subcommand should |
| reuse via `parents=[...]` so the same flag works before AND after |
| the subcommand name.""" |
| common = argparse.ArgumentParser(add_help=False) |
| common.add_argument("--pretty", action="store_true", |
| help="Pretty-print JSON output") |
|
|
| p = argparse.ArgumentParser(prog=prog, description=description, parents=[common]) |
| p.add_argument("--version", action="version", version=f"%(prog)s {VERSION}") |
| p._common_parents = [common] |
| return p |
|
|
|
|
| def run(parser: argparse.ArgumentParser, argv=None) -> int: |
| """Parse args, dispatch to `args.func(args)`, return an exit code. |
| Catches KeyboardInterrupt (→ 130) and uncaught exceptions (→ 1) |
| with a friendly stderr message. |
| |
| Intercepts `--version` / `-V` before argparse can complain about the |
| missing required subcommand — `argparse.required=True` on the |
| subparsers fires first otherwise.""" |
| raw_argv = sys.argv[1:] if argv is None else list(argv) |
| if any(a in ("--version", "-V") for a in raw_argv): |
| sys.stdout.write(f"{parser.prog} {VERSION}\n") |
| return 0 |
|
|
| args = parser.parse_args(argv) |
| try: |
| args.func(args) |
| except KeyboardInterrupt: |
| sys.stderr.write("interrupted\n") |
| return 130 |
| except SystemExit: |
| raise |
| except Exception as e: |
| fail(str(e)) |
| return 0 |
|
|