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 +23 -0
- convertaja/client.py +658 -0
- convertaja/errors.py +74 -0
- convertaja/py.typed +0 -0
- convertaja/webhooks.py +93 -0
- convertaja-0.1.0.dist-info/METADATA +234 -0
- convertaja-0.1.0.dist-info/RECORD +10 -0
- convertaja-0.1.0.dist-info/WHEEL +5 -0
- convertaja-0.1.0.dist-info/licenses/LICENSE +21 -0
- convertaja-0.1.0.dist-info/top_level.txt +1 -0
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,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
|