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.
- {pl_e2e_test-0.1.3/src/pl_e2e_test.egg-info → pl_e2e_test-0.1.4}/PKG-INFO +25 -1
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/README.md +24 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/pyproject.toml +1 -4
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4/src/pl_e2e_test.egg-info}/PKG-INFO +25 -1
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/pl_e2e_test.egg-info/SOURCES.txt +1 -1
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/__init__.py +3 -0
- pl_e2e_test-0.1.4/src/prairielearn_e2e/config.py +68 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/fuzz.py +6 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/harness.py +5 -2
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/html_checks.py +18 -7
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/pytest_plugin.py +26 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/question_tests.py +6 -1
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/reporting.py +1 -1
- pl_e2e_test-0.1.4/tests/test_config.py +88 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_fuzz.py +7 -1
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_harness.py +29 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_pytest_plugin.py +2 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_reporting.py +1 -1
- pl_e2e_test-0.1.3/src/prairielearn_e2e/config.json +0 -7
- pl_e2e_test-0.1.3/src/prairielearn_e2e/config.py +0 -30
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/LICENSE +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/MANIFEST.in +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/setup.cfg +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/pl_e2e_test.egg-info/dependency_links.txt +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/pl_e2e_test.egg-info/entry_points.txt +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/pl_e2e_test.egg-info/requires.txt +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/pl_e2e_test.egg-info/top_level.txt +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/__main__.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/_git.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/backend.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/cli.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/contract.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/discovery.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/errors.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/metadata.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/parallel.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/regression.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/repository.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/runner.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/server.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/sharding.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/__init__.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/conftest.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/stub_git.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_cli.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_contract.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_discovery.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_git.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_html_checks.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_metadata.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_parallel.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_publish_version.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_regression.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_repository.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.4}/tests/test_server.py +0 -0
- {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
|
+
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.
|
|
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
|
+
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
|
|
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(
|
|
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(
|
|
112
|
+
return _answer_names_matching_selectors(
|
|
113
|
+
html, config.element_solution_answer_selectors
|
|
114
|
+
)
|
|
111
115
|
|
|
112
116
|
|
|
113
|
-
def non_gradable_answer_names(
|
|
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,
|
|
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,
|
|
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(
|
|
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(
|
|
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)}
|
|
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
|
-
|
|
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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|