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.
Files changed (119) hide show
  1. pytest_prairielearn_grader-0.2.0/.github/copilot-instructions.md +187 -0
  2. pytest_prairielearn_grader-0.2.0/.github/workflows/lint.yaml +33 -0
  3. pytest_prairielearn_grader-0.2.0/.github/workflows/publish.yaml +86 -0
  4. pytest_prairielearn_grader-0.2.0/.github/workflows/test.yaml +22 -0
  5. pytest_prairielearn_grader-0.2.0/.gitignore +80 -0
  6. pytest_prairielearn_grader-0.2.0/.vscode/extensions.json +8 -0
  7. pytest_prairielearn_grader-0.2.0/.vscode/settings.json +19 -0
  8. pytest_prairielearn_grader-0.2.0/LICENSE +21 -0
  9. pytest_prairielearn_grader-0.2.0/PKG-INFO +43 -0
  10. pytest_prairielearn_grader-0.2.0/README.md +3 -0
  11. pytest_prairielearn_grader-0.2.0/docker/Dockerfile +37 -0
  12. pytest_prairielearn_grader-0.2.0/docker/requirements.txt +29 -0
  13. pytest_prairielearn_grader-0.2.0/docker/run.sh +56 -0
  14. pytest_prairielearn_grader-0.2.0/pyproject.toml +107 -0
  15. pytest_prairielearn_grader-0.2.0/quick_start.md +124 -0
  16. pytest_prairielearn_grader-0.2.0/src/pytest_prairielearn_grader/__init__.py +1 -0
  17. pytest_prairielearn_grader-0.2.0/src/pytest_prairielearn_grader/_student_code_runner.py +388 -0
  18. pytest_prairielearn_grader-0.2.0/src/pytest_prairielearn_grader/fixture.py +381 -0
  19. pytest_prairielearn_grader-0.2.0/src/pytest_prairielearn_grader/json_utils.py +158 -0
  20. pytest_prairielearn_grader-0.2.0/src/pytest_prairielearn_grader/plugin.py +733 -0
  21. pytest_prairielearn_grader-0.2.0/src/pytest_prairielearn_grader/py.typed +0 -0
  22. pytest_prairielearn_grader-0.2.0/src/pytest_prairielearn_grader/utils.py +325 -0
  23. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_core_exception/__init__.py +0 -0
  24. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_core_exception/expected_outcome.json +51 -0
  25. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_core_exception/scenario.py +10 -0
  26. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_core_exception/student_code_custom_class.py +6 -0
  27. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_core_exception/student_code_exception.py +2 -0
  28. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_core_exception/student_code_exit.py +1 -0
  29. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_core_exception/student_code_timeout.py +4 -0
  30. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_core_exception/student_code_wrong_var.py +1 -0
  31. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_fixture_exception/__init__.py +1 -0
  32. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_fixture_exception/expected_outcome.json +16 -0
  33. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_fixture_exception/scenario.py +23 -0
  34. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_fixture_exception/student_code.py +2 -0
  35. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_function_name/__init__.py +0 -0
  36. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_function_name/data.json +22 -0
  37. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_function_name/expected_outcome.json +55 -0
  38. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_function_name/func_name_code_bad_import.py +3 -0
  39. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_function_name/func_name_code_inner_bad_import.py +4 -0
  40. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_function_name/func_name_code_student.py +3 -0
  41. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_function_name/scenario.py +50 -0
  42. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_initialization_timeout/__init__.py +1 -0
  43. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_initialization_timeout/expected_outcome.json +34 -0
  44. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_initialization_timeout/scenario.py +29 -0
  45. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_initialization_timeout/student_code.py +10 -0
  46. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_matplotlib_plots/__init__.py +1 -0
  47. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_matplotlib_plots/expected_outcome.json +61 -0
  48. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_matplotlib_plots/scenario.py +138 -0
  49. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_matplotlib_plots/student_code.py +70 -0
  50. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_module_sandbox/__init__.py +0 -0
  51. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_module_sandbox/data.json +46 -0
  52. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_module_sandbox/expected_outcome.json +79 -0
  53. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_module_sandbox/scenario.py +96 -0
  54. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_module_sandbox/setup_code.py +19 -0
  55. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_module_sandbox/student_code.py +56 -0
  56. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_private_namespace/__init__.py +0 -0
  57. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_private_namespace/data.json +39 -0
  58. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_private_namespace/expected_outcome.json +43 -0
  59. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_private_namespace/scenario.py +22 -0
  60. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_private_namespace/setup_code.py +13 -0
  61. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_private_namespace/student_code.py +5 -0
  62. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_private_namespace/student_code_fail.py +1 -0
  63. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_private_namespace/student_code_fail_other.py +1 -0
  64. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_private_namespace/student_code_fail_read_builtin.py +1 -0
  65. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_privilege_drop/__init__.py +1 -0
  66. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_privilege_drop/expected_outcome.json +43 -0
  67. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_privilege_drop/scenario.py +67 -0
  68. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_privilege_drop/student_code.py +9 -0
  69. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_1/__init__.py +0 -0
  70. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_1/data.json +8 -0
  71. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_1/expected_outcome.json +97 -0
  72. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_1/scenario.py +86 -0
  73. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_1/setup_code.py +7 -0
  74. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_1/student_code_0.py +1 -0
  75. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_1/student_code_0_bad_func.py +6 -0
  76. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_1/student_code_100.py +9 -0
  77. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_1/student_code_33.py +9 -0
  78. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_1/student_code_66.py +9 -0
  79. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_2/__init__.py +0 -0
  80. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_2/data.json +38 -0
  81. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_2/expected_outcome.json +88 -0
  82. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_2/scenario.py +163 -0
  83. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_2/student_code_0.py +20 -0
  84. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_2/student_code_100.py +20 -0
  85. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_2_2/student_code_20.py +2 -0
  86. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_1/__init__.py +0 -0
  87. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_1/data.json +5 -0
  88. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_1/expected_outcome.json +88 -0
  89. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_1/scenario.py +103 -0
  90. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_1/student_code_0.py +25 -0
  91. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_1/student_code_100.py +18 -0
  92. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_1/student_code_66.py +18 -0
  93. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_2/__init__.py +0 -0
  94. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_2/expected_outcome.json +79 -0
  95. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_2/scenario.py +159 -0
  96. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_2/student_code_0.py +14 -0
  97. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_2/student_code_0_no_function.py +10 -0
  98. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_2/student_code_100.py +15 -0
  99. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_quiz_3_2/student_code_50.py +15 -0
  100. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_sandbox/__init__.py +0 -0
  101. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_sandbox/data.json +3 -0
  102. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_sandbox/expected_outcome.json +79 -0
  103. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_sandbox/leading_code.py +0 -0
  104. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_sandbox/scenario.py +122 -0
  105. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_sandbox/student_code.py +19 -0
  106. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_sandbox/student_code_other.py +13 -0
  107. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_sandbox/trailing_code.py +0 -0
  108. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_stdout_feature/__init__.py +0 -0
  109. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_stdout_feature/expected_outcome.json +71 -0
  110. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_stdout_feature/scenario.py +76 -0
  111. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_stdout_feature/student_code.py +4 -0
  112. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_stdout_feature/student_code_with_global_prints.py +20 -0
  113. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_timeout/__init__.py +0 -0
  114. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_timeout/expected_outcome.json +52 -0
  115. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_timeout/scenario.py +47 -0
  116. pytest_prairielearn_grader-0.2.0/tests/scenario_root/test_timeout/student_code.py +17 -0
  117. pytest_prairielearn_grader-0.2.0/tests/test_autograder_scenarios.py +146 -0
  118. pytest_prairielearn_grader-0.2.0/tests/test_serialize.py +90 -0
  119. 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,8 @@
1
+ {
2
+ "recommendations": [
3
+ "charliermarsh.ruff",
4
+ "tamasfe.even-better-toml",
5
+ "ms-python.python",
6
+ "ms-python.vscode-pylance"
7
+ ]
8
+ }
@@ -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,3 @@
1
+ # pytest-prairielearn-grader
2
+
3
+ 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"