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,521 @@
|
|
|
1
|
+
"""Codeforces operations: problems and their samples, contests, submitting and
|
|
2
|
+
watching verdicts, the code of your submissions, and the public API.
|
|
3
|
+
|
|
4
|
+
Getting in (HTTP, Cloudflare, logging in) is gateway.py's job, drawing
|
|
5
|
+
views.py's. Pages that anybody may read are fetched without logging in;
|
|
6
|
+
when one turns out to need a login (a group's contest), the saved login is
|
|
7
|
+
used.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import random
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Callable, Sequence
|
|
19
|
+
|
|
20
|
+
from rich.live import Live
|
|
21
|
+
from rich.text import Text
|
|
22
|
+
|
|
23
|
+
from cf_tool import languages, paths, ui, views
|
|
24
|
+
from cf_tool.codeforces import pages
|
|
25
|
+
from cf_tool.codeforces.gateway import Gateway
|
|
26
|
+
from cf_tool.codeforces.http import Uncertain, is_cloudflare_cookie
|
|
27
|
+
from cf_tool.codeforces.session import LAST_KEYS, Session
|
|
28
|
+
from cf_tool.errors import CFError
|
|
29
|
+
from cf_tool.settings import Settings
|
|
30
|
+
from cf_tool.workspace import ProblemInfo, Target
|
|
31
|
+
|
|
32
|
+
CONTESTS_MAX_AGE = 1800 # seconds: the contest list rarely changes
|
|
33
|
+
PROBLEMSET_MAX_AGE = 86400 # seconds: new problems come with new contests
|
|
34
|
+
API_INTERVAL = 2.1 # seconds between API calls
|
|
35
|
+
POLL_INTERVAL = 1.0 # seconds between looks at the verdicts
|
|
36
|
+
TOO_MANY_REQUESTS_WAIT = 300 # seconds clone waits when Codeforces asks it to slow down
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class FetchedProblem:
|
|
41
|
+
"""A problem as its page shows it."""
|
|
42
|
+
|
|
43
|
+
index: str # "A"
|
|
44
|
+
info: ProblemInfo
|
|
45
|
+
samples: list[tuple[str, str]] = field(default_factory=list) # (input, answer)
|
|
46
|
+
standard_io: bool = True
|
|
47
|
+
error: str = "" # why its samples couldn't be read; "" when they were
|
|
48
|
+
statement: str = "" # Markdown, ending in the problem's link; "" when the page has none
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Client:
|
|
52
|
+
def __init__(self, settings: Settings, session: Session | None = None) -> None:
|
|
53
|
+
self.settings = settings
|
|
54
|
+
self.session = session if session is not None else Session.load()
|
|
55
|
+
self.gw = Gateway(settings.host, settings.proxy, self.session)
|
|
56
|
+
self._languages: tuple[dict[str, str], bool] | None = None
|
|
57
|
+
self._last_api = float("-inf") # time.monotonic() of the last API call
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def host(self) -> str:
|
|
61
|
+
return self.settings.host
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def handle(self) -> str:
|
|
65
|
+
return self.session.handle
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def can_log_in(self) -> bool:
|
|
69
|
+
"""Whether a login is saved (cf login was run)."""
|
|
70
|
+
return bool(self.session.handle_or_email and self.session.password)
|
|
71
|
+
|
|
72
|
+
# ┌──────────────────────────────────────────────────────────┐
|
|
73
|
+
# │ Account │
|
|
74
|
+
# └──────────────────────────────────────────────────────────┘
|
|
75
|
+
|
|
76
|
+
def login(self, handle_or_email: str, password: str) -> str:
|
|
77
|
+
"""Save the credentials (encrypted) and log in; returns the handle."""
|
|
78
|
+
self._languages = None # NOTE: once logged in, the live list can be read
|
|
79
|
+
self.gw.set_credentials(handle_or_email, password)
|
|
80
|
+
return self.gw.login()
|
|
81
|
+
|
|
82
|
+
def logout(self) -> None:
|
|
83
|
+
"""Forget the login: credentials and cookies (Cloudflare's stay)."""
|
|
84
|
+
self.gw.http.clear_cookies(keep=is_cloudflare_cookie)
|
|
85
|
+
self.session.handle = self.session.handle_or_email = self.session.password = ""
|
|
86
|
+
self.gw.save()
|
|
87
|
+
|
|
88
|
+
def _get(self, url: str) -> str:
|
|
89
|
+
"""A page anybody may read; with the saved login when it needs one."""
|
|
90
|
+
body = self.gw.get(url)
|
|
91
|
+
if pages.find_handle(body) is None and self.can_log_in and pages.wants_login(body):
|
|
92
|
+
body = self.gw.get_authed(url)
|
|
93
|
+
return body
|
|
94
|
+
|
|
95
|
+
# ┌──────────────────────────────────────────────────────────┐
|
|
96
|
+
# │ Problems │
|
|
97
|
+
# └──────────────────────────────────────────────────────────┘
|
|
98
|
+
|
|
99
|
+
def fetch(self, target: Target) -> list[FetchedProblem]:
|
|
100
|
+
"""The problems target names, with their samples:
|
|
101
|
+
- a problem: its page
|
|
102
|
+
- a contest: its all-problems page, one request for every problem
|
|
103
|
+
- a contest without that page: its problem list, then each problem's page"""
|
|
104
|
+
if target.problem:
|
|
105
|
+
url = target.problem_url(self.host)
|
|
106
|
+
return [_read_problem(target.problem, self._get(url), url)]
|
|
107
|
+
body = self._get(target.problems_url(self.host))
|
|
108
|
+
parts = pages.split_problems(body)
|
|
109
|
+
if parts:
|
|
110
|
+
return [
|
|
111
|
+
_read_problem(index, part, target.with_problem(index).problem_url(self.host))
|
|
112
|
+
for index, part in parts
|
|
113
|
+
]
|
|
114
|
+
found = []
|
|
115
|
+
for row in self.problem_rows(target):
|
|
116
|
+
url = target.with_problem(row.id).problem_url(self.host)
|
|
117
|
+
try:
|
|
118
|
+
found.append(_read_problem(row.id, self._get(url), url))
|
|
119
|
+
except CFError as e:
|
|
120
|
+
found.append(
|
|
121
|
+
FetchedProblem(row.id, ProblemInfo(name=row.name, url=url), error=str(e))
|
|
122
|
+
)
|
|
123
|
+
return found
|
|
124
|
+
|
|
125
|
+
def problem_rows(self, target: Target) -> list[pages.ProblemRow]:
|
|
126
|
+
"""A contest's problems with their solve counts (yours marked when logged in)."""
|
|
127
|
+
if target.kind == "acmsguru":
|
|
128
|
+
raise CFError("acmsguru has no contest page")
|
|
129
|
+
url = target.contest_url(self.host)
|
|
130
|
+
body = self.gw.get_authed(url) if self.can_log_in else self._get(url)
|
|
131
|
+
return pages.parse_problems(body)
|
|
132
|
+
|
|
133
|
+
def race(self, target: Target) -> None:
|
|
134
|
+
"""Count down to the contest's start (nothing to wait for once it began)."""
|
|
135
|
+
if target.kind == "acmsguru":
|
|
136
|
+
raise CFError("acmsguru has no contests to race")
|
|
137
|
+
body = self._get(target.countdown_url(self.host))
|
|
138
|
+
if "Go!</a>" in body or 'class="countdown"' not in body:
|
|
139
|
+
return
|
|
140
|
+
# NOTE: against a fixed deadline: sleeping 1 s per tick would drift late
|
|
141
|
+
deadline = time.monotonic() + pages.find_countdown(body)
|
|
142
|
+
with Live(console=ui.console, auto_refresh=False, transient=True) as live:
|
|
143
|
+
while (remaining := deadline - time.monotonic()) > 0:
|
|
144
|
+
h, rest = divmod(math.ceil(remaining), 3600)
|
|
145
|
+
m, s = divmod(rest, 60)
|
|
146
|
+
live.update(
|
|
147
|
+
Text.assemble(("Starts in ", "cyan"), (f"{h:02d}:{m:02d}:{s:02d}", "bold")),
|
|
148
|
+
refresh=True,
|
|
149
|
+
)
|
|
150
|
+
time.sleep(remaining - math.floor(remaining) or 1) # NOTE: until the next tick
|
|
151
|
+
time.sleep(0.9)
|
|
152
|
+
|
|
153
|
+
# ┌──────────────────────────────────────────────────────────┐
|
|
154
|
+
# │ Submitting │
|
|
155
|
+
# └──────────────────────────────────────────────────────────┘
|
|
156
|
+
|
|
157
|
+
def submit(
|
|
158
|
+
self,
|
|
159
|
+
target: Target,
|
|
160
|
+
source: str,
|
|
161
|
+
choose_language: Callable[[dict[str, str]], tuple[str, str]],
|
|
162
|
+
filename: str = "",
|
|
163
|
+
) -> pages.Submission:
|
|
164
|
+
"""Submit source, then watch its verdict; returns the judged submission.
|
|
165
|
+
choose_language(offered) names the (id, name) to submit as, given the
|
|
166
|
+
languages the form offers. The form is sent once: when its answer is
|
|
167
|
+
lost, the submissions page tells whether it arrived."""
|
|
168
|
+
if not target.problem:
|
|
169
|
+
target._need_problem()
|
|
170
|
+
url = target.submit_url(self.host)
|
|
171
|
+
body = self.gw.get_authed(url)
|
|
172
|
+
handle = pages.require_handle(body)
|
|
173
|
+
offered = pages.parse_languages(body)
|
|
174
|
+
if offered:
|
|
175
|
+
languages.remember_languages(offered)
|
|
176
|
+
lang_id, lang_name = choose_language(offered or languages.known_languages())
|
|
177
|
+
shown = f" ({filename})" if filename else ""
|
|
178
|
+
ui.line(
|
|
179
|
+
("› ", "cyan bold"),
|
|
180
|
+
("Submitting ", "dim"),
|
|
181
|
+
(target.label, "bold"),
|
|
182
|
+
(f"{shown} as ", "dim"),
|
|
183
|
+
(lang_name, ""),
|
|
184
|
+
(f" · {handle}", "dim"),
|
|
185
|
+
)
|
|
186
|
+
csrf = pages.find_csrf(body)
|
|
187
|
+
ftaa, bfaa = self.gw.form_tokens()
|
|
188
|
+
before = self._newest(target) # NOTE: tells a lost answer's submission from older ones
|
|
189
|
+
try:
|
|
190
|
+
body = self.gw.post(
|
|
191
|
+
f"{url}?csrf_token={csrf}",
|
|
192
|
+
data={
|
|
193
|
+
"csrf_token": csrf,
|
|
194
|
+
"ftaa": ftaa,
|
|
195
|
+
"bfaa": bfaa,
|
|
196
|
+
"action": "submitSolutionFormSubmitted",
|
|
197
|
+
"submittedProblemIndex": target.problem,
|
|
198
|
+
"programTypeId": lang_id,
|
|
199
|
+
"contestId": target.contest,
|
|
200
|
+
"source": source,
|
|
201
|
+
"tabSize": "4",
|
|
202
|
+
"_tta": "594",
|
|
203
|
+
"sourceCodeConfirmed": "true",
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
except Uncertain as e:
|
|
207
|
+
ui.yellow(f"{e}: looking for the submission among yours...")
|
|
208
|
+
if not self._arrived(target, before):
|
|
209
|
+
raise CFError(
|
|
210
|
+
"The submission doesn't show up among yours: it didn't arrive. "
|
|
211
|
+
"Submit it again."
|
|
212
|
+
) from e
|
|
213
|
+
else:
|
|
214
|
+
error = pages.find_error_message(body)
|
|
215
|
+
if error:
|
|
216
|
+
raise CFError(error)
|
|
217
|
+
message = pages.find_message(body)
|
|
218
|
+
if message is None or "submitted successfully" not in message:
|
|
219
|
+
raise CFError(message or "Codeforces did not take the submission")
|
|
220
|
+
submission = self.watch(target, count=1, single=True)[0]
|
|
221
|
+
self.session.handle = handle
|
|
222
|
+
last = {"kind": target.kind, "contest": target.contest, "problem": target.problem}
|
|
223
|
+
last.update(group=target.group, submission=str(submission.id))
|
|
224
|
+
self.session.last_submission = {key: last.get(key, "") for key in LAST_KEYS}
|
|
225
|
+
self.gw.save()
|
|
226
|
+
return submission
|
|
227
|
+
|
|
228
|
+
def details(self, target: Target, submission_id: int) -> pages.SubmissionDetails | None:
|
|
229
|
+
"""A judged submission of yours with the test it failed, as Codeforces
|
|
230
|
+
shows it once the contest is over (what "Click to see test details"
|
|
231
|
+
fetches); None while it shows nothing (a running contest, someone
|
|
232
|
+
else's code)."""
|
|
233
|
+
url = target.submission_url(self.host, str(submission_id))
|
|
234
|
+
page = self.gw.get_authed(url)
|
|
235
|
+
if not pages.has_test_details(page):
|
|
236
|
+
return None
|
|
237
|
+
answer = self.gw.post(
|
|
238
|
+
f"{self.host}/data/submitSource",
|
|
239
|
+
{"submissionId": str(submission_id), "csrf_token": pages.find_csrf(page)},
|
|
240
|
+
# NOTE: as the page's own script asks for it
|
|
241
|
+
headers={"Referer": url, "X-Requested-With": "XMLHttpRequest"},
|
|
242
|
+
)
|
|
243
|
+
try:
|
|
244
|
+
data = json.loads(answer)
|
|
245
|
+
except ValueError:
|
|
246
|
+
return None
|
|
247
|
+
return pages.parse_submission_details(data) if isinstance(data, dict) else None
|
|
248
|
+
|
|
249
|
+
def find_submission(self, submission_id: int) -> Target | None:
|
|
250
|
+
"""The problem a submission of yours is to, looked up among your latest
|
|
251
|
+
ones; None when it isn't there."""
|
|
252
|
+
if not self.handle:
|
|
253
|
+
return None
|
|
254
|
+
for sub in self.api(f"user.status?handle={self.handle}&from=1&count=200"):
|
|
255
|
+
if sub.get("id") == submission_id and sub.get("contestId"):
|
|
256
|
+
contest = str(sub["contestId"])
|
|
257
|
+
index = str(sub.get("problem", {}).get("index", ""))
|
|
258
|
+
kind = "gym" if len(contest) >= 6 else "contest"
|
|
259
|
+
return Target(kind, contest, index.upper())
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
def _newest(self, target: Target) -> int:
|
|
263
|
+
"""The id of your newest submission to the contest; 0 when there is none."""
|
|
264
|
+
try:
|
|
265
|
+
return pages.parse_submissions(self.gw.get_authed(target.my_url(self.host)), 1)[0].id
|
|
266
|
+
except CFError:
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
def _arrived(self, target: Target, before: int) -> bool:
|
|
270
|
+
"""Whether your newest submission to the contest came after the one
|
|
271
|
+
numbered before, and is to target's problem."""
|
|
272
|
+
try:
|
|
273
|
+
newest = pages.parse_submissions(self.gw.get_authed(target.my_url(self.host)), 1)[0]
|
|
274
|
+
except CFError:
|
|
275
|
+
return False
|
|
276
|
+
return newest.id > before and newest.problem_index() == target.problem.lower()
|
|
277
|
+
|
|
278
|
+
def watch(
|
|
279
|
+
self, target: Target, count: int = 10, problem: str = "", single: bool = False
|
|
280
|
+
) -> list[pages.Submission]:
|
|
281
|
+
"""Look at your submissions to the contest about once a second until
|
|
282
|
+
every verdict is final; returns them. single shows the newest as a
|
|
283
|
+
panel (after submitting), otherwise a table of the latest ones."""
|
|
284
|
+
url = target.my_url(self.host)
|
|
285
|
+
|
|
286
|
+
def render(submissions: list[pages.Submission]):
|
|
287
|
+
if single: # NOTE: unboxed for a pipe: CP Editor reads "status: ..." from it
|
|
288
|
+
return views.submission_panel(submissions[0], boxed=ui.console.is_terminal)
|
|
289
|
+
return views.submission_table(submissions, problem)
|
|
290
|
+
|
|
291
|
+
# transient while judging; the final state is then printed in full
|
|
292
|
+
# (a live display gets cut to the terminal height)
|
|
293
|
+
with Live(console=ui.console, auto_refresh=False, transient=True) as live:
|
|
294
|
+
while True:
|
|
295
|
+
started = time.monotonic()
|
|
296
|
+
submissions = pages.parse_submissions(self.gw.get_authed(url), count)
|
|
297
|
+
if all(sub.end for sub in submissions):
|
|
298
|
+
break
|
|
299
|
+
live.update(render(submissions), refresh=True)
|
|
300
|
+
elapsed = time.monotonic() - started
|
|
301
|
+
if elapsed < POLL_INTERVAL:
|
|
302
|
+
time.sleep(POLL_INTERVAL - elapsed)
|
|
303
|
+
ui.console.print(render(submissions))
|
|
304
|
+
return submissions
|
|
305
|
+
|
|
306
|
+
# ┌──────────────────────────────────────────────────────────┐
|
|
307
|
+
# │ Code of submissions │
|
|
308
|
+
# └──────────────────────────────────────────────────────────┘
|
|
309
|
+
|
|
310
|
+
def _code(self, url: str) -> str:
|
|
311
|
+
body = self.gw.get(url)
|
|
312
|
+
message = pages.find_message(body)
|
|
313
|
+
if message is not None:
|
|
314
|
+
raise CFError(message)
|
|
315
|
+
return pages.find_code(body)
|
|
316
|
+
|
|
317
|
+
def pull(self, target: Target, folder_of: Callable[[str], Path], ac: bool) -> list[Path]:
|
|
318
|
+
"""Save the code of your submissions to target (a problem, or every
|
|
319
|
+
problem of a contest) into folder_of(problem index), as
|
|
320
|
+
<problem>_<submission id>.<ext>; returns the files written. ac:
|
|
321
|
+
accepted ones only."""
|
|
322
|
+
body = self.gw.get_authed(target.my_url(self.host))
|
|
323
|
+
saved = []
|
|
324
|
+
for sub in pages.parse_submissions(body):
|
|
325
|
+
index = sub.problem_index()
|
|
326
|
+
if target.problem and index != target.problem.lower():
|
|
327
|
+
continue
|
|
328
|
+
if ac and not ("Accepted" in sub.status or "Pretests passed" in sub.status):
|
|
329
|
+
continue
|
|
330
|
+
ext = languages.ext_of(sub.lang)
|
|
331
|
+
if ext == "txt":
|
|
332
|
+
continue
|
|
333
|
+
path = folder_of(index.upper()) / f"{index}_{sub.id}.{ext}"
|
|
334
|
+
if path.exists():
|
|
335
|
+
continue
|
|
336
|
+
try:
|
|
337
|
+
code = self._code(target.submission_url(self.host, str(sub.id)))
|
|
338
|
+
except CFError as e:
|
|
339
|
+
ui.red(f"Submission {sub.id}: {e}")
|
|
340
|
+
continue
|
|
341
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
342
|
+
path.write_text(code, encoding="utf-8", newline="")
|
|
343
|
+
ui.green(f"Saved {path}")
|
|
344
|
+
saved.append(path)
|
|
345
|
+
return saved
|
|
346
|
+
|
|
347
|
+
def clone(self, handle: str, folder: Path, ac: bool) -> int:
|
|
348
|
+
"""Save the code of every submission of handle into
|
|
349
|
+
folder/<handle>/<contest>/<problem>/<id>[_<verdict>].<ext>, skipping
|
|
350
|
+
files already there; returns how many were saved."""
|
|
351
|
+
submissions = self.api(f"user.status?handle={handle}")
|
|
352
|
+
total, saved = len(submissions), 0
|
|
353
|
+
ui.cyan(f"{handle} has {total} submissions")
|
|
354
|
+
for done, sub in enumerate(submissions, start=1):
|
|
355
|
+
verdict = sub.get("verdict", "")
|
|
356
|
+
if ac and verdict != "OK":
|
|
357
|
+
continue
|
|
358
|
+
ext = languages.ext_of(sub.get("programmingLanguage", ""))
|
|
359
|
+
contest = str(sub.get("contestId", "acmsguru"))
|
|
360
|
+
index = str(sub.get("problem", {}).get("index", "")).lower()
|
|
361
|
+
name = str(sub["id"]) + ("" if verdict == "OK" else f"_{verdict.lower()}")
|
|
362
|
+
path = folder / handle / contest / index / f"{name}.{ext}"
|
|
363
|
+
if ext == "txt" or path.exists():
|
|
364
|
+
continue
|
|
365
|
+
kind = "gym" if len(contest) >= 6 else "contest"
|
|
366
|
+
target = Target(kind, contest)
|
|
367
|
+
if contest == "acmsguru":
|
|
368
|
+
target = Target("acmsguru", "99999")
|
|
369
|
+
while True:
|
|
370
|
+
try:
|
|
371
|
+
code = self._code(target.submission_url(self.host, str(sub["id"])))
|
|
372
|
+
break
|
|
373
|
+
except CFError as e:
|
|
374
|
+
if "too many requests" not in str(e).lower():
|
|
375
|
+
ui.red(f"{done}/{total} {sub['id']}: {e}")
|
|
376
|
+
code = None
|
|
377
|
+
break
|
|
378
|
+
ui.yellow(f"Codeforces asks to slow down: waiting {TOO_MANY_REQUESTS_WAIT}s")
|
|
379
|
+
time.sleep(TOO_MANY_REQUESTS_WAIT)
|
|
380
|
+
if code is None:
|
|
381
|
+
continue
|
|
382
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
383
|
+
path.write_text(code, encoding="utf-8", newline="")
|
|
384
|
+
saved += 1
|
|
385
|
+
ui.green(f"{done}/{total} Saved {path}")
|
|
386
|
+
return saved
|
|
387
|
+
|
|
388
|
+
# ┌──────────────────────────────────────────────────────────┐
|
|
389
|
+
# │ Public API │
|
|
390
|
+
# └──────────────────────────────────────────────────────────┘
|
|
391
|
+
|
|
392
|
+
def api(self, method: str):
|
|
393
|
+
"""The result of a public API call ("contest.list?gym=false")."""
|
|
394
|
+
wait = self._last_api + API_INTERVAL - time.monotonic()
|
|
395
|
+
if wait > 0:
|
|
396
|
+
time.sleep(wait) # NOTE: the API allows one call every 2 seconds
|
|
397
|
+
try:
|
|
398
|
+
data = self.gw.get_json(f"{self.host}/api/{method}")
|
|
399
|
+
finally:
|
|
400
|
+
self._last_api = time.monotonic()
|
|
401
|
+
if data.get("status") != "OK":
|
|
402
|
+
raise CFError(f"Codeforces API: {data.get('comment') or 'the call failed'}")
|
|
403
|
+
return data["result"]
|
|
404
|
+
|
|
405
|
+
def contests(self) -> list[pages.Contest]:
|
|
406
|
+
"""Contests that haven't started yet, soonest first."""
|
|
407
|
+
result = _cached(
|
|
408
|
+
paths.STATE_DIR / "contests.json",
|
|
409
|
+
CONTESTS_MAX_AGE,
|
|
410
|
+
lambda: self.api("contest.list?gym=false"),
|
|
411
|
+
)
|
|
412
|
+
now = time.time()
|
|
413
|
+
upcoming = [
|
|
414
|
+
c for c in pages.parse_contests(result) if c.phase == "BEFORE" and c.start > now
|
|
415
|
+
]
|
|
416
|
+
return sorted(upcoming, key=lambda c: c.start)
|
|
417
|
+
|
|
418
|
+
def problemset(self) -> list[pages.ProblemsetProblem]:
|
|
419
|
+
"""Every problem of the problemset."""
|
|
420
|
+
result = _cached(
|
|
421
|
+
paths.STATE_DIR / "problemset.json",
|
|
422
|
+
PROBLEMSET_MAX_AGE,
|
|
423
|
+
lambda: self.api("problemset.problems"),
|
|
424
|
+
)
|
|
425
|
+
return pages.parse_problemset(result)
|
|
426
|
+
|
|
427
|
+
def solved(self, handle: str) -> set[tuple[int, str]]:
|
|
428
|
+
"""(contest id, index) of the problems handle has solved."""
|
|
429
|
+
return pages.parse_solved(self.api(f"user.status?handle={handle}"))
|
|
430
|
+
|
|
431
|
+
def languages(self) -> tuple[dict[str, str], bool]:
|
|
432
|
+
"""(id -> name, is_live): the submit form's list when logged in, else
|
|
433
|
+
the one read last (or the built-in table); read once per run. Never
|
|
434
|
+
opens a browser for it."""
|
|
435
|
+
if self._languages is None:
|
|
436
|
+
try:
|
|
437
|
+
body = self.gw.get(f"{self.host}/problemset/submit", browser_fallback=False)
|
|
438
|
+
live = pages.parse_languages(body)
|
|
439
|
+
except CFError:
|
|
440
|
+
live = {}
|
|
441
|
+
if live:
|
|
442
|
+
languages.remember_languages(live)
|
|
443
|
+
self._languages = (live, True) if live else (languages.known_languages(), False)
|
|
444
|
+
return self._languages
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _read_problem(index: str, body: str, url: str) -> FetchedProblem:
|
|
448
|
+
"""A problem's statement page (or its part of the all-problems page)."""
|
|
449
|
+
header = pages.find_problem_header(body)
|
|
450
|
+
info = ProblemInfo(
|
|
451
|
+
name=header.name,
|
|
452
|
+
url=url,
|
|
453
|
+
time_limit=header.time_limit,
|
|
454
|
+
memory_limit=header.memory_limit,
|
|
455
|
+
interactive=header.interactive,
|
|
456
|
+
ignore_case=header.ignore_case,
|
|
457
|
+
epsilon=header.epsilon,
|
|
458
|
+
any_answer=header.any_answer,
|
|
459
|
+
cases=pages.find_sample_cases(body),
|
|
460
|
+
)
|
|
461
|
+
statement = pages.statement_markdown(body)
|
|
462
|
+
if statement:
|
|
463
|
+
statement += f"\n<{url}>\n"
|
|
464
|
+
try:
|
|
465
|
+
inputs, outputs = pages.find_samples(body)
|
|
466
|
+
except CFError as e:
|
|
467
|
+
return FetchedProblem(
|
|
468
|
+
index.upper(),
|
|
469
|
+
info,
|
|
470
|
+
standard_io=pages.is_standard_io(body),
|
|
471
|
+
error=str(e),
|
|
472
|
+
statement=statement,
|
|
473
|
+
)
|
|
474
|
+
return FetchedProblem(
|
|
475
|
+
index.upper(),
|
|
476
|
+
info,
|
|
477
|
+
list(zip(inputs, outputs)),
|
|
478
|
+
pages.is_standard_io(body),
|
|
479
|
+
statement=statement,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _cached(path: Path, max_age: float, fetch: Callable[[], Any]) -> Any:
|
|
484
|
+
"""fetch()'s result, kept in path and reused while it is younger than
|
|
485
|
+
max_age seconds (an unreadable copy is fetched again)."""
|
|
486
|
+
try:
|
|
487
|
+
saved = json.loads(path.read_text(encoding="utf-8"))
|
|
488
|
+
if 0 <= time.time() - saved["fetched"] < max_age:
|
|
489
|
+
return saved["result"]
|
|
490
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
491
|
+
pass
|
|
492
|
+
result = fetch()
|
|
493
|
+
paths.write_atomic(path, json.dumps({"fetched": time.time(), "result": result}))
|
|
494
|
+
return result
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def pick_problem(
|
|
498
|
+
problems: list[pages.ProblemsetProblem],
|
|
499
|
+
solved: set[tuple[int, str]],
|
|
500
|
+
*,
|
|
501
|
+
low: int | None = None,
|
|
502
|
+
high: int | None = None,
|
|
503
|
+
tags: Sequence[str] = (),
|
|
504
|
+
rng: random.Random | None = None,
|
|
505
|
+
) -> tuple[pages.ProblemsetProblem | None, int]:
|
|
506
|
+
"""A random problem to practice on, and how many there were to pick from.
|
|
507
|
+
It is:
|
|
508
|
+
- not solved yet, and not a joke (*special) unless that tag is asked for
|
|
509
|
+
- rated from low to high, when given (unrated problems are then out)
|
|
510
|
+
- tagged with every tag given, and with no tag given as "!tag" either"""
|
|
511
|
+
wanted = {tag for tag in tags if not tag.startswith("!")}
|
|
512
|
+
avoided = {tag[1:] for tag in tags if tag.startswith("!")} | ({"*special"} - wanted)
|
|
513
|
+
pool = [
|
|
514
|
+
p
|
|
515
|
+
for p in problems
|
|
516
|
+
if (p.contest_id, p.index) not in solved
|
|
517
|
+
and wanted <= set(p.tags)
|
|
518
|
+
and not avoided & set(p.tags)
|
|
519
|
+
and (low is None or (p.rating is not None and low <= p.rating <= (high or low)))
|
|
520
|
+
]
|
|
521
|
+
return ((rng or random).choice(pool) if pool else None), len(pool)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Password obfuscation for the saved login (the scheme earlier cf-tools
|
|
2
|
+
used, so their saved logins keep working).
|
|
3
|
+
|
|
4
|
+
AES-128-GCM with key = MD5("glhf" + handle + "233"), hex(nonce + ciphertext).
|
|
5
|
+
The key derives only from the handle, so this is obfuscation against casual
|
|
6
|
+
file reads, not real security; the session file's 0600 mode does the rest.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
from cryptography.exceptions import InvalidTag
|
|
15
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
16
|
+
|
|
17
|
+
from cf_tool.errors import CFError
|
|
18
|
+
|
|
19
|
+
_NONCE_SIZE = 12
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _key(handle_or_email: str) -> bytes:
|
|
23
|
+
return hashlib.md5(f"glhf{handle_or_email}233".encode()).digest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def encrypt_password(handle_or_email: str, password: str) -> str:
|
|
27
|
+
nonce = os.urandom(_NONCE_SIZE)
|
|
28
|
+
sealed = AESGCM(_key(handle_or_email)).encrypt(nonce, password.encode(), None)
|
|
29
|
+
return (nonce + sealed).hex()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def decrypt_password(handle_or_email: str, encrypted: str) -> str:
|
|
33
|
+
try:
|
|
34
|
+
data = bytes.fromhex(encrypted)
|
|
35
|
+
except ValueError as e:
|
|
36
|
+
raise CFError("Cannot decode the password") from e
|
|
37
|
+
try:
|
|
38
|
+
plain = AESGCM(_key(handle_or_email)).decrypt(data[:_NONCE_SIZE], data[_NONCE_SIZE:], None)
|
|
39
|
+
except (InvalidTag, ValueError) as e:
|
|
40
|
+
raise CFError("Cannot decrypt the password (wrong handle?)") from e
|
|
41
|
+
return plain.decode()
|