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.
- docrenders_sdk-0.1.0/.gitignore +12 -0
- docrenders_sdk-0.1.0/PKG-INFO +61 -0
- docrenders_sdk-0.1.0/README.md +51 -0
- docrenders_sdk-0.1.0/docrenders/__init__.py +15 -0
- docrenders_sdk-0.1.0/docrenders/client.py +246 -0
- docrenders_sdk-0.1.0/pyproject.toml +18 -0
- docrenders_sdk-0.1.0/test_client.py +149 -0
|
@@ -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()
|