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 +83 -0
- twilldocs/client.py +121 -0
- twilldocs/errors.py +97 -0
- twilldocs/py.typed +0 -0
- twilldocs/resources/__init__.py +5 -0
- twilldocs/resources/api_keys.py +27 -0
- twilldocs/resources/brand.py +48 -0
- twilldocs/resources/documents.py +121 -0
- twilldocs/types.py +220 -0
- twilldocs-0.1.0.dist-info/METADATA +174 -0
- twilldocs-0.1.0.dist-info/RECORD +13 -0
- twilldocs-0.1.0.dist-info/WHEEL +4 -0
- twilldocs-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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,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.
|