twilldocs 0.1.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.
twilldocs/__init__.py ADDED
@@ -0,0 +1,83 @@
1
+ """Official Python SDK for the Twill Docs document generation API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .client import TwillDocs
6
+ from .errors import (
7
+ TwillAuthenticationError,
8
+ TwillConflictError,
9
+ TwillConnectionError,
10
+ TwillError,
11
+ TwillNotFoundError,
12
+ TwillPermissionError,
13
+ TwillRateLimitError,
14
+ TwillServerError,
15
+ TwillTimeoutError,
16
+ TwillValidationError,
17
+ )
18
+ from .types import (
19
+ ApiKey,
20
+ Brand,
21
+ BrandTheme,
22
+ DeliveryItem,
23
+ DeliveryNoteInput,
24
+ Document,
25
+ Employee,
26
+ Health,
27
+ InvoiceInput,
28
+ IssuedBy,
29
+ LineItem,
30
+ NdaInput,
31
+ OfferLetterInput,
32
+ Party,
33
+ PayslipInput,
34
+ PayslipLine,
35
+ PurchaseOrderInput,
36
+ QuoteInput,
37
+ ReceiptInput,
38
+ SellerParty,
39
+ ServiceAgreementInput,
40
+ TemplateName,
41
+ )
42
+
43
+ __version__ = "0.1.0"
44
+
45
+ __all__ = [
46
+ "TwillDocs",
47
+ # errors
48
+ "TwillError",
49
+ "TwillConnectionError",
50
+ "TwillTimeoutError",
51
+ "TwillAuthenticationError",
52
+ "TwillPermissionError",
53
+ "TwillNotFoundError",
54
+ "TwillConflictError",
55
+ "TwillServerError",
56
+ "TwillValidationError",
57
+ "TwillRateLimitError",
58
+ # shared types
59
+ "Party",
60
+ "SellerParty",
61
+ "LineItem",
62
+ "DeliveryItem",
63
+ "PayslipLine",
64
+ "Employee",
65
+ "IssuedBy",
66
+ "TemplateName",
67
+ # template inputs
68
+ "InvoiceInput",
69
+ "QuoteInput",
70
+ "ReceiptInput",
71
+ "PurchaseOrderInput",
72
+ "DeliveryNoteInput",
73
+ "PayslipInput",
74
+ "OfferLetterInput",
75
+ "NdaInput",
76
+ "ServiceAgreementInput",
77
+ # responses
78
+ "Document",
79
+ "ApiKey",
80
+ "Brand",
81
+ "BrandTheme",
82
+ "Health",
83
+ ]
twilldocs/client.py ADDED
@@ -0,0 +1,121 @@
1
+ """The Twill Docs API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional
6
+
7
+ import httpx
8
+
9
+ from .errors import TwillConnectionError, TwillError, TwillTimeoutError, error_from_response
10
+ from .resources.api_keys import ApiKeys
11
+ from .resources.brand import BrandResource
12
+ from .resources.documents import Documents
13
+ from .types import Health
14
+
15
+ DEFAULT_BASE_URL = "https://api.twilldocs.com"
16
+
17
+
18
+ class TwillDocs:
19
+ """
20
+ Client for the Twill Docs API.
21
+
22
+ >>> from twilldocs import TwillDocs
23
+ >>> twill = TwillDocs(api_key="twdc_...")
24
+ >>> doc = twill.documents.generate("invoice", {...})
25
+ >>> pdf = twill.documents.download(doc["id"])
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ api_key: str,
31
+ *,
32
+ base_url: str = DEFAULT_BASE_URL,
33
+ timeout: float = 30.0,
34
+ ) -> None:
35
+ if not api_key:
36
+ raise TwillError(
37
+ "An api_key is required to construct the TwillDocs client.",
38
+ type="authentication_error",
39
+ )
40
+ self._api_key = api_key
41
+ self._base_url = base_url.rstrip("/")
42
+ self._http = httpx.Client(timeout=timeout)
43
+
44
+ self.documents = Documents(self)
45
+ self.api_keys = ApiKeys(self)
46
+ self.brand = BrandResource(self)
47
+
48
+ # -- lifecycle ----------------------------------------------------------
49
+
50
+ def close(self) -> None:
51
+ self._http.close()
52
+
53
+ def __enter__(self) -> "TwillDocs":
54
+ return self
55
+
56
+ def __exit__(self, *_exc: Any) -> None:
57
+ self.close()
58
+
59
+ # -- public endpoints ---------------------------------------------------
60
+
61
+ def health(self) -> Health:
62
+ """Unauthenticated dependency health check."""
63
+ return self.request_json("GET", "/v1/health", auth=False)
64
+
65
+ # -- internal transport -------------------------------------------------
66
+
67
+ def request_json(self, method: str, path: str, **kwargs: Any) -> Any:
68
+ """Send a request and parse the JSON response. :meta private:"""
69
+ response = self._send(method, path, **kwargs)
70
+ if response.status_code == 204 or not response.content:
71
+ return None
72
+ return response.json()
73
+
74
+ def request_bytes(self, method: str, path: str, **kwargs: Any) -> bytes:
75
+ """Send a request and return the raw response bytes. :meta private:"""
76
+ return self._send(method, path, **kwargs).content
77
+
78
+ def _send(
79
+ self,
80
+ method: str,
81
+ path: str,
82
+ *,
83
+ json_body: Any = None,
84
+ data: Optional[Dict[str, Any]] = None,
85
+ files: Optional[Dict[str, Any]] = None,
86
+ headers: Optional[Dict[str, str]] = None,
87
+ auth: bool = True,
88
+ ) -> httpx.Response:
89
+ request_headers: Dict[str, str] = {"Accept": "application/json"}
90
+ if headers:
91
+ request_headers.update(headers)
92
+ if auth:
93
+ request_headers["Authorization"] = f"Bearer {self._api_key}"
94
+
95
+ try:
96
+ response = self._http.request(
97
+ method,
98
+ self._base_url + path,
99
+ json=json_body,
100
+ data=data,
101
+ files=files,
102
+ headers=request_headers,
103
+ )
104
+ except httpx.TimeoutException as exc:
105
+ raise TwillTimeoutError(f"Request to {path} timed out.", type="timeout_error") from exc
106
+ except httpx.HTTPError as exc:
107
+ raise TwillConnectionError(f"Request to {path} failed: {exc}", type="connection_error") from exc
108
+
109
+ if response.is_error:
110
+ request_id = response.headers.get("x-request-id")
111
+ try:
112
+ body: Any = response.json()
113
+ except ValueError:
114
+ body = response.text
115
+ retry_after: Optional[int] = None
116
+ if response.status_code == 429:
117
+ header_value = response.headers.get("retry-after", "")
118
+ retry_after = int(header_value) if header_value.isdigit() else None
119
+ raise error_from_response(response.status_code, body, request_id, retry_after)
120
+
121
+ return response
twilldocs/errors.py ADDED
@@ -0,0 +1,97 @@
1
+ """Exception hierarchy for the Twill Docs SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Optional
6
+
7
+
8
+ class TwillError(Exception):
9
+ """Base class for every error raised by the SDK. Catch this to catch them all."""
10
+
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ *,
15
+ status: Optional[int] = None,
16
+ type: str = "api_error",
17
+ request_id: Optional[str] = None,
18
+ body: Any = None,
19
+ ) -> None:
20
+ super().__init__(message)
21
+ self.message = message
22
+ self.status = status
23
+ self.type = type
24
+ self.request_id = request_id
25
+ self.body = body
26
+
27
+
28
+ class TwillConnectionError(TwillError):
29
+ """The request never reached the API (DNS, connection refused, socket error)."""
30
+
31
+
32
+ class TwillTimeoutError(TwillError):
33
+ """The request exceeded the configured timeout."""
34
+
35
+
36
+ class TwillAuthenticationError(TwillError):
37
+ """401 — missing, invalid, or revoked API key."""
38
+
39
+
40
+ class TwillPermissionError(TwillError):
41
+ """403 — the key is valid but not allowed to perform this action."""
42
+
43
+
44
+ class TwillNotFoundError(TwillError):
45
+ """404 — the resource doesn't exist (or isn't visible to this tenant)."""
46
+
47
+
48
+ class TwillConflictError(TwillError):
49
+ """409 — the request conflicts with the resource's current state."""
50
+
51
+
52
+ class TwillServerError(TwillError):
53
+ """5xx — something went wrong on the API's side. Usually safe to retry."""
54
+
55
+
56
+ class TwillValidationError(TwillError):
57
+ """400 / 422 — the request was malformed or failed validation."""
58
+
59
+ def __init__(self, message: str, *, errors: Optional[Dict[str, List[str]]] = None, **kwargs: Any) -> None:
60
+ super().__init__(message, **kwargs)
61
+ self.errors = errors
62
+
63
+
64
+ class TwillRateLimitError(TwillError):
65
+ """429 — rate limited. See ``retry_after`` for how long to wait."""
66
+
67
+ def __init__(self, message: str, *, retry_after: Optional[int] = None, **kwargs: Any) -> None:
68
+ super().__init__(message, **kwargs)
69
+ self.retry_after = retry_after
70
+
71
+
72
+ def error_from_response(
73
+ status: int,
74
+ body: Any,
75
+ request_id: Optional[str] = None,
76
+ retry_after: Optional[int] = None,
77
+ ) -> TwillError:
78
+ """Build the most specific error subclass for an HTTP response."""
79
+ parsed = body if isinstance(body, dict) else {}
80
+ message = parsed.get("message") or f"Request failed with status {status}"
81
+ common: Dict[str, Any] = {"status": status, "request_id": request_id, "body": body}
82
+
83
+ if status in (400, 422):
84
+ return TwillValidationError(message, type="invalid_request_error", errors=parsed.get("errors"), **common)
85
+ if status == 401:
86
+ return TwillAuthenticationError(message, type="authentication_error", **common)
87
+ if status == 403:
88
+ return TwillPermissionError(message, type="permission_error", **common)
89
+ if status == 404:
90
+ return TwillNotFoundError(message, type="not_found_error", **common)
91
+ if status == 409:
92
+ return TwillConflictError(message, type="conflict_error", **common)
93
+ if status == 429:
94
+ return TwillRateLimitError(message, type="rate_limit_error", retry_after=retry_after, **common)
95
+ if status >= 500:
96
+ return TwillServerError(message, type="api_error", **common)
97
+ return TwillError(message, type="api_error", **common)
twilldocs/py.typed ADDED
File without changes
@@ -0,0 +1,5 @@
1
+ from .api_keys import ApiKeys
2
+ from .brand import BrandResource
3
+ from .documents import Documents
4
+
5
+ __all__ = ["Documents", "ApiKeys", "BrandResource"]
@@ -0,0 +1,27 @@
1
+ """The API keys resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, List
6
+
7
+ from ..types import ApiKey
8
+
9
+ if TYPE_CHECKING:
10
+ from ..client import TwillDocs
11
+
12
+
13
+ class ApiKeys:
14
+ def __init__(self, client: "TwillDocs") -> None:
15
+ self._client = client
16
+
17
+ def list(self) -> List[ApiKey]:
18
+ """List the tenant's API keys, newest first."""
19
+ response = self._client.request_json("GET", "/v1/api-keys")
20
+ return response["data"]
21
+
22
+ def revoke(self, id: int) -> ApiKey:
23
+ """
24
+ Revoke an API key. Idempotent — revoking an already-revoked key
25
+ succeeds. The API rejects (409) revoking the tenant's only active key.
26
+ """
27
+ return self._client.request_json("DELETE", f"/v1/api-keys/{id}")
@@ -0,0 +1,48 @@
1
+ """The brand resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union
6
+
7
+ from ..types import Brand
8
+
9
+ if TYPE_CHECKING:
10
+ from ..client import TwillDocs
11
+
12
+ # A logo upload, in the shape httpx expects for a file field:
13
+ # (filename, content) or (filename, content, content_type)
14
+ LogoFile = Union[Tuple[str, bytes], Tuple[str, bytes, str]]
15
+
16
+
17
+ class BrandResource:
18
+ def __init__(self, client: "TwillDocs") -> None:
19
+ self._client = client
20
+
21
+ def retrieve(self) -> Brand:
22
+ """Fetch the tenant's current brand (theme + logo state + available themes)."""
23
+ return self._client.request_json("GET", "/v1/brand")
24
+
25
+ def update(self, *, theme: Optional[str] = None, logo: Optional[LogoFile] = None) -> Brand:
26
+ """
27
+ Update the theme and/or upload a logo (max 1 MB). Both are optional.
28
+
29
+ ``logo`` is a tuple of ``(filename, content)`` or
30
+ ``(filename, content, content_type)``, e.g.
31
+ ``("logo.png", png_bytes, "image/png")``.
32
+ """
33
+ data: Dict[str, Any] = {}
34
+ files: Dict[str, Any] = {}
35
+ if theme is not None:
36
+ data["theme"] = theme
37
+ if logo is not None:
38
+ files["logo"] = logo
39
+ return self._client.request_json(
40
+ "POST",
41
+ "/v1/brand",
42
+ data=data or None,
43
+ files=files or None,
44
+ )
45
+
46
+ def delete_logo(self) -> Brand:
47
+ """Remove the tenant's logo."""
48
+ return self._client.request_json("DELETE", "/v1/brand/logo")
@@ -0,0 +1,121 @@
1
+ """The documents resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from typing import TYPE_CHECKING, Any, Literal, Optional, overload
8
+
9
+ from ..errors import TwillError, TwillTimeoutError
10
+ from ..types import (
11
+ DeliveryNoteInput,
12
+ Document,
13
+ InvoiceInput,
14
+ NdaInput,
15
+ OfferLetterInput,
16
+ PayslipInput,
17
+ PurchaseOrderInput,
18
+ QuoteInput,
19
+ ReceiptInput,
20
+ ServiceAgreementInput,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ from ..client import TwillDocs
25
+
26
+
27
+ class Documents:
28
+ def __init__(self, client: "TwillDocs") -> None:
29
+ self._client = client
30
+
31
+ # -- create -------------------------------------------------------------
32
+ # One overload per template so the `input` type is checked against the
33
+ # template you name — passing a receipt payload to "invoice" is an error.
34
+
35
+ @overload
36
+ def create(self, template: Literal["invoice"], input: InvoiceInput, *, idempotency_key: Optional[str] = None) -> Document: ...
37
+ @overload
38
+ def create(self, template: Literal["quote"], input: QuoteInput, *, idempotency_key: Optional[str] = None) -> Document: ...
39
+ @overload
40
+ def create(self, template: Literal["receipt"], input: ReceiptInput, *, idempotency_key: Optional[str] = None) -> Document: ...
41
+ @overload
42
+ def create(self, template: Literal["purchase_order"], input: PurchaseOrderInput, *, idempotency_key: Optional[str] = None) -> Document: ...
43
+ @overload
44
+ def create(self, template: Literal["delivery_note"], input: DeliveryNoteInput, *, idempotency_key: Optional[str] = None) -> Document: ...
45
+ @overload
46
+ def create(self, template: Literal["payslip"], input: PayslipInput, *, idempotency_key: Optional[str] = None) -> Document: ...
47
+ @overload
48
+ def create(self, template: Literal["offer_letter"], input: OfferLetterInput, *, idempotency_key: Optional[str] = None) -> Document: ...
49
+ @overload
50
+ def create(self, template: Literal["nda"], input: NdaInput, *, idempotency_key: Optional[str] = None) -> Document: ...
51
+ @overload
52
+ def create(self, template: Literal["service_agreement"], input: ServiceAgreementInput, *, idempotency_key: Optional[str] = None) -> Document: ...
53
+
54
+ def create(self, template: str, input: Any, *, idempotency_key: Optional[str] = None) -> Document:
55
+ """
56
+ Create a document. Returns immediately with a ``pending`` document while
57
+ the PDF renders asynchronously.
58
+
59
+ The ``idempotency_key`` (auto-generated if omitted) makes a retried
60
+ request return the original document instead of a duplicate.
61
+ """
62
+ key = idempotency_key or uuid.uuid4().hex
63
+ return self._client.request_json(
64
+ "POST",
65
+ "/v1/documents",
66
+ json_body={"template": template, "input": input},
67
+ headers={"Idempotency-Key": key},
68
+ )
69
+
70
+ # -- retrieve / download ------------------------------------------------
71
+
72
+ def retrieve(self, id: int) -> Document:
73
+ """Fetch a document's current status."""
74
+ return self._client.request_json("GET", f"/v1/documents/{id}")
75
+
76
+ def download(self, id: int) -> bytes:
77
+ """Download the rendered PDF bytes. Only valid once the document has succeeded."""
78
+ return self._client.request_bytes("GET", f"/v1/documents/{id}/download")
79
+
80
+ def wait_until_ready(self, id: int, *, interval: float = 1.0, timeout: float = 60.0) -> Document:
81
+ """Poll until the document finishes rendering (or fails / times out)."""
82
+ deadline = time.monotonic() + timeout
83
+ while True:
84
+ doc = self.retrieve(id)
85
+ if doc["status"] == "succeeded":
86
+ return doc
87
+ if doc["status"] == "failed":
88
+ raise TwillError(
89
+ f"Render failed for document {id}: {doc.get('error') or 'unknown error'}",
90
+ type="api_error",
91
+ body=doc,
92
+ )
93
+ if time.monotonic() + interval > deadline:
94
+ raise TwillTimeoutError(f"Timed out waiting for document {id} to render.", type="timeout_error")
95
+ time.sleep(interval)
96
+
97
+ # -- generate (create + wait) -------------------------------------------
98
+
99
+ @overload
100
+ def generate(self, template: Literal["invoice"], input: InvoiceInput, *, idempotency_key: Optional[str] = None, interval: float = 1.0, timeout: float = 60.0) -> Document: ...
101
+ @overload
102
+ def generate(self, template: Literal["quote"], input: QuoteInput, *, idempotency_key: Optional[str] = None, interval: float = 1.0, timeout: float = 60.0) -> Document: ...
103
+ @overload
104
+ def generate(self, template: Literal["receipt"], input: ReceiptInput, *, idempotency_key: Optional[str] = None, interval: float = 1.0, timeout: float = 60.0) -> Document: ...
105
+ @overload
106
+ def generate(self, template: Literal["purchase_order"], input: PurchaseOrderInput, *, idempotency_key: Optional[str] = None, interval: float = 1.0, timeout: float = 60.0) -> Document: ...
107
+ @overload
108
+ def generate(self, template: Literal["delivery_note"], input: DeliveryNoteInput, *, idempotency_key: Optional[str] = None, interval: float = 1.0, timeout: float = 60.0) -> Document: ...
109
+ @overload
110
+ def generate(self, template: Literal["payslip"], input: PayslipInput, *, idempotency_key: Optional[str] = None, interval: float = 1.0, timeout: float = 60.0) -> Document: ...
111
+ @overload
112
+ def generate(self, template: Literal["offer_letter"], input: OfferLetterInput, *, idempotency_key: Optional[str] = None, interval: float = 1.0, timeout: float = 60.0) -> Document: ...
113
+ @overload
114
+ def generate(self, template: Literal["nda"], input: NdaInput, *, idempotency_key: Optional[str] = None, interval: float = 1.0, timeout: float = 60.0) -> Document: ...
115
+ @overload
116
+ def generate(self, template: Literal["service_agreement"], input: ServiceAgreementInput, *, idempotency_key: Optional[str] = None, interval: float = 1.0, timeout: float = 60.0) -> Document: ...
117
+
118
+ def generate(self, template: str, input: Any, *, idempotency_key: Optional[str] = None, interval: float = 1.0, timeout: float = 60.0) -> Document:
119
+ """Create a document and wait for it to finish rendering."""
120
+ created = self.create(template, input, idempotency_key=idempotency_key) # type: ignore[call-overload]
121
+ return self.wait_until_ready(created["id"], interval=interval, timeout=timeout)
twilldocs/types.py ADDED
@@ -0,0 +1,220 @@
1
+ """Typed shapes for the Twill Docs API — template inputs and responses.
2
+
3
+ Uses ``TypedDict`` so editors and mypy check the payloads you build, while the
4
+ values stay plain dicts at runtime.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Dict, List, Literal, Optional
10
+
11
+ from typing_extensions import NotRequired, TypedDict
12
+
13
+ # -- Shared building blocks --------------------------------------------------
14
+
15
+
16
+ class Party(TypedDict):
17
+ name: str
18
+ address: str
19
+ email: NotRequired[str]
20
+
21
+
22
+ class SellerParty(TypedDict):
23
+ name: str
24
+ address: str
25
+ email: NotRequired[str]
26
+ tax_id: NotRequired[str]
27
+
28
+
29
+ class LineItem(TypedDict):
30
+ description: str
31
+ quantity: float
32
+ unit_price: float # per-unit price, not the line total
33
+
34
+
35
+ class DeliveryItem(TypedDict):
36
+ description: str
37
+ quantity: float
38
+ unit: NotRequired[str]
39
+
40
+
41
+ class PayslipLine(TypedDict):
42
+ description: str
43
+ amount: float
44
+
45
+
46
+ class Employee(TypedDict):
47
+ name: str
48
+ id: NotRequired[str]
49
+ position: NotRequired[str]
50
+
51
+
52
+ class IssuedBy(TypedDict):
53
+ name: str
54
+ title: str
55
+
56
+
57
+ # -- Template inputs ---------------------------------------------------------
58
+
59
+
60
+ class InvoiceInput(TypedDict):
61
+ invoice_number: str
62
+ issue_date: str
63
+ due_date: str
64
+ currency: str
65
+ seller: SellerParty
66
+ buyer: Party
67
+ line_items: List[LineItem]
68
+ tax_rate: NotRequired[float]
69
+ notes: NotRequired[str]
70
+
71
+
72
+ class QuoteInput(TypedDict):
73
+ quote_number: str
74
+ issue_date: str
75
+ valid_until: str
76
+ currency: str
77
+ seller: SellerParty
78
+ buyer: Party
79
+ line_items: List[LineItem]
80
+ tax_rate: NotRequired[float]
81
+ notes: NotRequired[str]
82
+
83
+
84
+ class ReceiptInput(TypedDict):
85
+ receipt_number: str
86
+ issue_date: str
87
+ currency: str
88
+ seller: SellerParty
89
+ buyer: Party
90
+ line_items: List[LineItem]
91
+ tax_rate: NotRequired[float]
92
+ notes: NotRequired[str]
93
+ payment_method: NotRequired[str]
94
+
95
+
96
+ class PurchaseOrderInput(TypedDict):
97
+ po_number: str
98
+ issue_date: str
99
+ currency: str
100
+ seller: SellerParty
101
+ buyer: Party
102
+ line_items: List[LineItem]
103
+ needed_by: NotRequired[str]
104
+ tax_rate: NotRequired[float]
105
+ notes: NotRequired[str]
106
+
107
+
108
+ # `from` is a Python keyword, so this one uses the functional TypedDict syntax.
109
+ DeliveryNoteInput = TypedDict(
110
+ "DeliveryNoteInput",
111
+ {
112
+ "delivery_note_number": str,
113
+ "delivery_date": str,
114
+ "carrier": NotRequired[str],
115
+ "tracking_number": NotRequired[str],
116
+ "items": List[DeliveryItem],
117
+ "notes": NotRequired[str],
118
+ "from": Party,
119
+ "to": Party,
120
+ },
121
+ )
122
+
123
+
124
+ class PayslipInput(TypedDict):
125
+ payslip_number: str
126
+ pay_period_start: str
127
+ pay_period_end: str
128
+ currency: str
129
+ employee: Employee
130
+ employer: Party
131
+ earnings: List[PayslipLine]
132
+ deductions: NotRequired[List[PayslipLine]]
133
+ notes: NotRequired[str]
134
+
135
+
136
+ class OfferLetterInput(TypedDict):
137
+ candidate_name: str
138
+ position: str
139
+ start_date: str
140
+ salary_amount: float
141
+ salary_currency: str
142
+ issued_by: IssuedBy
143
+ company: Party
144
+ department: NotRequired[str]
145
+ offer_expiry_date: NotRequired[str]
146
+ reporting_manager: NotRequired[str]
147
+ salary_frequency: NotRequired[Literal["annual", "monthly"]]
148
+ benefits: NotRequired[str]
149
+
150
+
151
+ class NdaInput(TypedDict):
152
+ effective_date: str
153
+ term_months: int
154
+ governing_law: str
155
+ purpose: str
156
+ disclosing_party: Party
157
+ receiving_party: Party
158
+
159
+
160
+ class ServiceAgreementInput(TypedDict):
161
+ effective_date: str
162
+ term_description: str
163
+ services_description: str
164
+ fees_description: str
165
+ governing_law: str
166
+ provider: Party
167
+ client: Party
168
+
169
+
170
+ TemplateName = Literal[
171
+ "invoice",
172
+ "quote",
173
+ "receipt",
174
+ "purchase_order",
175
+ "delivery_note",
176
+ "payslip",
177
+ "offer_letter",
178
+ "nda",
179
+ "service_agreement",
180
+ ]
181
+
182
+ # -- Responses ---------------------------------------------------------------
183
+
184
+ DocumentStatus = Literal["pending", "processing", "succeeded", "failed"]
185
+
186
+
187
+ class Document(TypedDict):
188
+ id: int
189
+ status: DocumentStatus
190
+ error: NotRequired[Optional[str]]
191
+ created_at: NotRequired[str]
192
+
193
+
194
+ class ApiKey(TypedDict):
195
+ id: int
196
+ name: Optional[str]
197
+ last_used_at: Optional[str]
198
+ revoked_at: Optional[str]
199
+ created_at: str
200
+
201
+
202
+ class BrandTheme(TypedDict):
203
+ name: str
204
+ label: str
205
+ available: bool
206
+
207
+
208
+ class Brand(TypedDict):
209
+ theme: str
210
+ has_logo: bool
211
+ themes: List[BrandTheme]
212
+
213
+
214
+ class HealthCheck(TypedDict):
215
+ ok: bool
216
+
217
+
218
+ class Health(TypedDict):
219
+ status: Literal["ok", "degraded"]
220
+ checks: Dict[str, HealthCheck]
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.4
2
+ Name: twilldocs
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Twill Docs document generation API.
5
+ Project-URL: Homepage, https://www.twilldocs.com
6
+ Project-URL: Repository, https://github.com/twilldocs/twilldocs-python
7
+ Author: Twill Docs
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: api,documents,invoice,pdf,sdk,twilldocs
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.9
15
+ Requires-Dist: httpx>=0.24
16
+ Requires-Dist: typing-extensions>=4.0
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Twill Docs — Python SDK
20
+
21
+ The official Python SDK for [Twill Docs](https://www.twilldocs.com), the
22
+ document infrastructure API. Turn structured data into production-ready PDFs —
23
+ invoices, receipts, payslips, and more — with typed payloads.
24
+
25
+ - **Typed templates** — each document type has a `TypedDict` input, checked by mypy and your editor.
26
+ - **Typed errors** — catch `TwillRateLimitError`, `TwillValidationError`, and friends.
27
+ - Built on [httpx](https://www.python-httpx.org/).
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install twilldocs
33
+ ```
34
+
35
+ Requires Python 3.9+.
36
+
37
+ ## Quickstart
38
+
39
+ ```python
40
+ from twilldocs import TwillDocs
41
+
42
+ twill = TwillDocs(api_key="twdc_...")
43
+
44
+ # Create an invoice, wait for it to render, then download the PDF.
45
+ doc = twill.documents.generate("invoice", {
46
+ "invoice_number": "INV-1001",
47
+ "issue_date": "2026-07-22",
48
+ "due_date": "2026-08-21",
49
+ "currency": "USD",
50
+ "seller": {"name": "Northwind Studio", "address": "500 Market St", "tax_id": "US123456789"},
51
+ "buyer": {"name": "Acme Corp", "address": "1 Infinite Loop"},
52
+ "line_items": [
53
+ {"description": "Consulting", "quantity": 3, "unit_price": 1200},
54
+ {"description": "Travel expenses", "quantity": 1, "unit_price": 340},
55
+ ],
56
+ "tax_rate": 0.085,
57
+ })
58
+
59
+ pdf = twill.documents.download(doc["id"]) # bytes
60
+ open(f"invoice-{doc['id']}.pdf", "wb").write(pdf)
61
+ ```
62
+
63
+ You supply line items and the tax rate; **Twill computes the totals** and renders
64
+ the document.
65
+
66
+ ## Configuration
67
+
68
+ ```python
69
+ twill = TwillDocs(
70
+ api_key="twdc_...", # required
71
+ base_url="https://api.twilldocs.com", # default; use http://localhost:8080 for local dev
72
+ timeout=30.0, # per-request timeout in seconds
73
+ )
74
+ ```
75
+
76
+ The client holds a connection pool — reuse one instance, or use it as a context
77
+ manager:
78
+
79
+ ```python
80
+ with TwillDocs(api_key="twdc_...") as twill:
81
+ twill.documents.generate("receipt", {...})
82
+ ```
83
+
84
+ ## Documents
85
+
86
+ ```python
87
+ doc = twill.documents.create("invoice", {...}) # returns immediately, status "pending"
88
+ twill.documents.retrieve(doc["id"]) # check status
89
+ twill.documents.wait_until_ready(doc["id"]) # poll until succeeded / failed
90
+ pdf = twill.documents.download(doc["id"]) # bytes
91
+ twill.documents.generate("invoice", {...}) # create + wait, in one call
92
+ ```
93
+
94
+ Every `create`/`generate` sends an idempotency key automatically (override with
95
+ `idempotency_key=...`), so a retried request never produces a duplicate.
96
+
97
+ ### Templates
98
+
99
+ The template name you pass narrows the accepted input type. Templates and their
100
+ input `TypedDict`s:
101
+
102
+ | Template | Input type |
103
+ | -------- | ---------- |
104
+ | `invoice` | `InvoiceInput` |
105
+ | `quote` | `QuoteInput` |
106
+ | `receipt` | `ReceiptInput` |
107
+ | `purchase_order` | `PurchaseOrderInput` |
108
+ | `delivery_note` | `DeliveryNoteInput` |
109
+ | `payslip` | `PayslipInput` |
110
+ | `offer_letter` | `OfferLetterInput` |
111
+ | `nda` | `NdaInput` |
112
+ | `service_agreement` | `ServiceAgreementInput` |
113
+
114
+ All input types are importable for building payloads with full typing:
115
+
116
+ ```python
117
+ from twilldocs import InvoiceInput
118
+ ```
119
+
120
+ ## API keys
121
+
122
+ ```python
123
+ keys = twill.api_keys.list()
124
+ twill.api_keys.revoke(keys[0]["id"])
125
+ ```
126
+
127
+ ## Brand
128
+
129
+ ```python
130
+ twill.brand.retrieve()
131
+ twill.brand.update(theme="modern")
132
+ twill.brand.update(logo=("logo.png", open("logo.png", "rb").read(), "image/png"))
133
+ twill.brand.delete_logo()
134
+ ```
135
+
136
+ ## Errors
137
+
138
+ Every failure raises a subclass of `TwillError`:
139
+
140
+ ```python
141
+ from twilldocs import TwillValidationError, TwillRateLimitError, TwillError
142
+ import time
143
+
144
+ try:
145
+ twill.documents.generate("invoice", data)
146
+ except TwillValidationError as e:
147
+ print("Invalid input:", e.errors) # per-field messages
148
+ except TwillRateLimitError as e:
149
+ time.sleep(e.retry_after or 1)
150
+ except TwillError as e:
151
+ print(e.status, e.type, e.message)
152
+ ```
153
+
154
+ | Class | When |
155
+ | ----- | ---- |
156
+ | `TwillValidationError` | 400 / 422 (`.errors` has field messages) |
157
+ | `TwillAuthenticationError` | 401 |
158
+ | `TwillPermissionError` | 403 |
159
+ | `TwillNotFoundError` | 404 |
160
+ | `TwillConflictError` | 409 |
161
+ | `TwillRateLimitError` | 429 (`.retry_after` seconds) |
162
+ | `TwillServerError` | 5xx |
163
+ | `TwillConnectionError` | network failure before a response |
164
+ | `TwillTimeoutError` | request exceeded `timeout` |
165
+
166
+ ## Health
167
+
168
+ ```python
169
+ twill.health() # {"status": "ok" | "degraded", "checks": {...}}
170
+ ```
171
+
172
+ ## License
173
+
174
+ [MIT](./LICENSE)
@@ -0,0 +1,13 @@
1
+ twilldocs/__init__.py,sha256=SASJ15jurYTsjXtlKbxSxE01wZ7ka9GVcJFWfUhHaR0,1593
2
+ twilldocs/client.py,sha256=RyLUgEMsHueTL0H4qFRhm42BaKeG_OzoTiGmg4xRAsY,4062
3
+ twilldocs/errors.py,sha256=4CEXCGO93yK9Lq5iOpRw2F1ejxD_vWXhpVng2W7tP5E,3312
4
+ twilldocs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ twilldocs/types.py,sha256=3qFhFBWec3yCyv8rAQG9WHcnARUt9kgTJ7d5PpCCf5k,4584
6
+ twilldocs/resources/__init__.py,sha256=6t9VX4xmxWFoyJDarcq3PHSFBGEKd94HghBz2omcHGM,149
7
+ twilldocs/resources/api_keys.py,sha256=AdBCzUhdhk9ECNlPnb7Op0NtO9QyZc-fhePKpNtcj48,772
8
+ twilldocs/resources/brand.py,sha256=tqF7QeGr2NUkgwCptjNR0HS4nKZxlXSlfK8PW3miD00,1548
9
+ twilldocs/resources/documents.py,sha256=8saLE-BISBEecGAzEVBiEzTTIJXilCIZJNTNrv5glaI,6371
10
+ twilldocs-0.1.0.dist-info/METADATA,sha256=MLbv6kkn2nM4np59EUAAwGMzDt8g015E19lMCaOSeW0,4989
11
+ twilldocs-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
12
+ twilldocs-0.1.0.dist-info/licenses/LICENSE,sha256=v9tbhsf4CNXRzKrTQoOLIO-SSSWTMes-Lyn906rwzGs,1067
13
+ twilldocs-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Twill Docs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.