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/__init__.py +3 -0
- cf_tool/__main__.py +6 -0
- cf_tool/build.py +211 -0
- cf_tool/cli/__init__.py +117 -0
- cf_tool/cli/account.py +32 -0
- cf_tool/cli/common.py +262 -0
- cf_tool/cli/completion.py +226 -0
- cf_tool/cli/configure.py +386 -0
- cf_tool/cli/problems.py +399 -0
- cf_tool/cli/setup.py +195 -0
- cf_tool/cli/submitting.py +309 -0
- cf_tool/cli/testing.py +354 -0
- cf_tool/codeforces/__init__.py +3 -0
- cf_tool/codeforces/browser.py +346 -0
- cf_tool/codeforces/client.py +521 -0
- cf_tool/codeforces/crypto.py +41 -0
- cf_tool/codeforces/gateway.py +288 -0
- cf_tool/codeforces/http.py +213 -0
- cf_tool/codeforces/pages.py +767 -0
- cf_tool/codeforces/session.py +103 -0
- cf_tool/doctor.py +124 -0
- cf_tool/errors.py +37 -0
- cf_tool/judge.py +481 -0
- cf_tool/languages.py +1195 -0
- cf_tool/listen.py +178 -0
- cf_tool/paths.py +57 -0
- cf_tool/process.py +431 -0
- cf_tool/settings.py +386 -0
- cf_tool/stress.py +167 -0
- cf_tool/ui.py +455 -0
- cf_tool/views.py +306 -0
- cf_tool/workspace.py +777 -0
- cf_tool_cli-1.0.0.dist-info/METADATA +106 -0
- cf_tool_cli-1.0.0.dist-info/RECORD +38 -0
- cf_tool_cli-1.0.0.dist-info/WHEEL +5 -0
- cf_tool_cli-1.0.0.dist-info/entry_points.txt +2 -0
- cf_tool_cli-1.0.0.dist-info/licenses/LICENSE +48 -0
- cf_tool_cli-1.0.0.dist-info/top_level.txt +1 -0
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()
|