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.
@@ -0,0 +1,65 @@
1
+ """``bb-sync doctor`` —— 环境体检。
2
+
3
+ 本模块导出的是**扁平命令**(``bb-sync doctor [OPTIONS]``):
4
+ 它在语义上没有下级子命令,多一层 ``doctor doctor`` 只是噪音。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import typer
10
+
11
+ from bb_sync.context import Context
12
+ from bb_sync.core import doctor
13
+ from bb_sync.core.errors import EnvironmentError_
14
+
15
+ #: 模块对应的子命令名(供 cli.LazyGroup 识别)
16
+ SUBCOMMAND = "doctor"
17
+ HELP = "体检:检查前置条件(git / Node / 浏览器 / 网络等)"
18
+
19
+ #: 单命令 Typer app:借 Typer 从函数签名推导参数,随后由 cli 层取出那条命令平铺
20
+ app = typer.Typer(help=HELP, add_completion=False)
21
+
22
+
23
+ @app.command("doctor")
24
+ def doctor_cmd(
25
+ ctx: typer.Context,
26
+ list_only: bool = typer.Option(False, "--list", help="只列出检查项,不实际执行"),
27
+ skip_network: bool = typer.Option(False, "--skip-network", help="跳过网络可达性检查"),
28
+ ) -> None:
29
+ """体检:检查前置条件(git / Node / 浏览器 / 网络等)。"""
30
+ app_ctx: Context = ctx.obj
31
+ console = app_ctx.console
32
+
33
+ if list_only:
34
+ names = [fn.__name__.removeprefix("check_") for fn in doctor.CHECKS]
35
+ console.table(["检查项"], [[n] for n in names], json_data={"checks": names})
36
+ return
37
+
38
+ results = doctor.run_doctor(console, skip_network=skip_network)
39
+
40
+ if app_ctx.json_mode:
41
+ console.emit(
42
+ {
43
+ "ok": not any(r.required and not r.ok for r in results),
44
+ "checks": [
45
+ {
46
+ "label": r.label,
47
+ "ok": r.ok,
48
+ "detail": r.detail,
49
+ "hint": r.hint,
50
+ "required": r.required,
51
+ }
52
+ for r in results
53
+ ],
54
+ }
55
+ )
56
+
57
+ missing = [r for r in results if r.required and not r.ok]
58
+ if missing:
59
+ raise EnvironmentError_(
60
+ f"环境体检未通过:{'、'.join(r.label for r in missing)} 缺失",
61
+ "按上方提示逐项修复后重试",
62
+ )
63
+
64
+
65
+ __all__ = ["HELP", "SUBCOMMAND", "app", "doctor_cmd"]
@@ -0,0 +1,66 @@
1
+ """``bb-sync due`` —— 整理全部课程的待办到 ``due.md``。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from bb_sync import paths
8
+ from bb_sync.context import Context
9
+ from bb_sync.core import service
10
+ from bb_sync.core.config import ensure_config_file
11
+ from bb_sync.core.errors import ConfigError
12
+
13
+ SUBCOMMAND = "due"
14
+ HELP = """扫描课程主页 Due / To Do 与公告,生成按优先级排列的 due.md。
15
+
16
+ 公告里带日期的课堂测验与考试(quiz / exam)也会整理成待办,并在 due.md 中分别标记为
17
+ 【测验】【考试】。
18
+
19
+ 示例:
20
+ bb-sync due 扫描全部课程并写入 <root>/due.md
21
+ bb-sync due --course CSC5010 只扫描指定课程(可重复)
22
+ bb-sync due --root ~/University 临时指定输出目录
23
+ bb-sync due --headed 有头模式(首次登录 / 需要人工过 MFA)
24
+ bb-sync --json due 输出机器可读 JSON
25
+ """
26
+
27
+ app = typer.Typer(help=HELP.splitlines()[0], add_completion=False)
28
+
29
+
30
+ @app.command("due")
31
+ def due(
32
+ ctx: typer.Context,
33
+ headed: bool = typer.Option(False, "--headed", help="有头模式(首次登录/MFA 用)"),
34
+ course: list[str] = typer.Option(
35
+ None, "--course", help="只扫描指定课程代码(可多次),如 --course CSC5010"
36
+ ),
37
+ root: str = typer.Option(None, "--root", help="本次 due.md 的输出目录(临时覆盖配置)"),
38
+ ) -> None:
39
+ """生成课程待办清单。"""
40
+ app_ctx: Context = ctx.obj
41
+ console = app_ctx.console
42
+
43
+ overrides = {"root": root, "include": course or None}
44
+ settings, config_path = app_ctx.load_settings(cli_overrides=overrides)
45
+
46
+ if not config_path.exists():
47
+ if app_ctx.config_file:
48
+ raise ConfigError(
49
+ f"未找到配置文件:{config_path}", "检查 --config 路径,或省略该参数用默认配置"
50
+ )
51
+ ensure_config_file(paths.BB_SYNC_HOME / "config.yaml")
52
+ settings, config_path = app_ctx.load_settings(cli_overrides=overrides)
53
+ console.log(f"[info] 已生成默认配置 {config_path}(可按需修改)")
54
+
55
+ options = service.DueOptions(
56
+ settings=settings,
57
+ root=service.plan_root(settings, config_path, root),
58
+ console=console,
59
+ headed=headed,
60
+ courses=course or None,
61
+ )
62
+ stats = service.run_due(options)
63
+ console.emit(stats.as_dict())
64
+
65
+
66
+ __all__ = ["HELP", "SUBCOMMAND", "app", "due"]
@@ -0,0 +1,74 @@
1
+ """``bb-sync run`` —— 执行一次课程同步。
2
+
3
+ 本模块导出的是**扁平命令**(``bb-sync run [OPTIONS]``)。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import typer
9
+
10
+ from bb_sync import paths
11
+ from bb_sync.context import Context
12
+ from bb_sync.core import service
13
+ from bb_sync.core.config import ensure_config_file
14
+ from bb_sync.core.errors import ConfigError
15
+
16
+ #: 模块对应的子命令名
17
+ SUBCOMMAND = "run"
18
+ HELP = """执行一次课程同步并更新 due.md(下载目录取配置里的 root,可用 --root 临时覆盖)。
19
+
20
+ 示例:
21
+ bb-sync run 增量同步全部课程并更新待办清单
22
+ bb-sync run --course CSC5010 只同步指定课程并更新对应待办(可重复)
23
+ bb-sync run --dry-run 预览下载和待办,不写文件
24
+ bb-sync run --headed 有头模式(首次登录 / 需要人工过 MFA)
25
+ bb-sync run --json 输出机器可读 JSON,便于脚本消费
26
+ """
27
+
28
+ #: 单命令 Typer app:借 Typer 从函数签名推导参数,随后由 cli 层取出那条命令平铺
29
+ app = typer.Typer(help=HELP.splitlines()[0], add_completion=False)
30
+
31
+
32
+ @app.command("run")
33
+ def run(
34
+ ctx: typer.Context,
35
+ headed: bool = typer.Option(False, "--headed", help="有头模式(首次登录/MFA 用)"),
36
+ dry_run: bool = typer.Option(False, "--dry-run", help="只列出文件,不下载"),
37
+ course: list[str] = typer.Option(
38
+ None, "--course", help="只同步指定课程代码(可多次),如 --course CSC5010"
39
+ ),
40
+ root: str = typer.Option(None, "--root", help="本次同步的下载目录(临时覆盖配置里的 root)"),
41
+ ) -> None:
42
+ """执行一次课程同步并更新待办清单。"""
43
+ app_ctx: Context = ctx.obj
44
+ console = app_ctx.console
45
+
46
+ overrides = {"root": root, "include": course or None}
47
+ settings, config_path = app_ctx.load_settings(cli_overrides=overrides)
48
+
49
+ # 配置文件缺失时:显式指定的直接报错,其余自动落一份默认配置
50
+ if not config_path.exists():
51
+ if app_ctx.config_file:
52
+ raise ConfigError(
53
+ f"未找到配置文件:{config_path}", "检查 --config 路径,或省略该参数用默认配置"
54
+ )
55
+ ensure_config_file(paths.BB_SYNC_HOME / "config.yaml")
56
+ settings, config_path = app_ctx.load_settings(cli_overrides=overrides)
57
+ console.log(f"[info] 已生成默认配置 {config_path}(可按需修改)")
58
+
59
+ options = service.SyncOptions(
60
+ settings=settings,
61
+ config_path=config_path,
62
+ root=service.plan_root(settings, config_path, root),
63
+ console=console,
64
+ headed=headed,
65
+ dry_run=dry_run,
66
+ courses=course or None,
67
+ )
68
+ stats = service.run_sync(options)
69
+
70
+ # 结果走 stdout:--json 时输出统计对象;人读模式下汇总已在 stderr 打印
71
+ console.emit(stats.as_dict())
72
+
73
+
74
+ __all__ = ["HELP", "SUBCOMMAND", "app", "run"]
bb_sync/context.py ADDED
@@ -0,0 +1,69 @@
1
+ """全局运行上下文:把 Typer 的全局选项传给各子命令。
2
+
3
+ Typer 的子命令之间不共享状态,所以全局开关(``--json`` / ``--quiet`` / ``--verbose``
4
+ / ``--config``)统一放在顶层回调里,存进 :class:`Context` 对象,由命令层按需取用。
5
+
6
+ 性能要点:本模块**不在顶层导入** pydantic(``core.config``)与 rich
7
+ (``core.output``),否则 ``bb-sync --version`` 也要为它们付出百毫秒级导入成本。
8
+ 两者都在真正被用到时才导入。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from bb_sync import paths
18
+
19
+ if TYPE_CHECKING: # 仅供类型检查,运行时不导入
20
+ from bb_sync.core.config import Settings
21
+ from bb_sync.core.output import Console
22
+
23
+ #: 模块级缓存:Console 类在首次使用时才导入
24
+ _CONSOLE_CLS: Any = None
25
+
26
+
27
+ def _console_cls() -> Any:
28
+ global _CONSOLE_CLS
29
+ if _CONSOLE_CLS is None:
30
+ from bb_sync.core.output import Console
31
+
32
+ _CONSOLE_CLS = Console
33
+ return _CONSOLE_CLS
34
+
35
+
36
+ @dataclass
37
+ class Context:
38
+ """一次 CLI 调用的共享状态。"""
39
+
40
+ json_mode: bool = False
41
+ quiet: bool = False
42
+ verbose: bool = False
43
+ config_file: str | None = None
44
+ _console: Any = field(default=None, repr=False)
45
+
46
+ @property
47
+ def console(self) -> Console:
48
+ """惰性构造 Console(首次访问时才导入 rich)。"""
49
+ if self._console is None:
50
+ self._console = _console_cls()(
51
+ json_mode=self.json_mode, quiet=self.quiet, verbose=self.verbose
52
+ )
53
+ return self._console
54
+
55
+ @property
56
+ def home(self) -> Path:
57
+ return paths.BB_SYNC_HOME
58
+
59
+ def load_settings(self, *, cli_overrides: dict | None = None) -> tuple[Settings, Path]:
60
+ """按四层优先级加载配置(首次调用时才导入 pydantic)。"""
61
+ from bb_sync.core.config import load_settings
62
+
63
+ return load_settings(
64
+ Path(self.config_file).expanduser() if self.config_file else None,
65
+ cli_overrides=cli_overrides,
66
+ )
67
+
68
+
69
+ __all__ = ["Context"]
@@ -0,0 +1,5 @@
1
+ """核心基础设施:异常、输出、配置、容器与常量。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__: list[str] = []
bb_sync/core/config.py ADDED
@@ -0,0 +1,205 @@
1
+ """配置模型与加载:Pydantic 校验 + 四层优先级。
2
+
3
+ 优先级(高 → 低),对齐成熟 CLI 的惯例::
4
+
5
+ 1. CLI 参数 bb-sync run --root ~/University/courses
6
+ 2. 环境变量 BB_SYNC_ROOT=~/University/courses
7
+ 3. 配置文件 ~/.bb-sync/config.yaml
8
+ 4. 内置默认值 DEFAULT_CONFIG 同源
9
+
10
+ 配置文件位置解析:``--config`` 显式指定 > 当前目录 config.yaml > ``~/.bb-sync/config.yaml``。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from pathlib import Path
17
+ from typing import Any, Literal
18
+
19
+ import yaml
20
+ from pydantic import BaseModel, Field, ValidationError, field_validator
21
+
22
+ from bb_sync import paths
23
+ from bb_sync.core.errors import ConfigError
24
+
25
+ #: 环境变量前缀,如 ``BB_SYNC_ROOT``、``BB_SYNC_MAX_DEPTH``
26
+ ENV_PREFIX = "BB_SYNC_"
27
+
28
+ DEFAULT_KEYWORDS: dict[str, list[str]] = {
29
+ "assignments": ["assignment", "homework", "作业", "hw"],
30
+ "tutorials": ["tutorial", "lab", "实验", "指导", "recitation"],
31
+ "lectures": ["lecture", "课件", "讲义", "slides", "notes", "note"],
32
+ }
33
+
34
+
35
+ class Settings(BaseModel):
36
+ """bb-sync 全部可配置项。"""
37
+
38
+ root: str = "~/courses"
39
+ """课程资源根目录,支持 ``~`` 与相对路径(相对路径以配置文件所在目录为基准)。"""
40
+
41
+ include: Literal["all"] | list[str] = "all"
42
+ """同步哪些课程:``all`` 或课程代码列表。"""
43
+
44
+ course_dirs: dict[str, str] = Field(default_factory=dict)
45
+ """课程代码 → 本地文件夹名的显式映射。"""
46
+
47
+ keywords: dict[str, list[str]] = Field(default_factory=lambda: dict(DEFAULT_KEYWORDS))
48
+ """内容归类关键词。"""
49
+
50
+ announcements: bool = True
51
+ """是否同步公告到 ``announcements.md``。"""
52
+
53
+ max_depth: int = Field(default=3, ge=1, le=10)
54
+ """内容子文件夹递归最大深度。"""
55
+
56
+ model_config = {"extra": "ignore"}
57
+
58
+ @field_validator("include", mode="before")
59
+ @classmethod
60
+ def _normalize_include(cls, v: Any) -> Any:
61
+ """允许 ``include: CSC5010`` 这类单个标量写法。"""
62
+ if v is None:
63
+ return "all"
64
+ if isinstance(v, str) and v.lower() != "all":
65
+ return [v]
66
+ return v
67
+
68
+ @field_validator("course_dirs", mode="before")
69
+ @classmethod
70
+ def _normalize_course_dirs(cls, v: Any) -> Any:
71
+ """``course_dirs:`` 写成空值时归一为 ``{}``。"""
72
+ return v or {}
73
+
74
+ def resolve_root(self, config_path: Path | None = None) -> Path:
75
+ """把 ``root`` 解析为绝对路径(支持 ``~`` 与相对路径)。"""
76
+ raw = Path(self.root).expanduser()
77
+ if raw.is_absolute():
78
+ return raw
79
+ base = config_path.parent if config_path else Path.cwd()
80
+ return (base / raw).resolve()
81
+
82
+
83
+ def config_target(explicit: str | None = None) -> Path:
84
+ """配置文件位置:``--config`` > 当前目录 config.yaml > ``~/.bb-sync/``。"""
85
+ if explicit:
86
+ return Path(explicit).expanduser()
87
+ cwd_candidate = Path.cwd() / paths.CONFIG_NAME
88
+ if cwd_candidate.exists():
89
+ return cwd_candidate
90
+ return paths.BB_SYNC_HOME / paths.CONFIG_NAME
91
+
92
+
93
+ #: 首次运行自动生成的默认配置(带注释,供用户手改)
94
+ DEFAULT_CONFIG_TEXT = """\
95
+ # bb-sync 配置(首次运行自动生成,可按需修改)
96
+ # 也可用命令修改:bb-sync config set root <目录>
97
+
98
+ # 课程资源根目录(支持 ~ 和相对路径)
99
+ root: ~/courses
100
+
101
+ # 同步哪些课程:all = 全部,或列表如 [CSC5010, DDA5002]
102
+ include: all
103
+
104
+ # 课程代码 -> 本地文件夹名 的显式映射(未列出的自动命名)
105
+ course_dirs: {}
106
+
107
+ # 内容归类关键词:先匹配 assignments,再 tutorials,其余归 lectures
108
+ keywords:
109
+ assignments: ["assignment", "homework", "作业", "hw"]
110
+ tutorials: ["tutorial", "lab", "实验", "指导", "recitation"]
111
+ lectures: ["lecture", "课件", "讲义", "slides", "notes", "note"]
112
+
113
+ # 公告是否同步(写入课程目录 announcements.md)
114
+ announcements: true
115
+
116
+ # 内容子文件夹递归深度(1 = 只下内容区第一层)
117
+ max_depth: 3
118
+ """
119
+
120
+
121
+ def ensure_config_file(path: Path) -> bool:
122
+ """配置文件不存在时落一份默认配置;返回是否新建。"""
123
+ if path.exists():
124
+ return False
125
+ path.parent.mkdir(parents=True, exist_ok=True)
126
+ path.write_text(DEFAULT_CONFIG_TEXT, encoding="utf-8")
127
+ return True
128
+
129
+
130
+ def read_raw(path: Path) -> dict[str, Any]:
131
+ """读取 YAML 原文;文件缺失或语法错误抛 :class:`ConfigError`。"""
132
+ if not path.exists():
133
+ raise ConfigError(f"配置文件不存在:{path}", "运行 bb-sync config init 生成默认配置")
134
+ try:
135
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
136
+ except yaml.YAMLError as exc:
137
+ raise ConfigError(f"配置文件格式错误:{path}", str(exc)) from exc
138
+ if data is None:
139
+ return {}
140
+ if not isinstance(data, dict):
141
+ raise ConfigError(f"配置文件顶层必须是映射:{path}")
142
+ return data
143
+
144
+
145
+ def _env_overrides() -> dict[str, Any]:
146
+ """收集 ``BB_SYNC_*`` 环境变量(键名小写,下划线保留)。"""
147
+ out: dict[str, Any] = {}
148
+ for key, raw in os.environ.items():
149
+ if not key.startswith(ENV_PREFIX):
150
+ continue
151
+ name = key[len(ENV_PREFIX) :].lower()
152
+ if name in Settings.model_fields:
153
+ out[name] = raw
154
+ return out
155
+
156
+
157
+ def load_settings(
158
+ path: Path | None = None,
159
+ *,
160
+ cli_overrides: dict[str, Any] | None = None,
161
+ ) -> tuple[Settings, Path]:
162
+ """按四层优先级加载配置,返回 ``(Settings, 实际配置文件路径)``。
163
+
164
+ ``cli_overrides`` 里的 ``None`` 值会被忽略,方便直接传 argparse/Typer 的可选参数。
165
+ """
166
+ path = path or config_target()
167
+ layers: dict[str, Any] = {}
168
+ if path.exists():
169
+ layers.update(read_raw(path))
170
+ layers.update(_env_overrides())
171
+ if cli_overrides:
172
+ layers.update({k: v for k, v in cli_overrides.items() if v is not None})
173
+
174
+ try:
175
+ settings = Settings.model_validate(layers)
176
+ except ValidationError as exc:
177
+ raise ConfigError(f"配置项校验失败:{path}", _format_validation_error(exc)) from exc
178
+ return settings, path
179
+
180
+
181
+ def _format_validation_error(exc: ValidationError) -> str:
182
+ """把 Pydantic 报错压成人类可读的单行提示。"""
183
+ parts = []
184
+ for err in exc.errors():
185
+ loc = ".".join(str(x) for x in err["loc"]) or "(顶层)"
186
+ parts.append(f"{loc}: {err['msg']}")
187
+ return "; ".join(parts)
188
+
189
+
190
+ def dump_settings(settings: Settings) -> dict[str, Any]:
191
+ """导出为可 JSON/YAML 序列化的普通字典。"""
192
+ return settings.model_dump(mode="json")
193
+
194
+
195
+ __all__ = [
196
+ "DEFAULT_CONFIG_TEXT",
197
+ "DEFAULT_KEYWORDS",
198
+ "ENV_PREFIX",
199
+ "Settings",
200
+ "config_target",
201
+ "dump_settings",
202
+ "ensure_config_file",
203
+ "load_settings",
204
+ "read_raw",
205
+ ]
@@ -0,0 +1,190 @@
1
+ """配置文件的行级编辑器:修改值但保留原有注释与排版。
2
+
3
+ 直接 ``yaml.safe_dump`` 会丢掉用户写的注释,所以 ``config set/unset`` 走这套
4
+ 逐行正则操作。仅处理顶层标量键与「顶层映射下的一级子键」两种形态,
5
+ 覆盖面与配置结构(root / include / course_dirs.X)一致。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path
12
+
13
+ import yaml
14
+
15
+ from bb_sync.core.errors import ConfigError, NotFoundError
16
+
17
+ # ---------------------------------------------------------------- 读取辅助
18
+
19
+
20
+ def _split_inline(line: str) -> str:
21
+ """键行冒号后的值部分(去注释、去空白);无冒号返回空串。"""
22
+ return line.split(":", 1)[1].split("#", 1)[0].strip() if ":" in line else ""
23
+
24
+
25
+ def _block_end(lines: list[str], start: int) -> int:
26
+ """顶层键所在映射块的结束行号(不含):空行/注释/缩进行属于块内。"""
27
+ j = start + 1
28
+ while j < len(lines):
29
+ stripped = lines[j].strip()
30
+ if not stripped or stripped.startswith("#") or lines[j][0] in " \t":
31
+ j += 1
32
+ continue
33
+ break
34
+ return j
35
+
36
+
37
+ def _find_block(lines: list[str], parent: str) -> tuple[int, int] | None:
38
+ """定位顶层键的 ``(起始行, 块结束行)``。"""
39
+ pat = re.compile(rf"^{re.escape(parent)}\s*:")
40
+ for i, line in enumerate(lines):
41
+ if pat.match(line):
42
+ return i, _block_end(lines, i)
43
+ return None
44
+
45
+
46
+ def _child_indent(lines: list[str], start: int, end: int) -> str:
47
+ """取块内已有子键的缩进;没有则默认两个空格。"""
48
+ for k in range(start + 1, end):
49
+ s = lines[k]
50
+ if s.strip() and not s.strip().startswith("#") and s[0] in " \t":
51
+ return s[: len(s) - len(s.lstrip())]
52
+ return " "
53
+
54
+
55
+ # ---------------------------------------------------------------- 写操作
56
+
57
+
58
+ def set_top_level(lines: list[str], key: str, raw: str) -> None:
59
+ """替换顶层标量键的值(独占整行的注释不受影响);键不存在则追加。"""
60
+ pat = re.compile(rf"^(\s*){re.escape(key)}(\s*:\s*).*$")
61
+ for i, line in enumerate(lines):
62
+ m = pat.match(line)
63
+ if m:
64
+ lines[i] = f"{m.group(1)}{key}: {raw}"
65
+ return
66
+ if lines and lines[-1].strip():
67
+ lines.append("")
68
+ lines.append(f"{key}: {raw}")
69
+
70
+
71
+ def set_nested(lines: list[str], parent: str, child: str, raw: str) -> None:
72
+ """在 parent 映射下写入/更新 child 子键,保留原有注释与缩进。"""
73
+ blk = _find_block(lines, parent)
74
+ if blk is None: # 整个键不存在:追加到文件末尾
75
+ if lines and lines[-1].strip():
76
+ lines.append("")
77
+ lines.append(f"{parent}:")
78
+ lines.append(f" {child}: {raw}")
79
+ return
80
+ start, end = blk
81
+ inline = _split_inline(lines[start])
82
+ if inline:
83
+ if inline[0] in "{[": # 流式空映射(如 course_dirs: {})→ 展开为块式
84
+ lines[start] = lines[start].split(":", 1)[0] + ":"
85
+ lines.insert(start + 1, f" {child}: {raw}")
86
+ return
87
+ raise ConfigError(f"{parent} 的值不是映射,无法设置子键 {child}")
88
+ pat = re.compile(rf"^(\s+){re.escape(child)}(\s*:\s*).*$")
89
+ for k in range(start + 1, end):
90
+ m = pat.match(lines[k])
91
+ if m:
92
+ lines[k] = f"{m.group(1)}{child}: {raw}"
93
+ return
94
+ indent = _child_indent(lines, start, end)
95
+ last_content = start
96
+ for k in range(start + 1, end):
97
+ s = lines[k].strip()
98
+ if s and not s.startswith("#"):
99
+ last_content = k
100
+ lines.insert(last_content + 1, f"{indent}{child}: {raw}")
101
+
102
+
103
+ def unset_top_level(lines: list[str], key: str) -> None:
104
+ """删除顶层键:标量整行删除;映射(含流式 ``{}``)重置为 ``{}``。"""
105
+ blk = _find_block(lines, key)
106
+ if blk is None:
107
+ raise NotFoundError(f"配置里没有:{key}")
108
+ start, end = blk
109
+ inline = _split_inline(lines[start])
110
+ has_children = any(
111
+ lines[k].strip() and not lines[k].strip().startswith("#") and lines[k][0] in " \t"
112
+ for k in range(start + 1, end)
113
+ )
114
+ if (inline and inline[0] in "{[") or (not inline and has_children):
115
+ lines[start] = lines[start].split(":", 1)[0] + ": {}"
116
+ if not inline: # 只删内容行,保留归属下个键的注释
117
+ for k in range(end - 1, start, -1):
118
+ s = lines[k].strip()
119
+ if s and not s.startswith("#"):
120
+ del lines[k]
121
+ return
122
+ del lines[start]
123
+
124
+
125
+ def unset_nested(lines: list[str], parent: str, child: str) -> None:
126
+ """删除 ``parent.child`` 子键。"""
127
+ blk = _find_block(lines, parent)
128
+ if blk is None or _split_inline(lines[blk[0]]):
129
+ raise NotFoundError(f"配置里没有:{parent}.{child}")
130
+ start, end = blk
131
+ for k in range(start + 1, end):
132
+ if re.match(rf"^\s+{re.escape(child)}\s*:", lines[k]):
133
+ del lines[k]
134
+ return
135
+ raise NotFoundError(f"配置里没有:{parent}.{child}")
136
+
137
+
138
+ # ---------------------------------------------------------------- 对外接口
139
+
140
+
141
+ def parse_value(raw: str) -> object:
142
+ """把命令行传入的字符串按 YAML 标量解析,并拒绝映射(引导用点号写法)。"""
143
+ try:
144
+ parsed = yaml.safe_load(raw)
145
+ except yaml.YAMLError as exc:
146
+ raise ConfigError(f"值不是合法的 YAML:{raw}", str(exc)) from exc
147
+ if isinstance(parsed, dict):
148
+ raise ConfigError(
149
+ "set 不支持映射值;嵌套键请用点号写法",
150
+ "例如:bb-sync config set course_dirs.CSC5010 CSC5010_AI",
151
+ )
152
+ return parsed
153
+
154
+
155
+ def apply_to_file(path: Path, key: str, raw: str | None) -> str:
156
+ """在文件上执行 set(``raw`` 非空)或 unset(``raw`` 为 None)。
157
+
158
+ 返回人类可读的操作描述。文件必须已存在。
159
+ """
160
+ if not path.exists():
161
+ raise ConfigError(f"配置文件不存在:{path}", "运行 bb-sync config init 生成默认配置")
162
+ lines = path.read_text(encoding="utf-8").splitlines()
163
+
164
+ if raw is None:
165
+ if "." in key:
166
+ parent, child = key.split(".", 1)
167
+ unset_nested(lines, parent, child)
168
+ else:
169
+ unset_top_level(lines, key)
170
+ action = f"已移除 {key}"
171
+ else:
172
+ if "." in key:
173
+ parent, child = key.split(".", 1)
174
+ set_nested(lines, parent, child, raw)
175
+ else:
176
+ set_top_level(lines, key, raw)
177
+ action = f"{key} = {raw}"
178
+
179
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
180
+ return action
181
+
182
+
183
+ __all__ = [
184
+ "apply_to_file",
185
+ "parse_value",
186
+ "set_nested",
187
+ "set_top_level",
188
+ "unset_nested",
189
+ "unset_top_level",
190
+ ]