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/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()
@@ -0,0 +1,5 @@
1
+ """CLI 子命令实现,一个命令一个模块。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__: list[str] = []
@@ -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"]