Skip to content

Commit 742db2b

Browse files
Add redaction support
- Introduced `preview_redactions` and `apply_redactions` methods for generating redaction previews and applying redactions in both sync and async clients. - Added `PdfRedactionPreviewPayload` and `PdfRedactionApplyPayload` models for payload validation. - Implemented new redaction-related types: `PdfRedactionInstruction`, `PdfRedactionPreset`, `PdfRedactionType`, and `PdfRGBColor`. - Centralized file operation handling via `_post_file_operation` for shared logic across clients. Assisted-by: Codex diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index c5c55fd..91192e9 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -40,12 +40,19 @@ GifPdfRestPayload, JpegPdfRestPayload, PdfInfoPayload, + PdfRedactionApplyPayload, + PdfRedactionPreviewPayload, PdfRestRawFileResponse, PngPdfRestPayload, TiffPdfRestPayload, UploadURLs,) -from .types import ALL_PDF_INFO_QUERIES, PdfInfoQuery +from .types import ( + ALL_PDF_INFO_QUERIES, + PdfInfoQuery, + PdfRedactionInstruction, + PdfRGBColor, +) DEFAULT_BASE_URL = "https://api.pdfrest.com" API_KEY_ENV_VAR = "PDFREST_API_KEY" @@ -598,6 +605,53 @@ def _send_request(self, request: _RequestModel) -> Any: raise translate_httpx_error(exc) from exc return self._handle_response(response) + def _post_file_operation( + self, + *, + endpoint: str, + payload: dict[str, Any], + payload_model: type[BaseModel], + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + job_options = payload_model.model_validate(payload) + request = self.prepare_request( + "POST", + endpoint, + json_body=job_options.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = self._send_request(request) + raw_response = PdfRestRawFileResponse.model_validate(raw_payload) + + output_ids = raw_response.ids or [] + output_files = [ + self.fetch_file_info( + str(file_id), + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) + for file_id in output_ids + ] + + return PdfRestFileBasedResponse.model_validate( + { + "input_id": [str(file_id) for file_id in raw_response.input_id], + "output_file": [ + file.model_dump(mode="json", by_alias=True) for file in output_files + ], + "warning": raw_response.warning, + } + ) + def send_request(self, request: _RequestModel) -> Any: return self._send_request(request) @@ -674,6 +728,7 @@ def __init__( headers: AnyMapping | None = None, http_client: httpx.AsyncClient | None = None, transport: httpx.AsyncBaseTransport | None = None, + concurrency_limit: int = DEFAULT_FILE_INFO_CONCURRENCY, ) -> None: super().__init__( api_key=api_key, @@ -688,6 +743,7 @@ def __init__( timeout=self._config.timeout, transport=transport, ) + self._concurrency_limit = concurrency_limit async def aclose(self) -> None: if self._owns_http_client: @@ -716,6 +772,62 @@ async def _send_request(self, request: _RequestModel) -> Any: raise translate_httpx_error(exc) from exc return self._handle_response(response) + async def _post_file_operation( + self, + *, + endpoint: str, + payload: dict[str, Any], + payload_model: type[BaseModel], + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + job_options = payload_model.model_validate(payload) + request = self.prepare_request( + "POST", + endpoint, + json_body=job_options.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = await self._send_request(request) + raw_response = PdfRestRawFileResponse.model_validate(raw_payload) + + output_ids = raw_response.ids or [] + output_files: list[PdfRestFile] = [] + semaphore = asyncio.Semaphore(self._concurrency_limit) + + async def throttled_fetch_file_info(file_id: str): + async with semaphore: + return await self.fetch_file_info( + str(file_id), + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) + + if output_ids: + output_files = list( + await asyncio.gather( + *(throttled_fetch_file_info(str(file_id)) for file_id in output_ids) + ) + ) + + return PdfRestFileBasedResponse.model_validate( + { + "input_id": [str(file_id) for file_id in raw_response.input_id], + "output_file": [ + file.model_dump(mode="json", by_alias=True) for file in output_files + ], + "warning": raw_response.warning, + } + ) + async def send_request(self, request: _RequestModel) -> Any: return await self._send_request(request) @@ -1380,41 +1492,15 @@ def _convert_to_graphic( extra_body: Body | None = None, timeout: TimeoutTypes | None = None, ) -> PdfRestFileBasedResponse: - conversion_options = payload_model.model_validate(payload) - request = self.prepare_request( - "POST", - endpoint, - json_body=conversion_options.model_dump( - mode="json", by_alias=True, exclude_none=True, exclude_unset=True - ), + return self._post_file_operation( + endpoint=endpoint, + payload=payload, + payload_model=payload_model, extra_query=extra_query, extra_headers=extra_headers, extra_body=extra_body, timeout=timeout, ) - raw_payload = self._send_request(request) - raw_response = PdfRestRawFileResponse.model_validate(raw_payload) - - output_ids = raw_response.ids or [] - output_files = [ - self.fetch_file_info( - str(file_id), - extra_query=extra_query, - extra_headers=extra_headers, - timeout=timeout, - ) - for file_id in output_ids - ] - - return PdfRestFileBasedResponse.model_validate( - { - "input_id": [str(file_id) for file_id in raw_response.input_id], - "output_file": [ - file.model_dump(mode="json", by_alias=True) for file in output_files - ], - "warning": raw_response.warning, - } - ) def query_pdf_info( self, @@ -1443,6 +1529,67 @@ def query_pdf_info( raw_payload = self._send_request(request) return PdfRestInfoResponse.model_validate(raw_payload) + def preview_redactions( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + redactions: PdfRedactionInstruction | Sequence[PdfRedactionInstruction], + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Generate a PDF redaction preview with annotated redaction rectangles.""" + + payload: dict[str, Any] = { + "files": file, + "redactions": redactions, + } + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/pdf-with-redacted-text-preview", + payload=payload, + payload_model=PdfRedactionPreviewPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + def apply_redactions( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + rgb_color: PdfRGBColor | Sequence[int] | None = None, + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Apply previously previewed redactions and return the final redacted PDF.""" + + payload: dict[str, Any] = { + "files": file, + } + if rgb_color is not None: + payload["rgb_color"] = rgb_color + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/pdf-with-redacted-text-applied", + payload=payload, + payload_model=PdfRedactionApplyPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def convert_to_png( self, files: PdfRestFile | Sequence[PdfRestFile], @@ -1659,6 +1806,7 @@ def __init__( headers: AnyMapping | None = None, http_client: httpx.AsyncClient | None = None, transport: httpx.AsyncBaseTransport | None = None, + concurrency_limit: int = DEFAULT_FILE_INFO_CONCURRENCY, ) -> None: """Create an asynchronous pdfRest client.""" @@ -1669,6 +1817,7 @@ def __init__( headers=headers, http_client=http_client, transport=transport, + concurrency_limit=concurrency_limit, ) self._files_client = _AsyncFilesClient(self) @@ -1710,6 +1859,67 @@ async def query_pdf_info( raw_payload = await self._send_request(request) return PdfRestInfoResponse.model_validate(raw_payload) + async def preview_redactions( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + redactions: PdfRedactionInstruction | Sequence[PdfRedactionInstruction], + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Asynchronously generate a PDF redaction preview.""" + + payload: dict[str, Any] = { + "files": file, + "redactions": redactions, + } + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/pdf-with-redacted-text-preview", + payload=payload, + payload_model=PdfRedactionPreviewPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + async def apply_redactions( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + rgb_color: PdfRGBColor | Sequence[int] | None = None, + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Asynchronously apply PDF redactions.""" + + payload: dict[str, Any] = { + "files": file, + } + if rgb_color is not None: + payload["rgb_color"] = rgb_color + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/pdf-with-redacted-text-applied", + payload=payload, + payload_model=PdfRedactionApplyPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def up( self, *, @@ -1742,47 +1952,15 @@ async def _convert_to_graphic( extra_body: Body | None = None, timeout: TimeoutTypes | None = None, ) -> PdfRestFileBasedResponse: - conversion_options = payload_model.model_validate(payload) - request = self.prepare_request( - "POST", - endpoint, - json_body=conversion_options.model_dump( - mode="json", by_alias=True, exclude_none=True, exclude_unset=True - ), + return await self._post_file_operation( + endpoint=endpoint, + payload=payload, + payload_model=payload_model, extra_query=extra_query, extra_headers=extra_headers, extra_body=extra_body, timeout=timeout, ) - raw_payload = await self._send_request(request) - raw_response = PdfRestRawFileResponse.model_validate(raw_payload) - - output_ids = raw_response.ids or [] - output_files: list[PdfRestFile] = [] - if output_ids: - output_files = list( - await asyncio.gather( - *( - self.fetch_file_info( - str(file_id), - extra_query=extra_query, - extra_headers=extra_headers, - timeout=timeout, - ) - for file_id in output_ids - ) - ) - ) - - return PdfRestFileBasedResponse.model_validate( - { - "input_id": [str(file_id) for file_id in raw_response.input_id], - "output_file": [ - file.model_dump(mode="json", by_alias=True) for file in output_files - ], - "warning": raw_response.warning, - } - ) async def convert_to_png( self, diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 3b7b0ed..7738416 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import re from collections.abc import Callable, Sequence from pathlib import PurePath @@ -17,6 +18,8 @@ model_validator, ) +from pdfrest.types.public import PdfRedactionPreset + from ..types import PdfInfoQuery from . import PdfRestFile from .public import PdfRestFileID @@ -127,6 +130,17 @@ def _split_comma_list(value: Any) -> Any: raise ValueError(msg) +def _split_comma_string(value: Any) -> list[Any] | None: + if value is None: + return None + if isinstance(value, str): + return value.split(",") + if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray, str)): + return list(value) + msg = "Must be a list, or a comma separated string." + raise ValueError(msg) + + def _pdfrest_file_to_id(value: Any) -> Any: if isinstance(value, PdfRestFile): return value.id @@ -140,12 +154,17 @@ def _serialize_as_first_file_id(value: list[PdfRestFile]) -> str: def _serialize_as_comma_separated_string(value: list[Any] | None) -> str | None: if value is None: return None - return ",".join(value) + return ",".join(str(element) for element in value) PageRangeEntry = Annotated[str, AfterValidator(_validate_page_range_entry)] +def _serialize_redactions(value: list[_PdfRedactionVariant]) -> str: + payload = [entry.model_dump(mode="json", exclude_none=True) for entry in value] + return json.dumps(payload, separators=(",", ":")) + + def _allowed_mime_types( allowed_mime_types: str, *more_allowed_mime_types: str, error_msg: str | None ) -> Callable[[Any], Any]: @@ -251,6 +270,90 @@ class PdfInfoPayload(BaseModel): ] +RgbChannel = Annotated[int, Field(ge=0, le=255)] + + +class PdfLiteralRedactionModel(BaseModel): + type: Literal["literal"] + value: Annotated[str, Field(min_length=1)] + + +class PdfRegexRedactionModel(BaseModel): + type: Literal["regex"] + value: Annotated[str, Field(min_length=1)] + + +class PdfPresetRedactionModel(BaseModel): + type: Literal["preset"] + value: PdfRedactionPreset + + +_PdfRedactionVariant = Annotated[ + PdfLiteralRedactionModel | PdfRegexRedactionModel | PdfPresetRedactionModel, + Field(discriminator="type"), +] + + +class PdfRedactionPreviewPayload(BaseModel): + """Adapt caller options into a pdfRest-compatible redaction preview request.""" + + files: Annotated[ + list[PdfRestFile], + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices("file", "files"), + serialization_alias="id", + ), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types("application/pdf", error_msg="Must be a PDF file") + ), + PlainSerializer(_serialize_as_first_file_id), + ] + redactions: Annotated[ + list[_PdfRedactionVariant], + Field(min_length=1), + BeforeValidator(_ensure_list), + PlainSerializer(_serialize_redactions), + ] + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + +class PdfRedactionApplyPayload(BaseModel): + """Adapt caller options into a pdfRest-compatible redaction application request.""" + + files: Annotated[ + list[PdfRestFile], + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices("file", "files"), + serialization_alias="id", + ), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types("application/pdf", error_msg="Must be a PDF file") + ), + PlainSerializer(_serialize_as_first_file_id), + ] + rgb_color: Annotated[ + tuple[RgbChannel, RgbChannel, RgbChannel] | None, + Field(serialization_alias="rgb_color", default=None), + BeforeValidator(_split_comma_string), + PlainSerializer(_serialize_as_comma_separated_string), + ] = None + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + class PngPdfRestPayload(BasePdfRestGraphicPayload[Literal["rgb", "rgba", "gray"]]): """Adapt caller options into a pdfRest-ready PNG request payload.""" diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index 0efbb16..d2e5400 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -1,5 +1,19 @@ """Public import surface for shared pdfrest types.""" -from .public import ALL_PDF_INFO_QUERIES, PdfInfoQuery +from .public import ( + ALL_PDF_INFO_QUERIES, + PdfInfoQuery, + PdfRedactionInstruction, + PdfRedactionPreset, + PdfRedactionType, + PdfRGBColor, +) -__all__ = ["ALL_PDF_INFO_QUERIES", "PdfInfoQuery"] +__all__ = [ + "ALL_PDF_INFO_QUERIES", + "PdfInfoQuery", + "PdfRGBColor", + "PdfRedactionInstruction", + "PdfRedactionPreset", + "PdfRedactionType", +] diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 492a785..c9528d5 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -4,7 +4,16 @@ from typing import Literal, cast, get_args -__all__ = ("ALL_PDF_INFO_QUERIES", "PdfInfoQuery") +from typing_extensions import TypedDict + +__all__ = ( + "ALL_PDF_INFO_QUERIES", + "PdfInfoQuery", + "PdfRGBColor", + "PdfRedactionInstruction", + "PdfRedactionPreset", + "PdfRedactionType", +) PdfInfoQuery = Literal[ "tagged", @@ -43,3 +52,28 @@ ALL_PDF_INFO_QUERIES: tuple[PdfInfoQuery, ...] = cast( tuple[PdfInfoQuery, ...], get_args(PdfInfoQuery) ) + +PdfRedactionType = Literal["literal", "regex", "preset"] + +PdfRedactionPreset = Literal[ + "email", + "phone_number", + "date", + "us_ssn", + "url", + "credit_card", + "credit_debit_pin", + "bank_routing_number", + "international_bank_account_number", + "swift_bic_number", + "ipv4", + "ipv6", +] + + +class PdfRedactionInstruction(TypedDict): + type: PdfRedactionType + value: PdfRedactionPreset | str + + +PdfRGBColor = tuple[int, int, int]
1 parent bcacf5f commit 742db2b

4 files changed

Lines changed: 400 additions & 71 deletions

File tree

0 commit comments

Comments
 (0)