From 4d74fdff848f7a5da2a172ce31cf2990b9fd0fea Mon Sep 17 00:00:00 2001 From: Nana Dev Date: Fri, 7 Aug 2026 10:10:26 +0700 Subject: [PATCH] Add structured JSON CLI output --- CHANGELOG.md | 1 + MANIFEST.in | 1 + README.md | 11 +++ docs/json-output.md | 89 +++++++++++++++++++ pyproject.toml | 1 + src/creator_toolkit/main.py | 168 ++++++++++++++++++++++++++++++++---- tests/test_cli_json.py | 47 ++++++++++ tests/test_main.py | 68 ++++++++++++++- 8 files changed, 368 insertions(+), 18 deletions(-) create mode 100644 MANIFEST.in create mode 100644 docs/json-output.md create mode 100644 tests/test_cli_json.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4153aef..e53996e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project are documented in this file. The format is b - Scriptable `title`, `tags`, and `rename` subcommands. - Safe rename previews, confirmation, recovery manifests, and an `undo` command. +- Machine-readable JSON output for every scriptable subcommand. - Python 3.11–3.13 test matrix, linting, coverage, and distribution checks. ### Changed diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..fc2bedf --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +recursive-include docs *.md diff --git a/README.md b/README.md index f61e728..be05acf 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,17 @@ creator-toolkit title "video editing" creator-toolkit tags --count 5 ``` +Add `--json` to any scriptable subcommand for automation-friendly output: + +```bash +creator-toolkit title "video editing" --json +creator-toolkit tags --count 5 --json +creator-toolkit rename ./images --dry-run --json +``` + +See the [JSON output reference](https://github.com/HoungDev/creator-toolkit-cli/blob/main/docs/json-output.md) +for response schemas, exit codes, error handling, and automation examples. + Preview changes to `.jpg`, `.jpeg`, and `.png` files without touching the directory: ```bash diff --git a/docs/json-output.md b/docs/json-output.md new file mode 100644 index 0000000..b277f07 --- /dev/null +++ b/docs/json-output.md @@ -0,0 +1,89 @@ +# JSON output + +Every scriptable subcommand supports `--json`. Successful responses are written as one JSON +object to standard output. Runtime and validation errors are written as one JSON object to +standard error, leaving standard output empty. + +## Envelope + +Successful responses use this envelope: + +```json +{ + "schema_version": 1, + "command": "tags", + "ok": true, + "status": "generated", + "result": { + "count": 2, + "tags": ["automation", "python"] + } +} +``` + +Errors replace `result` with a stable error object: + +```json +{ + "schema_version": 1, + "command": "rename", + "ok": false, + "status": "error", + "error": { + "type": "FileNotFoundError", + "message": "Folder not found: ./missing" + } +} +``` + +The fields inside `result` depend on the command: + +- `title`: `title`. +- `tags`: `count` and `tags`. +- `rename`: `directory`, `manifest`, and `operations`. +- `undo`: `manifest` and `operations`. + +Each operation contains `source` and `destination` filenames. Rename statuses are `preview`, +`unchanged`, or `applied`; undo statuses are `preview` or `restored`. + +## Safe non-interactive use + +JSON mode never prompts. `rename` and `undo` therefore require either `--dry-run` or `--yes` when +combined with `--json`. Omitting both produces a structured `UsageError` and exit code 2. + +## Exit codes + +- `0`: success, including previews and human-confirmed cancellation. +- `1`: runtime or validation error. +- `2`: invalid CLI usage or an unsafe JSON-mode invocation. + +## Automation examples + +Read generated tags from Python: + +```python +import json +import subprocess + +result = subprocess.run( + ["creator-toolkit", "tags", "--count", "3", "--json"], + check=True, + capture_output=True, + text=True, +) +tags = json.loads(result.stdout)["result"]["tags"] +print(tags) +``` + +Inspect a rename plan from PowerShell without modifying files: + +```powershell +$plan = creator-toolkit rename .\images --dry-run --json | ConvertFrom-Json +$plan.result.operations | Format-Table source, destination +``` + +Apply a previously reviewed rename plan in CI: + +```bash +creator-toolkit rename ./images --yes --json > rename-result.json +``` diff --git a/pyproject.toml b/pyproject.toml index 5a5e1fe..5ec4b09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ creator-toolkit = "creator_toolkit.main:main" [project.urls] Changelog = "https://github.com/HoungDev/creator-toolkit-cli/blob/main/CHANGELOG.md" +Documentation = "https://github.com/HoungDev/creator-toolkit-cli/tree/main/docs" Issues = "https://github.com/HoungDev/creator-toolkit-cli/issues" Repository = "https://github.com/HoungDev/creator-toolkit-cli" diff --git a/src/creator_toolkit/main.py b/src/creator_toolkit/main.py index 06b33e1..025a76a 100644 --- a/src/creator_toolkit/main.py +++ b/src/creator_toolkit/main.py @@ -1,6 +1,9 @@ import argparse +import json +import sys from collections.abc import Sequence from pathlib import Path +from typing import Any from creator_toolkit.rename_images import ( ManifestError, @@ -13,6 +16,12 @@ from creator_toolkit.tag_generator import generate_tags from creator_toolkit.title_generator import generate_title +JSON_SCHEMA_VERSION = 1 + + +class UsageError(ValueError): + """Raised when a safe non-interactive invocation is incomplete.""" + def build_parser() -> argparse.ArgumentParser: """Build the command-line parser.""" @@ -20,24 +29,71 @@ def build_parser() -> argparse.ArgumentParser: prog="creator-toolkit", description="Tools for creator workflows" ) subparsers = parser.add_subparsers(dest="command") + title_parser = subparsers.add_parser("title", help="generate a title from a keyword") title_parser.add_argument("keyword") + title_parser.add_argument("--json", action="store_true", help="emit machine-readable JSON") + tags_parser = subparsers.add_parser("tags", help="generate a set of tags") tags_parser.add_argument("--count", type=int, default=5) + tags_parser.add_argument("--json", action="store_true", help="emit machine-readable JSON") rename_parser = subparsers.add_parser("rename", help="safely rename images in a directory") rename_parser.add_argument("folder") rename_parser.add_argument("--dry-run", action="store_true", help="preview without renaming") rename_parser.add_argument("--yes", action="store_true", help="apply without confirmation") rename_parser.add_argument("--manifest", type=Path, help="custom recovery manifest path") + rename_parser.add_argument("--json", action="store_true", help="emit machine-readable JSON") undo_parser = subparsers.add_parser("undo", help="reverse an applied rename manifest") undo_parser.add_argument("manifest", type=Path) undo_parser.add_argument("--dry-run", action="store_true", help="preview without restoring") undo_parser.add_argument("--yes", action="store_true", help="restore without confirmation") + undo_parser.add_argument("--json", action="store_true", help="emit machine-readable JSON") return parser +def _operation_records(operations: list[RenameOperation]) -> list[dict[str, str]]: + return [ + {"source": source.name, "destination": destination.name} + for source, destination in operations + ] + + +def _emit_json(payload: dict[str, Any], *, error: bool = False) -> None: + stream = sys.stderr if error else sys.stdout + print(json.dumps(payload, sort_keys=True), file=stream) + + +def _emit_success(command: str, status: str, result: dict[str, Any]) -> None: + _emit_json( + { + "schema_version": JSON_SCHEMA_VERSION, + "command": command, + "ok": True, + "status": status, + "result": result, + } + ) + + +def _report_error(command: str, error: Exception, *, json_output: bool, exit_code: int) -> int: + if json_output: + _emit_json( + { + "schema_version": JSON_SCHEMA_VERSION, + "command": command, + "ok": False, + "status": "error", + "error": {"type": type(error).__name__, "message": str(error)}, + }, + error=True, + ) + else: + print(error, file=sys.stderr) + return exit_code + + def print_operations(label: str, operations: list[RenameOperation]) -> None: """Print a concise operation summary.""" print(f"{label} {len(operations)} image(s).") @@ -58,12 +114,39 @@ def run_rename( dry_run: bool, assume_yes: bool, manifest_path: str | Path | None, + json_output: bool, ) -> int: """Preview and optionally apply an image rename plan.""" try: + if json_output and not (dry_run or assume_yes): + raise UsageError("JSON rename requires --dry-run or --yes.") + operations = plan_image_renames(folder) - print_operations("Planned", operations) - if dry_run or not operations: + if not json_output: + print_operations("Planned", operations) + if dry_run: + if json_output: + _emit_success( + "rename", + "preview", + { + "directory": str(Path(folder).resolve()), + "manifest": None, + "operations": _operation_records(operations), + }, + ) + return 0 + if not operations: + if json_output: + _emit_success( + "rename", + "unchanged", + { + "directory": str(Path(folder).resolve()), + "manifest": None, + "operations": [], + }, + ) return 0 if not assume_yes and not _confirmed("Apply these changes? [y/N]: "): print("Cancelled; no files were changed.") @@ -73,9 +156,22 @@ def run_rename( Path(manifest_path) if manifest_path is not None else generate_manifest_path(folder) ) applied = rename_images(folder, manifest_path=manifest) - print_operations("Renamed", applied) - print(f"Undo manifest: {manifest}") + if json_output: + _emit_success( + "rename", + "applied", + { + "directory": str(Path(folder).resolve()), + "manifest": str(manifest.resolve()), + "operations": _operation_records(applied), + }, + ) + else: + print_operations("Renamed", applied) + print(f"Undo manifest: {manifest}") return 0 + except UsageError as error: + return _report_error("rename", error, json_output=json_output, exit_code=2) except ( FileNotFoundError, NotADirectoryError, @@ -84,23 +180,48 @@ def run_rename( OSError, RuntimeError, ) as error: - print(error) - return 1 + return _report_error("rename", error, json_output=json_output, exit_code=1) -def run_undo(manifest: str | Path, *, dry_run: bool, assume_yes: bool) -> int: +def run_undo(manifest: str | Path, *, dry_run: bool, assume_yes: bool, json_output: bool) -> int: """Preview and optionally reverse a rename manifest.""" try: + if json_output and not (dry_run or assume_yes): + raise UsageError("JSON undo requires --dry-run or --yes.") + operations = undo_renames(manifest, dry_run=True) - print_operations("Planned restore of", operations) + if not json_output: + print_operations("Planned restore of", operations) if dry_run: + if json_output: + _emit_success( + "undo", + "preview", + { + "manifest": str(Path(manifest).resolve()), + "operations": _operation_records(operations), + }, + ) return 0 if not assume_yes and not _confirmed("Restore these filenames? [y/N]: "): print("Cancelled; no files were changed.") return 0 + restored = undo_renames(manifest) - print_operations("Restored", restored) + if json_output: + _emit_success( + "undo", + "restored", + { + "manifest": str(Path(manifest).resolve()), + "operations": _operation_records(restored), + }, + ) + else: + print_operations("Restored", restored) return 0 + except UsageError as error: + return _report_error("undo", error, json_output=json_output, exit_code=2) except ( FileNotFoundError, NotADirectoryError, @@ -109,8 +230,7 @@ def run_undo(manifest: str | Path, *, dry_run: bool, assume_yes: bool) -> int: OSError, RuntimeError, ) as error: - print(error) - return 1 + return _report_error("undo", error, json_output=json_output, exit_code=1) def interactive_menu() -> int: @@ -126,7 +246,11 @@ def interactive_menu() -> int: print(generate_tags()) elif choice == "3": return run_rename( - input("Enter folder path: "), dry_run=False, assume_yes=False, manifest_path=None + input("Enter folder path: "), + dry_run=False, + assume_yes=False, + manifest_path=None, + json_output=False, ) else: print("Invalid option") @@ -140,18 +264,32 @@ def main(argv: Sequence[str] | None = None) -> int: if args.command is None: return interactive_menu() if args.command == "title": - print(generate_title(args.keyword)) + title = generate_title(args.keyword) + if args.json: + _emit_success("title", "generated", {"title": title}) + else: + print(title) elif args.command == "tags": - print("\n".join(generate_tags(args.count))) + tags = generate_tags(args.count) + if args.json: + _emit_success("tags", "generated", {"count": len(tags), "tags": tags}) + else: + print("\n".join(tags)) elif args.command == "rename": return run_rename( args.folder, dry_run=args.dry_run, assume_yes=args.yes, manifest_path=args.manifest, + json_output=args.json, ) elif args.command == "undo": - return run_undo(args.manifest, dry_run=args.dry_run, assume_yes=args.yes) + return run_undo( + args.manifest, + dry_run=args.dry_run, + assume_yes=args.yes, + json_output=args.json, + ) return 0 diff --git a/tests/test_cli_json.py b/tests/test_cli_json.py new file mode 100644 index 0000000..eceb8cd --- /dev/null +++ b/tests/test_cli_json.py @@ -0,0 +1,47 @@ +import json +import subprocess +import sys + + +def run_cli(*arguments): + return subprocess.run( + [sys.executable, "-m", "creator_toolkit.main", *arguments], + check=False, + capture_output=True, + text=True, + ) + + +def test_installed_cli_emits_valid_title_json(): + result = run_cli("title", "creator workflow", "--json") + + assert result.returncode == 0 + assert result.stderr == "" + payload = json.loads(result.stdout) + assert payload["command"] == "title" + assert payload["ok"] is True + assert "Creator Workflow" in payload["result"]["title"] + + +def test_installed_cli_emits_json_error_to_stderr(tmp_path): + result = run_cli("rename", str(tmp_path / "missing"), "--dry-run", "--json") + + assert result.returncode == 1 + assert result.stdout == "" + payload = json.loads(result.stderr) + assert payload["command"] == "rename" + assert payload["ok"] is False + assert payload["error"]["type"] == "FileNotFoundError" + + +def test_installed_cli_rename_preview_is_non_mutating(tmp_path): + image = tmp_path / "photo.jpg" + image.write_bytes(b"photo") + + result = run_cli("rename", str(tmp_path), "--dry-run", "--json") + + assert result.returncode == 0 + assert result.stderr == "" + payload = json.loads(result.stdout) + assert payload["status"] == "preview" + assert image.exists() diff --git a/tests/test_main.py b/tests/test_main.py index d3c0e3d..10c0919 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,3 +1,5 @@ +import json + from creator_toolkit.main import interactive_menu, main @@ -13,7 +15,7 @@ def test_tags_command(capsys): def test_rename_command_reports_missing_folder(tmp_path, capsys): assert main(["rename", str(tmp_path / "missing"), "--dry-run"]) == 1 - assert "Folder not found" in capsys.readouterr().out + assert "Folder not found" in capsys.readouterr().err def test_rename_command_dry_run_changes_nothing(tmp_path, capsys): @@ -90,7 +92,67 @@ def test_undo_command_reports_invalid_manifest(tmp_path, capsys): manifest.write_text("not json", encoding="utf-8") assert main(["undo", str(manifest), "--yes"]) == 1 - assert "not valid JSON" in capsys.readouterr().out + assert "not valid JSON" in capsys.readouterr().err + + +def test_title_and_tags_json(capsys): + assert main(["title", "python", "--json"]) == 0 + title_payload = json.loads(capsys.readouterr().out) + assert title_payload["schema_version"] == 1 + assert title_payload["command"] == "title" + assert title_payload["result"]["title"] + + assert main(["tags", "--count", "2", "--json"]) == 0 + tags_payload = json.loads(capsys.readouterr().out) + assert tags_payload["command"] == "tags" + assert tags_payload["result"]["count"] == 2 + assert len(tags_payload["result"]["tags"]) == 2 + + +def test_json_rename_requires_explicit_mode(tmp_path, capsys): + assert main(["rename", str(tmp_path), "--json"]) == 2 + + captured = capsys.readouterr() + assert captured.out == "" + payload = json.loads(captured.err) + assert payload["ok"] is False + assert payload["error"]["type"] == "UsageError" + + +def test_json_rename_preview_and_runtime_error(tmp_path, capsys): + (tmp_path / "photo.jpg").write_bytes(b"photo") + + assert main(["rename", str(tmp_path), "--dry-run", "--json"]) == 0 + preview = json.loads(capsys.readouterr().out) + assert preview["status"] == "preview" + assert preview["result"]["operations"] == [ + {"source": "photo.jpg", "destination": "image_1.jpg"} + ] + + assert main(["rename", str(tmp_path / "missing"), "--dry-run", "--json"]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + error = json.loads(captured.err) + assert error["command"] == "rename" + assert error["error"]["type"] == "FileNotFoundError" + + +def test_json_rename_and_undo(tmp_path, capsys): + (tmp_path / "photo.jpg").write_bytes(b"photo") + + assert main(["rename", str(tmp_path), "--yes", "--json"]) == 0 + renamed = json.loads(capsys.readouterr().out) + assert renamed["status"] == "applied" + manifest = renamed["result"]["manifest"] + + assert main(["undo", manifest, "--dry-run", "--json"]) == 0 + preview = json.loads(capsys.readouterr().out) + assert preview["status"] == "preview" + + assert main(["undo", manifest, "--yes", "--json"]) == 0 + restored = json.loads(capsys.readouterr().out) + assert restored["status"] == "restored" + assert (tmp_path / "photo.jpg").exists() def test_interactive_title(monkeypatch, capsys): @@ -123,7 +185,7 @@ def test_interactive_rename_error(tmp_path, monkeypatch, capsys): monkeypatch.setattr("builtins.input", lambda _prompt: next(answers)) assert interactive_menu() == 1 - assert "Folder not found" in capsys.readouterr().out + assert "Folder not found" in capsys.readouterr().err def test_interactive_invalid_option(monkeypatch, capsys):