cli.py 37 KB

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