potluck.render
Tools for generating reports and managing jinja2 templates.
render.py
1""" 2Tools for generating reports and managing jinja2 templates. 3 4render.py 5""" 6 7import os 8import sys 9import json 10import base64 11 12import markdown 13import jinja2 14import pygments 15import pygments.lexers 16import pygments.formatters 17import importlib_resources 18import bs4 19 20from . import logging 21from . import html_tools 22from . import time_utils 23 24 25#-------------------# 26# 2/3 compatibility # 27#-------------------# 28 29if sys.version_info[0] < 3: 30 ModuleNotFound_or_Import_Error = ImportError 31else: 32 ModuleNotFound_or_Import_Error = ModuleNotFoundError 33 34#---------# 35# Globals # 36#---------# 37 38J2_ENV = None 39""" 40The global Jinja2 Environment. 41""" 42 43 44RESOURCES_DIR = None 45""" 46The global resource directory. 47""" 48 49 50BLANK_RUBRIC_TEMPLATE = "rubric.j2" 51""" 52The template name for blank rubrics. 53""" 54 55EVALUATION_REPORT_TEMPLATE = "standalone_report.j2" 56""" 57The template name for evaluation reports. 58""" 59 60TESTS_EVALUATION_REPORT_TEMPLATE = "standalone_tests_report.j2" 61""" 62The template name for tests evaluation reports. 63""" 64 65STANDALONE_INSTRUCTIONS_TEMPLATE = "standalone_instructions.j2" 66""" 67The template name for standalone instructions. 68""" 69 70INSTRUCTIONS_TEMPLATE = "instructions.j2" 71""" 72The template name for instructions (to be included in an HTML wrapper). 73""" 74 75ERROR_MSG = "FAILED TO COMPLETE" 76""" 77The message that will be logged if an error occurs during evaluation. 78""" 79 80DONE_MSG = "completed successfully" 81""" 82The message that will be printed if evaluation completes successfully. 83""" 84 85 86#-----------------------------------# 87# Setup & template/resource loading # 88#-----------------------------------# 89 90def register_filter(name, filter_function): 91 """ 92 Registers a function for use in jinja2 templates as a filter. 93 """ 94 J2_ENV.filters[name] = filter_function 95 96 97def setup(templates_directory=None, resources_directory=None): 98 """ 99 Sets up for report generation. Template and resources directories 100 are optional, with resources loaded from this package by default. 101 """ 102 global J2_ENV, RESOURCES_DIR 103 104 # Remember resources directory 105 RESOURCES_DIR = resources_directory 106 107 # Decide on loader type 108 if templates_directory is None: 109 loader = jinja2.PackageLoader('potluck', 'templates') 110 else: 111 loader = jinja2.FileSystemLoader(templates_directory) 112 113 # Set up Jinja2 environment 114 J2_ENV = jinja2.Environment(loader=loader, autoescape=False) 115 116 # Register custom Jinja2 filters 117 register_filter("fileslug", html_tools.fileslug) 118 register_filter("at_time", time_utils.at_time) 119 120 121def get_css(): 122 """ 123 Returns the CSS code for reports as a string. 124 """ 125 if RESOURCES_DIR is None: 126 pkg_files = importlib_resources.files("potluck") / "resources" 127 return pkg_files.joinpath("potluck.css").read_text(encoding="utf-8") 128 else: 129 with open(os.path.join(RESOURCES_DIR, "potluck.css")) as fin: 130 return fin.read() 131 132 133def get_js(): 134 """ 135 Returns the Javascript code for reports as a string. 136 """ 137 if RESOURCES_DIR is None: 138 pkg_files = importlib_resources.files("potluck") / "resources" 139 return pkg_files.joinpath("potluck.js").read_text(encoding="utf-8") 140 else: 141 with open(os.path.join(RESOURCES_DIR, "potluck.js")) as fin: 142 return fin.read() 143 144 145def load_template(template_name): 146 """ 147 Loads a template by name (a relative path within the configured 148 templates directory). 149 """ 150 try: 151 return J2_ENV.get_template(template_name) 152 except jinja2.exceptions.TemplateSyntaxError as e: 153 logging.log( 154 ( 155 "Syntax error while building Jinja2 template:" 156 " '{template_name}'\n" 157 "Error occurred on line: {lineno}\n" 158 "Error was: {error}\n" 159 '-' * 80 160 ).format( 161 template_name=template_name, 162 lineno=e.lineno, 163 error=str(e) 164 ) 165 ) 166 raise 167 except Exception as e: 168 logging.log( 169 ( 170 "Unexpected non-syntax error while building Jinja2" 171 " template: '{template_name}'\n" 172 "Error was: {error}\n" 173 '-' * 80 174 ).format( 175 template_name=template_name, 176 error=str(e) 177 ) 178 ) 179 raise 180 181 182#-----------------------------# 183# Generic Markdown Conversion # 184#-----------------------------# 185 186def render_markdown(md_source): 187 """ 188 Renders markdown as HTML. Uses pymdownx.extra extensions if 189 available, otherwise normal markdown.extra. 190 """ 191 try: 192 import pymdownx # noqa F401 193 x = 'pymdownx.extra' 194 except ModuleNotFound_or_Import_Error: 195 x = 'extra' 196 197 try: 198 # Try to render with extensions 199 result = markdown.markdown(md_source, extensions=[x]) 200 return result 201 except Exception: 202 # Backup: try without extensions, since in some cases they don't 203 # work I guess :( 204 print("Error rendering markdown with extensions:") 205 print(html_tools.string_traceback()) 206 return markdown.markdown(md_source, extensions=[]) 207 208 209#-----------------------------# 210# Rendering rubrics & reports # 211#-----------------------------# 212 213def render_blank_rubric(blank_report, output_file): 214 """ 215 Renders a blank rubric report as HTML into the specified file. The 216 report should come from `potluck.rubrics.Rubric.create_blank_report`. 217 """ 218 # Load our report template 219 template = load_template(BLANK_RUBRIC_TEMPLATE) 220 221 # Augment the given blank report object 222 augment_report(blank_report, blank=True) 223 224 # Render the report to the output file 225 with open(output_file, 'w', encoding="utf-8") as fout: 226 fout.write(template.render(blank_report)) 227 228 229def render_report( 230 report, 231 instructions, 232 snippets, 233 output_file, 234 html_output_file 235): 236 """ 237 Renders an evaluation report object as JSON into the specified output 238 file, and as HTML into the specified html output file. The report 239 should come from `potluck.rubrics.Rubric.evaluate`. In addition to 240 the report, instructions markdown source and a list of HTML snippets 241 are necessary. 242 """ 243 # Load our report template 244 template = load_template(EVALUATION_REPORT_TEMPLATE) 245 246 # Add a timestamp to our report object 247 report["timestamp"] = time_utils.timestring() 248 249 # Render the basic report to the JSON output file 250 with open(output_file, 'w', encoding="utf-8") as fout: 251 json.dump(report, fout) 252 253 # Augment the rubric report object with pre-rendered HTML stuff 254 augment_report(report) 255 256 # Render the HTML report to the html output file 257 with open(html_output_file, 'w', encoding="utf-8") as fout: 258 fout.write( 259 template.render( 260 report=report, 261 instructions=instructions, 262 snippets=snippets 263 ) 264 ) 265 266 267def render_tests_report( 268 report, 269 output_file, 270 html_output_file 271): 272 """ 273 Renders a tests validation report object as JSON into the specified 274 output file, and as HTML into the specified html output file. The 275 report should come from `potluck.rubrics.Rubric.validate_tests`. 276 """ 277 # Load our report template 278 template = load_template(TESTS_EVALUATION_REPORT_TEMPLATE) 279 280 # Add a timestamp to our report object 281 report["timestamp"] = time_utils.timestring() 282 283 # Render the basic report to the JSON output file 284 with open(output_file, 'w', encoding="utf-8") as fout: 285 json.dump(report, fout) 286 287 # TODO: Do we need to render any parts of the report as HTML? 288 289 # Render the HTML report to the html output file 290 with open(html_output_file, 'w', encoding="utf-8") as fout: 291 fout.write(template.render(report=report)) 292 293 294#------------------------# 295# Rendering instructions # 296#------------------------# 297 298def render_instructions( 299 task_info, 300 instructions, 301 rubric_table, 302 snippets, 303 output_file, 304 standalone=True, 305 report_rubric_link_coverage=False 306): 307 """ 308 Renders instructions in an HTML template and combines them with a 309 rubric table and snippets into one document. 310 311 Requires a task_info dictionary, an HTML string for the instructions 312 themselves, a blank rubric table for creating the rubric, and a list 313 of snippet HTML code fragments for creating the examples section. 314 315 The output file must be specified, and if you want to produce an HTML 316 fragment (without CSS or JS) instead of a standalone HTML file you 317 can pass standalone=False. 318 319 If report_rubric_link_coverage is set to True, messages about an 320 rubric goals which aren't linked to anywhere in the instructions will 321 be printed. 322 """ 323 # Load our report template 324 if standalone: 325 template = load_template(STANDALONE_INSTRUCTIONS_TEMPLATE) 326 else: 327 template = load_template(INSTRUCTIONS_TEMPLATE) 328 329 # Get a timestamp 330 timestamp = time_utils.timestring() 331 332 # Augment our rubric table 333 augment_report(rubric_table, blank=True) 334 335 # Render the HTML instructions 336 rendered = template.render( 337 taskid=task_info["id"], 338 task_info=task_info, 339 timestamp=timestamp, 340 instructions=instructions, 341 rendered_rubric=rubric_table["rendered_table"], 342 snippets=snippets, 343 css=get_css(), 344 js=get_js() 345 ) 346 347 # Check link coverage 348 if report_rubric_link_coverage: 349 # Find all anchor links 350 linked_to = set() 351 soup = bs4.BeautifulSoup(rendered, "html.parser") 352 for link in soup.find_all('a'): 353 ref = link.get('href') 354 if isinstance(ref, str) and ref.startswith('#goal:'): 355 linked_to.add(ref[1:]) 356 357 # Find all goal IDs: 358 core_ids = set() 359 extra_ids = set() 360 other_ids = set() 361 soup = bs4.BeautifulSoup( 362 rubric_table["rendered_table"], 363 "html.parser" 364 ) 365 for node in soup.find_all( 366 lambda tag: ( 367 tag.has_attr('id') 368 and tag.get('id').startswith('goal:') 369 ) 370 ): 371 parent_classes = node.parent.get('class', []) 372 if 'tag-category-core' in parent_classes: 373 core_ids.add(node['id']) 374 elif 'tag-category-extra' in parent_classes: 375 extra_ids.add(node['id']) 376 else: 377 other_ids.add(node['id']) 378 379 core_unlinked = core_ids - linked_to 380 extra_unlinked = extra_ids - linked_to 381 other_unlinked = other_ids - linked_to 382 383 invalid_links = linked_to - (core_ids | extra_ids | other_ids) 384 385 total_unlinked = ( 386 len(core_unlinked) 387 + len(extra_unlinked) 388 + len(other_unlinked) 389 ) 390 391 # Report on goals not linked 392 if total_unlinked == 0: 393 print("All goals were linked to from the instructions.") 394 else: 395 print( 396 ( 397 "{} goal(s) were not linked to from the" 398 " instructions:" 399 ).format(total_unlinked) 400 ) 401 if len(core_unlinked) > 0: 402 print("Core goals that weren't linked:") 403 for goal in sorted(core_unlinked): 404 print(' ' + goal) 405 406 if len(extra_unlinked) > 0: 407 print("Extra goals that weren't linked:") 408 for goal in sorted(extra_unlinked): 409 print(' ' + goal) 410 411 if len(other_unlinked) > 0: 412 print("Other goals that weren't linked:") 413 for goal in sorted(other_unlinked): 414 print(' ' + goal) 415 416 # Report on invalid links last 417 if len(invalid_links) == 0: 418 print("All links to rubric goals were valid.") 419 else: 420 print( 421 ( 422 "{} link(s) to rubric goal(s) were invalid:" 423 ).format(len(invalid_links)) 424 ) 425 for inv in sorted(invalid_links): 426 print(' ' + inv) 427 428 # Save to the output file 429 with open(output_file, 'w', encoding="utf-8") as fout: 430 fout.write(rendered) 431 432 433#-------------------# 434# Rendering helpers # 435#-------------------# 436 437 438def augment_report(report, blank=False): 439 """ 440 Adds the following keys to the given rubric report: 441 442 - 'rendered_table' - HTML code representing the report's table 443 - 'rendered_contexts' - HTML code representing the report's contexts 444 list 445 - 'css' - CSS code for reports 446 - 'js' - JavaScript code for reports 447 448 For each file dictionary in the 'files' slot, it also adds: 449 450 - 'code_html' - a rendering of the file's submitted code as HTML 451 452 (but only if that file has a "code" slot). 453 """ 454 if "files" in report: 455 for file_entry in report["files"]: 456 if "original_code" in file_entry: 457 # We render the fixed code, but encode the original 458 file_entry["base64"] = base64.standard_b64encode( 459 file_entry["original_code"].encode("utf-8") 460 ).decode("utf-8") 461 file_entry["code_html"] = render_code( 462 report["taskid"], 463 file_entry["filename"], 464 file_entry["code"] 465 ) 466 elif "code" in file_entry: 467 # Render & encode the same code 468 file_entry["base64"] = base64.standard_b64encode( 469 file_entry["code"].encode("utf-8") 470 ).decode("utf-8") 471 file_entry["code_html"] = render_code( 472 report["taskid"], 473 file_entry["filename"], 474 file_entry["code"] 475 ) 476 elif "raw" in file_entry: 477 # Encode raw data but don't render anything 478 # We won't try to present the file data in any fancy way 479 file_entry["base64"] = base64.standard_b64encode( 480 file_entry["raw"].encode("utf-8") 481 ).decode("utf-8") 482 # File entires should all have either 'raw' or 'code' 483 # slots, but if one doesn't, we'll just leave it alone 484 485 if "table" in report: 486 report['rendered_table'] = render_rubric_table( 487 report["taskid"], 488 report["table"], 489 blank=blank 490 ) 491 else: 492 report["rendered_table"] = "No table found." 493 494 if "contexts" in report: 495 # Note: contexts have already been formatted with/without values 496 # before this step, so blank can be ignored here. 497 report['rendered_contexts'] = render_contexts_list(report["contexts"]) 498 else: 499 report['rendered_contexts'] = "No context information available." 500 501 # Add CSS & JS to report 502 report["css"] = get_css() 503 report["js"] = get_js() 504 505 506def render_code(taskid, filename, raw_code): 507 """ 508 Given a task ID, a filename, and a string containing submitted code, 509 creates an HTML string containing markup for displaying that code in 510 a report. Each line is given a line ID so that other parts of the 511 report can reference specific lines of the file. 512 """ 513 if raw_code == '': 514 return "<pre># NO CODE AVAILABLE</pre>" 515 516 markup = pygments.highlight( 517 raw_code, 518 pygments.lexers.PythonLexer(), 519 pygments.formatters.HtmlFormatter() 520 ) 521 522 # TODO: Re-instate some kind of annotation mechanism where code lines 523 # can have back-links to annotations that target them? 524 525 highlighted = markup\ 526 .replace('<div class="highlight"><pre>', '')\ 527 .replace('</pre></div>', '') 528 529 annotated = '\n'.join( 530 annotate_line(taskid, filename, n + 1, line) 531 for n, line in enumerate(highlighted.split('\n')) 532 ) 533 return ( 534 '<pre id="{}">'.format(html_tools.block_id(taskid, filename)) 535 + annotated 536 + '</pre>' 537 ) 538 539 540def annotate_line(taskid, filename, lineno, line_html): 541 """ 542 Takes a task ID, a file name, a line number, and a line of 543 already-highlighted Python code (an HTML string), and returns the 544 same line wrapped in a span which includes a line ID. The returned 545 span also includes a `linenum` span before the line text which 546 includes a line number. 547 548 These lines will have click handlers added by `resources/potluck.js` 549 so that clicking on them will highlight/unhighlight the line. 550 """ 551 lineid = html_tools.line_id(taskid, filename, lineno) 552 return ( 553 '<span id="{lineid}"' 554 ' class="codeline codeline_plain">' 555 '<span class="linenum">{lineno} </span>' 556 ' {line_html}' 557 '</span>' 558 ).format( 559 lineid=lineid, 560 lineno=lineno, 561 line_html=line_html 562 ) 563 564 565def render_rubric_table(taskid, table, blank=False): 566 """ 567 Renders a rubric table for a specific task (from the 'table' slot of 568 an evaluation; see `potluck.rubrics.Rubric.evaluate`) as an HTML 569 string. Returns an HTML string that builds a div with class 570 "rubric_table". 571 572 `blank` can be set to True (instead of the default False) to make 573 rubric details more visible in blank rubrics where explanations 574 haven't been generated yet, but it means that explanations will not 575 be rendered in the output (warnings and notes still will be). Setting 576 blank to True will also cause table rows to display the rubric 577 versions of row topics and details even when feedback versions are 578 available. 579 """ 580 return '<div class="rubric_table">\n{}\n</div>'.format( 581 '\n'.join( 582 render_rubric_row(taskid, row, blank) 583 for row in table 584 ) 585 ) 586 587 588def render_rubric_row(taskid, row, blank=False): 589 """ 590 Renders a single row of a rubric table for a given task into an HTML 591 div (see `render_rubric_table`). The resulting div has class 592 "rubric_row", plus an additional class depending on the status of 593 the given row with the string 'status_' prefixed and any spaces 594 replaced by underscores. There are also 'tag_' classes added for 595 each tag that the row has. 596 597 If blank is given as True, the explanation for this row will be 598 discarded and replaced by the rubric details, which will then be 599 omitted from the topic area. By default (when blank is False), the 600 rubric details are available on expansion within the topic div and 601 the explanation is put in the assessment area. Also, when blank is 602 True, the rubric versions of the topic and details for this row are 603 displayed instead of the feedback versions. 604 """ 605 contents = '' 606 607 description = row.get( 608 "description", 609 ("Unknown", "No description available.") 610 ) 611 if blank: 612 topic, details = description[:2] 613 else: 614 topic = description[::2][-1] 615 details = description[1::2][-1] 616 617 topic_class = "topic" 618 details_node = ' ' + html_tools.create_help_button(details) 619 if blank: 620 topic_class += " no_details" 621 details_node = '' 622 623 rowid = row.get("id", "") 624 contents += ( 625 '<div {}class="{}" tabindex="0">\n{}\n{}\n{}\n{}\n</div>' 626 ).format( 627 'id="{}" '.format(rowid) if rowid else '', 628 topic_class, 629 html_tools.build_status_indicator(row["status"]), 630 "<span class='sr-only'>{}</span>".format(row["status"]), 631 "<span class='goal-topic'>{}</span>".format(topic), 632 details_node 633 ) 634 635 expl_text = row.get("explanation", '') 636 if blank: 637 expl_text = details 638 639 explanation = '<div class="explanation">{}</div>'.format(expl_text) 640 # Add notes to explanation if they exist: 641 if row.get("notes"): 642 notes = '<details class="notes">\n<summary>Notes</summary>\n<ul>' 643 for note in row.get("notes"): 644 notes += '\n<li class="note">{}</li>'.format(note) 645 notes += '</ul>\n</details>' 646 explanation += '\n' + notes 647 648 # Add warnings to explanation if they exist: 649 if row.get("warnings"): 650 warnings = ( 651 '<details class="warnings">\n<summary>Warnings</summary>\n<ul>' 652 ) 653 for warning in row.get("warnings"): 654 warnings += '\n<li class="warning">{}</li>'.format(warning) 655 warnings += '</ul>\n</details>' 656 explanation += '\n' + warnings 657 658 # Add assessment (explanation + notes + warnings) to result: 659 contents += '<div class="assessment">{}</div>'.format(explanation) 660 661 # Add subtable if there is one: 662 subtable = '' 663 if row.get("subtable"): 664 subtable = render_rubric_table(taskid, row.get("subtable"), blank) 665 666 contents += subtable 667 668 # CSS classes for the rubric row 669 classes = [ 670 "rubric_row", 671 html_tools.status_css_class(row["status"]) 672 ] + [ 673 "tag-{k}-{v}".format(k=k, v=v) 674 for (k, v) in row.get("tags", {}).items() 675 ] 676 # CSS class 'goal_category' for non-ID's items 677 if rowid == '': 678 classes.append("goal_category") 679 return '<div class="{}">{}</div>'.format( 680 ' '.join(classes), 681 contents 682 ) 683 684 685def render_contexts_list(contexts_list): 686 """ 687 Accepts a contexts list in the format returned by 688 `potluck.contexts.list_and_render_contexts`. Each context gets its 689 own details element that collapses to a single row, but expands to 690 show the context value(s). 691 692 This function returns a single HTML string containing code for all of 693 the contexts, wrapped in a div with class 'context_info'. 694 """ 695 result = "<div class='context_info'>" 696 for index, ctx in enumerate(contexts_list): 697 tl = min(ctx["level"], 8) # truncated level value 698 # TODO: Some way of drawing connectors that's not horribly 699 # fragile... 700 if ctx["value"] != "": 701 result += """ 702<details id='ctx_{index}' class='context_details level_{tl}'> 703 <summary>{desc}</summary> 704 {val} 705</details> 706""".format( 707 index=index, 708 tl=tl, 709 desc=ctx["description"], 710 val=ctx["value"], 711) 712 else: 713 result += """ 714<div id='ctx_{index}' class='context_details level_{tl}'> 715 {desc} 716</div> 717""".format( 718 index=index, 719 tl=tl, 720 desc=ctx["description"] 721) 722 723 result += "</div>" 724 return result
The global Jinja2 Environment.
The global resource directory.
The template name for blank rubrics.
The template name for evaluation reports.
The template name for tests evaluation reports.
The template name for standalone instructions.
The template name for instructions (to be included in an HTML wrapper).
The message that will be logged if an error occurs during evaluation.
The message that will be printed if evaluation completes successfully.
91def register_filter(name, filter_function): 92 """ 93 Registers a function for use in jinja2 templates as a filter. 94 """ 95 J2_ENV.filters[name] = filter_function
Registers a function for use in jinja2 templates as a filter.
98def setup(templates_directory=None, resources_directory=None): 99 """ 100 Sets up for report generation. Template and resources directories 101 are optional, with resources loaded from this package by default. 102 """ 103 global J2_ENV, RESOURCES_DIR 104 105 # Remember resources directory 106 RESOURCES_DIR = resources_directory 107 108 # Decide on loader type 109 if templates_directory is None: 110 loader = jinja2.PackageLoader('potluck', 'templates') 111 else: 112 loader = jinja2.FileSystemLoader(templates_directory) 113 114 # Set up Jinja2 environment 115 J2_ENV = jinja2.Environment(loader=loader, autoescape=False) 116 117 # Register custom Jinja2 filters 118 register_filter("fileslug", html_tools.fileslug) 119 register_filter("at_time", time_utils.at_time)
Sets up for report generation. Template and resources directories are optional, with resources loaded from this package by default.
122def get_css(): 123 """ 124 Returns the CSS code for reports as a string. 125 """ 126 if RESOURCES_DIR is None: 127 pkg_files = importlib_resources.files("potluck") / "resources" 128 return pkg_files.joinpath("potluck.css").read_text(encoding="utf-8") 129 else: 130 with open(os.path.join(RESOURCES_DIR, "potluck.css")) as fin: 131 return fin.read()
Returns the CSS code for reports as a string.
134def get_js(): 135 """ 136 Returns the Javascript code for reports as a string. 137 """ 138 if RESOURCES_DIR is None: 139 pkg_files = importlib_resources.files("potluck") / "resources" 140 return pkg_files.joinpath("potluck.js").read_text(encoding="utf-8") 141 else: 142 with open(os.path.join(RESOURCES_DIR, "potluck.js")) as fin: 143 return fin.read()
Returns the Javascript code for reports as a string.
146def load_template(template_name): 147 """ 148 Loads a template by name (a relative path within the configured 149 templates directory). 150 """ 151 try: 152 return J2_ENV.get_template(template_name) 153 except jinja2.exceptions.TemplateSyntaxError as e: 154 logging.log( 155 ( 156 "Syntax error while building Jinja2 template:" 157 " '{template_name}'\n" 158 "Error occurred on line: {lineno}\n" 159 "Error was: {error}\n" 160 '-' * 80 161 ).format( 162 template_name=template_name, 163 lineno=e.lineno, 164 error=str(e) 165 ) 166 ) 167 raise 168 except Exception as e: 169 logging.log( 170 ( 171 "Unexpected non-syntax error while building Jinja2" 172 " template: '{template_name}'\n" 173 "Error was: {error}\n" 174 '-' * 80 175 ).format( 176 template_name=template_name, 177 error=str(e) 178 ) 179 ) 180 raise
Loads a template by name (a relative path within the configured templates directory).
187def render_markdown(md_source): 188 """ 189 Renders markdown as HTML. Uses pymdownx.extra extensions if 190 available, otherwise normal markdown.extra. 191 """ 192 try: 193 import pymdownx # noqa F401 194 x = 'pymdownx.extra' 195 except ModuleNotFound_or_Import_Error: 196 x = 'extra' 197 198 try: 199 # Try to render with extensions 200 result = markdown.markdown(md_source, extensions=[x]) 201 return result 202 except Exception: 203 # Backup: try without extensions, since in some cases they don't 204 # work I guess :( 205 print("Error rendering markdown with extensions:") 206 print(html_tools.string_traceback()) 207 return markdown.markdown(md_source, extensions=[])
Renders markdown as HTML. Uses pymdownx.extra extensions if available, otherwise normal markdown.extra.
214def render_blank_rubric(blank_report, output_file): 215 """ 216 Renders a blank rubric report as HTML into the specified file. The 217 report should come from `potluck.rubrics.Rubric.create_blank_report`. 218 """ 219 # Load our report template 220 template = load_template(BLANK_RUBRIC_TEMPLATE) 221 222 # Augment the given blank report object 223 augment_report(blank_report, blank=True) 224 225 # Render the report to the output file 226 with open(output_file, 'w', encoding="utf-8") as fout: 227 fout.write(template.render(blank_report))
Renders a blank rubric report as HTML into the specified file. The
report should come from potluck.rubrics.Rubric.create_blank_report
.
230def render_report( 231 report, 232 instructions, 233 snippets, 234 output_file, 235 html_output_file 236): 237 """ 238 Renders an evaluation report object as JSON into the specified output 239 file, and as HTML into the specified html output file. The report 240 should come from `potluck.rubrics.Rubric.evaluate`. In addition to 241 the report, instructions markdown source and a list of HTML snippets 242 are necessary. 243 """ 244 # Load our report template 245 template = load_template(EVALUATION_REPORT_TEMPLATE) 246 247 # Add a timestamp to our report object 248 report["timestamp"] = time_utils.timestring() 249 250 # Render the basic report to the JSON output file 251 with open(output_file, 'w', encoding="utf-8") as fout: 252 json.dump(report, fout) 253 254 # Augment the rubric report object with pre-rendered HTML stuff 255 augment_report(report) 256 257 # Render the HTML report to the html output file 258 with open(html_output_file, 'w', encoding="utf-8") as fout: 259 fout.write( 260 template.render( 261 report=report, 262 instructions=instructions, 263 snippets=snippets 264 ) 265 )
Renders an evaluation report object as JSON into the specified output
file, and as HTML into the specified html output file. The report
should come from potluck.rubrics.Rubric.evaluate
. In addition to
the report, instructions markdown source and a list of HTML snippets
are necessary.
268def render_tests_report( 269 report, 270 output_file, 271 html_output_file 272): 273 """ 274 Renders a tests validation report object as JSON into the specified 275 output file, and as HTML into the specified html output file. The 276 report should come from `potluck.rubrics.Rubric.validate_tests`. 277 """ 278 # Load our report template 279 template = load_template(TESTS_EVALUATION_REPORT_TEMPLATE) 280 281 # Add a timestamp to our report object 282 report["timestamp"] = time_utils.timestring() 283 284 # Render the basic report to the JSON output file 285 with open(output_file, 'w', encoding="utf-8") as fout: 286 json.dump(report, fout) 287 288 # TODO: Do we need to render any parts of the report as HTML? 289 290 # Render the HTML report to the html output file 291 with open(html_output_file, 'w', encoding="utf-8") as fout: 292 fout.write(template.render(report=report))
Renders a tests validation report object as JSON into the specified
output file, and as HTML into the specified html output file. The
report should come from potluck.rubrics.Rubric.validate_tests
.
299def render_instructions( 300 task_info, 301 instructions, 302 rubric_table, 303 snippets, 304 output_file, 305 standalone=True, 306 report_rubric_link_coverage=False 307): 308 """ 309 Renders instructions in an HTML template and combines them with a 310 rubric table and snippets into one document. 311 312 Requires a task_info dictionary, an HTML string for the instructions 313 themselves, a blank rubric table for creating the rubric, and a list 314 of snippet HTML code fragments for creating the examples section. 315 316 The output file must be specified, and if you want to produce an HTML 317 fragment (without CSS or JS) instead of a standalone HTML file you 318 can pass standalone=False. 319 320 If report_rubric_link_coverage is set to True, messages about an 321 rubric goals which aren't linked to anywhere in the instructions will 322 be printed. 323 """ 324 # Load our report template 325 if standalone: 326 template = load_template(STANDALONE_INSTRUCTIONS_TEMPLATE) 327 else: 328 template = load_template(INSTRUCTIONS_TEMPLATE) 329 330 # Get a timestamp 331 timestamp = time_utils.timestring() 332 333 # Augment our rubric table 334 augment_report(rubric_table, blank=True) 335 336 # Render the HTML instructions 337 rendered = template.render( 338 taskid=task_info["id"], 339 task_info=task_info, 340 timestamp=timestamp, 341 instructions=instructions, 342 rendered_rubric=rubric_table["rendered_table"], 343 snippets=snippets, 344 css=get_css(), 345 js=get_js() 346 ) 347 348 # Check link coverage 349 if report_rubric_link_coverage: 350 # Find all anchor links 351 linked_to = set() 352 soup = bs4.BeautifulSoup(rendered, "html.parser") 353 for link in soup.find_all('a'): 354 ref = link.get('href') 355 if isinstance(ref, str) and ref.startswith('#goal:'): 356 linked_to.add(ref[1:]) 357 358 # Find all goal IDs: 359 core_ids = set() 360 extra_ids = set() 361 other_ids = set() 362 soup = bs4.BeautifulSoup( 363 rubric_table["rendered_table"], 364 "html.parser" 365 ) 366 for node in soup.find_all( 367 lambda tag: ( 368 tag.has_attr('id') 369 and tag.get('id').startswith('goal:') 370 ) 371 ): 372 parent_classes = node.parent.get('class', []) 373 if 'tag-category-core' in parent_classes: 374 core_ids.add(node['id']) 375 elif 'tag-category-extra' in parent_classes: 376 extra_ids.add(node['id']) 377 else: 378 other_ids.add(node['id']) 379 380 core_unlinked = core_ids - linked_to 381 extra_unlinked = extra_ids - linked_to 382 other_unlinked = other_ids - linked_to 383 384 invalid_links = linked_to - (core_ids | extra_ids | other_ids) 385 386 total_unlinked = ( 387 len(core_unlinked) 388 + len(extra_unlinked) 389 + len(other_unlinked) 390 ) 391 392 # Report on goals not linked 393 if total_unlinked == 0: 394 print("All goals were linked to from the instructions.") 395 else: 396 print( 397 ( 398 "{} goal(s) were not linked to from the" 399 " instructions:" 400 ).format(total_unlinked) 401 ) 402 if len(core_unlinked) > 0: 403 print("Core goals that weren't linked:") 404 for goal in sorted(core_unlinked): 405 print(' ' + goal) 406 407 if len(extra_unlinked) > 0: 408 print("Extra goals that weren't linked:") 409 for goal in sorted(extra_unlinked): 410 print(' ' + goal) 411 412 if len(other_unlinked) > 0: 413 print("Other goals that weren't linked:") 414 for goal in sorted(other_unlinked): 415 print(' ' + goal) 416 417 # Report on invalid links last 418 if len(invalid_links) == 0: 419 print("All links to rubric goals were valid.") 420 else: 421 print( 422 ( 423 "{} link(s) to rubric goal(s) were invalid:" 424 ).format(len(invalid_links)) 425 ) 426 for inv in sorted(invalid_links): 427 print(' ' + inv) 428 429 # Save to the output file 430 with open(output_file, 'w', encoding="utf-8") as fout: 431 fout.write(rendered)
Renders instructions in an HTML template and combines them with a rubric table and snippets into one document.
Requires a task_info dictionary, an HTML string for the instructions themselves, a blank rubric table for creating the rubric, and a list of snippet HTML code fragments for creating the examples section.
The output file must be specified, and if you want to produce an HTML fragment (without CSS or JS) instead of a standalone HTML file you can pass standalone=False.
If report_rubric_link_coverage is set to True, messages about an rubric goals which aren't linked to anywhere in the instructions will be printed.
439def augment_report(report, blank=False): 440 """ 441 Adds the following keys to the given rubric report: 442 443 - 'rendered_table' - HTML code representing the report's table 444 - 'rendered_contexts' - HTML code representing the report's contexts 445 list 446 - 'css' - CSS code for reports 447 - 'js' - JavaScript code for reports 448 449 For each file dictionary in the 'files' slot, it also adds: 450 451 - 'code_html' - a rendering of the file's submitted code as HTML 452 453 (but only if that file has a "code" slot). 454 """ 455 if "files" in report: 456 for file_entry in report["files"]: 457 if "original_code" in file_entry: 458 # We render the fixed code, but encode the original 459 file_entry["base64"] = base64.standard_b64encode( 460 file_entry["original_code"].encode("utf-8") 461 ).decode("utf-8") 462 file_entry["code_html"] = render_code( 463 report["taskid"], 464 file_entry["filename"], 465 file_entry["code"] 466 ) 467 elif "code" in file_entry: 468 # Render & encode the same code 469 file_entry["base64"] = base64.standard_b64encode( 470 file_entry["code"].encode("utf-8") 471 ).decode("utf-8") 472 file_entry["code_html"] = render_code( 473 report["taskid"], 474 file_entry["filename"], 475 file_entry["code"] 476 ) 477 elif "raw" in file_entry: 478 # Encode raw data but don't render anything 479 # We won't try to present the file data in any fancy way 480 file_entry["base64"] = base64.standard_b64encode( 481 file_entry["raw"].encode("utf-8") 482 ).decode("utf-8") 483 # File entires should all have either 'raw' or 'code' 484 # slots, but if one doesn't, we'll just leave it alone 485 486 if "table" in report: 487 report['rendered_table'] = render_rubric_table( 488 report["taskid"], 489 report["table"], 490 blank=blank 491 ) 492 else: 493 report["rendered_table"] = "No table found." 494 495 if "contexts" in report: 496 # Note: contexts have already been formatted with/without values 497 # before this step, so blank can be ignored here. 498 report['rendered_contexts'] = render_contexts_list(report["contexts"]) 499 else: 500 report['rendered_contexts'] = "No context information available." 501 502 # Add CSS & JS to report 503 report["css"] = get_css() 504 report["js"] = get_js()
Adds the following keys to the given rubric report:
- 'rendered_table' - HTML code representing the report's table
- 'rendered_contexts' - HTML code representing the report's contexts list
- 'css' - CSS code for reports
- 'js' - JavaScript code for reports
For each file dictionary in the 'files' slot, it also adds:
- 'code_html' - a rendering of the file's submitted code as HTML
(but only if that file has a "code" slot).
507def render_code(taskid, filename, raw_code): 508 """ 509 Given a task ID, a filename, and a string containing submitted code, 510 creates an HTML string containing markup for displaying that code in 511 a report. Each line is given a line ID so that other parts of the 512 report can reference specific lines of the file. 513 """ 514 if raw_code == '': 515 return "<pre># NO CODE AVAILABLE</pre>" 516 517 markup = pygments.highlight( 518 raw_code, 519 pygments.lexers.PythonLexer(), 520 pygments.formatters.HtmlFormatter() 521 ) 522 523 # TODO: Re-instate some kind of annotation mechanism where code lines 524 # can have back-links to annotations that target them? 525 526 highlighted = markup\ 527 .replace('<div class="highlight"><pre>', '')\ 528 .replace('</pre></div>', '') 529 530 annotated = '\n'.join( 531 annotate_line(taskid, filename, n + 1, line) 532 for n, line in enumerate(highlighted.split('\n')) 533 ) 534 return ( 535 '<pre id="{}">'.format(html_tools.block_id(taskid, filename)) 536 + annotated 537 + '</pre>' 538 )
Given a task ID, a filename, and a string containing submitted code, creates an HTML string containing markup for displaying that code in a report. Each line is given a line ID so that other parts of the report can reference specific lines of the file.
541def annotate_line(taskid, filename, lineno, line_html): 542 """ 543 Takes a task ID, a file name, a line number, and a line of 544 already-highlighted Python code (an HTML string), and returns the 545 same line wrapped in a span which includes a line ID. The returned 546 span also includes a `linenum` span before the line text which 547 includes a line number. 548 549 These lines will have click handlers added by `resources/potluck.js` 550 so that clicking on them will highlight/unhighlight the line. 551 """ 552 lineid = html_tools.line_id(taskid, filename, lineno) 553 return ( 554 '<span id="{lineid}"' 555 ' class="codeline codeline_plain">' 556 '<span class="linenum">{lineno} </span>' 557 ' {line_html}' 558 '</span>' 559 ).format( 560 lineid=lineid, 561 lineno=lineno, 562 line_html=line_html 563 )
Takes a task ID, a file name, a line number, and a line of
already-highlighted Python code (an HTML string), and returns the
same line wrapped in a span which includes a line ID. The returned
span also includes a linenum
span before the line text which
includes a line number.
These lines will have click handlers added by resources/potluck.js
so that clicking on them will highlight/unhighlight the line.
566def render_rubric_table(taskid, table, blank=False): 567 """ 568 Renders a rubric table for a specific task (from the 'table' slot of 569 an evaluation; see `potluck.rubrics.Rubric.evaluate`) as an HTML 570 string. Returns an HTML string that builds a div with class 571 "rubric_table". 572 573 `blank` can be set to True (instead of the default False) to make 574 rubric details more visible in blank rubrics where explanations 575 haven't been generated yet, but it means that explanations will not 576 be rendered in the output (warnings and notes still will be). Setting 577 blank to True will also cause table rows to display the rubric 578 versions of row topics and details even when feedback versions are 579 available. 580 """ 581 return '<div class="rubric_table">\n{}\n</div>'.format( 582 '\n'.join( 583 render_rubric_row(taskid, row, blank) 584 for row in table 585 ) 586 )
Renders a rubric table for a specific task (from the 'table' slot of
an evaluation; see potluck.rubrics.Rubric.evaluate
) as an HTML
string. Returns an HTML string that builds a div with class
"rubric_table".
blank
can be set to True (instead of the default False) to make
rubric details more visible in blank rubrics where explanations
haven't been generated yet, but it means that explanations will not
be rendered in the output (warnings and notes still will be). Setting
blank to True will also cause table rows to display the rubric
versions of row topics and details even when feedback versions are
available.
589def render_rubric_row(taskid, row, blank=False): 590 """ 591 Renders a single row of a rubric table for a given task into an HTML 592 div (see `render_rubric_table`). The resulting div has class 593 "rubric_row", plus an additional class depending on the status of 594 the given row with the string 'status_' prefixed and any spaces 595 replaced by underscores. There are also 'tag_' classes added for 596 each tag that the row has. 597 598 If blank is given as True, the explanation for this row will be 599 discarded and replaced by the rubric details, which will then be 600 omitted from the topic area. By default (when blank is False), the 601 rubric details are available on expansion within the topic div and 602 the explanation is put in the assessment area. Also, when blank is 603 True, the rubric versions of the topic and details for this row are 604 displayed instead of the feedback versions. 605 """ 606 contents = '' 607 608 description = row.get( 609 "description", 610 ("Unknown", "No description available.") 611 ) 612 if blank: 613 topic, details = description[:2] 614 else: 615 topic = description[::2][-1] 616 details = description[1::2][-1] 617 618 topic_class = "topic" 619 details_node = ' ' + html_tools.create_help_button(details) 620 if blank: 621 topic_class += " no_details" 622 details_node = '' 623 624 rowid = row.get("id", "") 625 contents += ( 626 '<div {}class="{}" tabindex="0">\n{}\n{}\n{}\n{}\n</div>' 627 ).format( 628 'id="{}" '.format(rowid) if rowid else '', 629 topic_class, 630 html_tools.build_status_indicator(row["status"]), 631 "<span class='sr-only'>{}</span>".format(row["status"]), 632 "<span class='goal-topic'>{}</span>".format(topic), 633 details_node 634 ) 635 636 expl_text = row.get("explanation", '') 637 if blank: 638 expl_text = details 639 640 explanation = '<div class="explanation">{}</div>'.format(expl_text) 641 # Add notes to explanation if they exist: 642 if row.get("notes"): 643 notes = '<details class="notes">\n<summary>Notes</summary>\n<ul>' 644 for note in row.get("notes"): 645 notes += '\n<li class="note">{}</li>'.format(note) 646 notes += '</ul>\n</details>' 647 explanation += '\n' + notes 648 649 # Add warnings to explanation if they exist: 650 if row.get("warnings"): 651 warnings = ( 652 '<details class="warnings">\n<summary>Warnings</summary>\n<ul>' 653 ) 654 for warning in row.get("warnings"): 655 warnings += '\n<li class="warning">{}</li>'.format(warning) 656 warnings += '</ul>\n</details>' 657 explanation += '\n' + warnings 658 659 # Add assessment (explanation + notes + warnings) to result: 660 contents += '<div class="assessment">{}</div>'.format(explanation) 661 662 # Add subtable if there is one: 663 subtable = '' 664 if row.get("subtable"): 665 subtable = render_rubric_table(taskid, row.get("subtable"), blank) 666 667 contents += subtable 668 669 # CSS classes for the rubric row 670 classes = [ 671 "rubric_row", 672 html_tools.status_css_class(row["status"]) 673 ] + [ 674 "tag-{k}-{v}".format(k=k, v=v) 675 for (k, v) in row.get("tags", {}).items() 676 ] 677 # CSS class 'goal_category' for non-ID's items 678 if rowid == '': 679 classes.append("goal_category") 680 return '<div class="{}">{}</div>'.format( 681 ' '.join(classes), 682 contents 683 )
Renders a single row of a rubric table for a given task into an HTML
div (see render_rubric_table
). The resulting div has class
"rubric_row", plus an additional class depending on the status of
the given row with the string 'status_' prefixed and any spaces
replaced by underscores. There are also 'tag_' classes added for
each tag that the row has.
If blank is given as True, the explanation for this row will be discarded and replaced by the rubric details, which will then be omitted from the topic area. By default (when blank is False), the rubric details are available on expansion within the topic div and the explanation is put in the assessment area. Also, when blank is True, the rubric versions of the topic and details for this row are displayed instead of the feedback versions.
686def render_contexts_list(contexts_list): 687 """ 688 Accepts a contexts list in the format returned by 689 `potluck.contexts.list_and_render_contexts`. Each context gets its 690 own details element that collapses to a single row, but expands to 691 show the context value(s). 692 693 This function returns a single HTML string containing code for all of 694 the contexts, wrapped in a div with class 'context_info'. 695 """ 696 result = "<div class='context_info'>" 697 for index, ctx in enumerate(contexts_list): 698 tl = min(ctx["level"], 8) # truncated level value 699 # TODO: Some way of drawing connectors that's not horribly 700 # fragile... 701 if ctx["value"] != "": 702 result += """ 703<details id='ctx_{index}' class='context_details level_{tl}'> 704 <summary>{desc}</summary> 705 {val} 706</details> 707""".format( 708 index=index, 709 tl=tl, 710 desc=ctx["description"], 711 val=ctx["value"], 712) 713 else: 714 result += """ 715<div id='ctx_{index}' class='context_details level_{tl}'> 716 {desc} 717</div> 718""".format( 719 index=index, 720 tl=tl, 721 desc=ctx["description"] 722) 723 724 result += "</div>" 725 return result
Accepts a contexts list in the format returned by
potluck.contexts.list_and_render_contexts
. Each context gets its
own details element that collapses to a single row, but expands to
show the context value(s).
This function returns a single HTML string containing code for all of the contexts, wrapped in a div with class 'context_info'.