Authoring Exercises
This guide explains how to write exercises and unit tests for notebook-ta.
Exercise Structure
Each exercise is defined in exercises.toml under [exercises.<id>]:
[exercises.ex1]
statement = "Write a function `add(a, b)` that returns the sum of two numbers."
additional_info = "No imports are needed."
The id (here ex1) is the stable identifier used in the %%notebook_ta cell magic line.
The optional name is an editable display name used by the benchmarking interface. When omitted,
it defaults to the subsection ID (ex1 here). Changing it
does not break saved solutions or historical benchmark records.
statement is optional — see Embedding statements in the notebook for an alternative that avoids duplicating the exercise description.
Free-text exercises
Use answer_type = "free_text" when the cell body is an explanation, proof, reflection, or other
prose answer rather than Python:
[exercises.explain_recursion]
answer_type = "free_text"
statement = "Explain recursion and the purpose of a base case."
evaluation_criteria = "A satisfactory answer explains self-invocation and termination."
prompt_on_free_text = "Evaluate the answer and identify one concrete improvement."
The student still submits through the same cell magic:
%%notebook_ta explain_recursion
Recursion occurs when a function calls itself. A base case stops the calls...
Notebook-ta treats that cell body as opaque text: it does not execute it, modify the notebook
namespace, or run unit tests. A non-empty answer within max_student_answer_length is sent directly
to the LLM for formative evaluation. No “Tests passed” indicator or hint button is displayed.
A free-text exercise must configure prompt_on_free_text or use global prompts.on_free_text.
evaluation_criteria is optional but strongly recommended for consistent feedback. Tests and
test-specific per-exercise limits are invalid for this answer type. LLM evaluation is
non-deterministic and should not be treated as authoritative grading without human review and an
appropriate audit process.
Writing Unit Tests
Tests are declared as TOML array tables: [[exercises.<id>.tests]].
The optional name defaults to Unit test 1, Unit test 2, and so on, according to the test’s
position within the exercise. Numbering includes tests that have an explicit name.
Exercise tests are trusted Python. Namespace separation and test timeouts are not a security sandbox; see the trust and security model before running third-party material.
Inline Tests
Use the code field to write a Python function directly in the TOML file:
[[exercises.ex1.tests]]
name = "add(2, 3) == 5"
code = """
def test_add(add):
result = add(2, 3)
return result == 5, f"Expected 5, got {result}"
"""
The runner inspects the test function’s signature and resolves every parameter name in the
student’s IPython namespace. In this example, the add parameter receives the object stored under
the name add. Multiple parameters are resolved independently in the same way.
Resolution happens before the test is called. If any parameter name is absent from the student’s
namespace, the test is not executed and is reported as failed with a message identifying the
missing name. The reserved student_globals parameter follows the explicit export rules in
Passing student symbols to tests instead.
It must return either:
A
bool(True= pass,False= fail)A
tuple[bool, str]where the string is a human-readable message
Any text printed to stdout is also captured and included in the message. Common ANSI SGR color and text-style sequences (including standard and bright colors, bold, and underline) are rendered in the notebook output, so existing terminal-style custom test reports remain readable.
Tests from the Current Global Scope
If a test function is already defined in the notebook’s current global scope, specify only its name:
[[exercises.ex1.tests]]
name = "Custom add test"
function = "test_add"
The named object must exist and be callable when the exercise runs. Otherwise, the test is reported as failed with a message identifying the missing function. Its parameters are resolved from the same global scope in the usual way.
External Tests
For complex tests, reference a function in an importable Python module:
[[exercises.ex3.tests]]
name = "Performance test"
module = "course_tests.ex3"
function = "test_performance"
For example, course_tests/ex3.py could contain:
def test_performance(build_index):
index = build_index(["alpha", "beta"])
return len(index) == 2, "Expected an index containing both values."
The module must be importable from the notebook’s working directory or PYTHONPATH. After loading
the configured function, the runner resolves its parameters exactly as it does for an inline test:
each parameter name is looked up in the student’s IPython namespace. If any name is missing, the
function is not called and the test is reported as failed with the missing name. The reserved
student_globals parameter uses the shared rules below.
Passing student symbols to tests
The following rules apply to inline, global-scope, and external tests. Put student_symbols or
export_student_globals in the test’s TOML table alongside code or function.
Named parameters
Named parameters are the preferred way to pass student definitions to a test. Only the requested
objects are serialized for the isolated test process. For example, a function declared as
def test_result(parse, render): ... receives the student’s parse and render objects. The test
fails before invocation if either name is not defined.
Exporting multiple symbols as a dictionary
When a test needs a dictionary of several student definitions, declare student_symbols. The
runner exports only those names and passes the resulting dictionary as student_globals:
[[exercises.ex2.tests]]
name = "Both functions are defined"
student_symbols = ["add", "mul"]
code = """
def test_both_defined(student_globals):
has_add = "add" in student_globals and callable(student_globals["add"])
has_mul = "mul" in student_globals and callable(student_globals["mul"])
return has_add and has_mul, "Expected both add() and mul() to be defined."
"""
A missing selected symbol is reported as a failed test. A test that declares a
student_globals parameter without either export option also fails with a configuration message.
The full IPython namespace can be exported explicitly with
export_student_globals = true, but this is strongly discouraged. Notebook namespaces commonly
contain large objects, open resources, module state, or objects that cloudpickle cannot serialize.
Exporting all of them can therefore make tests slow or cause process preparation to fail even when
the objects relevant to the test are valid. Use named parameters or student_symbols whenever
possible.
Unit Test Timeouts
Each unit test is cancelled if it runs longer than the global unit_test_timeout from
global_config.toml. The default is 5 seconds. A timeout is reported as a failed test in the
notebook and in benchmark runs, and the timeout message is included in the LLM prompt.
Override the timeout for one exercise with unit_test_timeout:
[exercises.ex_slow]
statement = "Implement a function that handles a large input."
unit_test_timeout = 15.0
Prompt Size Limits
Two global settings keep student submissions and test diagnostics from producing unbounded LLM prompts:
max_student_answer_length = 10000
max_unit_test_output_length = 4000
Both limits count Unicode characters. Student answers longer than
max_student_answer_length are still executed and tested, but notebook-ta emits an error
and does not contact the LLM. max_unit_test_output_length is a shared budget across all
unit test result messages in configured test order. Output beyond that budget is removed
and a warning is displayed; test names and pass/fail statuses are retained.
Override either limit for one exercise:
[exercises.ex_verbose]
statement = "Implement the requested reporting function."
max_student_answer_length = 20000
max_unit_test_output_length = 8000
Exercise-Level Prompt Overrides
You can override the global prompts for a specific exercise. Overrides may reuse fragments from the
global configuration’s [prompts.fragments] table:
[exercises.ex_hard]
statement = "Implement Dijkstra's algorithm."
prompt_on_success = """{{ prompt_base }}
Excellent! Analyse the time and space complexity of this graph algorithm.
"""
prompt_on_failure = """{{ prompt_base }}
Graph algorithms can be tricky. Think about the data structures you need. When the student asks
for hints, escalate guidance gradually.
"""
An unknown or malformed fragment reference in an exercise override raises ConfigurationError
while notebook-ta loads, before the exercise can be used.
Embedding Statements in the Notebook
To avoid duplicating the exercise description in both the notebook and exercises.toml, you can
omit statement from the TOML and embed it directly in a notebook markdown cell using a
<div id="<exercise-id>"> block:
<div id="ex1">
## Exercise 1 — Add two numbers
Write a function `add(a, b)` that returns the sum of `a` and `b`.
Example:
```python
add(2, 3) # → 5
```
</div>
The div renders normally in Jupyter and its inner content is used as the statement sent to the LLM.
Multiple markdown cells with the same id are concatenated in document order.
To use this feature, pass notebook_path= to notebook_ta.load():
import notebook_ta
notebook_ta.load(
"global_config.toml",
"exercises.toml",
notebook_path="my_notebook.ipynb", # path to the current notebook
)
If notebook_path is not provided, notebook-ta will try to detect the notebook path
automatically. Pass it explicitly when auto-detection
fails or for reproducibility.
Error handling — if
statementis absent from both the TOML and the notebook,load()raises aConfigurationErroridentifying the affected exercise.
Complete Example
[exercises.reverse]
statement = """
Write a function `reverse_list(lst)` that returns a new list
with the elements in reverse order.
"""
additional_info = "Do not use lst[::-1] or lst.reverse()."
[[exercises.reverse.tests]]
name = "reverse_list([1,2,3]) == [3,2,1]"
code = """
def test_basic(reverse_list):
return reverse_list([1,2,3]) == [3,2,1], "Basic reversal failed"
"""
[[exercises.reverse.tests]]
name = "Empty list returns empty list"
code = """
def test_empty(reverse_list):
return reverse_list([]) == [], "Empty list should return []"
"""
[[exercises.reverse.tests]]
name = "Original list not modified"
code = """
def test_no_mutation(reverse_list):
lst = [1, 2, 3]
copy = list(lst)
reverse_list(lst)
return lst == copy, "The original list was mutated!"
"""
Tips
Keep test names short and descriptive — they appear in the notebook output.
Return a meaningful message from your tests to help students understand failures.
Use
print()inside test functions to emit additional diagnostic information.Prefer named test parameters; use
student_symbolsonly when the test needs a dictionary.External module tests are useful for complex or reusable test logic shared across exercises.