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.
@@ -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."""