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/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