From addb48e5be164afc4d8cd94fc6c76aa427dcfd2c Mon Sep 17 00:00:00 2001 From: Saverio Mazza Date: Sat, 22 Aug 2026 14:26:12 +0200 Subject: [PATCH] fix(formats): reject inspection-only formats before doing the work process_file returned the inspection adapter as both input and output adapter for PDF and Office documents, so the engine extracted the whole document and ran detection over every block before render() failed and was reported as a generic 'output adapter failed during rendering'. Nothing indicated that these formats are inspection-only. They are now rejected up front with UnsupportedFormatError naming inspect_file. Two related edges: inspect_file silently ignored 'encoding' for those formats, and BuiltinFileAdapter accepted them and fell through to the CSV branch, decoding binary documents as UTF-8 text. Both now raise. A shared INSPECTION_ONLY_FORMATS set keeps the list in one place. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 10 +++++ src/pseudonymize/engine.py | 41 ++++++++++--------- src/pseudonymize/formats.py | 10 +++++ tests/integration/test_inspection_adapters.py | 10 +++-- tests/unit/test_inspection.py | 32 ++++++++++++++- 5 files changed, 78 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 585d90e..a8b746a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes follow Keep a Changelog and Semantic Versioning. ## [Unreleased] +### Fixed + +- `process_file` now rejects PDF, DOCX, XLSX, and PPTX immediately with `UnsupportedFormatError` + instead of extracting and processing the whole document before failing at rendering time with a + generic message. +- `inspect_file` now rejects an `encoding` argument for inspection-only formats instead of + silently ignoring it. +- `BuiltinFileAdapter` now rejects inspection-only formats at construction instead of decoding a + binary document as text. + ## [0.3.0] - 2026-08-22 ### Added diff --git a/src/pseudonymize/engine.py b/src/pseudonymize/engine.py index 8168901..b0db52f 100644 --- a/src/pseudonymize/engine.py +++ b/src/pseudonymize/engine.py @@ -29,8 +29,14 @@ FileProcessingError, InvalidKeyError, UnsupportedDataError, + UnsupportedFormatError, +) +from pseudonymize.formats import ( + INSPECTION_ONLY_FORMATS, + BuiltinFileAdapter, + FileFormat, + select_file_format, ) -from pseudonymize.formats import BuiltinFileAdapter, FileFormat, select_file_format from pseudonymize.policy import Policy from pseudonymize.processing import ( DetectionReport, @@ -468,19 +474,13 @@ def _processing_adapters( ) -> tuple[InputAdapter[Path], OutputAdapter]: if input_adapter is None and output_adapter is None: selected_format = select_file_format(source, format) - if selected_format == FileFormat.PDF: - from pseudonymize.inspection.pdf import PDFInspectionAdapter - - pdf_adapter = PDFInspectionAdapter() - return pdf_adapter, pdf_adapter - elif selected_format in (FileFormat.DOCX, FileFormat.XLSX, FileFormat.PPTX): - from pseudonymize.inspection.office import OfficeInspectionAdapter - - office_adapter = OfficeInspectionAdapter(selected_format) - return office_adapter, office_adapter - else: - builtin_adapter = BuiltinFileAdapter(selected_format, encoding) - return builtin_adapter, builtin_adapter + if selected_format in INSPECTION_ONLY_FORMATS: + raise UnsupportedFormatError( + f"{selected_format.value} supports inspection only; " + "use inspect_file or supply custom adapters" + ) + builtin_adapter = BuiltinFileAdapter(selected_format, encoding) + return builtin_adapter, builtin_adapter if input_adapter is None or output_adapter is None: raise ValueError("custom file processing requires input and output adapters") if format is not None or encoding is not None: @@ -496,16 +496,17 @@ def _inspection_adapter( ) -> InputAdapter[Path]: if input_adapter is None: selected_format = select_file_format(source, format) - if selected_format == FileFormat.PDF: - from pseudonymize.inspection.pdf import PDFInspectionAdapter + if selected_format in INSPECTION_ONLY_FORMATS: + if encoding is not None: + raise ValueError("encoding applies only to text-based formats") + if selected_format is FileFormat.PDF: + from pseudonymize.inspection.pdf import PDFInspectionAdapter - return PDFInspectionAdapter() - elif selected_format in (FileFormat.DOCX, FileFormat.XLSX, FileFormat.PPTX): + return PDFInspectionAdapter() from pseudonymize.inspection.office import OfficeInspectionAdapter return OfficeInspectionAdapter(selected_format) - else: - return BuiltinFileAdapter(selected_format, encoding) + return BuiltinFileAdapter(selected_format, encoding) if format is not None or encoding is not None: raise ValueError("custom adapters cannot be combined with format or encoding") return input_adapter diff --git a/src/pseudonymize/formats.py b/src/pseudonymize/formats.py index a243688..48374b7 100644 --- a/src/pseudonymize/formats.py +++ b/src/pseudonymize/formats.py @@ -38,6 +38,9 @@ class FileFormat(StrEnum): PPTX = "pptx" +INSPECTION_ONLY_FORMATS = frozenset( + {FileFormat.PDF, FileFormat.DOCX, FileFormat.XLSX, FileFormat.PPTX} +) _SUFFIX_FORMATS = { ".txt": FileFormat.TEXT, ".md": FileFormat.MARKDOWN, @@ -73,6 +76,13 @@ class BuiltinFileAdapter: encoding: str | None = None _state: "_AdapterState | None" = field(default=None, init=False, repr=False) + def __post_init__(self) -> None: + if self.format in INSPECTION_ONLY_FORMATS: + raise UnsupportedFormatError( + "the built-in adapter handles text-based formats only; " + f"{self.format.value} is available through inspection" + ) + def extract(self, source: Path) -> Document: decoded = _decode(source.read_bytes(), self.encoding) content: object diff --git a/tests/integration/test_inspection_adapters.py b/tests/integration/test_inspection_adapters.py index 72fa9dd..59dbdfc 100644 --- a/tests/integration/test_inspection_adapters.py +++ b/tests/integration/test_inspection_adapters.py @@ -127,15 +127,17 @@ def test_inspect_pptx(test_pptx_path: Path) -> None: def test_process_pdf_fails(test_pdf_path: Path, tmp_path: Path) -> None: engine = Pseudonymizer(policy=Policy.default()) - from pseudonymize.exceptions import AdapterExecutionError + from pseudonymize.exceptions import UnsupportedFormatError - with pytest.raises(AdapterExecutionError, match="output adapter failed during rendering"): + with pytest.raises(UnsupportedFormatError, match="inspection only"): engine.process_file(test_pdf_path, tmp_path / "out.pdf", format=FileFormat.PDF) + assert not (tmp_path / "out.pdf").exists() def test_process_docx_fails(test_docx_path: Path, tmp_path: Path) -> None: engine = Pseudonymizer(policy=Policy.default()) - from pseudonymize.exceptions import AdapterExecutionError + from pseudonymize.exceptions import UnsupportedFormatError - with pytest.raises(AdapterExecutionError, match="output adapter failed during rendering"): + with pytest.raises(UnsupportedFormatError, match="inspection only"): engine.process_file(test_docx_path, tmp_path / "out.docx", format=FileFormat.DOCX) + assert not (tmp_path / "out.docx").exists() diff --git a/tests/unit/test_inspection.py b/tests/unit/test_inspection.py index 5d86f59..385ad8c 100644 --- a/tests/unit/test_inspection.py +++ b/tests/unit/test_inspection.py @@ -6,7 +6,13 @@ import pytest from pseudonymize.document import Document -from pseudonymize.exceptions import AdapterContractError, AdapterExecutionError +from pseudonymize.engine import Pseudonymizer +from pseudonymize.exceptions import ( + AdapterContractError, + AdapterExecutionError, + UnsupportedFormatError, +) +from pseudonymize.formats import BuiltinFileAdapter, FileFormat from pseudonymize.inspection import office, pdf from pseudonymize.inspection.office import OfficeInspectionAdapter from pseudonymize.inspection.pdf import PDFInspectionAdapter @@ -58,3 +64,27 @@ def test_office_adapter_render_contract() -> None: adapter = OfficeInspectionAdapter("docx") with pytest.raises(AdapterContractError, match="inspection only"): adapter.render(Document("test", (), {})) + + +@pytest.mark.parametrize("suffix", [".pdf", ".docx", ".xlsx", ".pptx"]) +def test_process_file_rejects_inspection_only_formats_before_reading( + tmp_path: Path, suffix: str +) -> None: + source = tmp_path / f"document{suffix}" + source.write_bytes(b"never read") + with pytest.raises(UnsupportedFormatError, match="inspection only"): + Pseudonymizer().process_file(source, tmp_path / f"output{suffix}") + assert not (tmp_path / f"output{suffix}").exists() + + +@pytest.mark.parametrize("format", ["pdf", "docx", "xlsx", "pptx"]) +def test_builtin_adapter_rejects_inspection_only_formats(format: str) -> None: + with pytest.raises(UnsupportedFormatError, match="text-based formats only"): + BuiltinFileAdapter(FileFormat(format)) + + +def test_inspect_file_rejects_encoding_for_inspection_only_formats(tmp_path: Path) -> None: + source = tmp_path / "document.pdf" + source.write_bytes(b"never read") + with pytest.raises(ValueError, match="text-based formats"): + Pseudonymizer().inspect_file(source, encoding="utf-8")