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 @@
1
+ """LeetCode local workflow CLI package."""
@@ -0,0 +1,3 @@
1
+ from .cli import run
2
+
3
+ run()
@@ -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()