cf-tool-cli 1.0.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.
cf_tool/judge.py ADDED
@@ -0,0 +1,481 @@
1
+ """cf test: run the built code on each test, compare what it prints with the
2
+ answer, and report.
3
+
4
+ Outputs are compared the way Codeforces' usual checker compares them: word
5
+ by word, so spaces and line breaks don't matter. What the statement allows
6
+ widens that (cf get saved it, see check_for): numbers within an error, any
7
+ letter case. When it accepts any right answer, an output unlike the
8
+ sample's is marked as differing (?), not as wrong. --strict compares trimmed
9
+ lines instead, and a checker program (run as `checker input output answer`,
10
+ testlib's order) decides by itself.
11
+ An interactive problem runs against an interactor (`interactor input
12
+ output`), whose exit code is the verdict.
13
+
14
+ The results are kept by the code's hash, so cf submit can tell when this
15
+ exact code failed its tests.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ import os
23
+ import re
24
+ import tempfile
25
+ import time
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import Callable, Sequence
29
+
30
+ from cf_tool import paths, process, ui, views
31
+ from cf_tool.build import Program
32
+ from cf_tool.errors import CFError
33
+ from cf_tool.workspace import ProblemInfo, Test
34
+
35
+ DEFAULT_TIMEOUT = 10.0 # seconds a test may run when the time limit is unknown
36
+ CHECKER_TIMEOUT = 60.0 # seconds
37
+ RESULTS_KEPT = 200 # code versions whose results are remembered
38
+ DIFFERS = "differs" # a verdict: unlike the sample's answer, where any right one is accepted
39
+
40
+
41
+ def timeout_for(time_limit: float | None) -> float:
42
+ """Seconds a test may run before it is stopped: a few times the limit."""
43
+ if time_limit is None:
44
+ return DEFAULT_TIMEOUT
45
+ return max(3 * time_limit, time_limit + 2) # NOTE: a slower machine than the judge's passes
46
+
47
+
48
+ # ┌──────────────────────────────────────────────────────────┐
49
+ # │ Comparison │
50
+ # └──────────────────────────────────────────────────────────┘
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Check:
55
+ """How a test's output is compared with its answer."""
56
+
57
+ epsilon: float | None = None # numbers within this, absolute or relative
58
+ ignore_case: bool = False
59
+ strict: bool = False # trimmed lines, not words
60
+ checker: Program | None = None # a built checker that decides instead
61
+ any_answer: bool = False # the answer is one of the right ones: another isn't wrong
62
+
63
+ def describe(self) -> str:
64
+ """What this check accepts beyond the exact words of the answer; ""
65
+ when nothing."""
66
+ if self.checker is not None:
67
+ return f"Judging with {self.checker.label}"
68
+ allowed = []
69
+ if self.strict:
70
+ allowed.append("only the answer's exact lines")
71
+ if self.epsilon is not None:
72
+ allowed.append(f"numbers within {self.epsilon:g}")
73
+ if self.ignore_case:
74
+ allowed.append("any letter case")
75
+ text = "Accepting " + " and ".join(allowed) if allowed else ""
76
+ if self.any_answer:
77
+ note = "Any right answer is accepted, so an output unlike the sample's is marked ?"
78
+ text = f"{text}. {note}" if text else note
79
+ return text
80
+
81
+
82
+ def check_for(
83
+ info: ProblemInfo | None,
84
+ *,
85
+ epsilon: float | None = None,
86
+ ignore_case: bool = False,
87
+ strict: bool = False,
88
+ exact: bool = False,
89
+ checker: Program | None = None,
90
+ ) -> Check:
91
+ """How to compare: the options given win; unless exact, what the
92
+ statement allows (saved by cf get) fills in the rest."""
93
+ if checker is not None:
94
+ return Check(checker=checker)
95
+ any_answer = False
96
+ if not exact and info is not None:
97
+ epsilon = info.epsilon if epsilon is None else epsilon
98
+ ignore_case = ignore_case or info.ignore_case
99
+ any_answer = info.any_answer
100
+ return Check(epsilon=epsilon, ignore_case=ignore_case, strict=strict, any_answer=any_answer)
101
+
102
+
103
+ def trimmed(text: str) -> str:
104
+ """Each line without its surrounding spaces, and no blank lines at the end."""
105
+ return "".join(line.strip() + "\n" for line in text.rstrip().splitlines())
106
+
107
+
108
+ _NUMBER_RE = re.compile(r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?")
109
+
110
+
111
+ def _words_match(got: str, want: str, epsilon: float) -> bool:
112
+ """An output word against the answer's: numbers within epsilon, absolute
113
+ or relative (as testlib checks them), anything else exactly."""
114
+ if _NUMBER_RE.fullmatch(got) and _NUMBER_RE.fullmatch(want):
115
+ return abs(float(got) - float(want)) <= epsilon * max(1.0, abs(float(want))) + 1e-15
116
+ return got == want
117
+
118
+
119
+ def same_output(out: str, ans: str, check: Check) -> bool:
120
+ """Whether out counts as ans:
121
+ - strict: the same trimmed lines
122
+ - with an epsilon: word by word, numbers within it
123
+ - otherwise: the same words
124
+ each in any letter case when allowed"""
125
+ if check.ignore_case:
126
+ out, ans = out.lower(), ans.lower()
127
+ if check.strict:
128
+ return trimmed(out) == trimmed(ans)
129
+ got, want = out.split(), ans.split()
130
+ if check.epsilon is None:
131
+ return got == want
132
+ return len(got) == len(want) and all(
133
+ _words_match(g, w, check.epsilon) for g, w in zip(got, want)
134
+ )
135
+
136
+
137
+ def _run_checker(checker: Program, test: Test, output: bytes) -> tuple[bool, str]:
138
+ """The checker's verdict on output: (right, what it said)."""
139
+ fd, out_name = tempfile.mkstemp(prefix="cf-output-", suffix=".txt")
140
+ try:
141
+ with os.fdopen(fd, "wb") as f:
142
+ f.write(output)
143
+ answer = str(test.answer) if test.answer is not None else os.devnull
144
+ result = process.run(
145
+ [*checker.argv(), str(test.input), out_name, answer],
146
+ cwd=checker.source.parent,
147
+ timeout=CHECKER_TIMEOUT,
148
+ )
149
+ finally:
150
+ try:
151
+ os.unlink(out_name)
152
+ except OSError:
153
+ pass
154
+ said = (result.stderr or result.stdout).decode(errors="replace").strip()
155
+ if result.status == process.TIMEOUT:
156
+ return False, f"the checker ran over {CHECKER_TIMEOUT:g}s"
157
+ return result.ok, said
158
+
159
+
160
+ # ┌──────────────────────────────────────────────────────────┐
161
+ # │ Reports │
162
+ # └──────────────────────────────────────────────────────────┘
163
+
164
+
165
+ def format_memory(num_bytes: float) -> str:
166
+ if num_bytes >= 1024 * 1024:
167
+ return f"{num_bytes / 1024 / 1024:.1f} MB"
168
+ if num_bytes >= 1024:
169
+ return f"{num_bytes / 1024:.0f} KB"
170
+ return f"{int(num_bytes)} B"
171
+
172
+
173
+ def _usage(result: process.Result, info: ProblemInfo | None) -> list[tuple[str, str]]:
174
+ """Styled " 0.012s 3.1 MB" for a verdict line, flagged over a limit."""
175
+ time_limit = info.time_limit if info else None
176
+ memory_limit = info.memory_limit if info else None
177
+ over_time = time_limit is not None and result.cpu > time_limit
178
+ over_memory = memory_limit is not None and result.memory > memory_limit * 1024 * 1024
179
+ parts = [(" ", ""), (f"{result.cpu:.3f}s", "yellow" if over_time else "dim")]
180
+ if result.memory:
181
+ parts += [(" ", ""), (format_memory(result.memory), "yellow" if over_memory else "dim")]
182
+ if over_time:
183
+ parts.append((f" over the {time_limit:g}s limit", "yellow"))
184
+ if over_memory:
185
+ parts.append((f" over the {memory_limit} MB limit", "yellow"))
186
+ return parts
187
+
188
+
189
+ def _verdict(mark: str, style: str, test: Test, *rest: tuple[str, str]) -> None:
190
+ ui.line((f"{mark} ", style), (test.name, "bold"), *rest)
191
+
192
+
193
+ def _show_failure(result: process.Result) -> None:
194
+ """What a program printed before it failed: its output, and its last error line."""
195
+ output = result.stdout.decode(errors="replace")
196
+ if output.strip():
197
+ ui.console.print(views.program_output(output))
198
+ message = result.stderr.decode(errors="replace").strip()
199
+ if message:
200
+ ui.line(("stderr: ", "red"), (message.splitlines()[-1], ""))
201
+
202
+
203
+ # ┌──────────────────────────────────────────────────────────┐
204
+ # │ Running │
205
+ # └──────────────────────────────────────────────────────────┘
206
+
207
+
208
+ def run_test(
209
+ program: Program,
210
+ test: Test,
211
+ *,
212
+ info: ProblemInfo | None = None,
213
+ check: Check = Check(),
214
+ accept: bool = False,
215
+ ) -> bool | str | None:
216
+ """Run one test and print its verdict; returns whether it passed, None
217
+ when it has no answer to pass, DIFFERS when it may well be right:
218
+ - output that counts as the answer: passed, with time and memory
219
+ - running past its timeout: time limit exceeded
220
+ - printing past the output limit: output limit exceeded
221
+ - a crash or an exit code other than 0: runtime error, with what it printed
222
+ - no answer: its output is shown (and kept as the answer with accept)
223
+ - other output where any right answer is accepted: differs (?), shown
224
+ beside the answer
225
+ - other output: wrong answer, with output and answer side by side"""
226
+ timeout = timeout_for(info.time_limit if info else None)
227
+ result = program.run(stdin=test.input, timeout=timeout)
228
+ if result.status == process.TIMEOUT:
229
+ _verdict("✗", "red", test, (" time limit exceeded", "red"), (f" ({result.reason()})", ""))
230
+ return False
231
+ if result.status == process.OUTPUT:
232
+ _verdict(
233
+ "✗", "red", test, (" output limit exceeded", "red"), (f" ({result.reason()})", "")
234
+ )
235
+ return False
236
+ usage = _usage(result, info)
237
+ if not result.ok:
238
+ _verdict(
239
+ "✗", "red", test, *usage, (" runtime error", "red"), (f" ({result.reason()})", "")
240
+ )
241
+ _show_failure(result)
242
+ return False
243
+ output = result.stdout.decode(errors="replace")
244
+ if test.answer is None:
245
+ _verdict("•", "cyan", test, *usage, (" no answer to compare with", "dim"))
246
+ ui.console.print(views.program_output(output))
247
+ if accept:
248
+ answer = test.input.with_suffix(".ans")
249
+ if test.input.name.startswith("in") and test.input.suffix == ".txt":
250
+ answer = test.input.with_name(f"ans{test.name}.txt") # NOTE: cf-tool 0.3's names
251
+ answer.write_text(output, encoding="utf-8", newline="")
252
+ ui.green(f"Kept the output as the answer: {answer.name}")
253
+ return None
254
+ answer_text = test.answer.read_text(encoding="utf-8", errors="replace")
255
+ if check.checker is not None:
256
+ passed, said = _run_checker(check.checker, test, result.stdout)
257
+ else:
258
+ passed, said = same_output(output, answer_text, check), ""
259
+ if passed:
260
+ note = [(f" {said.splitlines()[0]}", "dim")] if said else []
261
+ _verdict("✓", "green", test, *usage, *note)
262
+ return True
263
+ differs = check.any_answer and check.checker is None
264
+ if differs:
265
+ _verdict("?", "yellow", test, *usage, (" differs from the sample's answer", "yellow"))
266
+ else:
267
+ _verdict("✗", "red", test, *usage, (" wrong answer", "red"))
268
+ if said:
269
+ ui.line(("checker: ", "red"), (said, ""))
270
+ sample_input = test.input.read_text(encoding="utf-8", errors="replace")
271
+ cases = info.sample_cases(test.name) if info else None
272
+ if check.strict or check.checker is not None:
273
+ out_lines, ans_lines = trimmed(output).splitlines(), trimmed(answer_text).splitlines()
274
+ else:
275
+ out_lines, ans_lines = output.strip().splitlines(), answer_text.strip().splitlines()
276
+ ui.console.print(
277
+ views.failed_sample(
278
+ sample_input,
279
+ out_lines,
280
+ ans_lines,
281
+ cases,
282
+ lambda o, a: (
283
+ same_output(o, a, check) if check.checker is None else o.strip() == a.strip()
284
+ ),
285
+ )
286
+ )
287
+ return DIFFERS if differs else False
288
+
289
+
290
+ INTERACTOR_VERDICTS = {1: "wrong answer", 2: "presentation error", 3: "the interactor failed"}
291
+
292
+
293
+ def run_interactive(
294
+ program: Program, interactor: Program, test: Test, *, info: ProblemInfo | None = None
295
+ ) -> bool:
296
+ """Run one test of an interactive problem against the interactor and
297
+ print the verdict; returns whether it passed. The interactor gets the
298
+ test's input and answer files, and its exit code is the verdict."""
299
+ timeout = timeout_for(info.time_limit if info else None)
300
+ fd, out_name = tempfile.mkstemp(prefix="cf-interactor-", suffix=".txt")
301
+ os.close(fd)
302
+ try:
303
+ extra = [str(test.input), out_name] + ([str(test.answer)] if test.answer else [])
304
+ solution, judge = process.interact(
305
+ program.argv(),
306
+ [*interactor.argv(), *extra],
307
+ cwd=interactor.source.parent,
308
+ solution_cwd=program.source.parent,
309
+ timeout=timeout,
310
+ big_stack=program.setup.big_stack,
311
+ )
312
+ finally:
313
+ try:
314
+ os.unlink(out_name)
315
+ except OSError:
316
+ pass
317
+ said = judge.stderr.decode(errors="replace").strip()
318
+ usage = _usage(solution, info)
319
+ if judge.status == process.TIMEOUT and solution.status == process.TIMEOUT:
320
+ _verdict(
321
+ "✗", "red", test, (" time limit exceeded", "red"), (f" ({solution.reason()})", "")
322
+ )
323
+ return False
324
+ if judge.status == process.TIMEOUT:
325
+ _verdict("✗", "red", test, *usage, (" the interactor waited for more", "red"))
326
+ return False
327
+ if not judge.ok: # NOTE: its verdict comes first: it may be why the solution failed
328
+ verdict = INTERACTOR_VERDICTS.get(judge.code or 0, f"the interactor's {judge.reason()}")
329
+ _verdict("✗", "red", test, *usage, (f" {verdict}", "red"))
330
+ if said:
331
+ ui.line(("interactor: ", "red"), (said, ""))
332
+ return False
333
+ if solution.status == process.TIMEOUT:
334
+ _verdict("✗", "red", test, (" didn't end after the interactor finished", "red"))
335
+ return False
336
+ if not solution.ok:
337
+ _verdict(
338
+ "✗", "red", test, *usage, (" runtime error", "red"), (f" ({solution.reason()})", "")
339
+ )
340
+ _show_failure(solution)
341
+ return False
342
+ note = [(f" {said.splitlines()[0]}", "dim")] if said else []
343
+ _verdict("✓", "green", test, *usage, *note)
344
+ return True
345
+
346
+
347
+ @dataclass
348
+ class Summary:
349
+ passed: list[str]
350
+ failed: list[str]
351
+ ran: list[str] # tests without an answer
352
+ differs: list[str] = field(default_factory=list) # unlike the answer, maybe right
353
+
354
+
355
+ def run_tests(
356
+ program: Program,
357
+ tests: Sequence[Test],
358
+ *,
359
+ info: ProblemInfo | None = None,
360
+ check: Check = Check(),
361
+ accept: bool = False,
362
+ interactor: Program | None = None,
363
+ ) -> Summary:
364
+ """Run the tests one by one, then print how many passed; returns which
365
+ passed, which didn't, and which differ where any right answer goes."""
366
+ summary = Summary([], [], [])
367
+ if check.describe():
368
+ ui.line((check.describe(), "dim"))
369
+ for test in tests:
370
+ try:
371
+ if interactor is not None:
372
+ verdict: bool | str | None = run_interactive(program, interactor, test, info=info)
373
+ else:
374
+ verdict = run_test(program, test, info=info, check=check, accept=accept)
375
+ except CFError as e:
376
+ ui.red(f"{test.name}: {e}")
377
+ verdict = False
378
+ verdicts = {
379
+ True: summary.passed,
380
+ False: summary.failed,
381
+ None: summary.ran,
382
+ DIFFERS: summary.differs,
383
+ }
384
+ verdicts[verdict].append(test.name)
385
+ judged = len(summary.passed) + len(summary.failed) + len(summary.differs)
386
+ if judged:
387
+ if summary.failed or summary.differs:
388
+ text = f"{len(summary.passed)} of {judged} passed"
389
+ else:
390
+ text = f"All {judged} passed"
391
+ if summary.differs:
392
+ # NOTE: not wrong for sure: any right answer is accepted
393
+ text += f", {len(summary.differs)} differ from the samples' answers (check them)"
394
+ if summary.ran:
395
+ text += f", {len(summary.ran)} without an answer"
396
+ if summary.failed:
397
+ mark = ("✗ ", "red bold")
398
+ else:
399
+ mark = ("? ", "yellow bold") if summary.differs else ("✓ ", "green bold")
400
+ ui.line(mark, (text, "bold"))
401
+ return summary
402
+
403
+
404
+ # ┌──────────────────────────────────────────────────────────┐
405
+ # │ Remembered results, watching │
406
+ # └──────────────────────────────────────────────────────────┘
407
+
408
+
409
+ def _code_key(source: Path) -> str:
410
+ return hashlib.sha256(source.read_bytes()).hexdigest()[:32]
411
+
412
+
413
+ def remember(source: Path, summary: Summary) -> None:
414
+ """Keep how this exact code did on its tests (for cf submit's warning)."""
415
+ path = paths.STATE_DIR / "results.json"
416
+ try:
417
+ results = json.loads(path.read_text(encoding="utf-8"))
418
+ if not isinstance(results, dict):
419
+ results = {}
420
+ except (OSError, ValueError):
421
+ results = {}
422
+ try:
423
+ key = _code_key(source)
424
+ except OSError:
425
+ return
426
+ results.pop(key, None)
427
+ results[key] = {
428
+ "failed": summary.failed,
429
+ "passed": summary.passed,
430
+ "differs": summary.differs,
431
+ "when": time.time(),
432
+ }
433
+ kept = dict(list(results.items())[-RESULTS_KEPT:])
434
+ try:
435
+ paths.write_atomic(path, json.dumps(kept) + "\n")
436
+ except OSError:
437
+ pass
438
+
439
+
440
+ def failed_tests(source: Path) -> list[str]:
441
+ """The tests this exact code failed when last tested; [] when it passed
442
+ them or wasn't tested."""
443
+ try:
444
+ results = json.loads((paths.STATE_DIR / "results.json").read_text(encoding="utf-8"))
445
+ entry = results.get(_code_key(source)) or {}
446
+ return [str(name) for name in entry.get("failed", [])]
447
+ except (OSError, ValueError, AttributeError):
448
+ return []
449
+
450
+
451
+ def watch(
452
+ files: Callable[[], list[Path]], run_once: Callable[[], None], interval: float = 0.3
453
+ ) -> None:
454
+ """Run run_once now, and again each time one of files() changes, until
455
+ Ctrl-C."""
456
+
457
+ def stamps() -> list[tuple[str, int]]:
458
+ found = []
459
+ for path in files():
460
+ try:
461
+ found.append((str(path), path.stat().st_mtime_ns))
462
+ except OSError:
463
+ pass
464
+ return found
465
+
466
+ last = None
467
+ try:
468
+ while True:
469
+ current = stamps() # NOTE: taken before the run, so a save during it counts
470
+ if current != last:
471
+ if last is not None:
472
+ ui.line(("── changed: testing again ──", "dim"))
473
+ last = current
474
+ try:
475
+ run_once()
476
+ except CFError as e:
477
+ ui.red(str(e))
478
+ ui.line(("Watching for changes (Ctrl-C ends it)", "dim"))
479
+ time.sleep(interval)
480
+ except KeyboardInterrupt:
481
+ print()