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/workspace.py ADDED
@@ -0,0 +1,777 @@
1
+ """Problems on disk, and which problem and which file a command means.
2
+
3
+ A contest gets a folder named for its id, each problem one inside it:
4
+
5
+ 2050/
6
+ a/
7
+ a.cpp your code (any name; several files are fine)
8
+ tests/1.in 1.ans tests: the samples, and your own (any name, .in with .ans)
9
+ .cf-problem.json what cf get learned: the url, limits, comparison rules
10
+
11
+ The metadata file names the problem, so a command run anywhere inside knows
12
+ which one it is. Folders made by cf-tool 0.3 (cf/contest/2050/a/, with
13
+ in1.txt and ans1.txt) and loose files named for a problem (2050A.cpp,
14
+ 2050/A.cpp) work too: those are read off their names.
15
+
16
+ When several code files could be the one meant, the user picks from a menu,
17
+ this problem's files first (named for it, or inside its folders), the most
18
+ recently edited first in each group, each with its edit time:
19
+
20
+ this problem's files 2050A.cpp cf2050a.py 2050-A.cpp 2050A_v2.cpp
21
+ 2050/A.cpp anything in 2050/a/, 2050A/ or cf/contest/2050/a/
22
+ ── other files ── A.cpp (the problem's letter) first, then the rest
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import re
30
+ import time
31
+ from dataclasses import asdict, dataclass, field, replace
32
+ from datetime import datetime
33
+ from pathlib import Path
34
+ from typing import Collection, Sequence
35
+
36
+ from rich.cells import cell_len
37
+
38
+ from cf_tool import languages, ui
39
+ from cf_tool.errors import CFError
40
+
41
+ METADATA = ".cf-problem.json"
42
+ TESTS = "tests"
43
+ STATEMENT = "statement.md"
44
+
45
+
46
+ # ┌──────────────────────────────────────────────────────────┐
47
+ # │ Targets │
48
+ # └──────────────────────────────────────────────────────────┘
49
+
50
+ _PROBLEM = r"[A-Za-z]{1,2}\d?" # A, C1, AA: a problem's index
51
+ _URL_RULES: list[tuple[re.Pattern[str], str]] = [
52
+ (re.compile(r"/contest/(?P<contest>\d+)(?:/problem/(?P<problem>\w+))?"), "contest"),
53
+ (re.compile(r"/gym/(?P<contest>\d+)(?:/problem/(?P<problem>\w+))?"), "gym"),
54
+ (re.compile(r"/problemset/problem/(?P<contest>\d+)/(?P<problem>\w+)"), "contest"),
55
+ (
56
+ re.compile(
57
+ r"/group/(?P<group>\w{10})(?:/contest/(?P<contest>\d+)(?:/problem/(?P<problem>\w+))?)?"
58
+ ),
59
+ "group",
60
+ ),
61
+ (
62
+ re.compile(r"/problemsets/acmsguru/problem/(?P<contest>\d+)/(?P<problem>\w+)"),
63
+ "acmsguru",
64
+ ),
65
+ ]
66
+ _SPEC_RULES = [
67
+ re.compile(rf"(?:cf)?(?P<contest>\d+)[-_ ]?(?P<problem>{_PROBLEM})", re.IGNORECASE),
68
+ re.compile(r"(?P<contest>\d+)"),
69
+ re.compile(rf"(?P<problem>{_PROBLEM})"),
70
+ ]
71
+ ACMSGURU_CONTEST = "99999"
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Target:
76
+ """A contest, or a problem of one, on Codeforces."""
77
+
78
+ kind: str = "contest" # contest, gym, group or acmsguru
79
+ contest: str = "" # "2050"; acmsguru's problems all sit in 99999
80
+ problem: str = "" # "A", "C1": upper case
81
+ group: str = "" # a group's id, for kind "group"
82
+
83
+ @property
84
+ def label(self) -> str:
85
+ """How messages name it: 2050A, contest 2050, gym 100001 B, acmsguru 100."""
86
+ if self.kind == "acmsguru":
87
+ return f"acmsguru {self.problem}".strip()
88
+ if self.contest and self.problem:
89
+ prefix = "gym " if self.kind == "gym" else ""
90
+ return f"{prefix}{self.contest}{self.problem}"
91
+ if self.contest:
92
+ return f"{'gym' if self.kind == 'gym' else 'contest'} {self.contest}"
93
+ return f"problem {self.problem}" if self.problem else "nothing"
94
+
95
+ def with_problem(self, problem: str) -> "Target":
96
+ return replace(self, problem=problem.upper())
97
+
98
+ def _base(self, host: str) -> str:
99
+ if self.kind == "acmsguru":
100
+ return f"{host}/problemsets/acmsguru"
101
+ if not self.contest:
102
+ raise CFError("Which contest? Give one, like 2050, or run this in a problem folder")
103
+ if self.kind == "gym":
104
+ return f"{host}/gym/{self.contest}"
105
+ if self.kind == "group":
106
+ return f"{host}/group/{self.group}/contest/{self.contest}"
107
+ return f"{host}/contest/{self.contest}"
108
+
109
+ def _need_problem(self) -> None:
110
+ if not self.problem:
111
+ raise CFError(
112
+ f"Which problem of {self.label}? Give one, like {self.contest or '2050'}A, "
113
+ "or run this in its folder"
114
+ )
115
+
116
+ def contest_url(self, host: str) -> str:
117
+ return self._base(host)
118
+
119
+ def problem_url(self, host: str) -> str:
120
+ self._need_problem()
121
+ if self.kind == "acmsguru":
122
+ return f"{host}/problemsets/acmsguru/problem/{ACMSGURU_CONTEST}/{self.problem}"
123
+ return f"{self._base(host)}/problem/{self.problem}"
124
+
125
+ def problems_url(self, host: str) -> str:
126
+ """The page with every problem's statement."""
127
+ return f"{self._base(host)}/problems"
128
+
129
+ def submit_url(self, host: str) -> str:
130
+ return f"{self._base(host)}/submit"
131
+
132
+ def my_url(self, host: str) -> str:
133
+ """Your submissions to the contest."""
134
+ if self.kind == "acmsguru":
135
+ raise CFError("acmsguru has no list of your submissions")
136
+ return f"{self._base(host)}/my"
137
+
138
+ def standings_url(self, host: str) -> str:
139
+ return f"{self._base(host)}/standings"
140
+
141
+ def countdown_url(self, host: str) -> str:
142
+ return f"{self._base(host)}/countdown"
143
+
144
+ def submission_url(self, host: str, submission_id: str) -> str:
145
+ if self.kind == "acmsguru":
146
+ return f"{host}/problemsets/acmsguru/submission/{ACMSGURU_CONTEST}/{submission_id}"
147
+ return f"{self._base(host)}/submission/{submission_id}"
148
+
149
+ def page_url(self, host: str) -> str:
150
+ """The problem's page, or the contest's when there is no problem."""
151
+ return self.problem_url(host) if self.problem else self.contest_url(host)
152
+
153
+
154
+ def _kind_for(contest: str) -> str:
155
+ return "gym" if len(contest) >= 6 else "contest" # NOTE: gym ids start at 100000
156
+
157
+
158
+ def read_spec(spec: str) -> dict[str, str] | None:
159
+ """What one argument names: a URL, 2050A, 2050 or A; None when it names
160
+ nothing (so it may be a file or a test)."""
161
+ text = spec.strip()
162
+ if "/" in text:
163
+ found: dict[str, str] = {}
164
+ for pattern, kind in _URL_RULES:
165
+ match = pattern.search(text)
166
+ if match:
167
+ found = {k: v for k, v in match.groupdict().items() if v}
168
+ found["kind"] = kind
169
+ if found.get("kind") == "acmsguru":
170
+ found["contest"] = ACMSGURU_CONTEST
171
+ return found or None
172
+ for pattern in _SPEC_RULES:
173
+ match = pattern.fullmatch(text)
174
+ if match:
175
+ return {k: v for k, v in match.groupdict().items() if v}
176
+ return None
177
+
178
+
179
+ def parse_target(specs: Sequence[str], base: Target | None = None) -> Target:
180
+ """The target that arguments name, with base (what the folder implies)
181
+ filling in what they leave out; a problem letter alone keeps base's
182
+ contest. Raises CFError when two arguments disagree or one is not a
183
+ contest or a problem."""
184
+ found: dict[str, str] = {}
185
+ for spec in specs:
186
+ parsed = read_spec(spec)
187
+ if parsed is None:
188
+ raise CFError(f'"{spec}" is not a contest or a problem (like 2050, 2050A or a URL)')
189
+ for key, value in parsed.items():
190
+ if key == "problem":
191
+ value = value.upper()
192
+ if found.get(key, value) != value:
193
+ raise CFError(f"{found[key]} and {value} name different {key}s")
194
+ found[key] = value
195
+ base = base or Target()
196
+ if "contest" not in found and "group" not in found:
197
+ if not found:
198
+ return base
199
+ # NOTE: a letter alone is a problem of the contest we're in
200
+ return Target(base.kind, base.contest, found.get("problem", base.problem), base.group)
201
+ contest = found.get("contest", "")
202
+ kind = found.get("kind") or _kind_for(contest)
203
+ # NOTE: naming a contest means that contest, not base's problem in it
204
+ return Target(kind, contest, found.get("problem", ""), found.get("group", ""))
205
+
206
+
207
+ # ┌──────────────────────────────────────────────────────────┐
208
+ # │ Problem folders │
209
+ # └──────────────────────────────────────────────────────────┘
210
+
211
+ # per sample: the test case of each input line and of each answer line
212
+ Cases = tuple[list[int], list[int]]
213
+
214
+
215
+ @dataclass
216
+ class ProblemInfo:
217
+ """What cf get learned about a problem, kept in its folder."""
218
+
219
+ name: str = "" # "A. Two Friends"
220
+ url: str = ""
221
+ time_limit: float | None = None # seconds
222
+ memory_limit: int | None = None # megabytes
223
+ interactive: bool = False
224
+ ignore_case: bool = False # the statement accepts answers in any letter case
225
+ epsilon: float | None = None # the absolute or relative error it accepts
226
+ any_answer: bool = False # it accepts any right answer, the sample's being one
227
+ cases: list[Cases | None] = field(default_factory=list) # per sample: its lines' cases
228
+
229
+ @property
230
+ def target(self) -> Target | None:
231
+ parsed = read_spec(self.url) if self.url else None
232
+ return parse_target([self.url]) if parsed else None
233
+
234
+ def sample_cases(self, test_name: str) -> Cases | None:
235
+ """The cases of sample test_name, if the page marked them."""
236
+ if not test_name.isdigit():
237
+ return None
238
+ index = int(test_name) - 1
239
+ return self.cases[index] if 0 <= index < len(self.cases) else None
240
+
241
+
242
+ def _number(value):
243
+ return value if isinstance(value, (int, float)) and not isinstance(value, bool) else None
244
+
245
+
246
+ def load_info(folder: Path) -> ProblemInfo | None:
247
+ """The problem info saved in folder; None if there is none or it is unreadable."""
248
+ try:
249
+ data = json.loads((folder / METADATA).read_text(encoding="utf-8"))
250
+ return ProblemInfo(
251
+ name=str(data.get("name", "")),
252
+ url=str(data.get("url", "")),
253
+ time_limit=_number(data.get("time_limit")),
254
+ memory_limit=_number(data.get("memory_limit")),
255
+ interactive=bool(data.get("interactive", False)),
256
+ ignore_case=bool(data.get("ignore_case", False)),
257
+ epsilon=_number(data.get("epsilon")),
258
+ any_answer=bool(data.get("any_answer", False)),
259
+ cases=[(list(c[0]), list(c[1])) if c else None for c in data.get("cases", [])],
260
+ )
261
+ except (OSError, ValueError, TypeError, IndexError, AttributeError):
262
+ return None
263
+
264
+
265
+ def save_info(folder: Path, info: ProblemInfo) -> None:
266
+ folder.mkdir(parents=True, exist_ok=True)
267
+ (folder / METADATA).write_text(json.dumps(asdict(info), indent=2) + "\n", encoding="utf-8")
268
+
269
+
270
+ def save_statement(folder: Path, markdown: str) -> None:
271
+ """Keep the problem's statement in its folder, as statement.md (cf get
272
+ writes it afresh each time)."""
273
+ folder.mkdir(parents=True, exist_ok=True)
274
+ (folder / STATEMENT).write_text(markdown, encoding="utf-8", newline="")
275
+
276
+
277
+ def find_problem_folder(start: Path) -> Path | None:
278
+ """The problem folder start is in (or is): the nearest one up the tree
279
+ holding a .cf-problem.json."""
280
+ start = start.resolve()
281
+ for folder in (start, *start.parents):
282
+ if (folder / METADATA).is_file():
283
+ return folder
284
+ return None
285
+
286
+
287
+ def _problem_children(folder: Path) -> list[Path]:
288
+ """The problem folders directly inside folder (a contest folder's problems)."""
289
+ return [child for child in _subdirs(folder) if (child / METADATA).is_file()]
290
+
291
+
292
+ def contest_folder(cwd: Path) -> Path | None:
293
+ """The contest folder cwd is in: the parent of its problem folder, or cwd
294
+ itself when problem folders sit directly in it."""
295
+ problem = find_problem_folder(cwd)
296
+ if problem is not None:
297
+ return problem.parent
298
+ return cwd.resolve() if _problem_children(cwd) else None
299
+
300
+
301
+ def _from_names(folder: Path) -> Target | None:
302
+ """The target a folder's own path spells out, as cf-tool 0.3 laid them
303
+ out (cf/contest/2050/a, cf/gym/100001, cf/group/<id>/7/b, cf/acmsguru/100)
304
+ or as people name them (2050/a, 2050/A, 2050A)."""
305
+ parts = folder.resolve().parts
306
+ for i in range(len(parts) - 1, -1, -1):
307
+ if parts[i] == "acmsguru" and i + 1 < len(parts):
308
+ return Target("acmsguru", ACMSGURU_CONTEST, parts[i + 1].upper())
309
+ if parts[i] == "group" and i + 2 < len(parts) and re.fullmatch(r"\w{10}", parts[i + 1]):
310
+ problem = parts[i + 3].upper() if i + 3 < len(parts) else ""
311
+ if re.fullmatch(r"\d+", parts[i + 2]):
312
+ return Target("group", parts[i + 2], problem, parts[i + 1])
313
+ name = folder.name
314
+ parent = folder.parent.name
315
+ if re.fullmatch(_PROBLEM, name) and re.fullmatch(r"\d+", parent):
316
+ return Target(_kind_for(parent), parent, name.upper())
317
+ if match := re.fullmatch(rf"(?:cf)?(\d+)[-_]?({_PROBLEM})", name, re.IGNORECASE):
318
+ return Target(_kind_for(match.group(1)), match.group(1), match.group(2).upper())
319
+ if re.fullmatch(r"\d+", name):
320
+ return Target(_kind_for(name), name)
321
+ return None
322
+
323
+
324
+ def here(cwd: Path) -> Target | None:
325
+ """The target the folder implies, in order:
326
+ - the problem folder it is in (its .cf-problem.json)
327
+ - the contest folder it is (one of its problems' metadata, without the problem)
328
+ - the names of its path (cf-tool 0.3's folders, 2050/a)
329
+ None when nothing says."""
330
+ problem = find_problem_folder(cwd)
331
+ if problem is not None:
332
+ info = load_info(problem)
333
+ if info is not None and info.target is not None:
334
+ return info.target
335
+ for child in _problem_children(cwd):
336
+ info = load_info(child)
337
+ if info is not None and info.target is not None:
338
+ return replace(info.target, problem="")
339
+ return _from_names(cwd)
340
+
341
+
342
+ def contest_folder_name(target: Target) -> str:
343
+ return "acmsguru" if target.kind == "acmsguru" else target.contest
344
+
345
+
346
+ def get_root(cwd: Path, root: Path | None = None) -> Path:
347
+ """Where cf get puts a contest's folder:
348
+ - next to the current contest folder, when run inside one
349
+ - the configured root
350
+ - the current folder"""
351
+ current = contest_folder(cwd)
352
+ if current is None:
353
+ named = _from_names(cwd) # NOTE: a folder made before metadata, with its tests
354
+ if named is not None and named.problem and tests_of(cwd):
355
+ current = cwd.resolve().parent
356
+ elif named is not None and any(tests_of(child) for child in _subdirs(cwd)):
357
+ current = cwd.resolve()
358
+ if current is not None:
359
+ return current.parent
360
+ return root if root is not None else cwd.resolve()
361
+
362
+
363
+ def problem_folder(root: Path, target: Target) -> Path:
364
+ """Where a problem's folder goes under root: <contest>/<problem, lower case>."""
365
+ return root / contest_folder_name(target) / target.problem.lower()
366
+
367
+
368
+ def locate(target: Target, cwd: Path, root: Path | None = None) -> Path | None:
369
+ """The folder of a problem, found from cwd:
370
+ - the problem folder cwd is in, if it is this problem's
371
+ - in the current contest folder, or in cwd
372
+ - under the configured root
373
+ - cf-tool 0.3's cf/<kind>/<contest>/<problem> above cwd
374
+ None when it has no folder."""
375
+ if not target.problem:
376
+ return None
377
+ current = find_problem_folder(cwd)
378
+ if current is not None and _is(current, target):
379
+ return current
380
+ places = [contest_folder(cwd), cwd.resolve()]
381
+ if root is not None:
382
+ places.append(root / contest_folder_name(target))
383
+ for place in places:
384
+ if place is None:
385
+ continue
386
+ for candidate in (
387
+ place / target.problem.lower(),
388
+ place / target.problem,
389
+ place / contest_folder_name(target) / target.problem.lower(),
390
+ ):
391
+ if candidate.is_dir() and _is(candidate, target):
392
+ return candidate
393
+ for folder in (cwd.resolve(), *cwd.resolve().parents):
394
+ for root_folder in (folder / "cf", folder) if folder.name == "cf" else (folder / "cf",):
395
+ legacy = root_folder / _old_layout(target)
396
+ if legacy.is_dir():
397
+ return legacy
398
+ return None
399
+
400
+
401
+ def _old_layout(target: Target) -> Path:
402
+ """A problem's folder below cf-tool 0.3's root folder (cf/)."""
403
+ problem = target.problem.lower()
404
+ if target.kind == "acmsguru":
405
+ return Path("acmsguru", problem)
406
+ if target.kind == "group":
407
+ return Path("group", target.group, target.contest, problem)
408
+ return Path(target.kind, target.contest, problem)
409
+
410
+
411
+ def _is(folder: Path, target: Target) -> bool:
412
+ """Whether folder is target's problem folder: by its metadata, else by its names."""
413
+ info = load_info(folder)
414
+ found = info.target if info is not None else _from_names(folder)
415
+ return (
416
+ found is not None
417
+ and found.problem == target.problem
418
+ and (not target.contest or found.contest == target.contest)
419
+ )
420
+
421
+
422
+ # ┌──────────────────────────────────────────────────────────┐
423
+ # │ Tests │
424
+ # └──────────────────────────────────────────────────────────┘
425
+
426
+
427
+ @dataclass(frozen=True)
428
+ class Test:
429
+ __test__ = False # NOTE: not a test class, to pytest
430
+
431
+ name: str # "1", "my", "stress-17"
432
+ input: Path
433
+ answer: Path | None # None: a test that is only run
434
+
435
+
436
+ def _test_order(name: str) -> tuple:
437
+ return (0, int(name), "") if name.isdigit() else (1, 0, name)
438
+
439
+
440
+ _OLD_INPUT_RE = re.compile(r"^in(\d+)\.txt$")
441
+
442
+
443
+ def tests_of(folder: Path) -> list[Test]:
444
+ """A problem folder's tests, numbered ones first in order, then the rest
445
+ by name:
446
+ - tests/<name>.in, with tests/<name>.ans (none: a test that is only run)
447
+ - cf-tool 0.3's in<i>.txt with ans<i>.txt, unless tests/ has that number"""
448
+ tests_folder = folder / TESTS
449
+ found: dict[str, Test] = {}
450
+ for entry in _entries(folder):
451
+ match = _OLD_INPUT_RE.match(entry.name)
452
+ if match and entry.is_file():
453
+ answer = folder / f"ans{match.group(1)}.txt"
454
+ found[match.group(1)] = Test(
455
+ match.group(1), entry, answer if answer.is_file() else None
456
+ )
457
+ if tests_folder.is_dir():
458
+ for entry in tests_folder.iterdir():
459
+ if entry.suffix == ".in" and entry.is_file():
460
+ answer = entry.with_suffix(".ans")
461
+ found[entry.stem] = Test(entry.stem, entry, answer if answer.is_file() else None)
462
+ return sorted(found.values(), key=lambda test: _test_order(test.name))
463
+
464
+
465
+ def _write(path: Path, text: str) -> None:
466
+ path.parent.mkdir(parents=True, exist_ok=True)
467
+ path.write_text(text, encoding="utf-8", newline="")
468
+
469
+
470
+ def save_samples(folder: Path, samples: Sequence[tuple[str, str]]) -> list[Test]:
471
+ """Write the samples as tests/1.in, 1.ans, ...; returns them as tests."""
472
+ written = []
473
+ for i, (sample_in, sample_ans) in enumerate(samples, start=1):
474
+ test = Test(str(i), folder / TESTS / f"{i}.in", folder / TESTS / f"{i}.ans")
475
+ _write(test.input, sample_in)
476
+ _write(test.answer, sample_ans) # type: ignore[arg-type]
477
+ written.append(test)
478
+ return written
479
+
480
+
481
+ def add_test(folder: Path, input_text: str, answer: str, name: str = "") -> Test:
482
+ """Save a test: under name, else the next free number; without an answer
483
+ (empty) it is a test that is only run. Returns it."""
484
+ if not name:
485
+ numbers = [int(t.name) for t in tests_of(folder) if t.name.isdigit()]
486
+ name = str(max(numbers, default=0) + 1)
487
+ tests_folder = folder / TESTS
488
+ test = Test(name, tests_folder / f"{name}.in", None)
489
+ _write(test.input, input_text)
490
+ answer_path = tests_folder / f"{name}.ans"
491
+ if answer.strip():
492
+ _write(answer_path, answer)
493
+ test = replace(test, answer=answer_path)
494
+ elif answer_path.exists():
495
+ answer_path.unlink()
496
+ return test
497
+
498
+
499
+ # ┌──────────────────────────────────────────────────────────┐
500
+ # │ Code files │
501
+ # └──────────────────────────────────────────────────────────┘
502
+
503
+ _SEP = r"[\W_]*" # what people put between the parts: 2050-A, cf_2050a
504
+ _TAIL = r"[\W_].*" # a variant's tail after a separator: _brute, -v2, " copy"
505
+ _CONTEST_ANY = r"\d+"
506
+ _PROBLEM_ANY = r"[a-z]\d?" # A, d, F1: problem indexes, when the target's is unknown
507
+ # helpers of a problem's folder, not solutions: never offered unless named
508
+ HELPERS = ("gen", "brute", "checker", "interactor")
509
+
510
+ RELATED, SAME_LETTER, UNRELATED = 2, 1, 0
511
+
512
+
513
+ @dataclass(frozen=True)
514
+ class CodeFile:
515
+ """A candidate: its path relative to the cwd, its language, its rank for
516
+ the target, and when it was last edited."""
517
+
518
+ name: str
519
+ language: str
520
+ score: int = UNRELATED
521
+ mtime: float = 0.0
522
+
523
+
524
+ class _Names:
525
+ """Name patterns for one problem; an unknown contest or problem matches any."""
526
+
527
+ def __init__(self, contest: str, problem: str) -> None:
528
+ self.contest, self.problem = contest, problem
529
+ c = re.escape(contest) if contest else _CONTEST_ANY
530
+ p = re.escape(problem) if problem else _PROBLEM_ANY
531
+ flags = re.IGNORECASE
532
+ self._contest = re.compile(rf"(?:cf{_SEP})?{c}", flags)
533
+ self._problem = re.compile(p, flags)
534
+ self._full = re.compile(rf"(?:cf{_SEP})?{c}{_SEP}{p}", flags)
535
+ # file names, which may carry a variant's tail
536
+ self._named = re.compile(rf"(?:cf{_SEP})?{c}{_SEP}{p}(?:{_TAIL})?", flags)
537
+ self._problem_named = re.compile(rf"{p}(?:{_TAIL})?", flags)
538
+
539
+ def is_contest(self, name: str) -> bool:
540
+ return bool(self._contest.fullmatch(name))
541
+
542
+ def is_problem(self, name: str) -> bool:
543
+ return bool(self._problem.fullmatch(name))
544
+
545
+ def is_full(self, name: str) -> bool:
546
+ return bool(self._full.fullmatch(name))
547
+
548
+ def score(self, path: Path) -> int:
549
+ """How clearly the file at path (absolute) is this problem's code:
550
+ RELATED, SAME_LETTER or UNRELATED."""
551
+ stem, parent, grand = path.stem, path.parent.name, path.parent.parent.name
552
+ in_problem = (self.is_problem(parent) and self.is_contest(grand)) or self.is_full(parent)
553
+ if (
554
+ in_problem # 2050/a/main.cpp, 2050A/sol.cpp, cf/contest/2050/a/brute.cpp
555
+ or self._named.fullmatch(stem) # 2050A.cpp, cf2050a_v2.cpp
556
+ # in the contest's folder: A.cpp, A-v2.cpp (A1.cpp is problem A1 there)
557
+ or (self.is_contest(parent) and self._problem_named.fullmatch(stem))
558
+ ):
559
+ return RELATED
560
+ if self.problem and self.is_problem(stem):
561
+ return SAME_LETTER
562
+ return UNRELATED
563
+
564
+ def base(self, cwd: Path) -> Path | None:
565
+ """The folder holding this problem's folders when cwd is one of them
566
+ (2050/, 2050/a/, 2050A/), else None."""
567
+ if self.is_contest(cwd.name) or self.is_full(cwd.name):
568
+ return cwd.parent
569
+ if self.is_problem(cwd.name) and self.is_contest(cwd.parent.name):
570
+ return cwd.parent.parent
571
+ return None
572
+
573
+ def folders(self, cwd: Path) -> list[Path]:
574
+ """Folders below cwd (two levels at most) where this problem's code may live."""
575
+ contest_dirs = [d for d in _subdirs(cwd) if self.is_contest(d.name)]
576
+ found = list(contest_dirs)
577
+ if self.is_contest(cwd.name): # the cwd itself is the contest's folder
578
+ contest_dirs.append(cwd)
579
+ if self.problem:
580
+ for base in contest_dirs:
581
+ found += [d for d in _subdirs(base) if self.is_problem(d.name)]
582
+ found += [d for d in _subdirs(cwd) if self.is_full(d.name)]
583
+ return found
584
+
585
+
586
+ def _entries(folder: Path) -> list[Path]:
587
+ try:
588
+ return sorted(folder.iterdir())
589
+ except OSError:
590
+ return []
591
+
592
+
593
+ def _subdirs(folder: Path) -> list[Path]:
594
+ return [e for e in _entries(folder) if e.is_dir() and not e.name.startswith(".")]
595
+
596
+
597
+ # a helper is named for its kind (check and generator count too), then a digit,
598
+ # _ or - and anything, or nothing more: gen.py, gen2.py, brute_c.cpp, check_a.py
599
+ _HELPER_RE = re.compile(
600
+ r"(generator|gen|brute|checker|check|interactor)(?:[\d_-].*)?", re.IGNORECASE
601
+ )
602
+
603
+
604
+ def helper_kind(name: str) -> str | None:
605
+ """Which of a problem's helpers a file is (one of HELPERS), or None for
606
+ other code: gen2.py -> gen, check_a.py -> checker, general.cpp -> None."""
607
+ match = _HELPER_RE.fullmatch(Path(name).stem)
608
+ if match is None:
609
+ return None
610
+ word = match.group(1).lower()
611
+ return {"generator": "gen", "check": "checker"}.get(word, word)
612
+
613
+
614
+ def is_helper(name: str) -> bool:
615
+ """Whether a file is one of a problem's helpers (gen.py, brute_c.cpp, ...)."""
616
+ return helper_kind(name) is not None
617
+
618
+
619
+ def find_code(
620
+ cwd: Path,
621
+ settings,
622
+ contest: str = "",
623
+ problem: str = "",
624
+ exclude: Collection[str] = (),
625
+ ) -> list[CodeFile]:
626
+ """Code files for the target problem, ranked best first: those in cwd,
627
+ and with a known contest, those in its folders below cwd, and when cwd is
628
+ one of these folders, the problem's files around it. Helpers (gen.*,
629
+ brute.*, ...) and the files in exclude are left out."""
630
+ cwd = cwd.resolve()
631
+ skip = {(cwd / name).resolve() for name in exclude} # NOTE: relative to cwd, or absolute
632
+ extensions = languages.code_extensions(settings)
633
+ names = _Names(contest, problem)
634
+ folders = [cwd]
635
+ around: list[Path] = [] # of these folders' files, only this problem's count
636
+ if contest:
637
+ folders += names.folders(cwd)
638
+ base = names.base(cwd)
639
+ if base is not None:
640
+ around = [
641
+ folder
642
+ for folder in [base, *names.folders(base)]
643
+ if folder != cwd and cwd not in folder.parents # NOTE: cwd and below: done
644
+ ]
645
+ found: dict[str, CodeFile] = {}
646
+ for folder in folders + around:
647
+ for entry in _entries(folder):
648
+ if not (entry.suffix.lower() in extensions and entry.is_file()):
649
+ continue
650
+ if is_helper(entry.name):
651
+ continue
652
+ score = names.score(entry)
653
+ if folder in around and score != RELATED:
654
+ continue
655
+ if entry.resolve() in skip:
656
+ continue
657
+ name = os.path.relpath(entry, cwd) # NOTE: ../../2050A.cpp from 2050/a/
658
+ key = languages.language_key(entry, settings)
659
+ if key is None:
660
+ continue
661
+ found[name] = CodeFile(name, key, score, entry.stat().st_mtime)
662
+ return sorted(found.values(), key=lambda f: (-f.score, -f.mtime, f.name))
663
+
664
+
665
+ def pick_code(
666
+ filename: str | None,
667
+ cwd: Path,
668
+ settings,
669
+ *,
670
+ contest: str = "",
671
+ problem: str = "",
672
+ action: str = "submit",
673
+ exclude: Collection[str] = (),
674
+ ) -> tuple[str, str]:
675
+ """Exactly one (code file, language): the file named, the only candidate,
676
+ or the user's pick among the candidates (the best pre-selected)."""
677
+ if filename:
678
+ key = languages.language_key(filename, settings)
679
+ if key is None:
680
+ raise CFError(
681
+ f"cf-tool doesn't know what language {filename} is in: give its "
682
+ f"extension a table with build and run commands in {settings.file}"
683
+ )
684
+ return filename, key
685
+ files = find_code(cwd, settings, contest, problem, exclude)
686
+ if not files:
687
+ where = f" for {contest}{problem}" if contest and problem else ""
688
+ raise CFError(f"No code file{where} here. cf new writes one from your starter code")
689
+ chosen = _choose(files, action)
690
+ return chosen.name, chosen.language
691
+
692
+
693
+ def _choose(files: list[CodeFile], action: str) -> CodeFile:
694
+ """The file to use out of files (ranked best first): the only one, or
695
+ the one the user picks, the best being pre-selected."""
696
+ if len(files) == 1:
697
+ return files[0]
698
+ if not ui.interactive(): # NOTE: a menu nobody can answer would wait forever
699
+ names = ", ".join(f.name for f in files[:4]) + (", ..." if len(files) > 4 else "")
700
+ raise CFError(
701
+ f"Several code files could be meant ({names}): name the one to {action}. "
702
+ "Files named gen*, brute*, check* or interactor* (gen2.py, brute_c.cpp) "
703
+ "are helpers, never in the way"
704
+ )
705
+ width = max(cell_len(f.name) for f in files)
706
+ now = time.time()
707
+ choices: list[ui.Choice | ui.Heading] = []
708
+ for i, f in enumerate(files):
709
+ if i and f.score < RELATED <= files[i - 1].score:
710
+ choices.append(ui.Heading("── other files ──"))
711
+ # names padded by display width, so the times line up
712
+ label = f.name + " " * (width - cell_len(f.name)) + " " + edited(f.mtime, now)
713
+ choices.append((label, f))
714
+ return ui.select(
715
+ f"Which file do you want to {action}?",
716
+ choices,
717
+ default=files[0],
718
+ search=len(files) > ui.MAX_SHORTCUTS,
719
+ )
720
+
721
+
722
+ _MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
723
+
724
+
725
+ def edited(mtime: float, now: float) -> str:
726
+ """When a file was last changed, as the file menu says it: today 18:09,
727
+ yesterday 16:11, Mar 31, or Mar 31 2025 for another year."""
728
+ moment, today = datetime.fromtimestamp(mtime), datetime.fromtimestamp(now).date()
729
+ days = (today - moment.date()).days
730
+ if days == 0:
731
+ return f"today {moment:%H:%M}"
732
+ if days == 1:
733
+ return f"yesterday {moment:%H:%M}"
734
+ date = f"{_MONTHS[moment.month - 1]} {moment.day}" # NOTE: not %b, which follows the locale
735
+ return date if moment.year == today.year else f"{date} {moment.year}"
736
+
737
+
738
+ _NAMED_RE = re.compile(rf"(?:cf{_SEP})?(\d+){_SEP}({_PROBLEM_ANY})(?:{_TAIL})?", re.IGNORECASE)
739
+ _CONTEST_RE = re.compile(rf"(?:cf{_SEP})?(\d+)", re.IGNORECASE)
740
+ _PROBLEM_RE = re.compile(rf"({_PROBLEM_ANY})(?:{_TAIL})?", re.IGNORECASE)
741
+
742
+
743
+ def named_target(name: str, cwd: Path) -> tuple[str, str]:
744
+ """(contest, problem) that a code file's name or folders spell out, ""
745
+ for what they don't: 2050A.cpp, 2050/A.cpp, 2050/a/main.cpp -> ("2050",
746
+ "A"); A.cpp -> ("", "A"); demo.cpp -> ("", "")."""
747
+ path = Path(os.path.normpath(cwd / name)) # NOTE: ../A.cpp: the real folder's name
748
+ stem, parent, grand = path.stem, path.parent.name, path.parent.parent.name
749
+ for text in (stem, parent):
750
+ if match := _NAMED_RE.fullmatch(text):
751
+ return match.group(1), match.group(2).upper()
752
+ for contest_text, problem_text in ((parent, stem), (grand, parent)):
753
+ contest, problem = _CONTEST_RE.fullmatch(contest_text), _PROBLEM_RE.fullmatch(problem_text)
754
+ if contest and problem:
755
+ return contest.group(1), problem.group(1).upper()
756
+ if match := _PROBLEM_RE.fullmatch(stem):
757
+ return "", match.group(1).upper()
758
+ return "", ""
759
+
760
+
761
+ def read_source(filename: str | Path) -> str:
762
+ """Text of a code file: UTF-8 (with or without BOM), else GB18030 — the
763
+ usual non-UTF-8 encoding of sources written on Chinese Windows setups."""
764
+ try:
765
+ data = Path(filename).read_bytes()
766
+ except OSError as e:
767
+ raise CFError(f"Cannot read {filename}: {e.strerror or e}") from e
768
+ try:
769
+ return data.decode("utf-8-sig")
770
+ except UnicodeDecodeError:
771
+ pass
772
+ try:
773
+ text, encoding = data.decode("gb18030"), "GB18030"
774
+ except UnicodeDecodeError:
775
+ text, encoding = data.decode("latin-1"), "Latin-1" # NOTE: decodes any bytes
776
+ ui.yellow(f"{filename} is not UTF-8; read it as {encoding}")
777
+ return text