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