Source code for interpreter.cli

# interpreter/cli.py
from __future__ import annotations

"""
interpreter.cli

CLI for turning human-friendly equation text into a TDPy JSON spec.

Examples:
    python -m interpreter.cli --in in/demo/reversible.txt --out in/demo/reversible.json
    python -m interpreter.cli --in - --print
"""

import argparse
import json
import sys
from pathlib import Path
from typing import Optional

from .api import interpret_file, write_spec_json

# Optional: if api.py exposes interpret_text(), use it for stdin mode.
try:  # pragma: no cover
    from .api import interpret_text  # type: ignore
except Exception:  # pragma: no cover
    interpret_text = None

from .models import InterpretConfig


def _eprint(msg: str) -> None:
    print(msg, file=sys.stderr)


def _dump_json(obj: object, *, indent: int = 2) -> str:
    return json.dumps(obj, indent=indent, ensure_ascii=False)


[docs] def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser( prog="interpreter", description="Interpret equation text into a TDPy JSON spec.", ) ap.add_argument( "--in", dest="infile", required=True, help="Input .txt file, or '-' for stdin.", ) ap.add_argument( "--out", dest="outfile", default=None, help="Output JSON spec path, or '-' for stdout. Default: <infile>.json (or stdout if --in '-').", ) # Behavior / UX ap.add_argument( "--strict", action="store_true", help="Fail (non-zero exit) if interpreter emits any warnings (or errors).", ) ap.add_argument( "--quiet", action="store_true", help="Suppress warnings/meta output (errors still print).", ) ap.add_argument( "--print", dest="do_print", action="store_true", help="Print JSON spec to stdout (same as --out '-').", ) ap.add_argument( "--indent", type=int, default=2, help="JSON indentation level for stdout printing (default: 2).", ) # Config mapping 1:1 to InterpretConfig ap.add_argument("--title", default=None, help="Override title.") ap.add_argument("--backend", default="auto", help="Solve backend to emit (auto|scipy|gekko).") ap.add_argument("--method", default="hybr", help="Solve method to emit (e.g., hybr, lm, ...).") ap.add_argument("--tol", type=float, default=1e-6, help="Tolerance to emit in solve block.") ap.add_argument("--max-iter", type=int, default=50, help="Max iterations to emit in solve block.") ap.add_argument("--max-restarts", type=int, default=2, help="Max restarts to emit in solve block.") ap.add_argument( "--default-guess", type=float, default=1.0, help="Default guess assigned to unknowns without explicit guesses (default: 1.0).", ) ap.add_argument( "--report", default="unknowns", choices=["unknowns", "all", "none"], help="Auto report selection if user doesn't provide report lines.", ) # Assignments behavior g_assign = ap.add_mutually_exclusive_group() g_assign.add_argument( "--keep-assignments", action="store_true", help="Keep trivial 'x = 3' as equations (do NOT pull into constants).", ) g_assign.add_argument( "--pull-assignments", action="store_true", help="Pull trivial 'x = 3' into constants when possible (default behavior).", ) # Units behavior g_units = ap.add_mutually_exclusive_group() g_units.add_argument( "--units", action="store_true", help="Enable unit parsing like '300 K' (default).", ) g_units.add_argument( "--no-units", action="store_true", help="Disable unit parsing like '300 K'.", ) args = ap.parse_args(argv) # Resolve output behavior outfile: Optional[str] = args.outfile if args.do_print: outfile = "-" # If reading from stdin and user didn't specify output, default to stdout if args.infile == "-" and outfile is None: outfile = "-" cfg = InterpretConfig( title=args.title, backend=str(args.backend), method=str(args.method), tol=float(args.tol), max_iter=int(args.max_iter), max_restarts=int(args.max_restarts), infer_report=str(args.report), keep_assignments_as_equations=bool(args.keep_assignments) and not bool(args.pull_assignments), enable_units=(not bool(args.no_units)), # default True default_guess=float(args.default_guess), strict_warnings=bool(args.strict), ) # Interpret if args.infile == "-": text = sys.stdin.read() if interpret_text is not None: res = interpret_text(text, cfg=cfg) # type: ignore[misc] else: # Fallback: keep behavior deterministic if api lacks interpret_text() from .parse import parse_text from .build_spec import build_from_parsed parsed = parse_text(text) res = build_from_parsed(parsed, cfg=cfg) else: res = interpret_file(args.infile, cfg=cfg) # Emit warnings/errors/meta to stderr (so stdout can be pure JSON when requested) if not args.quiet: if res.warnings: _eprint("Interpreter warnings:") for w in res.warnings: _eprint(f" - {w}") meta = getattr(res, "meta", None) if isinstance(meta, dict) and meta: parts = [] for k in ("n_equations", "n_unknowns", "n_constants"): if k in meta: parts.append(f"{k}={meta[k]}") if "constants_unresolved" in meta and meta["constants_unresolved"]: parts.append(f"constants_unresolved={meta['constants_unresolved']}") if "pulled_assignment_constants" in meta and meta["pulled_assignment_constants"]: parts.append(f"pulled={meta['pulled_assignment_constants']}") if parts: _eprint("Interpreter meta: " + ", ".join(parts)) if not res.ok and res.errors: _eprint("Interpreter errors:") for e in res.errors: _eprint(f" - {e}") # Non-zero exit on fatal errors, or on warnings when --strict is set. if not res.ok: return 2 if args.strict and res.warnings: return 3 # Output if outfile == "-": print(_dump_json(res.spec, indent=args.indent)) return 0 if outfile is None: if args.infile == "-": print(_dump_json(res.spec, indent=args.indent)) return 0 p_in = Path(args.infile) outfile = str(p_in.with_suffix(".json")) write_spec_json(res, outfile) if not args.quiet: _eprint(f"Wrote spec JSON: {outfile}") return 0
if __name__ == "__main__": # pragma: no cover raise SystemExit(main()) __all__ = ["main"]