pl-e2e-test 0.1.3__tar.gz → 0.1.4__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 (56) hide show
  1. {pl_e2e_test-0.1.3/src/pl_e2e_test.egg-info → pl_e2e_test-0.1.4}/PKG-INFO +25 -1
  2. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/README.md +24 -0
  3. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/pyproject.toml +1 -4
  4. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4/src/pl_e2e_test.egg-info}/PKG-INFO +25 -1
  5. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/pl_e2e_test.egg-info/SOURCES.txt +1 -1
  6. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/__init__.py +3 -0
  7. pl_e2e_test-0.1.4/src/prairielearn_e2e/config.py +68 -0
  8. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/fuzz.py +6 -0
  9. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/harness.py +5 -2
  10. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/html_checks.py +18 -7
  11. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/pytest_plugin.py +26 -0
  12. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/question_tests.py +6 -1
  13. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/reporting.py +1 -1
  14. pl_e2e_test-0.1.4/tests/test_config.py +88 -0
  15. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_fuzz.py +7 -1
  16. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_harness.py +29 -0
  17. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_pytest_plugin.py +2 -0
  18. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_reporting.py +1 -1
  19. pl_e2e_test-0.1.3/src/prairielearn_e2e/config.json +0 -7
  20. pl_e2e_test-0.1.3/src/prairielearn_e2e/config.py +0 -30
  21. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/LICENSE +0 -0
  22. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/MANIFEST.in +0 -0
  23. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/setup.cfg +0 -0
  24. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/pl_e2e_test.egg-info/dependency_links.txt +0 -0
  25. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/pl_e2e_test.egg-info/entry_points.txt +0 -0
  26. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/pl_e2e_test.egg-info/requires.txt +0 -0
  27. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/pl_e2e_test.egg-info/top_level.txt +0 -0
  28. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/__main__.py +0 -0
  29. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/_git.py +0 -0
  30. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/backend.py +0 -0
  31. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/cli.py +0 -0
  32. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/contract.py +0 -0
  33. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/discovery.py +0 -0
  34. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/errors.py +0 -0
  35. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/metadata.py +0 -0
  36. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/parallel.py +0 -0
  37. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/regression.py +0 -0
  38. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/repository.py +0 -0
  39. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/runner.py +0 -0
  40. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/server.py +0 -0
  41. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/sharding.py +0 -0
  42. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/__init__.py +0 -0
  43. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/conftest.py +0 -0
  44. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/stub_git.py +0 -0
  45. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_cli.py +0 -0
  46. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_contract.py +0 -0
  47. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_discovery.py +0 -0
  48. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_git.py +0 -0
  49. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_html_checks.py +0 -0
  50. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_metadata.py +0 -0
  51. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_parallel.py +0 -0
  52. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_publish_version.py +0 -0
  53. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_regression.py +0 -0
  54. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_repository.py +0 -0
  55. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_server.py +0 -0
  56. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/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
3
+ Version: 0.1.4
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
@@ -255,6 +255,25 @@ element controllers are archived into the test run's temporary directory. Standa
255
255
  cache environment settings such as `XDG_CACHE_HOME` can relocate the managed cache;
256
256
  CI systems can persist that directory between runs if desired.
257
257
 
258
+ ### Harness configuration
259
+
260
+ Selector settings are read automatically from the course's `pyproject.toml`:
261
+
262
+ ```toml
263
+ [tool.pl-e2e-test]
264
+ elementSolutionSelectors = ["pl-sketch"]
265
+ nonGradableSelectors = ['pl-sketch[read-only="true"]', 'pl-sketch[read-only="1"]']
266
+ ```
267
+
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.
272
+
273
+ Use `--config path/to/settings.toml` to select another TOML file with the same
274
+ `[tool.pl-e2e-test]` table. This works with normal runs, `diff`, and arguments after
275
+ `parallel run` or `parallel diff`. Explicit paths are relative to the working directory.
276
+
258
277
  ### Question-local configuration
259
278
 
260
279
  Store saved concrete seeds and narrow phase skips in `.question-e2e.json` beside the
@@ -331,6 +350,11 @@ from prairielearn_e2e import PrairieLearnBackend, QuestionCase, QuestionHarness
331
350
  increasingly complete portions of the lifecycle. `run_variant` remains an alias for
332
351
  the complete grade path.
333
352
 
353
+ Pass `config=load_config(course_root=course_root)` to `QuestionHarness` to load the
354
+ course's TOML settings, or `config=load_config("settings.toml")` for an explicit file.
355
+ `load_config` and `HarnessConfig` are exported from `prairielearn_e2e`. Omitting
356
+ `config` uses the built-in defaults.
357
+
334
358
  ## Development
335
359
 
336
360
  This project uses uv 0.9 or newer:
@@ -231,6 +231,25 @@ element controllers are archived into the test run's temporary directory. Standa
231
231
  cache environment settings such as `XDG_CACHE_HOME` can relocate the managed cache;
232
232
  CI systems can persist that directory between runs if desired.
233
233
 
234
+ ### Harness configuration
235
+
236
+ Selector settings are read automatically from the course's `pyproject.toml`:
237
+
238
+ ```toml
239
+ [tool.pl-e2e-test]
240
+ elementSolutionSelectors = ["pl-sketch"]
241
+ nonGradableSelectors = ['pl-sketch[read-only="true"]', 'pl-sketch[read-only="1"]']
242
+ ```
243
+
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.
248
+
249
+ Use `--config path/to/settings.toml` to select another TOML file with the same
250
+ `[tool.pl-e2e-test]` table. This works with normal runs, `diff`, and arguments after
251
+ `parallel run` or `parallel diff`. Explicit paths are relative to the working directory.
252
+
234
253
  ### Question-local configuration
235
254
 
236
255
  Store saved concrete seeds and narrow phase skips in `.question-e2e.json` beside the
@@ -307,6 +326,11 @@ from prairielearn_e2e import PrairieLearnBackend, QuestionCase, QuestionHarness
307
326
  increasingly complete portions of the lifecycle. `run_variant` remains an alias for
308
327
  the complete grade path.
309
328
 
329
+ Pass `config=load_config(course_root=course_root)` to `QuestionHarness` to load the
330
+ course's TOML settings, or `config=load_config("settings.toml")` for an explicit file.
331
+ `load_config` and `HarnessConfig` are exported from `prairielearn_e2e`. Omitting
332
+ `config` uses the built-in defaults.
333
+
310
334
  ## Development
311
335
 
312
336
  This project uses uv 0.9 or newer:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pl-e2e-test"
7
- version = "0.1.3"
7
+ version = "0.1.4"
8
8
  description = "Fuzz PrairieLearn question lifecycles against the real Python element runtime"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -37,9 +37,6 @@ pl-e2e-test = "prairielearn_e2e.cli:main"
37
37
  [tool.setuptools.packages.find]
38
38
  where = ["src"]
39
39
 
40
- [tool.setuptools.package-data]
41
- prairielearn_e2e = ["config.json"]
42
-
43
40
  [tool.pytest.ini_options]
44
41
  testpaths = ["tests"]
45
42
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pl-e2e-test
3
- Version: 0.1.3
3
+ Version: 0.1.4
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
@@ -255,6 +255,25 @@ element controllers are archived into the test run's temporary directory. Standa
255
255
  cache environment settings such as `XDG_CACHE_HOME` can relocate the managed cache;
256
256
  CI systems can persist that directory between runs if desired.
257
257
 
258
+ ### Harness configuration
259
+
260
+ Selector settings are read automatically from the course's `pyproject.toml`:
261
+
262
+ ```toml
263
+ [tool.pl-e2e-test]
264
+ elementSolutionSelectors = ["pl-sketch"]
265
+ nonGradableSelectors = ['pl-sketch[read-only="true"]', 'pl-sketch[read-only="1"]']
266
+ ```
267
+
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.
272
+
273
+ Use `--config path/to/settings.toml` to select another TOML file with the same
274
+ `[tool.pl-e2e-test]` table. This works with normal runs, `diff`, and arguments after
275
+ `parallel run` or `parallel diff`. Explicit paths are relative to the working directory.
276
+
258
277
  ### Question-local configuration
259
278
 
260
279
  Store saved concrete seeds and narrow phase skips in `.question-e2e.json` beside the
@@ -331,6 +350,11 @@ from prairielearn_e2e import PrairieLearnBackend, QuestionCase, QuestionHarness
331
350
  increasingly complete portions of the lifecycle. `run_variant` remains an alias for
332
351
  the complete grade path.
333
352
 
353
+ Pass `config=load_config(course_root=course_root)` to `QuestionHarness` to load the
354
+ course's TOML settings, or `config=load_config("settings.toml")` for an explicit file.
355
+ `load_config` and `HarnessConfig` are exported from `prairielearn_e2e`. Omitting
356
+ `config` uses the built-in defaults.
357
+
334
358
  ## Development
335
359
 
336
360
  This project uses uv 0.9 or newer:
@@ -13,7 +13,6 @@ src/prairielearn_e2e/__main__.py
13
13
  src/prairielearn_e2e/_git.py
14
14
  src/prairielearn_e2e/backend.py
15
15
  src/prairielearn_e2e/cli.py
16
- src/prairielearn_e2e/config.json
17
16
  src/prairielearn_e2e/config.py
18
17
  src/prairielearn_e2e/contract.py
19
18
  src/prairielearn_e2e/discovery.py
@@ -35,6 +34,7 @@ tests/__init__.py
35
34
  tests/conftest.py
36
35
  tests/stub_git.py
37
36
  tests/test_cli.py
37
+ tests/test_config.py
38
38
  tests/test_contract.py
39
39
  tests/test_discovery.py
40
40
  tests/test_fuzz.py
@@ -3,6 +3,7 @@
3
3
  from importlib.metadata import version
4
4
 
5
5
  from .backend import PrairieLearnBackend
6
+ from .config import HarnessConfig, load_config
6
7
  from .discovery import QuestionCase, discover_questions
7
8
  from .harness import LifecycleResult, QuestionHarness
8
9
  from .parallel import run_parallel
@@ -10,11 +11,13 @@ from .parallel import run_parallel
10
11
  __version__ = version("pl-e2e-test")
11
12
 
12
13
  __all__ = (
14
+ "HarnessConfig",
13
15
  "LifecycleResult",
14
16
  "PrairieLearnBackend",
15
17
  "QuestionCase",
16
18
  "QuestionHarness",
17
19
  "__version__",
18
20
  "discover_questions",
21
+ "load_config",
19
22
  "run_parallel",
20
23
  )
@@ -0,0 +1,68 @@
1
+ """Load harness settings from the course's pyproject.toml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class HarnessConfig:
12
+ element_solution_answer_selectors: tuple[str, ...] = ("pl-sketch",)
13
+ non_gradable_answer_selectors: tuple[str, ...] = (
14
+ 'pl-sketch[read-only="true"]',
15
+ 'pl-sketch[read-only="1"]',
16
+ )
17
+
18
+
19
+ DEFAULT_CONFIG = HarnessConfig()
20
+
21
+
22
+ def load_config(
23
+ path: str | Path | None = None, *, course_root: str | Path | None = None
24
+ ) -> HarnessConfig:
25
+ """Read [tool.pl-e2e-test], preserving defaults for omitted settings.
26
+
27
+ Without an explicit path, look for pyproject.toml in the course root.
28
+ A missing implicit file is allowed; a missing explicit file is an error.
29
+ """
30
+ config_path = (
31
+ Path(path)
32
+ if path is not None
33
+ else Path(course_root or Path.cwd()) / "pyproject.toml"
34
+ )
35
+ try:
36
+ with config_path.open("rb") as config_file:
37
+ document = tomllib.load(config_file)
38
+ except FileNotFoundError:
39
+ if path is not None:
40
+ raise
41
+ return DEFAULT_CONFIG
42
+ tool = document.get("tool", {})
43
+ if not isinstance(tool, dict):
44
+ raise TypeError("tool must be a table")
45
+ config = tool.get("pl-e2e-test", {})
46
+ if not isinstance(config, dict):
47
+ raise TypeError("tool.pl-e2e-test must be a table")
48
+
49
+ def selectors(key: str, default: tuple[str, ...]) -> tuple[str, ...]:
50
+ if key not in config:
51
+ return default
52
+ values = config[key]
53
+ if not isinstance(values, list) or any(
54
+ not isinstance(value, str) or not value.strip() for value in values
55
+ ):
56
+ raise TypeError(
57
+ f"tool.pl-e2e-test.{key} must be an array of non-empty strings"
58
+ )
59
+ return tuple(values)
60
+
61
+ return HarnessConfig(
62
+ element_solution_answer_selectors=selectors(
63
+ "elementSolutionSelectors", DEFAULT_CONFIG.element_solution_answer_selectors
64
+ ),
65
+ non_gradable_answer_selectors=selectors(
66
+ "nonGradableSelectors", DEFAULT_CONFIG.non_gradable_answer_selectors
67
+ ),
68
+ )
@@ -197,6 +197,9 @@ def positive_integer(value: str) -> int:
197
197
  def main(argv: Sequence[str] | None = None) -> int:
198
198
  parser = argparse.ArgumentParser(prog="pl-e2e-test diff", description=__doc__)
199
199
  parser.add_argument("--course-root", type=Path)
200
+ parser.add_argument(
201
+ "--config", type=Path, help="TOML config (default: COURSE_ROOT/pyproject.toml)"
202
+ )
200
203
  parser.add_argument("--base", default="main", help="base Git ref (default: main)")
201
204
  parser.add_argument("--head", default="HEAD", help="head Git ref (default: HEAD)")
202
205
  parser.add_argument("--seed-count", type=positive_integer, default=10)
@@ -276,6 +279,9 @@ def main(argv: Sequence[str] | None = None) -> int:
276
279
  print(f"Variant seeds: {', '.join(str(seed) for seed in seeds)}")
277
280
  replay_arguments = ["pl-e2e-test", "diff", "--fuzz-seed", str(fuzz_seed)]
278
281
  test_arguments: list[str] = []
282
+ if args.config is not None:
283
+ replay_arguments.extend(("--config", os.fspath(args.config)))
284
+ test_arguments.extend(("--config", os.fspath(args.config)))
279
285
  if args.prairielearn_path is not None:
280
286
  replay_arguments.extend(
281
287
  ("--prairielearn-path", os.fspath(args.prairielearn_path))
@@ -13,6 +13,7 @@ import lxml.html
13
13
  import numpy as np
14
14
 
15
15
  from .backend import PrairieLearnBackend
16
+ from .config import DEFAULT_CONFIG, HarnessConfig
16
17
  from .contract import PHASE_FIELDS, Phase
17
18
  from .discovery import QuestionCase
18
19
  from .errors import HarnessError, UnsupportedQuestion
@@ -112,6 +113,7 @@ class QuestionHarness:
112
113
  backend: PrairieLearnBackend,
113
114
  *,
114
115
  course_root: str | Path | None = None,
116
+ config: HarnessConfig = DEFAULT_CONFIG,
115
117
  ) -> None:
116
118
  if isinstance(question, QuestionCase):
117
119
  self.question = question
@@ -124,6 +126,7 @@ class QuestionHarness:
124
126
  directory = directory.parent
125
127
  self.question = QuestionCase(directory.resolve(), root)
126
128
  self.backend = backend
129
+ self.config = config
127
130
 
128
131
  def _options(self) -> dict[str, Any]:
129
132
  directory = self.question.directory
@@ -333,12 +336,12 @@ class QuestionHarness:
333
336
 
334
337
  show_correct = metadata.get("showCorrectAnswer", True)
335
338
  answer_names = validate_declared_answers(
336
- prepared_html, base["correct_answers"], show_correct
339
+ prepared_html, base["correct_answers"], show_correct, self.config
337
340
  )
338
341
  tested_answer_names = tuple(
339
342
  name
340
343
  for name in answer_names
341
- if name not in non_gradable_answer_names(prepared_html)
344
+ if name not in non_gradable_answer_names(prepared_html, self.config)
342
345
  )
343
346
  rendered_question = self._render(
344
347
  source, base, server, panel="question", show_correct=show_correct
@@ -6,7 +6,7 @@ from typing import Any
6
6
 
7
7
  import lxml.html
8
8
 
9
- from .config import ELEMENT_SOLUTION_ANSWER_SELECTORS, NON_GRADABLE_ANSWER_SELECTORS
9
+ from .config import DEFAULT_CONFIG, HarnessConfig
10
10
  from .errors import HarnessError
11
11
 
12
12
  _SIMPLE_CSS_SELECTOR = re.compile(
@@ -100,17 +100,23 @@ def _answer_names_matching_selectors(
100
100
  return frozenset(names)
101
101
 
102
102
 
103
- def element_solution_answer_names(html: str) -> frozenset[str]:
103
+ def element_solution_answer_names(
104
+ html: str, config: HarnessConfig = DEFAULT_CONFIG
105
+ ) -> frozenset[str]:
104
106
  """Return answer names whose solution is owned by the element.
105
107
 
106
108
  These elements do not need an entry in ``correct_answers``, but they can
107
109
  still be graded and must remain part of the parse/grade lifecycle checks.
108
110
  """
109
111
 
110
- return _answer_names_matching_selectors(html, ELEMENT_SOLUTION_ANSWER_SELECTORS)
112
+ return _answer_names_matching_selectors(
113
+ html, config.element_solution_answer_selectors
114
+ )
111
115
 
112
116
 
113
- def non_gradable_answer_names(html: str) -> frozenset[str]:
117
+ def non_gradable_answer_names(
118
+ html: str, config: HarnessConfig = DEFAULT_CONFIG
119
+ ) -> frozenset[str]:
114
120
  """Return answer names intentionally excluded from server-side grading.
115
121
 
116
122
  Configured display-only elements still carry an ``answers-name`` for
@@ -118,15 +124,20 @@ def non_gradable_answer_names(html: str) -> frozenset[str]:
118
124
  partial score.
119
125
  """
120
126
 
121
- return _answer_names_matching_selectors(html, NON_GRADABLE_ANSWER_SELECTORS)
127
+ return _answer_names_matching_selectors(html, config.non_gradable_answer_selectors)
122
128
 
123
129
 
124
130
  def validate_declared_answers(
125
- html: str, correct_answers: dict[str, Any], show_correct_answer: bool
131
+ html: str,
132
+ correct_answers: dict[str, Any],
133
+ show_correct_answer: bool,
134
+ config: HarnessConfig = DEFAULT_CONFIG,
126
135
  ) -> tuple[str, ...]:
127
136
  names = declared_answer_names(html)
128
137
  if show_correct_answer:
129
- ignored = non_gradable_answer_names(html) | element_solution_answer_names(html)
138
+ ignored = non_gradable_answer_names(
139
+ html, config
140
+ ) | element_solution_answer_names(html, config)
130
141
  require_correct_answers(
131
142
  tuple(name for name in names if name not in ignored), correct_answers
132
143
  )
@@ -13,11 +13,13 @@ from typing import Any, cast
13
13
  import pytest
14
14
 
15
15
  from .backend import PrairieLearnBackend
16
+ from .config import HarnessConfig, load_config
16
17
  from .discovery import QuestionCase, discover_questions, variant_seeds
17
18
  from .errors import HarnessError
18
19
  from .reporting import ShardReportRecorder, serialize_pytest_report
19
20
  from .sharding import Shard, parse_shard, selected_group_keys
20
21
 
22
+ _CONFIG_KEY: pytest.StashKey[HarnessConfig] = pytest.StashKey()
21
23
  _REPORTER_KEY: pytest.StashKey[ShardReportRecorder] = pytest.StashKey()
22
24
  _EMPTY_SHARD_KEY: pytest.StashKey[bool] = pytest.StashKey()
23
25
  _active_recorder: ShardReportRecorder | None = None
@@ -25,6 +27,12 @@ _active_recorder: ShardReportRecorder | None = None
25
27
 
26
28
  def pytest_addoption(parser: pytest.Parser) -> None:
27
29
  group = parser.getgroup("prairielearn-e2e")
30
+ group.addoption(
31
+ "--config",
32
+ dest="e2e_config",
33
+ metavar="PATH",
34
+ help="TOML config (default: COURSE_ROOT/pyproject.toml)",
35
+ )
28
36
  group.addoption(
29
37
  "--course-root",
30
38
  metavar="PATH",
@@ -98,6 +106,16 @@ def pytest_configure(config: pytest.Config) -> None:
98
106
  ):
99
107
  config.addinivalue_line("markers", f"{marker}: {description}")
100
108
 
109
+ config_path = cast(str | None, config.getoption("e2e_config"))
110
+ try:
111
+ config.stash[_CONFIG_KEY] = load_config(
112
+ config_path, course_root=_course_root(config)
113
+ )
114
+ except (OSError, ValueError, TypeError) as exc:
115
+ raise pytest.UsageError(
116
+ f"unable to load harness config {config_path!r}: {exc}"
117
+ ) from exc
118
+
101
119
  fuzz_seeds = cast(bool, config.getoption("fuzz_seeds"))
102
120
  fuzz_seed = cast(int | None, config.getoption("fuzz_seed"))
103
121
  shard = cast(Shard | None, config.getoption("shard"))
@@ -152,6 +170,9 @@ def pytest_report_header(config: pytest.Config) -> str | None:
152
170
  replay = f"{replay_command} --fuzz-seed {fuzz_seed}"
153
171
  if prairielearn_path is not None:
154
172
  replay += f" --prairielearn-path {shlex.quote(prairielearn_path)}"
173
+ config_path = cast(str | None, config.getoption("e2e_config"))
174
+ if config_path is not None:
175
+ replay += f" --config {shlex.quote(config_path)}"
155
176
  if shard is not None:
156
177
  replay += f" --shard {shard}"
157
178
  lines.extend(
@@ -333,3 +354,8 @@ def prairielearn_backend(
333
354
  tmp_path_factory.mktemp("prairielearn-core"),
334
355
  clone,
335
356
  )
357
+
358
+
359
+ @pytest.fixture(scope="session")
360
+ def harness_config(request: pytest.FixtureRequest) -> HarnessConfig:
361
+ return request.config.stash[_CONFIG_KEY]
@@ -28,7 +28,12 @@ def _run_check(
28
28
  pytest.skip(reason)
29
29
  backend: PrairieLearnBackend = request.getfixturevalue("prairielearn_backend")
30
30
  try:
31
- check(QuestionHarness(question, backend), seed)
31
+ check(
32
+ QuestionHarness(
33
+ question, backend, config=request.getfixturevalue("harness_config")
34
+ ),
35
+ seed,
36
+ )
32
37
  except UnsupportedQuestion as exc:
33
38
  pytest.skip(str(exc))
34
39
 
@@ -503,7 +503,7 @@ def _print_terminal(results: Sequence[ItemResult], warnings: Sequence[str]) -> N
503
503
  print(f"{result.outcome.upper():8} {result.nodeid}")
504
504
  duration = sum(result.duration for result in results)
505
505
  print(
506
- f"==================== {_summary(results)} in {duration:.2f}s "
506
+ f"==================== {_summary(results)}; combined test time: {duration:.2f}s "
507
507
  "===================="
508
508
  )
509
509
 
@@ -0,0 +1,88 @@
1
+ from pathlib import Path
2
+ from typing import cast
3
+
4
+ import pytest
5
+
6
+ from prairielearn_e2e import load_config, pytest_plugin
7
+ from prairielearn_e2e.config import DEFAULT_CONFIG
8
+ from prairielearn_e2e.errors import HarnessError
9
+ from prairielearn_e2e.html_checks import (
10
+ non_gradable_answer_names,
11
+ validate_declared_answers,
12
+ )
13
+
14
+ from .test_pytest_plugin import FakeConfig
15
+
16
+
17
+ def test_missing_course_config_uses_defaults(tmp_path: Path) -> None:
18
+ assert load_config(course_root=tmp_path) == DEFAULT_CONFIG
19
+ with pytest.raises(FileNotFoundError):
20
+ load_config(tmp_path / "missing.toml")
21
+
22
+
23
+ def test_custom_selectors_replace_lists_without_changing_defaults(
24
+ tmp_path: Path,
25
+ ) -> None:
26
+ path = tmp_path / "pyproject.toml"
27
+ path.write_text(
28
+ "[tool.pl-e2e-test]\n"
29
+ 'elementSolutionSelectors = ["custom-input"]\n'
30
+ 'nonGradableSelectors = ["custom-display"]\n'
31
+ )
32
+ config = load_config(course_root=tmp_path)
33
+ html = '<custom-input answers-name="x"></custom-input><custom-display answers-name="y"></custom-display>'
34
+ assert validate_declared_answers(html, {}, True, config) == ("x", "y")
35
+ assert non_gradable_answer_names(html, config) == {"y"}
36
+ with pytest.raises(HarnessError, match="non-null correct answers"):
37
+ validate_declared_answers(html, {}, True)
38
+ with pytest.raises(HarnessError, match="non-null correct answers"):
39
+ validate_declared_answers('<pl-sketch answers-name="x"/>', {}, True, config)
40
+
41
+
42
+ def test_omitted_settings_keep_defaults_and_empty_lists_disable(tmp_path: Path) -> None:
43
+ path = tmp_path / "settings.toml"
44
+ path.write_text("[tool.pl-e2e-test]\nnonGradableSelectors = []\n")
45
+ config = load_config(path)
46
+ assert config.element_solution_answer_selectors == ("pl-sketch",)
47
+ assert config.non_gradable_answer_selectors == ()
48
+ path.write_text('[project]\nname = "course"\n')
49
+ assert load_config(path) == DEFAULT_CONFIG
50
+
51
+
52
+ @pytest.mark.parametrize(
53
+ "contents",
54
+ [
55
+ "invalid toml",
56
+ 'tool = "bad"',
57
+ '[tool]\npl-e2e-test = "bad"',
58
+ '[tool.pl-e2e-test]\nelementSolutionSelectors = "pl-sketch"',
59
+ "[tool.pl-e2e-test]\nnonGradableSelectors = [1]",
60
+ '[tool.pl-e2e-test]\nnonGradableSelectors = [" "]',
61
+ ],
62
+ )
63
+ def test_invalid_config_is_a_usage_error(tmp_path: Path, contents: str) -> None:
64
+ path = tmp_path / "settings.toml"
65
+ path.write_text(contents)
66
+ config = FakeConfig(None)
67
+ config.options["e2e_config"] = str(path)
68
+ with pytest.raises(pytest.UsageError, match="unable to load harness config"):
69
+ pytest_plugin.pytest_configure(cast(pytest.Config, config))
70
+
71
+
72
+ def test_plugin_loads_course_config_and_explicit_override(tmp_path: Path) -> None:
73
+ (tmp_path / "pyproject.toml").write_text(
74
+ "[tool.pl-e2e-test]\nnonGradableSelectors = []\n"
75
+ )
76
+ config = FakeConfig(None)
77
+ config.options["course_root"] = str(tmp_path)
78
+ pytest_plugin.pytest_configure(cast(pytest.Config, config))
79
+ assert config.stash[pytest_plugin._CONFIG_KEY] == load_config(course_root=tmp_path)
80
+
81
+ explicit = tmp_path / "custom settings.toml"
82
+ explicit.write_text("[tool.pl-e2e-test]\nelementSolutionSelectors = []\n")
83
+ config.options["e2e_config"] = str(explicit)
84
+ pytest_plugin.pytest_configure(cast(pytest.Config, config))
85
+ assert config.stash[pytest_plugin._CONFIG_KEY] == load_config(explicit)
86
+ header = pytest_plugin.pytest_report_header(cast(pytest.Config, config))
87
+ assert header is not None
88
+ assert f"--config '{explicit}'" in header
@@ -175,6 +175,8 @@ def test_main_forwards_selected_questions_and_seed(
175
175
  [
176
176
  "--course-root",
177
177
  str(tmp_path),
178
+ "--config",
179
+ "custom settings.toml",
178
180
  "--prairielearn-path",
179
181
  "/tmp/prairielearn",
180
182
  "--seed-count",
@@ -189,6 +191,8 @@ def test_main_forwards_selected_questions_and_seed(
189
191
  assert calls == [
190
192
  (
191
193
  [
194
+ "--config",
195
+ "custom settings.toml",
192
196
  "--prairielearn-path",
193
197
  "/tmp/prairielearn",
194
198
  "--seed-count",
@@ -202,7 +206,9 @@ def test_main_forwards_selected_questions_and_seed(
202
206
  tmp_path,
203
207
  )
204
208
  ]
205
- assert "E2E fuzz seed: 12345" in capsys.readouterr().out
209
+ output = capsys.readouterr().out
210
+ assert "E2E fuzz seed: 12345" in output
211
+ assert "--config 'custom settings.toml'" in output
206
212
 
207
213
 
208
214
  def test_main_reports_invalid_pyproject(
@@ -69,3 +69,32 @@ def test_grade_check_reaches_grading_hooks(tmp_path: Path) -> None:
69
69
 
70
70
  with pytest.raises(RuntimeError, match="grading reached"):
71
71
  harness.run_grade(0)
72
+
73
+
74
+ def test_custom_config_applies_to_render_and_grade(tmp_path: Path) -> None:
75
+ from prairielearn_e2e import HarnessConfig
76
+ from prairielearn_e2e.errors import HarnessError
77
+
78
+ harness, backend = _harness(tmp_path)
79
+ (harness.question.directory / "question.html").write_text(
80
+ '<custom-display answers-name="display"></custom-display>'
81
+ '<custom-input answers-name="answer"></custom-input>'
82
+ )
83
+ (harness.question.directory / "server.py").write_text(
84
+ "def test(data):\n"
85
+ ' data["raw_submitted_answers"]["answer"] = "yes"\n'
86
+ ' data["partial_scores"]["answer"] = {"score": 1}\n'
87
+ )
88
+ with pytest.raises(HarnessError, match="non-null correct answers"):
89
+ harness.run_render(0)
90
+
91
+ configured = QuestionHarness(
92
+ harness.question,
93
+ cast(PrairieLearnBackend, backend),
94
+ config=HarnessConfig(
95
+ element_solution_answer_selectors=("custom-input",),
96
+ non_gradable_answer_selectors=("custom-display",),
97
+ ),
98
+ )
99
+ configured.run_render(0)
100
+ assert configured.run_grade(0).score == 1
@@ -11,6 +11,8 @@ from prairielearn_e2e.sharding import Shard
11
11
  class FakeConfig:
12
12
  def __init__(self, prairielearn_path: str | None) -> None:
13
13
  self.options: dict[str, Any] = {
14
+ "e2e_config": None,
15
+ "course_root": None,
14
16
  "question_path": ["questions/example"],
15
17
  "fuzz_seeds": True,
16
18
  "fuzz_seed": 12345,
@@ -95,7 +95,7 @@ def test_merge_reports_writes_terminal_and_junit(
95
95
 
96
96
  assert merge_reports([report_dir], junit) == 0
97
97
 
98
- assert "2 passed, 1 skipped" in capsys.readouterr().out
98
+ assert "2 passed, 1 skipped; combined test time: 2.25s" in capsys.readouterr().out
99
99
  suite = ET.parse(junit).getroot().find("testsuite")
100
100
  assert suite is not None
101
101
  assert suite.attrib == {
@@ -1,7 +0,0 @@
1
- {
2
- "element_solution_answer_selectors": ["pl-sketch"],
3
- "non_gradable_answer_selectors": [
4
- "pl-sketch[read-only=\"true\"]",
5
- "pl-sketch[read-only=\"1\"]"
6
- ]
7
- }
@@ -1,30 +0,0 @@
1
- """Load declarative configuration for the question harness."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- from pathlib import Path
7
- from typing import Any
8
-
9
- _CONFIG_PATH = Path(__file__).with_name("config.json")
10
-
11
-
12
- def _load_config() -> dict[str, Any]:
13
- with _CONFIG_PATH.open(encoding="utf-8") as config_file:
14
- config = json.load(config_file)
15
- if not isinstance(config, dict):
16
- raise TypeError("e2e-tests config must be a JSON object")
17
- return config
18
-
19
-
20
- _CONFIG = _load_config()
21
- ELEMENT_SOLUTION_ANSWER_SELECTORS: tuple[str, ...] = tuple(
22
- selector
23
- for selector in _CONFIG.get("element_solution_answer_selectors", [])
24
- if isinstance(selector, str) and selector.strip()
25
- )
26
- NON_GRADABLE_ANSWER_SELECTORS: tuple[str, ...] = tuple(
27
- selector
28
- for selector in _CONFIG.get("non_gradable_answer_selectors", [])
29
- if isinstance(selector, str) and selector.strip()
30
- )
File without changes
File without changes
File without changes