dokaz-api 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.
- dokaz_api/__init__.py +33 -0
- dokaz_api/_client.py +462 -0
- dokaz_api/_errors.py +51 -0
- dokaz_api/py.typed +0 -0
- dokaz_api/types.py +239 -0
- dokaz_api-0.1.0.dist-info/METADATA +209 -0
- dokaz_api-0.1.0.dist-info/RECORD +9 -0
- dokaz_api-0.1.0.dist-info/WHEEL +4 -0
- dokaz_api-0.1.0.dist-info/licenses/LICENSE +21 -0
dokaz_api/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Official Python client for the Dokaz API (https://api.dokaz.net).
|
|
2
|
+
|
|
3
|
+
from dokaz_api import Dokaz
|
|
4
|
+
dokaz = Dokaz() # DOKAZ_API_KEY if set, else the free tier (100 calls/day, no key)
|
|
5
|
+
pdf = dokaz.pdf_markdown("# Hello")
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._client import (
|
|
9
|
+
DEFAULT_BASE_URL,
|
|
10
|
+
OPERATIONS,
|
|
11
|
+
PRICING_URL,
|
|
12
|
+
Dokaz,
|
|
13
|
+
DokazResponse,
|
|
14
|
+
Quota,
|
|
15
|
+
ResponseInfo,
|
|
16
|
+
__version__,
|
|
17
|
+
read_quota,
|
|
18
|
+
)
|
|
19
|
+
from ._errors import DokazConnectionError, DokazError
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DEFAULT_BASE_URL",
|
|
23
|
+
"OPERATIONS",
|
|
24
|
+
"PRICING_URL",
|
|
25
|
+
"Dokaz",
|
|
26
|
+
"DokazConnectionError",
|
|
27
|
+
"DokazError",
|
|
28
|
+
"DokazResponse",
|
|
29
|
+
"Quota",
|
|
30
|
+
"ResponseInfo",
|
|
31
|
+
"__version__",
|
|
32
|
+
"read_quota",
|
|
33
|
+
]
|
dokaz_api/_client.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
# mypy: disable-error-code="no-any-return"
|
|
2
|
+
"""The Dokaz API client. Standard library only (urllib)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json as _json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import socket
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from email.message import Message
|
|
15
|
+
from typing import Any, Callable, Dict, Generic, List, Literal, Mapping, Optional, Tuple, TypeVar, Union, overload
|
|
16
|
+
|
|
17
|
+
from ._errors import DokazConnectionError, DokazError
|
|
18
|
+
from .types import (
|
|
19
|
+
BarcodeType,
|
|
20
|
+
CalendarRequest,
|
|
21
|
+
ClassifyResult,
|
|
22
|
+
EcLevel,
|
|
23
|
+
EmailBatchResult,
|
|
24
|
+
EmailResult,
|
|
25
|
+
ExtractResult,
|
|
26
|
+
InvoiceRequest,
|
|
27
|
+
InvoiceTotals,
|
|
28
|
+
KeywordsResult,
|
|
29
|
+
QrMatrix,
|
|
30
|
+
QrResult,
|
|
31
|
+
Rows,
|
|
32
|
+
RewriteResult,
|
|
33
|
+
SentimentResult,
|
|
34
|
+
SiteIntel,
|
|
35
|
+
SummarizeResult,
|
|
36
|
+
Tone,
|
|
37
|
+
XlsxRequest,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__version__ = "0.1.0"
|
|
41
|
+
|
|
42
|
+
DEFAULT_BASE_URL = "https://api.dokaz.net"
|
|
43
|
+
PRICING_URL = "https://api.dokaz.net/#pricing"
|
|
44
|
+
|
|
45
|
+
#: Every API operation this client implements: method name -> (HTTP method, path). The drift test
|
|
46
|
+
#: checks this table against the API's OpenAPI document, so an endpoint added to the API fails the
|
|
47
|
+
#: test until it gets a method here. Naming: the path after /v1 in snake_case, plus "_post" for the
|
|
48
|
+
#: POST form of a path that also has a GET.
|
|
49
|
+
OPERATIONS: Dict[str, Tuple[str, str]] = {
|
|
50
|
+
"invoice_pdf": ("POST", "/v1/invoice/pdf"),
|
|
51
|
+
"invoice_preview": ("POST", "/v1/invoice/preview"),
|
|
52
|
+
"invoice_sample": ("GET", "/v1/invoice/sample"),
|
|
53
|
+
"email_verify": ("GET", "/v1/email/verify"),
|
|
54
|
+
"email_verify_post": ("POST", "/v1/email/verify"),
|
|
55
|
+
"email_verify_batch": ("POST", "/v1/email/verify/batch"),
|
|
56
|
+
"site_intel": ("GET", "/v1/site/intel"),
|
|
57
|
+
"site_intel_post": ("POST", "/v1/site/intel"),
|
|
58
|
+
"text_summarize": ("POST", "/v1/text/summarize"),
|
|
59
|
+
"text_sentiment": ("POST", "/v1/text/sentiment"),
|
|
60
|
+
"text_keywords": ("POST", "/v1/text/keywords"),
|
|
61
|
+
"text_classify": ("POST", "/v1/text/classify"),
|
|
62
|
+
"text_extract": ("POST", "/v1/text/extract"),
|
|
63
|
+
"text_rewrite": ("POST", "/v1/text/rewrite"),
|
|
64
|
+
"qr": ("GET", "/v1/qr"),
|
|
65
|
+
"qr_post": ("POST", "/v1/qr"),
|
|
66
|
+
"qr_wifi": ("GET", "/v1/qr/wifi"),
|
|
67
|
+
"qr_vcard": ("GET", "/v1/qr/vcard"),
|
|
68
|
+
"barcode": ("GET", "/v1/barcode"),
|
|
69
|
+
"pdf_markdown": ("POST", "/v1/pdf/markdown"),
|
|
70
|
+
"convert_csv_to_json": ("POST", "/v1/convert/csv-to-json"),
|
|
71
|
+
"convert_json_to_csv": ("POST", "/v1/convert/json-to-csv"),
|
|
72
|
+
"convert_json_to_xlsx": ("POST", "/v1/convert/json-to-xlsx"),
|
|
73
|
+
"calendar_ics": ("GET", "/v1/calendar/ics"),
|
|
74
|
+
"calendar_ics_post": ("POST", "/v1/calendar/ics"),
|
|
75
|
+
"image_strip": ("POST", "/v1/image/strip"),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
T = TypeVar("T")
|
|
79
|
+
Query = Mapping[str, Union[str, int, float, bool, None]]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class Quota:
|
|
84
|
+
"""The plan and usage headers every /v1 response carries."""
|
|
85
|
+
|
|
86
|
+
#: free | starter | pro | business | rapid:<plan>
|
|
87
|
+
plan: Optional[str]
|
|
88
|
+
#: Calls allowed in the window (per day on free, per month on paid); "rapidapi" via RapidAPI.
|
|
89
|
+
limit: Optional[str]
|
|
90
|
+
used: Optional[int]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def read_quota(headers: Message) -> Quota:
|
|
94
|
+
used = headers.get("x-quota-used")
|
|
95
|
+
return Quota(plan=headers.get("x-plan"), limit=headers.get("x-quota-limit"), used=int(used) if used and used.isdigit() else None)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class ResponseInfo:
|
|
100
|
+
operation: str
|
|
101
|
+
status: int
|
|
102
|
+
headers: Message
|
|
103
|
+
quota: Quota
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class DokazResponse(Generic[T]):
|
|
108
|
+
"""A decoded response: the value plus the status and (case-insensitive) headers."""
|
|
109
|
+
|
|
110
|
+
data: T
|
|
111
|
+
status: int
|
|
112
|
+
headers: Message
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _Unset:
|
|
116
|
+
def __repr__(self) -> str:
|
|
117
|
+
return "<from DOKAZ_API_KEY>"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_UNSET = _Unset()
|
|
121
|
+
_JSON_TYPE = re.compile(r"^application/(?:[\w.+-]+\+)?json\b", re.I)
|
|
122
|
+
_TEXT_TYPE = re.compile(r"^(?:text/|image/svg\+xml\b)", re.I)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _env(name: str) -> Optional[str]:
|
|
126
|
+
v = os.environ.get(name)
|
|
127
|
+
return v if v else None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _query_string(q: Optional[Query]) -> str:
|
|
131
|
+
if not q:
|
|
132
|
+
return ""
|
|
133
|
+
pairs = []
|
|
134
|
+
for k, v in q.items():
|
|
135
|
+
if v is None:
|
|
136
|
+
continue
|
|
137
|
+
if isinstance(v, bool):
|
|
138
|
+
v = "1" if v else "0"
|
|
139
|
+
pairs.append((k, str(v)))
|
|
140
|
+
return "?" + urllib.parse.urlencode(pairs) if pairs else ""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _sniff_image(data: bytes) -> str:
|
|
144
|
+
if data[:4] == b"\x89PNG":
|
|
145
|
+
return "image/png"
|
|
146
|
+
if data[:2] == b"\xff\xd8":
|
|
147
|
+
return "image/jpeg"
|
|
148
|
+
return "application/octet-stream"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _drop_none(d: Mapping[str, Any]) -> Dict[str, Any]:
|
|
152
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class Dokaz:
|
|
156
|
+
"""Client for https://api.dokaz.net.
|
|
157
|
+
|
|
158
|
+
``api_key``: your key (dk_...). Default: the DOKAZ_API_KEY environment variable when set.
|
|
159
|
+
Pass ``None`` (or "") to call the free tier anonymously: 100 calls a day per IP, no key.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def __init__(
|
|
163
|
+
self,
|
|
164
|
+
api_key: Union[str, None, _Unset] = _UNSET,
|
|
165
|
+
*,
|
|
166
|
+
base_url: Optional[str] = None,
|
|
167
|
+
timeout: float = 60.0,
|
|
168
|
+
on_response: Optional[Callable[[ResponseInfo], None]] = None,
|
|
169
|
+
) -> None:
|
|
170
|
+
key = _env("DOKAZ_API_KEY") if isinstance(api_key, _Unset) else api_key
|
|
171
|
+
self.__api_key: Optional[str] = key or None
|
|
172
|
+
self.base_url: str = (base_url or _env("DOKAZ_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
173
|
+
self.timeout = timeout
|
|
174
|
+
self.on_response = on_response
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def has_api_key(self) -> bool:
|
|
178
|
+
return self.__api_key is not None
|
|
179
|
+
|
|
180
|
+
def __repr__(self) -> str:
|
|
181
|
+
return f"Dokaz({self.base_url!r}, {'key' if self.__api_key else 'anonymous'})"
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------ transport
|
|
184
|
+
|
|
185
|
+
def request(
|
|
186
|
+
self,
|
|
187
|
+
operation: str,
|
|
188
|
+
*,
|
|
189
|
+
query: Optional[Query] = None,
|
|
190
|
+
json: Any = None,
|
|
191
|
+
body: Optional[bytes] = None,
|
|
192
|
+
content_type: Optional[str] = None,
|
|
193
|
+
timeout: Optional[float] = None,
|
|
194
|
+
) -> DokazResponse[Any]:
|
|
195
|
+
"""Low-level call: any operation, returning the decoded body with its status and headers.
|
|
196
|
+
|
|
197
|
+
JSON responses are parsed, text (SVG, CSV, iCalendar) is ``str``, anything else ``bytes``.
|
|
198
|
+
"""
|
|
199
|
+
if operation not in OPERATIONS:
|
|
200
|
+
raise ValueError(f"dokaz: unknown operation {operation!r}")
|
|
201
|
+
method, path = OPERATIONS[operation]
|
|
202
|
+
url = self.base_url + path + _query_string(query)
|
|
203
|
+
headers = {"Accept": "*/*", "User-Agent": f"dokaz-api-python/{__version__}"}
|
|
204
|
+
if self.__api_key:
|
|
205
|
+
headers["X-Api-Key"] = self.__api_key
|
|
206
|
+
data: Optional[bytes] = None
|
|
207
|
+
if json is not None:
|
|
208
|
+
data = _json.dumps(json).encode("utf-8")
|
|
209
|
+
headers["Content-Type"] = "application/json"
|
|
210
|
+
elif body is not None:
|
|
211
|
+
data = bytes(body)
|
|
212
|
+
headers["Content-Type"] = content_type or "application/octet-stream"
|
|
213
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
214
|
+
limit = self.timeout if timeout is None else timeout
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
with urllib.request.urlopen(req, timeout=limit) as res: # noqa: S310 - https URL built above
|
|
218
|
+
status, resp_headers, raw = res.status, res.headers, res.read()
|
|
219
|
+
except urllib.error.HTTPError as e:
|
|
220
|
+
status, resp_headers = e.code, e.headers
|
|
221
|
+
try:
|
|
222
|
+
raw = e.read()
|
|
223
|
+
except Exception: # pragma: no cover - a body cut off mid-read
|
|
224
|
+
raw = b""
|
|
225
|
+
except (socket.timeout, TimeoutError) as e:
|
|
226
|
+
raise DokazConnectionError(f"dokaz: {method} {path} failed: timed out after {limit} s", operation) from e
|
|
227
|
+
except (urllib.error.URLError, OSError) as e:
|
|
228
|
+
reason = getattr(e, "reason", e)
|
|
229
|
+
raise DokazConnectionError(f"dokaz: {method} {path} failed: {reason}", operation) from e
|
|
230
|
+
|
|
231
|
+
if self.on_response is not None:
|
|
232
|
+
self.on_response(ResponseInfo(operation, status, resp_headers, read_quota(resp_headers)))
|
|
233
|
+
ctype = resp_headers.get("content-type") or ""
|
|
234
|
+
|
|
235
|
+
if status >= 400:
|
|
236
|
+
text = raw.decode("utf-8", "replace")
|
|
237
|
+
try:
|
|
238
|
+
parsed = _json.loads(text)
|
|
239
|
+
err_body = parsed if isinstance(parsed, dict) and isinstance(parsed.get("error"), str) else {"error": text[:500]}
|
|
240
|
+
except ValueError:
|
|
241
|
+
err_body = {"error": text[:500] or f"HTTP {status}"}
|
|
242
|
+
raise DokazError(f"dokaz: {method} {path} -> {status}: {err_body['error']}", status, err_body, resp_headers, operation)
|
|
243
|
+
|
|
244
|
+
value: Any
|
|
245
|
+
if _JSON_TYPE.match(ctype):
|
|
246
|
+
value = _json.loads(raw.decode("utf-8"))
|
|
247
|
+
elif _TEXT_TYPE.match(ctype):
|
|
248
|
+
value = raw.decode("utf-8")
|
|
249
|
+
else:
|
|
250
|
+
value = raw
|
|
251
|
+
return DokazResponse(value, status, resp_headers)
|
|
252
|
+
|
|
253
|
+
def _call(self, operation: str, **kw: Any) -> Any:
|
|
254
|
+
return self.request(operation, **kw).data
|
|
255
|
+
|
|
256
|
+
# ------------------------------------------------------------------ Invoice PDF
|
|
257
|
+
|
|
258
|
+
def invoice_pdf(self, invoice: InvoiceRequest) -> bytes:
|
|
259
|
+
"""Render an invoice, estimate or receipt as PDF bytes."""
|
|
260
|
+
return self._call("invoice_pdf", json=invoice)
|
|
261
|
+
|
|
262
|
+
def invoice_preview(self, invoice: InvoiceRequest) -> InvoiceTotals:
|
|
263
|
+
"""The computed totals and per-line amounts, to check the math before rendering."""
|
|
264
|
+
return self._call("invoice_preview", json=invoice)
|
|
265
|
+
|
|
266
|
+
def invoice_sample(self) -> bytes:
|
|
267
|
+
"""A sample invoice PDF rendered from built-in demo data."""
|
|
268
|
+
return self._call("invoice_sample")
|
|
269
|
+
|
|
270
|
+
# ------------------------------------------------------------------ Email Verify
|
|
271
|
+
|
|
272
|
+
def email_verify(self, email: str) -> EmailResult:
|
|
273
|
+
"""Verify one address: syntax, MX, disposable, role account, typo suggestion; score 0-100."""
|
|
274
|
+
return self._call("email_verify", query={"email": email})
|
|
275
|
+
|
|
276
|
+
def email_verify_post(self, email: str) -> EmailResult:
|
|
277
|
+
"""Same as email_verify, sent as a JSON body (keeps the address out of URLs and logs)."""
|
|
278
|
+
return self._call("email_verify_post", json={"email": email})
|
|
279
|
+
|
|
280
|
+
def email_verify_batch(self, emails: List[str]) -> EmailBatchResult:
|
|
281
|
+
"""Verify up to 50 addresses in one call; results in request order."""
|
|
282
|
+
return self._call("email_verify_batch", json={"emails": list(emails)})
|
|
283
|
+
|
|
284
|
+
# ------------------------------------------------------------------ Site Intel
|
|
285
|
+
|
|
286
|
+
def site_intel(self, url: str) -> SiteIntel:
|
|
287
|
+
"""Title/meta/OpenGraph, tech stack, contacts, socials and more for a public web page."""
|
|
288
|
+
return self._call("site_intel", query={"url": url})
|
|
289
|
+
|
|
290
|
+
def site_intel_post(self, url: str) -> SiteIntel:
|
|
291
|
+
"""Same as site_intel, as a JSON body; a scheme-less "example.com" is treated as https."""
|
|
292
|
+
return self._call("site_intel_post", json={"url": url})
|
|
293
|
+
|
|
294
|
+
# ------------------------------------------------------------------ Text AI
|
|
295
|
+
|
|
296
|
+
def text_summarize(self, text: str, *, sentences: Optional[int] = None, style: Optional[Literal["paragraph", "bullets"]] = None) -> SummarizeResult:
|
|
297
|
+
return self._call("text_summarize", json=_drop_none({"text": text, "sentences": sentences, "style": style}))
|
|
298
|
+
|
|
299
|
+
def text_sentiment(self, text: str) -> SentimentResult:
|
|
300
|
+
return self._call("text_sentiment", json={"text": text})
|
|
301
|
+
|
|
302
|
+
def text_keywords(self, text: str, *, max: Optional[int] = None) -> KeywordsResult: # noqa: A002 - the API's name
|
|
303
|
+
return self._call("text_keywords", json=_drop_none({"text": text, "max": max}))
|
|
304
|
+
|
|
305
|
+
def text_classify(self, text: str, labels: List[str]) -> ClassifyResult:
|
|
306
|
+
return self._call("text_classify", json={"text": text, "labels": list(labels)})
|
|
307
|
+
|
|
308
|
+
def text_extract(self, text: str, fields: List[str]) -> ExtractResult:
|
|
309
|
+
return self._call("text_extract", json={"text": text, "fields": list(fields)})
|
|
310
|
+
|
|
311
|
+
def text_rewrite(self, text: str, tone: Tone) -> RewriteResult:
|
|
312
|
+
return self._call("text_rewrite", json={"text": text, "tone": tone})
|
|
313
|
+
|
|
314
|
+
# ------------------------------------------------------------------ QR codes
|
|
315
|
+
# format "svg" (default) returns str, "png" bytes, "json" the module matrix.
|
|
316
|
+
|
|
317
|
+
@overload
|
|
318
|
+
def qr(self, data: str, *, format: Literal["png"], size: Optional[int] = ..., margin: Optional[int] = ..., ec: Optional[EcLevel] = ..., fg: Optional[str] = ..., bg: Optional[str] = ..., rounded: Optional[bool] = ...) -> bytes: ...
|
|
319
|
+
@overload
|
|
320
|
+
def qr(self, data: str, *, format: Literal["json"], size: Optional[int] = ..., margin: Optional[int] = ..., ec: Optional[EcLevel] = ..., fg: Optional[str] = ..., bg: Optional[str] = ..., rounded: Optional[bool] = ...) -> QrMatrix: ...
|
|
321
|
+
@overload
|
|
322
|
+
def qr(self, data: str, *, format: Optional[Literal["svg"]] = ..., size: Optional[int] = ..., margin: Optional[int] = ..., ec: Optional[EcLevel] = ..., fg: Optional[str] = ..., bg: Optional[str] = ..., rounded: Optional[bool] = ...) -> str: ...
|
|
323
|
+
def qr(self, data: str, *, format: Optional[str] = None, size: Optional[int] = None, margin: Optional[int] = None, ec: Optional[str] = None, fg: Optional[str] = None, bg: Optional[str] = None, rounded: Optional[bool] = None) -> QrResult:
|
|
324
|
+
"""A QR code for any text or URL (up to 2953 bytes)."""
|
|
325
|
+
return self._call("qr", query=dict(data=data, format=format, size=size, margin=margin, ec=ec, fg=fg, bg=bg, rounded=rounded))
|
|
326
|
+
|
|
327
|
+
@overload
|
|
328
|
+
def qr_post(self, data: str, *, format: Literal["png"], size: Optional[int] = ..., margin: Optional[int] = ..., ec: Optional[EcLevel] = ..., fg: Optional[str] = ..., bg: Optional[str] = ..., rounded: Optional[bool] = ...) -> bytes: ...
|
|
329
|
+
@overload
|
|
330
|
+
def qr_post(self, data: str, *, format: Literal["json"], size: Optional[int] = ..., margin: Optional[int] = ..., ec: Optional[EcLevel] = ..., fg: Optional[str] = ..., bg: Optional[str] = ..., rounded: Optional[bool] = ...) -> QrMatrix: ...
|
|
331
|
+
@overload
|
|
332
|
+
def qr_post(self, data: str, *, format: Optional[Literal["svg"]] = ..., size: Optional[int] = ..., margin: Optional[int] = ..., ec: Optional[EcLevel] = ..., fg: Optional[str] = ..., bg: Optional[str] = ..., rounded: Optional[bool] = ...) -> str: ...
|
|
333
|
+
def qr_post(self, data: str, *, format: Optional[str] = None, size: Optional[int] = None, margin: Optional[int] = None, ec: Optional[str] = None, fg: Optional[str] = None, bg: Optional[str] = None, rounded: Optional[bool] = None) -> QrResult:
|
|
334
|
+
"""Same as qr, sent as a JSON body."""
|
|
335
|
+
return self._call("qr_post", json=_drop_none(dict(data=data, format=format, size=size, margin=margin, ec=ec, fg=fg, bg=bg, rounded=rounded)))
|
|
336
|
+
|
|
337
|
+
@overload
|
|
338
|
+
def qr_wifi(self, ssid: str, *, password: Optional[str] = ..., type: Optional[Literal["WPA", "WEP", "nopass"]] = ..., hidden: Optional[bool] = ..., format: Literal["png"], size: Optional[int] = ..., margin: Optional[int] = ..., ec: Optional[EcLevel] = ..., fg: Optional[str] = ..., bg: Optional[str] = ..., rounded: Optional[bool] = ...) -> bytes: ...
|
|
339
|
+
@overload
|
|
340
|
+
def qr_wifi(self, ssid: str, *, password: Optional[str] = ..., type: Optional[Literal["WPA", "WEP", "nopass"]] = ..., hidden: Optional[bool] = ..., format: Literal["json"], size: Optional[int] = ..., margin: Optional[int] = ..., ec: Optional[EcLevel] = ..., fg: Optional[str] = ..., bg: Optional[str] = ..., rounded: Optional[bool] = ...) -> QrMatrix: ...
|
|
341
|
+
@overload
|
|
342
|
+
def qr_wifi(self, ssid: str, *, password: Optional[str] = ..., type: Optional[Literal["WPA", "WEP", "nopass"]] = ..., hidden: Optional[bool] = ..., format: Optional[Literal["svg"]] = ..., size: Optional[int] = ..., margin: Optional[int] = ..., ec: Optional[EcLevel] = ..., fg: Optional[str] = ..., bg: Optional[str] = ..., rounded: Optional[bool] = ...) -> str: ...
|
|
343
|
+
def qr_wifi(self, ssid: str, *, password: Optional[str] = None, type: Optional[str] = None, hidden: Optional[bool] = None, format: Optional[str] = None, size: Optional[int] = None, margin: Optional[int] = None, ec: Optional[str] = None, fg: Optional[str] = None, bg: Optional[str] = None, rounded: Optional[bool] = None) -> QrResult: # noqa: A002
|
|
344
|
+
"""A QR code that joins a Wi-Fi network when scanned."""
|
|
345
|
+
q = dict(ssid=ssid, password=password, type=type, hidden=hidden, format=format, size=size, margin=margin, ec=ec, fg=fg, bg=bg, rounded=rounded)
|
|
346
|
+
return self._call("qr_wifi", query=q)
|
|
347
|
+
|
|
348
|
+
@overload
|
|
349
|
+
def qr_vcard(self, name: str, *, phone: Optional[str] = ..., email: Optional[str] = ..., org: Optional[str] = ..., url: Optional[str] = ..., format: Literal["png"], size: Optional[int] = ..., margin: Optional[int] = ..., ec: Optional[EcLevel] = ..., fg: Optional[str] = ..., bg: Optional[str] = ..., rounded: Optional[bool] = ...) -> bytes: ...
|
|
350
|
+
@overload
|
|
351
|
+
def qr_vcard(self, name: str, *, phone: Optional[str] = ..., email: Optional[str] = ..., org: Optional[str] = ..., url: Optional[str] = ..., format: Literal["json"], size: Optional[int] = ..., margin: Optional[int] = ..., ec: Optional[EcLevel] = ..., fg: Optional[str] = ..., bg: Optional[str] = ..., rounded: Optional[bool] = ...) -> QrMatrix: ...
|
|
352
|
+
@overload
|
|
353
|
+
def qr_vcard(self, name: str, *, phone: Optional[str] = ..., email: Optional[str] = ..., org: Optional[str] = ..., url: Optional[str] = ..., format: Optional[Literal["svg"]] = ..., size: Optional[int] = ..., margin: Optional[int] = ..., ec: Optional[EcLevel] = ..., fg: Optional[str] = ..., bg: Optional[str] = ..., rounded: Optional[bool] = ...) -> str: ...
|
|
354
|
+
def qr_vcard(self, name: str, *, phone: Optional[str] = None, email: Optional[str] = None, org: Optional[str] = None, url: Optional[str] = None, format: Optional[str] = None, size: Optional[int] = None, margin: Optional[int] = None, ec: Optional[str] = None, fg: Optional[str] = None, bg: Optional[str] = None, rounded: Optional[bool] = None) -> QrResult:
|
|
355
|
+
"""A QR code holding a vCard 3.0 contact."""
|
|
356
|
+
q = dict(name=name, phone=phone, email=email, org=org, url=url, format=format, size=size, margin=margin, ec=ec, fg=fg, bg=bg, rounded=rounded)
|
|
357
|
+
return self._call("qr_vcard", query=q)
|
|
358
|
+
|
|
359
|
+
# ------------------------------------------------------------------ Barcodes
|
|
360
|
+
|
|
361
|
+
def barcode(
|
|
362
|
+
self,
|
|
363
|
+
data: str,
|
|
364
|
+
*,
|
|
365
|
+
type: Optional[BarcodeType] = None, # noqa: A002 - the API's name
|
|
366
|
+
text: Optional[bool] = None,
|
|
367
|
+
scale: Optional[int] = None,
|
|
368
|
+
height: Optional[int] = None,
|
|
369
|
+
margin: Optional[int] = None,
|
|
370
|
+
fg: Optional[str] = None,
|
|
371
|
+
bg: Optional[str] = None,
|
|
372
|
+
) -> str:
|
|
373
|
+
"""A Code 128 (default), EAN-13 or UPC-A barcode as SVG text."""
|
|
374
|
+
q = dict(data=data, type=type, text=text, scale=scale, height=height, margin=margin, fg=fg, bg=bg)
|
|
375
|
+
return self._call("barcode", query=q)
|
|
376
|
+
|
|
377
|
+
# ------------------------------------------------------------------ Markdown to PDF
|
|
378
|
+
|
|
379
|
+
def pdf_markdown(self, markdown: str, *, title: Optional[str] = None, page_size: Optional[Literal["letter", "a4"]] = None) -> bytes:
|
|
380
|
+
"""Render Markdown as PDF bytes."""
|
|
381
|
+
return self._call("pdf_markdown", json=_drop_none({"markdown": markdown, "title": title, "page_size": page_size}))
|
|
382
|
+
|
|
383
|
+
# ------------------------------------------------------------------ CSV / JSON / Excel
|
|
384
|
+
|
|
385
|
+
def convert_csv_to_json(self, csv: str, *, delimiter: Optional[str] = None, header: Optional[bool] = None) -> List[Any]:
|
|
386
|
+
"""CSV text to rows: dicts keyed by the header (default), or lists when header=False."""
|
|
387
|
+
return self._call("convert_csv_to_json", json=_drop_none({"csv": csv, "delimiter": delimiter, "header": header}))
|
|
388
|
+
|
|
389
|
+
def convert_json_to_csv(self, rows: Rows, *, delimiter: Optional[str] = None, header: Optional[bool] = None) -> str:
|
|
390
|
+
"""Rows (dicts, or lists) to RFC 4180 CSV text."""
|
|
391
|
+
return self._call("convert_json_to_csv", json=_drop_none({"rows": rows, "delimiter": delimiter, "header": header}))
|
|
392
|
+
|
|
393
|
+
def convert_json_to_xlsx(self, body: XlsxRequest) -> bytes:
|
|
394
|
+
"""Rows, {"rows", "sheet_name"}, or {"sheets": [...]} to an Excel workbook (.xlsx bytes)."""
|
|
395
|
+
return self._call("convert_json_to_xlsx", json=body)
|
|
396
|
+
|
|
397
|
+
# ------------------------------------------------------------------ Calendar (.ics)
|
|
398
|
+
|
|
399
|
+
def calendar_ics(
|
|
400
|
+
self,
|
|
401
|
+
title: str,
|
|
402
|
+
start: str,
|
|
403
|
+
*,
|
|
404
|
+
end: Optional[str] = None,
|
|
405
|
+
description: Optional[str] = None,
|
|
406
|
+
location: Optional[str] = None,
|
|
407
|
+
url: Optional[str] = None,
|
|
408
|
+
uid: Optional[str] = None,
|
|
409
|
+
status: Optional[Literal["confirmed", "tentative", "cancelled"]] = None,
|
|
410
|
+
reminder_minutes: Optional[int] = None,
|
|
411
|
+
) -> str:
|
|
412
|
+
"""One event from query parameters, as iCalendar text."""
|
|
413
|
+
return self._call("calendar_ics", query=self._ics_query(title, start, end, description, location, url, uid, status, reminder_minutes))
|
|
414
|
+
|
|
415
|
+
def calendar_ics_post(self, event: CalendarRequest) -> str:
|
|
416
|
+
"""One event, or {"name", "events": [...]} with organizer, attendees and repeats, as iCalendar text."""
|
|
417
|
+
return self._call("calendar_ics_post", json=event)
|
|
418
|
+
|
|
419
|
+
def calendar_ics_link(
|
|
420
|
+
self,
|
|
421
|
+
title: str,
|
|
422
|
+
start: str,
|
|
423
|
+
*,
|
|
424
|
+
end: Optional[str] = None,
|
|
425
|
+
description: Optional[str] = None,
|
|
426
|
+
location: Optional[str] = None,
|
|
427
|
+
url: Optional[str] = None,
|
|
428
|
+
uid: Optional[str] = None,
|
|
429
|
+
status: Optional[Literal["confirmed", "tentative", "cancelled"]] = None,
|
|
430
|
+
reminder_minutes: Optional[int] = None,
|
|
431
|
+
) -> str:
|
|
432
|
+
"""An "Add to calendar" link (the GET form of calendar_ics) for an email or a page.
|
|
433
|
+
|
|
434
|
+
Makes no request and never includes your key: whoever clicks it uses their own free call.
|
|
435
|
+
"""
|
|
436
|
+
q = self._ics_query(title, start, end, description, location, url, uid, status, reminder_minutes)
|
|
437
|
+
return self.base_url + OPERATIONS["calendar_ics"][1] + _query_string(q)
|
|
438
|
+
|
|
439
|
+
@staticmethod
|
|
440
|
+
def _ics_query(*values: Any) -> Dict[str, Any]:
|
|
441
|
+
keys = ("title", "start", "end", "description", "location", "url", "uid", "status", "reminder_minutes")
|
|
442
|
+
return dict(zip(keys, values))
|
|
443
|
+
|
|
444
|
+
# ------------------------------------------------------------------ Image metadata
|
|
445
|
+
|
|
446
|
+
def image_strip(self, image: bytes, *, keep_icc: Optional[bool] = None, keep_orientation: Optional[bool] = None) -> bytes:
|
|
447
|
+
"""A JPEG or PNG with EXIF/GPS/XMP/IPTC/comments removed; the pixels are untouched."""
|
|
448
|
+
data = bytes(image)
|
|
449
|
+
q = {"keep_icc": keep_icc, "keep_orientation": keep_orientation}
|
|
450
|
+
return self._call("image_strip", query=q, body=data, content_type=_sniff_image(data))
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
__all__ = [
|
|
454
|
+
"DEFAULT_BASE_URL",
|
|
455
|
+
"Dokaz",
|
|
456
|
+
"DokazResponse",
|
|
457
|
+
"OPERATIONS",
|
|
458
|
+
"PRICING_URL",
|
|
459
|
+
"Quota",
|
|
460
|
+
"ResponseInfo",
|
|
461
|
+
"read_quota",
|
|
462
|
+
]
|
dokaz_api/_errors.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Errors raised by the Dokaz client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from email.message import Message
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DokazError(Exception):
|
|
10
|
+
"""A non-2xx response. ``status`` is the HTTP status; ``body`` the API's error JSON.
|
|
11
|
+
|
|
12
|
+
The API's error body always has ``error`` (a message) and sometimes more: ``errors`` (every
|
|
13
|
+
validation problem), ``upgrade`` (on a 429, where to upgrade), ``retry_after`` (on a 503 from
|
|
14
|
+
the text AI endpoints, seconds to wait).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, status: int, body: Dict[str, Any], headers: Optional[Message], operation: str) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.status = status
|
|
20
|
+
self.body = body
|
|
21
|
+
self.headers = headers
|
|
22
|
+
self.operation = operation
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def error(self) -> str:
|
|
26
|
+
return str(self.body.get("error", ""))
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def errors(self) -> Optional[List[str]]:
|
|
30
|
+
v = self.body.get("errors")
|
|
31
|
+
return v if isinstance(v, list) else None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def retry_after(self) -> Optional[int]:
|
|
35
|
+
v = self.body.get("retry_after")
|
|
36
|
+
if isinstance(v, int):
|
|
37
|
+
return v
|
|
38
|
+
h = self.headers.get("retry-after") if self.headers is not None else None
|
|
39
|
+
return int(h) if h and h.isdigit() else None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def upgrade(self) -> Optional[str]:
|
|
43
|
+
v = self.body.get("upgrade")
|
|
44
|
+
return v if isinstance(v, str) else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DokazConnectionError(DokazError):
|
|
48
|
+
"""The request got no HTTP response: DNS, connection, TLS or timeout. ``status`` is 0."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, message: str, operation: str) -> None:
|
|
51
|
+
super().__init__(message, 0, {"error": message}, None, operation)
|
dokaz_api/py.typed
ADDED
|
File without changes
|
dokaz_api/types.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Request and response types for the Dokaz API.
|
|
2
|
+
|
|
3
|
+
Field names are exactly the API's (snake_case), so the endpoint docs at
|
|
4
|
+
https://api.dokaz.net/docs apply to these dicts unchanged. Every TypedDict here is
|
|
5
|
+
``total=False`` (Python 3.9 has no ``Required``); the docstrings say which keys the API requires.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------- invoice
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InvoiceParty(TypedDict, total=False):
|
|
16
|
+
"""``name`` is required. ``address``: up to 5 lines, a list or one newline-separated string."""
|
|
17
|
+
|
|
18
|
+
name: str
|
|
19
|
+
address: Union[List[str], str]
|
|
20
|
+
email: str
|
|
21
|
+
phone: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvoiceItem(TypedDict, total=False):
|
|
25
|
+
"""``description``, ``qty`` and ``unit_price`` are required."""
|
|
26
|
+
|
|
27
|
+
description: str
|
|
28
|
+
qty: float
|
|
29
|
+
unit_price: float
|
|
30
|
+
tax_rate: float
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
InvoiceRequest = TypedDict(
|
|
34
|
+
"InvoiceRequest",
|
|
35
|
+
{
|
|
36
|
+
"kind": Literal["invoice", "estimate", "receipt"],
|
|
37
|
+
"number": str,
|
|
38
|
+
"date": str,
|
|
39
|
+
"due": str,
|
|
40
|
+
"currency": Literal["USD", "EUR", "GBP", "CAD", "AUD"],
|
|
41
|
+
"from": InvoiceParty,
|
|
42
|
+
"to": InvoiceParty,
|
|
43
|
+
"items": List[InvoiceItem],
|
|
44
|
+
"tax_rate": float,
|
|
45
|
+
"discount": float,
|
|
46
|
+
"notes": str,
|
|
47
|
+
"terms": str,
|
|
48
|
+
"paid": bool,
|
|
49
|
+
"logo_text": str,
|
|
50
|
+
},
|
|
51
|
+
total=False,
|
|
52
|
+
)
|
|
53
|
+
"""Required: number, date (YYYY-MM-DD), from, to, items (1..60)."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class InvoiceLineTotal(TypedDict):
|
|
57
|
+
description: str
|
|
58
|
+
qty: float
|
|
59
|
+
unit_price: float
|
|
60
|
+
tax_rate: float
|
|
61
|
+
amount: float
|
|
62
|
+
tax: float
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class InvoiceTotals(TypedDict):
|
|
66
|
+
subtotal: float
|
|
67
|
+
tax: float
|
|
68
|
+
discount: float
|
|
69
|
+
total: float
|
|
70
|
+
currency: str
|
|
71
|
+
line_items: List[InvoiceLineTotal]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------- email
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class EmailChecks(TypedDict):
|
|
78
|
+
syntax: bool
|
|
79
|
+
domain_has_mx: Optional[bool]
|
|
80
|
+
mx_hosts: List[str]
|
|
81
|
+
disposable: bool
|
|
82
|
+
role_account: bool
|
|
83
|
+
free_provider: bool
|
|
84
|
+
typo_suggestion: Optional[str]
|
|
85
|
+
gibberish_local: bool
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class EmailResult(TypedDict):
|
|
89
|
+
email: str
|
|
90
|
+
normalized: str
|
|
91
|
+
valid: bool
|
|
92
|
+
score: int
|
|
93
|
+
checks: EmailChecks
|
|
94
|
+
reason: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class EmailBatchResult(TypedDict):
|
|
98
|
+
count: int
|
|
99
|
+
results: List[EmailResult]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------- site
|
|
103
|
+
|
|
104
|
+
SiteIntel = Dict[str, Any]
|
|
105
|
+
"""url, final_url, status, redirect_chain, title, description, og, tech, contacts, socials, ..."""
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------- text AI
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class SummarizeResult(TypedDict, total=False):
|
|
111
|
+
summary: str
|
|
112
|
+
sentences: int
|
|
113
|
+
input_chars: int
|
|
114
|
+
model: str
|
|
115
|
+
fallback_reason: str
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class SentimentResult(TypedDict, total=False):
|
|
119
|
+
label: str
|
|
120
|
+
score: float
|
|
121
|
+
confidence: float
|
|
122
|
+
model: str
|
|
123
|
+
fallback_reason: str
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class Keyword(TypedDict):
|
|
127
|
+
term: str
|
|
128
|
+
weight: float
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class KeywordsResult(TypedDict, total=False):
|
|
132
|
+
keywords: List[Keyword]
|
|
133
|
+
model: str
|
|
134
|
+
fallback_reason: str
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ClassifyResult(TypedDict, total=False):
|
|
138
|
+
label: str
|
|
139
|
+
scores: Dict[str, float]
|
|
140
|
+
model: str
|
|
141
|
+
fallback_reason: str
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ExtractResult(TypedDict, total=False):
|
|
145
|
+
data: Dict[str, Any]
|
|
146
|
+
model: str
|
|
147
|
+
fallback_reason: str
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class RewriteResult(TypedDict, total=False):
|
|
151
|
+
text: str
|
|
152
|
+
model: str
|
|
153
|
+
fallback_reason: str
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
Tone = Literal["professional", "casual", "concise", "friendly", "formal"]
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------- QR and barcode
|
|
159
|
+
|
|
160
|
+
QrFormat = Literal["svg", "png", "json"]
|
|
161
|
+
EcLevel = Literal["L", "M", "Q", "H"]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class QrMatrix(TypedDict):
|
|
165
|
+
"""format="json": the raw module matrix, one string of 1/0 per row."""
|
|
166
|
+
|
|
167
|
+
version: int
|
|
168
|
+
ec: str
|
|
169
|
+
mode: str
|
|
170
|
+
mask: int
|
|
171
|
+
modules: int
|
|
172
|
+
matrix: List[str]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
QrResult = Union[str, bytes, QrMatrix]
|
|
176
|
+
BarcodeType = Literal["code128", "ean13", "upca"]
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------- convert
|
|
179
|
+
|
|
180
|
+
Row = Dict[str, Any]
|
|
181
|
+
Rows = Union[List[Row], List[List[Any]]]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class XlsxSheet(TypedDict, total=False):
|
|
185
|
+
"""``name`` and ``rows`` are required."""
|
|
186
|
+
|
|
187
|
+
name: str
|
|
188
|
+
rows: Rows
|
|
189
|
+
header: bool
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
XlsxRequest = Union[Rows, Dict[str, Any]]
|
|
193
|
+
"""Rows; or {"rows": ..., "sheet_name"?: ..., "header"?: ...}; or {"sheets": [XlsxSheet, ...]}."""
|
|
194
|
+
|
|
195
|
+
# ---------------------------------------------------------------- calendar
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class CalendarPerson(TypedDict, total=False):
|
|
199
|
+
"""``email`` is required."""
|
|
200
|
+
|
|
201
|
+
name: str
|
|
202
|
+
email: str
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class CalendarRepeat(TypedDict, total=False):
|
|
206
|
+
"""``freq`` is required."""
|
|
207
|
+
|
|
208
|
+
freq: Literal["daily", "weekly", "monthly", "yearly"]
|
|
209
|
+
interval: int
|
|
210
|
+
count: int
|
|
211
|
+
until: str
|
|
212
|
+
by_day: List[Literal["MO", "TU", "WE", "TH", "FR", "SA", "SU"]]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class CalendarEvent(TypedDict, total=False):
|
|
216
|
+
"""``title`` and ``start`` are required. start: "2026-10-05T14:00:00-07:00" or "2026-10-05"."""
|
|
217
|
+
|
|
218
|
+
title: str
|
|
219
|
+
start: str
|
|
220
|
+
end: str
|
|
221
|
+
description: str
|
|
222
|
+
location: str
|
|
223
|
+
url: str
|
|
224
|
+
uid: str
|
|
225
|
+
status: Literal["confirmed", "tentative", "cancelled"]
|
|
226
|
+
reminder_minutes: int
|
|
227
|
+
organizer: CalendarPerson
|
|
228
|
+
attendees: List[CalendarPerson]
|
|
229
|
+
repeat: CalendarRepeat
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class CalendarFile(TypedDict, total=False):
|
|
233
|
+
"""Several events in one file. ``events`` (1..100) is required."""
|
|
234
|
+
|
|
235
|
+
name: str
|
|
236
|
+
events: List[CalendarEvent]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
CalendarRequest = Union[CalendarEvent, CalendarFile]
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dokaz-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official client for the Dokaz API: invoice PDFs, QR codes, barcodes, Markdown to PDF, CSV/JSON/Excel, calendar .ics files, image metadata removal, email verification, site intel and text AI.
|
|
5
|
+
Project-URL: Homepage, https://api.dokaz.net
|
|
6
|
+
Project-URL: Documentation, https://api.dokaz.net/docs
|
|
7
|
+
Project-URL: Pricing, https://api.dokaz.net/#pricing
|
|
8
|
+
Project-URL: OpenAPI, https://api.dokaz.net/openapi.json
|
|
9
|
+
Author: Dokaz Industries
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api,barcode,csv,dokaz,email-verification,exif,ics,invoice,markdown,pdf,qr-code,xlsx
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# dokaz-api
|
|
30
|
+
|
|
31
|
+
The official Python client for the [Dokaz API](https://api.dokaz.net): invoice PDFs, Markdown to
|
|
32
|
+
PDF, QR codes and barcodes, CSV/JSON/Excel conversion, calendar (.ics) files, photo metadata
|
|
33
|
+
removal, email verification, website intel and text AI, all from one key.
|
|
34
|
+
|
|
35
|
+
- Standard library only (urllib). No dependencies.
|
|
36
|
+
- Python 3.9+, fully typed (`py.typed`, TypedDicts for requests and responses).
|
|
37
|
+
- One method per API endpoint. PDFs, PNGs, JPEGs and spreadsheets come back as `bytes`; SVG, CSV
|
|
38
|
+
and iCalendar as `str`; everything else as parsed JSON.
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
pip install dokaz-api
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from dokaz_api import Dokaz
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Free tier and plans
|
|
49
|
+
|
|
50
|
+
**100 calls a day are free with no key and no signup.** Just call it. For more, get a key at
|
|
51
|
+
**[api.dokaz.net/#pricing](https://api.dokaz.net/#pricing)**: Starter $9/month (10,000 calls),
|
|
52
|
+
Pro $29/month (100,000), Business $79/month (500,000). Every plan covers every endpoint. The key
|
|
53
|
+
is shown straight after checkout.
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
dokaz = Dokaz() # uses DOKAZ_API_KEY if set, else the free tier
|
|
57
|
+
dokaz = Dokaz("dk_...") # or pass the key
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Quick start
|
|
61
|
+
|
|
62
|
+
### Invoice PDF
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
pdf = dokaz.invoice_pdf({
|
|
66
|
+
"number": "INV-2026-0142",
|
|
67
|
+
"date": "2026-09-06",
|
|
68
|
+
"due": "2026-10-06",
|
|
69
|
+
"from": {"name": "Northwind Studio LLC", "address": ["1200 Market Street", "San Francisco, CA 94102"]},
|
|
70
|
+
"to": {"name": "Acme Robotics Inc.", "email": "ap@acme-robotics.example"},
|
|
71
|
+
"items": [
|
|
72
|
+
{"description": "Brand identity design", "qty": 1, "unit_price": 2400},
|
|
73
|
+
{"description": "Front-end implementation (hourly)", "qty": 32, "unit_price": 110},
|
|
74
|
+
],
|
|
75
|
+
"tax_rate": 8.25,
|
|
76
|
+
})
|
|
77
|
+
open("invoice.pdf", "wb").write(pdf)
|
|
78
|
+
|
|
79
|
+
totals = dokaz.invoice_preview(same_body) # {"subtotal", "tax", "total", "line_items", ...}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Markdown to PDF
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
pdf = dokaz.pdf_markdown("# 2.4\n\n- Faster **exports**", title="Release notes", page_size="a4")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### QR codes and barcodes
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
svg = dokaz.qr("https://example.com", size=256, ec="Q") # str
|
|
92
|
+
png = dokaz.qr("https://example.com", format="png") # bytes
|
|
93
|
+
wifi = dokaz.qr_wifi("Cafe Guest", password="latte123", format="png")
|
|
94
|
+
card = dokaz.qr_vcard("Ada Lovelace", email="ada@example.com")
|
|
95
|
+
ean = dokaz.barcode("400638133393", type="ean13", text=True) # SVG str
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### CSV, JSON and Excel
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
rows = dokaz.convert_csv_to_json('name,city\n"Hopper, Grace",Arlington') # [{"name": ..., "city": ...}]
|
|
102
|
+
csv = dokaz.convert_json_to_csv([{"name": "Ada", "born": 1815}]) # RFC 4180 text
|
|
103
|
+
xlsx = dokaz.convert_json_to_xlsx({"rows": [{"order": "A-1001", "total": 129.5}], "sheet_name": "Orders"})
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Calendar (.ics) files
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
ics = dokaz.calendar_ics_post({
|
|
110
|
+
"title": "Kitchen remodel consult",
|
|
111
|
+
"start": "2026-10-05T14:00:00-07:00",
|
|
112
|
+
"end": "2026-10-05T15:00:00-07:00",
|
|
113
|
+
"reminder_minutes": 60,
|
|
114
|
+
})
|
|
115
|
+
# An "Add to calendar" link for an email, with no request made and no key in it:
|
|
116
|
+
link = dokaz.calendar_ics_link("Webinar", "2026-10-08T17:00:00Z")
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Strip photo metadata (EXIF, GPS, XMP)
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
clean = dokaz.image_strip(open("photo.jpg", "rb").read()) # same pixels, no metadata
|
|
123
|
+
no_icc = dokaz.image_strip(data, keep_icc=False)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Email verification and website intel
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
r = dokaz.email_verify("sales@gmial.com") # r["valid"], r["score"], r["checks"]["typo_suggestion"]
|
|
130
|
+
batch = dokaz.email_verify_batch(["a@example.org", "b@mailinator.com"]) # up to 50, one call
|
|
131
|
+
site = dokaz.site_intel("https://example.com") # title, OpenGraph, tech, contacts, socials
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Text AI
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
dokaz.text_summarize(text, sentences=2, style="bullets")
|
|
138
|
+
dokaz.text_sentiment(text)
|
|
139
|
+
dokaz.text_keywords(text, max=10)
|
|
140
|
+
dokaz.text_classify(text, ["billing", "bug report", "other"])
|
|
141
|
+
dokaz.text_extract(text, ["name", "company", "phone"])
|
|
142
|
+
dokaz.text_rewrite(text, "professional")
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Errors
|
|
146
|
+
|
|
147
|
+
Every non-2xx response raises `DokazError` carrying the API's error body:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from dokaz_api import DokazError
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
dokaz.invoice_pdf(body)
|
|
154
|
+
except DokazError as e:
|
|
155
|
+
e.status # 400, 401, 404, 413, 429, 503 ...
|
|
156
|
+
e.error # the API's message (e.body is the whole JSON)
|
|
157
|
+
e.errors # every validation problem, when listed
|
|
158
|
+
e.upgrade # on 429: where to upgrade
|
|
159
|
+
e.retry_after # on 503 (text AI capacity): seconds to wait
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
A request that gets no response at all (DNS, connection, timeout) raises `DokazConnectionError`,
|
|
163
|
+
a `DokazError` with `status` 0.
|
|
164
|
+
|
|
165
|
+
## Quota, options and raw responses
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
dokaz = Dokaz(
|
|
169
|
+
"dk_...", # default: DOKAZ_API_KEY; None forces the anonymous free tier
|
|
170
|
+
timeout=30, # seconds, default 60
|
|
171
|
+
on_response=lambda i: print(i.operation, i.status, i.quota), # Quota(plan, limit, used)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Any call with its status and headers (x-pdf-pages, x-csv-rows, x-metadata-removed ...):
|
|
175
|
+
r = dokaz.request("pdf_markdown", json={"markdown": "# Hi"})
|
|
176
|
+
r.headers["x-pdf-pages"], r.data
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Methods
|
|
180
|
+
|
|
181
|
+
| Method | Endpoint | Returns |
|
|
182
|
+
|---|---|---|
|
|
183
|
+
| `invoice_pdf(invoice)` | `POST /v1/invoice/pdf` | `bytes` (PDF) |
|
|
184
|
+
| `invoice_preview(invoice)` | `POST /v1/invoice/preview` | totals |
|
|
185
|
+
| `invoice_sample()` | `GET /v1/invoice/sample` | `bytes` (PDF) |
|
|
186
|
+
| `email_verify(email)` | `GET /v1/email/verify` | result |
|
|
187
|
+
| `email_verify_post(email)` | `POST /v1/email/verify` | result |
|
|
188
|
+
| `email_verify_batch(emails)` | `POST /v1/email/verify/batch` | `{"count", "results"}` |
|
|
189
|
+
| `site_intel(url)` | `GET /v1/site/intel` | dict |
|
|
190
|
+
| `site_intel_post(url)` | `POST /v1/site/intel` | dict |
|
|
191
|
+
| `text_summarize` `text_sentiment` `text_keywords` `text_classify` `text_extract` `text_rewrite` | `POST /v1/text/*` | dict |
|
|
192
|
+
| `qr(data, ...)` / `qr_post(data, ...)` | `GET` / `POST /v1/qr` | SVG `str`, PNG `bytes` or matrix |
|
|
193
|
+
| `qr_wifi(ssid, ...)` | `GET /v1/qr/wifi` | as `qr` |
|
|
194
|
+
| `qr_vcard(name, ...)` | `GET /v1/qr/vcard` | as `qr` |
|
|
195
|
+
| `barcode(data, ...)` | `GET /v1/barcode` | SVG `str` |
|
|
196
|
+
| `pdf_markdown(markdown, ...)` | `POST /v1/pdf/markdown` | `bytes` (PDF) |
|
|
197
|
+
| `convert_csv_to_json(csv, ...)` | `POST /v1/convert/csv-to-json` | rows |
|
|
198
|
+
| `convert_json_to_csv(rows, ...)` | `POST /v1/convert/json-to-csv` | CSV `str` |
|
|
199
|
+
| `convert_json_to_xlsx(body)` | `POST /v1/convert/json-to-xlsx` | `bytes` (.xlsx) |
|
|
200
|
+
| `calendar_ics(title, start, ...)` | `GET /v1/calendar/ics` | iCalendar `str` |
|
|
201
|
+
| `calendar_ics_post(event)` | `POST /v1/calendar/ics` | iCalendar `str` |
|
|
202
|
+
| `image_strip(data, ...)` | `POST /v1/image/strip` | `bytes` (image) |
|
|
203
|
+
|
|
204
|
+
Field names are the API's own, so the guides at [api.dokaz.net/docs](https://api.dokaz.net/docs)
|
|
205
|
+
and the [OpenAPI document](https://api.dokaz.net/openapi.json) apply unchanged.
|
|
206
|
+
|
|
207
|
+
## License
|
|
208
|
+
|
|
209
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
dokaz_api/__init__.py,sha256=tK-P1U9ok55VEXiXmJRnwky4SjWIfE3f-ITuCzHWZjw,697
|
|
2
|
+
dokaz_api/_client.py,sha256=M845_oJ-rATfD6OKfRgy0KdDSxhQywPD5KmQrQ6sF1w,22761
|
|
3
|
+
dokaz_api/_errors.py,sha256=KLg6iEJ4IktirkRU_scZbM5YPD9T__kNstrtODGX3hw,1742
|
|
4
|
+
dokaz_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
dokaz_api/types.py,sha256=dVCRBw17VBLjC2lPWMUtYfJ96c4XdNsw1Nbf9Kwfu64,5464
|
|
6
|
+
dokaz_api-0.1.0.dist-info/METADATA,sha256=dVg4l5aLXjwtKpz-K7NwwTakMALhzsCWJ1GNJnooG7k,8048
|
|
7
|
+
dokaz_api-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
dokaz_api-0.1.0.dist-info/licenses/LICENSE,sha256=DlCC1VXvLzUeys_p58OuWsHseaBntrDeZ-Us4j5fxfc,1073
|
|
9
|
+
dokaz_api-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dokaz Industries
|
|
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.
|