Repository navigation
Expand file tree
/
Copy pathconfig.py
More file actions
401 lines (312 loc) · 18.9 KB
/
Copy pathconfig.py
File metadata and controls
401 lines (312 loc) · 18.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
"""Configuration dataclass for pytest-prairielearn-grader."""
import keyword
from dataclasses import dataclass
from dataclasses import field
from pathlib import PurePosixPath
from pathlib import PureWindowsPath
from typing import Any
from typing import Literal
def _is_plain_relative_path(name: str) -> bool:
"""Whether name is a relative path to a file that stays inside the folder it's resolved against.
Entries always use "/", whatever the OS, so a config means the same thing on every machine.
Backslashes and drives are rejected because on Windows they'd be a separator or an anchor.
"""
if not name.strip() or "\\" in name or PureWindowsPath(name).drive:
return False
path = PurePosixPath(name)
# PurePosixPath drops "." and empty parts, so "." and "" have no parts left.
return not path.is_absolute() and bool(path.parts) and ".." not in path.parts
@dataclass(frozen=True, slots=True, kw_only=True)
class ConfigObject:
"""
Configuration object for the autograder sandbox environment.
This dataclass provides type-safe configuration for all autograder settings
that can be set using the data["params"] dictionary or module-level variables.
When provided to a test, this configuration takes precedence over all other
configuration sources (data.json params, module-level variables, etc.).
This class is immutable (frozen) and uses slots for memory efficiency.
All constructor arguments must be passed as keyword arguments.
Example usage:
```python
from pytest_prairielearn_grader import ConfigObject
# Define configuration at module level - plugin will automatically detect it
autograder_config = ConfigObject(
sandbox_timeout=2.0,
import_whitelist=["numpy", "math"],
builtin_whitelist=["len", "range", "sum"],
starting_vars={"coefficient": 10}
)
def test_student_code(sandbox):
result = sandbox.query_function("student_func")
assert result.status == "success"
```
"""
sandbox_timeout: float = 1.0
"""Timeout in seconds for sandbox initialization and operations.
Must be a positive number. Default is 1.0 second.
This timeout applies to both sandbox initialization and individual student code operations.
"""
import_whitelist: list[str] | None = None
"""List of allowed Python modules that student code can import.
If set, only these modules can be imported (whitelist mode).
If None, all modules are allowed except those in import_blacklist.
Can be combined with import_blacklist - blacklist is checked first, then whitelist.
Example: ["numpy", "math", "pandas"]
"""
import_blacklist: list[str] | None = None
"""List of Python modules that student code is prohibited from importing.
Modules in the blacklist are always blocked, even if in the whitelist, and so are their
submodules: "os" also blocks `import os.path`.
An entry can also name a function in a module, such as "numpy.linalg.inv". The function then
raises a RuntimeError when it's called, however student code reached it, and importing it by
name is blocked. Setup code and helper modules can't call it either.
Can be combined with import_whitelist for fine-grained control.
Default blocks dangerous system operations: ["os", "sys", "subprocess", "pathlib", "shutil"].
Example: ["requests", "socket", "numpy.linalg.inv"]
"""
helper_modules: list[str] | None = None
"""Modules from the tests folder that setup code, leading code and student code can import.
Each entry is a plain module name, so "helper_function" loads helper_function.py from
the folder that holds leading_code.py. Packages, dots, paths and the ".py" suffix aren't
accepted. Importing anything else in that folder still fails, because the folder isn't
added to the import path.
Listed modules don't need to be in import_whitelist. Listing one that import_blacklist
also blocks is a configuration error. A helper module runs in the sandbox with the same
builtins and import rules as student code.
In workspace mode, a listed name is reserved: a student file or folder with that name fails
the submission with a message asking the student to rename it. A helper module keeps these
restricted builtins, unlike the student's own modules.
Students can read everything in a helper module, so it must not contain answers.
Example: ["helper_function"]
"""
data_files: list[str] | None = None
"""Files from the tests folder that setup code, leading code and student code can read by relative path.
Each entry is a path relative to the folder that holds leading_code.py, such as
"pagerank_large.txt" or "data/points.csv". Absolute paths and ".." aren't accepted.
The sandbox starts in a fresh folder that holds a copy of each listed file at the same
relative path, so ``np.loadtxt("pagerank_large.txt")`` works as it does in a Jupyter
workspace. Student code can't change the copies, and changes to them couldn't reach the
originals anyway, which tests read through the ``tests_dir`` fixture. Without this setting
the sandbox's starting folder isn't changed.
A listed file that doesn't exist makes the submission ungradable, with a message for the
instructor. Students can read the listed files, so they must not contain answers.
Example: ["pagerank_large.txt"]
"""
builtin_whitelist: list[str] | None = None
"""List of allowed Python builtin functions that student code can use.
If set, only these builtins are accessible in student code (whitelist mode).
If None, all builtins are available.
Example: ["len", "range", "sum", "print"]
"""
names_for_user: list[str] | None = None
"""List of variable names to inject into student sandbox.
Only variables listed here will be injected into student code.
Values are taken from starting_vars or from setup_code execution.
This is a simplified version for ConfigObject - just provide variable names.
Example: ["coefficient", "threshold", "data_array"]
"""
student_code_pattern: str = "student_code*.py"
"""Glob pattern for finding student code files.
Used by pytest_generate_tests to discover student code variants.
Default is "student_code*.py".
Must be a valid glob pattern.
Example: "submission*.py"
"""
starting_vars: dict[str, Any] = field(default_factory=dict)
"""Additional variables to inject into the student sandbox namespace.
This is a dictionary of variable names to values.
Variables must also be listed in names_for_user to be injected into student code.
This allows ConfigObject to override values from data.json params.
Example: {"constant": 42, "data_array": [1, 2, 3]}
"""
workspace_mode: bool = False
"""Enable workspace grading mode.
When True, the autograder uses the workspace_sandbox fixture instead of the
regular sandbox. In workspace mode the student's submission is treated as a
multi-file Python project rooted at workspace_student_dir rather than a single
script. Tests query functions and variables using dotted module paths such as
``"models.classifier.predict"`` instead of bare function names.
Default is False (normal single-file mode).
"""
workspace_student_dir: str | None = None
"""Path to the student's workspace directory.
Interpreted according to the following rules:
- If an absolute path (starts with '/'): used as-is.
- If a relative path or None: resolved relative to the directory that contains
the test module (the same directory where data.json lives). When None, the
fixture looks for a sub-directory named ``"student"`` next to the test file,
which mirrors the ``/grade/student/`` directory PrairieLearn provides during
autograding.
For PrairieLearn deployment, set this to ``"/grade/student"`` (or leave None and
ensure a ``student/`` directory exists next to your test file for local dev).
Example: "/grade/student"
"""
workspace_exec_entry: str | None = None
"""Optional entry-point file to execute at sandbox startup in workspace mode.
When set, the file is executed (via ``exec``) inside the sandbox after
``sys.path`` has been set up and after ``setup_code.py`` has run. This is
useful for questions where a student's ``main.py`` sets up global state that
tests depend on.
The path is relative to workspace_student_dir.
If None (default), no file is executed at startup; tests import modules
on-demand using dotted paths.
Example: "main.py"
"""
notebook_cell_tag: str | None = None
"""Tag used to filter Jupyter notebook code cells for grading.
When the student submission is a ``.ipynb`` notebook file, this tag controls
which code cells are extracted and executed in the sandbox.
- If ``None`` (default), **all** code cells in the notebook are included.
- If set to a string (e.g. ``"#grade"``), only code cells whose first
non-empty line starts with that string are included. This mirrors the
convention used by the built-in PrairieLearn Python autograder.
To use this feature the ``student_code_pattern`` must match ``.ipynb``
files (e.g. ``"student_code*.ipynb"``), and ``nbformat`` must be installed
(``pip install pytest-prairielearn-grader[notebook]``).
This field has no effect when the student submission is a plain ``.py`` file
or when workspace mode is enabled.
Example: "#grade"
"""
syntax_errors_ungradable: bool = True
"""Whether to mark submissions as ungradable when student code has a SyntaxError.
When True (default), if student code fails to parse due to a SyntaxError,
the submission is marked as ungradable (``"gradable": false``) in the
results JSON. This means the student will NOT be penalized an attempt
on the question, and will see a clear error message about the syntax issue.
When False, syntax errors are treated like any other exception — the
student receives a score of 0 and the attempt is counted.
This is especially useful for introductory courses where students may
accidentally submit code with indentation or bracket errors.
Example: True
"""
output_level: Literal["none", "message", "traceback", "friendly"] | None = None
"""Default output level for test failure messages shown to students.
Controls how much information from exceptions is displayed in the grading
feedback. This sets the *global* default for all tests in the module.
Individual tests can override this with ``@pytest.mark.output(level="...")``.
Levels:
- ``"none"``: Show only the exception class name (e.g., ``AssertionError``).
- ``"message"``: Show exception name + first line of message (default when None).
- ``"traceback"``: Show exception name + message + full traceback.
- ``"friendly"``: Show only the exception message text with no traceback or
class name. Designed for use with the ``assert_equal`` / ``assert_fn_equal``
helpers to produce clean, student-readable output.
When None, the plugin default of ``"message"`` is used unless overridden
by a per-test ``@pytest.mark.output(level=...)`` marker.
Example: "friendly"
"""
def __post_init__(self) -> None:
"""Validate configuration values after initialization."""
# Validate sandbox_timeout
if self.sandbox_timeout <= 0:
raise ValueError(f"sandbox_timeout must be positive, got {self.sandbox_timeout}")
# Validate import_whitelist contains non-empty strings
if self.import_whitelist is not None:
if not isinstance(self.import_whitelist, list):
raise TypeError(f"import_whitelist must be a list, got {type(self.import_whitelist).__name__}")
if not self.import_whitelist:
raise ValueError("import_whitelist cannot be empty (use None to allow all imports)")
for module_name in self.import_whitelist:
if not isinstance(module_name, str) or not module_name.strip():
raise ValueError(f"import_whitelist must contain non-empty strings, got: {module_name!r}")
# Validate import_blacklist contains non-empty strings
if self.import_blacklist is not None:
if not isinstance(self.import_blacklist, list):
raise TypeError(f"import_blacklist must be a list, got {type(self.import_blacklist).__name__}")
for module_name in self.import_blacklist:
if not isinstance(module_name, str) or not module_name.strip():
raise ValueError(f"import_blacklist must contain non-empty strings, got: {module_name!r}")
# Validate helper_modules contains plain module names
if self.helper_modules is not None:
if not isinstance(self.helper_modules, list):
raise TypeError(f"helper_modules must be a list, got {type(self.helper_modules).__name__}")
if not self.helper_modules:
raise ValueError("helper_modules cannot be empty (use None for no helper modules)")
for module_name in self.helper_modules:
if not isinstance(module_name, str) or not module_name.isidentifier() or keyword.iskeyword(module_name):
raise ValueError(f"helper_modules must contain plain module names without dots, paths or '.py', got: {module_name!r}")
# Validate data_files contains relative paths that stay inside the tests folder
if self.data_files is not None:
if not isinstance(self.data_files, list):
raise TypeError(f"data_files must be a list, got {type(self.data_files).__name__}")
if not self.data_files:
raise ValueError("data_files cannot be empty (use None for no data files)")
for file_name in self.data_files:
if not isinstance(file_name, str) or not _is_plain_relative_path(file_name):
raise ValueError(f"data_files must contain relative paths inside the tests folder, without '..', got: {file_name!r}")
if len({PurePosixPath(name) for name in self.data_files}) != len(self.data_files):
raise ValueError(f"data_files lists the same file more than once: {self.data_files!r}")
# Validate builtin_whitelist contains non-empty strings
if self.builtin_whitelist is not None:
if not isinstance(self.builtin_whitelist, list):
raise TypeError(f"builtin_whitelist must be a list, got {type(self.builtin_whitelist).__name__}")
if not self.builtin_whitelist:
raise ValueError("builtin_whitelist cannot be empty (use None to allow all builtins)")
for builtin_name in self.builtin_whitelist:
if not isinstance(builtin_name, str) or not builtin_name.strip():
raise ValueError(f"builtin_whitelist must contain non-empty strings, got: {builtin_name!r}")
# Validate names_for_user structure
if self.names_for_user is not None:
if not isinstance(self.names_for_user, list):
raise TypeError(f"names_for_user must be a list, got {type(self.names_for_user).__name__}")
if not self.names_for_user:
raise ValueError("names_for_user cannot be empty (use None to inject no variables)")
for name in self.names_for_user:
if not isinstance(name, str) or not name.strip():
raise ValueError(f"names_for_user must contain non-empty strings, got: {name!r}")
# Validate student_code_pattern is non-empty
if not isinstance(self.student_code_pattern, str) or not self.student_code_pattern.strip():
raise ValueError(f"student_code_pattern must be a non-empty string, got: {self.student_code_pattern!r}")
# Validate starting_vars is a dict
if not isinstance(self.starting_vars, dict):
raise TypeError(f"starting_vars must be a dict, got {type(self.starting_vars).__name__}")
# Validate workspace fields
if not isinstance(self.workspace_mode, bool):
raise TypeError(f"workspace_mode must be a bool, got {type(self.workspace_mode).__name__}")
if self.workspace_student_dir is not None:
if not isinstance(self.workspace_student_dir, str) or not self.workspace_student_dir.strip():
raise ValueError(f"workspace_student_dir must be a non-empty string, got: {self.workspace_student_dir!r}")
if not self.workspace_mode:
raise ValueError(
"workspace_student_dir requires workspace_mode=True. Set workspace_mode=True or remove workspace_student_dir."
)
if self.workspace_exec_entry is not None:
if not isinstance(self.workspace_exec_entry, str) or not self.workspace_exec_entry.strip():
raise ValueError(f"workspace_exec_entry must be a non-empty string, got: {self.workspace_exec_entry!r}")
if not self.workspace_mode:
raise ValueError(
"workspace_exec_entry requires workspace_mode=True. Set workspace_mode=True or remove workspace_exec_entry."
)
if self.notebook_cell_tag is not None:
if not isinstance(self.notebook_cell_tag, str) or not self.notebook_cell_tag.strip():
raise ValueError(f"notebook_cell_tag must be a non-empty string, got: {self.notebook_cell_tag!r}")
if self.workspace_mode:
raise ValueError(
"notebook_cell_tag is not supported in workspace mode. Set workspace_mode=False or remove notebook_cell_tag."
)
# Validate cross-mode conflicts
if self.workspace_mode and self.student_code_pattern != "student_code*.py":
raise ValueError(
"student_code_pattern is not applicable in workspace mode "
"(workspace_mode=True). Remove student_code_pattern or set workspace_mode=False."
)
# Validate output_level
valid_output_levels = ("none", "message", "traceback", "friendly")
if self.output_level is not None and self.output_level not in valid_output_levels:
raise ValueError(f"output_level must be one of {valid_output_levels} or None, got: {self.output_level!r}")
def to_dict(self) -> dict[str, Any]:
"""
Convert the ConfigObject to a dictionary format compatible with data["params"].
Returns:
Dictionary representation of the configuration.
"""
result: dict[str, Any] = {}
if self.import_whitelist is not None:
result["import_whitelist"] = self.import_whitelist
if self.import_blacklist is not None:
result["import_blacklist"] = self.import_blacklist
if self.builtin_whitelist is not None:
result["builtin_whitelist"] = self.builtin_whitelist
if self.names_for_user is not None:
result["names_for_user"] = self.names_for_user
return result