cli.py 37 KB

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