"""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 # Make repo root importable. Tools are invoked as `scripts/odysseus-foo` # from any cwd; we want `from core.database import ...` to work. 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" # bumped centrally; every odysseus-* CLI reports this 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] # consumed by callers when building sub-parsers 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