convertaja 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.
convertaja/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """SDK oficial da API do Convertajá (https://convertaja.com.br/api).
2
+
3
+ >>> import convertaja
4
+ >>> client = convertaja.Convertaja(api_key="cj_live_...")
5
+ >>> client.lookups.cnpj("11.222.333/0001-81")["razaoSocial"]
6
+ """
7
+ from . import webhooks
8
+ from .client import DEFAULT_BASE_URL, BinaryFile, Convertaja, LastResponse, __version__
9
+ from .errors import ConvertajaError, ConvertajaSignatureError
10
+
11
+ Client = Convertaja
12
+
13
+ __all__ = [
14
+ "Convertaja",
15
+ "Client",
16
+ "ConvertajaError",
17
+ "ConvertajaSignatureError",
18
+ "BinaryFile",
19
+ "LastResponse",
20
+ "webhooks",
21
+ "DEFAULT_BASE_URL",
22
+ "__version__",
23
+ ]
convertaja/client.py ADDED
@@ -0,0 +1,658 @@
1
+ """Cliente da API do Convertajá (só biblioteca padrão: urllib)."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import http.client
6
+ import json
7
+ import mimetypes
8
+ import os
9
+ import random
10
+ import re
11
+ import socket
12
+ import time
13
+ import urllib.error
14
+ import urllib.parse
15
+ import urllib.request
16
+ import uuid
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple, Union
19
+
20
+ from . import webhooks as _webhooks
21
+ from .errors import ConvertajaError, parse_retry_after
22
+
23
+ __version__ = "0.1.0"
24
+ DEFAULT_BASE_URL = "https://convertaja.com.br"
25
+ MAX_RETRY_AFTER = 60.0
26
+ MAX_BACKOFF = 8.0
27
+ # 429 que não adianta repetir em seguida (cota diária de teste, lembrete repetido)
28
+ NON_RETRYABLE_429 = {"test_limit", "reminder_too_soon"}
29
+
30
+ JSON = Dict[str, Any]
31
+ # Arquivo: caminho (str/PathLike), bytes, objeto com .read(), {"path"}, {"data", "name"}, {"url"} ou {"base64", "name"}
32
+ FileInput = Any
33
+
34
+
35
+ @dataclass
36
+ class BinaryFile:
37
+ """Arquivo baixado da API."""
38
+
39
+ data: bytes
40
+ filename: Optional[str] = None
41
+ content_type: Optional[str] = None
42
+
43
+ @property
44
+ def size(self) -> int:
45
+ return len(self.data)
46
+
47
+ def save(self, path: Union[str, "os.PathLike[str]"]) -> str:
48
+ """Grava o arquivo no caminho indicado e devolve o caminho."""
49
+ with open(path, "wb") as fh:
50
+ fh.write(self.data)
51
+ return os.fspath(path)
52
+
53
+
54
+ @dataclass
55
+ class LastResponse:
56
+ status: int
57
+ request_id: Optional[str]
58
+ replayed: bool
59
+ idempotency_key: Optional[str]
60
+ attempts: int
61
+ headers: Dict[str, str] = field(default_factory=dict)
62
+
63
+
64
+ @dataclass
65
+ class _Upload:
66
+ local: bool
67
+ name: str = "arquivo"
68
+ data: bytes = b""
69
+ value: Optional[JSON] = None
70
+
71
+
72
+ def _normalize_base_url(url: Optional[str]) -> str:
73
+ s = (url or DEFAULT_BASE_URL).strip().rstrip("/")
74
+ if s.endswith("/v1"):
75
+ s = s[:-3]
76
+ return s
77
+
78
+
79
+ def _query_string(query: Optional[Mapping[str, Any]]) -> str:
80
+ if not query:
81
+ return ""
82
+ pairs: List[Tuple[str, str]] = []
83
+ for k, v in query.items():
84
+ if v is None or v == "":
85
+ continue
86
+ if isinstance(v, bool):
87
+ v = "true" if v else "false"
88
+ elif isinstance(v, (list, tuple)):
89
+ v = ",".join(str(x) for x in v)
90
+ elif hasattr(v, "isoformat"):
91
+ v = v.isoformat()
92
+ pairs.append((k, str(v)))
93
+ return ("?" + urllib.parse.urlencode(pairs)) if pairs else ""
94
+
95
+
96
+ def _seg(v: Any) -> str:
97
+ return urllib.parse.quote(str(v), safe="")
98
+
99
+
100
+ def _filename_from(cd: Optional[str]) -> Optional[str]:
101
+ if not cd:
102
+ return None
103
+ m = re.search(r"filename\*\s*=\s*(?:UTF-8|utf-8)''([^;]+)", cd)
104
+ if m:
105
+ try:
106
+ return urllib.parse.unquote(m.group(1).strip().strip('"'), encoding="utf-8", errors="strict")
107
+ except UnicodeDecodeError:
108
+ pass
109
+ m = re.search(r'filename\s*=\s*"?([^";]+)"?', cd, re.IGNORECASE)
110
+ return m.group(1).strip() if m else None
111
+
112
+
113
+ def _as_list(v: Any) -> List[Any]:
114
+ if v is None:
115
+ return []
116
+ if isinstance(v, (list, tuple)):
117
+ return list(v)
118
+ return [v]
119
+
120
+
121
+ def _to_upload(item: FileInput, index: int, fallback: str = "arquivo") -> _Upload:
122
+ if item is None:
123
+ raise TypeError(f"Arquivo {index + 1} vazio.")
124
+ if isinstance(item, (str, os.PathLike)):
125
+ p = os.fspath(item)
126
+ with open(p, "rb") as fh:
127
+ return _Upload(True, os.path.basename(p), fh.read())
128
+ if isinstance(item, (bytes, bytearray, memoryview)):
129
+ return _Upload(True, fallback, bytes(item))
130
+ if isinstance(item, Mapping):
131
+ if isinstance(item.get("url"), str):
132
+ v: JSON = {"url": item["url"]}
133
+ if item.get("name"):
134
+ v["name"] = item["name"]
135
+ return _Upload(False, value=v)
136
+ if isinstance(item.get("base64"), str):
137
+ return _Upload(False, value={"base64": item["base64"], "name": item.get("name") or fallback})
138
+ if item.get("path") is not None:
139
+ p = os.fspath(item["path"])
140
+ with open(p, "rb") as fh:
141
+ return _Upload(True, item.get("name") or os.path.basename(p), fh.read())
142
+ if item.get("data") is not None:
143
+ d = item["data"]
144
+ if hasattr(d, "read"):
145
+ d = d.read()
146
+ if isinstance(d, str):
147
+ d = d.encode("utf-8")
148
+ return _Upload(True, item.get("name") or fallback, bytes(d))
149
+ if hasattr(item, "read"):
150
+ data = item.read()
151
+ if isinstance(data, str):
152
+ data = data.encode("utf-8")
153
+ name = os.path.basename(str(getattr(item, "name", "") or "")) or fallback
154
+ return _Upload(True, name, bytes(data))
155
+ raise TypeError(f"Arquivo {index + 1}: use um caminho, bytes, arquivo aberto, {{'data', 'name'}}, {{'url'}} ou {{'base64', 'name'}}.")
156
+
157
+
158
+ def _to_json_file(u: _Upload) -> JSON:
159
+ if not u.local:
160
+ return dict(u.value or {})
161
+ return {"base64": base64.b64encode(u.data).decode("ascii"), "name": u.name}
162
+
163
+
164
+ def _quote_name(s: str) -> str:
165
+ return s.replace("\\", "\\\\").replace('"', "%22").replace("\r", " ").replace("\n", " ")
166
+
167
+
168
+ def _encode_multipart(fields: Sequence[Tuple[str, str]], files: Sequence[Tuple[str, _Upload]]) -> Tuple[bytes, str]:
169
+ boundary = "----convertaja" + uuid.uuid4().hex
170
+ out: List[bytes] = []
171
+ for name, value in fields:
172
+ out.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{_quote_name(name)}"\r\n\r\n'.encode("utf-8"))
173
+ out.append(str(value).encode("utf-8"))
174
+ out.append(b"\r\n")
175
+ for name, up in files:
176
+ ctype = mimetypes.guess_type(up.name)[0] or "application/octet-stream"
177
+ fname = _quote_name(up.name)
178
+ disp = f'form-data; name="{_quote_name(name)}"; filename="{fname}"'
179
+ if not up.name.isascii():
180
+ disp += "; filename*=UTF-8''" + urllib.parse.quote(up.name, safe="")
181
+ out.append(f"--{boundary}\r\nContent-Disposition: {disp}\r\nContent-Type: {ctype}\r\n\r\n".encode("utf-8"))
182
+ out.append(up.data)
183
+ out.append(b"\r\n")
184
+ out.append(f"--{boundary}--\r\n".encode("utf-8"))
185
+ return b"".join(out), f"multipart/form-data; boundary={boundary}"
186
+
187
+
188
+ def _wait_param(w: Union[bool, int, float, None]) -> Optional[str]:
189
+ if w is True:
190
+ return "true"
191
+ if isinstance(w, (int, float)) and not isinstance(w, bool) and w > 0:
192
+ return str(min(60, int(-(-w // 1))))
193
+ return None
194
+
195
+
196
+ class Convertaja:
197
+ """Cliente da API do Convertajá.
198
+
199
+ >>> client = Convertaja(api_key="cj_live_...")
200
+ >>> job = client.tools.run("comprimir-pdf", files=["contrato.pdf"], options={"level": "high"}, wait=True)
201
+ """
202
+
203
+ def __init__(
204
+ self,
205
+ api_key: Optional[str] = None,
206
+ *,
207
+ base_url: Optional[str] = None,
208
+ max_retries: int = 2,
209
+ timeout: float = 120.0,
210
+ retry_delay: float = 0.5,
211
+ user_agent: Optional[str] = None,
212
+ ) -> None:
213
+ key = api_key or os.environ.get("CONVERTAJA_API_KEY")
214
+ if not key:
215
+ raise ConvertajaError(
216
+ 'Informe a chave da API: Convertaja(api_key="cj_live_...") ou a variável CONVERTAJA_API_KEY.',
217
+ code="missing_api_key",
218
+ )
219
+ self.api_key = str(key)
220
+ self.base_url = _normalize_base_url(base_url)
221
+ self.max_retries = max_retries if isinstance(max_retries, int) and max_retries >= 0 else 2
222
+ self.timeout = float(timeout) if timeout and timeout > 0 else 120.0
223
+ self.retry_delay = float(retry_delay) if retry_delay is not None and retry_delay >= 0 else 0.5
224
+ self.user_agent = f"{user_agent} convertaja-python/{__version__}" if user_agent else f"convertaja-python/{__version__}"
225
+ self._sleep: Callable[[float], None] = time.sleep
226
+ self._opener = urllib.request.build_opener()
227
+
228
+ #: Último X-Credits-Remaining recebido (ou None)
229
+ self.last_credits_remaining: Optional[int] = None
230
+ #: X-RateLimit-* da última resposta: {"limit", "remaining", "reset"}
231
+ self.last_rate_limit: Optional[Dict[str, int]] = None
232
+ self.last_request_id: Optional[str] = None
233
+ self.last_response: Optional[LastResponse] = None
234
+
235
+ self.tools = Tools(self)
236
+ self.jobs = Jobs(self)
237
+ self.lookups = Lookups(self)
238
+ self.boleto = Boleto(self)
239
+ self.pix = Pix(self)
240
+ self.esign = Esign(self)
241
+ self.expedicao = Expedicao(self)
242
+ self.webhooks = WebhooksResource(self)
243
+
244
+ @property
245
+ def credits_remaining(self) -> Optional[int]:
246
+ """Créditos restantes informados pela última resposta."""
247
+ return self.last_credits_remaining
248
+
249
+ # ---------- Conta e dados ----------
250
+ def me(self, **opts: Any) -> JSON:
251
+ return self._request("GET", "/v1/me", **opts)
252
+
253
+ def usage(self, from_: Optional[str] = None, to: Optional[str] = None, **opts: Any) -> JSON:
254
+ return self._request("GET", "/v1/usage", query={"from": from_, "to": to}, **opts)
255
+
256
+ def validate(self, value: Optional[str] = None, *, type: Optional[str] = None, values: Optional[Sequence[str]] = None, **opts: Any) -> JSON: # noqa: A002
257
+ body: JSON = {}
258
+ if type is not None:
259
+ body["type"] = type
260
+ if values is not None:
261
+ body["values"] = list(values)
262
+ if value is not None:
263
+ body["value"] = value
264
+ return self._request("POST", "/v1/validate", json_body=body, **opts)
265
+
266
+ def qrcode(self, text: str, idempotency_key: Optional[str] = None, **params: Any) -> JSON:
267
+ return self._request("POST", "/v1/qrcode", json_body={"text": text, **params}, idempotency_key=idempotency_key)
268
+
269
+ def barcode(self, type: str, value: str, idempotency_key: Optional[str] = None, **params: Any) -> JSON: # noqa: A002
270
+ return self._request("POST", "/v1/barcode", json_body={"type": type, "value": value, **params}, idempotency_key=idempotency_key)
271
+
272
+ # ---------- HTTP ----------
273
+ def _backoff(self, attempt: int, retry_after: Optional[float]) -> float:
274
+ if retry_after is not None:
275
+ return min(MAX_RETRY_AFTER, max(0.0, retry_after))
276
+ return min(MAX_BACKOFF, self.retry_delay * (2 ** attempt) + random.random() * self.retry_delay * 0.25)
277
+
278
+ def _capture(self, status: int, headers: Dict[str, str], key: Optional[str], attempts: int) -> None:
279
+ cr = headers.get("x-credits-remaining")
280
+ if cr is not None and cr.strip().lstrip("-").isdigit():
281
+ self.last_credits_remaining = int(cr)
282
+ lim = headers.get("x-ratelimit-limit")
283
+ if lim is not None:
284
+ def _int(v: Optional[str]) -> int:
285
+ try:
286
+ return int(v or 0)
287
+ except ValueError:
288
+ return 0
289
+ self.last_rate_limit = {"limit": _int(lim), "remaining": _int(headers.get("x-ratelimit-remaining")), "reset": _int(headers.get("x-ratelimit-reset"))}
290
+ self.last_request_id = headers.get("x-request-id")
291
+ self.last_response = LastResponse(status, self.last_request_id, headers.get("idempotent-replayed") == "true", key, attempts, headers)
292
+
293
+ def _request(
294
+ self,
295
+ method: str,
296
+ path: str,
297
+ *,
298
+ query: Optional[Mapping[str, Any]] = None,
299
+ json_body: Any = None,
300
+ multipart: Optional[Tuple[Sequence[Tuple[str, str]], Sequence[Tuple[str, _Upload]]]] = None,
301
+ binary: bool = False,
302
+ idempotency_key: Optional[str] = None,
303
+ timeout: Optional[float] = None,
304
+ max_retries: Optional[int] = None,
305
+ ) -> Any:
306
+ """Requisição com novas tentativas (rede, 429, 5xx, 409 idempotency_in_progress).
307
+
308
+ Todo POST leva uma Idempotency-Key gerada uma vez por chamada e repetida em todas as
309
+ tentativas, para que uma nova tentativa nunca cobre duas vezes.
310
+ """
311
+ url = f"{self.base_url}{path}{_query_string(query)}"
312
+ retries = max_retries if isinstance(max_retries, int) and max_retries >= 0 else self.max_retries
313
+ key = str(idempotency_key or uuid.uuid4()) if method == "POST" else None
314
+ tmo = float(timeout) if timeout and timeout > 0 else self.timeout
315
+
316
+ body: Optional[bytes] = None
317
+ content_type: Optional[str] = None
318
+ if multipart is not None:
319
+ body, content_type = _encode_multipart(*multipart)
320
+ elif json_body is not None:
321
+ body = json.dumps(json_body, ensure_ascii=False).encode("utf-8")
322
+ content_type = "application/json"
323
+ elif method == "POST":
324
+ body = b""
325
+
326
+ attempt = 0
327
+ while True:
328
+ headers = {"Authorization": f"Bearer {self.api_key}", "Accept": "application/json", "User-Agent": self.user_agent}
329
+ if key:
330
+ headers["Idempotency-Key"] = key
331
+ if content_type:
332
+ headers["Content-Type"] = content_type
333
+ req = urllib.request.Request(url, data=body, method=method, headers=headers)
334
+ status: int
335
+ raw: bytes
336
+ try:
337
+ with self._opener.open(req, timeout=tmo) as resp:
338
+ status = resp.status
339
+ resp_headers = {k.lower(): v for k, v in resp.headers.items()}
340
+ raw = resp.read()
341
+ except urllib.error.HTTPError as e:
342
+ status = e.code
343
+ resp_headers = {k.lower(): v for k, v in (e.headers or {}).items()}
344
+ try:
345
+ raw = e.read()
346
+ except Exception: # noqa: BLE001
347
+ raw = b""
348
+ finally:
349
+ e.close()
350
+ except (urllib.error.URLError, socket.timeout, TimeoutError, ConnectionError, http.client.HTTPException, OSError) as e:
351
+ reason = getattr(e, "reason", e)
352
+ timed_out = isinstance(e, (socket.timeout, TimeoutError)) or isinstance(reason, (socket.timeout, TimeoutError))
353
+ if attempt < retries:
354
+ self._sleep(self._backoff(attempt, None))
355
+ attempt += 1
356
+ continue
357
+ if timed_out:
358
+ raise ConvertajaError(f"Tempo esgotado após {tmo:g} s ({method} {path}).", code="timeout") from e
359
+ raise ConvertajaError(f"Falha de rede em {method} {path}: {reason}", code="network_error") from e
360
+
361
+ self._capture(status, resp_headers, key, attempt + 1)
362
+ if 200 <= status < 300:
363
+ if binary:
364
+ return BinaryFile(raw, _filename_from(resp_headers.get("content-disposition")), resp_headers.get("content-type"))
365
+ if not raw:
366
+ return None
367
+ text = raw.decode("utf-8", errors="replace")
368
+ try:
369
+ return json.loads(text)
370
+ except ValueError:
371
+ return text
372
+ text = raw.decode("utf-8", errors="replace")
373
+ parsed: Any
374
+ try:
375
+ parsed = json.loads(text) if text else None
376
+ except ValueError:
377
+ parsed = text
378
+ error = ConvertajaError.from_response(status, parsed, resp_headers)
379
+ retryable = (
380
+ status >= 500
381
+ or status == 408
382
+ or (status == 429 and error.code not in NON_RETRYABLE_429)
383
+ or (status == 409 and error.code == "idempotency_in_progress")
384
+ )
385
+ if retryable and attempt < retries:
386
+ self._sleep(self._backoff(attempt, parse_retry_after(resp_headers.get("retry-after"))))
387
+ attempt += 1
388
+ continue
389
+ raise error
390
+
391
+ def _poll(self, getter: Callable[[], JSON], interval: float, timeout: float) -> JSON:
392
+ deadline = time.monotonic() + timeout
393
+ while True:
394
+ cur = getter()
395
+ if isinstance(cur, dict) and cur.get("status") in ("done", "error"):
396
+ return cur
397
+ if time.monotonic() + interval > deadline:
398
+ status = cur.get("status") if isinstance(cur, dict) else None
399
+ raise ConvertajaError(f"O recurso não terminou em {timeout:g} s (situação: {status}).", code="wait_timeout", details=cur)
400
+ time.sleep(interval)
401
+
402
+
403
+ def _opts(idempotency_key: Optional[str] = None, timeout: Optional[float] = None, max_retries: Optional[int] = None) -> Dict[str, Any]:
404
+ out: Dict[str, Any] = {}
405
+ if idempotency_key is not None:
406
+ out["idempotency_key"] = idempotency_key
407
+ if timeout is not None:
408
+ out["timeout"] = timeout
409
+ if max_retries is not None:
410
+ out["max_retries"] = max_retries
411
+ return out
412
+
413
+
414
+ class _Resource:
415
+ def __init__(self, client: Convertaja) -> None:
416
+ self._client = client
417
+
418
+
419
+ class Tools(_Resource):
420
+ def list(self, **opts: Any) -> JSON:
421
+ return self._client._request("GET", "/v1/tools", **opts)
422
+
423
+ def get(self, tool_id: str, **opts: Any) -> JSON:
424
+ return self._client._request("GET", f"/v1/tools/{_seg(tool_id)}", **opts)
425
+
426
+ def run(
427
+ self,
428
+ tool_id: str,
429
+ files: Union[FileInput, Sequence[FileInput], None] = None,
430
+ *,
431
+ options: Optional[Mapping[str, Any]] = None,
432
+ webhook_url: Optional[str] = None,
433
+ wait: Union[bool, int, float, None] = None,
434
+ extra_files: Optional[Mapping[str, FileInput]] = None,
435
+ idempotency_key: Optional[str] = None,
436
+ timeout: Optional[float] = None,
437
+ max_retries: Optional[int] = None,
438
+ ) -> JSON:
439
+ """Cria uma tarefa.
440
+
441
+ Arquivos locais vão em multipart/form-data; se houver algum ``{"url"}`` ou ``{"base64"}``,
442
+ tudo vai em JSON (os locais são convertidos para base64).
443
+ """
444
+ ups = [_to_upload(f, i) for i, f in enumerate(_as_list(files))]
445
+ extras = {k: _to_upload(v, 0, k) for k, v in (extra_files or {}).items() if v is not None}
446
+ everything = ups + list(extras.values())
447
+ w = _wait_param(wait)
448
+ if timeout is None and w:
449
+ timeout = 90.0 # ?wait segura a resposta até 60 s
450
+ common = dict(query={"wait": w}, **_opts(idempotency_key, timeout, max_retries))
451
+ path = f"/v1/tools/{_seg(tool_id)}"
452
+ if everything and all(u.local for u in everything):
453
+ fields: List[Tuple[str, str]] = []
454
+ if options:
455
+ fields.append(("options", json.dumps(dict(options), ensure_ascii=False)))
456
+ if webhook_url:
457
+ fields.append(("webhook_url", webhook_url))
458
+ parts = [("files", u) for u in ups] + [(k, u) for k, u in extras.items()]
459
+ return self._client._request("POST", path, multipart=(fields, parts), **common)
460
+ body: JSON = {"files": [_to_json_file(u) for u in ups]}
461
+ if extras:
462
+ body["extra_files"] = {k: _to_json_file(u) for k, u in extras.items()}
463
+ if options is not None:
464
+ body["options"] = dict(options)
465
+ if webhook_url:
466
+ body["webhook_url"] = webhook_url
467
+ return self._client._request("POST", path, json_body=body, **common)
468
+
469
+
470
+ class Jobs(_Resource):
471
+ def list(
472
+ self,
473
+ *,
474
+ limit: Optional[int] = None,
475
+ starting_after: Optional[str] = None,
476
+ status: Union[str, Sequence[str], None] = None,
477
+ tool: Optional[str] = None,
478
+ created_after: Any = None,
479
+ created_before: Any = None,
480
+ **opts: Any,
481
+ ) -> JSON:
482
+ q = {"limit": limit, "starting_after": starting_after, "status": status, "tool": tool, "created_after": created_after, "created_before": created_before}
483
+ return self._client._request("GET", "/v1/jobs", query=q, **opts)
484
+
485
+ def list_all(self, **params: Any) -> Iterator[JSON]:
486
+ """Percorre todas as páginas."""
487
+ cursor = params.pop("starting_after", None)
488
+ while True:
489
+ page = self.list(starting_after=cursor, **params)
490
+ for j in page.get("data") or []:
491
+ yield j
492
+ if not page.get("has_more") or not page.get("next_cursor"):
493
+ return
494
+ cursor = page["next_cursor"]
495
+
496
+ def get(self, job_id: str, **opts: Any) -> JSON:
497
+ return self._client._request("GET", f"/v1/jobs/{_seg(job_id)}", **opts)
498
+
499
+ def delete(self, job_id: str, **opts: Any) -> JSON:
500
+ return self._client._request("POST", f"/v1/jobs/{_seg(job_id)}/delete", **opts)
501
+
502
+ def wait(self, job_id: str, *, interval: float = 2.0, timeout: float = 300.0) -> JSON:
503
+ """Consulta a tarefa até ``status`` ser ``done`` ou ``error``."""
504
+ return self._client._poll(lambda: self.get(job_id), interval, timeout)
505
+
506
+ def download_file(self, job_id: str, index: int = 0, **opts: Any) -> BinaryFile:
507
+ return self._client._request("GET", f"/v1/jobs/{_seg(job_id)}/files/{_seg(index)}", binary=True, **opts)
508
+
509
+ def download(self, job_id: str, **opts: Any) -> BinaryFile:
510
+ return self._client._request("GET", f"/v1/jobs/{_seg(job_id)}/download", binary=True, **opts)
511
+
512
+
513
+ class Lookups(_Resource):
514
+ def cnpj(self, cnpj: str, **opts: Any) -> JSON:
515
+ clean = re.sub(r"[.\-/\s]", "", str(cnpj))
516
+ return self._client._request("GET", f"/v1/cnpj/{_seg(clean)}", **opts)
517
+
518
+ def cep(self, cep: str, **opts: Any) -> JSON:
519
+ return self._client._request("GET", f"/v1/cep/{_seg(re.sub(r'[^0-9]', '', str(cep)))}", **opts)
520
+
521
+
522
+ class Boleto(_Resource):
523
+ def parse(self, code: str, **opts: Any) -> JSON:
524
+ return self._client._request("POST", "/v1/boleto/parse", json_body={"code": str(code)}, **opts)
525
+
526
+
527
+ class Pix(_Resource):
528
+ def brcode(self, key: str, name: str, city: str, idempotency_key: Optional[str] = None, **params: Any) -> JSON:
529
+ """Gera o Pix copia e cola. Parâmetros extras: amount, txid, description, format, module_size, margin, color, background."""
530
+ return self._client._request("POST", "/v1/pix/brcode", json_body={"key": key, "name": name, "city": city, **params}, idempotency_key=idempotency_key)
531
+
532
+
533
+ class Envelopes(_Resource):
534
+ def create(
535
+ self,
536
+ file: FileInput,
537
+ signers: Sequence[Mapping[str, Any]],
538
+ *,
539
+ fields: Optional[Sequence[Mapping[str, Any]]] = None,
540
+ name: Optional[str] = None,
541
+ message: Optional[str] = None,
542
+ expires_at: Optional[str] = None,
543
+ sequential: Optional[bool] = None,
544
+ send: Optional[bool] = None,
545
+ return_links: Optional[bool] = None,
546
+ webhook_url: Optional[str] = None,
547
+ idempotency_key: Optional[str] = None,
548
+ timeout: Optional[float] = None,
549
+ max_retries: Optional[int] = None,
550
+ ) -> JSON:
551
+ """Cria um envelope de assinatura (PDF local em multipart, ou {"url"}/{"base64"} em JSON)."""
552
+ up = _to_upload(file, 0, "documento.pdf")
553
+ data: JSON = {"signers": [dict(s) for s in signers]}
554
+ for k, v in (("fields", fields), ("name", name), ("message", message), ("expires_at", expires_at), ("sequential", sequential), ("send", send), ("return_links", return_links), ("webhook_url", webhook_url)):
555
+ if v is not None:
556
+ data[k] = [dict(x) for x in v] if k == "fields" else v
557
+ o = _opts(idempotency_key, timeout, max_retries)
558
+ if up.local:
559
+ return self._client._request("POST", "/v1/esign/envelopes", multipart=([("data", json.dumps(data, ensure_ascii=False))], [("file", up)]), **o)
560
+ return self._client._request("POST", "/v1/esign/envelopes", json_body={"file": up.value, **data}, **o)
561
+
562
+ def list(self, *, limit: Optional[int] = None, cursor: Optional[str] = None, offset: Optional[int] = None, status: Optional[str] = None, **opts: Any) -> JSON:
563
+ return self._client._request("GET", "/v1/esign/envelopes", query={"limit": limit, "cursor": cursor, "offset": offset, "status": status}, **opts)
564
+
565
+ def list_all(self, **params: Any) -> Iterator[JSON]:
566
+ cursor = params.pop("cursor", None)
567
+ while True:
568
+ page = self.list(cursor=cursor, **params)
569
+ for e in page.get("data") or []:
570
+ yield e
571
+ if not page.get("has_more") or not page.get("next_cursor"):
572
+ return
573
+ cursor = page["next_cursor"]
574
+
575
+ def get(self, envelope_id: str, *, events: bool = False, links: bool = False, **opts: Any) -> JSON:
576
+ q = {"events": "true" if events else None, "links": "true" if links else None}
577
+ return self._client._request("GET", f"/v1/esign/envelopes/{_seg(envelope_id)}", query=q, **opts)
578
+
579
+ def send(self, envelope_id: str, *, return_links: Optional[bool] = None, **opts: Any) -> JSON:
580
+ body = None if return_links is None else {"return_links": bool(return_links)}
581
+ return self._client._request("POST", f"/v1/esign/envelopes/{_seg(envelope_id)}/send", json_body=body, **opts)
582
+
583
+ def remind(self, envelope_id: str, **opts: Any) -> JSON:
584
+ return self._client._request("POST", f"/v1/esign/envelopes/{_seg(envelope_id)}/remind", **opts)
585
+
586
+ def cancel(self, envelope_id: str, **opts: Any) -> JSON:
587
+ return self._client._request("POST", f"/v1/esign/envelopes/{_seg(envelope_id)}/cancel", **opts)
588
+
589
+ def delete(self, envelope_id: str, **opts: Any) -> JSON:
590
+ return self._client._request("POST", f"/v1/esign/envelopes/{_seg(envelope_id)}/delete", **opts)
591
+
592
+ def download_final(self, envelope_id: str, **opts: Any) -> BinaryFile:
593
+ return self._client._request("GET", f"/v1/esign/envelopes/{_seg(envelope_id)}/files/final", binary=True, **opts)
594
+
595
+ def download_audit(self, envelope_id: str, **opts: Any) -> BinaryFile:
596
+ return self._client._request("GET", f"/v1/esign/envelopes/{_seg(envelope_id)}/files/audit", binary=True, **opts)
597
+
598
+
599
+ class Esign(_Resource):
600
+ def __init__(self, client: Convertaja) -> None:
601
+ super().__init__(client)
602
+ self.envelopes = Envelopes(client)
603
+
604
+
605
+ class Lotes(_Resource):
606
+ def create(
607
+ self,
608
+ labels: Union[FileInput, Sequence[FileInput]],
609
+ xmls: Union[FileInput, Sequence[FileInput], None] = None,
610
+ *,
611
+ intercalar: Optional[bool] = None,
612
+ wait: Union[bool, int, float, None] = None,
613
+ idempotency_key: Optional[str] = None,
614
+ timeout: Optional[float] = None,
615
+ max_retries: Optional[int] = None,
616
+ ) -> JSON:
617
+ """Cria um lote da Central de Expedição: labels (PDFs das etiquetas) e xmls (XMLs ou ZIP das NF-e)."""
618
+ lab = [_to_upload(f, i, f"etiquetas-{i + 1}.pdf") for i, f in enumerate(_as_list(labels))]
619
+ xm = [_to_upload(f, i, f"notas-{i + 1}.xml") for i, f in enumerate(_as_list(xmls))]
620
+ if not lab:
621
+ raise TypeError('Informe pelo menos um PDF de etiquetas em "labels".')
622
+ if any(not u.local for u in lab + xm):
623
+ raise TypeError("A Central de Expedição aceita só arquivos locais (multipart).")
624
+ fields: List[Tuple[str, str]] = []
625
+ if intercalar is not None:
626
+ fields.append(("intercalar", "true" if intercalar else "false"))
627
+ w = _wait_param(wait)
628
+ if timeout is None and w:
629
+ timeout = 90.0
630
+ parts = [("labels", u) for u in lab] + [("xmls", u) for u in xm]
631
+ return self._client._request("POST", "/v1/expedicao/lotes", query={"wait": w}, multipart=(fields, parts), **_opts(idempotency_key, timeout, max_retries))
632
+
633
+ def get(self, lote_id: str, **opts: Any) -> JSON:
634
+ return self._client._request("GET", f"/v1/expedicao/lotes/{_seg(lote_id)}", **opts)
635
+
636
+ def wait(self, lote_id: str, *, interval: float = 2.0, timeout: float = 300.0) -> JSON:
637
+ return self._client._poll(lambda: self.get(lote_id), interval, timeout)
638
+
639
+ def download_file(self, lote_id: str, name: str, **opts: Any) -> BinaryFile:
640
+ return self._client._request("GET", f"/v1/expedicao/lotes/{_seg(lote_id)}/files/{_seg(name)}", binary=True, **opts)
641
+
642
+ def download(self, lote_id: str, **opts: Any) -> BinaryFile:
643
+ return self._client._request("GET", f"/v1/expedicao/lotes/{_seg(lote_id)}/download", binary=True, **opts)
644
+
645
+
646
+ class Expedicao(_Resource):
647
+ def __init__(self, client: Convertaja) -> None:
648
+ super().__init__(client)
649
+ self.lotes = Lotes(client)
650
+
651
+
652
+ class WebhooksResource(_Resource):
653
+ verify = staticmethod(_webhooks.verify)
654
+ construct_event = staticmethod(_webhooks.construct_event)
655
+
656
+ def test(self, url: str, **opts: Any) -> JSON:
657
+ """Envia um evento webhook.test assinado para a URL e devolve o que o receptor respondeu."""
658
+ return self._client._request("POST", "/v1/webhooks/test", json_body={"url": url}, **opts)
convertaja/errors.py ADDED
@@ -0,0 +1,74 @@
1
+ """Erros do SDK do Convertajá."""
2
+ from __future__ import annotations
3
+
4
+ import email.utils
5
+ import time
6
+ from typing import Any, Mapping, Optional
7
+
8
+
9
+ class ConvertajaError(Exception):
10
+ """Erro de uma chamada à API (HTTP 4xx/5xx), de rede, de tempo esgotado ou de webhook.
11
+
12
+ A API responde ``{"error": {"code", "message", "request_id"}}``; ``code`` é estável
13
+ e ``message`` vem em português.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ message: str,
19
+ *,
20
+ status: Optional[int] = None,
21
+ code: str = "unknown_error",
22
+ request_id: Optional[str] = None,
23
+ details: Any = None,
24
+ headers: Optional[Mapping[str, str]] = None,
25
+ retry_after: Optional[float] = None,
26
+ ) -> None:
27
+ super().__init__(message)
28
+ self.message = message
29
+ self.status = status
30
+ self.code = code
31
+ self.request_id = request_id
32
+ self.details = details
33
+ self.headers = dict(headers) if headers else None
34
+ self.retry_after = retry_after
35
+
36
+ def __repr__(self) -> str:
37
+ return f"ConvertajaError(status={self.status!r}, code={self.code!r}, message={self.message!r}, request_id={self.request_id!r})"
38
+
39
+ @classmethod
40
+ def from_response(cls, status: int, body: Any, headers: Mapping[str, str]) -> "ConvertajaError":
41
+ err = body.get("error") if isinstance(body, dict) and isinstance(body.get("error"), dict) else {}
42
+ message = err.get("message") or (body[:300] if isinstance(body, str) and body else f"HTTP {status}")
43
+ return cls(
44
+ message,
45
+ status=status,
46
+ code=err.get("code") or f"http_{status}",
47
+ request_id=err.get("request_id") or headers.get("x-request-id"),
48
+ details=err or body or None,
49
+ headers=headers,
50
+ retry_after=parse_retry_after(headers.get("retry-after")),
51
+ )
52
+
53
+
54
+ class ConvertajaSignatureError(ConvertajaError):
55
+ """Assinatura de webhook ausente, malformada, vencida ou que não confere."""
56
+
57
+ def __init__(self, message: str) -> None:
58
+ super().__init__(message, code="invalid_signature")
59
+
60
+
61
+ def parse_retry_after(value: Optional[str]) -> Optional[float]:
62
+ """Retry-After em segundos (aceita número ou data HTTP)."""
63
+ if value is None or str(value).strip() == "":
64
+ return None
65
+ s = str(value).strip()
66
+ try:
67
+ return max(0.0, float(s))
68
+ except ValueError:
69
+ pass
70
+ try:
71
+ dt = email.utils.parsedate_to_datetime(s)
72
+ return max(0.0, dt.timestamp() - time.time())
73
+ except (TypeError, ValueError, IndexError):
74
+ return None
convertaja/py.typed ADDED
File without changes
convertaja/webhooks.py ADDED
@@ -0,0 +1,93 @@
1
+ """Verificação dos webhooks do Convertajá.
2
+
3
+ Cabeçalho ``X-Convertaja-Signature: t=<unix>,v1=<hex(HMAC_SHA256(segredo, f"{t}.{corpo_bruto}"))>``.
4
+ Use SEMPRE o corpo bruto (os bytes exatos recebidos), nunca o JSON reserializado.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import hmac
10
+ import json
11
+ import time
12
+ from typing import Any, Dict, List, Optional, Tuple, Union
13
+
14
+ from .errors import ConvertajaSignatureError
15
+
16
+ DEFAULT_TOLERANCE_SEC = 300
17
+
18
+ RawBody = Union[str, bytes, bytearray, memoryview]
19
+
20
+
21
+ def _to_bytes(raw_body: RawBody) -> bytes:
22
+ if isinstance(raw_body, str):
23
+ return raw_body.encode("utf-8")
24
+ if isinstance(raw_body, (bytes, bytearray, memoryview)):
25
+ return bytes(raw_body)
26
+ raise TypeError("raw_body deve ser str ou bytes (o corpo bruto da requisição).")
27
+
28
+
29
+ def _parse_header(header: str) -> Tuple[Optional[str], List[str]]:
30
+ t: Optional[str] = None
31
+ sigs: List[str] = []
32
+ for part in str(header or "").split(","):
33
+ if "=" not in part:
34
+ continue
35
+ k, v = part.split("=", 1)
36
+ k, v = k.strip(), v.strip()
37
+ if k == "t":
38
+ t = v
39
+ elif k == "v1" and v:
40
+ sigs.append(v)
41
+ return t, sigs
42
+
43
+
44
+ def sign(secret: str, raw_body: RawBody, timestamp: Optional[int] = None) -> str:
45
+ """Gera o cabeçalho de assinatura (útil para testar o seu receptor)."""
46
+ ts = int(time.time()) if timestamp is None else int(timestamp)
47
+ mac = hmac.new(str(secret).encode("utf-8"), f"{ts}.".encode("utf-8") + _to_bytes(raw_body), hashlib.sha256).hexdigest()
48
+ return f"t={ts},v1={mac}"
49
+
50
+
51
+ def _check(raw_body: RawBody, header: Optional[str], secret: str, tolerance_sec: Optional[float]) -> Optional[str]:
52
+ if not secret:
53
+ return "Segredo do webhook ausente."
54
+ if not header:
55
+ return "Cabeçalho X-Convertaja-Signature ausente."
56
+ t, sigs = _parse_header(header)
57
+ if not t or not (t.isascii() and t.isdigit()) or not sigs:
58
+ return "Cabeçalho X-Convertaja-Signature malformado."
59
+ if tolerance_sec and tolerance_sec > 0 and abs(time.time() - int(t)) > tolerance_sec:
60
+ return "Assinatura fora da janela de tolerância (possível repetição)."
61
+ expected = hmac.new(str(secret).encode("utf-8"), f"{t}.".encode("utf-8") + _to_bytes(raw_body), hashlib.sha256).hexdigest()
62
+ if any(hmac.compare_digest(expected.encode("ascii"), s.encode("utf-8", "replace")) for s in sigs):
63
+ return None
64
+ return "Assinatura não confere."
65
+
66
+
67
+ def verify(raw_body: RawBody, header: Optional[str], secret: str, tolerance_sec: float = DEFAULT_TOLERANCE_SEC) -> bool:
68
+ """True se a assinatura confere e está dentro da tolerância (padrão 300 s)."""
69
+ try:
70
+ return _check(raw_body, header, secret, tolerance_sec) is None
71
+ except (TypeError, ValueError):
72
+ return False
73
+
74
+
75
+ def construct_event(raw_body: RawBody, header: Optional[str], secret: str, tolerance_sec: float = DEFAULT_TOLERANCE_SEC) -> Dict[str, Any]:
76
+ """Confere a assinatura e devolve o evento ``{id, type, created_at, data}``.
77
+
78
+ Lança :class:`ConvertajaSignatureError` se não conferir.
79
+ """
80
+ problem = _check(raw_body, header, secret, tolerance_sec)
81
+ if problem:
82
+ raise ConvertajaSignatureError(problem)
83
+ try:
84
+ event = json.loads(_to_bytes(raw_body).decode("utf-8"))
85
+ except (ValueError, UnicodeDecodeError):
86
+ raise ConvertajaSignatureError("Corpo do webhook não é JSON válido.") from None
87
+ if not isinstance(event, dict):
88
+ raise ConvertajaSignatureError("Corpo do webhook não é um objeto JSON.")
89
+ return event
90
+
91
+
92
+ # Alias no estilo do SDK Node
93
+ constructEvent = construct_event
@@ -0,0 +1,234 @@
1
+ Metadata-Version: 2.4
2
+ Name: convertaja
3
+ Version: 0.1.0
4
+ Summary: SDK oficial da API do Convertajá: PDF, DANFE, etiquetas ZPL, assinatura eletrônica, CNPJ, CEP, boleto e Pix.
5
+ Author-email: Convertajá <contato@convertaja.com.br>
6
+ License: MIT
7
+ Project-URL: Homepage, https://convertaja.com.br/api
8
+ Project-URL: Documentation, https://convertaja.com.br/api/docs
9
+ Keywords: convertaja,pdf,danfe,nfe,zpl,cnpj,cep,boleto,pix,assinatura eletronica,api,sdk
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Natural Language :: Portuguese (Brazilian)
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # convertaja (Python)
22
+
23
+ SDK oficial da [API do Convertajá](https://convertaja.com.br/api) para Python: comprimir e converter PDF, gerar DANFE a partir do XML da NF-e, converter etiquetas ZPL, assinatura eletrônica, Central de Expedição e consultas de CNPJ, CEP, boleto e Pix.
24
+
25
+ - Só biblioteca padrão (`urllib`), sem `requests`. Python 3.9 ou mais novo, com type hints (`py.typed`).
26
+ - Repete sozinho em falha de rede, 429 (respeitando `Retry-After`) e 5xx, **sem cobrar duas vezes** (Idempotency-Key automática).
27
+
28
+ > **Ainda não publicado no PyPI.** Até a publicação, instale pelo caminho local ou pelo repositório git (veja abaixo).
29
+
30
+ ## Instalação
31
+
32
+ ```bash
33
+ # caminho local
34
+ pip install ./caminho/para/integrations/sdk-python
35
+
36
+ # ou direto do git (quando o repositório estiver disponível)
37
+ pip install "git+https://github.com/convertaja/sdk-python.git"
38
+
39
+ # depois da publicação
40
+ pip install convertaja
41
+ ```
42
+
43
+ ## Primeiros passos
44
+
45
+ Crie a chave em **Minha conta → API** (`/conta/api`). Use `cj_test_…` para testar (não consome créditos, até 20 chamadas por dia) e `cj_live_…` em produção.
46
+
47
+ ```python
48
+ import os
49
+ from convertaja import Convertaja
50
+
51
+ cj = Convertaja(api_key=os.environ["CONVERTAJA_API_KEY"]) # ou só Convertaja() lendo CONVERTAJA_API_KEY
52
+
53
+ me = cj.me()
54
+ print(me["plan"]["label"], me["credits"]["remaining"])
55
+ print("Créditos depois da última chamada:", cj.credits_remaining)
56
+ ```
57
+
58
+ Opções: `base_url` (padrão `https://convertaja.com.br`), `max_retries` (padrão 2), `timeout` em segundos (padrão 120), `retry_delay` (padrão 0,5 s).
59
+
60
+ ### Comprimir um PDF
61
+
62
+ ```python
63
+ # wait=True segura a resposta até 60 s; se ainda não terminou, use jobs.wait
64
+ job = cj.tools.run(
65
+ "comprimir-pdf",
66
+ files=["contrato.pdf"], # caminho, bytes, arquivo aberto, {"data", "name"}, {"url"} ou {"base64", "name"}
67
+ options={"level": "high"}, # low | medium | high
68
+ wait=True,
69
+ )
70
+ if job["status"] not in ("done", "error"):
71
+ job = cj.jobs.wait(job["id"])
72
+ if job["status"] == "error":
73
+ raise RuntimeError(job["error"]["message"])
74
+
75
+ arquivo = cj.jobs.download(job["id"]) # um arquivo, ou ZIP se houver vários
76
+ arquivo.save(arquivo.filename or "comprimido.pdf")
77
+ ```
78
+
79
+ ### Consultar CNPJ e CEP (grátis)
80
+
81
+ ```python
82
+ empresa = cj.lookups.cnpj("11.222.333/0001-81") # aceita o CNPJ alfanumérico
83
+ print(empresa["razaoSocial"], empresa["situacao"])
84
+
85
+ endereco = cj.lookups.cep("01310-100")
86
+ print(endereco["logradouro"], endereco["cidade"], endereco["uf"])
87
+ ```
88
+
89
+ ### DANFE a partir do XML da NF-e
90
+
91
+ ```python
92
+ job = cj.tools.run(
93
+ "xml-nfe-para-pdf",
94
+ files=["nota.xml"], # vários XMLs ou um ZIP
95
+ options={"layout": "a4"}, # a4 | simplificado (10×15, etiqueta)
96
+ wait=True,
97
+ )
98
+ cj.jobs.download(job["id"]).save("danfe.pdf")
99
+ ```
100
+
101
+ ### ZPL para PDF
102
+
103
+ ```python
104
+ # código ZPL direto nas opções (sem arquivo)...
105
+ job = cj.tools.run(
106
+ "zpl-para-pdf",
107
+ options={"zpl": "^XA^FO50,50^A0N,40,40^FDOlá^FS^XZ", "width_mm": 100, "height_mm": 150, "dpmm": "8"},
108
+ wait=True,
109
+ )
110
+ # ...ou arquivos .zpl/.txt
111
+ job = cj.tools.run("zpl-para-pdf", files=["etiquetas.zpl"], wait=True)
112
+ cj.jobs.download(job["id"]).save("etiquetas.pdf")
113
+ ```
114
+
115
+ Liste as ferramentas, extensões aceitas e opções (JSON Schema) com `cj.tools.list()` ou `cj.tools.get("comprimir-pdf")`.
116
+
117
+ ### Assinatura eletrônica (envelope)
118
+
119
+ ```python
120
+ envelope = cj.esign.envelopes.create(
121
+ "contrato.pdf", # ou {"url": "https://…/contrato.pdf"}
122
+ signers=[
123
+ {"name": "Ana Souza", "email": "ana@cliente.com.br", "role": "sign"},
124
+ {"name": "Bruno Lima", "email": "bruno@empresa.com.br", "role": "witness"},
125
+ ],
126
+ fields=[
127
+ {"signer": 0, "type": "signature", "page": 0, "x": 0.1, "y": 0.75, "w": 0.3, "h": 0.07},
128
+ {"signer": 1, "type": "signature", "page": 0, "x": 0.55, "y": 0.75, "w": 0.3, "h": 0.07},
129
+ ],
130
+ name="Contrato de prestação de serviços",
131
+ message="Olá! Segue o contrato para assinatura.",
132
+ webhook_url="https://seusite.com.br/webhooks/convertaja",
133
+ )
134
+ print(envelope["id"], envelope["status"]) # K7QD-9WZ2-M4HT-R8PA pending
135
+
136
+ # depois que todos assinarem:
137
+ cj.esign.envelopes.download_final(envelope["id"]).save("contrato-assinado.pdf")
138
+ ```
139
+
140
+ Também há `list`, `list_all`, `get(id, events=True, links=True)`, `send` (rascunhos criados com `send=False`), `remind`, `cancel`, `delete` e `download_audit`.
141
+
142
+ ### Outros recursos
143
+
144
+ ```python
145
+ cj.validate("12.ABC.345/01DE-35", type="cnpj")
146
+ cj.validate(values=["123.456.789-09", "11.222.333/0001-81"]) # lote: 1 crédito a cada 50
147
+ cj.boleto.parse("23793.38128 86000.782713 36950.000632 1 10000000012345")
148
+ cj.pix.brcode("contato@empresa.com.br", "Empresa", "SAO PAULO", amount=10.5, format="png")
149
+ cj.qrcode("https://convertaja.com.br", format="svg")
150
+ cj.barcode("ean13", "789123456789", format="png")
151
+
152
+ # Tarefas
153
+ page = cj.jobs.list(status="done", limit=20) # page["next_cursor"] → starting_after
154
+ for job in cj.jobs.list_all(tool="comprimir-pdf"):
155
+ print(job["id"])
156
+ cj.jobs.download_file(job_id, 0).save("saida.pdf")
157
+ cj.jobs.delete(job_id) # apaga os arquivos antes de 1 hora (LGPD)
158
+
159
+ # Central de Expedição: etiquetas + XMLs das notas
160
+ lote = cj.expedicao.lotes.create(["etiquetas.pdf"], ["notas.zip"], intercalar=True, wait=True)
161
+ lote = cj.expedicao.lotes.wait(lote["id"])
162
+ cj.expedicao.lotes.download(lote["id"]).save("expedicao.zip")
163
+
164
+ # Webhook de teste (envia um evento assinado para a sua URL)
165
+ cj.webhooks.test("https://seusite.com.br/webhooks/convertaja")
166
+ ```
167
+
168
+ ## Webhooks: verificação da assinatura
169
+
170
+ Cada aviso chega com `X-Convertaja-Signature: t=<unix>,v1=<hex>`, onde `v1 = HMAC-SHA256(segredo, "<t>.<corpo bruto>")`. O segredo (`whsec_…`) fica em **Minha conta → API**. Verifique sempre com o **corpo bruto** e recuse assinaturas com mais de 300 s.
171
+
172
+ ```python
173
+ import os
174
+ from flask import Flask, request, abort
175
+ from convertaja import webhooks, ConvertajaSignatureError
176
+
177
+ app = Flask(__name__)
178
+
179
+ @app.post("/webhooks/convertaja")
180
+ def convertaja_webhook():
181
+ try:
182
+ event = webhooks.construct_event(
183
+ request.get_data(), # bytes exatos recebidos
184
+ request.headers.get("X-Convertaja-Signature"),
185
+ os.environ["CONVERTAJA_WEBHOOK_SECRET"],
186
+ )
187
+ except ConvertajaSignatureError:
188
+ abort(400)
189
+ if event["type"] == "job.completed":
190
+ tarefa = event["data"] # id, outputs, download_url…
191
+ elif event["type"] == "esign.envelope.completed":
192
+ envelope = event["data"]
193
+ return "", 200 # responda 2xx rápido; o Convertajá tenta até 3 vezes
194
+ ```
195
+
196
+ `webhooks.verify(raw_body, header, secret, tolerance_sec=300)` devolve `True`/`False` se você preferir tratar sozinho. Use o cabeçalho `X-Convertaja-Delivery` para descartar entregas repetidas.
197
+
198
+ ## Erros
199
+
200
+ Toda falha vira `ConvertajaError`:
201
+
202
+ ```python
203
+ from convertaja import ConvertajaError
204
+
205
+ try:
206
+ cj.tools.run("comprimir-pdf", files=["grande.pdf"])
207
+ except ConvertajaError as e:
208
+ print(e.status) # 402
209
+ print(e.code) # "insufficient_credits" (estável, para tratar no código)
210
+ print(e.message) # mensagem em português
211
+ print(e.request_id) # informe ao suporte
212
+ print(e.details) # corpo de erro completo (ex.: credits_required)
213
+ ```
214
+
215
+ Códigos do SDK, além dos da API: `network_error`, `timeout`, `wait_timeout` (em `jobs.wait`), `missing_api_key`, `invalid_signature`. Lista completa em [/api/docs](https://convertaja.com.br/api/docs).
216
+
217
+ ## Novas tentativas e idempotência
218
+
219
+ - Repete até `max_retries` vezes (padrão 2) em falha de rede, tempo esgotado, 408, 429 (exceto `test_limit` e `reminder_too_soon`), 5xx e 409 `idempotency_in_progress`.
220
+ - Em 429 espera o `Retry-After` da API (até 60 s); nos outros casos usa recuo exponencial (0,5 s, 1 s, 2 s… até 8 s).
221
+ - Todo POST leva uma `Idempotency-Key` (UUID) gerada **uma vez por chamada** e repetida em todas as tentativas: se a primeira chegou a ser processada, a repetição devolve a mesma resposta, sem novo débito.
222
+ - Para repetir com segurança entre processos, passe a sua chave: `cj.tools.run("comprimir-pdf", files=[...], idempotency_key=f"pedido-{pedido.id}")`. A mesma chave com outro conteúdo em 24 h dá 409 `idempotency_key_reused`.
223
+ - Depois de cada chamada: `cj.credits_remaining`, `cj.last_rate_limit`, `cj.last_request_id` e `cj.last_response` (`status`, `attempts`, `idempotency_key`, `replayed`).
224
+ - `timeout` vale por operação de rede (conexão e cada leitura), como no `urllib`.
225
+
226
+ ## Testes
227
+
228
+ ```bash
229
+ python3 -I -m unittest discover -s tests -v # servidor HTTP local numa thread; nenhuma chamada sai da máquina
230
+ ```
231
+
232
+ ## Licença
233
+
234
+ MIT
@@ -0,0 +1,10 @@
1
+ convertaja/__init__.py,sha256=ZtENTlADN9yaN6pViUS73k8c5OXZbv5b7fWR2nvJCOA,609
2
+ convertaja/client.py,sha256=7jzho4KdzokJhOw8IpRtf_5vt24kiDhRZXzJ7imDVcw,28530
3
+ convertaja/errors.py,sha256=rBsN_sqbh8MLPdC3lYSiiIwhOd3l2_TPBn8FIo7iDEo,2597
4
+ convertaja/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ convertaja/webhooks.py,sha256=V1ltcTFRWgjr2zy-VfZDe3gG6Gzh0IPgkqcRRhPQN4k,3653
6
+ convertaja-0.1.0.dist-info/licenses/LICENSE,sha256=aCKsdh-kR8Wu6X8bRA7XISy1uRCwRqIYijwbj9LqaAg,1068
7
+ convertaja-0.1.0.dist-info/METADATA,sha256=Z7u0fXK2D_814R4MI7zXD_ZP8ipMMQ8HjBRNUOkeuug,9451
8
+ convertaja-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
9
+ convertaja-0.1.0.dist-info/top_level.txt,sha256=tJArvu72ligFn4-YZ4wQNOCjcu0B_732SRcYuEAjOLw,11
10
+ convertaja-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Convertajá
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.
@@ -0,0 +1 @@
1
+ convertaja