testlens 0.3.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.
testlens/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """本地测试工具箱:清单、变更检测、面板、覆盖率。可作为插件嵌入宿主项目。
2
+
3
+ 模块边界:
4
+
5
+ config.py Config / PackageSpec / 校验 / detect_packages / TOML 加载
6
+ scan.py Scanner:脚本缓存与逐字变更检测(不依赖 git)
7
+ history.py HistoryStore:每次运行的索引 + 日志落盘
8
+ coverage_report.py CoverageService:解析 .coverage.* 并给出未覆盖行
9
+ junit.py junitxml 解析:用例统计 / 每脚本耗时 / 最慢 / 失败
10
+ runner.py RunManager:pytest 进程生命周期与输出缓冲
11
+ inventory.py 扫描结果 -> 页面数据 / 静态页
12
+ server.py HTTP 面板(只监听回环地址)
13
+ cli.py 命令行入口(list / changed / plan / page / serve / dev)
14
+ __main__.py `python -m testlens` 的入口
15
+ toolkit.py 聚合入口:Toolkit(config) 把上面全部串起来
16
+
17
+ 两个入口:
18
+
19
+ python -m testlens <command> CLI(读当前目录的 toml / py 配置)
20
+ from testlens import Toolkit 插件嵌入(宿主显式给 packages 与 repo_root)
21
+
22
+ 详见 packages/testlens/README.md 的「作为插件接入」与「依赖与运行要求」两节。
23
+ """
24
+
25
+ from .config import (
26
+ Config,
27
+ ConfigError,
28
+ PackageSpec,
29
+ build_config,
30
+ detect_packages,
31
+ load_config,
32
+ source_label,
33
+ )
34
+ from .toolkit import Toolkit
35
+
36
+ __version__ = "0.3.0"
37
+
38
+ __all__ = [
39
+ "Config",
40
+ "ConfigError",
41
+ "PackageSpec",
42
+ "Toolkit",
43
+ "build_config",
44
+ "detect_packages",
45
+ "load_config",
46
+ "source_label",
47
+ "__version__",
48
+ ]
testlens/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """`python -m testlens` 的入口。"""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
testlens/cli.py ADDED
@@ -0,0 +1,557 @@
1
+ #!/usr/bin/env python3
2
+ """命令行入口:`python -m testlens <command>`。
3
+
4
+ list 有哪些测试脚本、各有多少用例
5
+ changed 哪些脚本与缓存基线不一致
6
+ plan 该跑哪些(默认;加 --run 直接跑)
7
+ page 生成可直接双击的静态页
8
+ serve 起本地面板(读当前目录的 toml / py 配置)
9
+ dev 按目录起面板:优先读目录里的配置,没有才推断(像 `fastapi dev <app>`)
10
+
11
+ 配置来自宿主项目的 `testlens.toml` 或 `testlens_config.py`(也可以用 --config 指定)。插件**不带任何
12
+ 包定义**:找不到配置、或配置里没有 `[[package]]`,直接报错退出并给出样例。
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import importlib.util
19
+ import os
20
+ import re
21
+ import shlex
22
+ import subprocess
23
+ import sys
24
+ from dataclasses import replace
25
+ from pathlib import Path
26
+
27
+ from . import __version__
28
+ from . import __version__
29
+ from .config import (
30
+ DATA_DIR_NAME,
31
+ Config,
32
+ ConfigError,
33
+ PackageSpec,
34
+ build_config,
35
+ root_prefix,
36
+ count_tests,
37
+ load_config,
38
+ select_packages,
39
+ test_files,
40
+ )
41
+ from .toolkit import Toolkit
42
+
43
+
44
+ # ----------------------------------------------------------------------
45
+ # 配置:从 host 项目的 testlens.toml 读
46
+ # ----------------------------------------------------------------------
47
+ CONFIG_FILENAME = "testlens.toml"
48
+ # 同一目录里也可以只放 Python 配置:覆盖率这类 toml 里不方便表达的字段用它
49
+ # (形如 apps/web-service/testlens_config.py)。两者都在时 toml 优先。
50
+ CONFIG_MODULE = "testlens_config.py"
51
+ CONFIG_CANDIDATES = (CONFIG_FILENAME, CONFIG_MODULE)
52
+ CONFIG_ENV = "TESTLENS_CONFIG"
53
+
54
+ SAMPLE_CONFIG = """# testlens 的宿主配置。插件本身不带任何包定义,这里必须写 [[package]]。
55
+
56
+ [page]
57
+ dir = "reports" # 静态页目录,相对仓库根;省略就放 testlens.data/page
58
+ name = "testlens.html" # 文件名
59
+ single = false # true = 合成一个自包含的 HTML
60
+
61
+ [server]
62
+ port = 8787
63
+
64
+ [commands]
65
+ pytest_cmd = "uv run pytest"
66
+ coverage_json_cmd = "uv run coverage json"
67
+
68
+ [coverage]
69
+ warmup = true # 启动时数据文件不存在,就先跑一遍带 --cov 的测试
70
+
71
+ [[package]]
72
+ name = "web-service" # 唯一 id,只能小写字母/数字/连字符
73
+ label = "Web Service" # 界面上的包名
74
+ root = "apps/web-service" # 相对仓库根
75
+ source_pkg = "app" # 只写这一项:覆盖率四件套自动推导
76
+ """
77
+
78
+
79
+ def find_config(explicit: str | None = None) -> Path:
80
+ """定位配置文件:显式参数 > TESTLENS_CONFIG > 当前目录的 toml / py 配置。
81
+
82
+ 两个文件名按 toml 优先:声明式配置能表达的东西更全,Python 配置是给
83
+ 「要算路径、要写覆盖率参数」的场景兜底的。
84
+ """
85
+ if explicit:
86
+ path = Path(explicit).expanduser()
87
+ if not path.is_file():
88
+ raise ConfigError(f"配置文件不存在:{path}")
89
+ return path
90
+ if os.environ.get(CONFIG_ENV):
91
+ path = Path(os.environ[CONFIG_ENV]).expanduser()
92
+ if not path.is_file():
93
+ raise ConfigError(f"{CONFIG_ENV} 指向的配置文件不存在:{path}")
94
+ return path
95
+
96
+ for name in CONFIG_CANDIDATES:
97
+ path = Path.cwd() / name
98
+ if path.is_file():
99
+ return path
100
+
101
+ raise ConfigError(
102
+ f"当前目录没有 {CONFIG_FILENAME} 或 {CONFIG_MODULE},插件不自带包定义。\n"
103
+ f" 指向已有的配置:testlens serve --config path/to/{CONFIG_MODULE}\n"
104
+ f" 或者直接给应用目录:testlens dev apps/web-service\n"
105
+ f" 都没有就建一个:\n\n{SAMPLE_CONFIG}"
106
+ )
107
+
108
+
109
+ def load_cli_config(config_path: str | None = None) -> Config:
110
+ """CLI 的配置入口:`.toml` 走声明式,`.py` 取模块里的 config/toolkit。
111
+
112
+ `--config` 两种都收,这样只维护应用自己那份 Python 配置也能跑全部子命令
113
+ (list / plan / serve / page),不必在仓库根再写一份 toml。
114
+ """
115
+ path = find_config(config_path)
116
+ if path.suffix == ".toml":
117
+ return load_config(path)
118
+ if path.suffix == ".py":
119
+ return config_from_module(path)
120
+ raise ConfigError(f"配置文件只支持 .toml 或 .py:{path}")
121
+
122
+
123
+ def infer_spec(path: Path) -> PackageSpec:
124
+ """把一个目录当成一套测试:root='.'、测试在 <dir>/test,名字由目录名推出来。"""
125
+ raw = path.name or "app"
126
+ name = re.sub(r"[^a-z0-9-]+", "-", raw.lower()).strip("-") or "app"
127
+ return PackageSpec(name=name, label=raw, root=".")
128
+
129
+
130
+ def config_from_module(path: Path) -> Config:
131
+ """从 .py 里取 config(或 toolkit)。像 `fastapi dev app/main.py` 找 app 那样。"""
132
+ module_name = f"_testlens_target_{path.stem}"
133
+ spec = importlib.util.spec_from_file_location(module_name, path)
134
+ if spec is None or spec.loader is None:
135
+ raise ConfigError(f"没法当模块加载:{path}")
136
+
137
+ # 让模块里的 `import app.xxx` 能找到:把它的目录和当前目录放进 sys.path
138
+ for extra in (path.parent, Path.cwd()):
139
+ if str(extra) not in sys.path:
140
+ sys.path.insert(0, str(extra))
141
+
142
+ module = importlib.util.module_from_spec(spec)
143
+ try:
144
+ spec.loader.exec_module(module)
145
+ except Exception as error: # noqa: BLE001 - 用户的模块,报清楚原因即可
146
+ raise ConfigError(
147
+ f"加载 {path.name} 失败:{type(error).__name__}: {error}"
148
+ ) from error
149
+
150
+ for name in ("config", "toolkit"):
151
+ value = getattr(module, name, None)
152
+ if isinstance(value, Config):
153
+ # 记下来源,面板标题与启动横幅都会显示,用来确认读的是哪份配置
154
+ return replace(value, source=str(path.resolve()))
155
+ if isinstance(value, Toolkit):
156
+ return replace(value.config, source=str(path.resolve()))
157
+
158
+ raise ConfigError(
159
+ f"{path.name} 里没有找到 config(或 toolkit)。在文件里写一行就行:\n"
160
+ " config = build_config(packages=(PackageSpec(...),), repo_root=Path(__file__).parent)\n"
161
+ "注意只放配置,不要在导入时启动服务。"
162
+ )
163
+
164
+
165
+ def config_for_path(
166
+ target: str | None,
167
+ config_path: str | None,
168
+ *,
169
+ port: int | None = None,
170
+ open_browser: bool | None = None,
171
+ ) -> Config:
172
+ """`dev TARGET` 的配置。TARGET 可以是目录,也可以是 .py。
173
+
174
+ - `.py`:从模块里取 `config`(或 `toolkit`),适合需要覆盖率等完整配置的场景
175
+ - 目录:优先用它自己的 `testlens.toml`,其次 `testlens_config.py`,
176
+ 都没有才按目录推断(一个包、root='.'、测试在 test/)
177
+ """
178
+ if config_path:
179
+ base = load_cli_config(config_path)
180
+ else:
181
+ path = Path(target or ".").expanduser().resolve()
182
+ if path.is_file():
183
+ if path.suffix != ".py":
184
+ raise ConfigError(f"只接受目录或 .py 文件:{path}")
185
+ base = config_from_module(path)
186
+ elif path.is_dir():
187
+ config_file = next(
188
+ (path / name for name in CONFIG_CANDIDATES if (path / name).is_file()), None
189
+ )
190
+ if config_file is not None:
191
+ # 目录自带配置就用它:覆盖率参数只有宿主自己知道
192
+ base = load_cli_config(str(config_file))
193
+ else:
194
+ # 静态页的默认位置(testlens.data/page)由 build_config 给,这里不用再管
195
+ base = build_config(packages=(infer_spec(path),), repo_root=path)
196
+ else:
197
+ raise ConfigError(f"路径不存在:{path}")
198
+
199
+ overrides = {}
200
+ if port is not None:
201
+ overrides["port"] = port
202
+ if open_browser is not None:
203
+ overrides["open_browser"] = open_browser
204
+ return replace(base, **overrides) if overrides else base
205
+
206
+
207
+ SKIP_DIRS = {
208
+ ".git",
209
+ ".venv",
210
+ "venv",
211
+ "node_modules",
212
+ "__pycache__",
213
+ DATA_DIR_NAME,
214
+ ".data", # 旧版目录名,迁移期间也可能存在
215
+ "htmlcov",
216
+ "dist",
217
+ }
218
+
219
+
220
+ def discover_configs(root: Path, max_depth: int = 3) -> list[Path]:
221
+ """在目录下找可用的配置文件。只在「找不到配置」的提示里用,不参与主流程。"""
222
+ root = root.resolve()
223
+ found: list[Path] = []
224
+ for current, dirs, files in os.walk(root):
225
+ depth = len(Path(current).relative_to(root).parts)
226
+ if depth >= max_depth:
227
+ dirs[:] = []
228
+ dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS and not d.startswith("."))
229
+ found.extend(Path(current) / name for name in CONFIG_CANDIDATES if name in files)
230
+ return found
231
+
232
+
233
+ def missing_config_hint(config: Config) -> str:
234
+ """dev 按目录推断出 0 个脚本时的提示:说清这里缺什么、该指向哪。"""
235
+ root = config.repo_root
236
+ first = config.packages[0]
237
+ lines = [
238
+ f"这里没有可用的测试配置:{root}",
239
+ f" 既没有 {CONFIG_FILENAME} / {CONFIG_MODULE},"
240
+ f"推断出的 {first.tests_dir}/ 下也没有 {first.test_glob}。",
241
+ " 指向已有的配置再跑:",
242
+ " testlens dev apps/web-service",
243
+ f" testlens dev apps/web-service/{CONFIG_MODULE}",
244
+ ]
245
+ found = discover_configs(root)
246
+ if found:
247
+ lines.append(" 在这个目录下找到这些配置:")
248
+ for path in found:
249
+ try:
250
+ shown = path.relative_to(Path.cwd()).as_posix()
251
+ except ValueError:
252
+ shown = str(path)
253
+ lines.append(f" {shown}")
254
+ return "\n".join(lines)
255
+
256
+
257
+ def _describe(item: dict) -> str:
258
+ if item["status"] == "new":
259
+ return "新增(缓存里没有)"
260
+ if item["status"] == "missing":
261
+ return "已删除(缓存里还有)"
262
+ diff = item.get("diff")
263
+ if diff:
264
+ return f"内容已修改(+{diff['added']} -{diff['removed']} 行)"
265
+ return "内容已修改"
266
+
267
+
268
+ # ----------------------------------------------------------------------
269
+ # list
270
+ # ----------------------------------------------------------------------
271
+ def cmd_list(toolkit: Toolkit, packages: tuple[PackageSpec, ...]) -> int:
272
+ config = toolkit.config
273
+ total_files = 0
274
+ total_tests = 0
275
+
276
+ for pkg in packages:
277
+ files = test_files(pkg, config.repo_root)
278
+ if not files:
279
+ continue
280
+ rows = [(path, count_tests(path, pkg.func_prefix)) for path in files]
281
+ pkg_tests = sum(count for _, count in rows)
282
+ total_files += len(rows)
283
+ total_tests += pkg_tests
284
+
285
+ print(f"\n {pkg.label} ({pkg.root})")
286
+ print(f" {len(rows)} 个文件, {pkg_tests} 个测试函数")
287
+ for path, count in rows:
288
+ rel = path.relative_to(config.repo_root / pkg.root).as_posix()
289
+ print(f" {rel:<48} {count:>3}")
290
+
291
+ print(f"\n合计:{len(packages)} 个包,{total_files} 个脚本文件,{total_tests} 个测试函数")
292
+ print(" (参数化的用例会展开成多条,实际执行条数比这个数字多)")
293
+ return 0
294
+
295
+
296
+ # ----------------------------------------------------------------------
297
+ # changed
298
+ # ----------------------------------------------------------------------
299
+ def cmd_changed(toolkit: Toolkit, packages: tuple[PackageSpec, ...]) -> int:
300
+ result = toolkit.scan(packages)
301
+ changed = [item for item in result["scripts"] if item["changed"]]
302
+ if not changed:
303
+ print("\n没有检测到测试脚本变化。")
304
+ print(f" 缓存基线:{result['cached']} 个脚本({toolkit.scanner.cache_file})")
305
+ return 0
306
+
307
+ print(f"\n相对缓存基线,有 {len(changed)} 个测试脚本变化:\n")
308
+ for pkg in packages:
309
+ items = [item for item in changed if item["package"] == pkg.name]
310
+ if not items:
311
+ continue
312
+ print(f" {pkg.label} ({len(items)})")
313
+ for item in items:
314
+ print(f" {item['path']}")
315
+ print(f" · {_describe(item)}")
316
+ print()
317
+ return 0
318
+
319
+
320
+ # ----------------------------------------------------------------------
321
+ # plan
322
+ # ----------------------------------------------------------------------
323
+ def cmd_plan(toolkit: Toolkit, packages: tuple[PackageSpec, ...], *, run: bool = False) -> int:
324
+ result = toolkit.scan(packages)
325
+ changed = [item for item in result["scripts"] if item["changed"]]
326
+ if not changed:
327
+ print("\n没有检测到测试脚本变化,不需要重跑。")
328
+ print(f" 缓存基线:{result['cached']} 个脚本")
329
+ print(" 这是变更检测的边界:它只对比测试脚本自身。")
330
+ print(" 业务源码改过的话它不知道,那种情况在面板里点「全部执行」。")
331
+ return 0
332
+
333
+ print(f"\n检测到 {len(changed)} 个测试脚本有变化:\n")
334
+ for item in changed:
335
+ print(f" {item['path']}")
336
+ print(f" · {_describe(item)}")
337
+
338
+ repo_root = toolkit.config.repo_root
339
+ runnable = [
340
+ repo_root / item["path"] for item in changed if item["status"] != "missing"
341
+ ]
342
+ return _report(toolkit, runnable, run=run, title="建议运行")
343
+
344
+
345
+ def _report(toolkit: Toolkit, files: list[Path], *, run: bool, title: str) -> int:
346
+ config = toolkit.config
347
+ ordered = sorted(set(files))
348
+ total = 0
349
+ for path in ordered:
350
+ rel = path.relative_to(config.repo_root).as_posix()
351
+ for pkg in config.packages:
352
+ if rel.startswith(root_prefix(pkg)):
353
+ total += count_tests(path, pkg.func_prefix)
354
+ break
355
+ print(f"\n{title}:{len(ordered)} 个脚本 / {total} 个测试函数\n")
356
+ for path in ordered:
357
+ print(f" {path.relative_to(config.repo_root).as_posix()}")
358
+
359
+ if run:
360
+ print("\n开始运行…\n")
361
+ return _run(config, ordered)
362
+ print("\n直接跑:python -m testlens plan --run")
363
+ return 0
364
+
365
+
366
+ def _run(config: Config, files: list[Path]) -> int:
367
+ """按包分组跑。不同包的 pytest 配置(覆盖率、asyncio 模式)不一样,不能混。"""
368
+ grouped: dict[str, list[str]] = {}
369
+ for path in files:
370
+ rel = path.relative_to(config.repo_root).as_posix()
371
+ for pkg in config.packages:
372
+ if rel.startswith(root_prefix(pkg)):
373
+ grouped.setdefault(pkg.name, []).append(rel)
374
+ break
375
+
376
+ order = {pkg.name: index for index, pkg in enumerate(config.packages)}
377
+ failed = False
378
+ sys.stdout.flush()
379
+ for name in sorted(grouped, key=lambda name: order.get(name, len(order))):
380
+ completed = subprocess.run(
381
+ [*shlex.split(config.pytest_cmd), *grouped[name], "-q"],
382
+ cwd=config.repo_root,
383
+ check=False,
384
+ )
385
+ failed = failed or completed.returncode != 0
386
+
387
+ return 1 if failed else 0
388
+
389
+
390
+ # ----------------------------------------------------------------------
391
+ # page
392
+ # ----------------------------------------------------------------------
393
+ def cmd_page(toolkit: Toolkit, output: str | None, single: bool | None = None) -> int:
394
+ target = Path(output) if output else None
395
+ files = toolkit.build_static_page(target, single=single)
396
+ for path in files:
397
+ print(f"已生成 {path} {path.stat().st_size / 1024:.0f} KB")
398
+ payload = toolkit.build_payload()
399
+ print(f" {payload['total']} 个脚本,{payload['changed']} 个已更新")
400
+ source = toolkit.config.source
401
+ print(f" 想在页面上直接运行:testlens serve --config \"{source}\"" if source else " 想在页面上直接运行:testlens serve")
402
+ return 0
403
+
404
+
405
+ # ----------------------------------------------------------------------
406
+ # 入口
407
+ # ----------------------------------------------------------------------
408
+ def build_parser() -> argparse.ArgumentParser:
409
+ parser = argparse.ArgumentParser(
410
+ prog="testlens",
411
+ description="测试脚本清单、变更检测、面板与覆盖率",
412
+ )
413
+ config_help = (
414
+ f"配置文件路径:.toml 或 .py(默认在当前目录找 {CONFIG_FILENAME} / "
415
+ f"{CONFIG_MODULE},toml 优先)"
416
+ )
417
+ parser.add_argument("--config", default=None, metavar="PATH", help=config_help)
418
+ parser.add_argument(
419
+ "--version", action="version", version=f"%(prog)s {__version__}"
420
+ )
421
+ sub = parser.add_subparsers(dest="command")
422
+
423
+ def add_config_arg(target: argparse.ArgumentParser) -> None:
424
+ """子命令里也认 --config(`testlens plan --config x.py`)。
425
+
426
+ default=SUPPRESS 是关键:不这么写,子解析器的默认 None 会把顶层
427
+ `testlens --config x.py plan` 已经读到的值覆盖掉。
428
+ """
429
+ target.add_argument(
430
+ "--config", default=argparse.SUPPRESS, metavar="PATH", help=config_help
431
+ )
432
+
433
+ def add_warm_coverage_arg(target: argparse.ArgumentParser) -> None:
434
+ target.add_argument(
435
+ "--warm-coverage",
436
+ action=argparse.BooleanOptionalAction,
437
+ default=None,
438
+ help="启动前先生成缺失的覆盖率数据文件(覆盖配置里的 [coverage] warmup)",
439
+ )
440
+
441
+ def add_package_arg(target: argparse.ArgumentParser) -> None:
442
+ target.add_argument(
443
+ "--package",
444
+ default="all",
445
+ help="只处理一套:按 name 选(默认 all)",
446
+ )
447
+
448
+ p_list = sub.add_parser("list", help="列出全部脚本与各自的测试函数数")
449
+ add_package_arg(p_list)
450
+ add_config_arg(p_list)
451
+
452
+ p_changed = sub.add_parser("changed", help="哪些脚本与缓存基线不一致")
453
+ add_package_arg(p_changed)
454
+ add_config_arg(p_changed)
455
+
456
+ p_plan = sub.add_parser("plan", help="该跑哪些")
457
+ add_package_arg(p_plan)
458
+ add_config_arg(p_plan)
459
+ p_plan.add_argument("--run", action="store_true", help="列出后直接跑")
460
+
461
+ p_page = sub.add_parser("page", help="生成静态页")
462
+ add_config_arg(p_page)
463
+ p_page.add_argument("--output", default=None, help="自定义输出路径")
464
+ p_page.add_argument(
465
+ "--single",
466
+ action="store_true",
467
+ default=None,
468
+ help="合成一个自包含的 HTML(默认拆成 html + css + js)",
469
+ )
470
+
471
+ p_serve = sub.add_parser(
472
+ "serve", help="起本地测试面板(读当前目录的 testlens.toml / testlens_config.py)"
473
+ )
474
+ add_config_arg(p_serve)
475
+ p_serve.add_argument("--port", type=int, default=None)
476
+ p_serve.add_argument("--no-browser", action="store_true", help="不自动打开浏览器")
477
+ add_warm_coverage_arg(p_serve)
478
+
479
+ p_dev = sub.add_parser(
480
+ "dev",
481
+ help="起面板:给目录先读目录里的配置、没有才推断;给 .py 则读里面的 config",
482
+ )
483
+ add_config_arg(p_dev)
484
+ p_dev.add_argument(
485
+ "path",
486
+ nargs="?",
487
+ default=".",
488
+ help=(
489
+ "目录(先用目录里的 testlens.toml / testlens_config.py,都没有才推断)"
490
+ "或 .py 文件(读模块里的 config)"
491
+ ),
492
+ )
493
+ p_dev.add_argument("--port", type=int, default=None)
494
+ p_dev.add_argument("--no-browser", action="store_true", help="不自动打开浏览器")
495
+ add_warm_coverage_arg(p_dev)
496
+
497
+ return parser
498
+
499
+
500
+ def main(argv: list[str] | None = None) -> int:
501
+ parser = build_parser()
502
+ args = parser.parse_args(argv)
503
+
504
+ if args.command is None:
505
+ parser.print_help()
506
+ return 0
507
+
508
+ try:
509
+ if args.command == "dev":
510
+ config = config_for_path(
511
+ args.path,
512
+ args.config,
513
+ port=args.port,
514
+ open_browser=False if args.no_browser else None,
515
+ )
516
+ else:
517
+ config = load_cli_config(args.config)
518
+ except ConfigError as error:
519
+ print(f"配置有问题:\n{error}", file=sys.stderr)
520
+ return 2
521
+
522
+ if args.command in ("serve", "dev"):
523
+ if args.command == "serve":
524
+ overrides = {}
525
+ if args.port is not None:
526
+ overrides["port"] = args.port
527
+ if args.no_browser:
528
+ overrides["open_browser"] = False
529
+ if overrides:
530
+ config = replace(config, **overrides)
531
+ if args.warm_coverage is not None:
532
+ config = replace(config, coverage_warmup=args.warm_coverage)
533
+ toolkit = Toolkit(config)
534
+ # dev 没找到配置文件、按目录推断又扫不到脚本:直接说清楚,别起一个
535
+ # 「看着在跑、其实一个脚本都没有」的空面板
536
+ if config.source is None and not toolkit.scan()["total"]:
537
+ print(missing_config_hint(config), file=sys.stderr)
538
+ return 2
539
+ # 启动横幅(配置来源 / 仓库 / 包与脚本数)统一由 server.run 打印:
540
+ # serve 与 dev 的区别只在配置怎么来,其余输出没必要各写一份
541
+ return toolkit.serve()
542
+
543
+ toolkit = Toolkit(config)
544
+
545
+ if args.command == "page":
546
+ return cmd_page(toolkit, args.output, args.single)
547
+
548
+ packages = select_packages(config.packages, args.package)
549
+ if args.command == "list":
550
+ return cmd_list(toolkit, packages)
551
+ if args.command == "changed":
552
+ return cmd_changed(toolkit, packages)
553
+ return cmd_plan(toolkit, packages, run=args.run)
554
+
555
+
556
+ if __name__ == "__main__":
557
+ sys.exit(main())