potluck.snippets

Code for defining examples that should be shown as part of instructions.

snippets.py

This module can be used in specifications files to define named examples, which can then be compiled to HTML files using the --snippets option. These examples will be evaluated within the context of the solution code, and their output + return values will be formatted for display to the students. By using snippets this way, as long as your solution file is up-to-date, your example output will never be out-of-sync with what the problem set is actually asking for.

Example usage:

from potluck import snippets as sn

EXAMPLE = [
    {
        "key": "value",
        "key2": "value2",
    },
    {
        "key": "valueB",
        "key2": "value2B"
    }
]

sn.Variables(
    "vars", # snippet ID
    "<code>EXAMPLE</code> variable", # snippet displayed title
    (
        "A simple example of what the input data might look like, and"
        " the slightly more complex <code>DATA</code> variable provided"
        " in the starter code."
    ), # displayed snippet caption
    [ "EXAMPLE", "DATA" ] # list of variable names to display definitions of
).provide({ "EXAMPLE": EXAMPLE })
# provide overrides (or provides missing) solution module values

sn.FunctionCalls(
    "examples", # snippet ID
    "Example results", # title
    (
        "Some examples of what <code>processData</code> should return"
        " for various inputs, using the <code>EXAMPLE</code> and"
        " <code>DATA</code> variables shown above."
    ),
    # caption (note we're assuming the 'vars' snippet will be included
    # first, otherwise this caption doesn't make sense).
    [
        ("processData", (EXAMPLE, 1)),
        ("processData", (EXAMPLE, 2)),
        ("processData", (EXAMPLE, 3))",
        ("processData", (cu.SolnValue("DATA"), 3)),
    ], # list of function name, arguments tuples to evaluate
)

sn.RunModule(
    "run", # ID
    "Full output example", # title
    (
        "An example of what the output should look like when your code"
        " is run. Note that the blue text shows what inputs were"
        " provided in this example."
    ), # caption
).provide_inputs(["A", "B"]) # we're providing some inputs during the run

The various snippet classes like Variable, Expressions, and RunModule each inherit from potluck.specifications.TestGroup meaning that you can use the various modification methods from that module (such as provide_inputs as shown above) to control exactly how the code is run.

   1"""
   2Code for defining examples that should be shown as part of instructions.
   3
   4snippets.py
   5
   6This module can be used in specifications files to define named examples,
   7which can then be compiled to HTML files using the --snippets option.
   8These examples will be evaluated within the context of the solution code,
   9and their output + return values will be formatted for display to the
  10students. By using snippets this way, as long as your solution file is
  11up-to-date, your example output will never be out-of-sync with what the
  12problem set is actually asking for.
  13
  14Example usage:
  15
  16```py
  17from potluck import snippets as sn
  18
  19EXAMPLE = [
  20    {
  21        "key": "value",
  22        "key2": "value2",
  23    },
  24    {
  25        "key": "valueB",
  26        "key2": "value2B"
  27    }
  28]
  29
  30sn.Variables(
  31    "vars", # snippet ID
  32    "<code>EXAMPLE</code> variable", # snippet displayed title
  33    (
  34        "A simple example of what the input data might look like, and"
  35        " the slightly more complex <code>DATA</code> variable provided"
  36        " in the starter code."
  37    ), # displayed snippet caption
  38    [ "EXAMPLE", "DATA" ] # list of variable names to display definitions of
  39).provide({ "EXAMPLE": EXAMPLE })
  40# provide overrides (or provides missing) solution module values
  41
  42sn.FunctionCalls(
  43    "examples", # snippet ID
  44    "Example results", # title
  45    (
  46        "Some examples of what <code>processData</code> should return"
  47        " for various inputs, using the <code>EXAMPLE</code> and"
  48        " <code>DATA</code> variables shown above."
  49    ),
  50    # caption (note we're assuming the 'vars' snippet will be included
  51    # first, otherwise this caption doesn't make sense).
  52    [
  53        ("processData", (EXAMPLE, 1)),
  54        ("processData", (EXAMPLE, 2)),
  55        ("processData", (EXAMPLE, 3))",
  56        ("processData", (cu.SolnValue("DATA"), 3)),
  57    ], # list of function name, arguments tuples to evaluate
  58)
  59
  60sn.RunModule(
  61    "run", # ID
  62    "Full output example", # title
  63    (
  64        "An example of what the output should look like when your code"
  65        " is run. Note that the blue text shows what inputs were"
  66        " provided in this example."
  67    ), # caption
  68).provide_inputs(["A", "B"]) # we're providing some inputs during the run
  69```
  70
  71The various snippet classes like `Variable`, `Expressions`, and
  72`RunModule` each inherit from `potluck.specifications.TestGroup`
  73meaning that you can use the various modification methods from that
  74module (such as `provide_inputs` as shown above) to control exactly how
  75the code is run.
  76"""
  77
  78import textwrap
  79import re
  80import os
  81
  82import pygments
  83
  84from . import logging
  85from . import specifications
  86from . import file_utils
  87from . import html_tools
  88from . import harness
  89from . import render
  90from . import rubrics
  91from . import contexts
  92from . import context_utils
  93
  94
  95#---------#
  96# Helpers #
  97#---------#
  98
  99FMT_WIDTH = 80
 100"""
 101Line width we'll attempt to hit for formatting output nicely.
 102"""
 103
 104
 105def wrap_str(string, width):
 106    """
 107    Returns a list of strings, which when combined form the given
 108    string, where none is longer than the given width. Lines are broken
 109    at spaces whenever possible, but may be broken in the middle of
 110    words when a single word is longer than the target width. This
 111    function uses a greedy algorithm that doesn't optimize the evenness
 112    of the right margin. \\n and \\r are represented by those character
 113    pairs, but other characters are as-is.
 114    """
 115    escaped = string.replace('\n', '\\n')
 116    escaped = escaped.replace('\r', '\\r')
 117    words = escaped.split(' ')
 118    result = []
 119    while len(words) > 0: # until we've used up the words
 120        # Figure out how many words would fit on the next line
 121        i = 0
 122        # + i accounts for spaces we'll need
 123        while len(words[:i + 1]) + (i + 1) < width:
 124            i += 1
 125
 126        # Back off to the number that fits
 127        i -= 1
 128
 129        # first word doesn't fit!
 130        if i == -1:
 131            first = words[0]
 132            # Chop up the first word and put the pieces into the result
 133            pieces = len(first) // width
 134            for j in range(pieces):
 135                result.append(first[width * j:width * (j + 1)])
 136
 137            # Handle leftovers
 138            rest = first[width * pieces:]
 139            # Note that even empty leftovers serve as a placeholder to
 140            # ensure that a space gets added.
 141            words[0] = rest # put rest back into words, replacing old
 142        else:
 143            # at least one word fits
 144            line = ' '.join(words[:i + 1]) + ' '
 145            result.append(line)
 146            words = words[i + 1:]
 147
 148    # Remove the extra space introduced after the end of the last word
 149    # Works even for an empty string input
 150    result[-1] = result[-1][:-1]
 151
 152    return result
 153
 154
 155def wrapped_repr(obj, width=FMT_WIDTH, indentation=1, prefix='', memo=None):
 156    """
 157    Returns a string representing the given object, whose lines are not
 158    longer than the given width, and which uses the given number of
 159    spaces to indent sub-objects when necessary. Replacement for
 160    pprint.pformat, which does NOT reorder dictionary keys. May violate
 161    the width restriction when given a complex object that it doesn't
 162    know how to wrap whose repr alone is wider than the specified width.
 163
 164    A prefix may be supplied, which will be added to the first line of
 165    the given representation as-is. The prefix itself will not be subject
 166    to wrapping, but the first line of the result will in some cases wrap
 167    earlier to compensate for the prefix. The prefix should not include
 168    any newlines or carriage returns.
 169
 170    The memo helps avoid problems with recursion, and doesn't need to be
 171    provided explicitly.
 172    """
 173    # Create a memo if we need to
 174    if memo is None:
 175        memo = set()
 176
 177    # Check the memo
 178    if id(obj) in memo:
 179        return '...'
 180
 181    # Add ourselves to the memo
 182    memo.add(id(obj))
 183
 184    simple = prefix + repr(obj)
 185
 186    if len(simple) <= width: # no need to do anything fancy
 187        return simple
 188
 189    indent = ' ' * indentation
 190    between = '\n' + indent
 191
 192    broken = repr(obj)
 193    if len(broken) < width - indentation:
 194        return prefix + '\\' + between + broken
 195
 196    elif type(obj) == str: # need to wrap this string
 197        lines = wrap_str(obj, width - (indentation + 2))
 198        return (
 199            prefix + '(' + between
 200          + between.join('"' + line + '"' for line in lines)
 201          + '\n)'
 202        )
 203
 204    elif type(obj) == bytes: # need to wrap these bytes
 205        lines = wrap_str(obj, width - (indentation + 3))
 206        return (
 207            prefix + '(' + between
 208          + between.join('b"' + line + '"' for line in lines)
 209          + '\n)'
 210        )
 211
 212    elif type(obj) in (list, tuple, set): # need to wrap each item
 213        if type(obj) in (list, tuple):
 214            ldelim, rdelim = str(type(obj)())
 215        elif len(obj) == 0:
 216            return prefix + 'set()' # too bad if width is < 5
 217        else:
 218            ldelim = '{'
 219            rdelim = '}'
 220
 221        result = prefix + ldelim + between
 222        for item in obj:
 223            result += textwrap.indent(
 224                wrapped_repr(item, width - indentation, indentation),
 225                indent
 226            ) + ',' + between
 227
 228        return result[:-len(between)] + '\n' + rdelim
 229
 230    elif type(obj) == dict: # need to wrap keys and values
 231        indent = ' ' * indentation
 232        between = '\n' + indent
 233
 234        result = prefix + '{' + between
 235        for key in obj:
 236            key_repr = wrapped_repr(
 237                key,
 238                width - (indentation + 1),
 239                indentation
 240            )
 241            val = obj[key]
 242            val_repr = wrapped_repr(
 243                val,
 244                width - indentation * 2,
 245                indentation
 246            )
 247
 248            # Can we fit on one line?
 249            if len(indent) + len(key_repr) + len(val_repr) + 3 <= width:
 250                result += key_repr + ': ' + val_repr + ',' + between
 251            elif val_repr[1] == '\n': # ldelim/newline start
 252                result += (
 253                    key_repr + ':' + val_repr[0] + '\n'
 254                  + textwrap.indent(val_repr[2:], indent)
 255                  + ',' + between
 256                )
 257            else:
 258                result += key_repr + ':\n' + textwrap.indent(
 259                    val_repr,
 260                    indent * 2
 261                ) + ',' + between
 262
 263        # Chop off final between and add closing brace after a newline
 264        result = result[:-len(between)] + '\n}'
 265
 266        return result
 267
 268    else: # Not sure how to wrap this, so we give up...
 269        return prefix + repr(obj)
 270
 271
 272def wrapped_with_prefix(obj, prefix, indentation_level=0):
 273    """
 274    Returns a string representing the given object, as `wrapped_repr`
 275    would, except that:
 276
 277    1. The first line includes the given prefix (as a string without
 278       repr applied to it; after indentation) and the wrapping of object
 279       values takes this into account.
 280    2. The entire representation is indented by the given indentation
 281       level (one space per level).
 282    """
 283    rep = wrapped_repr(
 284        obj,
 285        width=FMT_WIDTH - indentation_level,
 286        indentation=1,
 287        prefix=prefix
 288    )
 289
 290    # remove extra indentation from tuple
 291    rep = textwrap.dedent(rep)
 292
 293    # restore indent and add prefix
 294    return textwrap.indent(rep, ' ' * indentation_level)
 295
 296
 297def extract(context, key):
 298    """
 299    Gets value from a context but raises a ValueError showing the
 300    context's error log if it can't.
 301    """
 302    if key in context:
 303        return context[key]
 304    else:
 305        # Note: we use a ValueError here instead of a KeyError because
 306        # KeyError uses repr on its message...
 307        if "ref_error_log" in context:
 308            raise ValueError(
 309                (
 310                    "Error: context is missing the '{}' key. Error log"
 311                    " is:\n{}"
 312                ).format(key, context["ref_error_log"]),
 313            )
 314        else:
 315            raise ValueError(
 316                "Error: context is missing the '{}' key. No error log"
 317                " available."
 318            )
 319
 320
 321#--------------#
 322# Registration #
 323#--------------#
 324
 325SNIPPET_REGISTRY = {}
 326"""
 327All registered snippets, by defining module and then snippet ID.
 328"""
 329
 330
 331#-----------------------#
 332# Snippet setup/cleanup #
 333#-----------------------#
 334
 335REMEMBER = {}
 336"""
 337Things to remember so we can undo setup changes.
 338"""
 339
 340
 341def snippet_setup(context):
 342    """
 343    If the wavesynth module is available, disables the playTrack and
 344    saveTrack functions from that module. If the optimism module is
 345    available, disables colors for that module.
 346    """
 347    try:
 348        import wavesynth
 349        REMEMBER["playTrack"] = wavesynth.playTrack
 350        wavesynth.playTrack = lambda _=None: None
 351        REMEMBER["saveTrack"] = wavesynth.saveTrack
 352        wavesynth.saveTrack = lambda _: None
 353    except ModuleNotFoundError:
 354        pass
 355
 356    try:
 357        import optimism
 358        REMEMBER["opt_colors"] = optimism.COLORS
 359        optimism.colors(False)
 360    except ModuleNotFoundError:
 361        pass
 362
 363    return context
 364
 365
 366def snippet_cleanup(context):
 367    """
 368    Puts things back how they were before `snippet_setup`.
 369    """
 370    try:
 371        import wavesynth
 372        wavesynth.playTrack = REMEMBER["playTrack"]
 373        wavesynth.saveTrack = REMEMBER["saveTrack"]
 374    except ModuleNotFoundError:
 375        pass
 376
 377    try:
 378        import optimism
 379        optimism.colors(REMEMBER["opt_colors"])
 380    except ModuleNotFoundError:
 381        pass
 382
 383    return context
 384
 385
 386#----------------------------#
 387# Snippet class & subclasses #
 388#----------------------------#
 389
 390class Snippet(specifications.TestGroup):
 391    """
 392    Common functionality for snippets. Don't instantiate this class;
 393    instead use one of the subclasses `Variables`, `Expressions`, or
 394    `RunModule`.
 395
 396    Note that a `Snippet` is a `potluck.specifications.TestGroup`, from
 397    which it inherits a powerful interface for customizing behavior. Some
 398    default modifications are applied to every snippet:
 399
 400    1. Output, including stderr and error tracebacks, is captured.
 401    2. A setup function is run which disables turtle tracing, wavesynth
 402        track playing/saving, and optimism colors (only when the relevant
 403        module(s) are available). A cleanup function that reverses all of
 404        these changes except the turtle speed is also run afterwards.
 405    """
 406    @staticmethod
 407    def base_context(task_info):
 408        """
 409        Creates a base context object for a snippet, given a task info
 410        object.
 411        """
 412        return {
 413            "task_info": task_info,
 414            "username": "__soln__",
 415            "submission_root": task_info["specification"].soln_path,
 416            "default_file": task_info["target"],
 417            "actual_file": task_info["target"]
 418        }
 419
 420    def __init__(
 421        self,
 422        sid,
 423        title,
 424        caption,
 425        tests,
 426        postscript='',
 427        glamers=None,
 428        ignore_errors=False
 429    ):
 430        """
 431        All `Snippet`s have a snippet ID, a title, a caption, and one or
 432        more tests. `tests` must be an iterable of unregistered
 433        `potluck.specifications.TestCase` objects.
 434
 435        An optional postscript is markdown source to be placed after the
 436        code block.
 437
 438        An optional list of glamer functions may be provided. These will
 439        be given individual pieces of HTML code and the associated
 440        context those were rendered from and their result value will
 441        replace the HTML code to be used; they are applied in-order.
 442
 443        Unless ignore_errors is set to False (default is True), errors
 444        generated by the base payload and captured along with output will
 445        be logged under the assumption that in most cases an exception in
 446        a snippet is a bug with the snippet code rather than intentional.
 447        """
 448        # Set ourselves up as a TestGroup...
 449        super().__init__(sid, '_')
 450
 451        # We store our arguments
 452        self.title = title
 453        self.caption = caption
 454        self.postscript = postscript
 455        self.glamers = glamers or []
 456        self.ignore_errors = ignore_errors
 457
 458        # Final edit function to manipulate the context to be rendered
 459        self.editor = None
 460
 461        # What's the name of our spec file?
 462        self.spec_file = file_utils.get_spec_file_name()
 463
 464        # Add our tests to ourself
 465        for t in tests:
 466            self.add(t)
 467
 468        # Fetch the defining-module-specific registry
 469        reg = SNIPPET_REGISTRY.setdefault(
 470            file_utils.get_spec_module_name(),
 471            {}
 472        )
 473
 474        # Prevent ID collision
 475        if sid in reg:
 476            raise ValueError(
 477                "Multiple snippets cannot be registered with the same"
 478                " ID ('{sid}')."
 479            )
 480
 481        # Register ourself
 482        reg[sid] = self
 483
 484        # A place to cache our compiled result
 485        self.cached_for = None
 486        self.cached_value = None
 487
 488        # Set up default augmentations
 489        self.capture_output(capture_errors=True, capture_stderr=True)
 490        self.do_setup(snippet_setup)
 491        self.do_cleanup(snippet_cleanup)
 492
 493    def provide(self, vars_map):
 494        """
 495        A convenience function for providing the snippet with values not
 496        defined in the solution module. Under the hood, this uses
 497        `specifications.HasPayload.use_decorations`, so using that
 498        alongside this will not work (one will overwrite the other). This
 499        also means that you cannot call `provide` multiple times on the
 500        same instance to accumulate provided values: each call discards
 501        previous provided values.
 502        """
 503        self.use_decorations(
 504            {
 505                k: (lambda _: v)
 506                for (k, v) in vars_map.items()
 507            },
 508            ignore_missing=True
 509        )
 510
 511    # TODO: define replacements so that formatting of calls can use
 512    # varnames?
 513
 514    def compile(self, base_context):
 515        """
 516        Runs the snippet, collects its results, and formats them as HTML.
 517        Returns a string containing HTML code for displaying the snippet
 518        (which assumes that the potluck.css stylesheet will be loaded).
 519
 520        This method will always re-run the compilation process,
 521        regardless of whether a cached result is available, and will
 522        update the cached result. Use `Snippet.get_html` to recompile
 523        only as needed.
 524
 525        Requires a base context object (see `potluck.contexts.Context`
 526        for the required structure).
 527
 528        The returned HTML's outermost tag is a &lt;section&gt; tag with
 529        an id that starts with 'snippet:' and then ends with the
 530        snippet's ID value.
 531        """
 532        # Set up traceback-rewriting for the specifications module we
 533        # were defined in
 534        html_tools.set_tb_rewrite(
 535            base_context["task_info"]["specification"].__file__,
 536            "<task specification>"
 537        )
 538        html_tools.set_tb_rewrite(
 539            base_context["submission_root"],
 540            "<solution>"
 541        )
 542
 543        # Tell the run_for_base_and_ref_values augmentation to run only
 544        # for ref values.
 545        self.augmentations.setdefault(
 546            "run_for_base_and_ref_values",
 547            {}
 548        )["ref_only"] = True
 549
 550        for case in self.tests:
 551            case.augmentations.setdefault(
 552                "run_for_base_and_ref_values",
 553                {}
 554            )["ref_only"] = True
 555
 556        # Create our Goal object
 557        goal = self.provide_goal()
 558
 559        # Reset and evaluate our goal:
 560        goal.reset_network()
 561        goal.evaluate(base_context)
 562
 563        # Grab the contexts that were used for each test
 564        if goal.test_in and len(goal.test_in["contexts"]) > 0:
 565            contexts = goal.test_in["contexts"]
 566        else:
 567            # This shouldn't be possible unless an empty list of
 568            # variables or functions was provided...
 569            raise ValueError(
 570                "A Snippet must have at least one context to compile."
 571                " (Did you pass an empty list to Variables or"
 572                " FunctionCalls?)"
 573            )
 574
 575        # Translate from markdown
 576        title = render.render_markdown(self.title)
 577        caption = render.render_markdown(self.caption)
 578        post = render.render_markdown(self.postscript)
 579
 580        context_dicts = []
 581        for ctx in contexts:
 582            try:
 583                cdict = ctx.create(base_context)
 584            except context_utils.ContextCreationError:
 585                cdict = {
 586                    # for RunModule snippets (note no ref_)
 587                    "filename": "UNKNOWN",
 588                    # In case it's a Variables snippet
 589                    "ref_variable": "UNKNOWN",
 590                    # for FunctionCalls snippets
 591                    "ref_function": "UNKNOWN",
 592                    "ref_args": (),
 593                    "ref_kwargs": {},
 594                    # for Blocks snippets
 595                    "ref_block": "UNKNOWN",
 596                    # For all kinds of snippets
 597                    "ref_value": "There was an error compiling this snippet.",
 598                    "ref_output": "There was an error compiling this snippet.",
 599                    "ref_error_log": html_tools.string_traceback()
 600                }
 601            context_dicts.append(cdict)
 602
 603        # Strip out solution file paths from output, and perform final
 604        # edits if we have an editor function
 605        soln_path = os.path.abspath(
 606            base_context["task_info"]["specification"].soln_path
 607        )
 608        for context in context_dicts:
 609            if (
 610                "ref_output" in context
 611            and soln_path in context["ref_output"]
 612            ):
 613                context["ref_output"] = context["ref_output"].replace(
 614                    soln_path,
 615                    '&lt;soln&gt;'
 616                )
 617
 618            if (
 619                "ref_error_log" in context
 620            and soln_path in context["ref_error_log"]
 621            ):
 622                context["ref_error_log"] = context["ref_error_log"].replace(
 623                    soln_path,
 624                    '&lt;soln&gt;'
 625                )
 626
 627            if self.editor:
 628                self.editor(context)
 629
 630        # Include title & caption, and render each context to produce our
 631        # result
 632        result = (
 633            '<section class="snippet" id="snippet:{sid}">'
 634            '<div class="snippet_title">{title}</div>\n'
 635            '{caption}\n<pre>{snippet}</pre>\n{post}'
 636            '</section>'
 637        ).format(
 638            sid=self.base_name,
 639            title=title,
 640            caption=caption,
 641            post=post,
 642            snippet=''.join(
 643                self.compile_context(cd)
 644                for cd in context_dicts
 645            )
 646        )
 647
 648        self.cached_for = base_context["task_info"]["id"]
 649        self.cached_value = result
 650
 651        return result
 652
 653    def compile_context(self, context):
 654        """
 655        Turns a context dictionary resulting from an individual snippet
 656        case into HTML code for displaying that result, using
 657        `render_context` but also potentially adding additional HTML for
 658        extra context slots (see e.g., `show_image` and `play_audio`).
 659        """
 660        if not self.ignore_errors and "ref_error" in context:
 661            logging.log(
 662                "Error captured by context creation:\n"
 663              + context["ref_error"]
 664              + "\nFull log is:\n"
 665              + context.get("ref_error_log", '<missing>')
 666            )
 667        result = self.render_context(context)
 668        for glamer in self.glamers:
 669            result = glamer(result, context)
 670
 671        return result
 672
 673    def render_context(self, context):
 674        """
 675        Override this to define how a specific Snippet sub-class should
 676        render a context dictionary into HTML code.
 677        """
 678        raise NotImplementedError(
 679            "Snippet is abstract and context rendering is only available"
 680            " via overrides in sub-classes."
 681        )
 682
 683    def get_html(self, task_info):
 684        """
 685        Returns the snippet value, either cached or newly-compiled
 686        depending on the presence of an appropriate cached value.
 687        """
 688        if self.cached_for == task_info["id"]:
 689            return self.cached_value
 690        else:
 691            return self.compile(Snippet.base_context(task_info))
 692
 693    def final_edit(self, editor):
 694        """
 695        Sets up a function to be run on each created context just before
 696        that context gets rendered. The editor may mutate the context it
 697        is given in order to change what gets rendered. Multiple calls to
 698        this function each simply replace the previous editor function.
 699
 700        Note that the ref_* values from the context are used to create
 701        the snippet.
 702        """
 703        self.editor = editor
 704
 705    def show_image(self):
 706        """
 707        Adds a glamer that augments output by appending a "Image "
 708        prompt followed by an image element which shows the contents of
 709        the "ref_image" context slot. To establish a "ref_image" slot,
 710        call a method like
 711        `potluck.specifications.HasPayload.capture_turtle_image`.
 712
 713        Returns self for chaining.
 714        """
 715        self.glamers.append(append_image_result)
 716        return self
 717
 718    def play_audio(self):
 719        """
 720        Adds a glamer that augments output by appending an "Audio "
 721        prompt followed by an audio element which plays the contents of
 722        the "ref_audio" context slot. To make an "ref_audio" slot, call a
 723        method like
 724        `potluck.specifications.HasPayload.capture_wavesynth`.
 725
 726        Returns self for chaining.
 727        """
 728        self.glamers.append(append_audio_result)
 729        return self
 730
 731    def show_memory_report(self):
 732        """
 733        Adds a glamer that augments output by appending a "Memory Report"
 734        prompt followed by a memory report showing the structure of the
 735        "ref_value" context slot.
 736
 737        Returns self for chaining.
 738        """
 739        self.glamers.append(append_memory_report)
 740        return self
 741
 742    def show_file_contents(self):
 743        """
 744        Adds a glamer that augments output by appending a "File"
 745        prompt followed by a text box showing the filename and the
 746        contents of that file. Requires 'ref_output_filename' and
 747        'ref_output_file_contents' slots, which could be established
 748        using a method like
 749        `potluck.specifications.HasPayload.capture_file_contents`
 750
 751        Returns self for chaining.
 752        """
 753        self.glamers.append(append_file_contents)
 754        return self
 755
 756
 757def highlight_code(code):
 758    """
 759    Runs pygments highlighting but converts from a div/pre/code setup
 760    back to just a code tag with the 'highlight' class.
 761    """
 762    markup = pygments.highlight(
 763        code,
 764        pygments.lexers.PythonLexer(),
 765        pygments.formatters.HtmlFormatter()
 766    )
 767    # Note: markup will start a div and a pre we want to get rid of
 768    start = '<div class="highlight"><pre>'
 769    end = '</pre></div>\n'
 770    if markup.startswith(start):
 771        markup = markup[len(start):]
 772    if markup.endswith(end):
 773        markup = markup[:-len(end)]
 774
 775    return '<code class="highlight">{}</code>'.format(markup)
 776
 777
 778class Variables(Snippet):
 779    """
 780    A snippet which shows the definition of one or more variables. Use
 781    this to display examples of input data, especially when you want to
 782    use shorter expressions in examples of running code. If you want to
 783    use variables that aren't defined in the solution module, use the
 784    `Snippet.provide` method (but note that that method is incompatible
 785    with using `specifications.HasPayload.use_decorations`).
 786    """
 787    def __init__(self, sid, title, caption, varnames, postscript=''):
 788        """
 789        A snippet ID, a title, and a caption are required, as is a list
 790        of strings indicating the names of variables to show definitions
 791        of. Use `Snippet.provide` to supply values for variables not in
 792        the solution module.
 793
 794        A postscript is optional (see `Snippet`).
 795        """
 796        cases = [
 797            specifications.TestValue(
 798                varname,
 799                register=False
 800            )
 801            for varname in varnames
 802        ]
 803        super().__init__(sid, title, caption, cases, postscript)
 804
 805    def render_context(self, context):
 806        """
 807        Renders a context created for goal evaluation as HTML markup for
 808        the definition of a variable, including Jupyter-notebook-style
 809        prompts.
 810        """
 811        varname = extract(context, "ref_variable")
 812        value = extract(context, "ref_value")
 813
 814        # format value's repr using wrapped_repr, but leaving room for
 815        # varname = at beginning of first line
 816        rep = wrapped_with_prefix(value, varname + ' = ')
 817
 818        # Use pygments to generate HTML markup for our assignment
 819        markup = highlight_code(rep)
 820        return (
 821            '<span class="prompt input">In []:</span>'
 822            '<div class="snippet-input">{}</div>\n'
 823        ).format(markup)
 824
 825
 826class RunModule(Snippet):
 827    """
 828    A snippet which shows the output produced by running a module.
 829    Functions like `potluck.specifications.HasPayload.provide_inputs`
 830    can be used to control exactly what happens.
 831
 832    The module to import is specified by the currently active file
 833    context (see `potluck.contexts.FileContext`).
 834    """
 835    def __init__(self, sid, title, caption, postscript=''):
 836        """
 837        A snippet ID, title, and caption are required.
 838
 839        A postscript is optional (see `Snippet`).
 840        """
 841        cases = [ specifications.TestImport(register=False) ]
 842        super().__init__(sid, title, caption, cases, postscript)
 843
 844    def render_context(self, context):
 845        """
 846        Renders a context created for goal evaluation as HTML markup for
 847        running a module. Includes a Jupyter-style prompt with %run magic
 848        syntax to show which file was run.
 849        """
 850        filename = extract(context, "filename") # Note: no ref_ here
 851        captured = context.get("ref_output", '')
 852        captured_errs = context.get("ref_error_log", '')
 853
 854        # Wrap faked inputs with spans so we can color them blue
 855        captured = re.sub(
 856            harness.FAKE_INPUT_PATTERN,
 857            r'<span class="input">\1</span>',
 858            captured
 859        )
 860
 861        result = (
 862            '<span class="prompt input">In []:</span>'
 863            '<div class="snippet-input">'
 864            '<code class="magic">%run {filename}</code>'
 865            '</div>\n'
 866        ).format(filename=filename)
 867        if captured:
 868            result += (
 869                '<span class="prompt printed">Prints</span>'
 870                '<div class="snippet-printed">{captured}</div>\n'
 871            ).format(captured=captured)
 872        if captured_errs:
 873            result += (
 874                '<span class="prompt stderr">Logs</span>'
 875                '<div class="snippet-stderr">{log}</div>\n'
 876            ).format(log=captured_errs)
 877
 878        return result
 879
 880
 881class FunctionCalls(Snippet):
 882    """
 883    A snippet which shows the results (printed output and return values)
 884    of calling one or more functions. To control what happens in detail,
 885    use specialization methods from `potluck.specifications.HasPayload`
 886    and `potluck.specifications.HasContext`.
 887    """
 888    def __init__(self, sid, title, caption, calls, postscript=''):
 889        """
 890        A snippet ID, title, and caption are required, along with a list
 891        of function calls. Each entry in the list must be a tuple
 892        containing a function name followed by a tuple of arguments,
 893        and optionally, a dictionary of keyword arguments.
 894
 895        A postscript is optional (see `Snippet`).
 896        """
 897        cases = [
 898            specifications.TestCase(
 899                fname,
 900                args,
 901                kwargs or {},
 902                register=False
 903            )
 904            for fname, args, kwargs in (
 905                map(lambda case: (case + (None,))[:3], calls)
 906            )
 907        ]
 908        super().__init__(sid, title, caption, cases, postscript)
 909
 910    def render_context(self, context):
 911        """
 912        Renders a context created for goal evaluation as HTML markup for
 913        calling a function. Includes a Jupyter-style prompt for input as
 914        well as the return value.
 915        """
 916        if "ref_error" in context:
 917            print(
 918                "Error during context creation:"
 919              + context["ref_error"]
 920              + "\nFull log is:\n"
 921              + context.get("ref_error_log", '<missing>')
 922            )
 923        fname = extract(context, "ref_function")
 924        value = extract(context, "ref_value")
 925        args = extract(context, "ref_args")
 926        kwargs = extract(context, "ref_kwargs")
 927        captured = context.get("ref_output", '')
 928        captured_errs = context.get("ref_error_log", '')
 929
 930        # Figure out representations of each argument
 931        argreps = []
 932        for arg in args:
 933            argreps.append(wrapped_with_prefix(arg, '', 1))
 934
 935        for kw in kwargs:
 936            argreps.append(wrapped_with_prefix(kwargs[kw], kw + "=", 1))
 937
 938        # Figure out full function call representation
 939        oneline = "{}({})".format(
 940            fname,
 941            ', '.join(rep.strip() for rep in argreps)
 942        )
 943        if '\n' not in oneline and len(oneline) <= FMT_WIDTH:
 944            callrep = oneline
 945        else:
 946            callrep = "{}(\n{}\n)".format(fname, ',\n'.join(argreps))
 947
 948        # Wrap faked inputs with spans so we can color them blue
 949        captured = re.sub(
 950            harness.FAKE_INPUT_PATTERN,
 951            r'<span class="input">\1</span>',
 952            captured
 953        )
 954
 955        # Highlight the function call
 956        callrep = highlight_code(callrep)
 957
 958        result = (
 959            '<span class="prompt input">In []:</span>'
 960            '<div class="snippet-input">{}</div>'
 961        ).format(callrep)
 962
 963        if captured:
 964            result += (
 965                '<span class="prompt printed">Prints</span>'
 966                '<div class="snippet-printed">{}</div>\n'
 967            ).format(captured)
 968
 969        if captured_errs:
 970            result += (
 971                '<span class="prompt stderr">Logs</span>'
 972                '<div class="snippet-stderr">{log}</div>\n'
 973            ).format(log=captured_errs)
 974
 975        # Highlight the return value
 976        if value is not None:
 977            value = highlight_code(wrapped_with_prefix(value, ""))
 978
 979            result += (
 980                '<span class="prompt output">Out[]:</span>'
 981                '<div class="snippet-output">{}</div>\n'
 982            ).format(value)
 983
 984        return result
 985
 986
 987class Blocks(Snippet):
 988    """
 989    A snippet which shows the results (printed output and result value of
 990    final line) of one or more blocks of statements, just like Jupyter
 991    Notebook cells. To control what happens in detail, use specialization
 992    methods from `potluck.specifications.HasPayload` and
 993    `potluck.specifications.HasContext`.
 994
 995    Note: Any direct side-effects of the blocks (like changing the value
 996    of a variable or defining a function) won't persist in the module
 997    used for evaluation. However, indirect effects (like appending to a
 998    list) will persist, and thus should be avoided because they could
 999    have unpredictable effects on other components of the system such as
1000    evaluation.
1001
1002    Along the same lines, each block of code to demonstrate is executed
1003    independently of the others. You cannot define a variable in one
1004    block and then use it in another (although you could fake this using
1005    the ability to define separate presentation and actual code).
1006    """
1007    def __init__(self, sid, title, caption, blocks, postscript=''):
1008        """
1009        A snippet ID, title, and caption are required, along with a list
1010        of code blocks. Each entry in the list should be either a
1011        multi-line string, or a tuple of two such strings. In the first
1012        case, the string is treated as the code to run, in the second,
1013        the first item in the tuple is the code to display, while the
1014        second is the code to actually run.
1015
1016        A postscript is optional (see `Snippet`).
1017        """
1018        cases = []
1019        for item in blocks:
1020            if isinstance(item, str):
1021                cases.append(
1022                    specifications.TestBlock(
1023                        sid,
1024                        item,
1025                        register=False
1026                    )
1027                )
1028            else:
1029                display, actual = item
1030                cases.append(
1031                    specifications.TestBlock(
1032                        sid,
1033                        display,
1034                        actual,
1035                        register=False
1036                    )
1037                )
1038
1039        super().__init__(sid, title, caption, cases, postscript)
1040
1041    def render_context(self, context):
1042        """
1043        Renders a context created for goal evaluation as HTML markup for
1044        executing a block of code. Includes a Jupyter-style prompt for
1045        input as well as the result value of the last line of the block
1046        as long as that's not None.
1047        """
1048        src = extract(context, "ref_block")
1049        value = extract(context, "ref_value")
1050        captured = context.get("ref_output", '')
1051        captured_errs = context.get("ref_error_log", '')
1052
1053        # Wrap faked inputs with spans so we can color them blue
1054        captured = re.sub(
1055            harness.FAKE_INPUT_PATTERN,
1056            r'<span class="input">\1</span>',
1057            captured
1058        )
1059
1060        # Highlight the code block
1061        blockrep = highlight_code(src)
1062
1063        result = (
1064            '<span class="prompt input">In []:</span>'
1065            '<div class="snippet-input">{}</div>\n'
1066        ).format(blockrep)
1067
1068        if captured:
1069            result += (
1070                '<span class="prompt printed">Prints</span>'
1071                '<div class="snippet-printed">{}</div>\n'
1072            ).format(captured)
1073
1074        if captured_errs:
1075            result += (
1076                '<span class="prompt stderr">Logs</span>'
1077                '<div class="snippet-stderr">{log}</div>\n'
1078            ).format(log=captured_errs)
1079
1080        # Highlight the return value
1081        if value is not None:
1082            value = highlight_code(wrapped_with_prefix(value, ""))
1083
1084            result += (
1085                '<span class="prompt output">Out[]:</span>'
1086                '<div class="snippet-output">{}</div>\n'
1087            ).format(value)
1088
1089        return result
1090
1091
1092class Fakes(Snippet):
1093    """
1094    One or more fake snippets, which format code, printed output, stderr
1095    output, and a result value (and possibly glamer additions) but each
1096    of these things is simply specified ahead of time. Note that you want
1097    to avoid using this if at all possible, because it re-creates the
1098    problems that this module was trying to solve (namely, examples
1099    becoming out-of-sync with the solution code). The specialization
1100    methods of this kind of snippet are ignored and have no effect,
1101    because no code is actually run.
1102    """
1103    def __init__(self, sid, title, caption, fake_contexts, postscript=''):
1104        """
1105        A snippet ID, title, and caption are required, along with a list
1106        of fake context dictionary objects to be rendered as if they came
1107        from a real test. Each dictionary must contain a "code" slot, and
1108        may contain "ref_value", "ref_output", and/or "ref_error_log"
1109        keys. If using glamers, relevant keys should be added directly to
1110        these fake contexts.
1111
1112        If "ref_value" or "ref_output" slots are missing, defaults of
1113        None and '' will be added, since these slots are ultimately
1114        required.
1115
1116        A postscript is optional (see `Snippet`).
1117        """
1118        self.fake_contexts = fake_contexts
1119        for ctx in self.fake_contexts:
1120            if 'code' not in ctx:
1121                raise ValueError(
1122                    "Fake context dictionaries must contain a 'code'"
1123                    " slot."
1124                )
1125            if 'ref_value' not in ctx:
1126                ctx['ref_value'] = None
1127            if 'ref_output' not in ctx:
1128                ctx['ref_output'] = ''
1129        super().__init__(sid, title, caption, [], postscript)
1130
1131    def create_goal(self):
1132        """
1133        We override TestGroup.create_goal to just create a dummy goal
1134        which depends on dummy contexts.
1135        """
1136        ctx_list = []
1137        for fake in self.fake_contexts:
1138            def make_builder():
1139                """
1140                We need to capture 'fake' on each iteration of the loop,
1141                which is why this extra layer of indirection is added.
1142                """
1143                nonlocal fake
1144                return lambda _: fake
1145            ctx_list.append(contexts.Context(builder=make_builder()))
1146
1147        return rubrics.NoteGoal(
1148            self.taskid,
1149            "fakeSnippetGoal:" + self.base_name,
1150            (
1151                "NoteGoal for fake snippet '{}'.".format(self.base_name),
1152                "A fake NoteGoal."
1153            ),
1154            test_in={ "contexts": ctx_list }
1155        )
1156
1157    def render_context(self, context):
1158        """
1159        Renders a context created for goal evaluation as HTML markup for
1160        executing a block of code. Includes a Jupyter-style prompt for
1161        input as well as the result value of the last line of the block
1162        as long as that's not None.
1163        """
1164        src = extract(context, "code")
1165        value = extract(context, "ref_value")
1166        captured = context.get("ref_output", '')
1167        captured_errs = context.get("ref_error_log", '')
1168
1169        # Wrap faked inputs with spans so we can color them blue
1170        captured = re.sub(
1171            harness.FAKE_INPUT_PATTERN,
1172            r'<span class="input">\1</span>',
1173            captured
1174        )
1175
1176        # Highlight the code block
1177        blockrep = highlight_code(src)
1178
1179        result = (
1180            '<span class="prompt input">In []:</span>'
1181            '<div class="snippet-input">{}</div>\n'
1182        ).format(blockrep)
1183
1184        if captured:
1185            result += (
1186                '<span class="prompt printed">Prints</span>'
1187                '<div class="snippet-printed">{}</div>\n'
1188            ).format(captured)
1189
1190        if captured_errs:
1191            result += (
1192                '<span class="prompt stderr">Logs</span>'
1193                '<div class="snippet-stderr">{log}</div>\n'
1194            ).format(log=captured_errs)
1195
1196        # Highlight the return value
1197        if value is not None:
1198            value = highlight_code(wrapped_with_prefix(value, ""))
1199
1200            result += (
1201                '<span class="prompt output">Out[]:</span>'
1202                '<div class="snippet-output">{}</div>\n'
1203            ).format(value)
1204
1205        return result
1206
1207
1208class Files(Snippet):
1209    """
1210    A snippet which shows the contents of one or more files, without
1211    running any code, in the same Jupyter-like format that file contents
1212    are shown when `Snippet.show_file_contents` is used. Most
1213    specialization methods don't work properly on this kind of snippet.
1214
1215    By default, file paths are interpreted as relative to the solutions
1216    directory, but specifying a different base directory via the
1217    constructor can change that.
1218    """
1219    def __init__(
1220        self,
1221        sid,
1222        title,
1223        caption,
1224        filepaths,
1225        base='__soln__',
1226        postscript=''
1227    ):
1228        """
1229        A snippet ID, title, and caption are required, along with a list
1230        of file paths. Each entry in the list should be a string path to
1231        a starter file relative to the starter directory. The versions of
1232        files in the solution directory will be used. But only files
1233        present in both starter and solution directories will be
1234        available.
1235
1236        If `base` is specified, it should be either '__soln__' (the
1237        default), '__starter__', or a path string. If it's __soln__ paths
1238        will be interpreted relative to the solution directory; if it's
1239        __starter__ they'll be interpreted relative to the starter
1240        directory, and for any other path, paths are interpreted relative
1241        to that directory. In any case, absolute paths will not be
1242        modified.
1243
1244        A postscript is optional (see `Snippet`).
1245        """
1246        if base == "__soln__":
1247            base = file_utils.current_solution_path()
1248        elif base == "__starter__":
1249            base = file_utils.current_starter_path()
1250        # else leave base as-is
1251
1252        self.filepaths = {
1253            path: os.path.join(base, path)
1254            for path in filepaths
1255        }
1256
1257        super().__init__(sid, title, caption, [], postscript)
1258
1259    def create_goal(self):
1260        """
1261        We override TestGroup.create_goal to just create a dummy goal
1262        which depends on dummy contexts.
1263        """
1264        ctx_list = []
1265        for showpath in self.filepaths:
1266            filepath = self.filepaths[showpath]
1267            with open(filepath, 'r', encoding="utf-8") as fileInput:
1268                contents = fileInput.read()
1269
1270            def make_builder():
1271                """
1272                We need to capture 'filepath' on each iteration of the
1273                loop, which is why this extra layer of indirection is
1274                added.
1275                """
1276                nonlocal showpath, filepath, contents
1277                sp = showpath
1278                fp = filepath
1279                ct = contents
1280                return lambda _: {
1281                    "path": sp,
1282                    "real_path": fp,
1283                    "contents": ct
1284                }
1285
1286            ctx_list.append(contexts.Context(builder=make_builder()))
1287
1288        return rubrics.NoteGoal(
1289            self.taskid,
1290            "fileSnippetGoal:" + self.base_name,
1291            (
1292                "NoteGoal for file snippet '{}'.".format(self.base_name),
1293                "A file-displaying NoteGoal."
1294            ),
1295            test_in={ "contexts": ctx_list }
1296        )
1297
1298    def render_context(self, context):
1299        """
1300        Renders a context with just file information as HTML markup for
1301        displaying a file with %more magic. Includes a Jupyter-style
1302        prompt for input as well as the contents of the file.
1303        """
1304        path = extract(context, "path")
1305        contents = extract(context, "contents")
1306
1307        result = (
1308            '<span class="prompt input">In []:</span>'
1309            '<div class="snippet-input">'
1310            '<code class="magic">%more {filename}</code>'
1311            '</div>\n'
1312            '<span class="prompt special">File</span>'
1313            '<div class="snippet-filename">{filename}</div>\n'
1314            '<div class="snippet-file-contents">{contents}</div>\n'
1315        ).format(filename=path, contents=contents)
1316
1317        return result
1318
1319
1320#---------#
1321# Glamers #
1322#---------#
1323
1324def append_image_result(markup, context):
1325    """
1326    Given some HTML markup and a context dictionary, turns the
1327    "ref_image" context slot into an HTML img tag and returns the given
1328    markup with that appended, prefixed by an "Image " prompt. If the
1329    context has no "image" value, the original markup is returned
1330    unmodified.
1331
1332    The "ref_image_alt" slot of the context is used as alt text for the
1333    image, with the "ref_output" slot being used as backup (under a
1334    hopeful assumption about turtleBeads being imported for printed
1335    descriptions). If neither is present, "no alt text available" will be
1336    used.
1337    """
1338    # Short-circuit unless we've got an image
1339    if "ref_image" not in context:
1340        return markup
1341
1342    image = extract(context, "ref_image")
1343    alt = context.get(
1344        "ref_image_alt",
1345        context.get("ref_output", "no alt text available")
1346    )
1347
1348    img_tag = html_tools.html_image(image, alt, ["example"])
1349    return (
1350        markup
1351      + '<span class="prompt special">Image </span>'
1352      + img_tag
1353    )
1354
1355
1356def append_audio_result(markup, context):
1357    """
1358    Given some HTML markup and a context dictionary, turns the
1359    "ref_audio" context slot into an HTML audio tag and returns the given
1360    markup with that appended, prefixed by an "Audio " prompt.
1361
1362    Note that this can result in a pretty large string if the WAV format
1363    is used, since the string needs to be base64-encoded and WAV is
1364    uncompressed (and we double the size by including the data URL twice)
1365    :(
1366
1367    TODO: Maybe use Ogg/Vorbis?
1368
1369    If there is no "ref_audio" context slot, the given markup is returned
1370    unmodified.
1371
1372    The "ref_audio" context value must be a dictionary with at least
1373    "mimetype" and "data" slots containing the MIME type for the data and
1374    the data itself (as a bytes object). It may also include a "label"
1375    slot which will be used invisibly as an aria-label property of the
1376    audio element; if absent no aria-label will be attached.
1377    """
1378    # Short-circuit unless we've got audio
1379    if "ref_audio" not in context:
1380        return markup
1381
1382    audio = extract(context, "ref_audio")
1383    mime = audio["mimetype"]
1384    data = audio["data"]
1385    label = audio.get("label")
1386
1387    audio_tag = html_tools.html_audio(data, mime, label)
1388    return (
1389        markup
1390      + '\n<span class="prompt special">Audio </span>'
1391      + audio_tag
1392    )
1393
1394
1395def append_memory_report(markup, context):
1396    """
1397    Given some HTML markup and a context dictionary, creates a memory
1398    report showing the structure of the object in the "ref_value" context
1399    slot, and appends that to the markup, using a 'Memory Report' tag.
1400    """
1401    # Do nothing if no ref_value is available
1402    if "ref_value" not in context:
1403        return markup
1404
1405    obj = extract(context, "ref_value")
1406
1407    report = html_tools.escape(specifications.memory_report(obj))
1408    formatted = "<pre>{report}</pre>".format(report=report)
1409    return (
1410        markup
1411      + '\n<span class="prompt special">Memory\nReport</span>'
1412      + formatted
1413    )
1414
1415
1416def append_file_contents(markup, context):
1417    """
1418    Given some HTML markup and a context dictionary, turns the
1419    "ref_output_filename" and "ref_output_file_contents" context slots
1420    into HTML markup displaying the contents of that file, which gets
1421    appended to the given markup and returned.
1422
1423    Note that this is intended to display the contents of text files.
1424
1425    If either of the "ref_output_filename" or "ref_output_file_contents"
1426    context slots are missing, the given markup is returned unmodified.
1427    """
1428    # Short-circuit unless we've got output file contents
1429    if (
1430        "ref_output_filename" not in context
1431     or "ref_output_file_contents" not in context
1432    ):
1433        return markup
1434
1435    filename = extract(context, "ref_output_filename")
1436    contents = extract(context, "ref_output_file_contents")
1437
1438    return (
1439        markup
1440      + (
1441            '\n<span class="prompt special">File</span>'
1442        )
1443      + '<div class="snippet-filename">{}</div>\n'.format(filename)
1444      + '<div class="snippet-file-contents">{}</div>\n'.format(contents)
1445    )
1446
1447
1448#--------#
1449# Lookup #
1450#--------#
1451
1452def list_snippets(task_info):
1453    """
1454    Returns a list containing all snippet IDs (strings) for the given
1455    task (as a task info dictionary).
1456    """
1457    reg = SNIPPET_REGISTRY.get(task_info["specification"].__name__, {})
1458    return list(reg.keys())
1459
1460
1461def get_html(task_info, sid):
1462    """
1463    Retrieves the HTML code (a string) for the snippet with the given ID
1464    in the given task (as a task info dictionary). Returns None if there
1465    is no such snippet.
1466    """
1467    reg = SNIPPET_REGISTRY.get(task_info["specification"].__name__, {})
1468    if sid not in reg:
1469        return None
1470    return reg[sid].get_html(task_info)
1471
1472
1473def get_all_snippets(task_info):
1474    """
1475    Returns a list of HTML strings containing each registered snippet for
1476    the given (as a task_info dictionary) task.
1477    """
1478    return [
1479        get_html(task_info, sid)
1480        for sid in list_snippets(task_info)
1481    ]
FMT_WIDTH = 80

Line width we'll attempt to hit for formatting output nicely.

def wrap_str(string, width):
106def wrap_str(string, width):
107    """
108    Returns a list of strings, which when combined form the given
109    string, where none is longer than the given width. Lines are broken
110    at spaces whenever possible, but may be broken in the middle of
111    words when a single word is longer than the target width. This
112    function uses a greedy algorithm that doesn't optimize the evenness
113    of the right margin. \\n and \\r are represented by those character
114    pairs, but other characters are as-is.
115    """
116    escaped = string.replace('\n', '\\n')
117    escaped = escaped.replace('\r', '\\r')
118    words = escaped.split(' ')
119    result = []
120    while len(words) > 0: # until we've used up the words
121        # Figure out how many words would fit on the next line
122        i = 0
123        # + i accounts for spaces we'll need
124        while len(words[:i + 1]) + (i + 1) < width:
125            i += 1
126
127        # Back off to the number that fits
128        i -= 1
129
130        # first word doesn't fit!
131        if i == -1:
132            first = words[0]
133            # Chop up the first word and put the pieces into the result
134            pieces = len(first) // width
135            for j in range(pieces):
136                result.append(first[width * j:width * (j + 1)])
137
138            # Handle leftovers
139            rest = first[width * pieces:]
140            # Note that even empty leftovers serve as a placeholder to
141            # ensure that a space gets added.
142            words[0] = rest # put rest back into words, replacing old
143        else:
144            # at least one word fits
145            line = ' '.join(words[:i + 1]) + ' '
146            result.append(line)
147            words = words[i + 1:]
148
149    # Remove the extra space introduced after the end of the last word
150    # Works even for an empty string input
151    result[-1] = result[-1][:-1]
152
153    return result

Returns a list of strings, which when combined form the given string, where none is longer than the given width. Lines are broken at spaces whenever possible, but may be broken in the middle of words when a single word is longer than the target width. This function uses a greedy algorithm that doesn't optimize the evenness of the right margin. \n and \r are represented by those character pairs, but other characters are as-is.

def wrapped_repr(obj, width=80, indentation=1, prefix='', memo=None):
156def wrapped_repr(obj, width=FMT_WIDTH, indentation=1, prefix='', memo=None):
157    """
158    Returns a string representing the given object, whose lines are not
159    longer than the given width, and which uses the given number of
160    spaces to indent sub-objects when necessary. Replacement for
161    pprint.pformat, which does NOT reorder dictionary keys. May violate
162    the width restriction when given a complex object that it doesn't
163    know how to wrap whose repr alone is wider than the specified width.
164
165    A prefix may be supplied, which will be added to the first line of
166    the given representation as-is. The prefix itself will not be subject
167    to wrapping, but the first line of the result will in some cases wrap
168    earlier to compensate for the prefix. The prefix should not include
169    any newlines or carriage returns.
170
171    The memo helps avoid problems with recursion, and doesn't need to be
172    provided explicitly.
173    """
174    # Create a memo if we need to
175    if memo is None:
176        memo = set()
177
178    # Check the memo
179    if id(obj) in memo:
180        return '...'
181
182    # Add ourselves to the memo
183    memo.add(id(obj))
184
185    simple = prefix + repr(obj)
186
187    if len(simple) <= width: # no need to do anything fancy
188        return simple
189
190    indent = ' ' * indentation
191    between = '\n' + indent
192
193    broken = repr(obj)
194    if len(broken) < width - indentation:
195        return prefix + '\\' + between + broken
196
197    elif type(obj) == str: # need to wrap this string
198        lines = wrap_str(obj, width - (indentation + 2))
199        return (
200            prefix + '(' + between
201          + between.join('"' + line + '"' for line in lines)
202          + '\n)'
203        )
204
205    elif type(obj) == bytes: # need to wrap these bytes
206        lines = wrap_str(obj, width - (indentation + 3))
207        return (
208            prefix + '(' + between
209          + between.join('b"' + line + '"' for line in lines)
210          + '\n)'
211        )
212
213    elif type(obj) in (list, tuple, set): # need to wrap each item
214        if type(obj) in (list, tuple):
215            ldelim, rdelim = str(type(obj)())
216        elif len(obj) == 0:
217            return prefix + 'set()' # too bad if width is < 5
218        else:
219            ldelim = '{'
220            rdelim = '}'
221
222        result = prefix + ldelim + between
223        for item in obj:
224            result += textwrap.indent(
225                wrapped_repr(item, width - indentation, indentation),
226                indent
227            ) + ',' + between
228
229        return result[:-len(between)] + '\n' + rdelim
230
231    elif type(obj) == dict: # need to wrap keys and values
232        indent = ' ' * indentation
233        between = '\n' + indent
234
235        result = prefix + '{' + between
236        for key in obj:
237            key_repr = wrapped_repr(
238                key,
239                width - (indentation + 1),
240                indentation
241            )
242            val = obj[key]
243            val_repr = wrapped_repr(
244                val,
245                width - indentation * 2,
246                indentation
247            )
248
249            # Can we fit on one line?
250            if len(indent) + len(key_repr) + len(val_repr) + 3 <= width:
251                result += key_repr + ': ' + val_repr + ',' + between
252            elif val_repr[1] == '\n': # ldelim/newline start
253                result += (
254                    key_repr + ':' + val_repr[0] + '\n'
255                  + textwrap.indent(val_repr[2:], indent)
256                  + ',' + between
257                )
258            else:
259                result += key_repr + ':\n' + textwrap.indent(
260                    val_repr,
261                    indent * 2
262                ) + ',' + between
263
264        # Chop off final between and add closing brace after a newline
265        result = result[:-len(between)] + '\n}'
266
267        return result
268
269    else: # Not sure how to wrap this, so we give up...
270        return prefix + repr(obj)

Returns a string representing the given object, whose lines are not longer than the given width, and which uses the given number of spaces to indent sub-objects when necessary. Replacement for pprint.pformat, which does NOT reorder dictionary keys. May violate the width restriction when given a complex object that it doesn't know how to wrap whose repr alone is wider than the specified width.

A prefix may be supplied, which will be added to the first line of the given representation as-is. The prefix itself will not be subject to wrapping, but the first line of the result will in some cases wrap earlier to compensate for the prefix. The prefix should not include any newlines or carriage returns.

The memo helps avoid problems with recursion, and doesn't need to be provided explicitly.

def wrapped_with_prefix(obj, prefix, indentation_level=0):
273def wrapped_with_prefix(obj, prefix, indentation_level=0):
274    """
275    Returns a string representing the given object, as `wrapped_repr`
276    would, except that:
277
278    1. The first line includes the given prefix (as a string without
279       repr applied to it; after indentation) and the wrapping of object
280       values takes this into account.
281    2. The entire representation is indented by the given indentation
282       level (one space per level).
283    """
284    rep = wrapped_repr(
285        obj,
286        width=FMT_WIDTH - indentation_level,
287        indentation=1,
288        prefix=prefix
289    )
290
291    # remove extra indentation from tuple
292    rep = textwrap.dedent(rep)
293
294    # restore indent and add prefix
295    return textwrap.indent(rep, ' ' * indentation_level)

Returns a string representing the given object, as wrapped_repr would, except that:

  1. The first line includes the given prefix (as a string without repr applied to it; after indentation) and the wrapping of object values takes this into account.
  2. The entire representation is indented by the given indentation level (one space per level).
def extract(context, key):
298def extract(context, key):
299    """
300    Gets value from a context but raises a ValueError showing the
301    context's error log if it can't.
302    """
303    if key in context:
304        return context[key]
305    else:
306        # Note: we use a ValueError here instead of a KeyError because
307        # KeyError uses repr on its message...
308        if "ref_error_log" in context:
309            raise ValueError(
310                (
311                    "Error: context is missing the '{}' key. Error log"
312                    " is:\n{}"
313                ).format(key, context["ref_error_log"]),
314            )
315        else:
316            raise ValueError(
317                "Error: context is missing the '{}' key. No error log"
318                " available."
319            )

Gets value from a context but raises a ValueError showing the context's error log if it can't.

SNIPPET_REGISTRY = {}

All registered snippets, by defining module and then snippet ID.

REMEMBER = {}

Things to remember so we can undo setup changes.

def snippet_setup(context):
342def snippet_setup(context):
343    """
344    If the wavesynth module is available, disables the playTrack and
345    saveTrack functions from that module. If the optimism module is
346    available, disables colors for that module.
347    """
348    try:
349        import wavesynth
350        REMEMBER["playTrack"] = wavesynth.playTrack
351        wavesynth.playTrack = lambda _=None: None
352        REMEMBER["saveTrack"] = wavesynth.saveTrack
353        wavesynth.saveTrack = lambda _: None
354    except ModuleNotFoundError:
355        pass
356
357    try:
358        import optimism
359        REMEMBER["opt_colors"] = optimism.COLORS
360        optimism.colors(False)
361    except ModuleNotFoundError:
362        pass
363
364    return context

If the wavesynth module is available, disables the playTrack and saveTrack functions from that module. If the optimism module is available, disables colors for that module.

def snippet_cleanup(context):
367def snippet_cleanup(context):
368    """
369    Puts things back how they were before `snippet_setup`.
370    """
371    try:
372        import wavesynth
373        wavesynth.playTrack = REMEMBER["playTrack"]
374        wavesynth.saveTrack = REMEMBER["saveTrack"]
375    except ModuleNotFoundError:
376        pass
377
378    try:
379        import optimism
380        optimism.colors(REMEMBER["opt_colors"])
381    except ModuleNotFoundError:
382        pass
383
384    return context

Puts things back how they were before snippet_setup.

class Snippet(potluck.specifications.TestGroup):
391class Snippet(specifications.TestGroup):
392    """
393    Common functionality for snippets. Don't instantiate this class;
394    instead use one of the subclasses `Variables`, `Expressions`, or
395    `RunModule`.
396
397    Note that a `Snippet` is a `potluck.specifications.TestGroup`, from
398    which it inherits a powerful interface for customizing behavior. Some
399    default modifications are applied to every snippet:
400
401    1. Output, including stderr and error tracebacks, is captured.
402    2. A setup function is run which disables turtle tracing, wavesynth
403        track playing/saving, and optimism colors (only when the relevant
404        module(s) are available). A cleanup function that reverses all of
405        these changes except the turtle speed is also run afterwards.
406    """
407    @staticmethod
408    def base_context(task_info):
409        """
410        Creates a base context object for a snippet, given a task info
411        object.
412        """
413        return {
414            "task_info": task_info,
415            "username": "__soln__",
416            "submission_root": task_info["specification"].soln_path,
417            "default_file": task_info["target"],
418            "actual_file": task_info["target"]
419        }
420
421    def __init__(
422        self,
423        sid,
424        title,
425        caption,
426        tests,
427        postscript='',
428        glamers=None,
429        ignore_errors=False
430    ):
431        """
432        All `Snippet`s have a snippet ID, a title, a caption, and one or
433        more tests. `tests` must be an iterable of unregistered
434        `potluck.specifications.TestCase` objects.
435
436        An optional postscript is markdown source to be placed after the
437        code block.
438
439        An optional list of glamer functions may be provided. These will
440        be given individual pieces of HTML code and the associated
441        context those were rendered from and their result value will
442        replace the HTML code to be used; they are applied in-order.
443
444        Unless ignore_errors is set to False (default is True), errors
445        generated by the base payload and captured along with output will
446        be logged under the assumption that in most cases an exception in
447        a snippet is a bug with the snippet code rather than intentional.
448        """
449        # Set ourselves up as a TestGroup...
450        super().__init__(sid, '_')
451
452        # We store our arguments
453        self.title = title
454        self.caption = caption
455        self.postscript = postscript
456        self.glamers = glamers or []
457        self.ignore_errors = ignore_errors
458
459        # Final edit function to manipulate the context to be rendered
460        self.editor = None
461
462        # What's the name of our spec file?
463        self.spec_file = file_utils.get_spec_file_name()
464
465        # Add our tests to ourself
466        for t in tests:
467            self.add(t)
468
469        # Fetch the defining-module-specific registry
470        reg = SNIPPET_REGISTRY.setdefault(
471            file_utils.get_spec_module_name(),
472            {}
473        )
474
475        # Prevent ID collision
476        if sid in reg:
477            raise ValueError(
478                "Multiple snippets cannot be registered with the same"
479                " ID ('{sid}')."
480            )
481
482        # Register ourself
483        reg[sid] = self
484
485        # A place to cache our compiled result
486        self.cached_for = None
487        self.cached_value = None
488
489        # Set up default augmentations
490        self.capture_output(capture_errors=True, capture_stderr=True)
491        self.do_setup(snippet_setup)
492        self.do_cleanup(snippet_cleanup)
493
494    def provide(self, vars_map):
495        """
496        A convenience function for providing the snippet with values not
497        defined in the solution module. Under the hood, this uses
498        `specifications.HasPayload.use_decorations`, so using that
499        alongside this will not work (one will overwrite the other). This
500        also means that you cannot call `provide` multiple times on the
501        same instance to accumulate provided values: each call discards
502        previous provided values.
503        """
504        self.use_decorations(
505            {
506                k: (lambda _: v)
507                for (k, v) in vars_map.items()
508            },
509            ignore_missing=True
510        )
511
512    # TODO: define replacements so that formatting of calls can use
513    # varnames?
514
515    def compile(self, base_context):
516        """
517        Runs the snippet, collects its results, and formats them as HTML.
518        Returns a string containing HTML code for displaying the snippet
519        (which assumes that the potluck.css stylesheet will be loaded).
520
521        This method will always re-run the compilation process,
522        regardless of whether a cached result is available, and will
523        update the cached result. Use `Snippet.get_html` to recompile
524        only as needed.
525
526        Requires a base context object (see `potluck.contexts.Context`
527        for the required structure).
528
529        The returned HTML's outermost tag is a &lt;section&gt; tag with
530        an id that starts with 'snippet:' and then ends with the
531        snippet's ID value.
532        """
533        # Set up traceback-rewriting for the specifications module we
534        # were defined in
535        html_tools.set_tb_rewrite(
536            base_context["task_info"]["specification"].__file__,
537            "<task specification>"
538        )
539        html_tools.set_tb_rewrite(
540            base_context["submission_root"],
541            "<solution>"
542        )
543
544        # Tell the run_for_base_and_ref_values augmentation to run only
545        # for ref values.
546        self.augmentations.setdefault(
547            "run_for_base_and_ref_values",
548            {}
549        )["ref_only"] = True
550
551        for case in self.tests:
552            case.augmentations.setdefault(
553                "run_for_base_and_ref_values",
554                {}
555            )["ref_only"] = True
556
557        # Create our Goal object
558        goal = self.provide_goal()
559
560        # Reset and evaluate our goal:
561        goal.reset_network()
562        goal.evaluate(base_context)
563
564        # Grab the contexts that were used for each test
565        if goal.test_in and len(goal.test_in["contexts"]) > 0:
566            contexts = goal.test_in["contexts"]
567        else:
568            # This shouldn't be possible unless an empty list of
569            # variables or functions was provided...
570            raise ValueError(
571                "A Snippet must have at least one context to compile."
572                " (Did you pass an empty list to Variables or"
573                " FunctionCalls?)"
574            )
575
576        # Translate from markdown
577        title = render.render_markdown(self.title)
578        caption = render.render_markdown(self.caption)
579        post = render.render_markdown(self.postscript)
580
581        context_dicts = []
582        for ctx in contexts:
583            try:
584                cdict = ctx.create(base_context)
585            except context_utils.ContextCreationError:
586                cdict = {
587                    # for RunModule snippets (note no ref_)
588                    "filename": "UNKNOWN",
589                    # In case it's a Variables snippet
590                    "ref_variable": "UNKNOWN",
591                    # for FunctionCalls snippets
592                    "ref_function": "UNKNOWN",
593                    "ref_args": (),
594                    "ref_kwargs": {},
595                    # for Blocks snippets
596                    "ref_block": "UNKNOWN",
597                    # For all kinds of snippets
598                    "ref_value": "There was an error compiling this snippet.",
599                    "ref_output": "There was an error compiling this snippet.",
600                    "ref_error_log": html_tools.string_traceback()
601                }
602            context_dicts.append(cdict)
603
604        # Strip out solution file paths from output, and perform final
605        # edits if we have an editor function
606        soln_path = os.path.abspath(
607            base_context["task_info"]["specification"].soln_path
608        )
609        for context in context_dicts:
610            if (
611                "ref_output" in context
612            and soln_path in context["ref_output"]
613            ):
614                context["ref_output"] = context["ref_output"].replace(
615                    soln_path,
616                    '&lt;soln&gt;'
617                )
618
619            if (
620                "ref_error_log" in context
621            and soln_path in context["ref_error_log"]
622            ):
623                context["ref_error_log"] = context["ref_error_log"].replace(
624                    soln_path,
625                    '&lt;soln&gt;'
626                )
627
628            if self.editor:
629                self.editor(context)
630
631        # Include title & caption, and render each context to produce our
632        # result
633        result = (
634            '<section class="snippet" id="snippet:{sid}">'
635            '<div class="snippet_title">{title}</div>\n'
636            '{caption}\n<pre>{snippet}</pre>\n{post}'
637            '</section>'
638        ).format(
639            sid=self.base_name,
640            title=title,
641            caption=caption,
642            post=post,
643            snippet=''.join(
644                self.compile_context(cd)
645                for cd in context_dicts
646            )
647        )
648
649        self.cached_for = base_context["task_info"]["id"]
650        self.cached_value = result
651
652        return result
653
654    def compile_context(self, context):
655        """
656        Turns a context dictionary resulting from an individual snippet
657        case into HTML code for displaying that result, using
658        `render_context` but also potentially adding additional HTML for
659        extra context slots (see e.g., `show_image` and `play_audio`).
660        """
661        if not self.ignore_errors and "ref_error" in context:
662            logging.log(
663                "Error captured by context creation:\n"
664              + context["ref_error"]
665              + "\nFull log is:\n"
666              + context.get("ref_error_log", '<missing>')
667            )
668        result = self.render_context(context)
669        for glamer in self.glamers:
670            result = glamer(result, context)
671
672        return result
673
674    def render_context(self, context):
675        """
676        Override this to define how a specific Snippet sub-class should
677        render a context dictionary into HTML code.
678        """
679        raise NotImplementedError(
680            "Snippet is abstract and context rendering is only available"
681            " via overrides in sub-classes."
682        )
683
684    def get_html(self, task_info):
685        """
686        Returns the snippet value, either cached or newly-compiled
687        depending on the presence of an appropriate cached value.
688        """
689        if self.cached_for == task_info["id"]:
690            return self.cached_value
691        else:
692            return self.compile(Snippet.base_context(task_info))
693
694    def final_edit(self, editor):
695        """
696        Sets up a function to be run on each created context just before
697        that context gets rendered. The editor may mutate the context it
698        is given in order to change what gets rendered. Multiple calls to
699        this function each simply replace the previous editor function.
700
701        Note that the ref_* values from the context are used to create
702        the snippet.
703        """
704        self.editor = editor
705
706    def show_image(self):
707        """
708        Adds a glamer that augments output by appending a "Image "
709        prompt followed by an image element which shows the contents of
710        the "ref_image" context slot. To establish a "ref_image" slot,
711        call a method like
712        `potluck.specifications.HasPayload.capture_turtle_image`.
713
714        Returns self for chaining.
715        """
716        self.glamers.append(append_image_result)
717        return self
718
719    def play_audio(self):
720        """
721        Adds a glamer that augments output by appending an "Audio "
722        prompt followed by an audio element which plays the contents of
723        the "ref_audio" context slot. To make an "ref_audio" slot, call a
724        method like
725        `potluck.specifications.HasPayload.capture_wavesynth`.
726
727        Returns self for chaining.
728        """
729        self.glamers.append(append_audio_result)
730        return self
731
732    def show_memory_report(self):
733        """
734        Adds a glamer that augments output by appending a "Memory Report"
735        prompt followed by a memory report showing the structure of the
736        "ref_value" context slot.
737
738        Returns self for chaining.
739        """
740        self.glamers.append(append_memory_report)
741        return self
742
743    def show_file_contents(self):
744        """
745        Adds a glamer that augments output by appending a "File"
746        prompt followed by a text box showing the filename and the
747        contents of that file. Requires 'ref_output_filename' and
748        'ref_output_file_contents' slots, which could be established
749        using a method like
750        `potluck.specifications.HasPayload.capture_file_contents`
751
752        Returns self for chaining.
753        """
754        self.glamers.append(append_file_contents)
755        return self

Common functionality for snippets. Don't instantiate this class; instead use one of the subclasses Variables, Expressions, or RunModule.

Note that a Snippet is a potluck.specifications.TestGroup, from which it inherits a powerful interface for customizing behavior. Some default modifications are applied to every snippet:

  1. Output, including stderr and error tracebacks, is captured.
  2. A setup function is run which disables turtle tracing, wavesynth track playing/saving, and optimism colors (only when the relevant module(s) are available). A cleanup function that reverses all of these changes except the turtle speed is also run afterwards.
Snippet( sid, title, caption, tests, postscript='', glamers=None, ignore_errors=False)
421    def __init__(
422        self,
423        sid,
424        title,
425        caption,
426        tests,
427        postscript='',
428        glamers=None,
429        ignore_errors=False
430    ):
431        """
432        All `Snippet`s have a snippet ID, a title, a caption, and one or
433        more tests. `tests` must be an iterable of unregistered
434        `potluck.specifications.TestCase` objects.
435
436        An optional postscript is markdown source to be placed after the
437        code block.
438
439        An optional list of glamer functions may be provided. These will
440        be given individual pieces of HTML code and the associated
441        context those were rendered from and their result value will
442        replace the HTML code to be used; they are applied in-order.
443
444        Unless ignore_errors is set to False (default is True), errors
445        generated by the base payload and captured along with output will
446        be logged under the assumption that in most cases an exception in
447        a snippet is a bug with the snippet code rather than intentional.
448        """
449        # Set ourselves up as a TestGroup...
450        super().__init__(sid, '_')
451
452        # We store our arguments
453        self.title = title
454        self.caption = caption
455        self.postscript = postscript
456        self.glamers = glamers or []
457        self.ignore_errors = ignore_errors
458
459        # Final edit function to manipulate the context to be rendered
460        self.editor = None
461
462        # What's the name of our spec file?
463        self.spec_file = file_utils.get_spec_file_name()
464
465        # Add our tests to ourself
466        for t in tests:
467            self.add(t)
468
469        # Fetch the defining-module-specific registry
470        reg = SNIPPET_REGISTRY.setdefault(
471            file_utils.get_spec_module_name(),
472            {}
473        )
474
475        # Prevent ID collision
476        if sid in reg:
477            raise ValueError(
478                "Multiple snippets cannot be registered with the same"
479                " ID ('{sid}')."
480            )
481
482        # Register ourself
483        reg[sid] = self
484
485        # A place to cache our compiled result
486        self.cached_for = None
487        self.cached_value = None
488
489        # Set up default augmentations
490        self.capture_output(capture_errors=True, capture_stderr=True)
491        self.do_setup(snippet_setup)
492        self.do_cleanup(snippet_cleanup)

All Snippets have a snippet ID, a title, a caption, and one or more tests. tests must be an iterable of unregistered potluck.specifications.TestCase objects.

An optional postscript is markdown source to be placed after the code block.

An optional list of glamer functions may be provided. These will be given individual pieces of HTML code and the associated context those were rendered from and their result value will replace the HTML code to be used; they are applied in-order.

Unless ignore_errors is set to False (default is True), errors generated by the base payload and captured along with output will be logged under the assumption that in most cases an exception in a snippet is a bug with the snippet code rather than intentional.

@staticmethod
def base_context(task_info):
407    @staticmethod
408    def base_context(task_info):
409        """
410        Creates a base context object for a snippet, given a task info
411        object.
412        """
413        return {
414            "task_info": task_info,
415            "username": "__soln__",
416            "submission_root": task_info["specification"].soln_path,
417            "default_file": task_info["target"],
418            "actual_file": task_info["target"]
419        }

Creates a base context object for a snippet, given a task info object.

def provide(self, vars_map):
494    def provide(self, vars_map):
495        """
496        A convenience function for providing the snippet with values not
497        defined in the solution module. Under the hood, this uses
498        `specifications.HasPayload.use_decorations`, so using that
499        alongside this will not work (one will overwrite the other). This
500        also means that you cannot call `provide` multiple times on the
501        same instance to accumulate provided values: each call discards
502        previous provided values.
503        """
504        self.use_decorations(
505            {
506                k: (lambda _: v)
507                for (k, v) in vars_map.items()
508            },
509            ignore_missing=True
510        )

A convenience function for providing the snippet with values not defined in the solution module. Under the hood, this uses specifications.HasPayload.use_decorations, so using that alongside this will not work (one will overwrite the other). This also means that you cannot call provide multiple times on the same instance to accumulate provided values: each call discards previous provided values.

def compile(self, base_context):
515    def compile(self, base_context):
516        """
517        Runs the snippet, collects its results, and formats them as HTML.
518        Returns a string containing HTML code for displaying the snippet
519        (which assumes that the potluck.css stylesheet will be loaded).
520
521        This method will always re-run the compilation process,
522        regardless of whether a cached result is available, and will
523        update the cached result. Use `Snippet.get_html` to recompile
524        only as needed.
525
526        Requires a base context object (see `potluck.contexts.Context`
527        for the required structure).
528
529        The returned HTML's outermost tag is a &lt;section&gt; tag with
530        an id that starts with 'snippet:' and then ends with the
531        snippet's ID value.
532        """
533        # Set up traceback-rewriting for the specifications module we
534        # were defined in
535        html_tools.set_tb_rewrite(
536            base_context["task_info"]["specification"].__file__,
537            "<task specification>"
538        )
539        html_tools.set_tb_rewrite(
540            base_context["submission_root"],
541            "<solution>"
542        )
543
544        # Tell the run_for_base_and_ref_values augmentation to run only
545        # for ref values.
546        self.augmentations.setdefault(
547            "run_for_base_and_ref_values",
548            {}
549        )["ref_only"] = True
550
551        for case in self.tests:
552            case.augmentations.setdefault(
553                "run_for_base_and_ref_values",
554                {}
555            )["ref_only"] = True
556
557        # Create our Goal object
558        goal = self.provide_goal()
559
560        # Reset and evaluate our goal:
561        goal.reset_network()
562        goal.evaluate(base_context)
563
564        # Grab the contexts that were used for each test
565        if goal.test_in and len(goal.test_in["contexts"]) > 0:
566            contexts = goal.test_in["contexts"]
567        else:
568            # This shouldn't be possible unless an empty list of
569            # variables or functions was provided...
570            raise ValueError(
571                "A Snippet must have at least one context to compile."
572                " (Did you pass an empty list to Variables or"
573                " FunctionCalls?)"
574            )
575
576        # Translate from markdown
577        title = render.render_markdown(self.title)
578        caption = render.render_markdown(self.caption)
579        post = render.render_markdown(self.postscript)
580
581        context_dicts = []
582        for ctx in contexts:
583            try:
584                cdict = ctx.create(base_context)
585            except context_utils.ContextCreationError:
586                cdict = {
587                    # for RunModule snippets (note no ref_)
588                    "filename": "UNKNOWN",
589                    # In case it's a Variables snippet
590                    "ref_variable": "UNKNOWN",
591                    # for FunctionCalls snippets
592                    "ref_function": "UNKNOWN",
593                    "ref_args": (),
594                    "ref_kwargs": {},
595                    # for Blocks snippets
596                    "ref_block": "UNKNOWN",
597                    # For all kinds of snippets
598                    "ref_value": "There was an error compiling this snippet.",
599                    "ref_output": "There was an error compiling this snippet.",
600                    "ref_error_log": html_tools.string_traceback()
601                }
602            context_dicts.append(cdict)
603
604        # Strip out solution file paths from output, and perform final
605        # edits if we have an editor function
606        soln_path = os.path.abspath(
607            base_context["task_info"]["specification"].soln_path
608        )
609        for context in context_dicts:
610            if (
611                "ref_output" in context
612            and soln_path in context["ref_output"]
613            ):
614                context["ref_output"] = context["ref_output"].replace(
615                    soln_path,
616                    '&lt;soln&gt;'
617                )
618
619            if (
620                "ref_error_log" in context
621            and soln_path in context["ref_error_log"]
622            ):
623                context["ref_error_log"] = context["ref_error_log"].replace(
624                    soln_path,
625                    '&lt;soln&gt;'
626                )
627
628            if self.editor:
629                self.editor(context)
630
631        # Include title & caption, and render each context to produce our
632        # result
633        result = (
634            '<section class="snippet" id="snippet:{sid}">'
635            '<div class="snippet_title">{title}</div>\n'
636            '{caption}\n<pre>{snippet}</pre>\n{post}'
637            '</section>'
638        ).format(
639            sid=self.base_name,
640            title=title,
641            caption=caption,
642            post=post,
643            snippet=''.join(
644                self.compile_context(cd)
645                for cd in context_dicts
646            )
647        )
648
649        self.cached_for = base_context["task_info"]["id"]
650        self.cached_value = result
651
652        return result

Runs the snippet, collects its results, and formats them as HTML. Returns a string containing HTML code for displaying the snippet (which assumes that the potluck.css stylesheet will be loaded).

This method will always re-run the compilation process, regardless of whether a cached result is available, and will update the cached result. Use Snippet.get_html to recompile only as needed.

Requires a base context object (see potluck.contexts.Context for the required structure).

The returned HTML's outermost tag is a <section> tag with an id that starts with 'snippet:' and then ends with the snippet's ID value.

def compile_context(self, context):
654    def compile_context(self, context):
655        """
656        Turns a context dictionary resulting from an individual snippet
657        case into HTML code for displaying that result, using
658        `render_context` but also potentially adding additional HTML for
659        extra context slots (see e.g., `show_image` and `play_audio`).
660        """
661        if not self.ignore_errors and "ref_error" in context:
662            logging.log(
663                "Error captured by context creation:\n"
664              + context["ref_error"]
665              + "\nFull log is:\n"
666              + context.get("ref_error_log", '<missing>')
667            )
668        result = self.render_context(context)
669        for glamer in self.glamers:
670            result = glamer(result, context)
671
672        return result

Turns a context dictionary resulting from an individual snippet case into HTML code for displaying that result, using render_context but also potentially adding additional HTML for extra context slots (see e.g., show_image and play_audio).

def render_context(self, context):
674    def render_context(self, context):
675        """
676        Override this to define how a specific Snippet sub-class should
677        render a context dictionary into HTML code.
678        """
679        raise NotImplementedError(
680            "Snippet is abstract and context rendering is only available"
681            " via overrides in sub-classes."
682        )

Override this to define how a specific Snippet sub-class should render a context dictionary into HTML code.

def get_html(self, task_info):
684    def get_html(self, task_info):
685        """
686        Returns the snippet value, either cached or newly-compiled
687        depending on the presence of an appropriate cached value.
688        """
689        if self.cached_for == task_info["id"]:
690            return self.cached_value
691        else:
692            return self.compile(Snippet.base_context(task_info))

Returns the snippet value, either cached or newly-compiled depending on the presence of an appropriate cached value.

def final_edit(self, editor):
694    def final_edit(self, editor):
695        """
696        Sets up a function to be run on each created context just before
697        that context gets rendered. The editor may mutate the context it
698        is given in order to change what gets rendered. Multiple calls to
699        this function each simply replace the previous editor function.
700
701        Note that the ref_* values from the context are used to create
702        the snippet.
703        """
704        self.editor = editor

Sets up a function to be run on each created context just before that context gets rendered. The editor may mutate the context it is given in order to change what gets rendered. Multiple calls to this function each simply replace the previous editor function.

Note that the ref_* values from the context are used to create the snippet.

def show_image(self):
706    def show_image(self):
707        """
708        Adds a glamer that augments output by appending a "Image "
709        prompt followed by an image element which shows the contents of
710        the "ref_image" context slot. To establish a "ref_image" slot,
711        call a method like
712        `potluck.specifications.HasPayload.capture_turtle_image`.
713
714        Returns self for chaining.
715        """
716        self.glamers.append(append_image_result)
717        return self

Adds a glamer that augments output by appending a "Image " prompt followed by an image element which shows the contents of the "ref_image" context slot. To establish a "ref_image" slot, call a method like potluck.specifications.HasPayload.capture_turtle_image.

Returns self for chaining.

def play_audio(self):
719    def play_audio(self):
720        """
721        Adds a glamer that augments output by appending an "Audio "
722        prompt followed by an audio element which plays the contents of
723        the "ref_audio" context slot. To make an "ref_audio" slot, call a
724        method like
725        `potluck.specifications.HasPayload.capture_wavesynth`.
726
727        Returns self for chaining.
728        """
729        self.glamers.append(append_audio_result)
730        return self

Adds a glamer that augments output by appending an "Audio " prompt followed by an audio element which plays the contents of the "ref_audio" context slot. To make an "ref_audio" slot, call a method like potluck.specifications.HasPayload.capture_wavesynth.

Returns self for chaining.

def show_memory_report(self):
732    def show_memory_report(self):
733        """
734        Adds a glamer that augments output by appending a "Memory Report"
735        prompt followed by a memory report showing the structure of the
736        "ref_value" context slot.
737
738        Returns self for chaining.
739        """
740        self.glamers.append(append_memory_report)
741        return self

Adds a glamer that augments output by appending a "Memory Report" prompt followed by a memory report showing the structure of the "ref_value" context slot.

Returns self for chaining.

def show_file_contents(self):
743    def show_file_contents(self):
744        """
745        Adds a glamer that augments output by appending a "File"
746        prompt followed by a text box showing the filename and the
747        contents of that file. Requires 'ref_output_filename' and
748        'ref_output_file_contents' slots, which could be established
749        using a method like
750        `potluck.specifications.HasPayload.capture_file_contents`
751
752        Returns self for chaining.
753        """
754        self.glamers.append(append_file_contents)
755        return self

Adds a glamer that augments output by appending a "File" prompt followed by a text box showing the filename and the contents of that file. Requires 'ref_output_filename' and 'ref_output_file_contents' slots, which could be established using a method like potluck.specifications.HasPayload.capture_file_contents

Returns self for chaining.

def highlight_code(code):
758def highlight_code(code):
759    """
760    Runs pygments highlighting but converts from a div/pre/code setup
761    back to just a code tag with the 'highlight' class.
762    """
763    markup = pygments.highlight(
764        code,
765        pygments.lexers.PythonLexer(),
766        pygments.formatters.HtmlFormatter()
767    )
768    # Note: markup will start a div and a pre we want to get rid of
769    start = '<div class="highlight"><pre>'
770    end = '</pre></div>\n'
771    if markup.startswith(start):
772        markup = markup[len(start):]
773    if markup.endswith(end):
774        markup = markup[:-len(end)]
775
776    return '<code class="highlight">{}</code>'.format(markup)

Runs pygments highlighting but converts from a div/pre/code setup back to just a code tag with the 'highlight' class.

class Variables(Snippet):
779class Variables(Snippet):
780    """
781    A snippet which shows the definition of one or more variables. Use
782    this to display examples of input data, especially when you want to
783    use shorter expressions in examples of running code. If you want to
784    use variables that aren't defined in the solution module, use the
785    `Snippet.provide` method (but note that that method is incompatible
786    with using `specifications.HasPayload.use_decorations`).
787    """
788    def __init__(self, sid, title, caption, varnames, postscript=''):
789        """
790        A snippet ID, a title, and a caption are required, as is a list
791        of strings indicating the names of variables to show definitions
792        of. Use `Snippet.provide` to supply values for variables not in
793        the solution module.
794
795        A postscript is optional (see `Snippet`).
796        """
797        cases = [
798            specifications.TestValue(
799                varname,
800                register=False
801            )
802            for varname in varnames
803        ]
804        super().__init__(sid, title, caption, cases, postscript)
805
806    def render_context(self, context):
807        """
808        Renders a context created for goal evaluation as HTML markup for
809        the definition of a variable, including Jupyter-notebook-style
810        prompts.
811        """
812        varname = extract(context, "ref_variable")
813        value = extract(context, "ref_value")
814
815        # format value's repr using wrapped_repr, but leaving room for
816        # varname = at beginning of first line
817        rep = wrapped_with_prefix(value, varname + ' = ')
818
819        # Use pygments to generate HTML markup for our assignment
820        markup = highlight_code(rep)
821        return (
822            '<span class="prompt input">In []:</span>'
823            '<div class="snippet-input">{}</div>\n'
824        ).format(markup)

A snippet which shows the definition of one or more variables. Use this to display examples of input data, especially when you want to use shorter expressions in examples of running code. If you want to use variables that aren't defined in the solution module, use the Snippet.provide method (but note that that method is incompatible with using specifications.HasPayload.use_decorations).

Variables(sid, title, caption, varnames, postscript='')
788    def __init__(self, sid, title, caption, varnames, postscript=''):
789        """
790        A snippet ID, a title, and a caption are required, as is a list
791        of strings indicating the names of variables to show definitions
792        of. Use `Snippet.provide` to supply values for variables not in
793        the solution module.
794
795        A postscript is optional (see `Snippet`).
796        """
797        cases = [
798            specifications.TestValue(
799                varname,
800                register=False
801            )
802            for varname in varnames
803        ]
804        super().__init__(sid, title, caption, cases, postscript)

A snippet ID, a title, and a caption are required, as is a list of strings indicating the names of variables to show definitions of. Use Snippet.provide to supply values for variables not in the solution module.

A postscript is optional (see Snippet).

def render_context(self, context):
806    def render_context(self, context):
807        """
808        Renders a context created for goal evaluation as HTML markup for
809        the definition of a variable, including Jupyter-notebook-style
810        prompts.
811        """
812        varname = extract(context, "ref_variable")
813        value = extract(context, "ref_value")
814
815        # format value's repr using wrapped_repr, but leaving room for
816        # varname = at beginning of first line
817        rep = wrapped_with_prefix(value, varname + ' = ')
818
819        # Use pygments to generate HTML markup for our assignment
820        markup = highlight_code(rep)
821        return (
822            '<span class="prompt input">In []:</span>'
823            '<div class="snippet-input">{}</div>\n'
824        ).format(markup)

Renders a context created for goal evaluation as HTML markup for the definition of a variable, including Jupyter-notebook-style prompts.

class RunModule(Snippet):
827class RunModule(Snippet):
828    """
829    A snippet which shows the output produced by running a module.
830    Functions like `potluck.specifications.HasPayload.provide_inputs`
831    can be used to control exactly what happens.
832
833    The module to import is specified by the currently active file
834    context (see `potluck.contexts.FileContext`).
835    """
836    def __init__(self, sid, title, caption, postscript=''):
837        """
838        A snippet ID, title, and caption are required.
839
840        A postscript is optional (see `Snippet`).
841        """
842        cases = [ specifications.TestImport(register=False) ]
843        super().__init__(sid, title, caption, cases, postscript)
844
845    def render_context(self, context):
846        """
847        Renders a context created for goal evaluation as HTML markup for
848        running a module. Includes a Jupyter-style prompt with %run magic
849        syntax to show which file was run.
850        """
851        filename = extract(context, "filename") # Note: no ref_ here
852        captured = context.get("ref_output", '')
853        captured_errs = context.get("ref_error_log", '')
854
855        # Wrap faked inputs with spans so we can color them blue
856        captured = re.sub(
857            harness.FAKE_INPUT_PATTERN,
858            r'<span class="input">\1</span>',
859            captured
860        )
861
862        result = (
863            '<span class="prompt input">In []:</span>'
864            '<div class="snippet-input">'
865            '<code class="magic">%run {filename}</code>'
866            '</div>\n'
867        ).format(filename=filename)
868        if captured:
869            result += (
870                '<span class="prompt printed">Prints</span>'
871                '<div class="snippet-printed">{captured}</div>\n'
872            ).format(captured=captured)
873        if captured_errs:
874            result += (
875                '<span class="prompt stderr">Logs</span>'
876                '<div class="snippet-stderr">{log}</div>\n'
877            ).format(log=captured_errs)
878
879        return result

A snippet which shows the output produced by running a module. Functions like potluck.specifications.HasPayload.provide_inputs can be used to control exactly what happens.

The module to import is specified by the currently active file context (see potluck.contexts.FileContext).

RunModule(sid, title, caption, postscript='')
836    def __init__(self, sid, title, caption, postscript=''):
837        """
838        A snippet ID, title, and caption are required.
839
840        A postscript is optional (see `Snippet`).
841        """
842        cases = [ specifications.TestImport(register=False) ]
843        super().__init__(sid, title, caption, cases, postscript)

A snippet ID, title, and caption are required.

A postscript is optional (see Snippet).

def render_context(self, context):
845    def render_context(self, context):
846        """
847        Renders a context created for goal evaluation as HTML markup for
848        running a module. Includes a Jupyter-style prompt with %run magic
849        syntax to show which file was run.
850        """
851        filename = extract(context, "filename") # Note: no ref_ here
852        captured = context.get("ref_output", '')
853        captured_errs = context.get("ref_error_log", '')
854
855        # Wrap faked inputs with spans so we can color them blue
856        captured = re.sub(
857            harness.FAKE_INPUT_PATTERN,
858            r'<span class="input">\1</span>',
859            captured
860        )
861
862        result = (
863            '<span class="prompt input">In []:</span>'
864            '<div class="snippet-input">'
865            '<code class="magic">%run {filename}</code>'
866            '</div>\n'
867        ).format(filename=filename)
868        if captured:
869            result += (
870                '<span class="prompt printed">Prints</span>'
871                '<div class="snippet-printed">{captured}</div>\n'
872            ).format(captured=captured)
873        if captured_errs:
874            result += (
875                '<span class="prompt stderr">Logs</span>'
876                '<div class="snippet-stderr">{log}</div>\n'
877            ).format(log=captured_errs)
878
879        return result

Renders a context created for goal evaluation as HTML markup for running a module. Includes a Jupyter-style prompt with %run magic syntax to show which file was run.

class FunctionCalls(Snippet):
882class FunctionCalls(Snippet):
883    """
884    A snippet which shows the results (printed output and return values)
885    of calling one or more functions. To control what happens in detail,
886    use specialization methods from `potluck.specifications.HasPayload`
887    and `potluck.specifications.HasContext`.
888    """
889    def __init__(self, sid, title, caption, calls, postscript=''):
890        """
891        A snippet ID, title, and caption are required, along with a list
892        of function calls. Each entry in the list must be a tuple
893        containing a function name followed by a tuple of arguments,
894        and optionally, a dictionary of keyword arguments.
895
896        A postscript is optional (see `Snippet`).
897        """
898        cases = [
899            specifications.TestCase(
900                fname,
901                args,
902                kwargs or {},
903                register=False
904            )
905            for fname, args, kwargs in (
906                map(lambda case: (case + (None,))[:3], calls)
907            )
908        ]
909        super().__init__(sid, title, caption, cases, postscript)
910
911    def render_context(self, context):
912        """
913        Renders a context created for goal evaluation as HTML markup for
914        calling a function. Includes a Jupyter-style prompt for input as
915        well as the return value.
916        """
917        if "ref_error" in context:
918            print(
919                "Error during context creation:"
920              + context["ref_error"]
921              + "\nFull log is:\n"
922              + context.get("ref_error_log", '<missing>')
923            )
924        fname = extract(context, "ref_function")
925        value = extract(context, "ref_value")
926        args = extract(context, "ref_args")
927        kwargs = extract(context, "ref_kwargs")
928        captured = context.get("ref_output", '')
929        captured_errs = context.get("ref_error_log", '')
930
931        # Figure out representations of each argument
932        argreps = []
933        for arg in args:
934            argreps.append(wrapped_with_prefix(arg, '', 1))
935
936        for kw in kwargs:
937            argreps.append(wrapped_with_prefix(kwargs[kw], kw + "=", 1))
938
939        # Figure out full function call representation
940        oneline = "{}({})".format(
941            fname,
942            ', '.join(rep.strip() for rep in argreps)
943        )
944        if '\n' not in oneline and len(oneline) <= FMT_WIDTH:
945            callrep = oneline
946        else:
947            callrep = "{}(\n{}\n)".format(fname, ',\n'.join(argreps))
948
949        # Wrap faked inputs with spans so we can color them blue
950        captured = re.sub(
951            harness.FAKE_INPUT_PATTERN,
952            r'<span class="input">\1</span>',
953            captured
954        )
955
956        # Highlight the function call
957        callrep = highlight_code(callrep)
958
959        result = (
960            '<span class="prompt input">In []:</span>'
961            '<div class="snippet-input">{}</div>'
962        ).format(callrep)
963
964        if captured:
965            result += (
966                '<span class="prompt printed">Prints</span>'
967                '<div class="snippet-printed">{}</div>\n'
968            ).format(captured)
969
970        if captured_errs:
971            result += (
972                '<span class="prompt stderr">Logs</span>'
973                '<div class="snippet-stderr">{log}</div>\n'
974            ).format(log=captured_errs)
975
976        # Highlight the return value
977        if value is not None:
978            value = highlight_code(wrapped_with_prefix(value, ""))
979
980            result += (
981                '<span class="prompt output">Out[]:</span>'
982                '<div class="snippet-output">{}</div>\n'
983            ).format(value)
984
985        return result

A snippet which shows the results (printed output and return values) of calling one or more functions. To control what happens in detail, use specialization methods from potluck.specifications.HasPayload and potluck.specifications.HasContext.

FunctionCalls(sid, title, caption, calls, postscript='')
889    def __init__(self, sid, title, caption, calls, postscript=''):
890        """
891        A snippet ID, title, and caption are required, along with a list
892        of function calls. Each entry in the list must be a tuple
893        containing a function name followed by a tuple of arguments,
894        and optionally, a dictionary of keyword arguments.
895
896        A postscript is optional (see `Snippet`).
897        """
898        cases = [
899            specifications.TestCase(
900                fname,
901                args,
902                kwargs or {},
903                register=False
904            )
905            for fname, args, kwargs in (
906                map(lambda case: (case + (None,))[:3], calls)
907            )
908        ]
909        super().__init__(sid, title, caption, cases, postscript)

A snippet ID, title, and caption are required, along with a list of function calls. Each entry in the list must be a tuple containing a function name followed by a tuple of arguments, and optionally, a dictionary of keyword arguments.

A postscript is optional (see Snippet).

def render_context(self, context):
911    def render_context(self, context):
912        """
913        Renders a context created for goal evaluation as HTML markup for
914        calling a function. Includes a Jupyter-style prompt for input as
915        well as the return value.
916        """
917        if "ref_error" in context:
918            print(
919                "Error during context creation:"
920              + context["ref_error"]
921              + "\nFull log is:\n"
922              + context.get("ref_error_log", '<missing>')
923            )
924        fname = extract(context, "ref_function")
925        value = extract(context, "ref_value")
926        args = extract(context, "ref_args")
927        kwargs = extract(context, "ref_kwargs")
928        captured = context.get("ref_output", '')
929        captured_errs = context.get("ref_error_log", '')
930
931        # Figure out representations of each argument
932        argreps = []
933        for arg in args:
934            argreps.append(wrapped_with_prefix(arg, '', 1))
935
936        for kw in kwargs:
937            argreps.append(wrapped_with_prefix(kwargs[kw], kw + "=", 1))
938
939        # Figure out full function call representation
940        oneline = "{}({})".format(
941            fname,
942            ', '.join(rep.strip() for rep in argreps)
943        )
944        if '\n' not in oneline and len(oneline) <= FMT_WIDTH:
945            callrep = oneline
946        else:
947            callrep = "{}(\n{}\n)".format(fname, ',\n'.join(argreps))
948
949        # Wrap faked inputs with spans so we can color them blue
950        captured = re.sub(
951            harness.FAKE_INPUT_PATTERN,
952            r'<span class="input">\1</span>',
953            captured
954        )
955
956        # Highlight the function call
957        callrep = highlight_code(callrep)
958
959        result = (
960            '<span class="prompt input">In []:</span>'
961            '<div class="snippet-input">{}</div>'
962        ).format(callrep)
963
964        if captured:
965            result += (
966                '<span class="prompt printed">Prints</span>'
967                '<div class="snippet-printed">{}</div>\n'
968            ).format(captured)
969
970        if captured_errs:
971            result += (
972                '<span class="prompt stderr">Logs</span>'
973                '<div class="snippet-stderr">{log}</div>\n'
974            ).format(log=captured_errs)
975
976        # Highlight the return value
977        if value is not None:
978            value = highlight_code(wrapped_with_prefix(value, ""))
979
980            result += (
981                '<span class="prompt output">Out[]:</span>'
982                '<div class="snippet-output">{}</div>\n'
983            ).format(value)
984
985        return result

Renders a context created for goal evaluation as HTML markup for calling a function. Includes a Jupyter-style prompt for input as well as the return value.

class Blocks(Snippet):
 988class Blocks(Snippet):
 989    """
 990    A snippet which shows the results (printed output and result value of
 991    final line) of one or more blocks of statements, just like Jupyter
 992    Notebook cells. To control what happens in detail, use specialization
 993    methods from `potluck.specifications.HasPayload` and
 994    `potluck.specifications.HasContext`.
 995
 996    Note: Any direct side-effects of the blocks (like changing the value
 997    of a variable or defining a function) won't persist in the module
 998    used for evaluation. However, indirect effects (like appending to a
 999    list) will persist, and thus should be avoided because they could
1000    have unpredictable effects on other components of the system such as
1001    evaluation.
1002
1003    Along the same lines, each block of code to demonstrate is executed
1004    independently of the others. You cannot define a variable in one
1005    block and then use it in another (although you could fake this using
1006    the ability to define separate presentation and actual code).
1007    """
1008    def __init__(self, sid, title, caption, blocks, postscript=''):
1009        """
1010        A snippet ID, title, and caption are required, along with a list
1011        of code blocks. Each entry in the list should be either a
1012        multi-line string, or a tuple of two such strings. In the first
1013        case, the string is treated as the code to run, in the second,
1014        the first item in the tuple is the code to display, while the
1015        second is the code to actually run.
1016
1017        A postscript is optional (see `Snippet`).
1018        """
1019        cases = []
1020        for item in blocks:
1021            if isinstance(item, str):
1022                cases.append(
1023                    specifications.TestBlock(
1024                        sid,
1025                        item,
1026                        register=False
1027                    )
1028                )
1029            else:
1030                display, actual = item
1031                cases.append(
1032                    specifications.TestBlock(
1033                        sid,
1034                        display,
1035                        actual,
1036                        register=False
1037                    )
1038                )
1039
1040        super().__init__(sid, title, caption, cases, postscript)
1041
1042    def render_context(self, context):
1043        """
1044        Renders a context created for goal evaluation as HTML markup for
1045        executing a block of code. Includes a Jupyter-style prompt for
1046        input as well as the result value of the last line of the block
1047        as long as that's not None.
1048        """
1049        src = extract(context, "ref_block")
1050        value = extract(context, "ref_value")
1051        captured = context.get("ref_output", '')
1052        captured_errs = context.get("ref_error_log", '')
1053
1054        # Wrap faked inputs with spans so we can color them blue
1055        captured = re.sub(
1056            harness.FAKE_INPUT_PATTERN,
1057            r'<span class="input">\1</span>',
1058            captured
1059        )
1060
1061        # Highlight the code block
1062        blockrep = highlight_code(src)
1063
1064        result = (
1065            '<span class="prompt input">In []:</span>'
1066            '<div class="snippet-input">{}</div>\n'
1067        ).format(blockrep)
1068
1069        if captured:
1070            result += (
1071                '<span class="prompt printed">Prints</span>'
1072                '<div class="snippet-printed">{}</div>\n'
1073            ).format(captured)
1074
1075        if captured_errs:
1076            result += (
1077                '<span class="prompt stderr">Logs</span>'
1078                '<div class="snippet-stderr">{log}</div>\n'
1079            ).format(log=captured_errs)
1080
1081        # Highlight the return value
1082        if value is not None:
1083            value = highlight_code(wrapped_with_prefix(value, ""))
1084
1085            result += (
1086                '<span class="prompt output">Out[]:</span>'
1087                '<div class="snippet-output">{}</div>\n'
1088            ).format(value)
1089
1090        return result

A snippet which shows the results (printed output and result value of final line) of one or more blocks of statements, just like Jupyter Notebook cells. To control what happens in detail, use specialization methods from potluck.specifications.HasPayload and potluck.specifications.HasContext.

Note: Any direct side-effects of the blocks (like changing the value of a variable or defining a function) won't persist in the module used for evaluation. However, indirect effects (like appending to a list) will persist, and thus should be avoided because they could have unpredictable effects on other components of the system such as evaluation.

Along the same lines, each block of code to demonstrate is executed independently of the others. You cannot define a variable in one block and then use it in another (although you could fake this using the ability to define separate presentation and actual code).

Blocks(sid, title, caption, blocks, postscript='')
1008    def __init__(self, sid, title, caption, blocks, postscript=''):
1009        """
1010        A snippet ID, title, and caption are required, along with a list
1011        of code blocks. Each entry in the list should be either a
1012        multi-line string, or a tuple of two such strings. In the first
1013        case, the string is treated as the code to run, in the second,
1014        the first item in the tuple is the code to display, while the
1015        second is the code to actually run.
1016
1017        A postscript is optional (see `Snippet`).
1018        """
1019        cases = []
1020        for item in blocks:
1021            if isinstance(item, str):
1022                cases.append(
1023                    specifications.TestBlock(
1024                        sid,
1025                        item,
1026                        register=False
1027                    )
1028                )
1029            else:
1030                display, actual = item
1031                cases.append(
1032                    specifications.TestBlock(
1033                        sid,
1034                        display,
1035                        actual,
1036                        register=False
1037                    )
1038                )
1039
1040        super().__init__(sid, title, caption, cases, postscript)

A snippet ID, title, and caption are required, along with a list of code blocks. Each entry in the list should be either a multi-line string, or a tuple of two such strings. In the first case, the string is treated as the code to run, in the second, the first item in the tuple is the code to display, while the second is the code to actually run.

A postscript is optional (see Snippet).

def render_context(self, context):
1042    def render_context(self, context):
1043        """
1044        Renders a context created for goal evaluation as HTML markup for
1045        executing a block of code. Includes a Jupyter-style prompt for
1046        input as well as the result value of the last line of the block
1047        as long as that's not None.
1048        """
1049        src = extract(context, "ref_block")
1050        value = extract(context, "ref_value")
1051        captured = context.get("ref_output", '')
1052        captured_errs = context.get("ref_error_log", '')
1053
1054        # Wrap faked inputs with spans so we can color them blue
1055        captured = re.sub(
1056            harness.FAKE_INPUT_PATTERN,
1057            r'<span class="input">\1</span>',
1058            captured
1059        )
1060
1061        # Highlight the code block
1062        blockrep = highlight_code(src)
1063
1064        result = (
1065            '<span class="prompt input">In []:</span>'
1066            '<div class="snippet-input">{}</div>\n'
1067        ).format(blockrep)
1068
1069        if captured:
1070            result += (
1071                '<span class="prompt printed">Prints</span>'
1072                '<div class="snippet-printed">{}</div>\n'
1073            ).format(captured)
1074
1075        if captured_errs:
1076            result += (
1077                '<span class="prompt stderr">Logs</span>'
1078                '<div class="snippet-stderr">{log}</div>\n'
1079            ).format(log=captured_errs)
1080
1081        # Highlight the return value
1082        if value is not None:
1083            value = highlight_code(wrapped_with_prefix(value, ""))
1084
1085            result += (
1086                '<span class="prompt output">Out[]:</span>'
1087                '<div class="snippet-output">{}</div>\n'
1088            ).format(value)
1089
1090        return result

Renders a context created for goal evaluation as HTML markup for executing a block of code. Includes a Jupyter-style prompt for input as well as the result value of the last line of the block as long as that's not None.

class Fakes(Snippet):
1093class Fakes(Snippet):
1094    """
1095    One or more fake snippets, which format code, printed output, stderr
1096    output, and a result value (and possibly glamer additions) but each
1097    of these things is simply specified ahead of time. Note that you want
1098    to avoid using this if at all possible, because it re-creates the
1099    problems that this module was trying to solve (namely, examples
1100    becoming out-of-sync with the solution code). The specialization
1101    methods of this kind of snippet are ignored and have no effect,
1102    because no code is actually run.
1103    """
1104    def __init__(self, sid, title, caption, fake_contexts, postscript=''):
1105        """
1106        A snippet ID, title, and caption are required, along with a list
1107        of fake context dictionary objects to be rendered as if they came
1108        from a real test. Each dictionary must contain a "code" slot, and
1109        may contain "ref_value", "ref_output", and/or "ref_error_log"
1110        keys. If using glamers, relevant keys should be added directly to
1111        these fake contexts.
1112
1113        If "ref_value" or "ref_output" slots are missing, defaults of
1114        None and '' will be added, since these slots are ultimately
1115        required.
1116
1117        A postscript is optional (see `Snippet`).
1118        """
1119        self.fake_contexts = fake_contexts
1120        for ctx in self.fake_contexts:
1121            if 'code' not in ctx:
1122                raise ValueError(
1123                    "Fake context dictionaries must contain a 'code'"
1124                    " slot."
1125                )
1126            if 'ref_value' not in ctx:
1127                ctx['ref_value'] = None
1128            if 'ref_output' not in ctx:
1129                ctx['ref_output'] = ''
1130        super().__init__(sid, title, caption, [], postscript)
1131
1132    def create_goal(self):
1133        """
1134        We override TestGroup.create_goal to just create a dummy goal
1135        which depends on dummy contexts.
1136        """
1137        ctx_list = []
1138        for fake in self.fake_contexts:
1139            def make_builder():
1140                """
1141                We need to capture 'fake' on each iteration of the loop,
1142                which is why this extra layer of indirection is added.
1143                """
1144                nonlocal fake
1145                return lambda _: fake
1146            ctx_list.append(contexts.Context(builder=make_builder()))
1147
1148        return rubrics.NoteGoal(
1149            self.taskid,
1150            "fakeSnippetGoal:" + self.base_name,
1151            (
1152                "NoteGoal for fake snippet '{}'.".format(self.base_name),
1153                "A fake NoteGoal."
1154            ),
1155            test_in={ "contexts": ctx_list }
1156        )
1157
1158    def render_context(self, context):
1159        """
1160        Renders a context created for goal evaluation as HTML markup for
1161        executing a block of code. Includes a Jupyter-style prompt for
1162        input as well as the result value of the last line of the block
1163        as long as that's not None.
1164        """
1165        src = extract(context, "code")
1166        value = extract(context, "ref_value")
1167        captured = context.get("ref_output", '')
1168        captured_errs = context.get("ref_error_log", '')
1169
1170        # Wrap faked inputs with spans so we can color them blue
1171        captured = re.sub(
1172            harness.FAKE_INPUT_PATTERN,
1173            r'<span class="input">\1</span>',
1174            captured
1175        )
1176
1177        # Highlight the code block
1178        blockrep = highlight_code(src)
1179
1180        result = (
1181            '<span class="prompt input">In []:</span>'
1182            '<div class="snippet-input">{}</div>\n'
1183        ).format(blockrep)
1184
1185        if captured:
1186            result += (
1187                '<span class="prompt printed">Prints</span>'
1188                '<div class="snippet-printed">{}</div>\n'
1189            ).format(captured)
1190
1191        if captured_errs:
1192            result += (
1193                '<span class="prompt stderr">Logs</span>'
1194                '<div class="snippet-stderr">{log}</div>\n'
1195            ).format(log=captured_errs)
1196
1197        # Highlight the return value
1198        if value is not None:
1199            value = highlight_code(wrapped_with_prefix(value, ""))
1200
1201            result += (
1202                '<span class="prompt output">Out[]:</span>'
1203                '<div class="snippet-output">{}</div>\n'
1204            ).format(value)
1205
1206        return result

One or more fake snippets, which format code, printed output, stderr output, and a result value (and possibly glamer additions) but each of these things is simply specified ahead of time. Note that you want to avoid using this if at all possible, because it re-creates the problems that this module was trying to solve (namely, examples becoming out-of-sync with the solution code). The specialization methods of this kind of snippet are ignored and have no effect, because no code is actually run.

Fakes(sid, title, caption, fake_contexts, postscript='')
1104    def __init__(self, sid, title, caption, fake_contexts, postscript=''):
1105        """
1106        A snippet ID, title, and caption are required, along with a list
1107        of fake context dictionary objects to be rendered as if they came
1108        from a real test. Each dictionary must contain a "code" slot, and
1109        may contain "ref_value", "ref_output", and/or "ref_error_log"
1110        keys. If using glamers, relevant keys should be added directly to
1111        these fake contexts.
1112
1113        If "ref_value" or "ref_output" slots are missing, defaults of
1114        None and '' will be added, since these slots are ultimately
1115        required.
1116
1117        A postscript is optional (see `Snippet`).
1118        """
1119        self.fake_contexts = fake_contexts
1120        for ctx in self.fake_contexts:
1121            if 'code' not in ctx:
1122                raise ValueError(
1123                    "Fake context dictionaries must contain a 'code'"
1124                    " slot."
1125                )
1126            if 'ref_value' not in ctx:
1127                ctx['ref_value'] = None
1128            if 'ref_output' not in ctx:
1129                ctx['ref_output'] = ''
1130        super().__init__(sid, title, caption, [], postscript)

A snippet ID, title, and caption are required, along with a list of fake context dictionary objects to be rendered as if they came from a real test. Each dictionary must contain a "code" slot, and may contain "ref_value", "ref_output", and/or "ref_error_log" keys. If using glamers, relevant keys should be added directly to these fake contexts.

If "ref_value" or "ref_output" slots are missing, defaults of None and '' will be added, since these slots are ultimately required.

A postscript is optional (see Snippet).

def create_goal(self):
1132    def create_goal(self):
1133        """
1134        We override TestGroup.create_goal to just create a dummy goal
1135        which depends on dummy contexts.
1136        """
1137        ctx_list = []
1138        for fake in self.fake_contexts:
1139            def make_builder():
1140                """
1141                We need to capture 'fake' on each iteration of the loop,
1142                which is why this extra layer of indirection is added.
1143                """
1144                nonlocal fake
1145                return lambda _: fake
1146            ctx_list.append(contexts.Context(builder=make_builder()))
1147
1148        return rubrics.NoteGoal(
1149            self.taskid,
1150            "fakeSnippetGoal:" + self.base_name,
1151            (
1152                "NoteGoal for fake snippet '{}'.".format(self.base_name),
1153                "A fake NoteGoal."
1154            ),
1155            test_in={ "contexts": ctx_list }
1156        )

We override TestGroup.create_goal to just create a dummy goal which depends on dummy contexts.

def render_context(self, context):
1158    def render_context(self, context):
1159        """
1160        Renders a context created for goal evaluation as HTML markup for
1161        executing a block of code. Includes a Jupyter-style prompt for
1162        input as well as the result value of the last line of the block
1163        as long as that's not None.
1164        """
1165        src = extract(context, "code")
1166        value = extract(context, "ref_value")
1167        captured = context.get("ref_output", '')
1168        captured_errs = context.get("ref_error_log", '')
1169
1170        # Wrap faked inputs with spans so we can color them blue
1171        captured = re.sub(
1172            harness.FAKE_INPUT_PATTERN,
1173            r'<span class="input">\1</span>',
1174            captured
1175        )
1176
1177        # Highlight the code block
1178        blockrep = highlight_code(src)
1179
1180        result = (
1181            '<span class="prompt input">In []:</span>'
1182            '<div class="snippet-input">{}</div>\n'
1183        ).format(blockrep)
1184
1185        if captured:
1186            result += (
1187                '<span class="prompt printed">Prints</span>'
1188                '<div class="snippet-printed">{}</div>\n'
1189            ).format(captured)
1190
1191        if captured_errs:
1192            result += (
1193                '<span class="prompt stderr">Logs</span>'
1194                '<div class="snippet-stderr">{log}</div>\n'
1195            ).format(log=captured_errs)
1196
1197        # Highlight the return value
1198        if value is not None:
1199            value = highlight_code(wrapped_with_prefix(value, ""))
1200
1201            result += (
1202                '<span class="prompt output">Out[]:</span>'
1203                '<div class="snippet-output">{}</div>\n'
1204            ).format(value)
1205
1206        return result

Renders a context created for goal evaluation as HTML markup for executing a block of code. Includes a Jupyter-style prompt for input as well as the result value of the last line of the block as long as that's not None.

class Files(Snippet):
1209class Files(Snippet):
1210    """
1211    A snippet which shows the contents of one or more files, without
1212    running any code, in the same Jupyter-like format that file contents
1213    are shown when `Snippet.show_file_contents` is used. Most
1214    specialization methods don't work properly on this kind of snippet.
1215
1216    By default, file paths are interpreted as relative to the solutions
1217    directory, but specifying a different base directory via the
1218    constructor can change that.
1219    """
1220    def __init__(
1221        self,
1222        sid,
1223        title,
1224        caption,
1225        filepaths,
1226        base='__soln__',
1227        postscript=''
1228    ):
1229        """
1230        A snippet ID, title, and caption are required, along with a list
1231        of file paths. Each entry in the list should be a string path to
1232        a starter file relative to the starter directory. The versions of
1233        files in the solution directory will be used. But only files
1234        present in both starter and solution directories will be
1235        available.
1236
1237        If `base` is specified, it should be either '__soln__' (the
1238        default), '__starter__', or a path string. If it's __soln__ paths
1239        will be interpreted relative to the solution directory; if it's
1240        __starter__ they'll be interpreted relative to the starter
1241        directory, and for any other path, paths are interpreted relative
1242        to that directory. In any case, absolute paths will not be
1243        modified.
1244
1245        A postscript is optional (see `Snippet`).
1246        """
1247        if base == "__soln__":
1248            base = file_utils.current_solution_path()
1249        elif base == "__starter__":
1250            base = file_utils.current_starter_path()
1251        # else leave base as-is
1252
1253        self.filepaths = {
1254            path: os.path.join(base, path)
1255            for path in filepaths
1256        }
1257
1258        super().__init__(sid, title, caption, [], postscript)
1259
1260    def create_goal(self):
1261        """
1262        We override TestGroup.create_goal to just create a dummy goal
1263        which depends on dummy contexts.
1264        """
1265        ctx_list = []
1266        for showpath in self.filepaths:
1267            filepath = self.filepaths[showpath]
1268            with open(filepath, 'r', encoding="utf-8") as fileInput:
1269                contents = fileInput.read()
1270
1271            def make_builder():
1272                """
1273                We need to capture 'filepath' on each iteration of the
1274                loop, which is why this extra layer of indirection is
1275                added.
1276                """
1277                nonlocal showpath, filepath, contents
1278                sp = showpath
1279                fp = filepath
1280                ct = contents
1281                return lambda _: {
1282                    "path": sp,
1283                    "real_path": fp,
1284                    "contents": ct
1285                }
1286
1287            ctx_list.append(contexts.Context(builder=make_builder()))
1288
1289        return rubrics.NoteGoal(
1290            self.taskid,
1291            "fileSnippetGoal:" + self.base_name,
1292            (
1293                "NoteGoal for file snippet '{}'.".format(self.base_name),
1294                "A file-displaying NoteGoal."
1295            ),
1296            test_in={ "contexts": ctx_list }
1297        )
1298
1299    def render_context(self, context):
1300        """
1301        Renders a context with just file information as HTML markup for
1302        displaying a file with %more magic. Includes a Jupyter-style
1303        prompt for input as well as the contents of the file.
1304        """
1305        path = extract(context, "path")
1306        contents = extract(context, "contents")
1307
1308        result = (
1309            '<span class="prompt input">In []:</span>'
1310            '<div class="snippet-input">'
1311            '<code class="magic">%more {filename}</code>'
1312            '</div>\n'
1313            '<span class="prompt special">File</span>'
1314            '<div class="snippet-filename">{filename}</div>\n'
1315            '<div class="snippet-file-contents">{contents}</div>\n'
1316        ).format(filename=path, contents=contents)
1317
1318        return result

A snippet which shows the contents of one or more files, without running any code, in the same Jupyter-like format that file contents are shown when Snippet.show_file_contents is used. Most specialization methods don't work properly on this kind of snippet.

By default, file paths are interpreted as relative to the solutions directory, but specifying a different base directory via the constructor can change that.

Files(sid, title, caption, filepaths, base='__soln__', postscript='')
1220    def __init__(
1221        self,
1222        sid,
1223        title,
1224        caption,
1225        filepaths,
1226        base='__soln__',
1227        postscript=''
1228    ):
1229        """
1230        A snippet ID, title, and caption are required, along with a list
1231        of file paths. Each entry in the list should be a string path to
1232        a starter file relative to the starter directory. The versions of
1233        files in the solution directory will be used. But only files
1234        present in both starter and solution directories will be
1235        available.
1236
1237        If `base` is specified, it should be either '__soln__' (the
1238        default), '__starter__', or a path string. If it's __soln__ paths
1239        will be interpreted relative to the solution directory; if it's
1240        __starter__ they'll be interpreted relative to the starter
1241        directory, and for any other path, paths are interpreted relative
1242        to that directory. In any case, absolute paths will not be
1243        modified.
1244
1245        A postscript is optional (see `Snippet`).
1246        """
1247        if base == "__soln__":
1248            base = file_utils.current_solution_path()
1249        elif base == "__starter__":
1250            base = file_utils.current_starter_path()
1251        # else leave base as-is
1252
1253        self.filepaths = {
1254            path: os.path.join(base, path)
1255            for path in filepaths
1256        }
1257
1258        super().__init__(sid, title, caption, [], postscript)

A snippet ID, title, and caption are required, along with a list of file paths. Each entry in the list should be a string path to a starter file relative to the starter directory. The versions of files in the solution directory will be used. But only files present in both starter and solution directories will be available.

If base is specified, it should be either '__soln__' (the default), '__starter__', or a path string. If it's __soln__ paths will be interpreted relative to the solution directory; if it's __starter__ they'll be interpreted relative to the starter directory, and for any other path, paths are interpreted relative to that directory. In any case, absolute paths will not be modified.

A postscript is optional (see Snippet).

def create_goal(self):
1260    def create_goal(self):
1261        """
1262        We override TestGroup.create_goal to just create a dummy goal
1263        which depends on dummy contexts.
1264        """
1265        ctx_list = []
1266        for showpath in self.filepaths:
1267            filepath = self.filepaths[showpath]
1268            with open(filepath, 'r', encoding="utf-8") as fileInput:
1269                contents = fileInput.read()
1270
1271            def make_builder():
1272                """
1273                We need to capture 'filepath' on each iteration of the
1274                loop, which is why this extra layer of indirection is
1275                added.
1276                """
1277                nonlocal showpath, filepath, contents
1278                sp = showpath
1279                fp = filepath
1280                ct = contents
1281                return lambda _: {
1282                    "path": sp,
1283                    "real_path": fp,
1284                    "contents": ct
1285                }
1286
1287            ctx_list.append(contexts.Context(builder=make_builder()))
1288
1289        return rubrics.NoteGoal(
1290            self.taskid,
1291            "fileSnippetGoal:" + self.base_name,
1292            (
1293                "NoteGoal for file snippet '{}'.".format(self.base_name),
1294                "A file-displaying NoteGoal."
1295            ),
1296            test_in={ "contexts": ctx_list }
1297        )

We override TestGroup.create_goal to just create a dummy goal which depends on dummy contexts.

def render_context(self, context):
1299    def render_context(self, context):
1300        """
1301        Renders a context with just file information as HTML markup for
1302        displaying a file with %more magic. Includes a Jupyter-style
1303        prompt for input as well as the contents of the file.
1304        """
1305        path = extract(context, "path")
1306        contents = extract(context, "contents")
1307
1308        result = (
1309            '<span class="prompt input">In []:</span>'
1310            '<div class="snippet-input">'
1311            '<code class="magic">%more {filename}</code>'
1312            '</div>\n'
1313            '<span class="prompt special">File</span>'
1314            '<div class="snippet-filename">{filename}</div>\n'
1315            '<div class="snippet-file-contents">{contents}</div>\n'
1316        ).format(filename=path, contents=contents)
1317
1318        return result

Renders a context with just file information as HTML markup for displaying a file with %more magic. Includes a Jupyter-style prompt for input as well as the contents of the file.

def append_image_result(markup, context):
1325def append_image_result(markup, context):
1326    """
1327    Given some HTML markup and a context dictionary, turns the
1328    "ref_image" context slot into an HTML img tag and returns the given
1329    markup with that appended, prefixed by an "Image " prompt. If the
1330    context has no "image" value, the original markup is returned
1331    unmodified.
1332
1333    The "ref_image_alt" slot of the context is used as alt text for the
1334    image, with the "ref_output" slot being used as backup (under a
1335    hopeful assumption about turtleBeads being imported for printed
1336    descriptions). If neither is present, "no alt text available" will be
1337    used.
1338    """
1339    # Short-circuit unless we've got an image
1340    if "ref_image" not in context:
1341        return markup
1342
1343    image = extract(context, "ref_image")
1344    alt = context.get(
1345        "ref_image_alt",
1346        context.get("ref_output", "no alt text available")
1347    )
1348
1349    img_tag = html_tools.html_image(image, alt, ["example"])
1350    return (
1351        markup
1352      + '<span class="prompt special">Image </span>'
1353      + img_tag
1354    )

Given some HTML markup and a context dictionary, turns the "ref_image" context slot into an HTML img tag and returns the given markup with that appended, prefixed by an "Image " prompt. If the context has no "image" value, the original markup is returned unmodified.

The "ref_image_alt" slot of the context is used as alt text for the image, with the "ref_output" slot being used as backup (under a hopeful assumption about turtleBeads being imported for printed descriptions). If neither is present, "no alt text available" will be used.

def append_audio_result(markup, context):
1357def append_audio_result(markup, context):
1358    """
1359    Given some HTML markup and a context dictionary, turns the
1360    "ref_audio" context slot into an HTML audio tag and returns the given
1361    markup with that appended, prefixed by an "Audio " prompt.
1362
1363    Note that this can result in a pretty large string if the WAV format
1364    is used, since the string needs to be base64-encoded and WAV is
1365    uncompressed (and we double the size by including the data URL twice)
1366    :(
1367
1368    TODO: Maybe use Ogg/Vorbis?
1369
1370    If there is no "ref_audio" context slot, the given markup is returned
1371    unmodified.
1372
1373    The "ref_audio" context value must be a dictionary with at least
1374    "mimetype" and "data" slots containing the MIME type for the data and
1375    the data itself (as a bytes object). It may also include a "label"
1376    slot which will be used invisibly as an aria-label property of the
1377    audio element; if absent no aria-label will be attached.
1378    """
1379    # Short-circuit unless we've got audio
1380    if "ref_audio" not in context:
1381        return markup
1382
1383    audio = extract(context, "ref_audio")
1384    mime = audio["mimetype"]
1385    data = audio["data"]
1386    label = audio.get("label")
1387
1388    audio_tag = html_tools.html_audio(data, mime, label)
1389    return (
1390        markup
1391      + '\n<span class="prompt special">Audio </span>'
1392      + audio_tag
1393    )

Given some HTML markup and a context dictionary, turns the "ref_audio" context slot into an HTML audio tag and returns the given markup with that appended, prefixed by an "Audio " prompt.

Note that this can result in a pretty large string if the WAV format is used, since the string needs to be base64-encoded and WAV is uncompressed (and we double the size by including the data URL twice) :(

TODO: Maybe use Ogg/Vorbis?

If there is no "ref_audio" context slot, the given markup is returned unmodified.

The "ref_audio" context value must be a dictionary with at least "mimetype" and "data" slots containing the MIME type for the data and the data itself (as a bytes object). It may also include a "label" slot which will be used invisibly as an aria-label property of the audio element; if absent no aria-label will be attached.

def append_memory_report(markup, context):
1396def append_memory_report(markup, context):
1397    """
1398    Given some HTML markup and a context dictionary, creates a memory
1399    report showing the structure of the object in the "ref_value" context
1400    slot, and appends that to the markup, using a 'Memory Report' tag.
1401    """
1402    # Do nothing if no ref_value is available
1403    if "ref_value" not in context:
1404        return markup
1405
1406    obj = extract(context, "ref_value")
1407
1408    report = html_tools.escape(specifications.memory_report(obj))
1409    formatted = "<pre>{report}</pre>".format(report=report)
1410    return (
1411        markup
1412      + '\n<span class="prompt special">Memory\nReport</span>'
1413      + formatted
1414    )

Given some HTML markup and a context dictionary, creates a memory report showing the structure of the object in the "ref_value" context slot, and appends that to the markup, using a 'Memory Report' tag.

def append_file_contents(markup, context):
1417def append_file_contents(markup, context):
1418    """
1419    Given some HTML markup and a context dictionary, turns the
1420    "ref_output_filename" and "ref_output_file_contents" context slots
1421    into HTML markup displaying the contents of that file, which gets
1422    appended to the given markup and returned.
1423
1424    Note that this is intended to display the contents of text files.
1425
1426    If either of the "ref_output_filename" or "ref_output_file_contents"
1427    context slots are missing, the given markup is returned unmodified.
1428    """
1429    # Short-circuit unless we've got output file contents
1430    if (
1431        "ref_output_filename" not in context
1432     or "ref_output_file_contents" not in context
1433    ):
1434        return markup
1435
1436    filename = extract(context, "ref_output_filename")
1437    contents = extract(context, "ref_output_file_contents")
1438
1439    return (
1440        markup
1441      + (
1442            '\n<span class="prompt special">File</span>'
1443        )
1444      + '<div class="snippet-filename">{}</div>\n'.format(filename)
1445      + '<div class="snippet-file-contents">{}</div>\n'.format(contents)
1446    )

Given some HTML markup and a context dictionary, turns the "ref_output_filename" and "ref_output_file_contents" context slots into HTML markup displaying the contents of that file, which gets appended to the given markup and returned.

Note that this is intended to display the contents of text files.

If either of the "ref_output_filename" or "ref_output_file_contents" context slots are missing, the given markup is returned unmodified.

def list_snippets(task_info):
1453def list_snippets(task_info):
1454    """
1455    Returns a list containing all snippet IDs (strings) for the given
1456    task (as a task info dictionary).
1457    """
1458    reg = SNIPPET_REGISTRY.get(task_info["specification"].__name__, {})
1459    return list(reg.keys())

Returns a list containing all snippet IDs (strings) for the given task (as a task info dictionary).

def get_html(task_info, sid):
1462def get_html(task_info, sid):
1463    """
1464    Retrieves the HTML code (a string) for the snippet with the given ID
1465    in the given task (as a task info dictionary). Returns None if there
1466    is no such snippet.
1467    """
1468    reg = SNIPPET_REGISTRY.get(task_info["specification"].__name__, {})
1469    if sid not in reg:
1470        return None
1471    return reg[sid].get_html(task_info)

Retrieves the HTML code (a string) for the snippet with the given ID in the given task (as a task info dictionary). Returns None if there is no such snippet.

def get_all_snippets(task_info):
1474def get_all_snippets(task_info):
1475    """
1476    Returns a list of HTML strings containing each registered snippet for
1477    the given (as a task_info dictionary) task.
1478    """
1479    return [
1480        get_html(task_info, sid)
1481        for sid in list_snippets(task_info)
1482    ]

Returns a list of HTML strings containing each registered snippet for the given (as a task_info dictionary) task.