"""命令行测试。 CLI 是**另一个进程**,所以这里全部通过 ``--db`` 指向真实的 SQLite 文件,而不是把 ``Controller`` 塞进去——那样测的就不是 CLI 了。 重点覆盖两件容易静默出错的事: * **输出契约**(``--json`` 的形状、退出码),因为脚本要依赖它; * **错误路径**,尤其是"给一个不存在的任务下指令"——它曾经会成功并留下一条永远不会 被执行的指令。 """ from __future__ import annotations import json import os from datetime import UTC, datetime from pathlib import Path import pytest from chongming_task.cli.main import ( EXIT_ERROR, EXIT_OK, EXIT_TASK_NOT_SUCCESSFUL, build_parser, main, ) from chongming_task.errors import ConfigurationError from chongming_task.models import TaskSnapshot from chongming_task.states import TaskStatus from chongming_task.stores.sqlite import SqliteTaskStore _TASKS_MODULE = "tasks_demo" @pytest.fixture def db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> str: """把示例任务模块加进导入路径,并返回一个全新的库路径。""" import sys examples = Path(__file__).resolve().parents[2] / "examples" monkeypatch.syspath_prepend(str(examples)) sys.modules.pop(_TASKS_MODULE, None) return str(tmp_path / "tasks.db") def _run_cli(db: str, *argv: str) -> int: return main(["--db", db, *argv]) # --------------------------------------------------------------------------- # 参数解析 # --------------------------------------------------------------------------- def test_parser_requires_a_subcommand() -> None: with pytest.raises(SystemExit): build_parser().parse_args([]) def test_parser_help_mentions_every_command() -> None: text = build_parser().format_help() for command in ( "tasks", "run", "ps", "status", "history", "stats", "buckets", "stop", "pause", "resume", ): assert command in text # --------------------------------------------------------------------------- # 定义 # --------------------------------------------------------------------------- def test_tasks_lists_the_registered_definitions( db: str, capsys: pytest.CaptureFixture[str] ) -> None: assert main(["tasks", "--tasks", _TASKS_MODULE]) == EXIT_OK out = capsys.readouterr().out assert "sync" in out assert "add" in out assert "两数相加。" in out, "注册项的 docstring 应当作为描述显示" def test_tasks_on_an_unimportable_module_fails_clearly( capsys: pytest.CaptureFixture[str], ) -> None: assert main(["tasks", "--tasks", "no.such.module"]) == EXIT_ERROR assert "无法导入任务模块" in capsys.readouterr().err def test_a_module_not_found_error_explains_sys_path( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """模块自身没找到时,必须说清"为什么找不到"。 最常踩的坑:``chongming-task`` 是**入口脚本**,不会把当前目录放进 ``sys.path``—— 把模块放在当前目录、然后直接 ``--tasks mymodule`` 会失败,而 "No module named" 本身完全不解释这件事。 """ monkeypatch.delenv("PYTHONPATH", raising=False) assert main(["tasks", "--tasks", "definitely_not_installed"]) == EXIT_ERROR err = capsys.readouterr().err assert "PYTHONPATH" in err assert "不会**把当前目录放进 sys.path" in err assert "python -m chongming_task.cli.main" in err def test_a_module_found_but_failing_to_import_says_so( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """模块存在、但内部导入失败:不能把矛头指向 sys.path,那是它自己的依赖问题。""" module = tmp_path / "broken_tasks.py" module.write_text("import no_such_dependency_here\n", encoding="utf-8") monkeypatch.syspath_prepend(str(tmp_path)) assert main(["tasks", "--tasks", "broken_tasks"]) == EXIT_ERROR err = capsys.readouterr().err assert "找到了,但它导入失败" in err assert "PYTHONPATH" not in err def test_the_cli_module_can_be_run_with_python_m() -> None: """备选入口:``python -m`` 会把当前目录放进 ``sys.path``,因此根目录的模块也能用。 这条用例钉住"备选入口存在"这个事实——它已经写进了错误提示里,不能悄悄消失。 """ source = Path(__file__).resolve().parents[2] / "src" / "chongming_task" / "cli" / "main.py" assert source.is_file() assert 'if __name__ == "__main__"' in source.read_text(encoding="utf-8") def test_tasks_on_a_module_without_register_fails_clearly( capsys: pytest.CaptureFixture[str], ) -> None: """约定必须说清楚——否则用户只会看到一句 AttributeError。""" assert main(["tasks", "--tasks", "json"]) == EXIT_ERROR assert "register(controller)" in capsys.readouterr().err # --------------------------------------------------------------------------- # 提交与执行 # --------------------------------------------------------------------------- def test_run_can_execute_in_a_child_process(db: str, capsys: pytest.CaptureFixture[str]) -> None: """``--executor process``:任务在子进程里跑,快照因此带上 pid。 这是"能被真的杀掉"那条能力在命令行上的入口——前台 ``run`` 遇到不配合的任务时, 另一个终端发 ``stop`` 就能把它连根拔掉(线程只能放弃等待)。 """ code = _run_cli( db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=2", "--arg", "right=3", "--executor", "process", ) assert code == EXIT_OK assert "succeeded" in capsys.readouterr().out store = SqliteTaskStore(db) try: snapshot = store.query()[0] assert snapshot.executor == "process" assert snapshot.pid is not None assert snapshot.pid != os.getpid(), "任务确实跑在另一个进程里" finally: store.close() def test_run_executes_and_exits_zero(db: str, capsys: pytest.CaptureFixture[str]) -> None: code = _run_cli( db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=2", "--arg", "right=3" ) assert code == EXIT_OK out = capsys.readouterr().out assert "succeeded" in out store = SqliteTaskStore(db) try: snapshots = store.query() assert len(snapshots) == 1 assert snapshots[0].outcome == {"value": 5} finally: store.close() def test_run_parses_json_typed_arguments(db: str) -> None: """``--arg n=3`` 应当得到整数 3,而不是字符串 "3"。""" assert ( _run_cli( db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=20", "--arg", "right=22" ) == EXIT_OK ) store = SqliteTaskStore(db) try: assert store.query()[0].outcome == {"value": 42} finally: store.close() def test_run_on_a_failing_task_exits_three(db: str) -> None: """命令成功、任务失败——两者用不同退出码区分,脚本才好判分支。""" code = _run_cli(db, "run", "--tasks", _TASKS_MODULE, "flaky", "--arg", "fail_times=9") assert code == EXIT_TASK_NOT_SUCCESSFUL def test_run_rejects_a_malformed_arg(db: str, capsys: pytest.CaptureFixture[str]) -> None: code = _run_cli(db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "noequals") assert code == EXIT_ERROR assert "K=V" in capsys.readouterr().err def test_run_records_the_bucket(db: str) -> None: code = _run_cli( db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=1", "--arg", "right=1", "--bucket-key", "tenant-9", ) assert code == EXIT_OK store = SqliteTaskStore(db) try: snapshot = store.query()[0] assert snapshot.bucket_key == "tenant-9" assert snapshot.bucket is not None finally: store.close() # --------------------------------------------------------------------------- # 查询 # --------------------------------------------------------------------------- def test_ps_defaults_to_unfinished_and_all_shows_everything( db: str, capsys: pytest.CaptureFixture[str] ) -> None: assert ( _run_cli(db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=1", "--arg", "right=1") == EXIT_OK ) capsys.readouterr() assert _run_cli(db, "ps") == EXIT_OK assert "没有匹配的任务" in capsys.readouterr().out, "默认只显示未落终态的" assert _run_cli(db, "ps", "--all") == EXIT_OK assert "succeeded" in capsys.readouterr().out def test_status_shows_detail_and_history(db: str, capsys: pytest.CaptureFixture[str]) -> None: _run_cli(db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=1", "--arg", "right=1") capsys.readouterr() assert _run_cli(db, "status", "x") == EXIT_ERROR # 不存在 store = SqliteTaskStore(db) try: task_id = store.query()[0].task_id finally: store.close() assert _run_cli(db, "status", task_id) == EXIT_OK out = capsys.readouterr().out assert "status" in out assert "迁移历史" in out assert "executor:completed" in out def test_stats_and_buckets(db: str, capsys: pytest.CaptureFixture[str]) -> None: _run_cli(db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=1", "--arg", "right=1") capsys.readouterr() assert _run_cli(db, "stats") == EXIT_OK out = capsys.readouterr().out assert "succeeded" in out assert "total" in out assert _run_cli(db, "buckets") == EXIT_OK assert "BUCKET" in capsys.readouterr().out def test_ps_rejects_an_unknown_status(db: str, capsys: pytest.CaptureFixture[str]) -> None: assert _run_cli(db, "ps", "--status", "no-such-status") == EXIT_ERROR assert "未知状态" in capsys.readouterr().err def test_ps_filters_by_pipeline_run(db: str, capsys: pytest.CaptureFixture[str]) -> None: """按管道查看是看板最常问的问题,过滤下推到存储层。""" from datetime import UTC, datetime from chongming_task.models import TaskSnapshot store = SqliteTaskStore(db) try: for task_id, run_id in (("a1", "run-a"), ("a2", "run-a"), ("b1", "run-b")): store.create( TaskSnapshot( task_id=task_id, name="节点", status=TaskStatus.SUCCEEDED, status_version=1, attempt=1, max_attempts=1, created_at=datetime(2026, 1, 1, tzinfo=UTC), ended_at=datetime(2026, 1, 1, 1, tzinfo=UTC), pipeline_run_id=run_id, ) ) finally: store.close() assert _run_cli(db, "ps", "--all", "--pipeline", "run-a") == EXIT_OK out = capsys.readouterr().out assert "a1" in out assert "a2" in out assert "b1" not in out assert _run_cli(db, "ps", "--all", "--pipeline", "no-such-run") == EXIT_OK assert "没有匹配的任务" in capsys.readouterr().out # --------------------------------------------------------------------------- # 输出契约 # --------------------------------------------------------------------------- def test_json_output_is_primitive_only(db: str, capsys: pytest.CaptureFixture[str]) -> None: """``--json`` 的输出必须全是原语,脚本才不必了解 datetime / 枚举的序列化规则。""" _run_cli(db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=2", "--arg", "right=3") capsys.readouterr() assert main(["--db", db, "--json", "ps", "--all"]) == EXIT_OK payload = json.loads(capsys.readouterr().out) assert isinstance(payload, list) entry = payload[0] assert entry["status"] == "succeeded" assert entry["outcome"] == '{"value": 5}' assert all( value is None or isinstance(value, (str, int, float, bool)) for value in entry.values() ), "出现了非原语字段" def test_json_error_path_uses_stderr_not_stdout(capsys: pytest.CaptureFixture[str]) -> None: assert main(["--json", "ps"]) == EXIT_ERROR captured = capsys.readouterr() assert captured.out == "" assert "未指定任务存储" in captured.err # --------------------------------------------------------------------------- # 错误路径 # --------------------------------------------------------------------------- def test_missing_db_is_reported_clearly( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.delenv("CHONGMING_TASK_DB", raising=False) assert main(["ps"]) == EXIT_ERROR assert "CHONGMING_TASK_DB" in capsys.readouterr().err def test_db_can_come_from_the_environment(db: str, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("CHONGMING_TASK_DB", db) _run_cli(db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=1", "--arg", "right=1") assert main(["ps", "--all"]) == EXIT_OK def test_stop_on_an_unknown_task_fails_instead_of_writing_a_dead_command( db: str, capsys: pytest.CaptureFixture[str] ) -> None: """曾经这里的 bug:给不存在的任务下指令会成功,留下一条永远不会被执行的指令, 而调用方还以为"停止已下发"。 """ _run_cli(db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=1", "--arg", "right=1") capsys.readouterr() assert _run_cli(db, "stop", "typo-task-id") == EXIT_ERROR assert "没有找到任务" in capsys.readouterr().err store = SqliteTaskStore(db) try: assert store.commands() == [], "不该留下任何指令" finally: store.close() def test_control_on_an_existing_task_is_accepted( db: str, capsys: pytest.CaptureFixture[str] ) -> None: _run_cli(db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=1", "--arg", "right=1") capsys.readouterr() store = SqliteTaskStore(db) try: task_id = store.query()[0].task_id finally: store.close() # 任务已落终态,但"找到并受理"仍然成立。 assert _run_cli(db, "stop", task_id, "--reason", "收尾") == EXIT_OK assert "已请求停止" in capsys.readouterr().out def test_store_directory_missing_is_reported_clearly( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: """SQLite 只会报 "unable to open database file",完全看不出是目录不存在。""" missing = tmp_path / "not-created" / "tasks.db" assert main(["--db", str(missing), "ps"]) == EXIT_ERROR assert "目录不存在" in capsys.readouterr().err # --------------------------------------------------------------------------- # 重启恢复 # --------------------------------------------------------------------------- def _plant_zombie(db: str, task_id: str = "zombie") -> None: """造一个"上次崩溃留下的"任务行:running 且租约早已过期。""" from datetime import UTC, datetime, timedelta from chongming_task.models import TaskSnapshot store = SqliteTaskStore(db) try: old = datetime.now(UTC) - timedelta(minutes=10) store.create( TaskSnapshot( task_id=task_id, name="遗留任务", status=TaskStatus.RUNNING, status_version=3, attempt=1, max_attempts=1, created_at=old, started_at=old, lease_owner="worker-old", lease_until=datetime.now(UTC) - timedelta(minutes=9), ) ) finally: store.close() def test_reconcile_dry_run_writes_nothing(db: str, capsys: pytest.CaptureFixture[str]) -> None: _plant_zombie(db) assert _run_cli(db, "reconcile", "--dry-run") == EXIT_OK out = capsys.readouterr().out assert "marked-lost" in out assert "预演" in out store = SqliteTaskStore(db) try: snapshot = store.get("zombie") assert snapshot is not None assert snapshot.status is TaskStatus.RUNNING, "预演不该写状态" assert store.history("zombie") == [] finally: store.close() def test_reconcile_marks_zombies(db: str, capsys: pytest.CaptureFixture[str]) -> None: _plant_zombie(db) assert _run_cli(db, "reconcile", "--reason", "worker-old 崩溃") == EXIT_OK assert "已把 1 个僵尸任务标记为 lost" in capsys.readouterr().out store = SqliteTaskStore(db) try: snapshot = store.get("zombie") assert snapshot is not None assert snapshot.status is TaskStatus.LOST assert store.history("zombie")[0].reason == "worker-old 崩溃" finally: store.close() def test_reconcile_with_nothing_to_do(db: str, capsys: pytest.CaptureFixture[str]) -> None: assert _run_cli(db, "reconcile") == EXIT_OK assert "没有需要处理的任务" in capsys.readouterr().out def test_reconcile_json_report(db: str, capsys: pytest.CaptureFixture[str]) -> None: _plant_zombie(db) assert main(["--db", db, "--json", "reconcile", "--dry-run"]) == EXIT_OK payload = json.loads(capsys.readouterr().out) assert payload["dry_run"] is True assert payload["entries"][0]["task_id"] == "zombie" assert payload["entries"][0]["previous_status"] == "running" assert payload["entries"][0]["outcome"] == "marked-lost" # --------------------------------------------------------------------------- # 节点与桶归属(自治节点 + 统一平面) # --------------------------------------------------------------------------- def test_bucket_spec_supports_ranges_and_lists() -> None: from chongming_task.cli.main import _parse_bucket_spec assert _parse_bucket_spec("0-3") == frozenset({0, 1, 2, 3}) assert _parse_bucket_spec("0-1,5,7-8") == frozenset({0, 1, 5, 7, 8}) assert _parse_bucket_spec(" 2 , 4 ") == frozenset({2, 4}) assert _parse_bucket_spec("3-3") == frozenset({3}) def test_bucket_spec_rejects_bad_input() -> None: from chongming_task.cli.main import _parse_bucket_spec for bad in ("3-1", "a", "0-", "1,x"): with pytest.raises(ConfigurationError): _parse_bucket_spec(bad) with pytest.raises(ConfigurationError, match="没有解析出任何桶"): _parse_bucket_spec(" , ") def test_ps_mine_filters_to_owned_buckets(db: str, capsys: pytest.CaptureFixture[str]) -> None: """自治架构里最常看的视图:"我这一份现在怎样"。""" _plant_zombie(db, "zombie") # 无桶任务 store = SqliteTaskStore(db) try: for task_id, bucket in (("in-a", 1), ("in-b", 40)): store.create( TaskSnapshot( task_id=task_id, name="节点任务", bucket=bucket, bucket_key=f"key-{bucket}", status=TaskStatus.SUCCEEDED, status_version=1, attempt=1, max_attempts=1, created_at=datetime(2026, 1, 1, tzinfo=UTC), ended_at=datetime(2026, 1, 1, 1, tzinfo=UTC), ) ) finally: store.close() assert main(["--db", db, "--owned-buckets", "0-31", "ps", "--all", "--mine"]) == EXIT_OK out = capsys.readouterr().out assert "in-a" in out assert "in-b" not in out, "别把别的桶的任务算进来" assert "zombie" not in out, "无桶任务不属于任何限定了归属的节点" def test_ps_mine_requires_owned_buckets(db: str, capsys: pytest.CaptureFixture[str]) -> None: assert main(["--db", db, "ps", "--mine"]) == EXIT_ERROR assert "--owned-buckets" in capsys.readouterr().err def test_reconcile_is_scoped_to_owned_buckets(db: str, capsys: pytest.CaptureFixture[str]) -> None: """节点只清理自己桶里的僵尸——这是自治架构的核心不变量。""" store = SqliteTaskStore(db) try: for task_id, bucket in (("mine", 1), ("theirs", 40)): old = datetime(2026, 1, 1, tzinfo=UTC) store.create( TaskSnapshot( task_id=task_id, name="遗留任务", bucket=bucket, bucket_key=f"key-{bucket}", status=TaskStatus.RUNNING, status_version=3, attempt=1, max_attempts=1, created_at=old, started_at=old, lease_owner="dead", lease_until=old, ) ) finally: store.close() assert main(["--db", db, "--owned-buckets", "0-31", "reconcile"]) == EXIT_OK out = capsys.readouterr().out assert "mine" in out assert "theirs" not in out store = SqliteTaskStore(db) try: assert store.get("mine").status is TaskStatus.LOST # type: ignore[union-attr] assert store.get("theirs").status is TaskStatus.RUNNING, ( # type: ignore[union-attr] "动了别的节点的任务" ) finally: store.close() def test_buckets_view_marks_owned_buckets(db: str, capsys: pytest.CaptureFixture[str]) -> None: assert main(["--db", db, "--num-buckets", "4", "--owned-buckets", "0-1", "buckets"]) == EXIT_OK out = capsys.readouterr().out marked = [line for line in out.splitlines() if line.startswith("*")] assert len(marked) == 2, f"应当恰好标出两个桶,实际:{marked}" def test_an_out_of_range_owned_bucket_is_reported( db: str, capsys: pytest.CaptureFixture[str] ) -> None: """桶号越界必须在启动时报错——否则症状是"任务永远排队且没有任何报错"。""" assert main(["--db", db, "--owned-buckets", "0-999", "ps"]) == EXIT_ERROR assert "桶号越界" in capsys.readouterr().err def test_run_respects_owned_buckets(db: str, capsys: pytest.CaptureFixture[str]) -> None: """``run`` 必须**真的**受归属约束。 曾经 ``run`` 自己构造 ``Controller(...)``,把 ``--owned-buckets`` 静默忽略了—— 参数被接受却不起作用,比报错更难查。 """ from chongming_task import BucketPolicy policy = BucketPolicy(num_buckets=64) mine = next(f"tenant-{i}" for i in range(999) if policy.bucket_for(f"tenant-{i}") < 16) theirs = next(f"tenant-{i}" for i in range(999) if policy.bucket_for(f"tenant-{i}") >= 48) # 限定了归属却没给 bucket_key → 必须报错(否则会产出没人认领的任务) assert ( main( [ "--db", db, "--num-buckets", "64", "--owned-buckets", "0-15", "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=1", "--arg", "right=1", "--task-id", "r1", ] ) == EXIT_ERROR ) assert "必须给出 bucket_key" in capsys.readouterr().err # 落在别人的桶 → 必须报错 assert ( main( [ "--db", db, "--num-buckets", "64", "--owned-buckets", "0-15", "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=1", "--arg", "right=1", "--task-id", "r2", "--bucket-key", theirs, ] ) == EXIT_ERROR ) assert "不属于本节点" in capsys.readouterr().err # 落在自己的桶 → 正常执行 assert ( main( [ "--db", db, "--num-buckets", "64", "--owned-buckets", "0-15", "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=20", "--arg", "right=22", "--task-id", "r3", "--bucket-key", mine, ] ) == EXIT_OK ) assert "succeeded" in capsys.readouterr().out def test_a_module_with_only_task_decorators_works( db: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """只用 ``@task`` 装饰、**没有** ``register()`` 的模块也必须能用。 ``@task`` 是后来加的写法;CLI 若只认 ``register()``,那个 API 在命令行下就废了。 """ module = tmp_path / "decorated_only.py" module.write_text( "from chongming_task import task\n" "\n" "@task(tags={'io'})\n" "def doubled(ctx, value: int) -> int:\n" " '''把数字翻倍。'''\n" " del ctx\n" " return value * 2\n", encoding="utf-8", ) monkeypatch.syspath_prepend(str(tmp_path)) assert main(["tasks", "--tasks", "decorated_only"]) == EXIT_OK out = capsys.readouterr().out assert "doubled" in out assert "把数字翻倍。" in out assert ( main(["--db", db, "run", "--tasks", "decorated_only", "doubled", "--arg", "value=21"]) == EXIT_OK ) assert "succeeded" in capsys.readouterr().out def test_a_module_with_neither_register_nor_task_is_reported( capsys: pytest.CaptureFixture[str], ) -> None: assert main(["tasks", "--tasks", "json"]) == EXIT_ERROR assert "既没有 register" in capsys.readouterr().err