128 lines
5.9 KiB
Python
Executable File
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())
|