pl-e2e-test 0.1.5__tar.gz → 0.1.7__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.
- {pl_e2e_test-0.1.5/src/pl_e2e_test.egg-info → pl_e2e_test-0.1.7}/PKG-INFO +82 -5
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/README.md +81 -4
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/pyproject.toml +1 -1
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7/src/pl_e2e_test.egg-info}/PKG-INFO +82 -5
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/contract.py +102 -20
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/harness.py +59 -7
- pl_e2e_test-0.1.7/tests/test_contract.py +214 -0
- pl_e2e_test-0.1.7/tests/test_harness.py +384 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_publish_version.py +35 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_server.py +17 -0
- pl_e2e_test-0.1.5/tests/test_contract.py +0 -93
- pl_e2e_test-0.1.5/tests/test_harness.py +0 -100
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/LICENSE +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/MANIFEST.in +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/setup.cfg +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/pl_e2e_test.egg-info/SOURCES.txt +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/pl_e2e_test.egg-info/dependency_links.txt +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/pl_e2e_test.egg-info/entry_points.txt +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/pl_e2e_test.egg-info/requires.txt +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/pl_e2e_test.egg-info/top_level.txt +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/__init__.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/__main__.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/_git.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/backend.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/cli.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/config.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/discovery.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/errors.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/expected_errors.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/fuzz.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/html_checks.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/metadata.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/parallel.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/pytest_plugin.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/question_tests.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/regression.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/reporting.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/repository.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/runner.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/server.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/src/prairielearn_e2e/sharding.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/__init__.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/conftest.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/stub_git.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_cli.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_config.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_discovery.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_fuzz.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_git.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_html_checks.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_metadata.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_parallel.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_pytest_plugin.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_question_tests.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_regression.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_reporting.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_repository.py +0 -0
- {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.7}/tests/test_sharding.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pl-e2e-test
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: Fuzz PrairieLearn question lifecycles against the real Python element runtime
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/PrairieLearn/pl-e2e-test
|
|
@@ -266,14 +266,90 @@ nonGradableSelectors = ['pl-sketch[read-only="true"]', 'pl-sketch[read-only="1"]
|
|
|
266
266
|
```
|
|
267
267
|
|
|
268
268
|
These are the defaults. `elementSolutionSelectors` identifies elements that provide
|
|
269
|
-
solutions without a `correct_answers` entry.
|
|
270
|
-
|
|
271
|
-
|
|
269
|
+
solutions without a `correct_answers` entry. It only relaxes that entry requirement;
|
|
270
|
+
it does not generate a test submission. `nonGradableSelectors` excludes display-only
|
|
271
|
+
answers from grading checks. Each supplied list replaces its default; omitted settings
|
|
272
|
+
keep their defaults, and `[]` disables a list.
|
|
272
273
|
|
|
273
274
|
Use `--config path/to/settings.toml` to select another TOML file with the same
|
|
274
275
|
`[tool.pl-e2e-test]` table. This works with normal runs, `diff`, and arguments after
|
|
275
276
|
`parallel run` or `parallel diff`. Explicit paths are relative to the working directory.
|
|
276
277
|
|
|
278
|
+
### Lifecycle JSON boundary
|
|
279
|
+
|
|
280
|
+
PrairieLearn crosses a JSON boundary after processing the elements in a lifecycle phase
|
|
281
|
+
and again after calling the question's `server.py` hook. The harness mirrors those
|
|
282
|
+
boundaries. It first checks the in-process result for legal field mutations, then
|
|
283
|
+
serializes and deserializes `data`; the normalized result is what the next hook or phase
|
|
284
|
+
receives.
|
|
285
|
+
|
|
286
|
+
This accepts the same Python values as PrairieLearn's JSON encoder rather than requiring
|
|
287
|
+
literal JSON-domain Python types. For example, tuples normalize to arrays (Python lists),
|
|
288
|
+
and supported non-string object keys such as numeric drawing IDs normalize to strings.
|
|
289
|
+
This conversion can be lossy: if two keys normalize to the same string, the last key in
|
|
290
|
+
serialized order wins, matching PrairieLearn's boundary. Unsupported objects, non-finite
|
|
291
|
+
floats, and integers outside JavaScript's safe range are rejected with the failing data
|
|
292
|
+
path.
|
|
293
|
+
|
|
294
|
+
### Correct submissions for custom elements
|
|
295
|
+
|
|
296
|
+
The grade check uses PrairieLearn's `test` lifecycle to obtain a deterministic correct
|
|
297
|
+
submission. PrairieLearn first calls `test(element_html, data)` on the elements in the
|
|
298
|
+
question, then the harness calls `test(data)` in the question's `server.py`. A reusable
|
|
299
|
+
course element should normally implement the element hook. The question hook can fill
|
|
300
|
+
in a submission when the correct state is question-specific or when a custom element
|
|
301
|
+
does not provide one itself.
|
|
302
|
+
|
|
303
|
+
For every gradable `answers-name`, the correct test case must provide:
|
|
304
|
+
|
|
305
|
+
- a non-null value in `data["raw_submitted_answers"]` under the answer name, or one or
|
|
306
|
+
more compound input keys prefixed with `answers-name-`; and
|
|
307
|
+
- `data["partial_scores"][answers_name]["score"] = 1`.
|
|
308
|
+
|
|
309
|
+
Raw values must survive PrairieLearn's JSON boundary and have the same browser transport
|
|
310
|
+
shape that the element's `parse` hook expects. Structured widgets commonly serialize
|
|
311
|
+
their state as a JSON string. The hook must derive its output only from deterministic
|
|
312
|
+
variant state such as `params` and `correct_answers`, mutate `data` in place, and return
|
|
313
|
+
`None`. The normal phase mutation checks and JSON-boundary normalization still apply.
|
|
314
|
+
|
|
315
|
+
For example, a question can supply the correct state for a drag-and-drop element with:
|
|
316
|
+
|
|
317
|
+
```python
|
|
318
|
+
import json
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def test(data):
|
|
322
|
+
if data["test_type"] != "correct":
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
answers_name = "ordering"
|
|
326
|
+
state = {
|
|
327
|
+
"solution": data["params"]["correct_order"],
|
|
328
|
+
"starter": [],
|
|
329
|
+
}
|
|
330
|
+
data["raw_submitted_answers"][answers_name] = json.dumps(state)
|
|
331
|
+
data["partial_scores"][answers_name] = {"score": 1}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Compound elements keep the declared answer name as their grading identity while using
|
|
335
|
+
namespaced child fields for browser inputs. For example, an element with
|
|
336
|
+
`answers-name="match"` may test and parse `match-dropdown-0` and `match-dropdown-1`,
|
|
337
|
+
then store its score in `partial_scores["match"]`; it does not need to invent a
|
|
338
|
+
`submitted_answers["match"]` value. After parsing, every raw field associated with a
|
|
339
|
+
declared gradable answer must either remain non-null under its child key or produce a
|
|
340
|
+
non-null value under its declared parent answer. This also supports elements that
|
|
341
|
+
normalize `answer-input` into `answer`. Unassociated transport fields from file-producing
|
|
342
|
+
elements may instead be consumed into `submitted_answers["_files"]`; they are not answer
|
|
343
|
+
parse-coverage obligations.
|
|
344
|
+
|
|
345
|
+
This contract keeps element-specific synthesis with the element or question that owns
|
|
346
|
+
the data. PrairieLearn's tuple-based `pl-matching` state normalizes to arrays at the JSON
|
|
347
|
+
boundary, and its existing namespaced test fields require no harness adapter. A question
|
|
348
|
+
using `pl-sketch` without an element-owned solution can supply its deterministic
|
|
349
|
+
`answers-name-sketchresponse-submission` payload in `server.py`. A course-owned
|
|
350
|
+
drag-and-drop element such as `pl-faded-parsons` can implement one reusable element
|
|
351
|
+
test hook that serializes its prepared correct tray and blank state.
|
|
352
|
+
|
|
277
353
|
### Question-local configuration
|
|
278
354
|
|
|
279
355
|
Store saved concrete seeds, narrow phase skips, and expected upstream errors in
|
|
@@ -407,7 +483,8 @@ environment. From a clean branch, run:
|
|
|
407
483
|
make publish-version VERSION=0.2.0
|
|
408
484
|
```
|
|
409
485
|
|
|
410
|
-
This
|
|
486
|
+
This installs Python 3.13, performs the same locked dependency sync used in CI, runs
|
|
487
|
+
the full checks, updates `pyproject.toml` and `uv.lock`, builds and smoke-tests the
|
|
411
488
|
distributions, creates a release commit and annotated `v0.2.0` tag, and atomically
|
|
412
489
|
pushes the branch and tag. The tag starts the workflow on `ubuntu-latest`; the workflow
|
|
413
490
|
rechecks the tag/version match, tests, builds, generates attestations, and publishes
|
|
@@ -242,14 +242,90 @@ nonGradableSelectors = ['pl-sketch[read-only="true"]', 'pl-sketch[read-only="1"]
|
|
|
242
242
|
```
|
|
243
243
|
|
|
244
244
|
These are the defaults. `elementSolutionSelectors` identifies elements that provide
|
|
245
|
-
solutions without a `correct_answers` entry.
|
|
246
|
-
|
|
247
|
-
|
|
245
|
+
solutions without a `correct_answers` entry. It only relaxes that entry requirement;
|
|
246
|
+
it does not generate a test submission. `nonGradableSelectors` excludes display-only
|
|
247
|
+
answers from grading checks. Each supplied list replaces its default; omitted settings
|
|
248
|
+
keep their defaults, and `[]` disables a list.
|
|
248
249
|
|
|
249
250
|
Use `--config path/to/settings.toml` to select another TOML file with the same
|
|
250
251
|
`[tool.pl-e2e-test]` table. This works with normal runs, `diff`, and arguments after
|
|
251
252
|
`parallel run` or `parallel diff`. Explicit paths are relative to the working directory.
|
|
252
253
|
|
|
254
|
+
### Lifecycle JSON boundary
|
|
255
|
+
|
|
256
|
+
PrairieLearn crosses a JSON boundary after processing the elements in a lifecycle phase
|
|
257
|
+
and again after calling the question's `server.py` hook. The harness mirrors those
|
|
258
|
+
boundaries. It first checks the in-process result for legal field mutations, then
|
|
259
|
+
serializes and deserializes `data`; the normalized result is what the next hook or phase
|
|
260
|
+
receives.
|
|
261
|
+
|
|
262
|
+
This accepts the same Python values as PrairieLearn's JSON encoder rather than requiring
|
|
263
|
+
literal JSON-domain Python types. For example, tuples normalize to arrays (Python lists),
|
|
264
|
+
and supported non-string object keys such as numeric drawing IDs normalize to strings.
|
|
265
|
+
This conversion can be lossy: if two keys normalize to the same string, the last key in
|
|
266
|
+
serialized order wins, matching PrairieLearn's boundary. Unsupported objects, non-finite
|
|
267
|
+
floats, and integers outside JavaScript's safe range are rejected with the failing data
|
|
268
|
+
path.
|
|
269
|
+
|
|
270
|
+
### Correct submissions for custom elements
|
|
271
|
+
|
|
272
|
+
The grade check uses PrairieLearn's `test` lifecycle to obtain a deterministic correct
|
|
273
|
+
submission. PrairieLearn first calls `test(element_html, data)` on the elements in the
|
|
274
|
+
question, then the harness calls `test(data)` in the question's `server.py`. A reusable
|
|
275
|
+
course element should normally implement the element hook. The question hook can fill
|
|
276
|
+
in a submission when the correct state is question-specific or when a custom element
|
|
277
|
+
does not provide one itself.
|
|
278
|
+
|
|
279
|
+
For every gradable `answers-name`, the correct test case must provide:
|
|
280
|
+
|
|
281
|
+
- a non-null value in `data["raw_submitted_answers"]` under the answer name, or one or
|
|
282
|
+
more compound input keys prefixed with `answers-name-`; and
|
|
283
|
+
- `data["partial_scores"][answers_name]["score"] = 1`.
|
|
284
|
+
|
|
285
|
+
Raw values must survive PrairieLearn's JSON boundary and have the same browser transport
|
|
286
|
+
shape that the element's `parse` hook expects. Structured widgets commonly serialize
|
|
287
|
+
their state as a JSON string. The hook must derive its output only from deterministic
|
|
288
|
+
variant state such as `params` and `correct_answers`, mutate `data` in place, and return
|
|
289
|
+
`None`. The normal phase mutation checks and JSON-boundary normalization still apply.
|
|
290
|
+
|
|
291
|
+
For example, a question can supply the correct state for a drag-and-drop element with:
|
|
292
|
+
|
|
293
|
+
```python
|
|
294
|
+
import json
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test(data):
|
|
298
|
+
if data["test_type"] != "correct":
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
answers_name = "ordering"
|
|
302
|
+
state = {
|
|
303
|
+
"solution": data["params"]["correct_order"],
|
|
304
|
+
"starter": [],
|
|
305
|
+
}
|
|
306
|
+
data["raw_submitted_answers"][answers_name] = json.dumps(state)
|
|
307
|
+
data["partial_scores"][answers_name] = {"score": 1}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Compound elements keep the declared answer name as their grading identity while using
|
|
311
|
+
namespaced child fields for browser inputs. For example, an element with
|
|
312
|
+
`answers-name="match"` may test and parse `match-dropdown-0` and `match-dropdown-1`,
|
|
313
|
+
then store its score in `partial_scores["match"]`; it does not need to invent a
|
|
314
|
+
`submitted_answers["match"]` value. After parsing, every raw field associated with a
|
|
315
|
+
declared gradable answer must either remain non-null under its child key or produce a
|
|
316
|
+
non-null value under its declared parent answer. This also supports elements that
|
|
317
|
+
normalize `answer-input` into `answer`. Unassociated transport fields from file-producing
|
|
318
|
+
elements may instead be consumed into `submitted_answers["_files"]`; they are not answer
|
|
319
|
+
parse-coverage obligations.
|
|
320
|
+
|
|
321
|
+
This contract keeps element-specific synthesis with the element or question that owns
|
|
322
|
+
the data. PrairieLearn's tuple-based `pl-matching` state normalizes to arrays at the JSON
|
|
323
|
+
boundary, and its existing namespaced test fields require no harness adapter. A question
|
|
324
|
+
using `pl-sketch` without an element-owned solution can supply its deterministic
|
|
325
|
+
`answers-name-sketchresponse-submission` payload in `server.py`. A course-owned
|
|
326
|
+
drag-and-drop element such as `pl-faded-parsons` can implement one reusable element
|
|
327
|
+
test hook that serializes its prepared correct tray and blank state.
|
|
328
|
+
|
|
253
329
|
### Question-local configuration
|
|
254
330
|
|
|
255
331
|
Store saved concrete seeds, narrow phase skips, and expected upstream errors in
|
|
@@ -383,7 +459,8 @@ environment. From a clean branch, run:
|
|
|
383
459
|
make publish-version VERSION=0.2.0
|
|
384
460
|
```
|
|
385
461
|
|
|
386
|
-
This
|
|
462
|
+
This installs Python 3.13, performs the same locked dependency sync used in CI, runs
|
|
463
|
+
the full checks, updates `pyproject.toml` and `uv.lock`, builds and smoke-tests the
|
|
387
464
|
distributions, creates a release commit and annotated `v0.2.0` tag, and atomically
|
|
388
465
|
pushes the branch and tag. The tag starts the workflow on `ubuntu-latest`; the workflow
|
|
389
466
|
rechecks the tag/version match, tests, builds, generates attestations, and publishes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pl-e2e-test
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: Fuzz PrairieLearn question lifecycles against the real Python element runtime
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/PrairieLearn/pl-e2e-test
|
|
@@ -266,14 +266,90 @@ nonGradableSelectors = ['pl-sketch[read-only="true"]', 'pl-sketch[read-only="1"]
|
|
|
266
266
|
```
|
|
267
267
|
|
|
268
268
|
These are the defaults. `elementSolutionSelectors` identifies elements that provide
|
|
269
|
-
solutions without a `correct_answers` entry.
|
|
270
|
-
|
|
271
|
-
|
|
269
|
+
solutions without a `correct_answers` entry. It only relaxes that entry requirement;
|
|
270
|
+
it does not generate a test submission. `nonGradableSelectors` excludes display-only
|
|
271
|
+
answers from grading checks. Each supplied list replaces its default; omitted settings
|
|
272
|
+
keep their defaults, and `[]` disables a list.
|
|
272
273
|
|
|
273
274
|
Use `--config path/to/settings.toml` to select another TOML file with the same
|
|
274
275
|
`[tool.pl-e2e-test]` table. This works with normal runs, `diff`, and arguments after
|
|
275
276
|
`parallel run` or `parallel diff`. Explicit paths are relative to the working directory.
|
|
276
277
|
|
|
278
|
+
### Lifecycle JSON boundary
|
|
279
|
+
|
|
280
|
+
PrairieLearn crosses a JSON boundary after processing the elements in a lifecycle phase
|
|
281
|
+
and again after calling the question's `server.py` hook. The harness mirrors those
|
|
282
|
+
boundaries. It first checks the in-process result for legal field mutations, then
|
|
283
|
+
serializes and deserializes `data`; the normalized result is what the next hook or phase
|
|
284
|
+
receives.
|
|
285
|
+
|
|
286
|
+
This accepts the same Python values as PrairieLearn's JSON encoder rather than requiring
|
|
287
|
+
literal JSON-domain Python types. For example, tuples normalize to arrays (Python lists),
|
|
288
|
+
and supported non-string object keys such as numeric drawing IDs normalize to strings.
|
|
289
|
+
This conversion can be lossy: if two keys normalize to the same string, the last key in
|
|
290
|
+
serialized order wins, matching PrairieLearn's boundary. Unsupported objects, non-finite
|
|
291
|
+
floats, and integers outside JavaScript's safe range are rejected with the failing data
|
|
292
|
+
path.
|
|
293
|
+
|
|
294
|
+
### Correct submissions for custom elements
|
|
295
|
+
|
|
296
|
+
The grade check uses PrairieLearn's `test` lifecycle to obtain a deterministic correct
|
|
297
|
+
submission. PrairieLearn first calls `test(element_html, data)` on the elements in the
|
|
298
|
+
question, then the harness calls `test(data)` in the question's `server.py`. A reusable
|
|
299
|
+
course element should normally implement the element hook. The question hook can fill
|
|
300
|
+
in a submission when the correct state is question-specific or when a custom element
|
|
301
|
+
does not provide one itself.
|
|
302
|
+
|
|
303
|
+
For every gradable `answers-name`, the correct test case must provide:
|
|
304
|
+
|
|
305
|
+
- a non-null value in `data["raw_submitted_answers"]` under the answer name, or one or
|
|
306
|
+
more compound input keys prefixed with `answers-name-`; and
|
|
307
|
+
- `data["partial_scores"][answers_name]["score"] = 1`.
|
|
308
|
+
|
|
309
|
+
Raw values must survive PrairieLearn's JSON boundary and have the same browser transport
|
|
310
|
+
shape that the element's `parse` hook expects. Structured widgets commonly serialize
|
|
311
|
+
their state as a JSON string. The hook must derive its output only from deterministic
|
|
312
|
+
variant state such as `params` and `correct_answers`, mutate `data` in place, and return
|
|
313
|
+
`None`. The normal phase mutation checks and JSON-boundary normalization still apply.
|
|
314
|
+
|
|
315
|
+
For example, a question can supply the correct state for a drag-and-drop element with:
|
|
316
|
+
|
|
317
|
+
```python
|
|
318
|
+
import json
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def test(data):
|
|
322
|
+
if data["test_type"] != "correct":
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
answers_name = "ordering"
|
|
326
|
+
state = {
|
|
327
|
+
"solution": data["params"]["correct_order"],
|
|
328
|
+
"starter": [],
|
|
329
|
+
}
|
|
330
|
+
data["raw_submitted_answers"][answers_name] = json.dumps(state)
|
|
331
|
+
data["partial_scores"][answers_name] = {"score": 1}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Compound elements keep the declared answer name as their grading identity while using
|
|
335
|
+
namespaced child fields for browser inputs. For example, an element with
|
|
336
|
+
`answers-name="match"` may test and parse `match-dropdown-0` and `match-dropdown-1`,
|
|
337
|
+
then store its score in `partial_scores["match"]`; it does not need to invent a
|
|
338
|
+
`submitted_answers["match"]` value. After parsing, every raw field associated with a
|
|
339
|
+
declared gradable answer must either remain non-null under its child key or produce a
|
|
340
|
+
non-null value under its declared parent answer. This also supports elements that
|
|
341
|
+
normalize `answer-input` into `answer`. Unassociated transport fields from file-producing
|
|
342
|
+
elements may instead be consumed into `submitted_answers["_files"]`; they are not answer
|
|
343
|
+
parse-coverage obligations.
|
|
344
|
+
|
|
345
|
+
This contract keeps element-specific synthesis with the element or question that owns
|
|
346
|
+
the data. PrairieLearn's tuple-based `pl-matching` state normalizes to arrays at the JSON
|
|
347
|
+
boundary, and its existing namespaced test fields require no harness adapter. A question
|
|
348
|
+
using `pl-sketch` without an element-owned solution can supply its deterministic
|
|
349
|
+
`answers-name-sketchresponse-submission` payload in `server.py`. A course-owned
|
|
350
|
+
drag-and-drop element such as `pl-faded-parsons` can implement one reusable element
|
|
351
|
+
test hook that serializes its prepared correct tray and blank state.
|
|
352
|
+
|
|
277
353
|
### Question-local configuration
|
|
278
354
|
|
|
279
355
|
Store saved concrete seeds, narrow phase skips, and expected upstream errors in
|
|
@@ -407,7 +483,8 @@ environment. From a clean branch, run:
|
|
|
407
483
|
make publish-version VERSION=0.2.0
|
|
408
484
|
```
|
|
409
485
|
|
|
410
|
-
This
|
|
486
|
+
This installs Python 3.13, performs the same locked dependency sync used in CI, runs
|
|
487
|
+
the full checks, updates `pyproject.toml` and `uv.lock`, builds and smoke-tests the
|
|
411
488
|
distributions, creates a release commit and annotated `v0.2.0` tag, and atomically
|
|
412
489
|
pushes the branch and tag. The tag starts the workflow on `ubuntu-latest`; the workflow
|
|
413
490
|
rechecks the tag/version match, tests, builds, generates attestations, and publishes
|
|
@@ -164,35 +164,117 @@ def validate_mutations(
|
|
|
164
164
|
raise HarnessError(f"{phase}: data[{name!r}] was illegally modified")
|
|
165
165
|
|
|
166
166
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
167
|
+
_JSON_ERRORS = (TypeError, ValueError, OverflowError, RecursionError)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _validate_integer_limits(
|
|
171
|
+
value: Any, path: str, active_containers: set[int]
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Apply PrairieLearn's integer limit to every JSON-supported container."""
|
|
174
|
+
|
|
175
|
+
if isinstance(value, int):
|
|
176
|
+
try:
|
|
177
|
+
assert_all_integers_within_limits(value)
|
|
178
|
+
except ValueError as exc:
|
|
179
|
+
raise ValueError(f"{path} contains oversized integer: {value}") from exc
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
if not isinstance(value, (dict, list, tuple)):
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
container_id = id(value)
|
|
186
|
+
if container_id in active_containers:
|
|
187
|
+
return
|
|
188
|
+
active_containers.add(container_id)
|
|
189
|
+
try:
|
|
190
|
+
if isinstance(value, dict):
|
|
191
|
+
for key, item in value.items():
|
|
192
|
+
if isinstance(key, int):
|
|
193
|
+
_validate_integer_limits(
|
|
194
|
+
key, f"{path} object key {key!r}", active_containers
|
|
195
|
+
)
|
|
196
|
+
_validate_integer_limits(item, f"{path}[{key!r}]", active_containers)
|
|
197
|
+
else:
|
|
176
198
|
for index, item in enumerate(value):
|
|
177
|
-
|
|
178
|
-
|
|
199
|
+
_validate_integer_limits(item, f"{path}[{index}]", active_containers)
|
|
200
|
+
finally:
|
|
201
|
+
active_containers.remove(container_id)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _json_fails(value: Any) -> bool:
|
|
205
|
+
try:
|
|
206
|
+
json.dumps(value, allow_nan=False)
|
|
207
|
+
except _JSON_ERRORS:
|
|
208
|
+
return True
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _json_failure_path(value: Any, path: str, active_containers: set[int]) -> str:
|
|
213
|
+
"""Locate an encoder failure after json.dumps has already rejected the value."""
|
|
214
|
+
|
|
215
|
+
if not isinstance(value, (dict, list, tuple)):
|
|
216
|
+
return path
|
|
217
|
+
|
|
218
|
+
container_id = id(value)
|
|
219
|
+
if container_id in active_containers:
|
|
220
|
+
return path
|
|
221
|
+
active_containers.add(container_id)
|
|
222
|
+
try:
|
|
179
223
|
if isinstance(value, dict):
|
|
180
224
|
for key, item in value.items():
|
|
181
|
-
if
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
225
|
+
if _json_fails({key: None}):
|
|
226
|
+
return f"{path} object key {key!r}"
|
|
227
|
+
if _json_fails(item):
|
|
228
|
+
return _json_failure_path(
|
|
229
|
+
item, f"{path}[{key!r}]", active_containers
|
|
230
|
+
)
|
|
231
|
+
else:
|
|
232
|
+
for index, item in enumerate(value):
|
|
233
|
+
if _json_fails(item):
|
|
234
|
+
return _json_failure_path(
|
|
235
|
+
item, f"{path}[{index}]", active_containers
|
|
236
|
+
)
|
|
237
|
+
finally:
|
|
238
|
+
active_containers.remove(container_id)
|
|
239
|
+
return path
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def validate_serializable(data: dict[str, Any], phase: Phase) -> None:
|
|
243
|
+
"""Validate and normalize data at PrairieLearn's Python-to-JSON boundary."""
|
|
186
244
|
|
|
187
245
|
try:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
json.dumps(data, allow_nan=False)
|
|
191
|
-
except (TypeError, ValueError) as exc:
|
|
246
|
+
_validate_integer_limits(data, "data", set())
|
|
247
|
+
except ValueError as exc:
|
|
192
248
|
raise HarnessError(
|
|
193
249
|
f"{phase}: data is not PrairieLearn JSON serializable: {exc}"
|
|
194
250
|
) from exc
|
|
195
251
|
|
|
252
|
+
try:
|
|
253
|
+
encoded = json.dumps(data, allow_nan=False)
|
|
254
|
+
except _JSON_ERRORS as exc:
|
|
255
|
+
try:
|
|
256
|
+
path = _json_failure_path(data, "data", set())
|
|
257
|
+
except RecursionError:
|
|
258
|
+
path = "data"
|
|
259
|
+
raise HarnessError(
|
|
260
|
+
f"{phase}: data is not PrairieLearn JSON serializable: {path}: {exc}"
|
|
261
|
+
) from exc
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
normalized = json.loads(encoded)
|
|
265
|
+
except _JSON_ERRORS as exc:
|
|
266
|
+
raise HarnessError(
|
|
267
|
+
f"{phase}: data is not PrairieLearn JSON serializable: data: {exc}"
|
|
268
|
+
) from exc
|
|
269
|
+
if not isinstance(normalized, dict):
|
|
270
|
+
raise HarnessError(
|
|
271
|
+
f"{phase}: data is not PrairieLearn JSON serializable: "
|
|
272
|
+
"data did not normalize to an object"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
data.clear()
|
|
276
|
+
data.update(normalized)
|
|
277
|
+
|
|
196
278
|
|
|
197
279
|
def snapshot(data: dict[str, Any], phase: Phase) -> dict[str, Any]:
|
|
198
280
|
validate_shape(data, phase)
|
|
@@ -68,6 +68,52 @@ def _aggregate_score(data: dict[str, Any], partial_credit: bool) -> None:
|
|
|
68
68
|
)
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
def _associated_answer_name(raw_name: str, answer_names: tuple[str, ...]) -> str | None:
|
|
72
|
+
if raw_name in answer_names:
|
|
73
|
+
return raw_name
|
|
74
|
+
return max(
|
|
75
|
+
(
|
|
76
|
+
answer_name
|
|
77
|
+
for answer_name in answer_names
|
|
78
|
+
if raw_name.startswith(f"{answer_name}-")
|
|
79
|
+
),
|
|
80
|
+
key=len,
|
|
81
|
+
default=None,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _assert_test_submission_coverage(
|
|
86
|
+
data: dict[str, Any], answer_names: tuple[str, ...]
|
|
87
|
+
) -> None:
|
|
88
|
+
raw_submitted_answers = data["raw_submitted_answers"]
|
|
89
|
+
missing: list[str] = []
|
|
90
|
+
for name in answer_names:
|
|
91
|
+
has_raw_submission = any(
|
|
92
|
+
_associated_answer_name(raw_name, answer_names) == name
|
|
93
|
+
and raw_value is not None
|
|
94
|
+
for raw_name, raw_value in raw_submitted_answers.items()
|
|
95
|
+
)
|
|
96
|
+
partial_score = data["partial_scores"].get(name)
|
|
97
|
+
score = partial_score.get("score") if isinstance(partial_score, dict) else None
|
|
98
|
+
has_full_partial_score = (
|
|
99
|
+
isinstance(score, (int, float))
|
|
100
|
+
and not isinstance(score, bool)
|
|
101
|
+
and float(score) == 1
|
|
102
|
+
)
|
|
103
|
+
if not has_raw_submission or not has_full_partial_score:
|
|
104
|
+
missing.append(name)
|
|
105
|
+
|
|
106
|
+
if missing:
|
|
107
|
+
raise HarnessError(
|
|
108
|
+
"correct test phase did not provide complete submissions for: "
|
|
109
|
+
+ ", ".join(missing)
|
|
110
|
+
+ ". Implement test(element_html, data) in the owning element or "
|
|
111
|
+
"test(data) in question server.py; populate raw_submitted_answers "
|
|
112
|
+
"with the answer name or namespaced child keys and set "
|
|
113
|
+
"partial_scores[answer_name].score to 1"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
71
117
|
def _assert_full_credit(data: dict[str, Any], answer_names: tuple[str, ...]) -> None:
|
|
72
118
|
if data["format_errors"]:
|
|
73
119
|
raise HarnessError(
|
|
@@ -79,15 +125,20 @@ def _assert_full_credit(data: dict[str, Any], answer_names: tuple[str, ...]) ->
|
|
|
79
125
|
raise HarnessError(
|
|
80
126
|
f"correct submission received score {data['score']!r}, expected 1"
|
|
81
127
|
)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
128
|
+
submitted_answers = data["submitted_answers"]
|
|
129
|
+
unparsed: list[str] = []
|
|
130
|
+
for raw_name in data["raw_submitted_answers"]:
|
|
131
|
+
parent_name = _associated_answer_name(raw_name, answer_names)
|
|
132
|
+
if parent_name is None:
|
|
133
|
+
continue
|
|
134
|
+
if (
|
|
135
|
+
submitted_answers.get(raw_name) is None
|
|
136
|
+
and submitted_answers.get(parent_name) is None
|
|
137
|
+
):
|
|
138
|
+
unparsed.append(raw_name)
|
|
88
139
|
if unparsed:
|
|
89
140
|
raise HarnessError(
|
|
90
|
-
"
|
|
141
|
+
"raw submission fields did not parse successfully: " + ", ".join(unparsed)
|
|
91
142
|
)
|
|
92
143
|
missing = [name for name in answer_names if name not in data["partial_scores"]]
|
|
93
144
|
if missing:
|
|
@@ -359,6 +410,7 @@ class QuestionHarness:
|
|
|
359
410
|
test = self._phase_data("test", base, test_type="correct")
|
|
360
411
|
self._process("test", source, test, server)
|
|
361
412
|
_aggregate_score(test, metadata.get("partialCredit", True))
|
|
413
|
+
_assert_test_submission_coverage(test, tested_answer_names)
|
|
362
414
|
|
|
363
415
|
parse = self._phase_data(
|
|
364
416
|
"parse",
|