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/cli.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""bb-sync 命令行入口(Typer + Click 延迟注册)。
|
|
2
|
+
|
|
3
|
+
设计要点:
|
|
4
|
+
|
|
5
|
+
- **全局选项与子命令分离**:``--json/--quiet/--verbose/--config`` 是全局开关,
|
|
6
|
+
子命令只管自己的业务参数
|
|
7
|
+
- **退出码语义化**:所有异常在 :func:`main` 统一兜底,由
|
|
8
|
+
:class:`~bb_sync.core.errors.BbSyncError` 决定退出码,脚本可据此分支
|
|
9
|
+
- **stdout/stderr 严格分流**:结果走 stdout(``--json`` 时是纯 JSON),
|
|
10
|
+
日志/进度/错误走 stderr
|
|
11
|
+
- **冷启动优先**:命令模块延迟导入(见 :class:`LazyGroup`),
|
|
12
|
+
避免 ``--version`` / ``--help`` 被 playwright/pydantic/keyring 拖慢
|
|
13
|
+
|
|
14
|
+
用法速查::
|
|
15
|
+
|
|
16
|
+
bb-sync run # 增量同步全部课程
|
|
17
|
+
bb-sync run --course CSC5010 # 只同步一门课
|
|
18
|
+
bb-sync course list --json # 机器可读地列出课程
|
|
19
|
+
bb-sync config set root ~/University/courses
|
|
20
|
+
bb-sync doctor
|
|
21
|
+
bb-sync auth login / logout / status
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import importlib
|
|
27
|
+
import sys
|
|
28
|
+
from types import ModuleType
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import click
|
|
32
|
+
|
|
33
|
+
from bb_sync import ISSUES_URL, __version__
|
|
34
|
+
from bb_sync.core.errors import BbSyncError, ExitCode, exit_code_for
|
|
35
|
+
|
|
36
|
+
#: 命令名 → (模块名, 一句话帮助)
|
|
37
|
+
SUBCOMMANDS: dict[str, tuple[str, str]] = {
|
|
38
|
+
"run": ("run", "执行一次课程同步"),
|
|
39
|
+
"due": ("due", "整理全部课程的待办到 due.md"),
|
|
40
|
+
"doctor": ("doctor", "体检:检查前置条件(git / Node / 浏览器 / 网络等)"),
|
|
41
|
+
"config": ("config", "查看 / 修改配置(如默认下载目录)"),
|
|
42
|
+
"auth": ("auth", "凭据管理:登录录入 / 退出 / 查看状态"),
|
|
43
|
+
"course": ("course", "课程操作(列出可同步的课程)"),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#: 扁平命令(无下级子命令):模块里直接定义 ``<name>_cmd`` 函数,
|
|
47
|
+
#: 其余模块用 ``app = typer.Typer(...)`` 声明为命令组
|
|
48
|
+
FLAT_COMMANDS = frozenset({"run", "due", "doctor"})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _notify_update_after_command(result: Any, **_: Any) -> Any:
|
|
52
|
+
"""普通命令成功后检查更新;提示失败绝不改变命令结果。"""
|
|
53
|
+
ctx = click.get_current_context(silent=True)
|
|
54
|
+
app_ctx = ctx.obj if ctx is not None else None
|
|
55
|
+
if app_ctx is None or getattr(app_ctx, "quiet", False):
|
|
56
|
+
return result
|
|
57
|
+
try:
|
|
58
|
+
from bb_sync.core.update import notify_if_outdated
|
|
59
|
+
|
|
60
|
+
notify_if_outdated(app_ctx.console)
|
|
61
|
+
except Exception: # 更新检查是附加功能,不能影响正常命令
|
|
62
|
+
pass
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _as_click_group(obj: object) -> Any:
|
|
67
|
+
"""把命令模块导出的 Typer 实例转成 Click 命令组。
|
|
68
|
+
|
|
69
|
+
Typer 实例不是 Click 组,需要 ``typer.main.get_command`` 转换
|
|
70
|
+
(延迟到此处调用,避免在模块顶层导入 typer)。
|
|
71
|
+
"""
|
|
72
|
+
import typer
|
|
73
|
+
|
|
74
|
+
return typer.main.get_command(obj) # type: ignore[arg-type]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _module_command(mod: ModuleType, cmd_name: str) -> click.Command | click.Group:
|
|
78
|
+
"""取出命令模块对外暴露的命令对象(统一为 ``click.Command``)。
|
|
79
|
+
|
|
80
|
+
模块统一导出 ``app = typer.Typer(...)``,由本函数按命令类型解包:
|
|
81
|
+
|
|
82
|
+
- **扁平命令**(``FLAT_COMMANDS``,如 run / doctor):模块里只有一条与命令同名的
|
|
83
|
+
子命令,这里把它取出来直接平铺到根下,得到 ``bb-sync run [OPTIONS]``,
|
|
84
|
+
而不是多余的 ``bb-sync run run``
|
|
85
|
+
- **命令组**(config / auth / course):整体作为组挂上,
|
|
86
|
+
保留 ``bb-sync config set`` / ``bb-sync course list`` 这样的两级形态
|
|
87
|
+
"""
|
|
88
|
+
app = getattr(mod, "app", None)
|
|
89
|
+
if app is None:
|
|
90
|
+
raise TypeError(f"命令模块 {mod.__name__!r} 缺少 app(Typer 实例)")
|
|
91
|
+
group = _as_click_group(app)
|
|
92
|
+
|
|
93
|
+
if cmd_name in FLAT_COMMANDS:
|
|
94
|
+
return _unwrap_flat(group, mod.__name__, cmd_name)
|
|
95
|
+
return _as_real_group(group, mod.__name__, cmd_name)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _unwrap_flat(group: click.Command, mod_name: str, cmd_name: str) -> click.Command:
|
|
99
|
+
"""扁平命令:从(可能被折叠的)Typer 命令里取出唯一那条命令。
|
|
100
|
+
|
|
101
|
+
Typer 会把「只有一个子命令」的 app 直接折叠成那条命令(此时没有
|
|
102
|
+
``commands`` 属性),两种情况都要兼容,并统一把名字改成 ``cmd_name``,
|
|
103
|
+
避免出现 ``bb-sync run run``。
|
|
104
|
+
"""
|
|
105
|
+
if not hasattr(group, "commands"):
|
|
106
|
+
group.name = cmd_name
|
|
107
|
+
return group
|
|
108
|
+
|
|
109
|
+
commands = getattr(group, "commands", None) or {}
|
|
110
|
+
if len(commands) != 1:
|
|
111
|
+
raise TypeError(f"扁平命令模块 {mod_name!r} 应恰好声明 1 条命令,实际 {len(commands)} 条")
|
|
112
|
+
inner = next(iter(commands.values()))
|
|
113
|
+
inner.name = cmd_name
|
|
114
|
+
return inner
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _as_real_group(group: click.Command, mod_name: str, cmd_name: str) -> click.Command:
|
|
118
|
+
"""命令组:保证返回的对象带 ``commands``,且名字与注册名一致。
|
|
119
|
+
|
|
120
|
+
**不能靠 ``isinstance(group, click.Group)`` 判断**——Typer 0.27 的组类型
|
|
121
|
+
``TyperGroup`` 并不继承 ``click.Group``(它继承 ``typer._click.core.Command``)。
|
|
122
|
+
因此这里用「有没有 ``commands`` 属性」作为「是不是命令组」的判据:
|
|
123
|
+
|
|
124
|
+
- 有 ``commands`` → 真命令组(如 config / auth),只把名字对齐注册名即可
|
|
125
|
+
- 没有 → 被 Typer 折叠成了单条命令(当 app 里的命令名与 app 名同名时会发生),
|
|
126
|
+
此时把它按真实子命令名重新包进一个 Click 组,否则
|
|
127
|
+
``bb-sync course list`` 里的 ``list`` 会被当成多余参数报 UsageError
|
|
128
|
+
"""
|
|
129
|
+
if hasattr(group, "commands"):
|
|
130
|
+
group.name = cmd_name
|
|
131
|
+
return group
|
|
132
|
+
|
|
133
|
+
# 被折叠成单条命令:用它的 name(真实子命令名)重建一个组
|
|
134
|
+
inner_name = group.name or ""
|
|
135
|
+
wrapper = click.Group(name=cmd_name, help=SUBCOMMANDS.get(cmd_name, ("", ""))[1])
|
|
136
|
+
wrapper.add_command(group, inner_name)
|
|
137
|
+
return wrapper
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class LazyGroup(click.Group):
|
|
141
|
+
"""按需导入子命令模块的 Click 命令组。
|
|
142
|
+
|
|
143
|
+
注册时只记下「命令名 → 模块名 + 一句话帮助」,真正解析到该命令时才
|
|
144
|
+
``import bb_sync.commands.<模块>``。这样 ``--version`` / ``--help`` 都不必
|
|
145
|
+
为 playwright、pydantic、keyring 的导入成本买单。
|
|
146
|
+
|
|
147
|
+
关键点:``--help`` 渲染命令列表时 Click 会逐个 ``get_command``;若在那里
|
|
148
|
+
做真实导入,help 就退化成全量加载。因此 :meth:`get_command` 分两级——
|
|
149
|
+
渲染帮助走 :meth:`_placeholder`(零导入),只有真正要执行命令时才导入。
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
153
|
+
super().__init__(*args, **kwargs)
|
|
154
|
+
self._loaded: set[str] = set()
|
|
155
|
+
#: 正在渲染根帮助(此时只需命令名与一句话说明,不必导入模块)
|
|
156
|
+
self._listing = False
|
|
157
|
+
|
|
158
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
159
|
+
return sorted(SUBCOMMANDS)
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _placeholder(name: str) -> click.Command:
|
|
163
|
+
"""给根帮助列表用的占位命令:只有元信息,不触发任何导入。"""
|
|
164
|
+
return click.Command(name, help=SUBCOMMANDS[name][1], params=[], callback=lambda: None)
|
|
165
|
+
|
|
166
|
+
def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
|
167
|
+
"""渲染根帮助的命令列表时只读本地元信息,不导入任何命令模块。"""
|
|
168
|
+
self._listing = True
|
|
169
|
+
try:
|
|
170
|
+
super().format_commands(ctx, formatter)
|
|
171
|
+
finally:
|
|
172
|
+
self._listing = False
|
|
173
|
+
|
|
174
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
|
175
|
+
if cmd_name not in SUBCOMMANDS:
|
|
176
|
+
return None
|
|
177
|
+
# 渲染根帮助列表:给占位符即可,避免为了列一行说明把 playwright 拖进来。
|
|
178
|
+
# 但用户显式 `bb-sync <cmd> --help` 时必须给真命令,所以用 _listing 区分。
|
|
179
|
+
if self._listing:
|
|
180
|
+
return self.commands.get(cmd_name) or self._placeholder(cmd_name)
|
|
181
|
+
|
|
182
|
+
module = SUBCOMMANDS[cmd_name][0]
|
|
183
|
+
if module in self._loaded:
|
|
184
|
+
return self.commands.get(cmd_name)
|
|
185
|
+
self._loaded.add(module)
|
|
186
|
+
|
|
187
|
+
mod = importlib.import_module(f"bb_sync.commands.{module}")
|
|
188
|
+
sub = _module_command(mod, cmd_name)
|
|
189
|
+
sub.help = SUBCOMMANDS[cmd_name][1]
|
|
190
|
+
self.add_command(sub, cmd_name)
|
|
191
|
+
return self.commands.get(cmd_name)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _build_cli() -> LazyGroup:
|
|
195
|
+
"""构建带全局选项的根命令组。"""
|
|
196
|
+
|
|
197
|
+
@click.group(
|
|
198
|
+
cls=LazyGroup,
|
|
199
|
+
name="bb-sync",
|
|
200
|
+
result_callback=_notify_update_after_command,
|
|
201
|
+
help="Blackboard 课程资源自动同步(ADFS SSO + Steel 浏览器后端)。",
|
|
202
|
+
invoke_without_command=True,
|
|
203
|
+
# 不用 no_args_is_help:那会让「无参数」以退出码 2 结束。
|
|
204
|
+
# 本工具约定无参数 = 显示帮助 + 退出码 0(见下方回调)。
|
|
205
|
+
no_args_is_help=False,
|
|
206
|
+
context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 100},
|
|
207
|
+
)
|
|
208
|
+
@click.option("--json", "json_mode", is_flag=True, help="结果以 JSON 输出到 stdout")
|
|
209
|
+
@click.option("--quiet", "-q", "quiet", is_flag=True, help="抑制 stderr 上的普通日志")
|
|
210
|
+
@click.option("--verbose", "-v", "verbose", is_flag=True, help="stderr 上输出调试信息")
|
|
211
|
+
@click.option(
|
|
212
|
+
"--config",
|
|
213
|
+
"config_file",
|
|
214
|
+
default=None,
|
|
215
|
+
help="指定配置文件路径(默认 CWD,其次 ~/.bb-sync/)",
|
|
216
|
+
)
|
|
217
|
+
@click.version_option(
|
|
218
|
+
__version__, "--version", prog_name="bb-sync", message="%(prog)s %(version)s"
|
|
219
|
+
)
|
|
220
|
+
@click.pass_context
|
|
221
|
+
def cli(
|
|
222
|
+
ctx: click.Context,
|
|
223
|
+
json_mode: bool,
|
|
224
|
+
quiet: bool,
|
|
225
|
+
verbose: bool,
|
|
226
|
+
config_file: str | None,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Blackboard 课程资源自动同步。"""
|
|
229
|
+
from bb_sync.context import Context as _Ctx
|
|
230
|
+
|
|
231
|
+
ctx.obj = _Ctx(
|
|
232
|
+
json_mode=json_mode,
|
|
233
|
+
quiet=quiet,
|
|
234
|
+
verbose=verbose,
|
|
235
|
+
config_file=config_file,
|
|
236
|
+
)
|
|
237
|
+
if ctx.invoked_subcommand is None:
|
|
238
|
+
click.echo(ctx.get_help())
|
|
239
|
+
ctx.exit(ExitCode.SUCCESS)
|
|
240
|
+
|
|
241
|
+
return cli # type: ignore[return-value]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _clickish_exception(exc: BaseException) -> bool:
|
|
245
|
+
"""判断是否为 Click / Typer 的「用法类」异常。
|
|
246
|
+
|
|
247
|
+
**不能直接 ``isinstance(exc, click.ClickException)``**:Typer ≥0.27 内置了
|
|
248
|
+
一份自己的 ``_click``(``typer._click.exceptions``),它与官方 ``click`` 是
|
|
249
|
+
两套互不继承的类层级(``typer.ClickException`` 不是 ``click.ClickException``
|
|
250
|
+
的子类)。若只按官方类型捕获,``No such option`` 这类用法错误会掉进兜底分支,
|
|
251
|
+
既打印堆栈又返回错误的退出码 1。
|
|
252
|
+
|
|
253
|
+
这类异常的共性:带 ``exit_code`` 属性、且定义 ``show()``(官方 click)或
|
|
254
|
+
``format_message()``(typer 内置 click)用于渲染错误信息,故按鸭子类型判定。
|
|
255
|
+
"""
|
|
256
|
+
return hasattr(exc, "exit_code") and (hasattr(exc, "show") or hasattr(exc, "format_message"))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def main() -> None:
|
|
260
|
+
"""控制台脚本入口:统一异常处理与退出码。"""
|
|
261
|
+
import typer
|
|
262
|
+
|
|
263
|
+
cli = _build_cli()
|
|
264
|
+
try:
|
|
265
|
+
cli.main(standalone_mode=False)
|
|
266
|
+
except (click.exceptions.Abort, KeyboardInterrupt):
|
|
267
|
+
sys.stderr.write("\n已中断\n")
|
|
268
|
+
raise SystemExit(ExitCode.INTERRUPTED) from None
|
|
269
|
+
except typer.Exit as exc:
|
|
270
|
+
# --help / --version 的正常退出,携带自己的退出码(通常 0)
|
|
271
|
+
raise SystemExit(exc.exit_code) from None
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
# 用法类异常(官方 click 与 typer 内置 click 都覆盖):渲染信息后按其退出码退出
|
|
274
|
+
if _clickish_exception(exc):
|
|
275
|
+
if hasattr(exc, "show"):
|
|
276
|
+
exc.show() # type: ignore[attr-defined]
|
|
277
|
+
else:
|
|
278
|
+
sys.stderr.write(str(exc) + "\n")
|
|
279
|
+
raise SystemExit(int(getattr(exc, "exit_code", ExitCode.USAGE_ERROR))) from None
|
|
280
|
+
if isinstance(exc, BbSyncError):
|
|
281
|
+
from bb_sync.core.output import Console
|
|
282
|
+
|
|
283
|
+
console = Console()
|
|
284
|
+
console.error(exc.message)
|
|
285
|
+
if exc.hint:
|
|
286
|
+
console.hint(exc.hint)
|
|
287
|
+
raise SystemExit(exc.exit_code) from None
|
|
288
|
+
if isinstance(exc, SystemExit):
|
|
289
|
+
raise
|
|
290
|
+
# 兜底:非预期异常显示堆栈,并明确引导到 issue 反馈
|
|
291
|
+
sys.stderr.write(f"✗ 未预期的错误:{exc}\n")
|
|
292
|
+
import traceback
|
|
293
|
+
|
|
294
|
+
traceback.print_exc()
|
|
295
|
+
sys.stderr.write(
|
|
296
|
+
f" 这看起来是 bb-sync 的问题,欢迎到 {ISSUES_URL} 提 issue 并附上以上信息\n"
|
|
297
|
+
)
|
|
298
|
+
raise SystemExit(exit_code_for(exc)) from None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
if __name__ == "__main__":
|
|
302
|
+
main()
|
bb_sync/commands/auth.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""``bb-sync auth`` —— 凭据管理(login / logout / status)。
|
|
2
|
+
|
|
3
|
+
密码不通过命令行参数传递:那会进 shell history / 进程列表 / CI 日志。
|
|
4
|
+
一律走隐藏输入(getpass)并存入系统钥匙串,不在任何文件里落明文。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from bb_sync.blackboard import login as login_mod
|
|
12
|
+
from bb_sync.context import Context
|
|
13
|
+
from bb_sync.creds import delete_credentials, load_credentials, mask
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
help="凭据管理:登录录入 / 退出 / 查看状态", no_args_is_help=True, add_completion=False
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command("login")
|
|
21
|
+
def login(ctx: typer.Context) -> None:
|
|
22
|
+
"""交互式录入学号与密码,存入系统钥匙串(密码不回显)。"""
|
|
23
|
+
app_ctx: Context = ctx.obj
|
|
24
|
+
login_mod.prompt_credentials(app_ctx.console)
|
|
25
|
+
app_ctx.console.emit({"saved": True, "backend": "keyring"})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command("logout")
|
|
29
|
+
def logout(ctx: typer.Context) -> None:
|
|
30
|
+
"""从系统钥匙串删除已保存的凭据。"""
|
|
31
|
+
app_ctx: Context = ctx.obj
|
|
32
|
+
removed = delete_credentials()
|
|
33
|
+
if removed:
|
|
34
|
+
app_ctx.console.success("已从系统钥匙串删除凭据")
|
|
35
|
+
else:
|
|
36
|
+
app_ctx.console.warn("钥匙串中没有保存的凭据")
|
|
37
|
+
app_ctx.console.emit({"removed": removed})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command("status")
|
|
41
|
+
def status(ctx: typer.Context) -> None:
|
|
42
|
+
"""查看凭据当前配置状态(只显示脱敏后的账号)。"""
|
|
43
|
+
app_ctx: Context = ctx.obj
|
|
44
|
+
console = app_ctx.console
|
|
45
|
+
|
|
46
|
+
creds = load_credentials()
|
|
47
|
+
if creds.ok:
|
|
48
|
+
payload = {
|
|
49
|
+
"configured": True,
|
|
50
|
+
"source": creds.source,
|
|
51
|
+
"account": mask(creds.student_id, tail=3),
|
|
52
|
+
}
|
|
53
|
+
if console.json_mode:
|
|
54
|
+
console.emit(payload)
|
|
55
|
+
else:
|
|
56
|
+
console.table(
|
|
57
|
+
["项目", "值"],
|
|
58
|
+
[
|
|
59
|
+
["状态", "已配置"],
|
|
60
|
+
["来源", creds.source],
|
|
61
|
+
["账号", mask(creds.student_id, tail=3)],
|
|
62
|
+
],
|
|
63
|
+
json_data=payload,
|
|
64
|
+
)
|
|
65
|
+
else:
|
|
66
|
+
payload = {"configured": False, "source": None, "account": None}
|
|
67
|
+
if console.json_mode:
|
|
68
|
+
console.emit(payload)
|
|
69
|
+
else:
|
|
70
|
+
console.warn("尚未配置凭据")
|
|
71
|
+
console.hint("运行 bb-sync auth login 录入")
|
|
72
|
+
console.emit(payload)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def register(root: typer.Typer) -> None:
|
|
76
|
+
"""挂载到根 app。"""
|
|
77
|
+
root.add_typer(app, name="auth")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
__all__ = ["app", "register"]
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""``bb-sync config`` —— 查看与修改配置文件(编辑时保留原有注释)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path, PureWindowsPath
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from bb_sync import paths
|
|
15
|
+
from bb_sync.context import Context
|
|
16
|
+
from bb_sync.core import config_store
|
|
17
|
+
from bb_sync.core.config import (
|
|
18
|
+
DEFAULT_CONFIG_TEXT,
|
|
19
|
+
Settings,
|
|
20
|
+
config_target,
|
|
21
|
+
dump_settings,
|
|
22
|
+
ensure_config_file,
|
|
23
|
+
load_settings,
|
|
24
|
+
)
|
|
25
|
+
from bb_sync.core.errors import ConfigError, NotFoundError
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(
|
|
28
|
+
help="查看 / 修改配置(如默认下载目录)", no_args_is_help=True, add_completion=False
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _target(ctx: typer.Context) -> Path:
|
|
33
|
+
"""本次操作的目标配置文件。"""
|
|
34
|
+
return config_target(ctx.obj.config_file)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _require(ctx: typer.Context) -> Path:
|
|
38
|
+
"""取配置文件路径,不存在则报错。"""
|
|
39
|
+
path = _target(ctx)
|
|
40
|
+
if not path.exists():
|
|
41
|
+
raise ConfigError(f"配置文件不存在:{path}", "运行 bb-sync config init 生成默认配置")
|
|
42
|
+
return path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _root_for_home(raw: str, target: Path) -> str:
|
|
46
|
+
"""把目标配置里的 root 转成用户级配置中语义相同的值。
|
|
47
|
+
|
|
48
|
+
相对路径在不同配置文件中的基准目录不同;同步到 ``BB_SYNC_HOME`` 前必须先
|
|
49
|
+
按目标配置文件所在目录解析成绝对路径,否则换目录运行时会指向错误位置。
|
|
50
|
+
"""
|
|
51
|
+
parsed = config_store.parse_value(raw)
|
|
52
|
+
if not isinstance(parsed, str):
|
|
53
|
+
return raw
|
|
54
|
+
value = parsed.strip()
|
|
55
|
+
if not value:
|
|
56
|
+
return raw
|
|
57
|
+
root = Path(value).expanduser()
|
|
58
|
+
if root.is_absolute() or PureWindowsPath(value).is_absolute():
|
|
59
|
+
return raw
|
|
60
|
+
return str((target.parent / root).resolve())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _sync_home_root(
|
|
64
|
+
target: Path,
|
|
65
|
+
key: str,
|
|
66
|
+
raw: str | None,
|
|
67
|
+
enabled: bool,
|
|
68
|
+
) -> Path | None:
|
|
69
|
+
"""把 root 的改动同步到用户级配置,避免换目录运行时使用旧值。"""
|
|
70
|
+
if not enabled or key != "root":
|
|
71
|
+
return None
|
|
72
|
+
home = paths.BB_SYNC_HOME / paths.CONFIG_NAME
|
|
73
|
+
if home.resolve() == target.resolve():
|
|
74
|
+
return None
|
|
75
|
+
if raw is None:
|
|
76
|
+
if not home.exists():
|
|
77
|
+
return None
|
|
78
|
+
try:
|
|
79
|
+
config_store.apply_to_file(home, "root", None)
|
|
80
|
+
except NotFoundError:
|
|
81
|
+
return None
|
|
82
|
+
else:
|
|
83
|
+
ensure_config_file(home)
|
|
84
|
+
config_store.apply_to_file(home, "root", _root_for_home(raw, target))
|
|
85
|
+
return home
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command("path")
|
|
89
|
+
def path_cmd(ctx: typer.Context) -> None:
|
|
90
|
+
"""显示实际生效的配置文件路径。"""
|
|
91
|
+
typer.echo(str(_target(ctx)))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.command("init")
|
|
95
|
+
def init(ctx: typer.Context) -> None:
|
|
96
|
+
"""生成一份默认配置文件(已存在时不覆盖)。"""
|
|
97
|
+
app_ctx: Context = ctx.obj
|
|
98
|
+
target = _target(ctx)
|
|
99
|
+
created = ensure_config_file(target)
|
|
100
|
+
if created:
|
|
101
|
+
app_ctx.console.success(f"已生成默认配置 {target}")
|
|
102
|
+
else:
|
|
103
|
+
app_ctx.console.log(f"配置文件已存在,未改动:{target}")
|
|
104
|
+
app_ctx.console.emit({"path": str(target), "created": created})
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command("show")
|
|
108
|
+
def show(ctx: typer.Context) -> None:
|
|
109
|
+
"""显示整份配置文件内容。"""
|
|
110
|
+
app_ctx: Context = ctx.obj
|
|
111
|
+
target = _target(ctx)
|
|
112
|
+
if not target.exists():
|
|
113
|
+
raise ConfigError(f"配置文件不存在:{target}", "运行 bb-sync config init 生成默认配置")
|
|
114
|
+
text = target.read_text(encoding="utf-8")
|
|
115
|
+
if app_ctx.json_mode:
|
|
116
|
+
settings, _ = load_settings(target)
|
|
117
|
+
app_ctx.console.emit(dump_settings(settings))
|
|
118
|
+
else:
|
|
119
|
+
app_ctx.console.print_raw(text)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command("get")
|
|
123
|
+
def get(
|
|
124
|
+
ctx: typer.Context,
|
|
125
|
+
key: str = typer.Argument(..., help="配置键,支持点号子键(如 root、course_dirs.CSC5010)"),
|
|
126
|
+
) -> None:
|
|
127
|
+
"""读取某一项配置。"""
|
|
128
|
+
app_ctx: Context = ctx.obj
|
|
129
|
+
target = _require(ctx)
|
|
130
|
+
settings, _ = load_settings(target)
|
|
131
|
+
node: object = dump_settings(settings) if not key else None
|
|
132
|
+
|
|
133
|
+
if key:
|
|
134
|
+
node = settings
|
|
135
|
+
for part in key.split("."):
|
|
136
|
+
if isinstance(node, dict):
|
|
137
|
+
node = node.get(part) if part in node else None
|
|
138
|
+
else:
|
|
139
|
+
node = getattr(node, part, None)
|
|
140
|
+
if node is None:
|
|
141
|
+
raise NotFoundError(f"配置里没有:{key}")
|
|
142
|
+
|
|
143
|
+
if isinstance(node, Settings):
|
|
144
|
+
node = dump_settings(node)
|
|
145
|
+
if app_ctx.json_mode:
|
|
146
|
+
app_ctx.console.emit(node)
|
|
147
|
+
elif isinstance(node, (dict, list)):
|
|
148
|
+
app_ctx.console.print_raw(
|
|
149
|
+
yaml.safe_dump(
|
|
150
|
+
node, allow_unicode=True, default_flow_style=False, sort_keys=False
|
|
151
|
+
).rstrip()
|
|
152
|
+
)
|
|
153
|
+
else:
|
|
154
|
+
app_ctx.console.print_raw(str(node))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@app.command("set")
|
|
158
|
+
def set_(
|
|
159
|
+
ctx: typer.Context,
|
|
160
|
+
key: str = typer.Argument(..., help="配置键(如 root、course_dirs.CSC5010)"),
|
|
161
|
+
value: str = typer.Argument(..., help="值(YAML 标量或列表,含空格时加引号)"),
|
|
162
|
+
sync_home: bool = typer.Option(
|
|
163
|
+
True,
|
|
164
|
+
"--sync-home/--no-sync-home",
|
|
165
|
+
help="修改 root 时同步更新用户级配置",
|
|
166
|
+
),
|
|
167
|
+
) -> None:
|
|
168
|
+
"""写入一项配置(永久生效);root 默认同步到用户级配置。"""
|
|
169
|
+
app_ctx: Context = ctx.obj
|
|
170
|
+
config_store.parse_value(value) # 校验 YAML 合法性
|
|
171
|
+
target = _target(ctx)
|
|
172
|
+
ensure_config_file(target)
|
|
173
|
+
action = config_store.apply_to_file(target, key, value)
|
|
174
|
+
synced = _sync_home_root(target, key, value, sync_home)
|
|
175
|
+
app_ctx.console.success(f"已写入 {target}")
|
|
176
|
+
if synced is not None:
|
|
177
|
+
app_ctx.console.success(f"已同步用户配置 {synced}")
|
|
178
|
+
payload: dict[str, str] = {
|
|
179
|
+
"path": str(target),
|
|
180
|
+
"action": action,
|
|
181
|
+
"key": key,
|
|
182
|
+
"value": value,
|
|
183
|
+
}
|
|
184
|
+
if synced is not None:
|
|
185
|
+
payload["synced_path"] = str(synced)
|
|
186
|
+
app_ctx.console.emit(payload)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.command("unset")
|
|
190
|
+
def unset(
|
|
191
|
+
ctx: typer.Context,
|
|
192
|
+
key: str = typer.Argument(..., help="配置键(如 root、course_dirs.CSC5010)"),
|
|
193
|
+
sync_home: bool = typer.Option(
|
|
194
|
+
True,
|
|
195
|
+
"--sync-home/--no-sync-home",
|
|
196
|
+
help="删除 root 时同步更新用户级配置",
|
|
197
|
+
),
|
|
198
|
+
) -> None:
|
|
199
|
+
"""删除某项配置(恢复内置默认值);root 默认同步到用户级配置。"""
|
|
200
|
+
app_ctx: Context = ctx.obj
|
|
201
|
+
target = _require(ctx)
|
|
202
|
+
action = config_store.apply_to_file(target, key, None)
|
|
203
|
+
synced = _sync_home_root(target, key, None, sync_home)
|
|
204
|
+
app_ctx.console.success(f"{action}({target})")
|
|
205
|
+
if synced is not None:
|
|
206
|
+
app_ctx.console.success(f"已同步用户配置 {synced}")
|
|
207
|
+
payload: dict[str, str] = {"path": str(target), "action": action, "key": key}
|
|
208
|
+
if synced is not None:
|
|
209
|
+
payload["synced_path"] = str(synced)
|
|
210
|
+
app_ctx.console.emit(payload)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@app.command("edit")
|
|
214
|
+
def edit(ctx: typer.Context) -> None:
|
|
215
|
+
"""用系统默认编辑器打开配置文件。"""
|
|
216
|
+
app_ctx: Context = ctx.obj
|
|
217
|
+
target = _target(ctx)
|
|
218
|
+
ensure_config_file(target)
|
|
219
|
+
if sys.platform == "win32":
|
|
220
|
+
os.startfile(target) # type: ignore[attr-defined]
|
|
221
|
+
elif sys.platform == "darwin" and not os.environ.get("EDITOR"):
|
|
222
|
+
# macOS 没有通用的终端默认编辑器;open -t 交给系统默认文本编辑器。
|
|
223
|
+
subprocess.call(["open", "-t", str(target)])
|
|
224
|
+
else:
|
|
225
|
+
editor = shlex.split(os.environ.get("EDITOR", "vi"))
|
|
226
|
+
subprocess.call([*editor, str(target)])
|
|
227
|
+
app_ctx.console.emit({"path": str(target), "opened": True})
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
#: ``DEFAULT_CONFIG_TEXT`` 在 help 中被引用,导入即用,避免 linter 报未使用
|
|
231
|
+
_ = DEFAULT_CONFIG_TEXT
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def register(root: typer.Typer) -> None:
|
|
235
|
+
"""挂载到根 app。"""
|
|
236
|
+
root.add_typer(app, name="config")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
__all__ = ["app", "register"]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""``bb-sync course`` —— 课程相关操作(当前提供 list)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from bb_sync.context import Context
|
|
8
|
+
from bb_sync.core import service
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="课程操作(列出可同步的课程)", no_args_is_help=True, add_completion=False)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command("list")
|
|
14
|
+
def list_cmd(
|
|
15
|
+
ctx: typer.Context,
|
|
16
|
+
course: list[str] = typer.Option(None, "--course", help="只看指定课程代码(可多次)"),
|
|
17
|
+
) -> None:
|
|
18
|
+
"""列出账号下可同步的课程(不下载任何文件)。"""
|
|
19
|
+
app_ctx: Context = ctx.obj
|
|
20
|
+
console = app_ctx.console
|
|
21
|
+
settings, config_path = app_ctx.load_settings(cli_overrides={"include": course or None})
|
|
22
|
+
options = service.SyncOptions(
|
|
23
|
+
settings=settings,
|
|
24
|
+
config_path=config_path,
|
|
25
|
+
root=service.plan_root(settings, config_path, None),
|
|
26
|
+
console=console,
|
|
27
|
+
headed=False,
|
|
28
|
+
dry_run=True,
|
|
29
|
+
courses=course or None,
|
|
30
|
+
)
|
|
31
|
+
courses = service.list_courses(options)
|
|
32
|
+
records = [c.as_dict() for c in courses]
|
|
33
|
+
console.table(
|
|
34
|
+
["课程代码", "标题", "本地文件夹"],
|
|
35
|
+
[[c.code or "-", c.title, c.slug] for c in courses],
|
|
36
|
+
json_data=records,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def register(root: typer.Typer) -> None:
|
|
41
|
+
"""挂载到根 app。"""
|
|
42
|
+
root.add_typer(app, name="course")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = ["app", "register"]
|