docrenders-sdk 0.1.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,12 @@
1
+ # JS
2
+ js/node_modules/
3
+ js/dist/
4
+
5
+ # Python
6
+ python/__pycache__/
7
+ python/*.egg-info/
8
+ python/dist/
9
+ python/.venv/
10
+
11
+ # Go
12
+ go/vendor/
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: docrenders-sdk
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the DocRenders API
5
+ Project-URL: Repository, https://github.com/JWhist/docrenders-sdks
6
+ License: MIT
7
+ Keywords: docrenders,markdown,pdf,sdk
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+
11
+ # docrenders-sdk (Python)
12
+
13
+ Official Python SDK for the [DocRenders API](https://www.docrenders.com). No dependencies — uses the standard library only.
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pip install docrenders-sdk
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```python
24
+ import os
25
+ from docrenders import DocRendersClient, RenderRequest, RenderFileRequest, RenderOptions
26
+
27
+ client = DocRendersClient(os.environ["PDFGEN_API_KEY"])
28
+
29
+ # Render to raw bytes
30
+ pdf = client.render(RenderRequest(
31
+ markdown="# Invoice\n\nDue: **$1,200**",
32
+ template="invoice",
33
+ options=RenderOptions(format="A4"),
34
+ ))
35
+
36
+ # Render and get a signed download URL (expires in 15 min)
37
+ result = client.render_signed_url(RenderRequest(markdown="# Report"))
38
+ print(result.url)
39
+
40
+ # Upload a file
41
+ with open("invoice.md", "rb") as f:
42
+ pdf = client.render_file(RenderFileRequest(
43
+ filename="invoice.md",
44
+ content=f.read(),
45
+ ))
46
+
47
+ # Check usage
48
+ usage = client.usage()
49
+ print(f"{usage.renders_used} / {usage.renders_limit} renders used")
50
+ ```
51
+
52
+ ## Error handling
53
+
54
+ ```python
55
+ from docrenders import DocRendersClient, DocRendersError, RenderRequest
56
+
57
+ try:
58
+ pdf = client.render(RenderRequest(markdown="# Hello"))
59
+ except DocRendersError as e:
60
+ print(e.code, str(e)) # e.g. "quota_exceeded"
61
+ ```
@@ -0,0 +1,51 @@
1
+ # docrenders-sdk (Python)
2
+
3
+ Official Python SDK for the [DocRenders API](https://www.docrenders.com). No dependencies — uses the standard library only.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install docrenders-sdk
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ import os
15
+ from docrenders import DocRendersClient, RenderRequest, RenderFileRequest, RenderOptions
16
+
17
+ client = DocRendersClient(os.environ["PDFGEN_API_KEY"])
18
+
19
+ # Render to raw bytes
20
+ pdf = client.render(RenderRequest(
21
+ markdown="# Invoice\n\nDue: **$1,200**",
22
+ template="invoice",
23
+ options=RenderOptions(format="A4"),
24
+ ))
25
+
26
+ # Render and get a signed download URL (expires in 15 min)
27
+ result = client.render_signed_url(RenderRequest(markdown="# Report"))
28
+ print(result.url)
29
+
30
+ # Upload a file
31
+ with open("invoice.md", "rb") as f:
32
+ pdf = client.render_file(RenderFileRequest(
33
+ filename="invoice.md",
34
+ content=f.read(),
35
+ ))
36
+
37
+ # Check usage
38
+ usage = client.usage()
39
+ print(f"{usage.renders_used} / {usage.renders_limit} renders used")
40
+ ```
41
+
42
+ ## Error handling
43
+
44
+ ```python
45
+ from docrenders import DocRendersClient, DocRendersError, RenderRequest
46
+
47
+ try:
48
+ pdf = client.render(RenderRequest(markdown="# Hello"))
49
+ except DocRendersError as e:
50
+ print(e.code, str(e)) # e.g. "quota_exceeded"
51
+ ```
@@ -0,0 +1,15 @@
1
+ """DocRenders Python SDK — convert Markdown or HTML to PDF via the DocRenders API."""
2
+
3
+ from .client import DocRendersClient, DocRendersError, RenderOptions, RenderRequest, RenderFileRequest, SignedURLResult, UsageResult, RateLimit, RenderUsage
4
+
5
+ __all__ = [
6
+ "DocRendersClient",
7
+ "DocRendersError",
8
+ "RenderOptions",
9
+ "RenderRequest",
10
+ "RenderFileRequest",
11
+ "SignedURLResult",
12
+ "UsageResult",
13
+ "RateLimit",
14
+ "RenderUsage",
15
+ ]
@@ -0,0 +1,246 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime
6
+ from typing import Optional, Tuple
7
+ import urllib.request
8
+ import urllib.error
9
+ import json
10
+
11
+ DEFAULT_BASE_URL = "https://www.docrenders.com"
12
+
13
+
14
+ class DocRendersError(Exception):
15
+ def __init__(self, code: str, message: str):
16
+ super().__init__(message)
17
+ self.code = code
18
+
19
+
20
+ @dataclass
21
+ class RenderOptions:
22
+ format: Optional[str] = None # "A4", "Letter", "Legal"
23
+ margin_top: Optional[str] = None
24
+ margin_right: Optional[str] = None
25
+ margin_bottom: Optional[str] = None
26
+ margin_left: Optional[str] = None
27
+ landscape: bool = False
28
+
29
+ def to_dict(self) -> dict:
30
+ d = {}
31
+ if self.format:
32
+ d["format"] = self.format
33
+ if self.margin_top:
34
+ d["margin_top"] = self.margin_top
35
+ if self.margin_right:
36
+ d["margin_right"] = self.margin_right
37
+ if self.margin_bottom:
38
+ d["margin_bottom"] = self.margin_bottom
39
+ if self.margin_left:
40
+ d["margin_left"] = self.margin_left
41
+ if self.landscape:
42
+ d["landscape"] = True
43
+ return d
44
+
45
+
46
+ @dataclass
47
+ class RenderRequest:
48
+ markdown: Optional[str] = None
49
+ html: Optional[str] = None
50
+ template: Optional[str] = None
51
+ options: RenderOptions = field(default_factory=RenderOptions)
52
+
53
+
54
+ @dataclass
55
+ class RenderFileRequest:
56
+ filename: str
57
+ content: bytes
58
+ options: RenderOptions = field(default_factory=RenderOptions)
59
+
60
+
61
+ @dataclass
62
+ class SignedURLResult:
63
+ url: str
64
+ expires_at: str
65
+ render_time_ms: int
66
+
67
+
68
+ @dataclass
69
+ class RateLimit:
70
+ requests_per_minute: int
71
+
72
+
73
+ @dataclass
74
+ class RenderUsage:
75
+ used: int
76
+ limit: int
77
+ period: str
78
+
79
+
80
+ @dataclass
81
+ class UsageResult:
82
+ key_prefix: str
83
+ plan: str
84
+ rate_limit: RateLimit
85
+ renders: RenderUsage
86
+
87
+
88
+ class DocRendersClient:
89
+ """Client for the DocRenders API."""
90
+
91
+ def __init__(self, api_key: str, base_url: str = DEFAULT_BASE_URL):
92
+ self.api_key = api_key
93
+ self.base_url = base_url.rstrip("/")
94
+
95
+ def _auth_header(self) -> dict:
96
+ return {"Authorization": f"Bearer {self.api_key}"}
97
+
98
+ def _post_json(self, path: str, payload: dict) -> urllib.request.Request:
99
+ data = json.dumps(payload).encode()
100
+ req = urllib.request.Request(
101
+ self.base_url + path,
102
+ data=data,
103
+ headers={**self._auth_header(), "Content-Type": "application/json"},
104
+ method="POST",
105
+ )
106
+ return req
107
+
108
+ def _raise_for_response(self, e: urllib.error.HTTPError) -> None:
109
+ try:
110
+ body = json.loads(e.read())
111
+ code = body.get("error", {}).get("code", "unknown_error")
112
+ message = body.get("error", {}).get("message", str(e))
113
+ except Exception:
114
+ code, message = "unknown_error", str(e)
115
+ raise DocRendersError(code, message)
116
+
117
+ def _render_payload(self, req: RenderRequest, output: str) -> dict:
118
+ payload: dict = {"output": output}
119
+ if req.markdown:
120
+ payload["markdown"] = req.markdown
121
+ if req.html:
122
+ payload["html"] = req.html
123
+ if req.template:
124
+ payload["template"] = req.template
125
+ opts = req.options.to_dict()
126
+ if opts:
127
+ payload["options"] = opts
128
+ return payload
129
+
130
+ def render(self, req: RenderRequest) -> bytes:
131
+ """Render Markdown or HTML to PDF. Returns raw PDF bytes."""
132
+ http_req = self._post_json("/render", self._render_payload(req, "binary"))
133
+ try:
134
+ with urllib.request.urlopen(http_req) as resp:
135
+ return resp.read()
136
+ except urllib.error.HTTPError as e:
137
+ self._raise_for_response(e)
138
+
139
+ def render_signed_url(self, req: RenderRequest) -> SignedURLResult:
140
+ """Render Markdown or HTML to PDF. Returns a signed download URL (expires in 15 min)."""
141
+ http_req = self._post_json("/render", self._render_payload(req, "url"))
142
+ try:
143
+ with urllib.request.urlopen(http_req) as resp:
144
+ body = json.loads(resp.read())
145
+ return SignedURLResult(
146
+ url=body["url"],
147
+ expires_at=body["expires_at"],
148
+ render_time_ms=body["render_time_ms"],
149
+ )
150
+ except urllib.error.HTTPError as e:
151
+ self._raise_for_response(e)
152
+
153
+ def render_file(self, req: RenderFileRequest) -> bytes:
154
+ """Upload a Markdown or HTML file and render it to PDF. Returns raw PDF bytes."""
155
+ data, content_type = _build_multipart(req, "binary")
156
+ http_req = urllib.request.Request(
157
+ self.base_url + "/render/file",
158
+ data=data,
159
+ headers={**self._auth_header(), "Content-Type": content_type},
160
+ method="POST",
161
+ )
162
+ try:
163
+ with urllib.request.urlopen(http_req) as resp:
164
+ return resp.read()
165
+ except urllib.error.HTTPError as e:
166
+ self._raise_for_response(e)
167
+
168
+ def render_file_signed_url(self, req: RenderFileRequest) -> SignedURLResult:
169
+ """Upload a Markdown or HTML file and render it to PDF. Returns a signed download URL."""
170
+ data, content_type = _build_multipart(req, "url")
171
+ http_req = urllib.request.Request(
172
+ self.base_url + "/render/file",
173
+ data=data,
174
+ headers={**self._auth_header(), "Content-Type": content_type},
175
+ method="POST",
176
+ )
177
+ try:
178
+ with urllib.request.urlopen(http_req) as resp:
179
+ body = json.loads(resp.read())
180
+ return SignedURLResult(
181
+ url=body["url"],
182
+ expires_at=body["expires_at"],
183
+ render_time_ms=body["render_time_ms"],
184
+ )
185
+ except urllib.error.HTTPError as e:
186
+ self._raise_for_response(e)
187
+
188
+ def usage(self) -> UsageResult:
189
+ """Return current period usage for the authenticated account."""
190
+ http_req = urllib.request.Request(
191
+ self.base_url + "/usage",
192
+ headers=self._auth_header(),
193
+ method="GET",
194
+ )
195
+ try:
196
+ with urllib.request.urlopen(http_req) as resp:
197
+ body = json.loads(resp.read())
198
+ r = body["renders"]
199
+ rl = body["rate_limit"]
200
+ return UsageResult(
201
+ key_prefix=body["key_prefix"],
202
+ plan=body["plan"],
203
+ rate_limit=RateLimit(requests_per_minute=rl["requests_per_minute"]),
204
+ renders=RenderUsage(used=r["used"], limit=r["limit"], period=r["period"]),
205
+ )
206
+ except urllib.error.HTTPError as e:
207
+ self._raise_for_response(e)
208
+
209
+
210
+ def _build_multipart(req: RenderFileRequest, output: str) -> Tuple[bytes, str]:
211
+ boundary = b"----DocRendersBoundary7MA4YWxkTrZu0gW"
212
+ lines = []
213
+
214
+ def field(name: str, value: str) -> None:
215
+ lines.append(b"--" + boundary)
216
+ lines.append(f'Content-Disposition: form-data; name="{name}"'.encode())
217
+ lines.append(b"")
218
+ lines.append(value.encode())
219
+
220
+ lines.append(b"--" + boundary)
221
+ lines.append(
222
+ f'Content-Disposition: form-data; name="file"; filename="{req.filename}"'.encode()
223
+ )
224
+ lines.append(b"Content-Type: application/octet-stream")
225
+ lines.append(b"")
226
+ lines.append(req.content)
227
+
228
+ field("output", output)
229
+ opts = req.options
230
+ if opts.format:
231
+ field("format", opts.format)
232
+ if opts.margin_top:
233
+ field("margin_top", opts.margin_top)
234
+ if opts.margin_right:
235
+ field("margin_right", opts.margin_right)
236
+ if opts.margin_bottom:
237
+ field("margin_bottom", opts.margin_bottom)
238
+ if opts.margin_left:
239
+ field("margin_left", opts.margin_left)
240
+ if opts.landscape:
241
+ field("landscape", "true")
242
+
243
+ lines.append(b"--" + boundary + b"--")
244
+ body = b"\r\n".join(lines)
245
+ content_type = f"multipart/form-data; boundary={boundary.decode()}"
246
+ return body, content_type
@@ -0,0 +1,18 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "docrenders-sdk"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for the DocRenders API"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.8"
12
+ keywords = ["pdf", "markdown", "docrenders", "sdk"]
13
+
14
+ [project.urls]
15
+ Repository = "https://github.com/JWhist/docrenders-sdks"
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["docrenders"]
@@ -0,0 +1,149 @@
1
+ """Tests for the DocRenders Python SDK using a mock HTTP server."""
2
+
3
+ import json
4
+ import threading
5
+ import unittest
6
+ from http.server import BaseHTTPRequestHandler, HTTPServer
7
+
8
+ from docrenders import DocRendersClient, DocRendersError, RenderOptions, RenderRequest, RenderFileRequest
9
+
10
+
11
+ def start_mock_server(handler_class):
12
+ server = HTTPServer(("127.0.0.1", 0), handler_class)
13
+ thread = threading.Thread(target=server.serve_forever)
14
+ thread.daemon = True
15
+ thread.start()
16
+ return server
17
+
18
+
19
+ class TestRenderBinary(unittest.TestCase):
20
+ def test_render_returns_bytes(self):
21
+ class Handler(BaseHTTPRequestHandler):
22
+ def do_POST(self):
23
+ length = int(self.headers.get("Content-Length", 0))
24
+ body = json.loads(self.rfile.read(length))
25
+ assert body["output"] == "binary"
26
+ assert self.headers.get("Authorization") == "Bearer dcr_test_key"
27
+ resp = b"%PDF-1.4 fake"
28
+ self.send_response(200)
29
+ self.send_header("Content-Type", "application/pdf")
30
+ self.send_header("Content-Length", str(len(resp)))
31
+ self.end_headers()
32
+ self.wfile.write(resp)
33
+
34
+ def log_message(self, *args): pass
35
+
36
+ server = start_mock_server(Handler)
37
+ client = DocRendersClient("dcr_test_key", base_url=f"http://127.0.0.1:{server.server_address[1]}")
38
+ result = client.render(RenderRequest(markdown="# Hello"))
39
+ self.assertEqual(result, b"%PDF-1.4 fake")
40
+ server.shutdown()
41
+
42
+
43
+ class TestRenderSignedURL(unittest.TestCase):
44
+ def test_render_signed_url(self):
45
+ class Handler(BaseHTTPRequestHandler):
46
+ def do_POST(self):
47
+ length = int(self.headers.get("Content-Length", 0))
48
+ body = json.loads(self.rfile.read(length))
49
+ assert body["output"] == "url"
50
+ resp = json.dumps({
51
+ "url": "https://storage.example.com/abc.pdf",
52
+ "expires_at": "2026-05-31T15:00:00Z",
53
+ "render_time_ms": 1200,
54
+ }).encode()
55
+ self.send_response(200)
56
+ self.send_header("Content-Type", "application/json")
57
+ self.send_header("Content-Length", str(len(resp)))
58
+ self.end_headers()
59
+ self.wfile.write(resp)
60
+
61
+ def log_message(self, *args): pass
62
+
63
+ server = start_mock_server(Handler)
64
+ client = DocRendersClient("dcr_test_key", base_url=f"http://127.0.0.1:{server.server_address[1]}")
65
+ result = client.render_signed_url(RenderRequest(markdown="# Hello"))
66
+ self.assertEqual(result.url, "https://storage.example.com/abc.pdf")
67
+ self.assertEqual(result.render_time_ms, 1200)
68
+ server.shutdown()
69
+
70
+
71
+ class TestRenderFile(unittest.TestCase):
72
+ def test_render_file_binary(self):
73
+ class Handler(BaseHTTPRequestHandler):
74
+ def do_POST(self):
75
+ # Read the multipart body — just verify it arrives and output=binary
76
+ length = int(self.headers.get("Content-Length", 0))
77
+ raw = self.rfile.read(length).decode(errors="replace")
78
+ assert "output" in raw
79
+ assert "binary" in raw
80
+ assert "invoice.md" in raw
81
+ resp = b"%PDF-1.4 fake"
82
+ self.send_response(200)
83
+ self.send_header("Content-Type", "application/pdf")
84
+ self.send_header("Content-Length", str(len(resp)))
85
+ self.end_headers()
86
+ self.wfile.write(resp)
87
+
88
+ def log_message(self, *args): pass
89
+
90
+ server = start_mock_server(Handler)
91
+ client = DocRendersClient("dcr_test_key", base_url=f"http://127.0.0.1:{server.server_address[1]}")
92
+ result = client.render_file(RenderFileRequest(filename="invoice.md", content=b"# Invoice"))
93
+ self.assertEqual(result, b"%PDF-1.4 fake")
94
+ server.shutdown()
95
+
96
+
97
+ class TestUsage(unittest.TestCase):
98
+ def test_usage(self):
99
+ class Handler(BaseHTTPRequestHandler):
100
+ def do_GET(self):
101
+ assert self.path == "/usage"
102
+ resp = json.dumps({
103
+ "key_prefix": "dcr_live_abcd1234",
104
+ "plan": "starter",
105
+ "rate_limit": {"requests_per_minute": 60},
106
+ "renders": {"used": 42, "limit": 5000, "period": "2026-06"},
107
+ }).encode()
108
+ self.send_response(200)
109
+ self.send_header("Content-Type", "application/json")
110
+ self.send_header("Content-Length", str(len(resp)))
111
+ self.end_headers()
112
+ self.wfile.write(resp)
113
+
114
+ def log_message(self, *args): pass
115
+
116
+ server = start_mock_server(Handler)
117
+ client = DocRendersClient("dcr_test_key", base_url=f"http://127.0.0.1:{server.server_address[1]}")
118
+ usage = client.usage()
119
+ self.assertEqual(usage.plan, "starter")
120
+ self.assertEqual(usage.renders.used, 42)
121
+ self.assertEqual(usage.renders.limit, 5000)
122
+ server.shutdown()
123
+
124
+
125
+ class TestAPIError(unittest.TestCase):
126
+ def test_api_error_raises(self):
127
+ class Handler(BaseHTTPRequestHandler):
128
+ def do_POST(self):
129
+ resp = json.dumps({
130
+ "error": {"code": "quota_exceeded", "message": "monthly render limit reached"}
131
+ }).encode()
132
+ self.send_response(429)
133
+ self.send_header("Content-Type", "application/json")
134
+ self.send_header("Content-Length", str(len(resp)))
135
+ self.end_headers()
136
+ self.wfile.write(resp)
137
+
138
+ def log_message(self, *args): pass
139
+
140
+ server = start_mock_server(Handler)
141
+ client = DocRendersClient("dcr_test_key", base_url=f"http://127.0.0.1:{server.server_address[1]}")
142
+ with self.assertRaises(DocRendersError) as cm:
143
+ client.render(RenderRequest(markdown="# Hello"))
144
+ self.assertEqual(cm.exception.code, "quota_exceeded")
145
+ server.shutdown()
146
+
147
+
148
+ if __name__ == "__main__":
149
+ unittest.main()