codeforces 0.1.2__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/__init__.py +94 -0
- cf/config.py +40 -0
- cf/contests.py +137 -0
- cf/edit.py +42 -0
- cf/parse.py +126 -0
- cf/run.py +139 -0
- cf/submit.py +165 -0
- cf/unsolved.py +60 -0
- codeforces-0.1.2.dist-info/METADATA +75 -0
- codeforces-0.1.2.dist-info/RECORD +13 -0
- codeforces-0.1.2.dist-info/WHEEL +4 -0
- codeforces-0.1.2.dist-info/entry_points.txt +2 -0
- utils.py +235 -0
cf/__init__.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
# Oldest Python we support, and the newest release this was tested against.
|
|
4
|
+
MIN_PYTHON = (3, 9)
|
|
5
|
+
LATEST_TESTED_PYTHON = (3, 14)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _check_python_version() -> None:
|
|
9
|
+
"""
|
|
10
|
+
Fail loudly on Python versions we do not support, and warn on untested ones.
|
|
11
|
+
|
|
12
|
+
Uses plain print instead of rich: this runs before any third party import,
|
|
13
|
+
because an unsupported interpreter usually breaks those imports first.
|
|
14
|
+
"""
|
|
15
|
+
current = ".".join(str(v) for v in sys.version_info[:3])
|
|
16
|
+
|
|
17
|
+
if sys.version_info < MIN_PYTHON:
|
|
18
|
+
minimum = ".".join(str(v) for v in MIN_PYTHON)
|
|
19
|
+
print(
|
|
20
|
+
f"ERROR: codeforces-cli needs Python {minimum} or newer, "
|
|
21
|
+
f"but this is Python {current} ({sys.executable}).\n"
|
|
22
|
+
f"Install it under a newer interpreter, for example:\n"
|
|
23
|
+
f" pipx install --python python3.13 codeforces",
|
|
24
|
+
file=sys.stderr,
|
|
25
|
+
)
|
|
26
|
+
raise SystemExit(1)
|
|
27
|
+
|
|
28
|
+
if sys.version_info[:2] > LATEST_TESTED_PYTHON:
|
|
29
|
+
latest = ".".join(str(v) for v in LATEST_TESTED_PYTHON)
|
|
30
|
+
print(
|
|
31
|
+
f"WARNING: Python {current} is newer than the latest version "
|
|
32
|
+
f"codeforces-cli was tested on ({latest}). "
|
|
33
|
+
f"If something breaks, please open an issue.",
|
|
34
|
+
file=sys.stderr,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_check_python_version()
|
|
39
|
+
|
|
40
|
+
import click
|
|
41
|
+
from .config import config
|
|
42
|
+
from .contests import contests
|
|
43
|
+
from .parse import parse
|
|
44
|
+
from .submit import submit
|
|
45
|
+
from .run import run
|
|
46
|
+
from .unsolved import unsolved
|
|
47
|
+
from .edit import edit_cmd
|
|
48
|
+
from rich.console import Console
|
|
49
|
+
from rich.table import Table
|
|
50
|
+
from typing import Dict
|
|
51
|
+
|
|
52
|
+
console = Console()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RichGroup(click.Group):
|
|
56
|
+
def format_help(self, ctx: click.Context, formatter: click.HelpFormatter):
|
|
57
|
+
cmds: Dict[str, click.Command] = ctx.command.commands # type: ignore
|
|
58
|
+
|
|
59
|
+
console.print(r"""[bold green]
|
|
60
|
+
|
|
61
|
+
__ ____
|
|
62
|
+
_________ ____/ /__ / __/___ _____________ _____
|
|
63
|
+
/ ___/ __ \/ __ / _ \/ /_/ __ \/ ___/ ___/ _ \/ ___/
|
|
64
|
+
/ /__/ /_/ / /_/ / __/ __/ /_/ / / / /__/ __(__ )
|
|
65
|
+
\___/\____/\__,_/\___/_/ \____/_/ \___/\___/____/
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
[/]""")
|
|
69
|
+
|
|
70
|
+
table = Table(show_header=True, header_style="bold green", show_lines=True)
|
|
71
|
+
table.add_column("Command", style="bright", justify="left")
|
|
72
|
+
table.add_column("Description")
|
|
73
|
+
|
|
74
|
+
for name, cmd in cmds.items():
|
|
75
|
+
table.add_row(
|
|
76
|
+
f"{name} {' '.join(['[dim]{' + e.name + '}[/]' for e in cmd.params])}", # type: ignore
|
|
77
|
+
(cmd.help or "No Description").strip() # type: ignore
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
console.print(table)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@click.group(cls=RichGroup)
|
|
84
|
+
def commands():
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
commands.add_command(config)
|
|
89
|
+
commands.add_command(contests)
|
|
90
|
+
commands.add_command(parse)
|
|
91
|
+
commands.add_command(run)
|
|
92
|
+
commands.add_command(submit)
|
|
93
|
+
commands.add_command(unsolved)
|
|
94
|
+
commands.add_command(edit_cmd)
|
cf/config.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from utils import CFClient
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command()
|
|
11
|
+
@click.option("--username", prompt="Enter your codeforces username")
|
|
12
|
+
@click.option("--cf_dir", prompt="Enter your codeforces directory")
|
|
13
|
+
def config(username: str, cf_dir: str):
|
|
14
|
+
"""
|
|
15
|
+
Configure the codeforces cli.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
client = CFClient(username)
|
|
19
|
+
if not client.login():
|
|
20
|
+
# login() already printed why it failed.
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
if cf_dir.startswith("~"):
|
|
24
|
+
cf_dir = os.path.expanduser('~') + cf_dir[1:]
|
|
25
|
+
if not os.path.isdir(cf_dir):
|
|
26
|
+
os.makedirs(cf_dir, exist_ok=True)
|
|
27
|
+
console.print(f"[dim]Created directory: {cf_dir}[/]")
|
|
28
|
+
|
|
29
|
+
cf_dir = os.path.abspath(cf_dir)
|
|
30
|
+
data = {
|
|
31
|
+
"dir": cf_dir,
|
|
32
|
+
"username": username,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
config_path = os.path.join(os.path.expanduser('~'), "codeforces.uwu")
|
|
36
|
+
with open(config_path, "w") as f:
|
|
37
|
+
f.write(json.dumps(data))
|
|
38
|
+
|
|
39
|
+
console.print("\n[bold green]Config set![/]\n" + f"dir: {cf_dir}")
|
|
40
|
+
console.print(f"\nHappy Coding! {username}")
|
cf/contests.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from bs4 import BeautifulSoup
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.style import Style
|
|
6
|
+
from utils import CFClient, get_config
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
colors = {
|
|
12
|
+
"red": "#ff1c1d",
|
|
13
|
+
"orange": "#ff981a",
|
|
14
|
+
"violet": "#ff55ff",
|
|
15
|
+
"gray": "#9c9388",
|
|
16
|
+
"blue": "#254b8c",
|
|
17
|
+
"admin": "#ffffff",
|
|
18
|
+
"cyan": "#57fcf2",
|
|
19
|
+
"green": "#72ff72",
|
|
20
|
+
"black": "#000000"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def format_writer(writer) -> str:
|
|
25
|
+
if writer.string is None:
|
|
26
|
+
return f"[white]{writer.contents[0].string}[/white][{colors['red']}]{writer.contents[1]}[/]"
|
|
27
|
+
else:
|
|
28
|
+
return f"[{colors[writer['class'][1].split('-')[1]]}]{writer.string.strip()}[/]"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@click.command()
|
|
32
|
+
@click.argument("_id", default=0, required=False)
|
|
33
|
+
def contests(_id: str):
|
|
34
|
+
"""
|
|
35
|
+
Get the list of current or upcoming contests.
|
|
36
|
+
"""
|
|
37
|
+
config = get_config(console)
|
|
38
|
+
if config is None:
|
|
39
|
+
return
|
|
40
|
+
if "username" not in config:
|
|
41
|
+
console.print("[bold red]ERROR: [/]Username not set. Please use `cf config`.\n")
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
client = CFClient(config['username'])
|
|
45
|
+
client.login()
|
|
46
|
+
|
|
47
|
+
if _id == 0:
|
|
48
|
+
console.log("Fetching contest details...")
|
|
49
|
+
r = client.session.get("https://codeforces.com/contests?complete=true")
|
|
50
|
+
if r.status_code != 200:
|
|
51
|
+
console.print(f"[bold red]ERROR:[/] Status Code: {r.status_code}")
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
soup = BeautifulSoup(r.text, "html.parser")
|
|
55
|
+
table = Table(title="Current or upcoming contests", show_lines=True)
|
|
56
|
+
|
|
57
|
+
c = soup.find('div', {'class': 'contestList'}).find('table').find_all('tr') # type: ignore
|
|
58
|
+
if not c:
|
|
59
|
+
console.print("[bold red]An error occured.[/]")
|
|
60
|
+
return
|
|
61
|
+
table.add_column("ID", justify="center")
|
|
62
|
+
for col in c[0].find_all('th'):
|
|
63
|
+
table.add_column(col.string, justify="center")
|
|
64
|
+
|
|
65
|
+
for cont in c[1:]:
|
|
66
|
+
_id = cont['data-contestid']
|
|
67
|
+
tds = cont.find_all('td')
|
|
68
|
+
start = tds[2].a.span.string.strip()
|
|
69
|
+
start = "\n".join(start.split())
|
|
70
|
+
|
|
71
|
+
last = tds[5].contents
|
|
72
|
+
|
|
73
|
+
if len(last) == 3:
|
|
74
|
+
last = last[0].strip() + "\n[#9c9388]" + (last[1].string or last[1].contents[1]).strip() + "[/]"
|
|
75
|
+
else:
|
|
76
|
+
last = f"[blue link=https://codeforces.com/contestRegistrants/{_id}]{last[3].contents[1].strip()}\n[/]"
|
|
77
|
+
last += "Until Closing\n"
|
|
78
|
+
last += f"[{colors['gray']}]{tds[5].contents[5].span.string.strip()}[/]"
|
|
79
|
+
|
|
80
|
+
table.add_row(
|
|
81
|
+
str(_id),
|
|
82
|
+
f"[link=https://codeforces.com/contests/{_id}]{(tds[0].string or tds[0].contents[0]).strip()}[/]",
|
|
83
|
+
"\n".join([format_writer(e) or "" for e in tds[1].find_all('a')]),
|
|
84
|
+
f"[blue link={tds[2].a['href']}]{start}[/]",
|
|
85
|
+
tds[3].string.strip(),
|
|
86
|
+
(tds[4].contents[0].strip() or "Running") + f"\n[{colors['gray']}]" + tds[4].span.string.strip() + "[/]",
|
|
87
|
+
last
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
console.print("\n\n", table)
|
|
91
|
+
console.print("[bold green]NOTE:[/] Use `cf contests ID` to view problems of an ongoing contest.\nAnd use `cf parse ID` to parse them.\n")
|
|
92
|
+
else:
|
|
93
|
+
console.log("Fetching contest details...")
|
|
94
|
+
r = client.session.get(f"https://codeforces.com/contest/{_id}")
|
|
95
|
+
if r.status_code != 200:
|
|
96
|
+
console.print(f"[bold red]ERROR:[/] Status Code: {r.status_code}")
|
|
97
|
+
return
|
|
98
|
+
if len(r.history) > 0:
|
|
99
|
+
console.print("[bold red]ERROR:[/] The contest has not started yet OR it doesn't exist.")
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
soup = BeautifulSoup(r.text, "html.parser")
|
|
103
|
+
p_tables = soup.find_all("table", {"class": "problems"})
|
|
104
|
+
if not p_tables:
|
|
105
|
+
console.print("[bold red]ERROR:[/] Unable to parse problems table.")
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
problems = p_tables[0].find_all('tr')[1:]
|
|
109
|
+
contest = soup.find_all("table", {"class": "rtable"})[0].find_all('tr')
|
|
110
|
+
contest_name = contest[0].th.a.string.strip()
|
|
111
|
+
contest_time = contest[1].td.span.string.strip()
|
|
112
|
+
table = Table(title=f"{contest_name} - {contest_time}", show_lines=True)
|
|
113
|
+
|
|
114
|
+
table.add_column("#", justify="center")
|
|
115
|
+
table.add_column("Name", justify="left")
|
|
116
|
+
table.add_column(" ", justify="center")
|
|
117
|
+
table.add_column(" ", justify="center")
|
|
118
|
+
|
|
119
|
+
for problem in problems:
|
|
120
|
+
kwargs = {}
|
|
121
|
+
if "accepted-problem" in (problem.get('class') or []):
|
|
122
|
+
kwargs['style'] = Style(bgcolor="#00ff00", color="#000000")
|
|
123
|
+
elif "rejected-problem" in (problem.get('class') or []):
|
|
124
|
+
kwargs['style'] = Style(bgcolor="red", color="#000000")
|
|
125
|
+
items = problem.find_all('td')
|
|
126
|
+
problem_name = items[1].find('a').contents[1].strip()
|
|
127
|
+
problem_details = items[1].find('div', {'class': 'notice'}).contents
|
|
128
|
+
table.add_row(
|
|
129
|
+
items[0].a.string.strip(),
|
|
130
|
+
f"[link=https://codeforces.com/contest/{_id}/problem/{items[0].a.string.strip()}]{problem_name}[/]",
|
|
131
|
+
f"[{colors['gray']}]" + problem_details[1].string.strip() + "\n" + problem_details[2].strip() + "[/]",
|
|
132
|
+
f"[blue link=https://codeforces.com/contest/{_id}/status/{items[0].a.string.strip()}]{items[3].a.contents[1].strip()}[/]",
|
|
133
|
+
**kwargs
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
console.print("\n\n", table)
|
|
137
|
+
console.print(f"\n[bold green]NOTE:[/] Use `cf parse {_id}` to parse all the problems and solve them from your terminal.\n\n")
|
cf/edit.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from utils import get_config
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
editor_cmds = {
|
|
10
|
+
"vscode": "code {path}",
|
|
11
|
+
"neovim": "nvim {path}",
|
|
12
|
+
"vim": "vim {path}",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command(name="edit")
|
|
17
|
+
@click.argument("contest_id", required=True)
|
|
18
|
+
@click.option("--editor", type=click.Choice(["vscode", "neovim", "vim"]), prompt="Select an editor: ")
|
|
19
|
+
def edit_cmd(contest_id: str, editor: str):
|
|
20
|
+
"""
|
|
21
|
+
Open a contest directory in an editor. (3 supported)
|
|
22
|
+
"""
|
|
23
|
+
conf = get_config(console)
|
|
24
|
+
if conf is None:
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
cf_dir = conf.get("dir")
|
|
28
|
+
if cf_dir is None:
|
|
29
|
+
console.print("[bold red]ERROR: [/]The default directory is not set.\nPlease run the `cf config` command.")
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
if not os.path.isdir(cf_dir):
|
|
33
|
+
console.print("[bold red]ERROR: [/]The default directory does not exist.\nPlease run the `cf config` command.")
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
contest_dir = os.path.join(cf_dir, contest_id)
|
|
37
|
+
if not os.path.isdir(contest_dir):
|
|
38
|
+
console.print(f"\n[bold red]ERROR: [/]The contest directory `{contest_dir}` does not exist.")
|
|
39
|
+
console.print(f"[bold green]TIP: [/]Use `cf parse {contest_id}` to create the directory and parse the contest.")
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
subprocess.run(editor_cmds[editor].format(path=os.path.join(cf_dir, contest_id)), shell=True)
|
cf/parse.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import os
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from bs4 import BeautifulSoup
|
|
5
|
+
from utils import get_config, get_bp, CFClient
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
# Global client to be initialized once
|
|
10
|
+
_client = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_client(username: str) -> CFClient:
|
|
14
|
+
global _client
|
|
15
|
+
if _client is None:
|
|
16
|
+
_client = CFClient(username)
|
|
17
|
+
_client.login()
|
|
18
|
+
return _client
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_problem(contest_id: int, problem: str, cf_dir: str, client: CFClient, print_info: bool = True, bp: str = "_"):
|
|
22
|
+
r = client.session.get(url=f"https://codeforces.com/contest/{contest_id}/problem/{problem}")
|
|
23
|
+
if len(r.history) > 0:
|
|
24
|
+
console.print("[bold red]ERROR:[/] Contest or problem not found OR Contest has not started yet.")
|
|
25
|
+
return
|
|
26
|
+
if r.status_code != 200:
|
|
27
|
+
console.print("[bold red]ERROR: [/]Unable to fetch problem details.")
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
contest_dir = os.path.join(cf_dir, str(contest_id))
|
|
31
|
+
|
|
32
|
+
if not os.path.isdir(contest_dir):
|
|
33
|
+
os.mkdir(contest_dir)
|
|
34
|
+
console.print(f"[bold green]INFO: [/]Created directory: `{contest_id}`")
|
|
35
|
+
|
|
36
|
+
soup = BeautifulSoup(r.text, "html.parser")
|
|
37
|
+
tests = soup.find('div', {"class": "sample-test"})
|
|
38
|
+
|
|
39
|
+
inputs = tests.find_all('div', {'class': 'input'}) # type: ignore
|
|
40
|
+
outputs = tests.find_all('div', {'class': 'output'}) # type: ignore
|
|
41
|
+
|
|
42
|
+
final_inps = []
|
|
43
|
+
final_outs = []
|
|
44
|
+
|
|
45
|
+
for inp in inputs:
|
|
46
|
+
final_inps.append("\n".join(e.strip() if type(e) == str else e.string.strip() for e in inp.find('pre').contents if not (type(e) != str and e.string is None)))
|
|
47
|
+
|
|
48
|
+
for out in outputs:
|
|
49
|
+
final_outs.append("\n".join(e.strip() if type(e) == str else e.string.strip() for e in out.find('pre').contents if not (type(e) != str and e.string is None)))
|
|
50
|
+
|
|
51
|
+
for i in range(len(final_inps)):
|
|
52
|
+
console.print(f"[bold green]INFO: [/]Parsing sample test case #{i + 1}...")
|
|
53
|
+
inp = final_inps[i]
|
|
54
|
+
out = final_outs[i]
|
|
55
|
+
|
|
56
|
+
input_file = os.path.join(contest_dir, f"{problem}.{i}.input.test")
|
|
57
|
+
with open(input_file, "w") as f:
|
|
58
|
+
f.write(inp)
|
|
59
|
+
|
|
60
|
+
output_file = os.path.join(contest_dir, f"{problem}.{i}.output.test")
|
|
61
|
+
with open(output_file, "w") as f:
|
|
62
|
+
f.write(out)
|
|
63
|
+
|
|
64
|
+
if bp != "_":
|
|
65
|
+
bp_text = get_bp(bp)
|
|
66
|
+
if bp_text is None:
|
|
67
|
+
console.print(f"[bold red]ERROR: [/]No boilerplate file found for `{bp}`.")
|
|
68
|
+
else:
|
|
69
|
+
bp_file = os.path.join(contest_dir, f"{problem}.{bp}")
|
|
70
|
+
with open(bp_file, "w") as f:
|
|
71
|
+
f.write(bp_text)
|
|
72
|
+
console.print(f"[bold green]INFO: [/]Created boilerplate `{problem}.{bp}` file.")
|
|
73
|
+
|
|
74
|
+
console.print(f"[bold green]Problem {contest_id} {problem} parsed successfully.[/]\n")
|
|
75
|
+
if print_info:
|
|
76
|
+
console.print(f"Use `cd {contest_dir}` to move the contest directory.")
|
|
77
|
+
console.print("Then use `cf run FILENAME` to check the sample test cases.\n")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@click.command()
|
|
81
|
+
@click.argument("contest_id", required=True)
|
|
82
|
+
@click.argument("problem", default="_", required=False)
|
|
83
|
+
@click.option("--lang", required=False, default="_")
|
|
84
|
+
def parse(contest_id: int, problem: str, lang: str):
|
|
85
|
+
"""
|
|
86
|
+
Parse the sample test cases for a problem OR a contest.
|
|
87
|
+
"""
|
|
88
|
+
problem = problem.lower()
|
|
89
|
+
data = get_config(console)
|
|
90
|
+
if data is None:
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
cf_dir = data.get("dir")
|
|
94
|
+
if cf_dir is None:
|
|
95
|
+
console.print("[bold red]ERROR: [/]The default directory for parsing is not set.\nPlease run the `cf config` command.")
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
if "username" not in data:
|
|
99
|
+
console.print("[bold red]ERROR: [/]Username not set. Please use `cf config`.\n")
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
client = get_client(data["username"])
|
|
103
|
+
|
|
104
|
+
if problem == "_":
|
|
105
|
+
r = client.session.get(url=f"https://codeforces.com/contest/{contest_id}")
|
|
106
|
+
if len(r.history) > 0:
|
|
107
|
+
console.print("[bold red]ERROR: [/]Contest has not started yet OR it doesn't exist.\n")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
if r.status_code != 200:
|
|
111
|
+
console.print(f"[bold red]ERROR: [/]Unable to fetch contest details.\nSTATUS CODE: [bold red]{r.status_code}[/]\n")
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
soup = BeautifulSoup(r.text, "html.parser")
|
|
115
|
+
p_tables = soup.find_all("table", {"class": "problems"})
|
|
116
|
+
if not p_tables:
|
|
117
|
+
console.print("[bold red]ERROR:[/] Unable to parse problems table.")
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
problems = p_tables[0].find_all('tr')[1:]
|
|
121
|
+
for i, p in enumerate(problems):
|
|
122
|
+
items = p.find_all('td')
|
|
123
|
+
p_id = items[0].a.string.strip().lower()
|
|
124
|
+
parse_problem(contest_id, p_id, cf_dir, client, print_info=(i == len(problems) - 1), bp=lang)
|
|
125
|
+
else:
|
|
126
|
+
parse_problem(contest_id, problem, cf_dir, client, bp=lang)
|
cf/run.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import time
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from utils import get_config
|
|
7
|
+
from typing import Optional, List, Union
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_cmd(ext: str, file: str) -> Optional[Union[str, List[str]]]:
|
|
13
|
+
is_linux = os.name == "posix"
|
|
14
|
+
if ext == "py":
|
|
15
|
+
return f"python3 {file}" if is_linux else f"py {file}"
|
|
16
|
+
elif ext == "cpp":
|
|
17
|
+
return [f"g++ {file}", ("./a.out" if is_linux else "a.exe")]
|
|
18
|
+
elif ext == "c":
|
|
19
|
+
return [f"gcc {file}", ("./a.out" if is_linux else "a.exe")]
|
|
20
|
+
else:
|
|
21
|
+
console.print("[bold red]ERROR: [/]The file extension is not supported.\n")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_available_problems(directory: str) -> list:
|
|
25
|
+
"""Get list of available problem IDs from test files."""
|
|
26
|
+
problems = set()
|
|
27
|
+
for f in os.listdir(directory):
|
|
28
|
+
if f.endswith(".input.test"):
|
|
29
|
+
# Format: {problem}.{num}.input.test
|
|
30
|
+
parts = f.split(".")
|
|
31
|
+
if len(parts) >= 3:
|
|
32
|
+
problems.add(parts[0])
|
|
33
|
+
return sorted(problems)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@click.command()
|
|
37
|
+
@click.argument("file", required=True)
|
|
38
|
+
@click.option("-p", "--problem", default=None, help="Problem ID (e.g., a, b, c)")
|
|
39
|
+
def run(file: str, problem: str):
|
|
40
|
+
"""
|
|
41
|
+
Check the sample test cases for a problem.
|
|
42
|
+
"""
|
|
43
|
+
slash = "/" if os.name == "posix" else "\\"
|
|
44
|
+
|
|
45
|
+
data = get_config(console)
|
|
46
|
+
if data is None:
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
cf_dir = data.get("dir")
|
|
50
|
+
if cf_dir is None:
|
|
51
|
+
console.print("[bold red]ERROR: [/]The default directory for parsing is not set.\nPlease run the `cf config` command.")
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
cf_dir = os.path.abspath(cf_dir)
|
|
55
|
+
current_dir = os.getcwd()
|
|
56
|
+
if not current_dir.startswith(cf_dir) and current_dir != cf_dir:
|
|
57
|
+
console.print("[bold red]ERROR: [/]The current directory is not a contest directory.\n")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
c_id = current_dir.split(slash)[-1]
|
|
61
|
+
if not c_id.isdigit():
|
|
62
|
+
console.print("[bold red]ERROR: [/]The current directory is not a contest directory.\n")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
if not os.path.isfile(file):
|
|
66
|
+
console.print("[bold red]ERROR: [/]The file does not exist.\n")
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
# Get just the filename without path
|
|
70
|
+
filename = os.path.basename(file)
|
|
71
|
+
p_ext = filename.split(".")[-1]
|
|
72
|
+
|
|
73
|
+
# Determine problem ID
|
|
74
|
+
if problem:
|
|
75
|
+
p_id = problem.lower()
|
|
76
|
+
else:
|
|
77
|
+
# Try to get from filename (e.g., "a.py" -> "a")
|
|
78
|
+
p_id = filename.split(".")[0].lower()
|
|
79
|
+
|
|
80
|
+
available_problems = get_available_problems(current_dir)
|
|
81
|
+
|
|
82
|
+
# Check if p_id matches exactly one problem
|
|
83
|
+
if p_id not in available_problems:
|
|
84
|
+
if len(available_problems) == 0:
|
|
85
|
+
console.print("[bold red]ERROR: [/]No test cases found in this directory. Run `cf parse` first.\n")
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
console.print(f"[bold yellow]WARNING: [/]No test cases found for problem '{p_id}'.")
|
|
89
|
+
console.print(f"[bold blue]Available problems: [/]{', '.join(available_problems)}\n")
|
|
90
|
+
p_id = click.prompt("Enter problem ID", type=click.Choice(available_problems, case_sensitive=False)).lower()
|
|
91
|
+
|
|
92
|
+
all_inputs = sorted([f for f in os.listdir(current_dir) if f.endswith(".input.test") and f.split(".")[0] == p_id])
|
|
93
|
+
all_outputs = sorted([f for f in os.listdir(current_dir) if f.endswith(".output.test") and f.split(".")[0] == p_id])
|
|
94
|
+
|
|
95
|
+
total_passed = 0
|
|
96
|
+
|
|
97
|
+
console.print(f"[bold blue]INFO: [/]Checking {len(all_inputs)} testcase(s)...\n")
|
|
98
|
+
for i in range(len(all_inputs)):
|
|
99
|
+
inp = all_inputs[i]
|
|
100
|
+
out = all_outputs[i]
|
|
101
|
+
|
|
102
|
+
cmd = run_cmd(p_ext, file)
|
|
103
|
+
if cmd is None:
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
if type(cmd) == list:
|
|
107
|
+
res = subprocess.run(cmd[0].split())
|
|
108
|
+
if res.returncode != 0:
|
|
109
|
+
console.print(f"[bold red]COMPILATION ERROR[/] ON TEST CASE {i + 1}\n")
|
|
110
|
+
continue
|
|
111
|
+
cmd = cmd[1]
|
|
112
|
+
|
|
113
|
+
if type(cmd) == str:
|
|
114
|
+
t1 = time.perf_counter() * 1000
|
|
115
|
+
try:
|
|
116
|
+
with open(inp) as f:
|
|
117
|
+
# BUG: for problems that have multiple inputs, this can confuse the user
|
|
118
|
+
# however, a good competitive programmar won't be confused by this :)
|
|
119
|
+
res = subprocess.run(cmd.split(), input=f.read().strip(), capture_output=True, text=True, timeout=10) # type: ignore
|
|
120
|
+
except subprocess.TimeoutExpired:
|
|
121
|
+
console.print(f"[bold red]DEFAULT TIME LIMIT EXCEEDED (10 seconds)[/] ON TEST CASE {i + 1}\n")
|
|
122
|
+
continue
|
|
123
|
+
t2 = time.perf_counter() * 1000
|
|
124
|
+
|
|
125
|
+
if res.returncode != 0:
|
|
126
|
+
console.print(f"[bold red]RUNTIME ERROR[/] ON TEST CASE {i + 1}: {res.stderr}\n")
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
with open(out) as f:
|
|
130
|
+
if res.stdout.strip() == f.read().strip():
|
|
131
|
+
console.print(f"[bold green]PASSED[/] ON TEST CASE {i + 1}: {t2 - t1:.2f}ms\n")
|
|
132
|
+
total_passed += 1
|
|
133
|
+
else:
|
|
134
|
+
console.print(f"[bold red]FAILED[/] ON TEST CASE {i + 1}\n")
|
|
135
|
+
console.print(f"\nYour Output:\n{res.stdout.strip()}\n\nExpected Output:\n{open(out).read().strip()}\n")
|
|
136
|
+
|
|
137
|
+
console.print(f"[bold blue]INFO: [/]Passed {total_passed}/{len(all_inputs)} testcases.\n")
|
|
138
|
+
if total_passed == len(all_inputs):
|
|
139
|
+
console.print("[bold green]SUCCESS: [/]All testcases passed!\nYou can submit using the `cf submit` command.\n")
|
cf/submit.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import time
|
|
3
|
+
import json
|
|
4
|
+
import websocket
|
|
5
|
+
import os
|
|
6
|
+
from utils import get_config, CFClient
|
|
7
|
+
from bs4 import BeautifulSoup
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.live import Live
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
lang_ids = {
|
|
15
|
+
"py": "70",
|
|
16
|
+
"c": "43",
|
|
17
|
+
"cpp": "73",
|
|
18
|
+
"cs": "79", # C#
|
|
19
|
+
"d": "28", # D
|
|
20
|
+
"go": "32", # Golang
|
|
21
|
+
"hs": "12", # Haskell
|
|
22
|
+
"java": "74",
|
|
23
|
+
"kt": "83", # Kotlin
|
|
24
|
+
"ml": "19", # Ocaml
|
|
25
|
+
"php": "6",
|
|
26
|
+
"rb": "67", # Ruby
|
|
27
|
+
"rs": "75", # Rust
|
|
28
|
+
"js": "55", # Nodejs
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@click.command()
|
|
33
|
+
@click.argument("file", required=True)
|
|
34
|
+
def submit(file: str):
|
|
35
|
+
"""
|
|
36
|
+
Submits your solution
|
|
37
|
+
"""
|
|
38
|
+
slash = "/" if os.name == "posix" else "\\"
|
|
39
|
+
|
|
40
|
+
data = get_config(console)
|
|
41
|
+
if data is None:
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
cf_dir = data.get("dir")
|
|
45
|
+
if cf_dir is None:
|
|
46
|
+
console.print("[bold red]ERROR: [/]The default directory for parsing is not set.\nPlease run the `cf config` command.")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
current_dir = os.getcwd()
|
|
50
|
+
cf_dir = os.path.abspath(cf_dir)
|
|
51
|
+
if not current_dir.startswith(cf_dir) and current_dir != cf_dir:
|
|
52
|
+
console.print("[bold red]ERROR: [/]The current directory is not a contest directory.\n")
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
c_id = current_dir.split(slash)[-1]
|
|
56
|
+
if not c_id.isdigit():
|
|
57
|
+
console.print("[bold red]ERROR: [/]The current directory is not a contest directory.\n")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
if not os.path.isfile(file):
|
|
61
|
+
console.print("[bold red]ERROR: [/]The file does not exist.\n")
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
p_id = file.split(".")[0].lower()
|
|
65
|
+
p_ext = file.split(".")[-1]
|
|
66
|
+
|
|
67
|
+
if p_ext not in lang_ids:
|
|
68
|
+
console.print("[bold red]ERROR: [/]The file extension is not supported.\n")
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
if "username" not in data:
|
|
72
|
+
console.print("[bold red]ERROR: [/]Username not set. Please use `cf config`.\n")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
clnt = CFClient(data['username'])
|
|
76
|
+
if not clnt.login():
|
|
77
|
+
# login() already printed why it failed.
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
url = f"https://codeforces.com/contest/{c_id}/submit"
|
|
81
|
+
csrf = clnt.get_csrf(url)
|
|
82
|
+
url += f"?csrf_token={csrf}"
|
|
83
|
+
with open(file, "r") as f:
|
|
84
|
+
source_code = f.read()
|
|
85
|
+
resp = clnt.session.post(url=url, allow_redirects=True, data={
|
|
86
|
+
"csrf_token": csrf,
|
|
87
|
+
"ftaa": "",
|
|
88
|
+
"bfaa": "",
|
|
89
|
+
"action": "submitSolutionFormSubmitted",
|
|
90
|
+
"submittedProblemIndex": p_id,
|
|
91
|
+
"programTypeId": lang_ids[p_ext],
|
|
92
|
+
"contestId": c_id,
|
|
93
|
+
"source": source_code,
|
|
94
|
+
"tabSize": "4",
|
|
95
|
+
"sourceCodeConfirmed": "true",
|
|
96
|
+
})
|
|
97
|
+
if not resp.url.startswith(f"https://codeforces.com/contest/{c_id}/my"):
|
|
98
|
+
console.print("[bold red]ERROR: [/] Submission failed.")
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
r = clnt.session.get(f"https://codeforces.com/contest/{c_id}/my")
|
|
102
|
+
soup = BeautifulSoup(r.text, "html.parser")
|
|
103
|
+
|
|
104
|
+
table = soup.find('table', {'class': 'status-frame-datatable'})
|
|
105
|
+
last_sub = table.find_all('tr')[1] # type: ignore
|
|
106
|
+
sub_id = int(last_sub['data-submission-id'])
|
|
107
|
+
sub_status = last_sub.find('td', {'class': 'status-verdict-cell'})
|
|
108
|
+
if sub_status['waiting'] == "true":
|
|
109
|
+
sub_status = "In Queue"
|
|
110
|
+
else:
|
|
111
|
+
sub_status = "IDK"
|
|
112
|
+
sub_time = last_sub.find('td', {'class': 'time-consumed-cell'}).string
|
|
113
|
+
sub_mem = last_sub.find('td', {'class': 'memory-consumed-cell'}).string
|
|
114
|
+
|
|
115
|
+
console.print(f"[bold green]SUBMITTED[/] [bold blue]https://codeforces.com/contest/{c_id}/submission/{sub_id}[/]")
|
|
116
|
+
live_text = f"""
|
|
117
|
+
Status: [bold white]{sub_status.strip()}[/]
|
|
118
|
+
Time: [bold white]{sub_time.strip()}[/]
|
|
119
|
+
Memory: [bold white]{sub_mem.strip()}[/]
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
pc = None
|
|
123
|
+
cc = None
|
|
124
|
+
metas = soup.find_all('meta')
|
|
125
|
+
|
|
126
|
+
for meta in metas:
|
|
127
|
+
if meta.get('name') == 'pc':
|
|
128
|
+
pc = meta.get('content')
|
|
129
|
+
elif meta.get('name') == 'cc':
|
|
130
|
+
cc = meta.get('content')
|
|
131
|
+
|
|
132
|
+
sub_watcher = websocket.WebSocket()
|
|
133
|
+
sub_watcher.connect(f"wss://pubsub.codeforces.com/ws/s_{pc}/s_{cc}?_={int(time.time())}")
|
|
134
|
+
|
|
135
|
+
live = Live(live_text, console=console)
|
|
136
|
+
live.start()
|
|
137
|
+
live.refresh()
|
|
138
|
+
while (True):
|
|
139
|
+
sub = json.loads(sub_watcher.recv())
|
|
140
|
+
sub_data = json.loads(sub['text'])['d']
|
|
141
|
+
live_sub_id = sub_data[1]
|
|
142
|
+
if live_sub_id == sub_id:
|
|
143
|
+
status = sub_data[6].strip()
|
|
144
|
+
test_case = sub_data[8]
|
|
145
|
+
|
|
146
|
+
if status == "OK":
|
|
147
|
+
status_text = "[bold green]ACCEPTED[/]"
|
|
148
|
+
elif status == "TESTING":
|
|
149
|
+
status_text = f"[bold]Running on test case: {test_case}[/]"
|
|
150
|
+
else:
|
|
151
|
+
status_text = f"[bold red]{' '.join(status.split('_'))}[/] on test case: {test_case}"
|
|
152
|
+
|
|
153
|
+
timee = sub_data[9]
|
|
154
|
+
memory = int(sub_data[10]) // 1000
|
|
155
|
+
live_text = f"""
|
|
156
|
+
Status: {status_text}
|
|
157
|
+
Time: [bold]{timee} ms[/]
|
|
158
|
+
Memory: [bold]{memory} KB[/]
|
|
159
|
+
"""
|
|
160
|
+
live.update(live_text)
|
|
161
|
+
live.refresh()
|
|
162
|
+
if status != "TESTING":
|
|
163
|
+
live.stop()
|
|
164
|
+
sub_watcher.close()
|
|
165
|
+
break
|
cf/unsolved.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from bs4 import BeautifulSoup
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from utils import CFClient, get_config
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command()
|
|
11
|
+
def unsolved():
|
|
12
|
+
"""Show unsolved problems"""
|
|
13
|
+
conf = get_config(console)
|
|
14
|
+
if conf is None:
|
|
15
|
+
return
|
|
16
|
+
|
|
17
|
+
if "username" not in conf:
|
|
18
|
+
console.print("[bold red]ERROR: [/]Username not found in config file. Please run `cf config`.")
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
client = CFClient(conf["username"])
|
|
22
|
+
if not client.login():
|
|
23
|
+
# login() already printed why it failed.
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
ps = client.session.get("https://codeforces.com/problemset")
|
|
27
|
+
if ps.status_code != 200:
|
|
28
|
+
console.print("[bold red]ERROR: [/]Failed to fetch unsolved problems.")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
soup = BeautifulSoup(ps.text, "html.parser")
|
|
32
|
+
unsolved_table = soup.find('table', {'class': 'rtable'})
|
|
33
|
+
if unsolved_table is None:
|
|
34
|
+
console.print("[bold red]ERROR: [/]Failed to fetch unsolved problems.")
|
|
35
|
+
return
|
|
36
|
+
problems = unsolved_table.find_all('tr')[1:] # type: ignore
|
|
37
|
+
|
|
38
|
+
if len(problems) == 0:
|
|
39
|
+
console.print("[bold green]WOW: [/]You do not have any unsolved problems.")
|
|
40
|
+
console.print("(This means any problem where you have submitted a solution but it was not accepted.)")
|
|
41
|
+
console.print("Obviously you will have several problems that you haven't tried.")
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
table = Table(title="Unsolved Problems", show_header=True, header_style="bold green", show_lines=True)
|
|
45
|
+
table.add_column("Problem ID", style="bright", justify="left", no_wrap=True)
|
|
46
|
+
table.add_column("Problem Name", style="bright", justify="left", no_wrap=True)
|
|
47
|
+
table.add_column("Last Submission", style="bright", justify="left", no_wrap=True)
|
|
48
|
+
for problem in problems:
|
|
49
|
+
data = problem.find_all('td')
|
|
50
|
+
_id = data[0].a.string.strip()
|
|
51
|
+
p_url = data[0].a['href'].strip()
|
|
52
|
+
name = data[1].a.string.strip()
|
|
53
|
+
sub_id = data[2].a.string.strip()
|
|
54
|
+
|
|
55
|
+
table.add_row(
|
|
56
|
+
f"[link=https://codeforces.com{p_url}]{_id}[/]", name,
|
|
57
|
+
f"[link=https://codeforces.com{data[2].a['href'].strip()}]{sub_id}[/]"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
console.print(table)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: codeforces
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: A simple command line tool to move your competitive programming workflow to your terminal.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Nirlep5252/codeforces-cli
|
|
6
|
+
Author: Nirlep5252
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: cli,codeforces,competitive programming,terminal
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Requires-Dist: beautifulsoup4>=4.9
|
|
19
|
+
Requires-Dist: click>=7.0
|
|
20
|
+
Requires-Dist: requests>=2.20
|
|
21
|
+
Requires-Dist: rich>=10.0
|
|
22
|
+
Requires-Dist: undetected-chromedriver>=3.5.5
|
|
23
|
+
Requires-Dist: websocket-client>=1.0
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# Codeforces CLI [WORK IN PROGRESS]
|
|
27
|
+
|
|
28
|
+
A simple command line tool to move your competitive programming workflow to your terminal.
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
### How to install?
|
|
33
|
+
|
|
34
|
+
#### 1. Install using pip
|
|
35
|
+
```
|
|
36
|
+
$ pip install -U codeforces
|
|
37
|
+
$ cf --help
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
#### 2. Install from source (using uv)
|
|
41
|
+
```
|
|
42
|
+
$ git clone https://github.com/Nirlep5252/codeforces-cli
|
|
43
|
+
$ cd ./codeforces-cli
|
|
44
|
+
$ uv sync
|
|
45
|
+
$ uv run cf --help
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Note:** Chrome browser is required for authentication.
|
|
49
|
+
|
|
50
|
+
Python 3.9 through 3.14 are supported. Newer versions probably work but are untested, and the CLI warns you when it sees one.
|
|
51
|
+
|
|
52
|
+
#### Authentication Note
|
|
53
|
+
|
|
54
|
+
Due to Cloudflare protection on Codeforces, authentication requires opening a browser window once. When you run `cf config` or first use `cf submit`, a browser will open for you to login. After successful login, your session is saved and subsequent commands will work without opening the browser again (until the session expires).
|
|
55
|
+
|
|
56
|
+
#### Current commands:
|
|
57
|
+
|
|
58
|
+
`cf config` - save your username and problems-directory (opens browser for login) \
|
|
59
|
+
`cf contests` - list all the current or upcoming contests \
|
|
60
|
+
`cf contests {ID}` - view all the problems of an ongoing contest \
|
|
61
|
+
`cf parse {Contest ID} {Problem ID | Optional} {--lang | Optional}` - parse the problem and its test cases \
|
|
62
|
+
`cf run {FILE}` - check the test cases for the current problem (works based on current directory) \
|
|
63
|
+
`cf submit {FILE}` - submit the problem (requires config) (works based on current directory) \
|
|
64
|
+
`cf unsolved` - return the list of all your unsolved problems \
|
|
65
|
+
`cf edit {CONTEST ID}` - open the contest folder in the editor of choice (only 3 supported so far)
|
|
66
|
+
|
|
67
|
+
#### TODO commands:
|
|
68
|
+
|
|
69
|
+
`cf standings {Contest ID | Optional}` - show all the standings of an ongoing of finished contests \
|
|
70
|
+
`cf suggest` - suggest a problem based on your current rating
|
|
71
|
+
|
|
72
|
+
#### TODO features:
|
|
73
|
+
|
|
74
|
+
- [ ] Support all languages in run
|
|
75
|
+
- [ ] A problem recommendation system, maybe?
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
cf/__init__.py,sha256=dyX_dvwy5rAK9iOctyMDHolMFaNT7SFoDowt-83ZaWA,2868
|
|
2
|
+
cf/config.py,sha256=Zg1TJoYLHYHzp4vqLwM591IF3AqEA8lUHrp1U4MWb4o,1089
|
|
3
|
+
cf/contests.py,sha256=1vidzLLrgHZ9Rg_1M08DEcpibE4qKCMKQHpau3I2nJ8,5582
|
|
4
|
+
cf/edit.py,sha256=K0q1MaDTFawH48iuu7q83qjLyVFDV99PpBa6KUepZ7U,1370
|
|
5
|
+
cf/parse.py,sha256=b3GA4NgryV2AbvaA9mK7nT2nUG6pqhYqIYU-gboQivY,4731
|
|
6
|
+
cf/run.py,sha256=Teq3C9SQaNTfhavb2z-SPeoAIC3lgxhDBX-3dzq2Pyg,5389
|
|
7
|
+
cf/submit.py,sha256=IIThz06Pdvv667HjXrd61d_xBkc3if4eXgywPg1FjsQ,5075
|
|
8
|
+
cf/unsolved.py,sha256=KO6TpyWuc7URnWfJbpAXtfcyGdqYjs8lvvgnLy74wA4,2202
|
|
9
|
+
utils.py,sha256=6LQDfwgGKdjXvk7BFIuxm9h9kd65LKcZQD3C0FH_fnk,8748
|
|
10
|
+
codeforces-0.1.2.dist-info/METADATA,sha256=TUpfQXCVg5r7Hy1cfjZ0mdxBif3aMldBBS0_EEpNEyk,2948
|
|
11
|
+
codeforces-0.1.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
12
|
+
codeforces-0.1.2.dist-info/entry_points.txt,sha256=vn6DktheP1e5YraK9l7X1q7CQIrf1E2y7ZqU3EYblxk,35
|
|
13
|
+
codeforces-0.1.2.dist-info/RECORD,,
|
utils.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
import types
|
|
6
|
+
import requests
|
|
7
|
+
from bs4 import BeautifulSoup
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _ensure_distutils_version() -> None:
|
|
13
|
+
"""
|
|
14
|
+
Register a minimal `distutils.version` module when the stdlib one is gone.
|
|
15
|
+
|
|
16
|
+
`undetected_chromedriver.patcher` does `from distutils.version import
|
|
17
|
+
LooseVersion`, and distutils was removed from the stdlib in Python 3.12.
|
|
18
|
+
Having setuptools installed also papers over this, but it is not a
|
|
19
|
+
dependency of undetected-chromedriver, so ship our own shim instead.
|
|
20
|
+
"""
|
|
21
|
+
try:
|
|
22
|
+
import distutils.version # noqa: F401
|
|
23
|
+
return
|
|
24
|
+
except ImportError:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
class LooseVersion:
|
|
28
|
+
"""Port of the distutils class, only what the patcher actually uses."""
|
|
29
|
+
|
|
30
|
+
component_re = re.compile(r"(\d+ | [a-z]+ | \.)", re.VERBOSE)
|
|
31
|
+
|
|
32
|
+
def __init__(self, vstring: Optional[str] = None):
|
|
33
|
+
self.vstring = ""
|
|
34
|
+
self.version = []
|
|
35
|
+
if vstring:
|
|
36
|
+
self.parse(vstring)
|
|
37
|
+
|
|
38
|
+
def parse(self, vstring: str) -> None:
|
|
39
|
+
self.vstring = vstring
|
|
40
|
+
components = [c for c in self.component_re.split(vstring) if c and c != "."]
|
|
41
|
+
for i, component in enumerate(components):
|
|
42
|
+
try:
|
|
43
|
+
components[i] = int(component)
|
|
44
|
+
except ValueError:
|
|
45
|
+
pass
|
|
46
|
+
self.version = components
|
|
47
|
+
|
|
48
|
+
def __str__(self):
|
|
49
|
+
return self.vstring
|
|
50
|
+
|
|
51
|
+
def __repr__(self):
|
|
52
|
+
return "LooseVersion ('%s')" % str(self)
|
|
53
|
+
|
|
54
|
+
def _key(self, other):
|
|
55
|
+
if isinstance(other, str):
|
|
56
|
+
return LooseVersion(other).version
|
|
57
|
+
if isinstance(other, LooseVersion):
|
|
58
|
+
return other.version
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def __eq__(self, other):
|
|
62
|
+
key = self._key(other)
|
|
63
|
+
return NotImplemented if key is None else self.version == key
|
|
64
|
+
|
|
65
|
+
def __lt__(self, other):
|
|
66
|
+
key = self._key(other)
|
|
67
|
+
return NotImplemented if key is None else self.version < key
|
|
68
|
+
|
|
69
|
+
def __le__(self, other):
|
|
70
|
+
key = self._key(other)
|
|
71
|
+
return NotImplemented if key is None else self.version <= key
|
|
72
|
+
|
|
73
|
+
def __gt__(self, other):
|
|
74
|
+
key = self._key(other)
|
|
75
|
+
return NotImplemented if key is None else self.version > key
|
|
76
|
+
|
|
77
|
+
def __ge__(self, other):
|
|
78
|
+
key = self._key(other)
|
|
79
|
+
return NotImplemented if key is None else self.version >= key
|
|
80
|
+
|
|
81
|
+
distutils = sys.modules.get("distutils")
|
|
82
|
+
if distutils is None:
|
|
83
|
+
distutils = types.ModuleType("distutils")
|
|
84
|
+
distutils.__path__ = [] # type: ignore[attr-defined]
|
|
85
|
+
sys.modules["distutils"] = distutils
|
|
86
|
+
|
|
87
|
+
version_module = types.ModuleType("distutils.version")
|
|
88
|
+
version_module.LooseVersion = LooseVersion # type: ignore[attr-defined]
|
|
89
|
+
sys.modules["distutils.version"] = version_module
|
|
90
|
+
distutils.version = version_module # type: ignore[attr-defined]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_config(console: Console) -> Optional[dict]:
|
|
94
|
+
config_path = os.path.join(os.path.expanduser("~"), "codeforces.uwu")
|
|
95
|
+
if not config_path:
|
|
96
|
+
console.print("[bold red]ERROR: [/]Config file not found.\nPlease run `cf config`\n")
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
if not os.path.isfile(config_path):
|
|
100
|
+
console.print("[bold red]ERROR: [/]Config file not found.\nPlease run `cf config`\n")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
data = None
|
|
104
|
+
with open(config_path, "r+") as f:
|
|
105
|
+
data = json.loads("".join(f.readlines()))
|
|
106
|
+
|
|
107
|
+
return data
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_bp(lang: str) -> Optional[str]:
|
|
111
|
+
bp_dir = os.path.join(os.path.expanduser("~"), "cf_boilerplates")
|
|
112
|
+
|
|
113
|
+
if not os.path.isdir(bp_dir):
|
|
114
|
+
return
|
|
115
|
+
template_path = os.path.join(bp_dir, "template." + lang)
|
|
116
|
+
if not os.path.isfile(template_path):
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
with open(template_path, "r") as f:
|
|
120
|
+
return f.read()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class CFClient:
|
|
124
|
+
def __init__(self, username: str):
|
|
125
|
+
self.username = username
|
|
126
|
+
self.session = requests.Session()
|
|
127
|
+
self.console = Console()
|
|
128
|
+
|
|
129
|
+
def login(self) -> bool:
|
|
130
|
+
# First, try to use saved cookies
|
|
131
|
+
if self._load_cookies():
|
|
132
|
+
if self._verify_login():
|
|
133
|
+
return True
|
|
134
|
+
# Cookies expired or invalid, need fresh login
|
|
135
|
+
|
|
136
|
+
# Need to open browser for login
|
|
137
|
+
try:
|
|
138
|
+
_ensure_distutils_version()
|
|
139
|
+
import undetected_chromedriver as uc
|
|
140
|
+
except ImportError as e:
|
|
141
|
+
if getattr(e, "name", None) == "undetected_chromedriver":
|
|
142
|
+
self.console.print("[bold red]ERROR:[/] undetected-chromedriver is not installed. Run: pip install undetected-chromedriver")
|
|
143
|
+
else:
|
|
144
|
+
self.console.print("[bold red]ERROR:[/] undetected-chromedriver is installed but could not be imported.")
|
|
145
|
+
self.console.print(f"[dim]Details: {e.__class__.__name__}: {e}[/]")
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
self.console.print("\n[bold cyan]Opening browser for login...[/]")
|
|
149
|
+
self.console.print("[dim]Please login to Codeforces in the browser window that opens.[/]")
|
|
150
|
+
self.console.print("[dim]The browser will close automatically once you're logged in.[/]\n")
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
import time
|
|
154
|
+
|
|
155
|
+
options = uc.ChromeOptions()
|
|
156
|
+
options.add_argument('--no-first-run')
|
|
157
|
+
options.add_argument('--no-service-autorun')
|
|
158
|
+
options.add_argument('--password-store=basic')
|
|
159
|
+
|
|
160
|
+
driver = uc.Chrome(options=options, use_subprocess=True)
|
|
161
|
+
driver.get("https://codeforces.com/enter")
|
|
162
|
+
|
|
163
|
+
# Wait for login to complete by checking for the username in the page
|
|
164
|
+
# Poll every second for up to 5 minutes
|
|
165
|
+
max_wait = 300 # 5 minutes
|
|
166
|
+
for _ in range(max_wait):
|
|
167
|
+
time.sleep(1)
|
|
168
|
+
try:
|
|
169
|
+
content = driver.page_source.lower()
|
|
170
|
+
except Exception:
|
|
171
|
+
continue
|
|
172
|
+
# Check if user is logged in (username appears in header/lang-chooser)
|
|
173
|
+
if self.username.lower() in content and "logout" in content:
|
|
174
|
+
# Extract cookies for requests session
|
|
175
|
+
cookies = driver.get_cookies()
|
|
176
|
+
for cookie in cookies:
|
|
177
|
+
self.session.cookies.set(cookie['name'], cookie['value'], domain=cookie.get('domain', '.codeforces.com'))
|
|
178
|
+
|
|
179
|
+
# Save cookies for future use
|
|
180
|
+
self._save_cookies(cookies)
|
|
181
|
+
|
|
182
|
+
self.console.print("[bold green]Login successful![/]")
|
|
183
|
+
driver.quit()
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
# Timeout
|
|
187
|
+
self.console.print("[bold red]ERROR:[/] Login timed out (5 minutes). Please try again.")
|
|
188
|
+
driver.quit()
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
except Exception as e:
|
|
192
|
+
self.console.print(f"[bold red]ERROR:[/] Login failed.")
|
|
193
|
+
self.console.print(f"[dim]Details: {e}[/dim]")
|
|
194
|
+
if 'driver' in locals():
|
|
195
|
+
driver.quit()
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
def _verify_login(self) -> bool:
|
|
199
|
+
"""Check if current session cookies are valid by making a request."""
|
|
200
|
+
try:
|
|
201
|
+
# Add browser-like headers
|
|
202
|
+
self.session.headers.update({
|
|
203
|
+
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
204
|
+
})
|
|
205
|
+
r = self.session.get("https://codeforces.com", timeout=10)
|
|
206
|
+
return self.username.lower() in r.text.lower() and "logout" in r.text.lower()
|
|
207
|
+
except Exception:
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
def _save_cookies(self, cookies: list) -> None:
|
|
211
|
+
"""Save cookies to config for future authenticated requests."""
|
|
212
|
+
cookie_path = os.path.join(os.path.expanduser("~"), "codeforces.cookies")
|
|
213
|
+
with open(cookie_path, "w") as f:
|
|
214
|
+
json.dump(cookies, f)
|
|
215
|
+
|
|
216
|
+
def _load_cookies(self) -> bool:
|
|
217
|
+
"""Load saved cookies into the session. Returns True if cookies were loaded."""
|
|
218
|
+
cookie_path = os.path.join(os.path.expanduser("~"), "codeforces.cookies")
|
|
219
|
+
|
|
220
|
+
if not os.path.isfile(cookie_path):
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
with open(cookie_path, "r") as f:
|
|
225
|
+
cookies = json.load(f)
|
|
226
|
+
for cookie in cookies:
|
|
227
|
+
self.session.cookies.set(cookie['name'], cookie['value'], domain=cookie.get('domain', '.codeforces.com'))
|
|
228
|
+
return True
|
|
229
|
+
except Exception:
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
def get_csrf(self, url) -> str:
|
|
233
|
+
r = self.session.get(url)
|
|
234
|
+
s = BeautifulSoup(r.text, "html.parser")
|
|
235
|
+
return s.find_all("span", {"class": "csrf-token"})[0]["data-csrf"]
|