pl-e2e-test 0.1.5__tar.gz → 0.1.6__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 (58) hide show
  1. {pl_e2e_test-0.1.5/src/pl_e2e_test.egg-info → pl_e2e_test-0.1.6}/PKG-INFO +77 -4
  2. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/README.md +76 -3
  3. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/pyproject.toml +1 -1
  4. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6/src/pl_e2e_test.egg-info}/PKG-INFO +77 -4
  5. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/contract.py +102 -20
  6. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/harness.py +59 -7
  7. pl_e2e_test-0.1.6/tests/test_contract.py +214 -0
  8. pl_e2e_test-0.1.6/tests/test_harness.py +321 -0
  9. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/tests/test_server.py +17 -0
  10. pl_e2e_test-0.1.5/tests/test_contract.py +0 -93
  11. pl_e2e_test-0.1.5/tests/test_harness.py +0 -100
  12. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/LICENSE +0 -0
  13. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/MANIFEST.in +0 -0
  14. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/setup.cfg +0 -0
  15. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/pl_e2e_test.egg-info/SOURCES.txt +0 -0
  16. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/pl_e2e_test.egg-info/dependency_links.txt +0 -0
  17. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/pl_e2e_test.egg-info/entry_points.txt +0 -0
  18. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/pl_e2e_test.egg-info/requires.txt +0 -0
  19. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/pl_e2e_test.egg-info/top_level.txt +0 -0
  20. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/__init__.py +0 -0
  21. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/__main__.py +0 -0
  22. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/_git.py +0 -0
  23. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/backend.py +0 -0
  24. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/cli.py +0 -0
  25. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/config.py +0 -0
  26. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/discovery.py +0 -0
  27. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/errors.py +0 -0
  28. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/expected_errors.py +0 -0
  29. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/fuzz.py +0 -0
  30. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/html_checks.py +0 -0
  31. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/metadata.py +0 -0
  32. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/parallel.py +0 -0
  33. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/pytest_plugin.py +0 -0
  34. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/question_tests.py +0 -0
  35. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/regression.py +0 -0
  36. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/reporting.py +0 -0
  37. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/repository.py +0 -0
  38. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/runner.py +0 -0
  39. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/server.py +0 -0
  40. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/sharding.py +0 -0
  41. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/tests/__init__.py +0 -0
  42. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/tests/conftest.py +0 -0
  43. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/tests/stub_git.py +0 -0
  44. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/tests/test_cli.py +0 -0
  45. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/tests/test_config.py +0 -0
  46. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/tests/test_discovery.py +0 -0
  47. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/tests/test_fuzz.py +0 -0
  48. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/tests/test_git.py +0 -0
  49. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/tests/test_html_checks.py +0 -0
  50. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/tests/test_metadata.py +0 -0
  51. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/tests/test_parallel.py +0 -0
  52. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/tests/test_publish_version.py +0 -0
  53. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/tests/test_pytest_plugin.py +0 -0
  54. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/tests/test_question_tests.py +0 -0
  55. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/tests/test_regression.py +0 -0
  56. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/tests/test_reporting.py +0 -0
  57. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/tests/test_repository.py +0 -0
  58. {pl_e2e_test-0.1.5 → pl_e2e_test-0.1.6}/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.5
3
+ Version: 0.1.6
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,87 @@ 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. `nonGradableSelectors` excludes
270
- display-only answers from grading checks. Each supplied list replaces its default;
271
- omitted settings keep their defaults, and `[]` disables a list.
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 must either remain
339
+ non-null under its child key or produce a non-null value under its declared parent
340
+ answer. This also supports elements that normalize `answer-input` into `answer`.
341
+
342
+ This contract keeps element-specific synthesis with the element or question that owns
343
+ the data. PrairieLearn's tuple-based `pl-matching` state normalizes to arrays at the JSON
344
+ boundary, and its existing namespaced test fields require no harness adapter. A question
345
+ using `pl-sketch` without an element-owned solution can supply its deterministic
346
+ `answers-name-sketchresponse-submission` payload in `server.py`. A course-owned
347
+ drag-and-drop element such as `pl-faded-parsons` can implement one reusable element
348
+ test hook that serializes its prepared correct tray and blank state.
349
+
277
350
  ### Question-local configuration
278
351
 
279
352
  Store saved concrete seeds, narrow phase skips, and expected upstream errors in
@@ -242,14 +242,87 @@ 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. `nonGradableSelectors` excludes
246
- display-only answers from grading checks. Each supplied list replaces its default;
247
- omitted settings keep their defaults, and `[]` disables a list.
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 must either remain
315
+ non-null under its child key or produce a non-null value under its declared parent
316
+ answer. This also supports elements that normalize `answer-input` into `answer`.
317
+
318
+ This contract keeps element-specific synthesis with the element or question that owns
319
+ the data. PrairieLearn's tuple-based `pl-matching` state normalizes to arrays at the JSON
320
+ boundary, and its existing namespaced test fields require no harness adapter. A question
321
+ using `pl-sketch` without an element-owned solution can supply its deterministic
322
+ `answers-name-sketchresponse-submission` payload in `server.py`. A course-owned
323
+ drag-and-drop element such as `pl-faded-parsons` can implement one reusable element
324
+ test hook that serializes its prepared correct tray and blank state.
325
+
253
326
  ### Question-local configuration
254
327
 
255
328
  Store saved concrete seeds, narrow phase skips, and expected upstream errors in
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pl-e2e-test"
7
- version = "0.1.5"
7
+ version = "0.1.6"
8
8
  description = "Fuzz PrairieLearn question lifecycles against the real Python element runtime"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pl-e2e-test
3
- Version: 0.1.5
3
+ Version: 0.1.6
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,87 @@ 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. `nonGradableSelectors` excludes
270
- display-only answers from grading checks. Each supplied list replaces its default;
271
- omitted settings keep their defaults, and `[]` disables a list.
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 must either remain
339
+ non-null under its child key or produce a non-null value under its declared parent
340
+ answer. This also supports elements that normalize `answer-input` into `answer`.
341
+
342
+ This contract keeps element-specific synthesis with the element or question that owns
343
+ the data. PrairieLearn's tuple-based `pl-matching` state normalizes to arrays at the JSON
344
+ boundary, and its existing namespaced test fields require no harness adapter. A question
345
+ using `pl-sketch` without an element-owned solution can supply its deterministic
346
+ `answers-name-sketchresponse-submission` payload in `server.py`. A course-owned
347
+ drag-and-drop element such as `pl-faded-parsons` can implement one reusable element
348
+ test hook that serializes its prepared correct tray and blank state.
349
+
277
350
  ### Question-local configuration
278
351
 
279
352
  Store saved concrete seeds, narrow phase skips, and expected upstream errors in
@@ -164,35 +164,117 @@ def validate_mutations(
164
164
  raise HarnessError(f"{phase}: data[{name!r}] was illegally modified")
165
165
 
166
166
 
167
- def validate_serializable(data: dict[str, Any], phase: Phase) -> None:
168
- def check_json(value: Any, path: str) -> None:
169
- if value is None or isinstance(value, (str, bool, int)):
170
- return
171
- if isinstance(value, float):
172
- if not math.isfinite(value):
173
- raise TypeError(f"{path} contains NaN or infinity")
174
- return
175
- if isinstance(value, list):
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
- check_json(item, f"{path}[{index}]")
178
- return
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 not isinstance(key, str):
182
- raise TypeError(f"{path} has a non-string object key")
183
- check_json(item, f"{path}[{key!r}]")
184
- return
185
- raise TypeError(f"{path} contains {type(value).__name__}, which is not JSON")
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
- check_json(data, "data")
189
- assert_all_integers_within_limits(data)
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
- unparsed = [
83
- name
84
- for name in answer_names
85
- if name not in data["submitted_answers"]
86
- or data["submitted_answers"][name] is None
87
- ]
128
+ submitted_answers = data["submitted_answers"]
129
+ unparsed: list[str] = []
130
+ for raw_name in data["raw_submitted_answers"]:
131
+ if submitted_answers.get(raw_name) is not None:
132
+ continue
133
+ parent_name = _associated_answer_name(raw_name, answer_names)
134
+ parent_parsed = (
135
+ parent_name is not None and submitted_answers.get(parent_name) is not None
136
+ )
137
+ if not parent_parsed:
138
+ unparsed.append(raw_name)
88
139
  if unparsed:
89
140
  raise HarnessError(
90
- "declared answers did not parse successfully: " + ", ".join(unparsed)
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",