bb-sync 1.9.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.
- bb_sync/__init__.py +23 -0
- bb_sync/__main__.py +8 -0
- bb_sync/blackboard/__init__.py +5 -0
- bb_sync/blackboard/downloader.py +348 -0
- bb_sync/blackboard/login.py +190 -0
- bb_sync/blackboard/models.py +143 -0
- bb_sync/blackboard/scraper.py +695 -0
- bb_sync/browser/__init__.py +5 -0
- bb_sync/browser/steel.py +511 -0
- bb_sync/cli.py +302 -0
- bb_sync/commands/__init__.py +5 -0
- bb_sync/commands/auth.py +80 -0
- bb_sync/commands/config.py +239 -0
- bb_sync/commands/course.py +45 -0
- bb_sync/commands/doctor.py +65 -0
- bb_sync/commands/due.py +66 -0
- bb_sync/commands/run.py +74 -0
- bb_sync/context.py +69 -0
- bb_sync/core/__init__.py +5 -0
- bb_sync/core/config.py +205 -0
- bb_sync/core/config_store.py +190 -0
- bb_sync/core/doctor.py +230 -0
- bb_sync/core/due.py +192 -0
- bb_sync/core/errors.py +107 -0
- bb_sync/core/output.py +151 -0
- bb_sync/core/service.py +446 -0
- bb_sync/core/update.py +286 -0
- bb_sync/creds.py +91 -0
- bb_sync/paths.py +26 -0
- bb_sync-1.9.0.dist-info/METADATA +265 -0
- bb_sync-1.9.0.dist-info/RECORD +34 -0
- bb_sync-1.9.0.dist-info/WHEEL +4 -0
- bb_sync-1.9.0.dist-info/entry_points.txt +2 -0
- bb_sync-1.9.0.dist-info/licenses/LICENSE +21 -0
bb_sync/core/doctor.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""环境体检:逐项检查前置条件,输出 ✓/✗ 与修复提示。
|
|
2
|
+
|
|
3
|
+
必查项(缺失即视为环境不可用): Python / git / Node.js≥22 / npm / 浏览器 / 网络。
|
|
4
|
+
可选展示项: 凭据 / Steel 后端状态。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from urllib import error, request
|
|
18
|
+
|
|
19
|
+
from bb_sync import __version__
|
|
20
|
+
from bb_sync.browser import steel
|
|
21
|
+
from bb_sync.core.errors import EnvironmentError_
|
|
22
|
+
from bb_sync.core.output import Console
|
|
23
|
+
from bb_sync.creds import load_credentials
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class CheckResult:
|
|
28
|
+
"""单项体检结果。"""
|
|
29
|
+
|
|
30
|
+
ok: bool
|
|
31
|
+
label: str
|
|
32
|
+
detail: str = ""
|
|
33
|
+
hint: str = ""
|
|
34
|
+
required: bool = True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _tool_version(cmd: list[str]) -> str | None:
|
|
38
|
+
"""运行 ``<tool> --version``,返回首行;失败返回 None。"""
|
|
39
|
+
try:
|
|
40
|
+
out = subprocess.run(
|
|
41
|
+
cmd, capture_output=True, text=True, timeout=6, encoding="utf-8", errors="ignore"
|
|
42
|
+
)
|
|
43
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
44
|
+
return None
|
|
45
|
+
lines = (out.stdout or out.stderr).strip().splitlines()
|
|
46
|
+
return lines[0].strip() if lines and out.returncode == 0 else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _git_install_hint() -> str:
|
|
50
|
+
"""按平台给出 git 安装方式。"""
|
|
51
|
+
if sys.platform == "darwin":
|
|
52
|
+
return "bb-sync 从 GitHub 安装需要 git:brew install git"
|
|
53
|
+
if sys.platform.startswith("win"):
|
|
54
|
+
return "bb-sync 从 GitHub 安装需要 git:winget install --id Git.Git -e"
|
|
55
|
+
return "bb-sync 从 GitHub 安装需要 git:请通过系统包管理器安装"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _browser_install_hint() -> str:
|
|
59
|
+
"""按平台给出浏览器安装方式。"""
|
|
60
|
+
if sys.platform == "darwin":
|
|
61
|
+
return "macOS 可运行 brew install --cask google-chrome 或 microsoft-edge"
|
|
62
|
+
if sys.platform.startswith("win"):
|
|
63
|
+
return (
|
|
64
|
+
"安装 Google Chrome 或使用 Windows 自带的 Edge;非默认位置时设置 CHROME_EXECUTABLE_PATH"
|
|
65
|
+
)
|
|
66
|
+
return "安装 Google Chrome、Chromium 或 Microsoft Edge;非默认位置时设置 CHROME_EXECUTABLE_PATH"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def check_python() -> CheckResult:
|
|
70
|
+
v = sys.version_info
|
|
71
|
+
return CheckResult(
|
|
72
|
+
(v.major, v.minor) >= (3, 11),
|
|
73
|
+
"Python",
|
|
74
|
+
f"{v.major}.{v.minor}.{v.micro}(需要 ≥3.11,uv 安装 bb-sync 时会自动准备)",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def check_git() -> CheckResult:
|
|
79
|
+
path = shutil.which("git")
|
|
80
|
+
ver = _tool_version([path, "--version"]) if path else None
|
|
81
|
+
return CheckResult(
|
|
82
|
+
bool(ver),
|
|
83
|
+
"git",
|
|
84
|
+
ver or "未找到",
|
|
85
|
+
_git_install_hint(),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def check_node() -> CheckResult:
|
|
90
|
+
try:
|
|
91
|
+
path = steel._node_bin()
|
|
92
|
+
except EnvironmentError_:
|
|
93
|
+
path = None
|
|
94
|
+
ver = _tool_version([path, "--version"]) if path else None
|
|
95
|
+
ok = False
|
|
96
|
+
if ver:
|
|
97
|
+
m = re.match(r"v(\d+)", ver)
|
|
98
|
+
ok = bool(m) and int(m.group(1)) >= 22
|
|
99
|
+
return CheckResult(
|
|
100
|
+
ok,
|
|
101
|
+
"Node.js",
|
|
102
|
+
ver or "未找到",
|
|
103
|
+
steel._node_install_hint() + "(自带 npm)",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def check_npm() -> CheckResult:
|
|
108
|
+
path = steel._npm_bin()
|
|
109
|
+
ver = _tool_version([path, "--version"]) if path else None
|
|
110
|
+
return CheckResult(
|
|
111
|
+
bool(ver),
|
|
112
|
+
"npm",
|
|
113
|
+
ver or "未找到",
|
|
114
|
+
"npm 随 Node.js 一起安装,单独缺失请重装 Node.js",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def check_browser() -> CheckResult:
|
|
119
|
+
custom = os.environ.get("CHROME_EXECUTABLE_PATH")
|
|
120
|
+
if custom and Path(custom).exists():
|
|
121
|
+
return CheckResult(True, "浏览器", f"自定义 {custom}")
|
|
122
|
+
browser = steel.detect_browser()
|
|
123
|
+
if browser:
|
|
124
|
+
name = "Edge" if "edge" in Path(browser).name.lower() else "Chrome"
|
|
125
|
+
return CheckResult(True, "浏览器", f"{name} ({browser})")
|
|
126
|
+
return CheckResult(
|
|
127
|
+
False,
|
|
128
|
+
"浏览器",
|
|
129
|
+
"未检测到 Chrome/Edge",
|
|
130
|
+
_browser_install_hint(),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def check_network(retries: int = 3) -> CheckResult:
|
|
135
|
+
"""检查 github.com 可达性(首次部署 Steel 需要;代理抖动会瞬时失败,故重试)。"""
|
|
136
|
+
last_err = ""
|
|
137
|
+
for _ in range(retries):
|
|
138
|
+
try:
|
|
139
|
+
req = request.Request("https://github.com", headers={"User-Agent": "bb-sync"})
|
|
140
|
+
request.urlopen(req, timeout=6)
|
|
141
|
+
return CheckResult(True, "网络", "github.com 可达")
|
|
142
|
+
except error.HTTPError: # 有响应即视为可达(如 301/403)
|
|
143
|
+
return CheckResult(True, "网络", "github.com 可达")
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
last_err = str(exc)
|
|
146
|
+
time.sleep(1.5)
|
|
147
|
+
return CheckResult(
|
|
148
|
+
False, "网络", f"github.com 不可达 ({last_err})", "代理用户请确认系统代理已开启"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def check_credentials() -> CheckResult:
|
|
153
|
+
try:
|
|
154
|
+
creds = load_credentials()
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
return CheckResult(
|
|
157
|
+
False, "凭据", f"读取失败: {exc}", "运行 bb-sync auth login 重新录入", required=False
|
|
158
|
+
)
|
|
159
|
+
if creds.ok:
|
|
160
|
+
return CheckResult(True, "凭据", f"已保存({creds.source})", required=False)
|
|
161
|
+
return CheckResult(
|
|
162
|
+
False, "凭据", "未配置", "运行 bb-sync auth login 录入学号密码", required=False
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def check_steel() -> CheckResult:
|
|
167
|
+
if steel.healthy():
|
|
168
|
+
return CheckResult(True, "Steel 后端", "服务端运行中", required=False)
|
|
169
|
+
if steel.is_deployed():
|
|
170
|
+
ref = steel.installed_ref()
|
|
171
|
+
label = f"{ref[:7]}…" if ref and len(ref) > 12 else ref
|
|
172
|
+
detail = f"已部署({label},未运行)" if label else "已部署(未运行,同步时自动启动)"
|
|
173
|
+
return CheckResult(True, "Steel 后端", detail, required=False)
|
|
174
|
+
return CheckResult(
|
|
175
|
+
False,
|
|
176
|
+
"Steel 后端",
|
|
177
|
+
"未部署",
|
|
178
|
+
"首次同步时自动下载部署(依赖 Node.js 与网络),无需手动操作",
|
|
179
|
+
required=False,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
#: 体检项顺序即输出顺序
|
|
184
|
+
CHECKS = (
|
|
185
|
+
check_python,
|
|
186
|
+
check_git,
|
|
187
|
+
check_node,
|
|
188
|
+
check_npm,
|
|
189
|
+
check_browser,
|
|
190
|
+
check_network,
|
|
191
|
+
check_credentials,
|
|
192
|
+
check_steel,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def run_doctor(console: Console, *, skip_network: bool = False) -> list[CheckResult]:
|
|
197
|
+
"""执行全部体检项,逐项输出,返回结果列表。"""
|
|
198
|
+
console.step(f"bb-sync {__version__} — doctor")
|
|
199
|
+
console.log("")
|
|
200
|
+
results: list[CheckResult] = []
|
|
201
|
+
for fn in CHECKS:
|
|
202
|
+
if skip_network and fn is check_network:
|
|
203
|
+
continue
|
|
204
|
+
result = fn()
|
|
205
|
+
results.append(result)
|
|
206
|
+
mark = "✓" if result.ok else "✗"
|
|
207
|
+
line = f"[{mark}] {result.label}" + (f" {result.detail}" if result.detail else "")
|
|
208
|
+
if result.ok:
|
|
209
|
+
console.log(line)
|
|
210
|
+
else:
|
|
211
|
+
console.error(line)
|
|
212
|
+
if result.hint:
|
|
213
|
+
console.hint(result.hint)
|
|
214
|
+
|
|
215
|
+
required_fail = sum(1 for r in results if r.required and not r.ok)
|
|
216
|
+
total_ok = sum(1 for r in results if r.ok)
|
|
217
|
+
console.log(
|
|
218
|
+
f"\n{total_ok}/{len(results)} 项通过"
|
|
219
|
+
+ (",存在缺失项,请按提示修复" if required_fail else "")
|
|
220
|
+
)
|
|
221
|
+
return results
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def environment_ok(console: Console) -> bool:
|
|
225
|
+
"""供 ``run`` 前置校验使用:必查项全过才返回 True。"""
|
|
226
|
+
missing = [r for r in run_doctor(console) if r.required and not r.ok]
|
|
227
|
+
return not missing
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
__all__ = ["CHECKS", "CheckResult", "environment_ok", "run_doctor"]
|
bb_sync/core/due.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""把课程 Due / To Do 数据渲染为按优先级排列的 ``due.md``。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections import Counter, defaultdict
|
|
7
|
+
from datetime import date, datetime, timedelta
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from bb_sync.blackboard.models import BASE, Course, DueItem
|
|
11
|
+
|
|
12
|
+
#: 分组顺序就是学生处理任务的优先级;「已过期事件」只是留档回看,所以排最后。
|
|
13
|
+
_BUCKET_ORDER = ("overdue", "today", "tomorrow", "next_7_days", "future", "unknown", "past_events")
|
|
14
|
+
_BUCKET_LABELS = {
|
|
15
|
+
"overdue": "已逾期",
|
|
16
|
+
"today": "今天",
|
|
17
|
+
"tomorrow": "明天",
|
|
18
|
+
"next_7_days": "未来 7 天",
|
|
19
|
+
"future": "之后",
|
|
20
|
+
"unknown": "日期未知",
|
|
21
|
+
"past_events": "已过期事件",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#: 非普通任务的待办类型标签(来自公告的课堂测验 / 考试);顺序即汇总文案里的出现顺序。
|
|
25
|
+
_KIND_LABELS = {"quiz": "测验", "exam": "考试"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def kind_label(kind: str) -> str:
|
|
29
|
+
"""返回待办类型的中文标签;普通任务返回空串。"""
|
|
30
|
+
return _KIND_LABELS.get(kind, "")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_past_event(item: DueItem, today: date) -> bool:
|
|
34
|
+
"""公告里的一次性事件(测验 / 考试)日期已过:它只能回看,没法再「做」。
|
|
35
|
+
|
|
36
|
+
与 ``overdue`` 的区别:To Do 模块的逾期任务意味着「还没交」,仍然要行动;
|
|
37
|
+
考完的测验 / 考试已是历史,混进待办里只会淹没真正要做的事。
|
|
38
|
+
"""
|
|
39
|
+
return item.kind in _KIND_LABELS and item.due_date is not None and item.due_date < today
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _bucket(item: DueItem, today: date) -> str:
|
|
43
|
+
"""按剩余时间归类;无法解析日期的任务放最后,避免被误判成低优先级。"""
|
|
44
|
+
if item.due_date is None:
|
|
45
|
+
return "unknown"
|
|
46
|
+
if _is_past_event(item, today):
|
|
47
|
+
return "past_events"
|
|
48
|
+
if item.due_date < today:
|
|
49
|
+
return "overdue"
|
|
50
|
+
if item.due_date == today:
|
|
51
|
+
return "today"
|
|
52
|
+
if item.due_date == today + timedelta(days=1):
|
|
53
|
+
return "tomorrow"
|
|
54
|
+
if item.due_date <= today + timedelta(days=7):
|
|
55
|
+
return "next_7_days"
|
|
56
|
+
return "future"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def relative_due(item: DueItem, today: date) -> str:
|
|
60
|
+
"""生成截止日期旁的中文提示。"""
|
|
61
|
+
if item.due_date is None:
|
|
62
|
+
return item.due_text or "日期未知"
|
|
63
|
+
delta = (item.due_date - today).days
|
|
64
|
+
if delta < 0:
|
|
65
|
+
# 逾期的 To Do 是「还没交」,公告里的测验 / 考试是「已经考过」,措辞分开。
|
|
66
|
+
return f"已过 {-delta} 天" if item.kind in _KIND_LABELS else f"逾期 {-delta} 天"
|
|
67
|
+
if delta == 0:
|
|
68
|
+
return "今天"
|
|
69
|
+
if delta == 1:
|
|
70
|
+
return "明天"
|
|
71
|
+
return f"还有 {delta} 天"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _escape_markdown(text: str) -> str:
|
|
75
|
+
"""转义会改变 Markdown 结构的常见字符,同时保留可读标题。"""
|
|
76
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
77
|
+
return re.sub(r"([\\`*_\[\]])", r"\\\1", text)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _sort_key(item: DueItem) -> tuple[date, str, str]:
|
|
81
|
+
return (item.due_date or date.max, item.course_label, item.title)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def sort_due_items(items: list[DueItem]) -> list[DueItem]:
|
|
85
|
+
"""按截止日期、课程和任务名返回稳定排序的新列表。"""
|
|
86
|
+
return sorted(items, key=_sort_key)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_due_markdown(
|
|
90
|
+
courses: list[Course],
|
|
91
|
+
items: list[DueItem],
|
|
92
|
+
*,
|
|
93
|
+
failed: int = 0,
|
|
94
|
+
today: date | None = None,
|
|
95
|
+
generated_at: datetime | None = None,
|
|
96
|
+
) -> str:
|
|
97
|
+
"""生成全局 ``due.md`` 文本。
|
|
98
|
+
|
|
99
|
+
输出按「已逾期 → 今天 → 明天 → 未来 7 天 → 之后 → 日期未知 → 已过期事件」排序;
|
|
100
|
+
同一分组内再按日期、课程和任务名稳定排序。
|
|
101
|
+
"""
|
|
102
|
+
day = today or date.today()
|
|
103
|
+
generated = generated_at or datetime.now().astimezone()
|
|
104
|
+
grouped: dict[str, list[DueItem]] = defaultdict(list)
|
|
105
|
+
for item in items:
|
|
106
|
+
grouped[_bucket(item, day)].append(item)
|
|
107
|
+
for bucket_items in grouped.values():
|
|
108
|
+
bucket_items.sort(key=_sort_key)
|
|
109
|
+
|
|
110
|
+
# 过期事件单独成区,汇总行也不把它算进「待办」,否则「测验 2 项」里混着考完的。
|
|
111
|
+
past_events = grouped.get("past_events", [])
|
|
112
|
+
kind_counts = Counter(item.kind for item in items if not _is_past_event(item, day))
|
|
113
|
+
# 只统计公告整理出的待办,按 _KIND_LABELS 的固定顺序列出(测验 → 考试)。
|
|
114
|
+
announcement_parts = [
|
|
115
|
+
f"{label} {kind_counts[kind]} 项"
|
|
116
|
+
for kind, label in _KIND_LABELS.items()
|
|
117
|
+
if kind_counts[kind]
|
|
118
|
+
]
|
|
119
|
+
lines = [
|
|
120
|
+
"# Blackboard 待办",
|
|
121
|
+
"",
|
|
122
|
+
f"> 更新时间:{generated.strftime('%Y-%m-%d %H:%M')}(本地时间)",
|
|
123
|
+
f"> 已检查 {len(courses)} 门课程,发现 {len(items)} 项待办;按优先级和截止日期排列。",
|
|
124
|
+
]
|
|
125
|
+
if announcement_parts:
|
|
126
|
+
lines.append(f"> 其中公告里的{'、'.join(announcement_parts)}已一并作为待办列出。")
|
|
127
|
+
if past_events:
|
|
128
|
+
lines.append(f"> 另有 {len(past_events)} 项公告里的测验/考试日期已过,列在最后仅供回看。")
|
|
129
|
+
if failed:
|
|
130
|
+
lines.append(f"> 注意:{failed} 门课程抓取失败,结果可能不完整。")
|
|
131
|
+
lines.append("")
|
|
132
|
+
|
|
133
|
+
if not items:
|
|
134
|
+
if failed:
|
|
135
|
+
lines.extend(["本次未读取到待办,但由于部分课程抓取失败,结果可能不完整。", ""])
|
|
136
|
+
else:
|
|
137
|
+
lines.extend(["## Congratulations! 🎉", "", "当前没有待办事项。", ""])
|
|
138
|
+
return "\n".join(lines)
|
|
139
|
+
|
|
140
|
+
for bucket in _BUCKET_ORDER:
|
|
141
|
+
bucket_items = grouped.get(bucket, [])
|
|
142
|
+
if not bucket_items:
|
|
143
|
+
continue
|
|
144
|
+
lines.append(f"## {_BUCKET_LABELS[bucket]}({len(bucket_items)})")
|
|
145
|
+
lines.append("")
|
|
146
|
+
for item in bucket_items:
|
|
147
|
+
due = item.due_date.isoformat() if item.due_date else "日期未知"
|
|
148
|
+
course_url = (
|
|
149
|
+
f"{BASE}/webapps/blackboard/execute/launcher?type=Course&id={item.course_id}"
|
|
150
|
+
)
|
|
151
|
+
label = kind_label(item.kind)
|
|
152
|
+
badge = f"【{label}】" if label else ""
|
|
153
|
+
lines.append(
|
|
154
|
+
f"- **{due}**({relative_due(item, day)}) — "
|
|
155
|
+
f"[{_escape_markdown(item.course_label)}]({course_url}) — "
|
|
156
|
+
f"{badge}{_escape_markdown(item.title)}"
|
|
157
|
+
)
|
|
158
|
+
lines.append("")
|
|
159
|
+
return "\n".join(lines)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def write_due_markdown(
|
|
163
|
+
path: Path,
|
|
164
|
+
courses: list[Course],
|
|
165
|
+
items: list[DueItem],
|
|
166
|
+
*,
|
|
167
|
+
failed: int = 0,
|
|
168
|
+
today: date | None = None,
|
|
169
|
+
generated_at: datetime | None = None,
|
|
170
|
+
) -> Path:
|
|
171
|
+
"""写入 ``due.md``,父目录不存在时自动创建。"""
|
|
172
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
path.write_text(
|
|
174
|
+
build_due_markdown(
|
|
175
|
+
courses,
|
|
176
|
+
items,
|
|
177
|
+
failed=failed,
|
|
178
|
+
today=today,
|
|
179
|
+
generated_at=generated_at,
|
|
180
|
+
),
|
|
181
|
+
encoding="utf-8",
|
|
182
|
+
)
|
|
183
|
+
return path
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
__all__ = [
|
|
187
|
+
"build_due_markdown",
|
|
188
|
+
"kind_label",
|
|
189
|
+
"relative_due",
|
|
190
|
+
"sort_due_items",
|
|
191
|
+
"write_due_markdown",
|
|
192
|
+
]
|
bb_sync/core/errors.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""异常体系与退出码。
|
|
2
|
+
|
|
3
|
+
约定:CLI 层只捕获 :class:`BbSyncError`,由它自带的 ``exit_code`` 决定进程退出码;
|
|
4
|
+
其他未预期异常统一归为 ``GENERAL_ERROR``,并打印堆栈方便报 bug。
|
|
5
|
+
|
|
6
|
+
退出码语义(对齐 BSD sysexits 精神,保持小整数便于脚本判断)::
|
|
7
|
+
|
|
8
|
+
0 SUCCESS 成功
|
|
9
|
+
1 GENERAL_ERROR 未分类错误
|
|
10
|
+
2 USAGE_ERROR 命令行用法错误(Typer/Click 自身也用 2)
|
|
11
|
+
3 AUTH_ERROR 登录 / 凭据失败
|
|
12
|
+
4 CONFIG_ERROR 配置文件问题
|
|
13
|
+
5 NETWORK_ERROR 网络 / 代理 / 下载失败
|
|
14
|
+
6 NOT_FOUND 目标资源不存在
|
|
15
|
+
7 ENVIRONMENT_ERROR 依赖缺失(Node / 浏览器 / Steel 等)
|
|
16
|
+
8 INTERRUPTED 用户中断(Ctrl+C)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from enum import IntEnum
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ExitCode(IntEnum):
|
|
25
|
+
"""进程退出码。"""
|
|
26
|
+
|
|
27
|
+
SUCCESS = 0
|
|
28
|
+
GENERAL_ERROR = 1
|
|
29
|
+
USAGE_ERROR = 2
|
|
30
|
+
AUTH_ERROR = 3
|
|
31
|
+
CONFIG_ERROR = 4
|
|
32
|
+
NETWORK_ERROR = 5
|
|
33
|
+
NOT_FOUND = 6
|
|
34
|
+
ENVIRONMENT_ERROR = 7
|
|
35
|
+
INTERRUPTED = 8
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BbSyncError(Exception):
|
|
39
|
+
"""所有可预期错误的基类,自带退出码与可选修复提示。"""
|
|
40
|
+
|
|
41
|
+
exit_code: ExitCode = ExitCode.GENERAL_ERROR
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str, hint: str = "") -> None:
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.message = message
|
|
46
|
+
self.hint = hint
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class UsageError(BbSyncError):
|
|
50
|
+
"""命令行参数用法错误。"""
|
|
51
|
+
|
|
52
|
+
exit_code = ExitCode.USAGE_ERROR
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ConfigError(BbSyncError):
|
|
56
|
+
"""配置文件缺失、格式错误或不支持的操作。"""
|
|
57
|
+
|
|
58
|
+
exit_code = ExitCode.CONFIG_ERROR
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AuthError(BbSyncError):
|
|
62
|
+
"""登录失败、凭据无效或缺失。"""
|
|
63
|
+
|
|
64
|
+
exit_code = ExitCode.AUTH_ERROR
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class NetworkError(BbSyncError):
|
|
68
|
+
"""网络不可达、代理异常、下载失败。"""
|
|
69
|
+
|
|
70
|
+
exit_code = ExitCode.NETWORK_ERROR
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class NotFoundError(BbSyncError):
|
|
74
|
+
"""课程 / 配置键 / 文件等目标不存在。"""
|
|
75
|
+
|
|
76
|
+
exit_code = ExitCode.NOT_FOUND
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class EnvironmentError_(BbSyncError):
|
|
80
|
+
"""运行环境不满足(Node / npm / 浏览器 / Steel 未就绪)。
|
|
81
|
+
|
|
82
|
+
名字带下划线后缀,避免与内置 :class:`EnvironmentError` 混淆。
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
exit_code = ExitCode.ENVIRONMENT_ERROR
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def exit_code_for(exc: BaseException) -> ExitCode:
|
|
89
|
+
"""把任意异常映射为退出码。"""
|
|
90
|
+
if isinstance(exc, BbSyncError):
|
|
91
|
+
return exc.exit_code
|
|
92
|
+
if isinstance(exc, KeyboardInterrupt):
|
|
93
|
+
return ExitCode.INTERRUPTED
|
|
94
|
+
return ExitCode.GENERAL_ERROR
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
__all__ = [
|
|
98
|
+
"AuthError",
|
|
99
|
+
"BbSyncError",
|
|
100
|
+
"ConfigError",
|
|
101
|
+
"EnvironmentError_",
|
|
102
|
+
"ExitCode",
|
|
103
|
+
"NetworkError",
|
|
104
|
+
"NotFoundError",
|
|
105
|
+
"UsageError",
|
|
106
|
+
"exit_code_for",
|
|
107
|
+
]
|
bb_sync/core/output.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""输出通道:结果走 stdout,诊断走 stderr。
|
|
2
|
+
|
|
3
|
+
Unix CLI 的核心约定,本项目严格遵守:
|
|
4
|
+
|
|
5
|
+
====================== ========================= ====================
|
|
6
|
+
通道 内容 典型用法
|
|
7
|
+
====================== ========================= ====================
|
|
8
|
+
stdout 命令的「数据结果」 ``bb-sync course list | jq '.[].code'``
|
|
9
|
+
stderr 日志 / 进度 / 警告 / 错误 人读,或 ``2>/dev/null`` 丢弃
|
|
10
|
+
====================== ========================= ====================
|
|
11
|
+
|
|
12
|
+
:class:`Console` 是唯一的输出入口,两个职责:
|
|
13
|
+
|
|
14
|
+
1. **结果输出**(``result`` / ``table`` / ``emit``)—— 支持「人读」与「JSON」双模式。
|
|
15
|
+
机器模式(``--json``)下,只有 ``emit`` 会往 stdout 吐**一个** JSON 文档,
|
|
16
|
+
Rich 表格与人类可读文本全部被抑制,保证 stdout 是干净可解析的。
|
|
17
|
+
2. **诊断输出**(``log`` / ``warn`` / ``error`` / ``success`` / ``hint``)—— 一律走 stderr,
|
|
18
|
+
且随 ``--quiet`` 降级。
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import sys
|
|
25
|
+
from collections.abc import Iterable, Sequence
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from rich.console import Console as RichConsole
|
|
29
|
+
from rich.table import Table
|
|
30
|
+
|
|
31
|
+
_NO_COLOR_ENV = "NO_COLOR"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Console:
|
|
35
|
+
"""统一输出出口。
|
|
36
|
+
|
|
37
|
+
:param json_mode: ``--json``:stdout 只输出机器可读 JSON。
|
|
38
|
+
:param quiet: ``--quiet``:抑制 stderr 上的普通日志(错误与警告仍显示)。
|
|
39
|
+
:param verbose: ``--verbose``:stderr 上追加调试信息。
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, *, json_mode: bool = False, quiet: bool = False, verbose: bool = False):
|
|
43
|
+
self.json_mode = json_mode
|
|
44
|
+
self.quiet = quiet
|
|
45
|
+
self.verbose = verbose
|
|
46
|
+
# 结果通道:人读内容可能带颜色;JSON 模式绝不带颜色
|
|
47
|
+
self._out = RichConsole(
|
|
48
|
+
file=sys.stdout,
|
|
49
|
+
soft_wrap=True,
|
|
50
|
+
no_color=json_mode,
|
|
51
|
+
highlight=False,
|
|
52
|
+
)
|
|
53
|
+
# 诊断通道:始终 stderr,颜色随 TTY 自适应
|
|
54
|
+
self._err = RichConsole(file=sys.stderr, stderr=True, soft_wrap=True, highlight=False)
|
|
55
|
+
|
|
56
|
+
# ------------------------------------------------------------ 诊断(stderr)
|
|
57
|
+
def log(self, message: str = "", *, level: str = "info") -> None:
|
|
58
|
+
"""常规日志 → stderr。``--quiet`` 时不显示。"""
|
|
59
|
+
if self.quiet:
|
|
60
|
+
return
|
|
61
|
+
style = {"info": "dim", "step": "cyan"}.get(level, "dim")
|
|
62
|
+
self._err.print(message, style=style, markup=False, highlight=False)
|
|
63
|
+
|
|
64
|
+
def step(self, message: str) -> None:
|
|
65
|
+
"""阶段性标题 → stderr,如 ``[scan] 获取课程列表 ...``。"""
|
|
66
|
+
if self.quiet:
|
|
67
|
+
return
|
|
68
|
+
self._err.print(message, style="bold cyan", markup=False, highlight=False)
|
|
69
|
+
|
|
70
|
+
def success(self, message: str) -> None:
|
|
71
|
+
"""成功提示 → stderr,带 ✓。"""
|
|
72
|
+
if self.quiet:
|
|
73
|
+
return
|
|
74
|
+
self._err.print(f"✓ {message}", style="green", markup=False, highlight=False)
|
|
75
|
+
|
|
76
|
+
def warn(self, message: str) -> None:
|
|
77
|
+
"""警告 → stderr,``--quiet`` 时仍显示。"""
|
|
78
|
+
self._err.print(f"! {message}", style="yellow", markup=False, highlight=False)
|
|
79
|
+
|
|
80
|
+
def error(self, message: str) -> None:
|
|
81
|
+
"""错误 → stderr,始终显示。"""
|
|
82
|
+
self._err.print(f"✗ {message}", style="bold red", markup=False, highlight=False)
|
|
83
|
+
|
|
84
|
+
def hint(self, message: str) -> None:
|
|
85
|
+
"""修复建议 → stderr,缩进显示在错误下方。"""
|
|
86
|
+
self._err.print(f" → {message}", style="dim", markup=False, highlight=False)
|
|
87
|
+
|
|
88
|
+
def debug(self, message: str) -> None:
|
|
89
|
+
"""调试信息 → stderr,仅 ``--verbose`` 显示。"""
|
|
90
|
+
if self.verbose and not self.quiet:
|
|
91
|
+
self._err.print(f"[debug] {message}", style="dim", markup=False, highlight=False)
|
|
92
|
+
|
|
93
|
+
def rule(self, title: str = "") -> None:
|
|
94
|
+
"""分隔线 → stderr。"""
|
|
95
|
+
if not self.quiet:
|
|
96
|
+
self._err.rule(title, style="dim")
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------ 结果(stdout)
|
|
99
|
+
def emit(self, data: Any) -> None:
|
|
100
|
+
"""输出结构化结果到 stdout。
|
|
101
|
+
|
|
102
|
+
人读模式(默认)只输出**标量**——因为 ``bb-sync run`` 的结果人类是通过
|
|
103
|
+
stderr 上的汇总看的,stdout 应保持干净,避免干扰 ``$(...)`` 取值。
|
|
104
|
+
容器类型(dict/list)在人读模式下**不输出**,仅在 ``--json`` 时输出,
|
|
105
|
+
这样 ``--json`` 的语义就是「stdout 上有且仅有一个 JSON 文档」。
|
|
106
|
+
"""
|
|
107
|
+
if self.json_mode:
|
|
108
|
+
self._write_json(data)
|
|
109
|
+
return
|
|
110
|
+
if isinstance(data, (str, int, float, bool)):
|
|
111
|
+
self._out.print(str(data), markup=False, highlight=False)
|
|
112
|
+
|
|
113
|
+
def _write_json(self, data: Any) -> None:
|
|
114
|
+
"""stdout 上输出单个 JSON 文档,UTF-8 原样保留中文。"""
|
|
115
|
+
text = json.dumps(data, ensure_ascii=False, indent=2, default=str)
|
|
116
|
+
sys.stdout.write(text + "\n")
|
|
117
|
+
sys.stdout.flush()
|
|
118
|
+
|
|
119
|
+
def table(
|
|
120
|
+
self,
|
|
121
|
+
columns: Sequence[str],
|
|
122
|
+
rows: Iterable[Sequence[Any]],
|
|
123
|
+
*,
|
|
124
|
+
title: str = "",
|
|
125
|
+
json_data: Any = None,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""表格输出。
|
|
128
|
+
|
|
129
|
+
``--json`` 模式下抑制表格,改为输出 ``json_data``(缺省时把 rows 转成
|
|
130
|
+
以 columns 为键的记录列表),保证脚本拿到的是稳定结构。
|
|
131
|
+
"""
|
|
132
|
+
rows = list(rows)
|
|
133
|
+
if self.json_mode:
|
|
134
|
+
if json_data is None:
|
|
135
|
+
json_data = [dict(zip(columns, r, strict=False)) for r in rows]
|
|
136
|
+
self._write_json(json_data)
|
|
137
|
+
return
|
|
138
|
+
table = Table(title=title or None, header_style="bold", box=None, pad_edge=False)
|
|
139
|
+
for col in columns:
|
|
140
|
+
table.add_column(str(col))
|
|
141
|
+
for row in rows:
|
|
142
|
+
table.add_row(*[str(c) for c in row])
|
|
143
|
+
# 表格本身是「人读结果」,所以走 stdout
|
|
144
|
+
self._out.print(table if table.row_count else "", markup=False)
|
|
145
|
+
|
|
146
|
+
def print_raw(self, text: str) -> None:
|
|
147
|
+
"""逐字输出到 stdout(不加样式),用于 ``config show`` 之类。"""
|
|
148
|
+
self._out.print(text, markup=False, highlight=False, soft_wrap=True)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
__all__ = ["Console"]
|