tailprint 1.3.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.
- tailprint/__init__.py +21 -0
- tailprint/client.py +475 -0
- tailprint/errors.py +36 -0
- tailprint/py.typed +0 -0
- tailprint-1.3.0.dist-info/METADATA +195 -0
- tailprint-1.3.0.dist-info/RECORD +8 -0
- tailprint-1.3.0.dist-info/WHEEL +5 -0
- tailprint-1.3.0.dist-info/top_level.txt +1 -0
tailprint/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Official Tailprint Python Client SDK."""
|
|
2
|
+
|
|
3
|
+
from .client import Tailprint
|
|
4
|
+
from .errors import (
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
RateLimitError,
|
|
7
|
+
TailprintError,
|
|
8
|
+
TimeoutError,
|
|
9
|
+
ValidationError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__version__ = "1.3.0"
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Tailprint",
|
|
15
|
+
"TailprintError",
|
|
16
|
+
"AuthenticationError",
|
|
17
|
+
"RateLimitError",
|
|
18
|
+
"ValidationError",
|
|
19
|
+
"TimeoutError",
|
|
20
|
+
"__version__",
|
|
21
|
+
]
|
tailprint/client.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""Official Tailprint Python Client SDK.
|
|
2
|
+
|
|
3
|
+
High-performance Tailwind HTML to Vector PDF, EU Factur-X / ZUGFeRD E-Invoicing,
|
|
4
|
+
PDF Operations (Merge, Split, Stamp, Compress, Fill Forms) & Asynchronous Batch Engine.
|
|
5
|
+
|
|
6
|
+
Documentation: https://tailprint.com/docs
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from typing import Any, Dict, List, Optional, Union
|
|
18
|
+
|
|
19
|
+
from .errors import (
|
|
20
|
+
AuthenticationError,
|
|
21
|
+
RateLimitError,
|
|
22
|
+
TailprintError,
|
|
23
|
+
TimeoutError,
|
|
24
|
+
ValidationError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
PDFInput = Union[bytes, bytearray, str]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Tailprint:
|
|
31
|
+
"""Tailprint API Client."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
api_key: Optional[str] = None,
|
|
36
|
+
base_url: Optional[str] = None,
|
|
37
|
+
timeout: float = 30.0,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Initialize Tailprint client.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
api_key: Tailprint API Key (e.g. 'sec_live_...'). Defaults to TAILPRINT_API_KEY env var.
|
|
43
|
+
base_url: Base API URL. Defaults to 'https://tailprint.com'.
|
|
44
|
+
timeout: Request timeout in seconds. Defaults to 30.0.
|
|
45
|
+
"""
|
|
46
|
+
self.api_key = api_key or os.environ.get("TAILPRINT_API_KEY", "")
|
|
47
|
+
self.base_url = (base_url or os.environ.get("TAILPRINT_BASE_URL", "https://tailprint.com")).rstrip("/")
|
|
48
|
+
self.timeout = timeout
|
|
49
|
+
|
|
50
|
+
def _to_base64_data_uri(self, input_val: PDFInput) -> str:
|
|
51
|
+
"""Helper to convert bytes or string to data URI / URL string."""
|
|
52
|
+
if isinstance(input_val, (bytes, bytearray)):
|
|
53
|
+
b64 = base64.b64encode(input_val).decode("utf-8")
|
|
54
|
+
return f"data:application/pdf;base64,{b64}"
|
|
55
|
+
if isinstance(input_val, str):
|
|
56
|
+
if input_val.startswith("http://") or input_val.startswith("https://") or input_val.startswith("data:"):
|
|
57
|
+
return input_val
|
|
58
|
+
# Assume raw base64 string
|
|
59
|
+
return f"data:application/pdf;base64,{input_val}"
|
|
60
|
+
raise ValueError("Invalid PDF input: must be bytes or string URL/Base64")
|
|
61
|
+
|
|
62
|
+
def _request(
|
|
63
|
+
self,
|
|
64
|
+
endpoint: str,
|
|
65
|
+
method: str = "POST",
|
|
66
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
67
|
+
expect_binary: bool = False,
|
|
68
|
+
) -> Union[bytes, Dict[str, Any]]:
|
|
69
|
+
"""Executes an HTTP request to the Tailprint API."""
|
|
70
|
+
url = f"{self.base_url}{endpoint}"
|
|
71
|
+
headers = {
|
|
72
|
+
"User-Agent": "Tailprint-Python-SDK/1.3.0",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if self.api_key:
|
|
76
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
77
|
+
|
|
78
|
+
data: Optional[bytes] = None
|
|
79
|
+
if payload is not None:
|
|
80
|
+
headers["Content-Type"] = "application/json"
|
|
81
|
+
data = json.dumps(payload).encode("utf-8")
|
|
82
|
+
|
|
83
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
|
87
|
+
content_type = response.headers.get("Content-Type", "")
|
|
88
|
+
body = response.read()
|
|
89
|
+
|
|
90
|
+
if expect_binary or "application/pdf" in content_type:
|
|
91
|
+
return body
|
|
92
|
+
|
|
93
|
+
if body:
|
|
94
|
+
try:
|
|
95
|
+
return json.loads(body.decode("utf-8"))
|
|
96
|
+
except Exception:
|
|
97
|
+
return body
|
|
98
|
+
return {}
|
|
99
|
+
|
|
100
|
+
except urllib.error.HTTPError as err:
|
|
101
|
+
err_body = err.read().decode("utf-8") if err.fp else ""
|
|
102
|
+
msg = err.reason
|
|
103
|
+
details = None
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
parsed = json.loads(err_body)
|
|
107
|
+
msg = parsed.get("message", msg)
|
|
108
|
+
details = parsed.get("details")
|
|
109
|
+
except Exception:
|
|
110
|
+
if err_body:
|
|
111
|
+
msg = err_body
|
|
112
|
+
|
|
113
|
+
if err.code == 401:
|
|
114
|
+
raise AuthenticationError(msg, err.code, details) from err
|
|
115
|
+
elif err.code == 429:
|
|
116
|
+
raise RateLimitError(msg, err.code, details) from err
|
|
117
|
+
elif err.code == 400:
|
|
118
|
+
raise ValidationError(msg, err.code, details) from err
|
|
119
|
+
elif err.code in (408, 504):
|
|
120
|
+
raise TimeoutError(msg, err.code, details) from err
|
|
121
|
+
else:
|
|
122
|
+
raise TailprintError(msg, err.code, details) from err
|
|
123
|
+
|
|
124
|
+
except urllib.error.URLError as err:
|
|
125
|
+
raise TailprintError(f"Connection failed: {err.reason}", 503) from err
|
|
126
|
+
|
|
127
|
+
# =========================================================================
|
|
128
|
+
# CORE PDF RENDERING
|
|
129
|
+
# =========================================================================
|
|
130
|
+
|
|
131
|
+
def render_pdf(
|
|
132
|
+
self,
|
|
133
|
+
html: Optional[str] = None,
|
|
134
|
+
url: Optional[str] = None,
|
|
135
|
+
format: str = "A4",
|
|
136
|
+
landscape: bool = False,
|
|
137
|
+
print_background: bool = True,
|
|
138
|
+
margin: Optional[Dict[str, str]] = None,
|
|
139
|
+
display_header_footer: Optional[bool] = None,
|
|
140
|
+
header_html: Optional[str] = None,
|
|
141
|
+
footer_html: Optional[str] = None,
|
|
142
|
+
smart_pagination: bool = True,
|
|
143
|
+
password: Optional[str] = None,
|
|
144
|
+
owner_password: Optional[str] = None,
|
|
145
|
+
wait_for: Optional[str] = None,
|
|
146
|
+
delay_ms: Optional[int] = None,
|
|
147
|
+
block_popups: bool = False,
|
|
148
|
+
no_store: bool = False,
|
|
149
|
+
store: bool = False,
|
|
150
|
+
expires_in: str = "24h",
|
|
151
|
+
) -> Union[bytes, Dict[str, Any]]:
|
|
152
|
+
"""Renders HTML (with Tailwind CSS classes) or a public URL to vector PDF.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
html: HTML string with Tailwind CSS utility classes.
|
|
156
|
+
url: Direct web URL to capture as vector PDF.
|
|
157
|
+
format: Paper size ('A4', 'Letter', 'Legal').
|
|
158
|
+
landscape: Landscape orientation if True.
|
|
159
|
+
margin: Dict with 'top', 'bottom', 'left', 'right' (e.g. {'top': '10mm'}).
|
|
160
|
+
store: If True, stores PDF on Tailprint CDN and returns a hosted link dict.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
bytes (PDF binary stream) or dict (if store=True).
|
|
164
|
+
"""
|
|
165
|
+
payload: Dict[str, Any] = {
|
|
166
|
+
"format": format,
|
|
167
|
+
"landscape": landscape,
|
|
168
|
+
"print_background": print_background,
|
|
169
|
+
"smart_pagination": smart_pagination,
|
|
170
|
+
"no_store": no_store,
|
|
171
|
+
"store": store,
|
|
172
|
+
"expires_in": expires_in,
|
|
173
|
+
}
|
|
174
|
+
if html is not None:
|
|
175
|
+
payload["html"] = html
|
|
176
|
+
if url is not None:
|
|
177
|
+
payload["url"] = url
|
|
178
|
+
if margin is not None:
|
|
179
|
+
payload["margin"] = margin
|
|
180
|
+
if header_html is not None:
|
|
181
|
+
payload["header_html"] = header_html
|
|
182
|
+
if footer_html is not None:
|
|
183
|
+
payload["footer_html"] = footer_html
|
|
184
|
+
if display_header_footer is not None:
|
|
185
|
+
payload["display_header_footer"] = display_header_footer
|
|
186
|
+
if password is not None:
|
|
187
|
+
payload["password"] = password
|
|
188
|
+
if owner_password is not None:
|
|
189
|
+
payload["owner_password"] = owner_password
|
|
190
|
+
if wait_for is not None:
|
|
191
|
+
payload["wait_for"] = wait_for
|
|
192
|
+
if delay_ms is not None:
|
|
193
|
+
payload["delay_ms"] = delay_ms
|
|
194
|
+
if block_popups:
|
|
195
|
+
payload["block_popups"] = True
|
|
196
|
+
|
|
197
|
+
return self._request("/v1/render/pdf", payload=payload, expect_binary=not store)
|
|
198
|
+
|
|
199
|
+
# =========================================================================
|
|
200
|
+
# ASYNCHRONOUS & BATCH RENDERING
|
|
201
|
+
# =========================================================================
|
|
202
|
+
|
|
203
|
+
def render_pdf_async(
|
|
204
|
+
self,
|
|
205
|
+
html: Optional[str] = None,
|
|
206
|
+
url: Optional[str] = None,
|
|
207
|
+
webhook_url: Optional[str] = None,
|
|
208
|
+
webhook_headers: Optional[Dict[str, str]] = None,
|
|
209
|
+
format: str = "A4",
|
|
210
|
+
landscape: bool = False,
|
|
211
|
+
margin: Optional[Dict[str, str]] = None,
|
|
212
|
+
header_html: Optional[str] = None,
|
|
213
|
+
footer_html: Optional[str] = None,
|
|
214
|
+
smart_pagination: bool = True,
|
|
215
|
+
delay_ms: Optional[int] = None,
|
|
216
|
+
expires_in: str = "24h",
|
|
217
|
+
) -> Dict[str, Any]:
|
|
218
|
+
"""Submits a PDF rendering job asynchronously in background.
|
|
219
|
+
|
|
220
|
+
Returns immediately with a job_id and status_url.
|
|
221
|
+
"""
|
|
222
|
+
payload: Dict[str, Any] = {
|
|
223
|
+
"format": format,
|
|
224
|
+
"landscape": landscape,
|
|
225
|
+
"smart_pagination": smart_pagination,
|
|
226
|
+
"async": True,
|
|
227
|
+
"expires_in": expires_in,
|
|
228
|
+
}
|
|
229
|
+
if html is not None:
|
|
230
|
+
payload["html"] = html
|
|
231
|
+
if url is not None:
|
|
232
|
+
payload["url"] = url
|
|
233
|
+
if webhook_url is not None:
|
|
234
|
+
payload["webhook_url"] = webhook_url
|
|
235
|
+
if webhook_headers is not None:
|
|
236
|
+
payload["webhook_headers"] = webhook_headers
|
|
237
|
+
if margin is not None:
|
|
238
|
+
payload["margin"] = margin
|
|
239
|
+
if header_html is not None:
|
|
240
|
+
payload["header_html"] = header_html
|
|
241
|
+
if footer_html is not None:
|
|
242
|
+
payload["footer_html"] = footer_html
|
|
243
|
+
if delay_ms is not None:
|
|
244
|
+
payload["delay_ms"] = delay_ms
|
|
245
|
+
|
|
246
|
+
res = self._request("/v1/render/pdf", payload=payload, expect_binary=False)
|
|
247
|
+
return res if isinstance(res, dict) else {}
|
|
248
|
+
|
|
249
|
+
def render_batch(
|
|
250
|
+
self,
|
|
251
|
+
items: List[Dict[str, Any]],
|
|
252
|
+
merge_output: bool = False,
|
|
253
|
+
merged_filename: str = "merged-batch.pdf",
|
|
254
|
+
webhook_url: Optional[str] = None,
|
|
255
|
+
webhook_headers: Optional[Dict[str, str]] = None,
|
|
256
|
+
expires_in: str = "24h",
|
|
257
|
+
) -> Dict[str, Any]:
|
|
258
|
+
"""Submits up to 100 documents for concurrent batch rendering with optional merging."""
|
|
259
|
+
payload: Dict[str, Any] = {
|
|
260
|
+
"items": items,
|
|
261
|
+
"merge_output": merge_output,
|
|
262
|
+
"merged_filename": merged_filename,
|
|
263
|
+
"expires_in": expires_in,
|
|
264
|
+
}
|
|
265
|
+
if webhook_url is not None:
|
|
266
|
+
payload["webhook_url"] = webhook_url
|
|
267
|
+
if webhook_headers is not None:
|
|
268
|
+
payload["webhook_headers"] = webhook_headers
|
|
269
|
+
|
|
270
|
+
res = self._request("/v1/render/batch", payload=payload, expect_binary=False)
|
|
271
|
+
return res if isinstance(res, dict) else {}
|
|
272
|
+
|
|
273
|
+
def get_job(self, job_id: str) -> Dict[str, Any]:
|
|
274
|
+
"""Retrieves the live processing status, progress, and download links of a job."""
|
|
275
|
+
res = self._request(f"/v1/jobs/{job_id}", method="GET", expect_binary=False)
|
|
276
|
+
return res if isinstance(res, dict) else {}
|
|
277
|
+
|
|
278
|
+
def wait_for_job(
|
|
279
|
+
self,
|
|
280
|
+
job_id: str,
|
|
281
|
+
poll_interval: float = 1.0,
|
|
282
|
+
timeout: float = 60.0,
|
|
283
|
+
) -> Dict[str, Any]:
|
|
284
|
+
"""Polls a job until it reaches 'completed' or 'failed', or until timeout expires."""
|
|
285
|
+
start_time = time.time()
|
|
286
|
+
while time.time() - start_time < timeout:
|
|
287
|
+
job = self.get_job(job_id)
|
|
288
|
+
if job.get("status") in ("completed", "failed"):
|
|
289
|
+
return job
|
|
290
|
+
time.sleep(poll_interval)
|
|
291
|
+
|
|
292
|
+
raise TimeoutError(f"Job {job_id} did not finish within {timeout}s", 408)
|
|
293
|
+
|
|
294
|
+
# =========================================================================
|
|
295
|
+
# EUROPEAN E-INVOICING (Factur-X / ZUGFeRD / EN 16931)
|
|
296
|
+
# =========================================================================
|
|
297
|
+
|
|
298
|
+
def generate_einvoice(
|
|
299
|
+
self,
|
|
300
|
+
invoice_number: str,
|
|
301
|
+
seller: Dict[str, Any],
|
|
302
|
+
buyer: Dict[str, Any],
|
|
303
|
+
line_items: List[Dict[str, Any]],
|
|
304
|
+
currency: str = "EUR",
|
|
305
|
+
issue_date: Optional[str] = None,
|
|
306
|
+
due_date: Optional[str] = None,
|
|
307
|
+
payment_iban: Optional[str] = None,
|
|
308
|
+
payment_bic: Optional[str] = None,
|
|
309
|
+
notes: Optional[str] = None,
|
|
310
|
+
profile: str = "BASIC",
|
|
311
|
+
store: bool = False,
|
|
312
|
+
expires_in: str = "24h",
|
|
313
|
+
) -> Union[bytes, Dict[str, Any]]:
|
|
314
|
+
"""Generates a legally compliant European Factur-X / ZUGFeRD hybrid PDF/A-3 invoice with embedded XML."""
|
|
315
|
+
payload: Dict[str, Any] = {
|
|
316
|
+
"invoiceNumber": invoice_number,
|
|
317
|
+
"currency": currency,
|
|
318
|
+
"seller": seller,
|
|
319
|
+
"buyer": buyer,
|
|
320
|
+
"lineItems": line_items,
|
|
321
|
+
"profile": profile,
|
|
322
|
+
"store": store,
|
|
323
|
+
"expires_in": expires_in,
|
|
324
|
+
}
|
|
325
|
+
if issue_date:
|
|
326
|
+
payload["issueDate"] = issue_date
|
|
327
|
+
if due_date:
|
|
328
|
+
payload["dueDate"] = due_date
|
|
329
|
+
if payment_iban:
|
|
330
|
+
payload["paymentIban"] = payment_iban
|
|
331
|
+
if payment_bic:
|
|
332
|
+
payload["paymentBic"] = payment_bic
|
|
333
|
+
if notes:
|
|
334
|
+
payload["notes"] = notes
|
|
335
|
+
|
|
336
|
+
return self._request("/v1/render/einvoice", payload=payload, expect_binary=not store)
|
|
337
|
+
|
|
338
|
+
def validate_einvoice(
|
|
339
|
+
self,
|
|
340
|
+
pdf: Optional[PDFInput] = None,
|
|
341
|
+
xml: Optional[str] = None,
|
|
342
|
+
) -> Dict[str, Any]:
|
|
343
|
+
"""Inspects a PDF or XML invoice to verify compliance with European standard EN 16931."""
|
|
344
|
+
payload: Dict[str, Any] = {}
|
|
345
|
+
if pdf is not None:
|
|
346
|
+
payload["pdf"] = self._to_base64_data_uri(pdf)
|
|
347
|
+
if xml is not None:
|
|
348
|
+
payload["xml"] = xml
|
|
349
|
+
|
|
350
|
+
res = self._request("/v1/pdf/validate-einvoice", payload=payload, expect_binary=False)
|
|
351
|
+
return res if isinstance(res, dict) else {}
|
|
352
|
+
|
|
353
|
+
# =========================================================================
|
|
354
|
+
# PDF OPERATIONS (Merge, Split, Fill, Compress, Stamp)
|
|
355
|
+
# =========================================================================
|
|
356
|
+
|
|
357
|
+
def merge_pdfs(
|
|
358
|
+
self,
|
|
359
|
+
pdfs: List[PDFInput],
|
|
360
|
+
output_filename: str = "merged.pdf",
|
|
361
|
+
store: bool = False,
|
|
362
|
+
expires_in: str = "24h",
|
|
363
|
+
) -> Union[bytes, Dict[str, Any]]:
|
|
364
|
+
"""Merges two or more PDFs into a single unified vector PDF."""
|
|
365
|
+
payload = {
|
|
366
|
+
"pdfs": [self._to_base64_data_uri(p) for p in pdfs],
|
|
367
|
+
"output_filename": output_filename,
|
|
368
|
+
"store": store,
|
|
369
|
+
"expires_in": expires_in,
|
|
370
|
+
}
|
|
371
|
+
return self._request("/v1/pdf/merge", payload=payload, expect_binary=not store)
|
|
372
|
+
|
|
373
|
+
def split_pdf(
|
|
374
|
+
self,
|
|
375
|
+
pdf: PDFInput,
|
|
376
|
+
pages: str,
|
|
377
|
+
output_filename: str = "extracted.pdf",
|
|
378
|
+
store: bool = False,
|
|
379
|
+
expires_in: str = "24h",
|
|
380
|
+
) -> Union[bytes, Dict[str, Any]]:
|
|
381
|
+
"""Extracts specific pages or page ranges from a PDF (e.g. '1-3, 5, 8-10')."""
|
|
382
|
+
payload = {
|
|
383
|
+
"pdf": self._to_base64_data_uri(pdf),
|
|
384
|
+
"pages": pages,
|
|
385
|
+
"output_filename": output_filename,
|
|
386
|
+
"store": store,
|
|
387
|
+
"expires_in": expires_in,
|
|
388
|
+
}
|
|
389
|
+
return self._request("/v1/pdf/split", payload=payload, expect_binary=not store)
|
|
390
|
+
|
|
391
|
+
def fill_pdf_form(
|
|
392
|
+
self,
|
|
393
|
+
pdf: PDFInput,
|
|
394
|
+
fields: Dict[str, Any],
|
|
395
|
+
flatten: bool = True,
|
|
396
|
+
output_filename: str = "completed-form.pdf",
|
|
397
|
+
store: bool = False,
|
|
398
|
+
expires_in: str = "24h",
|
|
399
|
+
) -> Union[bytes, Dict[str, Any]]:
|
|
400
|
+
"""Fills interactive AcroForms with text, checkboxes, and radios."""
|
|
401
|
+
payload = {
|
|
402
|
+
"pdf": self._to_base64_data_uri(pdf),
|
|
403
|
+
"fields": fields,
|
|
404
|
+
"flatten": flatten,
|
|
405
|
+
"output_filename": output_filename,
|
|
406
|
+
"store": store,
|
|
407
|
+
"expires_in": expires_in,
|
|
408
|
+
}
|
|
409
|
+
return self._request("/v1/pdf/fill-form", payload=payload, expect_binary=not store)
|
|
410
|
+
|
|
411
|
+
def compress_pdf(
|
|
412
|
+
self,
|
|
413
|
+
pdf: PDFInput,
|
|
414
|
+
output_filename: str = "compressed.pdf",
|
|
415
|
+
store: bool = False,
|
|
416
|
+
expires_in: str = "24h",
|
|
417
|
+
) -> Union[bytes, Dict[str, Any]]:
|
|
418
|
+
"""Shrinks PDF file size via object stream compression."""
|
|
419
|
+
payload = {
|
|
420
|
+
"pdf": self._to_base64_data_uri(pdf),
|
|
421
|
+
"output_filename": output_filename,
|
|
422
|
+
"store": store,
|
|
423
|
+
"expires_in": expires_in,
|
|
424
|
+
}
|
|
425
|
+
return self._request("/v1/pdf/compress", payload=payload, expect_binary=not store)
|
|
426
|
+
|
|
427
|
+
def stamp_pdf(
|
|
428
|
+
self,
|
|
429
|
+
pdf: PDFInput,
|
|
430
|
+
watermark_text: str,
|
|
431
|
+
color: str = "red",
|
|
432
|
+
opacity: float = 0.15,
|
|
433
|
+
font_size: int = 54,
|
|
434
|
+
output_filename: Optional[str] = None,
|
|
435
|
+
store: bool = False,
|
|
436
|
+
expires_in: str = "24h",
|
|
437
|
+
) -> Union[bytes, Dict[str, Any]]:
|
|
438
|
+
"""Stamps a diagonal watermark or compliance stamp across all pages of a PDF."""
|
|
439
|
+
payload: Dict[str, Any] = {
|
|
440
|
+
"pdf": self._to_base64_data_uri(pdf),
|
|
441
|
+
"watermark_text": watermark_text,
|
|
442
|
+
"color": color,
|
|
443
|
+
"opacity": opacity,
|
|
444
|
+
"font_size": font_size,
|
|
445
|
+
"store": store,
|
|
446
|
+
"expires_in": expires_in,
|
|
447
|
+
}
|
|
448
|
+
if output_filename:
|
|
449
|
+
payload["output_filename"] = output_filename
|
|
450
|
+
return self._request("/v1/pdf/stamp", payload=payload, expect_binary=not store)
|
|
451
|
+
|
|
452
|
+
# =========================================================================
|
|
453
|
+
# UTILITIES & USAGE
|
|
454
|
+
# =========================================================================
|
|
455
|
+
|
|
456
|
+
def get_usage(self) -> Dict[str, Any]:
|
|
457
|
+
"""Returns real-time monthly quota usage and limits."""
|
|
458
|
+
res = self._request("/v1/usage", method="GET", expect_binary=False)
|
|
459
|
+
return res if isinstance(res, dict) else {}
|
|
460
|
+
|
|
461
|
+
def verify_webhook_signature(
|
|
462
|
+
self,
|
|
463
|
+
payload_bytes: bytes,
|
|
464
|
+
signature_header: str,
|
|
465
|
+
secret: Optional[str] = None,
|
|
466
|
+
) -> bool:
|
|
467
|
+
"""Verifies the HMAC-SHA256 signature on an incoming webhook request."""
|
|
468
|
+
signing_key = (secret or self.api_key).encode("utf-8")
|
|
469
|
+
expected_sig = hmac.new(signing_key, payload_bytes, hashlib.sha256).hexdigest()
|
|
470
|
+
|
|
471
|
+
header_val = signature_header.strip()
|
|
472
|
+
if header_val.startswith("sha256="):
|
|
473
|
+
header_val = header_val[7:]
|
|
474
|
+
|
|
475
|
+
return hmac.compare_digest(expected_sig, header_val)
|
tailprint/errors.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Tailprint SDK exception classes."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TailprintError(Exception):
|
|
7
|
+
"""Base exception for all Tailprint SDK errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, status_code: int = 500, details: Optional[Any] = None) -> None:
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.message = message
|
|
12
|
+
self.status_code = status_code
|
|
13
|
+
self.details = details
|
|
14
|
+
|
|
15
|
+
def __repr__(self) -> str:
|
|
16
|
+
return f"{self.__class__.__name__}(message={self.message!r}, status_code={self.status_code})"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthenticationError(TailprintError):
|
|
20
|
+
"""Raised when an API key is missing, invalid, or inactive (HTTP 401)."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RateLimitError(TailprintError):
|
|
25
|
+
"""Raised when the monthly quota is exhausted (HTTP 429)."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ValidationError(TailprintError):
|
|
30
|
+
"""Raised when request parameters fail schema validation (HTTP 400)."""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TimeoutError(TailprintError):
|
|
35
|
+
"""Raised when a job or HTTP request exceeds its timeout (HTTP 408 / 504)."""
|
|
36
|
+
pass
|
tailprint/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tailprint
|
|
3
|
+
Version: 1.3.0
|
|
4
|
+
Summary: Official Python SDK for Tailprint — Developer-First PDF & Document Engine
|
|
5
|
+
Author-email: Tailprint <reach@clewaa.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://tailprint.com
|
|
8
|
+
Project-URL: Documentation, https://tailprint.com/docs
|
|
9
|
+
Project-URL: Repository, https://github.com/coheroseb/tailprint
|
|
10
|
+
Project-URL: Issues, https://github.com/coheroseb/tailprint/issues
|
|
11
|
+
Keywords: pdf,html-to-pdf,tailwind,factur-x,zugferd,einvoice,playwright,pdf-merge,pdf-split,pdf-stamp,document-generation
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Text Processing :: Markup :: HTML
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.8
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Tailprint Python SDK
|
|
29
|
+
|
|
30
|
+
[](https://pypi.org/project/tailprint/)
|
|
31
|
+
[](https://pypi.org/project/tailprint/)
|
|
32
|
+
[](https://opensource.org/licenses/MIT)
|
|
33
|
+
|
|
34
|
+
Official Python client for **Tailprint** ([tailprint.com](https://tailprint.com)) — the developer-first programmable PDF and document engine.
|
|
35
|
+
|
|
36
|
+
Turns Tailwind CSS HTML, dynamic data, and AI prompts into pixel-perfect vector **PDFs**, European **Factur-X / ZUGFeRD e-invoices**, and provides full PDF manipulation operations (merge, split, fill forms, stamp, compress, validate).
|
|
37
|
+
|
|
38
|
+
- **Zero dependencies:** Built entirely with standard library modules (`urllib`, `json`, `base64`, `hmac`). Installs in < 1 second with 0 dependency conflicts.
|
|
39
|
+
- **Async & Batch:** Native support for asynchronous background rendering and concurrent multi-document batch runs.
|
|
40
|
+
- **Enterprise-Ready:** Cryptographic webhook signature verification and zero-retention GDPR mode.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 📦 Installation
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install tailprint
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 🚀 Quickstart
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
import os
|
|
56
|
+
from tailprint import Tailprint
|
|
57
|
+
|
|
58
|
+
# Initialize with your API key (or set TAILPRINT_API_KEY environment variable)
|
|
59
|
+
tp = Tailprint(api_key=os.environ.get("TAILPRINT_API_KEY", "sec_live_..."))
|
|
60
|
+
|
|
61
|
+
# 1. Render Tailwind HTML to Vector PDF
|
|
62
|
+
pdf_bytes = tp.render_pdf(
|
|
63
|
+
html="""
|
|
64
|
+
<div class="p-12 bg-slate-900 text-white font-sans">
|
|
65
|
+
<h1 class="text-3xl font-bold text-indigo-400">Quarterly Invoice</h1>
|
|
66
|
+
<p class="text-slate-400 mt-2">Styled dynamically with Tailwind CSS.</p>
|
|
67
|
+
</div>
|
|
68
|
+
""",
|
|
69
|
+
format="A4",
|
|
70
|
+
smart_pagination=True,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
with open("invoice.pdf", "wb") as f:
|
|
74
|
+
f.write(pdf_bytes)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## ⚡ Asynchronous Rendering & Webhooks
|
|
80
|
+
|
|
81
|
+
For heavy 100+ page reports or async pipelines:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
# Submit rendering job in background
|
|
85
|
+
job = tp.render_pdf_async(
|
|
86
|
+
html="<h1>Heavy 200-page Annual Financial Report</h1>",
|
|
87
|
+
webhook_url="https://your-api.com/webhooks/pdf",
|
|
88
|
+
webhook_headers={"X-Secret": "your_webhook_secret"},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
print("Job queued:", job["job_id"])
|
|
92
|
+
|
|
93
|
+
# Optionally poll until finished:
|
|
94
|
+
completed_job = tp.wait_for_job(job["job_id"], poll_interval=1.0, timeout=60.0)
|
|
95
|
+
print("Download URL:", completed_job["results"][0]["download_url"])
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 📦 Concurrent Batch Rendering with Auto-Merge
|
|
101
|
+
|
|
102
|
+
Render up to 100 documents concurrently and optionally merge them into a single multi-page PDF:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
batch = tp.render_batch(
|
|
106
|
+
items=[
|
|
107
|
+
{"html": "<h1>Invoice #101</h1>", "filename": "inv-101.pdf"},
|
|
108
|
+
{"html": "<h1>Invoice #102</h1>", "filename": "inv-102.pdf"},
|
|
109
|
+
{"html": "<h1>Invoice #103</h1>", "filename": "inv-103.pdf"},
|
|
110
|
+
],
|
|
111
|
+
merge_output=True,
|
|
112
|
+
merged_filename="all-invoices.pdf",
|
|
113
|
+
webhook_url="https://your-api.com/webhooks/pdf",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
completed = tp.wait_for_job(batch["job_id"])
|
|
117
|
+
print("Merged PDF link:", completed["merged_output"]["download_url"])
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## 🇪🇺 European Factur-X / ZUGFeRD E-Invoicing
|
|
123
|
+
|
|
124
|
+
### Generate Compliant Hybrid PDF/A-3
|
|
125
|
+
```python
|
|
126
|
+
invoice_pdf = tp.generate_einvoice(
|
|
127
|
+
invoice_number="INV-2026-0042",
|
|
128
|
+
currency="EUR",
|
|
129
|
+
seller={"name": "Acme Nordic ApS", "country": "DK", "vat_id": "DK12345678"},
|
|
130
|
+
buyer={"name": "Global Logistics GmbH", "country": "DE", "vat_id": "DE987654321"},
|
|
131
|
+
line_items=[
|
|
132
|
+
{"name": "Consulting Services", "quantity": 10, "unit_price": 150.0, "vat_rate": 25.0}
|
|
133
|
+
],
|
|
134
|
+
payment_iban="DK001234567890",
|
|
135
|
+
)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Validate E-Invoice Compliance (EN 16931)
|
|
139
|
+
```python
|
|
140
|
+
report = tp.validate_einvoice(pdf=invoice_pdf)
|
|
141
|
+
print("Compliant:", report["is_valid"])
|
|
142
|
+
print("Profile:", report["profile"])
|
|
143
|
+
print("Totals:", report["invoice_details"]["totals"])
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## 🛠 Complete PDF Operations
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
# 1. Merge multiple PDFs
|
|
152
|
+
merged_pdf = tp.merge_pdfs([doc1_bytes, doc2_bytes])
|
|
153
|
+
|
|
154
|
+
# 2. Extract pages (e.g. pages 1 to 3, and page 5)
|
|
155
|
+
split_pdf = tp.split_pdf(doc_bytes, pages="1-3, 5")
|
|
156
|
+
|
|
157
|
+
# 3. Fill interactive AcroForms
|
|
158
|
+
filled_pdf = tp.fill_pdf_form(doc_bytes, fields={"customer_name": "Alice", "opt_in": True})
|
|
159
|
+
|
|
160
|
+
# 4. Stamp watermark or compliance mark
|
|
161
|
+
stamped_pdf = tp.stamp_pdf(doc_bytes, watermark_text="CONFIDENTIAL", opacity=0.2)
|
|
162
|
+
|
|
163
|
+
# 5. Compress PDF file size
|
|
164
|
+
compressed_pdf = tp.compress_pdf(doc_bytes)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## 🔐 Webhook Signature Verification
|
|
170
|
+
|
|
171
|
+
Verify HMAC-SHA256 signatures in FastAPI, Flask, or Django:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
from fastapi import FastAPI, Request, HTTPException
|
|
175
|
+
|
|
176
|
+
app = FastAPI()
|
|
177
|
+
|
|
178
|
+
@app.post("/webhooks/pdf")
|
|
179
|
+
async def handle_webhook(request: Request):
|
|
180
|
+
payload = await request.body()
|
|
181
|
+
sig_header = request.headers.get("x-tailprint-signature", "")
|
|
182
|
+
|
|
183
|
+
if not tp.verify_webhook_signature(payload, sig_header):
|
|
184
|
+
raise HTTPException(status_code=401, detail="Invalid signature")
|
|
185
|
+
|
|
186
|
+
data = await request.json()
|
|
187
|
+
print("PDF rendered:", data["data"]["download_url"])
|
|
188
|
+
return {"status": "ok"}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## 📄 License
|
|
194
|
+
|
|
195
|
+
MIT © [Tailprint](https://tailprint.com)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
tailprint/__init__.py,sha256=hA7ByzWVbiWIaODta1m2ZHyKptBK0lRHsV7CLWaRpW0,390
|
|
2
|
+
tailprint/client.py,sha256=xLkgsHqC_gFgINHZV_Ni7oRBZ9109odAgsd9htMFUeg,17648
|
|
3
|
+
tailprint/errors.py,sha256=SeWqbIQiWpIaocRtvAtRXeRxWK6zX-cBs_UFaw-fzvQ,1035
|
|
4
|
+
tailprint/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
tailprint-1.3.0.dist-info/METADATA,sha256=_tK99WjDxMlb4ASoVPjpjFgWkU4_EDSNMHb1_Gl5uRo,6338
|
|
6
|
+
tailprint-1.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
7
|
+
tailprint-1.3.0.dist-info/top_level.txt,sha256=9scmpv9ZYPn3tuzljvNF82itjYgKrB_htvow786Yg58,10
|
|
8
|
+
tailprint-1.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tailprint
|