leetcode-local-cli 0.7.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.
- leetcode_local_cli/__init__.py +1 -0
- leetcode_local_cli/__main__.py +3 -0
- leetcode_local_cli/auth.py +215 -0
- leetcode_local_cli/cli.py +217 -0
- leetcode_local_cli/client.py +373 -0
- leetcode_local_cli/doctor.py +277 -0
- leetcode_local_cli/problem.py +166 -0
- leetcode_local_cli/service.py +269 -0
- leetcode_local_cli/ui.py +250 -0
- leetcode_local_cli/version.py +12 -0
- leetcode_local_cli/workspace.py +228 -0
- leetcode_local_cli-0.7.0.dist-info/METADATA +253 -0
- leetcode_local_cli-0.7.0.dist-info/RECORD +16 -0
- leetcode_local_cli-0.7.0.dist-info/WHEEL +4 -0
- leetcode_local_cli-0.7.0.dist-info/entry_points.txt +3 -0
- leetcode_local_cli-0.7.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class QuestionIdError(str, Enum):
|
|
7
|
+
EMPTY = "empty"
|
|
8
|
+
NOT_DECIMAL = "not_decimal"
|
|
9
|
+
NOT_POSITIVE = "not_positive"
|
|
10
|
+
LEADING_ZERO = "leading_zero"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
QUESTION_ID_ERROR_MESSAGES = {
|
|
14
|
+
QuestionIdError.EMPTY: "题号不能为空",
|
|
15
|
+
QuestionIdError.NOT_DECIMAL: "题号必须是正整数",
|
|
16
|
+
QuestionIdError.NOT_POSITIVE: "题号必须大于 0",
|
|
17
|
+
QuestionIdError.LEADING_ZERO: "题号不能以 0 开头",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ParseQuestionIdResult:
|
|
23
|
+
question_id: str | None = None
|
|
24
|
+
error: QuestionIdError | None = None
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def ok(self) -> bool:
|
|
28
|
+
return self.error is None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def error_message(self) -> str | None:
|
|
32
|
+
if self.error is None:
|
|
33
|
+
return None
|
|
34
|
+
return QUESTION_ID_ERROR_MESSAGES[self.error]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ProblemSummary:
|
|
39
|
+
question_id: str
|
|
40
|
+
title: str
|
|
41
|
+
title_slug: str
|
|
42
|
+
difficulty: str
|
|
43
|
+
paid_only: bool
|
|
44
|
+
tags: list[str]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class ProblemDetail:
|
|
49
|
+
question_id: str
|
|
50
|
+
submit_question_id: str
|
|
51
|
+
title: str
|
|
52
|
+
title_slug: str
|
|
53
|
+
difficulty: str
|
|
54
|
+
tags: list[str]
|
|
55
|
+
content_html: str
|
|
56
|
+
python_code: str | None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_question_id(raw: str) -> ParseQuestionIdResult:
|
|
60
|
+
text = raw.strip()
|
|
61
|
+
|
|
62
|
+
if not text:
|
|
63
|
+
return ParseQuestionIdResult(error=QuestionIdError.EMPTY)
|
|
64
|
+
|
|
65
|
+
if not text.isdecimal():
|
|
66
|
+
return ParseQuestionIdResult(error=QuestionIdError.NOT_DECIMAL)
|
|
67
|
+
|
|
68
|
+
if text == "0":
|
|
69
|
+
return ParseQuestionIdResult(error=QuestionIdError.NOT_POSITIVE)
|
|
70
|
+
|
|
71
|
+
if text.startswith("0"):
|
|
72
|
+
return ParseQuestionIdResult(error=QuestionIdError.LEADING_ZERO)
|
|
73
|
+
|
|
74
|
+
return ParseQuestionIdResult(question_id=text)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def normalize_problem_summary(raw: dict[str, Any]) -> ProblemSummary:
|
|
78
|
+
question_id = raw.get("frontendQuestionId", "")
|
|
79
|
+
question_id = str(question_id) if isinstance(question_id, (str, int)) else ""
|
|
80
|
+
title = raw.get("title", "")
|
|
81
|
+
title = title if isinstance(title, str) else ""
|
|
82
|
+
title_slug = raw.get("titleSlug", "")
|
|
83
|
+
title_slug = title_slug if isinstance(title_slug, str) else ""
|
|
84
|
+
difficulty = raw.get("difficulty", "")
|
|
85
|
+
difficulty = difficulty if isinstance(difficulty, str) else ""
|
|
86
|
+
paid_only = bool(raw.get("paidOnly", False))
|
|
87
|
+
raw_tags = raw.get("topicTags")
|
|
88
|
+
tags = (
|
|
89
|
+
[
|
|
90
|
+
name
|
|
91
|
+
for tag in raw_tags
|
|
92
|
+
if isinstance(tag, dict)
|
|
93
|
+
and isinstance((name := tag.get("name")), str)
|
|
94
|
+
and name
|
|
95
|
+
]
|
|
96
|
+
if isinstance(raw_tags, list)
|
|
97
|
+
else []
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return ProblemSummary(
|
|
101
|
+
question_id=question_id,
|
|
102
|
+
title=title,
|
|
103
|
+
title_slug=title_slug,
|
|
104
|
+
difficulty=difficulty,
|
|
105
|
+
paid_only=paid_only,
|
|
106
|
+
tags=tags,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def normalize_problem_summaries(
|
|
111
|
+
raw_items: list[dict[str, Any]],
|
|
112
|
+
) -> list[ProblemSummary]:
|
|
113
|
+
summaries = [normalize_problem_summary(raw) for raw in raw_items]
|
|
114
|
+
return summaries
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def find_problem_by_id(
|
|
118
|
+
problems: list[ProblemSummary], question_id: str
|
|
119
|
+
) -> ProblemSummary | None:
|
|
120
|
+
for problem in problems:
|
|
121
|
+
if question_id == problem.question_id:
|
|
122
|
+
return problem
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def extract_python_code(code_snippets: Any) -> str | None:
|
|
127
|
+
if not isinstance(code_snippets, list):
|
|
128
|
+
return None
|
|
129
|
+
for snippet in code_snippets:
|
|
130
|
+
if not isinstance(snippet, dict):
|
|
131
|
+
continue
|
|
132
|
+
if snippet.get("langSlug") == "python3":
|
|
133
|
+
code = snippet.get("code")
|
|
134
|
+
return code if isinstance(code, str) else None
|
|
135
|
+
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def normalize_problem_detail(raw: dict[str, Any]) -> ProblemDetail:
|
|
140
|
+
raw_tags = raw.get("topicTags")
|
|
141
|
+
tags = (
|
|
142
|
+
[
|
|
143
|
+
name
|
|
144
|
+
for tag in raw_tags
|
|
145
|
+
if isinstance(tag, dict)
|
|
146
|
+
and isinstance((name := tag.get("name")), str)
|
|
147
|
+
and name
|
|
148
|
+
]
|
|
149
|
+
if isinstance(raw_tags, list)
|
|
150
|
+
else []
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def text(key: str) -> str:
|
|
154
|
+
value = raw.get(key)
|
|
155
|
+
return value if isinstance(value, str) else ""
|
|
156
|
+
|
|
157
|
+
return ProblemDetail(
|
|
158
|
+
question_id=text("questionFrontendId"),
|
|
159
|
+
submit_question_id=text("questionId"),
|
|
160
|
+
title=text("translatedTitle") or text("title"),
|
|
161
|
+
title_slug=text("titleSlug"),
|
|
162
|
+
difficulty=text("difficulty"),
|
|
163
|
+
tags=tags,
|
|
164
|
+
content_html=text("translatedContent") or text("content"),
|
|
165
|
+
python_code=extract_python_code(raw.get("codeSnippets")),
|
|
166
|
+
)
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
from time import sleep
|
|
2
|
+
|
|
3
|
+
from typer import Exit
|
|
4
|
+
|
|
5
|
+
from leetcode_local_cli.auth import (
|
|
6
|
+
REQUIRED_COOKIE_NAMES,
|
|
7
|
+
SessionFileError,
|
|
8
|
+
load_session,
|
|
9
|
+
)
|
|
10
|
+
from leetcode_local_cli.client import ClientErrorKind, LeetCodeClient
|
|
11
|
+
from leetcode_local_cli.doctor import (
|
|
12
|
+
DoctorReport,
|
|
13
|
+
DoctorStatus,
|
|
14
|
+
diagnose_remote,
|
|
15
|
+
diagnose_session,
|
|
16
|
+
diagnose_solution,
|
|
17
|
+
)
|
|
18
|
+
from leetcode_local_cli.problem import (
|
|
19
|
+
ProblemDetail,
|
|
20
|
+
ProblemSummary,
|
|
21
|
+
find_problem_by_id,
|
|
22
|
+
normalize_problem_detail,
|
|
23
|
+
normalize_problem_summaries,
|
|
24
|
+
parse_question_id,
|
|
25
|
+
)
|
|
26
|
+
from leetcode_local_cli.ui import error, loading, render_submission_target, warning
|
|
27
|
+
from leetcode_local_cli.workspace import WorkspaceError, parse_solution_submission
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
MAX_ATTEMPTS = 10
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def client_error_message(kind: ClientErrorKind | None) -> str:
|
|
34
|
+
match kind:
|
|
35
|
+
case ClientErrorKind.NETWORK:
|
|
36
|
+
return "网络请求失败,请检查网络连接"
|
|
37
|
+
|
|
38
|
+
case ClientErrorKind.HTTP:
|
|
39
|
+
return "LeetCode 接口返回异常,请稍后重试"
|
|
40
|
+
|
|
41
|
+
case ClientErrorKind.INVALID_JSON:
|
|
42
|
+
return "LeetCode 返回内容无法解析,请稍后重试"
|
|
43
|
+
|
|
44
|
+
case ClientErrorKind.INVALID_RESPONSE:
|
|
45
|
+
return "LeetCode 接口数据结构异常,可能是接口变更"
|
|
46
|
+
|
|
47
|
+
case ClientErrorKind.UNAUTHORIZED:
|
|
48
|
+
return "登录态无效或已过期,请重新执行 uv run lc login"
|
|
49
|
+
|
|
50
|
+
case ClientErrorKind.MISSING_CSRF:
|
|
51
|
+
return "缺少提交凭证 csrftoken,请重新执行 uv run lc login"
|
|
52
|
+
|
|
53
|
+
case _:
|
|
54
|
+
return "未知客户端错误"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _load_cookies_from_session() -> dict[str, str]:
|
|
58
|
+
session_check = diagnose_session()
|
|
59
|
+
if session_check.status is DoctorStatus.FAIL:
|
|
60
|
+
error(session_check.message)
|
|
61
|
+
if session_check.suggestion:
|
|
62
|
+
warning(session_check.suggestion)
|
|
63
|
+
raise Exit(1)
|
|
64
|
+
try:
|
|
65
|
+
session = load_session()
|
|
66
|
+
except SessionFileError as exc:
|
|
67
|
+
error(str(exc))
|
|
68
|
+
raise Exit(1)
|
|
69
|
+
if not isinstance(session, dict):
|
|
70
|
+
warning("未找到有效登录态,请先执行 uv run lc login")
|
|
71
|
+
raise Exit(1)
|
|
72
|
+
cookies = session.get("cookies")
|
|
73
|
+
if not isinstance(cookies, dict):
|
|
74
|
+
error("Session 文件结构无效,请重新执行 uv run lc login")
|
|
75
|
+
raise Exit(1)
|
|
76
|
+
valid_cookies: dict[str, str] = {}
|
|
77
|
+
for name in REQUIRED_COOKIE_NAMES:
|
|
78
|
+
value = cookies.get(name)
|
|
79
|
+
if not isinstance(value, str) or not value:
|
|
80
|
+
error(f"缺少或无效的 Cookie:{name}")
|
|
81
|
+
warning("请重新执行 uv run lc login")
|
|
82
|
+
raise Exit(1)
|
|
83
|
+
valid_cookies[name] = value
|
|
84
|
+
return valid_cookies
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse_question_id_or_exit(question_id: str) -> str:
|
|
88
|
+
parse_result = parse_question_id(question_id)
|
|
89
|
+
if not parse_result.ok:
|
|
90
|
+
error(parse_result.error_message or "题号解析失败")
|
|
91
|
+
raise Exit(1)
|
|
92
|
+
assert parse_result.question_id is not None
|
|
93
|
+
return parse_result.question_id
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _find_problem_summary_by_question_id_online(
|
|
97
|
+
client: LeetCodeClient,
|
|
98
|
+
question_id: str,
|
|
99
|
+
) -> ProblemSummary:
|
|
100
|
+
limit, skip = 100, 0
|
|
101
|
+
while True:
|
|
102
|
+
problem_list_data = client.problem_list(limit=limit, skip=skip)
|
|
103
|
+
if not problem_list_data.ok:
|
|
104
|
+
error(client_error_message(problem_list_data.error))
|
|
105
|
+
raise Exit(1)
|
|
106
|
+
problem_list = problem_list_data.data
|
|
107
|
+
if not isinstance(problem_list, dict):
|
|
108
|
+
error(client_error_message(ClientErrorKind.INVALID_RESPONSE))
|
|
109
|
+
raise Exit(1)
|
|
110
|
+
questions = problem_list.get("questions", [])
|
|
111
|
+
problem_summaries = normalize_problem_summaries(questions)
|
|
112
|
+
problem_summary = find_problem_by_id(problem_summaries, question_id)
|
|
113
|
+
if problem_summary:
|
|
114
|
+
return problem_summary
|
|
115
|
+
skip += limit
|
|
116
|
+
total = problem_list.get("total") or 0
|
|
117
|
+
if not questions or skip >= total:
|
|
118
|
+
error(f"未找到题号 {question_id}")
|
|
119
|
+
raise Exit(1)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _validate_show_options(limit: int, skip: int) -> None:
|
|
123
|
+
if limit <= 0:
|
|
124
|
+
error("limit 必须是正整数")
|
|
125
|
+
raise Exit(1)
|
|
126
|
+
if limit > 100:
|
|
127
|
+
error("limit 超过单次查询上限,最大为 100")
|
|
128
|
+
raise Exit(1)
|
|
129
|
+
if skip < 0:
|
|
130
|
+
error("skip 必须是非负整数")
|
|
131
|
+
raise Exit(1)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def get_user_status() -> dict:
|
|
135
|
+
cookies = _load_cookies_from_session()
|
|
136
|
+
with LeetCodeClient(cookies) as client:
|
|
137
|
+
user_status = client.user_status()
|
|
138
|
+
if not user_status.ok:
|
|
139
|
+
error(client_error_message(user_status.error))
|
|
140
|
+
raise Exit(1)
|
|
141
|
+
status = user_status.data
|
|
142
|
+
if not isinstance(status, dict) or not status.get("isSignedIn"):
|
|
143
|
+
error(client_error_message(ClientErrorKind.UNAUTHORIZED))
|
|
144
|
+
raise Exit(1)
|
|
145
|
+
return user_status.data
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def get_account_profile() -> dict:
|
|
149
|
+
cookies = _load_cookies_from_session()
|
|
150
|
+
with LeetCodeClient(cookies) as client:
|
|
151
|
+
with loading("正在获取账户信息..."):
|
|
152
|
+
account_profile = client.account_profile()
|
|
153
|
+
if not account_profile.ok:
|
|
154
|
+
error(client_error_message(account_profile.error))
|
|
155
|
+
raise Exit(1)
|
|
156
|
+
if not isinstance(account_profile.data, dict):
|
|
157
|
+
error(client_error_message(ClientErrorKind.INVALID_RESPONSE))
|
|
158
|
+
raise Exit(1)
|
|
159
|
+
return account_profile.data
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def get_problem_summaries(limit: int = 50, skip: int = 0) -> list[ProblemSummary]:
|
|
163
|
+
_validate_show_options(limit, skip)
|
|
164
|
+
cookies = _load_cookies_from_session()
|
|
165
|
+
with LeetCodeClient(cookies) as client:
|
|
166
|
+
with loading("正在获取题目索引..."):
|
|
167
|
+
problem_list_data = client.problem_list(limit=limit, skip=skip)
|
|
168
|
+
if not problem_list_data.ok:
|
|
169
|
+
error(client_error_message(problem_list_data.error))
|
|
170
|
+
raise Exit(1)
|
|
171
|
+
problem_list = problem_list_data.data
|
|
172
|
+
if not isinstance(problem_list, dict):
|
|
173
|
+
error(client_error_message(ClientErrorKind.INVALID_RESPONSE))
|
|
174
|
+
raise Exit(1)
|
|
175
|
+
questions = problem_list.get("questions", [])
|
|
176
|
+
|
|
177
|
+
if not isinstance(questions, list):
|
|
178
|
+
error("题目获取失败")
|
|
179
|
+
raise Exit(1)
|
|
180
|
+
return normalize_problem_summaries(questions)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def get_problem_detail_by_question_id(question_id: str) -> ProblemDetail:
|
|
184
|
+
normalized_question_id = _parse_question_id_or_exit(question_id)
|
|
185
|
+
cookies = _load_cookies_from_session()
|
|
186
|
+
with LeetCodeClient(cookies) as client:
|
|
187
|
+
with loading("正在获取题目索引..."):
|
|
188
|
+
problem_summary = _find_problem_summary_by_question_id_online(
|
|
189
|
+
client,
|
|
190
|
+
normalized_question_id,
|
|
191
|
+
)
|
|
192
|
+
with loading("正在获取题目详情..."):
|
|
193
|
+
problem_detail_data = client.problem_detail(problem_summary.title_slug)
|
|
194
|
+
if not problem_detail_data.ok:
|
|
195
|
+
error(client_error_message(problem_detail_data.error))
|
|
196
|
+
raise Exit(1)
|
|
197
|
+
if not isinstance(problem_detail_data.data, dict):
|
|
198
|
+
error(client_error_message(ClientErrorKind.INVALID_RESPONSE))
|
|
199
|
+
raise Exit(1)
|
|
200
|
+
problem_detail = normalize_problem_detail(problem_detail_data.data)
|
|
201
|
+
return problem_detail
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def get_doctor_report() -> DoctorReport:
|
|
205
|
+
session_check = diagnose_session()
|
|
206
|
+
solution_check = diagnose_solution()
|
|
207
|
+
|
|
208
|
+
cookies: dict[str, str] | None = None
|
|
209
|
+
try:
|
|
210
|
+
session = load_session()
|
|
211
|
+
except SessionFileError:
|
|
212
|
+
session = None
|
|
213
|
+
if isinstance(session, dict):
|
|
214
|
+
raw_cookies = session.get("cookies")
|
|
215
|
+
if isinstance(raw_cookies, dict):
|
|
216
|
+
valid_cookies: dict[str, str] = {}
|
|
217
|
+
for name in REQUIRED_COOKIE_NAMES:
|
|
218
|
+
value = raw_cookies.get(name)
|
|
219
|
+
if not isinstance(value, str) or not value:
|
|
220
|
+
break
|
|
221
|
+
valid_cookies[name] = value
|
|
222
|
+
else:
|
|
223
|
+
cookies = valid_cookies
|
|
224
|
+
|
|
225
|
+
with LeetCodeClient(cookies) as client:
|
|
226
|
+
remote_result = client.user_status()
|
|
227
|
+
connectivity_check, authentication_check = diagnose_remote(remote_result)
|
|
228
|
+
return DoctorReport(
|
|
229
|
+
checks=(
|
|
230
|
+
session_check,
|
|
231
|
+
connectivity_check,
|
|
232
|
+
authentication_check,
|
|
233
|
+
solution_check,
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def submit_current_solution() -> dict | None:
|
|
239
|
+
try:
|
|
240
|
+
metadata, code = parse_solution_submission()
|
|
241
|
+
submit_question_id, title_slug = (
|
|
242
|
+
metadata.submit_question_id,
|
|
243
|
+
metadata.title_slug,
|
|
244
|
+
)
|
|
245
|
+
except WorkspaceError as exc:
|
|
246
|
+
error(str(exc))
|
|
247
|
+
raise Exit(1)
|
|
248
|
+
render_submission_target(metadata)
|
|
249
|
+
cookies = _load_cookies_from_session()
|
|
250
|
+
with LeetCodeClient(cookies) as client:
|
|
251
|
+
submission_id = client.submit_solution(title_slug, submit_question_id, code)
|
|
252
|
+
if not submission_id.ok:
|
|
253
|
+
error(client_error_message(submission_id.error))
|
|
254
|
+
raise Exit(1)
|
|
255
|
+
|
|
256
|
+
for _ in range(MAX_ATTEMPTS):
|
|
257
|
+
result = client.get_submission_result(submission_id.data)
|
|
258
|
+
if not result.ok:
|
|
259
|
+
error(client_error_message(result.error))
|
|
260
|
+
raise Exit(1)
|
|
261
|
+
result_data = result.data
|
|
262
|
+
if not isinstance(result_data, dict):
|
|
263
|
+
error(client_error_message(ClientErrorKind.INVALID_RESPONSE))
|
|
264
|
+
raise Exit(1)
|
|
265
|
+
state = result_data.get("state")
|
|
266
|
+
if state not in {"PENDING", "STARTED"}:
|
|
267
|
+
return result_data
|
|
268
|
+
sleep(0.5)
|
|
269
|
+
return None
|
leetcode_local_cli/ui.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
from collections.abc import Iterator
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from html import unescape
|
|
4
|
+
from html.parser import HTMLParser
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from leetcode_local_cli.doctor import DoctorReport, DoctorStatus
|
|
14
|
+
|
|
15
|
+
console = Console(width=shutil.get_terminal_size(fallback=(120, 24)).columns)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _terminal_width() -> int:
|
|
19
|
+
return shutil.get_terminal_size(fallback=(console.width, 24)).columns
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _ProblemHTMLParser(HTMLParser):
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
super().__init__()
|
|
25
|
+
self.parts: list[str] = []
|
|
26
|
+
|
|
27
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
28
|
+
if tag in {"p", "div", "ul", "ol", "pre"}:
|
|
29
|
+
self.parts.append("\n")
|
|
30
|
+
elif tag == "li":
|
|
31
|
+
self.parts.append("\n- ")
|
|
32
|
+
elif tag == "br":
|
|
33
|
+
self.parts.append("\n")
|
|
34
|
+
elif tag == "sup":
|
|
35
|
+
self.parts.append("^")
|
|
36
|
+
elif tag == "sub":
|
|
37
|
+
self.parts.append("_")
|
|
38
|
+
|
|
39
|
+
def handle_endtag(self, tag: str) -> None:
|
|
40
|
+
if tag in {"p", "div", "ul", "ol", "pre"}:
|
|
41
|
+
self.parts.append("\n")
|
|
42
|
+
|
|
43
|
+
def handle_data(self, data: str) -> None:
|
|
44
|
+
self.parts.append(data)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _html_to_text(content_html: str) -> str:
|
|
48
|
+
parser = _ProblemHTMLParser()
|
|
49
|
+
parser.feed(content_html)
|
|
50
|
+
text = unescape("".join(parser.parts))
|
|
51
|
+
text = text.replace("\xa0", " ")
|
|
52
|
+
text = re.sub(r"[ \t]+\n", "\n", text)
|
|
53
|
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
54
|
+
return text.strip()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@contextmanager
|
|
58
|
+
def loading(message: str) -> Iterator[None]:
|
|
59
|
+
with console.status(message, spinner="dots"):
|
|
60
|
+
yield
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def success(message: str) -> None:
|
|
64
|
+
console.print(f"[bold green]{message}[/bold green]")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def warning(message: str) -> None:
|
|
68
|
+
console.print(f"[bold yellow]{message}[/bold yellow]")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def error(message: str) -> None:
|
|
72
|
+
console.print(f"[bold red]{message}[/bold red]")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def render_profile(profile: dict[str, Any]) -> None:
|
|
76
|
+
solved = profile["solved"]
|
|
77
|
+
total = profile["total"]
|
|
78
|
+
|
|
79
|
+
table = Table(title="LeetCode CN Profile")
|
|
80
|
+
table.add_column("Item", style="cyan", no_wrap=True)
|
|
81
|
+
table.add_column("Value", style="white")
|
|
82
|
+
|
|
83
|
+
table.add_row("Username", str(profile.get("username") or "-"))
|
|
84
|
+
table.add_row("Real Name", str(profile.get("real_name") or "-"))
|
|
85
|
+
table.add_row("Premium", "Yes" if profile.get("is_premium") else "No")
|
|
86
|
+
table.add_row(
|
|
87
|
+
"Solved",
|
|
88
|
+
f"All {solved['All']} | Easy {solved['Easy']} | Medium {solved['Medium']} | Hard {solved['Hard']}",
|
|
89
|
+
)
|
|
90
|
+
table.add_row(
|
|
91
|
+
"Total",
|
|
92
|
+
f"All {total['All']} | Easy {total['Easy']} | Medium {total['Medium']} | Hard {total['Hard']}",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
console.print(Panel(table, border_style="green", width=_terminal_width()))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def render_problem_list(problems: list[Any]) -> None:
|
|
99
|
+
if not problems:
|
|
100
|
+
warning("没有可展示的题目")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
table = Table(
|
|
104
|
+
title=f"LeetCode CN Problems ({len(problems)})",
|
|
105
|
+
border_style="cyan",
|
|
106
|
+
expand=True,
|
|
107
|
+
width=_terminal_width(),
|
|
108
|
+
)
|
|
109
|
+
table.add_column("ID", style="cyan", justify="right", no_wrap=True)
|
|
110
|
+
table.add_column("Title", style="white", overflow="fold")
|
|
111
|
+
table.add_column("Difficulty", justify="center", no_wrap=True)
|
|
112
|
+
table.add_column("Paid", justify="center", no_wrap=True)
|
|
113
|
+
table.add_column("Tags", style="dim", overflow="fold")
|
|
114
|
+
|
|
115
|
+
difficulty_styles = {
|
|
116
|
+
"Easy": "green",
|
|
117
|
+
"Medium": "yellow",
|
|
118
|
+
"Hard": "red",
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for problem in problems:
|
|
122
|
+
difficulty = problem.difficulty or "-"
|
|
123
|
+
difficulty_style = difficulty_styles.get(difficulty, "white")
|
|
124
|
+
paid_text = (
|
|
125
|
+
"[yellow]Paid[/yellow]" if problem.paid_only else "[green]Free[/green]"
|
|
126
|
+
)
|
|
127
|
+
tags = ", ".join(problem.tags[:4])
|
|
128
|
+
if len(problem.tags) > 4:
|
|
129
|
+
tags = f"{tags}, ..."
|
|
130
|
+
|
|
131
|
+
table.add_row(
|
|
132
|
+
problem.question_id,
|
|
133
|
+
problem.title or "-",
|
|
134
|
+
f"[{difficulty_style}]{difficulty}[/{difficulty_style}]",
|
|
135
|
+
paid_text,
|
|
136
|
+
tags or "-",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
console.print(table)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def render_problem_detail(problem: Any) -> None:
|
|
143
|
+
tags = ", ".join(problem.tags) if problem.tags else "-"
|
|
144
|
+
|
|
145
|
+
meta = Table.grid(padding=(0, 2))
|
|
146
|
+
meta.add_column(style="cyan", no_wrap=True)
|
|
147
|
+
meta.add_column(style="white")
|
|
148
|
+
meta.add_row("ID", problem.question_id)
|
|
149
|
+
meta.add_row("Slug", problem.title_slug)
|
|
150
|
+
meta.add_row("Difficulty", problem.difficulty)
|
|
151
|
+
meta.add_row("Tags", tags)
|
|
152
|
+
|
|
153
|
+
title = f"{problem.question_id}. {problem.title}"
|
|
154
|
+
console.print(
|
|
155
|
+
Panel(meta, title=title, border_style="cyan", width=_terminal_width())
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
content_text = _html_to_text(problem.content_html)
|
|
159
|
+
console.print(
|
|
160
|
+
Panel(
|
|
161
|
+
content_text or "-",
|
|
162
|
+
title="题面",
|
|
163
|
+
border_style="white",
|
|
164
|
+
width=_terminal_width(),
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if not problem.python_code:
|
|
169
|
+
warning("未找到 Python3 代码模板")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def render_submission_target(metadata: Any) -> None:
|
|
173
|
+
console.print(
|
|
174
|
+
"[bold cyan]当前提交目标:"
|
|
175
|
+
f"{metadata.problem_id}. {metadata.title} ({metadata.title_slug})"
|
|
176
|
+
"[/bold cyan]"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def render_doctor_report(report: DoctorReport) -> None:
|
|
181
|
+
labels = {
|
|
182
|
+
"session": "Session 文件",
|
|
183
|
+
"connectivity": "LeetCode 接口",
|
|
184
|
+
"authentication": "Cookie 登录态",
|
|
185
|
+
"solution": "solution.py",
|
|
186
|
+
}
|
|
187
|
+
status_labels = {
|
|
188
|
+
DoctorStatus.PASS: ("PASS", "green"),
|
|
189
|
+
DoctorStatus.WARNING: ("WARNING", "yellow"),
|
|
190
|
+
DoctorStatus.FAIL: ("FAIL", "red"),
|
|
191
|
+
}
|
|
192
|
+
table = Table(title="环境诊断", expand=True)
|
|
193
|
+
table.add_column("检查项", style="cyan", no_wrap=True)
|
|
194
|
+
table.add_column("状态", justify="center", no_wrap=True)
|
|
195
|
+
table.add_column("结果", overflow="fold")
|
|
196
|
+
table.add_column("建议", overflow="fold")
|
|
197
|
+
|
|
198
|
+
for check in report.checks:
|
|
199
|
+
status_text, status_style = status_labels[check.status]
|
|
200
|
+
table.add_row(
|
|
201
|
+
labels.get(check.name, check.name),
|
|
202
|
+
f"[{status_style}]{status_text}[/{status_style}]",
|
|
203
|
+
check.message,
|
|
204
|
+
check.suggestion or "-",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
border_style = "green" if report.ok else "red"
|
|
208
|
+
console.print(
|
|
209
|
+
Panel(
|
|
210
|
+
table,
|
|
211
|
+
border_style=border_style,
|
|
212
|
+
width=_terminal_width(),
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def render_submission_result(result: dict[str, Any] | None) -> None:
|
|
218
|
+
if result is None:
|
|
219
|
+
error("判题超时,请稍后到 LeetCode 查看结果")
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
status_msg = str(result.get("status_msg") or "-")
|
|
223
|
+
runtime = str(result.get("status_runtime") or result.get("runtime") or "-")
|
|
224
|
+
memory = str(result.get("memory") or "-")
|
|
225
|
+
total_correct = result.get("total_correct")
|
|
226
|
+
total_testcases = result.get("total_testcases")
|
|
227
|
+
|
|
228
|
+
if status_msg == "Accepted":
|
|
229
|
+
success("通过")
|
|
230
|
+
else:
|
|
231
|
+
error(f"提交失败:{status_msg}")
|
|
232
|
+
|
|
233
|
+
table = Table(
|
|
234
|
+
title="判题结果", border_style="green" if status_msg == "Accepted" else "red"
|
|
235
|
+
)
|
|
236
|
+
table.add_column("Item", style="cyan", no_wrap=True)
|
|
237
|
+
table.add_column("Value", style="white")
|
|
238
|
+
table.add_row("Status", status_msg)
|
|
239
|
+
table.add_row("Runtime", runtime)
|
|
240
|
+
table.add_row("Memory", memory)
|
|
241
|
+
if total_correct is not None and total_testcases is not None:
|
|
242
|
+
table.add_row("Cases", f"{total_correct} / {total_testcases}")
|
|
243
|
+
|
|
244
|
+
console.print(
|
|
245
|
+
Panel(
|
|
246
|
+
table,
|
|
247
|
+
border_style="green" if status_msg == "Accepted" else "red",
|
|
248
|
+
width=_terminal_width(),
|
|
249
|
+
)
|
|
250
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
PACKAGE_NAME = "leetcode-local-cli"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_version() -> str:
|
|
8
|
+
"""Return the installed distribution version."""
|
|
9
|
+
try:
|
|
10
|
+
return version(PACKAGE_NAME)
|
|
11
|
+
except PackageNotFoundError:
|
|
12
|
+
return "unknown"
|