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