pl-e2e-test 0.1.0__py3-none-any.whl
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.0.dist-info/METADATA +322 -0
- pl_e2e_test-0.1.0.dist-info/RECORD +27 -0
- pl_e2e_test-0.1.0.dist-info/WHEEL +5 -0
- pl_e2e_test-0.1.0.dist-info/entry_points.txt +5 -0
- pl_e2e_test-0.1.0.dist-info/licenses/LICENSE +21 -0
- pl_e2e_test-0.1.0.dist-info/top_level.txt +1 -0
- prairielearn_e2e/__init__.py +18 -0
- prairielearn_e2e/__main__.py +3 -0
- prairielearn_e2e/_git.py +167 -0
- prairielearn_e2e/backend.py +168 -0
- prairielearn_e2e/config.json +7 -0
- prairielearn_e2e/config.py +30 -0
- prairielearn_e2e/contract.py +199 -0
- prairielearn_e2e/discovery.py +212 -0
- prairielearn_e2e/errors.py +9 -0
- prairielearn_e2e/fuzz.py +293 -0
- prairielearn_e2e/harness.py +457 -0
- prairielearn_e2e/html_checks.py +133 -0
- prairielearn_e2e/metadata.py +56 -0
- prairielearn_e2e/pytest_plugin.py +335 -0
- prairielearn_e2e/question_tests.py +68 -0
- prairielearn_e2e/regression.py +128 -0
- prairielearn_e2e/reporting.py +675 -0
- prairielearn_e2e/repository.py +298 -0
- prairielearn_e2e/runner.py +28 -0
- prairielearn_e2e/server.py +128 -0
- prairielearn_e2e/sharding.py +46 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Resolve and materialize the PrairieLearn source used by the harness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import tarfile
|
|
9
|
+
import tempfile
|
|
10
|
+
import tomllib
|
|
11
|
+
from contextlib import AbstractContextManager
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from importlib.metadata import distribution
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Protocol
|
|
16
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
17
|
+
|
|
18
|
+
from filelock import FileLock, Timeout
|
|
19
|
+
from platformdirs import user_cache_path
|
|
20
|
+
|
|
21
|
+
from ._git import _GitBackend, _GitError, _GitFetchError, _SubprocessGitBackend
|
|
22
|
+
from .errors import HarnessError
|
|
23
|
+
|
|
24
|
+
_CACHE_LOCK_TIMEOUT_SECONDS = 120
|
|
25
|
+
_UV_GIT_QUERY_PARAMETERS = frozenset({"branch", "rev", "subdirectory", "tag"})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _LockFactory(Protocol):
|
|
29
|
+
def __call__(
|
|
30
|
+
self, path: Path, timeout: float
|
|
31
|
+
) -> AbstractContextManager[object]: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class PrairieLearnSource:
|
|
36
|
+
"""The exact Git source installed in the current Python environment."""
|
|
37
|
+
|
|
38
|
+
url: str
|
|
39
|
+
commit: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _valid_source(url: object, commit: object) -> PrairieLearnSource | None:
|
|
43
|
+
if not isinstance(url, str) or not url:
|
|
44
|
+
return None
|
|
45
|
+
if not isinstance(commit, str) or not commit:
|
|
46
|
+
return None
|
|
47
|
+
if any(character not in "0123456789abcdefABCDEF" for character in commit):
|
|
48
|
+
return None
|
|
49
|
+
return PrairieLearnSource(url=url, commit=commit)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _source_from_direct_url() -> PrairieLearnSource | None:
|
|
53
|
+
direct_url = distribution("prairielearn").read_text("direct_url.json")
|
|
54
|
+
if not direct_url:
|
|
55
|
+
return None
|
|
56
|
+
try:
|
|
57
|
+
parsed = json.loads(direct_url)
|
|
58
|
+
except (json.JSONDecodeError, TypeError):
|
|
59
|
+
return None
|
|
60
|
+
if not isinstance(parsed, dict):
|
|
61
|
+
return None
|
|
62
|
+
vcs_info = parsed.get("vcs_info")
|
|
63
|
+
if not isinstance(vcs_info, dict) or vcs_info.get("vcs") != "git":
|
|
64
|
+
return None
|
|
65
|
+
return _valid_source(parsed.get("url"), vcs_info.get("commit_id"))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _remote_url(git_source: str) -> str:
|
|
69
|
+
parsed = urlsplit(git_source)
|
|
70
|
+
query = urlencode(
|
|
71
|
+
[
|
|
72
|
+
(key, value)
|
|
73
|
+
for key, value in parse_qsl(parsed.query, keep_blank_values=True)
|
|
74
|
+
if key not in _UV_GIT_QUERY_PARAMETERS
|
|
75
|
+
]
|
|
76
|
+
)
|
|
77
|
+
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, query, ""))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _source_from_lock(course_root: Path) -> PrairieLearnSource | None:
|
|
81
|
+
try:
|
|
82
|
+
lock = tomllib.loads((course_root / "uv.lock").read_text(encoding="utf-8"))
|
|
83
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
84
|
+
return None
|
|
85
|
+
for package in lock.get("package", []):
|
|
86
|
+
if not isinstance(package, dict) or package.get("name") != "prairielearn":
|
|
87
|
+
continue
|
|
88
|
+
source = package.get("source")
|
|
89
|
+
if not isinstance(source, dict):
|
|
90
|
+
return None
|
|
91
|
+
git_source = source.get("git")
|
|
92
|
+
if not isinstance(git_source, str):
|
|
93
|
+
return None
|
|
94
|
+
parsed = urlsplit(git_source)
|
|
95
|
+
return _valid_source(_remote_url(git_source), parsed.fragment)
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def installed_prairielearn_source(course_root: Path) -> PrairieLearnSource:
|
|
100
|
+
"""Return the exact PrairieLearn Git source used by this environment."""
|
|
101
|
+
|
|
102
|
+
source = _source_from_direct_url() or _source_from_lock(course_root)
|
|
103
|
+
if source is None:
|
|
104
|
+
raise HarnessError(
|
|
105
|
+
"Could not determine the installed PrairieLearn Git source and revision"
|
|
106
|
+
)
|
|
107
|
+
return source
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _file_lock(path: Path, timeout: float) -> AbstractContextManager[object]:
|
|
111
|
+
return FileLock(path, timeout=timeout)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(slots=True)
|
|
115
|
+
class PrairieLearnRepository:
|
|
116
|
+
"""Materialize one installed PrairieLearn source from a cache or override."""
|
|
117
|
+
|
|
118
|
+
source: PrairieLearnSource
|
|
119
|
+
cache_root: Path | None = field(default=None, kw_only=True)
|
|
120
|
+
_git: _GitBackend = field(
|
|
121
|
+
default_factory=_SubprocessGitBackend,
|
|
122
|
+
repr=False,
|
|
123
|
+
compare=False,
|
|
124
|
+
kw_only=True,
|
|
125
|
+
)
|
|
126
|
+
_lock_factory: _LockFactory = field(
|
|
127
|
+
default=_file_lock,
|
|
128
|
+
repr=False,
|
|
129
|
+
compare=False,
|
|
130
|
+
kw_only=True,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def cache_path(self) -> Path:
|
|
135
|
+
"""Return the private bare-repository path for this source."""
|
|
136
|
+
|
|
137
|
+
source_key = hashlib.sha256(self.source.url.encode()).hexdigest()
|
|
138
|
+
root = self.cache_root or (
|
|
139
|
+
user_cache_path("pl-e2e-test", appauthor=False) / "prairielearn"
|
|
140
|
+
)
|
|
141
|
+
return root / f"{source_key}.git"
|
|
142
|
+
|
|
143
|
+
def materialize(
|
|
144
|
+
self, destination: Path, clone_path: str | Path | None = None
|
|
145
|
+
) -> Path:
|
|
146
|
+
"""Materialize elements from an override or the managed cache."""
|
|
147
|
+
|
|
148
|
+
if clone_path is not None:
|
|
149
|
+
return self.materialize_clone(clone_path, destination)
|
|
150
|
+
return self.materialize_cached(destination)
|
|
151
|
+
|
|
152
|
+
def materialize_cached(self, destination: Path) -> Path:
|
|
153
|
+
"""Materialize core elements from the managed repository cache."""
|
|
154
|
+
|
|
155
|
+
repository = self.cache_path
|
|
156
|
+
lock_path = repository.with_suffix(f"{repository.suffix}.lock")
|
|
157
|
+
try:
|
|
158
|
+
repository.parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
with self._lock_factory(lock_path, _CACHE_LOCK_TIMEOUT_SECONDS):
|
|
160
|
+
self._ensure_cached_commit(repository)
|
|
161
|
+
return self._archive_elements(
|
|
162
|
+
repository, destination, managed_cache=True
|
|
163
|
+
)
|
|
164
|
+
except Timeout as exc:
|
|
165
|
+
raise HarnessError(
|
|
166
|
+
f"Timed out after {_CACHE_LOCK_TIMEOUT_SECONDS} seconds waiting for "
|
|
167
|
+
f"managed PrairieLearn cache {repository}"
|
|
168
|
+
) from exc
|
|
169
|
+
except OSError as exc:
|
|
170
|
+
raise HarnessError(
|
|
171
|
+
f"Unable to use managed PrairieLearn cache {repository} for revision "
|
|
172
|
+
f"{self.source.commit} from {self.source.url}: {exc}. Supply "
|
|
173
|
+
"--prairielearn-path to use another Git repository"
|
|
174
|
+
) from exc
|
|
175
|
+
|
|
176
|
+
def materialize_clone(self, clone_path: str | Path, destination: Path) -> Path:
|
|
177
|
+
"""Materialize elements from an authoritative user-supplied repository."""
|
|
178
|
+
|
|
179
|
+
repository = Path(clone_path).resolve()
|
|
180
|
+
try:
|
|
181
|
+
is_repository = self._git.is_repository(repository)
|
|
182
|
+
except _GitError as exc:
|
|
183
|
+
raise HarnessError(f"Unable to inspect {repository}: {exc}") from exc
|
|
184
|
+
if not is_repository:
|
|
185
|
+
raise HarnessError(
|
|
186
|
+
f"{repository}: --prairielearn-path must name a Git repository"
|
|
187
|
+
)
|
|
188
|
+
try:
|
|
189
|
+
has_commit = self._git.has_commit(repository, self.source.commit)
|
|
190
|
+
except _GitError as exc:
|
|
191
|
+
raise HarnessError(f"Unable to inspect {repository}: {exc}") from exc
|
|
192
|
+
if not has_commit:
|
|
193
|
+
raise HarnessError(
|
|
194
|
+
f"{repository}: --prairielearn-path does not contain installed "
|
|
195
|
+
f"PrairieLearn revision {self.source.commit}; the supplied repository "
|
|
196
|
+
"is authoritative and will not be fetched"
|
|
197
|
+
)
|
|
198
|
+
return self._archive_elements(repository, destination)
|
|
199
|
+
|
|
200
|
+
def _validate_managed_cache(self, repository: Path) -> None:
|
|
201
|
+
try:
|
|
202
|
+
is_repository = self._git.is_repository(repository)
|
|
203
|
+
is_bare = is_repository and self._git.is_bare_repository(repository)
|
|
204
|
+
remote = self._git.origin_url(repository) if is_bare else None
|
|
205
|
+
except _GitError as exc:
|
|
206
|
+
raise HarnessError(
|
|
207
|
+
f"Unable to inspect managed PrairieLearn cache {repository}: {exc}"
|
|
208
|
+
) from exc
|
|
209
|
+
if not is_repository:
|
|
210
|
+
raise HarnessError(
|
|
211
|
+
f"Managed PrairieLearn cache {repository} is not a Git repository; "
|
|
212
|
+
"remove it and retry"
|
|
213
|
+
)
|
|
214
|
+
if not is_bare:
|
|
215
|
+
raise HarnessError(
|
|
216
|
+
f"Managed PrairieLearn cache {repository} is not a bare Git "
|
|
217
|
+
"repository; remove it and retry"
|
|
218
|
+
)
|
|
219
|
+
assert remote is not None
|
|
220
|
+
if remote != self.source.url:
|
|
221
|
+
raise HarnessError(
|
|
222
|
+
f"Managed PrairieLearn cache {repository} has unexpected origin "
|
|
223
|
+
f"{remote!r}; remove it and retry"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def _fetch_commit(
|
|
227
|
+
self, repository: Path, *, cache_path: Path | None = None
|
|
228
|
+
) -> None:
|
|
229
|
+
reported_path = cache_path or repository
|
|
230
|
+
try:
|
|
231
|
+
self._git.fetch_commit(repository, self.source.commit)
|
|
232
|
+
except (_GitFetchError, _GitError) as exc:
|
|
233
|
+
raise HarnessError(
|
|
234
|
+
f"Unable to fetch PrairieLearn revision {self.source.commit} from "
|
|
235
|
+
f"{self.source.url} into {reported_path}: {exc}. Supply "
|
|
236
|
+
"--prairielearn-path with a Git repository containing revision "
|
|
237
|
+
f"{self.source.commit} to run without this fetch"
|
|
238
|
+
) from exc
|
|
239
|
+
|
|
240
|
+
def _initialize_cache(self, repository: Path) -> None:
|
|
241
|
+
with tempfile.TemporaryDirectory(
|
|
242
|
+
prefix=f".{repository.stem}-", dir=repository.parent
|
|
243
|
+
) as temporary_directory:
|
|
244
|
+
temporary_repository = Path(temporary_directory) / "repository.git"
|
|
245
|
+
try:
|
|
246
|
+
self._git.initialize_bare(temporary_repository, self.source.url)
|
|
247
|
+
except _GitError as exc:
|
|
248
|
+
raise HarnessError(
|
|
249
|
+
f"Unable to initialize managed PrairieLearn cache "
|
|
250
|
+
f"{repository}: {exc}"
|
|
251
|
+
) from exc
|
|
252
|
+
self._fetch_commit(temporary_repository, cache_path=repository)
|
|
253
|
+
temporary_repository.rename(repository)
|
|
254
|
+
|
|
255
|
+
def _ensure_cached_commit(self, repository: Path) -> None:
|
|
256
|
+
if not repository.exists():
|
|
257
|
+
self._initialize_cache(repository)
|
|
258
|
+
return
|
|
259
|
+
self._validate_managed_cache(repository)
|
|
260
|
+
try:
|
|
261
|
+
has_commit = self._git.has_commit(repository, self.source.commit)
|
|
262
|
+
except _GitError as exc:
|
|
263
|
+
raise HarnessError(
|
|
264
|
+
f"Unable to inspect managed PrairieLearn cache {repository}: {exc}"
|
|
265
|
+
) from exc
|
|
266
|
+
if not has_commit:
|
|
267
|
+
self._fetch_commit(repository)
|
|
268
|
+
|
|
269
|
+
def _archive_elements(
|
|
270
|
+
self,
|
|
271
|
+
repository: Path,
|
|
272
|
+
destination: Path,
|
|
273
|
+
*,
|
|
274
|
+
managed_cache: bool = False,
|
|
275
|
+
) -> Path:
|
|
276
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
277
|
+
try:
|
|
278
|
+
archive = self._git.archive_elements(repository, self.source.commit)
|
|
279
|
+
except _GitError as exc:
|
|
280
|
+
if not managed_cache:
|
|
281
|
+
raise HarnessError(
|
|
282
|
+
f"Unable to archive PrairieLearn elements at revision "
|
|
283
|
+
f"{self.source.commit} from {repository}: {exc}"
|
|
284
|
+
) from exc
|
|
285
|
+
raise HarnessError(
|
|
286
|
+
f"Unable to archive PrairieLearn elements at revision "
|
|
287
|
+
f"{self.source.commit} from {repository}: {exc}. The managed cache "
|
|
288
|
+
f"uses {self.source.url}; supply --prairielearn-path with a Git "
|
|
289
|
+
f"repository containing revision {self.source.commit} to bypass it"
|
|
290
|
+
) from exc
|
|
291
|
+
with tarfile.open(fileobj=io.BytesIO(archive), mode="r:") as tar:
|
|
292
|
+
tar.extractall(destination, filter="data")
|
|
293
|
+
elements = destination / "elements"
|
|
294
|
+
if not elements.is_dir():
|
|
295
|
+
raise HarnessError(
|
|
296
|
+
f"PrairieLearn revision {self.source.commit} contains no core elements"
|
|
297
|
+
)
|
|
298
|
+
return elements
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Run the installed PrairieLearn question lifecycle test suite."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from . import pytest_plugin
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_tests(
|
|
16
|
+
arguments: Sequence[str], *, course_root: str | Path | None = None
|
|
17
|
+
) -> int:
|
|
18
|
+
"""Run the installed lifecycle cases with ordinary pytest arguments."""
|
|
19
|
+
|
|
20
|
+
root = Path(course_root or Path.cwd()).resolve()
|
|
21
|
+
test_module = Path(__file__).with_name("question_tests.py").resolve()
|
|
22
|
+
pytest_arguments = [os.fspath(test_module), "--course-root", os.fspath(root)]
|
|
23
|
+
pytest_arguments.extend(arguments)
|
|
24
|
+
return pytest.main(pytest_arguments, plugins=[pytest_plugin])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
28
|
+
return run_tests(sys.argv[1:] if argv is None else argv)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import io
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from prairielearn.internal.zygote_utils import get_module_function
|
|
12
|
+
|
|
13
|
+
from .contract import Phase, snapshot, validate_mutations, validate_serializable
|
|
14
|
+
from .errors import HarnessError
|
|
15
|
+
|
|
16
|
+
HOOKS: tuple[Phase, ...] = (
|
|
17
|
+
"generate",
|
|
18
|
+
"prepare",
|
|
19
|
+
"render",
|
|
20
|
+
"parse",
|
|
21
|
+
"grade",
|
|
22
|
+
"test",
|
|
23
|
+
"file",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ServerModule:
|
|
28
|
+
def __init__(self, path: Path, course_root: Path) -> None:
|
|
29
|
+
self.path = path
|
|
30
|
+
self.course_root = course_root
|
|
31
|
+
self.namespace: dict[str, Any] = {}
|
|
32
|
+
if path.is_file():
|
|
33
|
+
self.namespace = self._execute()
|
|
34
|
+
self._validate_signatures()
|
|
35
|
+
|
|
36
|
+
def _environment(self) -> tuple[Path, list[str]]:
|
|
37
|
+
old_cwd = Path.cwd()
|
|
38
|
+
old_path = sys.path.copy()
|
|
39
|
+
os.chdir(self.path.parent)
|
|
40
|
+
sys.path.insert(0, str(self.course_root / "serverFilesCourse"))
|
|
41
|
+
sys.path.insert(0, str(self.path.parent))
|
|
42
|
+
return old_cwd, old_path
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def _restore(old_cwd: Path, old_path: list[str]) -> None:
|
|
46
|
+
os.chdir(old_cwd)
|
|
47
|
+
sys.path[:] = old_path
|
|
48
|
+
|
|
49
|
+
def _execute(self) -> dict[str, Any]:
|
|
50
|
+
namespace: dict[str, Any] = {"__file__": str(self.path)}
|
|
51
|
+
stdout = io.StringIO()
|
|
52
|
+
stderr = io.StringIO()
|
|
53
|
+
old_cwd, old_path = self._environment()
|
|
54
|
+
try:
|
|
55
|
+
source = self.path.read_text(encoding="utf-8")
|
|
56
|
+
code = compile(source, self.path, "exec")
|
|
57
|
+
with redirect_stdout(stdout), redirect_stderr(stderr):
|
|
58
|
+
exec(code, namespace) # noqa: S102 -- server.py is the code under test.
|
|
59
|
+
finally:
|
|
60
|
+
self._restore(old_cwd, old_path)
|
|
61
|
+
output = stdout.getvalue() + stderr.getvalue()
|
|
62
|
+
if output:
|
|
63
|
+
raise HarnessError(
|
|
64
|
+
f"{self.path}: import emitted unexpected console output: {output!r}"
|
|
65
|
+
)
|
|
66
|
+
return namespace
|
|
67
|
+
|
|
68
|
+
def _validate_signatures(self) -> None:
|
|
69
|
+
for phase in HOOKS:
|
|
70
|
+
declared = self.namespace.get(phase)
|
|
71
|
+
if declared is None:
|
|
72
|
+
continue
|
|
73
|
+
if not callable(declared):
|
|
74
|
+
raise HarnessError(f"{self.path}: {phase} must be callable")
|
|
75
|
+
function = get_module_function(self.namespace, phase)
|
|
76
|
+
if function is None:
|
|
77
|
+
continue
|
|
78
|
+
expected = 2 if phase == "render" else 1
|
|
79
|
+
try:
|
|
80
|
+
inspect.signature(function).bind(*([object()] * expected))
|
|
81
|
+
except TypeError as exc:
|
|
82
|
+
arguments = "data, html" if phase == "render" else "data"
|
|
83
|
+
raise HarnessError(
|
|
84
|
+
f"{self.path}: {phase} must accept ({arguments}): {exc}"
|
|
85
|
+
) from exc
|
|
86
|
+
|
|
87
|
+
def has_hook(self, phase: Phase) -> bool:
|
|
88
|
+
return get_module_function(self.namespace, phase) is not None
|
|
89
|
+
|
|
90
|
+
def invoke(
|
|
91
|
+
self,
|
|
92
|
+
phase: Phase,
|
|
93
|
+
data: dict[str, Any],
|
|
94
|
+
html: str | None = None,
|
|
95
|
+
) -> Any:
|
|
96
|
+
function = get_module_function(self.namespace, phase)
|
|
97
|
+
if function is None:
|
|
98
|
+
return html if phase == "render" else None
|
|
99
|
+
before = snapshot(data, phase)
|
|
100
|
+
stdout = io.StringIO()
|
|
101
|
+
stderr = io.StringIO()
|
|
102
|
+
old_cwd, old_path = self._environment()
|
|
103
|
+
try:
|
|
104
|
+
args = (data, html) if phase == "render" else (data,)
|
|
105
|
+
with redirect_stdout(stdout), redirect_stderr(stderr):
|
|
106
|
+
result = function(*args)
|
|
107
|
+
finally:
|
|
108
|
+
self._restore(old_cwd, old_path)
|
|
109
|
+
output = stdout.getvalue() + stderr.getvalue()
|
|
110
|
+
if output:
|
|
111
|
+
raise HarnessError(
|
|
112
|
+
f"{self.path}: {phase} emitted unexpected console output: {output!r}"
|
|
113
|
+
)
|
|
114
|
+
if phase == "render":
|
|
115
|
+
if not isinstance(result, str):
|
|
116
|
+
raise HarnessError(f"{self.path}: render must return HTML text")
|
|
117
|
+
elif phase == "file":
|
|
118
|
+
if result is not None and not isinstance(
|
|
119
|
+
result, (str, bytes, bytearray, memoryview, io.IOBase)
|
|
120
|
+
):
|
|
121
|
+
raise HarnessError(f"{self.path}: file returned an unsupported value")
|
|
122
|
+
elif result is not None and result is not data:
|
|
123
|
+
raise HarnessError(
|
|
124
|
+
f"{self.path}: {phase} must mutate data in place and return None"
|
|
125
|
+
)
|
|
126
|
+
validate_mutations(before, data, phase)
|
|
127
|
+
validate_serializable(data, phase)
|
|
128
|
+
return result
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Stable test-suite sharding helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from collections.abc import Hashable, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class Shard:
|
|
12
|
+
"""A one-based shard index and the total number of shards."""
|
|
13
|
+
|
|
14
|
+
index: int
|
|
15
|
+
count: int
|
|
16
|
+
|
|
17
|
+
def __str__(self) -> str:
|
|
18
|
+
return f"{self.index}/{self.count}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_shard(value: str) -> Shard:
|
|
22
|
+
"""Parse an ``INDEX/COUNT`` shard specification."""
|
|
23
|
+
|
|
24
|
+
parts = value.split("/")
|
|
25
|
+
if len(parts) != 2:
|
|
26
|
+
raise argparse.ArgumentTypeError("must use INDEX/COUNT format")
|
|
27
|
+
try:
|
|
28
|
+
index, count = (int(part) for part in parts)
|
|
29
|
+
except ValueError:
|
|
30
|
+
raise argparse.ArgumentTypeError("index and count must be integers") from None
|
|
31
|
+
if count < 1:
|
|
32
|
+
raise argparse.ArgumentTypeError("count must be at least 1")
|
|
33
|
+
if index < 1 or index > count:
|
|
34
|
+
raise argparse.ArgumentTypeError(f"index must be between 1 and {count}")
|
|
35
|
+
return Shard(index=index, count=count)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def selected_group_keys(keys: Sequence[Hashable], shard: Shard) -> set[Hashable]:
|
|
39
|
+
"""Select stable first-seen groups for *shard* using round-robin assignment."""
|
|
40
|
+
|
|
41
|
+
unique_keys = list(dict.fromkeys(keys))
|
|
42
|
+
return {
|
|
43
|
+
key
|
|
44
|
+
for position, key in enumerate(unique_keys)
|
|
45
|
+
if position % shard.count == shard.index - 1
|
|
46
|
+
}
|