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.
@@ -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
+ }