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
|
@@ -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"]
|
bb_sync/commands/due.py
ADDED
|
@@ -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"]
|
bb_sync/commands/run.py
ADDED
|
@@ -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"]
|
bb_sync/core/__init__.py
ADDED
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
|
+
]
|