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,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()