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
cf_tool/cli/problems.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Commands for problems and contests: get, race, new, listen, pick,
|
|
2
|
+
problems, contests, open."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from cf_tool import languages, listen as listen_module, ui, views, workspace
|
|
15
|
+
from cf_tool.cli.common import (
|
|
16
|
+
cf_command,
|
|
17
|
+
client,
|
|
18
|
+
open_url,
|
|
19
|
+
settings,
|
|
20
|
+
short,
|
|
21
|
+
starter_language,
|
|
22
|
+
starter_values,
|
|
23
|
+
target_of,
|
|
24
|
+
write_starter,
|
|
25
|
+
)
|
|
26
|
+
from cf_tool.cli.completion import complete_contests, complete_languages, complete_problems
|
|
27
|
+
from cf_tool.errors import CFError
|
|
28
|
+
from cf_tool.workspace import Target
|
|
29
|
+
|
|
30
|
+
# ┌──────────────────────────────────────────────────────────┐
|
|
31
|
+
# │ cf get │
|
|
32
|
+
# └──────────────────────────────────────────────────────────┘
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def set_up(target: Target, key: str, tries: int = 1) -> Path:
|
|
36
|
+
"""Fetch target's problems (tries times, 2 s apart, while there are none
|
|
37
|
+
yet) and give each its folder: tests, info and starter code; returns the
|
|
38
|
+
contest's folder."""
|
|
39
|
+
for attempt in range(tries):
|
|
40
|
+
try:
|
|
41
|
+
found = client().fetch(target)
|
|
42
|
+
except CFError:
|
|
43
|
+
if attempt == tries - 1:
|
|
44
|
+
raise
|
|
45
|
+
found = []
|
|
46
|
+
if found:
|
|
47
|
+
break
|
|
48
|
+
if attempt < tries - 1:
|
|
49
|
+
time.sleep(2) # NOTE: right at the start, the problems may not show yet
|
|
50
|
+
if not found:
|
|
51
|
+
wait = f"cf race {target.contest} waits for its start"
|
|
52
|
+
raise CFError(f"{target.label} has no problems to see yet: {wait}")
|
|
53
|
+
root = workspace.get_root(Path.cwd(), settings().root_folder())
|
|
54
|
+
folders = []
|
|
55
|
+
grid = Table.grid(padding=(0, 2))
|
|
56
|
+
for column in ("folder", "name", "tests", "code", "notes"):
|
|
57
|
+
grid.add_column(no_wrap=column != "notes")
|
|
58
|
+
for problem in found:
|
|
59
|
+
problem_target = target.with_problem(problem.index)
|
|
60
|
+
folder = workspace.problem_folder(root, problem_target)
|
|
61
|
+
if problem.samples:
|
|
62
|
+
workspace.save_samples(folder, problem.samples)
|
|
63
|
+
workspace.save_info(folder, problem.info)
|
|
64
|
+
if problem.statement:
|
|
65
|
+
workspace.save_statement(folder, problem.statement)
|
|
66
|
+
code = write_starter(folder, problem_target, problem.info, key)
|
|
67
|
+
folders.append(folder)
|
|
68
|
+
count = len(problem.samples)
|
|
69
|
+
notes = []
|
|
70
|
+
if problem.error:
|
|
71
|
+
notes.append(f"no samples: {problem.error}")
|
|
72
|
+
if not problem.standard_io:
|
|
73
|
+
notes.append("reads and writes files")
|
|
74
|
+
if problem.info.interactive:
|
|
75
|
+
notes.append("interactive: cf test needs an interactor")
|
|
76
|
+
grid.add_row(
|
|
77
|
+
Text(short(folder), style=f"bold {ui.ACCENT}"),
|
|
78
|
+
Text(problem.info.name or problem.index),
|
|
79
|
+
Text(f"{count} test{'s' if count != 1 else ''}", style=ui.MUTED),
|
|
80
|
+
Text(code.name if code is not None else "", style=ui.MUTED),
|
|
81
|
+
Text("; ".join(notes), style=ui.WARNING),
|
|
82
|
+
)
|
|
83
|
+
ui.console.print(grid)
|
|
84
|
+
then = "cf test" if len(folders) == 1 else "cf test and cf submit there"
|
|
85
|
+
ui.line(("→ ", "cyan bold"), (f"cd {short(folders[0])}", "bold"), (f", then {then}", "dim"))
|
|
86
|
+
return folders[0].parent
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@click.command()
|
|
90
|
+
@click.argument("specs", nargs=-1, metavar="CONTEST|PROBLEM|URL", shell_complete=complete_contests)
|
|
91
|
+
@click.option(
|
|
92
|
+
"-l",
|
|
93
|
+
"--lang",
|
|
94
|
+
"language",
|
|
95
|
+
metavar="EXT",
|
|
96
|
+
shell_complete=complete_languages,
|
|
97
|
+
help="Starter code in this language.",
|
|
98
|
+
)
|
|
99
|
+
@cf_command
|
|
100
|
+
def get(specs: tuple[str, ...], language: str | None) -> None:
|
|
101
|
+
"""Set up a contest or a problem: a folder for each problem, with its
|
|
102
|
+
samples as tests and your starter code.
|
|
103
|
+
|
|
104
|
+
\b
|
|
105
|
+
cf get 2050 every problem of contest 2050: 2050/a, 2050/b, ...
|
|
106
|
+
cf get 2050b one problem
|
|
107
|
+
cf get URL a contest's or a problem's page (gym, groups too)
|
|
108
|
+
"""
|
|
109
|
+
target = target_of(specs)
|
|
110
|
+
if not target.contest:
|
|
111
|
+
raise CFError("Which contest or problem? Like cf get 2050, or cf get 2050b")
|
|
112
|
+
set_up(target, starter_language(language))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@click.command()
|
|
116
|
+
@click.argument("specs", nargs=-1, metavar="CONTEST", shell_complete=complete_contests)
|
|
117
|
+
@click.option(
|
|
118
|
+
"-l",
|
|
119
|
+
"--lang",
|
|
120
|
+
"language",
|
|
121
|
+
metavar="EXT",
|
|
122
|
+
shell_complete=complete_languages,
|
|
123
|
+
help="Starter code in this language.",
|
|
124
|
+
)
|
|
125
|
+
@cf_command
|
|
126
|
+
def race(specs: tuple[str, ...], language: str | None) -> None:
|
|
127
|
+
"""Count down to a contest's start, then open its problems and get them."""
|
|
128
|
+
target = target_of(specs)
|
|
129
|
+
if not target.contest:
|
|
130
|
+
raise CFError("Which contest? Like cf race 2050")
|
|
131
|
+
key = starter_language(language) # NOTE: asked now, not when the contest starts
|
|
132
|
+
contest = Target(target.kind, target.contest, "", target.group)
|
|
133
|
+
ui.line(("Waiting for ", "cyan"), (contest.label, "bold"))
|
|
134
|
+
client().race(contest)
|
|
135
|
+
open_url(contest.problems_url(settings().host))
|
|
136
|
+
set_up(contest, key, tries=5)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ┌──────────────────────────────────────────────────────────┐
|
|
140
|
+
# │ cf new │
|
|
141
|
+
# └──────────────────────────────────────────────────────────┘
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@click.command()
|
|
145
|
+
@click.argument("name", required=False)
|
|
146
|
+
@click.option(
|
|
147
|
+
"-l",
|
|
148
|
+
"--lang",
|
|
149
|
+
"language",
|
|
150
|
+
metavar="EXT",
|
|
151
|
+
shell_complete=complete_languages,
|
|
152
|
+
help="The language, when NAME has none.",
|
|
153
|
+
)
|
|
154
|
+
@cf_command
|
|
155
|
+
def new(name: str | None, language: str | None) -> None:
|
|
156
|
+
"""Write a code file from your starter code into this folder.
|
|
157
|
+
|
|
158
|
+
\b
|
|
159
|
+
cf new <problem>.<ext>, like a.cpp in 2050/a
|
|
160
|
+
cf new brute brute.cpp, from templates/brute.cpp when you have one
|
|
161
|
+
cf new gen.py gen.py: the extension picks the language
|
|
162
|
+
"""
|
|
163
|
+
cwd = Path.cwd()
|
|
164
|
+
folder = workspace.find_problem_folder(cwd)
|
|
165
|
+
info = workspace.load_info(folder) if folder else None
|
|
166
|
+
target = workspace.here(cwd)
|
|
167
|
+
key = languages.language_key(name, settings()) if name and "." in name else None
|
|
168
|
+
key = key or starter_language(language)
|
|
169
|
+
stem = (
|
|
170
|
+
Path(name).stem
|
|
171
|
+
if name
|
|
172
|
+
else (target.problem.lower() if target and target.problem else "main")
|
|
173
|
+
)
|
|
174
|
+
path = cwd / f"{stem}.{key}"
|
|
175
|
+
if path.exists():
|
|
176
|
+
raise CFError(f"{path.name} exists already")
|
|
177
|
+
text, origin = languages.starter(key, settings(), stem)
|
|
178
|
+
path.write_text(
|
|
179
|
+
languages.render(text, starter_values(target, info)), encoding="utf-8", newline=""
|
|
180
|
+
)
|
|
181
|
+
source = "the built-in starter" if origin == "built-in" else short(origin)
|
|
182
|
+
ui.line(("Wrote ", "green"), (path.name, "bold green"), (f" from {source}", "dim"))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ┌──────────────────────────────────────────────────────────┐
|
|
186
|
+
# │ cf listen │
|
|
187
|
+
# └──────────────────────────────────────────────────────────┘
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@click.command()
|
|
191
|
+
@click.option(
|
|
192
|
+
"--port",
|
|
193
|
+
default=listen_module.DEFAULT_PORT,
|
|
194
|
+
show_default=True,
|
|
195
|
+
help="The port (one the extension sends to, or one added in its settings).",
|
|
196
|
+
)
|
|
197
|
+
@click.option("--keep", is_flag=True, help="Keep listening after a contest or a problem arrived.")
|
|
198
|
+
@click.option(
|
|
199
|
+
"-l",
|
|
200
|
+
"--lang",
|
|
201
|
+
"language",
|
|
202
|
+
metavar="EXT",
|
|
203
|
+
shell_complete=complete_languages,
|
|
204
|
+
help="Starter code in this language.",
|
|
205
|
+
)
|
|
206
|
+
@cf_command
|
|
207
|
+
def listen(port: int, keep: bool, language: str | None) -> None:
|
|
208
|
+
"""Receive problems from the Competitive Companion browser extension.
|
|
209
|
+
|
|
210
|
+
Run it, then click the extension's button on a problem or contest page:
|
|
211
|
+
the problems land where cf get would put them, and cf fetches no page.
|
|
212
|
+
"""
|
|
213
|
+
key = starter_language(language)
|
|
214
|
+
root = workspace.get_root(Path.cwd(), settings().root_folder())
|
|
215
|
+
listen_module.listen(
|
|
216
|
+
root,
|
|
217
|
+
lambda folder, target, info: write_starter(folder, target, info, key),
|
|
218
|
+
port=port,
|
|
219
|
+
keep=keep,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ┌──────────────────────────────────────────────────────────┐
|
|
224
|
+
# │ cf pick │
|
|
225
|
+
# └──────────────────────────────────────────────────────────┘
|
|
226
|
+
|
|
227
|
+
# for Tab completion: the problemset's tags
|
|
228
|
+
TAGS = (
|
|
229
|
+
"*special", "2-sat", "binary search", "bitmasks", "brute force", "chinese remainder theorem",
|
|
230
|
+
"combinatorics", "communication", "constructive algorithms", "data structures",
|
|
231
|
+
"dfs and similar", "divide and conquer", "dp", "dsu", "expression parsing", "fft", "flows",
|
|
232
|
+
"games", "geometry", "graph matchings", "graphs", "greedy", "hashing", "implementation",
|
|
233
|
+
"interactive", "math", "matrices", "meet-in-the-middle", "number theory", "probabilities",
|
|
234
|
+
"schedules", "shortest paths", "sortings", "string suffix structures", "strings",
|
|
235
|
+
"ternary search", "trees", "two pointers",
|
|
236
|
+
) # fmt: skip
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _rating_range(ctx, param, value: str | None) -> tuple[int, int] | None:
|
|
240
|
+
"""--rating 1600 or 1400-1800 as (low, high)."""
|
|
241
|
+
if value is None:
|
|
242
|
+
return None
|
|
243
|
+
match = re.fullmatch(r"(\d+)(?:-(\d+))?", value.strip())
|
|
244
|
+
if not match:
|
|
245
|
+
raise click.BadParameter("a rating like 1600, or a range like 1400-1800")
|
|
246
|
+
low, high = int(match.group(1)), int(match.group(2) or match.group(1))
|
|
247
|
+
return min(low, high), max(low, high)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _tags(ctx, param, incomplete: str):
|
|
251
|
+
from cf_tool.cli.completion import words
|
|
252
|
+
|
|
253
|
+
return words(incomplete, [*TAGS, *(f"!{tag}" for tag in TAGS)])
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@click.command()
|
|
257
|
+
@click.option(
|
|
258
|
+
"-r", "--rating", callback=_rating_range, metavar="R|LOW-HIGH", help="e.g. 1600 or 1400-1800."
|
|
259
|
+
)
|
|
260
|
+
@click.option(
|
|
261
|
+
"-t",
|
|
262
|
+
"--tag",
|
|
263
|
+
"tags",
|
|
264
|
+
multiple=True,
|
|
265
|
+
shell_complete=_tags,
|
|
266
|
+
help='A tag it has (repeatable); "!tag" for one it hasn\'t.',
|
|
267
|
+
)
|
|
268
|
+
@click.option("--handle", default="", help="Whose solved problems to skip (default: yours).")
|
|
269
|
+
@click.option("--get", "get_it", is_flag=True, help="Set it up, as cf get does.")
|
|
270
|
+
@click.option("--open", "open_it", is_flag=True, help="Open it in the browser.")
|
|
271
|
+
@click.option(
|
|
272
|
+
"-l",
|
|
273
|
+
"--lang",
|
|
274
|
+
"language",
|
|
275
|
+
metavar="EXT",
|
|
276
|
+
shell_complete=complete_languages,
|
|
277
|
+
help="Starter code in this language.",
|
|
278
|
+
)
|
|
279
|
+
@cf_command
|
|
280
|
+
def pick(
|
|
281
|
+
rating: tuple[int, int] | None,
|
|
282
|
+
tags: tuple[str, ...],
|
|
283
|
+
handle: str,
|
|
284
|
+
get_it: bool,
|
|
285
|
+
open_it: bool,
|
|
286
|
+
language: str | None,
|
|
287
|
+
) -> None:
|
|
288
|
+
"""A random problem you haven't solved, by rating and tags, to practice on."""
|
|
289
|
+
from cf_tool.codeforces.client import pick_problem
|
|
290
|
+
|
|
291
|
+
c = client()
|
|
292
|
+
handle = handle or c.handle
|
|
293
|
+
with ui.status("Reading the problemset..."):
|
|
294
|
+
problems = c.problemset()
|
|
295
|
+
solved = c.solved(handle) if handle else set()
|
|
296
|
+
if not handle:
|
|
297
|
+
ui.yellow("Not logged in: solved problems aren't skipped (or give --handle).")
|
|
298
|
+
low, high = rating or (None, None)
|
|
299
|
+
problem, pool = pick_problem(problems, solved, low=low, high=high, tags=tags)
|
|
300
|
+
if problem is None:
|
|
301
|
+
raise CFError("No problem you haven't solved matches that")
|
|
302
|
+
target = workspace.parse_target([problem.id])
|
|
303
|
+
ui.line(
|
|
304
|
+
(problem.id, "bold green"),
|
|
305
|
+
(f" {problem.name}", "bold"),
|
|
306
|
+
(f" rating {problem.rating}" if problem.rating else " unrated", "cyan"),
|
|
307
|
+
)
|
|
308
|
+
ui.line((", ".join(problem.tags) or "no tags", "dim"))
|
|
309
|
+
url = target.problem_url(c.host)
|
|
310
|
+
ui.plain(f"{url} (one of {pool} to pick from)")
|
|
311
|
+
if open_it:
|
|
312
|
+
open_url(url)
|
|
313
|
+
if get_it:
|
|
314
|
+
set_up(target, starter_language(language))
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# ┌──────────────────────────────────────────────────────────┐
|
|
318
|
+
# │ cf problems, contests, open │
|
|
319
|
+
# └──────────────────────────────────────────────────────────┘
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@click.command()
|
|
323
|
+
@click.argument("specs", nargs=-1, metavar="[CONTEST]", shell_complete=complete_problems)
|
|
324
|
+
@cf_command
|
|
325
|
+
def problems(specs: tuple[str, ...]) -> None:
|
|
326
|
+
"""A contest's problems, with how many solved each (yours marked)."""
|
|
327
|
+
target = target_of(specs)
|
|
328
|
+
ui.console.print(views.problems_table(client().problem_rows(target)))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@click.command()
|
|
332
|
+
@click.option(
|
|
333
|
+
"--ics",
|
|
334
|
+
"ics_path",
|
|
335
|
+
type=click.Path(dir_okay=False),
|
|
336
|
+
help="Also save them as a calendar file (.ics) to import.",
|
|
337
|
+
)
|
|
338
|
+
@cf_command
|
|
339
|
+
def contests(ics_path: str | None) -> None:
|
|
340
|
+
"""Upcoming contests, soonest first."""
|
|
341
|
+
c = client()
|
|
342
|
+
upcoming = c.contests()
|
|
343
|
+
if not upcoming:
|
|
344
|
+
ui.plain("No contest is scheduled right now.")
|
|
345
|
+
return
|
|
346
|
+
ui.console.print(views.contests_table(upcoming, time.time()))
|
|
347
|
+
if ics_path:
|
|
348
|
+
Path(ics_path).write_text(
|
|
349
|
+
views.to_ics(upcoming, c.host, time.time()), encoding="utf-8", newline=""
|
|
350
|
+
)
|
|
351
|
+
ui.green(f"Saved them to {ics_path}: import it into your calendar")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _open(specs: tuple[str, ...], standings: bool, last: bool) -> None:
|
|
355
|
+
host = settings().host
|
|
356
|
+
urls = [spec for spec in specs if spec.startswith(("http://", "https://"))]
|
|
357
|
+
if urls and not (standings or last):
|
|
358
|
+
open_url(urls[0])
|
|
359
|
+
return
|
|
360
|
+
if last:
|
|
361
|
+
saved = client().session.last_submission
|
|
362
|
+
if not saved or not saved.get("submission"):
|
|
363
|
+
raise CFError("No submission yet: cf submit remembers the last one")
|
|
364
|
+
target = Target(
|
|
365
|
+
saved["kind"] or "contest", saved["contest"], saved["problem"], saved["group"]
|
|
366
|
+
)
|
|
367
|
+
open_url(target.submission_url(host, saved["submission"]))
|
|
368
|
+
return
|
|
369
|
+
target = target_of(specs)
|
|
370
|
+
open_url(target.standings_url(host) if standings else target.page_url(host))
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@click.command("open")
|
|
374
|
+
@click.argument("specs", nargs=-1, metavar="[PROBLEM|CONTEST]", shell_complete=complete_problems)
|
|
375
|
+
@click.option("-s", "--standings", is_flag=True, help="The contest's standings.")
|
|
376
|
+
@click.option("--last", is_flag=True, help="Your last submission.")
|
|
377
|
+
@cf_command
|
|
378
|
+
def open_(specs: tuple[str, ...], standings: bool, last: bool) -> None:
|
|
379
|
+
"""Open the problem (or contest) here in the browser."""
|
|
380
|
+
_open(specs, standings, last)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@click.command(hidden=True)
|
|
384
|
+
@click.argument("specs", nargs=-1)
|
|
385
|
+
@cf_command
|
|
386
|
+
def stand(specs: tuple[str, ...]) -> None:
|
|
387
|
+
"""cf open --standings (cf-tool 0.3's name)."""
|
|
388
|
+
_open(specs, True, False)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@click.command(hidden=True)
|
|
392
|
+
@click.argument("specs", nargs=-1)
|
|
393
|
+
@cf_command
|
|
394
|
+
def sid(specs: tuple[str, ...]) -> None:
|
|
395
|
+
"""cf open --last (cf-tool 0.3's name)."""
|
|
396
|
+
_open(specs, False, True)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
COMMANDS = [get, race, new, listen, pick, problems, contests, open_, stand, sid]
|
cf_tool/cli/setup.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Commands about cf-tool itself: config, langs, doctor, upgrade."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich import box
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from cf_tool import __version__, languages, paths, ui
|
|
15
|
+
from cf_tool.cli import configure
|
|
16
|
+
from cf_tool.cli.common import cf_command, client, settings, short
|
|
17
|
+
from cf_tool.errors import CFError
|
|
18
|
+
|
|
19
|
+
REPOSITORY = "Fau818/cf-tool"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.command()
|
|
23
|
+
@click.option("--edit", "edit_it", is_flag=True, help="Open config.toml in your editor instead.")
|
|
24
|
+
@click.option("--path", "path_only", is_flag=True, help="Print where config.toml is, and stop.")
|
|
25
|
+
@cf_command
|
|
26
|
+
def config(edit_it: bool, path_only: bool) -> None:
|
|
27
|
+
"""Change the settings, picking from lists: the language for new
|
|
28
|
+
problems, and for each language what it is submitted as, its compiler and
|
|
29
|
+
its starter code; the proxy; the login. They are saved in config.toml,
|
|
30
|
+
which --edit opens (every setting is explained in it)."""
|
|
31
|
+
path = paths.config_file()
|
|
32
|
+
if path_only:
|
|
33
|
+
click.echo(str(path)) # NOTE: never wrapped, for scripts
|
|
34
|
+
return
|
|
35
|
+
if edit_it:
|
|
36
|
+
configure.edit_file()
|
|
37
|
+
return
|
|
38
|
+
try:
|
|
39
|
+
settings()
|
|
40
|
+
except CFError as e:
|
|
41
|
+
ui.red(str(e))
|
|
42
|
+
if ui.interactive() and ui.confirm("Open it in your editor to fix it?", default=True):
|
|
43
|
+
configure.edit_file()
|
|
44
|
+
return
|
|
45
|
+
if not ui.interactive():
|
|
46
|
+
ui.plain(
|
|
47
|
+
f"cf config's menu needs a terminal. The settings are in {path} (cf config --edit)."
|
|
48
|
+
)
|
|
49
|
+
return
|
|
50
|
+
configure.menu()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@click.command()
|
|
54
|
+
@click.option(
|
|
55
|
+
"--all", "show_all", is_flag=True, help="Every language Codeforces offers, with its id."
|
|
56
|
+
)
|
|
57
|
+
@cf_command
|
|
58
|
+
def langs(show_all: bool) -> None:
|
|
59
|
+
"""The languages: what builds and runs each here, what it submits as,
|
|
60
|
+
and its starter code."""
|
|
61
|
+
current = settings()
|
|
62
|
+
offered, live = client().languages() if show_all else (languages.known_languages(), False)
|
|
63
|
+
if show_all:
|
|
64
|
+
table = Table(box=box.ROUNDED, border_style=ui.MUTED, header_style=f"bold {ui.ACCENT}")
|
|
65
|
+
for column in ("id", "Codeforces language", "files"):
|
|
66
|
+
table.add_column(column)
|
|
67
|
+
for ext, group in sorted(
|
|
68
|
+
languages.by_family(offered).items(), key=lambda item: languages.family_name(item[0])
|
|
69
|
+
):
|
|
70
|
+
for lid, name in group:
|
|
71
|
+
table.add_row(lid, Text(name), f".{ext}" if ext != "txt" else "")
|
|
72
|
+
ui.console.print(table)
|
|
73
|
+
if not live:
|
|
74
|
+
ui.yellow("Not logged in: this is the list read last (cf login reads the current one).")
|
|
75
|
+
return
|
|
76
|
+
table = Table(box=box.ROUNDED, border_style=ui.MUTED, header_style=f"bold {ui.ACCENT}")
|
|
77
|
+
for column in ("language", "files", "builds and runs with", "submits as", "starter code"):
|
|
78
|
+
table.add_column(column)
|
|
79
|
+
keys = list(dict.fromkeys([*configure.LISTED, *current.languages])) # NOTE: C: by its files
|
|
80
|
+
for key in keys:
|
|
81
|
+
language = languages.LANGUAGES.get(key)
|
|
82
|
+
files = " ".join(f".{ext}" for ext in (language.extensions if language else (key,)))
|
|
83
|
+
try:
|
|
84
|
+
setup = languages.setup(key, current, offered=offered)
|
|
85
|
+
tool = Text(setup.tool)
|
|
86
|
+
submits = Text(
|
|
87
|
+
setup.submit[1] if setup.submit else "not offered",
|
|
88
|
+
style="" if setup.submit else ui.WARNING,
|
|
89
|
+
)
|
|
90
|
+
except CFError as e:
|
|
91
|
+
hint = str(e).split(". To get one: ")
|
|
92
|
+
tool = Text(f"missing: {hint[-1]}" if len(hint) > 1 else str(e), style=ui.WARNING)
|
|
93
|
+
submits = Text("")
|
|
94
|
+
try:
|
|
95
|
+
_, origin = languages.starter(key, current)
|
|
96
|
+
starter = "built-in" if origin == "built-in" else short(origin)
|
|
97
|
+
except CFError:
|
|
98
|
+
starter = "missing"
|
|
99
|
+
name = languages.display_name(key) + (" *" if key == current.language else "")
|
|
100
|
+
table.add_row(
|
|
101
|
+
Text(name, style="bold" if key == current.language else ""),
|
|
102
|
+
files,
|
|
103
|
+
tool,
|
|
104
|
+
submits,
|
|
105
|
+
Text(starter),
|
|
106
|
+
)
|
|
107
|
+
ui.console.print(table)
|
|
108
|
+
ui.dim(f"* cf get's language. Change any of these in {short(current.file)} (cf config).")
|
|
109
|
+
ui.dim("cf langs --all lists the languages Codeforces offers.")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_MARKS = {"ok": ("✓ ", "green"), "warn": ("! ", "yellow"), "error": ("✗ ", "red")}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@click.command()
|
|
116
|
+
@cf_command
|
|
117
|
+
def doctor() -> None:
|
|
118
|
+
"""Check the setup (languages, compilers, login, Chrome) and say how to fix it."""
|
|
119
|
+
from cf_tool import doctor as checks
|
|
120
|
+
|
|
121
|
+
ui.plain(f"cf-tool {__version__}, Python {platform.python_version()}, {sys.platform}")
|
|
122
|
+
try:
|
|
123
|
+
findings = checks.run(settings(), client())
|
|
124
|
+
except CFError as e: # NOTE: a broken settings file is a finding too
|
|
125
|
+
findings = [checks.Finding(checks.ERROR, str(e))]
|
|
126
|
+
for finding in findings:
|
|
127
|
+
ui.line(_MARKS[finding.level], (finding.text, ""))
|
|
128
|
+
if finding.fix:
|
|
129
|
+
ui.line((" " + finding.fix, "cyan"))
|
|
130
|
+
if any(finding.level == checks.ERROR for finding in findings):
|
|
131
|
+
sys.exit(1)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
_VERSION_RE = re.compile(r"v?(\d+(?:\.\d+)*)(?:[-._]?(a|alpha|b|beta|c|rc)[-._]?(\d*))?", re.I)
|
|
135
|
+
_PRE_RANK = {"a": 0, "alpha": 0, "b": 1, "beta": 1, "c": 2, "rc": 2}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def version_key(version: str) -> tuple[int, ...] | None:
|
|
139
|
+
"""Sortable form of "0.3.0", "v0.3.0a1", "0.3.0-rc.2" (PEP 440 order:
|
|
140
|
+
0.3.0a1 < 0.3.0b1 < 0.3.0rc1 < 0.3.0); None if it is not a version."""
|
|
141
|
+
match = _VERSION_RE.fullmatch(version.strip())
|
|
142
|
+
if not match:
|
|
143
|
+
return None
|
|
144
|
+
release = [int(n) for n in match.group(1).split(".")]
|
|
145
|
+
release += [0] * (4 - len(release))
|
|
146
|
+
tag = (match.group(2) or "").lower()
|
|
147
|
+
return (*release, _PRE_RANK.get(tag, 3), int(match.group(3) or 0))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@click.command()
|
|
151
|
+
@cf_command
|
|
152
|
+
def upgrade() -> None:
|
|
153
|
+
"""Check GitHub for a newer cf-tool release."""
|
|
154
|
+
from curl_cffi import requests as curl_requests
|
|
155
|
+
|
|
156
|
+
ui.cyan(f"Current version is {__version__}")
|
|
157
|
+
url = f"https://api.github.com/repos/{REPOSITORY}/releases?per_page=30"
|
|
158
|
+
try:
|
|
159
|
+
response = curl_requests.get(
|
|
160
|
+
url,
|
|
161
|
+
timeout=10,
|
|
162
|
+
# NOTE: GitHub's API refuses requests without a User-Agent
|
|
163
|
+
headers={
|
|
164
|
+
"User-Agent": f"cf-tool/{__version__}",
|
|
165
|
+
"Accept": "application/vnd.github+json",
|
|
166
|
+
},
|
|
167
|
+
)
|
|
168
|
+
except Exception as e:
|
|
169
|
+
raise CFError(f"Cannot reach GitHub: {e}") from e
|
|
170
|
+
if response.status_code != 200:
|
|
171
|
+
raise CFError(f"Cannot list the releases of {REPOSITORY} ({response.status_code})")
|
|
172
|
+
current = version_key(__version__) or ()
|
|
173
|
+
on_prerelease = current[-2:-1] != (3,)
|
|
174
|
+
# NOTE: pre-releases are offered only to those already running one
|
|
175
|
+
tags = [
|
|
176
|
+
str(release.get("tag_name", ""))
|
|
177
|
+
for release in response.json()
|
|
178
|
+
if not release.get("draft") and (on_prerelease or not release.get("prerelease"))
|
|
179
|
+
]
|
|
180
|
+
versions = [(key, tag) for tag in tags if (key := version_key(tag))]
|
|
181
|
+
if not versions:
|
|
182
|
+
raise CFError(f"No release of {REPOSITORY} is published on GitHub yet")
|
|
183
|
+
latest_key, latest = max(versions)
|
|
184
|
+
if latest_key <= current:
|
|
185
|
+
ui.green("You already have the latest version")
|
|
186
|
+
return
|
|
187
|
+
ui.green(f"Latest version is {latest}. Upgrade with:")
|
|
188
|
+
if latest_key[-2] == 3: # NOTE: a release, not a pre-release: on PyPI
|
|
189
|
+
ui.plain(" uv tool upgrade cf-tool-cli (or: pipx upgrade cf-tool-cli)")
|
|
190
|
+
ui.plain("Installed from GitHub:")
|
|
191
|
+
ui.plain(f" uv tool install --force git+https://github.com/{REPOSITORY}@{latest}")
|
|
192
|
+
ui.plain(f" (or: pipx install --force git+https://github.com/{REPOSITORY}@{latest})")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
COMMANDS = [config, langs, doctor, upgrade]
|