mirror of
https://github.com/NandhaKishorM/laya.git
synced 2026-09-28 16:02:56 +08:00
feat(cli): answer your own questions, and give them the token budget they need # Conflicts: # README.md # laya/cli.py # tests/test_cli.py
467 lines
22 KiB
Python
467 lines
22 KiB
Python
"""CLI tests. No model weights are loaded: the Router is stubbed throughout."""
|
|
import io
|
|
import os
|
|
import sys
|
|
from contextlib import redirect_stderr, redirect_stdout
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from laya import cli # noqa: E402
|
|
|
|
PASS, FAIL = [], []
|
|
|
|
|
|
def check(name, condition, detail=""):
|
|
if condition:
|
|
PASS.append(name)
|
|
else:
|
|
FAIL.append("%s: %s" % (name, detail))
|
|
|
|
|
|
class StubDecision(dict):
|
|
def __init__(self):
|
|
super().__init__(model="multilingual", repo="convaiinnovations/laya",
|
|
reason="detected non-English text", detection={"lang": "de"}, workflow=None)
|
|
|
|
|
|
class StubRouter:
|
|
def __init__(self):
|
|
self.route_calls = []
|
|
self.predict_calls = []
|
|
|
|
def route(self, state, **kwargs):
|
|
self.route_calls.append((state, kwargs))
|
|
return StubDecision()
|
|
|
|
def predict(self, state, questions, **kwargs):
|
|
self.predict_calls.append((state, kwargs))
|
|
return {"answers": {"difficulty": {"score": 1.4}}, "routing": dict(StubDecision())}
|
|
|
|
|
|
def run_cli(argv, router=None):
|
|
stub = router or StubRouter()
|
|
original = cli.make_router
|
|
cli.make_router = lambda args: stub
|
|
out, err = io.StringIO(), io.StringIO()
|
|
code = None
|
|
try:
|
|
with redirect_stdout(out), redirect_stderr(err):
|
|
code = cli.main(argv)
|
|
except SystemExit as exit_: # argparse rejects a bad flag value by exiting
|
|
code = exit_.code
|
|
finally:
|
|
cli.make_router = original
|
|
return code, out.getvalue(), err.getvalue(), stub
|
|
|
|
|
|
# --------------------------------------------------------------------- routing (default)
|
|
code, out, err, stub = run_cli(["Ich wurde doppelt belastet"])
|
|
check("route: exit code", code == 0, "got %r" % code)
|
|
check("route: prints the chosen checkpoint", "multilingual" in out, out)
|
|
check("route: state carries the text", stub.route_calls[0][0] == {"text": "Ich wurde doppelt belastet"})
|
|
check("route: never loads a checkpoint", stub.predict_calls == [])
|
|
|
|
# --------------------------------------------------------------------- full prediction
|
|
code, out, err, stub = run_cli(["--predict", "Refactor this service"])
|
|
check("predict: exit code", code == 0, "got %r" % code)
|
|
check("predict: prints answers", "difficulty" in out, out)
|
|
check("predict: router.predict called once", len(stub.predict_calls) == 1)
|
|
|
|
# --------------------------------------------------------------------- json output
|
|
code, out, err, stub = run_cli(["--json", "hello there"])
|
|
check("json: exit code", code == 0, "got %r" % code)
|
|
check("json: raw decision printed", '"model": "multilingual"' in out, out)
|
|
|
|
# --------------------------------------------------------------------- friendly errors
|
|
class BrokenRouter:
|
|
def route(self, state, **kwargs):
|
|
raise OSError("connection failed")
|
|
|
|
|
|
code, out, err, stub = run_cli(["some text"], router=BrokenRouter())
|
|
check("error: exit code 2", code == 2, "got %r" % code)
|
|
check("error: names the failure", "could not run Laya" in err, err)
|
|
check("error: points at the fix", "Hugging Face hub" in err, err)
|
|
|
|
# --------------------------------------------------------------------- explicit flags
|
|
code, out, err, stub = run_cli(["--model", "english", "charged twice"])
|
|
check("flags: --model forwarded", stub.route_calls[0][1]["model"] == "english")
|
|
|
|
# ------------------------------------------------- --model is core's registry, not a copy of it
|
|
# `choices=` compared strings exactly, so the flag knew three spellings while the router it feeds
|
|
# has always resolved a dozen more. The names and aliases are imported from `laya.router` rather
|
|
# than written out, so this sweep follows the registry instead of drifting from it.
|
|
from laya.router import DEFAULT_MODELS, _ALIASES # noqa: E402
|
|
|
|
|
|
def parse_model(argv):
|
|
"""What `--model` settles to, or "REJECTED" when argparse exits 2 on it.
|
|
|
|
The value is produced by the real parser `main()` uses, so these checks cover the flag as a
|
|
user types it; `run_cli` cannot see it, because argparse stops before the router is called.
|
|
"""
|
|
try:
|
|
with redirect_stderr(io.StringIO()):
|
|
return cli.build_parser().parse_args(argv).model
|
|
except SystemExit:
|
|
return "REJECTED"
|
|
|
|
|
|
for name in sorted(DEFAULT_MODELS):
|
|
got = parse_model(["t", "--model", name])
|
|
check("model: %s accepted" % name, got == name, repr(got))
|
|
for alias, canonical in sorted(_ALIASES.items()):
|
|
got = parse_model(["t", "--model", alias])
|
|
check("model: alias %s is %s" % (alias, canonical), got == canonical, repr(got))
|
|
for spelling, want in (("EN", "english"), (" Multilingual ", "multilingual"),
|
|
("Typed-Decisions", "typed-decisions"), ("laya-MULTILINGUAL", "multilingual")):
|
|
got = parse_model(["t", "--model", spelling])
|
|
check("model: casing and spacing %r" % spelling, got == want, repr(got))
|
|
for auto in ("auto", "AUTO", " Auto "):
|
|
got = parse_model(["t", "--model", auto])
|
|
check("model: %r pins nothing" % auto, got is None, repr(got))
|
|
check("model: omitting it pins nothing either", parse_model(["t"]) is None)
|
|
for bad in ("gpt4", "english-ish", "laya-typed", ""):
|
|
got = parse_model(["t", "--model", bad])
|
|
check("model: %r rejected" % bad, got == "REJECTED", repr(got))
|
|
|
|
# The message is now core's, so it has to carry everything a user can name.
|
|
err = io.StringIO()
|
|
try:
|
|
with redirect_stderr(err):
|
|
cli.build_parser().parse_args(["t", "--model", "gpt4"])
|
|
except SystemExit:
|
|
pass
|
|
text = err.getvalue()
|
|
check("model: error names the checkpoints",
|
|
all(n in text for n in sorted(DEFAULT_MODELS)), text)
|
|
check("model: error names the aliases", "alias" in text and "'en'" in text, text)
|
|
check("model: error names auto", "'auto'" in text, text)
|
|
|
|
# And what reaches the router is the canonical name, whichever spelling was typed.
|
|
code, out, err, stub = run_cli(["charged twice", "--model", "en"])
|
|
check("model: alias forwarded canonical to route",
|
|
code == 0 and stub.route_calls[0][1]["model"] == "english", str(stub.route_calls))
|
|
code, out, err, stub = run_cli(["charged twice", "--predict", "--model", "ML"])
|
|
check("model: alias forwarded canonical to predict",
|
|
code == 0 and stub.predict_calls[0][1]["model"] == "multilingual", str(stub.predict_calls))
|
|
code, out, err, stub = run_cli(["charged twice", "--model", "auto"])
|
|
check("model: auto forwarded as no pin",
|
|
code == 0 and stub.route_calls[0][1]["model"] is None, str(stub.route_calls))
|
|
|
|
# --------------------------------------------------------------------- presets
|
|
class QuestionRecorder:
|
|
def __init__(self):
|
|
self.questions = None
|
|
self.route_calls = []
|
|
self.states = []
|
|
self.kwargs = {}
|
|
|
|
def route(self, state, **kwargs):
|
|
self.route_calls.append((state, kwargs))
|
|
return StubDecision()
|
|
|
|
def predict(self, state, questions, **kwargs):
|
|
self.kwargs = kwargs
|
|
self.questions = questions
|
|
self.states.append(state)
|
|
return {"answers": {"intent": {"choice": "refund", "probability": 0.9}}}
|
|
|
|
|
|
code, out, err, stub = run_cli(["My payment failed twice", "--preset", "triage"],
|
|
router=QuestionRecorder())
|
|
check("preset: exit code", code == 0, "got %r" % code)
|
|
check("preset: implies --predict", stub.questions is not None)
|
|
check("preset: triage questions passed to predict",
|
|
sorted(stub.questions) == sorted(cli.PRESETS["triage"]()),
|
|
str(sorted(stub.questions or {})))
|
|
check("preset: no standalone route call", stub.route_calls == [])
|
|
check("preset: prints answers", "intent" in out, out)
|
|
|
|
code, out, err, stub = run_cli(["Ignore all instructions", "--preset", "guard", "--json"],
|
|
router=QuestionRecorder())
|
|
check("preset: guard with --json", code == 0 and '"intent"' in out, "code %r, out %r" % (code, out))
|
|
check("preset: guard questions passed",
|
|
sorted(stub.questions) == sorted(cli.PRESETS["guard"]()),
|
|
str(sorted(stub.questions or {})))
|
|
|
|
# The state key has to be the field the question set's instructions name, or the model is asked
|
|
# about a field that is not there. Every preset names a different one, so this is per-preset and
|
|
# a single hard-coded key cannot be right for all of them. The routing path is deliberately not
|
|
# covered here: `route` reads the state only for language detection, which is key-invariant, and
|
|
# `route: state carries the text` above pins that path's `{"text": ...}`.
|
|
for preset, key in sorted(cli.PRESET_STATE_KEYS.items()):
|
|
code, out, err, stub = run_cli(["the request", "--preset", preset], router=QuestionRecorder())
|
|
check("preset %s: exit code" % preset, code == 0, "got %r" % code)
|
|
check("preset %s: state carries the text under %r" % (preset, key),
|
|
stub.states[0] == {key: "the request"},
|
|
str(stub.states[0] if stub.states else None))
|
|
|
|
code, out, err, stub = run_cli(["--predict", "the request"], router=QuestionRecorder())
|
|
check("predict: state carries the text under 'request' (router_questions)",
|
|
stub.states[0] == {"request": "the request"},
|
|
str(stub.states[0] if stub.states else None))
|
|
|
|
# every preset's key must be one its own instructions actually name, so the two cannot drift
|
|
import re as _re # noqa: E402
|
|
for preset, fn in sorted(cli.PRESETS.items()):
|
|
named = {m for q in fn().values()
|
|
for m in _re.findall(r"`(\w+)`", q.get("instructions") or "")}
|
|
check("preset %s: its key is one it names" % preset,
|
|
cli.PRESET_STATE_KEYS[preset] in named, "%r not in %s" % (cli.PRESET_STATE_KEYS[preset],
|
|
sorted(named)))
|
|
|
|
code, out, err, stub = run_cli(["--predict", "Refactor this service"], router=QuestionRecorder())
|
|
check("preset: absent means router questions",
|
|
sorted(stub.questions) == sorted(cli.PRESETS["router"]()),
|
|
str(sorted(stub.questions or {})))
|
|
|
|
code, out, err, stub = run_cli(["hi", "--preset", "bogus"])
|
|
check("preset: unknown name rejected by argparse", code == 2, "got %r" % code)
|
|
|
|
# --------------------------------------------------------------------- batch mode
|
|
import json # noqa: E402
|
|
import tempfile # noqa: E402
|
|
|
|
|
|
class BatchRouter:
|
|
"""Records whole-batch calls; any single-state call is a bug in batch mode."""
|
|
|
|
def __init__(self):
|
|
self.predict_batch_calls = []
|
|
self.route_batch_calls = []
|
|
|
|
def predict_batch(self, requests, batch_size=None):
|
|
self.predict_batch_calls.append((requests, batch_size))
|
|
return [{"model": "laya-rl", "answers": {"difficulty": {"score": float(i)}}, "usage": {}}
|
|
for i in range(len(requests))]
|
|
|
|
def route_batch(self, requests):
|
|
self.route_batch_calls.append(requests)
|
|
return [StubDecision() for _ in requests]
|
|
|
|
def route(self, state, **kwargs):
|
|
raise AssertionError("single route() called in batch mode")
|
|
|
|
def predict(self, state, questions, **kwargs):
|
|
raise AssertionError("single predict() called in batch mode")
|
|
|
|
|
|
def run_batch_cli(argv, router=None):
|
|
stub = router or BatchRouter()
|
|
original = cli.make_router
|
|
cli.make_router = lambda args: stub
|
|
out, err = io.StringIO(), io.StringIO()
|
|
try:
|
|
with redirect_stdout(out), redirect_stderr(err):
|
|
code = cli.main(argv)
|
|
finally:
|
|
cli.make_router = original
|
|
return code, out.getvalue(), err.getvalue(), stub
|
|
|
|
|
|
tmp = tempfile.mkdtemp()
|
|
path = os.path.join(tmp, "requests.txt")
|
|
with open(path, "w", encoding="utf-8") as handle:
|
|
handle.write("first ticket\n\n second ticket \n")
|
|
|
|
code, out, err, stub = run_batch_cli(["--batch", path, "--predict", "--batch-size", "8"])
|
|
check("batch predict: exit code", code == 0, "got %r %s" % (code, err))
|
|
requests, batch_size = stub.predict_batch_calls[0]
|
|
check("batch predict: exactly one predict_batch call", len(stub.predict_batch_calls) == 1)
|
|
check("batch predict: blank lines skipped, lines stripped",
|
|
[r["state"]["request"] for r in requests] == ["first ticket", "second ticket"],
|
|
str([r["state"] for r in requests]))
|
|
check("batch predict: --batch-size forwarded", batch_size == 8, str(batch_size))
|
|
check("batch predict: router questions on every request",
|
|
sorted(requests[0]["questions"]) == sorted(cli.PRESETS["router"]()),
|
|
str(sorted(requests[0]["questions"])))
|
|
check("batch predict: answers printed for both states", out.count("difficulty") == 2, out)
|
|
|
|
code, out, err, stub = run_batch_cli(["--batch", path, "--predict", "--json"])
|
|
parsed = [json.loads(line) for line in out.splitlines() if line.strip()]
|
|
check("batch --json: one JSONL record per state", len(parsed) == 2
|
|
and all("answers" in p for p in parsed), out)
|
|
|
|
code, out, err, stub = run_batch_cli(["--batch", path])
|
|
check("batch route: one route_batch call, no checkpoint",
|
|
len(stub.route_batch_calls) == 1 and stub.predict_batch_calls == [])
|
|
check("batch route: one line per state", out.count("multilingual") == 2, out)
|
|
|
|
code, out, err, stub = run_batch_cli(["--batch", path, "--preset", "triage"])
|
|
requests = stub.predict_batch_calls[0][0]
|
|
check("batch preset: state key follows the preset",
|
|
all(set(r["state"]) == {"message"} for r in requests), str([r["state"] for r in requests]))
|
|
|
|
code, out, err, stub = run_batch_cli(["--batch", path, "--predict", "--model", "english",
|
|
"--lang", "de"])
|
|
first = stub.predict_batch_calls[0][0][0]
|
|
check("batch flags: model/lang forwarded onto every request",
|
|
first.get("model") == "english" and first.get("lang") == "de", str(first))
|
|
|
|
code, out, err, stub = run_batch_cli(["--batch", os.path.join(tmp, "missing.txt")])
|
|
check("batch missing file: exit 2 with a named failure",
|
|
code == 2 and "could not read" in err, "code %r err %r" % (code, err))
|
|
|
|
code, out, err, stub = run_batch_cli(["--batch", path, "inline text"])
|
|
check("batch: text and --batch are mutually exclusive",
|
|
code == 2 and "not both" in err, "code %r err %r" % (code, err))
|
|
|
|
empty = os.path.join(tmp, "empty.txt")
|
|
open(empty, "w").close()
|
|
code, out, err, stub = run_batch_cli(["--batch", empty, "--predict"])
|
|
check("batch empty file: exit 2, no calls",
|
|
code == 2 and "no requests" in err and stub.predict_batch_calls == [],
|
|
"code %r err %r" % (code, err))
|
|
|
|
_original_stdin = sys.stdin
|
|
sys.stdin = io.StringIO("piped one\npiped two\n")
|
|
try:
|
|
code, out, err, stub = run_batch_cli(["--batch", "-", "--predict", "--json"])
|
|
finally:
|
|
sys.stdin = _original_stdin
|
|
check("batch -: reads stdin", code == 0
|
|
and len(stub.predict_batch_calls[0][0]) == 2
|
|
and len([l for l in out.splitlines() if l.strip()]) == 2, "code %r err %r" % (code, err))
|
|
|
|
# --------------------------------------------------------------------- --questions FILE
|
|
# The CLI could only ever answer the five built-in presets, while the library's whole input is a
|
|
# user-written question dict. A file is the only way to get one onto the command line, so the
|
|
# loader is the seam under test here: what it accepts, and what the request ends up under.
|
|
import json as _json # noqa: E402
|
|
import tempfile as _tempfile # noqa: E402
|
|
|
|
_TMP = _tempfile.mkdtemp(prefix="laya-cli-questions-")
|
|
|
|
|
|
def write_json(name, payload):
|
|
path = os.path.join(_TMP, name)
|
|
with open(path, "w", encoding="utf-8") as handle:
|
|
_json.dump(payload, handle)
|
|
return path
|
|
|
|
|
|
INTENTS = {
|
|
"intent": {"type": "choice", "instructions": "Which intent?",
|
|
"criteria": {"card_arrival": "where is my card", "fee": "a charge appeared"}},
|
|
}
|
|
INTENTS_PATH = write_json("intents.json", INTENTS)
|
|
KEYED_PATH = write_json("keyed.json", {"state_key": "body",
|
|
"questions": {"spam": {"type": "noul",
|
|
"instructions": "Is `body` spam?"}}})
|
|
|
|
code, out, err, stub = run_cli(["My payment failed twice", "--questions", INTENTS_PATH],
|
|
router=QuestionRecorder())
|
|
check("questions: exit code", code == 0, "got %r, err %r" % (code, err))
|
|
check("questions: implies --predict", stub.questions == INTENTS, str(stub.questions))
|
|
check("questions: state under the default key", stub.states[0] == {"request": "My payment failed twice"},
|
|
str(stub.states[0]))
|
|
check("questions: no standalone route call", stub.route_calls == [])
|
|
check("questions: prints answers", "intent" in out, out)
|
|
|
|
# A file that declares its own key has to win: the instructions name `body`, and asking about a
|
|
# field that is not there is the #426 failure this path keeps reproducing.
|
|
code, out, err, stub = run_cli(["hello", "--questions", KEYED_PATH], router=QuestionRecorder())
|
|
check("questions: declared state_key used", stub.states[0] == {"body": "hello"}, str(stub.states[0]))
|
|
check("questions: questions unwrapped",
|
|
sorted(stub.questions) == ["spam"], str(sorted(stub.questions or {})))
|
|
|
|
# The CLI validates the file's shape and defers question semantics to core, so a question set is
|
|
# handed over unchanged rather than normalised twice.
|
|
code, out, err, stub = run_cli(["hi", "--questions", write_json("odd.json", {"a": {"type": "bogus"}})],
|
|
router=QuestionRecorder())
|
|
check("questions: nonsense type reaches predict unchanged",
|
|
stub.questions == {"a": {"type": "bogus"}}, str(stub.questions))
|
|
|
|
for name, payload, fragment in (
|
|
("empty.json", {}, "holds no questions"),
|
|
("list.json", [1], "must be a JSON object"),
|
|
("scalar.json", {"a": 5}, "must map to an object"),
|
|
("badwrap.json", {"questions": [1]}, "'questions' field"),
|
|
("badkey.json", {"state_key": "", "questions": {"a": {"type": "noul"}}}, "'state_key'"),
|
|
):
|
|
code, out, err, stub = run_cli(["hi", "--questions", write_json(name, payload)],
|
|
router=QuestionRecorder())
|
|
check("questions %s: exit code 2" % name, code == 2, "got %r" % code)
|
|
check("questions %s: names the problem" % name, fragment in err, err)
|
|
|
|
code, out, err, stub = run_cli(["hi", "--questions", os.path.join(_TMP, "missing.json")],
|
|
router=QuestionRecorder())
|
|
check("questions: missing file is a handled error",
|
|
code == 2 and "no such --questions file" in err, "code %r, err %r" % (code, err))
|
|
|
|
code, out, err, stub = run_cli(["hi", "--questions", INTENTS_PATH, "--preset", "triage"],
|
|
router=QuestionRecorder())
|
|
check("questions: two sources refuse each other",
|
|
code == 2 and "pass one" in err, "code %r, err %r" % (code, err))
|
|
|
|
code, out, err, stub = run_cli(["hi", "--questions", INTENTS_PATH, "--json"],
|
|
router=QuestionRecorder())
|
|
check("questions: --json prints the raw answers", code == 0 and '"intent"' in out,
|
|
"code %r, out %r" % (code, out))
|
|
|
|
# The same file answers every preset's questions too, so --questions replaces none of them.
|
|
code, out, err, stub = run_cli(["hi", "--questions", INTENTS_PATH, "--model", "english"],
|
|
router=QuestionRecorder())
|
|
check("questions: --model still forwarded", stub.kwargs.get("model") == "english", str(stub.kwargs))
|
|
|
|
# --------------------------------------------------------------------- the token budget
|
|
# `--questions` is what makes a many-label question reachable from the shell, and a many-label
|
|
# question is exactly what overflows the shared option budget. Both halves have to be here: on
|
|
# `laya` (head budget 192) the 58-label Massive-Intent question scores 24/58 by default and
|
|
# 34/58 at --head-max-len 384, so the CLI that can ask the question must also be able to fit it.
|
|
# Nothing set has to mean nothing sent: the checkpoint's own budget stays in charge, and a
|
|
# `max_len=None` in the call would be a different promise from the one `predict` documents.
|
|
code, out, err, stub = run_cli(["hi", "--questions", INTENTS_PATH], router=QuestionRecorder())
|
|
check("budget: default sends no override", "max_len" not in stub.kwargs and
|
|
"head_max_len" not in stub.kwargs, str(stub.kwargs))
|
|
|
|
for argv, expected in (
|
|
(["--max-len", "1024"], {"max_len": 1024}),
|
|
(["--head-max-len", "384"], {"head_max_len": 384}),
|
|
(["--max-len", "1024", "--head-max-len", "384"], {"max_len": 1024, "head_max_len": 384}),
|
|
# A zero budget is a real value, not an absent one.
|
|
(["--head-max-len", "0"], {"head_max_len": 0}),
|
|
):
|
|
code, out, err, stub = run_cli(["hi", "--questions", INTENTS_PATH] + argv, router=QuestionRecorder())
|
|
sent = {k: v for k, v in stub.kwargs.items() if k in ("max_len", "head_max_len")}
|
|
check("budget %s: forwarded" % " ".join(argv), sent == expected, "%r != %r" % (sent, expected))
|
|
|
|
# The budget belongs to the request, not to how the questions arrived.
|
|
for argv in (["--predict"], ["--preset", "triage"], ["--questions", INTENTS_PATH]):
|
|
code, out, err, stub = run_cli(["hi"] + argv + ["--head-max-len", "512"], router=QuestionRecorder())
|
|
check("budget on %s: forwarded" % " ".join(argv), stub.kwargs.get("head_max_len") == 512,
|
|
str(stub.kwargs))
|
|
check("budget on %s: questions unaffected" % " ".join(argv), stub.questions is not None)
|
|
|
|
# A head budget cannot exceed the request budget it lives inside; that is core's rule, so the CLI
|
|
# passes the pair through and reports core's words rather than inventing its own.
|
|
class BudgetRejectingRouter(QuestionRecorder):
|
|
def predict(self, state, questions, **kwargs):
|
|
self.kwargs = kwargs
|
|
if kwargs.get("head_max_len", 0) > kwargs.get("max_len", 1 << 30):
|
|
raise ValueError("head_max_len must not exceed max_len")
|
|
return {"answers": {}}
|
|
|
|
|
|
code, out, err, stub = run_cli(["hi", "--questions", INTENTS_PATH, "--max-len", "128",
|
|
"--head-max-len", "384"], router=BudgetRejectingRouter())
|
|
check("budget: a rejected pair exits 2", code == 2, "got %r" % code)
|
|
check("budget: core's message reaches the shell", "head_max_len must not exceed max_len" in err, err)
|
|
check("budget: both flags reached predict together",
|
|
(stub.kwargs.get("max_len"), stub.kwargs.get("head_max_len")) == (128, 384), str(stub.kwargs))
|
|
|
|
for value in ("abc", ""):
|
|
code, out, err, stub = run_cli(["hi", "--questions", INTENTS_PATH, "--head-max-len=%s" % value],
|
|
router=QuestionRecorder())
|
|
check("budget %r: rejected by argparse" % value, code == 2 and "invalid int value" in err,
|
|
"code %r, err %r" % (code, err))
|
|
|
|
# --------------------------------------------------------------------- report
|
|
print("\n%d passed, %d failed" % (len(PASS), len(FAIL)))
|
|
for f in FAIL:
|
|
print(" FAIL", f)
|
|
if not FAIL:
|
|
print("all CLI tests passed")
|
|
sys.exit(1 if FAIL else 0)
|