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
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
"""All Codeforces HTML/JSON parsing in one place (regexes + BeautifulSoup).
|
|
2
|
+
|
|
3
|
+
Samples come in the classic markup (lines split by <br>), the newer one
|
|
4
|
+
(a <div class="test-example-line"> per line), and acmsguru's free-form
|
|
5
|
+
"Sample Input" statements.
|
|
6
|
+
|
|
7
|
+
Also here: recognizing the pages that stand *between* us and Codeforces
|
|
8
|
+
(Cloudflare's interstitial, Codeforces' own Turnstile gate and browser check).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import html as html_lib
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import datetime, timedelta, timezone
|
|
17
|
+
|
|
18
|
+
from bs4 import BeautifulSoup, Comment
|
|
19
|
+
|
|
20
|
+
from cf_tool.errors import CFError, NotLoggedInError
|
|
21
|
+
|
|
22
|
+
_HANDLE_RE = re.compile(r'handle = "([\s\S]+?)"')
|
|
23
|
+
_CSRF_RE = re.compile(r"csrf='(.+?)'")
|
|
24
|
+
_CSRF_DATA_RE = re.compile(r'data-csrf=[\'"]([a-f0-9]+)[\'"]')
|
|
25
|
+
_INPUT_RE = re.compile(r'class="input"[\s\S]*?<pre[^>]*>([\s\S]*?)</pre>')
|
|
26
|
+
_OUTPUT_RE = re.compile(r'class="output"[\s\S]*?<pre[^>]*>([\s\S]*?)</pre>')
|
|
27
|
+
# acmsguru statements are free-form: "Sample Input" <p>, then a <pre>
|
|
28
|
+
_SGU_INPUT_RE = re.compile(r"Sample\s+Input[\s\S]*?<pre[^>]*>([\s\S]*?)</pre>", re.IGNORECASE)
|
|
29
|
+
_SGU_OUTPUT_RE = re.compile(r"Sample\s+Output[\s\S]*?<pre[^>]*>([\s\S]*?)</pre>", re.IGNORECASE)
|
|
30
|
+
_COUNTDOWN_RE = re.compile(r'class=["\']countdown["\'][\s\S]*?(\d+):(\d+):(\d+)')
|
|
31
|
+
_ERROR_MSG_RE = re.compile(r'error[a-zA-Z_\- ]*">(.*?)</span>')
|
|
32
|
+
_SHOW_MSG_RE = re.compile(
|
|
33
|
+
r'Codeforces\.showMessage\("([^"]*)"\);\s*?Codeforces\.reformatTimes\(\);'
|
|
34
|
+
)
|
|
35
|
+
_CODE_RE = re.compile(r"<pre[\s\S]*?>([\s\S]*?)</pre>")
|
|
36
|
+
_UTC_OFFSET_RE = re.compile(r'name="utc_offset" content="([\s\S]+?)"')
|
|
37
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
38
|
+
_NUM_RE = re.compile(r"\d+")
|
|
39
|
+
|
|
40
|
+
# Codeforces has added classes over time, and the wording varies by problem
|
|
41
|
+
# age ("standard input" on newer pages, "stdin" on older ones): match loosely
|
|
42
|
+
_STANDARD_INPUT_RE = re.compile(
|
|
43
|
+
r'class="input-file input-standard"|'
|
|
44
|
+
r'class="input-file[^"]*"><div class="property-title">input</div>\s*(standard input|stdin)<'
|
|
45
|
+
)
|
|
46
|
+
_STANDARD_OUTPUT_RE = re.compile(
|
|
47
|
+
r'class="output-file output-standard"|'
|
|
48
|
+
r'class="output-file[^"]*"><div class="property-title">output</div>\s*(standard output|stdout)<'
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Cloudflare's managed-challenge interstitial. Never use "challenge-platform"
|
|
52
|
+
# as a marker: every normal page embeds Cloudflare's bot-detection script
|
|
53
|
+
# from /cdn-cgi/challenge-platform/scripts/jsd/.
|
|
54
|
+
_CLOUDFLARE_MARKERS = ("<title>Just a moment", "_cf_chl_opt")
|
|
55
|
+
# Codeforces' own Turnstile gate posts the solved token here, then reloads
|
|
56
|
+
_VERIFICATION_MARKER = "/data/turnstile"
|
|
57
|
+
# Codeforces' own check in front of the login page: a script that sets a cookie
|
|
58
|
+
# and reloads, so only a browser gets past it
|
|
59
|
+
_BROWSER_CHECK_MARKER = "Your browser is being checked"
|
|
60
|
+
_CAPTCHA_WORDS_RE = re.compile(r"captcha|robot|human|turnstile|verif", re.IGNORECASE)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_cloudflare_challenge(body: str) -> bool:
|
|
64
|
+
return any(marker in body for marker in _CLOUDFLARE_MARKERS)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_verification_gate(body: str) -> bool:
|
|
68
|
+
return _VERIFICATION_MARKER in body
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_gate(body: str) -> bool:
|
|
72
|
+
"""True for a page that stands in front of the content: Cloudflare's
|
|
73
|
+
interstitial, Codeforces' own Turnstile verification page or browser check."""
|
|
74
|
+
return is_cloudflare_challenge(body) or is_verification_gate(body) or is_browser_check(body)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_PAGE_TITLE_RE = re.compile(r"<title[^>]*>([\s\S]*?)</title>", re.IGNORECASE)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def describe_page(body: str) -> str:
|
|
81
|
+
"""What a page is, in a few words, for an error message."""
|
|
82
|
+
if is_cloudflare_challenge(body):
|
|
83
|
+
return "Cloudflare's check"
|
|
84
|
+
if is_browser_check(body):
|
|
85
|
+
return "Codeforces' browser check"
|
|
86
|
+
if is_verification_gate(body):
|
|
87
|
+
return "Codeforces' Turnstile check"
|
|
88
|
+
match = _PAGE_TITLE_RE.search(body)
|
|
89
|
+
title = " ".join(html_lib.unescape(match.group(1)).split()) if match else ""
|
|
90
|
+
if title:
|
|
91
|
+
return f'the page "{title}"'
|
|
92
|
+
return "a blank page" if not body.strip() else "a page without a title"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def has_turnstile(body: str) -> bool:
|
|
96
|
+
"""True when the page carries a Turnstile widget (only a browser can solve it)."""
|
|
97
|
+
return "cf-turnstile" in body
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def is_browser_check(body: str) -> bool:
|
|
101
|
+
"""True for Codeforces' "your browser is being checked" page."""
|
|
102
|
+
return _BROWSER_CHECK_MARKER in body
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def is_captcha_error(message: str) -> bool:
|
|
106
|
+
"""True when a form error is about the human check, not the credentials."""
|
|
107
|
+
return bool(_CAPTCHA_WORDS_RE.search(message))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def find_handle(body: str) -> str | None:
|
|
111
|
+
match = _HANDLE_RE.search(body)
|
|
112
|
+
return match.group(1) if match else None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def require_handle(body: str) -> str:
|
|
116
|
+
handle = find_handle(body)
|
|
117
|
+
if handle is None:
|
|
118
|
+
raise NotLoggedInError()
|
|
119
|
+
return handle
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def wants_login(body: str) -> bool:
|
|
123
|
+
"""Whether the page is the login form (a page Codeforces shows only once logged in)."""
|
|
124
|
+
return 'name="handleOrEmail"' in body or 'id="enterForm"' in body
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def find_csrf(body: str) -> str:
|
|
128
|
+
match = _CSRF_RE.search(body) or _CSRF_DATA_RE.search(body)
|
|
129
|
+
if not match:
|
|
130
|
+
raise CFError("Cannot find csrf")
|
|
131
|
+
return match.group(1)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _pre_to_text(inner: str) -> str:
|
|
135
|
+
"""Sample <pre> content -> text. Handles both the classic <br>-separated
|
|
136
|
+
markup and the newer one-<div>-per-line markup."""
|
|
137
|
+
if "test-example-line" in inner:
|
|
138
|
+
lines = re.findall(r"<div[^>]*>([\s\S]*?)</div>", inner)
|
|
139
|
+
# only the whole text is trimmed: leading spaces inside
|
|
140
|
+
# a line can be part of the input, e.g. ASCII-art grids
|
|
141
|
+
text = "\n".join(html_lib.unescape(_TAG_RE.sub("", line)) for line in lines)
|
|
142
|
+
return text.strip() + "\n"
|
|
143
|
+
text = re.sub(r"<[\s/br]+?>", "\n", inner) # <br> in all its spellings
|
|
144
|
+
text = _TAG_RE.sub("", text)
|
|
145
|
+
return html_lib.unescape(text).strip() + "\n"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def find_samples(body: str) -> tuple[list[str], list[str]]:
|
|
149
|
+
"""Sample (inputs, outputs) as text, each ending with a newline."""
|
|
150
|
+
inputs = _INPUT_RE.findall(body)
|
|
151
|
+
outputs = _OUTPUT_RE.findall(body)
|
|
152
|
+
if not inputs and not outputs:
|
|
153
|
+
inputs = _SGU_INPUT_RE.findall(body)
|
|
154
|
+
outputs = _SGU_OUTPUT_RE.findall(body)
|
|
155
|
+
if not inputs or not outputs or len(inputs) != len(outputs):
|
|
156
|
+
raise CFError(f"Cannot parse sample with input {len(inputs)} and output {len(outputs)}")
|
|
157
|
+
return [_pre_to_text(s) for s in inputs], [_pre_to_text(s) for s in outputs]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
_TITLE_RE = re.compile(r'class="header"><div class="title">([\s\S]*?)</div>')
|
|
161
|
+
_TIME_LIMIT_RE = re.compile(
|
|
162
|
+
r'class="time-limit"><div class="property-title">[^<]*</div>\s*([\d.]+)'
|
|
163
|
+
)
|
|
164
|
+
_MEMORY_LIMIT_RE = re.compile(
|
|
165
|
+
r'class="memory-limit"><div class="property-title">[^<]*</div>\s*(\d+)'
|
|
166
|
+
)
|
|
167
|
+
_INTERACTION_RE = re.compile(
|
|
168
|
+
r'class="section-title">\s*(?:Interaction|Протокол взаимодействия)\s*<'
|
|
169
|
+
)
|
|
170
|
+
_SAMPLE_LINE_RE = re.compile(
|
|
171
|
+
r'<div class="[^"]*\btest-example-line-(\d+)\b[^"]*"[^>]*>([\s\S]*?)</div>'
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
_ANY_CASE_RE = re.compile(
|
|
176
|
+
r"any letter case|case[- ]insensitive|в любом регистре|in any (?:letter )?case(?!\s*,)",
|
|
177
|
+
re.IGNORECASE,
|
|
178
|
+
)
|
|
179
|
+
# "in any case" means letter case only after words about printing ("In any
|
|
180
|
+
# case, ..." is the other "in any case")
|
|
181
|
+
_PRINTING_RE = re.compile(r"print|output|letter|answer|string|word|yes|no\b", re.IGNORECASE)
|
|
182
|
+
_ERROR_RE = re.compile(
|
|
183
|
+
r"(?:(?:absolute|relative)\s+(?:or\s+(?:absolute|relative)\s+)?(?:error|precision)|погрешност)"
|
|
184
|
+
r"[^.]{0,160}?(?:10\s*(?:\^\s*\{?|\s)\s*[-−]\s*(\d+)|\b1\s*[eE]\s*[-−]\s*(\d+))",
|
|
185
|
+
re.IGNORECASE,
|
|
186
|
+
)
|
|
187
|
+
# "If there are several answers, print any of them": the checker takes any right
|
|
188
|
+
# answer, the sample's being only one (not "don't print anything", "print any letter
|
|
189
|
+
# in any case")
|
|
190
|
+
_ANY_ANSWER_RE = re.compile(
|
|
191
|
+
r"(?<!not )(?<!n't )(?<!never )\b(?:print|output)\s+any\b(?!\s+(?:letter|case|extra))"
|
|
192
|
+
r"|\bany\s+(?:one\s+)?of\s+them\b[^.]{0,30}\baccepted\b"
|
|
193
|
+
r"|\bother\s+(?:correct\s+|valid\s+)?answers\s+(?:are|may\s+be)\s+(?:also\s+)?"
|
|
194
|
+
r"(?:possible|accepted|correct)"
|
|
195
|
+
r"|выведите\s+любо|любой\s+из\s+них",
|
|
196
|
+
re.IGNORECASE,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass
|
|
201
|
+
class ProblemHeader:
|
|
202
|
+
name: str # "A. Watermelon"
|
|
203
|
+
time_limit: float | None # seconds
|
|
204
|
+
memory_limit: int | None # megabytes
|
|
205
|
+
interactive: bool
|
|
206
|
+
ignore_case: bool = False # the statement accepts answers in any letter case
|
|
207
|
+
epsilon: float | None = None # the absolute or relative error it accepts
|
|
208
|
+
any_answer: bool = False # it accepts any right answer, the sample's being one
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _text(fragment: str) -> str:
|
|
212
|
+
return re.sub(r"\s+", " ", html_lib.unescape(_TAG_RE.sub(" ", fragment)))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _statement_text(body: str) -> str:
|
|
216
|
+
"""The statement's text before the samples, tags removed."""
|
|
217
|
+
start = body.find('class="problem-statement"')
|
|
218
|
+
if start < 0:
|
|
219
|
+
return ""
|
|
220
|
+
stop = body.find('class="sample-tests"', start)
|
|
221
|
+
return _text(body[start : stop if stop >= 0 else len(body)])
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _note_text(body: str) -> str:
|
|
225
|
+
"""The text of the note after the samples ("other answers are possible")."""
|
|
226
|
+
start = body.find('class="note"')
|
|
227
|
+
return _text(body[start:]) if start >= 0 else ""
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _accepts_any_case(statement: str) -> bool:
|
|
231
|
+
"""Whether the statement accepts answers in any letter case."""
|
|
232
|
+
for match in _ANY_CASE_RE.finditer(statement):
|
|
233
|
+
if not match.group().lower().startswith("in any"):
|
|
234
|
+
return True
|
|
235
|
+
if _PRINTING_RE.search(statement[max(0, match.start() - 80) : match.start()]):
|
|
236
|
+
return True
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def find_problem_header(body: str) -> ProblemHeader:
|
|
241
|
+
"""A problem page's title, limits, whether it is interactive, and how
|
|
242
|
+
lenient its checker is by the statement (a limit the page doesn't show,
|
|
243
|
+
as on acmsguru, is None)."""
|
|
244
|
+
title = _TITLE_RE.search(body)
|
|
245
|
+
time_limit = _TIME_LIMIT_RE.search(body)
|
|
246
|
+
memory_limit = _MEMORY_LIMIT_RE.search(body)
|
|
247
|
+
statement = _statement_text(body)
|
|
248
|
+
error = _ERROR_RE.search(statement)
|
|
249
|
+
return ProblemHeader(
|
|
250
|
+
name=html_lib.unescape(_TAG_RE.sub("", title.group(1))).strip() if title else "",
|
|
251
|
+
time_limit=float(time_limit.group(1)) if time_limit else None,
|
|
252
|
+
memory_limit=int(memory_limit.group(1)) if memory_limit else None,
|
|
253
|
+
interactive=bool(_INTERACTION_RE.search(body)),
|
|
254
|
+
ignore_case=_accepts_any_case(statement),
|
|
255
|
+
epsilon=10.0 ** -int(error.group(1) or error.group(2)) if error else None,
|
|
256
|
+
any_answer=bool(_ANY_ANSWER_RE.search(f"{statement} {_note_text(body)}")),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ------------------------------------------------- the statement as Markdown
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def statement_markdown(body: str) -> str:
|
|
264
|
+
"""A problem's statement as Markdown; "" when the page has none. Its parts
|
|
265
|
+
in order, as text without inline formatting:
|
|
266
|
+
- the title and the limits
|
|
267
|
+
- each section (legend, Input, Output, Note, ...) under its heading
|
|
268
|
+
- the examples as code blocks"""
|
|
269
|
+
statement = BeautifulSoup(body, "lxml").find("div", class_="problem-statement")
|
|
270
|
+
if statement is None:
|
|
271
|
+
return ""
|
|
272
|
+
for tag in statement.select("img, sub, sup, br"):
|
|
273
|
+
if tag.name == "img":
|
|
274
|
+
tag.replace_with(f")})")
|
|
275
|
+
else: # NOTE: old statements write a<sub>i</sub>, 10<sup>5</sup>: a_i, 10^5
|
|
276
|
+
mark = {"sub": "_", "sup": "^"}.get(tag.name)
|
|
277
|
+
tag.replace_with(mark + "".join(tag.get_text().split()) if mark else "\n")
|
|
278
|
+
parts = []
|
|
279
|
+
for div in statement.find_all("div", recursive=False):
|
|
280
|
+
classes = div.get("class") or []
|
|
281
|
+
title = div.find("div", class_=["title", "section-title"])
|
|
282
|
+
if title is not None:
|
|
283
|
+
parts.append(("# " if "header" in classes else "## ") + _words(title))
|
|
284
|
+
title.extract()
|
|
285
|
+
if "header" in classes:
|
|
286
|
+
parts.append(
|
|
287
|
+
" · ".join(
|
|
288
|
+
d.get_text(": ", strip=True) for d in div.find_all("div", recursive=False)
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
elif "sample-tests" in classes:
|
|
292
|
+
parts += _samples_markdown(div)
|
|
293
|
+
else:
|
|
294
|
+
parts += _blocks(div)
|
|
295
|
+
text = "\n\n".join(part for part in parts if part.strip())
|
|
296
|
+
# NOTE: Codeforces writes TeX between $$$ (inline) and $$$$$$ (display)
|
|
297
|
+
return text.replace("$$$$$$", "$$").replace("$$$", "$") + "\n"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _words(tag) -> str:
|
|
301
|
+
return " ".join(tag.get_text().split())
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _blocks(node) -> list[str]:
|
|
305
|
+
"""A section's paragraphs, list items (- ...) and preformatted text (code
|
|
306
|
+
blocks), in order; loose text between them is a paragraph too."""
|
|
307
|
+
blocks, loose = [], []
|
|
308
|
+
for child in node.children:
|
|
309
|
+
name = getattr(child, "name", None)
|
|
310
|
+
if name not in ("p", "div", "center", "ul", "ol", "pre"):
|
|
311
|
+
loose.append("" if isinstance(child, Comment) else child.get_text())
|
|
312
|
+
continue
|
|
313
|
+
blocks.append(" ".join("".join(loose).split()))
|
|
314
|
+
loose.clear()
|
|
315
|
+
if name == "pre":
|
|
316
|
+
blocks.append(_fenced(child.get_text()))
|
|
317
|
+
elif name in ("ul", "ol"):
|
|
318
|
+
blocks += ["- " + _words(item) for item in child.find_all("li", recursive=False)]
|
|
319
|
+
else:
|
|
320
|
+
blocks += (
|
|
321
|
+
_blocks(child) if child.find(["p", "div", "ul", "ol", "pre"]) else [_words(child)]
|
|
322
|
+
)
|
|
323
|
+
blocks.append(" ".join("".join(loose).split()))
|
|
324
|
+
return [block for block in blocks if block]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _samples_markdown(div) -> list[str]:
|
|
328
|
+
"""The examples, each input and output in a code block."""
|
|
329
|
+
try:
|
|
330
|
+
inputs, outputs = find_samples(str(div))
|
|
331
|
+
except CFError:
|
|
332
|
+
return []
|
|
333
|
+
parts = []
|
|
334
|
+
for i, (sample_in, sample_out) in enumerate(zip(inputs, outputs), start=1):
|
|
335
|
+
number = f" {i}" if len(inputs) > 1 else ""
|
|
336
|
+
parts += [
|
|
337
|
+
f"### Input{number}",
|
|
338
|
+
_fenced(sample_in),
|
|
339
|
+
f"### Output{number}",
|
|
340
|
+
_fenced(sample_out),
|
|
341
|
+
]
|
|
342
|
+
return parts
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _fenced(text: str) -> str:
|
|
346
|
+
return f"```\n{text.strip(chr(10))}\n```"
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _absolute(url: str) -> str:
|
|
350
|
+
if url.startswith("//"):
|
|
351
|
+
return "https:" + url
|
|
352
|
+
return "https://codeforces.com" + url if url.startswith("/") else url
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _line_cases(inner: str) -> list[int] | None:
|
|
356
|
+
"""The test case of each line of a sample <pre>, as its text keeps them;
|
|
357
|
+
None for the classic markup, which doesn't say."""
|
|
358
|
+
if "test-example-line" not in inner:
|
|
359
|
+
return None
|
|
360
|
+
lines = [
|
|
361
|
+
(int(case), html_lib.unescape(_TAG_RE.sub("", text)))
|
|
362
|
+
for case, text in _SAMPLE_LINE_RE.findall(inner)
|
|
363
|
+
]
|
|
364
|
+
# NOTE: _pre_to_text strips the whole text, which drops blank first and last lines
|
|
365
|
+
while lines and not lines[0][1].strip():
|
|
366
|
+
lines.pop(0)
|
|
367
|
+
while lines and not lines[-1][1].strip():
|
|
368
|
+
lines.pop()
|
|
369
|
+
return [case for case, _ in lines]
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def find_sample_cases(body: str) -> list[tuple[list[int], list[int]] | None]:
|
|
373
|
+
"""For each sample, the test case of each input and answer line (case 0:
|
|
374
|
+
the line with the number of cases); None where the page doesn't say."""
|
|
375
|
+
found = []
|
|
376
|
+
for inner_in, inner_out in zip(_INPUT_RE.findall(body), _OUTPUT_RE.findall(body)):
|
|
377
|
+
input_cases, answer_cases = _line_cases(inner_in), _line_cases(inner_out)
|
|
378
|
+
found.append(
|
|
379
|
+
None if input_cases is None or answer_cases is None else (input_cases, answer_cases)
|
|
380
|
+
)
|
|
381
|
+
return found
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
_HOLDER_RE = re.compile(r'<div class="problemindexholder" problemindex="(\w+)"')
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def split_problems(body: str) -> list[tuple[str, str]]:
|
|
388
|
+
"""A contest's all-problems page (/problems) as (index, that problem's
|
|
389
|
+
part of the page), in order; [] when it isn't one."""
|
|
390
|
+
starts = list(_HOLDER_RE.finditer(body))
|
|
391
|
+
return [
|
|
392
|
+
(
|
|
393
|
+
match.group(1),
|
|
394
|
+
body[match.start() : (starts[i + 1].start() if i + 1 < len(starts) else len(body))],
|
|
395
|
+
)
|
|
396
|
+
for i, match in enumerate(starts)
|
|
397
|
+
]
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def is_standard_io(body: str) -> bool:
|
|
401
|
+
return bool(_STANDARD_INPUT_RE.search(body) and _STANDARD_OUTPUT_RE.search(body))
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def find_countdown(body: str) -> int:
|
|
405
|
+
match = _COUNTDOWN_RE.search(body)
|
|
406
|
+
if not match:
|
|
407
|
+
raise CFError("Cannot find any countdown")
|
|
408
|
+
h, m, s = (int(g) for g in match.groups())
|
|
409
|
+
return h * 3600 + m * 60 + s
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def find_error_message(body: str) -> str | None:
|
|
413
|
+
"""First non-empty form error (forms also carry empty error placeholders)."""
|
|
414
|
+
for match in _ERROR_MSG_RE.finditer(body):
|
|
415
|
+
message = html_lib.unescape(_TAG_RE.sub("", match.group(1))).strip()
|
|
416
|
+
if message:
|
|
417
|
+
return message
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def find_message(body: str) -> str | None:
|
|
422
|
+
"""The Codeforces.showMessage flash text, if present."""
|
|
423
|
+
match = _SHOW_MSG_RE.search(body)
|
|
424
|
+
return match.group(1) if match else None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def find_code(body: str) -> str:
|
|
428
|
+
match = _CODE_RE.search(body)
|
|
429
|
+
if not match:
|
|
430
|
+
raise CFError("Cannot find any code")
|
|
431
|
+
return html_lib.unescape(match.group(1))
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def find_cf_offset(body: str) -> str:
|
|
435
|
+
match = _UTC_OFFSET_RE.search(body)
|
|
436
|
+
if not match:
|
|
437
|
+
raise CFError("Cannot find cf utc offset")
|
|
438
|
+
return match.group(1)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# ------------------------------------------------------------- problem list
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@dataclass
|
|
445
|
+
class ProblemRow:
|
|
446
|
+
id: str
|
|
447
|
+
name: str
|
|
448
|
+
io: str
|
|
449
|
+
limit: str
|
|
450
|
+
passed: str
|
|
451
|
+
state: str # tr class, e.g. "accepted-problem" / "rejected-problem" / ""
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def parse_problems(body: str) -> list[ProblemRow]:
|
|
455
|
+
soup = BeautifulSoup(body, "lxml")
|
|
456
|
+
table = soup.find("table", class_="problems")
|
|
457
|
+
if table is None:
|
|
458
|
+
raise CFError("No problem list on the page: has the contest begun?")
|
|
459
|
+
problems = []
|
|
460
|
+
for row in table.find_all("tr")[1:]:
|
|
461
|
+
cells = row.find_all("td")
|
|
462
|
+
if len(cells) < 2:
|
|
463
|
+
continue
|
|
464
|
+
problem_id = cells[0].get_text(strip=True)
|
|
465
|
+
name_link = cells[1].find("a")
|
|
466
|
+
name = name_link.get_text(strip=True) if name_link else ""
|
|
467
|
+
notice = cells[1].find("div", class_="notice")
|
|
468
|
+
io_text, limit = "", ""
|
|
469
|
+
if notice:
|
|
470
|
+
parts = [p.strip() for p in notice.get_text("\n").splitlines() if p.strip()]
|
|
471
|
+
if parts:
|
|
472
|
+
io_text = parts[0]
|
|
473
|
+
if len(parts) > 1:
|
|
474
|
+
limit = ", ".join(parts[1:])
|
|
475
|
+
passed = "0"
|
|
476
|
+
if len(cells) > 3:
|
|
477
|
+
digits = _NUM_RE.search(cells[3].get_text())
|
|
478
|
+
if digits:
|
|
479
|
+
passed = digits.group(0)
|
|
480
|
+
problems.append(
|
|
481
|
+
ProblemRow(
|
|
482
|
+
id=problem_id,
|
|
483
|
+
name=name,
|
|
484
|
+
io=io_text,
|
|
485
|
+
limit=limit,
|
|
486
|
+
passed=passed,
|
|
487
|
+
state=" ".join(row.get("class", [])),
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
if not problems:
|
|
491
|
+
raise CFError("No problems on the page: has the contest begun?")
|
|
492
|
+
return problems
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
# --------------------------------------------------------------- languages
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def parse_languages(body: str) -> dict[str, str]:
|
|
499
|
+
"""id -> display name from the submit page's programTypeId <select>."""
|
|
500
|
+
soup = BeautifulSoup(body, "lxml")
|
|
501
|
+
select = soup.find("select", attrs={"name": "programTypeId"})
|
|
502
|
+
if select is None:
|
|
503
|
+
return {}
|
|
504
|
+
return {
|
|
505
|
+
opt["value"]: opt.get_text(strip=True)
|
|
506
|
+
for opt in select.find_all("option")
|
|
507
|
+
if opt.get("value")
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# -------------------------------------------------------------- submissions
|
|
512
|
+
|
|
513
|
+
_WAIT_VERDICTS = ("null", "TESTING", "SUBMITTED")
|
|
514
|
+
_VERDICT_FORMAT_RE = re.compile(r"<span\sclass=[\"']?verdict-format-[^\"'>]+[\"']?>")
|
|
515
|
+
_VERDICT_COLOR_RE = re.compile(r"<span\sclass=[\"']?verdict-([^\"'>]+?)[\"']?>")
|
|
516
|
+
|
|
517
|
+
# ${c-...} marker -> rich style
|
|
518
|
+
STATUS_STYLE = {
|
|
519
|
+
"${c-waiting}": "white",
|
|
520
|
+
"${c-failed}": "red",
|
|
521
|
+
"${c-accepted}": "green",
|
|
522
|
+
"${c-rejected}": "blue",
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@dataclass
|
|
527
|
+
class Submission:
|
|
528
|
+
id: int = 0
|
|
529
|
+
name: str = ""
|
|
530
|
+
lang: str = ""
|
|
531
|
+
status: str = "" # with ${c-...}/${f-...} markers for its color and numbers
|
|
532
|
+
time_ms: int = 0
|
|
533
|
+
memory_bytes: int = 0
|
|
534
|
+
when: str = ""
|
|
535
|
+
passed: int = 0
|
|
536
|
+
end: bool = False
|
|
537
|
+
|
|
538
|
+
def styled_status(self) -> tuple[str, str]:
|
|
539
|
+
"""(plain text, rich style) for display."""
|
|
540
|
+
status = self.status
|
|
541
|
+
for marker in ("${f-points}", "${f-passed}", "${f-judged}"):
|
|
542
|
+
status = status.replace(marker, str(self.passed))
|
|
543
|
+
style = ""
|
|
544
|
+
for marker, marker_style in STATUS_STYLE.items():
|
|
545
|
+
if marker in status:
|
|
546
|
+
status = status.replace(marker, "")
|
|
547
|
+
style = marker_style
|
|
548
|
+
return status, style
|
|
549
|
+
|
|
550
|
+
def parse_memory(self) -> str:
|
|
551
|
+
if self.memory_bytes > 1024 * 1024:
|
|
552
|
+
return f"{self.memory_bytes / 1024 / 1024:.2f} MB"
|
|
553
|
+
if self.memory_bytes > 1024:
|
|
554
|
+
return f"{self.memory_bytes / 1024:.2f} KB"
|
|
555
|
+
return f"{self.memory_bytes} B"
|
|
556
|
+
|
|
557
|
+
def parse_time(self) -> str:
|
|
558
|
+
return f"{self.time_ms} ms"
|
|
559
|
+
|
|
560
|
+
def problem_index(self) -> str:
|
|
561
|
+
head, _, _ = self.name.partition(" ")
|
|
562
|
+
return head.lower().rstrip("-")
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _parse_when(raw: str, cf_offset: str) -> str:
|
|
566
|
+
for fmt in ("%d.%m.%Y %H:%M", "%b/%d/%Y %H:%M"):
|
|
567
|
+
try:
|
|
568
|
+
naive = datetime.strptime(raw, fmt)
|
|
569
|
+
break
|
|
570
|
+
except ValueError:
|
|
571
|
+
continue
|
|
572
|
+
else:
|
|
573
|
+
return raw
|
|
574
|
+
match = re.match(r"([+-])(\d{2}):(\d{2})", cf_offset)
|
|
575
|
+
if not match:
|
|
576
|
+
return naive.strftime("%Y-%m-%d %H:%M")
|
|
577
|
+
sign = 1 if match.group(1) == "+" else -1
|
|
578
|
+
offset = timezone(sign * timedelta(hours=int(match.group(2)), minutes=int(match.group(3))))
|
|
579
|
+
return naive.replace(tzinfo=offset).astimezone().strftime("%Y-%m-%d %H:%M")
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _parse_submission_row(row, cf_offset: str) -> Submission:
|
|
583
|
+
def text_of(node) -> str:
|
|
584
|
+
return node.get_text(strip=True) if node else ""
|
|
585
|
+
|
|
586
|
+
def int_of(node) -> int:
|
|
587
|
+
digits = _NUM_RE.search(node.get_text()) if node else None
|
|
588
|
+
return int(digits.group(0)) if digits else 0
|
|
589
|
+
|
|
590
|
+
verdict_wrapper = row.find(class_="submissionVerdictWrapper")
|
|
591
|
+
end = False
|
|
592
|
+
status = "Unknown"
|
|
593
|
+
if verdict_wrapper:
|
|
594
|
+
verdict = verdict_wrapper.get("submissionverdict", "null")
|
|
595
|
+
end = verdict not in _WAIT_VERDICTS
|
|
596
|
+
inner = verdict_wrapper.decode_contents()
|
|
597
|
+
inner = _VERDICT_FORMAT_RE.sub("", inner)
|
|
598
|
+
inner = _VERDICT_COLOR_RE.sub(r"${c-\1}", inner)
|
|
599
|
+
inner = _TAG_RE.sub("", inner)
|
|
600
|
+
status = html_lib.unescape(inner).strip() or "Unknown"
|
|
601
|
+
|
|
602
|
+
when_node = row.find(class_="format-time")
|
|
603
|
+
if when_node:
|
|
604
|
+
when = _parse_when(text_of(when_node), cf_offset)
|
|
605
|
+
else:
|
|
606
|
+
cells = row.find_all("td")
|
|
607
|
+
when = text_of(cells[1]) if len(cells) > 1 else ""
|
|
608
|
+
|
|
609
|
+
digits = _NUM_RE.search(status)
|
|
610
|
+
passed = int(digits.group(0)) if digits else 0
|
|
611
|
+
|
|
612
|
+
lang_cell = row.find(lambda tag: tag.name == "td" and not tag.has_attr("class"))
|
|
613
|
+
return Submission(
|
|
614
|
+
id=int_of(row.find(class_="id-cell")),
|
|
615
|
+
name=text_of(row.find("td", attrs={"data-problemid": True})),
|
|
616
|
+
lang=text_of(lang_cell),
|
|
617
|
+
status=status,
|
|
618
|
+
time_ms=int_of(row.find(class_="time-consumed-cell")),
|
|
619
|
+
memory_bytes=int_of(row.find(class_="memory-consumed-cell")) * 1024,
|
|
620
|
+
when=when,
|
|
621
|
+
passed=passed,
|
|
622
|
+
end=end,
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def parse_submissions(body: str, count: int = -1) -> list[Submission]:
|
|
627
|
+
"""Submissions from a /my (or status) page, newest first."""
|
|
628
|
+
cf_offset = ""
|
|
629
|
+
try:
|
|
630
|
+
cf_offset = find_cf_offset(body)
|
|
631
|
+
except CFError:
|
|
632
|
+
pass
|
|
633
|
+
soup = BeautifulSoup(body, "lxml")
|
|
634
|
+
rows = soup.find_all("tr", attrs={"data-submission-id": True})
|
|
635
|
+
if count >= 0:
|
|
636
|
+
rows = rows[:count]
|
|
637
|
+
submissions = [_parse_submission_row(row, cf_offset) for row in rows]
|
|
638
|
+
if not submissions:
|
|
639
|
+
raise CFError("Cannot find any submission")
|
|
640
|
+
return submissions
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
# ------------------------------------------------- a submission's tests
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@dataclass
|
|
647
|
+
class TestDetail:
|
|
648
|
+
"""A test of a judged submission, as Codeforces shows it under "Click to
|
|
649
|
+
see test details" (a long part cut to its start and "...")."""
|
|
650
|
+
|
|
651
|
+
__test__ = False # NOTE: not a pytest test class
|
|
652
|
+
|
|
653
|
+
number: int
|
|
654
|
+
verdict: str # WRONG_ANSWER, TIME_LIMIT_EXCEEDED, ...
|
|
655
|
+
input: str
|
|
656
|
+
output: str
|
|
657
|
+
answer: str
|
|
658
|
+
checker: str # what the checker said
|
|
659
|
+
|
|
660
|
+
@property
|
|
661
|
+
def cut(self) -> bool:
|
|
662
|
+
"""Whether Codeforces cut the input or the answer short."""
|
|
663
|
+
return self.input.endswith("...") or self.answer.endswith("...")
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
@dataclass
|
|
667
|
+
class SubmissionDetails:
|
|
668
|
+
verdict: str # "Wrong answer on test 27"
|
|
669
|
+
problem: str # "(F) Tea Blend"
|
|
670
|
+
failed: TestDetail | None # the test it failed, if any
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def has_test_details(body: str) -> bool:
|
|
674
|
+
"""Whether a submission page offers its tests' details: only your own
|
|
675
|
+
submissions do, once the contest is over."""
|
|
676
|
+
return "click-to-view-tests" in body
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def parse_submission_details(data: dict) -> SubmissionDetails:
|
|
680
|
+
"""A submission's verdict and failed test from /data/submitSource's JSON
|
|
681
|
+
(per test N: input#N, output#N, answer#N, rejected#N, ...)."""
|
|
682
|
+
|
|
683
|
+
def text(key: str) -> str:
|
|
684
|
+
return str(data.get(key) or "").replace("\r\n", "\n")
|
|
685
|
+
|
|
686
|
+
failed = None
|
|
687
|
+
count = int(data["testCount"]) if str(data.get("testCount", "")).isdigit() else 0
|
|
688
|
+
for n in range(1, count + 1):
|
|
689
|
+
if text(f"rejected#{n}").lower() == "true":
|
|
690
|
+
failed = TestDetail(
|
|
691
|
+
n,
|
|
692
|
+
text(f"verdict#{n}"),
|
|
693
|
+
text(f"input#{n}"),
|
|
694
|
+
text(f"output#{n}"),
|
|
695
|
+
text(f"answer#{n}"),
|
|
696
|
+
text(f"checkerStdoutAndStderr#{n}").strip(),
|
|
697
|
+
)
|
|
698
|
+
break
|
|
699
|
+
verdict = " ".join(html_lib.unescape(_TAG_RE.sub("", text("verdict"))).split())
|
|
700
|
+
problem = " ".join(html_lib.unescape(_TAG_RE.sub("", text("problemName"))).split())
|
|
701
|
+
return SubmissionDetails(verdict, problem, failed)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
# ------------------------------------------------------------- contests (API)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
@dataclass(frozen=True)
|
|
708
|
+
class Contest:
|
|
709
|
+
id: int
|
|
710
|
+
name: str
|
|
711
|
+
start: int # seconds since the epoch
|
|
712
|
+
duration: int # seconds
|
|
713
|
+
phase: str # "BEFORE", "CODING", "FINISHED", ...
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def parse_contests(result: list) -> list[Contest]:
|
|
717
|
+
"""The API's contest.list result as Contests (those without a start time left out)."""
|
|
718
|
+
return [
|
|
719
|
+
Contest(
|
|
720
|
+
int(c["id"]),
|
|
721
|
+
str(c.get("name", "")),
|
|
722
|
+
int(c["startTimeSeconds"]),
|
|
723
|
+
int(c.get("durationSeconds", 0)),
|
|
724
|
+
str(c.get("phase", "")),
|
|
725
|
+
)
|
|
726
|
+
for c in result
|
|
727
|
+
if isinstance(c, dict) and "id" in c and "startTimeSeconds" in c
|
|
728
|
+
]
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
@dataclass(frozen=True)
|
|
732
|
+
class ProblemsetProblem:
|
|
733
|
+
contest_id: int
|
|
734
|
+
index: str # "B", "F1"
|
|
735
|
+
name: str
|
|
736
|
+
rating: int | None # None: not rated yet
|
|
737
|
+
tags: tuple[str, ...]
|
|
738
|
+
|
|
739
|
+
@property
|
|
740
|
+
def id(self) -> str:
|
|
741
|
+
return f"{self.contest_id}{self.index}"
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def parse_problemset(result: dict) -> list[ProblemsetProblem]:
|
|
745
|
+
"""The API's problemset.problems result as problems."""
|
|
746
|
+
return [
|
|
747
|
+
ProblemsetProblem(
|
|
748
|
+
int(p["contestId"]),
|
|
749
|
+
str(p["index"]),
|
|
750
|
+
str(p.get("name", "")),
|
|
751
|
+
int(p["rating"]) if isinstance(p.get("rating"), (int, float)) else None,
|
|
752
|
+
tuple(str(tag) for tag in p.get("tags", [])),
|
|
753
|
+
)
|
|
754
|
+
for p in result.get("problems", [])
|
|
755
|
+
if isinstance(p, dict) and "contestId" in p and "index" in p
|
|
756
|
+
]
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def parse_solved(result: list) -> set[tuple[int, str]]:
|
|
760
|
+
"""(contest id, index) of every problem an API user.status result shows accepted."""
|
|
761
|
+
return {
|
|
762
|
+
(int(sub["problem"]["contestId"]), str(sub["problem"]["index"]))
|
|
763
|
+
for sub in result
|
|
764
|
+
if isinstance(sub, dict)
|
|
765
|
+
and sub.get("verdict") == "OK"
|
|
766
|
+
and "contestId" in sub.get("problem", {})
|
|
767
|
+
}
|