diff --git a/.gitignore b/.gitignore index a7bdcfa..5c339eb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ node_modules/ dist/ *.bak + +# python/ client package build artifacts +python/dist/ +python/build/ +python/*.egg-info/ +__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 896ebeb..b959d16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to capcut-cli are documented here. The format follows [Keep - `doctor` reports what each draft store holds — for every default CapCut/JianYing project directory it finds (or the one folder named with the new `--drafts `), a `draft-store` check counts the projects as readable, markerless, encrypted or unreadable. A JianYing 6.0+ store, where every project the app wrote is an encrypted payload, is now named once and up front (warn) with what still works — `init`, `quickstart` and `compile` build plaintext drafts from the bundled template — instead of being discovered one failed command at a time. Same classification as the `template.store` report of `init`/`quickstart`/`compile`. `capcut doctor --drafts ` also makes the check usable on a machine without the app, and in CI. - `examples/short-video-narration.md` (+ zh-CN) — silent clip → 9:16 draft with a TTS voiceover and script-accurate captions, as four commands (`quickstart --ratio 9:16` → `tts --text-file` → `caption --from-segment --script` → `lint`) and as one script, `examples/scripts/narrate-short.sh`. `examples/scripts/edge-tts-wav.sh` bridges edge-tts (MP3 only) to the WAV `tts` expects at `{out}`; any other engine plugs in through `--tts-cmd`. The vision-model step that writes the script is optional and stays outside the CLI: the script is a text file. +- `python/` — a thin Python client, published to PyPI as `capcut` (`pip install capcut`). `capcut.run(cmd, *args, **flags)` spawns the CLI once without a shell and returns the JSON it prints; keyword arguments become flags (`font_size=16` → `--font-size 16`), positionals pass through as single argv tokens, a non-zero exit raises `CommandError` with `status` and the CLI's JSON. `capcut.serve(jobs)` feeds the stateless JSONL queue and returns one result per job. `capcut.describe()`, `capcut.doctor()`, `capcut.version()`. Pure Python, no dependencies, Python ≥ 3.9; the binary is found on PATH or through `CAPCUT_CLI`. Not part of the npm tarball. ## [0.25.0] — 2026-09-18 diff --git a/README.md b/README.md index e3c2aff..d5d5485 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ https://github.com/user-attachments/assets/4e6ee99c-0745-4cfb-8e9b-ad873fb1259b npm install -g capcut-cli ``` +From Python: `pip install capcut` wraps the same binary — `capcut.run("quickstart", "my-short", video="clip.mp4", ratio="9:16")` — see [python/README.md](https://github.com/renezander030/capcut-cli/blob/master/python/README.md). + ```bash capcut doctor capcut quickstart my-first --video clip.mp4 --srt captions.srt diff --git a/README.zh-CN.md b/README.zh-CN.md index fbd9c24..ea3c3ce 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -30,6 +30,8 @@ https://github.com/user-attachments/assets/4e6ee99c-0745-4cfb-8e9b-ad873fb1259b npm install -g capcut-cli ``` +在 Python 里用:`pip install capcut` 封装同一个命令行 —— `capcut.run("quickstart", "我的短视频", video="clip.mp4", ratio="9:16")`,见 [python/README.md](https://github.com/renezander030/capcut-cli/blob/master/python/README.md)。 + ```bash capcut doctor capcut quickstart my-first --video clip.mp4 --srt captions.srt diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..7cdaae7 --- /dev/null +++ b/python/README.md @@ -0,0 +1,124 @@ +# capcut(Python 客户端) · capcut (Python client) + +中文 | [English](#english) + +用 Python 创建和编辑 CapCut / 剪映草稿。这是 [capcut-cli](https://github.com/renezander030/capcut-cli) 的一层薄封装:每次调用启动一次 `capcut` 命令,不经过 shell,返回它打印的那一份 JSON。没有服务、没有守护进程,磁盘上的草稿就是全部状态。打开剪映时,每一轨都还是可编辑的。 + +## 安装 + +```bash +npm install -g capcut-cli # 命令行本体,需要 Node ≥ 18 +pip install capcut # 本包,纯 Python,无依赖 +capcut doctor # 检查环境 +``` + +## 五行起步 + +```python +import capcut + +d = capcut.run("quickstart", "旁白短视频", video="clip.mp4", ratio="9:16") +capcut.run("add-text", d["draft_path"], "0s", "3s", "你好,世界", font_size=16) +print(capcut.run("lint", d["draft_path"])["summary"]) +``` + +- **关键字参数就是命令行选项**:`font_size=16` → `--font-size 16`,`karaoke=True` → `--karaoke`,列表会重复该选项,`None` / `False` 直接省略。 +- **位置参数原样传递**,每个参数就是一个 argv,中文、空格、引号都不需要转义。 +- 全部命令、参数和选项见[命令参考(中文)](https://github.com/renezander030/capcut-cli/blob/master/docs/command-reference.zh-CN.md),或者在 Python 里 `capcut.describe()`。 + +## 出错时 + +命令非零退出会抛出 `capcut.CommandError`,带 `status`、`data`(CLI 打印的 JSON,通常含 `error`)、`stdout`、`stderr`: + +```python +try: + capcut.run("lint", path) +except capcut.CommandError as e: + print(e.status, e.data) # lint 有错误时退出码为 2 +``` + +不想抛异常就用 `capcut.run_raw(...)`,它返回 `Result`(`ok`、`status`、`data`、`error`)。找不到 `capcut` 命令时抛 `capcut.CliNotFound`,提示里有安装命令;也可以用环境变量 `CAPCUT_CLI` 指定,例如 `CAPCUT_CLI="node /path/to/capcut-cli/dist/index.js"`。 + +## 批量:`serve` + +`capcut serve` 是一个无状态的 JSONL 任务队列。从 Python 喂任务进去,拿回每个任务一条结果: + +```python +results = capcut.serve([ + capcut.Job("add-text", project=path, args=["8s", "2s", "关注我"], id="title"), + capcut.Job("lint", project=path), +], workers=2) +for r in results: + print(r["id"], r["ok"], r["status"], r["stdout"]) +``` + +失败的任务是一条 `ok: false` 的结果,不是异常。 + +## 剪映 6.0+ 用户 + +新建的草稿是明文,据报告剪映 11.4(macOS)能打开并就地升级,其他版本未验证;已有的加密草稿本 CLI 不读取。`capcut.doctor()` 会报告环境,`capcut.run("decrypt", path)` 会报告某个草稿的加密状态;来龙去脉见 [jianying-encryption.zh-CN.md](https://github.com/renezander030/capcut-cli/blob/master/docs/jianying-encryption.zh-CN.md)。 + +--- + +## English + +Create and edit CapCut / JianYing drafts from Python. A thin layer over [capcut-cli](https://github.com/renezander030/capcut-cli): each call spawns the `capcut` binary once, without a shell, and returns the one JSON document it prints. No server, no daemon; the draft on disk is the only state, and every track stays editable in the app. + +### Install + +```bash +npm install -g capcut-cli # the CLI itself, Node >= 18 +pip install capcut # this package, pure Python, no dependencies +capcut doctor # environment check +``` + +### Five lines + +```python +import capcut + +d = capcut.run("quickstart", "Narrated short", video="clip.mp4", ratio="9:16") +capcut.run("add-text", d["draft_path"], "0s", "3s", "Hello, world", font_size=16) +print(capcut.run("lint", d["draft_path"])["summary"]) +``` + +- **Keyword arguments are flags**: `font_size=16` → `--font-size 16`, `karaoke=True` → `--karaoke`, a list repeats the flag, `None` / `False` are dropped. +- **Positional arguments pass through as they are**, one argv token each: text with spaces or quotes never needs escaping. +- Every command, argument and option: [command reference](https://github.com/renezander030/capcut-cli/blob/master/docs/command-reference.md), or `capcut.describe()` from Python. + +### Errors + +A non-zero exit raises `capcut.CommandError` with `status`, `data` (the CLI's JSON, usually with `error`), `stdout`, `stderr`: + +```python +try: + capcut.run("lint", path) +except capcut.CommandError as e: + print(e.status, e.data) # lint exits 2 on errors +``` + +`capcut.run_raw(...)` never raises; it returns a `Result` (`ok`, `status`, `data`, `error`). A missing binary raises `capcut.CliNotFound` with the install line; `CAPCUT_CLI` can point at one explicitly, e.g. `CAPCUT_CLI="node /path/to/capcut-cli/dist/index.js"`. + +### Batch: `serve` + +`capcut serve` is a stateless JSONL job queue. Feed it jobs from Python and get one result per job: + +```python +results = capcut.serve([ + capcut.Job("add-text", project=path, args=["8s", "2s", "Subscribe"], id="title"), + capcut.Job("lint", project=path), +], workers=2) +for r in results: + print(r["id"], r["ok"], r["status"], r["stdout"]) +``` + +A failed job is a result with `ok: false`, not an exception. + +### Development + +```bash +cd python && python -m unittest discover -s tests -v +python -m build +``` + +MIT, same as capcut-cli. diff --git a/python/capcut/__init__.py b/python/capcut/__init__.py new file mode 100644 index 0000000..a15ec06 --- /dev/null +++ b/python/capcut/__init__.py @@ -0,0 +1,303 @@ +"""capcut — a thin Python client for capcut-cli, the CapCut / JianYing (剪映) draft CLI. + +Every call spawns the ``capcut`` binary once, without a shell, and returns the one +JSON document it prints. Nothing runs in the background; the draft on disk is the +only state. + + import capcut + d = capcut.run("quickstart", "旁白短视频", video="clip.mp4", ratio="9:16") + capcut.run("add-text", d["draft_path"], "0s", "3s", "你好,世界", font_size=16) + print(capcut.run("lint", d["draft_path"])["summary"]) + +Keyword arguments become flags: ``font_size=16`` → ``--font-size 16``, ``karaoke=True`` +→ ``--karaoke``, a list repeats the flag, ``None``/``False`` are dropped. Positional +arguments are passed through as they are, each as one argv token, so text with spaces +or quotes never needs escaping. + +The binary is found through ``CAPCUT_CLI`` (a command line, e.g. +``"node /path/to/capcut-cli/dist/index.js"``) or ``capcut`` on PATH. +""" + +from __future__ import annotations + +import json +import os +import shlex +import shutil +import subprocess +import tempfile +from dataclasses import dataclass, field +from typing import Any, Iterable, List, Mapping, Optional, Sequence, Union + +__version__ = "0.1.0" +__all__ = [ + "CapcutError", + "CliNotFound", + "CommandError", + "Job", + "Result", + "cli_command", + "describe", + "doctor", + "flag_args", + "run", + "run_raw", + "serve", + "version", +] + +INSTALL_HINT = ( + "capcut-cli not found.\n" + " 安装:npm install -g capcut-cli (需要 Node ≥ 18,之后运行 capcut doctor 检查环境)\n" + " Install: npm install -g capcut-cli (needs Node >= 18; then run `capcut doctor`)\n" + ' Or point CAPCUT_CLI at the binary, e.g. CAPCUT_CLI="node /path/to/capcut-cli/dist/index.js".' +) + + +class CapcutError(Exception): + """Base class for everything this module raises.""" + + +class CliNotFound(CapcutError): + """The ``capcut`` binary is not on PATH and ``CAPCUT_CLI`` is not set.""" + + +@dataclass +class Result: + """One CLI invocation: exit status, raw streams, and the parsed JSON (``data``).""" + + cmd: str + args: List[str] + status: int + stdout: str + stderr: str + data: Any = None + + @property + def ok(self) -> bool: + return self.status == 0 + + @property + def error(self) -> Optional[str]: + """The CLI's own error message when it printed ``{"error": ...}``.""" + if isinstance(self.data, dict) and isinstance(self.data.get("error"), str): + return self.data["error"] + return None + + +class CommandError(CapcutError): + """The command exited non-zero. ``result`` carries status, streams and parsed JSON.""" + + def __init__(self, result: Result): + self.result = result + message = result.error or result.stderr.strip() or result.stdout.strip() or f"exit {result.status}" + super().__init__(f"capcut {result.cmd} failed (exit {result.status}): {message}") + + @property + def status(self) -> int: + return self.result.status + + @property + def data(self) -> Any: + return self.result.data + + @property + def stdout(self) -> str: + return self.result.stdout + + @property + def stderr(self) -> str: + return self.result.stderr + + +def cli_command() -> List[str]: + """The argv prefix that runs capcut-cli: ``CAPCUT_CLI`` split like a shell would, else ``capcut`` on PATH.""" + configured = os.environ.get("CAPCUT_CLI", "").strip() + if configured: + return shlex.split(configured, posix=(os.name != "nt")) + found = shutil.which("capcut") + if found: + return [found] + raise CliNotFound(INSTALL_HINT) + + +def flag_args(flags: Mapping[str, Any]) -> List[str]: + """``{"font_size": 16, "karaoke": True, "tag": ["a", "b"]}`` → ``["--font-size", "16", "--karaoke", "--tag", "a", "--tag", "b"]``.""" + out: List[str] = [] + for key, value in flags.items(): + flag = "--" + key.replace("_", "-") + if value is None or value is False: + continue + if value is True: + out.append(flag) + elif isinstance(value, (list, tuple)): + for item in value: + out.extend([flag, str(item)]) + else: + out.extend([flag, str(value)]) + return out + + +def _spawn(argv: List[str], *, input: Optional[str], timeout: Optional[float], cwd: Optional[str]) -> "subprocess.CompletedProcess[str]": + """Run argv with stdout/stderr captured to temporary files, not pipes. + + Large documents (``describe`` is >64 KiB) can be cut at the pipe buffer when the CLI + exits; the repository's own test helper captures to files for the same reason. + """ + with tempfile.TemporaryFile(mode="w+", encoding="utf-8", errors="replace") as out, tempfile.TemporaryFile( + mode="w+", encoding="utf-8", errors="replace" + ) as err: + try: + proc = subprocess.run( + argv, + input=input, + stdout=out, + stderr=err, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + cwd=cwd, + ) + except FileNotFoundError as exc: + raise CliNotFound(INSTALL_HINT) from exc + out.seek(0) + err.seek(0) + return subprocess.CompletedProcess(argv, proc.returncode, out.read(), err.read()) + + +def _parse(stdout: str) -> Any: + text = stdout.strip() + if not text: + return None + try: + return json.loads(text) + except ValueError: + pass + # Commands print one JSON document; if anything else reached stdout, the document is the last line. + last = text.splitlines()[-1].strip() + try: + return json.loads(last) + except ValueError: + return None + + +def run_raw( + cmd: str, + *args: Any, + human: bool = False, + timeout: Optional[float] = None, + cwd: Optional[str] = None, + input: Optional[str] = None, + **flags: Any, +) -> Result: + """Run one command and return the :class:`Result` whatever the exit status. + + ``human=True`` appends ``-H`` and leaves ``data`` as ``None`` (the table is in ``stdout``). + """ + argv_args = [str(a) for a in args] + flag_args(flags) + (["-H"] if human else []) + argv = cli_command() + [cmd] + argv_args + proc = _spawn(argv, input=input, timeout=timeout, cwd=cwd) + data = None if human else _parse(proc.stdout) + if data is None and proc.returncode != 0: + data = _parse(proc.stderr) # the CLI prints {"error": ...} on stderr + return Result(cmd=cmd, args=argv_args, status=proc.returncode, stdout=proc.stdout, stderr=proc.stderr, data=data) + + +def run(cmd: str, *args: Any, **kwargs: Any) -> Any: + """Run one command; return its parsed JSON (or the ``-H`` text). Raise :class:`CommandError` on a non-zero exit.""" + result = run_raw(cmd, *args, **kwargs) + if not result.ok: + raise CommandError(result) + return result.data if result.data is not None else result.stdout + + +def version() -> str: + """The installed capcut-cli version, e.g. ``"0.25.0"``.""" + result = run_raw("--version") + if not result.ok: + raise CommandError(result) + return result.stdout.strip() + + +def describe() -> Any: + """The full command surface as JSON (``capcut describe``): names, usage, options, exit codes.""" + return run("describe") + + +def doctor(**flags: Any) -> Any: + """The ``capcut doctor`` report. Returned even when a hard requirement is missing (exit 1); check ``["ok"]``.""" + result = run_raw("doctor", **flags) + if result.data is None: + raise CommandError(result) + return result.data + + +@dataclass +class Job: + """One line of the ``capcut serve`` JSONL queue.""" + + cmd: str + project: Optional[str] = None + args: Sequence[Any] = field(default_factory=list) + id: Optional[str] = None + retries: Optional[int] = None + timeout: Optional[int] = None + + def to_dict(self) -> dict: + job: dict = {"cmd": self.cmd} + if self.project is not None: + job["project"] = self.project + if self.args: + job["args"] = [str(a) for a in self.args] + if self.id is not None: + job["id"] = self.id + if self.retries is not None: + job["retries"] = self.retries + if self.timeout is not None: + job["timeout"] = self.timeout + return job + + +def serve( + jobs: Iterable[Union[Job, Mapping[str, Any]]], + *, + workers: Optional[int] = None, + fail_fast: bool = False, + retries: Optional[int] = None, + job_timeout_ms: Optional[int] = None, + backoff_ms: Optional[int] = None, + max_buffer_mb: Optional[int] = None, + timeout: Optional[float] = None, +) -> List[dict]: + """Feed jobs to ``capcut serve`` over stdin and return one result dict per job. + + Each result is ``{id, ok, cmd, args, status, stdout, stderr, attempts, duration_ms, deduplicated}`` + with ``stdout`` already parsed. A failed job is a result with ``ok: false``, not an exception; + only a queue that produced no results at all raises :class:`CommandError`. + """ + lines = [json.dumps(j.to_dict() if isinstance(j, Job) else dict(j), ensure_ascii=False) for j in jobs] + flags = flag_args( + { + "workers": workers, + "fail_fast": fail_fast, + "retries": retries, + "timeout": job_timeout_ms, + "backoff_ms": backoff_ms, + "max_buffer_mb": max_buffer_mb, + } + ) + argv = cli_command() + ["serve"] + flags + proc = _spawn(argv, input="\n".join(lines) + "\n", timeout=timeout, cwd=None) + results: List[dict] = [] + for line in proc.stdout.splitlines(): + line = line.strip() + if not line: + continue + try: + results.append(json.loads(line)) + except ValueError: + continue + if not results and proc.returncode != 0: + raise CommandError(Result(cmd="serve", args=flags, status=proc.returncode, stdout=proc.stdout, stderr=proc.stderr)) + return results diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..ab457a1 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "capcut" +version = "0.1.0" +description = "Python client for capcut-cli: create and edit CapCut / JianYing (剪映) drafts from Python. JSON in, JSON out, no server." +readme = "README.md" +requires-python = ">=3.9" +license = { text = "MIT" } +authors = [{ name = "Rene Zander" }] +keywords = ["capcut", "jianying", "剪映", "video", "draft", "cli", "automation", "short-video"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Multimedia :: Video", + "Topic :: Software Development :: Libraries", +] +dependencies = [] + +[project.urls] +Homepage = "https://github.com/renezander030/capcut-cli" +Documentation = "https://github.com/renezander030/capcut-cli/blob/master/python/README.md" +Changelog = "https://github.com/renezander030/capcut-cli/blob/master/CHANGELOG.md" +Issues = "https://github.com/renezander030/capcut-cli/issues" + +[tool.setuptools] +packages = ["capcut"] diff --git a/python/tests/fake_capcut.py b/python/tests/fake_capcut.py new file mode 100644 index 0000000..5b54d14 --- /dev/null +++ b/python/tests/fake_capcut.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Stand-in for the capcut binary: echoes what it was called with, as the real CLI would shape it.""" + +import json +import sys + +argv = sys.argv[1:] +if argv == ["--version"]: + print("0.0.0-fake") + sys.exit(0) + +cmd = argv[0] if argv else "" +rest = argv[1:] + +if cmd == "serve": + for line in sys.stdin: + if not line.strip(): + continue + job = json.loads(line) + ok = job["cmd"] != "fail" + print( + json.dumps( + { + "id": job.get("id"), + "ok": ok, + "cmd": job["cmd"], + "args": job.get("args", []), + "status": 0 if ok else 2, + "stdout": {"echo": job.get("args", [])} if ok else {"error": "boom"}, + "stderr": "", + "attempts": 1, + "duration_ms": 1, + "deduplicated": False, + }, + ensure_ascii=False, + ) + ) + sys.exit(0 if "--fail-fast" not in rest else 1) + +if cmd == "fail": + print(json.dumps({"error": "boom", "args": rest})) + sys.exit(2) + +if cmd == "doctor": + print(json.dumps({"ok": False, "checks": [{"name": "node", "status": "missing"}], "args": rest})) + sys.exit(1) + +if "-H" in rest: + print("Project: fake") + print("Duration: 1.00s") + sys.exit(0) + +print(json.dumps({"ok": True, "cmd": cmd, "args": rest}, ensure_ascii=False)) diff --git a/python/tests/test_client.py b/python/tests/test_client.py new file mode 100644 index 0000000..755c009 --- /dev/null +++ b/python/tests/test_client.py @@ -0,0 +1,104 @@ +import os +import shlex +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import capcut # noqa: E402 + +FAKE = Path(__file__).with_name("fake_capcut.py") + + +class FakeCliMixin: + def setUp(self): + self._saved = os.environ.get("CAPCUT_CLI") + os.environ["CAPCUT_CLI"] = " ".join(shlex.quote(p) for p in (sys.executable, str(FAKE))) + + def tearDown(self): + if self._saved is None: + os.environ.pop("CAPCUT_CLI", None) + else: + os.environ["CAPCUT_CLI"] = self._saved + + +class FlagArgs(unittest.TestCase): + def test_kwargs_become_flags(self): + got = capcut.flag_args({"font_size": 16, "karaoke": True, "no_probe": False, "color": None, "tag": ["a", "b"]}) + self.assertEqual(got, ["--font-size", "16", "--karaoke", "--tag", "a", "--tag", "b"]) + + +class Run(FakeCliMixin, unittest.TestCase): + def test_returns_parsed_json(self): + self.assertEqual(capcut.run("info", "/p"), {"ok": True, "cmd": "info", "args": ["/p"]}) + + def test_positionals_are_single_tokens_and_kwargs_are_flags(self): + got = capcut.run("add-text", "/p", "0s", "3s", "你好 世界 \"quoted\"", font_size=16, karaoke=True) + self.assertEqual(got["args"], ["/p", "0s", "3s", "你好 世界 \"quoted\"", "--font-size", "16", "--karaoke"]) + + def test_non_zero_exit_raises_with_status_and_data(self): + with self.assertRaises(capcut.CommandError) as cm: + capcut.run("fail", "/p") + self.assertEqual(cm.exception.status, 2) + self.assertEqual(cm.exception.data["error"], "boom") + self.assertIn("boom", str(cm.exception)) + + def test_run_raw_never_raises(self): + r = capcut.run_raw("fail", "/p") + self.assertFalse(r.ok) + self.assertEqual(r.error, "boom") + + def test_human_returns_text(self): + out = capcut.run("info", "/p", human=True) + self.assertIn("Project:", out) + + def test_version(self): + self.assertEqual(capcut.version(), "0.0.0-fake") + + def test_doctor_returns_report_even_on_exit_1(self): + report = capcut.doctor(drafts="/store") + self.assertFalse(report["ok"]) + self.assertEqual(report["args"], ["--drafts", "/store"]) + + +class Serve(FakeCliMixin, unittest.TestCase): + def test_jobs_round_trip_and_failed_jobs_are_results(self): + results = capcut.serve( + [capcut.Job("info", project="/p", id="a"), {"cmd": "fail", "project": "/p", "args": ["x"]}], + workers=2, + ) + self.assertEqual([r["id"] for r in results], ["a", None]) + self.assertTrue(results[0]["ok"]) + self.assertFalse(results[1]["ok"]) + self.assertEqual(results[1]["status"], 2) + + def test_job_to_dict_drops_unset_fields(self): + self.assertEqual(capcut.Job("lint", project="/p").to_dict(), {"cmd": "lint", "project": "/p"}) + self.assertEqual( + capcut.Job("add-text", "/p", ["0s", "2s", "hi"], id="t", retries=2, timeout=5000).to_dict(), + {"cmd": "add-text", "project": "/p", "args": ["0s", "2s", "hi"], "id": "t", "retries": 2, "timeout": 5000}, + ) + + +class NotInstalled(unittest.TestCase): + def test_missing_binary_names_both_install_lines(self): + saved = {k: os.environ.get(k) for k in ("CAPCUT_CLI", "PATH")} + try: + os.environ.pop("CAPCUT_CLI", None) + os.environ["PATH"] = "" + with self.assertRaises(capcut.CliNotFound) as cm: + capcut.run("info", "/p") + self.assertIn("npm install -g capcut-cli", str(cm.exception)) + self.assertIn("安装", str(cm.exception)) + self.assertIn("CAPCUT_CLI", str(cm.exception)) + finally: + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +if __name__ == "__main__": + unittest.main()