pdfbolt 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pdfbolt/__init__.py ADDED
@@ -0,0 +1,114 @@
1
+ from ._version import VERSION
2
+ from .client import PDFBolt
3
+ from .direct_result import DirectConversionResult
4
+ from .errors import (
5
+ PDFBoltAPIError,
6
+ PDFBoltConfigurationError,
7
+ PDFBoltError,
8
+ PDFBoltNetworkError,
9
+ PDFBoltValidationError,
10
+ PDFBoltWebhookSignatureError,
11
+ )
12
+ from .models import (
13
+ AsyncConversionJob,
14
+ AsyncConversionWebhookEvent,
15
+ OneTimeCredits,
16
+ RateLimitInfo,
17
+ RateLimitWindow,
18
+ RecurringCredits,
19
+ SyncConversionResult,
20
+ UsageSummary,
21
+ )
22
+ from .types import (
23
+ AsyncConversionWebhookStatus,
24
+ AsyncConvertParams,
25
+ AsyncHtmlParams,
26
+ AsyncOptions,
27
+ AsyncTemplateParams,
28
+ AsyncUrlParams,
29
+ CompressionLevel,
30
+ ContentDisposition,
31
+ ConversionErrorCode,
32
+ ConversionOptions,
33
+ DirectConvertParams,
34
+ DirectHtmlParams,
35
+ DirectOptions,
36
+ DirectTemplateParams,
37
+ DirectUrlParams,
38
+ DomainCookie,
39
+ EmulateMediaType,
40
+ HttpCredentials,
41
+ Margin,
42
+ MarginDimension,
43
+ PageDimension,
44
+ PaperFormat,
45
+ PDFBoltCookie,
46
+ PrintProduction,
47
+ SyncConversionStatus,
48
+ SyncConvertParams,
49
+ SyncHtmlParams,
50
+ SyncOptions,
51
+ SyncTemplateParams,
52
+ SyncUrlParams,
53
+ UrlCookie,
54
+ ViewportSize,
55
+ WaitForSelector,
56
+ WaitUntil,
57
+ )
58
+ from .webhooks import Webhooks, webhooks
59
+
60
+ __all__ = [
61
+ "AsyncConvertParams",
62
+ "AsyncConversionJob",
63
+ "AsyncConversionWebhookEvent",
64
+ "AsyncConversionWebhookStatus",
65
+ "AsyncHtmlParams",
66
+ "AsyncOptions",
67
+ "AsyncTemplateParams",
68
+ "AsyncUrlParams",
69
+ "CompressionLevel",
70
+ "ContentDisposition",
71
+ "ConversionErrorCode",
72
+ "ConversionOptions",
73
+ "DirectConvertParams",
74
+ "DirectConversionResult",
75
+ "DirectHtmlParams",
76
+ "DirectOptions",
77
+ "DirectTemplateParams",
78
+ "DirectUrlParams",
79
+ "DomainCookie",
80
+ "EmulateMediaType",
81
+ "HttpCredentials",
82
+ "Margin",
83
+ "MarginDimension",
84
+ "OneTimeCredits",
85
+ "PDFBolt",
86
+ "PDFBoltAPIError",
87
+ "PDFBoltCookie",
88
+ "PDFBoltConfigurationError",
89
+ "PDFBoltError",
90
+ "PDFBoltNetworkError",
91
+ "PDFBoltValidationError",
92
+ "PDFBoltWebhookSignatureError",
93
+ "PageDimension",
94
+ "PaperFormat",
95
+ "PrintProduction",
96
+ "RateLimitInfo",
97
+ "RateLimitWindow",
98
+ "RecurringCredits",
99
+ "SyncConvertParams",
100
+ "SyncConversionResult",
101
+ "SyncConversionStatus",
102
+ "SyncHtmlParams",
103
+ "SyncOptions",
104
+ "SyncTemplateParams",
105
+ "SyncUrlParams",
106
+ "UrlCookie",
107
+ "UsageSummary",
108
+ "VERSION",
109
+ "ViewportSize",
110
+ "Webhooks",
111
+ "WaitForSelector",
112
+ "WaitUntil",
113
+ "webhooks",
114
+ ]
pdfbolt/_utils.py ADDED
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from collections.abc import Mapping
5
+ from typing import Any
6
+
7
+ from .errors import PDFBoltValidationError
8
+
9
+ RAW_NESTED_KEYS = {
10
+ "additionalWebhookHeaders",
11
+ "additional_webhook_headers",
12
+ "extraHTTPHeaders",
13
+ "extra_http_headers",
14
+ "templateData",
15
+ "template_data",
16
+ }
17
+
18
+ API_KEY_OVERRIDES = {
19
+ "apply_extra_http_headers_to_all_resources": "applyExtraHTTPHeadersToAllResources",
20
+ "extra_http_headers": "extraHTTPHeaders",
21
+ }
22
+
23
+
24
+ def encode_base64(value: str) -> str:
25
+ return base64.b64encode(value.encode("utf-8")).decode("ascii")
26
+
27
+
28
+ def encode_header_footer_templates(params: dict[str, Any]) -> dict[str, Any]:
29
+ encoded = dict(params)
30
+ for key in ("header_template", "headerTemplate", "footer_template", "footerTemplate"):
31
+ value = encoded.get(key)
32
+ if isinstance(value, str):
33
+ encoded[key] = encode_base64(value)
34
+ return encoded
35
+
36
+
37
+ def split_request_options(params: Mapping[str, Any]) -> tuple[dict[str, Any], float | None]:
38
+ body: dict[str, Any] = {}
39
+ request_timeout = None
40
+
41
+ for key, value in params.items():
42
+ if key == "request_timeout":
43
+ request_timeout = _optional_timeout(value)
44
+ else:
45
+ body[key] = value
46
+
47
+ return body, request_timeout
48
+
49
+
50
+ def to_api_body(params: Mapping[str, Any]) -> dict[str, Any]:
51
+ return {_to_api_key(key): _to_api_value(value, key=key) for key, value in params.items()}
52
+
53
+
54
+ def require_string_field(params: Mapping[str, Any], field_name: str, method_name: str) -> str:
55
+ value = params.get(field_name)
56
+ if not isinstance(value, str):
57
+ raise PDFBoltValidationError(f"{field_name} is required when using {method_name}().")
58
+ return value
59
+
60
+
61
+ def require_object_field(
62
+ params: Mapping[str, Any],
63
+ field_name: str,
64
+ method_name: str,
65
+ ) -> Mapping[str, Any]:
66
+ value = params.get(field_name)
67
+ if not isinstance(value, Mapping):
68
+ raise PDFBoltValidationError(f"{field_name} must be an object when using {method_name}().")
69
+ return value
70
+
71
+
72
+ def merge_params(required: Mapping[str, Any], optional: Mapping[str, Any]) -> dict[str, Any]:
73
+ return {**required, **optional}
74
+
75
+
76
+ def _to_api_value(value: Any, *, key: str) -> Any:
77
+ if key in RAW_NESTED_KEYS:
78
+ return value
79
+
80
+ if isinstance(value, Mapping):
81
+ return to_api_body(value)
82
+
83
+ if isinstance(value, list):
84
+ return [_to_api_value(item, key="") for item in value]
85
+
86
+ return value
87
+
88
+
89
+ def _to_api_key(key: str) -> str:
90
+ if key in API_KEY_OVERRIDES:
91
+ return API_KEY_OVERRIDES[key]
92
+
93
+ if "_" not in key:
94
+ return key
95
+
96
+ parts = key.split("_")
97
+ return parts[0] + "".join(part[:1].upper() + part[1:] for part in parts[1:])
98
+
99
+
100
+ def _optional_timeout(value: Any) -> float | None:
101
+ if value is None:
102
+ return None
103
+
104
+ if isinstance(value, int | float) and not isinstance(value, bool):
105
+ return float(value)
106
+
107
+ raise PDFBoltValidationError("request_timeout must be a number of seconds.")
pdfbolt/_version.py ADDED
@@ -0,0 +1 @@
1
+ VERSION = "1.0.0"
pdfbolt/client.py ADDED
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import requests
4
+
5
+ from .http import DEFAULT_BASE_URL, DEFAULT_REQUEST_TIMEOUT_SECONDS, PDFBoltHttpClient
6
+ from .resources.async_conversions import AsyncConversionsResource
7
+ from .resources.direct import DirectResource
8
+ from .resources.sync import SyncResource
9
+ from .resources.usage import UsageResource
10
+ from .webhooks import Webhooks, webhooks
11
+
12
+
13
+ class PDFBolt:
14
+ webhooks: Webhooks = webhooks
15
+
16
+ def __init__(
17
+ self,
18
+ *,
19
+ api_key: str,
20
+ base_url: str = DEFAULT_BASE_URL,
21
+ request_timeout: float = DEFAULT_REQUEST_TIMEOUT_SECONDS,
22
+ session: requests.Session | None = None,
23
+ ) -> None:
24
+ http = PDFBoltHttpClient(
25
+ api_key=api_key,
26
+ base_url=base_url,
27
+ request_timeout=request_timeout,
28
+ session=session,
29
+ )
30
+
31
+ self.direct = DirectResource(http)
32
+ self.sync = SyncResource(http)
33
+ self.async_conversions = AsyncConversionsResource(http)
34
+ self.usage = UsageResource(http)
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import re
6
+ from collections.abc import Mapping
7
+ from pathlib import Path
8
+
9
+ from .errors import PDFBoltNetworkError
10
+ from .models import RateLimitInfo
11
+ from .rate_limit import read_header, read_number_header, read_rate_limit_info
12
+
13
+
14
+ class DirectConversionResult:
15
+ def __init__(self, *, body: bytes, headers: Mapping[str, str]) -> None:
16
+ self.headers = headers
17
+ self.content_type = _normalize_content_type(read_header(headers, "content-type"))
18
+ self.content_disposition = read_header(headers, "content-disposition")
19
+ self.conversion_cost = read_number_header(headers, "x-pdfbolt-conversion-cost")
20
+ self.filename = _parse_content_disposition_filename(self.content_disposition)
21
+ self.rate_limit: RateLimitInfo = read_rate_limit_info(headers)
22
+ self.base64: str | None
23
+
24
+ if self.content_type == "text/plain":
25
+ try:
26
+ self.base64 = body.decode("utf-8").strip()
27
+ if not self.base64:
28
+ raise ValueError("empty Base64 response")
29
+ self.buffer = base64.b64decode(self.base64, validate=True)
30
+ except (UnicodeDecodeError, binascii.Error, ValueError) as error:
31
+ raise PDFBoltNetworkError(
32
+ "PDFBolt API returned a malformed Base64 direct response."
33
+ ) from error
34
+ else:
35
+ self.base64 = None
36
+ self.buffer = body
37
+
38
+ @property
39
+ def size(self) -> int:
40
+ return len(self.buffer)
41
+
42
+ def save(self, file_path: str | Path) -> None:
43
+ Path(file_path).write_bytes(self.buffer)
44
+
45
+
46
+ def _normalize_content_type(value: str | None) -> str:
47
+ if not value:
48
+ return "application/pdf"
49
+
50
+ content_types = [
51
+ part.split(";", 1)[0].strip().lower()
52
+ for part in value.split(",")
53
+ if part.split(";", 1)[0].strip()
54
+ ]
55
+ if "application/pdf" in content_types:
56
+ return "application/pdf"
57
+ if "text/plain" in content_types:
58
+ return "text/plain"
59
+
60
+ return content_types[0] if content_types else "application/pdf"
61
+
62
+
63
+ def _parse_content_disposition_filename(content_disposition: str | None) -> str | None:
64
+ if not content_disposition:
65
+ return None
66
+
67
+ match = re.search(r'(?:^|;)\s*filename="?([^";]+)"?', content_disposition, re.IGNORECASE)
68
+ return match.group(1) if match else None
pdfbolt/errors.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+
5
+ from .models import RateLimitInfo
6
+ from .rate_limit import read_rate_limit_info
7
+
8
+
9
+ class PDFBoltError(Exception):
10
+ """Base class for all PDFBolt SDK errors."""
11
+
12
+
13
+ class PDFBoltAPIError(PDFBoltError):
14
+ """Raised when the PDFBolt API returns an HTTP error response."""
15
+
16
+ def __init__(
17
+ self,
18
+ *,
19
+ message: str,
20
+ status_code: int,
21
+ timestamp: str | None = None,
22
+ error_code: str | None = None,
23
+ error_message: str | None = None,
24
+ headers: Mapping[str, str] | None = None,
25
+ raw_body: str | None = None,
26
+ ) -> None:
27
+ super().__init__(message)
28
+ self.status_code = status_code
29
+ self.timestamp = timestamp
30
+ self.error_code = error_code
31
+ self.error_message = error_message
32
+ self.rate_limit: RateLimitInfo = read_rate_limit_info(headers)
33
+ self.headers = headers
34
+ self.raw_body = raw_body
35
+
36
+
37
+ class PDFBoltNetworkError(PDFBoltError):
38
+ """Raised when the SDK did not receive a usable HTTP response."""
39
+
40
+
41
+ class PDFBoltWebhookSignatureError(PDFBoltError):
42
+ """Raised when webhook signature verification fails."""
43
+
44
+
45
+ class PDFBoltValidationError(PDFBoltError):
46
+ """Raised before a request is sent when high-level helper parameters are invalid."""
47
+
48
+
49
+ class PDFBoltConfigurationError(PDFBoltError):
50
+ """Raised when the SDK client is missing required configuration."""
pdfbolt/http.py ADDED
@@ -0,0 +1,156 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Mapping
5
+ from typing import Any
6
+
7
+ import requests
8
+
9
+ from ._version import VERSION
10
+ from .errors import PDFBoltAPIError, PDFBoltConfigurationError, PDFBoltNetworkError
11
+
12
+ DEFAULT_BASE_URL = "https://api.pdfbolt.com"
13
+ DEFAULT_REQUEST_TIMEOUT_SECONDS = 120.0
14
+
15
+
16
+ class PDFBoltHttpClient:
17
+ def __init__(
18
+ self,
19
+ *,
20
+ api_key: str,
21
+ base_url: str = DEFAULT_BASE_URL,
22
+ request_timeout: float = DEFAULT_REQUEST_TIMEOUT_SECONDS,
23
+ session: requests.Session | None = None,
24
+ ) -> None:
25
+ if not api_key:
26
+ raise PDFBoltConfigurationError("PDFBolt API key is required.")
27
+
28
+ self._api_key = api_key
29
+ self._base_url = base_url.rstrip("/")
30
+ self._request_timeout = request_timeout
31
+ self._session = session or requests.Session()
32
+
33
+ def request_json(
34
+ self,
35
+ method: str,
36
+ path: str,
37
+ *,
38
+ body: Mapping[str, Any] | None = None,
39
+ request_timeout: float | None = None,
40
+ ) -> tuple[dict[str, Any], Mapping[str, str]]:
41
+ response = self._request(
42
+ method,
43
+ path,
44
+ body=body,
45
+ request_timeout=request_timeout,
46
+ accept="application/json",
47
+ )
48
+ try:
49
+ data = response.json()
50
+ except ValueError as error:
51
+ raise PDFBoltNetworkError(
52
+ f"PDFBolt API returned a malformed JSON response for {path} "
53
+ f"(status {response.status_code})."
54
+ ) from error
55
+
56
+ if not isinstance(data, dict):
57
+ raise PDFBoltNetworkError(
58
+ f"PDFBolt API returned a malformed JSON response for {path} "
59
+ f"(status {response.status_code})."
60
+ )
61
+
62
+ return data, response.headers
63
+
64
+ def request_binary(
65
+ self,
66
+ method: str,
67
+ path: str,
68
+ *,
69
+ body: Mapping[str, Any] | None = None,
70
+ request_timeout: float | None = None,
71
+ ) -> tuple[bytes, Mapping[str, str]]:
72
+ response = self._request(
73
+ method,
74
+ path,
75
+ body=body,
76
+ request_timeout=request_timeout,
77
+ accept="application/pdf, text/plain, application/json",
78
+ )
79
+ return response.content, response.headers
80
+
81
+ def _request(
82
+ self,
83
+ method: str,
84
+ path: str,
85
+ *,
86
+ body: Mapping[str, Any] | None,
87
+ request_timeout: float | None,
88
+ accept: str,
89
+ ) -> requests.Response:
90
+ timeout = self._request_timeout if request_timeout is None else request_timeout
91
+ headers = self._headers(accept=accept, has_body=body is not None)
92
+
93
+ try:
94
+ response = self._session.request(
95
+ method,
96
+ f"{self._base_url}{path}",
97
+ headers=headers,
98
+ json=body,
99
+ timeout=timeout,
100
+ )
101
+ except requests.Timeout as error:
102
+ raise PDFBoltNetworkError(f"PDFBolt request timed out after {timeout}s.") from error
103
+ except Exception as error:
104
+ raise PDFBoltNetworkError(
105
+ "PDFBolt request failed before receiving a response."
106
+ ) from error
107
+
108
+ if response.status_code >= 400:
109
+ raise _create_api_error(response)
110
+
111
+ return response
112
+
113
+ def _headers(self, *, accept: str, has_body: bool) -> dict[str, str]:
114
+ headers = {
115
+ "Accept": accept,
116
+ "API-KEY": self._api_key,
117
+ "User-Agent": f"pdfbolt-python/{VERSION}",
118
+ }
119
+ if has_body:
120
+ headers["Content-Type"] = "application/json"
121
+ return headers
122
+
123
+
124
+ def _create_api_error(response: requests.Response) -> PDFBoltAPIError:
125
+ raw_body = response.text
126
+ parsed = _parse_json_object(raw_body)
127
+ error_message = _read_string(parsed, "errorMessage")
128
+ message = (
129
+ error_message
130
+ or response.reason
131
+ or (f"PDFBolt API request failed with status {response.status_code}.")
132
+ )
133
+
134
+ return PDFBoltAPIError(
135
+ message=message,
136
+ status_code=response.status_code,
137
+ timestamp=_read_string(parsed, "timestamp"),
138
+ error_code=_read_string(parsed, "errorCode"),
139
+ error_message=error_message,
140
+ headers=response.headers,
141
+ raw_body=raw_body,
142
+ )
143
+
144
+
145
+ def _parse_json_object(raw_body: str) -> dict[str, Any] | None:
146
+ try:
147
+ parsed = json.loads(raw_body)
148
+ except json.JSONDecodeError:
149
+ return None
150
+
151
+ return parsed if isinstance(parsed, dict) else None
152
+
153
+
154
+ def _read_string(source: dict[str, Any] | None, key: str) -> str | None:
155
+ value = source.get(key) if source else None
156
+ return value if isinstance(value, str) else None