cli.py 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185
  1. """Command-line interface for driving a running app's scene graph over
  2. the netdebug ZeroMQ backend. Run from `bin/app` as `python -m pydrk ...`.
  3. With no subcommand an interactive shell is started."""
  4. import argparse
  5. import math
  6. import re
  7. import shlex
  8. import sys
  9. import zmq
  10. from . import exc, serial
  11. from .api import (
  12. Api,
  13. CallArgType,
  14. Expr,
  15. NODE_TYPE_NAMES,
  16. PropertyStatus,
  17. PropertySubType,
  18. PropertyType,
  19. SceneNodeType,
  20. )
  21. from .print_tree import print_tree
  22. from .vector_shape import VectorShape
  23. class UsageError(Exception):
  24. pass
  25. PYDRK_ERRORS = tuple(
  26. obj for obj in vars(exc).values() if isinstance(obj, type) and issubclass(obj, Exception)
  27. )
  28. def error_name(err):
  29. if isinstance(err, exc.UnknownError):
  30. return str(err)
  31. name = type(err).__name__
  32. name = name.replace("ID", "Id")
  33. name = name.replace("SExpr", "Sexpr")
  34. return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
  35. def resolve_path(cwd, arg):
  36. if arg.startswith("/"):
  37. tokens = arg.split("/")
  38. else:
  39. tokens = cwd + arg.split("/")
  40. out = []
  41. for token in tokens:
  42. if token in ("", "."):
  43. continue
  44. if token == "..":
  45. if out:
  46. out.pop()
  47. continue
  48. out.append(token)
  49. return "/" + "/".join(out)
  50. def format_value(val):
  51. if val is None:
  52. return "null"
  53. if isinstance(val, Expr):
  54. return f'"{val}"'
  55. if isinstance(val, bool):
  56. return "true" if val else "false"
  57. if isinstance(val, str):
  58. return f'"{val}"'
  59. return str(val)
  60. def prop_summary(api, path, prop):
  61. if prop.type == PropertyType.VECTOR_SHAPE:
  62. return "<shape>"
  63. vals = api.get_property_value(path, prop.name)
  64. formatted = [format_value(v) for v in vals]
  65. if len(formatted) == 1:
  66. return formatted[0]
  67. return "[" + ", ".join(formatted) + "]"
  68. def format_status_value(status, val):
  69. match status:
  70. case PropertyStatus.EXPR:
  71. return f'expr "{val}"'
  72. case PropertyStatus.NULL:
  73. return "null"
  74. case PropertyStatus.UNSET:
  75. return "unset"
  76. case _:
  77. return f"value {format_value(val)}"
  78. def parse_get_args(tokens, cwd):
  79. tokens = list(tokens)
  80. idx = None
  81. if tokens and tokens[-1].isdigit():
  82. idx = int(tokens.pop())
  83. if not tokens:
  84. raise UsageError("usage: get [path] PROP [idx]")
  85. prop_name = tokens.pop()
  86. if tokens:
  87. path = resolve_path(cwd, "/".join(tokens))
  88. else:
  89. path = "/" + "/".join(cwd)
  90. return (path, prop_name, idx)
  91. def parse_show_args(tokens, cwd):
  92. tokens = list(tokens)
  93. if not tokens:
  94. raise UsageError("usage: show [path] PROP")
  95. prop_name = tokens.pop()
  96. if tokens:
  97. path = resolve_path(cwd, "/".join(tokens))
  98. else:
  99. path = "/" + "/".join(cwd)
  100. return (path, prop_name)
  101. def parse_set_args(tokens, cwd):
  102. tokens = list(tokens)
  103. if not tokens:
  104. raise UsageError("usage: set [path] PROP [idx] VAL")
  105. value = tokens.pop()
  106. idx = 0
  107. if tokens and tokens[-1].isdigit():
  108. idx = int(tokens.pop())
  109. if not tokens:
  110. raise UsageError("missing property name in: set [path] PROP [idx] VAL")
  111. prop_name = tokens.pop()
  112. if tokens:
  113. path = resolve_path(cwd, "/".join(tokens))
  114. else:
  115. path = "/" + "/".join(cwd)
  116. return (path, prop_name, idx, value)
  117. def parse_uint32(token):
  118. try:
  119. val = int(token, 0)
  120. except ValueError:
  121. raise UsageError(f"invalid uint32 value: {token}")
  122. if not 0 <= val <= 0xFFFFFFFF:
  123. raise UsageError(f"uint32 value out of range: {token}")
  124. return val
  125. def encode_set_value(api, path, prop, token, index):
  126. if token == "null":
  127. api.set_property_null(path, prop.name, index)
  128. return
  129. match prop.type:
  130. case PropertyType.BOOL:
  131. if token == "true":
  132. api.set_property_bool(path, prop.name, index, True)
  133. elif token == "false":
  134. api.set_property_bool(path, prop.name, index, False)
  135. else:
  136. raise UsageError(f"invalid bool value: {token} (use true/false)")
  137. case PropertyType.UINT32:
  138. api.set_property_u32(path, prop.name, index, parse_uint32(token))
  139. case PropertyType.SCENE_NODE_ID:
  140. api.set_property_node_id(path, prop.name, index, parse_uint32(token))
  141. case PropertyType.FLOAT32:
  142. try:
  143. val = float(token)
  144. except ValueError:
  145. raise UsageError(f"invalid float32 value: {token}")
  146. api.set_property_f32(path, prop.name, index, val)
  147. case PropertyType.STR:
  148. api.set_property_str(path, prop.name, index, token)
  149. case PropertyType.ENUM:
  150. if prop.enum_items is None or token not in prop.enum_items:
  151. raise UsageError(f"invalid enum item: {token} (not in {prop.enum_items})")
  152. api.set_property_enum(path, prop.name, index, token)
  153. case _:
  154. raise UsageError(f"cannot set properties of type {PropertyType.to_str(prop.type)}")
  155. def prop_meta_lines(prop):
  156. array_len = "unbounded" if prop.array_len == 0 else str(prop.array_len)
  157. lines = [
  158. f"{prop.name}:",
  159. f" type: {PropertyType.to_str(prop.type)}",
  160. f" subtype: {PropertySubType.to_str(prop.subtype)}",
  161. f" array_len: {array_len}",
  162. f" null_allowed: {'yes' if prop.is_null_allowed else 'no'}",
  163. f" expr_allowed: {'yes' if prop.is_expr_allowed else 'no'}",
  164. ]
  165. if prop.min_val is not None and prop.max_val is not None:
  166. lines.append(f" range: [{format_value(prop.min_val)}, {format_value(prop.max_val)}]")
  167. if prop.enum_items is not None:
  168. lines.append(f" enum_items: [" + ", ".join(prop.enum_items) + "]")
  169. if prop.ui_name:
  170. lines.append(f" ui_name: {prop.ui_name}")
  171. if prop.desc:
  172. lines.append(f" desc: {prop.desc}")
  173. return lines
  174. def run_command(api, handler, args, cwd):
  175. try:
  176. handler(api, args, cwd)
  177. except UsageError as err:
  178. print(f"error: {err}", file=sys.stderr)
  179. sys.exit(1)
  180. except PYDRK_ERRORS as err:
  181. print(f"error: {error_name(err)}", file=sys.stderr)
  182. sys.exit(1)
  183. except zmq.error.Again:
  184. print(f"error: no reply from {api.addr}:{api.port}", file=sys.stderr)
  185. sys.exit(1)
  186. except zmq.error.ZMQError as err:
  187. print(f"error: {err}", file=sys.stderr)
  188. sys.exit(1)
  189. COMMAND_PARSERS = {}
  190. MAIN_PARSER = argparse.ArgumentParser()
  191. SHELL_BUILTINS = ("cd", "..", "...", "pwd", "exit", "quit")
  192. BUILTIN_HELP = {
  193. "cd": "cd [path] change the working node (no arg = /, .. pops one)",
  194. "..": ".., ..., .... go up one, two, three, ... nodes",
  195. "...": ".., ..., .... go up one, two, three, ... nodes",
  196. "pwd": "pwd print the working node path",
  197. "exit": "exit | quit leave the shell (Ctrl-D also works)",
  198. "quit": "exit | quit leave the shell (Ctrl-D also works)",
  199. "help": "help [command] show overall or per-command help",
  200. }
  201. def print_help(command=None):
  202. if command is None:
  203. MAIN_PARSER.print_help()
  204. print()
  205. print("shell builtins (interactive mode only):")
  206. for name in ("cd", "..", "pwd", "exit", "help"):
  207. print(f" {BUILTIN_HELP[name]}")
  208. elif command in COMMAND_PARSERS:
  209. COMMAND_PARSERS[command].print_help()
  210. elif command in BUILTIN_HELP:
  211. print(BUILTIN_HELP[command])
  212. else:
  213. raise UsageError(f"unknown command: {command}")
  214. def cmd_help(api, args, cwd):
  215. print_help(getattr(args, "topic", None))
  216. class ShellExit(Exception):
  217. pass
  218. class Shell:
  219. def __init__(self, api):
  220. self.api = api
  221. self.cwd = []
  222. def prompt(self):
  223. return f"pydrk:/{'/'.join(self.cwd)}> "
  224. def run(self):
  225. setup_completion(self)
  226. while True:
  227. try:
  228. line = input(self.prompt())
  229. except EOFError:
  230. print()
  231. return
  232. except KeyboardInterrupt:
  233. print()
  234. continue
  235. try:
  236. self.execute(line)
  237. except ShellExit:
  238. return
  239. clear_completion_cache()
  240. def execute(self, line):
  241. try:
  242. tokens = shlex.split(line)
  243. except ValueError as err:
  244. print(f"error: {err}", file=sys.stderr)
  245. return
  246. if not tokens:
  247. return
  248. cmd = tokens[0]
  249. if len(tokens) == 1 and len(cmd) >= 2 and not cmd.strip("."):
  250. self.cd(["/".join([".."] * (len(cmd) - 1))])
  251. return
  252. if cmd in ("exit", "quit"):
  253. raise ShellExit
  254. if cmd == "pwd":
  255. print("/" + "/".join(self.cwd))
  256. return
  257. if cmd == "cd":
  258. self.cd(tokens[1:])
  259. return
  260. parser = COMMAND_PARSERS.get(cmd)
  261. if parser is None:
  262. print(f"error: unknown command: {cmd}", file=sys.stderr)
  263. return
  264. try:
  265. args = parser.parse_args(tokens[1:])
  266. except SystemExit:
  267. return
  268. try:
  269. args.func(self.api, args, self.cwd)
  270. except UsageError as err:
  271. print(f"error: {err}", file=sys.stderr)
  272. except PYDRK_ERRORS as err:
  273. print(f"error: {error_name(err)}", file=sys.stderr)
  274. except zmq.error.Again:
  275. print(f"error: no reply from {self.api.addr}:{self.api.port}", file=sys.stderr)
  276. except zmq.error.ZMQError as err:
  277. print(f"error: {err}", file=sys.stderr)
  278. def cd(self, tokens):
  279. path = resolve_path(self.cwd, tokens[0] if tokens else "/")
  280. if path != "/":
  281. parent, _, name = path.rpartition("/")
  282. try:
  283. children = self.api.get_children(parent or "/")
  284. except UsageError as err:
  285. print(f"error: {err}", file=sys.stderr)
  286. return
  287. except PYDRK_ERRORS as err:
  288. print(f"error: {error_name(err)}", file=sys.stderr)
  289. return
  290. except zmq.error.Again:
  291. print(f"error: no reply from {self.api.addr}:{self.api.port}", file=sys.stderr)
  292. return
  293. except zmq.error.ZMQError as err:
  294. print(f"error: {err}", file=sys.stderr)
  295. return
  296. if not any(child_name == name for (child_name, _, _) in children):
  297. print(f"error: node_not_found: {path}", file=sys.stderr)
  298. return
  299. self.cwd = [token for token in path.split("/") if token]
  300. class Completer:
  301. def __init__(self, shell):
  302. self.shell = shell
  303. self.cache = {}
  304. def clear_cache(self):
  305. self.cache.clear()
  306. def cached_children(self, path):
  307. if path not in self.cache:
  308. try:
  309. self.cache[path] = [name for (name, _, _) in self.shell.api.get_children(path)]
  310. except Exception:
  311. self.cache[path] = []
  312. return self.cache[path]
  313. def cached_props(self, path):
  314. key = "props:" + path
  315. if key not in self.cache:
  316. try:
  317. self.cache[key] = [prop.name for prop in self.shell.api.get_properties(path)]
  318. except Exception:
  319. self.cache[key] = []
  320. return self.cache[key]
  321. def path_matches(self, token, text):
  322. # `token` is the whitespace-delimited word up to the cursor,
  323. # including any already-typed path components. `text` is what
  324. # readline wants replaced: readline's default completer delimiters
  325. # include "/", so text may be only the fragment after the last
  326. # slash. Matches are returned with the shared head stripped so
  327. # they align with readline's replacement window.
  328. strip = len(token) - len(text)
  329. idx = token.rfind("/")
  330. if idx == -1:
  331. head, prefix, prefix_part = "", token, ""
  332. else:
  333. head, prefix = token[:idx], token[idx + 1:]
  334. prefix_part = token[: idx + 1]
  335. if token.startswith("/"):
  336. base = resolve_path(self.shell.cwd, head if head else "/")
  337. else:
  338. base = resolve_path(self.shell.cwd, head if head else ".")
  339. return [
  340. (prefix_part + name + "/")[strip:]
  341. for name in self.cached_children(base)
  342. if name.startswith(prefix)
  343. ]
  344. def matches(self, text):
  345. import readline
  346. buf = readline.get_line_buffer()
  347. begidx = readline.get_begidx()
  348. if begidx == 0:
  349. words = sorted(set(COMMAND_PARSERS) | set(SHELL_BUILTINS))
  350. return [word for word in words if word.startswith(text)]
  351. typed = buf[:begidx]
  352. head_tokens = typed.split()
  353. cmd = head_tokens[0] if head_tokens else ""
  354. token = typed[typed.rfind(" ") + 1 :] + text
  355. if cmd in ("get", "set", "show") and len(head_tokens) == 1:
  356. cwd_path = "/" + "/".join(self.shell.cwd)
  357. found = []
  358. if "/" not in token:
  359. found += [name + "/" for name in self.cached_children(cwd_path)]
  360. found += [name + " " for name in self.cached_props(cwd_path)]
  361. return sorted(set(m for m in found if m.startswith(text)))
  362. return sorted(set(self.path_matches(token, text)))
  363. return sorted(set(self.path_matches(token, text)))
  364. def complete(self, text, state):
  365. found = self.matches(text)
  366. return found[state] if state < len(found) else None
  367. _ACTIVE_COMPLETER = None
  368. def setup_completion(shell):
  369. global _ACTIVE_COMPLETER
  370. try:
  371. import readline
  372. except ImportError:
  373. return
  374. readline.parse_and_bind("tab: complete")
  375. _ACTIVE_COMPLETER = Completer(shell)
  376. readline.set_completer(_ACTIVE_COMPLETER.complete)
  377. def clear_completion_cache():
  378. if _ACTIVE_COMPLETER is not None:
  379. _ACTIVE_COMPLETER.clear_cache()
  380. def shell_main(args):
  381. api = Api(args.addr, args.port)
  382. Shell(api).run()
  383. def build_parser():
  384. global MAIN_PARSER
  385. endpoint_args = argparse.ArgumentParser(add_help=False)
  386. endpoint_args.add_argument("--addr", default=argparse.SUPPRESS)
  387. endpoint_args.add_argument("--port", type=int, default=argparse.SUPPRESS)
  388. parser = argparse.ArgumentParser(prog="pydrk", description="Drive a running app over the netdebug backend")
  389. parser.add_argument("--addr", default="127.0.0.1")
  390. parser.add_argument("--port", type=int, default=9484)
  391. parser.add_argument("--selftest", action="store_true", help="run built-in checks and exit")
  392. sub = parser.add_subparsers(dest="command", metavar="<command>")
  393. p = sub.add_parser("ping", parents=[endpoint_args], help="connectivity check")
  394. p.set_defaults(func=cmd_ping)
  395. p = sub.add_parser("ls", parents=[endpoint_args], help="list a node's children and properties")
  396. p.add_argument("path", nargs="?", default=".")
  397. p.set_defaults(func=cmd_ls)
  398. p = sub.add_parser("tree", parents=[endpoint_args], help="recursively print a node's descendants")
  399. p.add_argument("path", nargs="?", default=".")
  400. p.add_argument("--depth", type=int, default=None)
  401. p.set_defaults(func=cmd_tree)
  402. p = sub.add_parser("props", parents=[endpoint_args], help="list a node's property metadata")
  403. p.add_argument("path", nargs="?", default=".")
  404. p.set_defaults(func=cmd_props)
  405. p = sub.add_parser("get", parents=[endpoint_args], help="print a property's values")
  406. p.add_argument("positionals", nargs="*", metavar="[path] PROP [idx]")
  407. p.set_defaults(func=cmd_get)
  408. p = sub.add_parser("show", parents=[endpoint_args], help="print everything about one property")
  409. p.add_argument("positionals", nargs="*", metavar="[path] PROP")
  410. p.set_defaults(func=cmd_show)
  411. p = sub.add_parser("set", parents=[endpoint_args], help="set a property value")
  412. p.add_argument("positionals", nargs="*", metavar="[path] PROP [idx] VAL")
  413. p.add_argument("--expr", action="store_true", help="send VAL as expr source to compile server-side")
  414. p.set_defaults(func=cmd_set)
  415. p = sub.add_parser("methods", parents=[endpoint_args], help="list a node's methods")
  416. p.add_argument("path")
  417. p.set_defaults(func=cmd_methods)
  418. p = sub.add_parser("signals", parents=[endpoint_args], help="list a node's signals")
  419. p.add_argument("path")
  420. p.set_defaults(func=cmd_signals)
  421. p = sub.add_parser("mknode", parents=[endpoint_args], help="create and attach a node")
  422. p.add_argument("parent_path")
  423. p.add_argument("name")
  424. p.add_argument("type")
  425. p.set_defaults(func=cmd_mknode)
  426. p = sub.add_parser(
  427. "rmnode",
  428. parents=[endpoint_args],
  429. help="remove a node subtree (runtime-only, undone by restarting the app)",
  430. )
  431. p.add_argument("path")
  432. p.set_defaults(func=cmd_rmnode)
  433. p = sub.add_parser("set-shape", parents=[endpoint_args], help="push vector shape data")
  434. p.add_argument("path")
  435. p.add_argument("--prop", default="shape")
  436. p.add_argument("--index", type=int, default=0)
  437. p.add_argument(
  438. "--box",
  439. nargs=8,
  440. action=ShapePrimAction,
  441. metavar=("X1", "Y1", "X2", "Y2", "R", "G", "B", "A"),
  442. )
  443. p.add_argument(
  444. "--gbox",
  445. nargs=12,
  446. action=ShapePrimAction,
  447. metavar=("X1", "Y1", "X2", "Y2", "R", "G", "B", "A", "R", "G", "B", "A"),
  448. )
  449. p.add_argument(
  450. "--vgradient",
  451. nargs=14,
  452. action=ShapePrimAction,
  453. metavar=(
  454. "X1", "Y1", "X2", "Y2", "R", "G", "B", "A", "R", "G", "B", "A", "STRIPS", "GAMMA",
  455. ),
  456. )
  457. p.add_argument(
  458. "--outline",
  459. nargs=9,
  460. action=ShapePrimAction,
  461. metavar=("X1", "Y1", "X2", "Y2", "BORDERPX", "R", "G", "B", "A"),
  462. )
  463. p.add_argument(
  464. "--line",
  465. nargs=9,
  466. action=ShapePrimAction,
  467. metavar=("X1", "Y1", "X2", "Y2", "THICKNESS", "R", "G", "B", "A"),
  468. )
  469. p.add_argument(
  470. "--glow",
  471. nargs=9,
  472. action=ShapePrimAction,
  473. metavar=("CX", "CY", "W", "H", "SEGMENTS", "R", "G", "B", "A"),
  474. )
  475. p.set_defaults(func=cmd_set_shape)
  476. p = sub.add_parser("call", parents=[endpoint_args], help="call a node method")
  477. p.add_argument("path")
  478. p.add_argument("method")
  479. p.add_argument("args", nargs="*", metavar="ARG")
  480. p.set_defaults(func=cmd_call)
  481. p = sub.add_parser("help", parents=[endpoint_args], help="show overall or per-command help")
  482. p.add_argument("topic", nargs="?", default=None)
  483. p.set_defaults(func=cmd_help)
  484. COMMAND_PARSERS.clear()
  485. COMMAND_PARSERS.update(sub.choices)
  486. MAIN_PARSER = parser
  487. return parser
  488. def cmd_ping(api, args, cwd):
  489. print(api.hello())
  490. def cmd_ls(api, args, cwd):
  491. path = resolve_path(cwd, args.path)
  492. for (name, node_id, node_type) in api.get_children(path):
  493. print(f"{name} {node_id} {NODE_TYPE_NAMES.get(node_type, '?')}")
  494. for prop in api.get_properties(path):
  495. prop_type = PropertyType.to_str(prop.type)
  496. print(f"{prop.name}: {prop_type} = {prop_summary(api, path, prop)}")
  497. def cmd_tree(api, args, cwd):
  498. print_tree(api, resolve_path(cwd, args.path), args.depth)
  499. def cmd_props(api, args, cwd):
  500. path = resolve_path(cwd, args.path)
  501. for prop in api.get_properties(path):
  502. for line in prop_meta_lines(prop):
  503. print(line)
  504. def print_prop_values(api, path, prop_name, idx):
  505. vals = api.get_property_value_full(path, prop_name)
  506. for i, (status, val) in enumerate(vals):
  507. if idx is not None and i != idx:
  508. continue
  509. print(f"{i}: {format_status_value(status, val)}")
  510. def cmd_get(api, args, cwd):
  511. path, prop_name, idx = parse_get_args(args.positionals, cwd)
  512. print_prop_values(api, path, prop_name, idx)
  513. def find_prop(api, path, prop_name):
  514. for prop in api.get_properties(path):
  515. if prop.name == prop_name:
  516. return prop
  517. raise exc.PropertyNotFound
  518. def cmd_show(api, args, cwd):
  519. path, prop_name = parse_show_args(args.positionals, cwd)
  520. prop = find_prop(api, path, prop_name)
  521. for line in prop_meta_lines(prop):
  522. print(line)
  523. if prop.depends:
  524. depends = ", ".join(f"({i}, {name})" for (i, name) in prop.depends)
  525. print(f" depends: [{depends}]")
  526. print_prop_values(api, path, prop_name, None)
  527. def cmd_set(api, args, cwd):
  528. path, prop_name, idx, value = parse_set_args(args.positionals, cwd)
  529. if args.expr:
  530. api.set_property_expr(path, prop_name, idx, value)
  531. return
  532. prop = find_prop(api, path, prop_name)
  533. encode_set_value(api, path, prop, value, idx)
  534. def format_signature(method_name, args, results):
  535. arg_strs = [f"{name}: {CallArgType.to_str(typ)}" for (name, _, typ) in args]
  536. result_strs = [f"{name}: {CallArgType.to_str(typ)}" for (name, _, typ) in (results or [])]
  537. return f"{method_name}(" + ", ".join(arg_strs) + ") -> (" + ", ".join(result_strs) + ")"
  538. def cmd_methods(api, args, cwd):
  539. path = resolve_path(cwd, args.path)
  540. for method_name in api.get_methods(path):
  541. method_args, results = api.get_method(path, method_name)
  542. print(format_signature(method_name, method_args, results))
  543. def cmd_signals(api, args, cwd):
  544. path = resolve_path(cwd, args.path)
  545. for sig_name in api.get_signals(path):
  546. print(sig_name)
  547. NODE_TYPES = {
  548. "layer": SceneNodeType.LAYER,
  549. "vector_art": SceneNodeType.VECTOR_ART,
  550. }
  551. def cmd_mknode(api, args, cwd):
  552. node_type = NODE_TYPES.get(args.type)
  553. if node_type is None:
  554. raise UsageError(f"unsupported node type: {args.type} (supported: {', '.join(NODE_TYPES)})")
  555. parent_path = resolve_path(cwd, args.parent_path)
  556. node_id = api.add_node(parent_path, args.name, node_type)
  557. path = parent_path.rstrip("/") + "/" + args.name
  558. print(f"id={node_id} path={path}")
  559. def cmd_rmnode(api, args, cwd):
  560. path = resolve_path(cwd, args.path)
  561. api.remove_node(path)
  562. SHAPE_MAX_VERTS = 65536
  563. class ShapePrimAction(argparse.Action):
  564. # argparse "append" actions keep one list per flag, losing the order
  565. # between different flags. This action records (flag, values) pairs in
  566. # true command-line order so primitives join as given.
  567. def __call__(self, parser, namespace, values, option_string=None):
  568. prims = list(getattr(namespace, "prims", []))
  569. prims.append(((option_string or "").lstrip("-"), values))
  570. namespace.prims = prims
  571. def coord_arg(token):
  572. try:
  573. return float(token)
  574. except ValueError:
  575. return token
  576. def num_arg(token, what):
  577. try:
  578. return float(token)
  579. except ValueError:
  580. raise UsageError(f"invalid {what} value: {token}")
  581. def int_arg(token, what):
  582. try:
  583. return int(token, 0)
  584. except ValueError:
  585. raise UsageError(f"invalid {what} value: {token}")
  586. def parse_shape_color_args(vals, count):
  587. if len(vals) != count:
  588. raise UsageError(f"expected {count} color values (R G B A), got {' '.join(vals)}")
  589. try:
  590. return [float(v) for v in vals]
  591. except ValueError:
  592. raise UsageError(f"invalid color value: {' '.join(vals)}")
  593. def build_shape(prims):
  594. shape = VectorShape()
  595. for (name, vals) in prims:
  596. match name:
  597. case "box":
  598. shape.add_filled_box(
  599. coord_arg(vals[0]),
  600. coord_arg(vals[1]),
  601. coord_arg(vals[2]),
  602. coord_arg(vals[3]),
  603. parse_shape_color_args(vals[4:], 4),
  604. )
  605. case "gbox":
  606. top = parse_shape_color_args(vals[4:8], 4)
  607. bottom = parse_shape_color_args(vals[8:], 4)
  608. shape.add_gradient_box(
  609. coord_arg(vals[0]),
  610. coord_arg(vals[1]),
  611. coord_arg(vals[2]),
  612. coord_arg(vals[3]),
  613. [top, top, bottom, bottom],
  614. )
  615. case "vgradient":
  616. top = parse_shape_color_args(vals[4:8], 4)
  617. bottom = parse_shape_color_args(vals[8:12], 4)
  618. strips = int_arg(vals[12], "strips")
  619. gamma = num_arg(vals[13], "gamma")
  620. if strips <= 0:
  621. raise UsageError(f"strips must be positive, got {strips}")
  622. shape.add_smooth_vertical_gradient(
  623. coord_arg(vals[0]),
  624. coord_arg(vals[1]),
  625. coord_arg(vals[2]),
  626. coord_arg(vals[3]),
  627. top,
  628. bottom,
  629. strips,
  630. gamma,
  631. )
  632. case "outline":
  633. shape.add_outline(
  634. coord_arg(vals[0]),
  635. coord_arg(vals[1]),
  636. coord_arg(vals[2]),
  637. coord_arg(vals[3]),
  638. coord_arg(vals[4]),
  639. parse_shape_color_args(vals[5:], 4),
  640. )
  641. case "line":
  642. coords = []
  643. for token in vals[:4]:
  644. if not isinstance(coord_arg(token), float):
  645. raise UsageError(f"line coordinates must be plain numbers, got {token}")
  646. coords.append(float(token))
  647. thickness = num_arg(vals[4], "thickness")
  648. shape.add_line(
  649. coords[0],
  650. coords[1],
  651. coords[2],
  652. coords[3],
  653. thickness,
  654. parse_shape_color_args(vals[5:], 4),
  655. )
  656. case "glow":
  657. segments = int_arg(vals[4], "segments")
  658. if segments <= 0:
  659. raise UsageError(f"segments must be positive, got {segments}")
  660. shape.add_radial_glow(
  661. coord_arg(vals[0]),
  662. coord_arg(vals[1]),
  663. coord_arg(vals[2]),
  664. coord_arg(vals[3]),
  665. segments,
  666. 0.0,
  667. 2.0 * math.pi,
  668. parse_shape_color_args(vals[5:], 4),
  669. )
  670. case _:
  671. raise UsageError(f"unknown shape primitive: {name}")
  672. if len(shape.verts) >= SHAPE_MAX_VERTS:
  673. raise UsageError(
  674. f"shape has {len(shape.verts)} vertices, exceeding the 16-bit index limit of {SHAPE_MAX_VERTS - 1}"
  675. )
  676. return shape
  677. def cmd_set_shape(api, args, cwd):
  678. prims = getattr(args, "prims", None) or []
  679. if not prims:
  680. raise UsageError("no shape primitives given (use --box, --gbox, --vgradient, --outline, --line, --glow)")
  681. shape = build_shape(prims)
  682. path = resolve_path(cwd, args.path)
  683. shape.set(api, path, args.prop, args.index)
  684. def parse_bool(token):
  685. if token == "true":
  686. return True
  687. if token == "false":
  688. return False
  689. raise UsageError(f"invalid bool value: {token} (use true/false)")
  690. def encode_call_arg(buf, arg_type, token, arg_name):
  691. match arg_type:
  692. case CallArgType.UINT32:
  693. serial.write_u32(buf, parse_uint32(token))
  694. case CallArgType.UINT64:
  695. try:
  696. val = int(token, 0)
  697. except ValueError:
  698. raise UsageError(f"invalid uint64 value for {arg_name}: {token}")
  699. if not 0 <= val <= 0xFFFFFFFFFFFFFFFF:
  700. raise UsageError(f"uint64 value out of range for {arg_name}: {token}")
  701. serial.write_u64(buf, val)
  702. case CallArgType.FLOAT32:
  703. serial.write_f32(buf, num_arg(token, arg_name))
  704. case CallArgType.BOOL:
  705. serial.write_u8(buf, int(parse_bool(token)))
  706. case CallArgType.STR:
  707. serial.encode_str(buf, token)
  708. case CallArgType.HASH:
  709. token = token.strip().lower()
  710. if len(token) != 64:
  711. raise UsageError(f"invalid hash for {arg_name}: expected 64 hex chars, got {token}")
  712. try:
  713. buf += bytes.fromhex(token)
  714. except ValueError:
  715. raise UsageError(f"invalid hash hex for {arg_name}: {token}")
  716. case _:
  717. raise UsageError(f"unsupported argument type: {CallArgType.to_str(arg_type)}")
  718. CALL_RESULT_SIZES = {
  719. CallArgType.UINT32: 4,
  720. CallArgType.UINT64: 8,
  721. CallArgType.FLOAT32: 4,
  722. CallArgType.BOOL: 1,
  723. }
  724. def decode_call_result(cur, typ):
  725. match typ:
  726. case CallArgType.STR:
  727. return serial.decode_str(cur)
  728. case CallArgType.HASH:
  729. return cur.read(32).hex()
  730. case _:
  731. data = cur.read(CALL_RESULT_SIZES[typ])
  732. return f"0x{data.hex()}"
  733. def cmd_call(api, args, cwd):
  734. path = resolve_path(cwd, args.path)
  735. method_args, results = api.get_method(path, args.method)
  736. if len(args.args) != len(method_args):
  737. sig = format_signature(args.method, method_args, results)
  738. raise UsageError(f"wrong number of arguments for {sig}, got {len(args.args)}")
  739. buf = bytearray()
  740. for (name, _, typ), token in zip(method_args, args.args):
  741. encode_call_arg(buf, typ, token, name)
  742. result = api.call_method(path, args.method, bytes(buf))
  743. if result is None or not results:
  744. print("void")
  745. return
  746. cur = serial.Cursor(result)
  747. outs = []
  748. for (name, _, typ) in results:
  749. try:
  750. outs.append(decode_call_result(cur, typ))
  751. except Exception:
  752. outs.append(f"0x{cur.remain_data().hex()}")
  753. break
  754. print(" ".join(outs))
  755. def run_selftests():
  756. from contextlib import redirect_stderr, redirect_stdout
  757. from io import StringIO
  758. from unittest.mock import Mock
  759. from .api import Expr, Property, PropertyStatus, PropertyType
  760. assert format_value(None) == "null"
  761. assert format_value(Expr("w/2")) == '"w/2"'
  762. assert format_value(True) == "true"
  763. assert format_value(False) == "false"
  764. assert format_value(1.0) == "1.0"
  765. assert format_value(10) == "10"
  766. assert format_value("hello world") == '"hello world"'
  767. assert format_status_value(PropertyStatus.EXPR, Expr("w/2")) == 'expr "w/2"'
  768. assert format_status_value(PropertyStatus.NULL, None) == "null"
  769. assert format_status_value(PropertyStatus.UNSET, 1.0) == "unset"
  770. assert format_status_value(PropertyStatus.OK, 1.0) == "value 1.0"
  771. assert NODE_TYPE_NAMES[SceneNodeType.LAYER] == "layer"
  772. assert NODE_TYPE_NAMES[SceneNodeType.VECTOR_ART] == "vector_art"
  773. assert NODE_TYPE_NAMES[SceneNodeType.PLUGIN_ROOT] == "plugin_root"
  774. for node_type, name in (
  775. (25, "chat_view"),
  776. (26, "priv_msg_node"),
  777. (27, "date_msg_node"),
  778. (28, "file_msg_node"),
  779. (29, "slider"),
  780. (30, "dropdown"),
  781. ):
  782. assert NODE_TYPE_NAMES[node_type] == name
  783. api = Mock()
  784. api.get_children.return_value = [("node", 42, node_type)]
  785. api.get_properties.return_value = []
  786. api.get_signals.return_value = []
  787. api.get_methods.return_value = []
  788. output = StringIO()
  789. with redirect_stdout(output):
  790. cmd_ls(api, argparse.Namespace(path="/"), [])
  791. print_tree(api, depth=1)
  792. assert f"node 42 {name}\n" in output.getvalue()
  793. assert f"[{name}]\n" in output.getvalue()
  794. assert error_name(exc.SExprGlobalNotFound()) == "sexpr_global_not_found"
  795. assert error_name(exc.PropertySExprNotAllowed()) == "property_sexpr_not_allowed"
  796. assert error_name(exc.PropertyOutOfRange()) == "property_out_of_range"
  797. assert error_name(exc.PropertyNotFound()) == "property_not_found"
  798. assert error_name(exc.NodeSiblingNameConflict()) == "node_sibling_name_conflict"
  799. assert resolve_path([], "/") == "/"
  800. assert resolve_path([], "") == "/"
  801. assert resolve_path([], "..") == "/"
  802. assert resolve_path(["a", "b"], "../..") == "/"
  803. assert resolve_path(["a", "b"], "../../../setting") == "/setting"
  804. assert resolve_path([], "//window") == "/window"
  805. assert resolve_path([], "/window/content") == "/window/content"
  806. assert resolve_path(["window"], "content") == "/window/content"
  807. assert resolve_path(["window", "content"], "..") == "/window"
  808. assert resolve_path(["window"], "../setting") == "/setting"
  809. assert resolve_path(["window"], "./content/.") == "/window/content"
  810. assert resolve_path([], "window//content/") == "/window/content"
  811. assert resolve_path(["a", "b", "c"], "../../foo/") == "/a/foo"
  812. api = Mock()
  813. api.get_children.return_value = [
  814. ("a", 1, SceneNodeType.LAYER),
  815. ("b", 2, SceneNodeType.LAYER),
  816. ("foo", 3, SceneNodeType.LAYER),
  817. ]
  818. shell = Shell(api)
  819. for command, expected in (
  820. ("..", ["a", "b"]),
  821. ("...", ["a"]),
  822. ("....", []),
  823. ("........", []),
  824. ("cd ../../foo/", ["a", "foo"]),
  825. ):
  826. shell.cwd = ["a", "b", "c"]
  827. shell.execute(command)
  828. assert shell.cwd == expected, command
  829. api.get_children.assert_called_with("/a")
  830. shell.cwd = []
  831. shell.execute("..")
  832. assert shell.cwd == []
  833. shell.cwd = ["a", "b", "c"]
  834. with redirect_stderr(StringIO()) as errors:
  835. shell.execute("cd ../../missing/")
  836. assert "node_not_found: /a/missing" in errors.getvalue()
  837. assert shell.cwd == ["a", "b", "c"]
  838. assert parse_get_args(["alpha"], []) == ("/", "alpha", None)
  839. assert parse_get_args(["/window/content", "alpha"], []) == ("/window/content", "alpha", None)
  840. assert parse_get_args(["window", "content", "alpha"], []) == ("/window/content", "alpha", None)
  841. assert parse_get_args(["rect", "2"], []) == ("/", "rect", 2)
  842. assert parse_get_args(["rect", "2"], ["window", "content"]) == ("/window/content", "rect", 2)
  843. for bad in ([], ["2"]):
  844. try:
  845. parse_get_args(bad, [])
  846. raise AssertionError(f"parse_get_args({bad}) should have raised")
  847. except UsageError:
  848. pass
  849. assert parse_show_args(["alpha"], []) == ("/", "alpha")
  850. assert parse_show_args(["window", "content", "alpha"], []) == ("/window/content", "alpha")
  851. assert parse_show_args(["alpha"], ["window", "content"]) == ("/window/content", "alpha")
  852. assert parse_set_args(["is_visible", "false"], []) == ("/", "is_visible", 0, "false")
  853. assert parse_set_args(["rect", "2", "w/2"], []) == ("/", "rect", 2, "w/2")
  854. assert parse_set_args(["/window/content", "rect", "2", "w/2"], []) == (
  855. "/window/content",
  856. "rect",
  857. 2,
  858. "w/2",
  859. )
  860. assert parse_set_args(["window", "content", "rect", "2", "1.0"], []) == (
  861. "/window/content",
  862. "rect",
  863. 2,
  864. "1.0",
  865. )
  866. assert parse_set_args(["rect", "w/2"], ["window"]) == ("/window", "rect", 0, "w/2")
  867. for bad in ([], ["false"], ["2", "false"]):
  868. try:
  869. parse_set_args(bad, [])
  870. raise AssertionError(f"parse_set_args({bad}) should have raised")
  871. except UsageError:
  872. pass
  873. assert parse_uint32("42") == 42
  874. assert parse_uint32("0x10") == 16
  875. for bad in ("-1", "x", "4294967296"):
  876. try:
  877. parse_uint32(bad)
  878. raise AssertionError(f"parse_uint32({bad}) should have raised")
  879. except UsageError:
  880. pass
  881. shape = build_shape([("box", ["0", "0", "w", "10", "1", "0", "0", "1"])])
  882. assert len(shape.verts) == 4 and len(shape.indices) == 6
  883. assert shape.verts[1][0] == "w" and shape.verts[3][1] == "10.0"
  884. shape = build_shape([("gbox", ["0", "0", "w", "h", "1", "1", "1", "1", "0", "0", "0", "1"])])
  885. assert len(shape.verts) == 4
  886. assert shape.verts[0][2] == [1.0, 1.0, 1.0, 1.0]
  887. assert shape.verts[2][2] == [0.0, 0.0, 0.0, 1.0]
  888. shape = build_shape(
  889. [("vgradient", ["0", "0", "w", "h", "1", "1", "1", "1", "0", "0", "0", "1", "8", "0.45"])]
  890. )
  891. assert len(shape.verts) == 8 * 4 and len(shape.indices) == 8 * 6
  892. shape = build_shape([("outline", ["0", "0", "w", "h", "2.0", "0", "0", "0", "1"])])
  893. assert len(shape.verts) == 16 and len(shape.indices) == 24
  894. shape = build_shape([("line", ["0", "0", "10", "0", "4", "1", "1", "1", "1"])])
  895. assert len(shape.verts) == 4 and len(shape.indices) == 6
  896. shape = build_shape([("glow", ["w/2", "h/2", "w", "h", "12", "1", "0", "0", "1"])])
  897. assert len(shape.verts) == 14 and len(shape.indices) == 36
  898. assert shape.verts[1][0] == "(w/2 + (w * 0.5))"
  899. shape = build_shape(
  900. [
  901. ("box", ["0", "0", "1", "1", "1", "1", "1", "1"]),
  902. ("outline", ["0", "0", "1", "1", "1", "0", "0", "0", "1"]),
  903. ]
  904. )
  905. assert len(shape.verts) == 4 + 16
  906. parser = build_parser()
  907. assert MAIN_PARSER is parser
  908. assert "help" in COMMAND_PARSERS
  909. for name in ("cd", "pwd", "exit", "quit", "help"):
  910. assert name in BUILTIN_HELP
  911. assert set(SHELL_BUILTINS) <= set(BUILTIN_HELP)
  912. args = parser.parse_args(
  913. ["set-shape", "/x", "--box", "0", "0", "w", "10", "1", "0", "0", "1"]
  914. )
  915. assert args.prims == [("box", ["0", "0", "w", "10", "1", "0", "0", "1"])]
  916. args = parser.parse_args(
  917. [
  918. "set-shape", "/x",
  919. "--box", "0", "0", "1", "1", "1", "1", "1", "1",
  920. "--outline", "0", "0", "1", "1", "1", "0", "0", "0", "1",
  921. ]
  922. )
  923. assert [name for (name, _) in args.prims] == ["box", "outline"]
  924. for bad in (
  925. ["line", ["0", "0", "w", "0", "4", "1", "1", "1", "1"]],
  926. ["vgradient", ["0", "0", "1", "1", "1", "1", "1", "1", "0", "0", "0", "1", "0", "0.45"]],
  927. ["glow", ["1", "1", "1", "1", "-3", "1", "0", "0", "1"]],
  928. ["box", ["0", "0", "1", "1", "x", "0", "0", "1"]],
  929. ):
  930. try:
  931. build_shape([bad])
  932. raise AssertionError(f"build_shape({bad}) should have raised")
  933. except UsageError:
  934. pass
  935. try:
  936. build_shape([("vgradient", ["0", "0", "1", "1", "1", "1", "1", "1", "0", "0", "0", "1", "20000", "1"])])
  937. raise AssertionError("oversized shape should have raised")
  938. except UsageError as err:
  939. assert "exceeding" in str(err)
  940. assert coord_arg("w/2") == "w/2"
  941. assert coord_arg("3.5") == 3.5
  942. buf = bytearray()
  943. encode_call_arg(buf, CallArgType.UINT32, "0x10", "n")
  944. encode_call_arg(buf, CallArgType.STR, "hello", "s")
  945. encode_call_arg(buf, CallArgType.HASH, "00" * 32, "h")
  946. encode_call_arg(buf, CallArgType.BOOL, "true", "b")
  947. encode_call_arg(buf, CallArgType.FLOAT32, "1.5", "f")
  948. assert bytes(buf) == bytes.fromhex("10000000") + b"\x05hello" + bytes(32) + b"\x01" + bytes.fromhex(
  949. "0000c03f"
  950. )
  951. cur = serial.Cursor(bytes(buf))
  952. assert serial.read_u32(cur) == 16
  953. assert serial.decode_str(cur) == "hello"
  954. assert cur.read(32) == bytes(32)
  955. for bad in (
  956. (CallArgType.BOOL, "yes"),
  957. (CallArgType.HASH, "1234"),
  958. (CallArgType.UINT64, "-1"),
  959. ):
  960. try:
  961. encode_call_arg(bytearray(), bad[0], bad[1], "x")
  962. raise AssertionError(f"encode_call_arg{bad} should have raised")
  963. except UsageError:
  964. pass
  965. prop = Property(
  966. "alpha",
  967. PropertyType.FLOAT32,
  968. 0,
  969. "Alpha",
  970. "Layer transparency",
  971. False,
  972. False,
  973. 1,
  974. 0.0,
  975. 1.0,
  976. None,
  977. [],
  978. )
  979. lines = prop_meta_lines(prop)
  980. assert lines[0] == "alpha:"
  981. assert " type: float32" in lines
  982. assert " array_len: 1" in lines
  983. assert " range: [0.0, 1.0]" in lines
  984. prop = prop._replace(array_len=0, min_val=None, max_val=None)
  985. assert " array_len: unbounded" in prop_meta_lines(prop)
  986. assert not any(line.startswith(" range:") for line in prop_meta_lines(prop))
  987. def main(argv=None):
  988. parser = build_parser()
  989. args = parser.parse_args(argv)
  990. if args.selftest:
  991. run_selftests()
  992. print("cli self-test OK")
  993. return
  994. if args.command is None:
  995. shell_main(args)
  996. return
  997. api = Api(args.addr, args.port)
  998. run_command(api, args.func, args, [])
  999. if __name__ == "__main__":
  1000. main()