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.
@@ -0,0 +1,373 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ BASE_URL = "https://leetcode.cn"
8
+ USER_AGENT = (
9
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
10
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
11
+ "Chrome/135.0.0.0 Safari/537.36"
12
+ )
13
+
14
+ USER_STATUS_QUERY = {
15
+ "query": """
16
+ query userStatus {
17
+ userStatus {
18
+ isSignedIn
19
+ username
20
+ realName
21
+ avatar
22
+ isPremium
23
+ }
24
+ }
25
+ """
26
+ }
27
+
28
+ DIFFICULTY_MAP = {
29
+ 1: "Easy",
30
+ 2: "Medium",
31
+ 3: "Hard",
32
+ }
33
+
34
+ PROBLEM_LIST_QUERY = """
35
+ query problemsetQuestionList(
36
+ $categorySlug: String,
37
+ $limit: Int,
38
+ $skip: Int,
39
+ $filters: QuestionListFilterInput
40
+ ) {
41
+ problemsetQuestionList(
42
+ categorySlug: $categorySlug,
43
+ limit: $limit,
44
+ skip: $skip,
45
+ filters: $filters
46
+ ) {
47
+ total
48
+ questions {
49
+ frontendQuestionId
50
+ title
51
+ titleSlug
52
+ difficulty
53
+ paidOnly
54
+ topicTags {
55
+ name
56
+ slug
57
+ }
58
+ }
59
+ }
60
+ }
61
+ """
62
+
63
+ QUESTION_DETAIL_QUERY = """
64
+ query questionData($titleSlug: String!) {
65
+ question(titleSlug: $titleSlug) {
66
+ questionId
67
+ questionFrontendId
68
+ title
69
+ translatedTitle
70
+ titleSlug
71
+ difficulty
72
+ content
73
+ translatedContent
74
+ topicTags {
75
+ name
76
+ slug
77
+ }
78
+ codeSnippets {
79
+ lang
80
+ langSlug
81
+ code
82
+ }
83
+ }
84
+ }
85
+ """
86
+
87
+
88
+ class ClientErrorKind(str, Enum):
89
+ NETWORK = "network"
90
+ HTTP = "http"
91
+ INVALID_JSON = "invalid_json"
92
+ INVALID_RESPONSE = "invalid_response"
93
+ UNAUTHORIZED = "unauthorized"
94
+ MISSING_CSRF = "missing_csrf"
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class ClientResult:
99
+ data: Any = None
100
+ error: ClientErrorKind | None = None
101
+ message: str = ""
102
+
103
+ @property
104
+ def ok(self) -> bool:
105
+ return self.error is None
106
+
107
+
108
+ class LeetCodeClient:
109
+ def __init__(self, cookies: dict[str, str] | None = None):
110
+ self.client = httpx.Client(
111
+ base_url=BASE_URL,
112
+ follow_redirects=True,
113
+ timeout=20.0,
114
+ headers={
115
+ "User-Agent": USER_AGENT,
116
+ "Accept": "application/json, text/plain, */*",
117
+ "Origin": BASE_URL,
118
+ "Referer": f"{BASE_URL}/",
119
+ },
120
+ )
121
+ if cookies:
122
+ self.client.cookies.update(cookies)
123
+
124
+ def _request_json(
125
+ self,
126
+ method: str,
127
+ path: str,
128
+ *,
129
+ timeout: float,
130
+ **kwargs: Any,
131
+ ) -> ClientResult:
132
+ try:
133
+ response = self.client.request(
134
+ method,
135
+ path,
136
+ timeout=timeout,
137
+ **kwargs,
138
+ )
139
+ response.raise_for_status()
140
+ except httpx.RequestError:
141
+ return ClientResult(error=ClientErrorKind.NETWORK)
142
+ except httpx.HTTPStatusError:
143
+ return ClientResult(error=ClientErrorKind.HTTP)
144
+
145
+ try:
146
+ result = response.json()
147
+ except ValueError:
148
+ return ClientResult(error=ClientErrorKind.INVALID_JSON)
149
+ if not isinstance(result, dict):
150
+ return ClientResult(error=ClientErrorKind.INVALID_RESPONSE)
151
+ return ClientResult(data=result)
152
+
153
+ def user_status(self) -> ClientResult:
154
+ result = self._request_json(
155
+ "POST",
156
+ "/graphql/",
157
+ json=USER_STATUS_QUERY,
158
+ timeout=10,
159
+ )
160
+ if not result.ok:
161
+ return result
162
+ payload = result.data
163
+ assert isinstance(payload, dict)
164
+ data = payload.get("data")
165
+ if not isinstance(data, dict):
166
+ return ClientResult(error=ClientErrorKind.INVALID_RESPONSE)
167
+ status = data.get("userStatus")
168
+ if not isinstance(status, dict):
169
+ return ClientResult(error=ClientErrorKind.INVALID_RESPONSE)
170
+ is_signed_in = status.get("isSignedIn")
171
+ username = status.get("username")
172
+ if not isinstance(is_signed_in, bool) or (
173
+ is_signed_in and (not isinstance(username, str) or not username)
174
+ ):
175
+ return ClientResult(error=ClientErrorKind.INVALID_RESPONSE)
176
+ return ClientResult(data=status)
177
+
178
+ def problem_stats(self) -> ClientResult:
179
+ result = self._request_json("GET", "/api/problems/all/", timeout=20)
180
+ if not result.ok:
181
+ return result
182
+ payload = result.data
183
+ assert isinstance(payload, dict)
184
+
185
+ stats = {
186
+ "solved": {
187
+ "All": 0,
188
+ "Easy": 0,
189
+ "Medium": 0,
190
+ "Hard": 0,
191
+ },
192
+ "total": {
193
+ "All": 0,
194
+ "Easy": 0,
195
+ "Medium": 0,
196
+ "Hard": 0,
197
+ },
198
+ }
199
+ items = payload.get("stat_status_pairs")
200
+ if not isinstance(items, list):
201
+ return ClientResult(
202
+ error=ClientErrorKind.INVALID_RESPONSE,
203
+ message="stat_status_pairs is not a list",
204
+ )
205
+ for item in items:
206
+ if not isinstance(item, dict):
207
+ return ClientResult(error=ClientErrorKind.INVALID_RESPONSE)
208
+ difficulty_data = item.get("difficulty")
209
+ if not isinstance(difficulty_data, dict):
210
+ return ClientResult(error=ClientErrorKind.INVALID_RESPONSE)
211
+ difficulty_level = difficulty_data.get("level")
212
+ difficulty = (
213
+ DIFFICULTY_MAP.get(difficulty_level)
214
+ if isinstance(difficulty_level, int)
215
+ else None
216
+ )
217
+
218
+ if not difficulty:
219
+ continue
220
+
221
+ stats["total"]["All"] += 1
222
+ stats["total"][difficulty] += 1
223
+
224
+ if item.get("status") == "ac":
225
+ stats["solved"]["All"] += 1
226
+ stats["solved"][difficulty] += 1
227
+
228
+ return ClientResult(data=stats)
229
+
230
+ def account_profile(self) -> ClientResult:
231
+ status_result = self.user_status()
232
+ if not status_result.ok:
233
+ return status_result
234
+ status = status_result.data
235
+ problem_profile_result = self.problem_stats()
236
+ if not problem_profile_result.ok:
237
+ return problem_profile_result
238
+ problem_profile = problem_profile_result.data
239
+ if not isinstance(status, dict):
240
+ return ClientResult(error=ClientErrorKind.INVALID_RESPONSE)
241
+ if not status.get("isSignedIn"):
242
+ return ClientResult(error=ClientErrorKind.UNAUTHORIZED)
243
+ if not isinstance(problem_profile, dict):
244
+ return ClientResult(
245
+ error=ClientErrorKind.INVALID_RESPONSE,
246
+ message="problem stats is not a dict",
247
+ )
248
+ return ClientResult(
249
+ data={
250
+ "username": status.get("username"),
251
+ "real_name": status.get("realName"),
252
+ "avatar": status.get("avatar"),
253
+ "is_premium": status.get("isPremium"),
254
+ "solved": problem_profile.get("solved"),
255
+ "total": problem_profile.get("total"),
256
+ }
257
+ )
258
+
259
+ def problem_list(self, limit: int = 50, skip: int = 0) -> ClientResult:
260
+ payload = {
261
+ "operationName": "problemsetQuestionList",
262
+ "query": PROBLEM_LIST_QUERY,
263
+ "variables": {
264
+ "categorySlug": "",
265
+ "limit": limit,
266
+ "skip": skip,
267
+ "filters": {},
268
+ },
269
+ }
270
+ result = self._request_json(
271
+ "POST",
272
+ "/graphql/",
273
+ json=payload,
274
+ timeout=10,
275
+ )
276
+ if not result.ok:
277
+ return result
278
+ response_payload = result.data
279
+ assert isinstance(response_payload, dict)
280
+ data = response_payload.get("data")
281
+ if not isinstance(data, dict):
282
+ return ClientResult(error=ClientErrorKind.INVALID_RESPONSE)
283
+ problem_list = data.get("problemsetQuestionList")
284
+ if not isinstance(problem_list, dict):
285
+ return ClientResult(error=ClientErrorKind.INVALID_RESPONSE)
286
+ questions = problem_list.get("questions")
287
+ total = problem_list.get("total")
288
+ if (
289
+ not isinstance(questions, list)
290
+ or not all(isinstance(item, dict) for item in questions)
291
+ or not isinstance(total, int)
292
+ ):
293
+ return ClientResult(error=ClientErrorKind.INVALID_RESPONSE)
294
+ return ClientResult(data=problem_list)
295
+
296
+ def problem_detail(self, title_slug: str) -> ClientResult:
297
+ payload = {
298
+ "operationName": "questionData",
299
+ "query": QUESTION_DETAIL_QUERY,
300
+ "variables": {
301
+ "titleSlug": title_slug,
302
+ },
303
+ }
304
+ result = self._request_json(
305
+ "POST",
306
+ "/graphql/",
307
+ json=payload,
308
+ timeout=10,
309
+ )
310
+ if not result.ok:
311
+ return result
312
+ response_payload = result.data
313
+ assert isinstance(response_payload, dict)
314
+ data = response_payload.get("data")
315
+ if not isinstance(data, dict):
316
+ return ClientResult(error=ClientErrorKind.INVALID_RESPONSE)
317
+ question = data.get("question")
318
+ if not isinstance(question, dict):
319
+ return ClientResult(error=ClientErrorKind.INVALID_RESPONSE)
320
+ return ClientResult(data=question)
321
+
322
+ def submit_solution(
323
+ self, title_slug: str, question_id: str, code: str
324
+ ) -> ClientResult:
325
+ payload = {
326
+ "lang": "python3",
327
+ "question_id": question_id,
328
+ "typed_code": code,
329
+ }
330
+ csrftoken = self.client.cookies.get("csrftoken")
331
+ if not csrftoken:
332
+ return ClientResult(error=ClientErrorKind.MISSING_CSRF)
333
+ result = self._request_json(
334
+ "POST",
335
+ f"/problems/{title_slug}/submit/",
336
+ json=payload,
337
+ headers={
338
+ "X-CSRFToken": csrftoken,
339
+ "Referer": f"{BASE_URL}/problems/{title_slug}/",
340
+ },
341
+ timeout=10,
342
+ )
343
+ if not result.ok:
344
+ return result
345
+ response_payload = result.data
346
+ assert isinstance(response_payload, dict)
347
+ submission_id = response_payload.get("submission_id")
348
+ if not isinstance(submission_id, int) or isinstance(submission_id, bool):
349
+ return ClientResult(error=ClientErrorKind.INVALID_RESPONSE)
350
+ return ClientResult(data=submission_id)
351
+
352
+ def get_submission_result(self, submission_id: int) -> ClientResult:
353
+ result = self._request_json(
354
+ "GET",
355
+ f"/submissions/detail/{submission_id}/check/",
356
+ timeout=10,
357
+ )
358
+ if not result.ok:
359
+ return result
360
+ payload = result.data
361
+ assert isinstance(payload, dict)
362
+ if not isinstance(payload.get("state"), str):
363
+ return ClientResult(error=ClientErrorKind.INVALID_RESPONSE)
364
+ return result
365
+
366
+ def close(self) -> None:
367
+ self.client.close()
368
+
369
+ def __enter__(self):
370
+ return self
371
+
372
+ def __exit__(self, exc_type, exc_val, exc_tb):
373
+ self.close()
@@ -0,0 +1,277 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ from pathlib import Path
4
+ import subprocess
5
+
6
+ from leetcode_local_cli.auth import SessionFileStatus, inspect_session_file
7
+ from leetcode_local_cli.client import ClientErrorKind, ClientResult
8
+ from leetcode_local_cli.workspace import (
9
+ SolutionFileStatus,
10
+ inspect_solution_file,
11
+ run_solution_file,
12
+ )
13
+
14
+
15
+ SESSION_CHECK_NAME = "session"
16
+ CONNECTIVITY_CHECK_NAME = "connectivity"
17
+ AUTHENTICATION_CHECK_NAME = "authentication"
18
+ SOLUTION_CHECK_NAME = "solution"
19
+ SOLUTION_RUN_TIMEOUT_SECONDS = 10
20
+
21
+
22
+ class DoctorStatus(str, Enum):
23
+ PASS = "pass"
24
+ WARNING = "warning"
25
+ FAIL = "fail"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class DoctorCheck:
30
+ name: str
31
+ status: DoctorStatus
32
+ message: str
33
+ suggestion: str | None = None
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class DoctorReport:
38
+ checks: tuple[DoctorCheck, ...]
39
+
40
+ @property
41
+ def ok(self) -> bool:
42
+ """Return whether the report contains no failed checks."""
43
+ return not any(check.status == DoctorStatus.FAIL for check in self.checks)
44
+
45
+
46
+ def diagnose_session(path: Path | None = None) -> DoctorCheck:
47
+ """Translate the local session inspection into a user-facing check.
48
+
49
+ The returned check may contain safe metadata and missing cookie names, but
50
+ it must never include cookie values.
51
+ """
52
+ inspection = inspect_session_file(path)
53
+ match inspection.status:
54
+ case SessionFileStatus.VALID:
55
+ username = inspection.username or "未知"
56
+ source = inspection.source or "未知"
57
+ return DoctorCheck(
58
+ name=SESSION_CHECK_NAME,
59
+ status=DoctorStatus.PASS,
60
+ message=f"有效(用户:{username},来源:{source})",
61
+ )
62
+
63
+ case SessionFileStatus.MISSING:
64
+ return DoctorCheck(
65
+ name=SESSION_CHECK_NAME,
66
+ status=DoctorStatus.FAIL,
67
+ message="未找到 Session 文件",
68
+ suggestion="请执行 uv run lc login 创建登录态",
69
+ )
70
+
71
+ case SessionFileStatus.READ_ERROR:
72
+ return DoctorCheck(
73
+ name=SESSION_CHECK_NAME,
74
+ status=DoctorStatus.FAIL,
75
+ message="无法读取 Session 文件",
76
+ suggestion="请检查文件权限,或重新执行 uv run lc login",
77
+ )
78
+
79
+ case SessionFileStatus.INVALID_JSON:
80
+ return DoctorCheck(
81
+ name=SESSION_CHECK_NAME,
82
+ status=DoctorStatus.FAIL,
83
+ message="Session 文件不是有效的 JSON",
84
+ suggestion="请执行 uv run lc login 重新生成登录态",
85
+ )
86
+
87
+ case SessionFileStatus.INVALID_STRUCTURE:
88
+ return DoctorCheck(
89
+ name=SESSION_CHECK_NAME,
90
+ status=DoctorStatus.FAIL,
91
+ message="Session 文件结构无效",
92
+ suggestion="请执行 uv run lc login 重新生成登录态",
93
+ )
94
+
95
+ case SessionFileStatus.MISSING_COOKIES:
96
+ missing = "、".join(inspection.missing_cookie_names)
97
+ return DoctorCheck(
98
+ name=SESSION_CHECK_NAME,
99
+ status=DoctorStatus.FAIL,
100
+ message=f"缺少或无效的 Cookie:{missing}",
101
+ suggestion="请执行 uv run lc login 刷新 Cookie",
102
+ )
103
+
104
+
105
+ def diagnose_solution(path: Path | None = None) -> DoctorCheck:
106
+ """Inspect solution.py and verify its local entry point in a subprocess."""
107
+ inspection = inspect_solution_file(path)
108
+ if inspection.status in {
109
+ SolutionFileStatus.READY,
110
+ SolutionFileStatus.NOT_SUBMITTABLE,
111
+ }:
112
+ runtime_failure = _diagnose_solution_runtime(path)
113
+ if runtime_failure is not None:
114
+ return runtime_failure
115
+
116
+ match inspection.status:
117
+ case SolutionFileStatus.READY:
118
+ metadata = inspection.metadata
119
+ target = (
120
+ f"{metadata.problem_id}. {metadata.title}" if metadata else "未知题目"
121
+ )
122
+ return DoctorCheck(
123
+ name=SOLUTION_CHECK_NAME,
124
+ status=DoctorStatus.PASS,
125
+ message=f"solution.py 语法、提交信息与本地运行正常({target})",
126
+ )
127
+
128
+ case SolutionFileStatus.MISSING:
129
+ return DoctorCheck(
130
+ name=SOLUTION_CHECK_NAME,
131
+ status=DoctorStatus.WARNING,
132
+ message="未找到 solution.py",
133
+ suggestion="请执行 uv run lc solve <题号> 创建解题文件",
134
+ )
135
+
136
+ case SolutionFileStatus.EMPTY:
137
+ return DoctorCheck(
138
+ name=SOLUTION_CHECK_NAME,
139
+ status=DoctorStatus.WARNING,
140
+ message="solution.py 当前为空",
141
+ suggestion="请执行 uv run lc solve <题号> 生成解题模板",
142
+ )
143
+
144
+ case SolutionFileStatus.READ_ERROR:
145
+ return DoctorCheck(
146
+ name=SOLUTION_CHECK_NAME,
147
+ status=DoctorStatus.FAIL,
148
+ message="无法读取 solution.py",
149
+ suggestion="请检查文件权限",
150
+ )
151
+
152
+ case SolutionFileStatus.INVALID_SYNTAX:
153
+ line = (
154
+ f"第 {inspection.syntax_line} 行"
155
+ if inspection.syntax_line
156
+ else "未知行"
157
+ )
158
+ return DoctorCheck(
159
+ name=SOLUTION_CHECK_NAME,
160
+ status=DoctorStatus.FAIL,
161
+ message=f"solution.py 存在 Python 语法错误({line})",
162
+ suggestion="请修复语法错误后重新执行 uv run lc doctor",
163
+ )
164
+
165
+ case SolutionFileStatus.NOT_SUBMITTABLE:
166
+ return DoctorCheck(
167
+ name=SOLUTION_CHECK_NAME,
168
+ status=DoctorStatus.WARNING,
169
+ message=f"solution.py 可编译,但暂不可提交:{inspection.detail}",
170
+ suggestion="请执行 uv run lc solve <题号> 重新生成标准模板",
171
+ )
172
+
173
+
174
+ def _diagnose_solution_runtime(path: Path | None) -> DoctorCheck | None:
175
+ try:
176
+ result = run_solution_file(
177
+ path,
178
+ timeout=SOLUTION_RUN_TIMEOUT_SECONDS,
179
+ )
180
+ except subprocess.TimeoutExpired:
181
+ return DoctorCheck(
182
+ name=SOLUTION_CHECK_NAME,
183
+ status=DoctorStatus.FAIL,
184
+ message=f"solution.py 本地运行超时({SOLUTION_RUN_TIMEOUT_SECONDS} 秒)",
185
+ suggestion="请检查死循环或耗时过长的本地测试",
186
+ )
187
+ except OSError:
188
+ return DoctorCheck(
189
+ name=SOLUTION_CHECK_NAME,
190
+ status=DoctorStatus.FAIL,
191
+ message="无法启动 Python 运行 solution.py",
192
+ suggestion="请检查当前 Python 环境",
193
+ )
194
+ if result.returncode:
195
+ return DoctorCheck(
196
+ name=SOLUTION_CHECK_NAME,
197
+ status=DoctorStatus.FAIL,
198
+ message=f"solution.py 本地运行失败(退出码:{result.returncode})",
199
+ suggestion="请执行 uv run lc test 定位本地测试错误",
200
+ )
201
+ return None
202
+
203
+
204
+ def diagnose_remote(result: ClientResult) -> tuple[DoctorCheck, DoctorCheck]:
205
+ """Translate one user-status request into connectivity and auth checks."""
206
+ if not result.ok:
207
+ match result.error:
208
+ case ClientErrorKind.NETWORK:
209
+ message = "无法连接 LeetCode 中文站"
210
+ suggestion = "请检查网络连接后重试"
211
+ case ClientErrorKind.HTTP:
212
+ message = "LeetCode 中文站返回异常 HTTP 状态"
213
+ suggestion = "请稍后重试"
214
+ case ClientErrorKind.INVALID_JSON:
215
+ message = "LeetCode 中文站响应无法解析"
216
+ suggestion = "请稍后重试,接口可能暂时异常"
217
+ case ClientErrorKind.INVALID_RESPONSE:
218
+ message = "LeetCode 中文站接口结构异常"
219
+ suggestion = "接口可能已变更,请检查项目更新"
220
+ case ClientErrorKind.UNAUTHORIZED | ClientErrorKind.MISSING_CSRF:
221
+ return (
222
+ DoctorCheck(
223
+ name=CONNECTIVITY_CHECK_NAME,
224
+ status=DoctorStatus.PASS,
225
+ message="LeetCode 中文站接口可访问",
226
+ ),
227
+ DoctorCheck(
228
+ name=AUTHENTICATION_CHECK_NAME,
229
+ status=DoctorStatus.FAIL,
230
+ message="登录凭证无效或不完整",
231
+ suggestion="请执行 uv run lc login 刷新登录态",
232
+ ),
233
+ )
234
+ case _:
235
+ message = "LeetCode 中文站诊断发生未知错误"
236
+ suggestion = "请稍后重试"
237
+
238
+ return (
239
+ DoctorCheck(
240
+ name=CONNECTIVITY_CHECK_NAME,
241
+ status=DoctorStatus.FAIL,
242
+ message=message,
243
+ suggestion=suggestion,
244
+ ),
245
+ DoctorCheck(
246
+ name=AUTHENTICATION_CHECK_NAME,
247
+ status=DoctorStatus.WARNING,
248
+ message="网络或接口异常,暂时无法验证 Cookie",
249
+ suggestion="请先解决连接问题后重新执行 uv run lc doctor",
250
+ ),
251
+ )
252
+
253
+ status = result.data
254
+ if not isinstance(status, dict):
255
+ return diagnose_remote(ClientResult(error=ClientErrorKind.INVALID_RESPONSE))
256
+
257
+ connectivity = DoctorCheck(
258
+ name=CONNECTIVITY_CHECK_NAME,
259
+ status=DoctorStatus.PASS,
260
+ message="LeetCode 中文站接口连接正常",
261
+ )
262
+ if not status.get("isSignedIn"):
263
+ authentication = DoctorCheck(
264
+ name=AUTHENTICATION_CHECK_NAME,
265
+ status=DoctorStatus.FAIL,
266
+ message="Cookie 无效、已过期或当前未登录",
267
+ suggestion="请执行 uv run lc login 刷新登录态",
268
+ )
269
+ else:
270
+ username = status.get("username")
271
+ username = username if isinstance(username, str) and username else "未知用户"
272
+ authentication = DoctorCheck(
273
+ name=AUTHENTICATION_CHECK_NAME,
274
+ status=DoctorStatus.PASS,
275
+ message=f"Cookie 有效(当前用户:{username})",
276
+ )
277
+ return connectivity, authentication