From 0e03d46053ac6d34a87fc79c4d4d5a021595c389 Mon Sep 17 00:00:00 2001 From: docushell-admin Date: Tue, 16 Jun 2026 21:13:38 +0530 Subject: [PATCH] Add internal Python surface scaffold Signed-off-by: docushell-admin --- .github/workflows/ci.yml | 2 + Makefile | 5 +- docs/execution-status.md | 2 + python/README.md | 17 ++ python/ethos_pdf/__init__.py | 41 ++++ python/ethos_pdf/_cli.py | 321 +++++++++++++++++++++++++++++++ python/tests/test_cli_surface.py | 172 +++++++++++++++++ 7 files changed, 559 insertions(+), 1 deletion(-) create mode 100644 python/README.md create mode 100644 python/ethos_pdf/__init__.py create mode 100644 python/ethos_pdf/_cli.py create mode 100644 python/tests/test_cli_surface.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54c3c322..a81f2e22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,8 @@ jobs: - run: cargo test --locked --workspace --all-features - name: fixture manifest validation run: python3 fixtures/validate_fixtures.py + - name: Python surface tests + run: PYTHONPATH=python python3 -m unittest discover -s python/tests - name: readiness gate tests run: python3 .github/scripts/test_readiness_gate.py - name: Gate Zero evidence preflight tests diff --git a/Makefile b/Makefile index d6052e57..31ae55c8 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ VERIFY_RENDERED_CROPS_OUT ?= $(ROOT)/target/verify-rendered-crops COMPARE_RENDERED_CROPS_LEFT ?= $(VERIFY_RENDERED_CROPS_OUT)/run1 COMPARE_RENDERED_CROPS_RIGHT ?= $(VERIFY_RENDERED_CROPS_OUT)/run2 -.PHONY: verify-alpha verify-alpha-tree verify-rendered-crops compare-rendered-crops release-hygiene release-advisory third-party-license-manifest release-notice-draft +.PHONY: verify-alpha verify-alpha-tree verify-rendered-crops compare-rendered-crops python-surface-test release-hygiene release-advisory third-party-license-manifest release-notice-draft $(ETHOS_BIN): cargo build --locked -p ethos-cli @@ -40,6 +40,9 @@ verify-rendered-crops: $(ETHOS_BIN) compare-rendered-crops: $(PYTHON) examples/verify/compare_rendered_crop_runs.py --left-run $(COMPARE_RENDERED_CROPS_LEFT) --right-run $(COMPARE_RENDERED_CROPS_RIGHT) +python-surface-test: + PYTHONPATH=$(ROOT)/python $(PYTHON) -m unittest discover -s python/tests + release-hygiene: cargo metadata --locked --offline --format-version 1 --no-deps >/dev/null $(CARGO_DENY) --version diff --git a/docs/execution-status.md b/docs/execution-status.md index 69c52199..14189bd1 100644 --- a/docs/execution-status.md +++ b/docs/execution-status.md @@ -19,6 +19,7 @@ The committed implementation now includes: - `ethos verify` now produces non-empty quote, value, presence, and table-cell verification checks over native Ethos document JSON and synthetic OpenDataLoader-style JSON through `--grounding opendataloader-json`; it also verifies quote/value/presence citations over pinned real OpenDataLoader 2.4.7 JSON, including grounded and ungrounded cases. Citation/config inputs are rejected when they drift outside the closed schemas. The public demo harness covers grounded, ungrounded, split-quote, not-found, stale-fingerprint, unsupported non-v1 claim, capability-limited, malformed-citation, malformed OpenDataLoader-style input, and summary-format reject paths. - Verification semantics are now trust-honest at alpha scope: quote containment is explicitly labeled, value/table-cell checks require normalized equality, fingerprint-pinned citations fail closed when source fingerprints are unavailable, and structured capability limits explain why a run is downgraded. - `make verify-alpha` is the current alpha trust-loop command: it checks native examples, split-quote evidence matching, unsupported non-v1 claim reporting, synthetic OpenDataLoader-style examples, pinned real OpenDataLoader grounded/ungrounded examples, schema validation, verify-alpha case inventory coverage, usage diagnostics for malformed citations and malformed OpenDataLoader-style structures, byte-identical repeated verification reports, byte-identical native crop descriptors, summary diagnostics for an ungrounded native case, and foreign fixture manifest hash binding. +- An internal Python surface scaffold exists under `python/ethos_pdf`. It shells out to a caller-provided local `ethos` CLI binary for `ethos doc parse` JSON, Markdown, and text output, and has stdlib unit tests that use a fake local command. This is pre-alpha scaffolding for Milestone B API shape work, not a public installation or publication path. - Native Ethos verification can emit deterministic, schema-backed crop descriptor JSON artifacts through `--crop-dir`; these bind `document_fingerprint`, page, bbox, and check ids. Native `crop_ref` filenames are logical evidence references derived from document fingerprint, check id, and page, while descriptors still record the exact observed bbox. When `--crop-source-pdf` is supplied, the CLI validates source-PDF fingerprint binding and emits PNG crop artifacts whose filenames, byte hashes, dimensions, and source fingerprint are bound from the descriptor. `make verify-rendered-crops` checks same-host repeated-run stability for the rendered artifact path, and `make compare-rendered-crops` classifies two rendered-crop runs by separating logical evidence identity from rendered artifact byte equality. Cross-platform rendered image determinism is not claimed; the 2026-06-14 macOS arm64 vs Linux x64 validation record in `docs/validation/rendered-crops-2026-06-14.md` preserved document fingerprint and `payload_sha256` but failed rendered artifact byte equality because the evidence bbox differed slightly across platforms. Still absent or not claimable: public benchmark reports, public competitor-comparison claims, public speed/quality/footprint claims, OCR/image-only support, real table extraction, mature list/heading/layout semantics beyond current fixture-backed alpha paths, semantic/arithmetic verification beyond deterministic evidence lookup, Phase 2 project-maintained PDFium builds, release packaging, and claim-audit approval for any public result wording. @@ -50,6 +51,7 @@ Milestone A has an accepted internal Gate Zero decision for roadmap control, so | PDFium loader/runtime checks | Landed: missing/mismatched version, artifact, and runtime library hashes fail deterministically | Release packaging and operator setup path still need hardening | | Real PDF backend | Landed for simple born-digital PDFs: page count, quantized spans, worker execution, timeout, page filtering, and fingerprint path exist | Wider corpus coverage, failure fixtures, memory-limit behavior, quirk log, and Gate Zero run are still missing | | Layout groundwork | Landed: basic paragraph text blocks, fixture-backed alpha heading and flat list-item elements, simple column reading order over quantized spans, fixture metadata checks against committed extraction/layout goldens for current read-order and element-type expectations, and alpha text/Markdown export goldens derived from committed layout output | Tables, nested/richer list and heading semantics, rotation/quirk handling, and confidence policy remain future work | +| Python surface scaffold | Landed: internal stdlib wrapper over a caller-provided local `ethos doc parse` command, with explicit JSON/Markdown/text methods, page selection passthrough, diagnostics passthrough, timeout handling, command failure reporting, and mocked-command unit coverage | Native binding work, broader API design, and public setup path remain future work | | Font policy groundwork | Partially landed: substitution table and profile policy are present; fixture output uses deterministic substitution IDs | Bundled fallback asset hashing and broader font/CID validation remain open | | Schema/example validation | Landed: schemas, examples, deterministic profile, referential integrity, and bbox sanity pass the `jsonschema` validation gate | Contract changes still require explicit versioning and compatibility review | | Trust-layer implementation | Landed: `ethos verify` quote/value/presence/table-cell checks, explicit quote-containment labeling, normalized equality for value/table-cell checks, stale and unverifiable fingerprint handling, unsupported claim reporting, structured capability limits, native Ethos JSON path, ODL-style adapter path with synthetic table/cell mapping, pinned real OpenDataLoader 2.4.7 grounded/ungrounded fixtures, foreign fixture manifest hash validation, crop-ref evidence plumbing, stable logical native crop refs, native crop descriptor artifacts, raw BGRA crop rendering in `ethos-pdf`, CLI PNG crop artifact production for bound native source PDFs, same-host rendered crop repeatability check, rendered-crop run comparison helper, strict citation/config input validation, citation input schema, split-quote fixture coverage, explicit unsupported non-v1 claim reporting, OpenDataLoader-style structure diagnostics for malformed bbox and unknown-page references, verify-alpha case inventory checks, and demo fixtures | Still needed: real OpenDataLoader table-cell grounding, additional adapter hardening against broader real output shapes, future claim-kind expansion outside the current v1 alpha policy, and a decision on whether cross-platform rendered crop artifact equality is worth pursuing after the current macOS/Linux bbox drift finding | diff --git a/python/README.md b/python/README.md new file mode 100644 index 00000000..62b73880 --- /dev/null +++ b/python/README.md @@ -0,0 +1,17 @@ +# Ethos Python Surface Scaffold + +This directory contains the internal pre-alpha Python surface scaffold for Ethos. + +The current module is intentionally thin: it shells out to a caller-provided local +`ethos` CLI binary and returns the `ethos doc parse` output. It does not bundle a +binary, publish an install path, or expand parser behavior. The scaffold exists so +Milestone B can begin locking a Python-facing API shape while the Rust CLI remains +the source of truth. + +Run the focused tests with: + +```sh +make python-surface-test +``` + +The tests use a fake local command, so they do not require PDFium. diff --git a/python/ethos_pdf/__init__.py b/python/ethos_pdf/__init__.py new file mode 100644 index 00000000..d94c6e3c --- /dev/null +++ b/python/ethos_pdf/__init__.py @@ -0,0 +1,41 @@ +# +# Copyright 2026 The Ethos maintainers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""Internal pre-alpha Python surface for local Ethos CLI parsing.""" + +from ._cli import ( + EthosCli, + EthosCommandError, + EthosNotFoundError, + EthosOutputError, + EthosPythonSurfaceError, + EthosTimeoutError, + parse_pdf_json, + parse_pdf_markdown, + parse_pdf_text, +) + +__all__ = [ + "EthosCli", + "EthosCommandError", + "EthosNotFoundError", + "EthosOutputError", + "EthosPythonSurfaceError", + "EthosTimeoutError", + "parse_pdf_json", + "parse_pdf_markdown", + "parse_pdf_text", +] diff --git a/python/ethos_pdf/_cli.py b/python/ethos_pdf/_cli.py new file mode 100644 index 00000000..5fce631a --- /dev/null +++ b/python/ethos_pdf/_cli.py @@ -0,0 +1,321 @@ +# +# Copyright 2026 The Ethos maintainers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path +from typing import Any, Dict, Mapping, Optional, Sequence, Union + +PathLike = Union[str, os.PathLike[str]] + +_DOC_PARSE_FORMATS = frozenset(("json", "markdown", "text")) + + +class EthosPythonSurfaceError(Exception): + """Base class for Python surface errors.""" + + +class EthosNotFoundError(EthosPythonSurfaceError): + """Raised when the configured local ethos binary cannot be executed.""" + + def __init__(self, ethos_bin: str) -> None: + super().__init__(f"ethos binary not found: {ethos_bin}") + self.ethos_bin = ethos_bin + + +class EthosTimeoutError(EthosPythonSurfaceError): + """Raised when the local ethos command exceeds the caller's timeout.""" + + def __init__(self, command: Sequence[str], timeout_seconds: float) -> None: + super().__init__( + f"ethos command timed out after {timeout_seconds:g}s: {' '.join(command)}" + ) + self.command = tuple(command) + self.timeout_seconds = timeout_seconds + + +class EthosCommandError(EthosPythonSurfaceError): + """Raised when the local ethos command exits non-zero.""" + + def __init__( + self, + command: Sequence[str], + returncode: int, + stdout: str, + stderr: str, + ) -> None: + super().__init__( + f"ethos command exited {returncode}: {' '.join(command)}" + ) + self.command = tuple(command) + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +class EthosOutputError(EthosPythonSurfaceError): + """Raised when a successful local ethos command returns unusable output.""" + + def __init__(self, message: str, command: Sequence[str], stdout: str) -> None: + super().__init__(message) + self.command = tuple(command) + self.stdout = stdout + + +class EthosCli: + """Small local wrapper around the Ethos CLI. + + The wrapper is deliberately explicit about the binary it runs. It does not + discover, download, or bundle Ethos; callers provide a local command path or + rely on their own PATH. + """ + + def __init__( + self, + ethos_bin: PathLike = "ethos", + *, + cwd: Optional[PathLike] = None, + env: Optional[Mapping[str, str]] = None, + ) -> None: + self.ethos_bin = os.fspath(ethos_bin) + self.cwd = None if cwd is None else os.fspath(cwd) + self.env = None if env is None else dict(env) + + def parse_pdf_json( + self, + input_pdf: PathLike, + *, + pages: Optional[str] = None, + diagnostics: bool = False, + timeout_seconds: Optional[float] = None, + ) -> Any: + """Parse a PDF and return the canonical JSON value.""" + + return self.parse_pdf( + input_pdf, + output_format="json", + pages=pages, + diagnostics=diagnostics, + timeout_seconds=timeout_seconds, + ) + + def parse_pdf_markdown( + self, + input_pdf: PathLike, + *, + pages: Optional[str] = None, + diagnostics: bool = False, + timeout_seconds: Optional[float] = None, + ) -> str: + """Parse a PDF and return Markdown output as UTF-8 text.""" + + return self.parse_pdf( + input_pdf, + output_format="markdown", + pages=pages, + diagnostics=diagnostics, + timeout_seconds=timeout_seconds, + ) + + def parse_pdf_text( + self, + input_pdf: PathLike, + *, + pages: Optional[str] = None, + diagnostics: bool = False, + timeout_seconds: Optional[float] = None, + ) -> str: + """Parse a PDF and return plain-text output as UTF-8 text.""" + + return self.parse_pdf( + input_pdf, + output_format="text", + pages=pages, + diagnostics=diagnostics, + timeout_seconds=timeout_seconds, + ) + + def parse_pdf( + self, + input_pdf: PathLike, + *, + output_format: str = "json", + pages: Optional[str] = None, + diagnostics: bool = False, + timeout_seconds: Optional[float] = None, + ) -> Any: + """Run `ethos doc parse` for one local PDF. + + `output_format` must be one of `json`, `markdown`, or `text`. JSON output + is decoded and returned as a Python value. Markdown and text outputs are + returned exactly as UTF-8 strings, including the CLI's trailing newline. + """ + + if output_format not in _DOC_PARSE_FORMATS: + allowed = ", ".join(sorted(_DOC_PARSE_FORMATS)) + raise ValueError(f"output_format must be one of: {allowed}") + if pages is not None and not pages: + raise ValueError("pages must be non-empty when provided") + if timeout_seconds is not None and timeout_seconds <= 0: + raise ValueError("timeout_seconds must be greater than zero when provided") + + pdf_path = Path(input_pdf) + if not pdf_path.is_file(): + raise FileNotFoundError(os.fspath(input_pdf)) + + command = self._doc_parse_command( + pdf_path, + output_format=output_format, + pages=pages, + diagnostics=diagnostics, + ) + stdout = self._run(command, timeout_seconds=timeout_seconds) + if output_format == "json": + try: + return json.loads(stdout) + except json.JSONDecodeError as exc: + raise EthosOutputError( + f"ethos returned invalid JSON: {exc.msg}", + command, + stdout, + ) from exc + return stdout + + def _doc_parse_command( + self, + pdf_path: Path, + *, + output_format: str, + pages: Optional[str], + diagnostics: bool, + ) -> Sequence[str]: + command = [ + self.ethos_bin, + "doc", + "parse", + os.fspath(pdf_path), + "--format", + output_format, + ] + if pages is not None: + command.extend(["--pages", pages]) + if diagnostics: + command.append("--diagnostics") + return tuple(command) + + def _run( + self, + command: Sequence[str], + *, + timeout_seconds: Optional[float], + ) -> str: + run_env: Optional[Dict[str, str]] = None + if self.env is not None: + run_env = dict(os.environ) + run_env.update(self.env) + + try: + completed = subprocess.run( + command, + cwd=self.cwd, + env=run_env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + timeout=timeout_seconds, + ) + except FileNotFoundError as exc: + raise EthosNotFoundError(self.ethos_bin) from exc + except subprocess.TimeoutExpired as exc: + timeout = timeout_seconds if timeout_seconds is not None else 0.0 + raise EthosTimeoutError(command, timeout) from exc + + stdout = _decode_utf8(completed.stdout, command, "stdout") + stderr = _decode_utf8(completed.stderr, command, "stderr") + if completed.returncode != 0: + raise EthosCommandError(command, completed.returncode, stdout, stderr) + return stdout + + +def parse_pdf_json( + input_pdf: PathLike, + *, + ethos_bin: PathLike = "ethos", + pages: Optional[str] = None, + diagnostics: bool = False, + timeout_seconds: Optional[float] = None, + env: Optional[Mapping[str, str]] = None, +) -> Any: + """Parse a PDF through a local ethos binary and return JSON.""" + + return EthosCli(ethos_bin, env=env).parse_pdf_json( + input_pdf, + pages=pages, + diagnostics=diagnostics, + timeout_seconds=timeout_seconds, + ) + + +def parse_pdf_markdown( + input_pdf: PathLike, + *, + ethos_bin: PathLike = "ethos", + pages: Optional[str] = None, + diagnostics: bool = False, + timeout_seconds: Optional[float] = None, + env: Optional[Mapping[str, str]] = None, +) -> str: + """Parse a PDF through a local ethos binary and return Markdown.""" + + return EthosCli(ethos_bin, env=env).parse_pdf_markdown( + input_pdf, + pages=pages, + diagnostics=diagnostics, + timeout_seconds=timeout_seconds, + ) + + +def parse_pdf_text( + input_pdf: PathLike, + *, + ethos_bin: PathLike = "ethos", + pages: Optional[str] = None, + diagnostics: bool = False, + timeout_seconds: Optional[float] = None, + env: Optional[Mapping[str, str]] = None, +) -> str: + """Parse a PDF through a local ethos binary and return plain text.""" + + return EthosCli(ethos_bin, env=env).parse_pdf_text( + input_pdf, + pages=pages, + diagnostics=diagnostics, + timeout_seconds=timeout_seconds, + ) + + +def _decode_utf8(data: bytes, command: Sequence[str], stream: str) -> str: + try: + return data.decode("utf-8") + except UnicodeDecodeError as exc: + raise EthosOutputError( + f"ethos returned non-UTF-8 {stream}", + command, + data.decode("utf-8", errors="replace"), + ) from exc diff --git a/python/tests/test_cli_surface.py b/python/tests/test_cli_surface.py new file mode 100644 index 00000000..332f894a --- /dev/null +++ b/python/tests/test_cli_surface.py @@ -0,0 +1,172 @@ +# +# Copyright 2026 The Ethos maintainers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +import os +import stat +import tempfile +import textwrap +import unittest +from pathlib import Path + +from ethos_pdf import ( + EthosCli, + EthosCommandError, + EthosNotFoundError, + EthosOutputError, + EthosTimeoutError, + parse_pdf_json, +) + + +FAKE_ETHOS = """\ +#!/usr/bin/env python3 +import json +import os +import sys +import time + +mode = os.environ.get("ETHOS_FAKE_MODE", "ok") +if mode == "fail": + sys.stdout.write("partial output\\n") + sys.stderr.write("usage: fake ethos failure\\n") + raise SystemExit(2) +if mode == "sleep": + time.sleep(10) + raise SystemExit(0) +if mode == "invalid-json": + sys.stdout.write("not-json\\n") + raise SystemExit(0) + +if sys.argv[1:3] != ["doc", "parse"]: + sys.stderr.write("unexpected command\\n") + raise SystemExit(2) + +output_format = "json" +if "--format" in sys.argv: + output_format = sys.argv[sys.argv.index("--format") + 1] + +if output_format == "json": + sys.stdout.write(json.dumps({"argv": sys.argv[1:], "ok": True}, sort_keys=True) + "\\n") +elif output_format == "markdown": + sys.stdout.write("# Alpha\\n\\nTrust loop\\n") +elif output_format == "text": + sys.stdout.write("Alpha trust loop\\n") +else: + sys.stderr.write("unsupported format\\n") + raise SystemExit(2) +""" + + +class PythonSurfaceTests(unittest.TestCase): + def setUp(self) -> None: + self.tempdir = tempfile.TemporaryDirectory() + self.root = Path(self.tempdir.name) + self.fake_ethos = self.root / "ethos" + self.fake_ethos.write_text(textwrap.dedent(FAKE_ETHOS), encoding="utf-8") + self.fake_ethos.chmod( + stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP + ) + self.pdf = self.root / "document.pdf" + self.pdf.write_bytes(b"%PDF-1.7\n") + + def tearDown(self) -> None: + self.tempdir.cleanup() + + def test_parse_pdf_json_invokes_doc_parse_with_pages_and_diagnostics(self) -> None: + result = parse_pdf_json( + self.pdf, + ethos_bin=self.fake_ethos, + pages="1-2", + diagnostics=True, + ) + + self.assertTrue(result["ok"]) + self.assertEqual( + result["argv"], + [ + "doc", + "parse", + str(self.pdf), + "--format", + "json", + "--pages", + "1-2", + "--diagnostics", + ], + ) + + def test_parse_pdf_text_preserves_cli_stdout(self) -> None: + result = EthosCli(self.fake_ethos).parse_pdf_text(self.pdf) + + self.assertEqual(result, "Alpha trust loop\n") + + def test_parse_pdf_markdown_preserves_cli_stdout(self) -> None: + result = EthosCli(self.fake_ethos).parse_pdf_markdown(self.pdf) + + self.assertEqual(result, "# Alpha\n\nTrust loop\n") + + def test_command_failure_raises_with_exit_code_and_streams(self) -> None: + cli = EthosCli(self.fake_ethos, env={"ETHOS_FAKE_MODE": "fail"}) + + with self.assertRaises(EthosCommandError) as caught: + cli.parse_pdf_json(self.pdf) + + self.assertEqual(caught.exception.returncode, 2) + self.assertIn("partial output", caught.exception.stdout) + self.assertIn("fake ethos failure", caught.exception.stderr) + + def test_missing_binary_raises_not_found(self) -> None: + cli = EthosCli(self.root / "missing-ethos") + + with self.assertRaises(EthosNotFoundError): + cli.parse_pdf_json(self.pdf) + + def test_timeout_raises_timeout_error(self) -> None: + cli = EthosCli(self.fake_ethos, env={"ETHOS_FAKE_MODE": "sleep"}) + + with self.assertRaises(EthosTimeoutError): + cli.parse_pdf_json(self.pdf, timeout_seconds=0.01) + + def test_invalid_format_is_rejected_before_command_execution(self) -> None: + with self.assertRaises(ValueError): + EthosCli(self.fake_ethos).parse_pdf(self.pdf, output_format="html") + + def test_empty_pages_is_rejected_before_command_execution(self) -> None: + with self.assertRaises(ValueError): + EthosCli(self.fake_ethos).parse_pdf_json(self.pdf, pages="") + + def test_non_positive_timeout_is_rejected_before_command_execution(self) -> None: + with self.assertRaises(ValueError): + EthosCli(self.fake_ethos).parse_pdf_json(self.pdf, timeout_seconds=0) + + def test_missing_input_pdf_raises_file_not_found(self) -> None: + with self.assertRaises(FileNotFoundError): + EthosCli(self.fake_ethos).parse_pdf_json(self.root / "missing.pdf") + + def test_invalid_json_stdout_raises_output_error(self) -> None: + cli = EthosCli(self.fake_ethos, env={"ETHOS_FAKE_MODE": "invalid-json"}) + + with self.assertRaises(EthosOutputError) as caught: + cli.parse_pdf_json(self.pdf) + + self.assertIn("invalid JSON", str(caught.exception)) + self.assertEqual(caught.exception.stdout, "not-json\n") + + +if __name__ == "__main__": + unittest.main()