Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8238b1f
Add Sign PDF tool
datalogics-cgreen Jan 20, 2026
9f9fa4a
Add Sign PDF tests
datalogics-cgreen Jan 20, 2026
b681ae4
signed-pdf: Require `location` for new signature fields
datalogics-cgreen Feb 16, 2026
ab18480
client, models: Simplify credential payload handling for PDF signing
datalogics-kam Feb 12, 2026
d02fcd8
models: Simplify signature configuration serialization
datalogics-kam Feb 12, 2026
afc16e5
models: Add location validation for new signature configurations
datalogics-kam Feb 12, 2026
f373474
tests/live: Update signature location in test_live_sign_pdf
datalogics-kam Feb 12, 2026
8672a32
tests/live: Add test for signing PDF with existing signature field
datalogics-kam Feb 12, 2026
cf34f16
tests: Update validation message in sign_pdf test
datalogics-kam Feb 16, 2026
3256e49
tests/live: Sanitize passphrase input in test_live_sign_pdf
datalogics-kam Feb 16, 2026
9884f99
models: Update error message to reference combined MIME types
datalogics-kam Feb 17, 2026
4553d72
tests/models: Support x509-ca-cert MIME type for DER credentials
datalogics-kam Feb 17, 2026
2630226
models: Split PdfSignatureConfiguration into separate types
datalogics-kam Feb 17, 2026
08d6293
tests: Add and enhance tests for signing PDF with invalid configurations
datalogics-kam Feb 17, 2026
05261b9
models/tests: Transition validators and add MIME type support
datalogics-kam Feb 17, 2026
4cbafee
models: Restrict PdfSignaturePoint coordinates to float type
datalogics-kam Feb 18, 2026
713f2ca
docs: Clarify payload modeling guidelines in AGENTS.md
datalogics-kam Feb 18, 2026
07e831b
tests/live: Enhance PDF signing tests with additional cases
datalogics-kam Feb 18, 2026
350e08f
models: Change TypeError to ValueError in validation logic
datalogics-kam Feb 18, 2026
a699dea
models/tests: Enforce logo_opacity > 0 and update validation tests
datalogics-kam Feb 18, 2026
d8bd605
tests: Add validation for handling multiple input, logo, or credentia…
datalogics-kam Feb 19, 2026
8a09b43
tests: Add parametric tests for multiple credential file rejection
datalogics-kam Feb 19, 2026
6d004b8
tests: Add tests for invalid signature type rejection
datalogics-kam Feb 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@
- Avoid `@field_validator` on payload models. Prefer existing `BeforeValidator`
helpers (e.g., `_allowed_mime_types`) so validation remains declarative and
consistent across schemas.
- In Pydantic validators, raise `ValueError`/`AssertionError` (or
`PydanticCustomError` when needed), not `TypeError`, so callers consistently
receive `ValidationError` surfaces.
- Keep user-facing `PdfRestClient` and `AsyncPdfRestClient` endpoint helpers
thin: they should primarily assemble payload dicts and delegate validation to
payload models (`model_validate`). Avoid duplicating payload validation in
Expand Down Expand Up @@ -128,6 +131,10 @@
helpers should pass sequences through without converting to raw IDs manually.
- When a payload accepts uploaded content, validate MIME types via
`_allowed_mime_types` to surface clear errors before making the request.
- Payload models should mirror pdfRest's request layout field-for-field. Do not
use `@model_serializer` on payload models. If callers need a friendlier input
shape, use `@model_validator(mode="before")` to map inputs onto the existing
pdfRest fields, and keep any wire formatting in field serializers.
- When an endpoint expects JSON-encoded structures (e.g., arrays of redaction
rules), expose typed arguments (TypedDicts, Literals, etc.) via
`pdfrest.types` and let the payload serializer produce the JSON string for the
Expand Down
75 changes: 75 additions & 0 deletions src/pdfrest/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
PdfRedactionPreviewPayload,
PdfRestRawFileResponse,
PdfRestrictPayload,
PdfSignPayload,
PdfSplitPayload,
PdfTextWatermarkPayload,
PdfToExcelPayload,
Expand Down Expand Up @@ -156,6 +157,8 @@
PdfRedactionInstruction,
PdfRestriction,
PdfRGBColor,
PdfSignatureConfiguration,
PdfSignatureCredentials,
PdfTextColor,
PdfXType,
PngColorModel,
Expand Down Expand Up @@ -3212,6 +3215,42 @@ def add_attachment_to_pdf(
timeout=timeout,
)

def sign_pdf(
self,
file: PdfRestFile | Sequence[PdfRestFile],
*,
signature_configuration: PdfSignatureConfiguration,
credentials: PdfSignatureCredentials,
logo: PdfRestFile | Sequence[PdfRestFile] | 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:
"""Digitally sign a PDF using PFX credentials or a certificate/private key."""

payload: dict[str, Any] = {
"files": file,
"signature_configuration": signature_configuration,
"credentials": credentials,
}

if logo is not None:
payload["logo"] = logo
if output is not None:
payload["output"] = output

return self._post_file_operation(
endpoint="/signed-pdf",
payload=payload,
payload_model=PdfSignPayload,
extra_query=extra_query,
extra_headers=extra_headers,
extra_body=extra_body,
timeout=timeout,
)

def blank_pdf(
self,
*,
Expand Down Expand Up @@ -5109,6 +5148,42 @@ async def add_attachment_to_pdf(
timeout=timeout,
)

async def sign_pdf(
self,
file: PdfRestFile | Sequence[PdfRestFile],
*,
signature_configuration: PdfSignatureConfiguration,
credentials: PdfSignatureCredentials,
logo: PdfRestFile | Sequence[PdfRestFile] | 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:
"""Digitally sign a PDF using PFX credentials or a certificate/private key."""

payload: dict[str, Any] = {
"files": file,
"signature_configuration": signature_configuration,
"credentials": credentials,
}

if logo is not None:
payload["logo"] = logo
if output is not None:
payload["output"] = output

return await self._post_file_operation(
endpoint="/signed-pdf",
payload=payload,
payload_model=PdfSignPayload,
extra_query=extra_query,
extra_headers=extra_headers,
extra_body=extra_body,
timeout=timeout,
)

async def blank_pdf(
self,
*,
Expand Down
233 changes: 232 additions & 1 deletion src/pdfrest/models/_internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,12 @@ def _serialize_text_objects(value: list[BaseModel]) -> str:
return to_json(payload).decode()


def _serialize_signature_configuration(
value: _PdfSignatureConfigurationModel,
) -> str:
return value.model_dump_json(exclude_none=True)


def _allowed_mime_types(
allowed_mime_types: str, *more_allowed_mime_types: str, error_msg: str | None
) -> Callable[[Any], Any]:
Expand All @@ -242,7 +248,10 @@ def allowed_mime_types_validator(
_ = allowed_mime_types_validator(item)
return value
if value.type not in combined_allowed_mime_types:
msg = error_msg or f"The file type must be one of: {allowed_mime_types}"
msg = (
error_msg
or f"The file type must be one of: {combined_allowed_mime_types}"
)
raise ValueError(msg)
return value

Expand Down Expand Up @@ -978,6 +987,44 @@ class PdfPresetRedactionModel(BaseModel):
value: PdfRedactionPreset


class _PdfSignaturePointModel(BaseModel):
x: float
y: float


class _PdfSignatureLocationModel(BaseModel):
bottom_left: _PdfSignaturePointModel
top_right: _PdfSignaturePointModel
page: str | int


class _PdfSignatureDisplayModel(BaseModel):
include_distinguished_name: bool | None = None
include_datetime: bool | None = None
contact: str | None = None
location: str | None = None
name: str | None = None
reason: str | None = None


class _PdfSignatureConfigurationModel(BaseModel):
type: Literal["new", "existing"]
name: str | None = None
logo_opacity: Annotated[float | None, Field(gt=0, le=1, default=None)] = None
location: _PdfSignatureLocationModel | None = None
display: _PdfSignatureDisplayModel | None = None

@model_validator(mode="after")
def _validate_location_requirements(self) -> _PdfSignatureConfigurationModel:
if self.type == "new" and self.location is None:
msg = (
"Missing location information for a new digital signature field. "
"See documentation for required fields."
)
raise ValueError(msg)
return self


_PdfRedactionVariant = Annotated[
PdfLiteralRedactionModel | PdfRegexRedactionModel | PdfPresetRedactionModel,
Field(discriminator="type"),
Expand Down Expand Up @@ -1435,6 +1482,190 @@ class PdfExportFormDataPayload(BaseModel):
] = None


class PdfSignPayload(BaseModel):
"""Adapt caller options into a pdfRest-ready sign request payload."""

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),
]
signature_configuration: Annotated[
_PdfSignatureConfigurationModel,
Field(serialization_alias="signature_configuration"),
PlainSerializer(_serialize_signature_configuration),
]
pfx_credential: Annotated[
list[PdfRestFile] | None,
Field(
default=None,
min_length=1,
max_length=1,
validation_alias=AliasChoices("pfx", "pfx_credential"),
serialization_alias="pfx_credential_id",
),
BeforeValidator(_ensure_list),
AfterValidator(
_allowed_mime_types(
"application/x-pkcs12",
"application/pkcs12",
"application/octet-stream",
error_msg="PFX credentials must be a .pfx or .p12 file",
)
),
PlainSerializer(_serialize_as_first_file_id),
] = None
pfx_passphrase: Annotated[
list[PdfRestFile] | None,
Field(
default=None,
min_length=1,
max_length=1,
validation_alias=AliasChoices("pfx_passphrase", "passphrase"),
serialization_alias="pfx_passphrase_id",
),
BeforeValidator(_ensure_list),
AfterValidator(
_allowed_mime_types(
"text/plain",
"application/octet-stream",
error_msg="PFX passphrase must be a text file",
)
),
PlainSerializer(_serialize_as_first_file_id),
] = None
certificate: Annotated[
list[PdfRestFile] | None,
Field(
default=None,
min_length=1,
max_length=1,
validation_alias=AliasChoices("certificate", "cert"),
serialization_alias="certificate_id",
),
BeforeValidator(_ensure_list),
AfterValidator(
# DER cert/key uploads are frequently tagged as x509-ca-cert (or octet-stream
# in some environments), so we intentionally keep this allowlist broad.
_allowed_mime_types(
"application/pkix-cert",
"application/x-x509-ca-cert",
"application/x-pem-file",
"application/pem-certificate-chain",
"application/octet-stream",
error_msg="Certificate must be a .pem or .der file",
)
),
PlainSerializer(_serialize_as_first_file_id),
] = None
private_key: Annotated[
list[PdfRestFile] | None,
Field(
default=None,
min_length=1,
max_length=1,
validation_alias=AliasChoices("private_key", "key"),
serialization_alias="private_key_id",
),
BeforeValidator(_ensure_list),
AfterValidator(
# Keep parity with provider/browser MIME detection for DER private keys.
_allowed_mime_types(
"application/pkix-cert",
"application/x-x509-ca-cert",
"application/pkcs8",
"application/x-pem-file",
"application/pem-certificate-chain",
"application/octet-stream",
error_msg="Private key must be a .pem or .der file",
)
),
PlainSerializer(_serialize_as_first_file_id),
] = None
logo: Annotated[
list[PdfRestFile] | None,
Field(
default=None,
min_length=1,
max_length=1,
validation_alias=AliasChoices("logo", "logos"),
serialization_alias="logo_id",
),
BeforeValidator(_ensure_list),
AfterValidator(
_allowed_mime_types(
Comment thread
datalogics-kam marked this conversation as resolved.
"image/jpeg",
"image/png",
"image/tiff",
"image/bmp",
error_msg="Logo must be an image file",
)
),
PlainSerializer(_serialize_as_first_file_id),
] = None
output: Annotated[
str | None,
Field(serialization_alias="output", min_length=1, default=None),
AfterValidator(_validate_output_prefix),
] = None

@model_validator(mode="before")
@classmethod
def _normalize_credentials(cls, data: Any) -> Any:
if not isinstance(data, Mapping):
return data

payload = cast(Mapping[object, Any], data)
credentials = payload.get("credentials")
if credentials is None:
return {str(key): value for key, value in payload.items()}
if not isinstance(credentials, Mapping):
msg = (
"credentials must be a mapping with either pfx/passphrase or "
"certificate/private_key."
)
raise ValueError(msg) # noqa: TRY004

normalized: dict[str, Any] = {str(key): value for key, value in payload.items()}
credential_map = cast(Mapping[object, Any], credentials)
for raw_key, value in credential_map.items():
key = str(raw_key)
if key not in normalized:
normalized[key] = value
return normalized

@model_validator(mode="after")
def _validate_credentials(self) -> PdfSignPayload:
has_pfx = self.pfx_credential is not None or self.pfx_passphrase is not None
has_pem = self.certificate is not None or self.private_key is not None

if has_pfx and has_pem:
msg = "Provide either PFX credentials (pfx + passphrase) or certificate/private_key, not both."
raise ValueError(msg)
if has_pfx:
if not self.pfx_credential or not self.pfx_passphrase:
msg = "Both pfx and passphrase are required when supplying PFX credentials."
raise ValueError(msg)
elif has_pem:
if not self.certificate or not self.private_key:
msg = "Both certificate and private_key are required when supplying PEM/DER credentials."
raise ValueError(msg)
else:
msg = "Either PFX credentials (pfx + passphrase) or certificate/private_key credentials are required."
raise ValueError(msg)

return self


class PdfCompressPayload(BaseModel):
"""Adapt caller options into a pdfRest-ready compress request payload."""

Expand Down
Loading