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,103 @@
|
|
|
1
|
+
"""The login session ($XDG_STATE_HOME/cf/session, mode 0600): the handle, the
|
|
2
|
+
credentials (the password encrypted, see crypto.py), the cookies with the
|
|
3
|
+
User-Agent they were issued to, and the last submission (for cf open --last).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from cf_tool import paths, ui
|
|
14
|
+
from cf_tool.codeforces.http import CookieDict
|
|
15
|
+
|
|
16
|
+
# what a submission is remembered by, for cf open --last
|
|
17
|
+
LAST_KEYS = ("kind", "contest", "problem", "group", "submission")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Session:
|
|
22
|
+
path: Path
|
|
23
|
+
handle: str = ""
|
|
24
|
+
handle_or_email: str = ""
|
|
25
|
+
password: str = "" # encrypted, see crypto.py
|
|
26
|
+
ftaa: str = ""
|
|
27
|
+
bfaa: str = ""
|
|
28
|
+
user_agent: str = ""
|
|
29
|
+
cookies: list[CookieDict] = field(default_factory=list)
|
|
30
|
+
last_submission: dict[str, str] | None = None
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def load(cls, path: Path | None = None) -> "Session":
|
|
34
|
+
"""The session stored at path; an empty one if missing or unreadable."""
|
|
35
|
+
path = path or paths.session_file()
|
|
36
|
+
try:
|
|
37
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
38
|
+
except FileNotFoundError:
|
|
39
|
+
return cls(path)
|
|
40
|
+
except (OSError, ValueError) as e:
|
|
41
|
+
ui.yellow(f"Ignoring the unreadable session {path} ({e}): run cf login again")
|
|
42
|
+
return cls(path)
|
|
43
|
+
if not isinstance(data, dict):
|
|
44
|
+
ui.yellow(f"Ignoring the malformed session {path}: run cf login again")
|
|
45
|
+
return cls(path)
|
|
46
|
+
last = data.get("last_submission")
|
|
47
|
+
return cls(
|
|
48
|
+
path=path,
|
|
49
|
+
handle=_text(data.get("handle")),
|
|
50
|
+
handle_or_email=_text(data.get("handle_or_email")),
|
|
51
|
+
password=_text(data.get("password")),
|
|
52
|
+
ftaa=_text(data.get("ftaa")),
|
|
53
|
+
bfaa=_text(data.get("bfaa")),
|
|
54
|
+
user_agent=_text(data.get("user_agent")),
|
|
55
|
+
cookies=_read_cookies(data.get("cookies")),
|
|
56
|
+
last_submission=_read_last(last) if isinstance(last, dict) else None,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def save(self) -> None:
|
|
60
|
+
data = {
|
|
61
|
+
"handle": self.handle,
|
|
62
|
+
"handle_or_email": self.handle_or_email,
|
|
63
|
+
"password": self.password,
|
|
64
|
+
"ftaa": self.ftaa,
|
|
65
|
+
"bfaa": self.bfaa,
|
|
66
|
+
"user_agent": self.user_agent,
|
|
67
|
+
"cookies": self.cookies,
|
|
68
|
+
"last_submission": self.last_submission,
|
|
69
|
+
}
|
|
70
|
+
paths.write_atomic(self.path, json.dumps(data, indent=2) + "\n", mode=0o600)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _text(value: Any) -> str:
|
|
74
|
+
return value if isinstance(value, str) else ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _read_cookies(raw: Any) -> list[CookieDict]:
|
|
78
|
+
if not isinstance(raw, list):
|
|
79
|
+
return []
|
|
80
|
+
return [
|
|
81
|
+
{
|
|
82
|
+
"name": c["name"],
|
|
83
|
+
"value": _text(c.get("value")),
|
|
84
|
+
"domain": c["domain"],
|
|
85
|
+
"path": _text(c.get("path")) or "/",
|
|
86
|
+
"secure": bool(c.get("secure")),
|
|
87
|
+
"expires": c.get("expires") if isinstance(c.get("expires"), (int, float)) else None,
|
|
88
|
+
}
|
|
89
|
+
for c in raw
|
|
90
|
+
if isinstance(c, dict) and _text(c.get("name")) and _text(c.get("domain"))
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _read_last(last: dict) -> dict[str, str]:
|
|
95
|
+
"""The last submission, also as cf-tool 0.3 saved it (problem_type, contest_id, ...)."""
|
|
96
|
+
old = {
|
|
97
|
+
"kind": "problem_type",
|
|
98
|
+
"contest": "contest_id",
|
|
99
|
+
"problem": "problem_id",
|
|
100
|
+
"group": "group_id",
|
|
101
|
+
"submission": "submission_id",
|
|
102
|
+
}
|
|
103
|
+
return {key: _text(last.get(key, last.get(old[key]))) for key in LAST_KEYS}
|
cf_tool/doctor.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""cf doctor: check the setup and say how to fix what's wrong, changing
|
|
2
|
+
nothing. It checks, in this order:
|
|
3
|
+
- the settings file
|
|
4
|
+
- each language that matters (the one for starter code, those set in
|
|
5
|
+
config.toml): its compiler, its starter file, what it submits as
|
|
6
|
+
- the login, over plain HTTP (no browser)
|
|
7
|
+
- Chrome, needed when Cloudflare checks the login"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Callable, Mapping
|
|
13
|
+
|
|
14
|
+
from cf_tool import languages
|
|
15
|
+
from cf_tool.codeforces import browser, pages
|
|
16
|
+
from cf_tool.errors import CFError, CloudflareChallengeError
|
|
17
|
+
from cf_tool.settings import Settings
|
|
18
|
+
|
|
19
|
+
OK, WARN, ERROR = "ok", "warn", "error"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Finding:
|
|
24
|
+
level: str # OK, WARN or ERROR
|
|
25
|
+
text: str
|
|
26
|
+
fix: str = "" # how to put it right
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check_language(
|
|
30
|
+
key: str,
|
|
31
|
+
settings: Settings,
|
|
32
|
+
offered: Mapping[str, str],
|
|
33
|
+
live: bool,
|
|
34
|
+
machine: languages.Machine | None = None,
|
|
35
|
+
) -> list[Finding]:
|
|
36
|
+
"""What's wrong with one language; all good: one OK. Errors: no
|
|
37
|
+
compiler, a missing starter file; a warning: Codeforces doesn't offer
|
|
38
|
+
the language it is set to submit as (checked when logged in)."""
|
|
39
|
+
name = languages.display_name(key)
|
|
40
|
+
try:
|
|
41
|
+
setup = languages.setup(key, settings, offered=offered, machine=machine)
|
|
42
|
+
except CFError as e:
|
|
43
|
+
return [Finding(ERROR, f"{name}: {e}")]
|
|
44
|
+
findings = []
|
|
45
|
+
try:
|
|
46
|
+
_, origin = languages.starter(key, settings)
|
|
47
|
+
except CFError as e:
|
|
48
|
+
findings.append(Finding(ERROR, f"{name}: {e}", f"fix template in [{key}] of config.toml"))
|
|
49
|
+
origin = ""
|
|
50
|
+
if setup.submit is None:
|
|
51
|
+
wanted = settings.language_settings(key).submit or name
|
|
52
|
+
level = WARN if live else OK
|
|
53
|
+
findings.append(
|
|
54
|
+
Finding(
|
|
55
|
+
level,
|
|
56
|
+
f"{name}: Codeforces doesn't offer {wanted}"
|
|
57
|
+
+ ("" if live else " (not checked: not logged in)"),
|
|
58
|
+
"cf submit lets you pick another" if live else "",
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
if not findings:
|
|
62
|
+
starter = "the built-in starter" if origin == "built-in" else origin
|
|
63
|
+
unchecked = "" if live else " (the list isn't checked: not logged in)"
|
|
64
|
+
findings.append(
|
|
65
|
+
Finding(
|
|
66
|
+
OK,
|
|
67
|
+
f"{name}: {setup.tool}, submits as {setup.submit[1] if setup.submit else '?'}, "
|
|
68
|
+
f"starts from {starter}{unchecked}",
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
return findings
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def check_login(client) -> tuple[Finding, bool]:
|
|
75
|
+
"""Whether Codeforces sees the saved session logged in: (finding, logged in).
|
|
76
|
+
- logged in: OK
|
|
77
|
+
- Cloudflare checks this connection, or it fails: a warning
|
|
78
|
+
- a saved login that expired: a warning (cf logs in again by itself)
|
|
79
|
+
- no saved login: an error"""
|
|
80
|
+
try:
|
|
81
|
+
body = client.gw.get(client.host, browser_fallback=False)
|
|
82
|
+
except CloudflareChallengeError:
|
|
83
|
+
finding = Finding(
|
|
84
|
+
WARN,
|
|
85
|
+
"Cloudflare is checking this connection right now",
|
|
86
|
+
"cf passes the check in a browser when it has to",
|
|
87
|
+
)
|
|
88
|
+
return finding, False
|
|
89
|
+
except CFError as e:
|
|
90
|
+
return Finding(WARN, f"Cannot reach {client.host}: {e}"), False
|
|
91
|
+
handle = pages.find_handle(body)
|
|
92
|
+
if handle:
|
|
93
|
+
return Finding(OK, f"Logged in as {handle}"), True
|
|
94
|
+
if client.can_log_in:
|
|
95
|
+
finding = Finding(
|
|
96
|
+
WARN, "The saved login has expired", "the next command that needs it logs in again"
|
|
97
|
+
)
|
|
98
|
+
return finding, False
|
|
99
|
+
return Finding(WARN, "Not logged in", "cf login (cf submit also asks when it needs to)"), False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def check_chrome(find: Callable[[], "str | None"] = browser.find_chrome) -> Finding:
|
|
103
|
+
"""Whether Chrome is installed: OK, or a warning."""
|
|
104
|
+
where = find()
|
|
105
|
+
if where:
|
|
106
|
+
return Finding(OK, f"Chrome: {where}")
|
|
107
|
+
return Finding(
|
|
108
|
+
WARN,
|
|
109
|
+
"Chrome not found: logging in fails when Cloudflare checks it",
|
|
110
|
+
"install Google Chrome or Chromium",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def run(settings: Settings, client) -> list[Finding]:
|
|
115
|
+
"""Every finding, in the order cf doctor prints them."""
|
|
116
|
+
findings = [Finding(WARN, warning, "fix it in config.toml") for warning in settings.warnings]
|
|
117
|
+
login, logged_in = check_login(client)
|
|
118
|
+
offered, live = client.languages() if logged_in else (languages.known_languages(), False)
|
|
119
|
+
keys = list(dict.fromkeys([settings.language or "cpp", *settings.languages]))
|
|
120
|
+
if not settings.language:
|
|
121
|
+
findings.append(Finding(OK, "No language picked yet: cf get asks for one"))
|
|
122
|
+
for key in keys:
|
|
123
|
+
findings += check_language(key, settings, offered, live)
|
|
124
|
+
return findings + [login, check_chrome()]
|
cf_tool/errors.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Exception types shared across cf-tool modules.
|
|
2
|
+
|
|
3
|
+
The login tiers (gateway.py) escalate on CFError, but stop at once on
|
|
4
|
+
LoginRejected (Codeforces said no — another tier would only repeat it) and on
|
|
5
|
+
BrowserUnavailable (no Chrome — a window would fail the same way).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CFError(Exception):
|
|
10
|
+
"""Base error: message is shown to the user in red, without a traceback."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NotLoggedInError(CFError):
|
|
14
|
+
"""Raised when Codeforces reports the session is not authenticated."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str = "Not logged in: run cf login") -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CloudflareChallengeError(CFError):
|
|
21
|
+
"""Raised when a response is a check page (Cloudflare's, or Codeforces' own)
|
|
22
|
+
instead of real content."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, message: str = "Blocked by a Cloudflare challenge") -> None:
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LoginRejected(CFError):
|
|
29
|
+
"""Codeforces itself rejected the login (e.g. wrong handle or password)."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BrowserError(CFError):
|
|
33
|
+
"""The browser fallback failed while driving a page."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BrowserUnavailable(BrowserError):
|
|
37
|
+
"""Chrome/Chromium could not be started at all."""
|