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,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
|