From ddb83f77c578471ffeafb607d7c7e33c68f8e6c0 Mon Sep 17 00:00:00 2001 From: "michal.szymanowski" Date: Sun, 14 Jun 2026 15:54:07 +0200 Subject: [PATCH] PDFBOLT-918 Harden Python SDK typing and CI --- .github/workflows/ci.yml | 50 +++++ README.md | 7 +- examples/__init__.py | 1 + pyproject.toml | 2 + scripts/test_pack.py | 121 ++++++++++++ src/pdfbolt/__init__.py | 70 +++++++ src/pdfbolt/models.py | 66 +++++-- src/pdfbolt/resources/async_conversions.py | 50 +++-- src/pdfbolt/resources/direct.py | 38 ++-- src/pdfbolt/resources/sync.py | 38 ++-- src/pdfbolt/types.py | 202 +++++++++++++++++++++ tests/test_client.py | 51 ++++++ tests/typecheck_usage.py | 55 ++++++ 13 files changed, 686 insertions(+), 65 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 examples/__init__.py create mode 100644 scripts/test_pack.py create mode 100644 src/pdfbolt/types.py create mode 100644 tests/typecheck_usage.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..434c17e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"] + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Lint + run: ruff check . + + - name: Typecheck + run: mypy src tests/typecheck_usage.py + + - name: Test + run: pytest + + - name: Build package + run: python -m build + + - name: Check package metadata + run: twine check dist/* + + - name: Smoke test package + run: python scripts/test_pack.py diff --git a/README.md b/README.md index eb45781..89dbd96 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Official Python SDK for the PDFBolt API. -PDFBolt generates PDFs from HTTPS URLs, raw HTML, and published templates. See the [PDFBolt docs](https://pdfbolt.com/docs) and [OpenAPI reference](https://pdfbolt.com/docs/api-reference) for the full REST API. The SDK is typed, uses `requests`, and is intended for server-side Python applications. +PDFBolt generates PDFs from HTTPS URLs, raw HTML, and published templates. See the [PDFBolt docs](https://pdfbolt.com/docs) and [OpenAPI reference](https://pdfbolt.com/docs/api-reference) for the full REST API. The SDK is typed, uses `requests`, and is intended for server-side Python applications. Typed request dictionaries and keyword options are exported for type-aware editors and static checkers. ## Installation @@ -359,17 +359,18 @@ PDFBoltValidationError PDFBoltConfigurationError ``` -Typed model exports include direct, sync, async job, webhook event, usage, and rate-limit result classes. +Typed request exports include conversion options, direct/sync/async request dictionaries, webhook event types, cookies, margins, dimensions, and other REST API parameter types. Typed model exports include direct, sync, async job, webhook event, usage, and rate-limit result classes. ## Development ```bash python -m pip install -e ".[dev]" ruff check . -mypy src +mypy src tests/typecheck_usage.py pytest python -m build twine check dist/* +python scripts/test_pack.py ``` ## Examples diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..f838531 --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1 @@ +"""Example modules used by SDK tests and local demos.""" diff --git a/pyproject.toml b/pyproject.toml index 36b1a68..ebdc24d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Typing :: Typed" ] dependencies = [ @@ -55,6 +56,7 @@ select = ["E", "F", "I", "UP", "B"] [tool.pytest.ini_options] testpaths = ["tests"] +pythonpath = ["."] [tool.mypy] python_version = "3.11" diff --git a/scripts/test_pack.py b/scripts/test_pack.py new file mode 100644 index 0000000..ffb6fd3 --- /dev/null +++ b/scripts/test_pack.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import glob +import os +import subprocess +import sys +import tarfile +import tempfile +import tomllib +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DIST = ROOT / "dist" +VERSION = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"]["version"] +REQUIRED_WHEEL_FILES = { + "pdfbolt/__init__.py", + "pdfbolt/types.py", + "pdfbolt/py.typed", + f"pdfbolt-{VERSION}.dist-info/METADATA", + f"pdfbolt-{VERSION}.dist-info/WHEEL", + f"pdfbolt-{VERSION}.dist-info/RECORD", +} +FORBIDDEN_WHEEL_PREFIXES = ("tests/", "examples/", "scripts/") +FORBIDDEN_SDIST_PREFIXES = (".git/", ".venv/", "build/", "dist/") + + +def main() -> None: + wheel = exactly_one(DIST / "*.whl") + sdist = exactly_one(DIST / "*.tar.gz") + + check_wheel_contents(wheel) + check_sdist_contents(sdist) + check_installed_wheel(wheel) + + print(f"Packed package {wheel.name} passed wheel, sdist, and import smoke checks.") + + +def exactly_one(pattern: Path) -> Path: + matches = [Path(match) for match in glob.glob(str(pattern))] + if len(matches) != 1: + raise SystemExit(f"Expected exactly one match for {pattern}, found {len(matches)}.") + return matches[0] + + +def check_wheel_contents(wheel: Path) -> None: + with zipfile.ZipFile(wheel) as archive: + names = set(archive.namelist()) + + missing = sorted(REQUIRED_WHEEL_FILES - names) + if missing: + raise SystemExit(f"Wheel is missing required files: {', '.join(missing)}") + + forbidden = [ + name for name in names if name == ".env" or name.startswith(FORBIDDEN_WHEEL_PREFIXES) + ] + if forbidden: + raise SystemExit(f"Wheel should not include: {', '.join(sorted(forbidden))}") + + +def check_sdist_contents(sdist: Path) -> None: + with tarfile.open(sdist) as archive: + names = {strip_sdist_root(member.name) for member in archive.getmembers()} + + required = { + "README.md", + "LICENSE", + "pyproject.toml", + "src/pdfbolt/__init__.py", + "src/pdfbolt/types.py", + "src/pdfbolt/py.typed", + "tests/test_client.py", + "tests/typecheck_usage.py", + } + missing = sorted(required - names) + if missing: + raise SystemExit(f"Sdist is missing required files: {', '.join(missing)}") + + forbidden = [name for name in names if name.startswith(FORBIDDEN_SDIST_PREFIXES)] + if forbidden: + raise SystemExit(f"Sdist should not include: {', '.join(sorted(forbidden))}") + + +def check_installed_wheel(wheel: Path) -> None: + with tempfile.TemporaryDirectory(prefix="pdfbolt-python-pack-") as temp_dir: + target = Path(temp_dir) / "site" + subprocess.run( + [ + sys.executable, + "-m", + "pip", + "install", + "--quiet", + "--target", + str(target), + str(wheel), + ], + check=True, + ) + + code = ( + "import pdfbolt; " + "required = ['PDFBolt', 'DirectConversionResult', 'VERSION', " + "'DirectConvertParams', 'SyncConvertParams', 'AsyncConvertParams']; " + "missing = [name for name in required if not hasattr(pdfbolt, name)]; " + "assert not missing, f'missing exports: {missing}'" + ) + subprocess.run( + [sys.executable, "-c", code], + check=True, + env={**os.environ, "PYTHONPATH": str(target)}, + ) + + +def strip_sdist_root(name: str) -> str: + parts = name.split("/", 1) + return parts[1] if len(parts) == 2 else name + + +if __name__ == "__main__": + main() diff --git a/src/pdfbolt/__init__.py b/src/pdfbolt/__init__.py index 71c8361..7ac205e 100644 --- a/src/pdfbolt/__init__.py +++ b/src/pdfbolt/__init__.py @@ -19,26 +19,96 @@ SyncConversionResult, UsageSummary, ) +from .types import ( + AsyncConversionWebhookStatus, + AsyncConvertParams, + AsyncHtmlParams, + AsyncOptions, + AsyncTemplateParams, + AsyncUrlParams, + CompressionLevel, + ContentDisposition, + ConversionErrorCode, + ConversionOptions, + DirectConvertParams, + DirectHtmlParams, + DirectOptions, + DirectTemplateParams, + DirectUrlParams, + DomainCookie, + EmulateMediaType, + HttpCredentials, + Margin, + MarginDimension, + PageDimension, + PaperFormat, + PDFBoltCookie, + PrintProduction, + SyncConversionStatus, + SyncConvertParams, + SyncHtmlParams, + SyncOptions, + SyncTemplateParams, + SyncUrlParams, + UrlCookie, + ViewportSize, + WaitForSelector, + WaitUntil, +) from .webhooks import Webhooks, webhooks __all__ = [ + "AsyncConvertParams", "AsyncConversionJob", "AsyncConversionWebhookEvent", + "AsyncConversionWebhookStatus", + "AsyncHtmlParams", + "AsyncOptions", + "AsyncTemplateParams", + "AsyncUrlParams", + "CompressionLevel", + "ContentDisposition", + "ConversionErrorCode", + "ConversionOptions", + "DirectConvertParams", "DirectConversionResult", + "DirectHtmlParams", + "DirectOptions", + "DirectTemplateParams", + "DirectUrlParams", + "DomainCookie", + "EmulateMediaType", + "HttpCredentials", + "Margin", + "MarginDimension", "OneTimeCredits", "PDFBolt", "PDFBoltAPIError", + "PDFBoltCookie", "PDFBoltConfigurationError", "PDFBoltError", "PDFBoltNetworkError", "PDFBoltValidationError", "PDFBoltWebhookSignatureError", + "PageDimension", + "PaperFormat", + "PrintProduction", "RateLimitInfo", "RateLimitWindow", "RecurringCredits", + "SyncConvertParams", "SyncConversionResult", + "SyncConversionStatus", + "SyncHtmlParams", + "SyncOptions", + "SyncTemplateParams", + "SyncUrlParams", + "UrlCookie", "UsageSummary", "VERSION", + "ViewportSize", "Webhooks", + "WaitForSelector", + "WaitUntil", "webhooks", ] diff --git a/src/pdfbolt/models.py b/src/pdfbolt/models.py index 05e9f2a..e1b8cf8 100644 --- a/src/pdfbolt/models.py +++ b/src/pdfbolt/models.py @@ -1,7 +1,9 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import Any, Literal + +from .types import AsyncConversionWebhookStatus, ConversionErrorCode, SyncConversionStatus @dataclass(frozen=True) @@ -20,12 +22,12 @@ class RateLimitInfo: @dataclass(frozen=True) class SyncConversionResult: request_id: str - status: str - error_code: str | None + status: SyncConversionStatus + error_code: ConversionErrorCode | None error_message: str | None document_url: str | None expires_at: str | None - is_async: bool + is_async: Literal[False] duration: int | float | None document_size_mb: int | float | None is_custom_s3_bucket: bool | None @@ -42,12 +44,12 @@ class AsyncConversionJob: @dataclass(frozen=True) class AsyncConversionWebhookEvent: request_id: str - status: str - error_code: str | None + status: AsyncConversionWebhookStatus + error_code: ConversionErrorCode | None error_message: str | None document_url: str | None expires_at: str | None - is_async: bool + is_async: Literal[True] duration: int | float | None document_size_mb: int | float | None is_custom_s3_bucket: bool | None @@ -84,12 +86,12 @@ def sync_conversion_result_from_api( ) -> SyncConversionResult: return SyncConversionResult( request_id=_required_str_field(data, "requestId", "sync conversion"), - status=_required_str_field(data, "status", "sync conversion"), + status=_required_sync_status_field(data, "status", "sync conversion"), error_code=_required_nullable_str_field(data, "errorCode", "sync conversion"), error_message=_required_nullable_str_field(data, "errorMessage", "sync conversion"), document_url=_required_nullable_str_field(data, "documentUrl", "sync conversion"), expires_at=_required_nullable_str_field(data, "expiresAt", "sync conversion"), - is_async=_required_bool_field(data, "isAsync", "sync conversion"), + is_async=_required_false_field(data, "isAsync", "sync conversion"), duration=_required_nullable_number_field(data, "duration", "sync conversion"), document_size_mb=_required_nullable_number_field(data, "documentSizeMb", "sync conversion"), is_custom_s3_bucket=_required_nullable_bool_field( @@ -114,12 +116,12 @@ def async_conversion_job_from_api( def webhook_event_from_api(data: dict[str, Any]) -> AsyncConversionWebhookEvent: return AsyncConversionWebhookEvent( request_id=_required_str_field(data, "requestId", "webhook payload"), - status=_required_str_field(data, "status", "webhook payload"), + status=_required_webhook_status_field(data, "status", "webhook payload"), error_code=_required_nullable_str_field(data, "errorCode", "webhook payload"), error_message=_required_nullable_str_field(data, "errorMessage", "webhook payload"), document_url=_required_nullable_str_field(data, "documentUrl", "webhook payload"), expires_at=_required_nullable_str_field(data, "expiresAt", "webhook payload"), - is_async=_required_bool_field(data, "isAsync", "webhook payload"), + is_async=_required_true_field(data, "isAsync", "webhook payload"), duration=_required_nullable_number_field(data, "duration", "webhook payload"), document_size_mb=_required_nullable_number_field(data, "documentSizeMb", "webhook payload"), is_custom_s3_bucket=_required_nullable_bool_field( @@ -214,6 +216,48 @@ def _required_bool_field(data: dict[str, Any], key: str, context: str) -> bool: return value +def _required_false_field(data: dict[str, Any], key: str, context: str) -> Literal[False]: + value = _required_bool_field(data, key, context) + if value is not False: + raise ValueError(f"Malformed PDFBolt {context} response: {key} must be false.") + + return False + + +def _required_true_field(data: dict[str, Any], key: str, context: str) -> Literal[True]: + value = _required_bool_field(data, key, context) + if value is not True: + raise ValueError(f"Malformed PDFBolt {context} response: {key} must be true.") + + return True + + +def _required_sync_status_field( + data: dict[str, Any], + key: str, + context: str, +) -> SyncConversionStatus: + value = _required_str_field(data, key, context) + if value != "SUCCESS": + raise ValueError(f"Malformed PDFBolt {context} response: {key} must be SUCCESS.") + + return "SUCCESS" + + +def _required_webhook_status_field( + data: dict[str, Any], + key: str, + context: str, +) -> AsyncConversionWebhookStatus: + value = _required_str_field(data, key, context) + if value == "SUCCESS": + return "SUCCESS" + if value == "FAILURE": + return "FAILURE" + + raise ValueError(f"Malformed PDFBolt {context} response: {key} must be SUCCESS or FAILURE.") + + def _required_nullable_str_field( data: dict[str, Any], key: str, diff --git a/src/pdfbolt/resources/async_conversions.py b/src/pdfbolt/resources/async_conversions.py index 670457b..d700e08 100644 --- a/src/pdfbolt/resources/async_conversions.py +++ b/src/pdfbolt/resources/async_conversions.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any +from typing import Any, Unpack, cast from .._utils import ( encode_base64, @@ -16,14 +16,15 @@ from ..http import PDFBoltHttpClient from ..models import AsyncConversionJob, async_conversion_job_from_api from ..rate_limit import read_rate_limit_info +from ..types import AsyncConvertParams, AsyncOptions class AsyncConversionsResource: def __init__(self, http: PDFBoltHttpClient) -> None: self._http = http - def convert(self, params: Mapping[str, Any]) -> AsyncConversionJob: - body, request_timeout = split_request_options(params) + def convert(self, params: AsyncConvertParams) -> AsyncConversionJob: + body, request_timeout = split_request_options(cast(Mapping[str, Any], params)) data, headers = self._http.request_json( "POST", "/v1/async", @@ -37,23 +38,38 @@ def convert(self, params: Mapping[str, Any]) -> AsyncConversionJob: "PDFBolt API returned a malformed async conversion response." ) from error - def from_url(self, *, url: str, webhook: str, **params: Any) -> AsyncConversionJob: - body = merge_params({"url": url, "webhook": webhook}, params) + def from_url( + self, + *, + url: str, + webhook: str, + **params: Unpack[AsyncOptions], + ) -> AsyncConversionJob: + body = merge_params({"url": url, "webhook": webhook}, cast(Mapping[str, Any], params)) require_string_field(body, "url", "async_conversions.from_url") require_string_field(body, "webhook", "async_conversions.from_url") - return self.convert(encode_header_footer_templates(body)) + return self.convert(cast(AsyncConvertParams, encode_header_footer_templates(body))) - def from_html(self, *, html: str, webhook: str, **params: Any) -> AsyncConversionJob: - body = merge_params({"html": html, "webhook": webhook}, params) + def from_html( + self, + *, + html: str, + webhook: str, + **params: Unpack[AsyncOptions], + ) -> AsyncConversionJob: + body = merge_params({"html": html, "webhook": webhook}, cast(Mapping[str, Any], params)) html_value = require_string_field(body, "html", "async_conversions.from_html") require_string_field(body, "webhook", "async_conversions.from_html") return self.convert( - encode_header_footer_templates( - { - **body, - "html": encode_base64(html_value), - } - ) + cast( + AsyncConvertParams, + encode_header_footer_templates( + { + **body, + "html": encode_base64(html_value), + } + ), + ), ) def from_template( @@ -62,7 +78,7 @@ def from_template( template_id: str, template_data: Mapping[str, Any], webhook: str, - **params: Any, + **params: Unpack[AsyncOptions], ) -> AsyncConversionJob: body = merge_params( { @@ -70,9 +86,9 @@ def from_template( "template_data": template_data, "webhook": webhook, }, - params, + cast(Mapping[str, Any], params), ) require_string_field(body, "template_id", "async_conversions.from_template") require_object_field(body, "template_data", "async_conversions.from_template") require_string_field(body, "webhook", "async_conversions.from_template") - return self.convert(encode_header_footer_templates(body)) + return self.convert(cast(AsyncConvertParams, encode_header_footer_templates(body))) diff --git a/src/pdfbolt/resources/direct.py b/src/pdfbolt/resources/direct.py index 4ec720a..a62070d 100644 --- a/src/pdfbolt/resources/direct.py +++ b/src/pdfbolt/resources/direct.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any +from typing import Any, Unpack, cast from .._utils import ( encode_base64, @@ -14,14 +14,15 @@ ) from ..direct_result import DirectConversionResult from ..http import PDFBoltHttpClient +from ..types import DirectConvertParams, DirectOptions class DirectResource: def __init__(self, http: PDFBoltHttpClient) -> None: self._http = http - def convert(self, params: Mapping[str, Any]) -> DirectConversionResult: - body, request_timeout = split_request_options(params) + def convert(self, params: DirectConvertParams) -> DirectConversionResult: + body, request_timeout = split_request_options(cast(Mapping[str, Any], params)) response_body, headers = self._http.request_binary( "POST", "/v1/direct", @@ -30,21 +31,24 @@ def convert(self, params: Mapping[str, Any]) -> DirectConversionResult: ) return DirectConversionResult(body=response_body, headers=headers) - def from_url(self, *, url: str, **params: Any) -> DirectConversionResult: - body = merge_params({"url": url}, params) + def from_url(self, *, url: str, **params: Unpack[DirectOptions]) -> DirectConversionResult: + body = merge_params({"url": url}, cast(Mapping[str, Any], params)) require_string_field(body, "url", "direct.from_url") - return self.convert(encode_header_footer_templates(body)) + return self.convert(cast(DirectConvertParams, encode_header_footer_templates(body))) - def from_html(self, *, html: str, **params: Any) -> DirectConversionResult: - body = merge_params({"html": html}, params) + def from_html(self, *, html: str, **params: Unpack[DirectOptions]) -> DirectConversionResult: + body = merge_params({"html": html}, cast(Mapping[str, Any], params)) html_value = require_string_field(body, "html", "direct.from_html") return self.convert( - encode_header_footer_templates( - { - **body, - "html": encode_base64(html_value), - } - ) + cast( + DirectConvertParams, + encode_header_footer_templates( + { + **body, + "html": encode_base64(html_value), + } + ), + ), ) def from_template( @@ -52,15 +56,15 @@ def from_template( *, template_id: str, template_data: Mapping[str, Any], - **params: Any, + **params: Unpack[DirectOptions], ) -> DirectConversionResult: body = merge_params( { "template_id": template_id, "template_data": template_data, }, - params, + cast(Mapping[str, Any], params), ) require_string_field(body, "template_id", "direct.from_template") require_object_field(body, "template_data", "direct.from_template") - return self.convert(encode_header_footer_templates(body)) + return self.convert(cast(DirectConvertParams, encode_header_footer_templates(body))) diff --git a/src/pdfbolt/resources/sync.py b/src/pdfbolt/resources/sync.py index e91e32e..ed7e6f8 100644 --- a/src/pdfbolt/resources/sync.py +++ b/src/pdfbolt/resources/sync.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any +from typing import Any, Unpack, cast from .._utils import ( encode_base64, @@ -16,14 +16,15 @@ from ..http import PDFBoltHttpClient from ..models import SyncConversionResult, sync_conversion_result_from_api from ..rate_limit import read_number_header, read_rate_limit_info +from ..types import SyncConvertParams, SyncOptions class SyncResource: def __init__(self, http: PDFBoltHttpClient) -> None: self._http = http - def convert(self, params: Mapping[str, Any]) -> SyncConversionResult: - body, request_timeout = split_request_options(params) + def convert(self, params: SyncConvertParams) -> SyncConversionResult: + body, request_timeout = split_request_options(cast(Mapping[str, Any], params)) data, headers = self._http.request_json( "POST", "/v1/sync", @@ -41,21 +42,24 @@ def convert(self, params: Mapping[str, Any]) -> SyncConversionResult: "PDFBolt API returned a malformed sync conversion response." ) from error - def from_url(self, *, url: str, **params: Any) -> SyncConversionResult: - body = merge_params({"url": url}, params) + def from_url(self, *, url: str, **params: Unpack[SyncOptions]) -> SyncConversionResult: + body = merge_params({"url": url}, cast(Mapping[str, Any], params)) require_string_field(body, "url", "sync.from_url") - return self.convert(encode_header_footer_templates(body)) + return self.convert(cast(SyncConvertParams, encode_header_footer_templates(body))) - def from_html(self, *, html: str, **params: Any) -> SyncConversionResult: - body = merge_params({"html": html}, params) + def from_html(self, *, html: str, **params: Unpack[SyncOptions]) -> SyncConversionResult: + body = merge_params({"html": html}, cast(Mapping[str, Any], params)) html_value = require_string_field(body, "html", "sync.from_html") return self.convert( - encode_header_footer_templates( - { - **body, - "html": encode_base64(html_value), - } - ) + cast( + SyncConvertParams, + encode_header_footer_templates( + { + **body, + "html": encode_base64(html_value), + } + ), + ), ) def from_template( @@ -63,15 +67,15 @@ def from_template( *, template_id: str, template_data: Mapping[str, Any], - **params: Any, + **params: Unpack[SyncOptions], ) -> SyncConversionResult: body = merge_params( { "template_id": template_id, "template_data": template_data, }, - params, + cast(Mapping[str, Any], params), ) require_string_field(body, "template_id", "sync.from_template") require_object_field(body, "template_data", "sync.from_template") - return self.convert(encode_header_footer_templates(body)) + return self.convert(cast(SyncConvertParams, encode_header_footer_templates(body))) diff --git a/src/pdfbolt/types.py b/src/pdfbolt/types.py new file mode 100644 index 0000000..fd5f3b8 --- /dev/null +++ b/src/pdfbolt/types.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Literal, Required, TypeAlias, TypedDict + +EmulateMediaType = Literal["screen", "print"] +WaitUntil = Literal["load", "domcontentloaded", "networkidle", "commit"] +PaperFormat = Literal[ + "Letter", + "Legal", + "Tabloid", + "Ledger", + "A0", + "A1", + "A2", + "A3", + "A4", + "A5", + "A6", +] +ContentDisposition = Literal["inline", "attachment"] +CompressionLevel = Literal["lossless", "low", "medium", "high"] +SyncConversionStatus = Literal["SUCCESS"] +AsyncConversionWebhookStatus = Literal["SUCCESS", "FAILURE"] +ConversionErrorCode: TypeAlias = ( + Literal[ + "BAD_REQUEST", + "UNAUTHORIZED", + "FORBIDDEN", + "NOT_FOUND", + "PAYLOAD_TOO_LARGE", + "PDF_SIZE_TOO_LARGE", + "TEMPLATE_EVAL_ERROR", + "TOO_MANY_REQUESTS", + "UNPROCESSABLE_ENTITY", + "SERVICE_UNAVAILABLE", + "GATEWAY_TIMEOUT", + "CUSTOM_S3_UPLOAD_ERROR", + "TARGET_CLOSED", + "NO_BROWSER_CONTEXT", + "URL_NOT_RESOLVED", + "PDF_PRINTING_FAILED", + "CONVERSION_TIMEOUT", + "UNEXPECTED_ERROR", + "INVALID_CREDENTIALS", + "HTTP_RESPONSE_FAILURE", + "CLIENT_DISCONNECTED", + ] + | str +) +PageDimension = int | float | str +MarginDimension = int | float | str + + +class HttpCredentials(TypedDict): + username: str + password: str + + +class ViewportSize(TypedDict): + width: int + height: int + + +class CookieOptions(TypedDict, total=False): + expires: int | float | None + http_only: bool | None + secure: bool | None + + +class UrlCookie(CookieOptions): + name: Required[str] + value: Required[str] + url: Required[str] + + +class DomainCookie(CookieOptions): + name: Required[str] + value: Required[str] + domain: Required[str] + path: Required[str] + + +PDFBoltCookie = UrlCookie | DomainCookie + + +class WaitForSelector(TypedDict): + selector: str + state: Literal["attached", "detached", "visible", "hidden"] + + +class Margin(TypedDict, total=False): + top: MarginDimension | None + right: MarginDimension | None + bottom: MarginDimension | None + left: MarginDimension | None + + +class PrintProduction(TypedDict, total=False): + pdf_standard: Literal["pdf-x-4", "pdf-x-1a"] | None + color_space: Literal["rgb", "cmyk"] | None + icc_profile: Literal["fogra39", "fogra51", "swop", "gracol"] | None + preserve_black: bool | None + + +class ConversionOptions(TypedDict, total=False): + emulate_media_type: EmulateMediaType | None + java_script_enabled: bool | None + http_credentials: HttpCredentials | None + viewport_size: ViewportSize | None + is_mobile: bool | None + device_scale_factor: int | float | None + extra_http_headers: Mapping[str, str] | None + apply_extra_http_headers_to_all_resources: bool | None + cookies: list[PDFBoltCookie] | None + wait_until: WaitUntil | None + wait_for_function: str | None + wait_for_selector: WaitForSelector | None + timeout: int | float | None + format: PaperFormat | None + landscape: bool | None + width: PageDimension | None + height: PageDimension | None + margin: Margin | None + page_ranges: str | None + prefer_css_page_size: bool | None + print_background: bool | None + scale: int | float | None + display_header_footer: bool | None + header_template: str | None + footer_template: str | None + tagged: bool | None + print_production: PrintProduction | None + content_disposition: ContentDisposition | None + filename: str | None + compression: CompressionLevel | None + request_timeout: int | float | None + + +class DirectOptions(ConversionOptions, total=False): + is_encoded: bool | None + + +class SyncOptions(ConversionOptions, total=False): + custom_s3_presigned_url: str | None + + +class AsyncOptions(ConversionOptions, total=False): + custom_s3_presigned_url: str | None + additional_webhook_headers: Mapping[str, str] | None + retry_delays: list[int] | None + + +class DirectUrlParams(DirectOptions): + url: Required[str] + + +class DirectHtmlParams(DirectOptions): + html: Required[str] + + +class DirectTemplateParams(DirectOptions): + template_id: Required[str] + template_data: Required[Mapping[str, Any]] + + +DirectConvertParams = DirectUrlParams | DirectHtmlParams | DirectTemplateParams + + +class SyncUrlParams(SyncOptions): + url: Required[str] + + +class SyncHtmlParams(SyncOptions): + html: Required[str] + + +class SyncTemplateParams(SyncOptions): + template_id: Required[str] + template_data: Required[Mapping[str, Any]] + + +SyncConvertParams = SyncUrlParams | SyncHtmlParams | SyncTemplateParams + + +class AsyncUrlParams(AsyncOptions): + url: Required[str] + webhook: Required[str] + + +class AsyncHtmlParams(AsyncOptions): + html: Required[str] + webhook: Required[str] + + +class AsyncTemplateParams(AsyncOptions): + template_id: Required[str] + template_data: Required[Mapping[str, Any]] + webhook: Required[str] + + +AsyncConvertParams = AsyncUrlParams | AsyncHtmlParams | AsyncTemplateParams diff --git a/tests/test_client.py b/tests/test_client.py index b2e02af..a201e4f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -170,6 +170,42 @@ def test_malformed_sync_response_maps_to_network_error() -> None: client.sync.from_url(url="https://example.com") +@pytest.mark.parametrize( + ("status_value", "is_async_value"), + [ + ("FAILURE", False), + ("SUCCESS", True), + ], +) +@responses.activate +def test_sync_response_requires_success_status_and_non_async_flag( + status_value: str, + is_async_value: bool, +) -> None: + responses.add( + responses.POST, + f"{BASE_URL}/v1/sync", + json={ + "requestId": "req_123", + "status": status_value, + "errorCode": None, + "errorMessage": None, + "documentUrl": "https://example.com/file.pdf", + "expiresAt": "2026-06-12T00:00:00Z", + "isAsync": is_async_value, + "duration": 584, + "documentSizeMb": 0.02, + "isCustomS3Bucket": False, + }, + status=200, + ) + + client = PDFBolt(api_key=API_KEY, base_url=BASE_URL) + + with pytest.raises(PDFBoltNetworkError, match="malformed sync conversion response"): + client.sync.from_url(url="https://example.com") + + @responses.activate def test_async_request_maps_options_and_keeps_request_timeout_out_of_body() -> None: responses.add( @@ -426,6 +462,21 @@ def test_webhook_schema_errors_map_to_webhook_error() -> None: webhooks.verify_and_parse(raw_body=raw_body, signature=signature, secret="secret") +@pytest.mark.parametrize( + "raw_body", + [ + b'{"requestId":"req_123","status":"PENDING","errorCode":null,"errorMessage":null,"documentUrl":"https://example.com/file.pdf","expiresAt":"2026-06-12T00:00:00Z","isAsync":true,"duration":100,"documentSizeMb":0.01,"isCustomS3Bucket":false}', + b'{"requestId":"req_123","status":"SUCCESS","errorCode":null,"errorMessage":null,"documentUrl":"https://example.com/file.pdf","expiresAt":"2026-06-12T00:00:00Z","isAsync":false,"duration":100,"documentSizeMb":0.01,"isCustomS3Bucket":false}', + ], +) +def test_webhook_payload_requires_final_status_and_async_flag(raw_body: bytes) -> None: + digest = hmac.new(b"secret", raw_body, hashlib.sha256).hexdigest() + signature = f"sha256={digest}" + + with pytest.raises(PDFBoltWebhookSignatureError, match="Invalid PDFBolt webhook payload"): + webhooks.verify_and_parse(raw_body=raw_body, signature=signature, secret="secret") + + def test_webhook_server_reads_chunked_body_with_trailers() -> None: raw_stream = BytesIO( b"5\r\nhello\r\n6;ext=value\r\n world\r\n0\r\nx-test: ok\r\nanother: value\r\n\r\n" diff --git a/tests/typecheck_usage.py b/tests/typecheck_usage.py new file mode 100644 index 0000000..4b3ea38 --- /dev/null +++ b/tests/typecheck_usage.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pdfbolt import ( + AsyncConvertParams, + DirectConversionResult, + DirectConvertParams, + PDFBolt, + SyncConversionResult, + SyncConvertParams, + UsageSummary, +) + +if TYPE_CHECKING: + client = PDFBolt(api_key="test-api-key") + + direct_params: DirectConvertParams = { + "html": "PGgxPkhlbGxvPC9oMT4=", + "format": "A4", + "margin": {"top": "12mm", "bottom": "12mm"}, + "print_background": True, + "is_encoded": True, + "request_timeout": 30, + } + + sync_params: SyncConvertParams = { + "url": "https://example.com", + "custom_s3_presigned_url": None, + "print_production": { + "pdf_standard": "pdf-x-4", + "color_space": "cmyk", + "icc_profile": "fogra39", + }, + } + + async_params: AsyncConvertParams = { + "template_id": "template-id", + "template_data": {"invoice_number": "INV-001"}, + "webhook": "https://example.com/webhook", + "additional_webhook_headers": {"x-test": "ok"}, + "retry_delays": [5, 15, 60], + } + + direct_result: DirectConversionResult = client.direct.from_url( + url="https://example.com", + format="A4", + extra_http_headers={"User-Agent": "render-browser/1.0"}, + ) + direct_convert_result: DirectConversionResult = client.direct.convert(direct_params) + sync_result: SyncConversionResult = client.sync.convert(sync_params) + async_job = client.async_conversions.convert(async_params) + usage: UsageSummary = client.usage.get(request_timeout=30) + + _ = (direct_result, direct_convert_result, sync_result, async_job, usage)