sharptoolz 0.2.0__tar.gz

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.
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: sharptoolz
3
+ Version: 0.2.0
4
+ Summary: Official Python client for the SharpToolz API
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: httpx<1,>=0.27
9
+ Requires-Dist: websockets<16,>=12
10
+
11
+ # SharpToolz Python SDK
12
+
13
+ ```bash
14
+ # Available after the first PyPI release
15
+ pip install sharptoolz
16
+ ```
17
+
18
+ ```python
19
+ import os
20
+ from sharptoolz import SharpToolz
21
+
22
+ with SharpToolz(api_key=os.environ["SHARPTOOLZ_API_KEY"]) as sharp:
23
+ session = sharp.hosted_forms.create(
24
+ template_id=template_id,
25
+ external_user_id=current_user_id,
26
+ origin="https://app.example.com",
27
+ mode="test",
28
+ preview_mode="protected",
29
+ )
30
+
31
+ # Return session["embed_url"] to your frontend.
32
+ ```
33
+
34
+ Only browser JavaScript mounts the returned URL with `@sharp-toolz/sdk/browser`.
35
+ Python keeps the API key on your backend and can create or edit hosted sessions,
36
+ list documents, and wait for PNG/PDF renders over a job-scoped WebSocket.
@@ -0,0 +1,26 @@
1
+ # SharpToolz Python SDK
2
+
3
+ ```bash
4
+ # Available after the first PyPI release
5
+ pip install sharptoolz
6
+ ```
7
+
8
+ ```python
9
+ import os
10
+ from sharptoolz import SharpToolz
11
+
12
+ with SharpToolz(api_key=os.environ["SHARPTOOLZ_API_KEY"]) as sharp:
13
+ session = sharp.hosted_forms.create(
14
+ template_id=template_id,
15
+ external_user_id=current_user_id,
16
+ origin="https://app.example.com",
17
+ mode="test",
18
+ preview_mode="protected",
19
+ )
20
+
21
+ # Return session["embed_url"] to your frontend.
22
+ ```
23
+
24
+ Only browser JavaScript mounts the returned URL with `@sharp-toolz/sdk/browser`.
25
+ Python keeps the API key on your backend and can create or edit hosted sessions,
26
+ list documents, and wait for PNG/PDF renders over a job-scoped WebSocket.
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sharptoolz"
7
+ version = "0.2.0"
8
+ description = "Official Python client for the SharpToolz API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ dependencies = [
13
+ "httpx>=0.27,<1",
14
+ "websockets>=12,<16",
15
+ ]
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["src"]
19
+
20
+ [tool.setuptools.package-data]
21
+ sharptoolz = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from .client import SharpToolz, SharpToolzError
2
+
3
+ __all__ = ["SharpToolz", "SharpToolzError"]
4
+ __version__ = "0.1.0"
@@ -0,0 +1,229 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from typing import Any, Callable
6
+ from urllib.parse import parse_qs, urlsplit
7
+ from uuid import uuid4
8
+
9
+ import httpx
10
+ from websockets.sync.client import connect as websocket_connect_default
11
+
12
+
13
+ DEFAULT_BASE_URL = "https://api.sharptoolz.com/api/v1"
14
+ TERMINAL_RENDER_STATUSES = {"completed", "failed"}
15
+
16
+
17
+ def _normalize_cursor(cursor: str | None) -> str | None:
18
+ if not cursor:
19
+ return None
20
+ parsed = urlsplit(cursor)
21
+ if parsed.scheme and parsed.netloc:
22
+ return parse_qs(parsed.query).get("cursor", [cursor])[0]
23
+ return cursor
24
+
25
+
26
+ class SharpToolzError(RuntimeError):
27
+ def __init__(self, message: str, *, status_code: int = 0, data: Any = None):
28
+ super().__init__(message)
29
+ self.status_code = status_code
30
+ self.data = data
31
+
32
+
33
+ class TemplatesResource:
34
+ def __init__(self, client: "SharpToolz"):
35
+ self._client = client
36
+
37
+ def list(self) -> dict[str, Any]:
38
+ return self._client._request("GET", "/templates")
39
+
40
+ class HostedFormsResource:
41
+ def __init__(self, client: "SharpToolz"):
42
+ self._client = client
43
+
44
+ def create(self, **payload: Any) -> dict[str, Any]:
45
+ return self._client._request("POST", "/embed-sessions", json=payload)
46
+
47
+ def edit(self, document_id: str, **payload: Any) -> dict[str, Any]:
48
+ return self._client._request("POST", f"/documents/{document_id}/session", json=payload)
49
+
50
+ def revoke(self, session_id: str) -> None:
51
+ self._client._request("DELETE", f"/embed-sessions/{session_id}")
52
+
53
+
54
+ class DocumentsResource:
55
+ def __init__(self, client: "SharpToolz"):
56
+ self._client = client
57
+
58
+ def get(self, document_id: str) -> dict[str, Any]:
59
+ return self._client._request("GET", f"/documents/{document_id}")
60
+
61
+ def list(self, *, external_user_id: str | None = None, cursor: str | None = None) -> dict[str, Any]:
62
+ params = {key: value for key, value in {
63
+ "external_user_id": external_user_id,
64
+ "cursor": _normalize_cursor(cursor),
65
+ }.items() if value}
66
+ return self._client._request("GET", "/documents", params=params)
67
+
68
+ def delete(self, document_id: str) -> None:
69
+ self._client._request("DELETE", f"/documents/{document_id}")
70
+
71
+ def upgrade(self, document_id: str, *, idempotency_key: str | None = None) -> dict[str, Any]:
72
+ return self._client._request(
73
+ "POST",
74
+ f"/documents/{document_id}/upgrade",
75
+ idempotency_key=idempotency_key or str(uuid4()),
76
+ )
77
+
78
+ def render(
79
+ self,
80
+ document_id: str,
81
+ *,
82
+ format: str = "pdf",
83
+ idempotency_key: str | None = None,
84
+ ) -> dict[str, Any]:
85
+ return self._client._request(
86
+ "POST",
87
+ f"/documents/{document_id}/render",
88
+ json={"format": format},
89
+ idempotency_key=idempotency_key or str(uuid4()),
90
+ )
91
+
92
+ def render_and_wait(
93
+ self,
94
+ document_id: str,
95
+ *,
96
+ format: str = "pdf",
97
+ idempotency_key: str | None = None,
98
+ timeout: float = 120,
99
+ poll_fallback: bool = True,
100
+ ) -> dict[str, Any]:
101
+ job = self.render(document_id, format=format, idempotency_key=idempotency_key)
102
+ return self._client.renders.wait(
103
+ job,
104
+ timeout=timeout,
105
+ poll_fallback=poll_fallback,
106
+ )
107
+
108
+
109
+ class RendersResource:
110
+ def __init__(self, client: "SharpToolz"):
111
+ self._client = client
112
+
113
+ def get(self, job_id: str) -> dict[str, Any]:
114
+ return self._client._request("GET", f"/renders/{job_id}")
115
+
116
+ def wait(
117
+ self,
118
+ job_or_id: dict[str, Any] | str,
119
+ *,
120
+ timeout: float = 120,
121
+ poll_fallback: bool = True,
122
+ ) -> dict[str, Any]:
123
+ job = self.get(job_or_id) if isinstance(job_or_id, str) else job_or_id
124
+ if not job.get("id"):
125
+ raise TypeError("A render job or job ID is required.")
126
+ if job.get("status") in TERMINAL_RENDER_STATUSES:
127
+ return self._finish(job)
128
+
129
+ watch = self._client._request("POST", f"/renders/{job['id']}/watch")
130
+ try:
131
+ terminal = self._wait_on_websocket(watch["websocket_url"], timeout=timeout)
132
+ return self._finish(self.get(terminal["id"]))
133
+ except Exception:
134
+ if not poll_fallback:
135
+ raise
136
+ return self._wait_by_polling(job["id"], timeout=timeout)
137
+
138
+ def _wait_on_websocket(self, url: str, *, timeout: float) -> dict[str, Any]:
139
+ deadline = time.monotonic() + timeout
140
+ with self._client._websocket_connect(url, open_timeout=min(10, timeout)) as socket:
141
+ while True:
142
+ remaining = deadline - time.monotonic()
143
+ if remaining <= 0:
144
+ raise SharpToolzError("Render wait timed out.")
145
+ raw = socket.recv(timeout=remaining)
146
+ message = json.loads(raw)
147
+ data = message.get("data") if message.get("type") == "render.updated" else None
148
+ if data and data.get("status") in TERMINAL_RENDER_STATUSES:
149
+ return data
150
+
151
+ def _wait_by_polling(self, job_id: str, *, timeout: float) -> dict[str, Any]:
152
+ deadline = time.monotonic() + timeout
153
+ interval = 0.75
154
+ while time.monotonic() < deadline:
155
+ time.sleep(interval)
156
+ job = self.get(job_id)
157
+ if job.get("status") in TERMINAL_RENDER_STATUSES:
158
+ return self._finish(job)
159
+ interval = min(interval * 1.5, 4)
160
+ raise SharpToolzError("Render wait timed out.")
161
+
162
+ @staticmethod
163
+ def _finish(job: dict[str, Any]) -> dict[str, Any]:
164
+ if job.get("status") == "failed":
165
+ code = job.get("error_code")
166
+ raise SharpToolzError(f"Render failed{f': {code}' if code else '.'}", data=job)
167
+ return job
168
+
169
+
170
+ class SharpToolz:
171
+ def __init__(
172
+ self,
173
+ *,
174
+ api_key: str,
175
+ base_url: str = DEFAULT_BASE_URL,
176
+ timeout: float = 30,
177
+ transport: httpx.BaseTransport | None = None,
178
+ websocket_connect: Callable[..., Any] = websocket_connect_default,
179
+ ):
180
+ if not isinstance(api_key, str) or not api_key.startswith("stz_live_"):
181
+ raise TypeError("A SharpToolz server API key is required.")
182
+ self._http = httpx.Client(
183
+ base_url=base_url.rstrip("/"),
184
+ timeout=timeout,
185
+ transport=transport,
186
+ headers={
187
+ "Authorization": f"Bearer {api_key}",
188
+ "Accept": "application/json",
189
+ "User-Agent": "sharptoolz-python/0.2.0",
190
+ },
191
+ )
192
+ self._websocket_connect = websocket_connect
193
+ self.templates = TemplatesResource(self)
194
+ self.hosted_forms = HostedFormsResource(self)
195
+ self.documents = DocumentsResource(self)
196
+ self.renders = RendersResource(self)
197
+
198
+ def __enter__(self) -> "SharpToolz":
199
+ return self
200
+
201
+ def __exit__(self, exc_type, exc, traceback) -> None:
202
+ self.close()
203
+
204
+ def close(self) -> None:
205
+ self._http.close()
206
+
207
+ def _request(
208
+ self,
209
+ method: str,
210
+ path: str,
211
+ *,
212
+ json: dict[str, Any] | None = None,
213
+ params: dict[str, Any] | None = None,
214
+ idempotency_key: str | None = None,
215
+ ) -> Any:
216
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
217
+ response = self._http.request(method, path, json=json, params=params, headers=headers)
218
+ if response.status_code == 204:
219
+ return None
220
+ try:
221
+ data = response.json()
222
+ except ValueError:
223
+ data = None
224
+ if response.is_error:
225
+ message = (
226
+ data.get("detail") if isinstance(data, dict) else None
227
+ ) or f"SharpToolz request failed with HTTP {response.status_code}."
228
+ raise SharpToolzError(message, status_code=response.status_code, data=data)
229
+ return data
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: sharptoolz
3
+ Version: 0.2.0
4
+ Summary: Official Python client for the SharpToolz API
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: httpx<1,>=0.27
9
+ Requires-Dist: websockets<16,>=12
10
+
11
+ # SharpToolz Python SDK
12
+
13
+ ```bash
14
+ # Available after the first PyPI release
15
+ pip install sharptoolz
16
+ ```
17
+
18
+ ```python
19
+ import os
20
+ from sharptoolz import SharpToolz
21
+
22
+ with SharpToolz(api_key=os.environ["SHARPTOOLZ_API_KEY"]) as sharp:
23
+ session = sharp.hosted_forms.create(
24
+ template_id=template_id,
25
+ external_user_id=current_user_id,
26
+ origin="https://app.example.com",
27
+ mode="test",
28
+ preview_mode="protected",
29
+ )
30
+
31
+ # Return session["embed_url"] to your frontend.
32
+ ```
33
+
34
+ Only browser JavaScript mounts the returned URL with `@sharp-toolz/sdk/browser`.
35
+ Python keeps the API key on your backend and can create or edit hosted sessions,
36
+ list documents, and wait for PNG/PDF renders over a job-scoped WebSocket.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/sharptoolz/__init__.py
4
+ src/sharptoolz/client.py
5
+ src/sharptoolz/py.typed
6
+ src/sharptoolz.egg-info/PKG-INFO
7
+ src/sharptoolz.egg-info/SOURCES.txt
8
+ src/sharptoolz.egg-info/dependency_links.txt
9
+ src/sharptoolz.egg-info/requires.txt
10
+ src/sharptoolz.egg-info/top_level.txt
11
+ tests/test_client.py
@@ -0,0 +1,2 @@
1
+ httpx<1,>=0.27
2
+ websockets<16,>=12
@@ -0,0 +1 @@
1
+ sharptoolz
@@ -0,0 +1,111 @@
1
+ import json
2
+ import unittest
3
+
4
+ import httpx
5
+
6
+ from sharptoolz import SharpToolz
7
+
8
+
9
+ class FakeSocket:
10
+ def __enter__(self):
11
+ return self
12
+
13
+ def __exit__(self, exc_type, exc, traceback):
14
+ return None
15
+
16
+ def recv(self, timeout=None):
17
+ return json.dumps({
18
+ "type": "render.updated",
19
+ "data": {"id": "job-1", "status": "completed"},
20
+ })
21
+
22
+
23
+ class SharpToolzClientTests(unittest.TestCase):
24
+ def test_api_key_stays_in_authorization_header(self):
25
+ captured = {}
26
+
27
+ def handler(request):
28
+ captured["request"] = request
29
+ return httpx.Response(200, json={"results": []})
30
+
31
+ with SharpToolz(
32
+ api_key="stz_live_test.key",
33
+ transport=httpx.MockTransport(handler),
34
+ ) as client:
35
+ client.templates.list()
36
+
37
+ request = captured["request"]
38
+ self.assertEqual(request.headers["Authorization"], "Bearer stz_live_test.key")
39
+ self.assertNotIn("stz_live_test.key", str(request.url))
40
+
41
+ def test_render_wait_uses_websocket_then_fetches_final_job_once(self):
42
+ calls = []
43
+
44
+ def handler(request):
45
+ calls.append((request.method, request.url.path))
46
+ if request.url.path.endswith("/watch"):
47
+ return httpx.Response(200, json={"websocket_url": "wss://api.sharptoolz.com/ws/job"})
48
+ return httpx.Response(200, json={
49
+ "id": "job-1",
50
+ "status": "completed",
51
+ "download_url": "https://signed.example/file",
52
+ })
53
+
54
+ with SharpToolz(
55
+ api_key="stz_live_test.key",
56
+ transport=httpx.MockTransport(handler),
57
+ websocket_connect=lambda *args, **kwargs: FakeSocket(),
58
+ ) as client:
59
+ result = client.renders.wait({"id": "job-1", "status": "queued"})
60
+
61
+ self.assertEqual(result["download_url"], "https://signed.example/file")
62
+ self.assertEqual(calls, [
63
+ ("POST", "/api/v1/renders/job-1/watch"),
64
+ ("GET", "/api/v1/renders/job-1"),
65
+ ])
66
+
67
+ def test_creation_and_editing_are_hosted_session_only(self):
68
+ calls = []
69
+
70
+ def handler(request):
71
+ calls.append((request.method, request.url.path))
72
+ return httpx.Response(201, json={"id": "session-1"})
73
+
74
+ with SharpToolz(
75
+ api_key="stz_live_test.key",
76
+ transport=httpx.MockTransport(handler),
77
+ ) as client:
78
+ client.hosted_forms.create(
79
+ template_id="template-1",
80
+ external_user_id="user-1",
81
+ origin="https://app.example",
82
+ )
83
+ client.hosted_forms.edit("document-1", origin="https://app.example")
84
+ self.assertFalse(hasattr(client.templates, "schema"))
85
+ self.assertFalse(hasattr(client.documents, "create"))
86
+
87
+ self.assertEqual(calls, [
88
+ ("POST", "/api/v1/embed-sessions"),
89
+ ("POST", "/api/v1/documents/document-1/session"),
90
+ ])
91
+
92
+ def test_document_list_accepts_api_next_url_as_cursor(self):
93
+ captured = {}
94
+
95
+ def handler(request):
96
+ captured["query"] = request.url.query.decode()
97
+ return httpx.Response(200, json={"results": [], "next": None, "previous": None})
98
+
99
+ with SharpToolz(
100
+ api_key="stz_live_test.key",
101
+ transport=httpx.MockTransport(handler),
102
+ ) as client:
103
+ client.documents.list(
104
+ cursor="https://api.sharptoolz.com/api/v1/documents?cursor=cD0yMDI2"
105
+ )
106
+
107
+ self.assertEqual(captured["query"], "cursor=cD0yMDI2")
108
+
109
+
110
+ if __name__ == "__main__":
111
+ unittest.main()