Commit 742db2b
committed
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
0 commit comments