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,293 @@
1
+ """Run lifecycle fuzz tests for questions changed between two Git refs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import secrets
8
+ import shlex
9
+ import subprocess
10
+ from collections.abc import Sequence
11
+ from pathlib import Path
12
+ from tempfile import TemporaryDirectory
13
+
14
+ from .discovery import variant_seeds
15
+ from .reporting import write_empty_shard_report
16
+ from .runner import run_tests
17
+ from .sharding import Shard, parse_shard
18
+
19
+ QUESTIONS_ROOT = Path("questions")
20
+ DEFAULT_E2E_DIFF_IGNORE = Path(".question-e2e.diffignore")
21
+
22
+
23
+ def git_repository_root(start: Path) -> Path:
24
+ """Return the Git repository containing *start*."""
25
+
26
+ result = subprocess.run(
27
+ ["git", "-C", os.fspath(start), "rev-parse", "--show-toplevel"],
28
+ check=True,
29
+ capture_output=True,
30
+ text=True,
31
+ )
32
+ return Path(result.stdout.strip()).resolve()
33
+
34
+
35
+ def load_e2e_diff_ignore(config_path: Path, *, missing_ok: bool = False) -> list[str]:
36
+ """Load Git-style ignore rules used by E2E change detection."""
37
+
38
+ try:
39
+ return config_path.read_text(encoding="utf-8").splitlines()
40
+ except FileNotFoundError:
41
+ if missing_ok:
42
+ return []
43
+ raise ValueError(f"Unable to read {config_path}: file does not exist") from None
44
+ except OSError as exc:
45
+ raise ValueError(f"Unable to read {config_path}: {exc}") from exc
46
+
47
+
48
+ def e2e_diff_ignored_files(
49
+ changed_files: Sequence[Path], ignore_rules: Sequence[str]
50
+ ) -> set[Path]:
51
+ """Return changed files matched using Git's native ignore semantics."""
52
+
53
+ if not changed_files or not ignore_rules:
54
+ return set()
55
+
56
+ with TemporaryDirectory(prefix="e2e-diff-ignore-") as temporary_directory:
57
+ temporary_root = Path(temporary_directory)
58
+ repository = temporary_root / "repository"
59
+ ignore_file = temporary_root / "exclude"
60
+ ignore_file.write_text("\n".join(ignore_rules), encoding="utf-8")
61
+
62
+ subprocess.run(
63
+ ["git", "init", "--quiet", os.fspath(repository)],
64
+ check=True,
65
+ stdout=subprocess.DEVNULL,
66
+ stderr=subprocess.PIPE,
67
+ )
68
+ result = subprocess.run(
69
+ [
70
+ "git",
71
+ "-C",
72
+ os.fspath(repository),
73
+ "-c",
74
+ f"core.excludesFile={ignore_file}",
75
+ "check-ignore",
76
+ "--no-index",
77
+ "--stdin",
78
+ "-z",
79
+ ],
80
+ check=False,
81
+ capture_output=True,
82
+ input=b"\0".join(os.fsencode(path) for path in changed_files) + b"\0",
83
+ )
84
+ if result.returncode not in (0, 1):
85
+ raise subprocess.CalledProcessError(
86
+ result.returncode,
87
+ result.args,
88
+ output=result.stdout,
89
+ stderr=result.stderr,
90
+ )
91
+
92
+ return {Path(os.fsdecode(path)) for path in result.stdout.split(b"\0") if path}
93
+
94
+
95
+ def nonignored_files_outside_questions(
96
+ changed_files: Sequence[Path], ignore_rules: Sequence[str]
97
+ ) -> list[Path]:
98
+ """Return sorted changed files that cause every question to be tested."""
99
+
100
+ ignored_files = e2e_diff_ignored_files(changed_files, ignore_rules)
101
+ return sorted(
102
+ (
103
+ path
104
+ for path in changed_files
105
+ if QUESTIONS_ROOT not in path.parents and path not in ignored_files
106
+ ),
107
+ key=lambda path: path.as_posix(),
108
+ )
109
+
110
+
111
+ def log_full_question_run(
112
+ changed_files: Sequence[Path], ignored_patterns: Sequence[str]
113
+ ) -> None:
114
+ """Log the changed files responsible for testing every question."""
115
+
116
+ outside_files = nonignored_files_outside_questions(changed_files, ignored_patterns)
117
+ print("Non-ignored changed files outside questions; testing every question:")
118
+ for path in outside_files:
119
+ print(f" - {shlex.quote(path.as_posix())}")
120
+
121
+
122
+ def question_paths_from_changed_files(
123
+ changed_files: Sequence[Path],
124
+ repository_root: Path,
125
+ ignore_rules: Sequence[str] = (),
126
+ ) -> list[Path]:
127
+ """Select changed questions, or all questions after a shared-file change."""
128
+
129
+ ignored_files = e2e_diff_ignored_files(changed_files, ignore_rules)
130
+ relevant_files = [path for path in changed_files if path not in ignored_files]
131
+ if any(QUESTIONS_ROOT not in path.parents for path in relevant_files):
132
+ return [QUESTIONS_ROOT]
133
+
134
+ question_paths: set[Path] = set()
135
+ for changed_file in relevant_files:
136
+ candidate = changed_file.parent
137
+ while candidate != QUESTIONS_ROOT and QUESTIONS_ROOT in candidate.parents:
138
+ if (repository_root / candidate / "info.json").is_file():
139
+ question_paths.add(candidate)
140
+ break
141
+ candidate = candidate.parent
142
+
143
+ return sorted(question_paths, key=lambda path: path.as_posix())
144
+
145
+
146
+ def changed_files_between(base: str, head: str, repository_root: Path) -> list[Path]:
147
+ """Return repository-relative files changed between two Git refs."""
148
+
149
+ result = subprocess.run(
150
+ [
151
+ "git",
152
+ "diff",
153
+ "--name-only",
154
+ "-z",
155
+ "--no-renames",
156
+ "--diff-filter=ACDMRT",
157
+ base,
158
+ head,
159
+ "--",
160
+ ],
161
+ cwd=repository_root,
162
+ check=True,
163
+ stdout=subprocess.PIPE,
164
+ )
165
+ return [Path(os.fsdecode(path)) for path in result.stdout.split(b"\0") if path]
166
+
167
+
168
+ def positive_integer(value: str) -> int:
169
+ parsed = int(value)
170
+ if parsed < 1:
171
+ raise argparse.ArgumentTypeError("must be at least 1")
172
+ if parsed > 2**32:
173
+ raise argparse.ArgumentTypeError("must not exceed 2**32")
174
+ return parsed
175
+
176
+
177
+ def main(argv: Sequence[str] | None = None) -> int:
178
+ parser = argparse.ArgumentParser(description=__doc__)
179
+ parser.add_argument("--course-root", type=Path)
180
+ parser.add_argument("--base", default="main", help="base Git ref (default: main)")
181
+ parser.add_argument("--head", default="HEAD", help="head Git ref (default: HEAD)")
182
+ parser.add_argument("--seed-count", type=positive_integer, default=10)
183
+ parser.add_argument(
184
+ "--fuzz-seed",
185
+ type=int,
186
+ help="master seed for reproducible pseudo-random variant seeds",
187
+ )
188
+ parser.add_argument(
189
+ "--prairielearn-path",
190
+ type=Path,
191
+ help=(
192
+ "PrairieLearn Git repository containing the installed pinned revision "
193
+ "(default: managed user cache)"
194
+ ),
195
+ )
196
+ parser.add_argument(
197
+ "--e2ediffignore",
198
+ type=Path,
199
+ help="E2E change-detection ignore file (default: COURSE_ROOT/.question-e2e.diffignore)",
200
+ )
201
+ parser.add_argument(
202
+ "--shard",
203
+ type=parse_shard,
204
+ metavar="INDEX/COUNT",
205
+ help="run one one-based shard of the selected question variants",
206
+ )
207
+ parser.add_argument(
208
+ "--shard-report",
209
+ type=Path,
210
+ metavar="PATH",
211
+ help="write a mergeable JSON result blob for this shard",
212
+ )
213
+ args, pytest_args = parser.parse_known_args(argv)
214
+ shard: Shard | None = args.shard
215
+ if args.shard_report is not None and shard is None:
216
+ parser.error("--shard-report requires --shard")
217
+ if shard is not None and args.fuzz_seed is None:
218
+ parser.error("sharded fuzz runs require an explicit shared --fuzz-seed")
219
+
220
+ try:
221
+ repository_root = (
222
+ args.course_root.resolve()
223
+ if args.course_root is not None
224
+ else git_repository_root(Path.cwd())
225
+ )
226
+ except (OSError, subprocess.CalledProcessError) as exc:
227
+ parser.error(f"could not find the course Git repository: {exc}")
228
+
229
+ ignore_path = args.e2ediffignore
230
+ if ignore_path is None:
231
+ ignore_path = repository_root / DEFAULT_E2E_DIFF_IGNORE
232
+ missing_ok = True
233
+ else:
234
+ if not ignore_path.is_absolute():
235
+ ignore_path = repository_root / ignore_path
236
+ missing_ok = False
237
+ try:
238
+ ignored_patterns = load_e2e_diff_ignore(ignore_path, missing_ok=missing_ok)
239
+ except ValueError as exc:
240
+ parser.error(str(exc))
241
+
242
+ changed_files = changed_files_between(args.base, args.head, repository_root)
243
+ question_paths = question_paths_from_changed_files(
244
+ changed_files, repository_root, ignored_patterns
245
+ )
246
+ if not question_paths:
247
+ print("No non-ignored changed questions; skipping end-to-end fuzz tests.")
248
+ if args.shard_report is not None:
249
+ assert shard is not None
250
+ write_empty_shard_report(args.shard_report, shard, fuzz_seed=args.fuzz_seed)
251
+ return 0
252
+ if question_paths == [QUESTIONS_ROOT]:
253
+ log_full_question_run(changed_files, ignored_patterns)
254
+
255
+ fuzz_seed = args.fuzz_seed
256
+ if fuzz_seed is None:
257
+ fuzz_seed = secrets.randbits(64)
258
+ seeds = variant_seeds({}, args.seed_count, fuzz_seed)
259
+ print(f"E2E fuzz seed: {fuzz_seed}")
260
+ print(f"Variant seeds: {', '.join(str(seed) for seed in seeds)}")
261
+ replay_arguments = ["pl-e2e-test-diff", "--fuzz-seed", str(fuzz_seed)]
262
+ test_arguments: list[str] = []
263
+ if args.prairielearn_path is not None:
264
+ replay_arguments.extend(
265
+ ("--prairielearn-path", os.fspath(args.prairielearn_path))
266
+ )
267
+ test_arguments.extend(
268
+ ("--prairielearn-path", os.fspath(args.prairielearn_path))
269
+ )
270
+ if shard is not None:
271
+ replay_arguments.extend(("--shard", str(shard)))
272
+ test_arguments.extend(("--shard", str(shard)))
273
+ if args.shard_report is not None:
274
+ test_arguments.extend(("--shard-report", os.fspath(args.shard_report)))
275
+ print(f"Replay with: {shlex.join(replay_arguments)}", flush=True)
276
+
277
+ test_arguments.extend(
278
+ [
279
+ "--seed-count",
280
+ str(args.seed_count),
281
+ "--fuzz-seed",
282
+ str(fuzz_seed),
283
+ ]
284
+ )
285
+ for question_path in question_paths:
286
+ print(
287
+ f"Testing {shlex.quote(os.fspath(question_path))} "
288
+ f"with {args.seed_count} fuzz seeds.",
289
+ flush=True,
290
+ )
291
+ test_arguments.extend(("--question-path", os.fspath(question_path)))
292
+ test_arguments.extend(pytest_args)
293
+ return run_tests(test_arguments, course_root=repository_root)