#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
"""Command-line interface for TDPy.
The CLI is intentionally file-driven and conservative. It preserves the
existing engineering workflows while adding a Sphinx skeleton helper for
GitHub Pages deployments.
Commands
--------
``list-inputs``
List files under the package ``in`` directory.
``run``
Run any problem definition and route by ``problem_type``.
``props``
Convenience wrapper for thermodynamic-property inputs.
``eqn``
Convenience wrapper for nonlinear equation-system inputs.
``opt``
Convenience wrapper for optimization inputs.
``menu``
Launch the optional interactive text menu.
``sphinx-skel``
Generate a conservative single-site Sphinx documentation skeleton.
Deployment notes
----------------
The ``sphinx-skel`` command follows the lessons learned from the other
engineering tool projects:
- dynamic reStructuredText heading underlines
- conservative Sphinx-safe generated files
- ``_static/.gitkeep`` and ``_templates/.gitkeep``
- minimal project-standard Sphinx Makefile
- importable-module filtering for autodoc
- deploy-safe mock imports for optional scientific and GUI dependencies
Typical documentation command::
python -m cli sphinx-skel docs
Then build locally with::
make -C docs html
"""
import argparse
import importlib.util
import os
import sys
from pathlib import Path
from typing import Any, Dict, Optional, Sequence, Tuple
# ---------- Import shim so `python cli.py ...` works with absolute imports ----------
if __package__ in (None, ""):
pkg_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if pkg_root not in sys.path:
sys.path.insert(0, pkg_root)
from apis import RunRequest # type: ignore
from app import TdpyApp # type: ignore
else:
# TDPy is currently a root-layout project. The stable runtime imports these
# modules by their top-level names, so keep the absolute imports for
# compatibility with the existing codebase and editable installs.
from apis import RunRequest
from app import TdpyApp
# ------------------------------ paths ------------------------------
def _pkg_dir() -> Path:
return Path(__file__).resolve().parent
def _in_dir() -> Path:
return _pkg_dir() / "in"
def _out_dir() -> Path:
return _pkg_dir() / "out"
def _resolve_in_path(user_path: str) -> Path:
"""Resolve an input path against the current directory and then ``in``."""
p = Path(user_path)
# 1) as provided
if p.is_absolute() and p.exists():
return p
if not p.is_absolute() and p.exists():
return p.resolve()
# 2) normalize if the caller passed "in/..."
parts = p.parts
if len(parts) >= 2 and parts[0] == "" and parts[1] == "in":
p2 = Path(*parts[2:])
cand = _in_dir() / p2
if cand.exists():
return cand.resolve()
# 3) under in
cand = _in_dir() / p
if cand.exists():
return cand.resolve()
# Last resort: return the likely candidate for a useful error message.
return cand
def _resolve_out_path(user_out: Optional[str], in_path: Path) -> Path:
"""Resolve an output path, defaulting to ``out/<input-stem>.out.json``."""
if user_out is None or str(user_out).strip() == "":
return (_out_dir() / f"{in_path.stem}.out.json").resolve()
p = Path(user_out)
if p.is_absolute():
return p
return (_out_dir() / p).resolve()
# ------------------------------ output helpers ------------------------------
def _print_list_inputs(payload: Dict[str, Any], verbose: bool) -> None:
in_dir = str(payload.get("in_dir", "") or "")
files = payload.get("files", []) or []
print(in_dir)
for rel in files:
if verbose:
print(str(Path(in_dir) / str(rel)))
else:
print(str(rel))
def _maybe_print_run_summary(res: Any) -> None:
"""Print a best-effort summary without assuming a specific response schema."""
msg = getattr(res, "message", None)
ok = getattr(res, "ok", None)
solver = getattr(res, "solver", None)
if msg is not None:
print(str(msg))
if solver is not None:
print(f"solver: {solver}")
if ok is not None and bool(ok) is False and msg is None:
print("run failed (ok=false)")
# ------------------------------ parsing helpers ------------------------------
def _coerce_scalar(s: str) -> Any:
"""Convert a string token into bool, int, float, ``None``, or string."""
t = s.strip()
if not t:
return t
lo = t.lower()
if lo in {"true", "yes", "y", "on"}:
return True
if lo in {"false", "no", "n", "off"}:
return False
if lo in {"none", "null"}:
return None
try:
if lo.startswith(("0x", "-0x")):
return int(t, 16)
if lo.startswith(("0b", "-0b")):
return int(t, 2)
if lo.startswith(("0o", "-0o")):
return int(t, 8)
if "." not in t and "e" not in lo:
return int(t)
except Exception:
pass
try:
return float(t)
except Exception:
return t
def _parse_kv(token: str) -> Tuple[str, Any]:
"""Parse ``key=value`` into a key and a coerced scalar value."""
if "=" not in token:
raise ValueError(f"Expected key=value, got {token!r}")
k, v = token.split("=", 1)
key = k.strip()
if not key:
raise ValueError(f"Empty key in {token!r}")
return key, _coerce_scalar(v)
# ------------------------------ Sphinx skeleton helpers ------------------------------
_RST_CHARS = ("=", "-", "~", "^")
# Conservative modules for a root-layout TDPy documentation site.
# ``units.core`` is intentionally not listed because the current runtime uses
# top-level ``units.py`` and packaging both ``units.py`` and a ``units`` package
# can create import ambiguity. The public stable module is ``units``.
_MODULES: tuple[str, ...] = (
# Application layer
"cli",
"app",
"apis",
"design",
"core",
"in_out",
"units",
"utils",
"main",
# Equations package
"equations.api",
"equations.solver",
"equations.optimizer",
"equations.safe_eval",
"equations.spec",
# Interpreter package
"interpreter.cli",
"interpreter.api",
"interpreter.build_spec",
"interpreter.intent",
"interpreter.models",
"interpreter.numeric_eval",
"interpreter.parse",
# Thermodynamic properties package
"thermo_props.api",
"thermo_props.core",
"thermo_props.state",
"thermo_props.coolprop_backend",
"thermo_props.cantera_backend",
"thermo_props.librh2o_ashrae_backend",
"thermo_props.nh3h2o_backend",
"thermo_props.ammonia_water",
# GUI modules
"gui_core_dpg",
"gui_log_dpg",
"gui_utils_dpg",
)
def _rst_heading(title: str, level: int = 0) -> str:
"""Return a Sphinx-safe reStructuredText heading."""
ch = _RST_CHARS[min(max(level, 0), len(_RST_CHARS) - 1)]
text = str(title).strip() or "Untitled"
return f"{text}\n{ch * len(text)}\n"
def _is_importable(module_name: str) -> bool:
"""Return whether a module can be located without importing it."""
try:
return importlib.util.find_spec(module_name) is not None
except Exception:
return False
def _write_text(path: Path, text: str, *, force: bool = False) -> bool:
"""Write text if missing or if ``force`` is enabled."""
if path.exists() and not force:
return False
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return True
def _touch(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.touch(exist_ok=True)
def _generate_conf_py() -> str:
"""Generate a conservative root-layout Sphinx ``conf.py``."""
return '''# Generated by TDPy cli.py
from __future__ import annotations
import sys
from pathlib import Path
# docs -> repository root
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
project = "TDPy"
author = "Pablo Marcel Montijo"
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.napoleon",
"sphinx.ext.viewcode",
]
templates_path = ["_templates"]
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
html_theme = "furo"
html_static_path = ["_static"]
autodoc_typehints = "description"
autodoc_member_order = "bysource"
autodoc_mock_imports = [
"CoolProp",
"CoolProp.CoolProp",
"cantera",
"dearpygui",
"dearpygui.dearpygui",
"gekko",
"matplotlib",
"matplotlib.pyplot",
"numpy",
"pandas",
"plotly",
"plotly.graph_objects",
"scipy",
"scipy.interpolate",
"scipy.linalg",
"scipy.optimize",
"sympy",
"yaml",
"pyfiglet",
]
napoleon_google_docstring = True
napoleon_numpy_docstring = True
'''
def _module_group(module_name: str) -> str:
"""Return a readable documentation group for a module name."""
if module_name.startswith("equations."):
return "Equations Package"
if module_name.startswith("interpreter."):
return "Interpreter Package"
if module_name.startswith("thermo_props."):
return "Thermo Props Package"
if module_name.startswith("gui_"):
return "GUI Modules"
return "Application Layer"
def _generate_api_rst(modules: Sequence[str]) -> str:
"""Generate an API page for the importable modules."""
parts: list[str] = [_rst_heading("API Reference", 0)]
grouped: Dict[str, list[str]] = {}
for mod in modules:
grouped.setdefault(_module_group(mod), []).append(mod)
group_order = [
"Application Layer",
"Equations Package",
"Interpreter Package",
"Thermo Props Package",
"GUI Modules",
]
for group in group_order:
mods = grouped.get(group, [])
if not mods:
continue
parts.append(_rst_heading(group, 1))
for mod in mods:
parts.append(_rst_heading(mod, 2))
parts.append(
f".. automodule:: {mod}\n"
" :members:\n"
" :undoc-members:\n"
" :show-inheritance:\n\n"
)
return "\n".join(parts).rstrip() + "\n"
def _generate_index_rst() -> str:
"""Generate the documentation root page."""
return (
_rst_heading("TDPy Documentation", 0)
+ "\n"
+ "TDPy is a Python-first engineering toolkit for thermodynamics, "
"property evaluation, nonlinear equation solving, optimization, "
"and EES-style engineering workflows.\n\n"
+ ".. toctree::\n"
" :maxdepth: 2\n"
" :caption: Contents:\n\n"
" api\n"
)
def _generate_makefile() -> str:
"""Generate the minimal project-standard Sphinx Makefile."""
return (
"# Minimal Sphinx Makefile\n"
".PHONY: html clean\n"
"html:\n"
"\t+sphinx-build -b html . _build/html\n"
"clean:\n"
"\t+rm -rf _build\n"
)
[docs]
def create_sphinx_skeleton(dest: str | Path, *, force: bool = False) -> Path:
"""Create a conservative Sphinx skeleton for the root-layout TDPy project."""
out_dir = Path(dest).expanduser().resolve()
out_dir.mkdir(parents=True, exist_ok=True)
# Ensure repository root is visible for importability checks when running
# directly from the project root or via ``python -m cli``.
root = Path(__file__).resolve().parent
root_s = str(root)
if root_s not in sys.path:
sys.path.insert(0, root_s)
importable_modules = [m for m in _MODULES if _is_importable(m)]
# Avoid an empty API page if some optional package discovery behaves oddly.
if not importable_modules:
importable_modules = ["cli", "app", "apis", "core", "design", "in_out", "units", "utils"]
_write_text(out_dir / "conf.py", _generate_conf_py(), force=force)
_write_text(out_dir / "index.rst", _generate_index_rst(), force=force)
_write_text(out_dir / "api.rst", _generate_api_rst(importable_modules), force=force)
_write_text(out_dir / "Makefile", _generate_makefile(), force=force)
_touch(out_dir / "_static" / ".gitkeep")
_touch(out_dir / "_templates" / ".gitkeep")
return out_dir
# ------------------------------ CLI parser ------------------------------
def _add_run_overrides(ap: argparse.ArgumentParser) -> None:
"""Add run-time override flags to a run-like parser."""
ap.add_argument(
"--backend",
default=argparse.SUPPRESS,
help="Override solve.backend for this run only (for example: auto, scipy, or gekko).",
)
method_help = (
"Override solve.method for this run only. "
"For equation systems, common SciPy root methods include hybr, lm, "
"broyden1, broyden2, anderson, krylov, df-sane, linearmixing, "
"diagbroyden, and excitingmixing. For optimization, common methods "
"include SLSQP, L-BFGS-B, TNC, trust-constr, COBYLA, Nelder-Mead, "
"and Powell."
)
ap.add_argument(
"--method",
default=argparse.SUPPRESS,
help=method_help,
)
ap.add_argument(
"--tol",
type=float,
default=argparse.SUPPRESS,
help="Override solve.tol for this run only.",
)
ap.add_argument(
"--max-iter",
type=int,
default=argparse.SUPPRESS,
help="Override solve.max_iter for this run only.",
)
ap.add_argument(
"--max-restarts",
type=int,
default=argparse.SUPPRESS,
help="Override solve.max_restarts for this run only.",
)
g_units = ap.add_mutually_exclusive_group()
g_units.add_argument(
"--use-units",
action="store_true",
default=argparse.SUPPRESS,
help="Enable unit parsing for this run only.",
)
g_units.add_argument(
"--no-units",
action="store_true",
default=argparse.SUPPRESS,
help="Disable unit parsing for this run only.",
)
g_ws = ap.add_mutually_exclusive_group()
g_ws.add_argument(
"--warm-start",
action="store_true",
dest="warm_start",
default=argparse.SUPPRESS,
help="Enable warm-start prepass to improve initial guesses.",
)
g_ws.add_argument(
"--no-warm-start",
action="store_false",
dest="warm_start",
default=argparse.SUPPRESS,
help="Disable warm-start prepass.",
)
ap.add_argument(
"--warm-start-passes",
type=int,
default=argparse.SUPPRESS,
help="Override warm-start passes.",
)
ap.add_argument(
"--warm-start-mode",
choices=["override", "conservative"],
default=argparse.SUPPRESS,
help="Override warm-start mode.",
)
ap.add_argument(
"--scipy-opt",
action="append",
default=argparse.SUPPRESS,
metavar="K=V",
help="Pass a SciPy option as key=value. May be repeated.",
)
ap.add_argument(
"--dry-run",
action="store_true",
help="Print resolved input/output paths and exit without running.",
)
[docs]
def build_parser() -> argparse.ArgumentParser:
"""Build the TDPy command-line parser."""
p = argparse.ArgumentParser(
prog="tdpy",
description="TDPy — console-first EES-like runner for JSON, YAML, and TXT inputs.",
)
sub = p.add_subparsers(dest="cmd", required=True)
# list-inputs
p_list = sub.add_parser("list-inputs", help="List files under in")
p_list.add_argument(
"--verbose",
action="store_true",
help="Print absolute paths.",
)
# run (generic)
p_run = sub.add_parser("run", help="Run a problem definition file")
p_run.add_argument(
"--in",
dest="infile",
required=True,
help="Input path relative to in, absolute, or relative to the current directory.",
)
p_run.add_argument(
"--out",
dest="outfile",
default=None,
help="Output path relative to out, or absolute. Defaults to <stem>.out.json.",
)
p_run.add_argument(
"--make-plots",
action="store_true",
help="Generate default Plotly HTML plots when supported.",
)
_add_run_overrides(p_run)
# props
p_props = sub.add_parser("props", help="Run a thermo_props problem file")
p_props.add_argument(
"--in",
dest="infile",
required=True,
help="Input path relative to in, absolute, or relative to the current directory.",
)
p_props.add_argument(
"--out",
dest="outfile",
default=None,
help="Output path relative to out, or absolute. Defaults to <stem>.out.json.",
)
_add_run_overrides(p_props)
# eqn
p_eqn = sub.add_parser("eqn", help="Run an equations problem file")
p_eqn.add_argument(
"--in",
dest="infile",
required=True,
help="Input path relative to in, absolute, or relative to the current directory.",
)
p_eqn.add_argument(
"--out",
dest="outfile",
default=None,
help="Output path relative to out, or absolute. Defaults to <stem>.out.json.",
)
_add_run_overrides(p_eqn)
# opt
p_opt = sub.add_parser("opt", help="Run an optimization problem file")
p_opt.add_argument(
"--in",
dest="infile",
required=True,
help="Input path relative to in, absolute, or relative to the current directory.",
)
p_opt.add_argument(
"--out",
dest="outfile",
default=None,
help="Output path relative to out, or absolute. Defaults to <stem>.out.json.",
)
_add_run_overrides(p_opt)
# menu
sub.add_parser("menu", help="Launch interactive start menu")
# sphinx-skel
p_sphinx = sub.add_parser(
"sphinx-skel",
help="Create a conservative Sphinx docs skeleton for GitHub Pages.",
)
p_sphinx.add_argument(
"dest",
nargs="?",
default="docs",
help="Destination directory. Default: docs",
)
p_sphinx.add_argument(
"--force",
action="store_true",
help="Overwrite existing generated files.",
)
return p
def _collect_opts_from_args(args: argparse.Namespace) -> Dict[str, Any]:
"""Build an opts dict only from explicitly provided override flags."""
d = vars(args)
opts: Dict[str, Any] = {}
mapping = {
"backend": "backend",
"method": "method",
"tol": "tol",
"max_iter": "max_iter",
"max_restarts": "max_restarts",
"warm_start": "warm_start",
"warm_start_passes": "warm_start_passes",
"warm_start_mode": "warm_start_mode",
}
for k_cli, k_opt in mapping.items():
if k_cli in d:
opts[k_opt] = d[k_cli]
if "use_units" in d and bool(d["use_units"]):
opts["use_units"] = True
if "no_units" in d and bool(d["no_units"]):
opts["use_units"] = False
if "scipy_opt" in d:
raw_list = d.get("scipy_opt") or []
sci: Dict[str, Any] = {}
for token in raw_list:
try:
k, v = _parse_kv(str(token))
except Exception as e:
raise SystemExit(f"Invalid --scipy-opt {token!r}: {e}") from e
sci[str(k)] = v
if sci:
opts["scipy_options"] = sci
return opts
def _make_run_request(
*,
in_path: Path,
out_path: Path,
make_plots: bool,
opts: Dict[str, Any],
) -> Any:
"""Construct ``RunRequest`` while remaining compatible with older versions."""
try:
return RunRequest(
in_path=in_path,
out_path=out_path,
make_plots=bool(make_plots),
opts=opts, # type: ignore[arg-type]
)
except TypeError:
return RunRequest(
in_path=in_path,
out_path=out_path,
make_plots=bool(make_plots),
)
def _run_file(
app: TdpyApp,
infile: str,
outfile: Optional[str],
*,
make_plots: bool = False,
args: Optional[argparse.Namespace] = None,
) -> int:
"""Resolve paths, create a run request, execute it, and print the output path."""
in_path = _resolve_in_path(infile)
if not in_path.exists():
print(f"Input file not found: {infile!r}")
print(f"Tried: {str(in_path)}")
return 2
out_path = _resolve_out_path(outfile, in_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
opts: Dict[str, Any] = _collect_opts_from_args(args) if args is not None else {}
if args is not None and getattr(args, "dry_run", False):
print(f"in : {str(in_path)}")
print(f"out: {str(out_path)}")
if opts:
print(f"opts: {opts}")
return 0
req = _make_run_request(
in_path=in_path,
out_path=out_path,
make_plots=bool(make_plots),
opts=opts,
)
res = app.run(req)
_maybe_print_run_summary(res)
outp = getattr(res, "out_path", None)
if outp is not None:
print(str(outp))
else:
print(str(out_path))
ok = bool(getattr(res, "ok", False))
return 0 if ok else 2
[docs]
def main(argv: list[str] | None = None) -> int:
"""Run the command-line interface."""
args = build_parser().parse_args(argv)
# Keep Sphinx skeleton generation lightweight. It should not need the app
# service or any solver backend to run successfully.
if args.cmd == "sphinx-skel":
out_dir = create_sphinx_skeleton(args.dest, force=bool(args.force))
print(str(out_dir))
return 0
app = TdpyApp()
if args.cmd == "list-inputs":
payload = app.list_inputs()
_print_list_inputs(payload, verbose=bool(args.verbose))
return 0
if args.cmd == "run":
return _run_file(
app,
infile=args.infile,
outfile=args.outfile,
make_plots=bool(args.make_plots),
args=args,
)
if args.cmd == "props":
return _run_file(
app,
infile=args.infile,
outfile=args.outfile,
make_plots=False,
args=args,
)
if args.cmd == "eqn":
return _run_file(
app,
infile=args.infile,
outfile=args.outfile,
make_plots=False,
args=args,
)
if args.cmd == "opt":
return _run_file(
app,
infile=args.infile,
outfile=args.outfile,
make_plots=False,
args=args,
)
if args.cmd == "menu":
# Avoid importing optional UI/menu dependencies unless needed.
try:
if __package__ in (None, ""):
from main import main as menu_main # type: ignore
else:
from main import main as menu_main # type: ignore
except Exception:
print("TDPy menu is unavailable. Use `tdpy list-inputs` or `tdpy run`.")
return 2
menu_main()
return 0
raise AssertionError("unreachable")
if __name__ == "__main__":
raise SystemExit(main())