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 "<the result of running your file>" 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)
Directory for finding task specifications.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
)
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 "<the result of running your file>" 1058 ) 1059 1060 return self
Abstract base class for tests which will create
potluck.contexts.Context
objects. Provides common tools for
managing context creation.
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.
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.
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
.
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.
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.
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 "<the result of running your file>" 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.
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.
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.
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.
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.
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.
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
.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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!
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.
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.
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.
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.
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.
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
.
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.
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.
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.
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.
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.
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.
Inherited Members
- HasPayload
- synthesize_payload_info
- construct_payload
- describe_payload
- ensure_payload_constructor_arg
- prepare_source
- wrap_module
- ignore_output
- copy_args
- use_harness
- set_timeout
- do_setup
- do_cleanup
- capture_output
- provide_inputs
- use_decorations
- capture_trace
- sample_result_distribution
- capture_turtle_image
- capture_wavesynth
- capture_file_contents
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.
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.
Inherited Members
- HasPayload
- synthesize_payload_info
- construct_payload
- describe_payload
- ensure_payload_constructor_arg
- prepare_source
- wrap_module
- ignore_output
- copy_args
- use_harness
- set_timeout
- do_setup
- do_cleanup
- capture_output
- provide_inputs
- use_decorations
- capture_trace
- sample_result_distribution
- capture_turtle_image
- capture_wavesynth
- capture_file_contents
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.
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.
Inherited Members
- HasPayload
- synthesize_payload_info
- construct_payload
- describe_payload
- ensure_payload_constructor_arg
- prepare_source
- wrap_module
- ignore_output
- copy_args
- use_harness
- set_timeout
- do_setup
- do_cleanup
- capture_output
- provide_inputs
- use_decorations
- capture_trace
- sample_result_distribution
- capture_turtle_image
- capture_wavesynth
- capture_file_contents
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.
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.
Inherited Members
- HasPayload
- synthesize_payload_info
- construct_payload
- describe_payload
- ensure_payload_constructor_arg
- prepare_source
- wrap_module
- ignore_output
- copy_args
- use_harness
- set_timeout
- do_setup
- do_cleanup
- capture_output
- provide_inputs
- use_decorations
- capture_trace
- sample_result_distribution
- capture_turtle_image
- capture_wavesynth
- capture_file_contents
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.
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.
Inherited Members
- HasPayload
- synthesize_payload_info
- construct_payload
- describe_payload
- ensure_payload_constructor_arg
- prepare_source
- wrap_module
- ignore_output
- copy_args
- use_harness
- set_timeout
- do_setup
- do_cleanup
- capture_output
- provide_inputs
- use_decorations
- capture_trace
- sample_result_distribution
- capture_turtle_image
- capture_wavesynth
- capture_file_contents
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.ImplementationCheck
s. 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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
Check
s.
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
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.
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.
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.
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.
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.
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'.
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.
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'.
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.
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.
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.
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".
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'.
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.
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.
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 )
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'.
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".
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).
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".
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'.
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.
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?
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.
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.
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.
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
.
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.
Inherited Members
- HasPayload
- synthesize_payload_info
- construct_payload
- describe_payload
- ensure_payload_constructor_arg
- prepare_source
- wrap_module
- ignore_output
- copy_args
- use_harness
- set_timeout
- do_setup
- do_cleanup
- capture_output
- provide_inputs
- use_decorations
- capture_trace
- sample_result_distribution
- capture_turtle_image
- capture_wavesynth
- capture_file_contents
- HasContext
- synthesize_context_info
- create_context
- set_context_description
- set_context_displayer
- describe_module_slot
- HasGoal
- provide_goal
- synthesize_goal_info
- create_goal_from_contexts
- ensure_goal_constructor_arg
- goal
- validate
- set_identifier
- set_goal_type
- set_goal_description
- test_module
- test_output
- test_with_harness
- test_trace
- check_trace_state
- check_invariant
- check_trace_count
- test_wavesynth_notes
- test_wavesynth_audio
- test_turtle_image
- test_file_contents
- compare_using
- succeed_unless_crashed
- compare_exactly
- compare_reports
- compare_strings_gently
- compare_strings_semi_strict
- compare_strings_firmly
- refine
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.Context
s
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.
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 (GroupClone
s are
also TestGroup
s). Goal construction parameters for this shadow
will be cloned from that parent at the time of instantiation.
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.
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
.
Inherited Members
- HasPayload
- synthesize_payload_info
- construct_payload
- describe_payload
- ensure_payload_constructor_arg
- prepare_source
- wrap_module
- ignore_output
- copy_args
- use_harness
- set_timeout
- do_setup
- do_cleanup
- capture_output
- provide_inputs
- use_decorations
- capture_trace
- sample_result_distribution
- capture_turtle_image
- capture_wavesynth
- capture_file_contents
- HasContext
- synthesize_context_info
- create_context
- set_context_description
- set_context_displayer
- describe_module_slot
- HasGoal
- provide_goal
- synthesize_goal_info
- create_goal_from_contexts
- ensure_goal_constructor_arg
- goal
- validate
- set_identifier
- set_goal_type
- set_goal_description
- test_module
- test_output
- test_with_harness
- test_trace
- check_trace_state
- check_invariant
- check_trace_count
- test_wavesynth_notes
- test_wavesynth_audio
- test_turtle_image
- test_file_contents
- compare_using
- succeed_unless_crashed
- compare_exactly
- compare_reports
- compare_strings_gently
- compare_strings_semi_strict
- compare_strings_firmly
- refine
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.
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.
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).
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.
Inherited Members
- HasContext
- synthesize_context_info
- create_context
- set_context_description
- set_context_displayer
- describe_module_slot
- HasGoal
- provide_goal
- synthesize_goal_info
- create_goal_from_contexts
- ensure_goal_constructor_arg
- goal
- validate
- set_identifier
- set_goal_type
- set_goal_description
- test_module
- test_output
- test_with_harness
- test_trace
- check_trace_state
- check_invariant
- check_trace_count
- test_wavesynth_notes
- test_wavesynth_audio
- test_turtle_image
- test_file_contents
- compare_using
- succeed_unless_crashed
- compare_exactly
- compare_reports
- compare_strings_gently
- compare_strings_semi_strict
- compare_strings_firmly
- refine
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.
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.
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.
Inherited Members
- HasContext
- synthesize_context_info
- create_context
- set_context_description
- set_context_displayer
- describe_module_slot
- HasGoal
- provide_goal
- synthesize_goal_info
- create_goal_from_contexts
- ensure_goal_constructor_arg
- goal
- validate
- set_identifier
- set_goal_type
- set_goal_description
- test_module
- test_output
- test_with_harness
- test_trace
- check_trace_state
- check_invariant
- check_trace_count
- test_wavesynth_notes
- test_wavesynth_audio
- test_turtle_image
- test_file_contents
- compare_using
- succeed_unless_crashed
- compare_exactly
- compare_reports
- compare_strings_gently
- compare_strings_semi_strict
- compare_strings_firmly
- refine
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
.
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.
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.
Inherited Members
- HasContext
- synthesize_context_info
- create_context
- set_context_description
- set_context_displayer
- describe_module_slot
- HasGoal
- provide_goal
- synthesize_goal_info
- create_goal_from_contexts
- ensure_goal_constructor_arg
- goal
- validate
- set_identifier
- set_goal_type
- set_goal_description
- test_module
- test_output
- test_with_harness
- test_trace
- check_trace_state
- check_invariant
- check_trace_count
- test_wavesynth_notes
- test_wavesynth_audio
- test_turtle_image
- test_file_contents
- compare_using
- succeed_unless_crashed
- compare_exactly
- compare_reports
- compare_strings_gently
- compare_strings_semi_strict
- compare_strings_firmly
- refine
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).
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. Useset_context_description
and/orset_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.
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.
Inherited Members
- HasContext
- synthesize_context_info
- create_context
- set_context_description
- set_context_displayer
- describe_module_slot
- HasGoal
- provide_goal
- synthesize_goal_info
- create_goal_from_contexts
- ensure_goal_constructor_arg
- goal
- validate
- set_identifier
- set_goal_type
- set_goal_description
- test_module
- test_output
- test_with_harness
- test_trace
- check_trace_state
- check_invariant
- check_trace_count
- test_wavesynth_notes
- test_wavesynth_audio
- test_turtle_image
- test_file_contents
- compare_using
- succeed_unless_crashed
- compare_exactly
- compare_reports
- compare_strings_gently
- compare_strings_semi_strict
- compare_strings_firmly
- refine
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.
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".
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.
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.
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.
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.
Inherited Members
- HasContext
- synthesize_context_info
- create_context
- set_context_description
- set_context_displayer
- describe_module_slot
- HasGoal
- provide_goal
- synthesize_goal_info
- create_goal_from_contexts
- ensure_goal_constructor_arg
- goal
- validate
- set_identifier
- set_goal_type
- set_goal_description
- test_module
- test_output
- test_with_harness
- test_trace
- check_trace_state
- check_invariant
- check_trace_count
- test_wavesynth_notes
- test_wavesynth_audio
- test_turtle_image
- test_file_contents
- compare_using
- succeed_unless_crashed
- compare_exactly
- compare_reports
- compare_strings_gently
- compare_strings_semi_strict
- compare_strings_firmly
- refine
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.
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:
{
Where
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.
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.
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.
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".
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.
Inherited Members
- HasContext
- synthesize_context_info
- create_context
- set_context_description
- set_context_displayer
- describe_module_slot
- HasGoal
- provide_goal
- synthesize_goal_info
- create_goal_from_contexts
- ensure_goal_constructor_arg
- goal
- validate
- set_identifier
- set_goal_type
- set_goal_description
- test_module
- test_output
- test_with_harness
- test_trace
- check_trace_state
- check_invariant
- check_trace_count
- test_wavesynth_notes
- test_wavesynth_audio
- test_turtle_image
- test_file_contents
- compare_using
- succeed_unless_crashed
- compare_exactly
- compare_reports
- compare_strings_gently
- compare_strings_semi_strict
- compare_strings_firmly
- refine
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.
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.
A list of fruitful built-in functions and methods, for use with
DontWasteFruit
and/or DontWasteBoxes
.
A list of non-fruitful built-in functions and methods, for use with
DontWasteFruit
and/or DontWasteBoxes
.
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.
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
.
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.
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
.
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.
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).
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!
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.
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.
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.