pytest-prairielearn-grader 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_prairielearn_grader-0.2.0/.github/copilot-instructions.md +187 -0
- pytest_prairielearn_grader-0.2.0/.github/workflows/lint.yaml +33 -0
- pytest_prairielearn_grader-0.2.0/.github/workflows/publish.yaml +86 -0
- pytest_prairielearn_grader-0.2.0/.github/workflows/test.yaml +22 -0
- pytest_prairielearn_grader-0.2.0/.gitignore +80 -0
- pytest_prairielearn_grader-0.2.0/.vscode/extensions.json +8 -0
- pytest_prairielearn_grader-0.2.0/.vscode/settings.json +19 -0
- pytest_prairielearn_grader-0.2.0/LICENSE +21 -0
- pytest_prairielearn_grader-0.2.0/PKG-INFO +43 -0
- pytest_prairielearn_grader-0.2.0/README.md +3 -0
- pytest_prairielearn_grader-0.2.0/docker/Dockerfile +37 -0
- pytest_prairielearn_grader-0.2.0/docker/requirements.txt +29 -0
- pytest_prairielearn_grader-0.2.0/docker/run.sh +56 -0
- pytest_prairielearn_grader-0.2.0/pyproject.toml +107 -0
- pytest_prairielearn_grader-0.2.0/quick_start.md +124 -0
- pytest_prairielearn_grader-0.2.0/src/pytest_prairielearn_grader/__init__.py +1 -0
- pytest_prairielearn_grader-0.2.0/src/pytest_prairielearn_grader/_student_code_runner.py +388 -0
- pytest_prairielearn_grader-0.2.0/src/pytest_prairielearn_grader/fixture.py +381 -0
- pytest_prairielearn_grader-0.2.0/src/pytest_prairielearn_grader/json_utils.py +158 -0
- pytest_prairielearn_grader-0.2.0/src/pytest_prairielearn_grader/plugin.py +733 -0
- pytest_prairielearn_grader-0.2.0/src/pytest_prairielearn_grader/py.typed +0 -0
- pytest_prairielearn_grader-0.2.0/src/pytest_prairielearn_grader/utils.py +325 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_core_exception/__init__.py +0 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_core_exception/expected_outcome.json +51 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_core_exception/scenario.py +10 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_core_exception/student_code_custom_class.py +6 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_core_exception/student_code_exception.py +2 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_core_exception/student_code_exit.py +1 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_core_exception/student_code_timeout.py +4 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_core_exception/student_code_wrong_var.py +1 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_fixture_exception/__init__.py +1 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_fixture_exception/expected_outcome.json +16 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_fixture_exception/scenario.py +23 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_fixture_exception/student_code.py +2 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_function_name/__init__.py +0 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_function_name/data.json +22 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_function_name/expected_outcome.json +55 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_function_name/func_name_code_bad_import.py +3 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_function_name/func_name_code_inner_bad_import.py +4 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_function_name/func_name_code_student.py +3 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_function_name/scenario.py +50 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_initialization_timeout/__init__.py +1 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_initialization_timeout/expected_outcome.json +34 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_initialization_timeout/scenario.py +29 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_initialization_timeout/student_code.py +10 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_matplotlib_plots/__init__.py +1 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_matplotlib_plots/expected_outcome.json +61 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_matplotlib_plots/scenario.py +138 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_matplotlib_plots/student_code.py +70 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_module_sandbox/__init__.py +0 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_module_sandbox/data.json +46 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_module_sandbox/expected_outcome.json +79 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_module_sandbox/scenario.py +96 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_module_sandbox/setup_code.py +19 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_module_sandbox/student_code.py +56 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_private_namespace/__init__.py +0 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_private_namespace/data.json +39 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_private_namespace/expected_outcome.json +43 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_private_namespace/scenario.py +22 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_private_namespace/setup_code.py +13 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_private_namespace/student_code.py +5 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_private_namespace/student_code_fail.py +1 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_private_namespace/student_code_fail_other.py +1 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_private_namespace/student_code_fail_read_builtin.py +1 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_privilege_drop/__init__.py +1 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_privilege_drop/expected_outcome.json +43 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_privilege_drop/scenario.py +67 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_privilege_drop/student_code.py +9 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_1/__init__.py +0 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_1/data.json +8 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_1/expected_outcome.json +97 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_1/scenario.py +86 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_1/setup_code.py +7 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_1/student_code_0.py +1 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_1/student_code_0_bad_func.py +6 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_1/student_code_100.py +9 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_1/student_code_33.py +9 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_1/student_code_66.py +9 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_2/__init__.py +0 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_2/data.json +38 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_2/expected_outcome.json +88 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_2/scenario.py +163 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_2/student_code_0.py +20 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_2/student_code_100.py +20 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_2/student_code_20.py +2 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_1/__init__.py +0 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_1/data.json +5 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_1/expected_outcome.json +88 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_1/scenario.py +103 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_1/student_code_0.py +25 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_1/student_code_100.py +18 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_1/student_code_66.py +18 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_2/__init__.py +0 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_2/expected_outcome.json +79 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_2/scenario.py +159 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_2/student_code_0.py +14 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_2/student_code_0_no_function.py +10 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_2/student_code_100.py +15 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_2/student_code_50.py +15 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_sandbox/__init__.py +0 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_sandbox/data.json +3 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_sandbox/expected_outcome.json +79 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_sandbox/leading_code.py +0 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_sandbox/scenario.py +122 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_sandbox/student_code.py +19 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_sandbox/student_code_other.py +13 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_sandbox/trailing_code.py +0 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_stdout_feature/__init__.py +0 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_stdout_feature/expected_outcome.json +71 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_stdout_feature/scenario.py +76 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_stdout_feature/student_code.py +4 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_stdout_feature/student_code_with_global_prints.py +20 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_timeout/__init__.py +0 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_timeout/expected_outcome.json +52 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_timeout/scenario.py +47 -0
- pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_timeout/student_code.py +17 -0
- pytest_prairielearn_grader-0.2.0/tests/test_autograder_scenarios.py +146 -0
- pytest_prairielearn_grader-0.2.0/tests/test_serialize.py +90 -0
- pytest_prairielearn_grader-0.2.0/uv.lock +1655 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Pytest PrairieLearn Grader - AI Coding Agent Instructions
|
|
2
|
+
|
|
3
|
+
## Project Overview
|
|
4
|
+
|
|
5
|
+
**pytest-prairielearn-grader** is a pytest plugin for autograding Python code, designed for integration with PrairieLearn. It executes student code in isolated sandbox environments and provides detailed feedback and scoring.
|
|
6
|
+
|
|
7
|
+
**Key Architecture**: Test harnesses run in the main process, while student code executes in separate subprocesses via Unix sockets, enabling security isolation and timeout enforcement.
|
|
8
|
+
|
|
9
|
+
## Core Components
|
|
10
|
+
|
|
11
|
+
### 1. Sandboxed Execution (`StudentFixture`)
|
|
12
|
+
|
|
13
|
+
- **Location**: [src/pytest_prairielearn_grader/fixture.py](src/pytest_prairielearn_grader/fixture.py)
|
|
14
|
+
- **How it works**: Tests use the `sandbox` or `module_sandbox` fixtures to interact with student code
|
|
15
|
+
- **Key methods**:
|
|
16
|
+
- `sandbox.query(var_name)` - retrieve variables from student code
|
|
17
|
+
- `sandbox.query_function(func_name, *args, **kwargs)` - execute functions in student code
|
|
18
|
+
- `sandbox.get_stdout()` - capture student code output
|
|
19
|
+
- **Important**: Student code runs in subprocess via `_student_code_runner.py`; results are JSON-serialized through socket communication
|
|
20
|
+
|
|
21
|
+
### 2. Student Code Runner Process
|
|
22
|
+
|
|
23
|
+
- **Location**: [src/pytest_prairielearn_grader/\_student_code_runner.py](src/pytest_prairielearn_grader/_student_code_runner.py)
|
|
24
|
+
- **Role**: Runs in isolated subprocess, executes student code, enforces security/timeouts
|
|
25
|
+
- **Socket Protocol**: Receives JSON requests (setup, query, function calls), returns JSON responses
|
|
26
|
+
- **Security Features**:
|
|
27
|
+
- Import whitelist/blacklist enforcement
|
|
28
|
+
- Builtin function restrictions
|
|
29
|
+
- Privilege dropping (Unix only)
|
|
30
|
+
- Timeout enforcement via asyncio
|
|
31
|
+
|
|
32
|
+
### 3. Test Execution & Grading
|
|
33
|
+
|
|
34
|
+
- **Location**: [src/pytest_prairielearn_grader/plugin.py](src/pytest_prairielearn_grader/plugin.py)
|
|
35
|
+
- **Custom Pytest Plugin**: Registers fixtures and collects grading metadata
|
|
36
|
+
- **Fixtures**:
|
|
37
|
+
- `sandbox` (function-scoped): Isolated per test, supports parameterization
|
|
38
|
+
- `module_sandbox` (module-scoped): Reused across module, single student code only
|
|
39
|
+
- `feedback` (function-scoped): Manages partial credit and messages
|
|
40
|
+
- `data_json` (module-scoped): Loads test parameters from `data.json`
|
|
41
|
+
|
|
42
|
+
### 4. Test Scenario Structure
|
|
43
|
+
|
|
44
|
+
- **Location**: [tests/scenario_root/](tests/scenario_root/)
|
|
45
|
+
- **Pattern**: Each test scenario has a directory `test_*` containing:
|
|
46
|
+
- `scenario.py` - test code using autograder fixtures
|
|
47
|
+
- `data.json` - parameters passed to student code
|
|
48
|
+
- `setup_code.py` - initialization code for student sandbox
|
|
49
|
+
- `student_code_*.py` - various student code variants for testing
|
|
50
|
+
- `expected_outcome.json` - expected pytest results for validation
|
|
51
|
+
|
|
52
|
+
## Critical Developer Workflows
|
|
53
|
+
|
|
54
|
+
### Running Tests
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# Run all scenario tests
|
|
58
|
+
pytest tests/test_autograder_scenarios.py -v
|
|
59
|
+
|
|
60
|
+
# Run specific scenario
|
|
61
|
+
pytest tests/test_autograder_scenarios.py::test_autograder_scenario_with_pytester[test_quiz_2_1] -v
|
|
62
|
+
|
|
63
|
+
# Run with output capture disabled (see prints)
|
|
64
|
+
pytest tests/test_autograder_scenarios.py -s
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Running a Single Scenario Directly
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Copy scenario files, run: cd tests/scenario_root/test_quiz_2_1 && pytest test_quiz_2_1.py -v
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Linting & Type Checking
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# Format with ruff
|
|
77
|
+
ruff format src/ tests/
|
|
78
|
+
|
|
79
|
+
# Check with ruff
|
|
80
|
+
ruff check src/ tests/ --fix
|
|
81
|
+
|
|
82
|
+
# Type check
|
|
83
|
+
mypy src/
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Key Design Patterns & Conventions
|
|
87
|
+
|
|
88
|
+
### 1. Grading Data Marks
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
@pytest.mark.grading_data(name="Test Name", points=5, include_stdout_feedback=False)
|
|
92
|
+
def test_something(sandbox: StudentFixture, feedback: FeedbackFixture) -> None:
|
|
93
|
+
# name: displayed to student
|
|
94
|
+
# points: max points for this test
|
|
95
|
+
# include_stdout_feedback: whether to capture student stdout
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 2. Partial Credit Pattern
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
def test_multi_step(sandbox: StudentFixture, feedback: FeedbackFixture) -> None:
|
|
102
|
+
feedback.set_score(0.5) # 50% credit if next assert fails
|
|
103
|
+
assert step_one_passes() # if fails here, student gets 50%
|
|
104
|
+
|
|
105
|
+
feedback.set_score(0.8) # 80% credit if next assert fails
|
|
106
|
+
assert step_two_passes() # if fails here, student gets 80%
|
|
107
|
+
|
|
108
|
+
feedback.set_score(1.0) # 100% if all pass
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### 3. Function Query Pattern
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
# In test:
|
|
115
|
+
result = sandbox.query_function("student_func", arg1, arg2, timeout=5)
|
|
116
|
+
|
|
117
|
+
# Query returns StudentFunctionResponse with:
|
|
118
|
+
# - status: SUCCESS, EXCEPTION, TIMEOUT, NOT_FOUND
|
|
119
|
+
# - value: JSON-deserialized return value
|
|
120
|
+
# - stdout/stderr: captured output
|
|
121
|
+
# - exception_name/message/traceback: if exception occurred
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### 4. Data Configuration Pattern
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
# data.json structure:
|
|
128
|
+
{
|
|
129
|
+
"params": {
|
|
130
|
+
"sin_coefficient": 2.5,
|
|
131
|
+
"cos_coefficient": 1.3,
|
|
132
|
+
"names_for_user": [
|
|
133
|
+
{"name": "sin_coefficient", "type": "float", "description": "..."},
|
|
134
|
+
{"name": "array_data", "type": "ndarray", "description": "..."}
|
|
135
|
+
],
|
|
136
|
+
"import_whitelist": ["numpy", "math"],
|
|
137
|
+
"builtin_whitelist": ["len", "range", "sum"]
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# In setup_code.py, define variables; they're injected if in names_for_user
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### 5. Serialization
|
|
145
|
+
|
|
146
|
+
- Uses `dill` for complex Python objects (numpy arrays, pandas DataFrames, matplotlib plots)
|
|
147
|
+
- Base64-encoded in JSON for transport
|
|
148
|
+
- See [src/pytest_prairielearn_grader/json_utils.py](src/pytest_prairielearn_grader/json_utils.py) for custom serialization
|
|
149
|
+
|
|
150
|
+
## Important Integration Points
|
|
151
|
+
|
|
152
|
+
### PrairieLearn Integration
|
|
153
|
+
|
|
154
|
+
- Test file must be named `tests/test_*.py`
|
|
155
|
+
- Student code file is expected as `student_code.py` (configurable via `student_code_pattern` global variable)
|
|
156
|
+
- Output is collected as JSON with test results, scores, and messages
|
|
157
|
+
- Exit codes: 0 = success, non-zero = test execution failures
|
|
158
|
+
|
|
159
|
+
### Command Line Options
|
|
160
|
+
|
|
161
|
+
- `--worker-username`: Identifies which student submission is running (used for privilege dropping)
|
|
162
|
+
- `--output-json`: Path where JSON results are written
|
|
163
|
+
- Custom pytest options (e.g., `-v`, `-s`) work normally
|
|
164
|
+
|
|
165
|
+
## Common Pitfalls & Solutions
|
|
166
|
+
|
|
167
|
+
| Issue | Solution |
|
|
168
|
+
| ------------------------------------------ | ------------------------------------------------------------------------------------- |
|
|
169
|
+
| "Student code server process terminated" | Check for unhandled exceptions in setup_code or timeout too short |
|
|
170
|
+
| Function not found errors | Ensure function is defined in student_code.py, not just setup_code.py |
|
|
171
|
+
| Timeout errors on network tests | Increase timeout via `@pytest.mark.sandbox_timeout(N)` |
|
|
172
|
+
| Serialization fails | Use `dill`-compatible types (check json_utils.py for supported types) |
|
|
173
|
+
| Module sandbox with multiple student codes | Use regular `sandbox` fixture instead; module_sandbox requires single student_code.py |
|
|
174
|
+
|
|
175
|
+
## Files to Reference When Extending
|
|
176
|
+
|
|
177
|
+
- **Adding new fixture**: [src/pytest_prairielearn_grader/plugin.py#L195-L210](src/pytest_prairielearn_grader/plugin.py#L195-L210)
|
|
178
|
+
- **New query type**: [src/pytest_prairielearn_grader/utils.py](src/pytest_prairielearn_grader/utils.py) (define TypedDict) + [src/pytest_prairielearn_grader/\_student_code_runner.py](src/pytest_prairielearn_grader/_student_code_runner.py) (handle message)
|
|
179
|
+
- **Custom marks**: [src/pytest_prairielearn_grader/utils.py#L5-L20](src/pytest_prairielearn_grader/utils.py#L5-L20) (define enum) + [src/pytest_prairielearn_grader/plugin.py](src/pytest_prairielearn_grader/plugin.py) (use in marker parsing)
|
|
180
|
+
- **Test examples**: [tests/scenario_root/](tests/scenario_root/) (all subdirectories follow same pattern)
|
|
181
|
+
|
|
182
|
+
## Python Version & Dependencies
|
|
183
|
+
|
|
184
|
+
- **Minimum Python**: 3.11
|
|
185
|
+
- **Key dependencies**: pytest, dill, prettytable
|
|
186
|
+
- **Dev dependencies**: numpy, pandas, mypy, ruff, matplotlib, sympy
|
|
187
|
+
- **Code style**: Ruff with specific rules configured in pyproject.toml (140 char line length, specific linters enabled)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: Lint
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
Lint:
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
with:
|
|
17
|
+
fetch-depth: 0
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.13"
|
|
21
|
+
- name: Install uv
|
|
22
|
+
uses: astral-sh/setup-uv@v5
|
|
23
|
+
- name: Check lockfile
|
|
24
|
+
run: uv lock --check
|
|
25
|
+
- name: Lint with ruff
|
|
26
|
+
continue-on-error: true
|
|
27
|
+
run: |
|
|
28
|
+
# Fail if codebase contains any of these issues
|
|
29
|
+
uv run ruff check .
|
|
30
|
+
uv run ruff format --check .
|
|
31
|
+
- name: Static Typechecking with mypy
|
|
32
|
+
run: |
|
|
33
|
+
uv run mypy --config-file pyproject.toml src tests
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*.*.*"
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
inputs:
|
|
9
|
+
tag:
|
|
10
|
+
description: "Git tag to build (e.g., v0.1.15)"
|
|
11
|
+
required: true
|
|
12
|
+
type: string
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
build:
|
|
16
|
+
if: github.event_name == 'push'
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
with:
|
|
21
|
+
ref: ${{ github.event.inputs.tag || github.ref }}
|
|
22
|
+
- uses: astral-sh/setup-uv@v5
|
|
23
|
+
- name: Build and publish to pypi
|
|
24
|
+
run: |
|
|
25
|
+
uv build
|
|
26
|
+
uv publish --token ${{ secrets.PYPI_TOKEN }}
|
|
27
|
+
|
|
28
|
+
build-docker:
|
|
29
|
+
if: always()
|
|
30
|
+
needs: build
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
permissions:
|
|
33
|
+
contents: read
|
|
34
|
+
steps:
|
|
35
|
+
- uses: actions/checkout@v4
|
|
36
|
+
with:
|
|
37
|
+
ref: ${{ github.event.inputs.tag || github.ref }}
|
|
38
|
+
|
|
39
|
+
- name: Set up Docker Buildx
|
|
40
|
+
uses: docker/setup-buildx-action@v3
|
|
41
|
+
|
|
42
|
+
- name: Log in to Docker Hub
|
|
43
|
+
uses: docker/login-action@v3
|
|
44
|
+
with:
|
|
45
|
+
username: ${{ secrets.DOCKER_USERNAME }}
|
|
46
|
+
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
47
|
+
|
|
48
|
+
- name: Extract version from tag
|
|
49
|
+
id: version
|
|
50
|
+
run: |
|
|
51
|
+
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then\
|
|
52
|
+
VERSION="${{ github.event.inputs.tag }}"
|
|
53
|
+
echo "VERSION=${VERSION#v}" >> $GITHUB_OUTPUT
|
|
54
|
+
else
|
|
55
|
+
echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
- name: Wait for PyPI package to be available
|
|
59
|
+
if: github.event_name != 'workflow_dispatch'
|
|
60
|
+
run: |
|
|
61
|
+
echo "Waiting 30 seconds for package to be available on PyPI..."
|
|
62
|
+
sleep 30
|
|
63
|
+
|
|
64
|
+
# Verify package is available
|
|
65
|
+
for i in {1..10}; do
|
|
66
|
+
if pip index versions pytest-prairielearn-grader 2>/dev/null | grep -q "${{ steps.version.outputs.VERSION }}"; then
|
|
67
|
+
echo "Package version ${{ steps.version.outputs.VERSION }} is available on PyPI"
|
|
68
|
+
exit 0
|
|
69
|
+
fi
|
|
70
|
+
if [ $i -lt 10 ]; then
|
|
71
|
+
echo "Attempt $i/10: Package not yet available, waiting 10 seconds..."
|
|
72
|
+
sleep 10
|
|
73
|
+
fi
|
|
74
|
+
done
|
|
75
|
+
echo "Warning: Could not verify package on PyPI, proceeding anyway"
|
|
76
|
+
|
|
77
|
+
- name: Build and push Docker image
|
|
78
|
+
uses: docker/build-push-action@v5
|
|
79
|
+
with:
|
|
80
|
+
context: ./docker
|
|
81
|
+
push: true
|
|
82
|
+
tags: |
|
|
83
|
+
${{ secrets.DOCKER_USERNAME }}/grader-python-pytest:v${{ steps.version.outputs.VERSION }}
|
|
84
|
+
${{ secrets.DOCKER_USERNAME }}/grader-python-pytest:latest
|
|
85
|
+
build-args: |
|
|
86
|
+
PACKAGE_VERSION=${{ steps.version.outputs.VERSION }}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
Tests:
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
|
15
|
+
os: [ubuntu-latest]
|
|
16
|
+
runs-on: ${{ matrix.os }}
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
- name: Install uv
|
|
20
|
+
uses: astral-sh/setup-uv@v5
|
|
21
|
+
- name: Test with pytest
|
|
22
|
+
run: uv run pytest tests -p no:prairielearn-grader
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
*.py[cod]
|
|
2
|
+
__pycache__
|
|
3
|
+
|
|
4
|
+
# Temp files
|
|
5
|
+
.*.sw[po]
|
|
6
|
+
*~
|
|
7
|
+
*.bak
|
|
8
|
+
.DS_Store
|
|
9
|
+
|
|
10
|
+
# C extensions
|
|
11
|
+
*.so
|
|
12
|
+
|
|
13
|
+
# Build and package files
|
|
14
|
+
*.egg
|
|
15
|
+
*.egg-info
|
|
16
|
+
.bootstrap
|
|
17
|
+
.build
|
|
18
|
+
.cache
|
|
19
|
+
.eggs
|
|
20
|
+
.env
|
|
21
|
+
.installed.cfg
|
|
22
|
+
.ve
|
|
23
|
+
bin
|
|
24
|
+
build
|
|
25
|
+
develop-eggs
|
|
26
|
+
dist
|
|
27
|
+
eggs
|
|
28
|
+
lib
|
|
29
|
+
lib64
|
|
30
|
+
parts
|
|
31
|
+
pip-wheel-metadata/
|
|
32
|
+
pyvenv*/
|
|
33
|
+
sdist
|
|
34
|
+
var
|
|
35
|
+
venv*/
|
|
36
|
+
wheelhouse
|
|
37
|
+
|
|
38
|
+
# Installer logs
|
|
39
|
+
pip-log.txt
|
|
40
|
+
|
|
41
|
+
# Unit test / coverage reports
|
|
42
|
+
.benchmarks
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.pytest
|
|
46
|
+
.pytest_cache/
|
|
47
|
+
.tox
|
|
48
|
+
coverage.xml
|
|
49
|
+
htmlcov
|
|
50
|
+
nosetests.xml
|
|
51
|
+
|
|
52
|
+
# Translations
|
|
53
|
+
*.mo
|
|
54
|
+
|
|
55
|
+
# Buildout
|
|
56
|
+
.mr.developer.cfg
|
|
57
|
+
|
|
58
|
+
# IDE project files
|
|
59
|
+
*.iml
|
|
60
|
+
*.komodoproject
|
|
61
|
+
.idea
|
|
62
|
+
.project
|
|
63
|
+
.pydevproject
|
|
64
|
+
|
|
65
|
+
# Complexity
|
|
66
|
+
output/*.html
|
|
67
|
+
output/*/index.html
|
|
68
|
+
|
|
69
|
+
# Sphinx
|
|
70
|
+
docs/_build
|
|
71
|
+
|
|
72
|
+
# Mypy Cache
|
|
73
|
+
.mypy_cache/
|
|
74
|
+
|
|
75
|
+
autograder_results.json
|
|
76
|
+
**/autograder_output.json
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
tests/test_demo.py
|
|
80
|
+
tests/test_demo/**
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"python.testing.unittestEnabled": false,
|
|
3
|
+
"python.testing.pytestEnabled": true,
|
|
4
|
+
"[python]": {
|
|
5
|
+
"editor.formatOnSave": true,
|
|
6
|
+
"editor.defaultFormatter": "charliermarsh.ruff",
|
|
7
|
+
"editor.codeActionsOnSave": {
|
|
8
|
+
"source.organizeImports": "explicit",
|
|
9
|
+
"source.fixAll": "explicit"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"[yaml][toml][json]": {
|
|
13
|
+
"editor.formatOnSave": true
|
|
14
|
+
},
|
|
15
|
+
"files.trimTrailingWhitespace": true,
|
|
16
|
+
"files.insertFinalNewline": true,
|
|
17
|
+
"files.trimFinalNewlines": true,
|
|
18
|
+
"python.analysis.typeCheckingMode": "basic",
|
|
19
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Eliot W. Robson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytest-prairielearn-grader
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A pytest plugin for autograding Python code. Designed for use with the PrairieLearn platform.
|
|
5
|
+
Project-URL: repository, https://github.com/eliotwrobson/pl-python-autograder-v2
|
|
6
|
+
Author-email: Eliot Robson <eliot.robson24@gmail.com>
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2025 Eliot W. Robson
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Classifier: Development Status :: 3 - Alpha
|
|
30
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
35
|
+
Requires-Python: >=3.11
|
|
36
|
+
Requires-Dist: dill>=0.4.0
|
|
37
|
+
Requires-Dist: prettytable>=3.8.0
|
|
38
|
+
Requires-Dist: pytest
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# pytest-prairielearn-grader
|
|
42
|
+
|
|
43
|
+
A new version of the Python autograder for PrairieLearn, made using a pytest plugin.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# https://docs.astral.sh/uv/guides/integration/docker/#available-images
|
|
2
|
+
FROM ghcr.io/astral-sh/uv:alpine
|
|
3
|
+
#ARG CACHEBUST=2025-07-15-14-16-18
|
|
4
|
+
|
|
5
|
+
# Needed to properly handle UTF-8
|
|
6
|
+
ENV PYTHONIOENCODING=UTF-8
|
|
7
|
+
ENV LANG=en_US.UTF-8
|
|
8
|
+
|
|
9
|
+
WORKDIR /
|
|
10
|
+
|
|
11
|
+
RUN apk update \
|
|
12
|
+
&& apk add --no-cache \
|
|
13
|
+
util-linux \
|
|
14
|
+
sudo \
|
|
15
|
+
build-base
|
|
16
|
+
|
|
17
|
+
# NOTE all the crap below is needed to make pygraphviz work, but we probably don't need to include this
|
|
18
|
+
# in the new grader image? We can add back if necessary but not sure what the use cases are.
|
|
19
|
+
# dos2unix \
|
|
20
|
+
# graphviz \
|
|
21
|
+
# graphviz-devel
|
|
22
|
+
|
|
23
|
+
COPY requirements.txt /
|
|
24
|
+
ARG PACKAGE_VERSION=latest
|
|
25
|
+
RUN uv venv --seed && uv pip install --no-cache-dir -r requirements.txt && \
|
|
26
|
+
uv pip install --no-cache-dir pytest-prairielearn-grader==${PACKAGE_VERSION}
|
|
27
|
+
|
|
28
|
+
# Anything that needs to be run post-install
|
|
29
|
+
# TODO Not clear yet how to make the grader run subprocess as a less privileged user
|
|
30
|
+
# RUN useradd ag
|
|
31
|
+
COPY --chmod=755 run.sh /run.sh
|
|
32
|
+
|
|
33
|
+
# Add serverFilesCourse to Python path
|
|
34
|
+
# TODO do we need to copy anything here?
|
|
35
|
+
ENV PYTHONPATH=/grade/serverFilesCourse/
|
|
36
|
+
|
|
37
|
+
ENTRYPOINT [ "/run.sh" ]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
|
|
2
|
+
beautifulsoup4==4.13.4
|
|
3
|
+
bokeh==3.7.3
|
|
4
|
+
colormath==3.0.0
|
|
5
|
+
defusedxml==0.7.1
|
|
6
|
+
Faker==37.5.3
|
|
7
|
+
folium==0.20.0
|
|
8
|
+
ipython==8.37.0
|
|
9
|
+
matplotlib==3.10.3
|
|
10
|
+
nbconvert==7.16.6
|
|
11
|
+
nbformat==5.10.4
|
|
12
|
+
networkx==3.4.2
|
|
13
|
+
nltk==3.9.1
|
|
14
|
+
numpy==2.3.2
|
|
15
|
+
openpyxl==3.1.5
|
|
16
|
+
pandas==2.3.1
|
|
17
|
+
Pillow==11.3.0
|
|
18
|
+
PuLP==3.2.2
|
|
19
|
+
Pygments==2.19.2
|
|
20
|
+
pyarrow==21.0.0
|
|
21
|
+
pytest==8.4.1
|
|
22
|
+
requests==2.32.4
|
|
23
|
+
scikit-image==0.25.2
|
|
24
|
+
scikit-learn==1.7.1
|
|
25
|
+
scipy==1.15.3
|
|
26
|
+
seaborn==0.13.2
|
|
27
|
+
sympy==1.14.0
|
|
28
|
+
text-unidecode==1.3
|
|
29
|
+
typing-extensions==4.14.1
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#! /bin/sh
|
|
2
|
+
|
|
3
|
+
##########################
|
|
4
|
+
# INIT
|
|
5
|
+
##########################
|
|
6
|
+
|
|
7
|
+
if [[ ! -d /grade ]]; then
|
|
8
|
+
echo "ERROR: /grade not found! Mounting may have failed."
|
|
9
|
+
exit 1
|
|
10
|
+
fi
|
|
11
|
+
|
|
12
|
+
# the parent directory containing everything about this grading job
|
|
13
|
+
export JOB_DIR='/grade'
|
|
14
|
+
# the job subdirectories
|
|
15
|
+
STUDENT_DIR=$JOB_DIR'/student'
|
|
16
|
+
TEST_DIR=$JOB_DIR'/tests'
|
|
17
|
+
OUT_DIR=$JOB_DIR'/results'
|
|
18
|
+
|
|
19
|
+
# where we will copy everything
|
|
20
|
+
export MERGE_DIR=$JOB_DIR'/run'
|
|
21
|
+
|
|
22
|
+
# now set up the stuff so that our run.sh can work
|
|
23
|
+
mkdir $MERGE_DIR
|
|
24
|
+
mkdir $MERGE_DIR/test_student
|
|
25
|
+
mkdir $OUT_DIR
|
|
26
|
+
|
|
27
|
+
mv $STUDENT_DIR/* $MERGE_DIR/test_student/
|
|
28
|
+
# TODO this has some pretty hardcoded assumptions about file names. Discuss in meeting
|
|
29
|
+
# what the best way to change this is
|
|
30
|
+
mv $TEST_DIR/* $MERGE_DIR/test_student/
|
|
31
|
+
mv $MERGE_DIR/test_student/test_student.py $MERGE_DIR/test_student.py
|
|
32
|
+
|
|
33
|
+
# Do not allow ag user to modify, rename, or delete any existing files
|
|
34
|
+
#chmod -R 755 "$MERGE_DIR"
|
|
35
|
+
#chmod 1777 "$MERGE_DIR"
|
|
36
|
+
|
|
37
|
+
##########################
|
|
38
|
+
# RUN
|
|
39
|
+
##########################
|
|
40
|
+
|
|
41
|
+
echo "[run] starting autograder"
|
|
42
|
+
|
|
43
|
+
# run the autograder as a limited user called ag
|
|
44
|
+
# TODO pass in the ag user id as a CLI option
|
|
45
|
+
#su -c "python3 $MERGE_DIR/pl_main.py" ag
|
|
46
|
+
uv run pytest -p pl-grader --color=no "$MERGE_DIR"
|
|
47
|
+
|
|
48
|
+
# TODO change the default output name
|
|
49
|
+
mv "$MERGE_DIR/autograder_results.json" "$OUT_DIR/results.json"
|
|
50
|
+
|
|
51
|
+
# if that didn't work, then print a last-ditch message
|
|
52
|
+
if [ ! -s $OUT_DIR/results.json ]; then
|
|
53
|
+
echo '{"succeeded": false, "score": 0.0, "message": "Your code could not be processed by the autograder. Please contact course staff and have them check the logs for this submission."}' > $OUT_DIR/results.json
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
echo "[run] autograder completed"
|