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/views.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Rich renderables: submission table/panel, problem list, failed-sample diff,
|
|
2
|
+
a submission's failed test.
|
|
3
|
+
|
|
4
|
+
Pure functions from data to renderables — printing (and live refresh) is
|
|
5
|
+
the caller's business. Every foreign string goes in as Text, never markup.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import operator
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
14
|
+
from rich import box
|
|
15
|
+
from rich.console import Group, RenderableType
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
from cf_tool import ui
|
|
21
|
+
from cf_tool.codeforces.pages import Contest, ProblemRow, Submission, TestDetail
|
|
22
|
+
from cf_tool.workspace import Cases
|
|
23
|
+
|
|
24
|
+
HEADER = f"bold {ui.ACCENT}"
|
|
25
|
+
|
|
26
|
+
LinesAlike = Callable[[str, str], bool] # (output line, answer line): counts as it?
|
|
27
|
+
|
|
28
|
+
# a failed sample shows at most this many lines of input / of the diff
|
|
29
|
+
INPUT_LINES = 30
|
|
30
|
+
DIFF_LINES = 40
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def submission_table(submissions: list[Submission], problem_id: str = "") -> Table:
|
|
34
|
+
"""Recent submissions (cf status), optionally one problem's."""
|
|
35
|
+
table = Table(box=box.ROUNDED, border_style=ui.MUTED, header_style=HEADER)
|
|
36
|
+
# long columns may wrap on narrow terminals; the short ones never do
|
|
37
|
+
for column in ("#", "when", "problem", "lang", "status", "time", "memory"):
|
|
38
|
+
table.add_column(column, justify="center", no_wrap=column in ("#", "time", "memory"))
|
|
39
|
+
for sub in submissions:
|
|
40
|
+
if problem_id and sub.problem_index() != problem_id.lower():
|
|
41
|
+
continue
|
|
42
|
+
status, style = sub.styled_status()
|
|
43
|
+
table.add_row(
|
|
44
|
+
str(sub.id),
|
|
45
|
+
Text(sub.when),
|
|
46
|
+
Text(sub.name),
|
|
47
|
+
Text(sub.lang),
|
|
48
|
+
Text(status, style=ui.styled(style)),
|
|
49
|
+
sub.parse_time(),
|
|
50
|
+
sub.parse_memory(),
|
|
51
|
+
)
|
|
52
|
+
return table
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def submission_panel(sub: Submission, boxed: bool = True) -> Panel | Table:
|
|
56
|
+
"""One submission in detail (after cf submit); unboxed, its lines read
|
|
57
|
+
"status: Accepted" for programs reading them."""
|
|
58
|
+
status, style = sub.styled_status()
|
|
59
|
+
grid = Table.grid(padding=(0, 1))
|
|
60
|
+
grid.add_column(justify="right", style=HEADER)
|
|
61
|
+
grid.add_column()
|
|
62
|
+
grid.add_row("#:", str(sub.id))
|
|
63
|
+
grid.add_row("when:", Text(sub.when))
|
|
64
|
+
grid.add_row("prob:", Text(sub.name))
|
|
65
|
+
grid.add_row("lang:", Text(sub.lang))
|
|
66
|
+
grid.add_row("status:", Text(status, style=ui.styled(style or "white")))
|
|
67
|
+
grid.add_row("time:", sub.parse_time() if sub.end else "-")
|
|
68
|
+
grid.add_row("memory:", sub.parse_memory() if sub.end else "-")
|
|
69
|
+
if not boxed:
|
|
70
|
+
return grid
|
|
71
|
+
border = ui.styled(style) if sub.end and style else ui.MUTED
|
|
72
|
+
return Panel(grid, box=box.ROUNDED, border_style=border, expand=False)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def problems_table(problems: list[ProblemRow]) -> Table:
|
|
76
|
+
"""A contest's problems (cf problems): solved rows green, failed ones red."""
|
|
77
|
+
table = Table(box=box.ROUNDED, border_style=ui.MUTED, header_style=HEADER)
|
|
78
|
+
for column in ("#", "problem", "passed", "limit", "IO"):
|
|
79
|
+
table.add_column(column, justify="center")
|
|
80
|
+
for prob in problems:
|
|
81
|
+
style = ""
|
|
82
|
+
if "accepted" in prob.state:
|
|
83
|
+
style = f"black on {ui.SUCCESS}"
|
|
84
|
+
elif "rejected" in prob.state:
|
|
85
|
+
style = f"black on {ui.ERROR}"
|
|
86
|
+
table.add_row(
|
|
87
|
+
*(Text(v) for v in (prob.id, prob.name, prob.passed, prob.limit, prob.io)),
|
|
88
|
+
style=style,
|
|
89
|
+
)
|
|
90
|
+
return table
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
_DAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
|
94
|
+
_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _when(moment: float) -> str:
|
|
98
|
+
"""A moment in local time: "Sat Nov 1 14:35" (English names whatever the locale)."""
|
|
99
|
+
local = datetime.fromtimestamp(moment)
|
|
100
|
+
return f"{_DAYS[local.weekday()]} {_MONTHS[local.month - 1]} {local.day} {local:%H:%M}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _until(seconds: float) -> str:
|
|
104
|
+
"""A wait in words: "35 min", "5 h 12 min", "3 days 4 h"."""
|
|
105
|
+
minutes = max(0, int(seconds // 60))
|
|
106
|
+
days, minutes = divmod(minutes, 24 * 60)
|
|
107
|
+
hours, minutes = divmod(minutes, 60)
|
|
108
|
+
if days:
|
|
109
|
+
return f"{days} day{'s' if days > 1 else ''} {hours} h"
|
|
110
|
+
return f"{hours} h {minutes} min" if hours else f"{minutes} min"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _length(seconds: int) -> str:
|
|
114
|
+
"""A contest's length: "2:15", or "14 days" for long ones."""
|
|
115
|
+
if seconds >= 24 * 3600:
|
|
116
|
+
days = round(seconds / (24 * 3600))
|
|
117
|
+
return f"{days} day{'s' if days > 1 else ''}"
|
|
118
|
+
return f"{seconds // 3600}:{seconds % 3600 // 60:02d}"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def contests_table(contests: list[Contest], now: float) -> Table:
|
|
122
|
+
"""Upcoming contests (`cf contests`), soonest first."""
|
|
123
|
+
table = Table(box=box.ROUNDED, border_style=ui.MUTED, header_style=HEADER)
|
|
124
|
+
for column in ("#", "contest", "starts", "in", "length"):
|
|
125
|
+
table.add_column(column, no_wrap=column != "contest")
|
|
126
|
+
for contest in contests:
|
|
127
|
+
table.add_row(
|
|
128
|
+
str(contest.id),
|
|
129
|
+
Text(contest.name),
|
|
130
|
+
_when(contest.start),
|
|
131
|
+
_until(contest.start - now),
|
|
132
|
+
_length(contest.duration),
|
|
133
|
+
)
|
|
134
|
+
return table
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _ics_text(value: str) -> str:
|
|
138
|
+
return value.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _ics_fold(line: str) -> str:
|
|
142
|
+
"""A content line folded to 75 characters, as calendars expect."""
|
|
143
|
+
parts = [line[:75]] + [" " + line[i : i + 74] for i in range(75, len(line), 74)]
|
|
144
|
+
return "\r\n".join(parts)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def to_ics(contests: list[Contest], host: str, now: float) -> str:
|
|
148
|
+
"""contests as an iCalendar file to import into a calendar app."""
|
|
149
|
+
|
|
150
|
+
def stamp(moment: float) -> str:
|
|
151
|
+
return datetime.fromtimestamp(moment, timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
152
|
+
|
|
153
|
+
lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//cf-tool//Codeforces contests//EN"]
|
|
154
|
+
for contest in contests:
|
|
155
|
+
url = f"{host}/contests/{contest.id}"
|
|
156
|
+
lines += [
|
|
157
|
+
"BEGIN:VEVENT",
|
|
158
|
+
f"UID:contest-{contest.id}@codeforces.com",
|
|
159
|
+
f"DTSTAMP:{stamp(now)}",
|
|
160
|
+
f"DTSTART:{stamp(contest.start)}",
|
|
161
|
+
f"DTEND:{stamp(contest.start + contest.duration)}",
|
|
162
|
+
f"SUMMARY:{_ics_text(contest.name)}",
|
|
163
|
+
f"URL:{url}",
|
|
164
|
+
f"DESCRIPTION:{_ics_text(url)}",
|
|
165
|
+
"END:VEVENT",
|
|
166
|
+
]
|
|
167
|
+
lines.append("END:VCALENDAR")
|
|
168
|
+
return "".join(_ics_fold(line) + "\r\n" for line in lines)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _clip(lines: list[str], limit: int) -> Text:
|
|
172
|
+
text = Text("\n".join(lines[:limit]))
|
|
173
|
+
if len(lines) > limit:
|
|
174
|
+
text.append(f"\n… {len(lines) - limit} more lines", style=ui.MUTED)
|
|
175
|
+
return text
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def program_output(text: str, title: str = "Output") -> Group:
|
|
179
|
+
"""Text a program printed (or got), under a -----title----- heading; its
|
|
180
|
+
first lines only."""
|
|
181
|
+
heading = Text(f"── {title.lower()} ──", style=ui.ACCENT)
|
|
182
|
+
if not text.strip():
|
|
183
|
+
return Group(heading, Text(f"(no {title.lower()})", style=ui.MUTED))
|
|
184
|
+
return Group(heading, _clip(text.rstrip("\n").split("\n"), INPUT_LINES))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _diff_rows(
|
|
188
|
+
rows: list[int], out_lines: list[str], ans_lines: list[str], same: LinesAlike = operator.eq
|
|
189
|
+
) -> Table:
|
|
190
|
+
"""Output and answer side by side for these line indexes, differences marked."""
|
|
191
|
+
diff = Table(box=box.SIMPLE_HEAD, header_style=HEADER, pad_edge=False)
|
|
192
|
+
diff.add_column("#", justify="right", style=ui.MUTED)
|
|
193
|
+
diff.add_column("output")
|
|
194
|
+
diff.add_column("answer")
|
|
195
|
+
for i in rows:
|
|
196
|
+
out = out_lines[i] if i < len(out_lines) else None
|
|
197
|
+
ans = ans_lines[i] if i < len(ans_lines) else None
|
|
198
|
+
alike = out is not None and ans is not None and same(out, ans)
|
|
199
|
+
diff.add_row(
|
|
200
|
+
str(i + 1),
|
|
201
|
+
(
|
|
202
|
+
Text("(missing)", style=ui.MUTED)
|
|
203
|
+
if out is None
|
|
204
|
+
else Text(out, style="" if alike else ui.ERROR)
|
|
205
|
+
),
|
|
206
|
+
(
|
|
207
|
+
Text("(missing)", style=ui.MUTED)
|
|
208
|
+
if ans is None
|
|
209
|
+
else Text(ans, style="" if alike else ui.SUCCESS)
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
return diff
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _failing_case(
|
|
216
|
+
input_lines: list[str],
|
|
217
|
+
out_lines: list[str],
|
|
218
|
+
ans_lines: list[str],
|
|
219
|
+
cases: Cases,
|
|
220
|
+
same: LinesAlike,
|
|
221
|
+
) -> Group | None:
|
|
222
|
+
"""The first test case of a multi-test sample that differs: its input and
|
|
223
|
+
lines only; None when the saved layout doesn't fit these files."""
|
|
224
|
+
input_cases, answer_cases = cases
|
|
225
|
+
if len(input_cases) != len(input_lines) or len(answer_cases) != len(ans_lines):
|
|
226
|
+
return None # NOTE: the sample was edited after cf parse
|
|
227
|
+
bad = sorted(
|
|
228
|
+
{
|
|
229
|
+
answer_cases[i]
|
|
230
|
+
for i in range(len(ans_lines))
|
|
231
|
+
if i >= len(out_lines) or not same(out_lines[i], ans_lines[i])
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
total = max(input_cases, default=0)
|
|
235
|
+
if not bad: # NOTE: every case matches, so the output only runs longer
|
|
236
|
+
extra = list(range(len(ans_lines), len(out_lines)))[:DIFF_LINES]
|
|
237
|
+
return Group(
|
|
238
|
+
Text("Extra output after the last test case", style=ui.ERROR),
|
|
239
|
+
_diff_rows(extra, out_lines, ans_lines, same),
|
|
240
|
+
)
|
|
241
|
+
case = bad[0]
|
|
242
|
+
others = f" (also {', '.join(map(str, bad[1:]))})" if len(bad) > 1 else ""
|
|
243
|
+
rows = [i for i, c in enumerate(answer_cases) if c == case][:DIFF_LINES]
|
|
244
|
+
case_input = [line for line, c in zip(input_lines, input_cases) if c == case]
|
|
245
|
+
return Group(
|
|
246
|
+
Text(f"Test case {case} of {total} differs{others}", style=ui.ERROR),
|
|
247
|
+
Text(f"── input of test case {case} ──", style=ui.ACCENT),
|
|
248
|
+
_clip(case_input, INPUT_LINES),
|
|
249
|
+
_diff_rows(rows, out_lines, ans_lines, same),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def failed_sample(
|
|
254
|
+
input_text: str,
|
|
255
|
+
out_lines: list[str],
|
|
256
|
+
ans_lines: list[str],
|
|
257
|
+
cases: Cases | None = None,
|
|
258
|
+
same: LinesAlike = operator.eq,
|
|
259
|
+
) -> Group:
|
|
260
|
+
"""What went wrong in a sample (same: whether an output line counts as
|
|
261
|
+
its answer line):
|
|
262
|
+
- multi-test, with cases known: the first differing test case only
|
|
263
|
+
- otherwise: the input, then output and answer side by side"""
|
|
264
|
+
input_lines = input_text.rstrip("\n").split("\n")
|
|
265
|
+
if cases is not None:
|
|
266
|
+
view = _failing_case(input_lines, out_lines, ans_lines, cases, same)
|
|
267
|
+
if view is not None:
|
|
268
|
+
return view
|
|
269
|
+
total = max(len(out_lines), len(ans_lines))
|
|
270
|
+
first_bad = next(
|
|
271
|
+
(
|
|
272
|
+
i
|
|
273
|
+
for i in range(total)
|
|
274
|
+
if i >= len(out_lines) or i >= len(ans_lines) or not same(out_lines[i], ans_lines[i])
|
|
275
|
+
),
|
|
276
|
+
0,
|
|
277
|
+
)
|
|
278
|
+
# long outputs: show a window starting a little before the first difference
|
|
279
|
+
start = max(0, first_bad - 5) if total > DIFF_LINES else 0
|
|
280
|
+
stop = min(total, start + DIFF_LINES)
|
|
281
|
+
parts: list[RenderableType] = [
|
|
282
|
+
Text("── input ──", style=ui.ACCENT),
|
|
283
|
+
_clip(input_lines, INPUT_LINES),
|
|
284
|
+
_diff_rows(list(range(start, stop)), out_lines, ans_lines, same),
|
|
285
|
+
]
|
|
286
|
+
if start or stop < total:
|
|
287
|
+
parts.append(Text(f"lines {start + 1}–{stop} of {total}", style=ui.MUTED))
|
|
288
|
+
return Group(*parts)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def failed_test(detail: TestDetail) -> Group:
|
|
292
|
+
"""The test a submission failed, as Codeforces shows it: what the checker
|
|
293
|
+
said, then input, output and answer like a failed sample."""
|
|
294
|
+
cut = ", cut short by Codeforces" if detail.cut else ""
|
|
295
|
+
parts: list[RenderableType] = [Text(f"── test {detail.number}{cut} ──", style=ui.ACCENT)]
|
|
296
|
+
if detail.checker:
|
|
297
|
+
parts.append(Text.assemble(("checker: ", ui.ERROR), detail.checker))
|
|
298
|
+
parts.append(
|
|
299
|
+
failed_sample(
|
|
300
|
+
detail.input,
|
|
301
|
+
detail.output.strip().splitlines(),
|
|
302
|
+
detail.answer.strip().splitlines(),
|
|
303
|
+
same=lambda out, ans: out.split() == ans.split(),
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
return Group(*parts)
|