formepdf 0.7.9__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.
forme/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """Forme Python SDK — client for the Forme hosted PDF API and local rendering."""
2
+
3
+ from .client import Forme, FormeError
4
+ from .templates import (
5
+ Document,
6
+ Page,
7
+ View,
8
+ Text,
9
+ Image,
10
+ Table,
11
+ Row,
12
+ Cell,
13
+ Svg,
14
+ QrCode,
15
+ Barcode,
16
+ PageBreak,
17
+ Fixed,
18
+ Watermark,
19
+ )
20
+
21
+ __all__ = [
22
+ # API client
23
+ "Forme",
24
+ "FormeError",
25
+ # Template components
26
+ "Document",
27
+ "Page",
28
+ "View",
29
+ "Text",
30
+ "Image",
31
+ "Table",
32
+ "Row",
33
+ "Cell",
34
+ "Svg",
35
+ "QrCode",
36
+ "Barcode",
37
+ "PageBreak",
38
+ "Fixed",
39
+ "Watermark",
40
+ ]
forme/client.py ADDED
@@ -0,0 +1,151 @@
1
+ """Forme API client. Zero dependencies — uses stdlib urllib + json."""
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.request
6
+ from typing import Any, Dict, Optional, Union
7
+
8
+
9
+ class FormeError(Exception):
10
+ """Raised on non-2xx responses from the Forme API."""
11
+
12
+ def __init__(self, status: int, message: str) -> None:
13
+ self.status = status
14
+ self.message = message
15
+ super().__init__(message)
16
+
17
+
18
+ class Forme:
19
+ """Client for the Forme hosted PDF API.
20
+
21
+ Args:
22
+ api_key: API key (e.g. ``"forme_sk_..."``).
23
+ base_url: Base URL of the API. Defaults to ``https://api.formepdf.com``.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ api_key: str,
29
+ base_url: str = "https://api.formepdf.com",
30
+ ) -> None:
31
+ self._api_key = api_key
32
+ self._base_url = base_url.rstrip("/")
33
+
34
+ # ------------------------------------------------------------------
35
+ # Public API
36
+ # ------------------------------------------------------------------
37
+
38
+ def render(
39
+ self,
40
+ slug: str,
41
+ data: Any = None,
42
+ *,
43
+ s3: Optional[Dict[str, Any]] = None,
44
+ ) -> Union[bytes, Dict[str, Any]]:
45
+ """Render a template to PDF (synchronous).
46
+
47
+ Returns raw PDF bytes, or a dict with ``{"url": "..."}`` when *s3*
48
+ is provided.
49
+ """
50
+ body: Dict[str, Any] = dict(data) if data is not None else {}
51
+ if s3 is not None:
52
+ body["s3"] = s3
53
+
54
+ resp_body, content_type = self._request(
55
+ "POST",
56
+ "/v1/render/{}".format(slug),
57
+ body=body,
58
+ )
59
+
60
+ if content_type is not None and "application/json" in content_type:
61
+ return json.loads(resp_body) # type: ignore[no-any-return]
62
+ return resp_body
63
+
64
+ def render_async(
65
+ self,
66
+ slug: str,
67
+ data: Any = None,
68
+ *,
69
+ webhook_url: Optional[str] = None,
70
+ ) -> Dict[str, Any]:
71
+ """Start an asynchronous render job.
72
+
73
+ Returns ``{"jobId": "...", "status": "pending"}``.
74
+ """
75
+ body: Dict[str, Any] = dict(data) if data is not None else {}
76
+ if webhook_url is not None:
77
+ body["webhookUrl"] = webhook_url
78
+
79
+ resp_body, _ = self._request(
80
+ "POST",
81
+ "/v1/render/{}/async".format(slug),
82
+ body=body,
83
+ )
84
+ return json.loads(resp_body) # type: ignore[no-any-return]
85
+
86
+ def get_job(self, job_id: str) -> Dict[str, Any]:
87
+ """Poll the status of an async render job."""
88
+ resp_body, _ = self._request("GET", "/v1/jobs/{}".format(job_id))
89
+ return json.loads(resp_body) # type: ignore[no-any-return]
90
+
91
+ def extract(self, pdf_bytes: bytes) -> Any:
92
+ """Extract embedded data from a PDF.
93
+
94
+ Returns the embedded data dict, or ``None`` if the PDF has no
95
+ embedded data.
96
+ """
97
+ try:
98
+ resp_body, _ = self._request(
99
+ "POST",
100
+ "/v1/extract",
101
+ body=pdf_bytes,
102
+ content_type="application/pdf",
103
+ )
104
+ result = json.loads(resp_body)
105
+ return result.get("data")
106
+ except FormeError as exc:
107
+ if exc.status == 404 and "no embedded data" in exc.message.lower():
108
+ return None
109
+ raise
110
+
111
+ # ------------------------------------------------------------------
112
+ # Internal helpers
113
+ # ------------------------------------------------------------------
114
+
115
+ def _request(
116
+ self,
117
+ method: str,
118
+ path: str,
119
+ *,
120
+ body: Any = None,
121
+ content_type: str = "application/json",
122
+ ) -> tuple:
123
+ """Send an HTTP request and return ``(response_bytes, content_type)``."""
124
+ url = self._base_url + path
125
+
126
+ if isinstance(body, bytes):
127
+ data = body
128
+ elif body is not None:
129
+ data = json.dumps(body).encode("utf-8")
130
+ else:
131
+ data = None
132
+
133
+ req = urllib.request.Request(url, data=data, method=method)
134
+ req.add_header("Authorization", "Bearer {}".format(self._api_key))
135
+ if data is not None:
136
+ req.add_header("Content-Type", content_type)
137
+
138
+ try:
139
+ resp = urllib.request.urlopen(req)
140
+ resp_content_type = resp.headers.get("Content-Type")
141
+ return resp.read(), resp_content_type
142
+ except urllib.error.HTTPError as exc:
143
+ status = exc.code
144
+ message = "Request failed with status {}".format(status)
145
+ try:
146
+ err_body = json.loads(exc.read())
147
+ if isinstance(err_body, dict):
148
+ message = err_body.get("error") or err_body.get("message") or message
149
+ except (ValueError, TypeError):
150
+ pass
151
+ raise FormeError(status, message) from None
forme/py.typed ADDED
File without changes