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
J2_ENV = None

The global Jinja2 Environment.

RESOURCES_DIR = None

The global resource directory.

BLANK_RUBRIC_TEMPLATE = 'rubric.j2'

The template name for blank rubrics.

EVALUATION_REPORT_TEMPLATE = 'standalone_report.j2'

The template name for evaluation reports.

TESTS_EVALUATION_REPORT_TEMPLATE = 'standalone_tests_report.j2'

The template name for tests evaluation reports.

STANDALONE_INSTRUCTIONS_TEMPLATE = 'standalone_instructions.j2'

The template name for standalone instructions.

INSTRUCTIONS_TEMPLATE = 'instructions.j2'

The template name for instructions (to be included in an HTML wrapper).

ERROR_MSG = 'FAILED TO COMPLETE'

The message that will be logged if an error occurs during evaluation.

DONE_MSG = 'completed successfully'

The message that will be printed if evaluation completes successfully.

def register_filter(name, filter_function):
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.

def setup(templates_directory=None, resources_directory=None):
 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.

def get_css():
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.

def get_js():
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.

def load_template(template_name):
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).

def render_markdown(md_source):
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.

def render_blank_rubric(blank_report, output_file):
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.

def render_report(report, instructions, snippets, output_file, html_output_file):
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.

def render_tests_report(report, output_file, html_output_file):
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.

def render_instructions( task_info, instructions, rubric_table, snippets, output_file, standalone=True, report_rubric_link_coverage=False):
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.

def augment_report(report, blank=False):
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).

def render_code(taskid, filename, raw_code):
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.

def annotate_line(taskid, filename, lineno, line_html):
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.

def render_rubric_table(taskid, table, blank=False):
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.

def render_rubric_row(taskid, row, blank=False):
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.

def render_contexts_list(contexts_list):
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'.