Skip to content

Commit b6c3cec

Browse files
Merge pull request #30 from datalogics-kam/pdfcloud-5594-redacted-values
PDFCLOUD-5594 Redacted values
2 parents 284f4d7 + 3a5b42e commit b6c3cec

11 files changed

Lines changed: 634 additions & 6 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,10 @@
173173
pdfRest wire quirks (for example, splitting comma-separated values or
174174
serializing only the first uploaded file ID), not re-implement constraint
175175
logic already expressed by Pydantic field types/annotations.
176+
- For demo/free-tier redactions, favor parseable-but-useless replacements over
177+
reconstructing likely true values. The SDK should remain operable (no parsing
178+
crashes) while preserving demo mode’s intent of withholding useful output
179+
fidelity.
176180
- Prefer reusable validator factories that take parameters (for example
177181
allowed-value/extension helpers with keyword-configured fallbacks) over
178182
bespoke one-off validator functions tied to a single field.

docs/getting-started.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,45 @@ For the official Cloud onboarding flow, see:
6666
interactively and generating starter code samples before integrating them
6767
into your project.
6868

69+
### Demo keys and redacted values
70+
71+
If you are using a demo/free-tier key, some API responses may include redacted
72+
values (for example `fa***`, `tr**`, masked strings, or placeholder IDs).
73+
74+
To keep response models parseable, the SDK replaces certain known demo-redacted
75+
values in a few response fields:
76+
77+
- `PdfRestInfoResponse` boolean fields:
78+
`tagged`, `image_only`, `contains_annotations`, `contains_signature`,
79+
`restrict_permissions_set`, `contains_xfa`, `contains_acroforms`,
80+
`contains_javascript`, `contains_transparency`, `contains_embedded_file`,
81+
`uses_embedded_fonts`, `uses_nonembedded_fonts`, `pdfa`, `pdfua_claim`,
82+
`pdfe_claim`, `pdfx_claim`, `requires_password_to_open`
83+
- `PdfRestInfoResponse.file_size` -> replaced with `0` when redacted
84+
- `PdfRestInfoResponse.all_queries_processed` -> replaced with `True` when redacted
85+
- unzip response file IDs are sanitized before file-info lookup, so
86+
`PdfRestFileBasedResponse.output_file.id` may be the null UUID
87+
`00000000-0000-4000-8000-000000000000` when demo IDs are redacted
88+
89+
When a replacement happens, the SDK logs a warning in this format:
90+
91+
`Demo value <val> detected in <field-name>; replaced with <replacement>`
92+
93+
When the API returns a demo restriction body message (for example the free-tier
94+
"watermarked or redacted" notice in `message`), the SDK also logs:
95+
96+
`Demo mode restriction message in response <METHOD URL> field=<field>: <message>`
97+
98+
To see these warnings in your app, configure Python logging (example):
99+
100+
```python
101+
import logging
102+
103+
logging.basicConfig(level=logging.WARNING)
104+
logging.getLogger("pdfrest.models").setLevel(logging.WARNING)
105+
logging.getLogger("pdfrest.client").setLevel(logging.WARNING)
106+
```
107+
69108
## 3. Add a short example program
70109

71110
Create `quickstart.py`:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pdfrest"
3-
version = "1.0.0"
3+
version = "1.0.1"
44
description = "Python client library for interacting with the PDFRest API"
55
readme = {file = "README.md", content-type = "text/markdown"}
66
authors = [

src/pdfrest/client.py

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,12 @@
192192
BACKOFF_JITTER_SECONDS = 0.1
193193
RETRYABLE_STATUS_CODES = {408, 425, 429, 499}
194194
_SUCCESSFUL_DELETION_MESSAGE = "successfully deleted"
195+
_DEMO_RESTRICTION_MESSAGE_FIELDS = ("message", "warning", "keyMessage")
196+
_DEMO_FALLBACK_FILE_ID = "00000000-0000-4000-8000-000000000000"
197+
_DEMO_FALLBACK_FILE_URL = "https://pdfrest.com/demo-redacted"
198+
_DEMO_FALLBACK_MIME_TYPE = "application/octet-stream"
199+
_DEMO_FALLBACK_FILE_NAME = "demo-redacted.bin"
200+
_DEMO_FALLBACK_FILE_SIZE = 1
195201

196202

197203
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
@@ -263,6 +269,17 @@ def _parse_retry_after_header(header_value: str | None) -> float | None:
263269
return seconds if seconds > 0 else 0.0
264270

265271

272+
def _is_demo_restriction_message(value: str) -> bool:
273+
normalized = value.strip().casefold()
274+
if not normalized:
275+
return False
276+
return (
277+
"watermarked or redacted" in normalized
278+
and "free account" in normalized
279+
and "upgrade your plan" in normalized
280+
)
281+
282+
266283
FileContent = IO[bytes] | bytes | str
267284
FileTuple2 = tuple[str | None, FileContent]
268285
FileTuple3 = tuple[str | None, FileContent, str | None]
@@ -308,6 +325,23 @@ def _extract_uploaded_file_ids(payload: Any) -> list[str]:
308325
return file_ids
309326

310327

328+
def _is_demo_fallback_file_id(file_id: str) -> bool:
329+
return file_id.strip().lower() == _DEMO_FALLBACK_FILE_ID
330+
331+
332+
def _build_demo_fallback_file(file_id: str) -> PdfRestFile:
333+
return PdfRestFile.model_validate(
334+
{
335+
"id": file_id,
336+
"name": _DEMO_FALLBACK_FILE_NAME,
337+
"url": _DEMO_FALLBACK_FILE_URL,
338+
"type": _DEMO_FALLBACK_MIME_TYPE,
339+
"size": _DEMO_FALLBACK_FILE_SIZE,
340+
"modified": datetime.now(timezone.utc),
341+
}
342+
)
343+
344+
311345
def _handle_deletion_failures(response: PdfRestDeletionResponse) -> None:
312346
failures: list[PdfRestDeleteError] = []
313347
for file_id, result in response.deletion_responses.items():
@@ -836,11 +870,13 @@ def _handle_response(self, response: httpx.Response) -> Any:
836870
f"{getattr(request, 'method', 'UNKNOWN')} {getattr(request, 'url', '')}"
837871
)
838872
if response.is_success:
873+
payload = self._decode_json(response)
874+
self._log_demo_restriction_messages(payload, request_label)
839875
if self._logger.isEnabledFor(logging.DEBUG):
840876
self._logger.debug(
841877
"Response %s status=%s", request_label, response.status_code
842878
)
843-
return self._decode_json(response)
879+
return payload
844880

845881
message, error_payload = self._extract_error_details(response)
846882
retry_after = _parse_retry_after_header(response.headers.get("Retry-After"))
@@ -888,6 +924,30 @@ def _decode_json(self, response: httpx.Response) -> Any:
888924
response_content=response.text,
889925
) from exc
890926

927+
def _log_demo_restriction_messages(self, payload: Any, request_label: str) -> None:
928+
if not isinstance(payload, Mapping):
929+
return
930+
931+
typed_payload = cast(Mapping[str, Any], payload)
932+
emitted_messages: set[str] = set()
933+
for field_name in _DEMO_RESTRICTION_MESSAGE_FIELDS:
934+
value = typed_payload.get(field_name)
935+
if not isinstance(value, str):
936+
continue
937+
message = value.strip()
938+
if not _is_demo_restriction_message(message):
939+
continue
940+
normalized_message = message.casefold()
941+
if normalized_message in emitted_messages:
942+
continue
943+
emitted_messages.add(normalized_message)
944+
self._logger.warning(
945+
"Demo mode restriction message in response %s field=%s: %s",
946+
request_label,
947+
field_name,
948+
message,
949+
)
950+
891951
@staticmethod
892952
def _extract_error_details(
893953
response: httpx.Response,
@@ -1160,7 +1220,17 @@ def fetch_file_info(
11601220
extra_headers=extra_headers,
11611221
timeout=timeout,
11621222
)
1163-
payload = self._send_request(request)
1223+
try:
1224+
payload = self._send_request(request)
1225+
except PdfRestApiError as exc:
1226+
if exc.status_code == 404 and _is_demo_fallback_file_id(file_id):
1227+
self._logger.warning(
1228+
"Demo fallback file id %s was not found during file-info lookup; "
1229+
"returning placeholder metadata.",
1230+
file_id,
1231+
)
1232+
return _build_demo_fallback_file(file_id)
1233+
raise
11641234
return PdfRestFile.model_validate(payload)
11651235

11661236

@@ -1435,7 +1505,17 @@ async def fetch_file_info(
14351505
extra_headers=extra_headers,
14361506
timeout=timeout,
14371507
)
1438-
payload = await self._send_request(request)
1508+
try:
1509+
payload = await self._send_request(request)
1510+
except PdfRestApiError as exc:
1511+
if exc.status_code == 404 and _is_demo_fallback_file_id(file_id):
1512+
self._logger.warning(
1513+
"Demo fallback file id %s was not found during file-info lookup; "
1514+
"returning placeholder metadata.",
1515+
file_id,
1516+
)
1517+
return _build_demo_fallback_file(file_id)
1518+
raise
14391519
return PdfRestFile.model_validate(payload)
14401520

14411521

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import re
5+
from typing import Any
6+
7+
from pydantic import ValidationInfo
8+
9+
LOGGER = logging.getLogger("pdfrest.models")
10+
11+
_DEMO_UUID = "00000000-0000-4000-8000-000000000000"
12+
_REDACTED_X_PATTERN = re.compile(r"^[Xx-]{8,}$")
13+
14+
15+
def _field_name(info: ValidationInfo) -> str:
16+
return info.field_name or "<unknown>"
17+
18+
19+
def _looks_like_demo_redaction(value: Any) -> bool:
20+
if not isinstance(value, str):
21+
return False
22+
if _looks_like_generate_redacted_string(value):
23+
return True
24+
return bool(_REDACTED_X_PATTERN.fullmatch(value))
25+
26+
27+
def _looks_like_generate_redacted_string(value: str) -> bool:
28+
"""Detect strings redacted by PDFCloud-API generateRedactedString.
29+
30+
The upstream redactor preserves the first two characters and replaces all
31+
non-whitespace characters after that with '*'.
32+
"""
33+
if len(value) < 3:
34+
return False
35+
tail = value[2:]
36+
if "*" not in tail:
37+
return False
38+
return all(char == "*" or char.isspace() for char in tail)
39+
40+
41+
def _log_replacement(original: Any, replacement: Any, info: ValidationInfo) -> None:
42+
LOGGER.warning(
43+
"Demo value %s detected in %s; replaced with %s",
44+
original,
45+
_field_name(info),
46+
replacement,
47+
)
48+
49+
50+
def _demo_bool_or_passthrough(
51+
value: Any, info: ValidationInfo, *, replacement: bool
52+
) -> Any:
53+
if value is None or isinstance(value, bool):
54+
return value
55+
if _looks_like_demo_redaction(value):
56+
# Intentionally clamp demo-redacted bool-like strings to a configured
57+
# constant. The goal is parseability without restoring potentially
58+
# meaningful signal that demo mode is designed to obscure.
59+
_log_replacement(value, replacement, info)
60+
return replacement
61+
return value
62+
63+
64+
def demo_bool_false_or_passthrough(value: Any, info: ValidationInfo) -> Any:
65+
return _demo_bool_or_passthrough(value, info, replacement=False)
66+
67+
68+
def demo_bool_true_or_passthrough(value: Any, info: ValidationInfo) -> Any:
69+
return _demo_bool_or_passthrough(value, info, replacement=True)
70+
71+
72+
def demo_file_id_or_passthrough(value: Any, info: ValidationInfo) -> Any:
73+
if value is None:
74+
return value
75+
if _looks_like_demo_redaction(value):
76+
replacement = _DEMO_UUID
77+
_log_replacement(value, replacement, info)
78+
return replacement
79+
return value
80+
81+
82+
def demo_int_or_passthrough(value: Any, info: ValidationInfo) -> Any:
83+
if value is None or isinstance(value, int):
84+
return value
85+
if _looks_like_demo_redaction(value):
86+
replacement = 0
87+
_log_replacement(value, replacement, info)
88+
return replacement
89+
return value

src/pdfrest/models/_internal.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
WatermarkHorizontalAlignment,
4646
WatermarkVerticalAlignment,
4747
)
48+
from ._demo_value_sanitizers import demo_file_id_or_passthrough
4849
from .public import PdfRestFile, PdfRestFileID
4950

5051
PdfConvertColorProfile = PdfPresetColorProfile | Literal["custom"]
@@ -2584,7 +2585,11 @@ class PdfRestRawUploadedFile(BaseModel):
25842585
"""
25852586

25862587
name: Annotated[str, Field(description="The name of the file")]
2587-
id: Annotated[PdfRestFileID, Field(description="The id of the file")]
2588+
id: Annotated[
2589+
PdfRestFileID,
2590+
BeforeValidator(demo_file_id_or_passthrough),
2591+
Field(description="The id of the file"),
2592+
]
25882593
output_url: Annotated[
25892594
list[HttpUrl] | HttpUrl | None,
25902595
Field(description="The url of the unzipped file", alias="outputUrl"),

0 commit comments

Comments
 (0)