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")