candidate 0.1.0__tar.gz

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,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: candidate
3
+ Version: 0.1.0
4
+ Summary: CLI tool to practice Competitive Programming and DSA
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: browser-cookie3
7
+ Requires-Dist: python-dotenv
8
+ Requires-Dist: requests
@@ -0,0 +1,137 @@
1
+ # candidate
2
+
3
+ A CLI tool for competitive programmers to run timed virtual contests using problems from **Codeforces**, **AtCoder**, and **LeetCode**. Problems are pulled from your unsolved pool and tracked in real time as you submit.
4
+
5
+ ---
6
+
7
+ ## Some notable features
8
+
9
+ - **Virtual contests** on Codeforces, AtCoder, LeetCode, or a mixed set
10
+ - Provides **tier-based problem selection**: 50% of problems are drawn from tier 1 (easier) and 50% from tier 2 (harder).
11
+ - The tool polls each platform and detects accepted submissions automatically; the contest ends as soon as all problems are solved.
12
+ - It will also auto-detect your session cookies, the tool reads your browser's stored session for AtCoder and LeetCode automatically (Chrome, Brave, Firefox, Safari, Edge, Opera are supported as of now, make sure you use one of these browsers)
13
+ - It supports **tag filtering** (`dp`, `graphs`). Not available in mixed (`x`) mode yet.
14
+ - You can adjust problems by **difficulty ranges and recency** via a `config.toml` file
15
+
16
+ ---
17
+
18
+ ## Requirements
19
+
20
+ - Python 3.11+
21
+ - For **LeetCode** and **AtCoder**: an account on the respective platform and at least one supported browser installed and logged in (Chrome, Brave, Firefox, Safari, Edge, or Opera)
22
+ - For **Codeforces**: only your handle is needed, no login required
23
+
24
+ ---
25
+
26
+ ## Configuration
27
+
28
+ Create a `config.toml` file in the project root (in the same directory as `config.example.toml`). Defaults are listed below.
29
+
30
+ ```toml
31
+
32
+ # don't leave these empty
33
+
34
+ [handles]
35
+ codeforces = "your_cf_handle"
36
+ atcoder = "your_ac_handle"
37
+ leetcode = "your_lc_handle"
38
+
39
+ [codeforces]
40
+ tier1 = [1000, 1500] # rating range for easier problems
41
+ tier2 = [1600, 1900] # rating range for harder problems
42
+ recent_max = 1900 # only include problems from contests >= given number
43
+
44
+ [atcoder]
45
+ tier1 = [1000, 1200]
46
+ tier2 = [1300, 1800]
47
+ recent_max = 320 # only include problems from ABC/ARC/AGC contests >= given number
48
+
49
+ [leetcode]
50
+ difficulties = ["MEDIUM", "HARD"] # valid values: "EASY", "MEDIUM", "HARD"
51
+ recent_max = 2500 # only include problems with number >= given number
52
+
53
+ [display]
54
+ show_difficulties = false # show difficulty label next to each problem title
55
+ ```
56
+
57
+ If a field is omitted, the following defaults apply:
58
+
59
+ |Key|Default|
60
+ |---|---|
61
+ |`handles.codeforces`|`""`|
62
+ |`handles.atcoder`|`""`|
63
+ |`handles.leetcode`|`""`|
64
+ |`codeforces.tier1`|`[1000, 1500]`|
65
+ |`codeforces.tier2`|`[1600, 1900]`|
66
+ |`codeforces.recent_max`|`1900`|
67
+ |`atcoder.tier1`|`[1000, 1200]`|
68
+ |`atcoder.tier2`|`[1300, 1800]`|
69
+ |`atcoder.recent_max`|`320`|
70
+ |`leetcode.difficulties`|`["MEDIUM", "HARD"]`|
71
+ |`leetcode.recent_max`|`2500`|
72
+ |`display.show_difficulties`|`false`|
73
+
74
+ You probably don't want to leave all handles empty. Enter at least one of them.
75
+
76
+ ### `config.toml` options
77
+
78
+ **`[handles]`**: Your usernames on each platform. Used to filter out problems you've already solved and to check submissions during a contest.
79
+
80
+ **`[codeforces]`**
81
+
82
+ - `tier1` / `tier2`: Rating ranges that define the two difficulty buckets. The contest sampler draws 50% of problems from tier 1 and 50% from tier 2.
83
+ - `recent_max`: Filters the problem pool to only include problems from contests with an ID at or above this value.
84
+
85
+ **`[atcoder]`**
86
+
87
+ - `tier1` / `tier2`: Same as Codeforces but uses AtCoder's internal difficulty score.
88
+ - `recent_max`: Only problems from ABC/ARC/AGC contests numbered at or above this value are included.
89
+
90
+ **`[leetcode]`**
91
+
92
+ - `difficulties`: Which difficulty levels to include in the pool. The sampler treats lower difficulties as tier 1 and higher as tier 2.
93
+ - `recent_max`: Only problems with a frontend ID at or above this value are included.
94
+
95
+ **`[display]`**
96
+
97
+ - `show_difficulties`: When `true`, each problem in the contest view shows its difficulty rating or label next to the title. Note that this is OFF by default.
98
+
99
+ ---
100
+
101
+ ## Usage
102
+
103
+ Just run the tool and follow the interactive setup:
104
+
105
+ ```
106
+ candidate
107
+ ```
108
+
109
+ You will be prompted for:
110
+
111
+ |Prompt|Description|
112
+ |---|---|
113
+ |**Platform**|`cf` (Codeforces), `ac` (AtCoder), `lc` (LeetCode), `x` (mixed)|
114
+ |**Time limit**|Contest duration in minutes|
115
+ |**Number of problems**|How many problems to include|
116
+ |**Tags**|Optional comma-separated topic filters (e.g. `dp,graphs`). Not supported in mixed mode|
117
+
118
+ After setup, a preview of the selected problems is shown. Press **Enter** to start the contest or **Ctrl+C** to abort.
119
+
120
+ During the contest:
121
+
122
+ - The timer and solve status update every second.
123
+ - Submissions are checked against the platform every 30 seconds.
124
+ - The contest ends automatically when all problems are accepted, or when time runs out.
125
+ - Press **Ctrl+C** at any time to quit early.
126
+
127
+ ---
128
+
129
+ ## Session Cookies
130
+
131
+ AtCoder and LeetCode require an active session to fetch your submission history and verify solves. `candidate` auto-detects your session cookie from whichever supported browser you use. This requires you to be logged in on both platforms on any supported browser.
132
+
133
+ Detected cookies are cached in a `.env` file in the project root so subsequent runs are instant. If auto-detection fails (maybe you are not logged in, or the browser is unsupported), the tool will print a clear message explaining how to set the cookie manually in `.env`.
134
+
135
+ Supported browsers are: **Chrome**, **Brave**, **Firefox**, **Safari**, **Edge**, **Opera**
136
+
137
+ ---
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "candidate"
7
+ version = "0.1.0"
8
+ description = "CLI tool to practice Competitive Programming and DSA"
9
+ requires-python = ">=3.11"
10
+
11
+ dependencies = [
12
+ "browser-cookie3",
13
+ "python-dotenv",
14
+ "requests",
15
+ ]
16
+
17
+ [project.scripts]
18
+ candidate = "candidate.main:run"
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,107 @@
1
+ import time
2
+ import re
3
+ import requests
4
+ from .network import req
5
+ from . import config
6
+ import os
7
+ from dotenv import load_dotenv
8
+ from pathlib import Path
9
+ from .config import *
10
+
11
+ def _cf_solved_set():
12
+ data = req(f"https://codeforces.com/api/user.status?handle={config.CF_HANDLE}&from=1&count=15")
13
+ if not data or data.get("status") != "OK":
14
+ return set()
15
+ return {
16
+ f"{s['problem']['contestId']}-{s['problem']['index']}"
17
+ for s in data["result"]
18
+ if s.get("verdict") == "OK" and "contestId" in s["problem"]
19
+ }
20
+
21
+ def _ac_solved_set(problems):
22
+ """
23
+ Queries AtCoder's personal submission list page per active contest, filtered by AC.
24
+ Fires exactly one request per contest and safely isolates the table rows.
25
+ """
26
+ solved = set()
27
+
28
+ contest_ids = set()
29
+ for p in problems:
30
+ if p.get("platform") == "ac":
31
+
32
+ parts = p["url"].split("/")
33
+ if len(parts) >= 5:
34
+ contest_ids.add(parts[4])
35
+
36
+ hdrs = {
37
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0"
38
+ }
39
+ cookies = {"REVEL_SESSION": AC_SESSION_COOKIE}
40
+
41
+ # print(contest_ids)
42
+ # with open("output.txt", "w", encoding="utf-8") as file:
43
+ # file.write("\n".join(str(item) for item in contest_ids))
44
+
45
+ for contest_id in contest_ids:
46
+
47
+ url = f"https://atcoder.jp/contests/{contest_id}/submissions/me?f.Status=AC"
48
+ try:
49
+ r = requests.get(url, headers=hdrs, cookies=cookies, timeout=6)
50
+ if r.status_code == 200:
51
+ # print(r.text)
52
+ # with open("out.txt", "w", encoding="utf-8") as file:
53
+ # file.write(r.text)
54
+ table_match = re.search(r'<table[^>]*>([\s\S]*?)</table>', r.text)
55
+ if table_match:
56
+ table_html = table_match.group(1)
57
+
58
+ pids = re.findall(r'/tasks/([a-zA-Z0-9_]+)', table_html)
59
+ solved.update(pids)
60
+ except Exception:
61
+ pass
62
+
63
+ # print(solved)
64
+ return solved
65
+
66
+ def _lc_solved_set():
67
+ hdrs = {
68
+ "content-type": "application/json",
69
+ "cookie": f"LEETCODE_SESSION={LC_SESSION_COOKIE}",
70
+ "referer": "https://leetcode.com",
71
+ "x-csrftoken": "dummy",
72
+ }
73
+ q_recent = """
74
+ query recentAcSubmissions($username: String!, $limit: Int!) {
75
+ recentAcSubmissionList(username: $username, limit: $limit) {
76
+ titleSlug
77
+ }
78
+ }
79
+ """
80
+ data = req("https://leetcode.com/graphql", method="POST",
81
+ headers=hdrs,
82
+ json_data={"query": q_recent, "variables": {"username": config.LC_HANDLE, "limit": 15}})
83
+
84
+ solved = set()
85
+ if data and not data.get("errors"):
86
+ for item in (data["data"].get("recentAcSubmissionList") or []):
87
+ solved.add(item["titleSlug"])
88
+ return solved
89
+
90
+ def check_solved(problems):
91
+ platforms = {p["platform"] for p in problems}
92
+ solved_sets = {}
93
+
94
+ if "cf" in platforms:
95
+ solved_sets["cf"] = _cf_solved_set()
96
+ if "ac" in platforms:
97
+ ac_probs = [p for p in problems if p["platform"] == "ac"]
98
+ solved_sets["ac"] = _ac_solved_set(ac_probs)
99
+ if "lc" in platforms:
100
+ solved_sets["lc"] = _lc_solved_set()
101
+
102
+ result = {}
103
+ for p in problems:
104
+ result[p["id"]] = p["id"] in solved_sets.get(p["platform"], set())
105
+ return result
106
+
107
+
@@ -0,0 +1,127 @@
1
+ import os
2
+ import browser_cookie3
3
+ from pathlib import Path
4
+ from dotenv import load_dotenv, set_key
5
+ import tomllib
6
+
7
+ ENV_PATH = Path(__file__).parent.parent.parent / ".env"
8
+ load_dotenv(ENV_PATH)
9
+
10
+ BROWSER_LOADERS = [
11
+ ("Chrome", browser_cookie3.chrome),
12
+ ("Brave", browser_cookie3.brave),
13
+ ("Firefox", browser_cookie3.firefox),
14
+ ("Safari", browser_cookie3.safari),
15
+ ("Edge", browser_cookie3.edge),
16
+ ("Opera", browser_cookie3.opera),
17
+ ]
18
+
19
+ COOKIE_TARGETS = {
20
+ "LC_SESSION_COOKIE": {
21
+ "domain": "leetcode.com",
22
+ "cookie_name": "LEETCODE_SESSION",
23
+ "label": "LeetCode",
24
+ },
25
+ "AC_SESSION_COOKIE": {
26
+ "domain": "atcoder.jp",
27
+ "cookie_name": "REVEL_SESSION",
28
+ "label": "AtCoder",
29
+ },
30
+ }
31
+
32
+
33
+ def _detect_from_browsers(domain: str, cookie_name: str) -> tuple[str, str]:
34
+ for browser_name, loader in BROWSER_LOADERS:
35
+ try:
36
+ cj = loader(domain_name=domain)
37
+ all_cookies = [(c.name, c.value[:20] + "...") for c in cj]
38
+ print(f"[debug] {browser_name} cookies for {domain}: {all_cookies}")
39
+
40
+ value = next((c.value for c in cj if c.name == cookie_name), "")
41
+ if value:
42
+ return value, browser_name
43
+ except Exception:
44
+ continue
45
+ return "", ""
46
+
47
+
48
+ def get_cookie(env_key: str) -> str:
49
+ target = COOKIE_TARGETS[env_key]
50
+ domain = target["domain"]
51
+ cookie_name = target["cookie_name"]
52
+ label = target["label"]
53
+
54
+ existing = os.environ.get(env_key, "")
55
+ if existing:
56
+ print(f"[cookies] {label}: loaded from .env")
57
+ return existing
58
+
59
+ print(f"[cookies] {label}: scanning browsers...", end=" ", flush=True)
60
+ value, browser_name = _detect_from_browsers(domain, cookie_name)
61
+
62
+ if value:
63
+ print(f"found in {browser_name}")
64
+ set_key(ENV_PATH, env_key, value)
65
+ os.environ[env_key] = value
66
+ return value
67
+
68
+ print("not found")
69
+ print(
70
+ f"[cookies] WARNING: Could not auto-detect {label} session cookie.\n"
71
+ f" Log in to {domain.lstrip('.')} in any supported browser, then re-run.\n"
72
+ f" Or set {env_key} manually in your .env file."
73
+ )
74
+ return ""
75
+
76
+
77
+ LC_SESSION_COOKIE = get_cookie("LC_SESSION_COOKIE")
78
+ AC_SESSION_COOKIE = get_cookie("AC_SESSION_COOKIE")
79
+
80
+ CONFIG_PATH = Path(__file__).parent.parent.parent / "config.toml"
81
+
82
+ def _load() -> dict:
83
+ if not CONFIG_PATH.exists():
84
+ raise FileNotFoundError(
85
+ f"config.toml not found at {CONFIG_PATH}\n"
86
+ f"Copy config.example.toml to config.toml and fill in your details."
87
+ )
88
+ with open(CONFIG_PATH, "rb") as f:
89
+ return tomllib.load(f)
90
+
91
+ _cfg = _load()
92
+ _handles = _cfg.get("handles", {})
93
+ _cf = _cfg.get("codeforces", {})
94
+ _ac = _cfg.get("atcoder", {})
95
+ _lc = _cfg.get("leetcode", {})
96
+ _display = _cfg.get("display", {})
97
+
98
+ # Contest defaults (Don't change these)
99
+ DEFAULT_MINUTES = 120
100
+ NUM_PROBLEMS = 4
101
+ CHECK_INTERVAL = 30
102
+
103
+ # Handles
104
+ CF_HANDLE = _handles.get("codeforces", "")
105
+ AC_HANDLE = _handles.get("atcoder", "")
106
+ LC_HANDLE = _handles.get("leetcode", "")
107
+
108
+ # Codeforces
109
+ CF_T1 = tuple(_cf.get("tier1", [1000, 1500]))
110
+ CF_T2 = tuple(_cf.get("tier2", [1600, 1900]))
111
+ CF_RECENT = _cf.get("recent_max", 1900)
112
+
113
+ # AtCoder
114
+ AC_T1 = tuple(_ac.get("tier1", [1000, 1200]))
115
+ AC_T2 = tuple(_ac.get("tier2", [1300, 1800]))
116
+ AC_RECENT = _ac.get("recent_max", 320)
117
+
118
+ # LeetCode
119
+ LC_DIFFICULTIES = _lc.get("difficulties", ["MEDIUM", "HARD"])
120
+ LC_RECENT = _lc.get("recent_max", 2500)
121
+
122
+ # Display
123
+ SHOW_DIFFICULTY = _display.get("show_difficulties", False)
124
+
125
+ BAR_FULL = "█"
126
+ BAR_EMPTY = "░"
127
+ URGENCY = "critical"
@@ -0,0 +1,217 @@
1
+ import random
2
+ from .network import req
3
+ from . import config
4
+ import re
5
+ import requests
6
+
7
+ def cf_fetch_problems(tags=None):
8
+ solved_data = req(f"https://codeforces.com/api/user.status?handle={config.CF_HANDLE}&from=1&count=10000")
9
+ solved = set()
10
+ if solved_data and solved_data.get("status") == "OK":
11
+ for s in solved_data["result"]:
12
+ if s.get("verdict") == "OK" and "contestId" in s["problem"]:
13
+ solved.add(f"{s['problem']['contestId']}-{s['problem']['index']}")
14
+
15
+ pool_data = req("https://codeforces.com/api/problemset.problems")
16
+ if not pool_data or pool_data.get("status") != "OK":
17
+ return [], []
18
+
19
+ t1, t2 = [], []
20
+ for p in pool_data["result"]["problems"]:
21
+ if p.get("contestId", 0) < config.CF_RECENT:
22
+ continue
23
+ rating = p.get("rating")
24
+ if rating is None:
25
+ continue
26
+ pid = f"{p['contestId']}-{p['index']}"
27
+ if pid in solved:
28
+ continue
29
+ if tags and not set(tags).intersection(set(p.get("tags", []))):
30
+ continue
31
+ entry = {
32
+ "id": pid,
33
+ "title": f"{p['contestId']}{p['index']} - {p['name']}",
34
+ "url": f"https://codeforces.com/problemset/problem/{p['contestId']}/{p['index']}",
35
+ "difficulty": str(rating),
36
+ "platform": "cf",
37
+ }
38
+ if config.CF_T1[0] <= rating <= config.CF_T1[1]:
39
+ t1.append(entry)
40
+ elif config.CF_T2[0] <= rating <= config.CF_T2[1]:
41
+ t2.append(entry)
42
+ return t1, t2
43
+
44
+ def ac_fetch_problems(tags=None):
45
+
46
+ # will return a list of problems respecting specified rating and problem recent-ness
47
+
48
+ hdrs = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0"}
49
+
50
+ # getting all submissions
51
+ solved_data = req(
52
+ f"https://kenkoooo.com/atcoder/atcoder-api/v3/user/submissions?user={config.AC_HANDLE}&from_second=0",
53
+ headers=hdrs,
54
+ )
55
+
56
+ solved = set() # store solved problem ids
57
+
58
+ if solved_data:
59
+ solved = {s["problem_id"] for s in solved_data if s.get("result") == "AC"}
60
+ try:
61
+ # this won't work, atcoder doesn't allow viewing submissions so ignore this
62
+ live_url = f"https://atcoder.jp/submissions?f.User={config.AC_HANDLE}&f.Status=AC"
63
+
64
+ r = requests.get(live_url, headers=hdrs, timeout=10)
65
+ if r.status_code == 200:
66
+ live_solved = re.findall(r"/contests/[^/]+/tasks/([a-zA-Z0-9_]+)", r.text)
67
+ solved.update(live_solved)
68
+ except Exception:
69
+ pass
70
+
71
+
72
+ probs = req("https://kenkoooo.com/atcoder/resources/problems.json", headers=hdrs) # for problems
73
+ models = req("https://kenkoooo.com/atcoder/resources/problem-models.json", headers=hdrs) # for rating of problems
74
+
75
+ if not probs or not models:
76
+ return [], []
77
+
78
+ t1, t2 = [], []
79
+ for p in probs:
80
+ pid = p["id"]
81
+ if pid in solved:
82
+ continue
83
+ parts = pid.split("_")
84
+
85
+ if parts and parts[0][:3] in ("abc", "arc", "agc"):
86
+ try:
87
+ if int(parts[0][3:]) < config.AC_RECENT:
88
+ continue
89
+ except ValueError:
90
+ continue
91
+ else:
92
+ continue
93
+
94
+ model = models.get(pid)
95
+ if not model or model.get("difficulty") is None:
96
+ continue
97
+ diff = model["difficulty"]
98
+
99
+ # our problem entry
100
+ entry = {
101
+ "id": pid,
102
+ "title": p["title"],
103
+ "url": f"https://atcoder.jp/contests/{p['contest_id']}/tasks/{pid}",
104
+ "difficulty": f"{diff:.0f}",
105
+ "platform": "ac",
106
+ }
107
+
108
+ if config.AC_T1[0] <= diff <= config.AC_T1[1]:
109
+ t1.append(entry)
110
+ elif config.AC_T2[0] <= diff <= config.AC_T2[1]:
111
+ t2.append(entry)
112
+ return t1, t2
113
+
114
+ def lc_fetch_problems(tags=None):
115
+ hdrs = {
116
+ "content-type": "application/json",
117
+ "cookie": f"LEETCODE_SESSION={config.LC_SESSION_COOKIE}",
118
+ }
119
+ query = """
120
+ query problemsetQuestionList($categorySlug: String, $limit: Int, $skip: Int, $filters: QuestionListFilterInput) {
121
+ problemsetQuestionList: questionList(categorySlug: $categorySlug, limit: $limit, skip: $skip, filters: $filters) {
122
+ data { questionFrontendId title titleSlug difficulty status topicTags { slug } }
123
+ }
124
+ }
125
+ """
126
+ questions = []
127
+ lc_solved = set()
128
+ for page in range(30):
129
+ variables = {
130
+ "categorySlug": "",
131
+ "skip": page * 100,
132
+ "limit": 100,
133
+ "filters": {"orderBy": "FRONTEND_ID", "sortOrder": "DESCENDING", "premiumOnly": False},
134
+ }
135
+ data = req("https://leetcode.com/graphql", method="POST",
136
+ headers=hdrs, json_data={"query": query, "variables": variables})
137
+ if not data or "errors" in data:
138
+ break
139
+ batch = data["data"]["problemsetQuestionList"]["data"]
140
+ if not batch:
141
+ break
142
+ for q in batch:
143
+ if q.get("status") == "ac":
144
+ lc_solved.add(q["titleSlug"])
145
+ questions.extend(batch)
146
+ try:
147
+ if batch and int(batch[-1]["questionFrontendId"]) < config.LC_RECENT:
148
+ break
149
+ except (ValueError, KeyError):
150
+ pass
151
+
152
+ allowed = {d.upper() for d in config.LC_DIFFICULTIES}
153
+ pool = []
154
+ for q in questions:
155
+ if q["titleSlug"] in lc_solved:
156
+ continue
157
+ if q.get("status") == "ac":
158
+ continue
159
+ try:
160
+ if int(q["questionFrontendId"]) < config.LC_RECENT:
161
+ continue
162
+ except ValueError:
163
+ continue
164
+ if q["difficulty"].upper() not in allowed:
165
+ continue
166
+ if tags:
167
+ problem_tags = {t["slug"] for t in q.get("topicTags", [])}
168
+ if not set(tags).intersection(problem_tags):
169
+ continue
170
+ pool.append({
171
+ "id": q["titleSlug"],
172
+ "title": f"{q['questionFrontendId']}. {q['title']}",
173
+ "url": f"https://leetcode.com/problems/{q['titleSlug']}/",
174
+ "difficulty": q["difficulty"],
175
+ "platform": "lc",
176
+ })
177
+
178
+ difficulty_rank = {"EASY": 0, "MEDIUM": 1, "HARD": 2}
179
+ sorted_diffs = sorted(allowed, key=lambda d: difficulty_rank.get(d, 1))
180
+ mid = len(sorted_diffs) // 2
181
+
182
+ t1_diffs = set(sorted_diffs[:max(1, mid)])
183
+ t2_diffs = set(sorted_diffs[max(1, mid):])
184
+ if not t2_diffs:
185
+ t2_diffs = t1_diffs
186
+ t1_diffs = set()
187
+
188
+ t1 = [p for p in pool if p["difficulty"].upper() in t1_diffs]
189
+ t2 = [p for p in pool if p["difficulty"].upper() in t2_diffs]
190
+ return t1, t2
191
+
192
+ def mixed_fetch_problems(tags=None):
193
+ t1, t2 = [], []
194
+ for fn in (lc_fetch_problems, cf_fetch_problems):
195
+ a, b = fn(tags)
196
+ t1 += a
197
+ t2 += b
198
+ return t1, t2
199
+
200
+ def sample_problems(t1, t2, total):
201
+
202
+ # get 'total' number of problems
203
+
204
+ t2_q = round(total * 0.5) # adjust to change difficulty of later problems
205
+ t1_q = total - t2_q
206
+
207
+ sel2 = random.sample(t2, min(t2_q, len(t2)))
208
+
209
+ t1_q += max(0, t2_q - len(sel2))
210
+
211
+ sel1 = random.sample(t1, min(t1_q, len(t1)))
212
+
213
+ rem = total - len(sel1) - len(sel2)
214
+ if rem > 0:
215
+ extra_pool = [p for p in t2 if p not in sel2]
216
+ sel2 += random.sample(extra_pool, min(rem, len(extra_pool)))
217
+ return sel1 + sel2
@@ -0,0 +1,187 @@
1
+ import sys
2
+ import time
3
+ import subprocess
4
+ from datetime import datetime, timedelta
5
+
6
+ from . import config
7
+ from .ui import (
8
+ tw, clr, clear_screen, draw_banner, draw_contest, draw_done,
9
+ platform_badge, diff_colour, hyperlink, fmt, C
10
+ )
11
+ from .fetchers import (
12
+ cf_fetch_problems, ac_fetch_problems, lc_fetch_problems,
13
+ mixed_fetch_problems, sample_problems
14
+ )
15
+ from .tracker import SolveTracker
16
+
17
+ def send_notification(msg_title, msg_body):
18
+ try:
19
+ subprocess.Popen(["notify-send", "--urgency", config.URGENCY,
20
+ "--app-name", "CP Timer", msg_title, msg_body])
21
+ except FileNotFoundError:
22
+ pass
23
+
24
+ def prompt_setup():
25
+ width = tw()
26
+ clear_screen()
27
+ print()
28
+ draw_banner(width)
29
+ print()
30
+
31
+ print(clr(" CONTEST SETUP", C.WHITE, C.BOLD))
32
+ print(clr(" " + "─" * 40, C.GREY))
33
+ print()
34
+
35
+ def ask(prompt, default=None):
36
+ suffix = f" [{default}]" if default is not None else ""
37
+ val = input(clr(f" {prompt}{suffix}: ", C.CYAN)).strip()
38
+ return val if val else str(default)
39
+
40
+ platform = ask("Platform (cf / ac / lc / x for mixed)", "lc").lower()
41
+ while platform not in ("cf", "ac", "lc", "x"):
42
+ print(clr(" Invalid. Choose cf, ac, lc, or x.", C.RED))
43
+ platform = ask("Platform", "lc").lower()
44
+
45
+ mins_raw = ask("Time limit (minutes)", config.DEFAULT_MINUTES)
46
+ try:
47
+ total = int(mins_raw) * 60
48
+ except ValueError:
49
+ total = config.DEFAULT_MINUTES * 60
50
+
51
+ num_raw = ask("Number of problems", config.NUM_PROBLEMS)
52
+ try:
53
+ num = int(num_raw)
54
+ except ValueError:
55
+ num = config.NUM_PROBLEMS
56
+
57
+ tags_raw = ask("Filter tags (comma-separated, or leave blank)", "")
58
+ tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else []
59
+
60
+ print()
61
+ print(clr(f" Fetching problems from {platform.upper()}...", C.YELLOW))
62
+ print()
63
+
64
+ if platform == "cf":
65
+ t1, t2 = cf_fetch_problems(tags)
66
+ elif platform == "ac":
67
+ t1, t2 = ac_fetch_problems(tags)
68
+ elif platform == "lc":
69
+ t1, t2 = lc_fetch_problems(tags)
70
+ else:
71
+ t1, t2 = mixed_fetch_problems(tags)
72
+
73
+ print(clr(f" Pool: {len(t1)} tier-1 + {len(t2)} tier-2 candidates", C.GREY))
74
+
75
+ if not t1 and not t2:
76
+ print(clr(" No problems found. Check handles / cookie / tags.", C.RED))
77
+ sys.exit(1)
78
+
79
+ problems = sample_problems(t1, t2, num)
80
+
81
+ if not problems:
82
+ print(clr(" Could not sample enough problems.", C.RED))
83
+ sys.exit(1)
84
+
85
+ print(clr(f" Selected {len(problems)} problems.", C.GREEN))
86
+ print()
87
+
88
+ clear_screen()
89
+ print()
90
+ draw_banner(width)
91
+ print()
92
+ print(clr(" CONTEST PREVIEW", C.WHITE, C.BOLD))
93
+ print(clr(" " + "─" * 40, C.GREY))
94
+ print()
95
+ print(clr(f" Platform : {platform.upper()}", C.GREY))
96
+ print(clr(f" Duration : {total // 60} min", C.GREY))
97
+ print(clr(f" Problems : {len(problems)}", C.GREY))
98
+ print()
99
+ print(clr(" " + "─" * 40, C.GREY))
100
+
101
+ for i, prob in enumerate(problems):
102
+ badge = platform_badge(prob["platform"])
103
+ title_link = hyperlink(prob["url"], prob["title"])
104
+ num_str = clr(f" {i+1:>2}.", C.GREY)
105
+ title = clr(title_link, C.WHITE)
106
+ diff = clr(f" {prob['difficulty']}", diff_colour(prob["difficulty"])) if config.SHOW_DIFFICULTY else ""
107
+ print(f"{num_str} {badge} {title}{diff}")
108
+ print()
109
+ print(clr(" " + "─" * 40, C.GREY))
110
+ print()
111
+ print(clr(" Press Enter to start the contest, Ctrl+C to abort.", C.CYAN))
112
+ print()
113
+ try:
114
+ input()
115
+ except KeyboardInterrupt:
116
+ print(clr("\n Aborted.\n", C.GREY))
117
+ sys.exit(0)
118
+
119
+ return problems, total
120
+
121
+ def run():
122
+ problems, total = prompt_setup()
123
+
124
+ start_dt = datetime.now()
125
+ end_dt = start_dt + timedelta(seconds=total)
126
+
127
+ tracker = SolveTracker(problems, start_dt)
128
+ tracker.start()
129
+
130
+ print("\033[?25l", end="", flush=True)
131
+
132
+ try:
133
+ while True:
134
+ elapsed = (datetime.now() - start_dt).total_seconds()
135
+ status, solve_times, last_check = tracker.snapshot()
136
+
137
+ width = tw()
138
+ clear_screen()
139
+ print()
140
+ draw_banner(width)
141
+ print()
142
+
143
+ frame = draw_contest(elapsed, total, start_dt, end_dt,
144
+ problems, status, solve_times, last_check, width)
145
+ print("\n".join(frame), flush=True)
146
+
147
+ if elapsed >= total:
148
+ send_notification("Contest Over", f"Time's up! {sum(status.values())}/{len(problems)} solved.")
149
+ break
150
+
151
+ if tracker.all_solved():
152
+ send_notification("Contest Complete!", f"All {len(problems)} problems solved in {fmt(elapsed)}!")
153
+ break
154
+
155
+ time.sleep(1)
156
+
157
+ # Final results
158
+ elapsed = (datetime.now() - start_dt).total_seconds()
159
+ status, solve_times, _ = tracker.snapshot()
160
+ tracker.stop()
161
+
162
+ clear_screen()
163
+ print()
164
+ draw_banner(tw())
165
+ print()
166
+ lines = draw_done(problems, solve_times, elapsed, total, tw())
167
+ print("\n".join(lines))
168
+ print("\a", end="", flush=True)
169
+
170
+ except KeyboardInterrupt:
171
+ elapsed = (datetime.now() - start_dt).total_seconds()
172
+ status, solve_times, _ = tracker.snapshot()
173
+ tracker.stop()
174
+
175
+ clear_screen()
176
+ print()
177
+ draw_banner(tw())
178
+ print()
179
+ lines = draw_done(problems, solve_times, elapsed, total, tw())
180
+ print("\n".join(lines))
181
+ print(clr("\n Quit early.\n", C.GREY))
182
+
183
+ finally:
184
+ print("\033[?25h", end="", flush=True) # restore cursor
185
+
186
+ if __name__ == "__main__":
187
+ run()
@@ -0,0 +1,22 @@
1
+ import time
2
+ import requests
3
+
4
+
5
+ def req(url, method="GET", headers=None, json_data=None, retries=3):
6
+ for attempt in range(retries):
7
+ try:
8
+ if method == "POST":
9
+ r = requests.post(url, headers=headers, json=json_data, timeout=15)
10
+ else:
11
+ r = requests.get(url, headers=headers, timeout=12)
12
+ r.raise_for_status()
13
+ return r.json()
14
+ except requests.exceptions.HTTPError as e:
15
+ sc = e.response.status_code
16
+ if sc in (429, 500, 502, 503, 504):
17
+ time.sleep(2 ** attempt)
18
+ else:
19
+ return None
20
+ except Exception:
21
+ time.sleep(2 ** attempt)
22
+ return None
@@ -0,0 +1,54 @@
1
+ import threading
2
+ from datetime import datetime
3
+ from .checkers import check_solved
4
+ from .config import CHECK_INTERVAL
5
+
6
+ class SolveTracker:
7
+ def __init__(self, problems, start_dt):
8
+ self.problems = problems
9
+ self.start_dt = start_dt
10
+ self.status = {p["id"]: False for p in problems}
11
+ self.solve_times = {}
12
+ self.last_check = None
13
+ self._lock = threading.Lock()
14
+ self._stop = threading.Event()
15
+
16
+ def start(self):
17
+ self._thread = threading.Thread(target=self._loop, daemon=True)
18
+ self._thread.start()
19
+
20
+ def stop(self):
21
+ self._stop.set()
22
+
23
+ def _loop(self):
24
+ self._do_check()
25
+ while not self._stop.is_set():
26
+ slept = 0
27
+ while slept < CHECK_INTERVAL and not self._stop.is_set():
28
+ self._stop.wait(1)
29
+ slept += 1
30
+ if not self._stop.is_set():
31
+ self._do_check()
32
+
33
+ def _do_check(self):
34
+ check_started = datetime.now()
35
+ with self._lock:
36
+ self.last_check = check_started
37
+ try:
38
+ fresh = check_solved(self.problems)
39
+ elapsed = (datetime.now() - self.start_dt).total_seconds()
40
+ with self._lock:
41
+ for pid, solved in fresh.items():
42
+ if solved and not self.status[pid]:
43
+ self.solve_times[pid] = elapsed
44
+ self.status[pid] = solved
45
+ except Exception:
46
+ pass
47
+
48
+ def snapshot(self):
49
+ with self._lock:
50
+ return dict(self.status), dict(self.solve_times), self.last_check
51
+
52
+ def all_solved(self):
53
+ with self._lock:
54
+ return all(self.status.values())
@@ -0,0 +1,253 @@
1
+ import shutil
2
+ from datetime import datetime
3
+ from .config import BAR_FULL, BAR_EMPTY, SHOW_DIFFICULTY, CHECK_INTERVAL
4
+
5
+ class C:
6
+ RESET = "\033[0m"
7
+ BOLD = "\033[1m"
8
+ DIM = "\033[2m"
9
+ WHITE = "\033[97m"
10
+ CYAN = "\033[96m"
11
+ GREEN = "\033[92m"
12
+ YELLOW = "\033[93m"
13
+ RED = "\033[91m"
14
+ GREY = "\033[90m"
15
+ BLUE = "\033[94m"
16
+
17
+ def clr(text, *codes):
18
+ return "".join(codes) + text + C.RESET
19
+
20
+ def hyperlink(url, text):
21
+ return f"\033]8;;{url}\033\\{text}\033]8;;\033\\"
22
+
23
+ def tw():
24
+ return shutil.get_terminal_size((80, 24)).columns
25
+
26
+ def fmt(seconds):
27
+ seconds = max(0, int(seconds))
28
+ h, r = divmod(seconds, 3600)
29
+ m, s = divmod(r, 60)
30
+ return f"{h:02d}:{m:02d}:{s:02d}"
31
+
32
+ def clear_screen():
33
+ print("\033[2J\033[H", end="", flush=True)
34
+
35
+ BANNER = [
36
+ " ██████╗██████╗ ████████╗██╗███╗ ███╗███████╗██████╗ ",
37
+ " ██╔════╝██╔══██╗ ██╔══╝██║████╗ ████║██╔════╝██╔══██╗",
38
+ " ██║ ██████╔╝ ██║ ██║██╔████╔██║█████╗ ██████╔╝",
39
+ " ██║ ██╔═══╝ ██║ ██║██║╚██╔╝██║██╔══╝ ██╔══██╗",
40
+ " ╚██████╗██║ ██║ ██║██║ ╚═╝ ██║███████╗██║ ██║",
41
+ " ╚═════╝╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝",
42
+ ]
43
+
44
+ def draw_banner(width):
45
+ bw = max(len(l) for l in BANNER)
46
+ pad = max(0, (width - bw) // 2)
47
+ for line in BANNER:
48
+ print(clr(" " * pad + line, C.CYAN, C.BOLD))
49
+
50
+ def progress_bar(elapsed, total, width):
51
+ ratio = min(elapsed / total, 1.0) if total else 1.0
52
+ filled = int(ratio * width)
53
+ colour = C.GREEN if ratio < 0.5 else (C.YELLOW if ratio < 0.8 else C.RED)
54
+ return clr(BAR_FULL * filled, colour, C.BOLD) + clr(BAR_EMPTY * (width - filled), C.GREY)
55
+
56
+ def diff_colour(diff_str):
57
+ d = diff_str.upper()
58
+ if d in ("EASY",) or (d.isdigit() and int(d) < 1200):
59
+ return C.GREEN
60
+ if d in ("MEDIUM",) or (d.isdigit() and 1200 <= int(d) < 1800):
61
+ return C.YELLOW
62
+ return C.RED
63
+
64
+ def platform_badge(platform):
65
+ badges = {"cf": clr(" CF ", C.BOLD, C.BLUE),
66
+ "ac": clr(" AC ", C.BOLD, C.CYAN),
67
+ "lc": clr(" LC ", C.BOLD, C.YELLOW)}
68
+ return badges.get(platform, platform.upper())
69
+
70
+ def draw_contest(elapsed, total, start_dt, end_dt,
71
+ problems, solve_status, solve_times, last_check, width):
72
+ remaining = max(0, total - elapsed)
73
+ ratio = min(elapsed / total, 1.0) if total else 1.0
74
+
75
+ if remaining <= 300:
76
+ clk_col = (C.RED, C.BOLD)
77
+ elif remaining <= 900:
78
+ clk_col = (C.YELLOW, C.BOLD)
79
+ else:
80
+ clk_col = (C.WHITE, C.BOLD)
81
+
82
+ inner_w = min(width - 4, 70)
83
+ box_w = inner_w + 4
84
+ lpad = max(0, (width - box_w) // 2)
85
+ ind = " " * lpad
86
+ border = clr("│", C.GREY)
87
+
88
+ def hline(): return ind + clr("┌" + "─" * (box_w - 2) + "┐", C.GREY)
89
+ def bline(): return ind + clr("└" + "─" * (box_w - 2) + "┘", C.GREY)
90
+ def mline(): return ind + clr("├" + "─" * (box_w - 2) + "┤", C.GREY)
91
+
92
+ def cline(s, raw=None):
93
+ vis = raw if raw is not None else len(s)
94
+ pad = inner_w - vis
95
+ lp = pad // 2
96
+ rp = pad - lp
97
+ return ind + border + " " + " " * lp + s + " " * rp + " " + border
98
+
99
+ def lline(s, raw=None):
100
+ vis = raw if raw is not None else len(s)
101
+ rpad = inner_w - vis
102
+ return ind + border + " " + s + " " * rpad + " " + border
103
+
104
+ out = []
105
+ out.append(hline())
106
+ rem_s = fmt(remaining)
107
+ out.append(cline(clr(rem_s, *clk_col), raw=len(rem_s)))
108
+
109
+ sub = f"elapsed {fmt(elapsed)} / total {fmt(total)}"
110
+ out.append(cline(clr(sub, C.GREY), raw=len(sub)))
111
+ out.append(mline())
112
+
113
+ bar = progress_bar(elapsed, total, inner_w)
114
+ out.append(ind + border + " " + bar + " " + border)
115
+
116
+ pct = f"{ratio*100:5.1f}%"
117
+ out.append(cline(clr(pct, C.DIM), raw=len(pct)))
118
+ out.append(mline())
119
+
120
+ ts = f"started {start_dt.strftime('%H:%M:%S')} ends {end_dt.strftime('%H:%M:%S')}"
121
+ out.append(cline(clr(ts, C.GREY), raw=len(ts)))
122
+ out.append(mline())
123
+
124
+ solved_count = sum(1 for p in problems if solve_status.get(p["id"]))
125
+ header = f" PROBLEMS {solved_count}/{len(problems)} solved"
126
+ out.append(lline(clr(header, C.WHITE, C.BOLD), raw=len(header)))
127
+ out.append(mline())
128
+
129
+ for i, prob in enumerate(problems):
130
+ pid = prob["id"]
131
+ is_done = solve_status.get(pid, False)
132
+ solved_t = solve_times.get(pid)
133
+
134
+ glyph = clr("[+]", C.GREEN, C.BOLD) if is_done else clr("[ ]", C.GREY)
135
+ badge = platform_badge(prob["platform"])
136
+
137
+ if is_done and solved_t:
138
+ right_str = f"+{fmt(solved_t)}"
139
+ right_disp = clr(right_str, C.GREEN)
140
+ right_raw = len(right_str)
141
+ elif SHOW_DIFFICULTY:
142
+ diff_str = prob["difficulty"]
143
+ right_str = f"{diff_str:>6}"
144
+ right_disp = clr(right_str, diff_colour(diff_str))
145
+ right_raw = len(right_str)
146
+ else:
147
+ right_str = ""
148
+ right_disp = ""
149
+ right_raw = 0
150
+
151
+ fixed_raw = 3 + 1 + 4 + 1
152
+ right_slot = right_raw + (2 if right_raw else 0)
153
+ max_title = inner_w - fixed_raw - right_slot
154
+
155
+ title_plain = prob["title"]
156
+ if len(title_plain) > max_title:
157
+ title_plain = title_plain[:max_title - 1] + "…"
158
+
159
+ title_linked = hyperlink(prob["url"], title_plain)
160
+ title_col = C.GREEN if is_done else C.WHITE
161
+ title_disp = clr(title_linked, title_col)
162
+
163
+ content_raw = len(title_plain)
164
+ pad = max(0, inner_w - fixed_raw - content_raw - right_slot)
165
+
166
+ line = (glyph + " " + badge + " " +
167
+ title_disp +
168
+ " " * pad +
169
+ (" " if right_raw else "") +
170
+ right_disp)
171
+
172
+ out.append(lline(line, raw=inner_w))
173
+
174
+ out.append(mline())
175
+
176
+ if last_check:
177
+ age = int((datetime.now() - last_check).total_seconds())
178
+ chk = f"last checked {age}s ago | polls every {CHECK_INTERVAL}s | Ctrl+C to quit"
179
+ else:
180
+ chk = f"waiting for first check... | Ctrl+C to quit"
181
+ out.append(cline(clr(chk, C.GREY), raw=len(chk)))
182
+ out.append(bline())
183
+
184
+ return out
185
+
186
+ def draw_done(problems, solve_times, elapsed, total, width):
187
+ inner_w = min(width - 4, 70)
188
+ box_w = inner_w + 4
189
+ lpad = max(0, (width - box_w) // 2)
190
+ ind = " " * lpad
191
+ border = clr("│", C.GREY)
192
+
193
+ def hline(): return ind + clr("┌" + "─" * (box_w - 2) + "┐", C.GREY)
194
+ def bline(): return ind + clr("└" + "─" * (box_w - 2) + "┘", C.GREY)
195
+ def mline(): return ind + clr("├" + "─" * (box_w - 2) + "┤", C.GREY)
196
+ def cline(s, raw=None):
197
+ vis = raw if raw is not None else len(s)
198
+ pad = inner_w - vis
199
+ lp = pad // 2; rp = pad - lp
200
+ return ind + border + " " + " " * lp + s + " " * rp + " " + border
201
+ def lline(s, raw=None):
202
+ vis = raw if raw is not None else len(s)
203
+ return ind + border + " " + s + " " * max(0, inner_w - vis) + " " + border
204
+
205
+ solved_count = sum(1 for p in problems if solve_times.get(p["id"]) is not None)
206
+ all_done = solved_count == len(problems)
207
+
208
+ lines = []
209
+ lines.append(hline())
210
+ msg = "ALL PROBLEMS SOLVED" if all_done else "CONTEST OVER"
211
+ msg_col = C.GREEN if all_done else C.RED
212
+ lines.append(cline(clr(msg, msg_col, C.BOLD), raw=len(msg)))
213
+ sub = f"{solved_count}/{len(problems)} solved | {fmt(elapsed)} elapsed"
214
+ lines.append(cline(clr(sub, C.GREY), raw=len(sub)))
215
+ lines.append(mline())
216
+
217
+ if SHOW_DIFFICULTY:
218
+ hdr = f" {'#':>2} {'Problem':<33} {'Diff':>6} {'Time':>10}"
219
+ else:
220
+ hdr = f" {'#':>2} {'Problem':<42} {'Time':>10}"
221
+ lines.append(lline(clr(hdr, C.WHITE, C.BOLD), raw=len(hdr)))
222
+ lines.append(mline())
223
+
224
+ for i, prob in enumerate(problems):
225
+ pid = prob["id"]
226
+ st = solve_times.get(pid)
227
+ done = st is not None
228
+ t_str = f"+{fmt(st)}" if done else "unsolved"
229
+ t_col = C.GREEN if done else C.GREY
230
+ t_disp = clr(f"{t_str:>10}", t_col)
231
+
232
+ if SHOW_DIFFICULTY:
233
+ title = prob["title"]
234
+ if len(title) > 33: title = title[:32] + "…"
235
+ diff_disp = clr(f"{prob['difficulty']:>6}", diff_colour(prob["difficulty"]))
236
+ title_link = hyperlink(prob["url"], title)
237
+ title_disp = clr(title_link, C.WHITE if done else C.DIM)
238
+ row_plain = f" {i+1:>2} {title:<33} "
239
+ row_disp = clr(f" {i+1:>2} ", C.WHITE if done else C.DIM) + title_disp + clr(" ", C.RESET)
240
+ vis = len(row_plain) + 6 + 2 + 10
241
+ lines.append(lline(row_disp + diff_disp + clr(" ", C.RESET) + t_disp, raw=vis))
242
+ else:
243
+ title = prob["title"]
244
+ if len(title) > 42: title = title[:41] + "…"
245
+ title_link = hyperlink(prob["url"], title)
246
+ title_disp = clr(title_link, C.WHITE if done else C.DIM)
247
+ row_plain = f" {i+1:>2} {title:<42} "
248
+ row_disp = clr(f" {i+1:>2} ", C.WHITE if done else C.DIM) + title_disp + clr(" ", C.RESET)
249
+ vis = len(row_plain) + 10
250
+ lines.append(lline(row_disp + t_disp, raw=vis))
251
+
252
+ lines.append(bline())
253
+ return lines
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: candidate
3
+ Version: 0.1.0
4
+ Summary: CLI tool to practice Competitive Programming and DSA
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: browser-cookie3
7
+ Requires-Dist: python-dotenv
8
+ Requires-Dist: requests
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/candidate/checkers.py
4
+ src/candidate/config.py
5
+ src/candidate/fetchers.py
6
+ src/candidate/main.py
7
+ src/candidate/network.py
8
+ src/candidate/tracker.py
9
+ src/candidate/ui.py
10
+ src/candidate.egg-info/PKG-INFO
11
+ src/candidate.egg-info/SOURCES.txt
12
+ src/candidate.egg-info/dependency_links.txt
13
+ src/candidate.egg-info/entry_points.txt
14
+ src/candidate.egg-info/requires.txt
15
+ src/candidate.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ candidate = candidate.main:run
@@ -0,0 +1,3 @@
1
+ browser-cookie3
2
+ python-dotenv
3
+ requests
@@ -0,0 +1 @@
1
+ candidate