From 57a12749fb000689652ab769af421ae60e4661b3 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:20:38 +0000 Subject: [PATCH 1/4] feat: Add validate_dom CLI tool to verify ParseResponse JSON schema --- CHANGELOG.md | 3 ++ tests/test_tools_validate_dom.py | 87 ++++++++++++++++++++++++++++++++ tools/validate_dom.py | 59 ++++++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 tests/test_tools_validate_dom.py create mode 100644 tools/validate_dom.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 46fa37c..86ca289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- [CLI] NewsDOM JSON 출력물이 스키마(`ParseResponse`)를 준수하는지 검증하는 `tools/validate_dom.py` 도구를 추가했습니다. + ### Security - 전역 500 에러 응답에도 표준 보안 헤더를 적용하여 예외 경로에서 header 누락을 방지 - MinerU subprocess argv 생성 시 `-`로 시작하는 option-like 인자를 거부하여 argument injection 위험을 낮춤 diff --git a/tests/test_tools_validate_dom.py b/tests/test_tools_validate_dom.py new file mode 100644 index 0000000..89b0ccd --- /dev/null +++ b/tests/test_tools_validate_dom.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import json +from pathlib import Path +import pytest +import runpy +import sys +import warnings + +from tools import validate_dom + + +@pytest.fixture +def mock_valid_json_file(tmp_path: Path) -> Path: + json_path = tmp_path / "valid.json" + data = { + "document_id": "doc_123", + "pages": [], + "quality": { + "status": "success", + "parser": "mineru", + "warnings": [] + } + } + json_path.write_text(json.dumps(data), encoding="utf-8") + return json_path + + +@pytest.fixture +def mock_invalid_schema_json_file(tmp_path: Path) -> Path: + json_path = tmp_path / "invalid_schema.json" + # Missing document_id which is required in ParseResponse + data = { + "pages": [] + } + json_path.write_text(json.dumps(data), encoding="utf-8") + return json_path + + +@pytest.fixture +def mock_invalid_format_json_file(tmp_path: Path) -> Path: + json_path = tmp_path / "invalid_format.json" + json_path.write_text("{ this is not a valid json ", encoding="utf-8") + return json_path + + +def test_validate_dom_success(mock_valid_json_file, capsys): + validate_dom.main([str(mock_valid_json_file)]) + out = capsys.readouterr().out + assert "Validation successful" in out + + +def test_validate_dom_invalid_schema(mock_invalid_schema_json_file, capsys): + with pytest.raises(SystemExit) as e: + validate_dom.main([str(mock_invalid_schema_json_file)]) + assert e.value.code == 1 + assert "Schema validation failed" in capsys.readouterr().err + + +def test_validate_dom_invalid_format(mock_invalid_format_json_file, capsys): + with pytest.raises(SystemExit) as e: + validate_dom.main([str(mock_invalid_format_json_file)]) + assert e.value.code == 1 + assert "Invalid JSON format" in capsys.readouterr().err + + +def test_validate_dom_not_found(tmp_path, capsys): + with pytest.raises(SystemExit) as e: + validate_dom.main([str(tmp_path / "missing.json")]) + assert e.value.code == 1 + assert "File not found" in capsys.readouterr().err + + +def test_validate_dom_wrong_ext(tmp_path, capsys): + txt = tmp_path / "wrong.txt" + txt.write_text("test", encoding="utf-8") + with pytest.raises(SystemExit) as e: + validate_dom.main([str(txt)]) + assert e.value.code == 1 + assert "must be a .json file" in capsys.readouterr().err + + +def test_validate_dom_main_execution(monkeypatch, mock_valid_json_file): + monkeypatch.setattr(sys, "argv", ["validate_dom.py", str(mock_valid_json_file)]) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + runpy.run_module("tools.validate_dom", run_name="__main__") diff --git a/tools/validate_dom.py b/tools/validate_dom.py new file mode 100644 index 0000000..54c699b --- /dev/null +++ b/tools/validate_dom.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +from pydantic import ValidationError + +_REPO_ROOT = Path(__file__).resolve().parents[1] +_SRC_ROOT = _REPO_ROOT / "src" +if str(_SRC_ROOT) not in sys.path: # pragma: no cover + sys.path.insert(0, str(_SRC_ROOT)) + +from newsdom_api.schemas import ParseResponse # noqa: E402 + + +def validate_dom(json_path: Path) -> dict[str, Any]: + """Validate DOM JSON against ParseResponse schema.""" + if not json_path.is_file(): + raise FileNotFoundError(f"File not found or is not a file: {json_path}") + if json_path.suffix.lower() != ".json": + raise ValueError("File must be a .json file.") + + try: + data = json.loads(json_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON format: {e}") from e + + try: + ParseResponse.model_validate(data) + except ValidationError as e: + raise ValueError(f"Schema validation failed:\n{e}") from e + + return data + + +def main(argv: list[str] | None = None) -> None: + """Run validate_dom main entry point.""" + parser = argparse.ArgumentParser( + description="Validate a NewsDOM JSON output against the ParseResponse schema." + ) + parser.add_argument("input", type=Path, help="Path to the JSON DOM file.") + + args = parser.parse_args(argv) + + try: + validate_dom(args.input) + print( + "Validation successful: The JSON file strictly matches the ParseResponse schema." + ) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": # pragma: no cover + main() From 70e31f994eca7f1cdb65e20ddd6dcaa4221c4a0c Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 3 Jul 2026 08:04:32 +0000 Subject: [PATCH 2/4] feat: Add validate_dom CLI tool to verify ParseResponse JSON schema --- tests/test_tools_validate_dom.py | 10 ++-------- tools/validate_dom.py | 22 ++++++++++++---------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/tests/test_tools_validate_dom.py b/tests/test_tools_validate_dom.py index 89b0ccd..ccd6985 100644 --- a/tests/test_tools_validate_dom.py +++ b/tests/test_tools_validate_dom.py @@ -16,11 +16,7 @@ def mock_valid_json_file(tmp_path: Path) -> Path: data = { "document_id": "doc_123", "pages": [], - "quality": { - "status": "success", - "parser": "mineru", - "warnings": [] - } + "quality": {"status": "success", "parser": "mineru", "warnings": []}, } json_path.write_text(json.dumps(data), encoding="utf-8") return json_path @@ -30,9 +26,7 @@ def mock_valid_json_file(tmp_path: Path) -> Path: def mock_invalid_schema_json_file(tmp_path: Path) -> Path: json_path = tmp_path / "invalid_schema.json" # Missing document_id which is required in ParseResponse - data = { - "pages": [] - } + data = {"pages": []} json_path.write_text(json.dumps(data), encoding="utf-8") return json_path diff --git a/tools/validate_dom.py b/tools/validate_dom.py index 54c699b..82ac7d7 100644 --- a/tools/validate_dom.py +++ b/tools/validate_dom.py @@ -8,15 +8,10 @@ from pydantic import ValidationError -_REPO_ROOT = Path(__file__).resolve().parents[1] -_SRC_ROOT = _REPO_ROOT / "src" -if str(_SRC_ROOT) not in sys.path: # pragma: no cover - sys.path.insert(0, str(_SRC_ROOT)) +# We inject sys.path inside main() to avoid global side-effects during test imports. -from newsdom_api.schemas import ParseResponse # noqa: E402 - -def validate_dom(json_path: Path) -> dict[str, Any]: +def validate_dom(json_path: Path) -> Any: """Validate DOM JSON against ParseResponse schema.""" if not json_path.is_file(): raise FileNotFoundError(f"File not found or is not a file: {json_path}") @@ -28,16 +23,23 @@ def validate_dom(json_path: Path) -> dict[str, Any]: except json.JSONDecodeError as e: raise ValueError(f"Invalid JSON format: {e}") from e + from newsdom_api.schemas import ParseResponse + try: - ParseResponse.model_validate(data) + validated_model = ParseResponse.model_validate(data) except ValidationError as e: raise ValueError(f"Schema validation failed:\n{e}") from e - return data + return validated_model def main(argv: list[str] | None = None) -> None: """Run validate_dom main entry point.""" + _REPO_ROOT = Path(__file__).resolve().parents[1] + _SRC_ROOT = _REPO_ROOT / "src" + if str(_SRC_ROOT) not in sys.path: # pragma: no cover + sys.path.insert(0, str(_SRC_ROOT)) + parser = argparse.ArgumentParser( description="Validate a NewsDOM JSON output against the ParseResponse schema." ) @@ -50,7 +52,7 @@ def main(argv: list[str] | None = None) -> None: print( "Validation successful: The JSON file strictly matches the ParseResponse schema." ) - except Exception as e: + except (FileNotFoundError, ValueError, OSError) as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) From 4a34916df5aeed02ec18d3472565986199938bb1 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:21:14 +0000 Subject: [PATCH 3/4] feat: Add validate_dom CLI tool to verify ParseResponse JSON schema --- tests/test_tools_validate_dom.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_tools_validate_dom.py b/tests/test_tools_validate_dom.py index ccd6985..898f865 100644 --- a/tests/test_tools_validate_dom.py +++ b/tests/test_tools_validate_dom.py @@ -79,3 +79,20 @@ def test_validate_dom_main_execution(monkeypatch, mock_valid_json_file): with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) runpy.run_module("tools.validate_dom", run_name="__main__") + + +def test_validate_dom_help_exit(capsys): + with pytest.raises(SystemExit) as e: + validate_dom.main(["--help"]) + assert e.value.code == 0 + assert "Validate a NewsDOM JSON output" in capsys.readouterr().out + + +def test_validate_dom_interrupt(monkeypatch, mock_valid_json_file): + def mock_validate(*args, **kwargs): + raise KeyboardInterrupt() + + monkeypatch.setattr(validate_dom, "validate_dom", mock_validate) + + with pytest.raises(KeyboardInterrupt): + validate_dom.main([str(mock_valid_json_file)]) From 2a8495ff25776294c9c9d768d65dd9d109cbddf0 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:18:25 +0000 Subject: [PATCH 4/4] feat: Add validate_dom CLI tool to verify ParseResponse JSON schema --- tests/test_tools_validate_dom.py | 3 ++- tools/validate_dom.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_tools_validate_dom.py b/tests/test_tools_validate_dom.py index 898f865..0a998ff 100644 --- a/tests/test_tools_validate_dom.py +++ b/tests/test_tools_validate_dom.py @@ -94,5 +94,6 @@ def mock_validate(*args, **kwargs): monkeypatch.setattr(validate_dom, "validate_dom", mock_validate) - with pytest.raises(KeyboardInterrupt): + with pytest.raises(SystemExit) as e: validate_dom.main([str(mock_valid_json_file)]) + assert e.value.code == 130 diff --git a/tools/validate_dom.py b/tools/validate_dom.py index 82ac7d7..52429c7 100644 --- a/tools/validate_dom.py +++ b/tools/validate_dom.py @@ -52,6 +52,8 @@ def main(argv: list[str] | None = None) -> None: print( "Validation successful: The JSON file strictly matches the ParseResponse schema." ) + except KeyboardInterrupt: + sys.exit(130) except (FileNotFoundError, ValueError, OSError) as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1)