potluck.specifications

High-level code for defining task specifications.

specifications.py

This is a layer on top of the potluck.rubrics.Goal classes and the potluck.contexts.Context classes; if more direct control is needed those modules can be used alongside this one.

Note that to define a specification, you'll need an evaluation directory set up (see the top-level README.md) and you'll need to create a spec.py file in a task-specific directory, as well as editing tasks.json to define the task's meta-data.

Example

Note: You can find this example in the "functionsTest" task defined in the "potluck_testarea/test_course/fall2021" directory's specs.

Let's assume that an assignment requests that a student write the following functions (this is the solution code):

def indentMessage(message, targetLength):
    indent = max(0, targetLength - len(message))
    return ' ' * indent + message

def printMessage(message, targetLength):
    print(indentMessage(message, targetLength))

import math

def ellipseArea(radius1, radius2):
    return radius1 * radius2 * math.pi

from turtle import *

def polygon(sideLength, nSides):
    for i in range(nSides):
        fd(sideLength)
        lt(360 / nSides)

A spec for the task that includes these functions might include the following code to both run unit tests and check implementation choices:

import turtle

from potluck import specifications as spec
from potluck import compare
from potluck import harness

# Define a few simple unit tests
# Note each TestCase created in this loop will be part of a TestGroup for
# the function it's testing.
for case in [ ("hello", 10), ("hi", 12) ]:
    spec.TestCase("indentMessage", case)
    spec.TestCase("printMessage", case)

# These tests don't get grouped with the test cases above because they
# have an explicit non-default group name.
spec.TestCase("indentMessage", ("longword", 4), group_name="advanced")
spec.TestCase("printMessage", ("longword", 4), group_name="advanced")

# Tests in this loop will again form a TestGroup
for case in [ (5, 5), (5, 10), (12.6, 7.3) ]:
    spec.TestCase("ellipseArea", case)


# Tests in this loop are also grouped
for case in [ (90, 4), (50, 5), (30, 12) ]:
    spec.TestCase("polygon", case)

# Extra test case that doesn't start at the origin
spec.TestCase("polygon", (40, 6), group_name="advanced").do_setup(
    lambda context: (turtle.lt(45), turtle.fd(20), context)[-1]
)


# Build two goals based on our TestCases for "indentMessage"
spec.group("indentMessage").goal("core")
spec.group("indentMessage", group_name="advanced").goal("extra")

# Similar goals for printMessage, but here we need to go beyond the
# default (test values for strict equality, as a "product"-type goal) and
# test outputs. Note that the comparator for the core goal will pass with
# whitespace-only differences, which doesn't make sense for this function
# except that we're testing indentation explicitly in a separate goal.
spec.group("printMessage").test_output().goal("core").compare_strings_firmly()
spec.group("printMessage", "advanced").test_output().goal("extra")

# Here we create and refine our core printMessage tests to look just at
# the initial whitespace. Here we need to compare exactly, since
# whitespace-only differences shouldn't be treated as partial successes.
spec.group("printMessage").test_output()    .refine(spec.Find, pattern="^ *", pattern_desc="the indentation")    .goal("core")    .compare_exactly()    .set_goal_description(
        (
            (
                " <code>printMessage</code> uses correct indentation"
            ),
            (
                "We will verify that <code>printMessage</code> includes"
                " the correct number of spaces before the message itself."
            ),
            (
                " <code>printMessage</code> uses correct indentation"
            ),
            (
                "We checked whether <code>printMessage</code> included"
                " the correct number of spaces before the message itself."
            ),
        )
    )


# A comparison function that will treat two numbers as equal if they
# agree to 3 significant figures:
fequals = compare.build_float_equality_checker(3)
# A goal for ellipseArea that uses this equality checker
spec.group("ellipseArea").goal("core").compare_using(fequals)
# Note: this is usually unnecessary as the default comparator tries to
# ignore floating-point rounding errors...


# For polygon, we'll trace calls to forward and to polygon itself
to_trace = [ "polygon", ("fd", "forward") ]
core_state = [ "position", "heading" ]
traces = spec.group("polygon")\
    .goal("core")\
    .do_setup(harness.warp_turtle)\
    .do_cleanup(harness.finalize_turtle)\
    .test_trace(to_trace, harness.capture_turtle_state)\
    .check_trace_state(core_state, check_args=True, only=["fd"])
adv_traces = spec.group("polygon", "advanced")\
    .goal("extra")\
    .do_setup(harness.warp_turtle)\
    .do_cleanup(harness.finalize_turtle)\
    .test_trace(to_trace, harness.capture_turtle_state)\
    .check_trace_state(core_state, check_args=True, only=["fd"])

# check that the position and heading of the turtle are the same
# before/after the call to polygon.
traces.also()\
    .goal("core")\
    .check_invariant(core_state, only=["polygon"])
adv_traces.also()\
    .goal("extra")\
    .check_invariant(core_state, only=["polygon"])

# Implementation checks: functions must be defined and must use certain
# constructs internally. Note that the second argument to FunctionDef
# should usually be omitted, and can either be an integer requiring a
# specific number of parameters or a string specifying parameters names
# in `evaluation.mast` style (e.g., "firstArg, _, thirdArg").
spec.FunctionDef("indentMessage", 2).require(
    spec.FunctionCall("len")
)
spec.FunctionDef("printMessage").require(
    spec.FunctionCall("print"),
    spec.FunctionCall("indentMessage")
)
spec.FunctionDef("ellipseArea").require(
    spec.Return()
)
spec.FunctionDef("polygon").require(
    spec.Loop(only="block").require(
        spec.FunctionCall(["fd", "forward"]) # must be in the loop
    )
)

# Misc goals

spec.NoParseErrors()
spec.DontWasteFruit()
spec.DontWasteBoxes()
spec.RequireDocstrings()

Customization of tests is achieved through the methods of the TestCase, TestGroup, TestGoal, Check, and related classes (and through the use of Check subclasses). In the code above we used functions like HasGoal.compare_using and Check.require to refine tests.

Once you have constructed some TestGoal and/or Check objects, simply write:

rubric = spec.rubric()

...and a potluck.rubrics.Rubric object will be created from the tests that you've defined in the current module.

As long as your specification file defines a variable named rubric which holds a potluck.rubrics.Rubric object, it will be usable.

Testing your Specifications

How can you be sure your specifications will work correctly? There is a built-in specifications checking system that can both check your specification against example submissions, as well as make sure that the solution code gets a perfect score. Running potluck_eval with the --check option invokes this system for a particular task. By default it will check just the solution code, but you may also provide example submissions and then set up expectations for them other than perfect success. To do this you can use the potluck.meta module's potluck.meta.example and potluck.meta.expect functions. Here's an example of what this might look like for the specification example above:

# Specifications tests using the meta module:
from potluck import meta # noqa E402

meta.example("imperfect")

meta.expect("partial", "style", "core", "documented")
meta.expect("partial", "style", "extra", "ignore the results")
meta.expect("failed", "procedure", "core", "define printMessage")
meta.expect("accomplished", "procedure", "core", "define printMessage",
            "call print")
meta.expect("failed", "procedure", "core", "define printMessage",
            "call indentMessage")
meta.expect("failed", "procedure", "core", "define polygon")
meta.expect("failed", "procedure", "core", "define polygon", "loop")
meta.expect("failed", "procedure", "core", "define polygon", "loop", "call")
meta.expect("failed", "process", "core", "correct function calls")
meta.expect("failed", "process", "core", "maintain invariants")
meta.expect("failed", "process", "extra", "correct function calls")
meta.expect("failed", "process", "extra", "maintain invariants")
meta.expect("failed", "product", "core", "ellipseArea")
meta.expect("failed", "behavior", "core", "correct indentation")
meta.expect("failed", "behavior", "extra", "correct output")

These expectations are based on the following broken submission:

def indentMessage(message, targetLength):
    len(message)
    indent = targetLength - len(message)
    return ' ' * indent + message

def printMessage(message, width):
    '''
    Prints a message, taking up at least the required width (will be
    wider if the message itself is wider). Uses indentMessage.
    '''
    print(' ' * width + message)

import math

def ellipseArea(radius1, radius2):
    '''
    Computes the area of an ellipse with the given radii (you may specify
    the major and minor radii in either order). Returns the result as a
    floating-point number.
    '''
    return radius1 * radius2 + math.pi

from turtle import *

def polygon(sideLength, nSides):
    '''
    Draws a polygon with the given side length and number of sides.
    '''
    for _ in range(nSides + 1):
        bk(sideLength)
        lt(360 / nSides)

Multi-file tasks (experimental)

If you want to grade a submission with multiple files, or otherwise use more-than-default contexts for things like loading the submitted code, you can instantiate a contexts.FileContext object pointing to one of the files you want to grade, instantiate various tests for that file, and then instantiate another file context for the next file to grade, followed by tests for that file, and so on. Essentially, when a test or check is created, it picks up some of the currently-registered auto contexts (see contexts.auto), most notably filename, and so by controlling the filename specified by the most-recently-instantiated contexts.FileContext, you can control which file tests are applied to. (TODO: test that!).

   1"""
   2High-level code for defining task specifications.
   3
   4specifications.py
   5
   6This is a layer on top of the `potluck.rubrics.Goal` classes and the
   7`potluck.contexts.Context` classes; if more direct control is needed
   8those modules can be used alongside this one.
   9
  10Note that to define a specification, you'll need an evaluation directory
  11set up (see the top-level `README.md`) and you'll need to create a
  12`spec.py` file in a task-specific directory, as well as editing
  13`tasks.json` to define the task's meta-data.
  14
  15## Example
  16
  17Note: You can find this example in the "functionsTest" task defined in
  18the "potluck_testarea/test_course/fall2021" directory's specs.
  19
  20Let's assume that an assignment requests that a student write the
  21following functions (this is the solution code):
  22
  23```py
  24def indentMessage(message, targetLength):
  25    indent = max(0, targetLength - len(message))
  26    return ' ' * indent + message
  27
  28def printMessage(message, targetLength):
  29    print(indentMessage(message, targetLength))
  30
  31import math
  32
  33def ellipseArea(radius1, radius2):
  34    return radius1 * radius2 * math.pi
  35
  36from turtle import *
  37
  38def polygon(sideLength, nSides):
  39    for i in range(nSides):
  40        fd(sideLength)
  41        lt(360 / nSides)
  42```
  43
  44A spec for the task that includes these functions might include the
  45following code to both run unit tests and check implementation choices:
  46
  47```py
  48import turtle
  49
  50from potluck import specifications as spec
  51from potluck import compare
  52from potluck import harness
  53
  54# Define a few simple unit tests
  55# Note each TestCase created in this loop will be part of a TestGroup for
  56# the function it's testing.
  57for case in [ ("hello", 10), ("hi", 12) ]:
  58    spec.TestCase("indentMessage", case)
  59    spec.TestCase("printMessage", case)
  60
  61# These tests don't get grouped with the test cases above because they
  62# have an explicit non-default group name.
  63spec.TestCase("indentMessage", ("longword", 4), group_name="advanced")
  64spec.TestCase("printMessage", ("longword", 4), group_name="advanced")
  65
  66# Tests in this loop will again form a TestGroup
  67for case in [ (5, 5), (5, 10), (12.6, 7.3) ]:
  68    spec.TestCase("ellipseArea", case)
  69
  70
  71# Tests in this loop are also grouped
  72for case in [ (90, 4), (50, 5), (30, 12) ]:
  73    spec.TestCase("polygon", case)
  74
  75# Extra test case that doesn't start at the origin
  76spec.TestCase("polygon", (40, 6), group_name="advanced").do_setup(
  77    lambda context: (turtle.lt(45), turtle.fd(20), context)[-1]
  78)
  79
  80
  81# Build two goals based on our TestCases for "indentMessage"
  82spec.group("indentMessage").goal("core")
  83spec.group("indentMessage", group_name="advanced").goal("extra")
  84
  85# Similar goals for printMessage, but here we need to go beyond the
  86# default (test values for strict equality, as a "product"-type goal) and
  87# test outputs. Note that the comparator for the core goal will pass with
  88# whitespace-only differences, which doesn't make sense for this function
  89# except that we're testing indentation explicitly in a separate goal.
  90spec.group("printMessage").test_output().goal("core").compare_strings_firmly()
  91spec.group("printMessage", "advanced").test_output().goal("extra")
  92
  93# Here we create and refine our core printMessage tests to look just at
  94# the initial whitespace. Here we need to compare exactly, since
  95# whitespace-only differences shouldn't be treated as partial successes.
  96spec.group("printMessage").test_output()\
  97    .refine(spec.Find, pattern="^ *", pattern_desc="the indentation")\
  98    .goal("core")\
  99    .compare_exactly()\
 100    .set_goal_description(
 101        (
 102            (
 103                " <code>printMessage</code> uses correct indentation"
 104            ),
 105            (
 106                "We will verify that <code>printMessage</code> includes"
 107                " the correct number of spaces before the message itself."
 108            ),
 109            (
 110                " <code>printMessage</code> uses correct indentation"
 111            ),
 112            (
 113                "We checked whether <code>printMessage</code> included"
 114                " the correct number of spaces before the message itself."
 115            ),
 116        )
 117    )
 118
 119
 120# A comparison function that will treat two numbers as equal if they
 121# agree to 3 significant figures:
 122fequals = compare.build_float_equality_checker(3)
 123# A goal for ellipseArea that uses this equality checker
 124spec.group("ellipseArea").goal("core").compare_using(fequals)
 125# Note: this is usually unnecessary as the default comparator tries to
 126# ignore floating-point rounding errors...
 127
 128
 129# For polygon, we'll trace calls to forward and to polygon itself
 130to_trace = [ "polygon", ("fd", "forward") ]
 131core_state = [ "position", "heading" ]
 132traces = spec.group("polygon")\\
 133    .goal("core")\\
 134    .do_setup(harness.warp_turtle)\\
 135    .do_cleanup(harness.finalize_turtle)\\
 136    .test_trace(to_trace, harness.capture_turtle_state)\\
 137    .check_trace_state(core_state, check_args=True, only=["fd"])
 138adv_traces = spec.group("polygon", "advanced")\\
 139    .goal("extra")\\
 140    .do_setup(harness.warp_turtle)\\
 141    .do_cleanup(harness.finalize_turtle)\\
 142    .test_trace(to_trace, harness.capture_turtle_state)\\
 143    .check_trace_state(core_state, check_args=True, only=["fd"])
 144
 145# check that the position and heading of the turtle are the same
 146# before/after the call to polygon.
 147traces.also()\\
 148    .goal("core")\\
 149    .check_invariant(core_state, only=["polygon"])
 150adv_traces.also()\\
 151    .goal("extra")\\
 152    .check_invariant(core_state, only=["polygon"])
 153
 154# Implementation checks: functions must be defined and must use certain
 155# constructs internally. Note that the second argument to FunctionDef
 156# should usually be omitted, and can either be an integer requiring a
 157# specific number of parameters or a string specifying parameters names
 158# in `evaluation.mast` style (e.g., "firstArg, _, thirdArg").
 159spec.FunctionDef("indentMessage", 2).require(
 160    spec.FunctionCall("len")
 161)
 162spec.FunctionDef("printMessage").require(
 163    spec.FunctionCall("print"),
 164    spec.FunctionCall("indentMessage")
 165)
 166spec.FunctionDef("ellipseArea").require(
 167    spec.Return()
 168)
 169spec.FunctionDef("polygon").require(
 170    spec.Loop(only="block").require(
 171        spec.FunctionCall(["fd", "forward"]) # must be in the loop
 172    )
 173)
 174
 175# Misc goals
 176
 177spec.NoParseErrors()
 178spec.DontWasteFruit()
 179spec.DontWasteBoxes()
 180spec.RequireDocstrings()
 181```
 182
 183Customization of tests is achieved through the methods of the `TestCase`,
 184`TestGroup`, `TestGoal`, `Check`, and related classes (and through the
 185use of `Check` subclasses). In the code above we used functions like
 186`HasGoal.compare_using` and `Check.require` to refine tests.
 187
 188Once you have constructed some `TestGoal` and/or `Check` objects, simply
 189write:
 190
 191```py
 192rubric = spec.rubric()
 193```
 194
 195...and a `potluck.rubrics.Rubric` object will be created from the tests
 196that you've defined in the current module.
 197
 198As long as your specification file defines a variable named `rubric`
 199which holds a `potluck.rubrics.Rubric` object, it will be usable.
 200
 201
 202## Testing your Specifications
 203
 204How can you be sure your specifications will work correctly? There is a
 205built-in specifications checking system that can both check your
 206specification against example submissions, as well as make sure that the
 207solution code gets a perfect score. Running `potluck_eval` with the
 208`--check` option invokes this system for a particular task. By default it
 209will check just the solution code, but you may also provide example
 210submissions and then set up expectations for them other than perfect
 211success. To do this you can use the `potluck.meta` module's
 212`potluck.meta.example` and `potluck.meta.expect` functions. Here's an
 213example of what this might look like for the specification example above:
 214
 215```py
 216# Specifications tests using the meta module:
 217from potluck import meta # noqa E402
 218
 219meta.example("imperfect")
 220
 221meta.expect("partial", "style", "core", "documented")
 222meta.expect("partial", "style", "extra", "ignore the results")
 223meta.expect("failed", "procedure", "core", "define printMessage")
 224meta.expect("accomplished", "procedure", "core", "define printMessage",
 225            "call print")
 226meta.expect("failed", "procedure", "core", "define printMessage",
 227            "call indentMessage")
 228meta.expect("failed", "procedure", "core", "define polygon")
 229meta.expect("failed", "procedure", "core", "define polygon", "loop")
 230meta.expect("failed", "procedure", "core", "define polygon", "loop", "call")
 231meta.expect("failed", "process", "core", "correct function calls")
 232meta.expect("failed", "process", "core", "maintain invariants")
 233meta.expect("failed", "process", "extra", "correct function calls")
 234meta.expect("failed", "process", "extra", "maintain invariants")
 235meta.expect("failed", "product", "core", "ellipseArea")
 236meta.expect("failed", "behavior", "core", "correct indentation")
 237meta.expect("failed", "behavior", "extra", "correct output")
 238```
 239
 240These expectations are based on the following broken submission:
 241
 242```py
 243def indentMessage(message, targetLength):
 244    len(message)
 245    indent = targetLength - len(message)
 246    return ' ' * indent + message
 247
 248def printMessage(message, width):
 249    '''
 250    Prints a message, taking up at least the required width (will be
 251    wider if the message itself is wider). Uses indentMessage.
 252    '''
 253    print(' ' * width + message)
 254
 255import math
 256
 257def ellipseArea(radius1, radius2):
 258    '''
 259    Computes the area of an ellipse with the given radii (you may specify
 260    the major and minor radii in either order). Returns the result as a
 261    floating-point number.
 262    '''
 263    return radius1 * radius2 + math.pi
 264
 265from turtle import *
 266
 267def polygon(sideLength, nSides):
 268    '''
 269    Draws a polygon with the given side length and number of sides.
 270    '''
 271    for _ in range(nSides + 1):
 272        bk(sideLength)
 273        lt(360 / nSides)
 274```
 275
 276## Multi-file tasks (experimental)
 277
 278If you want to grade a submission with multiple files, or otherwise use
 279more-than-default contexts for things like loading the submitted code,
 280you can instantiate a `contexts.FileContext` object pointing to one of
 281the files you want to grade, instantiate various tests for that file, and
 282then instantiate another file context for the next file to grade,
 283followed by tests for that file, and so on. Essentially, when a test or
 284check is created, it picks up some of the currently-registered auto
 285contexts (see `contexts.auto`), most notably `filename`, and so by
 286controlling the filename specified by the most-recently-instantiated
 287`contexts.FileContext`, you can control which file tests are applied to.
 288(TODO: test that!).
 289"""
 290# TODO: property -> payload -> condition description assembly...
 291
 292import ast
 293import re
 294import copy
 295
 296from . import logging
 297from . import mast
 298from . import rubrics
 299from . import contexts
 300from . import context_utils
 301from . import patterns
 302from . import phrasing
 303from . import html_tools
 304from . import compare
 305from . import explain
 306from . import harness
 307from . import file_utils
 308from . import validation
 309
 310
 311#---------#
 312# Globals #
 313#---------#
 314
 315SPECS_DIR = '.'
 316"""
 317Directory for finding task specifications.
 318"""
 319
 320
 321CONTEXT_SLOT_IMPLIED_TYPES = {
 322    "filename": "other",
 323    "file_path": "other",
 324    "source": "style",
 325    "docstrings": "style",
 326    "parse_errors": "procedure",
 327    "defs": "procedure",
 328    "top_scope": "procedure",
 329    "scope": "procedure",
 330    "module": "product",
 331    "trace": "process",
 332    "value": "product",
 333    "output": "behavior",
 334    "output_file_contents": "behavior",
 335    "image": "behavior",
 336    "audio": "behavior",
 337    "notes": "behavior",
 338}
 339"""
 340Based on just the context slot being used for testing with an
 341`potluck.rubrics.ComparisonTest`, we can guess the goal type that the
 342goal will fall into. This dictionary stores those associations. Use
 343`HasGoal.set_goal_type` to set a goal type explicitly if the default
 344isn't correct.
 345"""
 346
 347
 348#------------#
 349# Registries #
 350#------------#
 351
 352# A registry of TestGroup instances, organized according to the module
 353# they were instantiated within, their rubric category, and the name of
 354# the function they apply to.
 355# Keys by level are:
 356#   1. A module's __name__
 357#   2. A filename
 358#   3. A function name
 359#   4. A custom group name
 360# Ultimate values are `TestGroup` objects.
 361TEST_GROUP_REGISTRY = {}
 362
 363# A registry of Check instances, organized according to the module they
 364# were instantiated within. Each key is a module's __name__ and maps to a
 365# list of Check objects.
 366CHECKS_REGISTRY = {}
 367
 368# A mapping from module names to lists of HasGoal instances which will be
 369# called upon to provide goals for the rubric when it gets created.
 370GOAL_PROVIDERS = {}
 371
 372# A mapping from module names to lists of Goal objects that should be
 373# added to the rubric when it gets created.
 374GOALS = {}
 375
 376# Just like GOAL_PROVIDERS, but for goal providers that should be used
 377# to set up goals for the test validation process.
 378VALIDATION_GOAL_PROVIDERS = {}
 379
 380# Just like GOALS, but for goals which apply to the test validation
 381# process.
 382VALIDATION_GOALS = {}
 383
 384
 385def register_goal_provider(provider):
 386    """
 387    Registers a goal-provider (must have a zero-parameter `provide_goal`
 388    method, so most likely to be a `HasGoal` instance) to provide a goal
 389    to the rubric. Mostly this is handled automatically via
 390    `HasGoal.goal`, but it's useful to call it manually in some cases.
 391    """
 392    sname = file_utils.get_spec_module_name()
 393    if sname not in GOAL_PROVIDERS:
 394        GOAL_PROVIDERS[sname] = []
 395    GOAL_PROVIDERS[sname].append(provider)
 396
 397
 398def register_goal(goal):
 399    """
 400    Registers a goal to be added to the rubric. Mostly this is handled
 401    automatically, but it's useful to call it manually if you want to
 402    define your own custom goals in tandem with automatically-created goals.
 403    """
 404    sname = file_utils.get_spec_module_name()
 405    if sname not in GOALS:
 406        GOALS[sname] = []
 407    GOALS[sname].append(goal)
 408
 409
 410def register_validation_goal_provider(provider):
 411    """
 412    Registers a goal-provider (must have a zero-parameter `provide_goal`
 413    method, so most likely to be a `HasGoal` instance) to provide a goal
 414    to the rubric's test validation stage. Mostly this is handled
 415    automatically via `HasGoal.validate`, but it's useful to call it
 416    manually in some cases.
 417    """
 418    sname = file_utils.get_spec_module_name()
 419    if sname not in VALIDATION_GOAL_PROVIDERS:
 420        VALIDATION_GOAL_PROVIDERS[sname] = []
 421    VALIDATION_GOAL_PROVIDERS[sname].append(provider)
 422
 423
 424def register_validation_goal(goal):
 425    """
 426    Registers a goal to be added to the rubric. Mostly this is handled
 427    automatically, but it's useful to call it manually if you want to
 428    define your own custom goals in tandem with automatically-created goals.
 429    """
 430    sname = file_utils.get_spec_module_name()
 431    if sname not in VALIDATION_GOALS:
 432        VALIDATION_GOALS[sname] = []
 433    VALIDATION_GOALS[sname].append(goal)
 434
 435
 436def checklist(category, goal_type, create=False):
 437    """
 438    Retrieves the list of `Check` objects which have been registered
 439    under the given category and goal type. Raises a `KeyError` if no
 440    checks have been registered under that category, or if create is
 441    True, it creates an entry for that category and returns an empty
 442    list.
 443    """
 444    mname = file_utils.get_spec_module_name()
 445    module_registry = CHECKS_REGISTRY.get(mname)
 446
 447    if module_registry is None:
 448        if create:
 449            module_registry = CHECKS_REGISTRY.setdefault(mname, {})
 450        else:
 451            raise KeyError(
 452                f"There are no checks for module {mname}."
 453            )
 454
 455    category_registry = module_registry.get(category)
 456
 457    if category_registry is None:
 458        if create:
 459            category_registry = module_registry.setdefault(category, {})
 460        else:
 461            raise KeyError(
 462                f"There are no checks in module {mname} for category"
 463              + f" {category}."
 464            )
 465
 466    list_for_type = category_registry.get(goal_type)
 467
 468    if list_for_type is None:
 469        if create:
 470            list_for_type = category_registry.setdefault(goal_type, [])
 471        else:
 472            raise KeyError(
 473                f"There are no checks in module {mname} for category"
 474              + f" {category} and type {goal_type}."
 475            )
 476
 477    return list_for_type
 478
 479
 480#------------------------------------------------#
 481# Base classes for payload/context/goal managers #
 482#------------------------------------------------#
 483
 484def update_augmentations(base, extensions):
 485    """
 486    Takes two dictionaries and updates the first with the key/value
 487    pairs from the second, with special treatment of "with_setup" and
 488    "with_cleanup" keys so that setup/cleanup functions are accumulated
 489    via composition rather than overriding each other.
 490
 491    Edits the first dictionary but doesn't have a return value.
 492    """
 493    for key in extensions:
 494        if (
 495            key in ("with_setup", "with_cleanup")
 496        and key in base
 497        ):
 498            keyarg = key[5:]
 499            already = base[key][keyarg]
 500            incomming = extensions[key][keyarg]
 501            base[key] = {
 502                keyarg: lambda val: incomming(already(val))
 503            }
 504        else:
 505            base[key] = extensions[key]
 506
 507
 508class HasPayload:
 509    """
 510    An abstract base class for tests that track payload augmentations,
 511    since the augmentation system allows for common functionality.
 512    """
 513    def __init__(
 514        self,
 515        payload_constructor=harness.create_run_function_payload,
 516        default_payload_args=None,
 517        default_augmentations=None
 518    ):
 519        """
 520        A base payload creation function may be supplied (default is
 521        `potluck.harness.create_run_function_payload`).
 522
 523        Defaults for payload arguments and/or augmentations may be
 524        supplied. Payload arguments are just passed as keyword arguments
 525        to the payload constructor.
 526
 527        Augmentations should be a dictionary where keys name payload
 528        augmentation functions in the `potluck.harness` module, and
 529        values are dictionaries of keyword arguments to supply to those
 530        augmentations.
 531        """
 532        self.payload_constructor = payload_constructor
 533        self.default_payload_args = default_payload_args or {}
 534        self.default_augmentations = default_augmentations or {}
 535        self.payload_args = {}
 536        self.augmentations = {}
 537
 538    def synthesize_payload_info(self, group=None):
 539        """
 540        Synthesizes payload construction arguments and augmentations,
 541        returning a tuple containing the results in that order. If a
 542        group is given, it should also be a `HasPayload` instance, and
 543        its information will be mixed with local information as follows:
 544
 545          - First, group defaults will be loaded.
 546          - Next, this object's defaults will override those.
 547          - Third, group explicit values will be added.
 548          - Finally, local explicit values will have the final say.
 549
 550        Note: setup and cleanup functions accumulate via composition
 551        rather than replacing each other.
 552        """
 553        args = {}
 554        augmentations = {}
 555        if group:
 556            args.update(group.default_payload_args)
 557            augmentations.update(group.default_augmentations)
 558        args.update(self.default_payload_args)
 559        update_augmentations(augmentations, self.default_augmentations)
 560        if group:
 561            args.update(group.payload_args)
 562            update_augmentations(augmentations, group.augmentations)
 563        args.update(self.payload_args)
 564        update_augmentations(augmentations, self.augmentations)
 565
 566        return args, augmentations
 567
 568    def construct_payload(self, parent=None):
 569        """
 570        Constructs the augmented payload function based on the
 571        information assembled so far.
 572        """
 573        # Synthesize payload arguments & augmentations
 574        args, augmentations = self.synthesize_payload_info(parent)
 575
 576        # Construct base payload
 577        # TODO: less awkward here?
 578        # TODO NOT a HACK here!
 579        if (
 580            hasattr(parent, "payload_constructor")
 581        and parent.payload_constructor == harness.create_run_harness_payload
 582        ):
 583            cons = parent.payload_constructor
 584        else:
 585            cons = self.payload_constructor
 586
 587        result = cons(**args)
 588
 589        # Apply augmentations in order
 590        for fn_name in harness.AUGMENTATION_ORDER:
 591            if fn_name in augmentations:
 592                args = augmentations[fn_name]
 593                result = getattr(harness, fn_name)(result, **args)
 594
 595        return result
 596
 597    def describe_payload(self, parent=None, obfuscated=False):
 598        """
 599        Returns a pair of HTML strings for the topic and details of the
 600        payload that will be constructed by `construct_payload` (with an
 601        equivalent `parent` argument). If `obfuscated` is set to True,
 602        the obfuscated version of the description will be provided (see
 603        `potluck.explain.payload_description`)
 604        """
 605        # Synthesize info w/ parent
 606        args, augmentations = self.synthesize_payload_info(parent)
 607        return explain.payload_description(
 608            self.payload_constructor,
 609            args,
 610            augmentations,
 611            obfuscated=obfuscated
 612        )
 613
 614    def ensure_payload_constructor_arg(self, desired):
 615        """
 616        Ensures that this object's payload constructor accepts the given
 617        argument value, raising a `TypeError` if it does not.
 618        """
 619        cobj = self.payload_constructor.__code__
 620        arg_names = cobj.co_varnames[:cobj.co_argcount]
 621        if desired not in arg_names:
 622            raise TypeError(
 623                f"This operation is only allowed for HasPayload classes"
 624                f" associated with payload bases that accept a '{desired}'"
 625                f" argument ({self.payload_constructor.__name__} does not)."
 626            )
 627
 628    def prepare_source(self, prep):
 629        """
 630        Provides a prep function which will be run on the source code of
 631        the module being tested before the test. Only applicable to
 632        module import payloads. The string it returns will be used as the
 633        actual module source. Even if you don't need to modify the source
 634        code, this can be used to run some setup code right before the
 635        test itself.
 636
 637        This function returns self for chaining.
 638        """
 639        self.ensure_payload_constructor_arg("prep")
 640        self.payload_args["prep"] = prep
 641        return self
 642
 643    def wrap_module(self, wrapper):
 644        """
 645        Provides a wrapper function which will be applied to the module
 646        created by this test before the final checking step. Only
 647        applicable to module import payloads (use `use_decorations` to
 648        achieve a similar effect for other payload types). Mostly
 649        relevant when the `module` slot is being used somehow, such as
 650        when `HasGoal.test_module` is being used.
 651
 652        This function returns self for chaining.
 653        """
 654        self.ensure_payload_constructor_arg("wrap")
 655        self.payload_args["wrap"] = wrapper
 656        return self
 657
 658    def ignore_output(self):
 659        """
 660        Modifies the payload so that it no longer captures printed
 661        output. Useful for payloads that capture printed output by
 662        default when that functionality isn't needed.
 663        """
 664        # Modify default and explicit augmentations
 665        cpo = "capturing_printed_output"
 666        if cpo in self.default_augmentations:
 667            del self.default_augmentations[cpo]
 668        if cpo in self.augmentations:
 669            del self.augmentations[cpo]
 670
 671        # If we're actually broadcasting, we need to delete from children
 672        if isinstance(self, TestGroup):
 673            for test in self.tests:
 674                if cpo in test.default_augmentations:
 675                    del test.default_augmentations[cpo]
 676                if cpo in test.augmentations:
 677                    del test.augmentations[cpo]
 678
 679    def copy_args(self, copy=True):
 680        """
 681        Sets up the payload so that it will deeply copy argument values.
 682        Only works for payloads based on
 683        `evaluations.harness.create_run_function_payload`.
 684
 685        Set copy to False to disable argument copying (which is the
 686        default behavior).
 687
 688        Returns self for chaining.
 689        """
 690        self.ensure_payload_constructor_arg("copy_args")
 691        self.payload_args["copy_args"] = copy
 692        return self
 693
 694    def use_harness(self, harness_fn):
 695        """
 696        Modifies the payload so that it will use a test harness function
 697        instead of calling a target function directly. The payload must
 698        already have `potluck.harness.create_run_function_payload` as its
 699        base, or a `TypeError` will result.
 700
 701        The harness function will receive all the same positional and/or
 702        keyword arguments as the function being tested would have, except
 703        that it will also receive the function to test as its first
 704        positional argument.
 705        """
 706        if self.payload_constructor != harness.create_run_function_payload:
 707            raise TypeError(
 708                f"A test harness can only be applied to a test that's"
 709                f" normally based on a function call (but {self}"
 710                f" has payload constructor: {self.payload_constructor})."
 711            )
 712        self.payload_constructor = harness.create_run_harness_payload
 713        self.payload_args["harness"] = harness_fn
 714
 715    def set_timeout(self, time_limit):
 716        """
 717        Sets a timeout value that limits how long the payload will run.
 718
 719        Returns self for chaining.
 720        """
 721        self.augmentations["with_timeout"] = { "time_limit": time_limit }
 722        return self
 723
 724    def do_setup(self, setup_fn):
 725        """
 726        Adds a setup function for this payload, which will be run right
 727        before the payload starts. Returns self for chaining.
 728
 729        See `potluck.harness.with_setup` for details.
 730
 731        Note: `HasPayload.do_setup` and `HasPayload.do_cleanup` each
 732        accumulate setup/cleanup functions instead of replacing the
 733        previous setup/cleanup function, with setup functions added later
 734        affecting the results of setup functions added earlier. Also,
 735        setup/cleanup functions are accumulated between child and parent
 736        payload-bearing objects, rather than child setups/cleanups
 737        overriding parent setups/cleanups.
 738        """
 739        update_augmentations(
 740            self.augmentations,
 741            { "with_setup": { "setup": setup_fn } }
 742        )
 743        return self
 744
 745    def do_cleanup(self, cleanup_fn):
 746        """
 747        Adds a cleanup function for this test, which will be run right
 748        after the payload is finished. Returns self for chaining.
 749
 750        See `potluck.harness.with_cleanup` for details.
 751        """
 752        update_augmentations(
 753            self.augmentations,
 754            { "with_cleanup": { "cleanup": cleanup_fn } }
 755        )
 756        return self
 757
 758    def capture_output(self, capture_errors=False, capture_stderr=False):
 759        """
 760        Sets up output capturing, so that everything that gets printed
 761        will be captured in the "output" slot. If `capture_errors` is
 762        set to True, errors will be captured as part of the output
 763        instead of actually breaking the test. If `capture_stderr` is set
 764        to True, messages written to stderr will be captured into an
 765        additional "error_log" slot. Returns self for chaining.
 766        """
 767        self.augmentations["capturing_printed_output"] = {
 768            "capture_errors": capture_errors,
 769            "capture_stderr": capture_stderr
 770        }
 771        return self
 772
 773    def provide_inputs(self, strings, policy="hold"):
 774        """
 775        Set up a series of strings to use as the results of any `input()`
 776        calls made during the payload execution. For details, Refer to
 777        `potluck.harness.with_fake_input`.
 778
 779        Returns self for chaining.
 780        """
 781        self.augmentations["with_fake_input"] = {
 782            "inputs": strings,
 783            "extra_policy": policy
 784        }
 785        return self
 786
 787    def use_decorations(self, decorations, ignore_missing=False):
 788        """
 789        Sets the decorations dictionary for this payload, which maps
 790        function (or variable) names to decorator functions that will be
 791        temporarily applied to those values during testing. This has a
 792        lot of potential uses, like disabling a function ('decorate' it
 793        to return a new do-nothing function), performing some operation
 794        on the result of the test function before comparison happens (by
 795        decorating the function being tested), or even providing a new
 796        variable that's not defined by default (but that can often be
 797        accomplished via other means).
 798
 799        If `ignore_missing` is set to True instead of the default
 800        (False), then if a decoration is supposed to apply to a value
 801        which doesn't exist, instead of an error being raised, the
 802        decoration function will be called with `potluck.harness.Missing`
 803        as the input value.
 804
 805        This function returns self so that you can chain it with other
 806        modification functions.
 807
 808        Any old decorations dictionary will be overridden.
 809
 810        Note that decorations cannot be applied to payloads based on
 811        module imports, as the decorations are applied to a loaded
 812        module, and module import payloads re-load the target module with
 813        each test. `HasPayload.prepare_source` and
 814        `HasPayload.wrap_module` are the closest equivalents for module
 815        import payloads.
 816        """
 817        self.augmentations["with_module_decorations"] = {
 818            "decorations": decorations,
 819            "ignore_missing": ignore_missing
 820        }
 821        return self
 822
 823    def capture_trace(self, trace_targets, state_function):
 824        """
 825        Causes the payload to produce a "trace" context slot in
 826        addition to other slots, which holds a trace of function calls
 827        to functions named in the provided `trace_targets` sequence. See
 828        `potluck.harness.tracing_function_calls` for details, including
 829        the functionality of the state function.
 830
 831        The `trace_targets` list may include tuples, in which case calls
 832        to any of the functions in the tuple will be traced as if they
 833        were calls to the first function (useful for collapsing aliases
 834        like turtle.fd and turtle.forward).
 835
 836        This function returns self so that you can chain it with other
 837        modification functions.
 838
 839        Any old tracing setup will be overridden.
 840        """
 841        self.augmentations["tracing_function_calls"] = {
 842            "trace_targets": trace_targets,
 843            "state_function": state_function
 844        }
 845        return self
 846
 847    def sample_result_distribution(
 848        self,
 849        slot_map={
 850            "value": "distribution",
 851            "ref_value": "ref_distribution"
 852        },
 853        trials=50000
 854    ):
 855        """
 856        Modifies the payload so that it runs many times and the
 857        distribution of results is recorded. For details, see
 858        `potluck.harness.sampling_distribution_of_results`.
 859
 860        Returns self for chaining.
 861
 862        Note that this is useful only in very specific cases, is
 863        often quite slow, and even in cases where it might be
 864        applicable, needs to be used with a very careful comparator.
 865
 866        In particular, if you're sampling the distribution of results
 867        from a random function and comparing them to a reference
 868        distribution, even with a lot of trials, the chances that the
 869        two distributions diverge significantly just by bad luck are
 870        often unfortunately high. If you're evaluating hundreds of
 871        submissions per task and dozens of tasks per course and want to
 872        scrupulously avoid the chance of an erroneous test result,
 873        consider other methods of testing random functions, such as
 874        seed-based testing or other de-randomization techniques.
 875        """
 876        self.augmentations["sampling_distribution_of_results"] = {
 877            "slot_map": slot_map,
 878            "trials": trials
 879        }
 880        return self
 881
 882    def capture_turtle_image(self, alt_text=None, skip_reset=False):
 883        """
 884        Captures what's drawn on the turtle canvas, into an "image"
 885        context slot. See `potluck.harness.capturing_turtle_drawings`,
 886        which explains the alt_text and skip_reset arguments.
 887
 888        Returns self for chaining.
 889        """
 890        self.augmentations["capturing_turtle_drawings"] = {
 891            "alt_text": alt_text,
 892            "skip_reset": skip_reset,
 893        }
 894        return self
 895
 896    def capture_wavesynth(
 897        self,
 898        just_capture=None,
 899        label="resulting_audio"
 900    ):
 901        """
 902        Captures the current track in `wavesynth` as a list of note
 903        description strings, in a "notes" context slot, and as raw audio,
 904        in the "audio" slot. See
 905        `potluck.harness.capturing_wavesynth_audio`.
 906
 907        You can set either just_capture to "notes" or "audio" to capture
 908        just one or the other, or leave it at None (the default) to
 909        capture both.
 910
 911        A custom label may be provided for any resulting audio elements.
 912
 913        Returns self for chaining.
 914        """
 915        self.augmentations["capturing_wavesynth_audio"] = {
 916            "just_capture": just_capture,
 917            "label": label
 918        }
 919        return self
 920
 921    def capture_file_contents(self, filename, binary=False):
 922        """
 923        Captures the contents of a specific file after the code has
 924        finished running. Stores the file name in the "output_filename"
 925        slot and the file contents as a string in the
 926        "output_file_contents" slot.
 927
 928        If `binary` is set to True instead of the default False, the file
 929        will be read as a bytes object instead of a string.
 930
 931        Returns self for chaining.
 932        """
 933        self.augmentations["capturing_file_contents"] = {
 934            "filename": filename,
 935            "binary": binary
 936        }
 937
 938        return self
 939
 940
 941class HasContext:
 942    """
 943    Abstract base class for tests which will create
 944    `potluck.contexts.Context` objects. Provides common tools for
 945    managing context creation.
 946    """
 947    def __init__(self, default_context_args=None):
 948        """
 949        Default context args may be provided.
 950        """
 951        self.default_context_args = default_context_args or {}
 952        self.context_args = {}
 953
 954    def synthesize_context_info(self, group=None):
 955        """
 956        Synthesizes context construction arguments. If a group is
 957        given, it should also be a `HasContext` instance, and its
 958        information will be mixed with local information as follows:
 959
 960          - First, group defaults will be loaded.
 961          - Next, this object's defaults will override those.
 962          - Third, group explicit values will be added.
 963          - Finally, local explicit values will have the final say.
 964        """
 965        args = {}
 966        if group:
 967            args.update(group.default_context_args)
 968        args.update(self.default_context_args)
 969        if group:
 970            args.update(group.context_args)
 971        args.update(self.context_args)
 972
 973        return args
 974
 975    def create_context(
 976        self,
 977        builder,
 978        group=None
 979    ):
 980        """
 981        Creates the implied `potluck.contexts.Context` object. Needs to
 982        be given the context-builder function that the context will use.
 983        If a group is provided, its information is merged with local
 984        information using `synthesize_context_info`.
 985        """
 986        # Synthesize arguments
 987        args = self.synthesize_context_info(group)
 988
 989        if "builder" in args:
 990            logging.debug_msg(
 991                "Warning: overriding existing builder value in"
 992                " create_context."
 993            )
 994
 995        args["builder"] = builder
 996
 997        # Append our payload's description to our description if we have
 998        # a payload description
 999        if hasattr(self, "describe_payload"):
1000            obf_topic, obf_details = self.describe_payload(group, True)
1001            clear_topic, clear_details = self.describe_payload(group, False)
1002            defaults = (
1003                obf_topic,
1004                obf_details,
1005                clear_topic,
1006                clear_details
1007            )
1008
1009            if "description" not in args: # default
1010                args["description"] = defaults
1011
1012            # If we've only got a topic (shouldn't happen), double it
1013            if len(args["description"]) < 4:
1014                args["description"] = (
1015                    tuple(args["description"])
1016                  + defaults[len(args):]
1017                )
1018
1019        # Create and return our context object
1020        return contexts.Context(**args)
1021
1022    def set_context_description(self, description):
1023        """
1024        Sets a custom description for the context. Returns self for
1025        chaining. A description is a 2-tuple or 4-tuple of strings. If
1026        it's a 2-tuple, it specifies the title for the rubric entry and
1027        then the longer description. If it's a 4-tuple, the first two
1028        elements are the title and description used in blank rubrics,
1029        while the second two are used in displaying actual evaluation
1030        results.
1031        """
1032        self.context_args["description"] = description
1033        return self
1034
1035    def set_context_displayer(self, displayer):
1036        """
1037        Sets a custom context product display function. Returns self for
1038        chaining.
1039
1040        The function will be handed the context dictionary that was used
1041        for testing, and should return an HTML string which gets
1042        displayed in the "Test Results" section of the feedback.
1043        """
1044        self.context_args["display_product"] = displayer
1045        return self
1046
1047    def describe_module_slot(self):
1048        """
1049        Sets up the context to describe itself as producing a module from
1050        the submitted file. Doesn't actually change how the context
1051        works; it's assumed that the context already produces a "module"
1052        value via a module import payload.
1053        """
1054        # Modify context arguments
1055        self.context_args["display_product"] = lambda context: (
1056            "&lt;the result of running your file&gt;"
1057        )
1058
1059        return self
1060
1061
1062class HasGoal:
1063    """
1064    Abstract base class for tests which will create
1065    `potluck.rubrics.Goal` objects. Provides common tools for managing
1066    goal creation. Subclasses must override the `create_goal` method with
1067    a zero-argument method that returns a goal object.
1068    """
1069    def create_goal(self):
1070        """
1071        Returns the `potluck.rubrics.Goal` implied by this object. Must
1072        be implemented in concrete subclasses.
1073        """
1074        raise NotImplementedError(
1075            "HasGoal is an abstract class and cannot be used directly."
1076        )
1077
1078    def __init__(
1079        self,
1080        taskid,
1081        goal_constructor,
1082        default_goal_args=None,
1083    ):
1084        """
1085        A task ID string and a goal constructor must be specified.
1086        Default goal args may be provided. Set the goal category and/or
1087        type via goal args.
1088        """
1089        self.taskid = taskid
1090        self.default_goal_args = default_goal_args or {}
1091        self.goal_args = {}
1092        self.goal_constructor = goal_constructor
1093        self._cached_goal = None
1094
1095    def provide_goal(self):
1096        """
1097        Returns the result of `create_goal`, but if `provide_goal` has
1098        been called previously, returns the cached result of that
1099        previous call instead.
1100        """
1101        if self._cached_goal is None:
1102            self._cached_goal = self.create_goal()
1103        return self._cached_goal
1104
1105    def synthesize_goal_info(self, group=None):
1106        """
1107        Synthesizes goal construction arguments. If a group is given,
1108        it should also be a `HasGoal` instance, and its information will
1109        be mixed with local information as follows:
1110
1111          - First, group defaults will be loaded.
1112          - Next, this object's defaults will override those.
1113          - Third, group explicit values will be added.
1114          - Finally, local explicit values will have the final say.
1115
1116        A "goal_type" tag will be deduced from the context_slot goal
1117        argument if it hasn't been set explicitly.
1118
1119        Note that the "tags" argument is itself a dictionary, and values
1120        will be synthesized according to the same procedure as for the
1121        whole dictionary.
1122        """
1123        args = {}
1124        tags = {}
1125        if group:
1126            args.update(group.default_goal_args)
1127            tags = args.get("tags", {})
1128        args.update(self.default_goal_args)
1129        tags.update(args.get("tags", {}))
1130        if group:
1131            args.update(group.goal_args)
1132            tags.update(args.get("tags", {}))
1133        args.update(self.goal_args)
1134        tags.update(args.get("tags", {}))
1135        args["tags"] = tags
1136
1137        # Deduce goal type if it hasn't been specified explicitly
1138        if "goal_type" not in args["tags"]: # no explicit goal type
1139            if "context_slot" in args: # deduce from context slot
1140                args["tags"]["goal_type"] = CONTEXT_SLOT_IMPLIED_TYPES.get(
1141                    args["context_slot"],
1142                    "other"
1143                )
1144            else: # no slot to deduce from; guess "other"
1145                args["tags"]["goal_type"] = "other"
1146
1147        return args
1148
1149    def create_goal_from_contexts(self, contexts, group=None):
1150        """
1151        Creates the implied `potluck.rubrics.Goal` object. Needs to be
1152        provided with a list of contexts in which the goal should be
1153        evaluated. A group may be provided for info merging via
1154        `synthesize_goal_info`.
1155        """
1156        args = self.synthesize_goal_info(group)
1157
1158        # Set testing contexts for this goal
1159        if "test_in" not in args:
1160            args["test_in"] = {}
1161
1162        if "contexts" in args["test_in"]:
1163            logging.debug_msg(
1164                "Warning: overriding existing test_in/contexts value in"
1165                " create_goal_from_contexts."
1166            )
1167
1168        args["test_in"]["contexts"] = contexts
1169
1170        args["taskid"] = self.taskid
1171
1172        # Create and return our goal object
1173        return self.goal_constructor(**args)
1174
1175    def ensure_goal_constructor_arg(self, desired):
1176        """
1177        Raises a TypeError unless this object's goal constructor
1178        accepts an argument of the desired name.
1179        """
1180        cobj = self.goal_constructor.__init__.__code__
1181        arg_names = cobj.co_varnames[:cobj.co_argcount]
1182        if desired not in arg_names:
1183            raise TypeError(
1184                f"This operation is only allowed for HasGoal classes"
1185                f" associated with goal types that accept a '{desired}'"
1186                f" argument ({self.goal_constructor.__name__} does not)."
1187            )
1188
1189    def goal(self, category="core"):
1190        """
1191        Registers this goal-provider as part of the rubric, under the
1192        given category (default is "core"). Returns self for chaining.
1193        """
1194        if "tags" not in self.goal_args:
1195            self.goal_args["tags"] = {}
1196
1197        self.goal_args["tags"]["category"] = category
1198        register_goal_provider(self)
1199
1200        return self
1201
1202    def validate(self, category="core"):
1203        """
1204        Registers this goal-provider as part of the rubric's
1205        test-validation goals, under the given category (default is
1206        "core"). Returns self for chaining.
1207        """
1208        if "tags" not in self.goal_args:
1209            self.goal_args["tags"] = {}
1210
1211        self.goal_args["tags"]["category"] = category
1212        register_validation_goal_provider(self)
1213
1214        return self
1215
1216    def set_identifier(self, identifier):
1217        """
1218        Sets the identifier that will be used for the goal produced.
1219        Returns self for chaining.
1220        """
1221        self.goal_args["identifier"] = identifier
1222        return self
1223
1224    def set_goal_type(self, goal_type):
1225        """
1226        Sets an explicit goal type for this goal. Normally, goal types
1227        can be deduced with reasonable accuracy from existing
1228        information, but if that isn't working, use this method to
1229        explicitly declare a goal type. Returns self for chaining.
1230        """
1231        if "tags" not in self.goal_args:
1232            self.goal_args["tags"] = {}
1233        self.goal_args["tags"]["goal_type"] = goal_type
1234        return self
1235
1236    def set_goal_description(self, description):
1237        """
1238        Sets a custom description for the goal. Returns self for
1239        chaining. A description is a 2-tuple or 4-tuple of strings. If
1240        it's a 2-tuple, it specifies the title for the rubric entry and
1241        then the longer description. If it's a 4-tuple, the first two
1242        elements are the title and description used in blank rubrics,
1243        while the second two are used in displaying actual evaluation
1244        results.
1245        """
1246        self.goal_args["description"] = description
1247        return self
1248
1249    def test_module(self, module_comparator):
1250        """
1251        Changes the implied goal so that instead of comparing the printed
1252        output between runs of the submitted and solution modules, it
1253        compares the module objects that result from those runs, using
1254        the given comparator function. Only applicable to goals derived
1255        from payloads that run modules.
1256
1257        The comparator function will be given two module objects and will
1258        be expected to return an evaluation result, which is a dictionary
1259        with "status" and "explanation" keys (see `potluck.compare`).
1260
1261        Note that one should almost always use `set_goal_description`
1262        along with this function to describe what the given comparison
1263        function actually does.
1264
1265        This function returns self for chaining.
1266        """
1267        if (
1268            not hasattr(self, "ignore_output")
1269         or not hasattr(self, "describe_module_slot")
1270        ):
1271            raise TypeError(
1272                f"Module-based testing is only applicable for"
1273                f" Goal-generators which have ignore_output and"
1274                f" describe_module_slot methods ({self} does not)."
1275            )
1276
1277        # Set up our payload to ignore output (since we're using the
1278        # module object instead)
1279        self.ignore_output()
1280
1281        # Set up our context to focus on the module slot
1282        self.describe_module_slot()
1283
1284        # Modify goal arguments
1285        self.goal_args["context_slot"] = "module"
1286        self.goal_args["tags"]["goal_type"] = "product"
1287
1288        # Modify default (not explicit) description
1289        self.default_goal_args["description"] = (
1290            "Running your file must define the right values",
1291            (
1292                "Your file must define the same variables and functions"
1293                " as the solution file when run."
1294            )
1295        )
1296
1297        return self
1298
1299    def test_output(self, capture_errors=False):
1300        """
1301        Causes this test to compare printed output instead of result
1302        values. Automatically calls `self.capture_output` (which will
1303        normally be `HasPayload.capture_output`) to set that up, passing
1304        `capture_errors` on to that function if it exists.
1305
1306        Returns self for chaining.
1307        """
1308        if hasattr(self, "capture_output"):
1309            self.capture_output(capture_errors)
1310
1311        self.goal_args["context_slot"] = "output"
1312
1313        # Set default (not explicit) description
1314        if hasattr(self, "base_name"):
1315            if self.base_name == "import":
1316                self.default_goal_args["description"] = (
1317                    "Your program must print the correct output",
1318                    (
1319                        "The output printed when your program is"
1320                        " run must match the solution output."
1321                    )
1322                )
1323            else:
1324                self.default_goal_args["description"] = (
1325                    (
1326                        f"<code>{self.base_name}</code> must print the"
1327                        f" correct output"
1328                    ),
1329                    (
1330                        f"The output printed when your"
1331                        f" <code>{self.base_name}</code> function is run"
1332                        f" must match the solution output."
1333                    )
1334                )
1335        else:
1336            self.default_goal_args["description"] = (
1337                "Your code must print the correct output",
1338                (
1339                    "The output printed when your code is run must"
1340                    " match the solution output."
1341                )
1342            )
1343
1344        self.context_args["display_product"] = (
1345            contexts.build_context_value_displayer(
1346                "output",
1347                labels=[
1348                    "Your output",
1349                    "Solution output",
1350                    "Comparison"
1351                ]
1352            )
1353        )
1354
1355        return self
1356
1357    def test_with_harness(self, harness_fn):
1358        """
1359        Causes this test to compare test-harness results instead of
1360        direct function call results. Calls `self.use_harness` (which
1361        must be available, normally from `HasPayload.use_harness`).
1362        See `HasPayload.use_harness` for details on how the harness
1363        function will be applied.
1364
1365        Only applicable to a Goal which is based on testing a function
1366        call.
1367
1368        Note: If you want to test the printed output of a test harness
1369        rather than its return value, you can call both
1370        `test_with_harness` and `test_output`, but you should call
1371        `test_with_harness` second to set the description properly.
1372        """
1373        if (
1374            not hasattr(self, "base_name")
1375         or self.base_name == "import"
1376         or not hasattr(self, "use_harness")
1377        ):
1378            raise TypeError(
1379                f"Harness-based testing is only applicable for"
1380                f" Goal-generators which have a base_name attribute"
1381                f" which isn't 'import' and a use_harness method ({self}"
1382                f" does not)."
1383            )
1384
1385        # Set up harness for our payload
1386        self.use_harness(harness_fn)
1387
1388        # Set (default) description from the test harness
1389        self.default_goal_args["description"] = explain.harness_descriptions(
1390            harness_fn,
1391            self.base_name,
1392            '', # multiple TestCases so can't specify arguments
1393            '', # multiple TestCases so can't specify arguments
1394            'behavior' # TODO: less generic here
1395        )
1396
1397        return self
1398
1399    def test_trace(self, trace_targets, state_function):
1400        """
1401        Sets up this test to test a trace result instead of just what
1402        the function returns. Uses the `capture_trace` method to set up
1403        tracing, which must be available.
1404
1405        You can use `check_trace_state` and/or `check_invariant`
1406        afterwards to alter how the trace will be compared with the
1407        solution trace.
1408        """
1409        if (
1410            not hasattr(self, "base_name")
1411         or self.base_name == "import"
1412         or not hasattr(self, "capture_trace")
1413        ):
1414            raise TypeError(
1415                f"Trace-based testing is only applicable for"
1416                f" Goal-generators which have a capture_trace method"
1417                f" and a base_name attribute that's not 'import' ({self}"
1418                f" does not)."
1419            )
1420
1421        # Set up tracing
1422        self.capture_trace(trace_targets, state_function)
1423
1424        # Target the "trace" context slot
1425        self.goal_args["context_slot"] = "trace"
1426
1427        # Set our goal type to "process"
1428        self.goal_args.setdefault("tags", {})["goal_type"] = "process"
1429
1430        # Set default (not explicit) description
1431        self.default_goal_args["description"] = (
1432            f"<code>{self.base_name}</code> must use the correct process",
1433            (
1434                f"The pattern of functions called when your"
1435                f" <code>{self.base_name}</code> function is run must"
1436                f" match the solution process."
1437            )
1438        )
1439
1440        return self
1441
1442    def check_trace_state(
1443        self,
1444        state_slots,
1445        check_args=None,
1446        check_results=False,
1447        pre_or_post="pre",
1448        order_matters=False,
1449        only=None,
1450        tolerance="auto"
1451    ):
1452        """
1453        You must call `test_trace` first to set up tracing. This function
1454        changes the comparison function so that instead of comparing
1455        entire traces directly, we identify each function call using the
1456        function name, the values of specific state slots, and the
1457        parameters used (if `check_args` is non-empty) and/or results
1458        (if `check_results` is True). After identifying each function
1459        call in this way, we create a list of those calls in linear
1460        sequence, and compare those lists between the submitted and
1461        solution traces (only caring about order if order_matters is set
1462        to True).
1463
1464        The `state_slots` argument is required, it should be a list of
1465        strings and indicates which slots in the state dictionary we
1466        care about. It may be empty, in which case no state entries will
1467        be included in the call IDs.
1468
1469        If `check_args` is set it should be None or a list of strings
1470        and/or integers; named (and/or indexed) parameters will be
1471        included as identifying information for trace entries). It may
1472        also be True to check all arguments.
1473
1474        If `check_results` is set, the return value of each call will be
1475        included in its identifying information.
1476
1477        `pre_or_post` determines whether states before function calls or
1478        just before their returns are used. Set it to the string 'pre',
1479        the string 'post', or the string 'both' to include both states.
1480        The default is 'pre'.
1481
1482        `order_matters` can be set to True if you want to enforce
1483        matching of traces in-order, rather than allowing an equivalent
1484        set of function calls in any order to count as matched. Your
1485        specs will be more flexible if you can check correctness based
1486        on state-at-time-of-call rather than order-of-call so the
1487        default for this is False.
1488
1489        `only` should be a list of function names, or None. If not None,
1490        then only functions names in this list will be included in the
1491        check performed.
1492
1493        `tolerance` will be passed to `make_structure_comparator`; see
1494        that function for details; "auto" is the default.
1495
1496        Returns self for chaining.
1497
1498        For example, this kind of comparison could be used to ensure
1499        that a solution calls certain drawing commands with the right
1500        parameters from the right set of states, without regard to
1501        order, which is one way to specify that it "draws the right
1502        thing" even if we don't care what order things are drawn in.
1503        """
1504        # Check that tracing is set up
1505        if self.goal_args["context_slot"] != "trace":
1506            raise ValueError(
1507                "You must activate tracing using `test_trace` before"
1508                " calling `check_trace_state`."
1509            )
1510
1511        # Make sure we've got goal which requires a checker
1512        self.ensure_goal_constructor_arg("checker")
1513
1514        # Create our base comparator
1515        base_comparator = compare.make_structure_comparator(
1516            tolerance=tolerance,
1517            order_matters=order_matters
1518        )
1519        # TODO: Allow rounding differences in state by default, including
1520        # in args!
1521
1522        # Define our full comparator
1523        def compare_trace_states(submitted, solution):
1524            """
1525            A custom comparator function which compares certain parts of
1526            captured trace states.
1527            """
1528            # TODO: Better explanation which turns trace state dicts into
1529            # human-readable explanations of call situations...
1530            processed = []
1531            for trace in submitted, solution:
1532                rendered = []
1533                processed.append(rendered)
1534                for entry in harness.walk_trace(trace):
1535                    if only and entry["fname"] not in only:
1536                        continue
1537                    entry_id = { "fname": entry["fname"] }
1538
1539                    # Grab args if requested
1540                    if check_args:
1541                        if check_args is True:
1542                            entry_id["args"] = copy.copy(entry["args"])
1543                        else:
1544                            indices = [
1545                                i
1546                                for i in check_args
1547                                if isinstance(i, int)
1548                            ]
1549                            names = [
1550                                name
1551                                for name in check_args
1552                                if isinstance(name, str)
1553                            ]
1554
1555                            # Which args are we actually taking?
1556                            take = []
1557                            for i, argname in enumerate(entry["args"]):
1558                                if i in indices or argname in names:
1559                                    take.append(argname)
1560
1561                            entry_id["args"] = {
1562                                argname: entry["args"][argname]
1563                                for argname in take
1564                            }
1565
1566                    # Grab result if we need it
1567                    if check_results:
1568                        entry_id["result"] = entry["result"]
1569
1570                    # Grab pre- and/or post-call state values
1571                    if pre_or_post in ("pre", "both"):
1572                        entry_id["pre_state"] = {
1573                            slot: entry["pre_state"][slot]
1574                            for slot in state_slots
1575                        }
1576                    if pre_or_post in ("post", "both"):
1577                        entry_id["post_state"] = {
1578                            slot: entry["post_state"][slot]
1579                            for slot in state_slots
1580                        }
1581
1582                    rendered.append(entry_id)
1583
1584            # Run our base comparator on the two processed lists
1585            return base_comparator(processed[0], processed[1])
1586
1587        # Set up our comparator as the checker for this goal
1588        self.goal_args["checker"] = compare_trace_states
1589
1590        # Build description pieces
1591        targets = "the correct functions"
1592        if only:
1593            targets = "the " + phrasing.comma_list(
1594                f"<code>{fn}</code>"
1595                for fn in only
1596            ) + " " + phrasing.plural(len(only), "function")
1597
1598        conditions = []
1599        if order_matters:
1600            conditions.append("in the correct order")
1601
1602        if check_args:
1603            conditions.append("with the correct arguments")
1604
1605        if state_slots:
1606            conditions.append(
1607                "while the correct "
1608              + phrasing.comma_list(state_slots)
1609              + " " + phrasing.plural(len(state_slots), "value")
1610              + " " + phrasing.plural(len(state_slots), "is", "are")
1611              + " set up"
1612            )
1613
1614        if check_results:
1615            conditions.append(
1616                "and each call must return the correct result"
1617            )
1618
1619        # Set default (not explicit) description
1620        if hasattr(self, "base_name") and self.base_name != "input":
1621            self.default_goal_args["description"] = (
1622                (
1623                    f"<code>{self.base_name}</code> must make the correct"
1624                    f" function calls"
1625                ),
1626                (
1627                    f"Your <code>{self.base_name}</code> function must"
1628                  + f" call {targets} "
1629                  + ', '.join(conditions)
1630                )
1631            )
1632        else:
1633            self.default_goal_args["description"] = (
1634                "Your code must make the correct function calls",
1635                (
1636                    f"When your code is run it must call {targets} "
1637                  + ', '.join(conditions)
1638                )
1639            )
1640
1641        return self
1642
1643    def check_invariant(
1644        self,
1645        state_slots,
1646        only=None,
1647        partial_tolerance=0.2
1648    ):
1649        """
1650        You must call `test_trace` first to set up tracing. This function
1651        changes the comparison function so that instead of comparing
1652        entire traces directly, we check that specific state slots
1653        specified do not change between the pre- and post- states of
1654        each trace entry.
1655
1656        `only` may be set to None, in which case all entries are checked
1657        (the default), or a list of strings may be provided naming
1658        functions to check (others will be ignored).
1659
1660        `partial_tolerance` should be a fraction which specifies what
1661        percentage of function calls are allowed to violate the
1662        invariant while still returning a partial-success result.
1663
1664        By default for floating-point values there is a baseline level
1665        of tolerance for small changes.
1666
1667        Returns self for chaining.
1668        """
1669        # Check that tracing is set up
1670        if self.goal_args["context_slot"] != "trace":
1671            raise ValueError(
1672                "You must activate tracing using `test_trace` before"
1673                " calling `check_trace_state`."
1674            )
1675
1676        # Make sure we've got goal which requires a checker
1677        self.ensure_goal_constructor_arg("checker")
1678
1679        # Set up base comparator
1680        base_comparator = compare.omni_compare
1681
1682        # Build description of targets
1683        targets = "your functions"
1684        if only:
1685            targets = "the " + phrasing.comma_list(
1686                f"<code>{fn}</code>"
1687                for fn in only
1688            ) + " " + phrasing.plural(len(only), "function")
1689
1690        # Build description of states
1691        states = "the " + phrasing.comma_list(
1692            f"<code>{slot}</code>"
1693            for slot in state_slots
1694        ) + " " + phrasing.plural(len(state_slots), "value")
1695
1696        def check_for_invariants(submitted, *_):
1697            """
1698            Checks the submitted trace to make sure that certain state
1699            values don't change when certain functions are called. Any
1700            provided solution trace is ignored.
1701            """
1702            total = 0
1703            failed = []
1704            for entry in harness.walk_trace(submitted):
1705                # Only inspect targeted functions
1706                if only and entry["fname"] not in only:
1707                    continue
1708
1709                total += 1
1710
1711                # Grab pre/post states
1712                pre = entry["pre_state"]
1713                post = entry["post_state"]
1714
1715                # Compare each slot value between pre and post
1716                different = []
1717                for slot in state_slots:
1718                    same = base_comparator(pre[slot], post[slot])
1719                    if same["status"] != "accomplished":
1720                        different.append((slot, pre[slot], post[slot]))
1721
1722                if different:
1723                    failed.append((entry, different))
1724
1725            # Return an evaluation based on how many calls failed to be
1726            # invariant in terms of the specified state slots
1727            pct_failed = len(failed) / total
1728            if pct_failed == 0:
1729                return {
1730                    "status": "accomplished",
1731                    "explanation": (
1732                        f"all {total} calls to {targets} maintained "
1733                      + phrasing.plural(
1734                          len(state_slots),
1735                          "an invariant", "invariants"
1736                        )
1737                      + f" for {states}"
1738                    )
1739                }
1740            else:
1741                status = "failed"
1742                if pct_failed <= partial_tolerance:
1743                    status = "partial"
1744                return {
1745                    "status": status,
1746                    "explanation": (
1747                        f"out of {total} calls to {targets},"
1748                        f" {len(failed)} failed to maintain "
1749                      + phrasing.plural(
1750                          len(state_slots),
1751                          "an invariant", "invariants"
1752                        )
1753                      + f" for {states}:<br>\n"
1754                      + html_tools.build_list(
1755                            (
1756                                f"<code>{entry['fname']}("
1757                              + ', '.join(
1758                                    "{name}={val}".format(
1759                                        name=name,
1760                                        val=html_tools.dynamic_html_repr(
1761                                            entry['args'][name]
1762                                        )
1763                                    )
1764                                  for name in entry['args']
1765                                )
1766                              + ")</code> changed "
1767                              + html_tools.build_list(
1768                                    (
1769                                        "<code>{slot}</code> from"
1770                                        " <code>{pre}</code>"
1771                                        " to <code>{post}</code>"
1772                                    ).format(
1773                                        slot=slot,
1774                                        pre=html_tools.dynamic_html_repr(
1775                                            pre
1776                                        ),
1777                                        post=html_tools.dynamic_html_repr(
1778                                            post
1779                                        )
1780                                    )
1781                                    for slot, pre, post in different
1782                                )
1783                            )
1784                            for entry, different in failed
1785                        )
1786                    )
1787                }
1788
1789        # Set up our comparator as the checker for this goal
1790        self.goal_args["checker"] = check_for_invariants
1791
1792        # Set default (not explicit) descriptions:
1793        self.default_goal_args["description"] = (
1794            (
1795                f"{targets} must maintain ".capitalize()
1796              + phrasing.plural(
1797                  len(state_slots),
1798                  "an invariant", "invariants"
1799                )
1800              + f" for {states}"
1801            ),
1802            (
1803                f"Each call to {targets} must return {states} to "
1804              + phrasing.plural(len(state_slots), "its", "their")
1805              + " initial state before " + phrasing.plural(
1806                    len(only) if only else 2,
1807                    "it returns.",
1808                    "they return."
1809                )
1810            )
1811        )
1812
1813        # Now we're done; return self for chaining
1814        return self
1815
1816    def check_trace_count(self, target, double_or_half=False):
1817        """
1818        You must call `test_trace` first to set up tracing. This function
1819        changes the comparison function so that instead of comparing
1820        entire traces directly, we look at only function calls in the
1821        trace to functions with the provided target name, and we just
1822        compare how many there are, ignoring the state-at-call-time and
1823        even arguments-supplied information in the trace.
1824
1825        Returns partial success if the number of calls is close to
1826        correct, and if double_or_half is True, also returns partial
1827        success if the number of calls is double or half the correct
1828        value, or within one of double or half.
1829        """
1830        # Check that tracing is set up
1831        if self.goal_args["context_slot"] != "trace":
1832            raise ValueError(
1833                "You must activate tracing using `test_trace` before"
1834                " calling `check_trace_count`."
1835            )
1836
1837        # Make sure we've got goal which requires a checker
1838        self.ensure_goal_constructor_arg("checker")
1839
1840        # Define our full comparator
1841        def compare_trace_counts(submitted, solution):
1842            """
1843            A custom comparator function which compares the count of
1844            calls to a certain function in two traces.
1845            """
1846            counts = []
1847            # Count entries in each trace
1848            for trace in submitted, solution:
1849                count = 0
1850                for entry in harness.walk_trace(trace):
1851                    if entry["fname"] == target:
1852                        count += 1
1853                counts.append(count)
1854
1855            sub_count, soln_count = counts
1856
1857            if sub_count == soln_count:
1858                return {
1859                    "status": "accomplished",
1860                    "explanation": (
1861                        f"Number of function calls to"
1862                        f" <code>{target}</code> was correct"
1863                        f" ({soln_count})"
1864                    )
1865                }
1866            elif sub_count in (
1867                soln_count - 1,
1868                soln_count + 1
1869            ):
1870                return {
1871                    "status": "partial",
1872                    "explanation": (
1873                        f"Number of function calls to"
1874                        f" <code>{target}</code> ({sub_count}) was"
1875                        f" almost correct (should have been"
1876                        f" {soln_count})."
1877                    )
1878                }
1879            elif double_or_half and sub_count in (
1880                soln_count // 2,
1881                soln_count * 2
1882            ):
1883                return {
1884                    "status": "partial",
1885                    "explanation": (
1886                        f"Number of function calls to"
1887                        f" <code>{target}</code> ({sub_count}) was"
1888                        f" double or half of the correct value"
1889                        f" ({soln_count})."
1890                    )
1891                }
1892            elif double_or_half and sub_count in (
1893                soln_count // 2 - 1,
1894                soln_count // 2 + 1,
1895                soln_count * 2 - 1,
1896                soln_count * 2 + 1
1897            ):
1898                return {
1899                    "status": "partial",
1900                    "explanation": (
1901                        f"Number of function calls to"
1902                        f" <code>{target}</code> ({sub_count}) was"
1903                        f" nearly double or half of the correct value"
1904                        f" ({soln_count})."
1905                    )
1906                }
1907            else:
1908                return {
1909                    "status": "failed",
1910                    "explanation": (
1911                        f"Number of function calls to"
1912                        f" <code>{target}</code> ({sub_count}) was"
1913                        f" incorrect (should have been {soln_count})."
1914                    )
1915                }
1916
1917        # Set up our comparator as the checker for this goal
1918        self.goal_args["checker"] = compare_trace_counts
1919
1920        # Set default (not explicit) description
1921        if hasattr(self, "base_name") and self.base_name != "input":
1922            self.default_goal_args["description"] = (
1923                (
1924                    f"<code>{self.base_name}</code> must make the correct"
1925                    f" number of calls to <code>{target}</code>"
1926                ),
1927                (
1928                    f"Your <code>{self.base_name}</code> function must"
1929                    f" call <code>{target}</code> the correct number of"
1930                    f" times."
1931                )
1932            )
1933        else:
1934            self.default_goal_args["description"] = (
1935                (
1936                    f"Your code must make the correct number of function"
1937                    f" calls to <code>{target}</code>"
1938                ),
1939                (
1940                    f"When your code is run it must call"
1941                    f" <code>{target}<code> the correct number of"
1942                    f" times."
1943                )
1944            )
1945
1946        return self
1947
1948    def test_wavesynth_notes(self):
1949        """
1950        Sets up for testing note descriptions from the wavesynth module.
1951        """
1952        if hasattr(self, "capture_wavesynth"):
1953            self.capture_wavesynth(just_capture="notes")
1954
1955        self.goal_args["context_slot"] = "notes"
1956
1957        # Set default (not explicit) description
1958        if hasattr(self, "base_name"):
1959            if self.base_name == "import":
1960                what = "your program"
1961                What = "Your program"
1962                verb = "run"
1963            else:
1964                what = f"<code>{self.base_name}</code>"
1965                What = what
1966                verb = "called"
1967
1968            self.default_goal_args["description"] = (
1969                f"{What} must produce the correct note sequence",
1970                (
1971                    f"The notes added to the current track when {what}"
1972                    f" is {verb} must match the solution notes"
1973                    f" in terms of timing, instruments, pitches, and"
1974                    f" volumes."
1975                ),
1976                (
1977                    f"{What} produces the correct note"
1978                    " sequence"
1979                ),
1980                (
1981                    "We checked that the notes {what} adds to the"
1982                    " current track match those added by the solution."
1983                )
1984            )
1985
1986        else:
1987            self.default_goal_args["description"] = (
1988                "Your code must produce the correct note sequence",
1989                (
1990                    "The sequence of notes added to the current track"
1991                    " when your code is run must match the solution"
1992                    " notes."
1993                )
1994            )
1995
1996        self.context_args["display_product"] = (
1997            contexts.build_context_value_displayer(
1998                "notes",
1999                labels=[
2000                    "Your notes",
2001                    "Solution notes",
2002                    "Comparison"
2003                ]
2004            )
2005        )
2006
2007        return self
2008
2009    def test_wavesynth_audio(self):
2010        """
2011        Sets up for testing raw audio from the wavesynth module.
2012        """
2013        if hasattr(self, "capture_wavesynth"):
2014            self.capture_wavesynth(just_capture="audio")
2015
2016        self.goal_args["context_slot"] = "audio"
2017
2018        # Set default (not explicit) description
2019        if hasattr(self, "base_name"):
2020            if self.base_name == "import":
2021                what = "your program"
2022                verb = "run"
2023            else:
2024                what = f"<code>{self.base_name}</code>"
2025                verb = "called"
2026            self.default_goal_args["description"] = (
2027                f"{what.capitalize()} must produce the correct audio",
2028                (
2029                    f"The audio produced by calling"
2030                    f" <code>playTrack</code> after {what} is {verb}"
2031                    f" must match the solution audio."
2032                )
2033            )
2034        else:
2035            self.default_goal_args["description"] = (
2036                "Your code must produce the correct audio",
2037                (
2038                    "The audio produced by calling"
2039                    " <code>playTrack</code> after your code is run"
2040                    " must match the solution audio."
2041                )
2042            )
2043
2044        # TODO: Use snippet machinery!
2045        self.context_args["display_product"] = (
2046            contexts.build_context_value_displayer(
2047                "audio",
2048                labels=[
2049                    "Your audio",
2050                    "Solution audio",
2051                    "Comparison"
2052                ]
2053            )
2054        )
2055
2056        return self
2057
2058    def test_turtle_image(
2059        self,
2060        allowed_differences=0.03,
2061        partial_allowed=0.5,
2062        similarity_threshold=15
2063    ):
2064        """
2065        Sets up for testing the image drawn using turtle graphics. The
2066        arguments are passed on to `compare.make_image_comparator` to
2067        determine the strictness of the comparison. The defaults are
2068        fairly liberal, especially if what is being drawn does not take
2069        up a large area of the image.
2070
2071        TODO: Background subtraction!
2072        """
2073        # Capture turtle image (if we can)
2074        if hasattr(self, "capture_turtle_image"):
2075            self.capture_turtle_image()
2076
2077        # Set up image comparator
2078        self.compare_using(
2079            compare.make_image_comparator(
2080                allowed_differences,
2081                partial_allowed,
2082                similarity_threshold
2083            )
2084        )
2085
2086        # Set context slot to compare
2087        self.goal_args["context_slot"] = "image"
2088
2089        # Set default (not explicit) description
2090        if hasattr(self, "base_name"):
2091            if self.base_name == "import":
2092                What = "Your program"
2093                what = "your program"
2094                verb = "run"
2095            else:
2096                What = f"<code>{self.base_name}</code>"
2097                what = What
2098                verb = "called"
2099            self.default_goal_args["description"] = (
2100                f"{What} must draw the correct image",
2101                (
2102                    f"The image drawn in the turtle window after {what}"
2103                    f" is {verb} must match the solution image."
2104                )
2105            )
2106        else:
2107            self.default_goal_args["description"] = (
2108                "Your code must draw the correct image",
2109                (
2110                    "The image drawn in the turtle window after your"
2111                    " code is run must match the solution image."
2112                )
2113            )
2114
2115        # TODO: Use snippet machinery?
2116        # Set context value displayer
2117        self.context_args["display_product"] = (
2118            contexts.create_image_result_displayer()
2119        )
2120
2121        return self
2122
2123    def test_file_contents(self, filename=None, binary=False):
2124        """
2125        Causes this test to compare the contents of the specified file
2126        instead of result values. Automatically calls
2127        `self.capture_file_contents` (which will normally be
2128        `HasPayload.capture_file_contents`) to set that up. The `binary`
2129        argument will be passed through to that function, and indicates
2130        that file contents should be read as bytes, not as a string.
2131
2132        Note that if you are using a `TestGroup` that includes individual
2133        `SingleTest` objects which write to multiple different filenames,
2134        leave the filename argument out and
2135        `HasPayload.capture_file_contents` will not be called; you will
2136        have to call it yourself on individual `SingleTest` items.
2137
2138        Returns self for chaining.
2139        """
2140        if hasattr(self, "capture_file_contents") and filename is not None:
2141            self.capture_file_contents(filename, binary)
2142
2143        self.goal_args["context_slot"] = "output_file_contents"
2144
2145        # Set default (not explicit) description
2146        file_desc = "the appropriate file"
2147        if filename is not None:
2148            file_desc = "<code>" + filename + "</code>"
2149
2150        if hasattr(self, "base_name"):
2151            if self.base_name == "import":
2152                self.default_goal_args["description"] = (
2153                    (
2154                        f"Your program must write the correct data into"
2155                        f" {file_desc}"
2156                    ),
2157                    (
2158                        f"The data written into {file_desc} when your"
2159                        f" program is run must match what the solution"
2160                        f" writes."
2161                    )
2162                )
2163            else:
2164                self.default_goal_args["description"] = (
2165                    (
2166                        f"<code>{self.base_name}</code> must write the"
2167                        f" correct data into {file_desc}"
2168                    ),
2169                    (
2170                        f"The data written to {file_desc} when your"
2171                        f" <code>{self.base_name}</code> function is run"
2172                        f" must match what the solution writes."
2173                    )
2174                )
2175        else:
2176            self.default_goal_args["description"] = (
2177                (
2178                    f"Your code must write the correct data into"
2179                    f" {file_desc}"
2180                ),
2181                (
2182                    f"The data written into {file_desc} when your code"
2183                    f" is run must match the solution output."
2184                )
2185            )
2186
2187        self.context_args["display_product"] = (
2188            contexts.build_context_value_displayer(
2189                "output_file_contents",
2190                labels=[
2191                    f"Contents of {file_desc}",
2192                    "Correct contents",
2193                    "Comparison"
2194                ]
2195            )
2196        )
2197
2198        return self
2199
2200    # TODO: property -> payload -> condition description assembly...
2201    def compare_using(
2202        self,
2203        comparator_fn=None,
2204        context_slot=None
2205    ):
2206        """
2207        Specifies an alternate comparator for this goal (only works for
2208        `potluck.rubrics.ComparisonTest` as the `goal_constructor`). If a
2209        context_slot is also (or only) given, changes the context slot
2210        which will be compared as well.
2211
2212        The comparator function (if provided) must return a comparison
2213        result: a dictionary with "status" and "explanation" keys, where
2214        the status is one of "accomplished", "partial", or "failed". If
2215        no comparison function is provided, the current comparator will
2216        not be changed.
2217
2218        The context slot (if provided) must be a string naming the slot
2219        to use; see `potluck.contexts.Context` for a list of common slot
2220        names, but you could use your own custom slots too by using
2221        `HasPayload.do_setup` and/or `HasPayload.do_cleanup`, which can
2222        modify the context dictionary directly. If no context slot is
2223        specified, the current value will not be changed. Note that
2224        several other methods, like `test_output`, also modify the
2225        context slot and ordering matters; the last method to be called
2226        will determine which context slot is used.
2227
2228        Returns self for chaining.
2229        """
2230        self.ensure_goal_constructor_arg("checker")
2231        if comparator_fn is not None:
2232            self.goal_args["checker"] = comparator_fn
2233        if context_slot is not None:
2234            self.goal_args["context_slot"] = context_slot
2235        return self
2236
2237    def succeed_unless_crashed(self):
2238        """
2239        Overrides the comparator such that the goal always succeeds,
2240        unless the context builder fails because of a crash. Modifies
2241        the default goal arguments to note this.
2242
2243        Note that this won't check for captured errors (e.g., by using
2244        `HasGoal.test_output` and/or `HasPayload.capture_output` with
2245        the `capture_errors` option).
2246
2247        Returns self for chaining.
2248        """
2249        self.ensure_goal_constructor_arg("checker")
2250        self.goal_args["checker"] = lambda _1, _2: {
2251            "status": "accomplished",
2252            "explanation": "Test ran without errors."
2253        }
2254        # Set default goal type
2255        self.default_goal_args.setdefault(
2256            "tags",
2257            {}
2258        )["goal_type"] = "process"
2259
2260        # Set default (not explicit) description
2261        if hasattr(self, "base_name"):
2262            if self.base_name == "import":
2263                self.default_goal_args["description"] = (
2264                    "Your program must not crash",
2265                    "Your program must run without crashing.",
2266                    "Your program must not crash",
2267                    "We ran your program and checked if it crashed."
2268                )
2269            else:
2270                self.default_goal_args["description"] = (
2271                    f"<code>{self.base_name}</code> must not crash",
2272                    (
2273                        f"Your <code>{self.base_name}</code> function"
2274                        f" must run without crashing."
2275                    ),
2276                    f"<code>{self.base_name}</code> must not crash",
2277                    (
2278                        f"We ran your <code>{self.base_name}</code>"
2279                        f" function and checked whether it crashed."
2280                    )
2281                )
2282        else:
2283            self.default_goal_args["description"] = (
2284                "Your code must not crash",
2285                "Your code must run without crashing.",
2286                "Your code must not crash",
2287                "We ran your code and checked if it crashed."
2288            )
2289
2290        return self
2291
2292    def compare_exactly(self):
2293        """
2294        Overrides the comparator (see `compare_using`) with
2295        `potluck.compare.strict_equality_checker`, which compares items
2296        of any type for exact equality (the default
2297        `potluck.compare.omni_compare` function has various grades of
2298        partial success and ignores things like floating point rounding
2299        error). Returns the `TestGroup` for chaining.
2300
2301        Note: this is very rarely what you want, since it has weird edge
2302        cases that the default `potluck.compare.omni_compare` smoothes
2303        over.
2304        """
2305        self.ensure_goal_constructor_arg("checker")
2306        self.goal_args["checker"] = compare.strict_equality_checker
2307        return self
2308
2309    def compare_reports(self):
2310        """
2311        Overrides the comparator (see `compare_using`) with
2312        `potluck.compare.multiline_strings_are_exactly_equal`, which
2313        compares strings exactly and formats multi-line output nicely.
2314        This is just a convenience function to make this functionality
2315        more prominent; it returns the `TestGroup` for chaining.
2316        """
2317        self.ensure_goal_constructor_arg("checker")
2318        self.goal_args["checker"] = compare.multiline_strings_are_exactly_equal
2319        return self
2320
2321    def compare_strings_gently(
2322        self,
2323        line_match_threshold=0.5,
2324        sequence_match_threshold=0.8
2325    ):
2326        """
2327        Overrides the comparator (see `compare_using`) with
2328        `potluck.compare.very_fuzzy_string_compare`, which compares
2329        strings very roughly. This is just a convenience function to make
2330        this functionality more prominent; it returns the `TestGroup` for
2331        chaining. The `line_match_threshold` and
2332        `sequence_match_threshold` values are passed through to
2333        `compare.very_fuzzy_string_compare`.
2334        """
2335        self.ensure_goal_constructor_arg("checker")
2336        self.goal_args["checker"] = lambda val, ref: (
2337            compare.very_fuzzy_string_compare(
2338                val,
2339                ref,
2340                line_match_threshold,
2341                sequence_match_threshold
2342            )
2343        )
2344        return self
2345
2346    def compare_strings_semi_strict(self):
2347        """
2348        Overrides the comparator (see `comparator`) with
2349        `potluck.compare.strings_are_equal_modulo_whitespace`, which
2350        compares strings somewhat roughly (errors in whitespace and
2351        capitalization are mostly ignored). This is just a convenience
2352        function to make this functionality more prominent; it returns
2353        the `TestGroup` for chaining.
2354        """
2355        self.ensure_goal_constructor_arg("checker")
2356        self.goal_args["checker"] = (
2357            compare.strings_are_equal_modulo_whitespace
2358        )
2359        return self
2360
2361    def compare_strings_firmly(self):
2362        """
2363        Overrides the comparator (see `comparator`) with
2364        `potluck.compare.strings_are_equal_modulo_most_whitespace`,
2365        which works like
2366        `potluck.compare.strings_are_equal_modulo_whitespace` but it
2367        requires that word boundaries are preserved. This is just a
2368        convenience function to make this functionality more prominent;
2369        it returns the `TestGroup` for chaining.
2370        """
2371        self.ensure_goal_constructor_arg("checker")
2372        self.goal_args["checker"] = (
2373            compare.strings_are_equal_modulo_most_whitespace
2374        )
2375        return self
2376
2377    def refine(self, refiner_class, *refiner_args, **refiner_kwargs):
2378        """
2379        Creates a new `RefinedTest` based on the goal to be created by
2380        the current test (actually, based on the associated context
2381        objects; see `RefinedTest`).
2382
2383        You need to provide the class object to be instanced, and you may
2384        provide extra positional and/or keyword arguments that that
2385        refiner requires for initialization, beyond the parent object.
2386        This function returns the new `RefinedTest` instance for
2387        chaining.
2388
2389        Note that typically, it is not necessary for both the original
2390        and refined goals to appear in the rubric, and to achieve that,
2391        simply avoid calling the `goal` method of the original goal.
2392        """
2393        return refiner_class(self, *refiner_args, **refiner_kwargs)
2394
2395
2396#-------------------------------#
2397# SingleTest and derived classes #
2398#-------------------------------#
2399
2400class SingleTest(HasPayload, HasContext):
2401    """
2402    A `SingleTest` is a single test case for a function (or similar,
2403    like a test of a variable value or a test of importing a whole
2404    module). These things have a payload and a context, and can be (and
2405    usually are) registered as part of a `TestGroup`. The mere
2406    instantiation of a `SingleTest` object adds it to the test registry
2407    which means it will appear on the rubric created by `rubric`, unless
2408    `register` is set to False when it's constructed.
2409
2410    Most modification methods chain by returning the `SingleTest`
2411    object.
2412
2413    In terms of the rubric constructed by the `rubric` function, a
2414    `SingleTest` is actually a placeholder for a context
2415    (`potluck.contexts.Context`) which will be one of possibly multiple
2416    context objects used as testing contexts for a single
2417    `potluck.rubrics.Goal`. This goal object is derived from a
2418    `TestGroup`, which is automatically instantiated as soon as a
2419    `SingleTest` is created, but which will be associated with multiple
2420    `SingleTest` objects that share the same base name and group name.
2421    """
2422    def __init__(
2423        self,
2424        base_name,
2425        group_name="_",
2426        register=True,
2427        payload_constructor=harness.create_run_function_payload,
2428        default_payload_args=None,
2429        default_augmentations=None,
2430        default_context_args=None
2431    ):
2432        self.base_name = base_name
2433        self.group_name = group_name
2434        """
2435        A `base_name` is required and defines the base name for the
2436        `TestGroup` object that this `SingleTest` will register under; a
2437        `group_name` is optional and defaults to '_'. If `register` is
2438        set to False, this test won't automatically be registered with a
2439        test group, which generally means it also won't be used as part
2440        of a rubric.
2441
2442        The keyword arguments for the `HasPayload` and `HasContext`
2443        constructors will be passed through, but also receive defaults
2444        if not provided at this stage.
2445        """
2446        default_augmentations = default_augmentations or {
2447            "capturing_printed_output": {"capture_errors": False},
2448            "with_timeout": {"time_limit": 5},
2449            "run_in_sandbox": {},
2450            "run_for_base_and_ref_values": {},
2451        }
2452        # leave default_payload_args and default_context_args as None if
2453        # not provided so that HasContext.__init__ and
2454        # HasPayload.__init__ can establish their own defaults
2455
2456        # Initialize our payload setup
2457        HasPayload.__init__(
2458            self,
2459            payload_constructor=payload_constructor,
2460            default_payload_args=default_payload_args,
2461            default_augmentations=default_augmentations
2462        )
2463
2464        # Initialize our context info
2465        HasContext.__init__(
2466            self,
2467            default_context_args=default_context_args
2468        )
2469
2470        # Register ourself if requested to
2471        self.group = None
2472        if register:
2473            group(
2474                base_name,
2475                group_name,
2476                create=True
2477            ).add(self)
2478
2479
2480class TestImport(SingleTest):
2481    """
2482    A `TestImport` is a test which involves importing an entire module,
2483    and by default tests the printed output from that process. It is
2484    a `SingleTest`, and by default will be automatically registered under
2485    the name "import" with group name '_'.
2486
2487    Specialization methods like `wrap_module` can be used to control the
2488    details of the test; see `HasPayload` and `HasContext` for more
2489    details.
2490    """
2491    def __init__(self, group_name="_", register=True):
2492        """
2493        The module to be imported is defined by the currently-active
2494        filename (see `potluck.contexts.FileContext`).
2495
2496        A group name other than the default '_' may be provided, and
2497        automatic registration with a group may be disabled by setting
2498        `register` to False.
2499        """
2500        super().__init__(
2501            "import",
2502            group_name=group_name,
2503            register=register,
2504            payload_constructor=harness.create_module_import_payload,
2505            default_payload_args={
2506                "name_prefix": "test_",
2507                "use_fix_parse": True,
2508                "prep": None,
2509                "wrap": None
2510            },
2511            # default_augmentations is left as.. default
2512            default_context_args={
2513                # builder will be added elsehow
2514                # description if omitted has a smart default
2515                "display_product": (
2516                    contexts.build_context_value_displayer(
2517                        "output",
2518                        labels=[
2519                            "Your output",
2520                            "Solution output",
2521                            "Comparison"
2522                        ]
2523                    )
2524                ),
2525                # Capture auto-filename at instantiation time, and also
2526                # make sure we'll have access to sandboxes.
2527                "depends": contexts.auto(
2528                    "filename",
2529                    "file_path",
2530                    "ref_filename",
2531                    "ref_file_path",
2532                    "sandbox",
2533                    "ref_sandbox",
2534                ),
2535            }
2536        )
2537
2538        # If we're in a group, update that group's default goal
2539        # description to include our relevant filename...
2540        if self.group:
2541            # Figure out which file we're (automatically) targeting
2542            target = None
2543            # (should be exactly one context, and it should have a
2544            # target_file attribute)
2545            for ctx in self.default_context_args["depends"]:
2546                if hasattr(ctx, "target_file"):
2547                    target = ctx.target_file
2548                    break
2549
2550            # Override default description with a better one
2551            if target is not None:
2552                self.group.default_goal_args["description"] = (
2553                    (
2554                        f"Running <code>{target}</code> must exhibit"
2555                        f" the correct behavior"
2556                    ),
2557                    (
2558                        f"When we run <code>{target}</code> as a whole"
2559                        f" file, the pattern of printed output based"
2560                        f" on inputs must match the solution's"
2561                        f" behavior."
2562                    )
2563                )
2564
2565
2566class TestValue(SingleTest):
2567    """
2568    A `TestValue` is a test which involves inspecting the value of a
2569    variable in the submitted module. It is a `SingleTest`, and by
2570    default will be automatically registered under its variable name
2571    with group name '_'.
2572
2573    Specialization methods like `use_decorations` can be used to control
2574    the details of the test; see `HasPayload` and `HasContext` for more
2575    details. Note that many of those methods don't apply to this test,
2576    since we're just retrieving a variable value, not running a function
2577    or importing a module.
2578    """
2579    def __init__(self, varname, group_name="_", register=True):
2580        """
2581        The name of the variable to be inspected required.
2582
2583        A group name other than the default '_' may be provided, and
2584        automatic registration with a group may be disabled by setting
2585        `register` to False.
2586        """
2587        super().__init__(
2588            varname,
2589            group_name=group_name,
2590            register=register,
2591            payload_constructor=harness.create_read_variable_payload,
2592            default_payload_args={"varname": varname},
2593            default_augmentations={
2594                "with_timeout": {"time_limit": 5},
2595                "run_in_sandbox": {},
2596                "run_for_base_and_ref_values": {},
2597            },
2598            default_context_args={
2599                # builder will be added elsehow
2600                # description if omitted has a smart default
2601                "display_product": (
2602                    contexts.build_context_value_displayer(
2603                        "value",
2604                        labels=[
2605                            "Your value",
2606                            "Solution value",
2607                            "Comparison"
2608                        ]
2609                    )
2610                ),
2611                # Capture auto-filename at instantiation time
2612                "depends": contexts.auto("module", "ref_module"),
2613            }
2614        )
2615
2616
2617class TestCase(SingleTest):
2618    """
2619    A `TestCase` is a `SingleTest` representing a unit test for a
2620    function, with a basic setup (test equality of return values) by
2621    default. Different behavior may be achieved by calling various
2622    specialization methods (for example, to specify stdin contents).
2623
2624    The base name for the test group a case registers with will be the
2625    name of the function being tested. If you want to separate tests
2626    that share a function name into multiple groups (i.e., goals),
2627    specify distinct `group_name` values for the different `TestCase`
2628    objects you create.
2629
2630    Instantiating a `TestCase` is not enough to create a goal: goals are
2631    derived from `TestGroup` objects which group one or more test cases
2632    together (this is usually desirable, although it's possible to have
2633    groups that contain only a single test each). Call the `group`
2634    function to retrieve the implied group after instantiating one or
2635    more `TestCase` objects that share a function name and group name.
2636    """
2637    def __init__(
2638        self,
2639        fn_name,
2640        args=None,
2641        kwargs=None,
2642        group_name="_",
2643        register=True
2644    ):
2645        """
2646        The name of the function to test is the only required value,
2647        although a tuple of arguments is usually also provided (can be
2648        omitted to call without arguments). An optional dictionary of
2649        keyword arguments may also be supplied.
2650
2651        Normally all tests of a single function will be collapsed into a
2652        single goal, but by giving tests different `group_name` strings
2653        you can change this (having more different goals generally makes
2654        things a bit more forgiving). The group name is arbitrary; the
2655        default group name is '_'.
2656
2657        If `register` is given as False (True is the default), the test
2658        case won't be registered and will not be available for grouping
2659        or turning into a goal.
2660        """
2661        args = args or []
2662        kwargs = kwargs or {}
2663        self.args = args
2664        self.kwargs = kwargs
2665
2666        self.fn_name = fn_name
2667
2668        super().__init__(
2669            self.fn_name,
2670            group_name,
2671            register,
2672            payload_constructor=harness.create_run_function_payload,
2673            default_payload_args={
2674                "fname": fn_name,
2675                "posargs": args,
2676                "kwargs": kwargs,
2677                "copy_args": True
2678            },
2679            # default_augmentations has a good... default
2680            default_context_args={
2681                # builder will be added elsehow
2682                # description if omitted has a smart default
2683                "display_product": (
2684                    contexts.build_context_value_displayer(
2685                        "value",
2686                        labels=[
2687                            "Your result",
2688                            "Solution result",
2689                            "Comparison"
2690                        ]
2691                    )
2692                ),
2693                # Capture auto-filename at instantiation time
2694                "depends": contexts.auto("module", "ref_module"),
2695            }
2696        )
2697
2698
2699class TestBlock(SingleTest):
2700    """
2701    A `SingleTest` which runs a block of code, using the result value
2702    of the final expression in the block as the value to be tested. A
2703    name for the block must be provided, and will be used as the base
2704    name of the `SingleTest`, with "_" as the default group name.
2705
2706    A fake version of the block to display to students may be provided
2707    alongside the actual code to run.
2708    """
2709    def __init__(
2710        self,
2711        name,
2712        block,
2713        actual=None,
2714        group_name="_",
2715        register=True
2716    ):
2717        """
2718        The name for the block, plus the block of code itself (as a
2719        multi-line string) must be provided. The actual block name will
2720        be the provided name prefixed with 'block:', to prevent the
2721        possibility of identifier overlaps with other kinds of tests.
2722
2723        Optionally, if you want to simplify the appearance of the code, a
2724        multi-line string of code to run, OR a list of AST nodes to
2725        execute may be provided as the "actual" argument, in which case
2726        the "block" value will just be used for display purposes.
2727
2728        A group name other than the default '_' may be provided, and
2729        automatic registration with a group may be disabled by setting
2730        `register` to False.
2731        """
2732        self.name = "block:" + name
2733
2734        self.block = block
2735
2736        if not isinstance(block, str):
2737            raise TypeError(
2738                f"The block value must be a string. If you want to supply"
2739                f" a list of AST nodes, use the 'actual' parameter."
2740                f" (Failed for '{self.name}')"
2741            )
2742
2743        if actual is None:
2744            actual = block
2745
2746        if isinstance(actual, str):
2747            try:
2748                self.nodes = ast.parse(actual).body
2749            except Exception:
2750                raise ValueError(
2751                    f"Failed to compile code block for test"
2752                    f" '{self.name}'."
2753                )
2754            if len(self.nodes) == 0:
2755                raise ValueError(
2756                    f"Empty code string in `TestBlock` constructor."
2757                    f" (Failed for '{self.name}')"
2758                )
2759        else:
2760            try:
2761                actual = list(actual)
2762            except Exception:
2763                raise TypeError(
2764                    f"The 'actual' block of code must be provided as a"
2765                    f" string or as a list (or other iterable) of AST"
2766                    f" nodes). (Failed for '{self.name}')"
2767                )
2768            if len(block) == 0:
2769                raise ValueError(
2770                    f"Empty code block in `TestBlock` constructor."
2771                    f" (Failed for '{self.name}')"
2772                )
2773            if not isinstance(block[0], ast.AST):
2774                raise TypeError(
2775                    f"First code block item in `TestBlock` was not an"
2776                    f" AST node. (Failed for '{self.name}')"
2777                )
2778            self.nodes = actual
2779
2780        super().__init__(
2781            self.name,
2782            group_name,
2783            register,
2784            payload_constructor=harness.create_execute_code_block_payload,
2785            default_payload_args={
2786                "block_name": self.name,
2787                "src": self.block,
2788                "nodes": self.nodes
2789            },
2790            # default_augmentations has a good... default
2791            default_context_args={
2792                # builder will be added elsehow
2793                # description if omitted has a smart default
2794                "display_product": (
2795                    contexts.build_context_value_displayer(
2796                        "value",
2797                        labels=[
2798                            "Your result",
2799                            "Solution result",
2800                            "Comparison"
2801                        ]
2802                    )
2803                ),
2804                # Capture auto-filename at instantiation time
2805                "depends": contexts.auto("module", "ref_module"),
2806            }
2807        )
2808
2809
2810#---------------#
2811# Check classes #
2812#---------------#
2813
2814class Check:
2815    """
2816    `Check` is the base class for a few different classes that represent
2817    simplified/specialized `potluck.rubrics.ImplementationCheck`s. Each
2818    `Check` should be assigned to one of the categories:
2819
2820    - foundational (deprecated)
2821    - core
2822    - extra
2823    - feedback_only
2824    - auto
2825
2826    Generally only 'core' and 'extra' are needed; see
2827    `potluck.rubrics.foundational_core_extras_metric` and
2828    `potluck.rubrics.core_extras_categorized_metric`. Additionally, if
2829    using the later metric, a goal type should be included, which is
2830    "auto" by default but could reasonably be "procedure", "style" or
2831    "other".
2832
2833    When a Check's category/goal-type is set to 'auto' (the default
2834    for both) that property will be inherited from the parent Check if
2835    this check is a sub-rule, or set to 'core'/'procedure' if not.
2836
2837    When a check has a different category or goal type than its parent
2838    check, a copy of that parent check will be created belonging to the
2839    child category/type, and the original parent check won't include the
2840    different-category/type child. Also, any other children of the same
2841    parent that belong to the same category and goal type will be
2842    included on a single new-category/type copy of the parent, so it's
2843    not as if each alternate-category/type child creates its own parent
2844    `Check`.
2845
2846    This setup allows for extra requirements to be specified as the
2847    leaves of a `Check` subrule tree without having to specify the whole
2848    tree twice.
2849
2850    Note that the identifiers of each `Check` will be concatenated with
2851    their parent's identifiers, minus the 'check:' prefix, and separated
2852    by colons, so give each `Check` a better chance of ending up with a
2853    unique identifier before numeric-suffix-addition.
2854    """
2855    def __init__(
2856        self,
2857        identifier,
2858        patterns,
2859        limits,
2860        name=None,
2861        category='auto',
2862        goal_type='auto'
2863    ):
2864        """
2865        A check needs an identifier, a list of patterns (could be
2866        length-one), a tuple of limits (min/max, either or even both may
2867        be None), and it may specify a name instead of deriving one
2868        automatically from the patterns (see
2869        `potluck.rubrics.ImplementationCheck`), a category string (e.g.,
2870        'extra' instead of defaulting to 'auto'), and/or a goal type
2871        (e.g., 'style' instead of defaulting to 'auto'). All other
2872        `potluck.rubrics.ImplementationCheck` details are specified via
2873        calling methods on the object after it is created. The name may
2874        be a string containing HTML code, or a 2-item tuple to explicitly
2875        specify singular and plural forms of the name (otherwise an 's'
2876        will be added to the end of the name to generate the plural
2877        form).
2878        """
2879        self.taskid = file_utils.deduce_task_id()
2880
2881        if not isinstance(identifier, str):
2882            raise TypeError(
2883                "The identifier for a Check must be a string."
2884            )
2885        self.identifier = identifier
2886
2887        if not isinstance(patterns, (list, tuple)):
2888            raise TypeError(
2889                "Patterns for a check must be a list or tuple (may be"
2890              + " length-1)"
2891            )
2892
2893        # When we instantiate the Check we get the auto-context-provider
2894        # for the "scope" slot; this will be used later during goal
2895        # instantiation.
2896        self.test_in = { "contexts": contexts.auto("scope") }
2897
2898        # Core fields
2899        self.category = category
2900        self.goal_type = goal_type
2901        self.patterns = patterns
2902        self.limits = limits
2903
2904        if isinstance(name, str):
2905            # automatic pluralization by default
2906            self.name = name, name + 's'
2907        else:
2908            # Use a 2-item tuple to specify singular and plural forms
2909            self.name = name
2910
2911        self.subrules = []
2912
2913        self._description = None
2914        self._match_filters = []
2915        self._softmin = False
2916        self._softmax = False
2917        self._outside = None
2918        self._callees = False
2919        self._subslip = None
2920        self._match_identity_function = lambda code, node, env: (
2921            tuple(node) if isinstance(node, list) else node
2922        )
2923
2924        # Note: this is only set via FunctionCall.require_of_def
2925        self._check_in_def = False
2926
2927        self._force_smaller_match = False
2928
2929        # Register this Check
2930        checklist(category, goal_type, create=True).append(self)
2931
2932    def set_description(
2933        self,
2934        title,
2935        summary,
2936        final_title=None,
2937        final_summary=None
2938    ):
2939        """
2940        Sets up a custom description for this `Check`. If not used, a
2941        default description will be constructed based on the parameters
2942        of the check. The `title` and `summary` arguments are the rubric
2943        entry and associated description to be used when displaying an
2944        ungraded rubric, while the optional `final_title` and
2945        `final_summary` are the items to be used when displaying the
2946        goal as part of a graded rubric. If the final title and/or
2947        summary are omitted, the normal title/summary are used.
2948
2949        This function returns the `Check` for chaining.
2950        """
2951        if final_title is None:
2952            final_title = title
2953
2954        if final_summary is None:
2955            final_summary = summary
2956
2957        self._description = (title, summary, final_title, final_summary)
2958        return self
2959
2960    def match_filter(self, filter_function):
2961        """
2962        Adds a custom filtering function to this check that can throw out
2963        some potential matches. The filtering function will be given the
2964        entire submitted AST tree, the (potentially) matching AST node,
2965        and the binding environment of the match, and it should return
2966        True or False, where False will filter out that match. You can
2967        call this function multiple times and each individual match
2968        filter will be ANDed with the others.
2969
2970        This function returns the `Check` object for chaining.
2971        """
2972        self._match_filters.append(filter_function)
2973        return self
2974
2975    def softmin(self, value=True):
2976        """
2977        Turns the lower limit for matches for this check into a soft
2978        limit, so that too few copies still counts as partially
2979        completing this check overall. If the value argument is either
2980        "warn" or "note", then when too few matches are found, the check
2981        still succeeds, but a warning (or note) is generated that
2982        describes the unexpectedly low number of occurrences.
2983
2984        The value could also be a number, which establishes an alternate
2985        lower bound on the number of matches that will count for partial
2986        success on the check. E.g., if the min is 3, you could set the
2987        softmin at 2.5 and then a situation where there were 2 full and
2988        one partial matches would count as a partial match for the check
2989        overall.
2990
2991        This function returns the `Check` object for chaining.
2992        """
2993        self._softmin = value
2994        return self
2995
2996    def softmax(self, value=True):
2997        """
2998        Turns the upper limit for matches into a soft limit, just as with
2999        `softmin`. Also accepts the strings "warn" or "note", as well as
3000        integer values. Unlike `softmin`, when setting this value as a
3001        number, partial matches are ignored and will not push the rule
3002        over its hard or soft limits.
3003
3004        This function returns the `Check` object for chaining.
3005        """
3006        self._softmax = value
3007        return self
3008
3009    def outside(self, patterns):
3010        """
3011        Accepts a list of patterns (strings containing mast pseudo-code)
3012        or a single pattern string, and sets that as the exclusion
3013        pattern for this rule, which will ensure that matches which occur
3014        inside one of those patterns are ignored. Consider using values
3015        like `potluck.patterns.IF_PATTERN` or
3016        `potluck.patterns.ALL_FOR_AND_WHILE_LOOP_PATTERNS` as the
3017        patterns argument.
3018
3019        Note that currently, this function's effects are not
3020        automatically described by the description of the Check it's
3021        applied to, so you'll almost always need to use set_description
3022        to describe what the check does yourself.
3023        TODO: Some automatic default for that?
3024
3025        This function returns the `Check` object for chaining.
3026        """
3027        self._outside = patterns
3028        return self
3029
3030    def check_callees(self, turn_on=True):
3031        """
3032        Turns on callee-checking for this rule (or turns it off if given
3033        False as an argument). This means that matches for this rule will
3034        be searched for within the code of any locally-defined functions
3035        that are called from the code being inspected, which helps find
3036        things that you're looking for which a student puts into a helper
3037        function. Applying this to a top-level check is generally not
3038        useful, since any top-level checks already look for matches in
3039        the entire submitted module; it should always be applied to
3040        sub-rules.
3041
3042        TODO: This feature is still (as of 2020-6) a bit unstable and may
3043        slow things down substantially in some cases.
3044
3045        This function returns the `Check` object for chaining.
3046        """
3047        self._callees = turn_on
3048        return self
3049
3050    def subrule_tolerance(self, tolerance=0):
3051        """
3052        Sets the number of sub-rules that are allowed to go unmatched
3053        while still counting this rule as a partial match. The argument
3054        is a number, which may be fractional, since a partially-matched
3055        sub-rule counts as 0.5 of a fully-matched rule. By default the
3056        number is 0: if any sub-rule is unmatched, the entire
3057        match-in-consideration is ignored entirely.
3058
3059        This function returns the `Check` object for chaining.
3060        """
3061        self._subslip = tolerance
3062        return self
3063
3064    def count_using(self, identity_function):
3065        """
3066        Sets up a custom function to determine the identity of a match,
3067        which affects how matches are counting when considering limits.
3068        This function will be given three arguments: an AST node for the
3069        entire file, a matching AST node (or list of nodes if the match
3070        ends up matching something like a function body) and a list of
3071        matching environments (dictionaries mapping string keys to AST
3072        nodes). It must return a hashable object, and the number of
3073        matches will be determined by the cardinality of the set of
3074        such objects returned by all matching node/environments combos.
3075        It may also return a list of hashable objects in which case
3076        they'll each be mixed into the set to be counted.
3077
3078        This function returns the `Check` object for chaining.
3079        """
3080        self._match_identity_function = identity_function
3081
3082        return self
3083
3084    def force_smaller(self, force=True):
3085        """
3086        Forces a match for this rule to be smaller than the match for its
3087        super-rule (or smaller than the whole module if there is no
3088        super-rule). Set force to False to disable this behavior instead
3089        once it's been enabled (default is disabled).
3090
3091        Use this to force nested equivalent rules (like two nested Loops)
3092        to actually match nested structures.
3093
3094        Returns self for chaining.
3095        """
3096        self._force_smaller_match = force
3097
3098        return self
3099
3100    def require(self, *checks):
3101        """
3102        Adds one or more new sub-rules which much be matched within the
3103        code matched by this rule in order for a full match to occur. Use
3104        the `subrule_tolerance` method on the parent `Check` if you want
3105        to allow some required sub-rules to go unmatched while still
3106        generating a partial match. Use the `check_callees` method on the
3107        subrule being added if you want to search for that pattern in
3108        helper functions as well as in the scope of the match created by
3109        the parent rule.
3110
3111        The given `Check` objects will be appended to the subrules field
3112        of this parent object, which you can use to traverse all subrules
3113        if you need to. They will also be de-registered as top-level
3114        `Check`s.
3115
3116        This function returns the `Check` object for chaining.
3117
3118        WARNINGS:
3119        - Only inspects callees where the function position is a name
3120          (not an arbitrary expression)
3121        - Searches the top-level task code node for this name
3122          without understanding shadowing and without considering
3123          arguments/parameters
3124        - Attempts to match the full pattern within a single
3125          function (currently cannot automatically split pattern
3126          across a call)
3127        - Likely to cause even more exponential blowup
3128        - No attempts are made to respect scope when unifying
3129          env with match environments in callees
3130        """
3131        self.subrules.extend(checks)
3132
3133        # Remove these things from our checklist since they'll be
3134        # reporting to this check as their parent
3135        for ch in checks:
3136            checklist(ch.category, ch.goal_type).remove(ch)
3137
3138        return self
3139
3140    def set_identifier(self, identifier):
3141        """
3142        Explicitly sets the identifier to the given string. Useful to
3143        manually disambiguate multiple goals whose identifiers would
3144        otherwise be the same.
3145
3146        Returns self for chaining.
3147        """
3148        self.identifier = identifier
3149        return self
3150
3151    def build_implementation_checks(
3152        self,
3153        id_prefix=None,
3154        prefix=None,
3155        default_category='core',
3156        default_goal_type='procedure'
3157    ):
3158        """
3159        Uses the current settings for this `Check` to create one or more
3160        `potluck.rubrics.ImplementationCheck` objects for use in a
3161        rubric. Recursively builds any sub-rules first, and disentangles
3162        categories which is why it might return multiple checks. It
3163        returns a dictionary mapping category-name/goal-type pairs to
3164        single `potluck.rubrics.ImplementationCheck` instances for those
3165        category/goal-type combinations.
3166
3167        The id_prefix and prefix arguments specify prefixes to add to
3168        the identifier and description details of subgoals to help keep
3169        things specific. A prefix will be automatically added to the
3170        calls to `build_implementation_checks` for any sub-rules, which
3171        will include the existing prefix.
3172
3173        The default_category argument specifies what category should be
3174        used if this `Check`'s category is 'auto', and in the same vein,
3175        the default_goal_type is used for checks with 'auto' as their
3176        goal_type.
3177        """
3178
3179        # Determine a name for this construct
3180        if self.name is None:
3181            # Can't easily pluralize without an explicit name, so we don't try
3182            name = (self.patterns[0], self.patterns[0])
3183        else:
3184            name = self.name
3185
3186        # Decide on prefixes
3187        if id_prefix is not None:
3188            qualified_id = id_prefix + ':' + self.identifier
3189        else:
3190            qualified_id = self.identifier
3191
3192        if self.limits[0] == 1:
3193            sub_prefix = f"Within the {name[0]}"
3194        else:
3195            sub_prefix = f"Within {name[1]}"
3196
3197        # Create a generic description if there isn't one already
3198        if self._description is None:
3199            description = explain.code_check_description(
3200                self.limits,
3201                (
3202                    phrasing.a_an(name[0])
3203                    if self.limits[0] in (None, 0, 1)
3204                    else name[1]
3205                ),
3206                phrasing.comma_list(
3207                    [f"<code>{pat}</code>" for pat in self.patterns],
3208                    junction="or"
3209                )
3210            )
3211        else:
3212            description = self._description
3213
3214        if prefix is not None:
3215            # Adjust the sub-prefix
3216            sub_prefix = sub_prefix + " " + prefix[0].lower() + prefix[1:]
3217
3218            # Adjust the description (both pre-feedback and
3219            # post-feedback details entries if they exist)
3220            description = list(description)
3221            description[1::2] = [
3222                prefix + ', ' + r[0].lower() + r[1:]
3223                for r in description[1::2]
3224            ]
3225
3226        this_cat = self.category
3227        if this_cat == "auto":
3228            this_cat = default_category
3229
3230        this_goal_type = self.goal_type
3231        if this_goal_type == "auto":
3232            this_goal_type = default_goal_type
3233
3234        # Recursively create checks for sub-rules
3235        subs = []
3236        all_required_cat_types = set([(this_cat, this_goal_type)])
3237        for sub in self.subrules:
3238            sub_checks = sub.build_implementation_checks(
3239                qualified_id,
3240                sub_prefix,
3241                this_cat,
3242                this_goal_type
3243            )
3244            for cat, typ in sub_checks:
3245                all_required_cat_types.add((cat, typ))
3246            subs.append(sub_checks)
3247
3248        return {
3249            (cat, gt): rubrics.ImplementationCheck(
3250                taskid=self.taskid,
3251                identifier=qualified_id,
3252                pattern=self.patterns,
3253                name=self.name,
3254                min=self.limits[0],
3255                max=self.limits[1],
3256                description=description,
3257                match=lambda code, node, env: (
3258                    all(flt(code, node, env) for flt in self._match_filters)
3259                ),
3260                softmin=self._softmin,
3261                softmax=self._softmax,
3262                outside=self._outside,
3263                callees=self._callees,
3264                subslip=self._subslip,
3265                match_identity=self._match_identity_function,
3266                check_in_def=self._check_in_def,
3267                force_smaller_match=self._force_smaller_match,
3268                subrules=[s[(cat, gt)] for s in subs if (cat, gt) in s],
3269                tags={ "category": cat, "goal_type": gt },
3270                test_in=(
3271                    None # use parent goal's context
3272                    if prefix is not None # if there is a parent
3273                    else self.test_in
3274                ),
3275            )
3276            for (cat, gt) in all_required_cat_types
3277        }
3278
3279
3280class Import(Check):
3281    """
3282    A `Check` which tests for an import of a certain module. Typically,
3283    of course, it's not possible to complete a task without importing all
3284    of the necessary modules, so a positive import goal can be a bit of a
3285    freebie, but that's not always a bad thing, especially for students
3286    reading the rubric for hints.
3287
3288    The identifier will be 'import-' plus the module name for the module
3289    that must be imported.
3290    """
3291    def __init__(
3292        self,
3293        mname,
3294        import_names=None,
3295        limits=[1, 1],
3296        category='auto'
3297    ):
3298        """
3299        In addition to the module name and optional limits and category,
3300        a list of names to import may be specified, in which case the
3301        check requires a
3302
3303        ```py
3304        from <module> import <names>
3305        ```
3306
3307        import; otherwise it must be a simple
3308
3309        ```py
3310        import <module>
3311        ```
3312
3313        import. With import_names, importing more than the required
3314        names is allowed.
3315
3316        WARNING (2021-8-31) mast currently DOES NOT support the matching
3317        rules required to use the from module import names version! A
3318        warning will be logged if you try to use this, and it may be
3319        extremely slow and/or fail to recognize valid imports that it
3320        should.
3321
3322        If import_names is the special value `'*'`, a universal import
3323        will be required. `'*'` should not be provided as part of a list
3324        of specific import names.
3325
3326        If import_names is the special value `'any'`, then EITHER a
3327        normal import or a from ... import will be accepted, with no
3328        restrictions on the names imported.
3329        """
3330        patterns = [ f"import {mname}" ]
3331        what = f"the <code>{mname}</code> module"
3332
3333        if import_names == '*':
3334            patterns = [ f"from {mname} import *" ]
3335            what = f"everything from the <code>{mname}</code> module"
3336        elif import_names == "any":
3337            patterns.append(f"from {mname} import *")
3338            patterns.append(f"from {mname} import ___")
3339            what = f"the <code>{mname}</code> module or something from it"
3340
3341        elif import_names is not None:
3342            # pattern to match
3343            names = ', '.join(import_names)
3344            logging.log(
3345                "Warning: Support for requiring the import of multiple"
3346                " names from a module is BROKEN."
3347            ) # TODO: Fix this!
3348            # TODO [2021-8-31]: mast DOES NOT support this properly!
3349            patterns = [
3350                f"from {mname} import {names}, ___"
3351            ]
3352
3353            # description of the pattern
3354            names_desc = phrasing.comma_list(
3355                f"<code>{name}</code>"
3356                for name in import_names
3357            )
3358            what = f"{names_desc} from the <code>{mname}</code> module"
3359
3360        super().__init__(
3361            identifier="import-" + mname,
3362            patterns=patterns,
3363            limits=limits,
3364            name=(f"import of {what}", f"imports of {what}"),
3365            category=category
3366        )
3367
3368        # Set up a custom description (calling .set_description later
3369        # would override this)
3370        self.set_description(
3371            f"Import {what}",
3372            f"We will check to make sure that you import {what}.",
3373            f"Import {what}",
3374            f"We examined your code to check whether it imports {what}."
3375        )
3376
3377
3378class FunctionDef(Check):
3379    """
3380    A `Function` is a type of `Check` which tests for the presence of a
3381    function definition (see also `FunctionCall`). Any sub-rules will be
3382    searched within the body of that function definition.
3383
3384    The identifier will be "def-" plus the name of the function that must
3385    be defined.
3386    """
3387    def __init__(self, fn_name, params_spec=None, category='auto'):
3388        """
3389        You must specify the function name, and you may specify the
3390        parameters. If given, `params_spec` should be a string containing
3391        mast code that goes between the parentheses of a function
3392        definition, or a list or tuple of such strings providing
3393        alternates (see `potluck.patterns.function_def_patterns`).
3394
3395        If `params_spec` is omitted, any function signature with the
3396        specified name is accepted. Instead of a single string, a list of
3397        strings may also be supplied as `fn_name`, in which case any
3398        function using one of those names will be considered as a
3399        potential match.
3400
3401        You may also supply a rubric category string, which should
3402        usually be 'core' or 'extra'.
3403        """
3404        # determine mast patterns
3405        def_patterns = patterns.function_def_patterns(fn_name, params_spec)
3406
3407        # figure out HTML tags for descriptions
3408        code_tag, details_tag = html_tools.function_def_code_tags(
3409            fn_name,
3410            params_spec
3411        )
3412
3413        # Initialize the Check
3414        super().__init__(
3415            "def-" + fn_name,
3416            def_patterns,
3417            [1, 1],  # limits (exactly 1)
3418            (
3419                "definition of {}".format(code_tag),
3420                "definitions of {}".format(code_tag)
3421            ),  # name (w/ plural version)
3422            category=category
3423        )
3424
3425        # By default, only generate a note if we find multiple
3426        # fully-matching definitions
3427        self.softmax("note")
3428
3429        # Decide topic and details
3430        topic = "Define {}".format(code_tag)
3431        details = "Use <code>def</code> to define {}".format(details_tag)
3432
3433        # Set up a custom description (calling .set_description later
3434        # would override this)
3435        self.set_description(topic, details)
3436
3437
3438class FunctionCall(Check):
3439    """
3440    A custom `Check` which checks for the presence of a call to a
3441    specific function (or to one of several functions).
3442
3443    Note: Sub-rules aren't typically very useful, as they would be
3444    matched within the function call expression (not within the
3445    definition of the called function). You can the `callees` method of
3446    the super-rule instead of a FunctionCall sub-rule to check for things
3447    that might be placed in helper functions, or you can use the
3448    require_of_def method of a FunctionCall to place requirements on the
3449    AST makeup of the function being called (in conjunction with '_' as
3450    the fn_name, this provides a means of requiring helper functions that
3451    meet certain criteria without knowing their names).
3452
3453    The identifier will be "call-" plus the name of the function that must
3454    be called, or the name of the first function if multiple are
3455    specified, or "call-(any)" if the function name isn't specified.
3456    """
3457    def __init__(
3458        self,
3459        fn_name,
3460        limits=[1, None],
3461        args_spec=None,
3462        announce=None,
3463        is_method=False,
3464        category='auto'
3465    ):
3466        """
3467        A function name is required, and everything else is optional. You
3468        may also pass a list of strings for the function name to count
3469        multiple different function calls (e.g., when a function has an
3470        alias, like 'fd' and 'forward' in the 'turtle' module). Use '_'
3471        as the function name to match any function; the description will
3472        account for that if you do.
3473
3474        The `limits` parameter specifies the lower and upper limits on
3475        the number of calls required. Use `None` in the first (or second)
3476        position to specify no lower (or upper) limit. The default value
3477        is `[1, None]` which means "at least one."
3478
3479        The `args_spec` argument can be used to require a certain
3480        arrangement of parameters (see
3481        `potluck.patterns.function_call_patterns`) and may be a string, a
3482        list of strings, or a pair of integers and/or None similar to
3483        'limits' specifying how many positional parameters there should
3484        be (one element of the pair must be an integer for this to work).
3485
3486        The `announce` argument can be used to override the name of the
3487        function in the default description, although a custom
3488        description could also be applied using the `set_description`
3489        method. Unlike a custom description, an `announce` value is also
3490        used in the construction of explanation strings.
3491
3492        Set `is_method` to True if you want to look for calls as methods
3493        instead of normal function calls. Note that this implies you need
3494        to know ahead of time how students will import modules, since a
3495        call to 'turtle.fd' would need to be identified as a method call,
3496        whereas a call to 'fd' after 'from turtle import *' would not be
3497        a method call.
3498
3499        You may also supply a rubric category string, which should
3500        usually be 'core' or 'extra'.
3501        """
3502        if (
3503            isinstance(args_spec, (list, tuple))
3504        and len(args_spec) == 2
3505        and (
3506                isinstance(args_spec[0], int)
3507             or isinstance(args_spec[1], int)
3508            )
3509        ):
3510            args_limits = args_spec
3511            args_spec = "___"
3512            if args_limits[0] is None: # upper limit only
3513                args_desc = f"<at most {args_limits[1]} arguments>"
3514            elif args_limits[1] is None: # lower limit only
3515                args_desc = f"<at least {args_limits[0]} arguments>"
3516            else:
3517                args_desc = (
3518                    f"<{args_limits[0]}-{args_limits[1]} arguments>"
3519                )
3520        elif args_spec is None:
3521            args_limits = [None, None]
3522            args_desc = "-any arguments-"
3523        else:
3524            args_limits = [None, None]
3525            args_desc = args_spec
3526
3527        if fn_name == "_":
3528            fn_desc = "-any function-"
3529            identifier = "call-(any)"
3530        else:
3531            fn_desc = fn_name
3532            if isinstance(fn_name, str):
3533                identifier = "call-" + fn_name
3534            else:
3535                identifier = "call-" + fn_name[0]
3536
3537        # determine mast patterns
3538        call_patterns = patterns.function_call_patterns(
3539            fn_name,
3540            args_spec,
3541            is_method=is_method
3542        )
3543
3544        # figure out HTML tags for descriptions
3545        code_tag, details_tag = html_tools.function_call_code_tags(
3546            fn_desc,
3547            args_desc,
3548            is_method=is_method
3549        )
3550
3551        # store HTML tags for possible later use
3552        self.code_tags = (code_tag, details_tag)
3553
3554        if announce:
3555            code_tag = announce
3556
3557        # Initialize the Check
3558        super().__init__(
3559            identifier,
3560            call_patterns,
3561            limits,
3562            (
3563                "call to {}".format(code_tag),
3564                "calls to {}".format(code_tag)
3565            ), # name (w/ plural version)
3566            category=category,
3567        )
3568
3569        # Add a custom match filter if we have argument count limits
3570        if args_limits != [None, None]:
3571
3572            self._match_filters.append(
3573                lambda code, node, env: (
3574                    len(node.args) >= (args_limits[0] or 0)
3575                and (
3576                        (len(node.args) <= args_limits[1])
3577                        if args_limits[1] is not None
3578                        else True
3579                    ) # noqa E123
3580                )
3581            )
3582
3583        description = explain.function_call_description(
3584            code_tag,
3585            details_tag,
3586            limits,
3587            None
3588        )
3589
3590        # Set up a custom description (calling .set_description later
3591        # would override this)
3592        self.set_description(*description)
3593
3594    def require_of_def(self, *subrules):
3595        """
3596        Establishes one or more sub-rules that must match on the
3597        definition of the function being called (which must be defined
3598        within the current file!).
3599
3600        Note: this function modifies the provided subrules so that they
3601        will be set up to check within the definition of their parent.
3602        For this reason, they should be fresh sub-rules and should NOT be
3603        shared.
3604
3605        This function returns the `FunctionCall` object for chaining.
3606        """
3607        # TODO: The subrule description-augmentation doesn't quite line
3608        # up for these, and that needs to be fixed. Ideally, create a
3609        # secondary list of subrules-in-defs and set up separate
3610        # description-augmentation logic for those.
3611        self.subrules.extend(subrules)
3612        for r in subrules:
3613            r._check_in_def = True
3614
3615            # Remove these things from our checklist since they'll be
3616            # reporting to this check as their parent
3617            checklist(r.category, r.goal_type).remove(r)
3618
3619        return self
3620
3621    def must_be_local(self, exclude=[]):
3622        """
3623        Sets up a custom match match filter (via `Check.match_filter`)
3624        such that matches for this rule must be calls to locally-defined
3625        functions.
3626
3627        Note that if you were to store a locally-defined function in a
3628        local variable of another name and then call it via that
3629        variable, it wouldn't be recognized as a match. Tracing is
3630        probably a better approach if you're concerned about such
3631        situations.
3632
3633        If exclude is provided, it should be a collection of strings;
3634        function calls to functions whose names are in that collection
3635        will not be considered matches.
3636
3637        This function returns the `FunctionCall` object for chaining.
3638        """
3639        self.match_filter(
3640            lambda code, node, envs: (
3641                isinstance(node, ast.Call)
3642            and isinstance(node.func, ast.Name)
3643            and node.func.id not in exclude
3644            and mast.find(
3645                    code,
3646                    "def {}(___):\n ___".format(node.func.id)
3647                ) is not None # noqa E123
3648            )
3649        )
3650
3651        return self
3652
3653    def count_by_names(self, respect_module_names=False):
3654        """
3655        Sets up a custom match identity function (via `Check.count_using`
3656        such that matches for this rule are counted not by how many
3657        function calls appear, but by how many distinct function names
3658        are used for calls. If a matching function call doesn't use a
3659        Name or an Attribute as its func expression, it will not be
3660        counted at all, and if it is an attribute, only the attribute
3661        name part will be used as the ID to count, so a call to `forward`
3662        and another call to `turtle.forward` would count as the same
3663        name. Note that this only really makes sense in conjunction with
3664        a bindable slot as the function name (e.g., '_'), or with
3665        multiple possible function names.
3666
3667        If you want `forward` and `turtle.forward` to count as different
3668        names, set `respect_module_names` to True.
3669
3670        This also modifies the name variable to attempt to improve
3671        explanations of what happens.
3672
3673        Note that if you were to store a function in a variable with
3674        another name and then call it via that variable, it would be
3675        counted as a call to a different function. Tracing is probably a
3676        better approach if you're concerned about such situations.
3677
3678        This function returns the `FunctionCall` object for chaining.
3679        """
3680        # We know that only things matching the filter above will be
3681        # given to the count_using function as matches, so we know that
3682        # node.func.id will be valid.
3683        self.count_using(
3684            lambda code, node, envs: (
3685                node.func.id if (
3686                    isinstance(node, ast.Call)
3687                and isinstance(node.func, ast.Name)
3688                ) else (
3689                    node.func.name.id + '.' + node.func.attr
3690                    if respect_module_names else node.func.attr
3691                ) if (
3692                    isinstance(node, ast.Call)
3693                and isinstance(node.func, ast.Attribute)
3694                and isinstance(node.func.value, ast.Name)
3695                ) else (
3696                    '?.' + node.func.attr
3697                    if respect_module_names else node.func.attr
3698                ) if (
3699                    isinstance(node, ast.Call)
3700                and isinstance(node.func, ast.Attribute)
3701                ) else []
3702                # empty list effectively removes match from count
3703                # TODO: Do we need to support other configurations?
3704            )
3705        )
3706
3707        # name is normally using only the first alternate so it's not
3708        # too long, but if we're counting distinct functions, that
3709        # doesn't make sense.
3710        # Retrieve detailed code tag which contains all alternates
3711        code_tag, details_tag = self.code_tags
3712        # If there's only one "alternative", it doesn't make sense to
3713        # use this method, but in any case, we'll leave self.name
3714        # unchanged.
3715        if len(list(re.findall("<code>", details_tag))) > 1:
3716            # If we do have alternates, we need to describe differently
3717            # what it means to call them, because we're not counting
3718            # function calls, we're counting distinct functions called.
3719            self.name = (
3720                "call to one of {}".format(details_tag),
3721                "calls to distinct functions among {}".format(details_tag),
3722            )
3723
3724        return self
3725
3726
3727class IfElse(Check):
3728    """
3729    An `IfElse` is a `Check` which looks for an `if` or `if`/`else` node,
3730    and matches sub-rules within either the if or the else part. Note
3731    that Python turns `elifs` into nested if/else constructs behind the
3732    scenes, so the `if`/`else` pattern will potentially match once for
3733    each `elif` case, plus once for the original `if`, and the final
3734    `else` in an `elif` chain is attached to the last `elif` case, not
3735    the first `if` case.
3736
3737    The identifier will be just "ifelse".
3738    """
3739    def __init__(self, limits=[1, None], name=None, category='auto'):
3740        """
3741        An `IfElse` only needs to specify the limits on how many matches we
3742        are looking for, and may additionally supply a custom name.
3743
3744        You may also supply a rubric category string, which should
3745        usually be 'core' or 'extra'.
3746        """
3747        if name is None:
3748            name = "<code>if</code>/<code>else</code> block"
3749
3750        super().__init__(
3751            "ifelse",
3752            [patterns.IF_PATTERN],
3753            limits,
3754            name,
3755            category=category
3756        )
3757
3758        # Customize description a bit since patterns are pretty ugly
3759        super().set_description(
3760            *explain.code_check_description(
3761                limits=self.limits,
3762                short_desc="a conditional",
3763                long_desc=(
3764                    "an <code>if</code> statement (possibly accompanied"
3765                  + " by an <code>elif</code> or <code>else</code> block)"
3766                )
3767            )
3768        )
3769
3770
3771class Loop(Check):
3772    """
3773    A `Loop` is a `Check` which looks for any kind of looping construct,
3774    including for loops, while loops, and single list-, set-, and
3775    dictionary-comprehensions plus raw generator expressions (but not
3776    multiple-loop comprehensions).
3777
3778    The identifier will be just "loop", unless `only` is set to something
3779    other than `'block'` or `None`, in which case the `only` value will
3780    be used as the identifier instead.
3781    """
3782    def __init__(
3783        self,
3784        limits=[1, None],
3785        name=None,
3786        only=None,
3787        category='auto'
3788    ):
3789        """
3790        Limits may be specified (defaults to 'at least 1'), and a custom
3791        `name` may also be given. If `only` is given, it should be one of
3792        the following strings:
3793
3794            'for' - only accept for loops
3795            'while' - only accept while loops
3796            'block' - only accept for and while loops, not list
3797                comprehensions
3798            'comprehension' - only accept list comprehensions
3799
3800        You may also supply a rubric category string, which should
3801        usually be 'core' or 'extra'.
3802
3803        Note that any requirements attached to a Loop will be required of
3804        each loop for that loop to count as a match, so if you want to
3805        require two loops and require a certain construct be present in
3806        at least one of them, you should have one Loop check with [2, 2]
3807        limits and no inner requirements, and another Loop check with [1,
3808        None] limits (the default) that contains your required construct.
3809        """
3810        if name is None:
3811            name = "loop"
3812
3813        loop_patterns = patterns.ALL_SINGLE_LOOP_AND_COMPREHENSION_PATTERNS
3814
3815        if only == 'for':
3816            loop_patterns = patterns.ALL_FOR_PATTERNS
3817            loop_name = "a <code>for</code> loop"
3818        elif only == 'while':
3819            loop_patterns = patterns.ALL_WHILE_PATTERNS
3820            loop_name = "a <code>while</code> loop"
3821        elif only == 'block':
3822            loop_patterns = patterns.ALL_FOR_AND_WHILE_LOOP_PATTERNS
3823            loop_name = "a <code>for</code> or <code>while</code> loop"
3824        elif only == 'comprehension':
3825            loop_patterns = (
3826                patterns.ALL_SINGLE_LOOP_AND_COMPREHENSION_PATTERNS
3827            )
3828            loop_name = "a comprehension"
3829            name = "comprehension"
3830        else:
3831            loop_name = "any kind of loop"
3832
3833        super().__init__(
3834            "loop" if only in (None, 'block') else only,
3835            loop_patterns,
3836            limits,
3837            name,
3838            category=category
3839        )
3840
3841        # Customize description a bit since patterns are pretty ugly
3842        super().set_description(
3843            *explain.code_check_description(
3844                limits=self.limits,
3845                short_desc=loop_name,
3846                long_desc=loop_name
3847            )
3848        )
3849
3850
3851class Return(Check):
3852    """
3853    A `Return` is a `Check` which looks for a return statement.
3854
3855    The identifier will be just "return".
3856    """
3857    def __init__(
3858        self,
3859        limits=[1, None],
3860        name=None,
3861        allow_bare=False,
3862        category='auto'
3863    ):
3864        """
3865        Limits may be specified (defaults to 'at least 1'), and a custom
3866        `name` may also be given.
3867
3868        allow_bare may be set to True (default is False) to allow a bare
3869        return statement with no value to count.
3870
3871        You may also supply a rubric category string, which should
3872        usually be 'core' or 'extra'.
3873        """
3874        if name is None:
3875            name = "<code>return</code> statement"
3876
3877        patterns = [ "return _" ]
3878        if allow_bare:
3879            patterns.append("return")
3880
3881        super().__init__(
3882            "return",
3883            patterns,
3884            limits,
3885            name,
3886            category=category
3887        )
3888
3889
3890class Try(Check):
3891    """
3892    A `Try` is a `Check` which looks for any kind of try/except/finally
3893    construct, although it won't match if there are multiple except
3894    clauses (TODO: fix that? (it's hard...)). They also will only match
3895    uses of 'as' if the name is exactly 'e' (TODO: Fix that (hard)).
3896
3897    The identifier will be "try".
3898    """
3899    def __init__(
3900        self,
3901        limits=[1, None],
3902        name=None,
3903        only=None,
3904        category='auto'
3905    ):
3906        """
3907        Limits may be specified (defaults to 'at least 1'), and a custom
3908        `name` may also be given.
3909
3910        You may also supply a rubric category string, which should
3911        usually be 'core' or 'extra'.
3912
3913        Note that any requirements attached to a `Try` can be satisfied
3914        in the try part, the except part or the finally part if there is
3915        one. There is no way to require something be present in one
3916        specific part (TODO: that).
3917        """
3918        if name is None:
3919            name = "try/except statement"
3920
3921        super().__init__(
3922            "try",
3923            patterns.TRY_EXCEPT_PATTERNS,
3924            limits,
3925            name,
3926            category=category
3927        )
3928
3929        # Customize description a bit since patterns are pretty ugly
3930        super().set_description(
3931            *explain.code_check_description(
3932                limits=self.limits,
3933                short_desc="a try/except statement",
3934                long_desc="a try/except statement"
3935            )
3936        )
3937
3938
3939class With(Check):
3940    """
3941    A `With` is a `Check` which looks for a 'with' block with up to two
3942    context handlers defined, with or without an 'as -name-' part for
3943    each handler. TODO: Allow for more handlers?
3944
3945    The identifier will be "with".
3946    """
3947    def __init__(
3948        self,
3949        limits=[1, None],
3950        name=None,
3951        only=None,
3952        category='auto'
3953    ):
3954        """
3955        Limits may be specified (defaults to 'at least 1'), and a custom
3956        `name` may also be given.
3957
3958        You may also supply a rubric category string, which should
3959        usually be 'core' or 'extra'.
3960        """
3961        if name is None:
3962            name = "with statement"
3963
3964        super().__init__(
3965            "with",
3966            patterns.WITH_PATTERNS,
3967            limits,
3968            name,
3969            category=category
3970        )
3971
3972        # Customize description a bit since patterns are pretty ugly
3973        super().set_description(
3974            *explain.code_check_description(
3975                limits=self.limits,
3976                short_desc="a with statement",
3977                long_desc="a with statement"
3978            )
3979        )
3980
3981
3982#-------------#
3983# Test groups #
3984#-------------#
3985
3986def group(base_name, group_name="_", create=False):
3987    """
3988    Retrieves a `TestGroup` object for a particular group of tests,
3989    identified by the name of the thing being tested and the group
3990    name (defaults to '_', the default group name). Note that the current
3991    relevant filename and the module in which the `group` function is
3992    being called are also used to determine which group is returned.
3993
3994    For import tests, the base name is "import"; for function tests and
3995    variable value tests, the base name is the name of the function or
3996    variable being tested (since "import" is a keyword, it is not a valid
3997    function or variable name).
3998
3999    The retrieved group's methods may be used to modify it (they
4000    chain together so you only have to call `group` once). If there are
4001    no tests matching the given criteria, a `KeyError` will be thrown
4002    unless create is given as True, in which case a new empty `TestGroup`
4003    will be created, registered, and returned.
4004    """
4005    result = (
4006        TEST_GROUP_REGISTRY
4007          .get(file_utils.get_spec_module_name(), {})
4008          .get(contexts.RELEVANT_FILENAME, {})
4009          .get(base_name, {})
4010          .get(group_name, None)
4011    )
4012
4013    if result is None:
4014        if not create:
4015            raise KeyError(
4016                f"There are no tests for '{base_name}' in group"
4017              + f" '{group_name}' The tests registry is:\n"
4018              + f"{TEST_GROUP_REGISTRY}"
4019            )
4020        else:
4021            result = (
4022                TEST_GROUP_REGISTRY
4023                  .setdefault(file_utils.get_spec_module_name(), {})
4024                  .setdefault(contexts.RELEVANT_FILENAME, {})
4025                  .setdefault(base_name, {})
4026                  .setdefault(group_name, TestGroup(base_name, group_name))
4027            )
4028
4029    return result
4030
4031
4032def merge(groups, base_name, group_name="_"):
4033    """
4034    Merges several existing groups, returning a new group which contains
4035    all of the tests from the merged groups, which are de-registered in
4036    the process. The new group has the given base and group names.
4037
4038    Note that merge must be called after any operations on the groups to
4039    be merged, since their tests will be removed, and it MUST be called
4040    before any switch in the active fiename.
4041
4042    # TODO: Make this less fragile?
4043    """
4044    new = TestGroup(base_name, group_name)
4045    for group in groups:
4046        # Re-register tests with the new group
4047        for test in group.tests:
4048            test.group = None
4049            new.add(test)
4050
4051        # Remove all tests from old group:
4052        group.tests = []
4053
4054        # De-register this group
4055        del TEST_GROUP_REGISTRY\
4056            [file_utils.get_spec_module_name()]\
4057            [contexts.RELEVANT_FILENAME]\
4058            [test.base_name]\
4059            [test.group_name] # noqa E211
4060        # TODO: Is it okay to leave empties behind?
4061
4062    # Register our merged group and return it
4063    return TEST_GROUP_REGISTRY\
4064        .setdefault(file_utils.get_spec_module_name(), {})\
4065        .setdefault(contexts.RELEVANT_FILENAME, {})\
4066        .setdefault(base_name, {})\
4067        .setdefault(group_name, new)
4068
4069
4070class TestGroup(HasPayload, HasContext, HasGoal):
4071    """
4072    A class representing a group of tests, with methods that can modify
4073    the group. In the rubric, each group of tests becomes a single
4074    `potluck.rubrics.Goal`. Do not create these yourself as they are
4075    created automatically as `TestCase` objects are defined. Instead,
4076    call the `group` function to retrieve a test group after one or more
4077    of its `TestCase` instances have been created.
4078
4079    Note that a `TestGroup` isn't actually `HasPayload` or `HasContext`
4080    but serves as a group object for its `TestCase` objects which are.
4081    """
4082    def __init__(self, base_name, group_name):
4083        """
4084        A group collects tests associated with a particular group name
4085        and base name. It starts out empty but
4086        goals are added to
4087        it automatically. It has various methods for modifying how tests
4088        are run.
4089        """
4090        self.base_name = base_name
4091        self.group_name = group_name
4092        if self.group_name == "_":
4093            group_ident = ""
4094        else:
4095            group_ident = ":" + self.group_name
4096
4097        self.tests = []
4098
4099        # No payload defaults (left to the Test objects)
4100        HasPayload.__init__(self)
4101
4102        # No context defaults (left to the Test objects)
4103        HasContext.__init__(self)
4104
4105        # Set up goal defaults (assuming our base name is the name of a
4106        # function being tested).
4107        if self.base_name == "import":
4108            HasGoal.__init__(
4109                self,
4110                file_utils.deduce_task_id(),
4111                rubrics.ComparisonTest,
4112                default_goal_args={
4113                    "identifier": "import" + group_ident,
4114                    "description": (
4115                        "Your code must exhibit the correct behavior",
4116                        (
4117                            "When we run your submitted code as a whole"
4118                            " file, the pattern of printed output based"
4119                            " on inputs must match the solution's"
4120                            " behavior."
4121                        )
4122                    ),
4123                    "context_slot": "output",
4124                    "checker": compare.omni_compare
4125                }
4126            )
4127        else:
4128            HasGoal.__init__(
4129                self,
4130                file_utils.deduce_task_id(),
4131                rubrics.ComparisonTest,
4132                default_goal_args={
4133                    "identifier": self.base_name + group_ident,
4134                    "description": (
4135                        (
4136                            f"<code>{base_name}</code> must return the"
4137                            f" correct result"
4138                        ),
4139                        (
4140                            f"The result returned when your"
4141                            f" <code>{base_name}</code> function is run must"
4142                            f" match the solution result."
4143                        )
4144                    ),
4145                    "context_slot": "value",
4146                    "checker": compare.omni_compare
4147                }
4148            )
4149
4150    def add(self, test):
4151        """
4152        Adds the given test to this group.
4153
4154        Throws an error if the test is already in a group.
4155        """
4156        if test.group is not None:
4157            raise ValueError(
4158                f"Can't add a test ({test}) to a second group ({self.name})."
4159            )
4160        self.tests.append(test)
4161        test.group = self
4162
4163    def create_goal(self):
4164        """
4165        Constructs and returns the `potluck.rubrics.Goal` object implied
4166        by this `TestGroup`.
4167        """
4168        # Create contexts for our goal from each test in this group
4169        contexts = []
4170        for test in self.tests:
4171            payload = test.construct_payload(self)
4172            # TODO: auto-description here?
4173            # context_args
4174            # custom_context_description
4175            contexts.append(test.create_context(payload, self))
4176
4177        # Create and return result
4178        return self.create_goal_from_contexts(contexts)
4179
4180    def also(self):
4181        """
4182        Constructs a new GroupClone based on this test group (or clone).
4183        Returns the constructed clone. Note that the results of any
4184        customization methods called before this method will be reflected
4185        in the clone, but the results of customization methods called
4186        later will not be. Furthermore, customization methods called on
4187        the clone will not affect the original.
4188
4189        However, new `Test` instances which get grouped into this
4190        `TestGroup` will also be covered by the clone, as long as
4191        `provide_goal` has not been called yet on either the original or
4192        the clone.
4193        """
4194        return GroupClone(self)
4195
4196
4197class GroupClone(TestGroup):
4198    """
4199    A semi-shallow clone of a test group which creates a separate
4200    `potluck.rubrics.Goal` based on the same `potluck.contexts.Context`s
4201    as the original group. Create it using `TestGroup.also` which will
4202    set it up as a clone of the group (or clone) that `also` was called
4203    on.
4204
4205    Method calls on the original object after the call to `also` do not
4206    affect the goal created by the clone, while methods called on the
4207    clone do not affect the original object's goal. However, `Test`
4208    objects created after the creation of the group will be picked up by
4209    both the original and the clone.
4210
4211    The goal that the clone creates will have the same base identifier as
4212    the original goal, although if it's in a different category it will
4213    have a different qualified identifier. Use `HasGoal.set_identifier`
4214    to change the identifier if necessary; the ID system will
4215    automatically append a -number suffix to non-unique identifiers at
4216    rendering time of course.
4217    """
4218    def __init__(self, parent):
4219        """
4220        A parent `TestGroup` instance is required (`GroupClone`s are
4221        also `TestGroup`s). Goal construction parameters for this shadow
4222        will be cloned from that parent at the time of instantiation.
4223        """
4224        self.parent = parent
4225
4226        # Clone payload info from parent
4227        HasPayload.__init__(
4228            self,
4229            parent.payload_constructor,
4230            copy.deepcopy(parent.default_payload_args),
4231            copy.deepcopy(parent.default_augmentations)
4232        )
4233        # Copy explicit args as well
4234        self.payload_args = copy.deepcopy(parent.payload_args)
4235
4236        # Clone context info from parent
4237        HasContext.__init__(
4238            self,
4239            copy.deepcopy(parent.default_context_args)
4240        )
4241        # Copy explicit args as well
4242        self.context_args = copy.deepcopy(parent.context_args)
4243
4244        # Clone goal info from parent
4245        HasGoal.__init__(
4246            self,
4247            parent.taskid,
4248            parent.goal_constructor,
4249            copy.deepcopy(parent.default_goal_args)
4250        )
4251        # Copy explicit args as well
4252        self.goal_args = copy.deepcopy(parent.goal_args)
4253
4254        # Copy parent names
4255        self.base_name = parent.base_name
4256        self.group_name = parent.group_name
4257
4258        # Note: we never actually call TestGroup.__init__
4259        # As a result we do not have tests
4260
4261    def add(self):
4262        """
4263        Override to disable adding tests.
4264        """
4265        raise NotImplementedError("Cannot add a test to a cloned group.")
4266
4267    def create_goal(self):
4268        """
4269        Constructs and returns the `potluck.rubrics.Goal` object implied
4270        by this `GroupClone`.
4271        """
4272        # TODO: This breaks a lot of things you might want to do with a
4273        # clone, since they DON'T get their own contexts, so you can't
4274        # call something like test_trace and actually get trace. We need
4275        # better error messages around that, AND/or to fix it!
4276        # Note that this is the point of a clone though: you can always
4277        # easily create duplicate Goal objects...
4278        # Dig up parent contexts via goal (w/ caching)
4279        pgoal = self.parent.provide_goal()
4280        parent_contexts = pgoal.test_in["contexts"]
4281
4282        # Warn if for some reason we had explicit contexts
4283        if (
4284            "test_in" in self.goal_args
4285        and "contexts" in self.goal_args["test_in"]
4286        ):
4287            logging.debug_msg(
4288                "Warning: overriding existing test_in/contexts value in"
4289                " GroupClone.create_goal."
4290            )
4291
4292        # Return a goal created using our parent's contexts
4293        return self.create_goal_from_contexts(parent_contexts)
4294
4295
4296#------------#
4297# Refinement #
4298#------------#
4299
4300class RefinedTest(HasContext, HasGoal):
4301    """
4302    Represents further processing of a test result, via a new
4303    `potluck.rubrics.Goal` object that gets tested in a group of
4304    `potluck.contexts.Context` objects which are based on extensions of
4305    the context(s) used for the parent object (or which will be tested in
4306    a single context which merges information from parent contexts, if
4307    `_merge` is set). Any `HasGoal` subclass can support refinement (and
4308    `HasGoal.refine` is the proper way to instantiate refined tests).
4309
4310    Subclasses should override the `build_context` method to define what
4311    kind of additional processing they want to do to each context of the
4312    parent goal. This method needs to accept a context dictionary as its
4313    only argument (besides self) and return a dictionary of any new
4314    context slots it creates/updates, just like all context builder
4315    functions.
4316
4317    Subclasses may also set the `_merge` property to True instead of the
4318    default False, which will cause them to derive a single context that
4319    depends on all of the parent contexts instead of deriving one child
4320    context per parent context.
4321
4322    Note that by default the goal constructed will be an
4323    `potluck.rubrics.ComparisonTest`, and the same context slot as the
4324    parent will be used as the slot to test. You can use the `HasGoal`
4325    machinery to change these defaults.
4326
4327    The refined goal's identifier will be the parent goal's identifier
4328    plus a colon plus the identifier given to the refined goal.
4329    """
4330
4331    _merge = False
4332    """
4333    Set this to True in a child class if rather than creating one derived
4334    context for each parent context, the resulting goal should be tested
4335    in just a single context that depends on ALL of the parent context
4336    objects individually.
4337    """
4338
4339    def build_context(self, prev_context):
4340        """
4341        Not implemented (override to specify how refined contexts are
4342        created from base contexts).
4343        """
4344        raise NotImplementedError(
4345            "RefinedTest is an abstract class and cannot be used"
4346            " directly."
4347        )
4348
4349    def __init__(
4350        self,
4351        parent,
4352        identifier,
4353        context_slot=None,
4354        checker=None
4355    ):
4356        """
4357        A parent object is required; it must have a provide_goal method,
4358        and should be a `HasGoal` instance.
4359
4360        An identifier is also required, it will be combined with the
4361        parent's identifier (separated by a colon).
4362
4363        A specific context slot to target and checker to use may be
4364        specified, or if left as defaults these will be inherited from
4365        the parent object.
4366
4367        Note that supplying context and/or goal descriptions via
4368        `HasContext.set_context_description` and/or
4369        `HasGoal.set_goal_description` is almost always necessary.
4370        """
4371        self.parent = parent
4372
4373        # No context defaults (but note that builder & depends will be
4374        # overwritten in the end.
4375        HasContext.__init__(self)
4376
4377        if context_slot is None:
4378            context_slot = parent.goal_args.get(
4379                "context_slot",
4380                parent.goal_args.get(
4381                    "context_slot",
4382                    parent.default_goal_args.get("context_slot", "value")
4383                )
4384            )
4385
4386        if checker is None:
4387            checker = parent.default_goal_args.get(
4388                "checker",
4389                compare.omni_compare
4390            )
4391
4392        pident = parent.goal_args.get(
4393            "identifier",
4394            parent.default_goal_args.get("identifier")
4395        )
4396        if pident is None:
4397            id_prefix = ""
4398        else:
4399            id_prefix = pident + ":"
4400
4401        # Set up goal defaults
4402        HasGoal.__init__(
4403            self,
4404            parent.taskid,
4405            rubrics.ComparisonTest,
4406            default_goal_args={
4407                "identifier": id_prefix + identifier,
4408                "context_slot": context_slot,
4409                "checker": checker
4410            }
4411        )
4412
4413    def create_goal(self):
4414        """
4415        Returns the `potluck.rubrics.Goal` implied by this refined test.
4416        """
4417        pgoal = self.parent.provide_goal()
4418        parent_contexts = pgoal.test_in["contexts"]
4419
4420        if "depends" in self.context_args:
4421            logging.debug_msg(
4422                "Warning: overriding existing depends value in"
4423                " Refine.create_goal."
4424            )
4425
4426        # Construct a child context for each parent context
4427        if self._merge:
4428            # derive one child context that depends on all parent
4429            # contexts at once
4430            self.context_args["depends"] = parent_contexts
4431            contexts = [ self.create_context(self.build_context) ]
4432        else: # derive one child context from each parent context
4433            contexts = []
4434            for pc in parent_contexts:
4435                # Create context w/ specific dependency
4436                self.context_args["depends"] = [ pc ]
4437                contexts.append(self.create_context(self.build_context))
4438
4439        # Clean up dependency information for future
4440        del self.context_args["depends"]
4441
4442        # Create & return our goal
4443        return self.create_goal_from_contexts(contexts)
4444
4445
4446class AlterContext(RefinedTest):
4447    """
4448    A `RefinedTest` which simply applies an arbitrary context-builder
4449    function to the unrefined context. As usual for a context builder,
4450    the function's results will be updated into the existing context
4451    automatically. Note that a simpler way to achieve similar
4452    functionality is to use `HasPayload.do_setup` and/or
4453    `HasPayload.do_cleanup` to add custom context slots, along with
4454    `HasGoal.compare_using` to set the context slot to compare.
4455    """
4456    def __init__(
4457        self,
4458        parent,
4459        identifier,
4460        context_builder,
4461        **kwargs
4462    ):
4463        """
4464        A parent goal provider, an identifier, and a context builder
4465        function are necessary.
4466
4467        Further keyword arguments will be passed through to
4468        `RefinedTest`'s constructor.
4469        """
4470        self.builder = context_builder
4471
4472        super().__init__(
4473            parent,
4474            identifier,
4475            **kwargs
4476        )
4477
4478        cs = self.default_goal_args.get("context_slot", "value")
4479
4480        self.default_context_args["display_product"] = (
4481            contexts.build_context_value_displayer(
4482                cs,
4483                labels=[
4484                    f"Your {cs}",
4485                    f"Solution {cs}",
4486                    "Comparison",
4487                ]
4488            )
4489        )
4490
4491    def build_context(self, prev_context):
4492        """
4493        A context builder that simply runs the specified custom context
4494        builder function.
4495        """
4496        return self.builder(prev_context)
4497
4498
4499class Transform(RefinedTest):
4500    """
4501    A Transform is a kind of refinement which applies an arbitrary
4502    function to a context slot, sorting the result of that function in
4503    the same slot. The specific slot that the transformation is applied
4504    to is implied by the "context_slot" default goal argument of the goal
4505    being refined, although a specific context slot to target may be
4506    specified via the arguments passed through to `RefinedTest`.
4507    """
4508    def __init__(
4509        self,
4510        parent,
4511        identifier,
4512        transformer,
4513        result_desc="a transformed result",
4514        refine_ref=True,
4515        **kwargs
4516    ):
4517        """
4518        A parent goal provider, an identifier, and a transformation
4519        function are necessary. A description for the result may be
4520        provided if a full custom description isn't being used.
4521        `refine_ref` may be set to False to avoid also transforming the
4522        equivalent reference slot.
4523
4524        Further keyword arguments will be passed through to
4525        `RefinedTest`'s constructor.
4526        """
4527        self.transformer = transformer
4528
4529        self.refine_ref = refine_ref
4530
4531        super().__init__(
4532            parent,
4533            identifier,
4534            **kwargs
4535        )
4536
4537        cs = self.default_goal_args.get("context_slot", "value")
4538
4539        # TODO: Some way to name individual parent contexts here...
4540        self.default_context_args["description"] = (
4541            f"{result_desc} of the {cs}".capitalize(),
4542            f"We will create {result_desc} from the {cs}.",
4543            f"{result_desc} of the {cs}".capitalize(),
4544            f"We created {result_desc} from the {cs}.",
4545        )
4546
4547        self.default_context_args["display_product"] = (
4548            contexts.build_context_value_displayer(
4549                cs,
4550                labels=[
4551                    f"Your {cs}",
4552                    f"Solution {cs}",
4553                    "Comparison",
4554                ]
4555            )
4556        )
4557
4558        self.default_goal_args["description"] = (
4559            f"{result_desc} of the {cs} must be correct".capitalize(),
4560            f"{result_desc} of the {cs} must match the solution's {cs}.",
4561            f"{result_desc} of the {cs} must be correct".capitalize(),
4562            (
4563                f"We checked whether {result_desc} of the {cs} matched"
4564                f" the solution's {cs}."
4565            )
4566        )
4567
4568    def build_context(self, prev_context):
4569        """
4570        A context builder that replaces certain context fields with the
4571        results of running a transformation function over them.
4572        """
4573        result = {}
4574        # TODO: Args synthesis here?!?
4575        context_slot = self.default_goal_args.get("context_slot", "value")
4576        target_slots = [ context_slot ]
4577        if self.refine_ref:
4578            target_slots.append("ref_" + context_slot)
4579
4580        for slot in target_slots:
4581            orig = context_utils.extract(prev_context, slot)
4582            transformed = self.transformer(orig)
4583            result[slot] = transformed
4584
4585        return result
4586
4587
4588class Find(RefinedTest):
4589    """
4590    A Find is a kind of refinement which applies a regular expression to
4591    a context slot (which we hope will be holding a string).
4592    """
4593    def __init__(
4594        self,
4595        parent,
4596        pattern,
4597        pattern_desc="a specific part",
4598        missing_result=None,
4599        first_match=True,
4600        match_details=False,
4601        refine_ref=True,
4602        **kwargs
4603    ):
4604        """
4605        A parent goal provider and a pattern (either a string or a
4606        compiled regular expression) are necessary.
4607
4608        The identifier will be based on replacing spaces in the pattern
4609        description with underscores.
4610
4611        Behavior may be controlled by the following keyword arguments:
4612
4613        - `pattern_desc`: A string that will be used to provide automatic
4614            context and goal descriptions. Use `set_context_description`
4615            and/or `set_goal_description` if this isn't expressive
4616            enough.
4617        - `first_match`: If set to False, the result will be a (possibly
4618            empty) list of all matches. If set to True (the default) only
4619            the first match is used, and None is used if there are no
4620            matches.
4621        - `missing_result`: A value to use if no match is found and
4622            first_match is set to True.
4623        - `match_details`: If True, the result will take the form of one
4624            or more re.Match objects instead of strings.
4625        - `refine_ref`: If True, the "ref_" context slot that matches the
4626            target context slot will be refined in addition to the base
4627            slot.
4628
4629        Further keyword arguments will be passed through to
4630        `RefinedTest`'s constructor.
4631
4632        Note that only very generic default context and goal descriptions
4633        are provided.
4634        """
4635        self.pattern = pattern
4636        if isinstance(self.pattern, str):
4637            self.pattern = re.compile(self.pattern)
4638
4639        self.first_match = first_match
4640        self.missing_result = missing_result
4641        self.match_details = match_details
4642        self.refine_ref = refine_ref
4643
4644        if "identifier" not in kwargs:
4645            kwargs["identifier"] = pattern_desc.replace(' ', '_')
4646
4647        super().__init__(parent, **kwargs)
4648
4649        cs = self.default_goal_args.get("context_slot", "value")
4650
4651        # TODO: Some way to name individual parent contexts here...
4652        self.default_context_args["description"] = (
4653            f"{pattern_desc} of the {cs}".capitalize(),
4654            f"We will search through the {cs} for {pattern_desc}.",
4655            f"{pattern_desc} of the {cs}".capitalize(),
4656            f"We searched through the {cs} for {pattern_desc}.",
4657        )
4658
4659        self.default_context_args["display_product"] = (
4660            contexts.build_context_value_displayer(
4661                cs,
4662                labels=[
4663                    f"Your {cs}",
4664                    f"Solution {cs}",
4665                    "Comparison",
4666                ]
4667            )
4668        )
4669
4670        self.default_goal_args["description"] = (
4671            f"{pattern_desc} of the {cs} must be correct".capitalize(),
4672            f"{pattern_desc} of the {cs} must match the solution's {cs}.",
4673            f"{pattern_desc} of the {cs} must be correct".capitalize(),
4674            (
4675                f"We checked whether {pattern_desc} of the {cs} matched"
4676                f" the solution's {cs}."
4677            )
4678        )
4679
4680    def build_context(self, prev_context):
4681        """
4682        A context builder that replaces certain context fields with the
4683        results of running a regular expression over them.
4684        """
4685        result = {}
4686        # TODO: Args synthesis here?!?
4687        context_slot = self.default_goal_args.get("context_slot", "value")
4688        target_slots = [ context_slot ]
4689        if self.refine_ref:
4690            target_slots.append("ref_" + context_slot)
4691
4692        for slot in target_slots:
4693            orig = context_utils.extract(prev_context, slot)
4694            if not isinstance(orig, str):
4695                raise TypeError(
4696                    (
4697                        "Attempted to refine '{}' context, but it was "
4698                      + "not a string."
4699                    ).format(slot)
4700                )
4701
4702            matches = self.pattern.finditer(orig)
4703
4704            if self.first_match:
4705                try:
4706                    first = next(matches)
4707                    if self.match_details:
4708                        result[slot] = first
4709                    else:
4710                        result[slot] = first.group()
4711                except StopIteration:
4712                    result[slot] = self.missing_result
4713            else:
4714                if self.match_details:
4715                    objs = [m for m in matches]
4716                else:
4717                    objs = [m.group() for m in matches]
4718
4719                result[slot] = objs
4720
4721        return result
4722
4723
4724class DistinctionReport(RefinedTest):
4725    """
4726    A `RefinedTest` which analyzes results from the context slot
4727    originally targeted, and determines, among parent contexts that were
4728    originally going to be separate tests, which results are distinct and
4729    which are identical. It creates a "distinctions" context slot
4730    containing a multi-line string reporting on these distinctions, and
4731    sets up that slot as the test target.
4732    """
4733    _merge = True # we are going to merge parent contexts
4734
4735    def build_context(self, prev_context):
4736        """
4737        Uses the "__unmerged__" special context slot to access
4738        individual results from each parent context, and creates a
4739        mapping in the "distinctions" slot that maps unique results to
4740        lists of context dictionaries in which the test produced those
4741        results (note that this means that results must be hashable!).
4742
4743        The slot of each parent context that it pays attention to is
4744        determined by the slot which the unrefined goal would have
4745        tested.
4746        """
4747        result = { "distinctions": {}, "ref_distinctions": {} }
4748
4749        # Process both actual and reference values
4750        for prefix in ("", "ref_"):
4751            slot = prefix + self.original_slot
4752            dest = prefix + "distinctions"
4753
4754            # produce our mapping from results to lists of contexts
4755            mapping = {}
4756            for i, parent_context in enumerate(
4757                prev_context["__unmerged__"]
4758            ):
4759                # get value and signature
4760                val = parent_context[slot]
4761                if self.filters:
4762                    for filt in self.filters:
4763                        val = filt(val)
4764                # update our mapping
4765                mapping.setdefault(val, []).append(parent_context)
4766
4767            result[dest] = mapping
4768
4769        return result
4770
4771    def render(report):
4772        """
4773        A class method for rendering a distinction report as HTML.
4774        """
4775        uniques = []
4776        groups = []
4777        all_contexts = []
4778        for result in report:
4779            contexts = report[result]
4780            all_contexts.extend(contexts)
4781            if len(contexts) == 1:
4782                uniques.append(contexts[0])
4783            else:
4784                groups.append(contexts)
4785
4786        if len(groups) == 0:
4787            return (
4788                f"All {len(all_contexts)} contexts produced distinct"
4789                f" outcomes:<br>\n"
4790            ) + html_tools.build_list(
4791                ctx["__builder__"].feedback_topic()
4792                for ctx in all_contexts
4793            )
4794        elif len(uniques) == 0 and len(groups) == 1:
4795            return (
4796                f"All {len(all_contexts)} contexts produced equivalent"
4797                f" outcomes:<br>\n"
4798            ) + html_tools.build_list(
4799                ctx["__builder__"].feedback_topic()
4800                for ctx in all_contexts
4801            )
4802        else:
4803            items = [
4804                f"Group #{i+1}:<br>\n" + html_tools.build_list(
4805                    ctx["__builder__"].feedback_topic()
4806                    for ctx in group
4807                )
4808                for i, group in enumerate(groups)
4809            ] + [
4810                "Unique: " + ctx["__builder__"].feedback_topic()
4811                for ctx in uniques
4812            ]
4813            return (
4814                "The contexts' outcomes were grouped as follows:\n"
4815            ) + html_tools.build_list(items)
4816
4817    def display_context_value(context):
4818        """
4819        A class method to be used as a context value displayer.
4820        """
4821        val = context["distinctions"]
4822        ref = context["ref_distinctions"]
4823
4824        return html_tools.build_html_tabs(
4825            [
4826                ("Your distinctions", DistinctionReport.render(val)),
4827                ("Correct distinctions", DistinctionReport.render(ref)),
4828            ]
4829        )
4830
4831    def compare(val, ref):
4832        """
4833        A class method for comparing distinction mappings. Succeeds if
4834        the mappings have equivalent groupings with respect to the
4835        `potluck.contexts.Context` objects that produce different
4836        results, and fails otherwise.
4837        """
4838        val_rmap = {}
4839        ref_rmap = {}
4840
4841        val_groups = []
4842        ref_groups = []
4843
4844        for result in val:
4845            contexts = val[result]
4846            val_groups.append(
4847                set(id(context["__builder__"]) for context in contexts)
4848            )
4849            for context in contexts:
4850                val_rmap[id(context["__builder__"])] = result
4851
4852        for result in ref:
4853            contexts = ref[result]
4854            ref_groups.append(
4855                set(id(context["__builder__"]) for context in contexts)
4856            )
4857            for context in contexts:
4858                ref_rmap[id(context["__builder__"])] = result
4859
4860        # Make sure ordering is the same because it shouldn't matter
4861        val_groups.sort(key=lambda group: sorted(group))
4862        ref_groups.sort(key=lambda group: sorted(group))
4863
4864        # Groupings are the same
4865        if val_groups == ref_groups:
4866            return {
4867                "status": "accomplished",
4868                "explanation": (
4869                    "The distinctions among the results of your code"
4870                    " were the same as the distinctions among results"
4871                    " for the solution code:<br>"
4872                ) + DistinctionReport.render(val)
4873            }
4874        else:
4875            return {
4876                "status": "failed",
4877                "explanation": (
4878                    "The distinctions among the results of your code"
4879                    " were different than the distinctions among"
4880                    " results for the solution:<br>"
4881                ) + html_tools.build_html_tabs(
4882                    [
4883                        (
4884                            "Your distinctions",
4885                            DistinctionReport.render(val)
4886                        ),
4887                        (
4888                            "Correct distinctions",
4889                            DistinctionReport.render(ref)
4890                        )
4891                    ]
4892                    # TODO: Add a tab reporting differences in terms of
4893                    # pairwise constraints violated?
4894                )
4895            }
4896
4897    def __init__(
4898        self,
4899        parent,
4900        filters=None,
4901        **kwargs
4902    ):
4903        """
4904        Only a parent is required; extra keyword arguments will be
4905        passed on to `RefinedTest.__init__`.
4906
4907        `filters` allows one to specify a list of filter functions, which
4908        will be applied to the raw result values that are being
4909        distinguished.
4910
4911        The identifier will be "distinctions".
4912        """
4913        super().__init__(
4914            parent,
4915            "distinctions",
4916            **kwargs
4917        )
4918
4919        self.filters = filters or []
4920
4921        # Fetch the old context slot and set our new one
4922        cs = self.default_goal_args.get("context_slot", "value")
4923        # TODO: Args synthesis here?!?
4924        self.original_slot = cs
4925        self.default_goal_args["context_slot"] = "distinctions"
4926
4927        # Set a goal-type tag based on the parent slot
4928        tags = self.default_goal_args.setdefault("tags", {})
4929        tags["goal_type"] = CONTEXT_SLOT_IMPLIED_TYPES.get(cs, "other")
4930
4931        # TODO: Some way to name individual parent contexts here...
4932        self.default_context_args["description"] = (
4933            f"Distinctions between {cs}s",
4934            (
4935                f"We gather {cs}s from multiple tests and check which"
4936                f" tests have distinct results."
4937            ),
4938            f"Distinctions between {cs}s",
4939            (
4940                f"We gathered {cs}s from multiple tests and checked"
4941                f" which tests had distinct results."
4942            )
4943        )
4944
4945        self.default_context_args["display_product"] = (
4946            DistinctionReport.display_context_value
4947        )
4948
4949        self.default_goal_args["description"] = (
4950            f"{cs} distinctions must be correct".capitalize(),
4951            (
4952                f"Distinctions between {cs}s based on arguments and/or"
4953                f" inputs must match the solution's distinctions."
4954            ),
4955            f"{cs} distinctions must be correct".capitalize(),
4956            (
4957                f"We checked whether, for different arguments and/or"
4958                f" inputs, your code created the same distinct (or"
4959                f" identical) {cs}s as the solution code."
4960            )
4961        )
4962
4963        # Set up report-based comparison
4964        self.compare_using(DistinctionReport.compare)
4965
4966
4967class Reference:
4968    """
4969    A class used for representing object references in memory maps and
4970    diagrams. A reference is really just an integer.
4971    """
4972    def __init__(self, num):
4973        """
4974        Needs to know what number we're assigned.
4975        """
4976        self.num = num
4977
4978    def __hash__(self):
4979        """
4980        Hash function based on the number.
4981        """
4982        return 1928928 + self.num
4983
4984    def __eq__(self, other):
4985        """
4986        Comparison for references (two refs with the same num are the
4987        same).
4988        """
4989        return self.num == other.num
4990
4991    def __repr__(self):
4992        """
4993        The representation is an @ sign followed by the integer.
4994        """
4995        return "@{}".format(self.num)
4996
4997
4998def memory_map(obj, assigned, count_from=0):
4999    """
5000    Modifies the given assignment dictionary to include an assignment
5001    between the given object's ID and a tuple containing the current
5002    counter (the third arugment) and a shallow object based on the given
5003    object, where any complex sub-objects replaced by References which
5004    will also appear in the assignment map. The assignment map provided
5005    to start from must be a dictionary, but it may be empty.
5006
5007    For example, if the original value were the list [[1, 2], 3, [1, 2]]
5008    where both [1, 2] sublists are the same list, the final `assigned`
5009    dictionary would be:
5010
5011    {
5012        <id1>: (0, [ Reference(1), 3, Reference(1) ]),
5013        <id2>: (1, [1, 2])
5014    }
5015
5016    Where <id1> and <id2> are the ids of the two lists.
5017
5018    This function returns a tuple containing the highest ID it assigned
5019    within the assignments, and the provided object if it's small, or a
5020    Reference instance if it's large. Only tuples, lists, sets, and
5021    dicts have their contents replaced; custom objects don't. Strings
5022    are treated as references, but of course not altered, and any custom
5023    objects are treated this way too.
5024    """
5025    if id(obj) in assigned:
5026        return None, Reference(assigned[id(obj)][0])
5027
5028    if isinstance(obj, (int, float, complex, bool, type(None))):
5029        # Simple values are used as-is:
5030        return None, obj
5031    elif isinstance(obj, (tuple, list, set)):
5032        # Structures are made shallow and referenced
5033        original_n = count_from
5034        # Must happen before recursion
5035        assigned[id(obj)] = (original_n, None) # placeholder
5036        count_from += 1
5037        parts = []
5038        for sub in obj:
5039            highest_id, repl = memory_map(sub, assigned, count_from)
5040            parts.append(repl)
5041            if highest_id is not None:
5042                count_from = highest_id + 1
5043            # else don't change count_from; we didn't assign any new IDs
5044        shallow = type(obj)(parts)
5045        assigned[id(obj)] = (original_n, shallow)
5046        return count_from - 1, Reference(original_n)
5047    elif isinstance(obj, dict):
5048        # Dictionaries use references for both keys and values
5049        original_n = count_from
5050        count_from += 1
5051        shallow = {}
5052        # Must happen before recursion
5053        assigned[id(obj)] = (original_n, shallow)
5054        for key in obj:
5055            highest_id, krepl = memory_map(key, assigned, count_from)
5056            if highest_id is not None:
5057                count_from = highest_id + 1
5058            # else don't change count_from; we didn't assign any new IDs
5059            highest_id, vrepl = memory_map(obj[key], assigned, count_from)
5060            if highest_id is not None:
5061                count_from = highest_id + 1
5062            # else don't change count_from; we didn't assign any new IDs
5063
5064            # Insert key/value pair
5065            shallow[krepl] = vrepl
5066
5067        return count_from - 1, Reference(original_n)
5068    else:
5069        # All other values including strings  are referenced but not
5070        # made shallow
5071        assigned[id(obj)] = (count_from, obj)
5072        return count_from, Reference(count_from)
5073
5074
5075def memory_report(obj):
5076    """
5077    Returns a memory report, which is like an exploded repr of an object
5078    where 'large' values like strings and lists get assigned an ID and
5079    are reported on a separate line.
5080    """
5081    refs = {}
5082    _ = memory_map(obj, refs, 0) # modifies ref; we ignore the result
5083
5084    result = ''
5085    for num, shallow in sorted(refs.values()):
5086        result += '@{}: {}\n'.format(num, repr(shallow))
5087
5088    return result
5089
5090
5091class MemoryDiagram(RefinedTest):
5092    """
5093    A `RefinedTest` which produces a text-based memory diagram from the
5094    contents of the context slot originally targeted. It creates a
5095    "memory_report" context slot
5096    containing a multi-line string which specifies the memory layout of
5097    the original object, which may contains tuples, lists, dictionaries,
5098    and/or sets, as well as primitive values like numbers, Booleans,
5099    strings, and Nones. It sets up that slot as the test target.
5100    """
5101    def build_context(self, prev_context):
5102        """
5103        Based on the original target slot, creates a "memory_report"
5104        slot which holds a multi-line string representing the memory
5105        layout of the original object. See the `memory_report` function
5106        for details on what the diagram will look like.
5107        """
5108        result = {
5109            "memory_report": 'NO REPORT GENERATED',
5110            "ref_memory_report": 'NO REPORT GENERATED'
5111        }
5112
5113        # Process both actual and reference values
5114        for prefix in ("", "ref_"):
5115            slot = prefix + self.original_slot
5116            dest = prefix + "memory_report"
5117
5118            # produce our mapping from results to lists of contexts
5119            result[dest] = memory_report(prev_context[slot])
5120
5121        return result
5122
5123    def __init__(
5124        self,
5125        parent,
5126        **kwargs
5127    ):
5128        """
5129        Only a parent is required; extra keyword arguments will be
5130        passed on to `RefinedTest.__init__`.
5131
5132        The identifier will be "memory_report".
5133        """
5134        super().__init__(
5135            parent,
5136            "memory_report",
5137            **kwargs
5138        )
5139
5140        # Fetch the old context slot and set our new one
5141        cs = self.default_goal_args.get("context_slot", "value")
5142        # TODO: Args synthesis here?!?
5143        self.original_slot = cs
5144        self.default_goal_args["context_slot"] = "memory_report"
5145
5146        # Set a goal-type tag based on the parent slot
5147        tags = self.default_goal_args.setdefault("tags", {})
5148        tags["goal_type"] = CONTEXT_SLOT_IMPLIED_TYPES.get(cs, "other")
5149
5150        self.default_context_args["description"] = (
5151            f"Memory report of the {cs}",
5152            (
5153                f"We will produce a memory report from the {cs} which"
5154                f" indicates the structure of the value in memory,"
5155                f" including which parts are aliases of each other."
5156            ),
5157            f"Memory report of the {cs}",
5158            (
5159                f"We produced a memory report from the {cs} which"
5160                f" indicates the structure of the value in memory,"
5161                f" including which parts are aliases of each other."
5162            )
5163        )
5164
5165        self.default_context_args["display_product"] = (
5166            contexts.build_context_value_displayer(
5167                "memory_report",
5168                labels=[
5169                    "Your memory report:",
5170                    "Solution memory report:",
5171                    "Differences"
5172                ]
5173            )
5174        )
5175
5176        if hasattr(self, "base_name") or hasattr(parent, "base_name"):
5177            if hasattr(self, "base_name"):
5178                base_name = self.base_name
5179            else:
5180                base_name = parent.base_name
5181
5182            if cs == "value":
5183                cs = "result"
5184
5185            if base_name == "import":
5186                self.default_goal_args["description"] = (
5187                    (
5188                        f"The {cs} after running your program must have"
5189                        f" the correct memory structure"
5190                    ),
5191                    (
5192                        f"The memory structure of the {cs} produced by"
5193                        f" your code must match that of the {cs}"
5194                        f" produced by the solution code, including"
5195                        f" which parts are aliases of each other."
5196                    ),
5197                    (
5198                        f"The {cs} after running your program must have"
5199                        f" the correct memory structure"
5200                    ),
5201                    (
5202                        f"We checked whether the memory structure of the {cs}"
5203                        f" produced by your code matched the memory structure"
5204                        f" produced by the solution code."
5205                    )
5206                )
5207            else:
5208                self.default_goal_args["description"] = (
5209                    (
5210                        f"The {cs} of <code>{base_name}</code>"
5211                        f" must have the correct memory structure"
5212                    ),
5213                    (
5214                        f"The memory structure of the {cs} produced by"
5215                        f" running <code>{base_name}</code> must"
5216                        f" match that of the {cs} produced by the"
5217                        f" solution code, including which parts are"
5218                        f" aliases of each other."
5219                    ),
5220                    (
5221                        f"The {cs} of <code>{base_name}</code>"
5222                        f" must have the correct memory structure"
5223                    ),
5224                    (
5225                        f"We checked whether the memory structure of"
5226                        f" the {cs} produced by"
5227                        f" <code>{base_name}</code> matched the"
5228                        f" memory structure produced by the solution"
5229                        f" code."
5230                    )
5231                )
5232        else:
5233            self.default_goal_args["description"] = (
5234                f"The {cs} must have the correct memory structureee",
5235                (
5236                    f"The memory structure of the {cs} produced by your code"
5237                    f" must match that of the {cs} produced by the solution"
5238                    f" code, including which parts are aliases of each other."
5239                ),
5240                f"The {cs} must have the correct memory structureee",
5241                (
5242                    f"We checked whether the memory structure of the {cs}"
5243                    f" produced by your code matched the memory structure"
5244                    f" produced by the solution code."
5245                )
5246            )
5247
5248        # Set up report-based comparison
5249        self.compare_reports()
5250
5251
5252#-------------#
5253# Extra Goals #
5254#-------------#
5255
5256
5257def NoParseErrors(category="core"):
5258    """
5259    Registers a miscellaneous goal requiring that there not be any parse
5260    errors.
5261
5262    The goal is a `potluck.rubrics.NoParseErrors` instance.
5263    """
5264    register_goal(
5265        rubrics.NoParseErrors(
5266            file_utils.deduce_task_id(),
5267            tags={ "category": category }
5268        )
5269    )
5270
5271
5272def RequireDocstrings(exclude=None, category="core"):
5273    """
5274    Registers a miscellaneous goal requiring that all functions have
5275    non-empty docstrings. Capitalized to feel like Test or Check, but not
5276    a class because it has no reason to be.
5277
5278    A list of strings specifying function names to exclude may be given,
5279    and is useful for preventing penalties for students who choose not to
5280    do optional tasks.
5281
5282    A category other than the default 'core' may also be specified.
5283
5284    The goal is a `potluck.rubrics.AllFunctionsHaveDocstrings` instance.
5285    """
5286    register_goal(
5287        rubrics.AllFunctionsHaveDocstrings(
5288            file_utils.deduce_task_id(),
5289            exclude,
5290            tags={ "category": category }
5291        )
5292    )
5293
5294
5295# Builtin functions & methods we use with ARE fruitful
5296FRUITFUL_BUILTINS = [
5297    'input',
5298    'max', 'min',
5299    'round',
5300    'ceil', '.ceil', 'floor', '.floor',
5301    'len',
5302    'int', 'float', 'str', 'repr', 'list', 'tuple', 'dict', 'set', 'type',
5303    'range', 'reversed',
5304    '.lower', '.upper', '.capitalize'
5305    '.startswith', '.endswith',
5306    '.isspace', '.isalpha', '.isdigit', '.isnumeric', '.isalnum',
5307    '.format',
5308    '.join', '.split',
5309    '.index',
5310    '.keys', '.values', '.items',
5311    '.get',
5312    # Note: .pop is very often used legitimately without actually needing
5313    # the return value, so we don't include it as a fruitful function
5314    # here
5315    # '.pop',
5316]
5317"""
5318A list of fruitful built-in functions and methods, for use with
5319`DontWasteFruit` and/or `DontWasteBoxes`.
5320"""
5321
5322
5323# Buitin functions and methods we use which are NOT fruitful
5324NON_FRUITFUL_BUILTINS = [
5325    'print',
5326    '.append',
5327    '.insert',
5328    '.extend',
5329    '.remove',
5330    '.update',
5331]
5332# Note that we *don't* include .pop in this list either!
5333"""
5334A list of non-fruitful built-in functions and methods, for use with
5335`DontWasteFruit` and/or `DontWasteBoxes`.
5336"""
5337
5338
5339def DontNestFunctions(exclude=None, category='core', description=None):
5340    """
5341    Registers a miscellaneous goal requiring that within the code of the
5342    submission, there aren't any function definitions within other
5343    definitions. A list of function names to exclude from the check may
5344    be provided, and a category other than the default 'core' may also be
5345    provided. Finally, a custom description tuple can be supplied,
5346    although in most situations the default should be fine.
5347
5348    The goal is a `potluck.rubrics.FunctionsArentNested` instance.
5349    """
5350    args = {
5351        "exclude": exclude,
5352        "tags": {"category": category}
5353    }
5354    if description is not None:
5355        args["description"] = description
5356
5357    register_goal(
5358        rubrics.FunctionsArentNested(
5359            file_utils.deduce_task_id(),
5360            **args
5361        )
5362    )
5363
5364
5365def DontWasteFruit(
5366    extra=FRUITFUL_BUILTINS,
5367    exclude=[],
5368    category="extra",
5369    description=None
5370):
5371    """
5372    Registers a miscellaneous goal requiring that the submitted code
5373    doesn't ignore return values from fruitful functions. See
5374    `potluck.rubrics.DoesntWasteFruit`.
5375    """
5376    args = {
5377        "extra": extra,
5378        "exclude": exclude,
5379        "tags": { "category": category },
5380    }
5381    if description is not None:
5382        args["description"] = description
5383
5384    register_goal(
5385        rubrics.DoesntWasteFruit(
5386            file_utils.deduce_task_id(),
5387            **args
5388        )
5389    )
5390
5391
5392def DontWasteBoxes(
5393    exclude=[],
5394    category="extra",
5395    tolerance=2,
5396    check_loop_vars=False,
5397    description=None
5398):
5399    """
5400    Registers a miscellaneous goal requiring that within the code of the
5401    submission, there aren't any unused variables, unless they're named
5402    '_'. See `potluck.rubrics.DoesntWasteBoxes`.
5403
5404    Unless `check_loop_vars` is set to True, loop variables in for loops
5405    will not be checked, since these are required but often legitimately
5406    go unused.
5407    """
5408    args = {
5409        "exclude": exclude,
5410        "tolerance": tolerance,
5411        "check_loop_vars": check_loop_vars,
5412        "tags": { "category": category },
5413    }
5414    if description is not None:
5415        args["description"] = description
5416
5417    register_goal(
5418        rubrics.DoesntWasteBoxes(
5419            file_utils.deduce_task_id(),
5420            **args
5421        )
5422    )
5423
5424
5425#------------------#
5426# Validation Goals #
5427#------------------#
5428
5429def RequireTestCases(
5430    requirements,
5431    category="core",
5432    description=None
5433):
5434    """
5435    Registers a validation goal requiring that the submitted code
5436    creates a certain number of expectations (using the `optimism`
5437    module) for each of certain target functions and/or files.
5438    `requirements` must be a dictionary mapping function name strings
5439    (which can't end in '.py') and/or file name strings (which do end in
5440    '.py') that need testing to the minimum number of test cases
5441    required for each. A custom category and description may be
5442    provided; the default category is core. The underlying goal created
5443    is a `potluck.validation.DefinesEnoughTests`.
5444    """
5445    args = { "tags": { "category": category } }
5446    if description is not None:
5447        args["description"] = description
5448
5449    # Sort out function/file requirements
5450    function_reqs = {}
5451    file_reqs = {}
5452    for req in requirements:
5453        if req.endswith('.py'):
5454            file_reqs[req] = requirements[req]
5455        else:
5456            function_reqs[req] = requirements[req]
5457
5458    args["function_reqs"] = function_reqs
5459    args["file_reqs"] = file_reqs
5460
5461    register_validation_goal(
5462        validation.DefinesEnoughTests(
5463            file_utils.deduce_task_id(),
5464            **args
5465        )
5466    )
5467
5468
5469def TestsMustPass(
5470    category="extra",
5471    description=None
5472):
5473    """
5474    Registers a validation goal requiring that all test cases checked by
5475    the submitted code (using the `optimism` module) must pass (when run
5476    against the solution code during validation). The goal is a
5477    `potluck.validation.ChecksSucceed`. A category (other than the
5478    default "extra") and a custom description are optional.
5479    """
5480    args = { "tags": { "category": category } }
5481    if description is not None:
5482        args["description"] = description
5483
5484    register_validation_goal(
5485        validation.ChecksSucceed(
5486            file_utils.deduce_task_id(),
5487            **args
5488        )
5489    )
5490
5491
5492#-----------------#
5493# Rubric creation #
5494#-----------------#
5495
5496def rubric(metric=rubrics.core_extras_flat_metric):
5497    """
5498    Creates a `potluck.rubrics.Rubric` based on the test and check
5499    objects instantiated within the current module.
5500
5501    A non-default metric function may be supplied; see e.g.
5502    `rubrics.core_extras_flat_metric` (which is the default).
5503    """
5504    validation_goals = []
5505    evaluation_goals = []
5506
5507    # Name of the specifications module this function is being called in
5508    sname = file_utils.get_spec_module_name()
5509
5510    # Directly-registered validation goals
5511    validation_goals.extend(VALIDATION_GOALS.get(sname, []))
5512
5513    # Goals via providers
5514    for provider in VALIDATION_GOAL_PROVIDERS.get(sname, []):
5515        try:
5516            validation_goals.append(provider.provide_goal())
5517        except Exception:
5518            raise ValueError(
5519                "Unable to create validation goal from: " + repr(provider)
5520            )
5521            # TODO: Better reporting (e.g., which line goal was defined on)
5522
5523    # Directly-registered evaluation goals
5524    evaluation_goals.extend(GOALS.get(sname, []))
5525
5526    # Goals via providers
5527    for provider in GOAL_PROVIDERS.get(sname, []):
5528        try:
5529            evaluation_goals.append(provider.provide_goal())
5530        except Exception:
5531            raise ValueError("Unable to create goal from: " + repr(provider))
5532            # TODO: Better reporting (e.g., which line goal was defined on)
5533
5534    # Checks
5535    checks = CHECKS_REGISTRY.get(sname, {})
5536    for cat in checks:
5537        cat_registry = checks[cat]
5538        for goal_type in cat_registry:
5539            list_of_checks = cat_registry[goal_type]
5540            for check_obj in list_of_checks:
5541                cat_gt_map = check_obj.build_implementation_checks()
5542                evaluation_goals.extend(cat_gt_map.values())
5543
5544    # Result
5545    return rubrics.Rubric(
5546        evaluation_goals,
5547        metric,
5548        validation_goals,
5549        file_utils.get_spec_file_name()
5550    )
5551
5552
5553#-----------------#
5554# Context control #
5555#-----------------#
5556
5557_PREP_FUNCTIONS = []
5558"""
5559A list of prep functions to apply on module import. Note that these
5560don't apply to `TestImport` contexts, which use
5561`HasPayload.prepare_source` for that purpose.
5562"""
5563
5564_WRAPPERS = []
5565"""
5566A list of wrapper functions to apply on module import. Note that these
5567don't apply to `TestImport` contexts, which use `HasPayload.wrap_module`
5568for that purpose.
5569"""
5570
5571
5572def file(filename):
5573    """
5574    After calling this function, subsequent tests, checks, etc. will all
5575    by default run against the submitted file with the given filename,
5576    rather than the default submitted file. Can be called multiple times
5577    to establish segments of the spec file that apply to different
5578    submitted files.
5579
5580    Calling this function will reset any establish prep and/or wrap
5581    functions.
5582
5583    TODO: What if a lower-level auto-context has been established, such
5584    that changing the File auto-context doesn't matter?!?
5585
5586    TODO: Test this!
5587    """
5588    global _PREP_FUNCTIONS, _WRAPPERS
5589    _PREP_FUNCTIONS = []
5590    _WRAPPERS = []
5591    contexts.FileContext(filename)
5592    contexts.ModuleContext()
5593
5594
5595def add_module_prep(prep_fn):
5596    """
5597    Adds an additional module prep function to the list of active module
5598    prep functions, to be run when a submitted module is imported for
5599    testing. The prep function will be applied to imports for tests
5600    below where this Function is called; `TestImport` imports are not
5601    affected because they use `HasPayload.prepare_source` instead.
5602
5603    The prep function must accept a context dictionary as an argument,
5604    and should return the same dictionary (or a modified dictionary). If
5605    a zero-argument function is provided, it will be modified to accept
5606    and return a context dictionary without alteration.
5607    """
5608    global _PREP_FUNCTIONS
5609    _PREP_FUNCTIONS.append(prep_fn)
5610    activate_preps_and_wraps()
5611
5612
5613def add_module_wrapper(wrapper):
5614    """
5615    Adds an additional module wrapper function to the list of active
5616    module wrapper functions, to be run when a submitted module is
5617    imported for testing. The wrapper function will be applied to
5618    imports for tests below where this Function is called; `TestImport`
5619    imports are not affected because they use `HasPayload.wrap_module`
5620    instead.
5621
5622    The wrap function must accept the imported module as an argument,
5623    and its return value will be used in place of that module. If a
5624    zero-argument function is provided, it will be modified to accept
5625    and return a module without alteration.
5626    """
5627    global _WRAPPERS
5628    _WRAPPERS.append(wrapper)
5629    activate_preps_and_wraps()
5630
5631
5632def activate_preps_and_wraps():
5633    """
5634    Activates the current prep/wrap function lists by constructing a
5635    `contexts.ModuleContext` using them.
5636    """
5637    def do_prep(ctx):
5638        """
5639        Combined prep function
5640        """
5641        for fn in _PREP_FUNCTIONS:
5642            if fn.__code__.co_argcount == 0:
5643                fn()
5644            else:
5645                ctx = fn(ctx)
5646        return ctx
5647
5648    def do_wrap(mod):
5649        """
5650        Combined module wrapper.
5651        """
5652        for fn in _WRAPPERS:
5653            if fn.__code__.co_argcount == 0:
5654                fn()
5655            else:
5656                mod = fn(mod)
5657
5658        return mod
5659
5660    contexts.ModuleContext(prep=do_prep, wrap=do_wrap)
SPECS_DIR = '.'

Directory for finding task specifications.

CONTEXT_SLOT_IMPLIED_TYPES = {'filename': 'other', 'file_path': 'other', 'source': 'style', 'docstrings': 'style', 'parse_errors': 'procedure', 'defs': 'procedure', 'top_scope': 'procedure', 'scope': 'procedure', 'module': 'product', 'trace': 'process', 'value': 'product', 'output': 'behavior', 'output_file_contents': 'behavior', 'image': 'behavior', 'audio': 'behavior', 'notes': 'behavior'}

Based on just the context slot being used for testing with an potluck.rubrics.ComparisonTest, we can guess the goal type that the goal will fall into. This dictionary stores those associations. Use HasGoal.set_goal_type to set a goal type explicitly if the default isn't correct.

def register_goal_provider(provider):
386def register_goal_provider(provider):
387    """
388    Registers a goal-provider (must have a zero-parameter `provide_goal`
389    method, so most likely to be a `HasGoal` instance) to provide a goal
390    to the rubric. Mostly this is handled automatically via
391    `HasGoal.goal`, but it's useful to call it manually in some cases.
392    """
393    sname = file_utils.get_spec_module_name()
394    if sname not in GOAL_PROVIDERS:
395        GOAL_PROVIDERS[sname] = []
396    GOAL_PROVIDERS[sname].append(provider)

Registers a goal-provider (must have a zero-parameter provide_goal method, so most likely to be a HasGoal instance) to provide a goal to the rubric. Mostly this is handled automatically via HasGoal.goal, but it's useful to call it manually in some cases.

def register_goal(goal):
399def register_goal(goal):
400    """
401    Registers a goal to be added to the rubric. Mostly this is handled
402    automatically, but it's useful to call it manually if you want to
403    define your own custom goals in tandem with automatically-created goals.
404    """
405    sname = file_utils.get_spec_module_name()
406    if sname not in GOALS:
407        GOALS[sname] = []
408    GOALS[sname].append(goal)

Registers a goal to be added to the rubric. Mostly this is handled automatically, but it's useful to call it manually if you want to define your own custom goals in tandem with automatically-created goals.

def register_validation_goal_provider(provider):
411def register_validation_goal_provider(provider):
412    """
413    Registers a goal-provider (must have a zero-parameter `provide_goal`
414    method, so most likely to be a `HasGoal` instance) to provide a goal
415    to the rubric's test validation stage. Mostly this is handled
416    automatically via `HasGoal.validate`, but it's useful to call it
417    manually in some cases.
418    """
419    sname = file_utils.get_spec_module_name()
420    if sname not in VALIDATION_GOAL_PROVIDERS:
421        VALIDATION_GOAL_PROVIDERS[sname] = []
422    VALIDATION_GOAL_PROVIDERS[sname].append(provider)

Registers a goal-provider (must have a zero-parameter provide_goal method, so most likely to be a HasGoal instance) to provide a goal to the rubric's test validation stage. Mostly this is handled automatically via HasGoal.validate, but it's useful to call it manually in some cases.

def register_validation_goal(goal):
425def register_validation_goal(goal):
426    """
427    Registers a goal to be added to the rubric. Mostly this is handled
428    automatically, but it's useful to call it manually if you want to
429    define your own custom goals in tandem with automatically-created goals.
430    """
431    sname = file_utils.get_spec_module_name()
432    if sname not in VALIDATION_GOALS:
433        VALIDATION_GOALS[sname] = []
434    VALIDATION_GOALS[sname].append(goal)

Registers a goal to be added to the rubric. Mostly this is handled automatically, but it's useful to call it manually if you want to define your own custom goals in tandem with automatically-created goals.

def checklist(category, goal_type, create=False):
437def checklist(category, goal_type, create=False):
438    """
439    Retrieves the list of `Check` objects which have been registered
440    under the given category and goal type. Raises a `KeyError` if no
441    checks have been registered under that category, or if create is
442    True, it creates an entry for that category and returns an empty
443    list.
444    """
445    mname = file_utils.get_spec_module_name()
446    module_registry = CHECKS_REGISTRY.get(mname)
447
448    if module_registry is None:
449        if create:
450            module_registry = CHECKS_REGISTRY.setdefault(mname, {})
451        else:
452            raise KeyError(
453                f"There are no checks for module {mname}."
454            )
455
456    category_registry = module_registry.get(category)
457
458    if category_registry is None:
459        if create:
460            category_registry = module_registry.setdefault(category, {})
461        else:
462            raise KeyError(
463                f"There are no checks in module {mname} for category"
464              + f" {category}."
465            )
466
467    list_for_type = category_registry.get(goal_type)
468
469    if list_for_type is None:
470        if create:
471            list_for_type = category_registry.setdefault(goal_type, [])
472        else:
473            raise KeyError(
474                f"There are no checks in module {mname} for category"
475              + f" {category} and type {goal_type}."
476            )
477
478    return list_for_type

Retrieves the list of Check objects which have been registered under the given category and goal type. Raises a KeyError if no checks have been registered under that category, or if create is True, it creates an entry for that category and returns an empty list.

def update_augmentations(base, extensions):
485def update_augmentations(base, extensions):
486    """
487    Takes two dictionaries and updates the first with the key/value
488    pairs from the second, with special treatment of "with_setup" and
489    "with_cleanup" keys so that setup/cleanup functions are accumulated
490    via composition rather than overriding each other.
491
492    Edits the first dictionary but doesn't have a return value.
493    """
494    for key in extensions:
495        if (
496            key in ("with_setup", "with_cleanup")
497        and key in base
498        ):
499            keyarg = key[5:]
500            already = base[key][keyarg]
501            incomming = extensions[key][keyarg]
502            base[key] = {
503                keyarg: lambda val: incomming(already(val))
504            }
505        else:
506            base[key] = extensions[key]

Takes two dictionaries and updates the first with the key/value pairs from the second, with special treatment of "with_setup" and "with_cleanup" keys so that setup/cleanup functions are accumulated via composition rather than overriding each other.

Edits the first dictionary but doesn't have a return value.

class HasPayload:
509class HasPayload:
510    """
511    An abstract base class for tests that track payload augmentations,
512    since the augmentation system allows for common functionality.
513    """
514    def __init__(
515        self,
516        payload_constructor=harness.create_run_function_payload,
517        default_payload_args=None,
518        default_augmentations=None
519    ):
520        """
521        A base payload creation function may be supplied (default is
522        `potluck.harness.create_run_function_payload`).
523
524        Defaults for payload arguments and/or augmentations may be
525        supplied. Payload arguments are just passed as keyword arguments
526        to the payload constructor.
527
528        Augmentations should be a dictionary where keys name payload
529        augmentation functions in the `potluck.harness` module, and
530        values are dictionaries of keyword arguments to supply to those
531        augmentations.
532        """
533        self.payload_constructor = payload_constructor
534        self.default_payload_args = default_payload_args or {}
535        self.default_augmentations = default_augmentations or {}
536        self.payload_args = {}
537        self.augmentations = {}
538
539    def synthesize_payload_info(self, group=None):
540        """
541        Synthesizes payload construction arguments and augmentations,
542        returning a tuple containing the results in that order. If a
543        group is given, it should also be a `HasPayload` instance, and
544        its information will be mixed with local information as follows:
545
546          - First, group defaults will be loaded.
547          - Next, this object's defaults will override those.
548          - Third, group explicit values will be added.
549          - Finally, local explicit values will have the final say.
550
551        Note: setup and cleanup functions accumulate via composition
552        rather than replacing each other.
553        """
554        args = {}
555        augmentations = {}
556        if group:
557            args.update(group.default_payload_args)
558            augmentations.update(group.default_augmentations)
559        args.update(self.default_payload_args)
560        update_augmentations(augmentations, self.default_augmentations)
561        if group:
562            args.update(group.payload_args)
563            update_augmentations(augmentations, group.augmentations)
564        args.update(self.payload_args)
565        update_augmentations(augmentations, self.augmentations)
566
567        return args, augmentations
568
569    def construct_payload(self, parent=None):
570        """
571        Constructs the augmented payload function based on the
572        information assembled so far.
573        """
574        # Synthesize payload arguments & augmentations
575        args, augmentations = self.synthesize_payload_info(parent)
576
577        # Construct base payload
578        # TODO: less awkward here?
579        # TODO NOT a HACK here!
580        if (
581            hasattr(parent, "payload_constructor")
582        and parent.payload_constructor == harness.create_run_harness_payload
583        ):
584            cons = parent.payload_constructor
585        else:
586            cons = self.payload_constructor
587
588        result = cons(**args)
589
590        # Apply augmentations in order
591        for fn_name in harness.AUGMENTATION_ORDER:
592            if fn_name in augmentations:
593                args = augmentations[fn_name]
594                result = getattr(harness, fn_name)(result, **args)
595
596        return result
597
598    def describe_payload(self, parent=None, obfuscated=False):
599        """
600        Returns a pair of HTML strings for the topic and details of the
601        payload that will be constructed by `construct_payload` (with an
602        equivalent `parent` argument). If `obfuscated` is set to True,
603        the obfuscated version of the description will be provided (see
604        `potluck.explain.payload_description`)
605        """
606        # Synthesize info w/ parent
607        args, augmentations = self.synthesize_payload_info(parent)
608        return explain.payload_description(
609            self.payload_constructor,
610            args,
611            augmentations,
612            obfuscated=obfuscated
613        )
614
615    def ensure_payload_constructor_arg(self, desired):
616        """
617        Ensures that this object's payload constructor accepts the given
618        argument value, raising a `TypeError` if it does not.
619        """
620        cobj = self.payload_constructor.__code__
621        arg_names = cobj.co_varnames[:cobj.co_argcount]
622        if desired not in arg_names:
623            raise TypeError(
624                f"This operation is only allowed for HasPayload classes"
625                f" associated with payload bases that accept a '{desired}'"
626                f" argument ({self.payload_constructor.__name__} does not)."
627            )
628
629    def prepare_source(self, prep):
630        """
631        Provides a prep function which will be run on the source code of
632        the module being tested before the test. Only applicable to
633        module import payloads. The string it returns will be used as the
634        actual module source. Even if you don't need to modify the source
635        code, this can be used to run some setup code right before the
636        test itself.
637
638        This function returns self for chaining.
639        """
640        self.ensure_payload_constructor_arg("prep")
641        self.payload_args["prep"] = prep
642        return self
643
644    def wrap_module(self, wrapper):
645        """
646        Provides a wrapper function which will be applied to the module
647        created by this test before the final checking step. Only
648        applicable to module import payloads (use `use_decorations` to
649        achieve a similar effect for other payload types). Mostly
650        relevant when the `module` slot is being used somehow, such as
651        when `HasGoal.test_module` is being used.
652
653        This function returns self for chaining.
654        """
655        self.ensure_payload_constructor_arg("wrap")
656        self.payload_args["wrap"] = wrapper
657        return self
658
659    def ignore_output(self):
660        """
661        Modifies the payload so that it no longer captures printed
662        output. Useful for payloads that capture printed output by
663        default when that functionality isn't needed.
664        """
665        # Modify default and explicit augmentations
666        cpo = "capturing_printed_output"
667        if cpo in self.default_augmentations:
668            del self.default_augmentations[cpo]
669        if cpo in self.augmentations:
670            del self.augmentations[cpo]
671
672        # If we're actually broadcasting, we need to delete from children
673        if isinstance(self, TestGroup):
674            for test in self.tests:
675                if cpo in test.default_augmentations:
676                    del test.default_augmentations[cpo]
677                if cpo in test.augmentations:
678                    del test.augmentations[cpo]
679
680    def copy_args(self, copy=True):
681        """
682        Sets up the payload so that it will deeply copy argument values.
683        Only works for payloads based on
684        `evaluations.harness.create_run_function_payload`.
685
686        Set copy to False to disable argument copying (which is the
687        default behavior).
688
689        Returns self for chaining.
690        """
691        self.ensure_payload_constructor_arg("copy_args")
692        self.payload_args["copy_args"] = copy
693        return self
694
695    def use_harness(self, harness_fn):
696        """
697        Modifies the payload so that it will use a test harness function
698        instead of calling a target function directly. The payload must
699        already have `potluck.harness.create_run_function_payload` as its
700        base, or a `TypeError` will result.
701
702        The harness function will receive all the same positional and/or
703        keyword arguments as the function being tested would have, except
704        that it will also receive the function to test as its first
705        positional argument.
706        """
707        if self.payload_constructor != harness.create_run_function_payload:
708            raise TypeError(
709                f"A test harness can only be applied to a test that's"
710                f" normally based on a function call (but {self}"
711                f" has payload constructor: {self.payload_constructor})."
712            )
713        self.payload_constructor = harness.create_run_harness_payload
714        self.payload_args["harness"] = harness_fn
715
716    def set_timeout(self, time_limit):
717        """
718        Sets a timeout value that limits how long the payload will run.
719
720        Returns self for chaining.
721        """
722        self.augmentations["with_timeout"] = { "time_limit": time_limit }
723        return self
724
725    def do_setup(self, setup_fn):
726        """
727        Adds a setup function for this payload, which will be run right
728        before the payload starts. Returns self for chaining.
729
730        See `potluck.harness.with_setup` for details.
731
732        Note: `HasPayload.do_setup` and `HasPayload.do_cleanup` each
733        accumulate setup/cleanup functions instead of replacing the
734        previous setup/cleanup function, with setup functions added later
735        affecting the results of setup functions added earlier. Also,
736        setup/cleanup functions are accumulated between child and parent
737        payload-bearing objects, rather than child setups/cleanups
738        overriding parent setups/cleanups.
739        """
740        update_augmentations(
741            self.augmentations,
742            { "with_setup": { "setup": setup_fn } }
743        )
744        return self
745
746    def do_cleanup(self, cleanup_fn):
747        """
748        Adds a cleanup function for this test, which will be run right
749        after the payload is finished. Returns self for chaining.
750
751        See `potluck.harness.with_cleanup` for details.
752        """
753        update_augmentations(
754            self.augmentations,
755            { "with_cleanup": { "cleanup": cleanup_fn } }
756        )
757        return self
758
759    def capture_output(self, capture_errors=False, capture_stderr=False):
760        """
761        Sets up output capturing, so that everything that gets printed
762        will be captured in the "output" slot. If `capture_errors` is
763        set to True, errors will be captured as part of the output
764        instead of actually breaking the test. If `capture_stderr` is set
765        to True, messages written to stderr will be captured into an
766        additional "error_log" slot. Returns self for chaining.
767        """
768        self.augmentations["capturing_printed_output"] = {
769            "capture_errors": capture_errors,
770            "capture_stderr": capture_stderr
771        }
772        return self
773
774    def provide_inputs(self, strings, policy="hold"):
775        """
776        Set up a series of strings to use as the results of any `input()`
777        calls made during the payload execution. For details, Refer to
778        `potluck.harness.with_fake_input`.
779
780        Returns self for chaining.
781        """
782        self.augmentations["with_fake_input"] = {
783            "inputs": strings,
784            "extra_policy": policy
785        }
786        return self
787
788    def use_decorations(self, decorations, ignore_missing=False):
789        """
790        Sets the decorations dictionary for this payload, which maps
791        function (or variable) names to decorator functions that will be
792        temporarily applied to those values during testing. This has a
793        lot of potential uses, like disabling a function ('decorate' it
794        to return a new do-nothing function), performing some operation
795        on the result of the test function before comparison happens (by
796        decorating the function being tested), or even providing a new
797        variable that's not defined by default (but that can often be
798        accomplished via other means).
799
800        If `ignore_missing` is set to True instead of the default
801        (False), then if a decoration is supposed to apply to a value
802        which doesn't exist, instead of an error being raised, the
803        decoration function will be called with `potluck.harness.Missing`
804        as the input value.
805
806        This function returns self so that you can chain it with other
807        modification functions.
808
809        Any old decorations dictionary will be overridden.
810
811        Note that decorations cannot be applied to payloads based on
812        module imports, as the decorations are applied to a loaded
813        module, and module import payloads re-load the target module with
814        each test. `HasPayload.prepare_source` and
815        `HasPayload.wrap_module` are the closest equivalents for module
816        import payloads.
817        """
818        self.augmentations["with_module_decorations"] = {
819            "decorations": decorations,
820            "ignore_missing": ignore_missing
821        }
822        return self
823
824    def capture_trace(self, trace_targets, state_function):
825        """
826        Causes the payload to produce a "trace" context slot in
827        addition to other slots, which holds a trace of function calls
828        to functions named in the provided `trace_targets` sequence. See
829        `potluck.harness.tracing_function_calls` for details, including
830        the functionality of the state function.
831
832        The `trace_targets` list may include tuples, in which case calls
833        to any of the functions in the tuple will be traced as if they
834        were calls to the first function (useful for collapsing aliases
835        like turtle.fd and turtle.forward).
836
837        This function returns self so that you can chain it with other
838        modification functions.
839
840        Any old tracing setup will be overridden.
841        """
842        self.augmentations["tracing_function_calls"] = {
843            "trace_targets": trace_targets,
844            "state_function": state_function
845        }
846        return self
847
848    def sample_result_distribution(
849        self,
850        slot_map={
851            "value": "distribution",
852            "ref_value": "ref_distribution"
853        },
854        trials=50000
855    ):
856        """
857        Modifies the payload so that it runs many times and the
858        distribution of results is recorded. For details, see
859        `potluck.harness.sampling_distribution_of_results`.
860
861        Returns self for chaining.
862
863        Note that this is useful only in very specific cases, is
864        often quite slow, and even in cases where it might be
865        applicable, needs to be used with a very careful comparator.
866
867        In particular, if you're sampling the distribution of results
868        from a random function and comparing them to a reference
869        distribution, even with a lot of trials, the chances that the
870        two distributions diverge significantly just by bad luck are
871        often unfortunately high. If you're evaluating hundreds of
872        submissions per task and dozens of tasks per course and want to
873        scrupulously avoid the chance of an erroneous test result,
874        consider other methods of testing random functions, such as
875        seed-based testing or other de-randomization techniques.
876        """
877        self.augmentations["sampling_distribution_of_results"] = {
878            "slot_map": slot_map,
879            "trials": trials
880        }
881        return self
882
883    def capture_turtle_image(self, alt_text=None, skip_reset=False):
884        """
885        Captures what's drawn on the turtle canvas, into an "image"
886        context slot. See `potluck.harness.capturing_turtle_drawings`,
887        which explains the alt_text and skip_reset arguments.
888
889        Returns self for chaining.
890        """
891        self.augmentations["capturing_turtle_drawings"] = {
892            "alt_text": alt_text,
893            "skip_reset": skip_reset,
894        }
895        return self
896
897    def capture_wavesynth(
898        self,
899        just_capture=None,
900        label="resulting_audio"
901    ):
902        """
903        Captures the current track in `wavesynth` as a list of note
904        description strings, in a "notes" context slot, and as raw audio,
905        in the "audio" slot. See
906        `potluck.harness.capturing_wavesynth_audio`.
907
908        You can set either just_capture to "notes" or "audio" to capture
909        just one or the other, or leave it at None (the default) to
910        capture both.
911
912        A custom label may be provided for any resulting audio elements.
913
914        Returns self for chaining.
915        """
916        self.augmentations["capturing_wavesynth_audio"] = {
917            "just_capture": just_capture,
918            "label": label
919        }
920        return self
921
922    def capture_file_contents(self, filename, binary=False):
923        """
924        Captures the contents of a specific file after the code has
925        finished running. Stores the file name in the "output_filename"
926        slot and the file contents as a string in the
927        "output_file_contents" slot.
928
929        If `binary` is set to True instead of the default False, the file
930        will be read as a bytes object instead of a string.
931
932        Returns self for chaining.
933        """
934        self.augmentations["capturing_file_contents"] = {
935            "filename": filename,
936            "binary": binary
937        }
938
939        return self

An abstract base class for tests that track payload augmentations, since the augmentation system allows for common functionality.

HasPayload( payload_constructor=<function create_run_function_payload>, default_payload_args=None, default_augmentations=None)
514    def __init__(
515        self,
516        payload_constructor=harness.create_run_function_payload,
517        default_payload_args=None,
518        default_augmentations=None
519    ):
520        """
521        A base payload creation function may be supplied (default is
522        `potluck.harness.create_run_function_payload`).
523
524        Defaults for payload arguments and/or augmentations may be
525        supplied. Payload arguments are just passed as keyword arguments
526        to the payload constructor.
527
528        Augmentations should be a dictionary where keys name payload
529        augmentation functions in the `potluck.harness` module, and
530        values are dictionaries of keyword arguments to supply to those
531        augmentations.
532        """
533        self.payload_constructor = payload_constructor
534        self.default_payload_args = default_payload_args or {}
535        self.default_augmentations = default_augmentations or {}
536        self.payload_args = {}
537        self.augmentations = {}

A base payload creation function may be supplied (default is potluck.harness.create_run_function_payload).

Defaults for payload arguments and/or augmentations may be supplied. Payload arguments are just passed as keyword arguments to the payload constructor.

Augmentations should be a dictionary where keys name payload augmentation functions in the potluck.harness module, and values are dictionaries of keyword arguments to supply to those augmentations.

def synthesize_payload_info(self, group=None):
539    def synthesize_payload_info(self, group=None):
540        """
541        Synthesizes payload construction arguments and augmentations,
542        returning a tuple containing the results in that order. If a
543        group is given, it should also be a `HasPayload` instance, and
544        its information will be mixed with local information as follows:
545
546          - First, group defaults will be loaded.
547          - Next, this object's defaults will override those.
548          - Third, group explicit values will be added.
549          - Finally, local explicit values will have the final say.
550
551        Note: setup and cleanup functions accumulate via composition
552        rather than replacing each other.
553        """
554        args = {}
555        augmentations = {}
556        if group:
557            args.update(group.default_payload_args)
558            augmentations.update(group.default_augmentations)
559        args.update(self.default_payload_args)
560        update_augmentations(augmentations, self.default_augmentations)
561        if group:
562            args.update(group.payload_args)
563            update_augmentations(augmentations, group.augmentations)
564        args.update(self.payload_args)
565        update_augmentations(augmentations, self.augmentations)
566
567        return args, augmentations

Synthesizes payload construction arguments and augmentations, returning a tuple containing the results in that order. If a group is given, it should also be a HasPayload instance, and its information will be mixed with local information as follows:

  • First, group defaults will be loaded.
  • Next, this object's defaults will override those.
  • Third, group explicit values will be added.
  • Finally, local explicit values will have the final say.

Note: setup and cleanup functions accumulate via composition rather than replacing each other.

def construct_payload(self, parent=None):
569    def construct_payload(self, parent=None):
570        """
571        Constructs the augmented payload function based on the
572        information assembled so far.
573        """
574        # Synthesize payload arguments & augmentations
575        args, augmentations = self.synthesize_payload_info(parent)
576
577        # Construct base payload
578        # TODO: less awkward here?
579        # TODO NOT a HACK here!
580        if (
581            hasattr(parent, "payload_constructor")
582        and parent.payload_constructor == harness.create_run_harness_payload
583        ):
584            cons = parent.payload_constructor
585        else:
586            cons = self.payload_constructor
587
588        result = cons(**args)
589
590        # Apply augmentations in order
591        for fn_name in harness.AUGMENTATION_ORDER:
592            if fn_name in augmentations:
593                args = augmentations[fn_name]
594                result = getattr(harness, fn_name)(result, **args)
595
596        return result

Constructs the augmented payload function based on the information assembled so far.

def describe_payload(self, parent=None, obfuscated=False):
598    def describe_payload(self, parent=None, obfuscated=False):
599        """
600        Returns a pair of HTML strings for the topic and details of the
601        payload that will be constructed by `construct_payload` (with an
602        equivalent `parent` argument). If `obfuscated` is set to True,
603        the obfuscated version of the description will be provided (see
604        `potluck.explain.payload_description`)
605        """
606        # Synthesize info w/ parent
607        args, augmentations = self.synthesize_payload_info(parent)
608        return explain.payload_description(
609            self.payload_constructor,
610            args,
611            augmentations,
612            obfuscated=obfuscated
613        )

Returns a pair of HTML strings for the topic and details of the payload that will be constructed by construct_payload (with an equivalent parent argument). If obfuscated is set to True, the obfuscated version of the description will be provided (see potluck.explain.payload_description)

def ensure_payload_constructor_arg(self, desired):
615    def ensure_payload_constructor_arg(self, desired):
616        """
617        Ensures that this object's payload constructor accepts the given
618        argument value, raising a `TypeError` if it does not.
619        """
620        cobj = self.payload_constructor.__code__
621        arg_names = cobj.co_varnames[:cobj.co_argcount]
622        if desired not in arg_names:
623            raise TypeError(
624                f"This operation is only allowed for HasPayload classes"
625                f" associated with payload bases that accept a '{desired}'"
626                f" argument ({self.payload_constructor.__name__} does not)."
627            )

Ensures that this object's payload constructor accepts the given argument value, raising a TypeError if it does not.

def prepare_source(self, prep):
629    def prepare_source(self, prep):
630        """
631        Provides a prep function which will be run on the source code of
632        the module being tested before the test. Only applicable to
633        module import payloads. The string it returns will be used as the
634        actual module source. Even if you don't need to modify the source
635        code, this can be used to run some setup code right before the
636        test itself.
637
638        This function returns self for chaining.
639        """
640        self.ensure_payload_constructor_arg("prep")
641        self.payload_args["prep"] = prep
642        return self

Provides a prep function which will be run on the source code of the module being tested before the test. Only applicable to module import payloads. The string it returns will be used as the actual module source. Even if you don't need to modify the source code, this can be used to run some setup code right before the test itself.

This function returns self for chaining.

def wrap_module(self, wrapper):
644    def wrap_module(self, wrapper):
645        """
646        Provides a wrapper function which will be applied to the module
647        created by this test before the final checking step. Only
648        applicable to module import payloads (use `use_decorations` to
649        achieve a similar effect for other payload types). Mostly
650        relevant when the `module` slot is being used somehow, such as
651        when `HasGoal.test_module` is being used.
652
653        This function returns self for chaining.
654        """
655        self.ensure_payload_constructor_arg("wrap")
656        self.payload_args["wrap"] = wrapper
657        return self

Provides a wrapper function which will be applied to the module created by this test before the final checking step. Only applicable to module import payloads (use use_decorations to achieve a similar effect for other payload types). Mostly relevant when the module slot is being used somehow, such as when HasGoal.test_module is being used.

This function returns self for chaining.

def ignore_output(self):
659    def ignore_output(self):
660        """
661        Modifies the payload so that it no longer captures printed
662        output. Useful for payloads that capture printed output by
663        default when that functionality isn't needed.
664        """
665        # Modify default and explicit augmentations
666        cpo = "capturing_printed_output"
667        if cpo in self.default_augmentations:
668            del self.default_augmentations[cpo]
669        if cpo in self.augmentations:
670            del self.augmentations[cpo]
671
672        # If we're actually broadcasting, we need to delete from children
673        if isinstance(self, TestGroup):
674            for test in self.tests:
675                if cpo in test.default_augmentations:
676                    del test.default_augmentations[cpo]
677                if cpo in test.augmentations:
678                    del test.augmentations[cpo]

Modifies the payload so that it no longer captures printed output. Useful for payloads that capture printed output by default when that functionality isn't needed.

def copy_args(self, copy=True):
680    def copy_args(self, copy=True):
681        """
682        Sets up the payload so that it will deeply copy argument values.
683        Only works for payloads based on
684        `evaluations.harness.create_run_function_payload`.
685
686        Set copy to False to disable argument copying (which is the
687        default behavior).
688
689        Returns self for chaining.
690        """
691        self.ensure_payload_constructor_arg("copy_args")
692        self.payload_args["copy_args"] = copy
693        return self

Sets up the payload so that it will deeply copy argument values. Only works for payloads based on evaluations.harness.create_run_function_payload.

Set copy to False to disable argument copying (which is the default behavior).

Returns self for chaining.

def use_harness(self, harness_fn):
695    def use_harness(self, harness_fn):
696        """
697        Modifies the payload so that it will use a test harness function
698        instead of calling a target function directly. The payload must
699        already have `potluck.harness.create_run_function_payload` as its
700        base, or a `TypeError` will result.
701
702        The harness function will receive all the same positional and/or
703        keyword arguments as the function being tested would have, except
704        that it will also receive the function to test as its first
705        positional argument.
706        """
707        if self.payload_constructor != harness.create_run_function_payload:
708            raise TypeError(
709                f"A test harness can only be applied to a test that's"
710                f" normally based on a function call (but {self}"
711                f" has payload constructor: {self.payload_constructor})."
712            )
713        self.payload_constructor = harness.create_run_harness_payload
714        self.payload_args["harness"] = harness_fn

Modifies the payload so that it will use a test harness function instead of calling a target function directly. The payload must already have potluck.harness.create_run_function_payload as its base, or a TypeError will result.

The harness function will receive all the same positional and/or keyword arguments as the function being tested would have, except that it will also receive the function to test as its first positional argument.

def set_timeout(self, time_limit):
716    def set_timeout(self, time_limit):
717        """
718        Sets a timeout value that limits how long the payload will run.
719
720        Returns self for chaining.
721        """
722        self.augmentations["with_timeout"] = { "time_limit": time_limit }
723        return self

Sets a timeout value that limits how long the payload will run.

Returns self for chaining.

def do_setup(self, setup_fn):
725    def do_setup(self, setup_fn):
726        """
727        Adds a setup function for this payload, which will be run right
728        before the payload starts. Returns self for chaining.
729
730        See `potluck.harness.with_setup` for details.
731
732        Note: `HasPayload.do_setup` and `HasPayload.do_cleanup` each
733        accumulate setup/cleanup functions instead of replacing the
734        previous setup/cleanup function, with setup functions added later
735        affecting the results of setup functions added earlier. Also,
736        setup/cleanup functions are accumulated between child and parent
737        payload-bearing objects, rather than child setups/cleanups
738        overriding parent setups/cleanups.
739        """
740        update_augmentations(
741            self.augmentations,
742            { "with_setup": { "setup": setup_fn } }
743        )
744        return self

Adds a setup function for this payload, which will be run right before the payload starts. Returns self for chaining.

See potluck.harness.with_setup for details.

Note: HasPayload.do_setup and HasPayload.do_cleanup each accumulate setup/cleanup functions instead of replacing the previous setup/cleanup function, with setup functions added later affecting the results of setup functions added earlier. Also, setup/cleanup functions are accumulated between child and parent payload-bearing objects, rather than child setups/cleanups overriding parent setups/cleanups.

def do_cleanup(self, cleanup_fn):
746    def do_cleanup(self, cleanup_fn):
747        """
748        Adds a cleanup function for this test, which will be run right
749        after the payload is finished. Returns self for chaining.
750
751        See `potluck.harness.with_cleanup` for details.
752        """
753        update_augmentations(
754            self.augmentations,
755            { "with_cleanup": { "cleanup": cleanup_fn } }
756        )
757        return self

Adds a cleanup function for this test, which will be run right after the payload is finished. Returns self for chaining.

See potluck.harness.with_cleanup for details.

def capture_output(self, capture_errors=False, capture_stderr=False):
759    def capture_output(self, capture_errors=False, capture_stderr=False):
760        """
761        Sets up output capturing, so that everything that gets printed
762        will be captured in the "output" slot. If `capture_errors` is
763        set to True, errors will be captured as part of the output
764        instead of actually breaking the test. If `capture_stderr` is set
765        to True, messages written to stderr will be captured into an
766        additional "error_log" slot. Returns self for chaining.
767        """
768        self.augmentations["capturing_printed_output"] = {
769            "capture_errors": capture_errors,
770            "capture_stderr": capture_stderr
771        }
772        return self

Sets up output capturing, so that everything that gets printed will be captured in the "output" slot. If capture_errors is set to True, errors will be captured as part of the output instead of actually breaking the test. If capture_stderr is set to True, messages written to stderr will be captured into an additional "error_log" slot. Returns self for chaining.

def provide_inputs(self, strings, policy='hold'):
774    def provide_inputs(self, strings, policy="hold"):
775        """
776        Set up a series of strings to use as the results of any `input()`
777        calls made during the payload execution. For details, Refer to
778        `potluck.harness.with_fake_input`.
779
780        Returns self for chaining.
781        """
782        self.augmentations["with_fake_input"] = {
783            "inputs": strings,
784            "extra_policy": policy
785        }
786        return self

Set up a series of strings to use as the results of any input() calls made during the payload execution. For details, Refer to potluck.harness.with_fake_input.

Returns self for chaining.

def use_decorations(self, decorations, ignore_missing=False):
788    def use_decorations(self, decorations, ignore_missing=False):
789        """
790        Sets the decorations dictionary for this payload, which maps
791        function (or variable) names to decorator functions that will be
792        temporarily applied to those values during testing. This has a
793        lot of potential uses, like disabling a function ('decorate' it
794        to return a new do-nothing function), performing some operation
795        on the result of the test function before comparison happens (by
796        decorating the function being tested), or even providing a new
797        variable that's not defined by default (but that can often be
798        accomplished via other means).
799
800        If `ignore_missing` is set to True instead of the default
801        (False), then if a decoration is supposed to apply to a value
802        which doesn't exist, instead of an error being raised, the
803        decoration function will be called with `potluck.harness.Missing`
804        as the input value.
805
806        This function returns self so that you can chain it with other
807        modification functions.
808
809        Any old decorations dictionary will be overridden.
810
811        Note that decorations cannot be applied to payloads based on
812        module imports, as the decorations are applied to a loaded
813        module, and module import payloads re-load the target module with
814        each test. `HasPayload.prepare_source` and
815        `HasPayload.wrap_module` are the closest equivalents for module
816        import payloads.
817        """
818        self.augmentations["with_module_decorations"] = {
819            "decorations": decorations,
820            "ignore_missing": ignore_missing
821        }
822        return self

Sets the decorations dictionary for this payload, which maps function (or variable) names to decorator functions that will be temporarily applied to those values during testing. This has a lot of potential uses, like disabling a function ('decorate' it to return a new do-nothing function), performing some operation on the result of the test function before comparison happens (by decorating the function being tested), or even providing a new variable that's not defined by default (but that can often be accomplished via other means).

If ignore_missing is set to True instead of the default (False), then if a decoration is supposed to apply to a value which doesn't exist, instead of an error being raised, the decoration function will be called with potluck.harness.Missing as the input value.

This function returns self so that you can chain it with other modification functions.

Any old decorations dictionary will be overridden.

Note that decorations cannot be applied to payloads based on module imports, as the decorations are applied to a loaded module, and module import payloads re-load the target module with each test. HasPayload.prepare_source and HasPayload.wrap_module are the closest equivalents for module import payloads.

def capture_trace(self, trace_targets, state_function):
824    def capture_trace(self, trace_targets, state_function):
825        """
826        Causes the payload to produce a "trace" context slot in
827        addition to other slots, which holds a trace of function calls
828        to functions named in the provided `trace_targets` sequence. See
829        `potluck.harness.tracing_function_calls` for details, including
830        the functionality of the state function.
831
832        The `trace_targets` list may include tuples, in which case calls
833        to any of the functions in the tuple will be traced as if they
834        were calls to the first function (useful for collapsing aliases
835        like turtle.fd and turtle.forward).
836
837        This function returns self so that you can chain it with other
838        modification functions.
839
840        Any old tracing setup will be overridden.
841        """
842        self.augmentations["tracing_function_calls"] = {
843            "trace_targets": trace_targets,
844            "state_function": state_function
845        }
846        return self

Causes the payload to produce a "trace" context slot in addition to other slots, which holds a trace of function calls to functions named in the provided trace_targets sequence. See potluck.harness.tracing_function_calls for details, including the functionality of the state function.

The trace_targets list may include tuples, in which case calls to any of the functions in the tuple will be traced as if they were calls to the first function (useful for collapsing aliases like turtle.fd and turtle.forward).

This function returns self so that you can chain it with other modification functions.

Any old tracing setup will be overridden.

def sample_result_distribution( self, slot_map={'value': 'distribution', 'ref_value': 'ref_distribution'}, trials=50000):
848    def sample_result_distribution(
849        self,
850        slot_map={
851            "value": "distribution",
852            "ref_value": "ref_distribution"
853        },
854        trials=50000
855    ):
856        """
857        Modifies the payload so that it runs many times and the
858        distribution of results is recorded. For details, see
859        `potluck.harness.sampling_distribution_of_results`.
860
861        Returns self for chaining.
862
863        Note that this is useful only in very specific cases, is
864        often quite slow, and even in cases where it might be
865        applicable, needs to be used with a very careful comparator.
866
867        In particular, if you're sampling the distribution of results
868        from a random function and comparing them to a reference
869        distribution, even with a lot of trials, the chances that the
870        two distributions diverge significantly just by bad luck are
871        often unfortunately high. If you're evaluating hundreds of
872        submissions per task and dozens of tasks per course and want to
873        scrupulously avoid the chance of an erroneous test result,
874        consider other methods of testing random functions, such as
875        seed-based testing or other de-randomization techniques.
876        """
877        self.augmentations["sampling_distribution_of_results"] = {
878            "slot_map": slot_map,
879            "trials": trials
880        }
881        return self

Modifies the payload so that it runs many times and the distribution of results is recorded. For details, see potluck.harness.sampling_distribution_of_results.

Returns self for chaining.

Note that this is useful only in very specific cases, is often quite slow, and even in cases where it might be applicable, needs to be used with a very careful comparator.

In particular, if you're sampling the distribution of results from a random function and comparing them to a reference distribution, even with a lot of trials, the chances that the two distributions diverge significantly just by bad luck are often unfortunately high. If you're evaluating hundreds of submissions per task and dozens of tasks per course and want to scrupulously avoid the chance of an erroneous test result, consider other methods of testing random functions, such as seed-based testing or other de-randomization techniques.

def capture_turtle_image(self, alt_text=None, skip_reset=False):
883    def capture_turtle_image(self, alt_text=None, skip_reset=False):
884        """
885        Captures what's drawn on the turtle canvas, into an "image"
886        context slot. See `potluck.harness.capturing_turtle_drawings`,
887        which explains the alt_text and skip_reset arguments.
888
889        Returns self for chaining.
890        """
891        self.augmentations["capturing_turtle_drawings"] = {
892            "alt_text": alt_text,
893            "skip_reset": skip_reset,
894        }
895        return self

Captures what's drawn on the turtle canvas, into an "image" context slot. See potluck.harness.capturing_turtle_drawings, which explains the alt_text and skip_reset arguments.

Returns self for chaining.

def capture_wavesynth(self, just_capture=None, label='resulting_audio'):
897    def capture_wavesynth(
898        self,
899        just_capture=None,
900        label="resulting_audio"
901    ):
902        """
903        Captures the current track in `wavesynth` as a list of note
904        description strings, in a "notes" context slot, and as raw audio,
905        in the "audio" slot. See
906        `potluck.harness.capturing_wavesynth_audio`.
907
908        You can set either just_capture to "notes" or "audio" to capture
909        just one or the other, or leave it at None (the default) to
910        capture both.
911
912        A custom label may be provided for any resulting audio elements.
913
914        Returns self for chaining.
915        """
916        self.augmentations["capturing_wavesynth_audio"] = {
917            "just_capture": just_capture,
918            "label": label
919        }
920        return self

Captures the current track in wavesynth as a list of note description strings, in a "notes" context slot, and as raw audio, in the "audio" slot. See potluck.harness.capturing_wavesynth_audio.

You can set either just_capture to "notes" or "audio" to capture just one or the other, or leave it at None (the default) to capture both.

A custom label may be provided for any resulting audio elements.

Returns self for chaining.

def capture_file_contents(self, filename, binary=False):
922    def capture_file_contents(self, filename, binary=False):
923        """
924        Captures the contents of a specific file after the code has
925        finished running. Stores the file name in the "output_filename"
926        slot and the file contents as a string in the
927        "output_file_contents" slot.
928
929        If `binary` is set to True instead of the default False, the file
930        will be read as a bytes object instead of a string.
931
932        Returns self for chaining.
933        """
934        self.augmentations["capturing_file_contents"] = {
935            "filename": filename,
936            "binary": binary
937        }
938
939        return self

Captures the contents of a specific file after the code has finished running. Stores the file name in the "output_filename" slot and the file contents as a string in the "output_file_contents" slot.

If binary is set to True instead of the default False, the file will be read as a bytes object instead of a string.

Returns self for chaining.

class HasContext:
 942class HasContext:
 943    """
 944    Abstract base class for tests which will create
 945    `potluck.contexts.Context` objects. Provides common tools for
 946    managing context creation.
 947    """
 948    def __init__(self, default_context_args=None):
 949        """
 950        Default context args may be provided.
 951        """
 952        self.default_context_args = default_context_args or {}
 953        self.context_args = {}
 954
 955    def synthesize_context_info(self, group=None):
 956        """
 957        Synthesizes context construction arguments. If a group is
 958        given, it should also be a `HasContext` instance, and its
 959        information will be mixed with local information as follows:
 960
 961          - First, group defaults will be loaded.
 962          - Next, this object's defaults will override those.
 963          - Third, group explicit values will be added.
 964          - Finally, local explicit values will have the final say.
 965        """
 966        args = {}
 967        if group:
 968            args.update(group.default_context_args)
 969        args.update(self.default_context_args)
 970        if group:
 971            args.update(group.context_args)
 972        args.update(self.context_args)
 973
 974        return args
 975
 976    def create_context(
 977        self,
 978        builder,
 979        group=None
 980    ):
 981        """
 982        Creates the implied `potluck.contexts.Context` object. Needs to
 983        be given the context-builder function that the context will use.
 984        If a group is provided, its information is merged with local
 985        information using `synthesize_context_info`.
 986        """
 987        # Synthesize arguments
 988        args = self.synthesize_context_info(group)
 989
 990        if "builder" in args:
 991            logging.debug_msg(
 992                "Warning: overriding existing builder value in"
 993                " create_context."
 994            )
 995
 996        args["builder"] = builder
 997
 998        # Append our payload's description to our description if we have
 999        # a payload description
1000        if hasattr(self, "describe_payload"):
1001            obf_topic, obf_details = self.describe_payload(group, True)
1002            clear_topic, clear_details = self.describe_payload(group, False)
1003            defaults = (
1004                obf_topic,
1005                obf_details,
1006                clear_topic,
1007                clear_details
1008            )
1009
1010            if "description" not in args: # default
1011                args["description"] = defaults
1012
1013            # If we've only got a topic (shouldn't happen), double it
1014            if len(args["description"]) < 4:
1015                args["description"] = (
1016                    tuple(args["description"])
1017                  + defaults[len(args):]
1018                )
1019
1020        # Create and return our context object
1021        return contexts.Context(**args)
1022
1023    def set_context_description(self, description):
1024        """
1025        Sets a custom description for the context. Returns self for
1026        chaining. A description is a 2-tuple or 4-tuple of strings. If
1027        it's a 2-tuple, it specifies the title for the rubric entry and
1028        then the longer description. If it's a 4-tuple, the first two
1029        elements are the title and description used in blank rubrics,
1030        while the second two are used in displaying actual evaluation
1031        results.
1032        """
1033        self.context_args["description"] = description
1034        return self
1035
1036    def set_context_displayer(self, displayer):
1037        """
1038        Sets a custom context product display function. Returns self for
1039        chaining.
1040
1041        The function will be handed the context dictionary that was used
1042        for testing, and should return an HTML string which gets
1043        displayed in the "Test Results" section of the feedback.
1044        """
1045        self.context_args["display_product"] = displayer
1046        return self
1047
1048    def describe_module_slot(self):
1049        """
1050        Sets up the context to describe itself as producing a module from
1051        the submitted file. Doesn't actually change how the context
1052        works; it's assumed that the context already produces a "module"
1053        value via a module import payload.
1054        """
1055        # Modify context arguments
1056        self.context_args["display_product"] = lambda context: (
1057            "&lt;the result of running your file&gt;"
1058        )
1059
1060        return self

Abstract base class for tests which will create potluck.contexts.Context objects. Provides common tools for managing context creation.

HasContext(default_context_args=None)
948    def __init__(self, default_context_args=None):
949        """
950        Default context args may be provided.
951        """
952        self.default_context_args = default_context_args or {}
953        self.context_args = {}

Default context args may be provided.

def synthesize_context_info(self, group=None):
955    def synthesize_context_info(self, group=None):
956        """
957        Synthesizes context construction arguments. If a group is
958        given, it should also be a `HasContext` instance, and its
959        information will be mixed with local information as follows:
960
961          - First, group defaults will be loaded.
962          - Next, this object's defaults will override those.
963          - Third, group explicit values will be added.
964          - Finally, local explicit values will have the final say.
965        """
966        args = {}
967        if group:
968            args.update(group.default_context_args)
969        args.update(self.default_context_args)
970        if group:
971            args.update(group.context_args)
972        args.update(self.context_args)
973
974        return args

Synthesizes context construction arguments. If a group is given, it should also be a HasContext instance, and its information will be mixed with local information as follows:

  • First, group defaults will be loaded.
  • Next, this object's defaults will override those.
  • Third, group explicit values will be added.
  • Finally, local explicit values will have the final say.
def create_context(self, builder, group=None):
 976    def create_context(
 977        self,
 978        builder,
 979        group=None
 980    ):
 981        """
 982        Creates the implied `potluck.contexts.Context` object. Needs to
 983        be given the context-builder function that the context will use.
 984        If a group is provided, its information is merged with local
 985        information using `synthesize_context_info`.
 986        """
 987        # Synthesize arguments
 988        args = self.synthesize_context_info(group)
 989
 990        if "builder" in args:
 991            logging.debug_msg(
 992                "Warning: overriding existing builder value in"
 993                " create_context."
 994            )
 995
 996        args["builder"] = builder
 997
 998        # Append our payload's description to our description if we have
 999        # a payload description
1000        if hasattr(self, "describe_payload"):
1001            obf_topic, obf_details = self.describe_payload(group, True)
1002            clear_topic, clear_details = self.describe_payload(group, False)
1003            defaults = (
1004                obf_topic,
1005                obf_details,
1006                clear_topic,
1007                clear_details
1008            )
1009
1010            if "description" not in args: # default
1011                args["description"] = defaults
1012
1013            # If we've only got a topic (shouldn't happen), double it
1014            if len(args["description"]) < 4:
1015                args["description"] = (
1016                    tuple(args["description"])
1017                  + defaults[len(args):]
1018                )
1019
1020        # Create and return our context object
1021        return contexts.Context(**args)

Creates the implied potluck.contexts.Context object. Needs to be given the context-builder function that the context will use. If a group is provided, its information is merged with local information using synthesize_context_info.

def set_context_description(self, description):
1023    def set_context_description(self, description):
1024        """
1025        Sets a custom description for the context. Returns self for
1026        chaining. A description is a 2-tuple or 4-tuple of strings. If
1027        it's a 2-tuple, it specifies the title for the rubric entry and
1028        then the longer description. If it's a 4-tuple, the first two
1029        elements are the title and description used in blank rubrics,
1030        while the second two are used in displaying actual evaluation
1031        results.
1032        """
1033        self.context_args["description"] = description
1034        return self

Sets a custom description for the context. Returns self for chaining. A description is a 2-tuple or 4-tuple of strings. If it's a 2-tuple, it specifies the title for the rubric entry and then the longer description. If it's a 4-tuple, the first two elements are the title and description used in blank rubrics, while the second two are used in displaying actual evaluation results.

def set_context_displayer(self, displayer):
1036    def set_context_displayer(self, displayer):
1037        """
1038        Sets a custom context product display function. Returns self for
1039        chaining.
1040
1041        The function will be handed the context dictionary that was used
1042        for testing, and should return an HTML string which gets
1043        displayed in the "Test Results" section of the feedback.
1044        """
1045        self.context_args["display_product"] = displayer
1046        return self

Sets a custom context product display function. Returns self for chaining.

The function will be handed the context dictionary that was used for testing, and should return an HTML string which gets displayed in the "Test Results" section of the feedback.

def describe_module_slot(self):
1048    def describe_module_slot(self):
1049        """
1050        Sets up the context to describe itself as producing a module from
1051        the submitted file. Doesn't actually change how the context
1052        works; it's assumed that the context already produces a "module"
1053        value via a module import payload.
1054        """
1055        # Modify context arguments
1056        self.context_args["display_product"] = lambda context: (
1057            "&lt;the result of running your file&gt;"
1058        )
1059
1060        return self

Sets up the context to describe itself as producing a module from the submitted file. Doesn't actually change how the context works; it's assumed that the context already produces a "module" value via a module import payload.

class HasGoal:
1063class HasGoal:
1064    """
1065    Abstract base class for tests which will create
1066    `potluck.rubrics.Goal` objects. Provides common tools for managing
1067    goal creation. Subclasses must override the `create_goal` method with
1068    a zero-argument method that returns a goal object.
1069    """
1070    def create_goal(self):
1071        """
1072        Returns the `potluck.rubrics.Goal` implied by this object. Must
1073        be implemented in concrete subclasses.
1074        """
1075        raise NotImplementedError(
1076            "HasGoal is an abstract class and cannot be used directly."
1077        )
1078
1079    def __init__(
1080        self,
1081        taskid,
1082        goal_constructor,
1083        default_goal_args=None,
1084    ):
1085        """
1086        A task ID string and a goal constructor must be specified.
1087        Default goal args may be provided. Set the goal category and/or
1088        type via goal args.
1089        """
1090        self.taskid = taskid
1091        self.default_goal_args = default_goal_args or {}
1092        self.goal_args = {}
1093        self.goal_constructor = goal_constructor
1094        self._cached_goal = None
1095
1096    def provide_goal(self):
1097        """
1098        Returns the result of `create_goal`, but if `provide_goal` has
1099        been called previously, returns the cached result of that
1100        previous call instead.
1101        """
1102        if self._cached_goal is None:
1103            self._cached_goal = self.create_goal()
1104        return self._cached_goal
1105
1106    def synthesize_goal_info(self, group=None):
1107        """
1108        Synthesizes goal construction arguments. If a group is given,
1109        it should also be a `HasGoal` instance, and its information will
1110        be mixed with local information as follows:
1111
1112          - First, group defaults will be loaded.
1113          - Next, this object's defaults will override those.
1114          - Third, group explicit values will be added.
1115          - Finally, local explicit values will have the final say.
1116
1117        A "goal_type" tag will be deduced from the context_slot goal
1118        argument if it hasn't been set explicitly.
1119
1120        Note that the "tags" argument is itself a dictionary, and values
1121        will be synthesized according to the same procedure as for the
1122        whole dictionary.
1123        """
1124        args = {}
1125        tags = {}
1126        if group:
1127            args.update(group.default_goal_args)
1128            tags = args.get("tags", {})
1129        args.update(self.default_goal_args)
1130        tags.update(args.get("tags", {}))
1131        if group:
1132            args.update(group.goal_args)
1133            tags.update(args.get("tags", {}))
1134        args.update(self.goal_args)
1135        tags.update(args.get("tags", {}))
1136        args["tags"] = tags
1137
1138        # Deduce goal type if it hasn't been specified explicitly
1139        if "goal_type" not in args["tags"]: # no explicit goal type
1140            if "context_slot" in args: # deduce from context slot
1141                args["tags"]["goal_type"] = CONTEXT_SLOT_IMPLIED_TYPES.get(
1142                    args["context_slot"],
1143                    "other"
1144                )
1145            else: # no slot to deduce from; guess "other"
1146                args["tags"]["goal_type"] = "other"
1147
1148        return args
1149
1150    def create_goal_from_contexts(self, contexts, group=None):
1151        """
1152        Creates the implied `potluck.rubrics.Goal` object. Needs to be
1153        provided with a list of contexts in which the goal should be
1154        evaluated. A group may be provided for info merging via
1155        `synthesize_goal_info`.
1156        """
1157        args = self.synthesize_goal_info(group)
1158
1159        # Set testing contexts for this goal
1160        if "test_in" not in args:
1161            args["test_in"] = {}
1162
1163        if "contexts" in args["test_in"]:
1164            logging.debug_msg(
1165                "Warning: overriding existing test_in/contexts value in"
1166                " create_goal_from_contexts."
1167            )
1168
1169        args["test_in"]["contexts"] = contexts
1170
1171        args["taskid"] = self.taskid
1172
1173        # Create and return our goal object
1174        return self.goal_constructor(**args)
1175
1176    def ensure_goal_constructor_arg(self, desired):
1177        """
1178        Raises a TypeError unless this object's goal constructor
1179        accepts an argument of the desired name.
1180        """
1181        cobj = self.goal_constructor.__init__.__code__
1182        arg_names = cobj.co_varnames[:cobj.co_argcount]
1183        if desired not in arg_names:
1184            raise TypeError(
1185                f"This operation is only allowed for HasGoal classes"
1186                f" associated with goal types that accept a '{desired}'"
1187                f" argument ({self.goal_constructor.__name__} does not)."
1188            )
1189
1190    def goal(self, category="core"):
1191        """
1192        Registers this goal-provider as part of the rubric, under the
1193        given category (default is "core"). Returns self for chaining.
1194        """
1195        if "tags" not in self.goal_args:
1196            self.goal_args["tags"] = {}
1197
1198        self.goal_args["tags"]["category"] = category
1199        register_goal_provider(self)
1200
1201        return self
1202
1203    def validate(self, category="core"):
1204        """
1205        Registers this goal-provider as part of the rubric's
1206        test-validation goals, under the given category (default is
1207        "core"). Returns self for chaining.
1208        """
1209        if "tags" not in self.goal_args:
1210            self.goal_args["tags"] = {}
1211
1212        self.goal_args["tags"]["category"] = category
1213        register_validation_goal_provider(self)
1214
1215        return self
1216
1217    def set_identifier(self, identifier):
1218        """
1219        Sets the identifier that will be used for the goal produced.
1220        Returns self for chaining.
1221        """
1222        self.goal_args["identifier"] = identifier
1223        return self
1224
1225    def set_goal_type(self, goal_type):
1226        """
1227        Sets an explicit goal type for this goal. Normally, goal types
1228        can be deduced with reasonable accuracy from existing
1229        information, but if that isn't working, use this method to
1230        explicitly declare a goal type. Returns self for chaining.
1231        """
1232        if "tags" not in self.goal_args:
1233            self.goal_args["tags"] = {}
1234        self.goal_args["tags"]["goal_type"] = goal_type
1235        return self
1236
1237    def set_goal_description(self, description):
1238        """
1239        Sets a custom description for the goal. Returns self for
1240        chaining. A description is a 2-tuple or 4-tuple of strings. If
1241        it's a 2-tuple, it specifies the title for the rubric entry and
1242        then the longer description. If it's a 4-tuple, the first two
1243        elements are the title and description used in blank rubrics,
1244        while the second two are used in displaying actual evaluation
1245        results.
1246        """
1247        self.goal_args["description"] = description
1248        return self
1249
1250    def test_module(self, module_comparator):
1251        """
1252        Changes the implied goal so that instead of comparing the printed
1253        output between runs of the submitted and solution modules, it
1254        compares the module objects that result from those runs, using
1255        the given comparator function. Only applicable to goals derived
1256        from payloads that run modules.
1257
1258        The comparator function will be given two module objects and will
1259        be expected to return an evaluation result, which is a dictionary
1260        with "status" and "explanation" keys (see `potluck.compare`).
1261
1262        Note that one should almost always use `set_goal_description`
1263        along with this function to describe what the given comparison
1264        function actually does.
1265
1266        This function returns self for chaining.
1267        """
1268        if (
1269            not hasattr(self, "ignore_output")
1270         or not hasattr(self, "describe_module_slot")
1271        ):
1272            raise TypeError(
1273                f"Module-based testing is only applicable for"
1274                f" Goal-generators which have ignore_output and"
1275                f" describe_module_slot methods ({self} does not)."
1276            )
1277
1278        # Set up our payload to ignore output (since we're using the
1279        # module object instead)
1280        self.ignore_output()
1281
1282        # Set up our context to focus on the module slot
1283        self.describe_module_slot()
1284
1285        # Modify goal arguments
1286        self.goal_args["context_slot"] = "module"
1287        self.goal_args["tags"]["goal_type"] = "product"
1288
1289        # Modify default (not explicit) description
1290        self.default_goal_args["description"] = (
1291            "Running your file must define the right values",
1292            (
1293                "Your file must define the same variables and functions"
1294                " as the solution file when run."
1295            )
1296        )
1297
1298        return self
1299
1300    def test_output(self, capture_errors=False):
1301        """
1302        Causes this test to compare printed output instead of result
1303        values. Automatically calls `self.capture_output` (which will
1304        normally be `HasPayload.capture_output`) to set that up, passing
1305        `capture_errors` on to that function if it exists.
1306
1307        Returns self for chaining.
1308        """
1309        if hasattr(self, "capture_output"):
1310            self.capture_output(capture_errors)
1311
1312        self.goal_args["context_slot"] = "output"
1313
1314        # Set default (not explicit) description
1315        if hasattr(self, "base_name"):
1316            if self.base_name == "import":
1317                self.default_goal_args["description"] = (
1318                    "Your program must print the correct output",
1319                    (
1320                        "The output printed when your program is"
1321                        " run must match the solution output."
1322                    )
1323                )
1324            else:
1325                self.default_goal_args["description"] = (
1326                    (
1327                        f"<code>{self.base_name}</code> must print the"
1328                        f" correct output"
1329                    ),
1330                    (
1331                        f"The output printed when your"
1332                        f" <code>{self.base_name}</code> function is run"
1333                        f" must match the solution output."
1334                    )
1335                )
1336        else:
1337            self.default_goal_args["description"] = (
1338                "Your code must print the correct output",
1339                (
1340                    "The output printed when your code is run must"
1341                    " match the solution output."
1342                )
1343            )
1344
1345        self.context_args["display_product"] = (
1346            contexts.build_context_value_displayer(
1347                "output",
1348                labels=[
1349                    "Your output",
1350                    "Solution output",
1351                    "Comparison"
1352                ]
1353            )
1354        )
1355
1356        return self
1357
1358    def test_with_harness(self, harness_fn):
1359        """
1360        Causes this test to compare test-harness results instead of
1361        direct function call results. Calls `self.use_harness` (which
1362        must be available, normally from `HasPayload.use_harness`).
1363        See `HasPayload.use_harness` for details on how the harness
1364        function will be applied.
1365
1366        Only applicable to a Goal which is based on testing a function
1367        call.
1368
1369        Note: If you want to test the printed output of a test harness
1370        rather than its return value, you can call both
1371        `test_with_harness` and `test_output`, but you should call
1372        `test_with_harness` second to set the description properly.
1373        """
1374        if (
1375            not hasattr(self, "base_name")
1376         or self.base_name == "import"
1377         or not hasattr(self, "use_harness")
1378        ):
1379            raise TypeError(
1380                f"Harness-based testing is only applicable for"
1381                f" Goal-generators which have a base_name attribute"
1382                f" which isn't 'import' and a use_harness method ({self}"
1383                f" does not)."
1384            )
1385
1386        # Set up harness for our payload
1387        self.use_harness(harness_fn)
1388
1389        # Set (default) description from the test harness
1390        self.default_goal_args["description"] = explain.harness_descriptions(
1391            harness_fn,
1392            self.base_name,
1393            '', # multiple TestCases so can't specify arguments
1394            '', # multiple TestCases so can't specify arguments
1395            'behavior' # TODO: less generic here
1396        )
1397
1398        return self
1399
1400    def test_trace(self, trace_targets, state_function):
1401        """
1402        Sets up this test to test a trace result instead of just what
1403        the function returns. Uses the `capture_trace` method to set up
1404        tracing, which must be available.
1405
1406        You can use `check_trace_state` and/or `check_invariant`
1407        afterwards to alter how the trace will be compared with the
1408        solution trace.
1409        """
1410        if (
1411            not hasattr(self, "base_name")
1412         or self.base_name == "import"
1413         or not hasattr(self, "capture_trace")
1414        ):
1415            raise TypeError(
1416                f"Trace-based testing is only applicable for"
1417                f" Goal-generators which have a capture_trace method"
1418                f" and a base_name attribute that's not 'import' ({self}"
1419                f" does not)."
1420            )
1421
1422        # Set up tracing
1423        self.capture_trace(trace_targets, state_function)
1424
1425        # Target the "trace" context slot
1426        self.goal_args["context_slot"] = "trace"
1427
1428        # Set our goal type to "process"
1429        self.goal_args.setdefault("tags", {})["goal_type"] = "process"
1430
1431        # Set default (not explicit) description
1432        self.default_goal_args["description"] = (
1433            f"<code>{self.base_name}</code> must use the correct process",
1434            (
1435                f"The pattern of functions called when your"
1436                f" <code>{self.base_name}</code> function is run must"
1437                f" match the solution process."
1438            )
1439        )
1440
1441        return self
1442
1443    def check_trace_state(
1444        self,
1445        state_slots,
1446        check_args=None,
1447        check_results=False,
1448        pre_or_post="pre",
1449        order_matters=False,
1450        only=None,
1451        tolerance="auto"
1452    ):
1453        """
1454        You must call `test_trace` first to set up tracing. This function
1455        changes the comparison function so that instead of comparing
1456        entire traces directly, we identify each function call using the
1457        function name, the values of specific state slots, and the
1458        parameters used (if `check_args` is non-empty) and/or results
1459        (if `check_results` is True). After identifying each function
1460        call in this way, we create a list of those calls in linear
1461        sequence, and compare those lists between the submitted and
1462        solution traces (only caring about order if order_matters is set
1463        to True).
1464
1465        The `state_slots` argument is required, it should be a list of
1466        strings and indicates which slots in the state dictionary we
1467        care about. It may be empty, in which case no state entries will
1468        be included in the call IDs.
1469
1470        If `check_args` is set it should be None or a list of strings
1471        and/or integers; named (and/or indexed) parameters will be
1472        included as identifying information for trace entries). It may
1473        also be True to check all arguments.
1474
1475        If `check_results` is set, the return value of each call will be
1476        included in its identifying information.
1477
1478        `pre_or_post` determines whether states before function calls or
1479        just before their returns are used. Set it to the string 'pre',
1480        the string 'post', or the string 'both' to include both states.
1481        The default is 'pre'.
1482
1483        `order_matters` can be set to True if you want to enforce
1484        matching of traces in-order, rather than allowing an equivalent
1485        set of function calls in any order to count as matched. Your
1486        specs will be more flexible if you can check correctness based
1487        on state-at-time-of-call rather than order-of-call so the
1488        default for this is False.
1489
1490        `only` should be a list of function names, or None. If not None,
1491        then only functions names in this list will be included in the
1492        check performed.
1493
1494        `tolerance` will be passed to `make_structure_comparator`; see
1495        that function for details; "auto" is the default.
1496
1497        Returns self for chaining.
1498
1499        For example, this kind of comparison could be used to ensure
1500        that a solution calls certain drawing commands with the right
1501        parameters from the right set of states, without regard to
1502        order, which is one way to specify that it "draws the right
1503        thing" even if we don't care what order things are drawn in.
1504        """
1505        # Check that tracing is set up
1506        if self.goal_args["context_slot"] != "trace":
1507            raise ValueError(
1508                "You must activate tracing using `test_trace` before"
1509                " calling `check_trace_state`."
1510            )
1511
1512        # Make sure we've got goal which requires a checker
1513        self.ensure_goal_constructor_arg("checker")
1514
1515        # Create our base comparator
1516        base_comparator = compare.make_structure_comparator(
1517            tolerance=tolerance,
1518            order_matters=order_matters
1519        )
1520        # TODO: Allow rounding differences in state by default, including
1521        # in args!
1522
1523        # Define our full comparator
1524        def compare_trace_states(submitted, solution):
1525            """
1526            A custom comparator function which compares certain parts of
1527            captured trace states.
1528            """
1529            # TODO: Better explanation which turns trace state dicts into
1530            # human-readable explanations of call situations...
1531            processed = []
1532            for trace in submitted, solution:
1533                rendered = []
1534                processed.append(rendered)
1535                for entry in harness.walk_trace(trace):
1536                    if only and entry["fname"] not in only:
1537                        continue
1538                    entry_id = { "fname": entry["fname"] }
1539
1540                    # Grab args if requested
1541                    if check_args:
1542                        if check_args is True:
1543                            entry_id["args"] = copy.copy(entry["args"])
1544                        else:
1545                            indices = [
1546                                i
1547                                for i in check_args
1548                                if isinstance(i, int)
1549                            ]
1550                            names = [
1551                                name
1552                                for name in check_args
1553                                if isinstance(name, str)
1554                            ]
1555
1556                            # Which args are we actually taking?
1557                            take = []
1558                            for i, argname in enumerate(entry["args"]):
1559                                if i in indices or argname in names:
1560                                    take.append(argname)
1561
1562                            entry_id["args"] = {
1563                                argname: entry["args"][argname]
1564                                for argname in take
1565                            }
1566
1567                    # Grab result if we need it
1568                    if check_results:
1569                        entry_id["result"] = entry["result"]
1570
1571                    # Grab pre- and/or post-call state values
1572                    if pre_or_post in ("pre", "both"):
1573                        entry_id["pre_state"] = {
1574                            slot: entry["pre_state"][slot]
1575                            for slot in state_slots
1576                        }
1577                    if pre_or_post in ("post", "both"):
1578                        entry_id["post_state"] = {
1579                            slot: entry["post_state"][slot]
1580                            for slot in state_slots
1581                        }
1582
1583                    rendered.append(entry_id)
1584
1585            # Run our base comparator on the two processed lists
1586            return base_comparator(processed[0], processed[1])
1587
1588        # Set up our comparator as the checker for this goal
1589        self.goal_args["checker"] = compare_trace_states
1590
1591        # Build description pieces
1592        targets = "the correct functions"
1593        if only:
1594            targets = "the " + phrasing.comma_list(
1595                f"<code>{fn}</code>"
1596                for fn in only
1597            ) + " " + phrasing.plural(len(only), "function")
1598
1599        conditions = []
1600        if order_matters:
1601            conditions.append("in the correct order")
1602
1603        if check_args:
1604            conditions.append("with the correct arguments")
1605
1606        if state_slots:
1607            conditions.append(
1608                "while the correct "
1609              + phrasing.comma_list(state_slots)
1610              + " " + phrasing.plural(len(state_slots), "value")
1611              + " " + phrasing.plural(len(state_slots), "is", "are")
1612              + " set up"
1613            )
1614
1615        if check_results:
1616            conditions.append(
1617                "and each call must return the correct result"
1618            )
1619
1620        # Set default (not explicit) description
1621        if hasattr(self, "base_name") and self.base_name != "input":
1622            self.default_goal_args["description"] = (
1623                (
1624                    f"<code>{self.base_name}</code> must make the correct"
1625                    f" function calls"
1626                ),
1627                (
1628                    f"Your <code>{self.base_name}</code> function must"
1629                  + f" call {targets} "
1630                  + ', '.join(conditions)
1631                )
1632            )
1633        else:
1634            self.default_goal_args["description"] = (
1635                "Your code must make the correct function calls",
1636                (
1637                    f"When your code is run it must call {targets} "
1638                  + ', '.join(conditions)
1639                )
1640            )
1641
1642        return self
1643
1644    def check_invariant(
1645        self,
1646        state_slots,
1647        only=None,
1648        partial_tolerance=0.2
1649    ):
1650        """
1651        You must call `test_trace` first to set up tracing. This function
1652        changes the comparison function so that instead of comparing
1653        entire traces directly, we check that specific state slots
1654        specified do not change between the pre- and post- states of
1655        each trace entry.
1656
1657        `only` may be set to None, in which case all entries are checked
1658        (the default), or a list of strings may be provided naming
1659        functions to check (others will be ignored).
1660
1661        `partial_tolerance` should be a fraction which specifies what
1662        percentage of function calls are allowed to violate the
1663        invariant while still returning a partial-success result.
1664
1665        By default for floating-point values there is a baseline level
1666        of tolerance for small changes.
1667
1668        Returns self for chaining.
1669        """
1670        # Check that tracing is set up
1671        if self.goal_args["context_slot"] != "trace":
1672            raise ValueError(
1673                "You must activate tracing using `test_trace` before"
1674                " calling `check_trace_state`."
1675            )
1676
1677        # Make sure we've got goal which requires a checker
1678        self.ensure_goal_constructor_arg("checker")
1679
1680        # Set up base comparator
1681        base_comparator = compare.omni_compare
1682
1683        # Build description of targets
1684        targets = "your functions"
1685        if only:
1686            targets = "the " + phrasing.comma_list(
1687                f"<code>{fn}</code>"
1688                for fn in only
1689            ) + " " + phrasing.plural(len(only), "function")
1690
1691        # Build description of states
1692        states = "the " + phrasing.comma_list(
1693            f"<code>{slot}</code>"
1694            for slot in state_slots
1695        ) + " " + phrasing.plural(len(state_slots), "value")
1696
1697        def check_for_invariants(submitted, *_):
1698            """
1699            Checks the submitted trace to make sure that certain state
1700            values don't change when certain functions are called. Any
1701            provided solution trace is ignored.
1702            """
1703            total = 0
1704            failed = []
1705            for entry in harness.walk_trace(submitted):
1706                # Only inspect targeted functions
1707                if only and entry["fname"] not in only:
1708                    continue
1709
1710                total += 1
1711
1712                # Grab pre/post states
1713                pre = entry["pre_state"]
1714                post = entry["post_state"]
1715
1716                # Compare each slot value between pre and post
1717                different = []
1718                for slot in state_slots:
1719                    same = base_comparator(pre[slot], post[slot])
1720                    if same["status"] != "accomplished":
1721                        different.append((slot, pre[slot], post[slot]))
1722
1723                if different:
1724                    failed.append((entry, different))
1725
1726            # Return an evaluation based on how many calls failed to be
1727            # invariant in terms of the specified state slots
1728            pct_failed = len(failed) / total
1729            if pct_failed == 0:
1730                return {
1731                    "status": "accomplished",
1732                    "explanation": (
1733                        f"all {total} calls to {targets} maintained "
1734                      + phrasing.plural(
1735                          len(state_slots),
1736                          "an invariant", "invariants"
1737                        )
1738                      + f" for {states}"
1739                    )
1740                }
1741            else:
1742                status = "failed"
1743                if pct_failed <= partial_tolerance:
1744                    status = "partial"
1745                return {
1746                    "status": status,
1747                    "explanation": (
1748                        f"out of {total} calls to {targets},"
1749                        f" {len(failed)} failed to maintain "
1750                      + phrasing.plural(
1751                          len(state_slots),
1752                          "an invariant", "invariants"
1753                        )
1754                      + f" for {states}:<br>\n"
1755                      + html_tools.build_list(
1756                            (
1757                                f"<code>{entry['fname']}("
1758                              + ', '.join(
1759                                    "{name}={val}".format(
1760                                        name=name,
1761                                        val=html_tools.dynamic_html_repr(
1762                                            entry['args'][name]
1763                                        )
1764                                    )
1765                                  for name in entry['args']
1766                                )
1767                              + ")</code> changed "
1768                              + html_tools.build_list(
1769                                    (
1770                                        "<code>{slot}</code> from"
1771                                        " <code>{pre}</code>"
1772                                        " to <code>{post}</code>"
1773                                    ).format(
1774                                        slot=slot,
1775                                        pre=html_tools.dynamic_html_repr(
1776                                            pre
1777                                        ),
1778                                        post=html_tools.dynamic_html_repr(
1779                                            post
1780                                        )
1781                                    )
1782                                    for slot, pre, post in different
1783                                )
1784                            )
1785                            for entry, different in failed
1786                        )
1787                    )
1788                }
1789
1790        # Set up our comparator as the checker for this goal
1791        self.goal_args["checker"] = check_for_invariants
1792
1793        # Set default (not explicit) descriptions:
1794        self.default_goal_args["description"] = (
1795            (
1796                f"{targets} must maintain ".capitalize()
1797              + phrasing.plural(
1798                  len(state_slots),
1799                  "an invariant", "invariants"
1800                )
1801              + f" for {states}"
1802            ),
1803            (
1804                f"Each call to {targets} must return {states} to "
1805              + phrasing.plural(len(state_slots), "its", "their")
1806              + " initial state before " + phrasing.plural(
1807                    len(only) if only else 2,
1808                    "it returns.",
1809                    "they return."
1810                )
1811            )
1812        )
1813
1814        # Now we're done; return self for chaining
1815        return self
1816
1817    def check_trace_count(self, target, double_or_half=False):
1818        """
1819        You must call `test_trace` first to set up tracing. This function
1820        changes the comparison function so that instead of comparing
1821        entire traces directly, we look at only function calls in the
1822        trace to functions with the provided target name, and we just
1823        compare how many there are, ignoring the state-at-call-time and
1824        even arguments-supplied information in the trace.
1825
1826        Returns partial success if the number of calls is close to
1827        correct, and if double_or_half is True, also returns partial
1828        success if the number of calls is double or half the correct
1829        value, or within one of double or half.
1830        """
1831        # Check that tracing is set up
1832        if self.goal_args["context_slot"] != "trace":
1833            raise ValueError(
1834                "You must activate tracing using `test_trace` before"
1835                " calling `check_trace_count`."
1836            )
1837
1838        # Make sure we've got goal which requires a checker
1839        self.ensure_goal_constructor_arg("checker")
1840
1841        # Define our full comparator
1842        def compare_trace_counts(submitted, solution):
1843            """
1844            A custom comparator function which compares the count of
1845            calls to a certain function in two traces.
1846            """
1847            counts = []
1848            # Count entries in each trace
1849            for trace in submitted, solution:
1850                count = 0
1851                for entry in harness.walk_trace(trace):
1852                    if entry["fname"] == target:
1853                        count += 1
1854                counts.append(count)
1855
1856            sub_count, soln_count = counts
1857
1858            if sub_count == soln_count:
1859                return {
1860                    "status": "accomplished",
1861                    "explanation": (
1862                        f"Number of function calls to"
1863                        f" <code>{target}</code> was correct"
1864                        f" ({soln_count})"
1865                    )
1866                }
1867            elif sub_count in (
1868                soln_count - 1,
1869                soln_count + 1
1870            ):
1871                return {
1872                    "status": "partial",
1873                    "explanation": (
1874                        f"Number of function calls to"
1875                        f" <code>{target}</code> ({sub_count}) was"
1876                        f" almost correct (should have been"
1877                        f" {soln_count})."
1878                    )
1879                }
1880            elif double_or_half and sub_count in (
1881                soln_count // 2,
1882                soln_count * 2
1883            ):
1884                return {
1885                    "status": "partial",
1886                    "explanation": (
1887                        f"Number of function calls to"
1888                        f" <code>{target}</code> ({sub_count}) was"
1889                        f" double or half of the correct value"
1890                        f" ({soln_count})."
1891                    )
1892                }
1893            elif double_or_half and sub_count in (
1894                soln_count // 2 - 1,
1895                soln_count // 2 + 1,
1896                soln_count * 2 - 1,
1897                soln_count * 2 + 1
1898            ):
1899                return {
1900                    "status": "partial",
1901                    "explanation": (
1902                        f"Number of function calls to"
1903                        f" <code>{target}</code> ({sub_count}) was"
1904                        f" nearly double or half of the correct value"
1905                        f" ({soln_count})."
1906                    )
1907                }
1908            else:
1909                return {
1910                    "status": "failed",
1911                    "explanation": (
1912                        f"Number of function calls to"
1913                        f" <code>{target}</code> ({sub_count}) was"
1914                        f" incorrect (should have been {soln_count})."
1915                    )
1916                }
1917
1918        # Set up our comparator as the checker for this goal
1919        self.goal_args["checker"] = compare_trace_counts
1920
1921        # Set default (not explicit) description
1922        if hasattr(self, "base_name") and self.base_name != "input":
1923            self.default_goal_args["description"] = (
1924                (
1925                    f"<code>{self.base_name}</code> must make the correct"
1926                    f" number of calls to <code>{target}</code>"
1927                ),
1928                (
1929                    f"Your <code>{self.base_name}</code> function must"
1930                    f" call <code>{target}</code> the correct number of"
1931                    f" times."
1932                )
1933            )
1934        else:
1935            self.default_goal_args["description"] = (
1936                (
1937                    f"Your code must make the correct number of function"
1938                    f" calls to <code>{target}</code>"
1939                ),
1940                (
1941                    f"When your code is run it must call"
1942                    f" <code>{target}<code> the correct number of"
1943                    f" times."
1944                )
1945            )
1946
1947        return self
1948
1949    def test_wavesynth_notes(self):
1950        """
1951        Sets up for testing note descriptions from the wavesynth module.
1952        """
1953        if hasattr(self, "capture_wavesynth"):
1954            self.capture_wavesynth(just_capture="notes")
1955
1956        self.goal_args["context_slot"] = "notes"
1957
1958        # Set default (not explicit) description
1959        if hasattr(self, "base_name"):
1960            if self.base_name == "import":
1961                what = "your program"
1962                What = "Your program"
1963                verb = "run"
1964            else:
1965                what = f"<code>{self.base_name}</code>"
1966                What = what
1967                verb = "called"
1968
1969            self.default_goal_args["description"] = (
1970                f"{What} must produce the correct note sequence",
1971                (
1972                    f"The notes added to the current track when {what}"
1973                    f" is {verb} must match the solution notes"
1974                    f" in terms of timing, instruments, pitches, and"
1975                    f" volumes."
1976                ),
1977                (
1978                    f"{What} produces the correct note"
1979                    " sequence"
1980                ),
1981                (
1982                    "We checked that the notes {what} adds to the"
1983                    " current track match those added by the solution."
1984                )
1985            )
1986
1987        else:
1988            self.default_goal_args["description"] = (
1989                "Your code must produce the correct note sequence",
1990                (
1991                    "The sequence of notes added to the current track"
1992                    " when your code is run must match the solution"
1993                    " notes."
1994                )
1995            )
1996
1997        self.context_args["display_product"] = (
1998            contexts.build_context_value_displayer(
1999                "notes",
2000                labels=[
2001                    "Your notes",
2002                    "Solution notes",
2003                    "Comparison"
2004                ]
2005            )
2006        )
2007
2008        return self
2009
2010    def test_wavesynth_audio(self):
2011        """
2012        Sets up for testing raw audio from the wavesynth module.
2013        """
2014        if hasattr(self, "capture_wavesynth"):
2015            self.capture_wavesynth(just_capture="audio")
2016
2017        self.goal_args["context_slot"] = "audio"
2018
2019        # Set default (not explicit) description
2020        if hasattr(self, "base_name"):
2021            if self.base_name == "import":
2022                what = "your program"
2023                verb = "run"
2024            else:
2025                what = f"<code>{self.base_name}</code>"
2026                verb = "called"
2027            self.default_goal_args["description"] = (
2028                f"{what.capitalize()} must produce the correct audio",
2029                (
2030                    f"The audio produced by calling"
2031                    f" <code>playTrack</code> after {what} is {verb}"
2032                    f" must match the solution audio."
2033                )
2034            )
2035        else:
2036            self.default_goal_args["description"] = (
2037                "Your code must produce the correct audio",
2038                (
2039                    "The audio produced by calling"
2040                    " <code>playTrack</code> after your code is run"
2041                    " must match the solution audio."
2042                )
2043            )
2044
2045        # TODO: Use snippet machinery!
2046        self.context_args["display_product"] = (
2047            contexts.build_context_value_displayer(
2048                "audio",
2049                labels=[
2050                    "Your audio",
2051                    "Solution audio",
2052                    "Comparison"
2053                ]
2054            )
2055        )
2056
2057        return self
2058
2059    def test_turtle_image(
2060        self,
2061        allowed_differences=0.03,
2062        partial_allowed=0.5,
2063        similarity_threshold=15
2064    ):
2065        """
2066        Sets up for testing the image drawn using turtle graphics. The
2067        arguments are passed on to `compare.make_image_comparator` to
2068        determine the strictness of the comparison. The defaults are
2069        fairly liberal, especially if what is being drawn does not take
2070        up a large area of the image.
2071
2072        TODO: Background subtraction!
2073        """
2074        # Capture turtle image (if we can)
2075        if hasattr(self, "capture_turtle_image"):
2076            self.capture_turtle_image()
2077
2078        # Set up image comparator
2079        self.compare_using(
2080            compare.make_image_comparator(
2081                allowed_differences,
2082                partial_allowed,
2083                similarity_threshold
2084            )
2085        )
2086
2087        # Set context slot to compare
2088        self.goal_args["context_slot"] = "image"
2089
2090        # Set default (not explicit) description
2091        if hasattr(self, "base_name"):
2092            if self.base_name == "import":
2093                What = "Your program"
2094                what = "your program"
2095                verb = "run"
2096            else:
2097                What = f"<code>{self.base_name}</code>"
2098                what = What
2099                verb = "called"
2100            self.default_goal_args["description"] = (
2101                f"{What} must draw the correct image",
2102                (
2103                    f"The image drawn in the turtle window after {what}"
2104                    f" is {verb} must match the solution image."
2105                )
2106            )
2107        else:
2108            self.default_goal_args["description"] = (
2109                "Your code must draw the correct image",
2110                (
2111                    "The image drawn in the turtle window after your"
2112                    " code is run must match the solution image."
2113                )
2114            )
2115
2116        # TODO: Use snippet machinery?
2117        # Set context value displayer
2118        self.context_args["display_product"] = (
2119            contexts.create_image_result_displayer()
2120        )
2121
2122        return self
2123
2124    def test_file_contents(self, filename=None, binary=False):
2125        """
2126        Causes this test to compare the contents of the specified file
2127        instead of result values. Automatically calls
2128        `self.capture_file_contents` (which will normally be
2129        `HasPayload.capture_file_contents`) to set that up. The `binary`
2130        argument will be passed through to that function, and indicates
2131        that file contents should be read as bytes, not as a string.
2132
2133        Note that if you are using a `TestGroup` that includes individual
2134        `SingleTest` objects which write to multiple different filenames,
2135        leave the filename argument out and
2136        `HasPayload.capture_file_contents` will not be called; you will
2137        have to call it yourself on individual `SingleTest` items.
2138
2139        Returns self for chaining.
2140        """
2141        if hasattr(self, "capture_file_contents") and filename is not None:
2142            self.capture_file_contents(filename, binary)
2143
2144        self.goal_args["context_slot"] = "output_file_contents"
2145
2146        # Set default (not explicit) description
2147        file_desc = "the appropriate file"
2148        if filename is not None:
2149            file_desc = "<code>" + filename + "</code>"
2150
2151        if hasattr(self, "base_name"):
2152            if self.base_name == "import":
2153                self.default_goal_args["description"] = (
2154                    (
2155                        f"Your program must write the correct data into"
2156                        f" {file_desc}"
2157                    ),
2158                    (
2159                        f"The data written into {file_desc} when your"
2160                        f" program is run must match what the solution"
2161                        f" writes."
2162                    )
2163                )
2164            else:
2165                self.default_goal_args["description"] = (
2166                    (
2167                        f"<code>{self.base_name}</code> must write the"
2168                        f" correct data into {file_desc}"
2169                    ),
2170                    (
2171                        f"The data written to {file_desc} when your"
2172                        f" <code>{self.base_name}</code> function is run"
2173                        f" must match what the solution writes."
2174                    )
2175                )
2176        else:
2177            self.default_goal_args["description"] = (
2178                (
2179                    f"Your code must write the correct data into"
2180                    f" {file_desc}"
2181                ),
2182                (
2183                    f"The data written into {file_desc} when your code"
2184                    f" is run must match the solution output."
2185                )
2186            )
2187
2188        self.context_args["display_product"] = (
2189            contexts.build_context_value_displayer(
2190                "output_file_contents",
2191                labels=[
2192                    f"Contents of {file_desc}",
2193                    "Correct contents",
2194                    "Comparison"
2195                ]
2196            )
2197        )
2198
2199        return self
2200
2201    # TODO: property -> payload -> condition description assembly...
2202    def compare_using(
2203        self,
2204        comparator_fn=None,
2205        context_slot=None
2206    ):
2207        """
2208        Specifies an alternate comparator for this goal (only works for
2209        `potluck.rubrics.ComparisonTest` as the `goal_constructor`). If a
2210        context_slot is also (or only) given, changes the context slot
2211        which will be compared as well.
2212
2213        The comparator function (if provided) must return a comparison
2214        result: a dictionary with "status" and "explanation" keys, where
2215        the status is one of "accomplished", "partial", or "failed". If
2216        no comparison function is provided, the current comparator will
2217        not be changed.
2218
2219        The context slot (if provided) must be a string naming the slot
2220        to use; see `potluck.contexts.Context` for a list of common slot
2221        names, but you could use your own custom slots too by using
2222        `HasPayload.do_setup` and/or `HasPayload.do_cleanup`, which can
2223        modify the context dictionary directly. If no context slot is
2224        specified, the current value will not be changed. Note that
2225        several other methods, like `test_output`, also modify the
2226        context slot and ordering matters; the last method to be called
2227        will determine which context slot is used.
2228
2229        Returns self for chaining.
2230        """
2231        self.ensure_goal_constructor_arg("checker")
2232        if comparator_fn is not None:
2233            self.goal_args["checker"] = comparator_fn
2234        if context_slot is not None:
2235            self.goal_args["context_slot"] = context_slot
2236        return self
2237
2238    def succeed_unless_crashed(self):
2239        """
2240        Overrides the comparator such that the goal always succeeds,
2241        unless the context builder fails because of a crash. Modifies
2242        the default goal arguments to note this.
2243
2244        Note that this won't check for captured errors (e.g., by using
2245        `HasGoal.test_output` and/or `HasPayload.capture_output` with
2246        the `capture_errors` option).
2247
2248        Returns self for chaining.
2249        """
2250        self.ensure_goal_constructor_arg("checker")
2251        self.goal_args["checker"] = lambda _1, _2: {
2252            "status": "accomplished",
2253            "explanation": "Test ran without errors."
2254        }
2255        # Set default goal type
2256        self.default_goal_args.setdefault(
2257            "tags",
2258            {}
2259        )["goal_type"] = "process"
2260
2261        # Set default (not explicit) description
2262        if hasattr(self, "base_name"):
2263            if self.base_name == "import":
2264                self.default_goal_args["description"] = (
2265                    "Your program must not crash",
2266                    "Your program must run without crashing.",
2267                    "Your program must not crash",
2268                    "We ran your program and checked if it crashed."
2269                )
2270            else:
2271                self.default_goal_args["description"] = (
2272                    f"<code>{self.base_name}</code> must not crash",
2273                    (
2274                        f"Your <code>{self.base_name}</code> function"
2275                        f" must run without crashing."
2276                    ),
2277                    f"<code>{self.base_name}</code> must not crash",
2278                    (
2279                        f"We ran your <code>{self.base_name}</code>"
2280                        f" function and checked whether it crashed."
2281                    )
2282                )
2283        else:
2284            self.default_goal_args["description"] = (
2285                "Your code must not crash",
2286                "Your code must run without crashing.",
2287                "Your code must not crash",
2288                "We ran your code and checked if it crashed."
2289            )
2290
2291        return self
2292
2293    def compare_exactly(self):
2294        """
2295        Overrides the comparator (see `compare_using`) with
2296        `potluck.compare.strict_equality_checker`, which compares items
2297        of any type for exact equality (the default
2298        `potluck.compare.omni_compare` function has various grades of
2299        partial success and ignores things like floating point rounding
2300        error). Returns the `TestGroup` for chaining.
2301
2302        Note: this is very rarely what you want, since it has weird edge
2303        cases that the default `potluck.compare.omni_compare` smoothes
2304        over.
2305        """
2306        self.ensure_goal_constructor_arg("checker")
2307        self.goal_args["checker"] = compare.strict_equality_checker
2308        return self
2309
2310    def compare_reports(self):
2311        """
2312        Overrides the comparator (see `compare_using`) with
2313        `potluck.compare.multiline_strings_are_exactly_equal`, which
2314        compares strings exactly and formats multi-line output nicely.
2315        This is just a convenience function to make this functionality
2316        more prominent; it returns the `TestGroup` for chaining.
2317        """
2318        self.ensure_goal_constructor_arg("checker")
2319        self.goal_args["checker"] = compare.multiline_strings_are_exactly_equal
2320        return self
2321
2322    def compare_strings_gently(
2323        self,
2324        line_match_threshold=0.5,
2325        sequence_match_threshold=0.8
2326    ):
2327        """
2328        Overrides the comparator (see `compare_using`) with
2329        `potluck.compare.very_fuzzy_string_compare`, which compares
2330        strings very roughly. This is just a convenience function to make
2331        this functionality more prominent; it returns the `TestGroup` for
2332        chaining. The `line_match_threshold` and
2333        `sequence_match_threshold` values are passed through to
2334        `compare.very_fuzzy_string_compare`.
2335        """
2336        self.ensure_goal_constructor_arg("checker")
2337        self.goal_args["checker"] = lambda val, ref: (
2338            compare.very_fuzzy_string_compare(
2339                val,
2340                ref,
2341                line_match_threshold,
2342                sequence_match_threshold
2343            )
2344        )
2345        return self
2346
2347    def compare_strings_semi_strict(self):
2348        """
2349        Overrides the comparator (see `comparator`) with
2350        `potluck.compare.strings_are_equal_modulo_whitespace`, which
2351        compares strings somewhat roughly (errors in whitespace and
2352        capitalization are mostly ignored). This is just a convenience
2353        function to make this functionality more prominent; it returns
2354        the `TestGroup` for chaining.
2355        """
2356        self.ensure_goal_constructor_arg("checker")
2357        self.goal_args["checker"] = (
2358            compare.strings_are_equal_modulo_whitespace
2359        )
2360        return self
2361
2362    def compare_strings_firmly(self):
2363        """
2364        Overrides the comparator (see `comparator`) with
2365        `potluck.compare.strings_are_equal_modulo_most_whitespace`,
2366        which works like
2367        `potluck.compare.strings_are_equal_modulo_whitespace` but it
2368        requires that word boundaries are preserved. This is just a
2369        convenience function to make this functionality more prominent;
2370        it returns the `TestGroup` for chaining.
2371        """
2372        self.ensure_goal_constructor_arg("checker")
2373        self.goal_args["checker"] = (
2374            compare.strings_are_equal_modulo_most_whitespace
2375        )
2376        return self
2377
2378    def refine(self, refiner_class, *refiner_args, **refiner_kwargs):
2379        """
2380        Creates a new `RefinedTest` based on the goal to be created by
2381        the current test (actually, based on the associated context
2382        objects; see `RefinedTest`).
2383
2384        You need to provide the class object to be instanced, and you may
2385        provide extra positional and/or keyword arguments that that
2386        refiner requires for initialization, beyond the parent object.
2387        This function returns the new `RefinedTest` instance for
2388        chaining.
2389
2390        Note that typically, it is not necessary for both the original
2391        and refined goals to appear in the rubric, and to achieve that,
2392        simply avoid calling the `goal` method of the original goal.
2393        """
2394        return refiner_class(self, *refiner_args, **refiner_kwargs)

Abstract base class for tests which will create potluck.rubrics.Goal objects. Provides common tools for managing goal creation. Subclasses must override the create_goal method with a zero-argument method that returns a goal object.

HasGoal(taskid, goal_constructor, default_goal_args=None)
1079    def __init__(
1080        self,
1081        taskid,
1082        goal_constructor,
1083        default_goal_args=None,
1084    ):
1085        """
1086        A task ID string and a goal constructor must be specified.
1087        Default goal args may be provided. Set the goal category and/or
1088        type via goal args.
1089        """
1090        self.taskid = taskid
1091        self.default_goal_args = default_goal_args or {}
1092        self.goal_args = {}
1093        self.goal_constructor = goal_constructor
1094        self._cached_goal = None

A task ID string and a goal constructor must be specified. Default goal args may be provided. Set the goal category and/or type via goal args.

def create_goal(self):
1070    def create_goal(self):
1071        """
1072        Returns the `potluck.rubrics.Goal` implied by this object. Must
1073        be implemented in concrete subclasses.
1074        """
1075        raise NotImplementedError(
1076            "HasGoal is an abstract class and cannot be used directly."
1077        )

Returns the potluck.rubrics.Goal implied by this object. Must be implemented in concrete subclasses.

def provide_goal(self):
1096    def provide_goal(self):
1097        """
1098        Returns the result of `create_goal`, but if `provide_goal` has
1099        been called previously, returns the cached result of that
1100        previous call instead.
1101        """
1102        if self._cached_goal is None:
1103            self._cached_goal = self.create_goal()
1104        return self._cached_goal

Returns the result of create_goal, but if provide_goal has been called previously, returns the cached result of that previous call instead.

def synthesize_goal_info(self, group=None):
1106    def synthesize_goal_info(self, group=None):
1107        """
1108        Synthesizes goal construction arguments. If a group is given,
1109        it should also be a `HasGoal` instance, and its information will
1110        be mixed with local information as follows:
1111
1112          - First, group defaults will be loaded.
1113          - Next, this object's defaults will override those.
1114          - Third, group explicit values will be added.
1115          - Finally, local explicit values will have the final say.
1116
1117        A "goal_type" tag will be deduced from the context_slot goal
1118        argument if it hasn't been set explicitly.
1119
1120        Note that the "tags" argument is itself a dictionary, and values
1121        will be synthesized according to the same procedure as for the
1122        whole dictionary.
1123        """
1124        args = {}
1125        tags = {}
1126        if group:
1127            args.update(group.default_goal_args)
1128            tags = args.get("tags", {})
1129        args.update(self.default_goal_args)
1130        tags.update(args.get("tags", {}))
1131        if group:
1132            args.update(group.goal_args)
1133            tags.update(args.get("tags", {}))
1134        args.update(self.goal_args)
1135        tags.update(args.get("tags", {}))
1136        args["tags"] = tags
1137
1138        # Deduce goal type if it hasn't been specified explicitly
1139        if "goal_type" not in args["tags"]: # no explicit goal type
1140            if "context_slot" in args: # deduce from context slot
1141                args["tags"]["goal_type"] = CONTEXT_SLOT_IMPLIED_TYPES.get(
1142                    args["context_slot"],
1143                    "other"
1144                )
1145            else: # no slot to deduce from; guess "other"
1146                args["tags"]["goal_type"] = "other"
1147
1148        return args

Synthesizes goal construction arguments. If a group is given, it should also be a HasGoal instance, and its information will be mixed with local information as follows:

  • First, group defaults will be loaded.
  • Next, this object's defaults will override those.
  • Third, group explicit values will be added.
  • Finally, local explicit values will have the final say.

A "goal_type" tag will be deduced from the context_slot goal argument if it hasn't been set explicitly.

Note that the "tags" argument is itself a dictionary, and values will be synthesized according to the same procedure as for the whole dictionary.

def create_goal_from_contexts(self, contexts, group=None):
1150    def create_goal_from_contexts(self, contexts, group=None):
1151        """
1152        Creates the implied `potluck.rubrics.Goal` object. Needs to be
1153        provided with a list of contexts in which the goal should be
1154        evaluated. A group may be provided for info merging via
1155        `synthesize_goal_info`.
1156        """
1157        args = self.synthesize_goal_info(group)
1158
1159        # Set testing contexts for this goal
1160        if "test_in" not in args:
1161            args["test_in"] = {}
1162
1163        if "contexts" in args["test_in"]:
1164            logging.debug_msg(
1165                "Warning: overriding existing test_in/contexts value in"
1166                " create_goal_from_contexts."
1167            )
1168
1169        args["test_in"]["contexts"] = contexts
1170
1171        args["taskid"] = self.taskid
1172
1173        # Create and return our goal object
1174        return self.goal_constructor(**args)

Creates the implied potluck.rubrics.Goal object. Needs to be provided with a list of contexts in which the goal should be evaluated. A group may be provided for info merging via synthesize_goal_info.

def ensure_goal_constructor_arg(self, desired):
1176    def ensure_goal_constructor_arg(self, desired):
1177        """
1178        Raises a TypeError unless this object's goal constructor
1179        accepts an argument of the desired name.
1180        """
1181        cobj = self.goal_constructor.__init__.__code__
1182        arg_names = cobj.co_varnames[:cobj.co_argcount]
1183        if desired not in arg_names:
1184            raise TypeError(
1185                f"This operation is only allowed for HasGoal classes"
1186                f" associated with goal types that accept a '{desired}'"
1187                f" argument ({self.goal_constructor.__name__} does not)."
1188            )

Raises a TypeError unless this object's goal constructor accepts an argument of the desired name.

def goal(self, category='core'):
1190    def goal(self, category="core"):
1191        """
1192        Registers this goal-provider as part of the rubric, under the
1193        given category (default is "core"). Returns self for chaining.
1194        """
1195        if "tags" not in self.goal_args:
1196            self.goal_args["tags"] = {}
1197
1198        self.goal_args["tags"]["category"] = category
1199        register_goal_provider(self)
1200
1201        return self

Registers this goal-provider as part of the rubric, under the given category (default is "core"). Returns self for chaining.

def validate(self, category='core'):
1203    def validate(self, category="core"):
1204        """
1205        Registers this goal-provider as part of the rubric's
1206        test-validation goals, under the given category (default is
1207        "core"). Returns self for chaining.
1208        """
1209        if "tags" not in self.goal_args:
1210            self.goal_args["tags"] = {}
1211
1212        self.goal_args["tags"]["category"] = category
1213        register_validation_goal_provider(self)
1214
1215        return self

Registers this goal-provider as part of the rubric's test-validation goals, under the given category (default is "core"). Returns self for chaining.

def set_identifier(self, identifier):
1217    def set_identifier(self, identifier):
1218        """
1219        Sets the identifier that will be used for the goal produced.
1220        Returns self for chaining.
1221        """
1222        self.goal_args["identifier"] = identifier
1223        return self

Sets the identifier that will be used for the goal produced. Returns self for chaining.

def set_goal_type(self, goal_type):
1225    def set_goal_type(self, goal_type):
1226        """
1227        Sets an explicit goal type for this goal. Normally, goal types
1228        can be deduced with reasonable accuracy from existing
1229        information, but if that isn't working, use this method to
1230        explicitly declare a goal type. Returns self for chaining.
1231        """
1232        if "tags" not in self.goal_args:
1233            self.goal_args["tags"] = {}
1234        self.goal_args["tags"]["goal_type"] = goal_type
1235        return self

Sets an explicit goal type for this goal. Normally, goal types can be deduced with reasonable accuracy from existing information, but if that isn't working, use this method to explicitly declare a goal type. Returns self for chaining.

def set_goal_description(self, description):
1237    def set_goal_description(self, description):
1238        """
1239        Sets a custom description for the goal. Returns self for
1240        chaining. A description is a 2-tuple or 4-tuple of strings. If
1241        it's a 2-tuple, it specifies the title for the rubric entry and
1242        then the longer description. If it's a 4-tuple, the first two
1243        elements are the title and description used in blank rubrics,
1244        while the second two are used in displaying actual evaluation
1245        results.
1246        """
1247        self.goal_args["description"] = description
1248        return self

Sets a custom description for the goal. Returns self for chaining. A description is a 2-tuple or 4-tuple of strings. If it's a 2-tuple, it specifies the title for the rubric entry and then the longer description. If it's a 4-tuple, the first two elements are the title and description used in blank rubrics, while the second two are used in displaying actual evaluation results.

def test_module(self, module_comparator):
1250    def test_module(self, module_comparator):
1251        """
1252        Changes the implied goal so that instead of comparing the printed
1253        output between runs of the submitted and solution modules, it
1254        compares the module objects that result from those runs, using
1255        the given comparator function. Only applicable to goals derived
1256        from payloads that run modules.
1257
1258        The comparator function will be given two module objects and will
1259        be expected to return an evaluation result, which is a dictionary
1260        with "status" and "explanation" keys (see `potluck.compare`).
1261
1262        Note that one should almost always use `set_goal_description`
1263        along with this function to describe what the given comparison
1264        function actually does.
1265
1266        This function returns self for chaining.
1267        """
1268        if (
1269            not hasattr(self, "ignore_output")
1270         or not hasattr(self, "describe_module_slot")
1271        ):
1272            raise TypeError(
1273                f"Module-based testing is only applicable for"
1274                f" Goal-generators which have ignore_output and"
1275                f" describe_module_slot methods ({self} does not)."
1276            )
1277
1278        # Set up our payload to ignore output (since we're using the
1279        # module object instead)
1280        self.ignore_output()
1281
1282        # Set up our context to focus on the module slot
1283        self.describe_module_slot()
1284
1285        # Modify goal arguments
1286        self.goal_args["context_slot"] = "module"
1287        self.goal_args["tags"]["goal_type"] = "product"
1288
1289        # Modify default (not explicit) description
1290        self.default_goal_args["description"] = (
1291            "Running your file must define the right values",
1292            (
1293                "Your file must define the same variables and functions"
1294                " as the solution file when run."
1295            )
1296        )
1297
1298        return self

Changes the implied goal so that instead of comparing the printed output between runs of the submitted and solution modules, it compares the module objects that result from those runs, using the given comparator function. Only applicable to goals derived from payloads that run modules.

The comparator function will be given two module objects and will be expected to return an evaluation result, which is a dictionary with "status" and "explanation" keys (see potluck.compare).

Note that one should almost always use set_goal_description along with this function to describe what the given comparison function actually does.

This function returns self for chaining.

def test_output(self, capture_errors=False):
1300    def test_output(self, capture_errors=False):
1301        """
1302        Causes this test to compare printed output instead of result
1303        values. Automatically calls `self.capture_output` (which will
1304        normally be `HasPayload.capture_output`) to set that up, passing
1305        `capture_errors` on to that function if it exists.
1306
1307        Returns self for chaining.
1308        """
1309        if hasattr(self, "capture_output"):
1310            self.capture_output(capture_errors)
1311
1312        self.goal_args["context_slot"] = "output"
1313
1314        # Set default (not explicit) description
1315        if hasattr(self, "base_name"):
1316            if self.base_name == "import":
1317                self.default_goal_args["description"] = (
1318                    "Your program must print the correct output",
1319                    (
1320                        "The output printed when your program is"
1321                        " run must match the solution output."
1322                    )
1323                )
1324            else:
1325                self.default_goal_args["description"] = (
1326                    (
1327                        f"<code>{self.base_name}</code> must print the"
1328                        f" correct output"
1329                    ),
1330                    (
1331                        f"The output printed when your"
1332                        f" <code>{self.base_name}</code> function is run"
1333                        f" must match the solution output."
1334                    )
1335                )
1336        else:
1337            self.default_goal_args["description"] = (
1338                "Your code must print the correct output",
1339                (
1340                    "The output printed when your code is run must"
1341                    " match the solution output."
1342                )
1343            )
1344
1345        self.context_args["display_product"] = (
1346            contexts.build_context_value_displayer(
1347                "output",
1348                labels=[
1349                    "Your output",
1350                    "Solution output",
1351                    "Comparison"
1352                ]
1353            )
1354        )
1355
1356        return self

Causes this test to compare printed output instead of result values. Automatically calls self.capture_output (which will normally be HasPayload.capture_output) to set that up, passing capture_errors on to that function if it exists.

Returns self for chaining.

def test_with_harness(self, harness_fn):
1358    def test_with_harness(self, harness_fn):
1359        """
1360        Causes this test to compare test-harness results instead of
1361        direct function call results. Calls `self.use_harness` (which
1362        must be available, normally from `HasPayload.use_harness`).
1363        See `HasPayload.use_harness` for details on how the harness
1364        function will be applied.
1365
1366        Only applicable to a Goal which is based on testing a function
1367        call.
1368
1369        Note: If you want to test the printed output of a test harness
1370        rather than its return value, you can call both
1371        `test_with_harness` and `test_output`, but you should call
1372        `test_with_harness` second to set the description properly.
1373        """
1374        if (
1375            not hasattr(self, "base_name")
1376         or self.base_name == "import"
1377         or not hasattr(self, "use_harness")
1378        ):
1379            raise TypeError(
1380                f"Harness-based testing is only applicable for"
1381                f" Goal-generators which have a base_name attribute"
1382                f" which isn't 'import' and a use_harness method ({self}"
1383                f" does not)."
1384            )
1385
1386        # Set up harness for our payload
1387        self.use_harness(harness_fn)
1388
1389        # Set (default) description from the test harness
1390        self.default_goal_args["description"] = explain.harness_descriptions(
1391            harness_fn,
1392            self.base_name,
1393            '', # multiple TestCases so can't specify arguments
1394            '', # multiple TestCases so can't specify arguments
1395            'behavior' # TODO: less generic here
1396        )
1397
1398        return self

Causes this test to compare test-harness results instead of direct function call results. Calls self.use_harness (which must be available, normally from HasPayload.use_harness). See HasPayload.use_harness for details on how the harness function will be applied.

Only applicable to a Goal which is based on testing a function call.

Note: If you want to test the printed output of a test harness rather than its return value, you can call both test_with_harness and test_output, but you should call test_with_harness second to set the description properly.

def test_trace(self, trace_targets, state_function):
1400    def test_trace(self, trace_targets, state_function):
1401        """
1402        Sets up this test to test a trace result instead of just what
1403        the function returns. Uses the `capture_trace` method to set up
1404        tracing, which must be available.
1405
1406        You can use `check_trace_state` and/or `check_invariant`
1407        afterwards to alter how the trace will be compared with the
1408        solution trace.
1409        """
1410        if (
1411            not hasattr(self, "base_name")
1412         or self.base_name == "import"
1413         or not hasattr(self, "capture_trace")
1414        ):
1415            raise TypeError(
1416                f"Trace-based testing is only applicable for"
1417                f" Goal-generators which have a capture_trace method"
1418                f" and a base_name attribute that's not 'import' ({self}"
1419                f" does not)."
1420            )
1421
1422        # Set up tracing
1423        self.capture_trace(trace_targets, state_function)
1424
1425        # Target the "trace" context slot
1426        self.goal_args["context_slot"] = "trace"
1427
1428        # Set our goal type to "process"
1429        self.goal_args.setdefault("tags", {})["goal_type"] = "process"
1430
1431        # Set default (not explicit) description
1432        self.default_goal_args["description"] = (
1433            f"<code>{self.base_name}</code> must use the correct process",
1434            (
1435                f"The pattern of functions called when your"
1436                f" <code>{self.base_name}</code> function is run must"
1437                f" match the solution process."
1438            )
1439        )
1440
1441        return self

Sets up this test to test a trace result instead of just what the function returns. Uses the capture_trace method to set up tracing, which must be available.

You can use check_trace_state and/or check_invariant afterwards to alter how the trace will be compared with the solution trace.

def check_trace_state( self, state_slots, check_args=None, check_results=False, pre_or_post='pre', order_matters=False, only=None, tolerance='auto'):
1443    def check_trace_state(
1444        self,
1445        state_slots,
1446        check_args=None,
1447        check_results=False,
1448        pre_or_post="pre",
1449        order_matters=False,
1450        only=None,
1451        tolerance="auto"
1452    ):
1453        """
1454        You must call `test_trace` first to set up tracing. This function
1455        changes the comparison function so that instead of comparing
1456        entire traces directly, we identify each function call using the
1457        function name, the values of specific state slots, and the
1458        parameters used (if `check_args` is non-empty) and/or results
1459        (if `check_results` is True). After identifying each function
1460        call in this way, we create a list of those calls in linear
1461        sequence, and compare those lists between the submitted and
1462        solution traces (only caring about order if order_matters is set
1463        to True).
1464
1465        The `state_slots` argument is required, it should be a list of
1466        strings and indicates which slots in the state dictionary we
1467        care about. It may be empty, in which case no state entries will
1468        be included in the call IDs.
1469
1470        If `check_args` is set it should be None or a list of strings
1471        and/or integers; named (and/or indexed) parameters will be
1472        included as identifying information for trace entries). It may
1473        also be True to check all arguments.
1474
1475        If `check_results` is set, the return value of each call will be
1476        included in its identifying information.
1477
1478        `pre_or_post` determines whether states before function calls or
1479        just before their returns are used. Set it to the string 'pre',
1480        the string 'post', or the string 'both' to include both states.
1481        The default is 'pre'.
1482
1483        `order_matters` can be set to True if you want to enforce
1484        matching of traces in-order, rather than allowing an equivalent
1485        set of function calls in any order to count as matched. Your
1486        specs will be more flexible if you can check correctness based
1487        on state-at-time-of-call rather than order-of-call so the
1488        default for this is False.
1489
1490        `only` should be a list of function names, or None. If not None,
1491        then only functions names in this list will be included in the
1492        check performed.
1493
1494        `tolerance` will be passed to `make_structure_comparator`; see
1495        that function for details; "auto" is the default.
1496
1497        Returns self for chaining.
1498
1499        For example, this kind of comparison could be used to ensure
1500        that a solution calls certain drawing commands with the right
1501        parameters from the right set of states, without regard to
1502        order, which is one way to specify that it "draws the right
1503        thing" even if we don't care what order things are drawn in.
1504        """
1505        # Check that tracing is set up
1506        if self.goal_args["context_slot"] != "trace":
1507            raise ValueError(
1508                "You must activate tracing using `test_trace` before"
1509                " calling `check_trace_state`."
1510            )
1511
1512        # Make sure we've got goal which requires a checker
1513        self.ensure_goal_constructor_arg("checker")
1514
1515        # Create our base comparator
1516        base_comparator = compare.make_structure_comparator(
1517            tolerance=tolerance,
1518            order_matters=order_matters
1519        )
1520        # TODO: Allow rounding differences in state by default, including
1521        # in args!
1522
1523        # Define our full comparator
1524        def compare_trace_states(submitted, solution):
1525            """
1526            A custom comparator function which compares certain parts of
1527            captured trace states.
1528            """
1529            # TODO: Better explanation which turns trace state dicts into
1530            # human-readable explanations of call situations...
1531            processed = []
1532            for trace in submitted, solution:
1533                rendered = []
1534                processed.append(rendered)
1535                for entry in harness.walk_trace(trace):
1536                    if only and entry["fname"] not in only:
1537                        continue
1538                    entry_id = { "fname": entry["fname"] }
1539
1540                    # Grab args if requested
1541                    if check_args:
1542                        if check_args is True:
1543                            entry_id["args"] = copy.copy(entry["args"])
1544                        else:
1545                            indices = [
1546                                i
1547                                for i in check_args
1548                                if isinstance(i, int)
1549                            ]
1550                            names = [
1551                                name
1552                                for name in check_args
1553                                if isinstance(name, str)
1554                            ]
1555
1556                            # Which args are we actually taking?
1557                            take = []
1558                            for i, argname in enumerate(entry["args"]):
1559                                if i in indices or argname in names:
1560                                    take.append(argname)
1561
1562                            entry_id["args"] = {
1563                                argname: entry["args"][argname]
1564                                for argname in take
1565                            }
1566
1567                    # Grab result if we need it
1568                    if check_results:
1569                        entry_id["result"] = entry["result"]
1570
1571                    # Grab pre- and/or post-call state values
1572                    if pre_or_post in ("pre", "both"):
1573                        entry_id["pre_state"] = {
1574                            slot: entry["pre_state"][slot]
1575                            for slot in state_slots
1576                        }
1577                    if pre_or_post in ("post", "both"):
1578                        entry_id["post_state"] = {
1579                            slot: entry["post_state"][slot]
1580                            for slot in state_slots
1581                        }
1582
1583                    rendered.append(entry_id)
1584
1585            # Run our base comparator on the two processed lists
1586            return base_comparator(processed[0], processed[1])
1587
1588        # Set up our comparator as the checker for this goal
1589        self.goal_args["checker"] = compare_trace_states
1590
1591        # Build description pieces
1592        targets = "the correct functions"
1593        if only:
1594            targets = "the " + phrasing.comma_list(
1595                f"<code>{fn}</code>"
1596                for fn in only
1597            ) + " " + phrasing.plural(len(only), "function")
1598
1599        conditions = []
1600        if order_matters:
1601            conditions.append("in the correct order")
1602
1603        if check_args:
1604            conditions.append("with the correct arguments")
1605
1606        if state_slots:
1607            conditions.append(
1608                "while the correct "
1609              + phrasing.comma_list(state_slots)
1610              + " " + phrasing.plural(len(state_slots), "value")
1611              + " " + phrasing.plural(len(state_slots), "is", "are")
1612              + " set up"
1613            )
1614
1615        if check_results:
1616            conditions.append(
1617                "and each call must return the correct result"
1618            )
1619
1620        # Set default (not explicit) description
1621        if hasattr(self, "base_name") and self.base_name != "input":
1622            self.default_goal_args["description"] = (
1623                (
1624                    f"<code>{self.base_name}</code> must make the correct"
1625                    f" function calls"
1626                ),
1627                (
1628                    f"Your <code>{self.base_name}</code> function must"
1629                  + f" call {targets} "
1630                  + ', '.join(conditions)
1631                )
1632            )
1633        else:
1634            self.default_goal_args["description"] = (
1635                "Your code must make the correct function calls",
1636                (
1637                    f"When your code is run it must call {targets} "
1638                  + ', '.join(conditions)
1639                )
1640            )
1641
1642        return self

You must call test_trace first to set up tracing. This function changes the comparison function so that instead of comparing entire traces directly, we identify each function call using the function name, the values of specific state slots, and the parameters used (if check_args is non-empty) and/or results (if check_results is True). After identifying each function call in this way, we create a list of those calls in linear sequence, and compare those lists between the submitted and solution traces (only caring about order if order_matters is set to True).

The state_slots argument is required, it should be a list of strings and indicates which slots in the state dictionary we care about. It may be empty, in which case no state entries will be included in the call IDs.

If check_args is set it should be None or a list of strings and/or integers; named (and/or indexed) parameters will be included as identifying information for trace entries). It may also be True to check all arguments.

If check_results is set, the return value of each call will be included in its identifying information.

pre_or_post determines whether states before function calls or just before their returns are used. Set it to the string 'pre', the string 'post', or the string 'both' to include both states. The default is 'pre'.

order_matters can be set to True if you want to enforce matching of traces in-order, rather than allowing an equivalent set of function calls in any order to count as matched. Your specs will be more flexible if you can check correctness based on state-at-time-of-call rather than order-of-call so the default for this is False.

only should be a list of function names, or None. If not None, then only functions names in this list will be included in the check performed.

tolerance will be passed to make_structure_comparator; see that function for details; "auto" is the default.

Returns self for chaining.

For example, this kind of comparison could be used to ensure that a solution calls certain drawing commands with the right parameters from the right set of states, without regard to order, which is one way to specify that it "draws the right thing" even if we don't care what order things are drawn in.

def check_invariant(self, state_slots, only=None, partial_tolerance=0.2):
1644    def check_invariant(
1645        self,
1646        state_slots,
1647        only=None,
1648        partial_tolerance=0.2
1649    ):
1650        """
1651        You must call `test_trace` first to set up tracing. This function
1652        changes the comparison function so that instead of comparing
1653        entire traces directly, we check that specific state slots
1654        specified do not change between the pre- and post- states of
1655        each trace entry.
1656
1657        `only` may be set to None, in which case all entries are checked
1658        (the default), or a list of strings may be provided naming
1659        functions to check (others will be ignored).
1660
1661        `partial_tolerance` should be a fraction which specifies what
1662        percentage of function calls are allowed to violate the
1663        invariant while still returning a partial-success result.
1664
1665        By default for floating-point values there is a baseline level
1666        of tolerance for small changes.
1667
1668        Returns self for chaining.
1669        """
1670        # Check that tracing is set up
1671        if self.goal_args["context_slot"] != "trace":
1672            raise ValueError(
1673                "You must activate tracing using `test_trace` before"
1674                " calling `check_trace_state`."
1675            )
1676
1677        # Make sure we've got goal which requires a checker
1678        self.ensure_goal_constructor_arg("checker")
1679
1680        # Set up base comparator
1681        base_comparator = compare.omni_compare
1682
1683        # Build description of targets
1684        targets = "your functions"
1685        if only:
1686            targets = "the " + phrasing.comma_list(
1687                f"<code>{fn}</code>"
1688                for fn in only
1689            ) + " " + phrasing.plural(len(only), "function")
1690
1691        # Build description of states
1692        states = "the " + phrasing.comma_list(
1693            f"<code>{slot}</code>"
1694            for slot in state_slots
1695        ) + " " + phrasing.plural(len(state_slots), "value")
1696
1697        def check_for_invariants(submitted, *_):
1698            """
1699            Checks the submitted trace to make sure that certain state
1700            values don't change when certain functions are called. Any
1701            provided solution trace is ignored.
1702            """
1703            total = 0
1704            failed = []
1705            for entry in harness.walk_trace(submitted):
1706                # Only inspect targeted functions
1707                if only and entry["fname"] not in only:
1708                    continue
1709
1710                total += 1
1711
1712                # Grab pre/post states
1713                pre = entry["pre_state"]
1714                post = entry["post_state"]
1715
1716                # Compare each slot value between pre and post
1717                different = []
1718                for slot in state_slots:
1719                    same = base_comparator(pre[slot], post[slot])
1720                    if same["status"] != "accomplished":
1721                        different.append((slot, pre[slot], post[slot]))
1722
1723                if different:
1724                    failed.append((entry, different))
1725
1726            # Return an evaluation based on how many calls failed to be
1727            # invariant in terms of the specified state slots
1728            pct_failed = len(failed) / total
1729            if pct_failed == 0:
1730                return {
1731                    "status": "accomplished",
1732                    "explanation": (
1733                        f"all {total} calls to {targets} maintained "
1734                      + phrasing.plural(
1735                          len(state_slots),
1736                          "an invariant", "invariants"
1737                        )
1738                      + f" for {states}"
1739                    )
1740                }
1741            else:
1742                status = "failed"
1743                if pct_failed <= partial_tolerance:
1744                    status = "partial"
1745                return {
1746                    "status": status,
1747                    "explanation": (
1748                        f"out of {total} calls to {targets},"
1749                        f" {len(failed)} failed to maintain "
1750                      + phrasing.plural(
1751                          len(state_slots),
1752                          "an invariant", "invariants"
1753                        )
1754                      + f" for {states}:<br>\n"
1755                      + html_tools.build_list(
1756                            (
1757                                f"<code>{entry['fname']}("
1758                              + ', '.join(
1759                                    "{name}={val}".format(
1760                                        name=name,
1761                                        val=html_tools.dynamic_html_repr(
1762                                            entry['args'][name]
1763                                        )
1764                                    )
1765                                  for name in entry['args']
1766                                )
1767                              + ")</code> changed "
1768                              + html_tools.build_list(
1769                                    (
1770                                        "<code>{slot}</code> from"
1771                                        " <code>{pre}</code>"
1772                                        " to <code>{post}</code>"
1773                                    ).format(
1774                                        slot=slot,
1775                                        pre=html_tools.dynamic_html_repr(
1776                                            pre
1777                                        ),
1778                                        post=html_tools.dynamic_html_repr(
1779                                            post
1780                                        )
1781                                    )
1782                                    for slot, pre, post in different
1783                                )
1784                            )
1785                            for entry, different in failed
1786                        )
1787                    )
1788                }
1789
1790        # Set up our comparator as the checker for this goal
1791        self.goal_args["checker"] = check_for_invariants
1792
1793        # Set default (not explicit) descriptions:
1794        self.default_goal_args["description"] = (
1795            (
1796                f"{targets} must maintain ".capitalize()
1797              + phrasing.plural(
1798                  len(state_slots),
1799                  "an invariant", "invariants"
1800                )
1801              + f" for {states}"
1802            ),
1803            (
1804                f"Each call to {targets} must return {states} to "
1805              + phrasing.plural(len(state_slots), "its", "their")
1806              + " initial state before " + phrasing.plural(
1807                    len(only) if only else 2,
1808                    "it returns.",
1809                    "they return."
1810                )
1811            )
1812        )
1813
1814        # Now we're done; return self for chaining
1815        return self

You must call test_trace first to set up tracing. This function changes the comparison function so that instead of comparing entire traces directly, we check that specific state slots specified do not change between the pre- and post- states of each trace entry.

only may be set to None, in which case all entries are checked (the default), or a list of strings may be provided naming functions to check (others will be ignored).

partial_tolerance should be a fraction which specifies what percentage of function calls are allowed to violate the invariant while still returning a partial-success result.

By default for floating-point values there is a baseline level of tolerance for small changes.

Returns self for chaining.

def check_trace_count(self, target, double_or_half=False):
1817    def check_trace_count(self, target, double_or_half=False):
1818        """
1819        You must call `test_trace` first to set up tracing. This function
1820        changes the comparison function so that instead of comparing
1821        entire traces directly, we look at only function calls in the
1822        trace to functions with the provided target name, and we just
1823        compare how many there are, ignoring the state-at-call-time and
1824        even arguments-supplied information in the trace.
1825
1826        Returns partial success if the number of calls is close to
1827        correct, and if double_or_half is True, also returns partial
1828        success if the number of calls is double or half the correct
1829        value, or within one of double or half.
1830        """
1831        # Check that tracing is set up
1832        if self.goal_args["context_slot"] != "trace":
1833            raise ValueError(
1834                "You must activate tracing using `test_trace` before"
1835                " calling `check_trace_count`."
1836            )
1837
1838        # Make sure we've got goal which requires a checker
1839        self.ensure_goal_constructor_arg("checker")
1840
1841        # Define our full comparator
1842        def compare_trace_counts(submitted, solution):
1843            """
1844            A custom comparator function which compares the count of
1845            calls to a certain function in two traces.
1846            """
1847            counts = []
1848            # Count entries in each trace
1849            for trace in submitted, solution:
1850                count = 0
1851                for entry in harness.walk_trace(trace):
1852                    if entry["fname"] == target:
1853                        count += 1
1854                counts.append(count)
1855
1856            sub_count, soln_count = counts
1857
1858            if sub_count == soln_count:
1859                return {
1860                    "status": "accomplished",
1861                    "explanation": (
1862                        f"Number of function calls to"
1863                        f" <code>{target}</code> was correct"
1864                        f" ({soln_count})"
1865                    )
1866                }
1867            elif sub_count in (
1868                soln_count - 1,
1869                soln_count + 1
1870            ):
1871                return {
1872                    "status": "partial",
1873                    "explanation": (
1874                        f"Number of function calls to"
1875                        f" <code>{target}</code> ({sub_count}) was"
1876                        f" almost correct (should have been"
1877                        f" {soln_count})."
1878                    )
1879                }
1880            elif double_or_half and sub_count in (
1881                soln_count // 2,
1882                soln_count * 2
1883            ):
1884                return {
1885                    "status": "partial",
1886                    "explanation": (
1887                        f"Number of function calls to"
1888                        f" <code>{target}</code> ({sub_count}) was"
1889                        f" double or half of the correct value"
1890                        f" ({soln_count})."
1891                    )
1892                }
1893            elif double_or_half and sub_count in (
1894                soln_count // 2 - 1,
1895                soln_count // 2 + 1,
1896                soln_count * 2 - 1,
1897                soln_count * 2 + 1
1898            ):
1899                return {
1900                    "status": "partial",
1901                    "explanation": (
1902                        f"Number of function calls to"
1903                        f" <code>{target}</code> ({sub_count}) was"
1904                        f" nearly double or half of the correct value"
1905                        f" ({soln_count})."
1906                    )
1907                }
1908            else:
1909                return {
1910                    "status": "failed",
1911                    "explanation": (
1912                        f"Number of function calls to"
1913                        f" <code>{target}</code> ({sub_count}) was"
1914                        f" incorrect (should have been {soln_count})."
1915                    )
1916                }
1917
1918        # Set up our comparator as the checker for this goal
1919        self.goal_args["checker"] = compare_trace_counts
1920
1921        # Set default (not explicit) description
1922        if hasattr(self, "base_name") and self.base_name != "input":
1923            self.default_goal_args["description"] = (
1924                (
1925                    f"<code>{self.base_name}</code> must make the correct"
1926                    f" number of calls to <code>{target}</code>"
1927                ),
1928                (
1929                    f"Your <code>{self.base_name}</code> function must"
1930                    f" call <code>{target}</code> the correct number of"
1931                    f" times."
1932                )
1933            )
1934        else:
1935            self.default_goal_args["description"] = (
1936                (
1937                    f"Your code must make the correct number of function"
1938                    f" calls to <code>{target}</code>"
1939                ),
1940                (
1941                    f"When your code is run it must call"
1942                    f" <code>{target}<code> the correct number of"
1943                    f" times."
1944                )
1945            )
1946
1947        return self

You must call test_trace first to set up tracing. This function changes the comparison function so that instead of comparing entire traces directly, we look at only function calls in the trace to functions with the provided target name, and we just compare how many there are, ignoring the state-at-call-time and even arguments-supplied information in the trace.

Returns partial success if the number of calls is close to correct, and if double_or_half is True, also returns partial success if the number of calls is double or half the correct value, or within one of double or half.

def test_wavesynth_notes(self):
1949    def test_wavesynth_notes(self):
1950        """
1951        Sets up for testing note descriptions from the wavesynth module.
1952        """
1953        if hasattr(self, "capture_wavesynth"):
1954            self.capture_wavesynth(just_capture="notes")
1955
1956        self.goal_args["context_slot"] = "notes"
1957
1958        # Set default (not explicit) description
1959        if hasattr(self, "base_name"):
1960            if self.base_name == "import":
1961                what = "your program"
1962                What = "Your program"
1963                verb = "run"
1964            else:
1965                what = f"<code>{self.base_name}</code>"
1966                What = what
1967                verb = "called"
1968
1969            self.default_goal_args["description"] = (
1970                f"{What} must produce the correct note sequence",
1971                (
1972                    f"The notes added to the current track when {what}"
1973                    f" is {verb} must match the solution notes"
1974                    f" in terms of timing, instruments, pitches, and"
1975                    f" volumes."
1976                ),
1977                (
1978                    f"{What} produces the correct note"
1979                    " sequence"
1980                ),
1981                (
1982                    "We checked that the notes {what} adds to the"
1983                    " current track match those added by the solution."
1984                )
1985            )
1986
1987        else:
1988            self.default_goal_args["description"] = (
1989                "Your code must produce the correct note sequence",
1990                (
1991                    "The sequence of notes added to the current track"
1992                    " when your code is run must match the solution"
1993                    " notes."
1994                )
1995            )
1996
1997        self.context_args["display_product"] = (
1998            contexts.build_context_value_displayer(
1999                "notes",
2000                labels=[
2001                    "Your notes",
2002                    "Solution notes",
2003                    "Comparison"
2004                ]
2005            )
2006        )
2007
2008        return self

Sets up for testing note descriptions from the wavesynth module.

def test_wavesynth_audio(self):
2010    def test_wavesynth_audio(self):
2011        """
2012        Sets up for testing raw audio from the wavesynth module.
2013        """
2014        if hasattr(self, "capture_wavesynth"):
2015            self.capture_wavesynth(just_capture="audio")
2016
2017        self.goal_args["context_slot"] = "audio"
2018
2019        # Set default (not explicit) description
2020        if hasattr(self, "base_name"):
2021            if self.base_name == "import":
2022                what = "your program"
2023                verb = "run"
2024            else:
2025                what = f"<code>{self.base_name}</code>"
2026                verb = "called"
2027            self.default_goal_args["description"] = (
2028                f"{what.capitalize()} must produce the correct audio",
2029                (
2030                    f"The audio produced by calling"
2031                    f" <code>playTrack</code> after {what} is {verb}"
2032                    f" must match the solution audio."
2033                )
2034            )
2035        else:
2036            self.default_goal_args["description"] = (
2037                "Your code must produce the correct audio",
2038                (
2039                    "The audio produced by calling"
2040                    " <code>playTrack</code> after your code is run"
2041                    " must match the solution audio."
2042                )
2043            )
2044
2045        # TODO: Use snippet machinery!
2046        self.context_args["display_product"] = (
2047            contexts.build_context_value_displayer(
2048                "audio",
2049                labels=[
2050                    "Your audio",
2051                    "Solution audio",
2052                    "Comparison"
2053                ]
2054            )
2055        )
2056
2057        return self

Sets up for testing raw audio from the wavesynth module.

def test_turtle_image( self, allowed_differences=0.03, partial_allowed=0.5, similarity_threshold=15):
2059    def test_turtle_image(
2060        self,
2061        allowed_differences=0.03,
2062        partial_allowed=0.5,
2063        similarity_threshold=15
2064    ):
2065        """
2066        Sets up for testing the image drawn using turtle graphics. The
2067        arguments are passed on to `compare.make_image_comparator` to
2068        determine the strictness of the comparison. The defaults are
2069        fairly liberal, especially if what is being drawn does not take
2070        up a large area of the image.
2071
2072        TODO: Background subtraction!
2073        """
2074        # Capture turtle image (if we can)
2075        if hasattr(self, "capture_turtle_image"):
2076            self.capture_turtle_image()
2077
2078        # Set up image comparator
2079        self.compare_using(
2080            compare.make_image_comparator(
2081                allowed_differences,
2082                partial_allowed,
2083                similarity_threshold
2084            )
2085        )
2086
2087        # Set context slot to compare
2088        self.goal_args["context_slot"] = "image"
2089
2090        # Set default (not explicit) description
2091        if hasattr(self, "base_name"):
2092            if self.base_name == "import":
2093                What = "Your program"
2094                what = "your program"
2095                verb = "run"
2096            else:
2097                What = f"<code>{self.base_name}</code>"
2098                what = What
2099                verb = "called"
2100            self.default_goal_args["description"] = (
2101                f"{What} must draw the correct image",
2102                (
2103                    f"The image drawn in the turtle window after {what}"
2104                    f" is {verb} must match the solution image."
2105                )
2106            )
2107        else:
2108            self.default_goal_args["description"] = (
2109                "Your code must draw the correct image",
2110                (
2111                    "The image drawn in the turtle window after your"
2112                    " code is run must match the solution image."
2113                )
2114            )
2115
2116        # TODO: Use snippet machinery?
2117        # Set context value displayer
2118        self.context_args["display_product"] = (
2119            contexts.create_image_result_displayer()
2120        )
2121
2122        return self

Sets up for testing the image drawn using turtle graphics. The arguments are passed on to compare.make_image_comparator to determine the strictness of the comparison. The defaults are fairly liberal, especially if what is being drawn does not take up a large area of the image.

TODO: Background subtraction!

def test_file_contents(self, filename=None, binary=False):
2124    def test_file_contents(self, filename=None, binary=False):
2125        """
2126        Causes this test to compare the contents of the specified file
2127        instead of result values. Automatically calls
2128        `self.capture_file_contents` (which will normally be
2129        `HasPayload.capture_file_contents`) to set that up. The `binary`
2130        argument will be passed through to that function, and indicates
2131        that file contents should be read as bytes, not as a string.
2132
2133        Note that if you are using a `TestGroup` that includes individual
2134        `SingleTest` objects which write to multiple different filenames,
2135        leave the filename argument out and
2136        `HasPayload.capture_file_contents` will not be called; you will
2137        have to call it yourself on individual `SingleTest` items.
2138
2139        Returns self for chaining.
2140        """
2141        if hasattr(self, "capture_file_contents") and filename is not None:
2142            self.capture_file_contents(filename, binary)
2143
2144        self.goal_args["context_slot"] = "output_file_contents"
2145
2146        # Set default (not explicit) description
2147        file_desc = "the appropriate file"
2148        if filename is not None:
2149            file_desc = "<code>" + filename + "</code>"
2150
2151        if hasattr(self, "base_name"):
2152            if self.base_name == "import":
2153                self.default_goal_args["description"] = (
2154                    (
2155                        f"Your program must write the correct data into"
2156                        f" {file_desc}"
2157                    ),
2158                    (
2159                        f"The data written into {file_desc} when your"
2160                        f" program is run must match what the solution"
2161                        f" writes."
2162                    )
2163                )
2164            else:
2165                self.default_goal_args["description"] = (
2166                    (
2167                        f"<code>{self.base_name}</code> must write the"
2168                        f" correct data into {file_desc}"
2169                    ),
2170                    (
2171                        f"The data written to {file_desc} when your"
2172                        f" <code>{self.base_name}</code> function is run"
2173                        f" must match what the solution writes."
2174                    )
2175                )
2176        else:
2177            self.default_goal_args["description"] = (
2178                (
2179                    f"Your code must write the correct data into"
2180                    f" {file_desc}"
2181                ),
2182                (
2183                    f"The data written into {file_desc} when your code"
2184                    f" is run must match the solution output."
2185                )
2186            )
2187
2188        self.context_args["display_product"] = (
2189            contexts.build_context_value_displayer(
2190                "output_file_contents",
2191                labels=[
2192                    f"Contents of {file_desc}",
2193                    "Correct contents",
2194                    "Comparison"
2195                ]
2196            )
2197        )
2198
2199        return self

Causes this test to compare the contents of the specified file instead of result values. Automatically calls self.capture_file_contents (which will normally be HasPayload.capture_file_contents) to set that up. The binary argument will be passed through to that function, and indicates that file contents should be read as bytes, not as a string.

Note that if you are using a TestGroup that includes individual SingleTest objects which write to multiple different filenames, leave the filename argument out and HasPayload.capture_file_contents will not be called; you will have to call it yourself on individual SingleTest items.

Returns self for chaining.

def compare_using(self, comparator_fn=None, context_slot=None):
2202    def compare_using(
2203        self,
2204        comparator_fn=None,
2205        context_slot=None
2206    ):
2207        """
2208        Specifies an alternate comparator for this goal (only works for
2209        `potluck.rubrics.ComparisonTest` as the `goal_constructor`). If a
2210        context_slot is also (or only) given, changes the context slot
2211        which will be compared as well.
2212
2213        The comparator function (if provided) must return a comparison
2214        result: a dictionary with "status" and "explanation" keys, where
2215        the status is one of "accomplished", "partial", or "failed". If
2216        no comparison function is provided, the current comparator will
2217        not be changed.
2218
2219        The context slot (if provided) must be a string naming the slot
2220        to use; see `potluck.contexts.Context` for a list of common slot
2221        names, but you could use your own custom slots too by using
2222        `HasPayload.do_setup` and/or `HasPayload.do_cleanup`, which can
2223        modify the context dictionary directly. If no context slot is
2224        specified, the current value will not be changed. Note that
2225        several other methods, like `test_output`, also modify the
2226        context slot and ordering matters; the last method to be called
2227        will determine which context slot is used.
2228
2229        Returns self for chaining.
2230        """
2231        self.ensure_goal_constructor_arg("checker")
2232        if comparator_fn is not None:
2233            self.goal_args["checker"] = comparator_fn
2234        if context_slot is not None:
2235            self.goal_args["context_slot"] = context_slot
2236        return self

Specifies an alternate comparator for this goal (only works for potluck.rubrics.ComparisonTest as the goal_constructor). If a context_slot is also (or only) given, changes the context slot which will be compared as well.

The comparator function (if provided) must return a comparison result: a dictionary with "status" and "explanation" keys, where the status is one of "accomplished", "partial", or "failed". If no comparison function is provided, the current comparator will not be changed.

The context slot (if provided) must be a string naming the slot to use; see potluck.contexts.Context for a list of common slot names, but you could use your own custom slots too by using HasPayload.do_setup and/or HasPayload.do_cleanup, which can modify the context dictionary directly. If no context slot is specified, the current value will not be changed. Note that several other methods, like test_output, also modify the context slot and ordering matters; the last method to be called will determine which context slot is used.

Returns self for chaining.

def succeed_unless_crashed(self):
2238    def succeed_unless_crashed(self):
2239        """
2240        Overrides the comparator such that the goal always succeeds,
2241        unless the context builder fails because of a crash. Modifies
2242        the default goal arguments to note this.
2243
2244        Note that this won't check for captured errors (e.g., by using
2245        `HasGoal.test_output` and/or `HasPayload.capture_output` with
2246        the `capture_errors` option).
2247
2248        Returns self for chaining.
2249        """
2250        self.ensure_goal_constructor_arg("checker")
2251        self.goal_args["checker"] = lambda _1, _2: {
2252            "status": "accomplished",
2253            "explanation": "Test ran without errors."
2254        }
2255        # Set default goal type
2256        self.default_goal_args.setdefault(
2257            "tags",
2258            {}
2259        )["goal_type"] = "process"
2260
2261        # Set default (not explicit) description
2262        if hasattr(self, "base_name"):
2263            if self.base_name == "import":
2264                self.default_goal_args["description"] = (
2265                    "Your program must not crash",
2266                    "Your program must run without crashing.",
2267                    "Your program must not crash",
2268                    "We ran your program and checked if it crashed."
2269                )
2270            else:
2271                self.default_goal_args["description"] = (
2272                    f"<code>{self.base_name}</code> must not crash",
2273                    (
2274                        f"Your <code>{self.base_name}</code> function"
2275                        f" must run without crashing."
2276                    ),
2277                    f"<code>{self.base_name}</code> must not crash",
2278                    (
2279                        f"We ran your <code>{self.base_name}</code>"
2280                        f" function and checked whether it crashed."
2281                    )
2282                )
2283        else:
2284            self.default_goal_args["description"] = (
2285                "Your code must not crash",
2286                "Your code must run without crashing.",
2287                "Your code must not crash",
2288                "We ran your code and checked if it crashed."
2289            )
2290
2291        return self

Overrides the comparator such that the goal always succeeds, unless the context builder fails because of a crash. Modifies the default goal arguments to note this.

Note that this won't check for captured errors (e.g., by using HasGoal.test_output and/or HasPayload.capture_output with the capture_errors option).

Returns self for chaining.

def compare_exactly(self):
2293    def compare_exactly(self):
2294        """
2295        Overrides the comparator (see `compare_using`) with
2296        `potluck.compare.strict_equality_checker`, which compares items
2297        of any type for exact equality (the default
2298        `potluck.compare.omni_compare` function has various grades of
2299        partial success and ignores things like floating point rounding
2300        error). Returns the `TestGroup` for chaining.
2301
2302        Note: this is very rarely what you want, since it has weird edge
2303        cases that the default `potluck.compare.omni_compare` smoothes
2304        over.
2305        """
2306        self.ensure_goal_constructor_arg("checker")
2307        self.goal_args["checker"] = compare.strict_equality_checker
2308        return self

Overrides the comparator (see compare_using) with potluck.compare.strict_equality_checker, which compares items of any type for exact equality (the default potluck.compare.omni_compare function has various grades of partial success and ignores things like floating point rounding error). Returns the TestGroup for chaining.

Note: this is very rarely what you want, since it has weird edge cases that the default potluck.compare.omni_compare smoothes over.

def compare_reports(self):
2310    def compare_reports(self):
2311        """
2312        Overrides the comparator (see `compare_using`) with
2313        `potluck.compare.multiline_strings_are_exactly_equal`, which
2314        compares strings exactly and formats multi-line output nicely.
2315        This is just a convenience function to make this functionality
2316        more prominent; it returns the `TestGroup` for chaining.
2317        """
2318        self.ensure_goal_constructor_arg("checker")
2319        self.goal_args["checker"] = compare.multiline_strings_are_exactly_equal
2320        return self

Overrides the comparator (see compare_using) with potluck.compare.multiline_strings_are_exactly_equal, which compares strings exactly and formats multi-line output nicely. This is just a convenience function to make this functionality more prominent; it returns the TestGroup for chaining.

def compare_strings_gently(self, line_match_threshold=0.5, sequence_match_threshold=0.8):
2322    def compare_strings_gently(
2323        self,
2324        line_match_threshold=0.5,
2325        sequence_match_threshold=0.8
2326    ):
2327        """
2328        Overrides the comparator (see `compare_using`) with
2329        `potluck.compare.very_fuzzy_string_compare`, which compares
2330        strings very roughly. This is just a convenience function to make
2331        this functionality more prominent; it returns the `TestGroup` for
2332        chaining. The `line_match_threshold` and
2333        `sequence_match_threshold` values are passed through to
2334        `compare.very_fuzzy_string_compare`.
2335        """
2336        self.ensure_goal_constructor_arg("checker")
2337        self.goal_args["checker"] = lambda val, ref: (
2338            compare.very_fuzzy_string_compare(
2339                val,
2340                ref,
2341                line_match_threshold,
2342                sequence_match_threshold
2343            )
2344        )
2345        return self

Overrides the comparator (see compare_using) with potluck.compare.very_fuzzy_string_compare, which compares strings very roughly. This is just a convenience function to make this functionality more prominent; it returns the TestGroup for chaining. The line_match_threshold and sequence_match_threshold values are passed through to compare.very_fuzzy_string_compare.

def compare_strings_semi_strict(self):
2347    def compare_strings_semi_strict(self):
2348        """
2349        Overrides the comparator (see `comparator`) with
2350        `potluck.compare.strings_are_equal_modulo_whitespace`, which
2351        compares strings somewhat roughly (errors in whitespace and
2352        capitalization are mostly ignored). This is just a convenience
2353        function to make this functionality more prominent; it returns
2354        the `TestGroup` for chaining.
2355        """
2356        self.ensure_goal_constructor_arg("checker")
2357        self.goal_args["checker"] = (
2358            compare.strings_are_equal_modulo_whitespace
2359        )
2360        return self

Overrides the comparator (see comparator) with potluck.compare.strings_are_equal_modulo_whitespace, which compares strings somewhat roughly (errors in whitespace and capitalization are mostly ignored). This is just a convenience function to make this functionality more prominent; it returns the TestGroup for chaining.

def compare_strings_firmly(self):
2362    def compare_strings_firmly(self):
2363        """
2364        Overrides the comparator (see `comparator`) with
2365        `potluck.compare.strings_are_equal_modulo_most_whitespace`,
2366        which works like
2367        `potluck.compare.strings_are_equal_modulo_whitespace` but it
2368        requires that word boundaries are preserved. This is just a
2369        convenience function to make this functionality more prominent;
2370        it returns the `TestGroup` for chaining.
2371        """
2372        self.ensure_goal_constructor_arg("checker")
2373        self.goal_args["checker"] = (
2374            compare.strings_are_equal_modulo_most_whitespace
2375        )
2376        return self

Overrides the comparator (see comparator) with potluck.compare.strings_are_equal_modulo_most_whitespace, which works like potluck.compare.strings_are_equal_modulo_whitespace but it requires that word boundaries are preserved. This is just a convenience function to make this functionality more prominent; it returns the TestGroup for chaining.

def refine(self, refiner_class, *refiner_args, **refiner_kwargs):
2378    def refine(self, refiner_class, *refiner_args, **refiner_kwargs):
2379        """
2380        Creates a new `RefinedTest` based on the goal to be created by
2381        the current test (actually, based on the associated context
2382        objects; see `RefinedTest`).
2383
2384        You need to provide the class object to be instanced, and you may
2385        provide extra positional and/or keyword arguments that that
2386        refiner requires for initialization, beyond the parent object.
2387        This function returns the new `RefinedTest` instance for
2388        chaining.
2389
2390        Note that typically, it is not necessary for both the original
2391        and refined goals to appear in the rubric, and to achieve that,
2392        simply avoid calling the `goal` method of the original goal.
2393        """
2394        return refiner_class(self, *refiner_args, **refiner_kwargs)

Creates a new RefinedTest based on the goal to be created by the current test (actually, based on the associated context objects; see RefinedTest).

You need to provide the class object to be instanced, and you may provide extra positional and/or keyword arguments that that refiner requires for initialization, beyond the parent object. This function returns the new RefinedTest instance for chaining.

Note that typically, it is not necessary for both the original and refined goals to appear in the rubric, and to achieve that, simply avoid calling the goal method of the original goal.

class SingleTest(HasPayload, HasContext):
2401class SingleTest(HasPayload, HasContext):
2402    """
2403    A `SingleTest` is a single test case for a function (or similar,
2404    like a test of a variable value or a test of importing a whole
2405    module). These things have a payload and a context, and can be (and
2406    usually are) registered as part of a `TestGroup`. The mere
2407    instantiation of a `SingleTest` object adds it to the test registry
2408    which means it will appear on the rubric created by `rubric`, unless
2409    `register` is set to False when it's constructed.
2410
2411    Most modification methods chain by returning the `SingleTest`
2412    object.
2413
2414    In terms of the rubric constructed by the `rubric` function, a
2415    `SingleTest` is actually a placeholder for a context
2416    (`potluck.contexts.Context`) which will be one of possibly multiple
2417    context objects used as testing contexts for a single
2418    `potluck.rubrics.Goal`. This goal object is derived from a
2419    `TestGroup`, which is automatically instantiated as soon as a
2420    `SingleTest` is created, but which will be associated with multiple
2421    `SingleTest` objects that share the same base name and group name.
2422    """
2423    def __init__(
2424        self,
2425        base_name,
2426        group_name="_",
2427        register=True,
2428        payload_constructor=harness.create_run_function_payload,
2429        default_payload_args=None,
2430        default_augmentations=None,
2431        default_context_args=None
2432    ):
2433        self.base_name = base_name
2434        self.group_name = group_name
2435        """
2436        A `base_name` is required and defines the base name for the
2437        `TestGroup` object that this `SingleTest` will register under; a
2438        `group_name` is optional and defaults to '_'. If `register` is
2439        set to False, this test won't automatically be registered with a
2440        test group, which generally means it also won't be used as part
2441        of a rubric.
2442
2443        The keyword arguments for the `HasPayload` and `HasContext`
2444        constructors will be passed through, but also receive defaults
2445        if not provided at this stage.
2446        """
2447        default_augmentations = default_augmentations or {
2448            "capturing_printed_output": {"capture_errors": False},
2449            "with_timeout": {"time_limit": 5},
2450            "run_in_sandbox": {},
2451            "run_for_base_and_ref_values": {},
2452        }
2453        # leave default_payload_args and default_context_args as None if
2454        # not provided so that HasContext.__init__ and
2455        # HasPayload.__init__ can establish their own defaults
2456
2457        # Initialize our payload setup
2458        HasPayload.__init__(
2459            self,
2460            payload_constructor=payload_constructor,
2461            default_payload_args=default_payload_args,
2462            default_augmentations=default_augmentations
2463        )
2464
2465        # Initialize our context info
2466        HasContext.__init__(
2467            self,
2468            default_context_args=default_context_args
2469        )
2470
2471        # Register ourself if requested to
2472        self.group = None
2473        if register:
2474            group(
2475                base_name,
2476                group_name,
2477                create=True
2478            ).add(self)

A SingleTest is a single test case for a function (or similar, like a test of a variable value or a test of importing a whole module). These things have a payload and a context, and can be (and usually are) registered as part of a TestGroup. The mere instantiation of a SingleTest object adds it to the test registry which means it will appear on the rubric created by rubric, unless register is set to False when it's constructed.

Most modification methods chain by returning the SingleTest object.

In terms of the rubric constructed by the rubric function, a SingleTest is actually a placeholder for a context (potluck.contexts.Context) which will be one of possibly multiple context objects used as testing contexts for a single potluck.rubrics.Goal. This goal object is derived from a TestGroup, which is automatically instantiated as soon as a SingleTest is created, but which will be associated with multiple SingleTest objects that share the same base name and group name.

SingleTest( base_name, group_name='_', register=True, payload_constructor=<function create_run_function_payload>, default_payload_args=None, default_augmentations=None, default_context_args=None)
2423    def __init__(
2424        self,
2425        base_name,
2426        group_name="_",
2427        register=True,
2428        payload_constructor=harness.create_run_function_payload,
2429        default_payload_args=None,
2430        default_augmentations=None,
2431        default_context_args=None
2432    ):
2433        self.base_name = base_name
2434        self.group_name = group_name
2435        """
2436        A `base_name` is required and defines the base name for the
2437        `TestGroup` object that this `SingleTest` will register under; a
2438        `group_name` is optional and defaults to '_'. If `register` is
2439        set to False, this test won't automatically be registered with a
2440        test group, which generally means it also won't be used as part
2441        of a rubric.
2442
2443        The keyword arguments for the `HasPayload` and `HasContext`
2444        constructors will be passed through, but also receive defaults
2445        if not provided at this stage.
2446        """
2447        default_augmentations = default_augmentations or {
2448            "capturing_printed_output": {"capture_errors": False},
2449            "with_timeout": {"time_limit": 5},
2450            "run_in_sandbox": {},
2451            "run_for_base_and_ref_values": {},
2452        }
2453        # leave default_payload_args and default_context_args as None if
2454        # not provided so that HasContext.__init__ and
2455        # HasPayload.__init__ can establish their own defaults
2456
2457        # Initialize our payload setup
2458        HasPayload.__init__(
2459            self,
2460            payload_constructor=payload_constructor,
2461            default_payload_args=default_payload_args,
2462            default_augmentations=default_augmentations
2463        )
2464
2465        # Initialize our context info
2466        HasContext.__init__(
2467            self,
2468            default_context_args=default_context_args
2469        )
2470
2471        # Register ourself if requested to
2472        self.group = None
2473        if register:
2474            group(
2475                base_name,
2476                group_name,
2477                create=True
2478            ).add(self)

A base payload creation function may be supplied (default is potluck.harness.create_run_function_payload).

Defaults for payload arguments and/or augmentations may be supplied. Payload arguments are just passed as keyword arguments to the payload constructor.

Augmentations should be a dictionary where keys name payload augmentation functions in the potluck.harness module, and values are dictionaries of keyword arguments to supply to those augmentations.

group_name

A base_name is required and defines the base name for the TestGroup object that this SingleTest will register under; a group_name is optional and defaults to '_'. If register is set to False, this test won't automatically be registered with a test group, which generally means it also won't be used as part of a rubric.

The keyword arguments for the HasPayload and HasContext constructors will be passed through, but also receive defaults if not provided at this stage.

class TestImport(SingleTest):
2481class TestImport(SingleTest):
2482    """
2483    A `TestImport` is a test which involves importing an entire module,
2484    and by default tests the printed output from that process. It is
2485    a `SingleTest`, and by default will be automatically registered under
2486    the name "import" with group name '_'.
2487
2488    Specialization methods like `wrap_module` can be used to control the
2489    details of the test; see `HasPayload` and `HasContext` for more
2490    details.
2491    """
2492    def __init__(self, group_name="_", register=True):
2493        """
2494        The module to be imported is defined by the currently-active
2495        filename (see `potluck.contexts.FileContext`).
2496
2497        A group name other than the default '_' may be provided, and
2498        automatic registration with a group may be disabled by setting
2499        `register` to False.
2500        """
2501        super().__init__(
2502            "import",
2503            group_name=group_name,
2504            register=register,
2505            payload_constructor=harness.create_module_import_payload,
2506            default_payload_args={
2507                "name_prefix": "test_",
2508                "use_fix_parse": True,
2509                "prep": None,
2510                "wrap": None
2511            },
2512            # default_augmentations is left as.. default
2513            default_context_args={
2514                # builder will be added elsehow
2515                # description if omitted has a smart default
2516                "display_product": (
2517                    contexts.build_context_value_displayer(
2518                        "output",
2519                        labels=[
2520                            "Your output",
2521                            "Solution output",
2522                            "Comparison"
2523                        ]
2524                    )
2525                ),
2526                # Capture auto-filename at instantiation time, and also
2527                # make sure we'll have access to sandboxes.
2528                "depends": contexts.auto(
2529                    "filename",
2530                    "file_path",
2531                    "ref_filename",
2532                    "ref_file_path",
2533                    "sandbox",
2534                    "ref_sandbox",
2535                ),
2536            }
2537        )
2538
2539        # If we're in a group, update that group's default goal
2540        # description to include our relevant filename...
2541        if self.group:
2542            # Figure out which file we're (automatically) targeting
2543            target = None
2544            # (should be exactly one context, and it should have a
2545            # target_file attribute)
2546            for ctx in self.default_context_args["depends"]:
2547                if hasattr(ctx, "target_file"):
2548                    target = ctx.target_file
2549                    break
2550
2551            # Override default description with a better one
2552            if target is not None:
2553                self.group.default_goal_args["description"] = (
2554                    (
2555                        f"Running <code>{target}</code> must exhibit"
2556                        f" the correct behavior"
2557                    ),
2558                    (
2559                        f"When we run <code>{target}</code> as a whole"
2560                        f" file, the pattern of printed output based"
2561                        f" on inputs must match the solution's"
2562                        f" behavior."
2563                    )
2564                )

A TestImport is a test which involves importing an entire module, and by default tests the printed output from that process. It is a SingleTest, and by default will be automatically registered under the name "import" with group name '_'.

Specialization methods like wrap_module can be used to control the details of the test; see HasPayload and HasContext for more details.

TestImport(group_name='_', register=True)
2492    def __init__(self, group_name="_", register=True):
2493        """
2494        The module to be imported is defined by the currently-active
2495        filename (see `potluck.contexts.FileContext`).
2496
2497        A group name other than the default '_' may be provided, and
2498        automatic registration with a group may be disabled by setting
2499        `register` to False.
2500        """
2501        super().__init__(
2502            "import",
2503            group_name=group_name,
2504            register=register,
2505            payload_constructor=harness.create_module_import_payload,
2506            default_payload_args={
2507                "name_prefix": "test_",
2508                "use_fix_parse": True,
2509                "prep": None,
2510                "wrap": None
2511            },
2512            # default_augmentations is left as.. default
2513            default_context_args={
2514                # builder will be added elsehow
2515                # description if omitted has a smart default
2516                "display_product": (
2517                    contexts.build_context_value_displayer(
2518                        "output",
2519                        labels=[
2520                            "Your output",
2521                            "Solution output",
2522                            "Comparison"
2523                        ]
2524                    )
2525                ),
2526                # Capture auto-filename at instantiation time, and also
2527                # make sure we'll have access to sandboxes.
2528                "depends": contexts.auto(
2529                    "filename",
2530                    "file_path",
2531                    "ref_filename",
2532                    "ref_file_path",
2533                    "sandbox",
2534                    "ref_sandbox",
2535                ),
2536            }
2537        )
2538
2539        # If we're in a group, update that group's default goal
2540        # description to include our relevant filename...
2541        if self.group:
2542            # Figure out which file we're (automatically) targeting
2543            target = None
2544            # (should be exactly one context, and it should have a
2545            # target_file attribute)
2546            for ctx in self.default_context_args["depends"]:
2547                if hasattr(ctx, "target_file"):
2548                    target = ctx.target_file
2549                    break
2550
2551            # Override default description with a better one
2552            if target is not None:
2553                self.group.default_goal_args["description"] = (
2554                    (
2555                        f"Running <code>{target}</code> must exhibit"
2556                        f" the correct behavior"
2557                    ),
2558                    (
2559                        f"When we run <code>{target}</code> as a whole"
2560                        f" file, the pattern of printed output based"
2561                        f" on inputs must match the solution's"
2562                        f" behavior."
2563                    )
2564                )

The module to be imported is defined by the currently-active filename (see potluck.contexts.FileContext).

A group name other than the default '_' may be provided, and automatic registration with a group may be disabled by setting register to False.

class TestValue(SingleTest):
2567class TestValue(SingleTest):
2568    """
2569    A `TestValue` is a test which involves inspecting the value of a
2570    variable in the submitted module. It is a `SingleTest`, and by
2571    default will be automatically registered under its variable name
2572    with group name '_'.
2573
2574    Specialization methods like `use_decorations` can be used to control
2575    the details of the test; see `HasPayload` and `HasContext` for more
2576    details. Note that many of those methods don't apply to this test,
2577    since we're just retrieving a variable value, not running a function
2578    or importing a module.
2579    """
2580    def __init__(self, varname, group_name="_", register=True):
2581        """
2582        The name of the variable to be inspected required.
2583
2584        A group name other than the default '_' may be provided, and
2585        automatic registration with a group may be disabled by setting
2586        `register` to False.
2587        """
2588        super().__init__(
2589            varname,
2590            group_name=group_name,
2591            register=register,
2592            payload_constructor=harness.create_read_variable_payload,
2593            default_payload_args={"varname": varname},
2594            default_augmentations={
2595                "with_timeout": {"time_limit": 5},
2596                "run_in_sandbox": {},
2597                "run_for_base_and_ref_values": {},
2598            },
2599            default_context_args={
2600                # builder will be added elsehow
2601                # description if omitted has a smart default
2602                "display_product": (
2603                    contexts.build_context_value_displayer(
2604                        "value",
2605                        labels=[
2606                            "Your value",
2607                            "Solution value",
2608                            "Comparison"
2609                        ]
2610                    )
2611                ),
2612                # Capture auto-filename at instantiation time
2613                "depends": contexts.auto("module", "ref_module"),
2614            }
2615        )

A TestValue is a test which involves inspecting the value of a variable in the submitted module. It is a SingleTest, and by default will be automatically registered under its variable name with group name '_'.

Specialization methods like use_decorations can be used to control the details of the test; see HasPayload and HasContext for more details. Note that many of those methods don't apply to this test, since we're just retrieving a variable value, not running a function or importing a module.

TestValue(varname, group_name='_', register=True)
2580    def __init__(self, varname, group_name="_", register=True):
2581        """
2582        The name of the variable to be inspected required.
2583
2584        A group name other than the default '_' may be provided, and
2585        automatic registration with a group may be disabled by setting
2586        `register` to False.
2587        """
2588        super().__init__(
2589            varname,
2590            group_name=group_name,
2591            register=register,
2592            payload_constructor=harness.create_read_variable_payload,
2593            default_payload_args={"varname": varname},
2594            default_augmentations={
2595                "with_timeout": {"time_limit": 5},
2596                "run_in_sandbox": {},
2597                "run_for_base_and_ref_values": {},
2598            },
2599            default_context_args={
2600                # builder will be added elsehow
2601                # description if omitted has a smart default
2602                "display_product": (
2603                    contexts.build_context_value_displayer(
2604                        "value",
2605                        labels=[
2606                            "Your value",
2607                            "Solution value",
2608                            "Comparison"
2609                        ]
2610                    )
2611                ),
2612                # Capture auto-filename at instantiation time
2613                "depends": contexts.auto("module", "ref_module"),
2614            }
2615        )

The name of the variable to be inspected required.

A group name other than the default '_' may be provided, and automatic registration with a group may be disabled by setting register to False.

class TestCase(SingleTest):
2618class TestCase(SingleTest):
2619    """
2620    A `TestCase` is a `SingleTest` representing a unit test for a
2621    function, with a basic setup (test equality of return values) by
2622    default. Different behavior may be achieved by calling various
2623    specialization methods (for example, to specify stdin contents).
2624
2625    The base name for the test group a case registers with will be the
2626    name of the function being tested. If you want to separate tests
2627    that share a function name into multiple groups (i.e., goals),
2628    specify distinct `group_name` values for the different `TestCase`
2629    objects you create.
2630
2631    Instantiating a `TestCase` is not enough to create a goal: goals are
2632    derived from `TestGroup` objects which group one or more test cases
2633    together (this is usually desirable, although it's possible to have
2634    groups that contain only a single test each). Call the `group`
2635    function to retrieve the implied group after instantiating one or
2636    more `TestCase` objects that share a function name and group name.
2637    """
2638    def __init__(
2639        self,
2640        fn_name,
2641        args=None,
2642        kwargs=None,
2643        group_name="_",
2644        register=True
2645    ):
2646        """
2647        The name of the function to test is the only required value,
2648        although a tuple of arguments is usually also provided (can be
2649        omitted to call without arguments). An optional dictionary of
2650        keyword arguments may also be supplied.
2651
2652        Normally all tests of a single function will be collapsed into a
2653        single goal, but by giving tests different `group_name` strings
2654        you can change this (having more different goals generally makes
2655        things a bit more forgiving). The group name is arbitrary; the
2656        default group name is '_'.
2657
2658        If `register` is given as False (True is the default), the test
2659        case won't be registered and will not be available for grouping
2660        or turning into a goal.
2661        """
2662        args = args or []
2663        kwargs = kwargs or {}
2664        self.args = args
2665        self.kwargs = kwargs
2666
2667        self.fn_name = fn_name
2668
2669        super().__init__(
2670            self.fn_name,
2671            group_name,
2672            register,
2673            payload_constructor=harness.create_run_function_payload,
2674            default_payload_args={
2675                "fname": fn_name,
2676                "posargs": args,
2677                "kwargs": kwargs,
2678                "copy_args": True
2679            },
2680            # default_augmentations has a good... default
2681            default_context_args={
2682                # builder will be added elsehow
2683                # description if omitted has a smart default
2684                "display_product": (
2685                    contexts.build_context_value_displayer(
2686                        "value",
2687                        labels=[
2688                            "Your result",
2689                            "Solution result",
2690                            "Comparison"
2691                        ]
2692                    )
2693                ),
2694                # Capture auto-filename at instantiation time
2695                "depends": contexts.auto("module", "ref_module"),
2696            }
2697        )

A TestCase is a SingleTest representing a unit test for a function, with a basic setup (test equality of return values) by default. Different behavior may be achieved by calling various specialization methods (for example, to specify stdin contents).

The base name for the test group a case registers with will be the name of the function being tested. If you want to separate tests that share a function name into multiple groups (i.e., goals), specify distinct group_name values for the different TestCase objects you create.

Instantiating a TestCase is not enough to create a goal: goals are derived from TestGroup objects which group one or more test cases together (this is usually desirable, although it's possible to have groups that contain only a single test each). Call the group function to retrieve the implied group after instantiating one or more TestCase objects that share a function name and group name.

TestCase(fn_name, args=None, kwargs=None, group_name='_', register=True)
2638    def __init__(
2639        self,
2640        fn_name,
2641        args=None,
2642        kwargs=None,
2643        group_name="_",
2644        register=True
2645    ):
2646        """
2647        The name of the function to test is the only required value,
2648        although a tuple of arguments is usually also provided (can be
2649        omitted to call without arguments). An optional dictionary of
2650        keyword arguments may also be supplied.
2651
2652        Normally all tests of a single function will be collapsed into a
2653        single goal, but by giving tests different `group_name` strings
2654        you can change this (having more different goals generally makes
2655        things a bit more forgiving). The group name is arbitrary; the
2656        default group name is '_'.
2657
2658        If `register` is given as False (True is the default), the test
2659        case won't be registered and will not be available for grouping
2660        or turning into a goal.
2661        """
2662        args = args or []
2663        kwargs = kwargs or {}
2664        self.args = args
2665        self.kwargs = kwargs
2666
2667        self.fn_name = fn_name
2668
2669        super().__init__(
2670            self.fn_name,
2671            group_name,
2672            register,
2673            payload_constructor=harness.create_run_function_payload,
2674            default_payload_args={
2675                "fname": fn_name,
2676                "posargs": args,
2677                "kwargs": kwargs,
2678                "copy_args": True
2679            },
2680            # default_augmentations has a good... default
2681            default_context_args={
2682                # builder will be added elsehow
2683                # description if omitted has a smart default
2684                "display_product": (
2685                    contexts.build_context_value_displayer(
2686                        "value",
2687                        labels=[
2688                            "Your result",
2689                            "Solution result",
2690                            "Comparison"
2691                        ]
2692                    )
2693                ),
2694                # Capture auto-filename at instantiation time
2695                "depends": contexts.auto("module", "ref_module"),
2696            }
2697        )

The name of the function to test is the only required value, although a tuple of arguments is usually also provided (can be omitted to call without arguments). An optional dictionary of keyword arguments may also be supplied.

Normally all tests of a single function will be collapsed into a single goal, but by giving tests different group_name strings you can change this (having more different goals generally makes things a bit more forgiving). The group name is arbitrary; the default group name is '_'.

If register is given as False (True is the default), the test case won't be registered and will not be available for grouping or turning into a goal.

class TestBlock(SingleTest):
2700class TestBlock(SingleTest):
2701    """
2702    A `SingleTest` which runs a block of code, using the result value
2703    of the final expression in the block as the value to be tested. A
2704    name for the block must be provided, and will be used as the base
2705    name of the `SingleTest`, with "_" as the default group name.
2706
2707    A fake version of the block to display to students may be provided
2708    alongside the actual code to run.
2709    """
2710    def __init__(
2711        self,
2712        name,
2713        block,
2714        actual=None,
2715        group_name="_",
2716        register=True
2717    ):
2718        """
2719        The name for the block, plus the block of code itself (as a
2720        multi-line string) must be provided. The actual block name will
2721        be the provided name prefixed with 'block:', to prevent the
2722        possibility of identifier overlaps with other kinds of tests.
2723
2724        Optionally, if you want to simplify the appearance of the code, a
2725        multi-line string of code to run, OR a list of AST nodes to
2726        execute may be provided as the "actual" argument, in which case
2727        the "block" value will just be used for display purposes.
2728
2729        A group name other than the default '_' may be provided, and
2730        automatic registration with a group may be disabled by setting
2731        `register` to False.
2732        """
2733        self.name = "block:" + name
2734
2735        self.block = block
2736
2737        if not isinstance(block, str):
2738            raise TypeError(
2739                f"The block value must be a string. If you want to supply"
2740                f" a list of AST nodes, use the 'actual' parameter."
2741                f" (Failed for '{self.name}')"
2742            )
2743
2744        if actual is None:
2745            actual = block
2746
2747        if isinstance(actual, str):
2748            try:
2749                self.nodes = ast.parse(actual).body
2750            except Exception:
2751                raise ValueError(
2752                    f"Failed to compile code block for test"
2753                    f" '{self.name}'."
2754                )
2755            if len(self.nodes) == 0:
2756                raise ValueError(
2757                    f"Empty code string in `TestBlock` constructor."
2758                    f" (Failed for '{self.name}')"
2759                )
2760        else:
2761            try:
2762                actual = list(actual)
2763            except Exception:
2764                raise TypeError(
2765                    f"The 'actual' block of code must be provided as a"
2766                    f" string or as a list (or other iterable) of AST"
2767                    f" nodes). (Failed for '{self.name}')"
2768                )
2769            if len(block) == 0:
2770                raise ValueError(
2771                    f"Empty code block in `TestBlock` constructor."
2772                    f" (Failed for '{self.name}')"
2773                )
2774            if not isinstance(block[0], ast.AST):
2775                raise TypeError(
2776                    f"First code block item in `TestBlock` was not an"
2777                    f" AST node. (Failed for '{self.name}')"
2778                )
2779            self.nodes = actual
2780
2781        super().__init__(
2782            self.name,
2783            group_name,
2784            register,
2785            payload_constructor=harness.create_execute_code_block_payload,
2786            default_payload_args={
2787                "block_name": self.name,
2788                "src": self.block,
2789                "nodes": self.nodes
2790            },
2791            # default_augmentations has a good... default
2792            default_context_args={
2793                # builder will be added elsehow
2794                # description if omitted has a smart default
2795                "display_product": (
2796                    contexts.build_context_value_displayer(
2797                        "value",
2798                        labels=[
2799                            "Your result",
2800                            "Solution result",
2801                            "Comparison"
2802                        ]
2803                    )
2804                ),
2805                # Capture auto-filename at instantiation time
2806                "depends": contexts.auto("module", "ref_module"),
2807            }
2808        )

A SingleTest which runs a block of code, using the result value of the final expression in the block as the value to be tested. A name for the block must be provided, and will be used as the base name of the SingleTest, with "_" as the default group name.

A fake version of the block to display to students may be provided alongside the actual code to run.

TestBlock(name, block, actual=None, group_name='_', register=True)
2710    def __init__(
2711        self,
2712        name,
2713        block,
2714        actual=None,
2715        group_name="_",
2716        register=True
2717    ):
2718        """
2719        The name for the block, plus the block of code itself (as a
2720        multi-line string) must be provided. The actual block name will
2721        be the provided name prefixed with 'block:', to prevent the
2722        possibility of identifier overlaps with other kinds of tests.
2723
2724        Optionally, if you want to simplify the appearance of the code, a
2725        multi-line string of code to run, OR a list of AST nodes to
2726        execute may be provided as the "actual" argument, in which case
2727        the "block" value will just be used for display purposes.
2728
2729        A group name other than the default '_' may be provided, and
2730        automatic registration with a group may be disabled by setting
2731        `register` to False.
2732        """
2733        self.name = "block:" + name
2734
2735        self.block = block
2736
2737        if not isinstance(block, str):
2738            raise TypeError(
2739                f"The block value must be a string. If you want to supply"
2740                f" a list of AST nodes, use the 'actual' parameter."
2741                f" (Failed for '{self.name}')"
2742            )
2743
2744        if actual is None:
2745            actual = block
2746
2747        if isinstance(actual, str):
2748            try:
2749                self.nodes = ast.parse(actual).body
2750            except Exception:
2751                raise ValueError(
2752                    f"Failed to compile code block for test"
2753                    f" '{self.name}'."
2754                )
2755            if len(self.nodes) == 0:
2756                raise ValueError(
2757                    f"Empty code string in `TestBlock` constructor."
2758                    f" (Failed for '{self.name}')"
2759                )
2760        else:
2761            try:
2762                actual = list(actual)
2763            except Exception:
2764                raise TypeError(
2765                    f"The 'actual' block of code must be provided as a"
2766                    f" string or as a list (or other iterable) of AST"
2767                    f" nodes). (Failed for '{self.name}')"
2768                )
2769            if len(block) == 0:
2770                raise ValueError(
2771                    f"Empty code block in `TestBlock` constructor."
2772                    f" (Failed for '{self.name}')"
2773                )
2774            if not isinstance(block[0], ast.AST):
2775                raise TypeError(
2776                    f"First code block item in `TestBlock` was not an"
2777                    f" AST node. (Failed for '{self.name}')"
2778                )
2779            self.nodes = actual
2780
2781        super().__init__(
2782            self.name,
2783            group_name,
2784            register,
2785            payload_constructor=harness.create_execute_code_block_payload,
2786            default_payload_args={
2787                "block_name": self.name,
2788                "src": self.block,
2789                "nodes": self.nodes
2790            },
2791            # default_augmentations has a good... default
2792            default_context_args={
2793                # builder will be added elsehow
2794                # description if omitted has a smart default
2795                "display_product": (
2796                    contexts.build_context_value_displayer(
2797                        "value",
2798                        labels=[
2799                            "Your result",
2800                            "Solution result",
2801                            "Comparison"
2802                        ]
2803                    )
2804                ),
2805                # Capture auto-filename at instantiation time
2806                "depends": contexts.auto("module", "ref_module"),
2807            }
2808        )

The name for the block, plus the block of code itself (as a multi-line string) must be provided. The actual block name will be the provided name prefixed with 'block:', to prevent the possibility of identifier overlaps with other kinds of tests.

Optionally, if you want to simplify the appearance of the code, a multi-line string of code to run, OR a list of AST nodes to execute may be provided as the "actual" argument, in which case the "block" value will just be used for display purposes.

A group name other than the default '_' may be provided, and automatic registration with a group may be disabled by setting register to False.

class Check:
2815class Check:
2816    """
2817    `Check` is the base class for a few different classes that represent
2818    simplified/specialized `potluck.rubrics.ImplementationCheck`s. Each
2819    `Check` should be assigned to one of the categories:
2820
2821    - foundational (deprecated)
2822    - core
2823    - extra
2824    - feedback_only
2825    - auto
2826
2827    Generally only 'core' and 'extra' are needed; see
2828    `potluck.rubrics.foundational_core_extras_metric` and
2829    `potluck.rubrics.core_extras_categorized_metric`. Additionally, if
2830    using the later metric, a goal type should be included, which is
2831    "auto" by default but could reasonably be "procedure", "style" or
2832    "other".
2833
2834    When a Check's category/goal-type is set to 'auto' (the default
2835    for both) that property will be inherited from the parent Check if
2836    this check is a sub-rule, or set to 'core'/'procedure' if not.
2837
2838    When a check has a different category or goal type than its parent
2839    check, a copy of that parent check will be created belonging to the
2840    child category/type, and the original parent check won't include the
2841    different-category/type child. Also, any other children of the same
2842    parent that belong to the same category and goal type will be
2843    included on a single new-category/type copy of the parent, so it's
2844    not as if each alternate-category/type child creates its own parent
2845    `Check`.
2846
2847    This setup allows for extra requirements to be specified as the
2848    leaves of a `Check` subrule tree without having to specify the whole
2849    tree twice.
2850
2851    Note that the identifiers of each `Check` will be concatenated with
2852    their parent's identifiers, minus the 'check:' prefix, and separated
2853    by colons, so give each `Check` a better chance of ending up with a
2854    unique identifier before numeric-suffix-addition.
2855    """
2856    def __init__(
2857        self,
2858        identifier,
2859        patterns,
2860        limits,
2861        name=None,
2862        category='auto',
2863        goal_type='auto'
2864    ):
2865        """
2866        A check needs an identifier, a list of patterns (could be
2867        length-one), a tuple of limits (min/max, either or even both may
2868        be None), and it may specify a name instead of deriving one
2869        automatically from the patterns (see
2870        `potluck.rubrics.ImplementationCheck`), a category string (e.g.,
2871        'extra' instead of defaulting to 'auto'), and/or a goal type
2872        (e.g., 'style' instead of defaulting to 'auto'). All other
2873        `potluck.rubrics.ImplementationCheck` details are specified via
2874        calling methods on the object after it is created. The name may
2875        be a string containing HTML code, or a 2-item tuple to explicitly
2876        specify singular and plural forms of the name (otherwise an 's'
2877        will be added to the end of the name to generate the plural
2878        form).
2879        """
2880        self.taskid = file_utils.deduce_task_id()
2881
2882        if not isinstance(identifier, str):
2883            raise TypeError(
2884                "The identifier for a Check must be a string."
2885            )
2886        self.identifier = identifier
2887
2888        if not isinstance(patterns, (list, tuple)):
2889            raise TypeError(
2890                "Patterns for a check must be a list or tuple (may be"
2891              + " length-1)"
2892            )
2893
2894        # When we instantiate the Check we get the auto-context-provider
2895        # for the "scope" slot; this will be used later during goal
2896        # instantiation.
2897        self.test_in = { "contexts": contexts.auto("scope") }
2898
2899        # Core fields
2900        self.category = category
2901        self.goal_type = goal_type
2902        self.patterns = patterns
2903        self.limits = limits
2904
2905        if isinstance(name, str):
2906            # automatic pluralization by default
2907            self.name = name, name + 's'
2908        else:
2909            # Use a 2-item tuple to specify singular and plural forms
2910            self.name = name
2911
2912        self.subrules = []
2913
2914        self._description = None
2915        self._match_filters = []
2916        self._softmin = False
2917        self._softmax = False
2918        self._outside = None
2919        self._callees = False
2920        self._subslip = None
2921        self._match_identity_function = lambda code, node, env: (
2922            tuple(node) if isinstance(node, list) else node
2923        )
2924
2925        # Note: this is only set via FunctionCall.require_of_def
2926        self._check_in_def = False
2927
2928        self._force_smaller_match = False
2929
2930        # Register this Check
2931        checklist(category, goal_type, create=True).append(self)
2932
2933    def set_description(
2934        self,
2935        title,
2936        summary,
2937        final_title=None,
2938        final_summary=None
2939    ):
2940        """
2941        Sets up a custom description for this `Check`. If not used, a
2942        default description will be constructed based on the parameters
2943        of the check. The `title` and `summary` arguments are the rubric
2944        entry and associated description to be used when displaying an
2945        ungraded rubric, while the optional `final_title` and
2946        `final_summary` are the items to be used when displaying the
2947        goal as part of a graded rubric. If the final title and/or
2948        summary are omitted, the normal title/summary are used.
2949
2950        This function returns the `Check` for chaining.
2951        """
2952        if final_title is None:
2953            final_title = title
2954
2955        if final_summary is None:
2956            final_summary = summary
2957
2958        self._description = (title, summary, final_title, final_summary)
2959        return self
2960
2961    def match_filter(self, filter_function):
2962        """
2963        Adds a custom filtering function to this check that can throw out
2964        some potential matches. The filtering function will be given the
2965        entire submitted AST tree, the (potentially) matching AST node,
2966        and the binding environment of the match, and it should return
2967        True or False, where False will filter out that match. You can
2968        call this function multiple times and each individual match
2969        filter will be ANDed with the others.
2970
2971        This function returns the `Check` object for chaining.
2972        """
2973        self._match_filters.append(filter_function)
2974        return self
2975
2976    def softmin(self, value=True):
2977        """
2978        Turns the lower limit for matches for this check into a soft
2979        limit, so that too few copies still counts as partially
2980        completing this check overall. If the value argument is either
2981        "warn" or "note", then when too few matches are found, the check
2982        still succeeds, but a warning (or note) is generated that
2983        describes the unexpectedly low number of occurrences.
2984
2985        The value could also be a number, which establishes an alternate
2986        lower bound on the number of matches that will count for partial
2987        success on the check. E.g., if the min is 3, you could set the
2988        softmin at 2.5 and then a situation where there were 2 full and
2989        one partial matches would count as a partial match for the check
2990        overall.
2991
2992        This function returns the `Check` object for chaining.
2993        """
2994        self._softmin = value
2995        return self
2996
2997    def softmax(self, value=True):
2998        """
2999        Turns the upper limit for matches into a soft limit, just as with
3000        `softmin`. Also accepts the strings "warn" or "note", as well as
3001        integer values. Unlike `softmin`, when setting this value as a
3002        number, partial matches are ignored and will not push the rule
3003        over its hard or soft limits.
3004
3005        This function returns the `Check` object for chaining.
3006        """
3007        self._softmax = value
3008        return self
3009
3010    def outside(self, patterns):
3011        """
3012        Accepts a list of patterns (strings containing mast pseudo-code)
3013        or a single pattern string, and sets that as the exclusion
3014        pattern for this rule, which will ensure that matches which occur
3015        inside one of those patterns are ignored. Consider using values
3016        like `potluck.patterns.IF_PATTERN` or
3017        `potluck.patterns.ALL_FOR_AND_WHILE_LOOP_PATTERNS` as the
3018        patterns argument.
3019
3020        Note that currently, this function's effects are not
3021        automatically described by the description of the Check it's
3022        applied to, so you'll almost always need to use set_description
3023        to describe what the check does yourself.
3024        TODO: Some automatic default for that?
3025
3026        This function returns the `Check` object for chaining.
3027        """
3028        self._outside = patterns
3029        return self
3030
3031    def check_callees(self, turn_on=True):
3032        """
3033        Turns on callee-checking for this rule (or turns it off if given
3034        False as an argument). This means that matches for this rule will
3035        be searched for within the code of any locally-defined functions
3036        that are called from the code being inspected, which helps find
3037        things that you're looking for which a student puts into a helper
3038        function. Applying this to a top-level check is generally not
3039        useful, since any top-level checks already look for matches in
3040        the entire submitted module; it should always be applied to
3041        sub-rules.
3042
3043        TODO: This feature is still (as of 2020-6) a bit unstable and may
3044        slow things down substantially in some cases.
3045
3046        This function returns the `Check` object for chaining.
3047        """
3048        self._callees = turn_on
3049        return self
3050
3051    def subrule_tolerance(self, tolerance=0):
3052        """
3053        Sets the number of sub-rules that are allowed to go unmatched
3054        while still counting this rule as a partial match. The argument
3055        is a number, which may be fractional, since a partially-matched
3056        sub-rule counts as 0.5 of a fully-matched rule. By default the
3057        number is 0: if any sub-rule is unmatched, the entire
3058        match-in-consideration is ignored entirely.
3059
3060        This function returns the `Check` object for chaining.
3061        """
3062        self._subslip = tolerance
3063        return self
3064
3065    def count_using(self, identity_function):
3066        """
3067        Sets up a custom function to determine the identity of a match,
3068        which affects how matches are counting when considering limits.
3069        This function will be given three arguments: an AST node for the
3070        entire file, a matching AST node (or list of nodes if the match
3071        ends up matching something like a function body) and a list of
3072        matching environments (dictionaries mapping string keys to AST
3073        nodes). It must return a hashable object, and the number of
3074        matches will be determined by the cardinality of the set of
3075        such objects returned by all matching node/environments combos.
3076        It may also return a list of hashable objects in which case
3077        they'll each be mixed into the set to be counted.
3078
3079        This function returns the `Check` object for chaining.
3080        """
3081        self._match_identity_function = identity_function
3082
3083        return self
3084
3085    def force_smaller(self, force=True):
3086        """
3087        Forces a match for this rule to be smaller than the match for its
3088        super-rule (or smaller than the whole module if there is no
3089        super-rule). Set force to False to disable this behavior instead
3090        once it's been enabled (default is disabled).
3091
3092        Use this to force nested equivalent rules (like two nested Loops)
3093        to actually match nested structures.
3094
3095        Returns self for chaining.
3096        """
3097        self._force_smaller_match = force
3098
3099        return self
3100
3101    def require(self, *checks):
3102        """
3103        Adds one or more new sub-rules which much be matched within the
3104        code matched by this rule in order for a full match to occur. Use
3105        the `subrule_tolerance` method on the parent `Check` if you want
3106        to allow some required sub-rules to go unmatched while still
3107        generating a partial match. Use the `check_callees` method on the
3108        subrule being added if you want to search for that pattern in
3109        helper functions as well as in the scope of the match created by
3110        the parent rule.
3111
3112        The given `Check` objects will be appended to the subrules field
3113        of this parent object, which you can use to traverse all subrules
3114        if you need to. They will also be de-registered as top-level
3115        `Check`s.
3116
3117        This function returns the `Check` object for chaining.
3118
3119        WARNINGS:
3120        - Only inspects callees where the function position is a name
3121          (not an arbitrary expression)
3122        - Searches the top-level task code node for this name
3123          without understanding shadowing and without considering
3124          arguments/parameters
3125        - Attempts to match the full pattern within a single
3126          function (currently cannot automatically split pattern
3127          across a call)
3128        - Likely to cause even more exponential blowup
3129        - No attempts are made to respect scope when unifying
3130          env with match environments in callees
3131        """
3132        self.subrules.extend(checks)
3133
3134        # Remove these things from our checklist since they'll be
3135        # reporting to this check as their parent
3136        for ch in checks:
3137            checklist(ch.category, ch.goal_type).remove(ch)
3138
3139        return self
3140
3141    def set_identifier(self, identifier):
3142        """
3143        Explicitly sets the identifier to the given string. Useful to
3144        manually disambiguate multiple goals whose identifiers would
3145        otherwise be the same.
3146
3147        Returns self for chaining.
3148        """
3149        self.identifier = identifier
3150        return self
3151
3152    def build_implementation_checks(
3153        self,
3154        id_prefix=None,
3155        prefix=None,
3156        default_category='core',
3157        default_goal_type='procedure'
3158    ):
3159        """
3160        Uses the current settings for this `Check` to create one or more
3161        `potluck.rubrics.ImplementationCheck` objects for use in a
3162        rubric. Recursively builds any sub-rules first, and disentangles
3163        categories which is why it might return multiple checks. It
3164        returns a dictionary mapping category-name/goal-type pairs to
3165        single `potluck.rubrics.ImplementationCheck` instances for those
3166        category/goal-type combinations.
3167
3168        The id_prefix and prefix arguments specify prefixes to add to
3169        the identifier and description details of subgoals to help keep
3170        things specific. A prefix will be automatically added to the
3171        calls to `build_implementation_checks` for any sub-rules, which
3172        will include the existing prefix.
3173
3174        The default_category argument specifies what category should be
3175        used if this `Check`'s category is 'auto', and in the same vein,
3176        the default_goal_type is used for checks with 'auto' as their
3177        goal_type.
3178        """
3179
3180        # Determine a name for this construct
3181        if self.name is None:
3182            # Can't easily pluralize without an explicit name, so we don't try
3183            name = (self.patterns[0], self.patterns[0])
3184        else:
3185            name = self.name
3186
3187        # Decide on prefixes
3188        if id_prefix is not None:
3189            qualified_id = id_prefix + ':' + self.identifier
3190        else:
3191            qualified_id = self.identifier
3192
3193        if self.limits[0] == 1:
3194            sub_prefix = f"Within the {name[0]}"
3195        else:
3196            sub_prefix = f"Within {name[1]}"
3197
3198        # Create a generic description if there isn't one already
3199        if self._description is None:
3200            description = explain.code_check_description(
3201                self.limits,
3202                (
3203                    phrasing.a_an(name[0])
3204                    if self.limits[0] in (None, 0, 1)
3205                    else name[1]
3206                ),
3207                phrasing.comma_list(
3208                    [f"<code>{pat}</code>" for pat in self.patterns],
3209                    junction="or"
3210                )
3211            )
3212        else:
3213            description = self._description
3214
3215        if prefix is not None:
3216            # Adjust the sub-prefix
3217            sub_prefix = sub_prefix + " " + prefix[0].lower() + prefix[1:]
3218
3219            # Adjust the description (both pre-feedback and
3220            # post-feedback details entries if they exist)
3221            description = list(description)
3222            description[1::2] = [
3223                prefix + ', ' + r[0].lower() + r[1:]
3224                for r in description[1::2]
3225            ]
3226
3227        this_cat = self.category
3228        if this_cat == "auto":
3229            this_cat = default_category
3230
3231        this_goal_type = self.goal_type
3232        if this_goal_type == "auto":
3233            this_goal_type = default_goal_type
3234
3235        # Recursively create checks for sub-rules
3236        subs = []
3237        all_required_cat_types = set([(this_cat, this_goal_type)])
3238        for sub in self.subrules:
3239            sub_checks = sub.build_implementation_checks(
3240                qualified_id,
3241                sub_prefix,
3242                this_cat,
3243                this_goal_type
3244            )
3245            for cat, typ in sub_checks:
3246                all_required_cat_types.add((cat, typ))
3247            subs.append(sub_checks)
3248
3249        return {
3250            (cat, gt): rubrics.ImplementationCheck(
3251                taskid=self.taskid,
3252                identifier=qualified_id,
3253                pattern=self.patterns,
3254                name=self.name,
3255                min=self.limits[0],
3256                max=self.limits[1],
3257                description=description,
3258                match=lambda code, node, env: (
3259                    all(flt(code, node, env) for flt in self._match_filters)
3260                ),
3261                softmin=self._softmin,
3262                softmax=self._softmax,
3263                outside=self._outside,
3264                callees=self._callees,
3265                subslip=self._subslip,
3266                match_identity=self._match_identity_function,
3267                check_in_def=self._check_in_def,
3268                force_smaller_match=self._force_smaller_match,
3269                subrules=[s[(cat, gt)] for s in subs if (cat, gt) in s],
3270                tags={ "category": cat, "goal_type": gt },
3271                test_in=(
3272                    None # use parent goal's context
3273                    if prefix is not None # if there is a parent
3274                    else self.test_in
3275                ),
3276            )
3277            for (cat, gt) in all_required_cat_types
3278        }

Check is the base class for a few different classes that represent simplified/specialized potluck.rubrics.ImplementationChecks. Each Check should be assigned to one of the categories:

  • foundational (deprecated)
  • core
  • extra
  • feedback_only
  • auto

Generally only 'core' and 'extra' are needed; see potluck.rubrics.foundational_core_extras_metric and potluck.rubrics.core_extras_categorized_metric. Additionally, if using the later metric, a goal type should be included, which is "auto" by default but could reasonably be "procedure", "style" or "other".

When a Check's category/goal-type is set to 'auto' (the default for both) that property will be inherited from the parent Check if this check is a sub-rule, or set to 'core'/'procedure' if not.

When a check has a different category or goal type than its parent check, a copy of that parent check will be created belonging to the child category/type, and the original parent check won't include the different-category/type child. Also, any other children of the same parent that belong to the same category and goal type will be included on a single new-category/type copy of the parent, so it's not as if each alternate-category/type child creates its own parent Check.

This setup allows for extra requirements to be specified as the leaves of a Check subrule tree without having to specify the whole tree twice.

Note that the identifiers of each Check will be concatenated with their parent's identifiers, minus the 'check:' prefix, and separated by colons, so give each Check a better chance of ending up with a unique identifier before numeric-suffix-addition.

Check( identifier, patterns, limits, name=None, category='auto', goal_type='auto')
2856    def __init__(
2857        self,
2858        identifier,
2859        patterns,
2860        limits,
2861        name=None,
2862        category='auto',
2863        goal_type='auto'
2864    ):
2865        """
2866        A check needs an identifier, a list of patterns (could be
2867        length-one), a tuple of limits (min/max, either or even both may
2868        be None), and it may specify a name instead of deriving one
2869        automatically from the patterns (see
2870        `potluck.rubrics.ImplementationCheck`), a category string (e.g.,
2871        'extra' instead of defaulting to 'auto'), and/or a goal type
2872        (e.g., 'style' instead of defaulting to 'auto'). All other
2873        `potluck.rubrics.ImplementationCheck` details are specified via
2874        calling methods on the object after it is created. The name may
2875        be a string containing HTML code, or a 2-item tuple to explicitly
2876        specify singular and plural forms of the name (otherwise an 's'
2877        will be added to the end of the name to generate the plural
2878        form).
2879        """
2880        self.taskid = file_utils.deduce_task_id()
2881
2882        if not isinstance(identifier, str):
2883            raise TypeError(
2884                "The identifier for a Check must be a string."
2885            )
2886        self.identifier = identifier
2887
2888        if not isinstance(patterns, (list, tuple)):
2889            raise TypeError(
2890                "Patterns for a check must be a list or tuple (may be"
2891              + " length-1)"
2892            )
2893
2894        # When we instantiate the Check we get the auto-context-provider
2895        # for the "scope" slot; this will be used later during goal
2896        # instantiation.
2897        self.test_in = { "contexts": contexts.auto("scope") }
2898
2899        # Core fields
2900        self.category = category
2901        self.goal_type = goal_type
2902        self.patterns = patterns
2903        self.limits = limits
2904
2905        if isinstance(name, str):
2906            # automatic pluralization by default
2907            self.name = name, name + 's'
2908        else:
2909            # Use a 2-item tuple to specify singular and plural forms
2910            self.name = name
2911
2912        self.subrules = []
2913
2914        self._description = None
2915        self._match_filters = []
2916        self._softmin = False
2917        self._softmax = False
2918        self._outside = None
2919        self._callees = False
2920        self._subslip = None
2921        self._match_identity_function = lambda code, node, env: (
2922            tuple(node) if isinstance(node, list) else node
2923        )
2924
2925        # Note: this is only set via FunctionCall.require_of_def
2926        self._check_in_def = False
2927
2928        self._force_smaller_match = False
2929
2930        # Register this Check
2931        checklist(category, goal_type, create=True).append(self)

A check needs an identifier, a list of patterns (could be length-one), a tuple of limits (min/max, either or even both may be None), and it may specify a name instead of deriving one automatically from the patterns (see potluck.rubrics.ImplementationCheck), a category string (e.g., 'extra' instead of defaulting to 'auto'), and/or a goal type (e.g., 'style' instead of defaulting to 'auto'). All other potluck.rubrics.ImplementationCheck details are specified via calling methods on the object after it is created. The name may be a string containing HTML code, or a 2-item tuple to explicitly specify singular and plural forms of the name (otherwise an 's' will be added to the end of the name to generate the plural form).

def set_description(self, title, summary, final_title=None, final_summary=None):
2933    def set_description(
2934        self,
2935        title,
2936        summary,
2937        final_title=None,
2938        final_summary=None
2939    ):
2940        """
2941        Sets up a custom description for this `Check`. If not used, a
2942        default description will be constructed based on the parameters
2943        of the check. The `title` and `summary` arguments are the rubric
2944        entry and associated description to be used when displaying an
2945        ungraded rubric, while the optional `final_title` and
2946        `final_summary` are the items to be used when displaying the
2947        goal as part of a graded rubric. If the final title and/or
2948        summary are omitted, the normal title/summary are used.
2949
2950        This function returns the `Check` for chaining.
2951        """
2952        if final_title is None:
2953            final_title = title
2954
2955        if final_summary is None:
2956            final_summary = summary
2957
2958        self._description = (title, summary, final_title, final_summary)
2959        return self

Sets up a custom description for this Check. If not used, a default description will be constructed based on the parameters of the check. The title and summary arguments are the rubric entry and associated description to be used when displaying an ungraded rubric, while the optional final_title and final_summary are the items to be used when displaying the goal as part of a graded rubric. If the final title and/or summary are omitted, the normal title/summary are used.

This function returns the Check for chaining.

def match_filter(self, filter_function):
2961    def match_filter(self, filter_function):
2962        """
2963        Adds a custom filtering function to this check that can throw out
2964        some potential matches. The filtering function will be given the
2965        entire submitted AST tree, the (potentially) matching AST node,
2966        and the binding environment of the match, and it should return
2967        True or False, where False will filter out that match. You can
2968        call this function multiple times and each individual match
2969        filter will be ANDed with the others.
2970
2971        This function returns the `Check` object for chaining.
2972        """
2973        self._match_filters.append(filter_function)
2974        return self

Adds a custom filtering function to this check that can throw out some potential matches. The filtering function will be given the entire submitted AST tree, the (potentially) matching AST node, and the binding environment of the match, and it should return True or False, where False will filter out that match. You can call this function multiple times and each individual match filter will be ANDed with the others.

This function returns the Check object for chaining.

def softmin(self, value=True):
2976    def softmin(self, value=True):
2977        """
2978        Turns the lower limit for matches for this check into a soft
2979        limit, so that too few copies still counts as partially
2980        completing this check overall. If the value argument is either
2981        "warn" or "note", then when too few matches are found, the check
2982        still succeeds, but a warning (or note) is generated that
2983        describes the unexpectedly low number of occurrences.
2984
2985        The value could also be a number, which establishes an alternate
2986        lower bound on the number of matches that will count for partial
2987        success on the check. E.g., if the min is 3, you could set the
2988        softmin at 2.5 and then a situation where there were 2 full and
2989        one partial matches would count as a partial match for the check
2990        overall.
2991
2992        This function returns the `Check` object for chaining.
2993        """
2994        self._softmin = value
2995        return self

Turns the lower limit for matches for this check into a soft limit, so that too few copies still counts as partially completing this check overall. If the value argument is either "warn" or "note", then when too few matches are found, the check still succeeds, but a warning (or note) is generated that describes the unexpectedly low number of occurrences.

The value could also be a number, which establishes an alternate lower bound on the number of matches that will count for partial success on the check. E.g., if the min is 3, you could set the softmin at 2.5 and then a situation where there were 2 full and one partial matches would count as a partial match for the check overall.

This function returns the Check object for chaining.

def softmax(self, value=True):
2997    def softmax(self, value=True):
2998        """
2999        Turns the upper limit for matches into a soft limit, just as with
3000        `softmin`. Also accepts the strings "warn" or "note", as well as
3001        integer values. Unlike `softmin`, when setting this value as a
3002        number, partial matches are ignored and will not push the rule
3003        over its hard or soft limits.
3004
3005        This function returns the `Check` object for chaining.
3006        """
3007        self._softmax = value
3008        return self

Turns the upper limit for matches into a soft limit, just as with softmin. Also accepts the strings "warn" or "note", as well as integer values. Unlike softmin, when setting this value as a number, partial matches are ignored and will not push the rule over its hard or soft limits.

This function returns the Check object for chaining.

def outside(self, patterns):
3010    def outside(self, patterns):
3011        """
3012        Accepts a list of patterns (strings containing mast pseudo-code)
3013        or a single pattern string, and sets that as the exclusion
3014        pattern for this rule, which will ensure that matches which occur
3015        inside one of those patterns are ignored. Consider using values
3016        like `potluck.patterns.IF_PATTERN` or
3017        `potluck.patterns.ALL_FOR_AND_WHILE_LOOP_PATTERNS` as the
3018        patterns argument.
3019
3020        Note that currently, this function's effects are not
3021        automatically described by the description of the Check it's
3022        applied to, so you'll almost always need to use set_description
3023        to describe what the check does yourself.
3024        TODO: Some automatic default for that?
3025
3026        This function returns the `Check` object for chaining.
3027        """
3028        self._outside = patterns
3029        return self

Accepts a list of patterns (strings containing mast pseudo-code) or a single pattern string, and sets that as the exclusion pattern for this rule, which will ensure that matches which occur inside one of those patterns are ignored. Consider using values like potluck.patterns.IF_PATTERN or potluck.patterns.ALL_FOR_AND_WHILE_LOOP_PATTERNS as the patterns argument.

Note that currently, this function's effects are not automatically described by the description of the Check it's applied to, so you'll almost always need to use set_description to describe what the check does yourself. TODO: Some automatic default for that?

This function returns the Check object for chaining.

def check_callees(self, turn_on=True):
3031    def check_callees(self, turn_on=True):
3032        """
3033        Turns on callee-checking for this rule (or turns it off if given
3034        False as an argument). This means that matches for this rule will
3035        be searched for within the code of any locally-defined functions
3036        that are called from the code being inspected, which helps find
3037        things that you're looking for which a student puts into a helper
3038        function. Applying this to a top-level check is generally not
3039        useful, since any top-level checks already look for matches in
3040        the entire submitted module; it should always be applied to
3041        sub-rules.
3042
3043        TODO: This feature is still (as of 2020-6) a bit unstable and may
3044        slow things down substantially in some cases.
3045
3046        This function returns the `Check` object for chaining.
3047        """
3048        self._callees = turn_on
3049        return self

Turns on callee-checking for this rule (or turns it off if given False as an argument). This means that matches for this rule will be searched for within the code of any locally-defined functions that are called from the code being inspected, which helps find things that you're looking for which a student puts into a helper function. Applying this to a top-level check is generally not useful, since any top-level checks already look for matches in the entire submitted module; it should always be applied to sub-rules.

TODO: This feature is still (as of 2020-6) a bit unstable and may slow things down substantially in some cases.

This function returns the Check object for chaining.

def subrule_tolerance(self, tolerance=0):
3051    def subrule_tolerance(self, tolerance=0):
3052        """
3053        Sets the number of sub-rules that are allowed to go unmatched
3054        while still counting this rule as a partial match. The argument
3055        is a number, which may be fractional, since a partially-matched
3056        sub-rule counts as 0.5 of a fully-matched rule. By default the
3057        number is 0: if any sub-rule is unmatched, the entire
3058        match-in-consideration is ignored entirely.
3059
3060        This function returns the `Check` object for chaining.
3061        """
3062        self._subslip = tolerance
3063        return self

Sets the number of sub-rules that are allowed to go unmatched while still counting this rule as a partial match. The argument is a number, which may be fractional, since a partially-matched sub-rule counts as 0.5 of a fully-matched rule. By default the number is 0: if any sub-rule is unmatched, the entire match-in-consideration is ignored entirely.

This function returns the Check object for chaining.

def count_using(self, identity_function):
3065    def count_using(self, identity_function):
3066        """
3067        Sets up a custom function to determine the identity of a match,
3068        which affects how matches are counting when considering limits.
3069        This function will be given three arguments: an AST node for the
3070        entire file, a matching AST node (or list of nodes if the match
3071        ends up matching something like a function body) and a list of
3072        matching environments (dictionaries mapping string keys to AST
3073        nodes). It must return a hashable object, and the number of
3074        matches will be determined by the cardinality of the set of
3075        such objects returned by all matching node/environments combos.
3076        It may also return a list of hashable objects in which case
3077        they'll each be mixed into the set to be counted.
3078
3079        This function returns the `Check` object for chaining.
3080        """
3081        self._match_identity_function = identity_function
3082
3083        return self

Sets up a custom function to determine the identity of a match, which affects how matches are counting when considering limits. This function will be given three arguments: an AST node for the entire file, a matching AST node (or list of nodes if the match ends up matching something like a function body) and a list of matching environments (dictionaries mapping string keys to AST nodes). It must return a hashable object, and the number of matches will be determined by the cardinality of the set of such objects returned by all matching node/environments combos. It may also return a list of hashable objects in which case they'll each be mixed into the set to be counted.

This function returns the Check object for chaining.

def force_smaller(self, force=True):
3085    def force_smaller(self, force=True):
3086        """
3087        Forces a match for this rule to be smaller than the match for its
3088        super-rule (or smaller than the whole module if there is no
3089        super-rule). Set force to False to disable this behavior instead
3090        once it's been enabled (default is disabled).
3091
3092        Use this to force nested equivalent rules (like two nested Loops)
3093        to actually match nested structures.
3094
3095        Returns self for chaining.
3096        """
3097        self._force_smaller_match = force
3098
3099        return self

Forces a match for this rule to be smaller than the match for its super-rule (or smaller than the whole module if there is no super-rule). Set force to False to disable this behavior instead once it's been enabled (default is disabled).

Use this to force nested equivalent rules (like two nested Loops) to actually match nested structures.

Returns self for chaining.

def require(self, *checks):
3101    def require(self, *checks):
3102        """
3103        Adds one or more new sub-rules which much be matched within the
3104        code matched by this rule in order for a full match to occur. Use
3105        the `subrule_tolerance` method on the parent `Check` if you want
3106        to allow some required sub-rules to go unmatched while still
3107        generating a partial match. Use the `check_callees` method on the
3108        subrule being added if you want to search for that pattern in
3109        helper functions as well as in the scope of the match created by
3110        the parent rule.
3111
3112        The given `Check` objects will be appended to the subrules field
3113        of this parent object, which you can use to traverse all subrules
3114        if you need to. They will also be de-registered as top-level
3115        `Check`s.
3116
3117        This function returns the `Check` object for chaining.
3118
3119        WARNINGS:
3120        - Only inspects callees where the function position is a name
3121          (not an arbitrary expression)
3122        - Searches the top-level task code node for this name
3123          without understanding shadowing and without considering
3124          arguments/parameters
3125        - Attempts to match the full pattern within a single
3126          function (currently cannot automatically split pattern
3127          across a call)
3128        - Likely to cause even more exponential blowup
3129        - No attempts are made to respect scope when unifying
3130          env with match environments in callees
3131        """
3132        self.subrules.extend(checks)
3133
3134        # Remove these things from our checklist since they'll be
3135        # reporting to this check as their parent
3136        for ch in checks:
3137            checklist(ch.category, ch.goal_type).remove(ch)
3138
3139        return self

Adds one or more new sub-rules which much be matched within the code matched by this rule in order for a full match to occur. Use the subrule_tolerance method on the parent Check if you want to allow some required sub-rules to go unmatched while still generating a partial match. Use the check_callees method on the subrule being added if you want to search for that pattern in helper functions as well as in the scope of the match created by the parent rule.

The given Check objects will be appended to the subrules field of this parent object, which you can use to traverse all subrules if you need to. They will also be de-registered as top-level Checks.

This function returns the Check object for chaining.

WARNINGS:

  • Only inspects callees where the function position is a name (not an arbitrary expression)
  • Searches the top-level task code node for this name without understanding shadowing and without considering arguments/parameters
  • Attempts to match the full pattern within a single function (currently cannot automatically split pattern across a call)
  • Likely to cause even more exponential blowup
  • No attempts are made to respect scope when unifying env with match environments in callees
def set_identifier(self, identifier):
3141    def set_identifier(self, identifier):
3142        """
3143        Explicitly sets the identifier to the given string. Useful to
3144        manually disambiguate multiple goals whose identifiers would
3145        otherwise be the same.
3146
3147        Returns self for chaining.
3148        """
3149        self.identifier = identifier
3150        return self

Explicitly sets the identifier to the given string. Useful to manually disambiguate multiple goals whose identifiers would otherwise be the same.

Returns self for chaining.

def build_implementation_checks( self, id_prefix=None, prefix=None, default_category='core', default_goal_type='procedure'):
3152    def build_implementation_checks(
3153        self,
3154        id_prefix=None,
3155        prefix=None,
3156        default_category='core',
3157        default_goal_type='procedure'
3158    ):
3159        """
3160        Uses the current settings for this `Check` to create one or more
3161        `potluck.rubrics.ImplementationCheck` objects for use in a
3162        rubric. Recursively builds any sub-rules first, and disentangles
3163        categories which is why it might return multiple checks. It
3164        returns a dictionary mapping category-name/goal-type pairs to
3165        single `potluck.rubrics.ImplementationCheck` instances for those
3166        category/goal-type combinations.
3167
3168        The id_prefix and prefix arguments specify prefixes to add to
3169        the identifier and description details of subgoals to help keep
3170        things specific. A prefix will be automatically added to the
3171        calls to `build_implementation_checks` for any sub-rules, which
3172        will include the existing prefix.
3173
3174        The default_category argument specifies what category should be
3175        used if this `Check`'s category is 'auto', and in the same vein,
3176        the default_goal_type is used for checks with 'auto' as their
3177        goal_type.
3178        """
3179
3180        # Determine a name for this construct
3181        if self.name is None:
3182            # Can't easily pluralize without an explicit name, so we don't try
3183            name = (self.patterns[0], self.patterns[0])
3184        else:
3185            name = self.name
3186
3187        # Decide on prefixes
3188        if id_prefix is not None:
3189            qualified_id = id_prefix + ':' + self.identifier
3190        else:
3191            qualified_id = self.identifier
3192
3193        if self.limits[0] == 1:
3194            sub_prefix = f"Within the {name[0]}"
3195        else:
3196            sub_prefix = f"Within {name[1]}"
3197
3198        # Create a generic description if there isn't one already
3199        if self._description is None:
3200            description = explain.code_check_description(
3201                self.limits,
3202                (
3203                    phrasing.a_an(name[0])
3204                    if self.limits[0] in (None, 0, 1)
3205                    else name[1]
3206                ),
3207                phrasing.comma_list(
3208                    [f"<code>{pat}</code>" for pat in self.patterns],
3209                    junction="or"
3210                )
3211            )
3212        else:
3213            description = self._description
3214
3215        if prefix is not None:
3216            # Adjust the sub-prefix
3217            sub_prefix = sub_prefix + " " + prefix[0].lower() + prefix[1:]
3218
3219            # Adjust the description (both pre-feedback and
3220            # post-feedback details entries if they exist)
3221            description = list(description)
3222            description[1::2] = [
3223                prefix + ', ' + r[0].lower() + r[1:]
3224                for r in description[1::2]
3225            ]
3226
3227        this_cat = self.category
3228        if this_cat == "auto":
3229            this_cat = default_category
3230
3231        this_goal_type = self.goal_type
3232        if this_goal_type == "auto":
3233            this_goal_type = default_goal_type
3234
3235        # Recursively create checks for sub-rules
3236        subs = []
3237        all_required_cat_types = set([(this_cat, this_goal_type)])
3238        for sub in self.subrules:
3239            sub_checks = sub.build_implementation_checks(
3240                qualified_id,
3241                sub_prefix,
3242                this_cat,
3243                this_goal_type
3244            )
3245            for cat, typ in sub_checks:
3246                all_required_cat_types.add((cat, typ))
3247            subs.append(sub_checks)
3248
3249        return {
3250            (cat, gt): rubrics.ImplementationCheck(
3251                taskid=self.taskid,
3252                identifier=qualified_id,
3253                pattern=self.patterns,
3254                name=self.name,
3255                min=self.limits[0],
3256                max=self.limits[1],
3257                description=description,
3258                match=lambda code, node, env: (
3259                    all(flt(code, node, env) for flt in self._match_filters)
3260                ),
3261                softmin=self._softmin,
3262                softmax=self._softmax,
3263                outside=self._outside,
3264                callees=self._callees,
3265                subslip=self._subslip,
3266                match_identity=self._match_identity_function,
3267                check_in_def=self._check_in_def,
3268                force_smaller_match=self._force_smaller_match,
3269                subrules=[s[(cat, gt)] for s in subs if (cat, gt) in s],
3270                tags={ "category": cat, "goal_type": gt },
3271                test_in=(
3272                    None # use parent goal's context
3273                    if prefix is not None # if there is a parent
3274                    else self.test_in
3275                ),
3276            )
3277            for (cat, gt) in all_required_cat_types
3278        }

Uses the current settings for this Check to create one or more potluck.rubrics.ImplementationCheck objects for use in a rubric. Recursively builds any sub-rules first, and disentangles categories which is why it might return multiple checks. It returns a dictionary mapping category-name/goal-type pairs to single potluck.rubrics.ImplementationCheck instances for those category/goal-type combinations.

The id_prefix and prefix arguments specify prefixes to add to the identifier and description details of subgoals to help keep things specific. A prefix will be automatically added to the calls to build_implementation_checks for any sub-rules, which will include the existing prefix.

The default_category argument specifies what category should be used if this Check's category is 'auto', and in the same vein, the default_goal_type is used for checks with 'auto' as their goal_type.

class Import(Check):
3281class Import(Check):
3282    """
3283    A `Check` which tests for an import of a certain module. Typically,
3284    of course, it's not possible to complete a task without importing all
3285    of the necessary modules, so a positive import goal can be a bit of a
3286    freebie, but that's not always a bad thing, especially for students
3287    reading the rubric for hints.
3288
3289    The identifier will be 'import-' plus the module name for the module
3290    that must be imported.
3291    """
3292    def __init__(
3293        self,
3294        mname,
3295        import_names=None,
3296        limits=[1, 1],
3297        category='auto'
3298    ):
3299        """
3300        In addition to the module name and optional limits and category,
3301        a list of names to import may be specified, in which case the
3302        check requires a
3303
3304        ```py
3305        from <module> import <names>
3306        ```
3307
3308        import; otherwise it must be a simple
3309
3310        ```py
3311        import <module>
3312        ```
3313
3314        import. With import_names, importing more than the required
3315        names is allowed.
3316
3317        WARNING (2021-8-31) mast currently DOES NOT support the matching
3318        rules required to use the from module import names version! A
3319        warning will be logged if you try to use this, and it may be
3320        extremely slow and/or fail to recognize valid imports that it
3321        should.
3322
3323        If import_names is the special value `'*'`, a universal import
3324        will be required. `'*'` should not be provided as part of a list
3325        of specific import names.
3326
3327        If import_names is the special value `'any'`, then EITHER a
3328        normal import or a from ... import will be accepted, with no
3329        restrictions on the names imported.
3330        """
3331        patterns = [ f"import {mname}" ]
3332        what = f"the <code>{mname}</code> module"
3333
3334        if import_names == '*':
3335            patterns = [ f"from {mname} import *" ]
3336            what = f"everything from the <code>{mname}</code> module"
3337        elif import_names == "any":
3338            patterns.append(f"from {mname} import *")
3339            patterns.append(f"from {mname} import ___")
3340            what = f"the <code>{mname}</code> module or something from it"
3341
3342        elif import_names is not None:
3343            # pattern to match
3344            names = ', '.join(import_names)
3345            logging.log(
3346                "Warning: Support for requiring the import of multiple"
3347                " names from a module is BROKEN."
3348            ) # TODO: Fix this!
3349            # TODO [2021-8-31]: mast DOES NOT support this properly!
3350            patterns = [
3351                f"from {mname} import {names}, ___"
3352            ]
3353
3354            # description of the pattern
3355            names_desc = phrasing.comma_list(
3356                f"<code>{name}</code>"
3357                for name in import_names
3358            )
3359            what = f"{names_desc} from the <code>{mname}</code> module"
3360
3361        super().__init__(
3362            identifier="import-" + mname,
3363            patterns=patterns,
3364            limits=limits,
3365            name=(f"import of {what}", f"imports of {what}"),
3366            category=category
3367        )
3368
3369        # Set up a custom description (calling .set_description later
3370        # would override this)
3371        self.set_description(
3372            f"Import {what}",
3373            f"We will check to make sure that you import {what}.",
3374            f"Import {what}",
3375            f"We examined your code to check whether it imports {what}."
3376        )

A Check which tests for an import of a certain module. Typically, of course, it's not possible to complete a task without importing all of the necessary modules, so a positive import goal can be a bit of a freebie, but that's not always a bad thing, especially for students reading the rubric for hints.

The identifier will be 'import-' plus the module name for the module that must be imported.

Import(mname, import_names=None, limits=[1, 1], category='auto')
3292    def __init__(
3293        self,
3294        mname,
3295        import_names=None,
3296        limits=[1, 1],
3297        category='auto'
3298    ):
3299        """
3300        In addition to the module name and optional limits and category,
3301        a list of names to import may be specified, in which case the
3302        check requires a
3303
3304        ```py
3305        from <module> import <names>
3306        ```
3307
3308        import; otherwise it must be a simple
3309
3310        ```py
3311        import <module>
3312        ```
3313
3314        import. With import_names, importing more than the required
3315        names is allowed.
3316
3317        WARNING (2021-8-31) mast currently DOES NOT support the matching
3318        rules required to use the from module import names version! A
3319        warning will be logged if you try to use this, and it may be
3320        extremely slow and/or fail to recognize valid imports that it
3321        should.
3322
3323        If import_names is the special value `'*'`, a universal import
3324        will be required. `'*'` should not be provided as part of a list
3325        of specific import names.
3326
3327        If import_names is the special value `'any'`, then EITHER a
3328        normal import or a from ... import will be accepted, with no
3329        restrictions on the names imported.
3330        """
3331        patterns = [ f"import {mname}" ]
3332        what = f"the <code>{mname}</code> module"
3333
3334        if import_names == '*':
3335            patterns = [ f"from {mname} import *" ]
3336            what = f"everything from the <code>{mname}</code> module"
3337        elif import_names == "any":
3338            patterns.append(f"from {mname} import *")
3339            patterns.append(f"from {mname} import ___")
3340            what = f"the <code>{mname}</code> module or something from it"
3341
3342        elif import_names is not None:
3343            # pattern to match
3344            names = ', '.join(import_names)
3345            logging.log(
3346                "Warning: Support for requiring the import of multiple"
3347                " names from a module is BROKEN."
3348            ) # TODO: Fix this!
3349            # TODO [2021-8-31]: mast DOES NOT support this properly!
3350            patterns = [
3351                f"from {mname} import {names}, ___"
3352            ]
3353
3354            # description of the pattern
3355            names_desc = phrasing.comma_list(
3356                f"<code>{name}</code>"
3357                for name in import_names
3358            )
3359            what = f"{names_desc} from the <code>{mname}</code> module"
3360
3361        super().__init__(
3362            identifier="import-" + mname,
3363            patterns=patterns,
3364            limits=limits,
3365            name=(f"import of {what}", f"imports of {what}"),
3366            category=category
3367        )
3368
3369        # Set up a custom description (calling .set_description later
3370        # would override this)
3371        self.set_description(
3372            f"Import {what}",
3373            f"We will check to make sure that you import {what}.",
3374            f"Import {what}",
3375            f"We examined your code to check whether it imports {what}."
3376        )

In addition to the module name and optional limits and category, a list of names to import may be specified, in which case the check requires a

from <module> import <names>

import; otherwise it must be a simple

import <module>

import. With import_names, importing more than the required names is allowed.

WARNING (2021-8-31) mast currently DOES NOT support the matching rules required to use the from module import names version! A warning will be logged if you try to use this, and it may be extremely slow and/or fail to recognize valid imports that it should.

If import_names is the special value '*', a universal import will be required. '*' should not be provided as part of a list of specific import names.

If import_names is the special value 'any', then EITHER a normal import or a from ... import will be accepted, with no restrictions on the names imported.

class FunctionDef(Check):
3379class FunctionDef(Check):
3380    """
3381    A `Function` is a type of `Check` which tests for the presence of a
3382    function definition (see also `FunctionCall`). Any sub-rules will be
3383    searched within the body of that function definition.
3384
3385    The identifier will be "def-" plus the name of the function that must
3386    be defined.
3387    """
3388    def __init__(self, fn_name, params_spec=None, category='auto'):
3389        """
3390        You must specify the function name, and you may specify the
3391        parameters. If given, `params_spec` should be a string containing
3392        mast code that goes between the parentheses of a function
3393        definition, or a list or tuple of such strings providing
3394        alternates (see `potluck.patterns.function_def_patterns`).
3395
3396        If `params_spec` is omitted, any function signature with the
3397        specified name is accepted. Instead of a single string, a list of
3398        strings may also be supplied as `fn_name`, in which case any
3399        function using one of those names will be considered as a
3400        potential match.
3401
3402        You may also supply a rubric category string, which should
3403        usually be 'core' or 'extra'.
3404        """
3405        # determine mast patterns
3406        def_patterns = patterns.function_def_patterns(fn_name, params_spec)
3407
3408        # figure out HTML tags for descriptions
3409        code_tag, details_tag = html_tools.function_def_code_tags(
3410            fn_name,
3411            params_spec
3412        )
3413
3414        # Initialize the Check
3415        super().__init__(
3416            "def-" + fn_name,
3417            def_patterns,
3418            [1, 1],  # limits (exactly 1)
3419            (
3420                "definition of {}".format(code_tag),
3421                "definitions of {}".format(code_tag)
3422            ),  # name (w/ plural version)
3423            category=category
3424        )
3425
3426        # By default, only generate a note if we find multiple
3427        # fully-matching definitions
3428        self.softmax("note")
3429
3430        # Decide topic and details
3431        topic = "Define {}".format(code_tag)
3432        details = "Use <code>def</code> to define {}".format(details_tag)
3433
3434        # Set up a custom description (calling .set_description later
3435        # would override this)
3436        self.set_description(topic, details)

A Function is a type of Check which tests for the presence of a function definition (see also FunctionCall). Any sub-rules will be searched within the body of that function definition.

The identifier will be "def-" plus the name of the function that must be defined.

FunctionDef(fn_name, params_spec=None, category='auto')
3388    def __init__(self, fn_name, params_spec=None, category='auto'):
3389        """
3390        You must specify the function name, and you may specify the
3391        parameters. If given, `params_spec` should be a string containing
3392        mast code that goes between the parentheses of a function
3393        definition, or a list or tuple of such strings providing
3394        alternates (see `potluck.patterns.function_def_patterns`).
3395
3396        If `params_spec` is omitted, any function signature with the
3397        specified name is accepted. Instead of a single string, a list of
3398        strings may also be supplied as `fn_name`, in which case any
3399        function using one of those names will be considered as a
3400        potential match.
3401
3402        You may also supply a rubric category string, which should
3403        usually be 'core' or 'extra'.
3404        """
3405        # determine mast patterns
3406        def_patterns = patterns.function_def_patterns(fn_name, params_spec)
3407
3408        # figure out HTML tags for descriptions
3409        code_tag, details_tag = html_tools.function_def_code_tags(
3410            fn_name,
3411            params_spec
3412        )
3413
3414        # Initialize the Check
3415        super().__init__(
3416            "def-" + fn_name,
3417            def_patterns,
3418            [1, 1],  # limits (exactly 1)
3419            (
3420                "definition of {}".format(code_tag),
3421                "definitions of {}".format(code_tag)
3422            ),  # name (w/ plural version)
3423            category=category
3424        )
3425
3426        # By default, only generate a note if we find multiple
3427        # fully-matching definitions
3428        self.softmax("note")
3429
3430        # Decide topic and details
3431        topic = "Define {}".format(code_tag)
3432        details = "Use <code>def</code> to define {}".format(details_tag)
3433
3434        # Set up a custom description (calling .set_description later
3435        # would override this)
3436        self.set_description(topic, details)

You must specify the function name, and you may specify the parameters. If given, params_spec should be a string containing mast code that goes between the parentheses of a function definition, or a list or tuple of such strings providing alternates (see potluck.patterns.function_def_patterns).

If params_spec is omitted, any function signature with the specified name is accepted. Instead of a single string, a list of strings may also be supplied as fn_name, in which case any function using one of those names will be considered as a potential match.

You may also supply a rubric category string, which should usually be 'core' or 'extra'.

class FunctionCall(Check):
3439class FunctionCall(Check):
3440    """
3441    A custom `Check` which checks for the presence of a call to a
3442    specific function (or to one of several functions).
3443
3444    Note: Sub-rules aren't typically very useful, as they would be
3445    matched within the function call expression (not within the
3446    definition of the called function). You can the `callees` method of
3447    the super-rule instead of a FunctionCall sub-rule to check for things
3448    that might be placed in helper functions, or you can use the
3449    require_of_def method of a FunctionCall to place requirements on the
3450    AST makeup of the function being called (in conjunction with '_' as
3451    the fn_name, this provides a means of requiring helper functions that
3452    meet certain criteria without knowing their names).
3453
3454    The identifier will be "call-" plus the name of the function that must
3455    be called, or the name of the first function if multiple are
3456    specified, or "call-(any)" if the function name isn't specified.
3457    """
3458    def __init__(
3459        self,
3460        fn_name,
3461        limits=[1, None],
3462        args_spec=None,
3463        announce=None,
3464        is_method=False,
3465        category='auto'
3466    ):
3467        """
3468        A function name is required, and everything else is optional. You
3469        may also pass a list of strings for the function name to count
3470        multiple different function calls (e.g., when a function has an
3471        alias, like 'fd' and 'forward' in the 'turtle' module). Use '_'
3472        as the function name to match any function; the description will
3473        account for that if you do.
3474
3475        The `limits` parameter specifies the lower and upper limits on
3476        the number of calls required. Use `None` in the first (or second)
3477        position to specify no lower (or upper) limit. The default value
3478        is `[1, None]` which means "at least one."
3479
3480        The `args_spec` argument can be used to require a certain
3481        arrangement of parameters (see
3482        `potluck.patterns.function_call_patterns`) and may be a string, a
3483        list of strings, or a pair of integers and/or None similar to
3484        'limits' specifying how many positional parameters there should
3485        be (one element of the pair must be an integer for this to work).
3486
3487        The `announce` argument can be used to override the name of the
3488        function in the default description, although a custom
3489        description could also be applied using the `set_description`
3490        method. Unlike a custom description, an `announce` value is also
3491        used in the construction of explanation strings.
3492
3493        Set `is_method` to True if you want to look for calls as methods
3494        instead of normal function calls. Note that this implies you need
3495        to know ahead of time how students will import modules, since a
3496        call to 'turtle.fd' would need to be identified as a method call,
3497        whereas a call to 'fd' after 'from turtle import *' would not be
3498        a method call.
3499
3500        You may also supply a rubric category string, which should
3501        usually be 'core' or 'extra'.
3502        """
3503        if (
3504            isinstance(args_spec, (list, tuple))
3505        and len(args_spec) == 2
3506        and (
3507                isinstance(args_spec[0], int)
3508             or isinstance(args_spec[1], int)
3509            )
3510        ):
3511            args_limits = args_spec
3512            args_spec = "___"
3513            if args_limits[0] is None: # upper limit only
3514                args_desc = f"<at most {args_limits[1]} arguments>"
3515            elif args_limits[1] is None: # lower limit only
3516                args_desc = f"<at least {args_limits[0]} arguments>"
3517            else:
3518                args_desc = (
3519                    f"<{args_limits[0]}-{args_limits[1]} arguments>"
3520                )
3521        elif args_spec is None:
3522            args_limits = [None, None]
3523            args_desc = "-any arguments-"
3524        else:
3525            args_limits = [None, None]
3526            args_desc = args_spec
3527
3528        if fn_name == "_":
3529            fn_desc = "-any function-"
3530            identifier = "call-(any)"
3531        else:
3532            fn_desc = fn_name
3533            if isinstance(fn_name, str):
3534                identifier = "call-" + fn_name
3535            else:
3536                identifier = "call-" + fn_name[0]
3537
3538        # determine mast patterns
3539        call_patterns = patterns.function_call_patterns(
3540            fn_name,
3541            args_spec,
3542            is_method=is_method
3543        )
3544
3545        # figure out HTML tags for descriptions
3546        code_tag, details_tag = html_tools.function_call_code_tags(
3547            fn_desc,
3548            args_desc,
3549            is_method=is_method
3550        )
3551
3552        # store HTML tags for possible later use
3553        self.code_tags = (code_tag, details_tag)
3554
3555        if announce:
3556            code_tag = announce
3557
3558        # Initialize the Check
3559        super().__init__(
3560            identifier,
3561            call_patterns,
3562            limits,
3563            (
3564                "call to {}".format(code_tag),
3565                "calls to {}".format(code_tag)
3566            ), # name (w/ plural version)
3567            category=category,
3568        )
3569
3570        # Add a custom match filter if we have argument count limits
3571        if args_limits != [None, None]:
3572
3573            self._match_filters.append(
3574                lambda code, node, env: (
3575                    len(node.args) >= (args_limits[0] or 0)
3576                and (
3577                        (len(node.args) <= args_limits[1])
3578                        if args_limits[1] is not None
3579                        else True
3580                    ) # noqa E123
3581                )
3582            )
3583
3584        description = explain.function_call_description(
3585            code_tag,
3586            details_tag,
3587            limits,
3588            None
3589        )
3590
3591        # Set up a custom description (calling .set_description later
3592        # would override this)
3593        self.set_description(*description)
3594
3595    def require_of_def(self, *subrules):
3596        """
3597        Establishes one or more sub-rules that must match on the
3598        definition of the function being called (which must be defined
3599        within the current file!).
3600
3601        Note: this function modifies the provided subrules so that they
3602        will be set up to check within the definition of their parent.
3603        For this reason, they should be fresh sub-rules and should NOT be
3604        shared.
3605
3606        This function returns the `FunctionCall` object for chaining.
3607        """
3608        # TODO: The subrule description-augmentation doesn't quite line
3609        # up for these, and that needs to be fixed. Ideally, create a
3610        # secondary list of subrules-in-defs and set up separate
3611        # description-augmentation logic for those.
3612        self.subrules.extend(subrules)
3613        for r in subrules:
3614            r._check_in_def = True
3615
3616            # Remove these things from our checklist since they'll be
3617            # reporting to this check as their parent
3618            checklist(r.category, r.goal_type).remove(r)
3619
3620        return self
3621
3622    def must_be_local(self, exclude=[]):
3623        """
3624        Sets up a custom match match filter (via `Check.match_filter`)
3625        such that matches for this rule must be calls to locally-defined
3626        functions.
3627
3628        Note that if you were to store a locally-defined function in a
3629        local variable of another name and then call it via that
3630        variable, it wouldn't be recognized as a match. Tracing is
3631        probably a better approach if you're concerned about such
3632        situations.
3633
3634        If exclude is provided, it should be a collection of strings;
3635        function calls to functions whose names are in that collection
3636        will not be considered matches.
3637
3638        This function returns the `FunctionCall` object for chaining.
3639        """
3640        self.match_filter(
3641            lambda code, node, envs: (
3642                isinstance(node, ast.Call)
3643            and isinstance(node.func, ast.Name)
3644            and node.func.id not in exclude
3645            and mast.find(
3646                    code,
3647                    "def {}(___):\n ___".format(node.func.id)
3648                ) is not None # noqa E123
3649            )
3650        )
3651
3652        return self
3653
3654    def count_by_names(self, respect_module_names=False):
3655        """
3656        Sets up a custom match identity function (via `Check.count_using`
3657        such that matches for this rule are counted not by how many
3658        function calls appear, but by how many distinct function names
3659        are used for calls. If a matching function call doesn't use a
3660        Name or an Attribute as its func expression, it will not be
3661        counted at all, and if it is an attribute, only the attribute
3662        name part will be used as the ID to count, so a call to `forward`
3663        and another call to `turtle.forward` would count as the same
3664        name. Note that this only really makes sense in conjunction with
3665        a bindable slot as the function name (e.g., '_'), or with
3666        multiple possible function names.
3667
3668        If you want `forward` and `turtle.forward` to count as different
3669        names, set `respect_module_names` to True.
3670
3671        This also modifies the name variable to attempt to improve
3672        explanations of what happens.
3673
3674        Note that if you were to store a function in a variable with
3675        another name and then call it via that variable, it would be
3676        counted as a call to a different function. Tracing is probably a
3677        better approach if you're concerned about such situations.
3678
3679        This function returns the `FunctionCall` object for chaining.
3680        """
3681        # We know that only things matching the filter above will be
3682        # given to the count_using function as matches, so we know that
3683        # node.func.id will be valid.
3684        self.count_using(
3685            lambda code, node, envs: (
3686                node.func.id if (
3687                    isinstance(node, ast.Call)
3688                and isinstance(node.func, ast.Name)
3689                ) else (
3690                    node.func.name.id + '.' + node.func.attr
3691                    if respect_module_names else node.func.attr
3692                ) if (
3693                    isinstance(node, ast.Call)
3694                and isinstance(node.func, ast.Attribute)
3695                and isinstance(node.func.value, ast.Name)
3696                ) else (
3697                    '?.' + node.func.attr
3698                    if respect_module_names else node.func.attr
3699                ) if (
3700                    isinstance(node, ast.Call)
3701                and isinstance(node.func, ast.Attribute)
3702                ) else []
3703                # empty list effectively removes match from count
3704                # TODO: Do we need to support other configurations?
3705            )
3706        )
3707
3708        # name is normally using only the first alternate so it's not
3709        # too long, but if we're counting distinct functions, that
3710        # doesn't make sense.
3711        # Retrieve detailed code tag which contains all alternates
3712        code_tag, details_tag = self.code_tags
3713        # If there's only one "alternative", it doesn't make sense to
3714        # use this method, but in any case, we'll leave self.name
3715        # unchanged.
3716        if len(list(re.findall("<code>", details_tag))) > 1:
3717            # If we do have alternates, we need to describe differently
3718            # what it means to call them, because we're not counting
3719            # function calls, we're counting distinct functions called.
3720            self.name = (
3721                "call to one of {}".format(details_tag),
3722                "calls to distinct functions among {}".format(details_tag),
3723            )
3724
3725        return self

A custom Check which checks for the presence of a call to a specific function (or to one of several functions).

Note: Sub-rules aren't typically very useful, as they would be matched within the function call expression (not within the definition of the called function). You can the callees method of the super-rule instead of a FunctionCall sub-rule to check for things that might be placed in helper functions, or you can use the require_of_def method of a FunctionCall to place requirements on the AST makeup of the function being called (in conjunction with '_' as the fn_name, this provides a means of requiring helper functions that meet certain criteria without knowing their names).

The identifier will be "call-" plus the name of the function that must be called, or the name of the first function if multiple are specified, or "call-(any)" if the function name isn't specified.

FunctionCall( fn_name, limits=[1, None], args_spec=None, announce=None, is_method=False, category='auto')
3458    def __init__(
3459        self,
3460        fn_name,
3461        limits=[1, None],
3462        args_spec=None,
3463        announce=None,
3464        is_method=False,
3465        category='auto'
3466    ):
3467        """
3468        A function name is required, and everything else is optional. You
3469        may also pass a list of strings for the function name to count
3470        multiple different function calls (e.g., when a function has an
3471        alias, like 'fd' and 'forward' in the 'turtle' module). Use '_'
3472        as the function name to match any function; the description will
3473        account for that if you do.
3474
3475        The `limits` parameter specifies the lower and upper limits on
3476        the number of calls required. Use `None` in the first (or second)
3477        position to specify no lower (or upper) limit. The default value
3478        is `[1, None]` which means "at least one."
3479
3480        The `args_spec` argument can be used to require a certain
3481        arrangement of parameters (see
3482        `potluck.patterns.function_call_patterns`) and may be a string, a
3483        list of strings, or a pair of integers and/or None similar to
3484        'limits' specifying how many positional parameters there should
3485        be (one element of the pair must be an integer for this to work).
3486
3487        The `announce` argument can be used to override the name of the
3488        function in the default description, although a custom
3489        description could also be applied using the `set_description`
3490        method. Unlike a custom description, an `announce` value is also
3491        used in the construction of explanation strings.
3492
3493        Set `is_method` to True if you want to look for calls as methods
3494        instead of normal function calls. Note that this implies you need
3495        to know ahead of time how students will import modules, since a
3496        call to 'turtle.fd' would need to be identified as a method call,
3497        whereas a call to 'fd' after 'from turtle import *' would not be
3498        a method call.
3499
3500        You may also supply a rubric category string, which should
3501        usually be 'core' or 'extra'.
3502        """
3503        if (
3504            isinstance(args_spec, (list, tuple))
3505        and len(args_spec) == 2
3506        and (
3507                isinstance(args_spec[0], int)
3508             or isinstance(args_spec[1], int)
3509            )
3510        ):
3511            args_limits = args_spec
3512            args_spec = "___"
3513            if args_limits[0] is None: # upper limit only
3514                args_desc = f"<at most {args_limits[1]} arguments>"
3515            elif args_limits[1] is None: # lower limit only
3516                args_desc = f"<at least {args_limits[0]} arguments>"
3517            else:
3518                args_desc = (
3519                    f"<{args_limits[0]}-{args_limits[1]} arguments>"
3520                )
3521        elif args_spec is None:
3522            args_limits = [None, None]
3523            args_desc = "-any arguments-"
3524        else:
3525            args_limits = [None, None]
3526            args_desc = args_spec
3527
3528        if fn_name == "_":
3529            fn_desc = "-any function-"
3530            identifier = "call-(any)"
3531        else:
3532            fn_desc = fn_name
3533            if isinstance(fn_name, str):
3534                identifier = "call-" + fn_name
3535            else:
3536                identifier = "call-" + fn_name[0]
3537
3538        # determine mast patterns
3539        call_patterns = patterns.function_call_patterns(
3540            fn_name,
3541            args_spec,
3542            is_method=is_method
3543        )
3544
3545        # figure out HTML tags for descriptions
3546        code_tag, details_tag = html_tools.function_call_code_tags(
3547            fn_desc,
3548            args_desc,
3549            is_method=is_method
3550        )
3551
3552        # store HTML tags for possible later use
3553        self.code_tags = (code_tag, details_tag)
3554
3555        if announce:
3556            code_tag = announce
3557
3558        # Initialize the Check
3559        super().__init__(
3560            identifier,
3561            call_patterns,
3562            limits,
3563            (
3564                "call to {}".format(code_tag),
3565                "calls to {}".format(code_tag)
3566            ), # name (w/ plural version)
3567            category=category,
3568        )
3569
3570        # Add a custom match filter if we have argument count limits
3571        if args_limits != [None, None]:
3572
3573            self._match_filters.append(
3574                lambda code, node, env: (
3575                    len(node.args) >= (args_limits[0] or 0)
3576                and (
3577                        (len(node.args) <= args_limits[1])
3578                        if args_limits[1] is not None
3579                        else True
3580                    ) # noqa E123
3581                )
3582            )
3583
3584        description = explain.function_call_description(
3585            code_tag,
3586            details_tag,
3587            limits,
3588            None
3589        )
3590
3591        # Set up a custom description (calling .set_description later
3592        # would override this)
3593        self.set_description(*description)

A function name is required, and everything else is optional. You may also pass a list of strings for the function name to count multiple different function calls (e.g., when a function has an alias, like 'fd' and 'forward' in the 'turtle' module). Use '_' as the function name to match any function; the description will account for that if you do.

The limits parameter specifies the lower and upper limits on the number of calls required. Use None in the first (or second) position to specify no lower (or upper) limit. The default value is [1, None] which means "at least one."

The args_spec argument can be used to require a certain arrangement of parameters (see potluck.patterns.function_call_patterns) and may be a string, a list of strings, or a pair of integers and/or None similar to 'limits' specifying how many positional parameters there should be (one element of the pair must be an integer for this to work).

The announce argument can be used to override the name of the function in the default description, although a custom description could also be applied using the set_description method. Unlike a custom description, an announce value is also used in the construction of explanation strings.

Set is_method to True if you want to look for calls as methods instead of normal function calls. Note that this implies you need to know ahead of time how students will import modules, since a call to 'turtle.fd' would need to be identified as a method call, whereas a call to 'fd' after 'from turtle import *' would not be a method call.

You may also supply a rubric category string, which should usually be 'core' or 'extra'.

def require_of_def(self, *subrules):
3595    def require_of_def(self, *subrules):
3596        """
3597        Establishes one or more sub-rules that must match on the
3598        definition of the function being called (which must be defined
3599        within the current file!).
3600
3601        Note: this function modifies the provided subrules so that they
3602        will be set up to check within the definition of their parent.
3603        For this reason, they should be fresh sub-rules and should NOT be
3604        shared.
3605
3606        This function returns the `FunctionCall` object for chaining.
3607        """
3608        # TODO: The subrule description-augmentation doesn't quite line
3609        # up for these, and that needs to be fixed. Ideally, create a
3610        # secondary list of subrules-in-defs and set up separate
3611        # description-augmentation logic for those.
3612        self.subrules.extend(subrules)
3613        for r in subrules:
3614            r._check_in_def = True
3615
3616            # Remove these things from our checklist since they'll be
3617            # reporting to this check as their parent
3618            checklist(r.category, r.goal_type).remove(r)
3619
3620        return self

Establishes one or more sub-rules that must match on the definition of the function being called (which must be defined within the current file!).

Note: this function modifies the provided subrules so that they will be set up to check within the definition of their parent. For this reason, they should be fresh sub-rules and should NOT be shared.

This function returns the FunctionCall object for chaining.

def must_be_local(self, exclude=[]):
3622    def must_be_local(self, exclude=[]):
3623        """
3624        Sets up a custom match match filter (via `Check.match_filter`)
3625        such that matches for this rule must be calls to locally-defined
3626        functions.
3627
3628        Note that if you were to store a locally-defined function in a
3629        local variable of another name and then call it via that
3630        variable, it wouldn't be recognized as a match. Tracing is
3631        probably a better approach if you're concerned about such
3632        situations.
3633
3634        If exclude is provided, it should be a collection of strings;
3635        function calls to functions whose names are in that collection
3636        will not be considered matches.
3637
3638        This function returns the `FunctionCall` object for chaining.
3639        """
3640        self.match_filter(
3641            lambda code, node, envs: (
3642                isinstance(node, ast.Call)
3643            and isinstance(node.func, ast.Name)
3644            and node.func.id not in exclude
3645            and mast.find(
3646                    code,
3647                    "def {}(___):\n ___".format(node.func.id)
3648                ) is not None # noqa E123
3649            )
3650        )
3651
3652        return self

Sets up a custom match match filter (via Check.match_filter) such that matches for this rule must be calls to locally-defined functions.

Note that if you were to store a locally-defined function in a local variable of another name and then call it via that variable, it wouldn't be recognized as a match. Tracing is probably a better approach if you're concerned about such situations.

If exclude is provided, it should be a collection of strings; function calls to functions whose names are in that collection will not be considered matches.

This function returns the FunctionCall object for chaining.

def count_by_names(self, respect_module_names=False):
3654    def count_by_names(self, respect_module_names=False):
3655        """
3656        Sets up a custom match identity function (via `Check.count_using`
3657        such that matches for this rule are counted not by how many
3658        function calls appear, but by how many distinct function names
3659        are used for calls. If a matching function call doesn't use a
3660        Name or an Attribute as its func expression, it will not be
3661        counted at all, and if it is an attribute, only the attribute
3662        name part will be used as the ID to count, so a call to `forward`
3663        and another call to `turtle.forward` would count as the same
3664        name. Note that this only really makes sense in conjunction with
3665        a bindable slot as the function name (e.g., '_'), or with
3666        multiple possible function names.
3667
3668        If you want `forward` and `turtle.forward` to count as different
3669        names, set `respect_module_names` to True.
3670
3671        This also modifies the name variable to attempt to improve
3672        explanations of what happens.
3673
3674        Note that if you were to store a function in a variable with
3675        another name and then call it via that variable, it would be
3676        counted as a call to a different function. Tracing is probably a
3677        better approach if you're concerned about such situations.
3678
3679        This function returns the `FunctionCall` object for chaining.
3680        """
3681        # We know that only things matching the filter above will be
3682        # given to the count_using function as matches, so we know that
3683        # node.func.id will be valid.
3684        self.count_using(
3685            lambda code, node, envs: (
3686                node.func.id if (
3687                    isinstance(node, ast.Call)
3688                and isinstance(node.func, ast.Name)
3689                ) else (
3690                    node.func.name.id + '.' + node.func.attr
3691                    if respect_module_names else node.func.attr
3692                ) if (
3693                    isinstance(node, ast.Call)
3694                and isinstance(node.func, ast.Attribute)
3695                and isinstance(node.func.value, ast.Name)
3696                ) else (
3697                    '?.' + node.func.attr
3698                    if respect_module_names else node.func.attr
3699                ) if (
3700                    isinstance(node, ast.Call)
3701                and isinstance(node.func, ast.Attribute)
3702                ) else []
3703                # empty list effectively removes match from count
3704                # TODO: Do we need to support other configurations?
3705            )
3706        )
3707
3708        # name is normally using only the first alternate so it's not
3709        # too long, but if we're counting distinct functions, that
3710        # doesn't make sense.
3711        # Retrieve detailed code tag which contains all alternates
3712        code_tag, details_tag = self.code_tags
3713        # If there's only one "alternative", it doesn't make sense to
3714        # use this method, but in any case, we'll leave self.name
3715        # unchanged.
3716        if len(list(re.findall("<code>", details_tag))) > 1:
3717            # If we do have alternates, we need to describe differently
3718            # what it means to call them, because we're not counting
3719            # function calls, we're counting distinct functions called.
3720            self.name = (
3721                "call to one of {}".format(details_tag),
3722                "calls to distinct functions among {}".format(details_tag),
3723            )
3724
3725        return self

Sets up a custom match identity function (via Check.count_using such that matches for this rule are counted not by how many function calls appear, but by how many distinct function names are used for calls. If a matching function call doesn't use a Name or an Attribute as its func expression, it will not be counted at all, and if it is an attribute, only the attribute name part will be used as the ID to count, so a call to forward and another call to turtle.forward would count as the same name. Note that this only really makes sense in conjunction with a bindable slot as the function name (e.g., '_'), or with multiple possible function names.

If you want forward and turtle.forward to count as different names, set respect_module_names to True.

This also modifies the name variable to attempt to improve explanations of what happens.

Note that if you were to store a function in a variable with another name and then call it via that variable, it would be counted as a call to a different function. Tracing is probably a better approach if you're concerned about such situations.

This function returns the FunctionCall object for chaining.

class IfElse(Check):
3728class IfElse(Check):
3729    """
3730    An `IfElse` is a `Check` which looks for an `if` or `if`/`else` node,
3731    and matches sub-rules within either the if or the else part. Note
3732    that Python turns `elifs` into nested if/else constructs behind the
3733    scenes, so the `if`/`else` pattern will potentially match once for
3734    each `elif` case, plus once for the original `if`, and the final
3735    `else` in an `elif` chain is attached to the last `elif` case, not
3736    the first `if` case.
3737
3738    The identifier will be just "ifelse".
3739    """
3740    def __init__(self, limits=[1, None], name=None, category='auto'):
3741        """
3742        An `IfElse` only needs to specify the limits on how many matches we
3743        are looking for, and may additionally supply a custom name.
3744
3745        You may also supply a rubric category string, which should
3746        usually be 'core' or 'extra'.
3747        """
3748        if name is None:
3749            name = "<code>if</code>/<code>else</code> block"
3750
3751        super().__init__(
3752            "ifelse",
3753            [patterns.IF_PATTERN],
3754            limits,
3755            name,
3756            category=category
3757        )
3758
3759        # Customize description a bit since patterns are pretty ugly
3760        super().set_description(
3761            *explain.code_check_description(
3762                limits=self.limits,
3763                short_desc="a conditional",
3764                long_desc=(
3765                    "an <code>if</code> statement (possibly accompanied"
3766                  + " by an <code>elif</code> or <code>else</code> block)"
3767                )
3768            )
3769        )

An IfElse is a Check which looks for an if or if/else node, and matches sub-rules within either the if or the else part. Note that Python turns elifs into nested if/else constructs behind the scenes, so the if/else pattern will potentially match once for each elif case, plus once for the original if, and the final else in an elif chain is attached to the last elif case, not the first if case.

The identifier will be just "ifelse".

IfElse(limits=[1, None], name=None, category='auto')
3740    def __init__(self, limits=[1, None], name=None, category='auto'):
3741        """
3742        An `IfElse` only needs to specify the limits on how many matches we
3743        are looking for, and may additionally supply a custom name.
3744
3745        You may also supply a rubric category string, which should
3746        usually be 'core' or 'extra'.
3747        """
3748        if name is None:
3749            name = "<code>if</code>/<code>else</code> block"
3750
3751        super().__init__(
3752            "ifelse",
3753            [patterns.IF_PATTERN],
3754            limits,
3755            name,
3756            category=category
3757        )
3758
3759        # Customize description a bit since patterns are pretty ugly
3760        super().set_description(
3761            *explain.code_check_description(
3762                limits=self.limits,
3763                short_desc="a conditional",
3764                long_desc=(
3765                    "an <code>if</code> statement (possibly accompanied"
3766                  + " by an <code>elif</code> or <code>else</code> block)"
3767                )
3768            )
3769        )

An IfElse only needs to specify the limits on how many matches we are looking for, and may additionally supply a custom name.

You may also supply a rubric category string, which should usually be 'core' or 'extra'.

class Loop(Check):
3772class Loop(Check):
3773    """
3774    A `Loop` is a `Check` which looks for any kind of looping construct,
3775    including for loops, while loops, and single list-, set-, and
3776    dictionary-comprehensions plus raw generator expressions (but not
3777    multiple-loop comprehensions).
3778
3779    The identifier will be just "loop", unless `only` is set to something
3780    other than `'block'` or `None`, in which case the `only` value will
3781    be used as the identifier instead.
3782    """
3783    def __init__(
3784        self,
3785        limits=[1, None],
3786        name=None,
3787        only=None,
3788        category='auto'
3789    ):
3790        """
3791        Limits may be specified (defaults to 'at least 1'), and a custom
3792        `name` may also be given. If `only` is given, it should be one of
3793        the following strings:
3794
3795            'for' - only accept for loops
3796            'while' - only accept while loops
3797            'block' - only accept for and while loops, not list
3798                comprehensions
3799            'comprehension' - only accept list comprehensions
3800
3801        You may also supply a rubric category string, which should
3802        usually be 'core' or 'extra'.
3803
3804        Note that any requirements attached to a Loop will be required of
3805        each loop for that loop to count as a match, so if you want to
3806        require two loops and require a certain construct be present in
3807        at least one of them, you should have one Loop check with [2, 2]
3808        limits and no inner requirements, and another Loop check with [1,
3809        None] limits (the default) that contains your required construct.
3810        """
3811        if name is None:
3812            name = "loop"
3813
3814        loop_patterns = patterns.ALL_SINGLE_LOOP_AND_COMPREHENSION_PATTERNS
3815
3816        if only == 'for':
3817            loop_patterns = patterns.ALL_FOR_PATTERNS
3818            loop_name = "a <code>for</code> loop"
3819        elif only == 'while':
3820            loop_patterns = patterns.ALL_WHILE_PATTERNS
3821            loop_name = "a <code>while</code> loop"
3822        elif only == 'block':
3823            loop_patterns = patterns.ALL_FOR_AND_WHILE_LOOP_PATTERNS
3824            loop_name = "a <code>for</code> or <code>while</code> loop"
3825        elif only == 'comprehension':
3826            loop_patterns = (
3827                patterns.ALL_SINGLE_LOOP_AND_COMPREHENSION_PATTERNS
3828            )
3829            loop_name = "a comprehension"
3830            name = "comprehension"
3831        else:
3832            loop_name = "any kind of loop"
3833
3834        super().__init__(
3835            "loop" if only in (None, 'block') else only,
3836            loop_patterns,
3837            limits,
3838            name,
3839            category=category
3840        )
3841
3842        # Customize description a bit since patterns are pretty ugly
3843        super().set_description(
3844            *explain.code_check_description(
3845                limits=self.limits,
3846                short_desc=loop_name,
3847                long_desc=loop_name
3848            )
3849        )

A Loop is a Check which looks for any kind of looping construct, including for loops, while loops, and single list-, set-, and dictionary-comprehensions plus raw generator expressions (but not multiple-loop comprehensions).

The identifier will be just "loop", unless only is set to something other than 'block' or None, in which case the only value will be used as the identifier instead.

Loop(limits=[1, None], name=None, only=None, category='auto')
3783    def __init__(
3784        self,
3785        limits=[1, None],
3786        name=None,
3787        only=None,
3788        category='auto'
3789    ):
3790        """
3791        Limits may be specified (defaults to 'at least 1'), and a custom
3792        `name` may also be given. If `only` is given, it should be one of
3793        the following strings:
3794
3795            'for' - only accept for loops
3796            'while' - only accept while loops
3797            'block' - only accept for and while loops, not list
3798                comprehensions
3799            'comprehension' - only accept list comprehensions
3800
3801        You may also supply a rubric category string, which should
3802        usually be 'core' or 'extra'.
3803
3804        Note that any requirements attached to a Loop will be required of
3805        each loop for that loop to count as a match, so if you want to
3806        require two loops and require a certain construct be present in
3807        at least one of them, you should have one Loop check with [2, 2]
3808        limits and no inner requirements, and another Loop check with [1,
3809        None] limits (the default) that contains your required construct.
3810        """
3811        if name is None:
3812            name = "loop"
3813
3814        loop_patterns = patterns.ALL_SINGLE_LOOP_AND_COMPREHENSION_PATTERNS
3815
3816        if only == 'for':
3817            loop_patterns = patterns.ALL_FOR_PATTERNS
3818            loop_name = "a <code>for</code> loop"
3819        elif only == 'while':
3820            loop_patterns = patterns.ALL_WHILE_PATTERNS
3821            loop_name = "a <code>while</code> loop"
3822        elif only == 'block':
3823            loop_patterns = patterns.ALL_FOR_AND_WHILE_LOOP_PATTERNS
3824            loop_name = "a <code>for</code> or <code>while</code> loop"
3825        elif only == 'comprehension':
3826            loop_patterns = (
3827                patterns.ALL_SINGLE_LOOP_AND_COMPREHENSION_PATTERNS
3828            )
3829            loop_name = "a comprehension"
3830            name = "comprehension"
3831        else:
3832            loop_name = "any kind of loop"
3833
3834        super().__init__(
3835            "loop" if only in (None, 'block') else only,
3836            loop_patterns,
3837            limits,
3838            name,
3839            category=category
3840        )
3841
3842        # Customize description a bit since patterns are pretty ugly
3843        super().set_description(
3844            *explain.code_check_description(
3845                limits=self.limits,
3846                short_desc=loop_name,
3847                long_desc=loop_name
3848            )
3849        )

Limits may be specified (defaults to 'at least 1'), and a custom name may also be given. If only is given, it should be one of the following strings:

'for' - only accept for loops
'while' - only accept while loops
'block' - only accept for and while loops, not list
    comprehensions
'comprehension' - only accept list comprehensions

You may also supply a rubric category string, which should usually be 'core' or 'extra'.

Note that any requirements attached to a Loop will be required of each loop for that loop to count as a match, so if you want to require two loops and require a certain construct be present in at least one of them, you should have one Loop check with [2, 2] limits and no inner requirements, and another Loop check with [1, None] limits (the default) that contains your required construct.

class Return(Check):
3852class Return(Check):
3853    """
3854    A `Return` is a `Check` which looks for a return statement.
3855
3856    The identifier will be just "return".
3857    """
3858    def __init__(
3859        self,
3860        limits=[1, None],
3861        name=None,
3862        allow_bare=False,
3863        category='auto'
3864    ):
3865        """
3866        Limits may be specified (defaults to 'at least 1'), and a custom
3867        `name` may also be given.
3868
3869        allow_bare may be set to True (default is False) to allow a bare
3870        return statement with no value to count.
3871
3872        You may also supply a rubric category string, which should
3873        usually be 'core' or 'extra'.
3874        """
3875        if name is None:
3876            name = "<code>return</code> statement"
3877
3878        patterns = [ "return _" ]
3879        if allow_bare:
3880            patterns.append("return")
3881
3882        super().__init__(
3883            "return",
3884            patterns,
3885            limits,
3886            name,
3887            category=category
3888        )

A Return is a Check which looks for a return statement.

The identifier will be just "return".

Return(limits=[1, None], name=None, allow_bare=False, category='auto')
3858    def __init__(
3859        self,
3860        limits=[1, None],
3861        name=None,
3862        allow_bare=False,
3863        category='auto'
3864    ):
3865        """
3866        Limits may be specified (defaults to 'at least 1'), and a custom
3867        `name` may also be given.
3868
3869        allow_bare may be set to True (default is False) to allow a bare
3870        return statement with no value to count.
3871
3872        You may also supply a rubric category string, which should
3873        usually be 'core' or 'extra'.
3874        """
3875        if name is None:
3876            name = "<code>return</code> statement"
3877
3878        patterns = [ "return _" ]
3879        if allow_bare:
3880            patterns.append("return")
3881
3882        super().__init__(
3883            "return",
3884            patterns,
3885            limits,
3886            name,
3887            category=category
3888        )

Limits may be specified (defaults to 'at least 1'), and a custom name may also be given.

allow_bare may be set to True (default is False) to allow a bare return statement with no value to count.

You may also supply a rubric category string, which should usually be 'core' or 'extra'.

class Try(Check):
3891class Try(Check):
3892    """
3893    A `Try` is a `Check` which looks for any kind of try/except/finally
3894    construct, although it won't match if there are multiple except
3895    clauses (TODO: fix that? (it's hard...)). They also will only match
3896    uses of 'as' if the name is exactly 'e' (TODO: Fix that (hard)).
3897
3898    The identifier will be "try".
3899    """
3900    def __init__(
3901        self,
3902        limits=[1, None],
3903        name=None,
3904        only=None,
3905        category='auto'
3906    ):
3907        """
3908        Limits may be specified (defaults to 'at least 1'), and a custom
3909        `name` may also be given.
3910
3911        You may also supply a rubric category string, which should
3912        usually be 'core' or 'extra'.
3913
3914        Note that any requirements attached to a `Try` can be satisfied
3915        in the try part, the except part or the finally part if there is
3916        one. There is no way to require something be present in one
3917        specific part (TODO: that).
3918        """
3919        if name is None:
3920            name = "try/except statement"
3921
3922        super().__init__(
3923            "try",
3924            patterns.TRY_EXCEPT_PATTERNS,
3925            limits,
3926            name,
3927            category=category
3928        )
3929
3930        # Customize description a bit since patterns are pretty ugly
3931        super().set_description(
3932            *explain.code_check_description(
3933                limits=self.limits,
3934                short_desc="a try/except statement",
3935                long_desc="a try/except statement"
3936            )
3937        )

A Try is a Check which looks for any kind of try/except/finally construct, although it won't match if there are multiple except clauses (TODO: fix that? (it's hard...)). They also will only match uses of 'as' if the name is exactly 'e' (TODO: Fix that (hard)).

The identifier will be "try".

Try(limits=[1, None], name=None, only=None, category='auto')
3900    def __init__(
3901        self,
3902        limits=[1, None],
3903        name=None,
3904        only=None,
3905        category='auto'
3906    ):
3907        """
3908        Limits may be specified (defaults to 'at least 1'), and a custom
3909        `name` may also be given.
3910
3911        You may also supply a rubric category string, which should
3912        usually be 'core' or 'extra'.
3913
3914        Note that any requirements attached to a `Try` can be satisfied
3915        in the try part, the except part or the finally part if there is
3916        one. There is no way to require something be present in one
3917        specific part (TODO: that).
3918        """
3919        if name is None:
3920            name = "try/except statement"
3921
3922        super().__init__(
3923            "try",
3924            patterns.TRY_EXCEPT_PATTERNS,
3925            limits,
3926            name,
3927            category=category
3928        )
3929
3930        # Customize description a bit since patterns are pretty ugly
3931        super().set_description(
3932            *explain.code_check_description(
3933                limits=self.limits,
3934                short_desc="a try/except statement",
3935                long_desc="a try/except statement"
3936            )
3937        )

Limits may be specified (defaults to 'at least 1'), and a custom name may also be given.

You may also supply a rubric category string, which should usually be 'core' or 'extra'.

Note that any requirements attached to a Try can be satisfied in the try part, the except part or the finally part if there is one. There is no way to require something be present in one specific part (TODO: that).

class With(Check):
3940class With(Check):
3941    """
3942    A `With` is a `Check` which looks for a 'with' block with up to two
3943    context handlers defined, with or without an 'as -name-' part for
3944    each handler. TODO: Allow for more handlers?
3945
3946    The identifier will be "with".
3947    """
3948    def __init__(
3949        self,
3950        limits=[1, None],
3951        name=None,
3952        only=None,
3953        category='auto'
3954    ):
3955        """
3956        Limits may be specified (defaults to 'at least 1'), and a custom
3957        `name` may also be given.
3958
3959        You may also supply a rubric category string, which should
3960        usually be 'core' or 'extra'.
3961        """
3962        if name is None:
3963            name = "with statement"
3964
3965        super().__init__(
3966            "with",
3967            patterns.WITH_PATTERNS,
3968            limits,
3969            name,
3970            category=category
3971        )
3972
3973        # Customize description a bit since patterns are pretty ugly
3974        super().set_description(
3975            *explain.code_check_description(
3976                limits=self.limits,
3977                short_desc="a with statement",
3978                long_desc="a with statement"
3979            )
3980        )

A With is a Check which looks for a 'with' block with up to two context handlers defined, with or without an 'as -name-' part for each handler. TODO: Allow for more handlers?

The identifier will be "with".

With(limits=[1, None], name=None, only=None, category='auto')
3948    def __init__(
3949        self,
3950        limits=[1, None],
3951        name=None,
3952        only=None,
3953        category='auto'
3954    ):
3955        """
3956        Limits may be specified (defaults to 'at least 1'), and a custom
3957        `name` may also be given.
3958
3959        You may also supply a rubric category string, which should
3960        usually be 'core' or 'extra'.
3961        """
3962        if name is None:
3963            name = "with statement"
3964
3965        super().__init__(
3966            "with",
3967            patterns.WITH_PATTERNS,
3968            limits,
3969            name,
3970            category=category
3971        )
3972
3973        # Customize description a bit since patterns are pretty ugly
3974        super().set_description(
3975            *explain.code_check_description(
3976                limits=self.limits,
3977                short_desc="a with statement",
3978                long_desc="a with statement"
3979            )
3980        )

Limits may be specified (defaults to 'at least 1'), and a custom name may also be given.

You may also supply a rubric category string, which should usually be 'core' or 'extra'.

def group(base_name, group_name='_', create=False):
3987def group(base_name, group_name="_", create=False):
3988    """
3989    Retrieves a `TestGroup` object for a particular group of tests,
3990    identified by the name of the thing being tested and the group
3991    name (defaults to '_', the default group name). Note that the current
3992    relevant filename and the module in which the `group` function is
3993    being called are also used to determine which group is returned.
3994
3995    For import tests, the base name is "import"; for function tests and
3996    variable value tests, the base name is the name of the function or
3997    variable being tested (since "import" is a keyword, it is not a valid
3998    function or variable name).
3999
4000    The retrieved group's methods may be used to modify it (they
4001    chain together so you only have to call `group` once). If there are
4002    no tests matching the given criteria, a `KeyError` will be thrown
4003    unless create is given as True, in which case a new empty `TestGroup`
4004    will be created, registered, and returned.
4005    """
4006    result = (
4007        TEST_GROUP_REGISTRY
4008          .get(file_utils.get_spec_module_name(), {})
4009          .get(contexts.RELEVANT_FILENAME, {})
4010          .get(base_name, {})
4011          .get(group_name, None)
4012    )
4013
4014    if result is None:
4015        if not create:
4016            raise KeyError(
4017                f"There are no tests for '{base_name}' in group"
4018              + f" '{group_name}' The tests registry is:\n"
4019              + f"{TEST_GROUP_REGISTRY}"
4020            )
4021        else:
4022            result = (
4023                TEST_GROUP_REGISTRY
4024                  .setdefault(file_utils.get_spec_module_name(), {})
4025                  .setdefault(contexts.RELEVANT_FILENAME, {})
4026                  .setdefault(base_name, {})
4027                  .setdefault(group_name, TestGroup(base_name, group_name))
4028            )
4029
4030    return result

Retrieves a TestGroup object for a particular group of tests, identified by the name of the thing being tested and the group name (defaults to '_', the default group name). Note that the current relevant filename and the module in which the group function is being called are also used to determine which group is returned.

For import tests, the base name is "import"; for function tests and variable value tests, the base name is the name of the function or variable being tested (since "import" is a keyword, it is not a valid function or variable name).

The retrieved group's methods may be used to modify it (they chain together so you only have to call group once). If there are no tests matching the given criteria, a KeyError will be thrown unless create is given as True, in which case a new empty TestGroup will be created, registered, and returned.

def merge(groups, base_name, group_name='_'):
4033def merge(groups, base_name, group_name="_"):
4034    """
4035    Merges several existing groups, returning a new group which contains
4036    all of the tests from the merged groups, which are de-registered in
4037    the process. The new group has the given base and group names.
4038
4039    Note that merge must be called after any operations on the groups to
4040    be merged, since their tests will be removed, and it MUST be called
4041    before any switch in the active fiename.
4042
4043    # TODO: Make this less fragile?
4044    """
4045    new = TestGroup(base_name, group_name)
4046    for group in groups:
4047        # Re-register tests with the new group
4048        for test in group.tests:
4049            test.group = None
4050            new.add(test)
4051
4052        # Remove all tests from old group:
4053        group.tests = []
4054
4055        # De-register this group
4056        del TEST_GROUP_REGISTRY\
4057            [file_utils.get_spec_module_name()]\
4058            [contexts.RELEVANT_FILENAME]\
4059            [test.base_name]\
4060            [test.group_name] # noqa E211
4061        # TODO: Is it okay to leave empties behind?
4062
4063    # Register our merged group and return it
4064    return TEST_GROUP_REGISTRY\
4065        .setdefault(file_utils.get_spec_module_name(), {})\
4066        .setdefault(contexts.RELEVANT_FILENAME, {})\
4067        .setdefault(base_name, {})\
4068        .setdefault(group_name, new)

Merges several existing groups, returning a new group which contains all of the tests from the merged groups, which are de-registered in the process. The new group has the given base and group names.

Note that merge must be called after any operations on the groups to be merged, since their tests will be removed, and it MUST be called before any switch in the active fiename.

TODO: Make this less fragile?

class TestGroup(HasPayload, HasContext, HasGoal):
4071class TestGroup(HasPayload, HasContext, HasGoal):
4072    """
4073    A class representing a group of tests, with methods that can modify
4074    the group. In the rubric, each group of tests becomes a single
4075    `potluck.rubrics.Goal`. Do not create these yourself as they are
4076    created automatically as `TestCase` objects are defined. Instead,
4077    call the `group` function to retrieve a test group after one or more
4078    of its `TestCase` instances have been created.
4079
4080    Note that a `TestGroup` isn't actually `HasPayload` or `HasContext`
4081    but serves as a group object for its `TestCase` objects which are.
4082    """
4083    def __init__(self, base_name, group_name):
4084        """
4085        A group collects tests associated with a particular group name
4086        and base name. It starts out empty but
4087        goals are added to
4088        it automatically. It has various methods for modifying how tests
4089        are run.
4090        """
4091        self.base_name = base_name
4092        self.group_name = group_name
4093        if self.group_name == "_":
4094            group_ident = ""
4095        else:
4096            group_ident = ":" + self.group_name
4097
4098        self.tests = []
4099
4100        # No payload defaults (left to the Test objects)
4101        HasPayload.__init__(self)
4102
4103        # No context defaults (left to the Test objects)
4104        HasContext.__init__(self)
4105
4106        # Set up goal defaults (assuming our base name is the name of a
4107        # function being tested).
4108        if self.base_name == "import":
4109            HasGoal.__init__(
4110                self,
4111                file_utils.deduce_task_id(),
4112                rubrics.ComparisonTest,
4113                default_goal_args={
4114                    "identifier": "import" + group_ident,
4115                    "description": (
4116                        "Your code must exhibit the correct behavior",
4117                        (
4118                            "When we run your submitted code as a whole"
4119                            " file, the pattern of printed output based"
4120                            " on inputs must match the solution's"
4121                            " behavior."
4122                        )
4123                    ),
4124                    "context_slot": "output",
4125                    "checker": compare.omni_compare
4126                }
4127            )
4128        else:
4129            HasGoal.__init__(
4130                self,
4131                file_utils.deduce_task_id(),
4132                rubrics.ComparisonTest,
4133                default_goal_args={
4134                    "identifier": self.base_name + group_ident,
4135                    "description": (
4136                        (
4137                            f"<code>{base_name}</code> must return the"
4138                            f" correct result"
4139                        ),
4140                        (
4141                            f"The result returned when your"
4142                            f" <code>{base_name}</code> function is run must"
4143                            f" match the solution result."
4144                        )
4145                    ),
4146                    "context_slot": "value",
4147                    "checker": compare.omni_compare
4148                }
4149            )
4150
4151    def add(self, test):
4152        """
4153        Adds the given test to this group.
4154
4155        Throws an error if the test is already in a group.
4156        """
4157        if test.group is not None:
4158            raise ValueError(
4159                f"Can't add a test ({test}) to a second group ({self.name})."
4160            )
4161        self.tests.append(test)
4162        test.group = self
4163
4164    def create_goal(self):
4165        """
4166        Constructs and returns the `potluck.rubrics.Goal` object implied
4167        by this `TestGroup`.
4168        """
4169        # Create contexts for our goal from each test in this group
4170        contexts = []
4171        for test in self.tests:
4172            payload = test.construct_payload(self)
4173            # TODO: auto-description here?
4174            # context_args
4175            # custom_context_description
4176            contexts.append(test.create_context(payload, self))
4177
4178        # Create and return result
4179        return self.create_goal_from_contexts(contexts)
4180
4181    def also(self):
4182        """
4183        Constructs a new GroupClone based on this test group (or clone).
4184        Returns the constructed clone. Note that the results of any
4185        customization methods called before this method will be reflected
4186        in the clone, but the results of customization methods called
4187        later will not be. Furthermore, customization methods called on
4188        the clone will not affect the original.
4189
4190        However, new `Test` instances which get grouped into this
4191        `TestGroup` will also be covered by the clone, as long as
4192        `provide_goal` has not been called yet on either the original or
4193        the clone.
4194        """
4195        return GroupClone(self)

A class representing a group of tests, with methods that can modify the group. In the rubric, each group of tests becomes a single potluck.rubrics.Goal. Do not create these yourself as they are created automatically as TestCase objects are defined. Instead, call the group function to retrieve a test group after one or more of its TestCase instances have been created.

Note that a TestGroup isn't actually HasPayload or HasContext but serves as a group object for its TestCase objects which are.

TestGroup(base_name, group_name)
4083    def __init__(self, base_name, group_name):
4084        """
4085        A group collects tests associated with a particular group name
4086        and base name. It starts out empty but
4087        goals are added to
4088        it automatically. It has various methods for modifying how tests
4089        are run.
4090        """
4091        self.base_name = base_name
4092        self.group_name = group_name
4093        if self.group_name == "_":
4094            group_ident = ""
4095        else:
4096            group_ident = ":" + self.group_name
4097
4098        self.tests = []
4099
4100        # No payload defaults (left to the Test objects)
4101        HasPayload.__init__(self)
4102
4103        # No context defaults (left to the Test objects)
4104        HasContext.__init__(self)
4105
4106        # Set up goal defaults (assuming our base name is the name of a
4107        # function being tested).
4108        if self.base_name == "import":
4109            HasGoal.__init__(
4110                self,
4111                file_utils.deduce_task_id(),
4112                rubrics.ComparisonTest,
4113                default_goal_args={
4114                    "identifier": "import" + group_ident,
4115                    "description": (
4116                        "Your code must exhibit the correct behavior",
4117                        (
4118                            "When we run your submitted code as a whole"
4119                            " file, the pattern of printed output based"
4120                            " on inputs must match the solution's"
4121                            " behavior."
4122                        )
4123                    ),
4124                    "context_slot": "output",
4125                    "checker": compare.omni_compare
4126                }
4127            )
4128        else:
4129            HasGoal.__init__(
4130                self,
4131                file_utils.deduce_task_id(),
4132                rubrics.ComparisonTest,
4133                default_goal_args={
4134                    "identifier": self.base_name + group_ident,
4135                    "description": (
4136                        (
4137                            f"<code>{base_name}</code> must return the"
4138                            f" correct result"
4139                        ),
4140                        (
4141                            f"The result returned when your"
4142                            f" <code>{base_name}</code> function is run must"
4143                            f" match the solution result."
4144                        )
4145                    ),
4146                    "context_slot": "value",
4147                    "checker": compare.omni_compare
4148                }
4149            )

A group collects tests associated with a particular group name and base name. It starts out empty but goals are added to it automatically. It has various methods for modifying how tests are run.

def add(self, test):
4151    def add(self, test):
4152        """
4153        Adds the given test to this group.
4154
4155        Throws an error if the test is already in a group.
4156        """
4157        if test.group is not None:
4158            raise ValueError(
4159                f"Can't add a test ({test}) to a second group ({self.name})."
4160            )
4161        self.tests.append(test)
4162        test.group = self

Adds the given test to this group.

Throws an error if the test is already in a group.

def create_goal(self):
4164    def create_goal(self):
4165        """
4166        Constructs and returns the `potluck.rubrics.Goal` object implied
4167        by this `TestGroup`.
4168        """
4169        # Create contexts for our goal from each test in this group
4170        contexts = []
4171        for test in self.tests:
4172            payload = test.construct_payload(self)
4173            # TODO: auto-description here?
4174            # context_args
4175            # custom_context_description
4176            contexts.append(test.create_context(payload, self))
4177
4178        # Create and return result
4179        return self.create_goal_from_contexts(contexts)

Constructs and returns the potluck.rubrics.Goal object implied by this TestGroup.

def also(self):
4181    def also(self):
4182        """
4183        Constructs a new GroupClone based on this test group (or clone).
4184        Returns the constructed clone. Note that the results of any
4185        customization methods called before this method will be reflected
4186        in the clone, but the results of customization methods called
4187        later will not be. Furthermore, customization methods called on
4188        the clone will not affect the original.
4189
4190        However, new `Test` instances which get grouped into this
4191        `TestGroup` will also be covered by the clone, as long as
4192        `provide_goal` has not been called yet on either the original or
4193        the clone.
4194        """
4195        return GroupClone(self)

Constructs a new GroupClone based on this test group (or clone). Returns the constructed clone. Note that the results of any customization methods called before this method will be reflected in the clone, but the results of customization methods called later will not be. Furthermore, customization methods called on the clone will not affect the original.

However, new Test instances which get grouped into this TestGroup will also be covered by the clone, as long as provide_goal has not been called yet on either the original or the clone.

class GroupClone(TestGroup):
4198class GroupClone(TestGroup):
4199    """
4200    A semi-shallow clone of a test group which creates a separate
4201    `potluck.rubrics.Goal` based on the same `potluck.contexts.Context`s
4202    as the original group. Create it using `TestGroup.also` which will
4203    set it up as a clone of the group (or clone) that `also` was called
4204    on.
4205
4206    Method calls on the original object after the call to `also` do not
4207    affect the goal created by the clone, while methods called on the
4208    clone do not affect the original object's goal. However, `Test`
4209    objects created after the creation of the group will be picked up by
4210    both the original and the clone.
4211
4212    The goal that the clone creates will have the same base identifier as
4213    the original goal, although if it's in a different category it will
4214    have a different qualified identifier. Use `HasGoal.set_identifier`
4215    to change the identifier if necessary; the ID system will
4216    automatically append a -number suffix to non-unique identifiers at
4217    rendering time of course.
4218    """
4219    def __init__(self, parent):
4220        """
4221        A parent `TestGroup` instance is required (`GroupClone`s are
4222        also `TestGroup`s). Goal construction parameters for this shadow
4223        will be cloned from that parent at the time of instantiation.
4224        """
4225        self.parent = parent
4226
4227        # Clone payload info from parent
4228        HasPayload.__init__(
4229            self,
4230            parent.payload_constructor,
4231            copy.deepcopy(parent.default_payload_args),
4232            copy.deepcopy(parent.default_augmentations)
4233        )
4234        # Copy explicit args as well
4235        self.payload_args = copy.deepcopy(parent.payload_args)
4236
4237        # Clone context info from parent
4238        HasContext.__init__(
4239            self,
4240            copy.deepcopy(parent.default_context_args)
4241        )
4242        # Copy explicit args as well
4243        self.context_args = copy.deepcopy(parent.context_args)
4244
4245        # Clone goal info from parent
4246        HasGoal.__init__(
4247            self,
4248            parent.taskid,
4249            parent.goal_constructor,
4250            copy.deepcopy(parent.default_goal_args)
4251        )
4252        # Copy explicit args as well
4253        self.goal_args = copy.deepcopy(parent.goal_args)
4254
4255        # Copy parent names
4256        self.base_name = parent.base_name
4257        self.group_name = parent.group_name
4258
4259        # Note: we never actually call TestGroup.__init__
4260        # As a result we do not have tests
4261
4262    def add(self):
4263        """
4264        Override to disable adding tests.
4265        """
4266        raise NotImplementedError("Cannot add a test to a cloned group.")
4267
4268    def create_goal(self):
4269        """
4270        Constructs and returns the `potluck.rubrics.Goal` object implied
4271        by this `GroupClone`.
4272        """
4273        # TODO: This breaks a lot of things you might want to do with a
4274        # clone, since they DON'T get their own contexts, so you can't
4275        # call something like test_trace and actually get trace. We need
4276        # better error messages around that, AND/or to fix it!
4277        # Note that this is the point of a clone though: you can always
4278        # easily create duplicate Goal objects...
4279        # Dig up parent contexts via goal (w/ caching)
4280        pgoal = self.parent.provide_goal()
4281        parent_contexts = pgoal.test_in["contexts"]
4282
4283        # Warn if for some reason we had explicit contexts
4284        if (
4285            "test_in" in self.goal_args
4286        and "contexts" in self.goal_args["test_in"]
4287        ):
4288            logging.debug_msg(
4289                "Warning: overriding existing test_in/contexts value in"
4290                " GroupClone.create_goal."
4291            )
4292
4293        # Return a goal created using our parent's contexts
4294        return self.create_goal_from_contexts(parent_contexts)

A semi-shallow clone of a test group which creates a separate potluck.rubrics.Goal based on the same potluck.contexts.Contexts as the original group. Create it using TestGroup.also which will set it up as a clone of the group (or clone) that also was called on.

Method calls on the original object after the call to also do not affect the goal created by the clone, while methods called on the clone do not affect the original object's goal. However, Test objects created after the creation of the group will be picked up by both the original and the clone.

The goal that the clone creates will have the same base identifier as the original goal, although if it's in a different category it will have a different qualified identifier. Use HasGoal.set_identifier to change the identifier if necessary; the ID system will automatically append a -number suffix to non-unique identifiers at rendering time of course.

GroupClone(parent)
4219    def __init__(self, parent):
4220        """
4221        A parent `TestGroup` instance is required (`GroupClone`s are
4222        also `TestGroup`s). Goal construction parameters for this shadow
4223        will be cloned from that parent at the time of instantiation.
4224        """
4225        self.parent = parent
4226
4227        # Clone payload info from parent
4228        HasPayload.__init__(
4229            self,
4230            parent.payload_constructor,
4231            copy.deepcopy(parent.default_payload_args),
4232            copy.deepcopy(parent.default_augmentations)
4233        )
4234        # Copy explicit args as well
4235        self.payload_args = copy.deepcopy(parent.payload_args)
4236
4237        # Clone context info from parent
4238        HasContext.__init__(
4239            self,
4240            copy.deepcopy(parent.default_context_args)
4241        )
4242        # Copy explicit args as well
4243        self.context_args = copy.deepcopy(parent.context_args)
4244
4245        # Clone goal info from parent
4246        HasGoal.__init__(
4247            self,
4248            parent.taskid,
4249            parent.goal_constructor,
4250            copy.deepcopy(parent.default_goal_args)
4251        )
4252        # Copy explicit args as well
4253        self.goal_args = copy.deepcopy(parent.goal_args)
4254
4255        # Copy parent names
4256        self.base_name = parent.base_name
4257        self.group_name = parent.group_name
4258
4259        # Note: we never actually call TestGroup.__init__
4260        # As a result we do not have tests

A parent TestGroup instance is required (GroupClones are also TestGroups). Goal construction parameters for this shadow will be cloned from that parent at the time of instantiation.

def add(self):
4262    def add(self):
4263        """
4264        Override to disable adding tests.
4265        """
4266        raise NotImplementedError("Cannot add a test to a cloned group.")

Override to disable adding tests.

def create_goal(self):
4268    def create_goal(self):
4269        """
4270        Constructs and returns the `potluck.rubrics.Goal` object implied
4271        by this `GroupClone`.
4272        """
4273        # TODO: This breaks a lot of things you might want to do with a
4274        # clone, since they DON'T get their own contexts, so you can't
4275        # call something like test_trace and actually get trace. We need
4276        # better error messages around that, AND/or to fix it!
4277        # Note that this is the point of a clone though: you can always
4278        # easily create duplicate Goal objects...
4279        # Dig up parent contexts via goal (w/ caching)
4280        pgoal = self.parent.provide_goal()
4281        parent_contexts = pgoal.test_in["contexts"]
4282
4283        # Warn if for some reason we had explicit contexts
4284        if (
4285            "test_in" in self.goal_args
4286        and "contexts" in self.goal_args["test_in"]
4287        ):
4288            logging.debug_msg(
4289                "Warning: overriding existing test_in/contexts value in"
4290                " GroupClone.create_goal."
4291            )
4292
4293        # Return a goal created using our parent's contexts
4294        return self.create_goal_from_contexts(parent_contexts)

Constructs and returns the potluck.rubrics.Goal object implied by this GroupClone.

class RefinedTest(HasContext, HasGoal):
4301class RefinedTest(HasContext, HasGoal):
4302    """
4303    Represents further processing of a test result, via a new
4304    `potluck.rubrics.Goal` object that gets tested in a group of
4305    `potluck.contexts.Context` objects which are based on extensions of
4306    the context(s) used for the parent object (or which will be tested in
4307    a single context which merges information from parent contexts, if
4308    `_merge` is set). Any `HasGoal` subclass can support refinement (and
4309    `HasGoal.refine` is the proper way to instantiate refined tests).
4310
4311    Subclasses should override the `build_context` method to define what
4312    kind of additional processing they want to do to each context of the
4313    parent goal. This method needs to accept a context dictionary as its
4314    only argument (besides self) and return a dictionary of any new
4315    context slots it creates/updates, just like all context builder
4316    functions.
4317
4318    Subclasses may also set the `_merge` property to True instead of the
4319    default False, which will cause them to derive a single context that
4320    depends on all of the parent contexts instead of deriving one child
4321    context per parent context.
4322
4323    Note that by default the goal constructed will be an
4324    `potluck.rubrics.ComparisonTest`, and the same context slot as the
4325    parent will be used as the slot to test. You can use the `HasGoal`
4326    machinery to change these defaults.
4327
4328    The refined goal's identifier will be the parent goal's identifier
4329    plus a colon plus the identifier given to the refined goal.
4330    """
4331
4332    _merge = False
4333    """
4334    Set this to True in a child class if rather than creating one derived
4335    context for each parent context, the resulting goal should be tested
4336    in just a single context that depends on ALL of the parent context
4337    objects individually.
4338    """
4339
4340    def build_context(self, prev_context):
4341        """
4342        Not implemented (override to specify how refined contexts are
4343        created from base contexts).
4344        """
4345        raise NotImplementedError(
4346            "RefinedTest is an abstract class and cannot be used"
4347            " directly."
4348        )
4349
4350    def __init__(
4351        self,
4352        parent,
4353        identifier,
4354        context_slot=None,
4355        checker=None
4356    ):
4357        """
4358        A parent object is required; it must have a provide_goal method,
4359        and should be a `HasGoal` instance.
4360
4361        An identifier is also required, it will be combined with the
4362        parent's identifier (separated by a colon).
4363
4364        A specific context slot to target and checker to use may be
4365        specified, or if left as defaults these will be inherited from
4366        the parent object.
4367
4368        Note that supplying context and/or goal descriptions via
4369        `HasContext.set_context_description` and/or
4370        `HasGoal.set_goal_description` is almost always necessary.
4371        """
4372        self.parent = parent
4373
4374        # No context defaults (but note that builder & depends will be
4375        # overwritten in the end.
4376        HasContext.__init__(self)
4377
4378        if context_slot is None:
4379            context_slot = parent.goal_args.get(
4380                "context_slot",
4381                parent.goal_args.get(
4382                    "context_slot",
4383                    parent.default_goal_args.get("context_slot", "value")
4384                )
4385            )
4386
4387        if checker is None:
4388            checker = parent.default_goal_args.get(
4389                "checker",
4390                compare.omni_compare
4391            )
4392
4393        pident = parent.goal_args.get(
4394            "identifier",
4395            parent.default_goal_args.get("identifier")
4396        )
4397        if pident is None:
4398            id_prefix = ""
4399        else:
4400            id_prefix = pident + ":"
4401
4402        # Set up goal defaults
4403        HasGoal.__init__(
4404            self,
4405            parent.taskid,
4406            rubrics.ComparisonTest,
4407            default_goal_args={
4408                "identifier": id_prefix + identifier,
4409                "context_slot": context_slot,
4410                "checker": checker
4411            }
4412        )
4413
4414    def create_goal(self):
4415        """
4416        Returns the `potluck.rubrics.Goal` implied by this refined test.
4417        """
4418        pgoal = self.parent.provide_goal()
4419        parent_contexts = pgoal.test_in["contexts"]
4420
4421        if "depends" in self.context_args:
4422            logging.debug_msg(
4423                "Warning: overriding existing depends value in"
4424                " Refine.create_goal."
4425            )
4426
4427        # Construct a child context for each parent context
4428        if self._merge:
4429            # derive one child context that depends on all parent
4430            # contexts at once
4431            self.context_args["depends"] = parent_contexts
4432            contexts = [ self.create_context(self.build_context) ]
4433        else: # derive one child context from each parent context
4434            contexts = []
4435            for pc in parent_contexts:
4436                # Create context w/ specific dependency
4437                self.context_args["depends"] = [ pc ]
4438                contexts.append(self.create_context(self.build_context))
4439
4440        # Clean up dependency information for future
4441        del self.context_args["depends"]
4442
4443        # Create & return our goal
4444        return self.create_goal_from_contexts(contexts)

Represents further processing of a test result, via a new potluck.rubrics.Goal object that gets tested in a group of potluck.contexts.Context objects which are based on extensions of the context(s) used for the parent object (or which will be tested in a single context which merges information from parent contexts, if _merge is set). Any HasGoal subclass can support refinement (and HasGoal.refine is the proper way to instantiate refined tests).

Subclasses should override the build_context method to define what kind of additional processing they want to do to each context of the parent goal. This method needs to accept a context dictionary as its only argument (besides self) and return a dictionary of any new context slots it creates/updates, just like all context builder functions.

Subclasses may also set the _merge property to True instead of the default False, which will cause them to derive a single context that depends on all of the parent contexts instead of deriving one child context per parent context.

Note that by default the goal constructed will be an potluck.rubrics.ComparisonTest, and the same context slot as the parent will be used as the slot to test. You can use the HasGoal machinery to change these defaults.

The refined goal's identifier will be the parent goal's identifier plus a colon plus the identifier given to the refined goal.

RefinedTest(parent, identifier, context_slot=None, checker=None)
4350    def __init__(
4351        self,
4352        parent,
4353        identifier,
4354        context_slot=None,
4355        checker=None
4356    ):
4357        """
4358        A parent object is required; it must have a provide_goal method,
4359        and should be a `HasGoal` instance.
4360
4361        An identifier is also required, it will be combined with the
4362        parent's identifier (separated by a colon).
4363
4364        A specific context slot to target and checker to use may be
4365        specified, or if left as defaults these will be inherited from
4366        the parent object.
4367
4368        Note that supplying context and/or goal descriptions via
4369        `HasContext.set_context_description` and/or
4370        `HasGoal.set_goal_description` is almost always necessary.
4371        """
4372        self.parent = parent
4373
4374        # No context defaults (but note that builder & depends will be
4375        # overwritten in the end.
4376        HasContext.__init__(self)
4377
4378        if context_slot is None:
4379            context_slot = parent.goal_args.get(
4380                "context_slot",
4381                parent.goal_args.get(
4382                    "context_slot",
4383                    parent.default_goal_args.get("context_slot", "value")
4384                )
4385            )
4386
4387        if checker is None:
4388            checker = parent.default_goal_args.get(
4389                "checker",
4390                compare.omni_compare
4391            )
4392
4393        pident = parent.goal_args.get(
4394            "identifier",
4395            parent.default_goal_args.get("identifier")
4396        )
4397        if pident is None:
4398            id_prefix = ""
4399        else:
4400            id_prefix = pident + ":"
4401
4402        # Set up goal defaults
4403        HasGoal.__init__(
4404            self,
4405            parent.taskid,
4406            rubrics.ComparisonTest,
4407            default_goal_args={
4408                "identifier": id_prefix + identifier,
4409                "context_slot": context_slot,
4410                "checker": checker
4411            }
4412        )

A parent object is required; it must have a provide_goal method, and should be a HasGoal instance.

An identifier is also required, it will be combined with the parent's identifier (separated by a colon).

A specific context slot to target and checker to use may be specified, or if left as defaults these will be inherited from the parent object.

Note that supplying context and/or goal descriptions via HasContext.set_context_description and/or HasGoal.set_goal_description is almost always necessary.

def build_context(self, prev_context):
4340    def build_context(self, prev_context):
4341        """
4342        Not implemented (override to specify how refined contexts are
4343        created from base contexts).
4344        """
4345        raise NotImplementedError(
4346            "RefinedTest is an abstract class and cannot be used"
4347            " directly."
4348        )

Not implemented (override to specify how refined contexts are created from base contexts).

def create_goal(self):
4414    def create_goal(self):
4415        """
4416        Returns the `potluck.rubrics.Goal` implied by this refined test.
4417        """
4418        pgoal = self.parent.provide_goal()
4419        parent_contexts = pgoal.test_in["contexts"]
4420
4421        if "depends" in self.context_args:
4422            logging.debug_msg(
4423                "Warning: overriding existing depends value in"
4424                " Refine.create_goal."
4425            )
4426
4427        # Construct a child context for each parent context
4428        if self._merge:
4429            # derive one child context that depends on all parent
4430            # contexts at once
4431            self.context_args["depends"] = parent_contexts
4432            contexts = [ self.create_context(self.build_context) ]
4433        else: # derive one child context from each parent context
4434            contexts = []
4435            for pc in parent_contexts:
4436                # Create context w/ specific dependency
4437                self.context_args["depends"] = [ pc ]
4438                contexts.append(self.create_context(self.build_context))
4439
4440        # Clean up dependency information for future
4441        del self.context_args["depends"]
4442
4443        # Create & return our goal
4444        return self.create_goal_from_contexts(contexts)

Returns the potluck.rubrics.Goal implied by this refined test.

class AlterContext(RefinedTest):
4447class AlterContext(RefinedTest):
4448    """
4449    A `RefinedTest` which simply applies an arbitrary context-builder
4450    function to the unrefined context. As usual for a context builder,
4451    the function's results will be updated into the existing context
4452    automatically. Note that a simpler way to achieve similar
4453    functionality is to use `HasPayload.do_setup` and/or
4454    `HasPayload.do_cleanup` to add custom context slots, along with
4455    `HasGoal.compare_using` to set the context slot to compare.
4456    """
4457    def __init__(
4458        self,
4459        parent,
4460        identifier,
4461        context_builder,
4462        **kwargs
4463    ):
4464        """
4465        A parent goal provider, an identifier, and a context builder
4466        function are necessary.
4467
4468        Further keyword arguments will be passed through to
4469        `RefinedTest`'s constructor.
4470        """
4471        self.builder = context_builder
4472
4473        super().__init__(
4474            parent,
4475            identifier,
4476            **kwargs
4477        )
4478
4479        cs = self.default_goal_args.get("context_slot", "value")
4480
4481        self.default_context_args["display_product"] = (
4482            contexts.build_context_value_displayer(
4483                cs,
4484                labels=[
4485                    f"Your {cs}",
4486                    f"Solution {cs}",
4487                    "Comparison",
4488                ]
4489            )
4490        )
4491
4492    def build_context(self, prev_context):
4493        """
4494        A context builder that simply runs the specified custom context
4495        builder function.
4496        """
4497        return self.builder(prev_context)

A RefinedTest which simply applies an arbitrary context-builder function to the unrefined context. As usual for a context builder, the function's results will be updated into the existing context automatically. Note that a simpler way to achieve similar functionality is to use HasPayload.do_setup and/or HasPayload.do_cleanup to add custom context slots, along with HasGoal.compare_using to set the context slot to compare.

AlterContext(parent, identifier, context_builder, **kwargs)
4457    def __init__(
4458        self,
4459        parent,
4460        identifier,
4461        context_builder,
4462        **kwargs
4463    ):
4464        """
4465        A parent goal provider, an identifier, and a context builder
4466        function are necessary.
4467
4468        Further keyword arguments will be passed through to
4469        `RefinedTest`'s constructor.
4470        """
4471        self.builder = context_builder
4472
4473        super().__init__(
4474            parent,
4475            identifier,
4476            **kwargs
4477        )
4478
4479        cs = self.default_goal_args.get("context_slot", "value")
4480
4481        self.default_context_args["display_product"] = (
4482            contexts.build_context_value_displayer(
4483                cs,
4484                labels=[
4485                    f"Your {cs}",
4486                    f"Solution {cs}",
4487                    "Comparison",
4488                ]
4489            )
4490        )

A parent goal provider, an identifier, and a context builder function are necessary.

Further keyword arguments will be passed through to RefinedTest's constructor.

def build_context(self, prev_context):
4492    def build_context(self, prev_context):
4493        """
4494        A context builder that simply runs the specified custom context
4495        builder function.
4496        """
4497        return self.builder(prev_context)

A context builder that simply runs the specified custom context builder function.

class Transform(RefinedTest):
4500class Transform(RefinedTest):
4501    """
4502    A Transform is a kind of refinement which applies an arbitrary
4503    function to a context slot, sorting the result of that function in
4504    the same slot. The specific slot that the transformation is applied
4505    to is implied by the "context_slot" default goal argument of the goal
4506    being refined, although a specific context slot to target may be
4507    specified via the arguments passed through to `RefinedTest`.
4508    """
4509    def __init__(
4510        self,
4511        parent,
4512        identifier,
4513        transformer,
4514        result_desc="a transformed result",
4515        refine_ref=True,
4516        **kwargs
4517    ):
4518        """
4519        A parent goal provider, an identifier, and a transformation
4520        function are necessary. A description for the result may be
4521        provided if a full custom description isn't being used.
4522        `refine_ref` may be set to False to avoid also transforming the
4523        equivalent reference slot.
4524
4525        Further keyword arguments will be passed through to
4526        `RefinedTest`'s constructor.
4527        """
4528        self.transformer = transformer
4529
4530        self.refine_ref = refine_ref
4531
4532        super().__init__(
4533            parent,
4534            identifier,
4535            **kwargs
4536        )
4537
4538        cs = self.default_goal_args.get("context_slot", "value")
4539
4540        # TODO: Some way to name individual parent contexts here...
4541        self.default_context_args["description"] = (
4542            f"{result_desc} of the {cs}".capitalize(),
4543            f"We will create {result_desc} from the {cs}.",
4544            f"{result_desc} of the {cs}".capitalize(),
4545            f"We created {result_desc} from the {cs}.",
4546        )
4547
4548        self.default_context_args["display_product"] = (
4549            contexts.build_context_value_displayer(
4550                cs,
4551                labels=[
4552                    f"Your {cs}",
4553                    f"Solution {cs}",
4554                    "Comparison",
4555                ]
4556            )
4557        )
4558
4559        self.default_goal_args["description"] = (
4560            f"{result_desc} of the {cs} must be correct".capitalize(),
4561            f"{result_desc} of the {cs} must match the solution's {cs}.",
4562            f"{result_desc} of the {cs} must be correct".capitalize(),
4563            (
4564                f"We checked whether {result_desc} of the {cs} matched"
4565                f" the solution's {cs}."
4566            )
4567        )
4568
4569    def build_context(self, prev_context):
4570        """
4571        A context builder that replaces certain context fields with the
4572        results of running a transformation function over them.
4573        """
4574        result = {}
4575        # TODO: Args synthesis here?!?
4576        context_slot = self.default_goal_args.get("context_slot", "value")
4577        target_slots = [ context_slot ]
4578        if self.refine_ref:
4579            target_slots.append("ref_" + context_slot)
4580
4581        for slot in target_slots:
4582            orig = context_utils.extract(prev_context, slot)
4583            transformed = self.transformer(orig)
4584            result[slot] = transformed
4585
4586        return result

A Transform is a kind of refinement which applies an arbitrary function to a context slot, sorting the result of that function in the same slot. The specific slot that the transformation is applied to is implied by the "context_slot" default goal argument of the goal being refined, although a specific context slot to target may be specified via the arguments passed through to RefinedTest.

Transform( parent, identifier, transformer, result_desc='a transformed result', refine_ref=True, **kwargs)
4509    def __init__(
4510        self,
4511        parent,
4512        identifier,
4513        transformer,
4514        result_desc="a transformed result",
4515        refine_ref=True,
4516        **kwargs
4517    ):
4518        """
4519        A parent goal provider, an identifier, and a transformation
4520        function are necessary. A description for the result may be
4521        provided if a full custom description isn't being used.
4522        `refine_ref` may be set to False to avoid also transforming the
4523        equivalent reference slot.
4524
4525        Further keyword arguments will be passed through to
4526        `RefinedTest`'s constructor.
4527        """
4528        self.transformer = transformer
4529
4530        self.refine_ref = refine_ref
4531
4532        super().__init__(
4533            parent,
4534            identifier,
4535            **kwargs
4536        )
4537
4538        cs = self.default_goal_args.get("context_slot", "value")
4539
4540        # TODO: Some way to name individual parent contexts here...
4541        self.default_context_args["description"] = (
4542            f"{result_desc} of the {cs}".capitalize(),
4543            f"We will create {result_desc} from the {cs}.",
4544            f"{result_desc} of the {cs}".capitalize(),
4545            f"We created {result_desc} from the {cs}.",
4546        )
4547
4548        self.default_context_args["display_product"] = (
4549            contexts.build_context_value_displayer(
4550                cs,
4551                labels=[
4552                    f"Your {cs}",
4553                    f"Solution {cs}",
4554                    "Comparison",
4555                ]
4556            )
4557        )
4558
4559        self.default_goal_args["description"] = (
4560            f"{result_desc} of the {cs} must be correct".capitalize(),
4561            f"{result_desc} of the {cs} must match the solution's {cs}.",
4562            f"{result_desc} of the {cs} must be correct".capitalize(),
4563            (
4564                f"We checked whether {result_desc} of the {cs} matched"
4565                f" the solution's {cs}."
4566            )
4567        )

A parent goal provider, an identifier, and a transformation function are necessary. A description for the result may be provided if a full custom description isn't being used. refine_ref may be set to False to avoid also transforming the equivalent reference slot.

Further keyword arguments will be passed through to RefinedTest's constructor.

def build_context(self, prev_context):
4569    def build_context(self, prev_context):
4570        """
4571        A context builder that replaces certain context fields with the
4572        results of running a transformation function over them.
4573        """
4574        result = {}
4575        # TODO: Args synthesis here?!?
4576        context_slot = self.default_goal_args.get("context_slot", "value")
4577        target_slots = [ context_slot ]
4578        if self.refine_ref:
4579            target_slots.append("ref_" + context_slot)
4580
4581        for slot in target_slots:
4582            orig = context_utils.extract(prev_context, slot)
4583            transformed = self.transformer(orig)
4584            result[slot] = transformed
4585
4586        return result

A context builder that replaces certain context fields with the results of running a transformation function over them.

class Find(RefinedTest):
4589class Find(RefinedTest):
4590    """
4591    A Find is a kind of refinement which applies a regular expression to
4592    a context slot (which we hope will be holding a string).
4593    """
4594    def __init__(
4595        self,
4596        parent,
4597        pattern,
4598        pattern_desc="a specific part",
4599        missing_result=None,
4600        first_match=True,
4601        match_details=False,
4602        refine_ref=True,
4603        **kwargs
4604    ):
4605        """
4606        A parent goal provider and a pattern (either a string or a
4607        compiled regular expression) are necessary.
4608
4609        The identifier will be based on replacing spaces in the pattern
4610        description with underscores.
4611
4612        Behavior may be controlled by the following keyword arguments:
4613
4614        - `pattern_desc`: A string that will be used to provide automatic
4615            context and goal descriptions. Use `set_context_description`
4616            and/or `set_goal_description` if this isn't expressive
4617            enough.
4618        - `first_match`: If set to False, the result will be a (possibly
4619            empty) list of all matches. If set to True (the default) only
4620            the first match is used, and None is used if there are no
4621            matches.
4622        - `missing_result`: A value to use if no match is found and
4623            first_match is set to True.
4624        - `match_details`: If True, the result will take the form of one
4625            or more re.Match objects instead of strings.
4626        - `refine_ref`: If True, the "ref_" context slot that matches the
4627            target context slot will be refined in addition to the base
4628            slot.
4629
4630        Further keyword arguments will be passed through to
4631        `RefinedTest`'s constructor.
4632
4633        Note that only very generic default context and goal descriptions
4634        are provided.
4635        """
4636        self.pattern = pattern
4637        if isinstance(self.pattern, str):
4638            self.pattern = re.compile(self.pattern)
4639
4640        self.first_match = first_match
4641        self.missing_result = missing_result
4642        self.match_details = match_details
4643        self.refine_ref = refine_ref
4644
4645        if "identifier" not in kwargs:
4646            kwargs["identifier"] = pattern_desc.replace(' ', '_')
4647
4648        super().__init__(parent, **kwargs)
4649
4650        cs = self.default_goal_args.get("context_slot", "value")
4651
4652        # TODO: Some way to name individual parent contexts here...
4653        self.default_context_args["description"] = (
4654            f"{pattern_desc} of the {cs}".capitalize(),
4655            f"We will search through the {cs} for {pattern_desc}.",
4656            f"{pattern_desc} of the {cs}".capitalize(),
4657            f"We searched through the {cs} for {pattern_desc}.",
4658        )
4659
4660        self.default_context_args["display_product"] = (
4661            contexts.build_context_value_displayer(
4662                cs,
4663                labels=[
4664                    f"Your {cs}",
4665                    f"Solution {cs}",
4666                    "Comparison",
4667                ]
4668            )
4669        )
4670
4671        self.default_goal_args["description"] = (
4672            f"{pattern_desc} of the {cs} must be correct".capitalize(),
4673            f"{pattern_desc} of the {cs} must match the solution's {cs}.",
4674            f"{pattern_desc} of the {cs} must be correct".capitalize(),
4675            (
4676                f"We checked whether {pattern_desc} of the {cs} matched"
4677                f" the solution's {cs}."
4678            )
4679        )
4680
4681    def build_context(self, prev_context):
4682        """
4683        A context builder that replaces certain context fields with the
4684        results of running a regular expression over them.
4685        """
4686        result = {}
4687        # TODO: Args synthesis here?!?
4688        context_slot = self.default_goal_args.get("context_slot", "value")
4689        target_slots = [ context_slot ]
4690        if self.refine_ref:
4691            target_slots.append("ref_" + context_slot)
4692
4693        for slot in target_slots:
4694            orig = context_utils.extract(prev_context, slot)
4695            if not isinstance(orig, str):
4696                raise TypeError(
4697                    (
4698                        "Attempted to refine '{}' context, but it was "
4699                      + "not a string."
4700                    ).format(slot)
4701                )
4702
4703            matches = self.pattern.finditer(orig)
4704
4705            if self.first_match:
4706                try:
4707                    first = next(matches)
4708                    if self.match_details:
4709                        result[slot] = first
4710                    else:
4711                        result[slot] = first.group()
4712                except StopIteration:
4713                    result[slot] = self.missing_result
4714            else:
4715                if self.match_details:
4716                    objs = [m for m in matches]
4717                else:
4718                    objs = [m.group() for m in matches]
4719
4720                result[slot] = objs
4721
4722        return result

A Find is a kind of refinement which applies a regular expression to a context slot (which we hope will be holding a string).

Find( parent, pattern, pattern_desc='a specific part', missing_result=None, first_match=True, match_details=False, refine_ref=True, **kwargs)
4594    def __init__(
4595        self,
4596        parent,
4597        pattern,
4598        pattern_desc="a specific part",
4599        missing_result=None,
4600        first_match=True,
4601        match_details=False,
4602        refine_ref=True,
4603        **kwargs
4604    ):
4605        """
4606        A parent goal provider and a pattern (either a string or a
4607        compiled regular expression) are necessary.
4608
4609        The identifier will be based on replacing spaces in the pattern
4610        description with underscores.
4611
4612        Behavior may be controlled by the following keyword arguments:
4613
4614        - `pattern_desc`: A string that will be used to provide automatic
4615            context and goal descriptions. Use `set_context_description`
4616            and/or `set_goal_description` if this isn't expressive
4617            enough.
4618        - `first_match`: If set to False, the result will be a (possibly
4619            empty) list of all matches. If set to True (the default) only
4620            the first match is used, and None is used if there are no
4621            matches.
4622        - `missing_result`: A value to use if no match is found and
4623            first_match is set to True.
4624        - `match_details`: If True, the result will take the form of one
4625            or more re.Match objects instead of strings.
4626        - `refine_ref`: If True, the "ref_" context slot that matches the
4627            target context slot will be refined in addition to the base
4628            slot.
4629
4630        Further keyword arguments will be passed through to
4631        `RefinedTest`'s constructor.
4632
4633        Note that only very generic default context and goal descriptions
4634        are provided.
4635        """
4636        self.pattern = pattern
4637        if isinstance(self.pattern, str):
4638            self.pattern = re.compile(self.pattern)
4639
4640        self.first_match = first_match
4641        self.missing_result = missing_result
4642        self.match_details = match_details
4643        self.refine_ref = refine_ref
4644
4645        if "identifier" not in kwargs:
4646            kwargs["identifier"] = pattern_desc.replace(' ', '_')
4647
4648        super().__init__(parent, **kwargs)
4649
4650        cs = self.default_goal_args.get("context_slot", "value")
4651
4652        # TODO: Some way to name individual parent contexts here...
4653        self.default_context_args["description"] = (
4654            f"{pattern_desc} of the {cs}".capitalize(),
4655            f"We will search through the {cs} for {pattern_desc}.",
4656            f"{pattern_desc} of the {cs}".capitalize(),
4657            f"We searched through the {cs} for {pattern_desc}.",
4658        )
4659
4660        self.default_context_args["display_product"] = (
4661            contexts.build_context_value_displayer(
4662                cs,
4663                labels=[
4664                    f"Your {cs}",
4665                    f"Solution {cs}",
4666                    "Comparison",
4667                ]
4668            )
4669        )
4670
4671        self.default_goal_args["description"] = (
4672            f"{pattern_desc} of the {cs} must be correct".capitalize(),
4673            f"{pattern_desc} of the {cs} must match the solution's {cs}.",
4674            f"{pattern_desc} of the {cs} must be correct".capitalize(),
4675            (
4676                f"We checked whether {pattern_desc} of the {cs} matched"
4677                f" the solution's {cs}."
4678            )
4679        )

A parent goal provider and a pattern (either a string or a compiled regular expression) are necessary.

The identifier will be based on replacing spaces in the pattern description with underscores.

Behavior may be controlled by the following keyword arguments:

  • pattern_desc: A string that will be used to provide automatic context and goal descriptions. Use set_context_description and/or set_goal_description if this isn't expressive enough.
  • first_match: If set to False, the result will be a (possibly empty) list of all matches. If set to True (the default) only the first match is used, and None is used if there are no matches.
  • missing_result: A value to use if no match is found and first_match is set to True.
  • match_details: If True, the result will take the form of one or more re.Match objects instead of strings.
  • refine_ref: If True, the "ref_" context slot that matches the target context slot will be refined in addition to the base slot.

Further keyword arguments will be passed through to RefinedTest's constructor.

Note that only very generic default context and goal descriptions are provided.

def build_context(self, prev_context):
4681    def build_context(self, prev_context):
4682        """
4683        A context builder that replaces certain context fields with the
4684        results of running a regular expression over them.
4685        """
4686        result = {}
4687        # TODO: Args synthesis here?!?
4688        context_slot = self.default_goal_args.get("context_slot", "value")
4689        target_slots = [ context_slot ]
4690        if self.refine_ref:
4691            target_slots.append("ref_" + context_slot)
4692
4693        for slot in target_slots:
4694            orig = context_utils.extract(prev_context, slot)
4695            if not isinstance(orig, str):
4696                raise TypeError(
4697                    (
4698                        "Attempted to refine '{}' context, but it was "
4699                      + "not a string."
4700                    ).format(slot)
4701                )
4702
4703            matches = self.pattern.finditer(orig)
4704
4705            if self.first_match:
4706                try:
4707                    first = next(matches)
4708                    if self.match_details:
4709                        result[slot] = first
4710                    else:
4711                        result[slot] = first.group()
4712                except StopIteration:
4713                    result[slot] = self.missing_result
4714            else:
4715                if self.match_details:
4716                    objs = [m for m in matches]
4717                else:
4718                    objs = [m.group() for m in matches]
4719
4720                result[slot] = objs
4721
4722        return result

A context builder that replaces certain context fields with the results of running a regular expression over them.

class DistinctionReport(RefinedTest):
4725class DistinctionReport(RefinedTest):
4726    """
4727    A `RefinedTest` which analyzes results from the context slot
4728    originally targeted, and determines, among parent contexts that were
4729    originally going to be separate tests, which results are distinct and
4730    which are identical. It creates a "distinctions" context slot
4731    containing a multi-line string reporting on these distinctions, and
4732    sets up that slot as the test target.
4733    """
4734    _merge = True # we are going to merge parent contexts
4735
4736    def build_context(self, prev_context):
4737        """
4738        Uses the "__unmerged__" special context slot to access
4739        individual results from each parent context, and creates a
4740        mapping in the "distinctions" slot that maps unique results to
4741        lists of context dictionaries in which the test produced those
4742        results (note that this means that results must be hashable!).
4743
4744        The slot of each parent context that it pays attention to is
4745        determined by the slot which the unrefined goal would have
4746        tested.
4747        """
4748        result = { "distinctions": {}, "ref_distinctions": {} }
4749
4750        # Process both actual and reference values
4751        for prefix in ("", "ref_"):
4752            slot = prefix + self.original_slot
4753            dest = prefix + "distinctions"
4754
4755            # produce our mapping from results to lists of contexts
4756            mapping = {}
4757            for i, parent_context in enumerate(
4758                prev_context["__unmerged__"]
4759            ):
4760                # get value and signature
4761                val = parent_context[slot]
4762                if self.filters:
4763                    for filt in self.filters:
4764                        val = filt(val)
4765                # update our mapping
4766                mapping.setdefault(val, []).append(parent_context)
4767
4768            result[dest] = mapping
4769
4770        return result
4771
4772    def render(report):
4773        """
4774        A class method for rendering a distinction report as HTML.
4775        """
4776        uniques = []
4777        groups = []
4778        all_contexts = []
4779        for result in report:
4780            contexts = report[result]
4781            all_contexts.extend(contexts)
4782            if len(contexts) == 1:
4783                uniques.append(contexts[0])
4784            else:
4785                groups.append(contexts)
4786
4787        if len(groups) == 0:
4788            return (
4789                f"All {len(all_contexts)} contexts produced distinct"
4790                f" outcomes:<br>\n"
4791            ) + html_tools.build_list(
4792                ctx["__builder__"].feedback_topic()
4793                for ctx in all_contexts
4794            )
4795        elif len(uniques) == 0 and len(groups) == 1:
4796            return (
4797                f"All {len(all_contexts)} contexts produced equivalent"
4798                f" outcomes:<br>\n"
4799            ) + html_tools.build_list(
4800                ctx["__builder__"].feedback_topic()
4801                for ctx in all_contexts
4802            )
4803        else:
4804            items = [
4805                f"Group #{i+1}:<br>\n" + html_tools.build_list(
4806                    ctx["__builder__"].feedback_topic()
4807                    for ctx in group
4808                )
4809                for i, group in enumerate(groups)
4810            ] + [
4811                "Unique: " + ctx["__builder__"].feedback_topic()
4812                for ctx in uniques
4813            ]
4814            return (
4815                "The contexts' outcomes were grouped as follows:\n"
4816            ) + html_tools.build_list(items)
4817
4818    def display_context_value(context):
4819        """
4820        A class method to be used as a context value displayer.
4821        """
4822        val = context["distinctions"]
4823        ref = context["ref_distinctions"]
4824
4825        return html_tools.build_html_tabs(
4826            [
4827                ("Your distinctions", DistinctionReport.render(val)),
4828                ("Correct distinctions", DistinctionReport.render(ref)),
4829            ]
4830        )
4831
4832    def compare(val, ref):
4833        """
4834        A class method for comparing distinction mappings. Succeeds if
4835        the mappings have equivalent groupings with respect to the
4836        `potluck.contexts.Context` objects that produce different
4837        results, and fails otherwise.
4838        """
4839        val_rmap = {}
4840        ref_rmap = {}
4841
4842        val_groups = []
4843        ref_groups = []
4844
4845        for result in val:
4846            contexts = val[result]
4847            val_groups.append(
4848                set(id(context["__builder__"]) for context in contexts)
4849            )
4850            for context in contexts:
4851                val_rmap[id(context["__builder__"])] = result
4852
4853        for result in ref:
4854            contexts = ref[result]
4855            ref_groups.append(
4856                set(id(context["__builder__"]) for context in contexts)
4857            )
4858            for context in contexts:
4859                ref_rmap[id(context["__builder__"])] = result
4860
4861        # Make sure ordering is the same because it shouldn't matter
4862        val_groups.sort(key=lambda group: sorted(group))
4863        ref_groups.sort(key=lambda group: sorted(group))
4864
4865        # Groupings are the same
4866        if val_groups == ref_groups:
4867            return {
4868                "status": "accomplished",
4869                "explanation": (
4870                    "The distinctions among the results of your code"
4871                    " were the same as the distinctions among results"
4872                    " for the solution code:<br>"
4873                ) + DistinctionReport.render(val)
4874            }
4875        else:
4876            return {
4877                "status": "failed",
4878                "explanation": (
4879                    "The distinctions among the results of your code"
4880                    " were different than the distinctions among"
4881                    " results for the solution:<br>"
4882                ) + html_tools.build_html_tabs(
4883                    [
4884                        (
4885                            "Your distinctions",
4886                            DistinctionReport.render(val)
4887                        ),
4888                        (
4889                            "Correct distinctions",
4890                            DistinctionReport.render(ref)
4891                        )
4892                    ]
4893                    # TODO: Add a tab reporting differences in terms of
4894                    # pairwise constraints violated?
4895                )
4896            }
4897
4898    def __init__(
4899        self,
4900        parent,
4901        filters=None,
4902        **kwargs
4903    ):
4904        """
4905        Only a parent is required; extra keyword arguments will be
4906        passed on to `RefinedTest.__init__`.
4907
4908        `filters` allows one to specify a list of filter functions, which
4909        will be applied to the raw result values that are being
4910        distinguished.
4911
4912        The identifier will be "distinctions".
4913        """
4914        super().__init__(
4915            parent,
4916            "distinctions",
4917            **kwargs
4918        )
4919
4920        self.filters = filters or []
4921
4922        # Fetch the old context slot and set our new one
4923        cs = self.default_goal_args.get("context_slot", "value")
4924        # TODO: Args synthesis here?!?
4925        self.original_slot = cs
4926        self.default_goal_args["context_slot"] = "distinctions"
4927
4928        # Set a goal-type tag based on the parent slot
4929        tags = self.default_goal_args.setdefault("tags", {})
4930        tags["goal_type"] = CONTEXT_SLOT_IMPLIED_TYPES.get(cs, "other")
4931
4932        # TODO: Some way to name individual parent contexts here...
4933        self.default_context_args["description"] = (
4934            f"Distinctions between {cs}s",
4935            (
4936                f"We gather {cs}s from multiple tests and check which"
4937                f" tests have distinct results."
4938            ),
4939            f"Distinctions between {cs}s",
4940            (
4941                f"We gathered {cs}s from multiple tests and checked"
4942                f" which tests had distinct results."
4943            )
4944        )
4945
4946        self.default_context_args["display_product"] = (
4947            DistinctionReport.display_context_value
4948        )
4949
4950        self.default_goal_args["description"] = (
4951            f"{cs} distinctions must be correct".capitalize(),
4952            (
4953                f"Distinctions between {cs}s based on arguments and/or"
4954                f" inputs must match the solution's distinctions."
4955            ),
4956            f"{cs} distinctions must be correct".capitalize(),
4957            (
4958                f"We checked whether, for different arguments and/or"
4959                f" inputs, your code created the same distinct (or"
4960                f" identical) {cs}s as the solution code."
4961            )
4962        )
4963
4964        # Set up report-based comparison
4965        self.compare_using(DistinctionReport.compare)

A RefinedTest which analyzes results from the context slot originally targeted, and determines, among parent contexts that were originally going to be separate tests, which results are distinct and which are identical. It creates a "distinctions" context slot containing a multi-line string reporting on these distinctions, and sets up that slot as the test target.

DistinctionReport(parent, filters=None, **kwargs)
4898    def __init__(
4899        self,
4900        parent,
4901        filters=None,
4902        **kwargs
4903    ):
4904        """
4905        Only a parent is required; extra keyword arguments will be
4906        passed on to `RefinedTest.__init__`.
4907
4908        `filters` allows one to specify a list of filter functions, which
4909        will be applied to the raw result values that are being
4910        distinguished.
4911
4912        The identifier will be "distinctions".
4913        """
4914        super().__init__(
4915            parent,
4916            "distinctions",
4917            **kwargs
4918        )
4919
4920        self.filters = filters or []
4921
4922        # Fetch the old context slot and set our new one
4923        cs = self.default_goal_args.get("context_slot", "value")
4924        # TODO: Args synthesis here?!?
4925        self.original_slot = cs
4926        self.default_goal_args["context_slot"] = "distinctions"
4927
4928        # Set a goal-type tag based on the parent slot
4929        tags = self.default_goal_args.setdefault("tags", {})
4930        tags["goal_type"] = CONTEXT_SLOT_IMPLIED_TYPES.get(cs, "other")
4931
4932        # TODO: Some way to name individual parent contexts here...
4933        self.default_context_args["description"] = (
4934            f"Distinctions between {cs}s",
4935            (
4936                f"We gather {cs}s from multiple tests and check which"
4937                f" tests have distinct results."
4938            ),
4939            f"Distinctions between {cs}s",
4940            (
4941                f"We gathered {cs}s from multiple tests and checked"
4942                f" which tests had distinct results."
4943            )
4944        )
4945
4946        self.default_context_args["display_product"] = (
4947            DistinctionReport.display_context_value
4948        )
4949
4950        self.default_goal_args["description"] = (
4951            f"{cs} distinctions must be correct".capitalize(),
4952            (
4953                f"Distinctions between {cs}s based on arguments and/or"
4954                f" inputs must match the solution's distinctions."
4955            ),
4956            f"{cs} distinctions must be correct".capitalize(),
4957            (
4958                f"We checked whether, for different arguments and/or"
4959                f" inputs, your code created the same distinct (or"
4960                f" identical) {cs}s as the solution code."
4961            )
4962        )
4963
4964        # Set up report-based comparison
4965        self.compare_using(DistinctionReport.compare)

Only a parent is required; extra keyword arguments will be passed on to RefinedTest.__init__.

filters allows one to specify a list of filter functions, which will be applied to the raw result values that are being distinguished.

The identifier will be "distinctions".

def build_context(self, prev_context):
4736    def build_context(self, prev_context):
4737        """
4738        Uses the "__unmerged__" special context slot to access
4739        individual results from each parent context, and creates a
4740        mapping in the "distinctions" slot that maps unique results to
4741        lists of context dictionaries in which the test produced those
4742        results (note that this means that results must be hashable!).
4743
4744        The slot of each parent context that it pays attention to is
4745        determined by the slot which the unrefined goal would have
4746        tested.
4747        """
4748        result = { "distinctions": {}, "ref_distinctions": {} }
4749
4750        # Process both actual and reference values
4751        for prefix in ("", "ref_"):
4752            slot = prefix + self.original_slot
4753            dest = prefix + "distinctions"
4754
4755            # produce our mapping from results to lists of contexts
4756            mapping = {}
4757            for i, parent_context in enumerate(
4758                prev_context["__unmerged__"]
4759            ):
4760                # get value and signature
4761                val = parent_context[slot]
4762                if self.filters:
4763                    for filt in self.filters:
4764                        val = filt(val)
4765                # update our mapping
4766                mapping.setdefault(val, []).append(parent_context)
4767
4768            result[dest] = mapping
4769
4770        return result

Uses the "__unmerged__" special context slot to access individual results from each parent context, and creates a mapping in the "distinctions" slot that maps unique results to lists of context dictionaries in which the test produced those results (note that this means that results must be hashable!).

The slot of each parent context that it pays attention to is determined by the slot which the unrefined goal would have tested.

def render(report):
4772    def render(report):
4773        """
4774        A class method for rendering a distinction report as HTML.
4775        """
4776        uniques = []
4777        groups = []
4778        all_contexts = []
4779        for result in report:
4780            contexts = report[result]
4781            all_contexts.extend(contexts)
4782            if len(contexts) == 1:
4783                uniques.append(contexts[0])
4784            else:
4785                groups.append(contexts)
4786
4787        if len(groups) == 0:
4788            return (
4789                f"All {len(all_contexts)} contexts produced distinct"
4790                f" outcomes:<br>\n"
4791            ) + html_tools.build_list(
4792                ctx["__builder__"].feedback_topic()
4793                for ctx in all_contexts
4794            )
4795        elif len(uniques) == 0 and len(groups) == 1:
4796            return (
4797                f"All {len(all_contexts)} contexts produced equivalent"
4798                f" outcomes:<br>\n"
4799            ) + html_tools.build_list(
4800                ctx["__builder__"].feedback_topic()
4801                for ctx in all_contexts
4802            )
4803        else:
4804            items = [
4805                f"Group #{i+1}:<br>\n" + html_tools.build_list(
4806                    ctx["__builder__"].feedback_topic()
4807                    for ctx in group
4808                )
4809                for i, group in enumerate(groups)
4810            ] + [
4811                "Unique: " + ctx["__builder__"].feedback_topic()
4812                for ctx in uniques
4813            ]
4814            return (
4815                "The contexts' outcomes were grouped as follows:\n"
4816            ) + html_tools.build_list(items)

A class method for rendering a distinction report as HTML.

def display_context_value(context):
4818    def display_context_value(context):
4819        """
4820        A class method to be used as a context value displayer.
4821        """
4822        val = context["distinctions"]
4823        ref = context["ref_distinctions"]
4824
4825        return html_tools.build_html_tabs(
4826            [
4827                ("Your distinctions", DistinctionReport.render(val)),
4828                ("Correct distinctions", DistinctionReport.render(ref)),
4829            ]
4830        )

A class method to be used as a context value displayer.

def compare(val, ref):
4832    def compare(val, ref):
4833        """
4834        A class method for comparing distinction mappings. Succeeds if
4835        the mappings have equivalent groupings with respect to the
4836        `potluck.contexts.Context` objects that produce different
4837        results, and fails otherwise.
4838        """
4839        val_rmap = {}
4840        ref_rmap = {}
4841
4842        val_groups = []
4843        ref_groups = []
4844
4845        for result in val:
4846            contexts = val[result]
4847            val_groups.append(
4848                set(id(context["__builder__"]) for context in contexts)
4849            )
4850            for context in contexts:
4851                val_rmap[id(context["__builder__"])] = result
4852
4853        for result in ref:
4854            contexts = ref[result]
4855            ref_groups.append(
4856                set(id(context["__builder__"]) for context in contexts)
4857            )
4858            for context in contexts:
4859                ref_rmap[id(context["__builder__"])] = result
4860
4861        # Make sure ordering is the same because it shouldn't matter
4862        val_groups.sort(key=lambda group: sorted(group))
4863        ref_groups.sort(key=lambda group: sorted(group))
4864
4865        # Groupings are the same
4866        if val_groups == ref_groups:
4867            return {
4868                "status": "accomplished",
4869                "explanation": (
4870                    "The distinctions among the results of your code"
4871                    " were the same as the distinctions among results"
4872                    " for the solution code:<br>"
4873                ) + DistinctionReport.render(val)
4874            }
4875        else:
4876            return {
4877                "status": "failed",
4878                "explanation": (
4879                    "The distinctions among the results of your code"
4880                    " were different than the distinctions among"
4881                    " results for the solution:<br>"
4882                ) + html_tools.build_html_tabs(
4883                    [
4884                        (
4885                            "Your distinctions",
4886                            DistinctionReport.render(val)
4887                        ),
4888                        (
4889                            "Correct distinctions",
4890                            DistinctionReport.render(ref)
4891                        )
4892                    ]
4893                    # TODO: Add a tab reporting differences in terms of
4894                    # pairwise constraints violated?
4895                )
4896            }

A class method for comparing distinction mappings. Succeeds if the mappings have equivalent groupings with respect to the potluck.contexts.Context objects that produce different results, and fails otherwise.

class Reference:
4968class Reference:
4969    """
4970    A class used for representing object references in memory maps and
4971    diagrams. A reference is really just an integer.
4972    """
4973    def __init__(self, num):
4974        """
4975        Needs to know what number we're assigned.
4976        """
4977        self.num = num
4978
4979    def __hash__(self):
4980        """
4981        Hash function based on the number.
4982        """
4983        return 1928928 + self.num
4984
4985    def __eq__(self, other):
4986        """
4987        Comparison for references (two refs with the same num are the
4988        same).
4989        """
4990        return self.num == other.num
4991
4992    def __repr__(self):
4993        """
4994        The representation is an @ sign followed by the integer.
4995        """
4996        return "@{}".format(self.num)

A class used for representing object references in memory maps and diagrams. A reference is really just an integer.

Reference(num)
4973    def __init__(self, num):
4974        """
4975        Needs to know what number we're assigned.
4976        """
4977        self.num = num

Needs to know what number we're assigned.

def memory_map(obj, assigned, count_from=0):
4999def memory_map(obj, assigned, count_from=0):
5000    """
5001    Modifies the given assignment dictionary to include an assignment
5002    between the given object's ID and a tuple containing the current
5003    counter (the third arugment) and a shallow object based on the given
5004    object, where any complex sub-objects replaced by References which
5005    will also appear in the assignment map. The assignment map provided
5006    to start from must be a dictionary, but it may be empty.
5007
5008    For example, if the original value were the list [[1, 2], 3, [1, 2]]
5009    where both [1, 2] sublists are the same list, the final `assigned`
5010    dictionary would be:
5011
5012    {
5013        <id1>: (0, [ Reference(1), 3, Reference(1) ]),
5014        <id2>: (1, [1, 2])
5015    }
5016
5017    Where <id1> and <id2> are the ids of the two lists.
5018
5019    This function returns a tuple containing the highest ID it assigned
5020    within the assignments, and the provided object if it's small, or a
5021    Reference instance if it's large. Only tuples, lists, sets, and
5022    dicts have their contents replaced; custom objects don't. Strings
5023    are treated as references, but of course not altered, and any custom
5024    objects are treated this way too.
5025    """
5026    if id(obj) in assigned:
5027        return None, Reference(assigned[id(obj)][0])
5028
5029    if isinstance(obj, (int, float, complex, bool, type(None))):
5030        # Simple values are used as-is:
5031        return None, obj
5032    elif isinstance(obj, (tuple, list, set)):
5033        # Structures are made shallow and referenced
5034        original_n = count_from
5035        # Must happen before recursion
5036        assigned[id(obj)] = (original_n, None) # placeholder
5037        count_from += 1
5038        parts = []
5039        for sub in obj:
5040            highest_id, repl = memory_map(sub, assigned, count_from)
5041            parts.append(repl)
5042            if highest_id is not None:
5043                count_from = highest_id + 1
5044            # else don't change count_from; we didn't assign any new IDs
5045        shallow = type(obj)(parts)
5046        assigned[id(obj)] = (original_n, shallow)
5047        return count_from - 1, Reference(original_n)
5048    elif isinstance(obj, dict):
5049        # Dictionaries use references for both keys and values
5050        original_n = count_from
5051        count_from += 1
5052        shallow = {}
5053        # Must happen before recursion
5054        assigned[id(obj)] = (original_n, shallow)
5055        for key in obj:
5056            highest_id, krepl = memory_map(key, assigned, count_from)
5057            if highest_id is not None:
5058                count_from = highest_id + 1
5059            # else don't change count_from; we didn't assign any new IDs
5060            highest_id, vrepl = memory_map(obj[key], assigned, count_from)
5061            if highest_id is not None:
5062                count_from = highest_id + 1
5063            # else don't change count_from; we didn't assign any new IDs
5064
5065            # Insert key/value pair
5066            shallow[krepl] = vrepl
5067
5068        return count_from - 1, Reference(original_n)
5069    else:
5070        # All other values including strings  are referenced but not
5071        # made shallow
5072        assigned[id(obj)] = (count_from, obj)
5073        return count_from, Reference(count_from)

Modifies the given assignment dictionary to include an assignment between the given object's ID and a tuple containing the current counter (the third arugment) and a shallow object based on the given object, where any complex sub-objects replaced by References which will also appear in the assignment map. The assignment map provided to start from must be a dictionary, but it may be empty.

For example, if the original value were the list [[1, 2], 3, [1, 2]] where both [1, 2] sublists are the same list, the final assigned dictionary would be:

{ : (0, [ Reference(1), 3, Reference(1) ]), : (1, [1, 2]) }

Where and are the ids of the two lists.

This function returns a tuple containing the highest ID it assigned within the assignments, and the provided object if it's small, or a Reference instance if it's large. Only tuples, lists, sets, and dicts have their contents replaced; custom objects don't. Strings are treated as references, but of course not altered, and any custom objects are treated this way too.

def memory_report(obj):
5076def memory_report(obj):
5077    """
5078    Returns a memory report, which is like an exploded repr of an object
5079    where 'large' values like strings and lists get assigned an ID and
5080    are reported on a separate line.
5081    """
5082    refs = {}
5083    _ = memory_map(obj, refs, 0) # modifies ref; we ignore the result
5084
5085    result = ''
5086    for num, shallow in sorted(refs.values()):
5087        result += '@{}: {}\n'.format(num, repr(shallow))
5088
5089    return result

Returns a memory report, which is like an exploded repr of an object where 'large' values like strings and lists get assigned an ID and are reported on a separate line.

class MemoryDiagram(RefinedTest):
5092class MemoryDiagram(RefinedTest):
5093    """
5094    A `RefinedTest` which produces a text-based memory diagram from the
5095    contents of the context slot originally targeted. It creates a
5096    "memory_report" context slot
5097    containing a multi-line string which specifies the memory layout of
5098    the original object, which may contains tuples, lists, dictionaries,
5099    and/or sets, as well as primitive values like numbers, Booleans,
5100    strings, and Nones. It sets up that slot as the test target.
5101    """
5102    def build_context(self, prev_context):
5103        """
5104        Based on the original target slot, creates a "memory_report"
5105        slot which holds a multi-line string representing the memory
5106        layout of the original object. See the `memory_report` function
5107        for details on what the diagram will look like.
5108        """
5109        result = {
5110            "memory_report": 'NO REPORT GENERATED',
5111            "ref_memory_report": 'NO REPORT GENERATED'
5112        }
5113
5114        # Process both actual and reference values
5115        for prefix in ("", "ref_"):
5116            slot = prefix + self.original_slot
5117            dest = prefix + "memory_report"
5118
5119            # produce our mapping from results to lists of contexts
5120            result[dest] = memory_report(prev_context[slot])
5121
5122        return result
5123
5124    def __init__(
5125        self,
5126        parent,
5127        **kwargs
5128    ):
5129        """
5130        Only a parent is required; extra keyword arguments will be
5131        passed on to `RefinedTest.__init__`.
5132
5133        The identifier will be "memory_report".
5134        """
5135        super().__init__(
5136            parent,
5137            "memory_report",
5138            **kwargs
5139        )
5140
5141        # Fetch the old context slot and set our new one
5142        cs = self.default_goal_args.get("context_slot", "value")
5143        # TODO: Args synthesis here?!?
5144        self.original_slot = cs
5145        self.default_goal_args["context_slot"] = "memory_report"
5146
5147        # Set a goal-type tag based on the parent slot
5148        tags = self.default_goal_args.setdefault("tags", {})
5149        tags["goal_type"] = CONTEXT_SLOT_IMPLIED_TYPES.get(cs, "other")
5150
5151        self.default_context_args["description"] = (
5152            f"Memory report of the {cs}",
5153            (
5154                f"We will produce a memory report from the {cs} which"
5155                f" indicates the structure of the value in memory,"
5156                f" including which parts are aliases of each other."
5157            ),
5158            f"Memory report of the {cs}",
5159            (
5160                f"We produced a memory report from the {cs} which"
5161                f" indicates the structure of the value in memory,"
5162                f" including which parts are aliases of each other."
5163            )
5164        )
5165
5166        self.default_context_args["display_product"] = (
5167            contexts.build_context_value_displayer(
5168                "memory_report",
5169                labels=[
5170                    "Your memory report:",
5171                    "Solution memory report:",
5172                    "Differences"
5173                ]
5174            )
5175        )
5176
5177        if hasattr(self, "base_name") or hasattr(parent, "base_name"):
5178            if hasattr(self, "base_name"):
5179                base_name = self.base_name
5180            else:
5181                base_name = parent.base_name
5182
5183            if cs == "value":
5184                cs = "result"
5185
5186            if base_name == "import":
5187                self.default_goal_args["description"] = (
5188                    (
5189                        f"The {cs} after running your program must have"
5190                        f" the correct memory structure"
5191                    ),
5192                    (
5193                        f"The memory structure of the {cs} produced by"
5194                        f" your code must match that of the {cs}"
5195                        f" produced by the solution code, including"
5196                        f" which parts are aliases of each other."
5197                    ),
5198                    (
5199                        f"The {cs} after running your program must have"
5200                        f" the correct memory structure"
5201                    ),
5202                    (
5203                        f"We checked whether the memory structure of the {cs}"
5204                        f" produced by your code matched the memory structure"
5205                        f" produced by the solution code."
5206                    )
5207                )
5208            else:
5209                self.default_goal_args["description"] = (
5210                    (
5211                        f"The {cs} of <code>{base_name}</code>"
5212                        f" must have the correct memory structure"
5213                    ),
5214                    (
5215                        f"The memory structure of the {cs} produced by"
5216                        f" running <code>{base_name}</code> must"
5217                        f" match that of the {cs} produced by the"
5218                        f" solution code, including which parts are"
5219                        f" aliases of each other."
5220                    ),
5221                    (
5222                        f"The {cs} of <code>{base_name}</code>"
5223                        f" must have the correct memory structure"
5224                    ),
5225                    (
5226                        f"We checked whether the memory structure of"
5227                        f" the {cs} produced by"
5228                        f" <code>{base_name}</code> matched the"
5229                        f" memory structure produced by the solution"
5230                        f" code."
5231                    )
5232                )
5233        else:
5234            self.default_goal_args["description"] = (
5235                f"The {cs} must have the correct memory structureee",
5236                (
5237                    f"The memory structure of the {cs} produced by your code"
5238                    f" must match that of the {cs} produced by the solution"
5239                    f" code, including which parts are aliases of each other."
5240                ),
5241                f"The {cs} must have the correct memory structureee",
5242                (
5243                    f"We checked whether the memory structure of the {cs}"
5244                    f" produced by your code matched the memory structure"
5245                    f" produced by the solution code."
5246                )
5247            )
5248
5249        # Set up report-based comparison
5250        self.compare_reports()

A RefinedTest which produces a text-based memory diagram from the contents of the context slot originally targeted. It creates a "memory_report" context slot containing a multi-line string which specifies the memory layout of the original object, which may contains tuples, lists, dictionaries, and/or sets, as well as primitive values like numbers, Booleans, strings, and Nones. It sets up that slot as the test target.

MemoryDiagram(parent, **kwargs)
5124    def __init__(
5125        self,
5126        parent,
5127        **kwargs
5128    ):
5129        """
5130        Only a parent is required; extra keyword arguments will be
5131        passed on to `RefinedTest.__init__`.
5132
5133        The identifier will be "memory_report".
5134        """
5135        super().__init__(
5136            parent,
5137            "memory_report",
5138            **kwargs
5139        )
5140
5141        # Fetch the old context slot and set our new one
5142        cs = self.default_goal_args.get("context_slot", "value")
5143        # TODO: Args synthesis here?!?
5144        self.original_slot = cs
5145        self.default_goal_args["context_slot"] = "memory_report"
5146
5147        # Set a goal-type tag based on the parent slot
5148        tags = self.default_goal_args.setdefault("tags", {})
5149        tags["goal_type"] = CONTEXT_SLOT_IMPLIED_TYPES.get(cs, "other")
5150
5151        self.default_context_args["description"] = (
5152            f"Memory report of the {cs}",
5153            (
5154                f"We will produce a memory report from the {cs} which"
5155                f" indicates the structure of the value in memory,"
5156                f" including which parts are aliases of each other."
5157            ),
5158            f"Memory report of the {cs}",
5159            (
5160                f"We produced a memory report from the {cs} which"
5161                f" indicates the structure of the value in memory,"
5162                f" including which parts are aliases of each other."
5163            )
5164        )
5165
5166        self.default_context_args["display_product"] = (
5167            contexts.build_context_value_displayer(
5168                "memory_report",
5169                labels=[
5170                    "Your memory report:",
5171                    "Solution memory report:",
5172                    "Differences"
5173                ]
5174            )
5175        )
5176
5177        if hasattr(self, "base_name") or hasattr(parent, "base_name"):
5178            if hasattr(self, "base_name"):
5179                base_name = self.base_name
5180            else:
5181                base_name = parent.base_name
5182
5183            if cs == "value":
5184                cs = "result"
5185
5186            if base_name == "import":
5187                self.default_goal_args["description"] = (
5188                    (
5189                        f"The {cs} after running your program must have"
5190                        f" the correct memory structure"
5191                    ),
5192                    (
5193                        f"The memory structure of the {cs} produced by"
5194                        f" your code must match that of the {cs}"
5195                        f" produced by the solution code, including"
5196                        f" which parts are aliases of each other."
5197                    ),
5198                    (
5199                        f"The {cs} after running your program must have"
5200                        f" the correct memory structure"
5201                    ),
5202                    (
5203                        f"We checked whether the memory structure of the {cs}"
5204                        f" produced by your code matched the memory structure"
5205                        f" produced by the solution code."
5206                    )
5207                )
5208            else:
5209                self.default_goal_args["description"] = (
5210                    (
5211                        f"The {cs} of <code>{base_name}</code>"
5212                        f" must have the correct memory structure"
5213                    ),
5214                    (
5215                        f"The memory structure of the {cs} produced by"
5216                        f" running <code>{base_name}</code> must"
5217                        f" match that of the {cs} produced by the"
5218                        f" solution code, including which parts are"
5219                        f" aliases of each other."
5220                    ),
5221                    (
5222                        f"The {cs} of <code>{base_name}</code>"
5223                        f" must have the correct memory structure"
5224                    ),
5225                    (
5226                        f"We checked whether the memory structure of"
5227                        f" the {cs} produced by"
5228                        f" <code>{base_name}</code> matched the"
5229                        f" memory structure produced by the solution"
5230                        f" code."
5231                    )
5232                )
5233        else:
5234            self.default_goal_args["description"] = (
5235                f"The {cs} must have the correct memory structureee",
5236                (
5237                    f"The memory structure of the {cs} produced by your code"
5238                    f" must match that of the {cs} produced by the solution"
5239                    f" code, including which parts are aliases of each other."
5240                ),
5241                f"The {cs} must have the correct memory structureee",
5242                (
5243                    f"We checked whether the memory structure of the {cs}"
5244                    f" produced by your code matched the memory structure"
5245                    f" produced by the solution code."
5246                )
5247            )
5248
5249        # Set up report-based comparison
5250        self.compare_reports()

Only a parent is required; extra keyword arguments will be passed on to RefinedTest.__init__.

The identifier will be "memory_report".

def build_context(self, prev_context):
5102    def build_context(self, prev_context):
5103        """
5104        Based on the original target slot, creates a "memory_report"
5105        slot which holds a multi-line string representing the memory
5106        layout of the original object. See the `memory_report` function
5107        for details on what the diagram will look like.
5108        """
5109        result = {
5110            "memory_report": 'NO REPORT GENERATED',
5111            "ref_memory_report": 'NO REPORT GENERATED'
5112        }
5113
5114        # Process both actual and reference values
5115        for prefix in ("", "ref_"):
5116            slot = prefix + self.original_slot
5117            dest = prefix + "memory_report"
5118
5119            # produce our mapping from results to lists of contexts
5120            result[dest] = memory_report(prev_context[slot])
5121
5122        return result

Based on the original target slot, creates a "memory_report" slot which holds a multi-line string representing the memory layout of the original object. See the memory_report function for details on what the diagram will look like.

def NoParseErrors(category='core'):
5258def NoParseErrors(category="core"):
5259    """
5260    Registers a miscellaneous goal requiring that there not be any parse
5261    errors.
5262
5263    The goal is a `potluck.rubrics.NoParseErrors` instance.
5264    """
5265    register_goal(
5266        rubrics.NoParseErrors(
5267            file_utils.deduce_task_id(),
5268            tags={ "category": category }
5269        )
5270    )

Registers a miscellaneous goal requiring that there not be any parse errors.

The goal is a potluck.rubrics.NoParseErrors instance.

def RequireDocstrings(exclude=None, category='core'):
5273def RequireDocstrings(exclude=None, category="core"):
5274    """
5275    Registers a miscellaneous goal requiring that all functions have
5276    non-empty docstrings. Capitalized to feel like Test or Check, but not
5277    a class because it has no reason to be.
5278
5279    A list of strings specifying function names to exclude may be given,
5280    and is useful for preventing penalties for students who choose not to
5281    do optional tasks.
5282
5283    A category other than the default 'core' may also be specified.
5284
5285    The goal is a `potluck.rubrics.AllFunctionsHaveDocstrings` instance.
5286    """
5287    register_goal(
5288        rubrics.AllFunctionsHaveDocstrings(
5289            file_utils.deduce_task_id(),
5290            exclude,
5291            tags={ "category": category }
5292        )
5293    )

Registers a miscellaneous goal requiring that all functions have non-empty docstrings. Capitalized to feel like Test or Check, but not a class because it has no reason to be.

A list of strings specifying function names to exclude may be given, and is useful for preventing penalties for students who choose not to do optional tasks.

A category other than the default 'core' may also be specified.

The goal is a potluck.rubrics.AllFunctionsHaveDocstrings instance.

FRUITFUL_BUILTINS = ['input', 'max', 'min', 'round', 'ceil', '.ceil', 'floor', '.floor', 'len', 'int', 'float', 'str', 'repr', 'list', 'tuple', 'dict', 'set', 'type', 'range', 'reversed', '.lower', '.upper', '.capitalize.startswith', '.endswith', '.isspace', '.isalpha', '.isdigit', '.isnumeric', '.isalnum', '.format', '.join', '.split', '.index', '.keys', '.values', '.items', '.get']

A list of fruitful built-in functions and methods, for use with DontWasteFruit and/or DontWasteBoxes.

NON_FRUITFUL_BUILTINS = ['print', '.append', '.insert', '.extend', '.remove', '.update']

A list of non-fruitful built-in functions and methods, for use with DontWasteFruit and/or DontWasteBoxes.

def DontNestFunctions(exclude=None, category='core', description=None):
5340def DontNestFunctions(exclude=None, category='core', description=None):
5341    """
5342    Registers a miscellaneous goal requiring that within the code of the
5343    submission, there aren't any function definitions within other
5344    definitions. A list of function names to exclude from the check may
5345    be provided, and a category other than the default 'core' may also be
5346    provided. Finally, a custom description tuple can be supplied,
5347    although in most situations the default should be fine.
5348
5349    The goal is a `potluck.rubrics.FunctionsArentNested` instance.
5350    """
5351    args = {
5352        "exclude": exclude,
5353        "tags": {"category": category}
5354    }
5355    if description is not None:
5356        args["description"] = description
5357
5358    register_goal(
5359        rubrics.FunctionsArentNested(
5360            file_utils.deduce_task_id(),
5361            **args
5362        )
5363    )

Registers a miscellaneous goal requiring that within the code of the submission, there aren't any function definitions within other definitions. A list of function names to exclude from the check may be provided, and a category other than the default 'core' may also be provided. Finally, a custom description tuple can be supplied, although in most situations the default should be fine.

The goal is a potluck.rubrics.FunctionsArentNested instance.

def DontWasteFruit( extra=['input', 'max', 'min', 'round', 'ceil', '.ceil', 'floor', '.floor', 'len', 'int', 'float', 'str', 'repr', 'list', 'tuple', 'dict', 'set', 'type', 'range', 'reversed', '.lower', '.upper', '.capitalize.startswith', '.endswith', '.isspace', '.isalpha', '.isdigit', '.isnumeric', '.isalnum', '.format', '.join', '.split', '.index', '.keys', '.values', '.items', '.get'], exclude=[], category='extra', description=None):
5366def DontWasteFruit(
5367    extra=FRUITFUL_BUILTINS,
5368    exclude=[],
5369    category="extra",
5370    description=None
5371):
5372    """
5373    Registers a miscellaneous goal requiring that the submitted code
5374    doesn't ignore return values from fruitful functions. See
5375    `potluck.rubrics.DoesntWasteFruit`.
5376    """
5377    args = {
5378        "extra": extra,
5379        "exclude": exclude,
5380        "tags": { "category": category },
5381    }
5382    if description is not None:
5383        args["description"] = description
5384
5385    register_goal(
5386        rubrics.DoesntWasteFruit(
5387            file_utils.deduce_task_id(),
5388            **args
5389        )
5390    )

Registers a miscellaneous goal requiring that the submitted code doesn't ignore return values from fruitful functions. See potluck.rubrics.DoesntWasteFruit.

def DontWasteBoxes( exclude=[], category='extra', tolerance=2, check_loop_vars=False, description=None):
5393def DontWasteBoxes(
5394    exclude=[],
5395    category="extra",
5396    tolerance=2,
5397    check_loop_vars=False,
5398    description=None
5399):
5400    """
5401    Registers a miscellaneous goal requiring that within the code of the
5402    submission, there aren't any unused variables, unless they're named
5403    '_'. See `potluck.rubrics.DoesntWasteBoxes`.
5404
5405    Unless `check_loop_vars` is set to True, loop variables in for loops
5406    will not be checked, since these are required but often legitimately
5407    go unused.
5408    """
5409    args = {
5410        "exclude": exclude,
5411        "tolerance": tolerance,
5412        "check_loop_vars": check_loop_vars,
5413        "tags": { "category": category },
5414    }
5415    if description is not None:
5416        args["description"] = description
5417
5418    register_goal(
5419        rubrics.DoesntWasteBoxes(
5420            file_utils.deduce_task_id(),
5421            **args
5422        )
5423    )

Registers a miscellaneous goal requiring that within the code of the submission, there aren't any unused variables, unless they're named '_'. See potluck.rubrics.DoesntWasteBoxes.

Unless check_loop_vars is set to True, loop variables in for loops will not be checked, since these are required but often legitimately go unused.

def RequireTestCases(requirements, category='core', description=None):
5430def RequireTestCases(
5431    requirements,
5432    category="core",
5433    description=None
5434):
5435    """
5436    Registers a validation goal requiring that the submitted code
5437    creates a certain number of expectations (using the `optimism`
5438    module) for each of certain target functions and/or files.
5439    `requirements` must be a dictionary mapping function name strings
5440    (which can't end in '.py') and/or file name strings (which do end in
5441    '.py') that need testing to the minimum number of test cases
5442    required for each. A custom category and description may be
5443    provided; the default category is core. The underlying goal created
5444    is a `potluck.validation.DefinesEnoughTests`.
5445    """
5446    args = { "tags": { "category": category } }
5447    if description is not None:
5448        args["description"] = description
5449
5450    # Sort out function/file requirements
5451    function_reqs = {}
5452    file_reqs = {}
5453    for req in requirements:
5454        if req.endswith('.py'):
5455            file_reqs[req] = requirements[req]
5456        else:
5457            function_reqs[req] = requirements[req]
5458
5459    args["function_reqs"] = function_reqs
5460    args["file_reqs"] = file_reqs
5461
5462    register_validation_goal(
5463        validation.DefinesEnoughTests(
5464            file_utils.deduce_task_id(),
5465            **args
5466        )
5467    )

Registers a validation goal requiring that the submitted code creates a certain number of expectations (using the optimism module) for each of certain target functions and/or files. requirements must be a dictionary mapping function name strings (which can't end in '.py') and/or file name strings (which do end in '.py') that need testing to the minimum number of test cases required for each. A custom category and description may be provided; the default category is core. The underlying goal created is a potluck.validation.DefinesEnoughTests.

def TestsMustPass(category='extra', description=None):
5470def TestsMustPass(
5471    category="extra",
5472    description=None
5473):
5474    """
5475    Registers a validation goal requiring that all test cases checked by
5476    the submitted code (using the `optimism` module) must pass (when run
5477    against the solution code during validation). The goal is a
5478    `potluck.validation.ChecksSucceed`. A category (other than the
5479    default "extra") and a custom description are optional.
5480    """
5481    args = { "tags": { "category": category } }
5482    if description is not None:
5483        args["description"] = description
5484
5485    register_validation_goal(
5486        validation.ChecksSucceed(
5487            file_utils.deduce_task_id(),
5488            **args
5489        )
5490    )

Registers a validation goal requiring that all test cases checked by the submitted code (using the optimism module) must pass (when run against the solution code during validation). The goal is a potluck.validation.ChecksSucceed. A category (other than the default "extra") and a custom description are optional.

def rubric(metric=<function core_extras_flat_metric>):
5497def rubric(metric=rubrics.core_extras_flat_metric):
5498    """
5499    Creates a `potluck.rubrics.Rubric` based on the test and check
5500    objects instantiated within the current module.
5501
5502    A non-default metric function may be supplied; see e.g.
5503    `rubrics.core_extras_flat_metric` (which is the default).
5504    """
5505    validation_goals = []
5506    evaluation_goals = []
5507
5508    # Name of the specifications module this function is being called in
5509    sname = file_utils.get_spec_module_name()
5510
5511    # Directly-registered validation goals
5512    validation_goals.extend(VALIDATION_GOALS.get(sname, []))
5513
5514    # Goals via providers
5515    for provider in VALIDATION_GOAL_PROVIDERS.get(sname, []):
5516        try:
5517            validation_goals.append(provider.provide_goal())
5518        except Exception:
5519            raise ValueError(
5520                "Unable to create validation goal from: " + repr(provider)
5521            )
5522            # TODO: Better reporting (e.g., which line goal was defined on)
5523
5524    # Directly-registered evaluation goals
5525    evaluation_goals.extend(GOALS.get(sname, []))
5526
5527    # Goals via providers
5528    for provider in GOAL_PROVIDERS.get(sname, []):
5529        try:
5530            evaluation_goals.append(provider.provide_goal())
5531        except Exception:
5532            raise ValueError("Unable to create goal from: " + repr(provider))
5533            # TODO: Better reporting (e.g., which line goal was defined on)
5534
5535    # Checks
5536    checks = CHECKS_REGISTRY.get(sname, {})
5537    for cat in checks:
5538        cat_registry = checks[cat]
5539        for goal_type in cat_registry:
5540            list_of_checks = cat_registry[goal_type]
5541            for check_obj in list_of_checks:
5542                cat_gt_map = check_obj.build_implementation_checks()
5543                evaluation_goals.extend(cat_gt_map.values())
5544
5545    # Result
5546    return rubrics.Rubric(
5547        evaluation_goals,
5548        metric,
5549        validation_goals,
5550        file_utils.get_spec_file_name()
5551    )

Creates a potluck.rubrics.Rubric based on the test and check objects instantiated within the current module.

A non-default metric function may be supplied; see e.g. rubrics.core_extras_flat_metric (which is the default).

def file(filename):
5573def file(filename):
5574    """
5575    After calling this function, subsequent tests, checks, etc. will all
5576    by default run against the submitted file with the given filename,
5577    rather than the default submitted file. Can be called multiple times
5578    to establish segments of the spec file that apply to different
5579    submitted files.
5580
5581    Calling this function will reset any establish prep and/or wrap
5582    functions.
5583
5584    TODO: What if a lower-level auto-context has been established, such
5585    that changing the File auto-context doesn't matter?!?
5586
5587    TODO: Test this!
5588    """
5589    global _PREP_FUNCTIONS, _WRAPPERS
5590    _PREP_FUNCTIONS = []
5591    _WRAPPERS = []
5592    contexts.FileContext(filename)
5593    contexts.ModuleContext()

After calling this function, subsequent tests, checks, etc. will all by default run against the submitted file with the given filename, rather than the default submitted file. Can be called multiple times to establish segments of the spec file that apply to different submitted files.

Calling this function will reset any establish prep and/or wrap functions.

TODO: What if a lower-level auto-context has been established, such that changing the File auto-context doesn't matter?!?

TODO: Test this!

def add_module_prep(prep_fn):
5596def add_module_prep(prep_fn):
5597    """
5598    Adds an additional module prep function to the list of active module
5599    prep functions, to be run when a submitted module is imported for
5600    testing. The prep function will be applied to imports for tests
5601    below where this Function is called; `TestImport` imports are not
5602    affected because they use `HasPayload.prepare_source` instead.
5603
5604    The prep function must accept a context dictionary as an argument,
5605    and should return the same dictionary (or a modified dictionary). If
5606    a zero-argument function is provided, it will be modified to accept
5607    and return a context dictionary without alteration.
5608    """
5609    global _PREP_FUNCTIONS
5610    _PREP_FUNCTIONS.append(prep_fn)
5611    activate_preps_and_wraps()

Adds an additional module prep function to the list of active module prep functions, to be run when a submitted module is imported for testing. The prep function will be applied to imports for tests below where this Function is called; TestImport imports are not affected because they use HasPayload.prepare_source instead.

The prep function must accept a context dictionary as an argument, and should return the same dictionary (or a modified dictionary). If a zero-argument function is provided, it will be modified to accept and return a context dictionary without alteration.

def add_module_wrapper(wrapper):
5614def add_module_wrapper(wrapper):
5615    """
5616    Adds an additional module wrapper function to the list of active
5617    module wrapper functions, to be run when a submitted module is
5618    imported for testing. The wrapper function will be applied to
5619    imports for tests below where this Function is called; `TestImport`
5620    imports are not affected because they use `HasPayload.wrap_module`
5621    instead.
5622
5623    The wrap function must accept the imported module as an argument,
5624    and its return value will be used in place of that module. If a
5625    zero-argument function is provided, it will be modified to accept
5626    and return a module without alteration.
5627    """
5628    global _WRAPPERS
5629    _WRAPPERS.append(wrapper)
5630    activate_preps_and_wraps()

Adds an additional module wrapper function to the list of active module wrapper functions, to be run when a submitted module is imported for testing. The wrapper function will be applied to imports for tests below where this Function is called; TestImport imports are not affected because they use HasPayload.wrap_module instead.

The wrap function must accept the imported module as an argument, and its return value will be used in place of that module. If a zero-argument function is provided, it will be modified to accept and return a module without alteration.

def activate_preps_and_wraps():
5633def activate_preps_and_wraps():
5634    """
5635    Activates the current prep/wrap function lists by constructing a
5636    `contexts.ModuleContext` using them.
5637    """
5638    def do_prep(ctx):
5639        """
5640        Combined prep function
5641        """
5642        for fn in _PREP_FUNCTIONS:
5643            if fn.__code__.co_argcount == 0:
5644                fn()
5645            else:
5646                ctx = fn(ctx)
5647        return ctx
5648
5649    def do_wrap(mod):
5650        """
5651        Combined module wrapper.
5652        """
5653        for fn in _WRAPPERS:
5654            if fn.__code__.co_argcount == 0:
5655                fn()
5656            else:
5657                mod = fn(mod)
5658
5659        return mod
5660
5661    contexts.ModuleContext(prep=do_prep, wrap=do_wrap)

Activates the current prep/wrap function lists by constructing a contexts.ModuleContext using them.