CAT-SOOP is a flexible, programmable learning management system based on the Python programming language. https://catsoop.mit.edu
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

747 lines
21 KiB

  1. # This file is part of CAT-SOOP
  2. # Copyright (c) 2011-2020 by The CAT-SOOP Developers <catsoop-dev@mit.edu>
  3. #
  4. # This program is free software: you can redistribute it and/or modify it under
  5. # the terms of the GNU Affero General Public License as published by the Free
  6. # Software Foundation, either version 3 of the License, or (at your option) any
  7. # later version.
  8. #
  9. # This program is distributed in the hope that it will be useful, but WITHOUT
  10. # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
  11. # FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
  12. # details.
  13. #
  14. # You should have received a copy of the GNU Affero General Public License
  15. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  16. import os
  17. import ast
  18. import imp
  19. import math
  20. import cmath
  21. import random
  22. from collections import Sequence, defaultdict
  23. from ply import lex, yacc
  24. import mpmath
  25. numpy = None
  26. def check_numpy():
  27. global numpy
  28. if numpy is not None:
  29. return True
  30. try:
  31. import numpy
  32. default_funcs.update(
  33. {
  34. "transpose": (numpy.transpose, _render_transpose),
  35. "norm": (numpy.linalg.norm, _render_norm),
  36. "dot": (numpy.dot, _render_dot),
  37. }
  38. )
  39. return True
  40. except:
  41. return False
  42. def _render_transpose(x):
  43. out = r"\left({%s}\right)^T" % x[0]
  44. if len(x) != 1:
  45. return out, "transpose takes exactly one argument"
  46. return out
  47. def _render_norm(x):
  48. out = r"\left\|{%s}\right\|" % x[0]
  49. if len(x) != 1:
  50. return out, "norm takes exactly one argument"
  51. return out
  52. def _render_dot(x):
  53. if len(x) == 0:
  54. return "\cdot", "dot takes exactly two arguments"
  55. if len(x) != 2:
  56. return "\cdot ".join(x), "dot takes exactly two arguments"
  57. return r"\left(%s\right)\cdot \left(%s\right)" % (x[0], x[1])
  58. smallbox, _ = csm_tutor.question("smallbox")
  59. defaults = {
  60. "csq_error_on_unknown_variable": False,
  61. "csq_input_check": lambda raw, tree: None,
  62. "csq_render_result": True,
  63. "csq_syntax": "base",
  64. "csq_num_trials": 20,
  65. "csq_ratio_threshold": 1e-9,
  66. "csq_absolute_threshold": None,
  67. "csq_precision": 1000,
  68. "csq_soln": ["6", "sqrt(2)"],
  69. "csq_npoints": 1,
  70. "csq_msg_function": lambda sub: (""),
  71. "csq_show_check": False,
  72. "csq_variable_dimensions": {},
  73. "csq_names": {},
  74. }
  75. default_names = {
  76. "pi": [mpmath.mpc(math.pi)],
  77. "e": [mpmath.mpc(math.e)],
  78. "j": [mpmath.mpc(1j)],
  79. "i": [mpmath.mpc(1j)],
  80. }
  81. def _draw_sqrt(x):
  82. out = r"\sqrt{%s}" % (", ".join(x))
  83. if len(x) != 1:
  84. return out, "sqrt takes exactly one argument"
  85. return out
  86. def _draw_abs(x):
  87. out = r"\left|%s\right|" % x[0]
  88. if len(x) != 1:
  89. return out, "abs takes exactly one argument"
  90. return out
  91. def _draw_default(context, c):
  92. out = r"%s(%s)" % (c[1], ", ".join(c[2]))
  93. if len(c[2]) == 1 and _implicit_multiplication(context):
  94. return out, "Assuming implicit multiplication."
  95. else:
  96. return out, "Unknown function <tt>%s</tt>." % c[1]
  97. def _default_func(context, names, funcs, c):
  98. if _implicit_multiplication(context):
  99. if len(c[2]) == 1:
  100. # implicit multiplication
  101. val1 = eval_expr(context, names, funcs, c[1])
  102. val2 = eval_expr(context, names, funcs, c[2][0])
  103. return val1 * val2
  104. return random.random()
  105. def _draw_func(name):
  106. def _drawer(args):
  107. return r"%s\left(%s\right)" % (name, ", ".join(args))
  108. return _drawer
  109. def _draw_log(x):
  110. if len(x) == 0:
  111. base = ""
  112. elif len(x) == 1:
  113. base = "e"
  114. else:
  115. base = x[1]
  116. if any((i in x[0]) for i in " -+"):
  117. arg = r"\left(%s\right)" % x[0]
  118. else:
  119. arg = x[0]
  120. out = r"\log_{%s}{%s}" % (base, arg)
  121. if len(x) > 2:
  122. return out, "log takes at most 2 arguments"
  123. return out
  124. default_funcs = {
  125. "atan": (cmath.atan, _draw_func(r"\text{tan}^{-1}")),
  126. "asin": (cmath.asin, _draw_func(r"\text{sin}^{-1}")),
  127. "acos": (cmath.acos, _draw_func(r"\text{cos}^{-1}")),
  128. "tan": (cmath.tan, _draw_func(r"\text{tan}")),
  129. "sin": (cmath.sin, _draw_func(r"\text{sin}")),
  130. "cos": (cmath.cos, _draw_func(r"\text{cos}")),
  131. "log": (cmath.log, _draw_log),
  132. "sqrt": (cmath.sqrt, _draw_sqrt),
  133. "abs": (abs, _draw_abs),
  134. "_default": (_default_func, _draw_default),
  135. }
  136. def _contains(l, test):
  137. if not isinstance(l, list):
  138. return False
  139. elif l[0] == test:
  140. return True
  141. elif l[0] == "CALL":
  142. return _contains(l[1], test) or any(_contains(i, test) for i in l[2])
  143. else:
  144. return any(_contains(i, test) for i in l[1:])
  145. def eval_expr(context, names, funcs, n):
  146. return _eval_map[n[0]](context, names, funcs, n)
  147. def eval_name(context, names, funcs, n):
  148. return names[n[1]]
  149. def eval_number(context, names, funcs, n):
  150. if n[1].endswith("j"):
  151. return mpmath.mpc(imag=n[1][:-1])
  152. return mpmath.mpf(n[1])
  153. def check_shapes(x, y):
  154. xs = getattr(x, "shape", None)
  155. ys = getattr(y, "shape", None)
  156. if xs is not None and all(i == 1 for i in xs):
  157. xs = None
  158. if ys is not None and all(i == 1 for i in ys):
  159. ys = None
  160. if xs is not None and ys is not None and xs != ys:
  161. raise ValueError("array shapes do not match: %s and %s" % (xs, ys))
  162. def _to_numpy(x):
  163. if check_numpy() and isinstance(x, numpy.ndarray):
  164. return x.astype(numpy.complex_)
  165. return x
  166. def eval_binop(func, match_shapes=True):
  167. def _evaler(context, names, funcs, o):
  168. left = eval_expr(context, names, funcs, o[1])
  169. right = eval_expr(context, names, funcs, o[2])
  170. left = _to_numpy(left)
  171. right = _to_numpy(right)
  172. if match_shapes:
  173. check_shapes(left, right)
  174. return func(left, right)
  175. return _evaler
  176. def eval_uminus(context, names, funcs, o):
  177. return -eval_expr(context, names, funcs, o[1])
  178. def eval_uplus(context, names, funcs, o):
  179. return +eval_expr(context, names, funcs, o[1])
  180. def eval_call(context, names, funcs, c):
  181. if c[1][0] == "NAME" and c[1][1] in funcs:
  182. return funcs[c[1][1]][0](*(eval_expr(context, names, funcs, i) for i in c[2]))
  183. else:
  184. return funcs["_default"][0](context, names, funcs, c)
  185. def _div(x, y):
  186. return x / y
  187. _eval_map = {
  188. "NAME": eval_name,
  189. "NUMBER": eval_number,
  190. "+": eval_binop(lambda x, y: x + y),
  191. "-": eval_binop(lambda x, y: x - y),
  192. "*": eval_binop(lambda x, y: x * y),
  193. "/": eval_binop(_div),
  194. "@": eval_binop(lambda x, y: x @ y, match_shapes=False),
  195. "^": eval_binop(lambda x, y: x ** y, match_shapes=False),
  196. "u-": eval_uminus,
  197. "u+": eval_uplus,
  198. "CALL": eval_call,
  199. }
  200. def _run_one_test(context, sub, soln, funcs, ratio_threshold, absolute_threshold):
  201. _sub_names = _get_all_names(sub)
  202. _sol_names = _get_all_names(soln)
  203. maps_to_try = _get_all_mappings(context, _sub_names, _sol_names)
  204. for m in maps_to_try:
  205. try:
  206. subm = _to_numpy(eval_expr(context, m, funcs, sub))
  207. except:
  208. return False
  209. sol = _to_numpy(eval_expr(context, m, funcs, soln))
  210. mag = abs
  211. if len(context["csq_variable_dimensions"]) > 0 and check_numpy():
  212. def mag(x):
  213. try:
  214. return numpy.linalg.norm(x)
  215. except:
  216. return (x.real ** 2 + x.imag ** 2) ** 0.5
  217. try:
  218. r = ratio_threshold is not None
  219. a = absolute_threshold is not None
  220. if r and a:
  221. threshold = max(ratio_threshold * sol, absolute_threshold)
  222. elif r:
  223. threshold = ratio_threshold * sol
  224. elif a:
  225. threshold = absolute_threshold
  226. else:
  227. return False
  228. if mag(subm - sol) > mag(threshold):
  229. return False
  230. except:
  231. return False
  232. return True
  233. def _get_all_names(tree):
  234. if not isinstance(tree, list):
  235. return []
  236. elif tree[0] == "NAME":
  237. return [tree[1]]
  238. elif tree[0] == "CALL":
  239. return _get_all_names(tree[1]) + sum((_get_all_names(i) for i in tree[2]), [])
  240. else:
  241. return sum((_get_all_names(i) for i in tree[1:]), [])
  242. def _get_random_value():
  243. return mpmath.mpf(random.uniform(1, 30))
  244. def _fix_precision(names):
  245. return {
  246. k: mpmath.mpc(v) if isinstance(v, (float, int)) else v for k, v in names.items()
  247. }
  248. def _get_all_mappings(context, soln_names, sub_names):
  249. names = dict(context.get("csq_default_names", default_names))
  250. names.update(_fix_precision(context.get("csq_names", {})))
  251. dimensions = context["csq_variable_dimensions"]
  252. dim_vars = defaultdict(lambda: random.randint(2, 30))
  253. for n in soln_names:
  254. if n not in names:
  255. if n in dimensions:
  256. d = [i if isinstance(i, int) else dim_vars[i] for i in dimensions[n]]
  257. names[n] = numpy.random.rand(*d)
  258. else:
  259. names[n] = _get_random_value()
  260. for n in sub_names or []:
  261. if n not in names:
  262. if n in dimensions:
  263. d = [i if isinstance(i, int) else dim_vars[i] for i in dimensions[n]]
  264. names[n] = numpy.random.rand(*d)
  265. else:
  266. names[n] = _get_random_value()
  267. # map each name to a list of values to test
  268. for n in names:
  269. if callable(names[n]):
  270. names[n] = names[n]()
  271. if numpy is not None and isinstance(names[n], numpy.ndarray):
  272. names[n] = [names[n]]
  273. else:
  274. try:
  275. names[n] = [i for i in names[n]]
  276. except:
  277. names[n] = [names[n]]
  278. # get a list of dictionaries, each representing one mapping to test
  279. return _all_mappings_helper(names)
  280. def _all_mappings_helper(m):
  281. lm = len(m)
  282. if lm == 0:
  283. return {}
  284. n = list(m.keys())[0]
  285. test = [{n: i} for i in m[n]]
  286. if lm == 1:
  287. return test
  288. c = dict(m)
  289. del c[n]
  290. o = _all_mappings_helper(c)
  291. out = []
  292. for i in o:
  293. for j in test:
  294. d = dict(i)
  295. d.update(j)
  296. out.append(d)
  297. return out
  298. def total_points(**info):
  299. return info["csq_npoints"]
  300. def _get_syntax_module(context):
  301. syntax = context["csq_syntax"]
  302. fname = os.path.join(
  303. context["cs_fs_root"],
  304. "__QTYPES__",
  305. "expression",
  306. "__SYNTAX__",
  307. "%s.py" % syntax,
  308. )
  309. return imp.load_source(syntax, fname)
  310. def _implicit_multiplication(context):
  311. m = _get_syntax_module(context)
  312. if hasattr(m, "implicit_multiplication"):
  313. return m.implicit_multiplication
  314. else:
  315. return True
  316. def _get_parser(context):
  317. return _get_syntax_module(context).parser(lex, yacc)
  318. def handle_submission(submissions, **info):
  319. with mpmath.workdps(info["csq_precision"]):
  320. if len(info["csq_variable_dimensions"]) > 0:
  321. assert check_numpy()
  322. _sub = sub = submissions[info["csq_name"]]
  323. solns = info["csq_soln"]
  324. parser = _get_parser(info)
  325. funcs = dict(info.get("csq_default_funcs", default_funcs))
  326. funcs.update(info.get("csq_funcs", {}))
  327. try:
  328. sub = parser.parse(sub)
  329. except:
  330. return {
  331. "score": False,
  332. "msg": '<font color="red">Error: ' "could not parse input.</font>",
  333. }
  334. _m = None
  335. if sub is None:
  336. result = False
  337. else:
  338. in_check = info["csq_input_check"](_sub, sub)
  339. if in_check is not None:
  340. result = False
  341. _m = in_check
  342. else:
  343. if not isinstance(solns, list):
  344. solns = [solns]
  345. solns = [parser.parse(i) for i in solns]
  346. result = False
  347. for soln in solns:
  348. for attempt in range(info["csq_num_trials"]):
  349. _sub_names = _get_all_names(sub)
  350. _sol_names = _get_all_names(soln)
  351. if info["csq_error_on_unknown_variable"]:
  352. _unique_names = set(_sub_names).difference(_sol_names)
  353. if len(_unique_names) > 0:
  354. _s = "s" if len(_unique_names) > 1 else ""
  355. _v = ", ".join(
  356. tree2tex(info, funcs, ["NAME", i])[0]
  357. for i in _unique_names
  358. )
  359. _m = "Unknown variable%s: $%s$" % (_s, _v)
  360. result = _run_one_test(
  361. info,
  362. sub,
  363. soln,
  364. funcs,
  365. info["csq_ratio_threshold"],
  366. info["csq_absolute_threshold"],
  367. )
  368. if not result:
  369. break
  370. if result:
  371. break
  372. if info["csq_show_check"]:
  373. if result:
  374. msg = '<img src="%s" />' % info["cs_check_image"]
  375. else:
  376. msg = '<img src="%s" />' % info["cs_cross_image"]
  377. else:
  378. msg = ""
  379. n = info["csq_name"]
  380. msg += info["csq_msg_function"](submissions[info["csq_name"]])
  381. msg = info["csm_language"].source_transform_string(info, msg)
  382. msg = (
  383. '\n<script type="text/javascript">'
  384. "\n// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3"
  385. '\ndocument.getElementById("image%s").innerHTML = %r;'
  386. "\n// @license-end"
  387. "\n</script>"
  388. ) % (n, msg)
  389. if info["csq_render_result"]:
  390. msg += get_display(info, n, sub, False, _m or "")
  391. else:
  392. msg += _m or ""
  393. return {"score": result, "msg": msg}
  394. checktext = "Check Syntax"
  395. def handle_check(submission, **info):
  396. if len(info["csq_variable_dimensions"]) > 0:
  397. assert check_numpy()
  398. last = submission.get(info["csq_name"])
  399. return get_display(info, info["csq_name"], last)
  400. def render_html(last_log, **info):
  401. if len(info["csq_variable_dimensions"]) > 0:
  402. if not check_numpy():
  403. return '<font color="red">Error: the <tt>numpy</tt> module is required for nonscalar values'
  404. name = info["csq_name"]
  405. out = smallbox["render_html"](last_log, **info)
  406. out += "\n<span id='image%s'></span>" % (name,)
  407. return out
  408. def get_display(info, name, last, reparse=True, extra_msg=""):
  409. try:
  410. if reparse:
  411. parser = _get_parser(info)
  412. tree = parser.parse(last)
  413. else:
  414. tree = last
  415. funcs = dict(default_funcs)
  416. funcs.update(info.get("csq_funcs", {}))
  417. last = "<displaymath>%s</displaymath>" % tree2tex(info, funcs, tree)[0]
  418. except:
  419. last = '<font color="red">ERROR: Could not interpret your input</font>'
  420. last += csm_language.source_transform_string(info, extra_msg)
  421. out = '<div id="expr%s">Your entry was parsed as:<br/>%s</div>' % (name, last)
  422. out += (
  423. '<script type="text/javascript">'
  424. "\n// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3"
  425. '\ncatsoop.render_all_math(document.getElementById("expr%s"), true);'
  426. "\n// @license-end"
  427. "\n</script>" % name
  428. )
  429. return out
  430. def answer_display(**info):
  431. if len(info["csq_variable_dimensions"]) > 0:
  432. if not check_numpy():
  433. return '<font color="red">Error: the <tt>numpy</tt> module is required for nonscalar values'
  434. parser = _get_parser(info)
  435. funcs = dict(default_funcs)
  436. funcs.update(info.get("csq_funcs", {}))
  437. if isinstance(info["csq_soln"], str):
  438. a = tree2tex(info, funcs, parser.parse(info["csq_soln"]))[0]
  439. out = (
  440. "<p>Solution: <tt>%s</tt><br>"
  441. '<div id="%s_soln"><displaymath>%s</displaymath></div>'
  442. "<p>"
  443. ) % (info["csq_soln"], info["csq_name"], a)
  444. else:
  445. out = ('<p><div id="%s_soln">' "<b>Multiple Possible Solutions:</b>") % info[
  446. "csq_name"
  447. ]
  448. count = 1
  449. for i in info["csq_soln"]:
  450. out += '<hr width="80%" />'
  451. a = tree2tex(info, funcs, parser.parse(i))[0]
  452. out += (
  453. "<p>Solution %s: <tt>%s</tt><br>" "<displaymath>%s</displaymath></p>"
  454. ) % (count, i, a)
  455. count += 1
  456. out += "</div>"
  457. out += (
  458. '<script type="text/javascript">'
  459. "\n// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3"
  460. '\ncatsoop.render_all_math(document.getElementById("%s_soln"), true);'
  461. "\n// @license-end"
  462. "\n</script>" % info["csq_name"]
  463. )
  464. return out
  465. # LaTeX Conversion
  466. GREEK_LETTERS = [
  467. "alpha",
  468. "beta",
  469. "gamma",
  470. "delta",
  471. "epsilon",
  472. "zeta",
  473. "eta",
  474. "theta",
  475. "iota",
  476. "kappa",
  477. "lambda",
  478. "mu",
  479. "nu",
  480. "xi",
  481. "omicron",
  482. "pi",
  483. "rho",
  484. "sigma",
  485. "tau",
  486. "upsilon",
  487. "phi",
  488. "chi",
  489. "psi",
  490. "omega",
  491. ]
  492. GREEK_DICT = {}
  493. for i in GREEK_LETTERS:
  494. GREEK_DICT[i] = "\\%s" % i
  495. GREEK_DICT[i.upper()] = "\\%s" % i.title()
  496. def name2tex(context, funcs, n):
  497. prec = 5
  498. on = n = n[1]
  499. s = None
  500. if "_" in n:
  501. n, s = n.split("_")
  502. if n in GREEK_DICT:
  503. n = GREEK_DICT[n]
  504. if on in context["csq_variable_dimensions"]:
  505. n = r"\mathbf{%s}" % n
  506. if s is not None:
  507. if s in GREEK_DICT:
  508. s = GREEK_DICT[s]
  509. return ("%s_{%s}" % (n, s)), prec
  510. return n, prec
  511. def plus2tex(context, funcs, n):
  512. prec = 1
  513. left, lprec = tree2tex(context, funcs, n[1])
  514. right, rprec = tree2tex(context, funcs, n[2])
  515. return "%s + %s" % (left, right), prec
  516. def minus2tex(context, funcs, n):
  517. prec = 1
  518. left, lprec = tree2tex(context, funcs, n[1])
  519. right, rprec = tree2tex(context, funcs, n[2])
  520. if rprec <= prec:
  521. right = r"\left(%s\right)" % right
  522. return "%s - %s" % (left, right), prec
  523. def div2tex(context, funcs, n):
  524. prec = 2
  525. left, lprec = tree2tex(context, funcs, n[1])
  526. right, rprec = tree2tex(context, funcs, n[2])
  527. return (r"\frac{%s}{%s}" % (left, right)), prec
  528. def times2tex(context, funcs, n):
  529. prec = 2
  530. left, lprec = tree2tex(context, funcs, n[1])
  531. if lprec < prec:
  532. left = r"\left(%s\right)" % left
  533. right, rprec = tree2tex(context, funcs, n[2])
  534. if rprec < prec:
  535. right = r"\left(%s\right)" % right
  536. return r"%s \times %s" % (left, right), prec
  537. def matmul2tex(context, funcs, n):
  538. prec = 2
  539. left, lprec = tree2tex(context, funcs, n[1])
  540. if lprec < prec:
  541. left = r"\left(%s\right)" % left
  542. right, rprec = tree2tex(context, funcs, n[2])
  543. if rprec < prec:
  544. right = r"\left(%s\right)" % right
  545. return r"%s%s" % (left, right), prec
  546. def exp2tex(context, funcs, n):
  547. prec = 4
  548. left, lprec = tree2tex(context, funcs, n[1])
  549. if lprec <= prec:
  550. left = r"\left(%s\right)" % left
  551. right, rprec = tree2tex(context, funcs, n[2])
  552. return (r"%s ^ {%s}" % (left, right)), prec
  553. def uminus2tex(context, funcs, n):
  554. prec = 3
  555. operand, oprec = tree2tex(context, funcs, n[1])
  556. if oprec < prec:
  557. operand = r"\left(%s\right)" % operand
  558. return "-%s" % operand, prec
  559. def uplus2tex(context, funcs, n):
  560. prec = 3
  561. operand, oprec = tree2tex(context, funcs, n[1])
  562. if oprec < prec:
  563. operand = r"\left(%s\right)" % operand
  564. return "+%s" % operand, prec
  565. def call2tex(context, funcs, c):
  566. prec = 6
  567. if c[1][0] == "NAME" and c[1][1] in funcs:
  568. o = funcs[c[1][1]][1]([tree2tex(context, funcs, i)[0] for i in c[2]])
  569. else:
  570. new_c = list(c)
  571. new_c[1] = tree2tex(context, funcs, c[1])[0]
  572. new_c[2] = [tree2tex(context, funcs, i)[0] for i in c[2]]
  573. o = funcs["_default"][1](context, new_c)
  574. if isinstance(o, str):
  575. pass
  576. elif isinstance(o, Sequence) and len(o) > 1:
  577. o = r"{\color{red} \underbrace{%s}_{\text{%s}}}" % tuple(o[:2])
  578. return o, prec
  579. def _opt_clear_dec_part(x):
  580. n = x.split(".", 1)
  581. if len(n) == 1 or all(i == "0" for i in n[1]):
  582. return n[0]
  583. return ".".join(n)
  584. def number2tex(context, funcs, x):
  585. n = x[1].lower()
  586. if n.endswith("j"):
  587. imag = True
  588. n = n[:-1]
  589. else:
  590. imag = False
  591. if "e" in n:
  592. o = [r"%s\times 10^{%s}" % tuple(n.split("e")), 22]
  593. else:
  594. o = [n, 5]
  595. if imag:
  596. o[0] += "j"
  597. return tuple(o)
  598. _tree_map = {
  599. "NAME": name2tex,
  600. "NUMBER": number2tex,
  601. "+": plus2tex,
  602. "-": minus2tex,
  603. "*": times2tex,
  604. "/": div2tex,
  605. "^": exp2tex,
  606. "@": matmul2tex,
  607. "u-": uminus2tex,
  608. "u+": uplus2tex,
  609. "CALL": call2tex,
  610. }
  611. def tree2tex(context, funcs, tree):
  612. return _tree_map[tree[0]](context, funcs, tree)