Files
tai/taicpp/tools/parity_gen_cases.py
T

128 lines
5.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate tests/diff/cases/cli_generated.json from tai.py's build_parser().
Every argparse error and choice message is exercised from the parser's own
declaration (read with ast, tai.py untouched), so the case list cannot drift
from the flags. Usage: tools/parity_gen_cases.py [--check]
--check exits 1 if the committed file differs from what the parser implies.
Facet note: these cases verify argument PARSING only (messages, exit code),
so they name symbols as `flag:--x#parse`, never the flag's behavior.
"""
import ast
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
PY = os.path.join(os.path.dirname(ROOT), "tai.py")
TARGET = os.path.join(ROOT, "tests", "diff", "cases", "cli_generated.json")
class Opt:
def __init__(self, call):
self.flag = next(a.value for a in call.args if isinstance(a, ast.Constant) and isinstance(a.value, str))
kw = {k.arg: k.value for k in call.keywords}
self.action = ast.literal_eval(kw["action"]) if "action" in kw and isinstance(kw["action"], ast.Constant) else None
self.type = kw["type"].id if "type" in kw and isinstance(kw["type"], ast.Name) else None
self.choices = list(ast.literal_eval(kw["choices"])) if "choices" in kw else None
nargs = kw.get("nargs")
self.nargs = None
if isinstance(nargs, ast.Constant):
self.nargs = nargs.value
elif isinstance(nargs, ast.Attribute):
self.nargs = nargs.attr # REMAINDER
@property
def takes_value(self):
return self.action != "store_true" and self.nargs != "REMAINDER"
@property
def optional_value(self):
return self.nargs == "?"
class CaseBuilder:
def __init__(self):
self.cases = []
def add(self, cid, doc, argv, flag, facet="parse"):
self.cases.append({
"id": cid, "doc": doc, "symbols": ["flag:%s#%s" % (flag, facet)] if flag else [],
"steps": [{"argv": argv, "timeout": 30}],
"compare": {"stdout": True, "stderr": True, "exit": True},
})
def options():
with open(PY, encoding="utf-8") as fh:
tree = ast.parse(fh.read())
fn = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == "build_parser")
out = []
for node in ast.walk(fn):
if isinstance(node, ast.Call) and getattr(node.func, "attr", "") == "add_argument":
if any(isinstance(a, ast.Constant) and isinstance(a.value, str) and a.value.startswith("--") for a in node.args):
out.append(Opt(node))
return out
def build():
b = CaseBuilder()
opts = options()
for o in opts:
slug = o.flag[2:]
if o.takes_value and not o.optional_value:
b.add("cli.err.%s.missing" % slug, "%s with no value" % o.flag, [o.flag], o.flag)
if o.choices:
b.add("cli.err.%s.choice" % slug, "%s with a value outside its choices" % o.flag, [o.flag, "zzz-not-a-choice"], o.flag)
if o.type == "int":
b.add("cli.err.%s.int" % slug, "%s with a non-integer" % o.flag, [o.flag, "abc"], o.flag)
if o.action == "store_true":
b.add("cli.err.%s.explicit" % slug, "%s given an explicit value" % o.flag, ["%s=1" % o.flag], o.flag)
# Prefix abbreviation: python argparse allows unique prefixes and rejects ambiguous ones.
b.add("cli.err.unknown.long", "unknown long flag", ["--bogus"], None)
b.add("cli.err.unknown.short", "unknown short flag", ["-z"], None)
b.add("cli.err.ambiguous.debug", "--deb matches --debug and --debug-format", ["--deb"], "--debug")
b.add("cli.err.ambiguous.cap", "--cap-m matches --cap-model and --cap-max-turns", ["--cap-m"], "--cap-model")
b.add("cli.err.ambiguous.install", "--ins matches --install, --install-telegram", ["--ins"], "--install")
b.add("cli.err.ambiguous.tui", "--tu matches --tui and --tui-local", ["--tu"], "--tui")
b.add("cli.prefix.version", "unique prefix --vers resolves to --version", ["--vers"], "--version")
b.add("cli.version.long", "--version", ["--version"], "--version", "output")
b.add("cli.version.with_choice", "--version alongside a valid choice flag", ["--version", "--cap-permission-mode", "none"], "--cap-permission-mode")
b.add("cli.help.long", "--help text, exact", ["--help"], None)
b.add("cli.help.short", "-h text, exact", ["-h"], None)
b.add("cli.help.wins_over_error", "-h before a bad flag prints help, exit 0", ["-h", "--bogus"], None)
b.add("cli.err.error_before_help", "bad choice before -h is an error, exit 2", ["--cap-permission-mode", "zzz", "-h"], "--cap-permission-mode")
b.add("cli.cap_manifest", "--cap-manifest text, exact", ["--cap-manifest"], "--cap-manifest", "output")
b.cases[-1]["tests"] = ["CapCliTests.test_cap_manifest_flag_prints_valid_toml"]
# Choice success path for each choices option, parse-only via --version short-circuit is not
# reliable for all flags, so only the flags whose success path is side-effect free are listed.
for o in opts:
if o.choices:
for ch in o.choices:
if o.flag == "--debug":
continue # --debug starts logging; success behavior is a behavior case
b.add("cli.ok.%s.%s" % (o.flag[2:], ch), "%s %s accepted, then --version" % (o.flag, ch), [o.flag, ch, "--version"], o.flag)
return {"cases": b.cases}
def main():
text = json.dumps(build(), indent=1) + "\n"
if "--check" in sys.argv:
have = open(TARGET).read() if os.path.exists(TARGET) else ""
if have != text:
print("cli_generated.json is stale: run tools/parity_gen_cases.py")
return 1
print("cli_generated.json current")
return 0
os.makedirs(os.path.dirname(TARGET), exist_ok=True)
with open(TARGET, "w") as fh:
fh.write(text)
print("wrote %s (%d cases)" % (TARGET, len(json.loads(text)["cases"])))
return 0
if __name__ == "__main__":
sys.exit(main())