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.
 
 
 

670 lines
23 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 json
  19. import logging
  20. import traceback
  21. import collections.abc
  22. from base64 import b64encode
  23. from urllib.parse import urlencode
  24. LOGGER = logging.getLogger("cs")
  25. def _execfile(*args):
  26. fn = args[0]
  27. with open(fn) as f:
  28. c = compile(f.read(), fn, "exec")
  29. exec(c, *args[1:])
  30. def get_sandbox(context):
  31. base = os.path.join(
  32. context["cs_fs_root"], "__QTYPES__", "pythoncode", "__SANDBOXES__", "base.py"
  33. )
  34. _execfile(base, context)
  35. def js_files(info):
  36. if info["csq_interface"] == "ace":
  37. return ["BASE/scripts/ace/ace.js"]
  38. else:
  39. return []
  40. def html_format(string):
  41. s = (
  42. string.replace("&", "&amp;")
  43. .replace("<", "&lt;")
  44. .replace(">", "&gt;")
  45. .replace("\t", " ")
  46. .splitlines(False)
  47. )
  48. jx = 0
  49. for ix, line in enumerate(s):
  50. for jx, char in enumerate(line):
  51. if char != " ":
  52. break
  53. s[ix] = "&nbsp;" * jx + line[jx:]
  54. return "<br/>".join(s)
  55. defaults = {
  56. "csq_input_check": lambda x: None,
  57. "csq_code_pre": "",
  58. "csq_code_post": "",
  59. "csq_initial": "pass # Your code here",
  60. "csq_soln": 'print("Hello, World!")',
  61. "csq_tests": [],
  62. "csq_hint": lambda score, code, info: "", # post-test hint generator
  63. "csq_log_keypresses": True,
  64. "csq_variable_blacklist": [],
  65. "csq_import_blacklist": [],
  66. "csq_cpu_limit": 2,
  67. "csq_nproc_limit": 0,
  68. "csq_memory_limit": 32e6,
  69. "csq_interface": "ace",
  70. "csq_rows": 14,
  71. "csq_font_size": 16,
  72. "csq_always_show_tests": False,
  73. "csq_test_defaults": {},
  74. "csq_use_simple_checker": False,
  75. "csq_result_as_string": False,
  76. }
  77. class NoResult:
  78. pass
  79. def _default_check_function(sub, soln):
  80. sub = sub.get("result", NoResult)
  81. soln = soln.get("result", NoResult)
  82. return sub == soln and sub is not NoResult
  83. def _default_simple_check_function(sub, soln):
  84. return sub == soln
  85. def _default_string_check_function(sub, soln):
  86. return ast.literal_eval(sub) == ast.literal_eval(soln)
  87. test_defaults = {
  88. "npoints": 1,
  89. "code": "",
  90. "code_pre": "",
  91. "variable": "ans",
  92. "description": "",
  93. "include": False,
  94. "include_soln": False,
  95. "include_description": False,
  96. "grade": True,
  97. "show_description": True,
  98. "show_code": True,
  99. "show_stderr": True,
  100. "transform_output": lambda x: '<tt style="white-space: pre-wrap">%s</tt>'
  101. % (html_format(repr(x)),),
  102. "sandbox_options": {},
  103. "count_opcodes": False,
  104. "opcode_limit": None,
  105. "show_timing": False,
  106. "show_opcode_count": False,
  107. }
  108. def init(info):
  109. if info["csq_interface"] == "upload":
  110. info["csq_rerender"] = True
  111. def total_points(**info):
  112. if "csq_npoints" in info:
  113. return info["csq_npoints"]
  114. return total_test_points(**info)
  115. def total_test_points(**info):
  116. bak = info["csq_tests"]
  117. info["csq_tests"] = []
  118. for i in bak:
  119. info["csq_tests"].append(dict(test_defaults))
  120. info["csq_tests"][-1].update(info["csq_test_defaults"])
  121. info["csq_tests"][-1].update(i)
  122. return sum(i["npoints"] for i in info["csq_tests"])
  123. checktext = "Run Code"
  124. def handle_check(submissions, **info):
  125. try:
  126. code = info["csm_loader"].get_file_data(info, submissions, info["csq_name"])
  127. code = code.decode().replace("\r\n", "\n")
  128. except:
  129. return {
  130. "score": 0,
  131. "msg": '<div class="bs-callout bs-callout-danger"><span class="text-danger"><b>Error:</b> Unable to decode the specified file. Is this the file you intended to upload?</span></div>',
  132. }
  133. code = "\n\n".join(["import os\nos.unlink(__file__)", info["csq_code_pre"], code])
  134. get_sandbox(info)
  135. results = info["sandbox_run_code"](info, code, info.get("csq_sandbox_options", {}))
  136. err = info["fix_error_msg"](
  137. results["fname"], results["err"], info["csq_code_pre"].count("\n") + 2, code
  138. )
  139. complete = results.get("info", {}).get("complete", False)
  140. trunc = False
  141. outlines = results["out"].split("\n")
  142. if len(outlines) > 10:
  143. trunc = True
  144. outlines = outlines[:10]
  145. out = "\n".join(outlines)
  146. if len(out) >= 5000:
  147. trunc = True
  148. out = out[:5000]
  149. if trunc:
  150. out += "\n\n...OUTPUT TRUNCATED..."
  151. timeout = False
  152. if (not complete) and ("SIGTERM" in err):
  153. timeout = True
  154. err = (
  155. "Your code did not run to completion, "
  156. "but no error message was returned."
  157. "\nThis normally means that your code contains an "
  158. "infinite loop or otherwise took too long to run."
  159. )
  160. msg = '<div class="response">'
  161. if not timeout:
  162. msg += "<p><b>"
  163. if complete:
  164. msg += '<font color="darkgreen">' "Your code ran to completion." "</font>"
  165. else:
  166. msg += '<font color="red">' "Your code did not run to completion." "</font>"
  167. msg += "</b></p>"
  168. if out != "":
  169. msg += "\n<p><b>Your code produced the following output:</b>"
  170. msg += "<br/><pre>%s</pre></p>" % html_format(out)
  171. if err != "":
  172. if not timeout:
  173. msg += "\n<p><b>Your code produced an error:</b>"
  174. msg += "\n<br/><font color='red'><tt>%s</tt></font></p>" % html_format(err)
  175. msg += "</div>"
  176. return msg
  177. def handle_submission(submissions, **info):
  178. try:
  179. code = info["csm_loader"].get_file_data(info, submissions, info["csq_name"])
  180. code = code.decode().replace("\r\n", "\n")
  181. except Exception as err:
  182. LOGGER.warn(
  183. "[pythoncode] handle_submission error '%s', traceback=%s"
  184. % (err, traceback.format_exc())
  185. )
  186. return {
  187. "score": 0,
  188. "msg": '<div class="bs-callout bs-callout-danger"><span class="text-danger"><b>Error:</b> Unable to decode the specified file. Is this the file you intended to upload?</span></div>',
  189. }
  190. if info["csq_use_simple_checker"]:
  191. if info["csq_result_as_string"]:
  192. default_checker = _default_string_check_function
  193. else:
  194. default_checker = _default_simple_check_function
  195. else:
  196. default_checker = _default_check_function
  197. tests = [dict(test_defaults) for i in info["csq_tests"]]
  198. for (i, j) in zip(tests, info["csq_tests"]):
  199. i.update(info["csq_test_defaults"])
  200. i.update(j)
  201. show_tests = [i for i in tests if i["include"]]
  202. if len(show_tests) > 0:
  203. code = code.rsplit("### Test Cases")[0]
  204. inp = info["csq_input_check"](code)
  205. if inp is not None:
  206. msg = ('<div class="response">' '<font color="red">%s</font>' "</div>") % inp
  207. return {"score": 0, "msg": msg}
  208. bak = info["csq_tests"]
  209. info["csq_tests"] = []
  210. for i in bak:
  211. new = dict(test_defaults)
  212. i.update(info["csq_test_defaults"])
  213. new.update(i)
  214. if new["grade"]:
  215. info["csq_tests"].append(new)
  216. get_sandbox(info)
  217. score = 0
  218. if info["csq_always_show_tests"]:
  219. msg = ""
  220. else:
  221. msg = (
  222. """\n<br/><button onclick="if(this.nextSibling.style.display === 'none'){this.nextSibling.style.display = 'block';}else{this.nextSibling.style.display = 'none';}" class="btn btn-catsoop">"""
  223. "Show/Hide Detailed Results</button>"
  224. )
  225. msg += (
  226. '<div class="response" id="%s_result_showhide" %s>' "<h2>Test Results:</h2>"
  227. ) % (
  228. info["csq_name"],
  229. 'style="display:none"' if not info["csq_always_show_tests"] else "",
  230. )
  231. test_results = []
  232. count = 1
  233. for test in info["csq_tests"]:
  234. test["result_as_string"] = test.get(
  235. "result_as_string", info.get("csq_result_as_string", False)
  236. )
  237. out, err, log = info["sandbox_run_test"](info, code, test)
  238. if "cached_result" in test:
  239. log_s = repr(test["cached_result"])
  240. err_s = "Loaded cached result"
  241. else:
  242. out_s, err_s, log_s = info["sandbox_run_test"](info, info["csq_soln"], test)
  243. if count != 1:
  244. msg += "\n<p></p><hr/><p></p>"
  245. msg += "\n<center><h3>Test %02d</h3>" % count
  246. if test["show_description"]:
  247. msg += "\n<i>%s</i>" % test["description"]
  248. msg += "</center><p></p>"
  249. if test["show_code"]:
  250. html_code_pieces = [
  251. i for i in map(lambda x: html_format(test[x]), ["code_pre", "code"])
  252. ]
  253. html_code_pieces.insert(1, "#Your Code Here")
  254. html_code = "<br/>".join(i for i in html_code_pieces if i)
  255. msg += "\nThe test case was:<br/>\n<p><tt>%s</tt></p>" % html_code
  256. msg += "<p>&nbsp;</p>"
  257. result = {"details": log, "out": out, "err": err}
  258. result_s = {"details": log_s, "out": out_s, "err": err_s}
  259. if test["variable"] is not None:
  260. if "result" in log:
  261. result["result"] = log["result"]
  262. del log["result"]
  263. if "result" in log_s:
  264. result_s["result"] = log_s["result"]
  265. del log_s["result"]
  266. checker = test.get("check_function", default_checker)
  267. try:
  268. if info["csq_use_simple_checker"]:
  269. # legacy checker
  270. check_result = checker(result["result"], result_s["result"])
  271. else:
  272. check_result = checker(result, result_s)
  273. except:
  274. check_result = 0.0
  275. if isinstance(check_result, collections.abc.Mapping):
  276. percentage = check_result["score"]
  277. extra_msg = check_result["msg"]
  278. elif isinstance(check_result, collections.abc.Sequence):
  279. percentage, extra_msg = check_result
  280. else:
  281. percentage = check_result
  282. extra_msg = ""
  283. test_results.append(percentage)
  284. imfile = None
  285. if percentage == 1.0:
  286. imfile = info["cs_check_image"]
  287. elif percentage == 0.0:
  288. imfile = info["cs_cross_image"]
  289. score += percentage * test["npoints"]
  290. expected_variable = test["variable"] is not None
  291. solution_ran = result_s != {}
  292. submission_ran = result != {}
  293. show_code = test["show_code"]
  294. error_in_solution = result_s["err"] != ""
  295. error_in_submission = result["err"] != ""
  296. solution_produced_output = result_s["out"] != ""
  297. submission_produced_output = result["out"] != ""
  298. got_submission_result = "result" in result
  299. got_solution_result = "result" in result_s
  300. if imfile is None:
  301. image = ""
  302. else:
  303. image = "<img src='%s' />" % imfile
  304. # report timing and/or opcode count
  305. if test["show_timing"] == True:
  306. test["show_timing"] = "%.06f"
  307. do_timing = test["show_timing"] and "duration" in result_s["details"]
  308. do_opcount = test["show_opcode_count"] and "opcode_count" in result_s["details"]
  309. if do_timing or do_opcount:
  310. msg += "\n<p>"
  311. if do_timing:
  312. _timing = result_s["details"]["duration"]
  313. msg += (
  314. "\nOur solution ran for %s seconds." % test["show_timing"]
  315. ) % _timing
  316. if do_timing and do_opcount:
  317. msg += "\n<br/>"
  318. if do_opcount:
  319. _opcount = result_s["details"]["opcode_count"]
  320. msg += "\nOur solution executed %s Python opcodes.<br/>" % _opcount
  321. if do_timing or do_opcount:
  322. msg += "\n</p>"
  323. if expected_variable and show_code:
  324. if got_solution_result:
  325. msg += (
  326. "\n<p>Our solution produced the following value for <tt>%s</tt>:"
  327. ) % test["variable"]
  328. m = test["transform_output"](result_s["result"])
  329. msg += "\n<br/><font color='blue'>%s</font></p>" % m
  330. else:
  331. msg += (
  332. "\n<p>Our solution did not produce a value for <tt>%s</tt>.</p>"
  333. ) % test["variable"]
  334. if solution_produced_output and show_code:
  335. msg += "\n<p>Our code produced the following output:"
  336. msg += "<br/><pre>%s</pre></p>" % html_format(result_s["out"])
  337. if error_in_solution and test["show_stderr"]:
  338. msg += "\n<p><b>OOPS!</b> Our code produced an error:"
  339. e = html_format(result_s["err"])
  340. msg += "\n<br/><font color='red'><tt>%s</tt></font></p>" % e
  341. if show_code:
  342. msg += "<p>&nbsp;</p>"
  343. # report timing and/or opcode count
  344. do_timing = test["show_timing"] and "duration" in result["details"]
  345. do_opcount = test["show_opcode_count"] and "opcode_count" in result["details"]
  346. if do_timing or do_opcount:
  347. msg += "\n<p>"
  348. if do_timing:
  349. _timing = result["details"]["duration"]
  350. msg += (
  351. "\nYour solution ran for %s seconds." % test["show_timing"]
  352. ) % _timing
  353. if do_timing and do_opcount:
  354. msg += "\n<br/>"
  355. if do_opcount:
  356. _opcount = result["details"]["opcode_count"]
  357. msg += "\nYour code executed %d Python opcodes.<br/>" % _opcount
  358. if do_timing or do_opcount:
  359. msg += "\n</p>"
  360. if expected_variable and show_code:
  361. if got_submission_result:
  362. msg += (
  363. "\n<p>Your submission produced the following value for <tt>%s</tt>:"
  364. ) % test["variable"]
  365. m = test["transform_output"](result["result"])
  366. msg += "\n<br/><font color='blue'>%s</font>%s</p>" % (m, image)
  367. else:
  368. msg += (
  369. "\n<p>Your submission did not produce a value for <tt>%s</tt>.</p>"
  370. ) % test["variable"]
  371. else:
  372. msg += "\n<center>%s</center>" % (image)
  373. if submission_produced_output and show_code:
  374. msg += "\n<p>Your code produced the following output:"
  375. msg += "<br/><pre>%s</pre></p>" % html_format(result["out"])
  376. if error_in_submission and test["show_stderr"]:
  377. msg += "\n<p>Your submission produced an error:"
  378. e = html_format(result["err"])
  379. msg += "\n<br/><font color='red'><tt>%s</tt></font></p>" % e
  380. msg += "\n<br/><center>%s</center>" % (image)
  381. if extra_msg:
  382. msg += "\n<p>%s</p>" % extra_msg
  383. count += 1
  384. msg += "\n</div>"
  385. tp = total_test_points(**info)
  386. overall = float(score) / tp if tp != 0 else 0
  387. hint_func = info.get("csq_hint")
  388. if hint_func:
  389. try:
  390. hint = hint_func(test_results, code, info)
  391. msg += hint or ""
  392. LOGGER.debug("[pythoncode] hint=%s" % hint)
  393. except Exception as err:
  394. LOGGER.warn(
  395. "[pythoncode] hint function %s produced error=%s at %s"
  396. % (hint_func, err, traceback.format_exc())
  397. )
  398. msg = (
  399. ("\n<br/>&nbsp;Your score on your most recent " "submission was: %01.02f%%")
  400. % (overall * 100)
  401. ) + msg
  402. out = {"score": overall, "msg": msg}
  403. return out
  404. def make_initial_display(info):
  405. init = info["csq_initial"]
  406. tests = [dict(test_defaults) for i in info["csq_tests"]]
  407. for (i, j) in zip(tests, info["csq_tests"]):
  408. i.update(j)
  409. show_tests = [i for i in tests if i["include"]]
  410. l = len(show_tests) - 1
  411. if l > -1:
  412. init += "\n\n\n### Test Cases:\n"
  413. get_sandbox(info)
  414. for ix, i in enumerate(show_tests):
  415. i["result_as_string"] = i.get(
  416. "result_as_string", info.get("csq_result_as_string", False)
  417. )
  418. init += "\n# Test Case %d" % (ix + 1)
  419. if i["include_soln"]:
  420. if "cached_result" in i:
  421. log_s = i["cached_result"]
  422. else:
  423. out_s, err_s, log_s = info["sandbox_run_test"](
  424. info, info["csq_soln"], i
  425. )
  426. init += " (Should print: %s)" % log_s
  427. init += "\n"
  428. if i["include_description"]:
  429. init += "# %s\n" % i["description"]
  430. init += i["code"]
  431. if info.get("csq_python3", True):
  432. init += '\nprint("Test Case %d:", %s)' % (ix + 1, i["variable"])
  433. if i["include_soln"]:
  434. init += '\nprint("Expected:", %s)' % (log_s,)
  435. else:
  436. init += '\nprint "Test Case %d:", %s' % (ix + 1, i["variable"])
  437. if i["include_soln"]:
  438. init += '\nprint "Expected:", %s' % (log_s,)
  439. if ix != l:
  440. init += "\n"
  441. return init
  442. def render_html_textarea(last_log, **info):
  443. return tutor.question("bigbox")[0]["render_html"](last_log, **info)
  444. def render_html_upload(last_log, **info):
  445. name = info["csq_name"]
  446. init = last_log.get(name, (None, info["csq_initial"]))
  447. if isinstance(init, str):
  448. fname = ""
  449. else:
  450. fname, init = init
  451. params = {
  452. "name": name,
  453. "init": str(init),
  454. "safeinit": (init or "").replace("<", "&lt;"),
  455. "b64init": b64encode(make_initial_display(info).encode()).decode(),
  456. "dl": (' download="%s"' % info["csq_skeleton_name"])
  457. if "csq_skeleton_name" in info
  458. else "download",
  459. }
  460. out = ""
  461. if info.get("csq_show_skeleton", True):
  462. out += (
  463. """\n<a href="data:text/plain;base64,%(b64init)s" """
  464. """target="_blank"%(dl)s>Code Skeleton</a><br />"""
  465. ) % params
  466. if last_log.get(name, None) is not None:
  467. try:
  468. fname, loc = last_log[name]
  469. loc = os.path.basename(loc)
  470. _path = info["cs_path_info"]
  471. if info["csm_cslog"].ENCRYPT_KEY is not None:
  472. seed = (
  473. info["cs_path_info"][0]
  474. if info["cs_path_info"]
  475. else info["cs_path_info"]
  476. )
  477. _path = [
  478. info["csm_cslog"]._e(i, repr(seed)) for i in info["cs_path_info"]
  479. ]
  480. else:
  481. _path = info["cs_path_info"]
  482. qstring = urlencode({"path": json.dumps(_path), "fname": loc})
  483. out += "<br/>"
  484. safe_fname = (
  485. fname.replace("<", "")
  486. .replace(">", "")
  487. .replace('"', "")
  488. .replace("'", "")
  489. )
  490. out += (
  491. '<a href="%s/_util/get_upload?%s" '
  492. 'download="%s">Download Most '
  493. "Recent Submission</a><br/>"
  494. ) % (info["cs_url_root"], qstring, safe_fname)
  495. except:
  496. pass
  497. out += (
  498. """\n<input type="file" style="display: none" id=%(name)s name="%(name)s" />"""
  499. % params
  500. )
  501. out += (
  502. """\n<button class="btn btn-catsoop" id="%s_select_button">Select File</button>&nbsp;"""
  503. """\n<tt><span id="%s_selected_file">No file selected</span></tt>"""
  504. ) % (name, name)
  505. out += (
  506. """\n<script type="text/javascript">"""
  507. "\n// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3"
  508. """\ndocument.getElementById('%s').value = '';"""
  509. """\ndocument.getElementById('%s_select_button').addEventListener('click', function (){"""
  510. """\n document.getElementById("%s").click();"""
  511. """\n});"""
  512. """\ndocument.getElementById('%s').addEventListener('change', function (){"""
  513. """\n document.getElementById('%s_selected_file').innerText = document.getElementById('%s').value;"""
  514. """\n});"""
  515. "\n// @license-end"
  516. """\n</script>"""
  517. ) % (name, name, name, name, name, name)
  518. return out
  519. def render_html_ace(last_log, **info):
  520. name = info["csq_name"]
  521. init = last_log.get(name, None)
  522. if init is None:
  523. init = make_initial_display(info)
  524. init = str(init)
  525. fontsize = info["csq_font_size"]
  526. params = {
  527. "name": name,
  528. "init": init,
  529. "safeinit": init.replace("<", "&lt;"),
  530. "height": info["csq_rows"] * (fontsize + 4),
  531. "fontsize": fontsize,
  532. }
  533. return (
  534. """
  535. <div class="ace_editor_wrapper" id="container%(name)s">
  536. <div id="editor%(name)s" name="editor%(name)s" class="embedded_ace_code">%(safeinit)s</div></div>
  537. <input type="hidden" name="%(name)s" id="%(name)s" />
  538. <input type="hidden" name="%(name)s_log" id="%(name)s_log" />
  539. <script type="text/javascript">
  540. // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3
  541. var log%(name)s = new Array();
  542. var editor%(name)s = ace.edit("editor%(name)s");
  543. editor%(name)s.setTheme("ace/theme/textmate");
  544. editor%(name)s.getSession().setMode("ace/mode/python");
  545. editor%(name)s.setShowFoldWidgets(false);
  546. editor%(name)s.setValue(%(init)r)
  547. document.getElementById("%(name)s").value = editor%(name)s.getValue();
  548. editor%(name)s.on("change",function(e){
  549. editor%(name)s.getSession().setUseSoftTabs(true);
  550. document.getElementById("%(name)s").value = editor%(name)s.getValue();
  551. });
  552. editor%(name)s.clearSelection()
  553. editor%(name)s.getSession().setUseSoftTabs(true);
  554. editor%(name)s.on("paste",function(txt){editor%(name)s.getSession().setUseSoftTabs(false);});
  555. editor%(name)s.getSession().setTabSize(4);
  556. editor%(name)s.setFontSize("%(fontsize)spx");
  557. document.getElementById("container%(name)s").style.height = "%(height)spx";
  558. document.getElementById("editor%(name)s").style.height = "%(height)spx";
  559. editor%(name)s.resize(true);
  560. // @license-end
  561. </script>"""
  562. % params
  563. )
  564. RENDERERS = {
  565. "textarea": render_html_textarea,
  566. "ace": render_html_ace,
  567. "upload": render_html_upload,
  568. }
  569. def render_html(last_log, **info):
  570. renderer = info["csq_interface"]
  571. if renderer in RENDERERS:
  572. return RENDERERS[renderer](last_log or {}, **info)
  573. return (
  574. "<font color='red'>" "Invalid <tt>pythoncode</tt> interface: %s" "</font>"
  575. ) % renderer
  576. def answer_display(**info):
  577. out = (
  578. "Here is the solution we wrote:<br/>"
  579. '\n<pre><code id="%s_soln_highlight" class="lang-python">%s</code></pre>'
  580. '\n<script type="text/javascript">'
  581. "\n// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3"
  582. '\nhljs.highlightBlock(document.getElementById("%s_soln_highlight"));'
  583. "\n// @license-end"
  584. "\n</script>"
  585. ) % (info["csq_name"], info["csq_soln"].replace("<", "&lt;"), info["csq_name"])
  586. return out