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/settings.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Settings: config.toml, where every key is optional.
|
|
2
|
+
|
|
3
|
+
language = "cpp" # what cf get writes starter code in
|
|
4
|
+
root = "~/codeforces" # where cf get puts contests
|
|
5
|
+
host = "https://codeforces.com"
|
|
6
|
+
proxy = "socks5://127.0.0.1:1080"
|
|
7
|
+
|
|
8
|
+
[cpp] # one table per language, by file extension
|
|
9
|
+
compiler = "g++-15"
|
|
10
|
+
flags = "-DLOCAL"
|
|
11
|
+
submit = "G++20" # part of a Codeforces language's name, or its id
|
|
12
|
+
template = "~/cp/template.cpp"
|
|
13
|
+
build = "g++ {src} -o {exe}"
|
|
14
|
+
run = "{exe}"
|
|
15
|
+
|
|
16
|
+
tomlkit reads and writes it, so the user's comments and layout survive the
|
|
17
|
+
changes cf-tool makes (the language picked on the first cf get). The JSON
|
|
18
|
+
config of cf-tool 0.3 (and of the Go cf-tool it began as) is turned into one
|
|
19
|
+
on the first run, see migrate().
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import tomlkit
|
|
31
|
+
from tomlkit.exceptions import TOMLKitError
|
|
32
|
+
|
|
33
|
+
from cf_tool import paths
|
|
34
|
+
from cf_tool.errors import CFError
|
|
35
|
+
from cf_tool.process import split_command
|
|
36
|
+
|
|
37
|
+
DEFAULT_HOST = "https://codeforces.com"
|
|
38
|
+
TOP_KEYS = ("language", "root", "host", "proxy")
|
|
39
|
+
LANGUAGE_KEYS = ("compiler", "flags", "submit", "template", "build", "run")
|
|
40
|
+
|
|
41
|
+
DEFAULT_TEXT = """\
|
|
42
|
+
# cf-tool settings. Every line is optional: delete one to get its default back.
|
|
43
|
+
|
|
44
|
+
# The language `cf get` writes starter code in: cpp, c, py, java, kt, rs or go.
|
|
45
|
+
{language_line}
|
|
46
|
+
|
|
47
|
+
# Where `cf get` puts contests (default: the folder you run it in).
|
|
48
|
+
# root = "~/codeforces"
|
|
49
|
+
|
|
50
|
+
# host = "https://codeforces.com"
|
|
51
|
+
# The proxy for Codeforces (default: the environment's HTTPS_PROXY / ALL_PROXY).
|
|
52
|
+
# proxy = "socks5://127.0.0.1:1080"
|
|
53
|
+
|
|
54
|
+
# One table per language, named by its file extension; every key is optional.
|
|
55
|
+
# [cpp]
|
|
56
|
+
# compiler = "g++-15" # default: the best one found here, GCC first
|
|
57
|
+
# flags = "-DLOCAL" # added after the default flags
|
|
58
|
+
# submit = "G++20" # part of a Codeforces language's name, or its id
|
|
59
|
+
# # (cf langs --all lists them); default: the newest
|
|
60
|
+
# template = "~/cp/template.cpp" # starter code for new problems
|
|
61
|
+
# build = "g++ -std=c++20 -O2 {{src}} -o {{exe}}" # replaces the whole build command
|
|
62
|
+
# run = "{{exe}}" # replaces the run command
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def default_text(language: str) -> str:
|
|
67
|
+
"""config.toml as cf-tool first writes it: every setting explained, the
|
|
68
|
+
language set when known (else cf get asks for it)."""
|
|
69
|
+
line = f'language = "{language}"' if language else LANGUAGE_UNSET
|
|
70
|
+
return DEFAULT_TEXT.format(language_line=line)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
LANGUAGE_UNSET = '# language = "cpp" (cf get asks)'
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class LanguageSettings:
|
|
78
|
+
"""One language's table in config.toml; empty values mean the default."""
|
|
79
|
+
|
|
80
|
+
compiler: str = ""
|
|
81
|
+
flags: tuple[str, ...] = ()
|
|
82
|
+
submit: str = ""
|
|
83
|
+
template: str = ""
|
|
84
|
+
build: tuple[str, ...] | None = None # None: the built-in command; (): nothing to build
|
|
85
|
+
run: tuple[str, ...] | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Settings:
|
|
90
|
+
language: str = "" # "" until picked
|
|
91
|
+
root: str = ""
|
|
92
|
+
host: str = DEFAULT_HOST
|
|
93
|
+
proxy: str = ""
|
|
94
|
+
languages: dict[str, LanguageSettings] = field(default_factory=dict)
|
|
95
|
+
warnings: list[str] = field(default_factory=list) # unknown keys, worth telling the user
|
|
96
|
+
path: Path | None = None # where they are saved (None: paths.config_file())
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def load(cls, path: Path | None = None) -> "Settings":
|
|
100
|
+
"""The settings in config.toml; the defaults when it doesn't exist.
|
|
101
|
+
Raises CFError for a file that isn't valid TOML or holds a wrong value."""
|
|
102
|
+
path = path or paths.config_file()
|
|
103
|
+
try:
|
|
104
|
+
text = path.read_text(encoding="utf-8")
|
|
105
|
+
except FileNotFoundError:
|
|
106
|
+
return cls(path=path)
|
|
107
|
+
except OSError as e:
|
|
108
|
+
raise CFError(f"Cannot read {path}: {e.strerror or e}") from e
|
|
109
|
+
try:
|
|
110
|
+
data = tomlkit.parse(text).unwrap()
|
|
111
|
+
except TOMLKitError as e:
|
|
112
|
+
# NOTE: the usual slip on Windows: in "C:\path" a backslash escapes
|
|
113
|
+
hint = " (write a Windows path as 'C:\\path' or C:/path)" if "\\" in text else ""
|
|
114
|
+
raise CFError(f"{path} isn't valid TOML: {e}{hint}") from e
|
|
115
|
+
return _from_data(data, path)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def file(self) -> Path:
|
|
119
|
+
return self.path or paths.config_file()
|
|
120
|
+
|
|
121
|
+
def language_settings(self, key: str) -> LanguageSettings:
|
|
122
|
+
return self.languages.get(key, LanguageSettings())
|
|
123
|
+
|
|
124
|
+
def root_folder(self) -> Path | None:
|
|
125
|
+
"""Where cf get puts contests, if set."""
|
|
126
|
+
return Path(self.root).expanduser() if self.root else None
|
|
127
|
+
|
|
128
|
+
def update(self, key: str, value: Any, table: str | None = None) -> None:
|
|
129
|
+
"""Set one setting (in a language's table when given) and save
|
|
130
|
+
config.toml, keeping everything else in it as it was."""
|
|
131
|
+
|
|
132
|
+
def edit(doc) -> None:
|
|
133
|
+
if table is None:
|
|
134
|
+
doc[key] = value
|
|
135
|
+
return
|
|
136
|
+
if table not in doc:
|
|
137
|
+
doc[table] = tomlkit.table()
|
|
138
|
+
doc[table][key] = value
|
|
139
|
+
|
|
140
|
+
language = value if (key, table) == ("language", None) else self.language
|
|
141
|
+
self._change(edit, language, key == "language" and table is None and value)
|
|
142
|
+
|
|
143
|
+
def remove(self, key: str, table: str | None = None) -> None:
|
|
144
|
+
"""Take a setting out of config.toml (back to its default), keeping
|
|
145
|
+
everything else; a language's table left empty goes too."""
|
|
146
|
+
|
|
147
|
+
def edit(doc) -> None:
|
|
148
|
+
holder = doc if table is None else doc.get(table)
|
|
149
|
+
if holder is not None and key in holder:
|
|
150
|
+
del holder[key]
|
|
151
|
+
if table is not None and table in doc and not len(doc[table]):
|
|
152
|
+
del doc[table]
|
|
153
|
+
|
|
154
|
+
if not self.file.exists():
|
|
155
|
+
return
|
|
156
|
+
self._change(edit, self.language)
|
|
157
|
+
|
|
158
|
+
def _change(self, edit, language: str, new_language: Any = None) -> None:
|
|
159
|
+
"""Apply edit to config.toml's document and save it, then read the
|
|
160
|
+
settings back into self. A missing file starts as the explained one."""
|
|
161
|
+
path = self.file
|
|
162
|
+
try:
|
|
163
|
+
text = path.read_text(encoding="utf-8")
|
|
164
|
+
except FileNotFoundError:
|
|
165
|
+
text = ""
|
|
166
|
+
except OSError as e:
|
|
167
|
+
raise CFError(f"Cannot read {path}: {e.strerror or e}") from e
|
|
168
|
+
if not text:
|
|
169
|
+
text = default_text(language)
|
|
170
|
+
elif new_language and LANGUAGE_UNSET in text:
|
|
171
|
+
text = text.replace(LANGUAGE_UNSET, f'language = "{new_language}"') # NOTE: in place
|
|
172
|
+
try:
|
|
173
|
+
doc = tomlkit.parse(text)
|
|
174
|
+
except TOMLKitError as e:
|
|
175
|
+
raise CFError(f"{path} isn't valid TOML: {e}") from e
|
|
176
|
+
edit(doc)
|
|
177
|
+
new_text = tomlkit.dumps(doc)
|
|
178
|
+
updated = _from_data(tomlkit.parse(new_text).unwrap(), path)
|
|
179
|
+
paths.write_atomic(path, new_text)
|
|
180
|
+
self.language, self.root, self.host, self.proxy = (
|
|
181
|
+
updated.language,
|
|
182
|
+
updated.root,
|
|
183
|
+
updated.host,
|
|
184
|
+
updated.proxy,
|
|
185
|
+
)
|
|
186
|
+
self.languages = updated.languages
|
|
187
|
+
|
|
188
|
+
def write_default(self) -> Path:
|
|
189
|
+
"""Create config.toml with every setting explained, if it is missing;
|
|
190
|
+
returns its path."""
|
|
191
|
+
path = self.file
|
|
192
|
+
if not path.exists():
|
|
193
|
+
paths.write_atomic(path, default_text(self.language))
|
|
194
|
+
return path
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _text(value: Any, where: str) -> str:
|
|
198
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
199
|
+
return str(value)
|
|
200
|
+
if not isinstance(value, str):
|
|
201
|
+
raise CFError(f'{where} should be text, like "..."')
|
|
202
|
+
return value
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _words(value: Any, where: str) -> tuple[str, ...]:
|
|
206
|
+
"""A command or flags: one string (split like a command line) or a list of words."""
|
|
207
|
+
if isinstance(value, str):
|
|
208
|
+
return tuple(split_command(value))
|
|
209
|
+
if isinstance(value, list) and all(isinstance(word, str) for word in value):
|
|
210
|
+
return tuple(value)
|
|
211
|
+
raise CFError(f'{where} should be text, like "...", or a list of words')
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _from_data(data: dict[str, Any], path: Path) -> Settings:
|
|
215
|
+
"""Settings from parsed config.toml data. Raises CFError for a wrong
|
|
216
|
+
value; unknown keys become warnings."""
|
|
217
|
+
settings = Settings(path=path)
|
|
218
|
+
name = path.name
|
|
219
|
+
for key, value in data.items():
|
|
220
|
+
where = f"{name}: {key}"
|
|
221
|
+
if isinstance(value, dict):
|
|
222
|
+
settings.languages[key.lower().lstrip(".")] = _language_from(
|
|
223
|
+
value, f"{name}: [{key}]", settings
|
|
224
|
+
)
|
|
225
|
+
elif key == "language":
|
|
226
|
+
settings.language = _text(value, where).strip().lower().lstrip(".")
|
|
227
|
+
elif key == "root":
|
|
228
|
+
settings.root = _text(value, where).strip()
|
|
229
|
+
elif key == "host":
|
|
230
|
+
host = _text(value, where).strip().rstrip("/")
|
|
231
|
+
if not re.match(r"https?://[\w.-]+(:\d+)?$", host):
|
|
232
|
+
raise CFError(f'{where} should be like "https://codeforces.com"')
|
|
233
|
+
settings.host = host
|
|
234
|
+
elif key == "proxy":
|
|
235
|
+
settings.proxy = _text(value, where).strip()
|
|
236
|
+
else:
|
|
237
|
+
settings.warnings.append(f'{name}: unknown setting "{key}"')
|
|
238
|
+
return settings
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _language_from(data: dict[str, Any], where: str, settings: Settings) -> LanguageSettings:
|
|
242
|
+
values: dict[str, Any] = {}
|
|
243
|
+
for key, value in data.items():
|
|
244
|
+
place = f"{where} {key}"
|
|
245
|
+
if key in ("compiler", "template"):
|
|
246
|
+
values[key] = _text(value, place).strip()
|
|
247
|
+
elif key == "submit":
|
|
248
|
+
values[key] = _text(value, place).strip()
|
|
249
|
+
elif key in ("flags", "build", "run"):
|
|
250
|
+
values[key] = _words(value, place)
|
|
251
|
+
else:
|
|
252
|
+
settings.warnings.append(f'{where}: unknown setting "{key}"')
|
|
253
|
+
return LanguageSettings(**values)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ┌──────────────────────────────────────────────────────────┐
|
|
257
|
+
# │ From cf-tool 0.3 │
|
|
258
|
+
# └──────────────────────────────────────────────────────────┘
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def old_config_files() -> list[Path]:
|
|
262
|
+
"""Where cf-tool 0.3 (and the Go tool) kept their JSON config."""
|
|
263
|
+
return [paths.CONFIG_DIR / "config", Path.home() / ".cf" / "config"]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def migrate(sources: list[Path] | None = None, target: Path | None = None) -> list[str]:
|
|
267
|
+
"""Write config.toml from an old JSON config when there is no config.toml
|
|
268
|
+
yet; returns what to tell the user ([] when there was nothing to do).
|
|
269
|
+
Carried over: the default language, each language's starter file and
|
|
270
|
+
Codeforces language, host and proxy; scripts only for languages cf-tool
|
|
271
|
+
doesn't know (it builds the others itself now). The old file is left as
|
|
272
|
+
it was."""
|
|
273
|
+
from cf_tool import languages
|
|
274
|
+
|
|
275
|
+
target = target or paths.config_file()
|
|
276
|
+
if target.exists():
|
|
277
|
+
return []
|
|
278
|
+
source = next((p for p in (sources or old_config_files()) if p.is_file()), None)
|
|
279
|
+
if source is None:
|
|
280
|
+
return []
|
|
281
|
+
try:
|
|
282
|
+
data = json.loads(source.read_text(encoding="utf-8") or "{}")
|
|
283
|
+
templates = [t for t in data.get("template") or [] if isinstance(t, dict)]
|
|
284
|
+
default = int(data.get("default", 0))
|
|
285
|
+
except (OSError, ValueError, TypeError, AttributeError) as e:
|
|
286
|
+
return [f"Could not read your old settings in {source} ({e}); starting afresh."]
|
|
287
|
+
|
|
288
|
+
def key_of(template: dict) -> str:
|
|
289
|
+
suffixes = template.get("suffix") or []
|
|
290
|
+
if isinstance(suffixes, str):
|
|
291
|
+
suffixes = suffixes.split()
|
|
292
|
+
for suffix in suffixes:
|
|
293
|
+
if (key := languages.language_key(str(suffix))) is not None:
|
|
294
|
+
return key
|
|
295
|
+
name = languages.LANGS.get(str(template.get("lang", "")), "")
|
|
296
|
+
ext = languages.ext_of(name) if name else "txt"
|
|
297
|
+
if ext in languages.LANGUAGES:
|
|
298
|
+
return ext
|
|
299
|
+
first = str(suffixes[0]) if suffixes else ext
|
|
300
|
+
return first.lower().lstrip(".")
|
|
301
|
+
|
|
302
|
+
chosen: dict[str, dict] = {}
|
|
303
|
+
skipped: list[tuple[str, str]] = [] # (alias, its language)
|
|
304
|
+
default_key = key_of(templates[default]) if 0 <= default < len(templates) else ""
|
|
305
|
+
for i, template in enumerate(templates):
|
|
306
|
+
key = key_of(template)
|
|
307
|
+
if key in chosen and i != default:
|
|
308
|
+
skipped.append((str(template.get("alias", "?")), key))
|
|
309
|
+
continue
|
|
310
|
+
if key in chosen:
|
|
311
|
+
skipped.append((str(chosen[key].get("alias", "?")), key))
|
|
312
|
+
chosen[key] = template
|
|
313
|
+
|
|
314
|
+
doc = tomlkit.parse(default_text(default_key))
|
|
315
|
+
host = str(data.get("host") or "").rstrip("/")
|
|
316
|
+
if host and host != DEFAULT_HOST:
|
|
317
|
+
doc["host"] = host
|
|
318
|
+
if data.get("proxy"):
|
|
319
|
+
doc["proxy"] = str(data["proxy"])
|
|
320
|
+
carried: list[str] = []
|
|
321
|
+
dropped_scripts: list[str] = []
|
|
322
|
+
for key, template in chosen.items():
|
|
323
|
+
table = tomlkit.table()
|
|
324
|
+
if template.get("path"):
|
|
325
|
+
table["template"] = str(template["path"])
|
|
326
|
+
if str(template.get("lang", "")).strip():
|
|
327
|
+
table["submit"] = str(template["lang"]).strip()
|
|
328
|
+
if key in languages.LANGUAGES:
|
|
329
|
+
if template.get("before_script") or template.get("script"):
|
|
330
|
+
dropped_scripts.append(str(template.get("alias", key)))
|
|
331
|
+
else:
|
|
332
|
+
if template.get("before_script"):
|
|
333
|
+
table["build"] = _new_placeholders(str(template["before_script"]))
|
|
334
|
+
if template.get("script"):
|
|
335
|
+
table["run"] = _new_placeholders(str(template["script"]))
|
|
336
|
+
if len(table):
|
|
337
|
+
doc[key] = table
|
|
338
|
+
shown = []
|
|
339
|
+
for name, value in table.items():
|
|
340
|
+
language = languages.LANGS.get(str(value)) if name == "submit" else None
|
|
341
|
+
shown.append(f"{name} = {value}" + (f" ({language})" if language else ""))
|
|
342
|
+
carried.append(f"[{key}] " + ", ".join(shown))
|
|
343
|
+
paths.write_atomic(target, tomlkit.dumps(doc))
|
|
344
|
+
|
|
345
|
+
notes = [f"Moved your settings from {source} to {target}:"]
|
|
346
|
+
if default_key:
|
|
347
|
+
notes.append(f' language = "{default_key}"')
|
|
348
|
+
notes += [f" {line}" for line in carried]
|
|
349
|
+
if host and host != DEFAULT_HOST:
|
|
350
|
+
notes.append(f' host = "{host}"')
|
|
351
|
+
if data.get("proxy"):
|
|
352
|
+
notes.append(f' proxy = "{data["proxy"]}"')
|
|
353
|
+
if dropped_scripts:
|
|
354
|
+
notes.append(
|
|
355
|
+
"Not carried over: the build and run scripts of "
|
|
356
|
+
+ ", ".join(f'"{alias}"' for alias in dropped_scripts)
|
|
357
|
+
+ " (cf-tool finds your compilers itself now; cf langs shows what it uses)."
|
|
358
|
+
)
|
|
359
|
+
folders = data.get("folder_name") if isinstance(data.get("folder_name"), dict) else {}
|
|
360
|
+
defaults = {"root": "cf", "contest": "contest", "gym": "gym", "group": "group"}
|
|
361
|
+
if any(folders.get(name, value) != value for name, value in defaults.items()):
|
|
362
|
+
notes.append(
|
|
363
|
+
"Not carried over: your folder names (cf-tool finds problems by their "
|
|
364
|
+
".cf-problem.json now, and puts new ones in <contest>/<problem>)."
|
|
365
|
+
)
|
|
366
|
+
for alias, key in skipped:
|
|
367
|
+
kept = str(chosen[key].get("alias", "?"))
|
|
368
|
+
notes.append(
|
|
369
|
+
f'Not carried over: the template "{alias}" ({languages.display_name(key)} has one '
|
|
370
|
+
f'setting now, and "{kept}" was kept).'
|
|
371
|
+
)
|
|
372
|
+
return notes
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _new_placeholders(script: str) -> str:
|
|
376
|
+
"""A 0.3 script with today's placeholders. It runs in the code's folder, so
|
|
377
|
+
the folder ($%path%$) becomes ./ ({src} names the file with its folder)."""
|
|
378
|
+
for old, new in (
|
|
379
|
+
("$%path%$$%full%$", "{src}"),
|
|
380
|
+
("$%path%$", "./"),
|
|
381
|
+
("$%full%$", "{src}"),
|
|
382
|
+
("$%file%$", "{name}"),
|
|
383
|
+
("$%rand%$", "{name}"),
|
|
384
|
+
):
|
|
385
|
+
script = script.replace(old, new)
|
|
386
|
+
return script
|
cf_tool/stress.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""cf stress: random tests until the solution and a brute force disagree.
|
|
2
|
+
|
|
3
|
+
A generator prints one test for the seed it gets as its argument, and the
|
|
4
|
+
brute force's output is that test's answer. The first test the solution
|
|
5
|
+
fails is saved among the problem's tests (tests/stress-<seed>), so cf test
|
|
6
|
+
keeps checking it; each cf stress first brings those answers up to date with
|
|
7
|
+
the brute force. All three are built and run like any code file, in any
|
|
8
|
+
language cf-tool knows.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from cf_tool import judge, process, ui, views, workspace
|
|
16
|
+
from cf_tool.build import Program
|
|
17
|
+
from cf_tool.errors import CFError
|
|
18
|
+
from cf_tool.judge import Check, same_output
|
|
19
|
+
|
|
20
|
+
GENERATOR_TIMEOUT = 10.0 # seconds
|
|
21
|
+
BRUTE_TIMEOUT = 60.0 # seconds: a brute force may be slow, just not stuck
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _failed_on(test: bytes, what: str, result: process.Result) -> CFError:
|
|
25
|
+
"""The error for a generator or brute force that didn't produce a test or
|
|
26
|
+
its answer, with the test it was given (if any) shown first."""
|
|
27
|
+
if test:
|
|
28
|
+
ui.console.print(views.program_output(test.decode(errors="replace"), "Input"))
|
|
29
|
+
said = (result.stderr or result.stdout).decode(errors="replace").strip()
|
|
30
|
+
return CFError(
|
|
31
|
+
f"The {what} failed ({result.reason()})" + (f": {said.splitlines()[-1]}" if said else "")
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _accepted(test: bytes, got: bytes, want: bytes, check: Check, folder: Path) -> tuple[bool, str]:
|
|
36
|
+
"""Whether the solution's output counts as the brute force's: (yes, what a checker said)."""
|
|
37
|
+
if check.checker is None:
|
|
38
|
+
out, ans = (data.decode(errors="replace") for data in (got, want))
|
|
39
|
+
return same_output(out, ans, check), ""
|
|
40
|
+
scratch = workspace.Test("stress", folder / ".cf-stress.in", folder / ".cf-stress.ans")
|
|
41
|
+
try:
|
|
42
|
+
scratch.input.write_bytes(test)
|
|
43
|
+
scratch.answer.write_bytes(want) # type: ignore[union-attr]
|
|
44
|
+
return judge._run_checker(check.checker, scratch, got)
|
|
45
|
+
finally:
|
|
46
|
+
for path in (scratch.input, scratch.answer):
|
|
47
|
+
try:
|
|
48
|
+
path.unlink() # type: ignore[union-attr]
|
|
49
|
+
except OSError:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def stress(
|
|
54
|
+
folder: Path,
|
|
55
|
+
generator: Program,
|
|
56
|
+
brute: Program,
|
|
57
|
+
solution: Program,
|
|
58
|
+
*,
|
|
59
|
+
tests: int = 1000,
|
|
60
|
+
seed: int = 1,
|
|
61
|
+
check: Check = Check(),
|
|
62
|
+
time_limit: float | None = None,
|
|
63
|
+
) -> workspace.Test | None:
|
|
64
|
+
"""Run up to `tests` random tests (all three programs built already);
|
|
65
|
+
returns the test saved for the first one the solution fails (wrong
|
|
66
|
+
answer, crash or time limit), or None when it passed them all. Per test:
|
|
67
|
+
- the generator prints it, from its seed
|
|
68
|
+
- the brute force answers it
|
|
69
|
+
- the solution runs on it, and its output is compared with that answer"""
|
|
70
|
+
timeout = judge.timeout_for(time_limit)
|
|
71
|
+
refresh(folder, brute)
|
|
72
|
+
with ui.status("Stress testing...") as show:
|
|
73
|
+
for test_seed in range(seed, seed + tests):
|
|
74
|
+
show(f"Test {test_seed - seed + 1} of {tests} (seed {test_seed})...")
|
|
75
|
+
made = process.run(
|
|
76
|
+
[*generator.argv(), str(test_seed)],
|
|
77
|
+
cwd=generator.source.parent,
|
|
78
|
+
timeout=GENERATOR_TIMEOUT,
|
|
79
|
+
)
|
|
80
|
+
if not made.ok:
|
|
81
|
+
raise _failed_on(b"", f"generator (seed {test_seed})", made)
|
|
82
|
+
answer = brute.run(stdin=made.stdout, timeout=BRUTE_TIMEOUT)
|
|
83
|
+
if not answer.ok:
|
|
84
|
+
raise _failed_on(made.stdout, f"brute force (seed {test_seed})", answer)
|
|
85
|
+
result = solution.run(stdin=made.stdout, timeout=timeout)
|
|
86
|
+
said = ""
|
|
87
|
+
if result.ok:
|
|
88
|
+
passed, said = _accepted(made.stdout, result.stdout, answer.stdout, check, folder)
|
|
89
|
+
if passed:
|
|
90
|
+
continue
|
|
91
|
+
return _report(folder, test_seed, made.stdout, result, answer.stdout, said, check)
|
|
92
|
+
ui.green(f"All {tests} tests passed (seeds {seed} to {seed + tests - 1})")
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def refresh(folder: Path, brute: Program) -> None:
|
|
97
|
+
"""Bring the answers of tests saved by an earlier cf stress (stress-*) up
|
|
98
|
+
to date with the brute force as it is now, and say which changed: one
|
|
99
|
+
saved while the brute force was wrong fails cf test for nothing.
|
|
100
|
+
- an answer the brute force still gives: left alone
|
|
101
|
+
- another answer: replaced, and named in a note
|
|
102
|
+
- a brute force that fails on the input (an older generator's?): left,
|
|
103
|
+
with a note"""
|
|
104
|
+
changed, failing = [], []
|
|
105
|
+
for test in workspace.tests_of(folder):
|
|
106
|
+
if not test.name.startswith("stress-") or test.answer is None:
|
|
107
|
+
continue
|
|
108
|
+
answer = brute.run(stdin=test.input, timeout=BRUTE_TIMEOUT)
|
|
109
|
+
if not answer.ok:
|
|
110
|
+
failing.append(test.name)
|
|
111
|
+
continue
|
|
112
|
+
now = answer.stdout.decode(errors="replace")
|
|
113
|
+
if same_output(now, test.answer.read_text(encoding="utf-8", errors="replace"), Check()):
|
|
114
|
+
continue
|
|
115
|
+
test.answer.write_text(now, encoding="utf-8", newline="")
|
|
116
|
+
changed.append(test.name)
|
|
117
|
+
if changed:
|
|
118
|
+
ui.cyan(
|
|
119
|
+
f"Updated the answers of {', '.join(changed)} (saved by an earlier cf stress) "
|
|
120
|
+
f"to what {brute.name} prints now"
|
|
121
|
+
)
|
|
122
|
+
if failing:
|
|
123
|
+
ui.yellow(
|
|
124
|
+
f"{brute.name} fails on {', '.join(failing)} (saved by an earlier cf stress): "
|
|
125
|
+
"if they came from an older generator, delete them from tests/"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _report(
|
|
130
|
+
folder: Path,
|
|
131
|
+
test_seed: int,
|
|
132
|
+
test: bytes,
|
|
133
|
+
result: process.Result,
|
|
134
|
+
want: bytes,
|
|
135
|
+
said: str,
|
|
136
|
+
check: Check,
|
|
137
|
+
) -> workspace.Test:
|
|
138
|
+
"""Save the failing test among the problem's tests and show what went
|
|
139
|
+
wrong; returns the saved test."""
|
|
140
|
+
test_text, want_text = test.decode(errors="replace"), want.decode(errors="replace")
|
|
141
|
+
saved = workspace.add_test(folder, test_text, want_text, name=f"stress-{test_seed}")
|
|
142
|
+
verdict = {
|
|
143
|
+
process.OK: "Wrong answer",
|
|
144
|
+
process.TIMEOUT: "Time limit exceeded",
|
|
145
|
+
process.OUTPUT: "Output limit exceeded",
|
|
146
|
+
}.get(result.status, "Runtime error")
|
|
147
|
+
detail = "" if result.ok else f" ({result.reason()})"
|
|
148
|
+
ui.red(f"{verdict}{detail} on the test from seed {test_seed}")
|
|
149
|
+
ui.green(f"Saved it as the test {saved.name}: cf test {saved.name} runs it again")
|
|
150
|
+
if said:
|
|
151
|
+
ui.line(("checker: ", "red"), (said, ""))
|
|
152
|
+
if result.ok:
|
|
153
|
+
output = result.stdout.decode(errors="replace")
|
|
154
|
+
ui.console.print(
|
|
155
|
+
views.failed_sample(
|
|
156
|
+
test_text,
|
|
157
|
+
output.strip().splitlines(),
|
|
158
|
+
want_text.strip().splitlines(),
|
|
159
|
+
same=lambda o, a: same_output(o, a, check),
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
ui.console.print(views.program_output(test_text, "Input"))
|
|
164
|
+
message = result.stderr.decode(errors="replace").strip()
|
|
165
|
+
if message:
|
|
166
|
+
ui.line(("stderr: ", "red"), (message.splitlines()[-1], ""))
|
|
167
|
+
return saved
|