Addresses the LOW/MEDIUM items flagged in the Task 8-10 and Task 3-6 code reviews: - validator.py: R5 no longer hardcodes the preset enum; any non-custom template value is treated as a preset. Future enum additions in schema.py flow through automatically. - validate.py: narrow contextlib.suppress(Exception) to specific exception types (AttributeError / io.UnsupportedOperation / OSError), and move the contextlib/io imports to the module-top stdlib block. - validator.py: document the layer=0 convention (CLI I/O / pre-validation) on the CheckError dataclass field so future readers understand why validate.py emits layer=0 for missing-file / JSON-parse errors. - test_cli_validate.py: add regression test for the "missing file in --json mode becomes layer=0 + exit 1" behaviour previously uncovered.
180 lines
5.9 KiB
Python
180 lines
5.9 KiB
Python
"""Standalone CLI for validating comic script JSONs.
|
|
|
|
Usage:
|
|
python -m tk_comfyui_batch_image.validate <file1> [file2 ...] [options]
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import contextlib
|
|
import io
|
|
import json
|
|
import sys
|
|
from dataclasses import asdict
|
|
from pathlib import Path
|
|
|
|
from .core.validator import CheckError, collect_errors
|
|
|
|
|
|
def _load_json(path: Path) -> dict:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def _summarize_file(path: Path, data: dict) -> dict:
|
|
pages = data.get("pages", [])
|
|
return {
|
|
"job_id": data.get("job_id"),
|
|
"page_count": len(pages),
|
|
"panel_count": sum(len(p.get("panels", [])) for p in pages),
|
|
}
|
|
|
|
|
|
def _format_human_errors(errors: list[CheckError]) -> str:
|
|
blocks = []
|
|
for e in errors:
|
|
lines = [f" [L{e.layer}] {e.path}", f" {e.message}"]
|
|
if e.hint:
|
|
lines.append(f" hint: {e.hint}")
|
|
blocks.append("\n".join(lines))
|
|
return "\n\n".join(blocks)
|
|
|
|
|
|
def _print_file_human(path: Path, status: str, errors: list[CheckError],
|
|
info: dict | None, suppressed: int = 0):
|
|
if status == "ok":
|
|
print(f"✓ {path}")
|
|
if info:
|
|
print(f" version {info.get('version', '?')}, "
|
|
f"{info['page_count']} pages, {info['panel_count']} panels, "
|
|
f"job_id={info['job_id']}")
|
|
else:
|
|
total = len(errors) + suppressed
|
|
print(f"✗ {path} ({total} errors)")
|
|
print()
|
|
print(_format_human_errors(errors))
|
|
if suppressed > 0:
|
|
print()
|
|
print(f" ... and {suppressed} more error(s) suppressed "
|
|
f"(raise --max-errors to see all)")
|
|
|
|
|
|
def _file_result_dict(path: Path, status: str, errors: list[CheckError],
|
|
info: dict | None, truncated: bool = False) -> dict:
|
|
d = {
|
|
"path": str(path),
|
|
"status": status,
|
|
"errors": [asdict(e) for e in errors],
|
|
"truncated": truncated,
|
|
}
|
|
if info is not None:
|
|
d["info"] = info
|
|
return d
|
|
|
|
|
|
def _ensure_utf8_stdio() -> None:
|
|
"""Make stdout/stderr UTF-8 on Windows terminals (cp950 by default)
|
|
so ✓/✗ characters don't raise UnicodeEncodeError. No-op on streams
|
|
already encoding UTF-8 or lacking `reconfigure`."""
|
|
for name in ("stdout", "stderr"):
|
|
stream = getattr(sys, name, None)
|
|
if stream is None:
|
|
continue
|
|
reconfigure = getattr(stream, "reconfigure", None)
|
|
if reconfigure is not None:
|
|
with contextlib.suppress(AttributeError, io.UnsupportedOperation, OSError):
|
|
reconfigure(encoding="utf-8")
|
|
continue
|
|
buf = getattr(stream, "buffer", None)
|
|
if buf is None:
|
|
continue
|
|
with contextlib.suppress(AttributeError, io.UnsupportedOperation, OSError):
|
|
setattr(sys, name, io.TextIOWrapper(buf, encoding="utf-8", line_buffering=True))
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
_ensure_utf8_stdio()
|
|
parser = argparse.ArgumentParser(
|
|
prog="python -m tk_comfyui_batch_image.validate",
|
|
description="Validate comic script JSON(s).",
|
|
)
|
|
parser.add_argument("files", nargs="*", help="JSON file(s) to validate")
|
|
parser.add_argument("--json", action="store_true",
|
|
help="Emit machine-readable JSON instead of human-readable text")
|
|
parser.add_argument("--max-errors", type=int, default=20, metavar="N",
|
|
help="Show at most N errors per file (default: 20)")
|
|
args = parser.parse_args(argv)
|
|
|
|
if not args.files:
|
|
parser.print_usage(sys.stderr)
|
|
return 2
|
|
|
|
results: list[dict] = []
|
|
ok_count = 0
|
|
fail_count = 0
|
|
|
|
for raw in args.files:
|
|
path = Path(raw)
|
|
|
|
if not path.exists():
|
|
if args.json:
|
|
results.append(_file_result_dict(
|
|
path, "fail",
|
|
[CheckError(layer=0, path="<io>", message="file not found")],
|
|
None, False,
|
|
))
|
|
fail_count += 1
|
|
continue
|
|
print(f"✗ {path} (file not found)", file=sys.stderr)
|
|
return 3
|
|
|
|
try:
|
|
data = _load_json(path)
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
if args.json:
|
|
results.append(_file_result_dict(
|
|
path, "fail",
|
|
[CheckError(layer=0, path="<io>",
|
|
message=f"I/O or JSON parse error: {e}")],
|
|
None, False,
|
|
))
|
|
fail_count += 1
|
|
continue
|
|
print(f"✗ {path} (I/O or JSON parse error: {e})", file=sys.stderr)
|
|
return 3
|
|
|
|
errors = collect_errors(data)
|
|
if errors:
|
|
fail_count += 1
|
|
shown = errors[:args.max_errors]
|
|
suppressed = max(0, len(errors) - args.max_errors)
|
|
truncated = suppressed > 0
|
|
if args.json:
|
|
results.append(_file_result_dict(path, "fail", shown, None, truncated))
|
|
else:
|
|
_print_file_human(path, "fail", shown, None, suppressed)
|
|
else:
|
|
ok_count += 1
|
|
info = {"version": data.get("version"), **_summarize_file(path, data)}
|
|
if args.json:
|
|
results.append(_file_result_dict(path, "ok", [], info, False))
|
|
else:
|
|
_print_file_human(path, "ok", [], info)
|
|
|
|
if args.json:
|
|
print(json.dumps({
|
|
"summary": {"total": ok_count + fail_count,
|
|
"ok": ok_count, "fail": fail_count},
|
|
"files": results,
|
|
}, indent=2, ensure_ascii=False))
|
|
elif len(args.files) > 1:
|
|
print()
|
|
print("---")
|
|
total = ok_count + fail_count
|
|
print(f"Summary: {total} files, {ok_count} ✓ ok, {fail_count} ✗ fail")
|
|
|
|
return 1 if fail_count > 0 else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|