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 @@
|
|
|
1
|
+
"""LeetCode local workflow CLI package."""
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from getpass import getpass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import browser_cookie3
|
|
9
|
+
|
|
10
|
+
LC_DOMAIN = "leetcode.cn"
|
|
11
|
+
# BROWSER_LOADERS = [("Edge", browser_cookie3.edge), ("Chrome", browser_cookie3.chrome)]
|
|
12
|
+
BROWSER_LOADERS = [("Chrome", browser_cookie3.chrome)]
|
|
13
|
+
|
|
14
|
+
REQUIRED_COOKIE_NAMES = ("LEETCODE_SESSION", "csrftoken")
|
|
15
|
+
PROJECT_ROOT = Path.cwd()
|
|
16
|
+
SESSION_DIR = PROJECT_ROOT / ".leetcode_local_cli"
|
|
17
|
+
SESSION_FILE = SESSION_DIR / "session.json"
|
|
18
|
+
LEGACY_SESSION_DIR = PROJECT_ROOT / ".aether_lc"
|
|
19
|
+
LEGACY_SESSION_FILE = LEGACY_SESSION_DIR / "session.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SessionFileStatus(str, Enum):
|
|
23
|
+
VALID = "valid"
|
|
24
|
+
MISSING = "missing"
|
|
25
|
+
INVALID_JSON = "invalid_json"
|
|
26
|
+
INVALID_STRUCTURE = "invalid_structure"
|
|
27
|
+
MISSING_COOKIES = "missing_cookies"
|
|
28
|
+
READ_ERROR = "read_error"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class SessionFileInspection:
|
|
33
|
+
status: SessionFileStatus
|
|
34
|
+
missing_cookie_names: tuple[str, ...] = ()
|
|
35
|
+
username: str | None = None
|
|
36
|
+
source: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SessionFileError(OSError):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resolve_default_session_file() -> Path:
|
|
44
|
+
"""Move the pre-v0.7 session to the canonical directory when possible."""
|
|
45
|
+
if SESSION_FILE.exists() or not LEGACY_SESSION_FILE.exists():
|
|
46
|
+
return SESSION_FILE
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
SESSION_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
50
|
+
if os.name != "nt":
|
|
51
|
+
os.chmod(SESSION_DIR, 0o700)
|
|
52
|
+
LEGACY_SESSION_FILE.replace(SESSION_FILE)
|
|
53
|
+
if os.name != "nt":
|
|
54
|
+
os.chmod(SESSION_FILE, 0o600)
|
|
55
|
+
try:
|
|
56
|
+
LEGACY_SESSION_DIR.rmdir()
|
|
57
|
+
except OSError:
|
|
58
|
+
# Preserve a non-empty legacy directory instead of deleting unrelated data.
|
|
59
|
+
pass
|
|
60
|
+
except OSError:
|
|
61
|
+
if SESSION_FILE.exists():
|
|
62
|
+
return SESSION_FILE
|
|
63
|
+
return LEGACY_SESSION_FILE
|
|
64
|
+
|
|
65
|
+
return SESSION_FILE
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_cookies_from_browser() -> tuple[str, dict[str, str]] | None:
|
|
69
|
+
for browser_name, loader in BROWSER_LOADERS:
|
|
70
|
+
try:
|
|
71
|
+
cookie_jar = loader(domain_name=LC_DOMAIN)
|
|
72
|
+
|
|
73
|
+
except Exception:
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
cookies_dict: dict[str, str] = {
|
|
77
|
+
cookie.name: cookie.value
|
|
78
|
+
for cookie in cookie_jar
|
|
79
|
+
if (
|
|
80
|
+
cookie.domain
|
|
81
|
+
and cookie.domain.lstrip(".").endswith(LC_DOMAIN)
|
|
82
|
+
and cookie.value is not None
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
if all(name in cookies_dict for name in REQUIRED_COOKIE_NAMES):
|
|
86
|
+
return browser_name, {
|
|
87
|
+
"LEETCODE_SESSION": cookies_dict["LEETCODE_SESSION"],
|
|
88
|
+
"csrftoken": cookies_dict["csrftoken"],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse_cookie_header(cookies: str) -> dict[str, str] | None:
|
|
95
|
+
cookies_dict = {}
|
|
96
|
+
for item in cookies.split(";"):
|
|
97
|
+
if "=" in item:
|
|
98
|
+
key, val = item.strip().split("=", 1)
|
|
99
|
+
cookies_dict[key] = val
|
|
100
|
+
|
|
101
|
+
if all(name in cookies_dict for name in REQUIRED_COOKIE_NAMES):
|
|
102
|
+
return {
|
|
103
|
+
"LEETCODE_SESSION": cookies_dict["LEETCODE_SESSION"],
|
|
104
|
+
"csrftoken": cookies_dict["csrftoken"],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_cookies_from_input() -> dict[str, str] | None:
|
|
111
|
+
return parse_cookie_header(getpass("请粘贴 Cookie(输入内容不会回显):\n"))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def save_session(session_data: dict, path: Path | None = None) -> None:
|
|
115
|
+
if path is None:
|
|
116
|
+
_resolve_default_session_file()
|
|
117
|
+
file_path = path or SESSION_FILE
|
|
118
|
+
temporary_file = file_path.with_suffix(f"{file_path.suffix}.tmp")
|
|
119
|
+
try:
|
|
120
|
+
file_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
121
|
+
if path is None and os.name != "nt":
|
|
122
|
+
os.chmod(file_path.parent, 0o700)
|
|
123
|
+
descriptor = os.open(
|
|
124
|
+
temporary_file,
|
|
125
|
+
os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
|
|
126
|
+
0o600,
|
|
127
|
+
)
|
|
128
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as file:
|
|
129
|
+
json.dump(session_data, file, indent=4, ensure_ascii=False)
|
|
130
|
+
os.chmod(temporary_file, 0o600)
|
|
131
|
+
temporary_file.replace(file_path)
|
|
132
|
+
os.chmod(file_path, 0o600)
|
|
133
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
134
|
+
try:
|
|
135
|
+
temporary_file.unlink(missing_ok=True)
|
|
136
|
+
except OSError:
|
|
137
|
+
pass
|
|
138
|
+
raise SessionFileError("无法保存 Session 文件") from exc
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load_session() -> dict | None:
|
|
142
|
+
file_path = _resolve_default_session_file()
|
|
143
|
+
try:
|
|
144
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
145
|
+
return json.load(f)
|
|
146
|
+
except FileNotFoundError:
|
|
147
|
+
return None
|
|
148
|
+
except json.JSONDecodeError:
|
|
149
|
+
return None
|
|
150
|
+
except OSError as exc:
|
|
151
|
+
raise SessionFileError("无法读取 Session 文件") from exc
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def inspect_session_file(path: Path | None = None) -> SessionFileInspection:
|
|
155
|
+
if path is None:
|
|
156
|
+
path = _resolve_default_session_file()
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
content = path.read_text(encoding="utf-8")
|
|
160
|
+
|
|
161
|
+
except FileNotFoundError:
|
|
162
|
+
return SessionFileInspection(
|
|
163
|
+
status=SessionFileStatus.MISSING,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
except OSError:
|
|
167
|
+
return SessionFileInspection(
|
|
168
|
+
status=SessionFileStatus.READ_ERROR,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
data = json.loads(content)
|
|
173
|
+
|
|
174
|
+
except json.JSONDecodeError:
|
|
175
|
+
return SessionFileInspection(
|
|
176
|
+
status=SessionFileStatus.INVALID_JSON,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if not isinstance(data, dict):
|
|
180
|
+
return SessionFileInspection(
|
|
181
|
+
status=SessionFileStatus.INVALID_STRUCTURE,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
cookies = data.get("cookies")
|
|
185
|
+
|
|
186
|
+
if not isinstance(cookies, dict):
|
|
187
|
+
return SessionFileInspection(
|
|
188
|
+
status=SessionFileStatus.INVALID_STRUCTURE,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
missing_cookie_names: list[str] = []
|
|
192
|
+
|
|
193
|
+
for cookie_name in REQUIRED_COOKIE_NAMES:
|
|
194
|
+
cookie_value = cookies.get(cookie_name)
|
|
195
|
+
|
|
196
|
+
if not isinstance(cookie_value, str) or cookie_value == "":
|
|
197
|
+
missing_cookie_names.append(cookie_name)
|
|
198
|
+
|
|
199
|
+
if missing_cookie_names:
|
|
200
|
+
return SessionFileInspection(
|
|
201
|
+
status=SessionFileStatus.MISSING_COOKIES,
|
|
202
|
+
missing_cookie_names=tuple(missing_cookie_names),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
username, source = data.get("username"), data.get("source")
|
|
206
|
+
|
|
207
|
+
if not isinstance(username, str):
|
|
208
|
+
username = None
|
|
209
|
+
|
|
210
|
+
if not isinstance(source, str):
|
|
211
|
+
source = None
|
|
212
|
+
|
|
213
|
+
return SessionFileInspection(
|
|
214
|
+
status=SessionFileStatus.VALID, username=username, source=source
|
|
215
|
+
)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
from typer import Exit, Option, Typer, echo
|
|
5
|
+
|
|
6
|
+
from leetcode_local_cli.auth import (
|
|
7
|
+
SessionFileError,
|
|
8
|
+
get_cookies_from_browser,
|
|
9
|
+
get_cookies_from_input,
|
|
10
|
+
save_session,
|
|
11
|
+
)
|
|
12
|
+
from leetcode_local_cli.client import ClientErrorKind, LeetCodeClient
|
|
13
|
+
from leetcode_local_cli.ui import (
|
|
14
|
+
loading,
|
|
15
|
+
render_doctor_report,
|
|
16
|
+
render_submission_result,
|
|
17
|
+
success,
|
|
18
|
+
warning,
|
|
19
|
+
error,
|
|
20
|
+
render_profile,
|
|
21
|
+
render_problem_detail,
|
|
22
|
+
render_problem_list,
|
|
23
|
+
)
|
|
24
|
+
from leetcode_local_cli.service import (
|
|
25
|
+
client_error_message,
|
|
26
|
+
get_account_profile,
|
|
27
|
+
get_doctor_report,
|
|
28
|
+
get_problem_detail_by_question_id,
|
|
29
|
+
get_problem_summaries,
|
|
30
|
+
get_user_status,
|
|
31
|
+
submit_current_solution,
|
|
32
|
+
)
|
|
33
|
+
from leetcode_local_cli.workspace import (
|
|
34
|
+
ProblemMetadata,
|
|
35
|
+
SolutionFileStatus,
|
|
36
|
+
inspect_solution_file,
|
|
37
|
+
run_solution_file,
|
|
38
|
+
write_solution_file,
|
|
39
|
+
)
|
|
40
|
+
from leetcode_local_cli.version import PACKAGE_NAME, get_version
|
|
41
|
+
|
|
42
|
+
app = Typer(help="力扣中文站本地化刷题 CLI 工具", no_args_is_help=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _configure_utf8_output() -> None:
|
|
46
|
+
"""Keep localized CLI output writable when Windows redirects the streams."""
|
|
47
|
+
for stream in (sys.stdout, sys.stderr):
|
|
48
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
49
|
+
if callable(reconfigure):
|
|
50
|
+
reconfigure(encoding="utf-8", errors="backslashreplace")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run() -> None:
|
|
54
|
+
"""Run the command-line application with deterministic UTF-8 output."""
|
|
55
|
+
_configure_utf8_output()
|
|
56
|
+
app()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _version_callback(value: bool) -> None:
|
|
60
|
+
if value:
|
|
61
|
+
echo(f"{PACKAGE_NAME} {get_version()}")
|
|
62
|
+
raise Exit()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.callback()
|
|
66
|
+
def main(
|
|
67
|
+
version: Annotated[
|
|
68
|
+
bool,
|
|
69
|
+
Option(
|
|
70
|
+
"--version",
|
|
71
|
+
callback=_version_callback,
|
|
72
|
+
is_eager=True,
|
|
73
|
+
help="显示版本并退出",
|
|
74
|
+
),
|
|
75
|
+
] = False,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""力扣中文站本地化刷题 CLI 工具。"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command()
|
|
81
|
+
def login() -> None:
|
|
82
|
+
with loading("正在读取浏览器 Cookie..."):
|
|
83
|
+
browser_result = get_cookies_from_browser()
|
|
84
|
+
if not browser_result:
|
|
85
|
+
warning("未从浏览器读取到 Cookie,请手动粘贴")
|
|
86
|
+
source, cookies = "manual", get_cookies_from_input()
|
|
87
|
+
else:
|
|
88
|
+
source, cookies = browser_result
|
|
89
|
+
if not cookies:
|
|
90
|
+
warning("未获取有效 Cookie")
|
|
91
|
+
raise Exit(1)
|
|
92
|
+
with LeetCodeClient(cookies) as client:
|
|
93
|
+
status_result = client.user_status()
|
|
94
|
+
status = status_result.data
|
|
95
|
+
if not status_result.ok:
|
|
96
|
+
error(client_error_message(status_result.error))
|
|
97
|
+
raise Exit(1)
|
|
98
|
+
if isinstance(status, dict) and status.get("isSignedIn"):
|
|
99
|
+
username = status.get("username")
|
|
100
|
+
if not isinstance(username, str) or not username:
|
|
101
|
+
error(client_error_message(ClientErrorKind.INVALID_RESPONSE))
|
|
102
|
+
raise Exit(1)
|
|
103
|
+
try:
|
|
104
|
+
save_session(
|
|
105
|
+
{
|
|
106
|
+
"site": "leetcode.cn",
|
|
107
|
+
"source": source,
|
|
108
|
+
"username": username,
|
|
109
|
+
"cookies": cookies,
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
except SessionFileError as exc:
|
|
113
|
+
error(str(exc))
|
|
114
|
+
raise Exit(1)
|
|
115
|
+
success("成功登录")
|
|
116
|
+
else:
|
|
117
|
+
warning("Cookie 无效或已过期")
|
|
118
|
+
raise Exit(1)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command()
|
|
122
|
+
def status() -> None:
|
|
123
|
+
user_status = get_user_status()
|
|
124
|
+
username = user_status.get("username", "未知用户")
|
|
125
|
+
success(f"在线状态: 当前账号 {username}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@app.command()
|
|
129
|
+
def profile() -> None:
|
|
130
|
+
account_profile = get_account_profile()
|
|
131
|
+
render_profile(account_profile)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@app.command()
|
|
135
|
+
def get(question_id: str) -> None:
|
|
136
|
+
problem_detail = get_problem_detail_by_question_id(question_id)
|
|
137
|
+
render_problem_detail(problem_detail)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.command()
|
|
141
|
+
def show(limit: int = 50, skip: int = 0) -> None:
|
|
142
|
+
problem_summaries = get_problem_summaries(limit=limit, skip=skip)
|
|
143
|
+
render_problem_list(problem_summaries)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.command()
|
|
147
|
+
def solve(question_id: str) -> None:
|
|
148
|
+
problem_detail = get_problem_detail_by_question_id(question_id)
|
|
149
|
+
render_problem_detail(problem_detail)
|
|
150
|
+
if not problem_detail.python_code:
|
|
151
|
+
error("题目未提供 Python3 代码模板,无法生成 solution.py")
|
|
152
|
+
raise Exit(1)
|
|
153
|
+
if (
|
|
154
|
+
not problem_detail.question_id
|
|
155
|
+
or not problem_detail.submit_question_id
|
|
156
|
+
or not problem_detail.title
|
|
157
|
+
or not problem_detail.title_slug
|
|
158
|
+
):
|
|
159
|
+
error("题目元信息不完整,无法生成可提交的 solution.py")
|
|
160
|
+
raise Exit(1)
|
|
161
|
+
write_solution_file(
|
|
162
|
+
problem_detail.python_code,
|
|
163
|
+
ProblemMetadata(
|
|
164
|
+
problem_id=problem_detail.question_id,
|
|
165
|
+
submit_question_id=problem_detail.submit_question_id,
|
|
166
|
+
title=problem_detail.title,
|
|
167
|
+
title_slug=problem_detail.title_slug,
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@app.command()
|
|
173
|
+
def test() -> None:
|
|
174
|
+
inspection = inspect_solution_file()
|
|
175
|
+
match inspection.status:
|
|
176
|
+
case SolutionFileStatus.MISSING:
|
|
177
|
+
error("未找到 solution.py,请先执行 lc solve <题号>")
|
|
178
|
+
raise Exit(1)
|
|
179
|
+
case SolutionFileStatus.EMPTY:
|
|
180
|
+
error("solution.py 当前为空,请先执行 lc solve <题号>")
|
|
181
|
+
raise Exit(1)
|
|
182
|
+
case SolutionFileStatus.READ_ERROR:
|
|
183
|
+
error("无法读取 solution.py,请检查文件权限")
|
|
184
|
+
raise Exit(1)
|
|
185
|
+
case SolutionFileStatus.INVALID_SYNTAX:
|
|
186
|
+
line = (
|
|
187
|
+
f"第 {inspection.syntax_line} 行"
|
|
188
|
+
if inspection.syntax_line
|
|
189
|
+
else "未知行"
|
|
190
|
+
)
|
|
191
|
+
error(f"solution.py 存在 Python 语法错误({line})")
|
|
192
|
+
raise Exit(1)
|
|
193
|
+
|
|
194
|
+
result = run_solution_file()
|
|
195
|
+
if result.returncode:
|
|
196
|
+
error("本地测试失败")
|
|
197
|
+
raise Exit(result.returncode)
|
|
198
|
+
success("本地测试通过")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@app.command()
|
|
202
|
+
def doctor() -> None:
|
|
203
|
+
with loading("正在检查本地环境与 LeetCode 连接..."):
|
|
204
|
+
report = get_doctor_report()
|
|
205
|
+
render_doctor_report(report)
|
|
206
|
+
if not report.ok:
|
|
207
|
+
raise Exit(1)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@app.command()
|
|
211
|
+
def submit() -> None:
|
|
212
|
+
result = submit_current_solution()
|
|
213
|
+
render_submission_result(result)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
run()
|