konecty-sdk-python 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.
- cli/__init__.py +7 -0
- cli/apply.py +455 -0
- cli/backup.py +129 -0
- cli/pull.py +262 -0
- konecty_sdk_python-0.1.0.dist-info/METADATA +30 -0
- konecty_sdk_python-0.1.0.dist-info/RECORD +13 -0
- konecty_sdk_python-0.1.0.dist-info/WHEEL +4 -0
- lib/client.py +423 -0
- lib/file_manager.py +248 -0
- lib/filters.py +155 -0
- lib/model.py +76 -0
- lib/settings.py +115 -0
- lib/types.py +303 -0
lib/client.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""Módulo para gerenciar configurações do Konecty."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, AsyncGenerator, Dict, List, Optional, Union, cast
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
|
|
10
|
+
from .file_manager import FileManager
|
|
11
|
+
from .filters import KonectyFilter, KonectyFindParams
|
|
12
|
+
from .types import KonectyDateTime
|
|
13
|
+
|
|
14
|
+
# Configura o logger do urllib3 para mostrar apenas erros
|
|
15
|
+
logging.getLogger("urllib3.connectionpool").setLevel(logging.ERROR)
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
KonectyDict = Dict[str, Any]
|
|
20
|
+
|
|
21
|
+
KONECTY_UPDATE_IGNORE_FIELDS = [
|
|
22
|
+
"_id",
|
|
23
|
+
"code",
|
|
24
|
+
"_updatedAt",
|
|
25
|
+
"_createdAt",
|
|
26
|
+
"_updatedBy",
|
|
27
|
+
"_createdBy",
|
|
28
|
+
]
|
|
29
|
+
KONECTY_CREATE_IGNORE_FIELDS = ["_updatedAt", "_createdAt", "_updatedBy", "_createdBy"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_first_dict(items: List[Any]) -> Optional[KonectyDict]:
|
|
33
|
+
"""Retorna o primeiro item de uma lista como dicionário ou None se estiver vazia."""
|
|
34
|
+
if not items:
|
|
35
|
+
return None
|
|
36
|
+
first = items[0]
|
|
37
|
+
if isinstance(first, dict):
|
|
38
|
+
return cast(KonectyDict, first)
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class KonectyError(Exception):
|
|
43
|
+
"""Exceção base para erros do Konecty."""
|
|
44
|
+
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class KonectyAPIError(KonectyError):
|
|
49
|
+
"""Exceção para erros da API."""
|
|
50
|
+
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class KonectyValidationError(KonectyError):
|
|
55
|
+
"""Exceção para erros de validação."""
|
|
56
|
+
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class KonectySerializationError(KonectyError):
|
|
61
|
+
"""Exceção para erros de serialização."""
|
|
62
|
+
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
super().__init__("Tipo não serializável")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def json_serial(obj: Any) -> str:
|
|
68
|
+
"""Serializa objetos para JSON."""
|
|
69
|
+
if isinstance(obj, datetime):
|
|
70
|
+
return obj.isoformat()
|
|
71
|
+
raise KonectySerializationError()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class KonectyClient:
|
|
75
|
+
def __init__(self, base_url: str, token: str) -> None:
|
|
76
|
+
self.base_url = base_url
|
|
77
|
+
self.headers = {"Authorization": f"{token}"}
|
|
78
|
+
self.file_manager = FileManager(base_url=base_url, headers=self.headers)
|
|
79
|
+
|
|
80
|
+
async def find(self, module: str, options: KonectyFindParams) -> List[KonectyDict]:
|
|
81
|
+
params: Dict[str, str] = {}
|
|
82
|
+
for key, value in options.model_dump(exclude_none=True).items():
|
|
83
|
+
params[key] = (
|
|
84
|
+
json.dumps(value, default=json_serial)
|
|
85
|
+
if key != "fields"
|
|
86
|
+
else ",".join(value)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
async with (
|
|
90
|
+
aiohttp.ClientSession() as session,
|
|
91
|
+
session.get(
|
|
92
|
+
f"{self.base_url}/rest/data/{module}/find",
|
|
93
|
+
params=params,
|
|
94
|
+
headers={"Authorization": self.headers["Authorization"]},
|
|
95
|
+
) as response,
|
|
96
|
+
):
|
|
97
|
+
response.raise_for_status()
|
|
98
|
+
result = await response.json()
|
|
99
|
+
if not result.get("success", False):
|
|
100
|
+
errors = result.get("errors", [])
|
|
101
|
+
logger.error(errors)
|
|
102
|
+
raise KonectyAPIError(errors)
|
|
103
|
+
data = result.get("data", [])
|
|
104
|
+
return cast(List[KonectyDict], data)
|
|
105
|
+
|
|
106
|
+
def find_sync(self, module: str, options: KonectyFindParams) -> List[KonectyDict]:
|
|
107
|
+
"""Versão síncrona de find."""
|
|
108
|
+
params: Dict[str, str] = {}
|
|
109
|
+
for key, value in options.model_dump(exclude_none=True).items():
|
|
110
|
+
params[key] = (
|
|
111
|
+
json.dumps(value, default=json_serial)
|
|
112
|
+
if key != "fields"
|
|
113
|
+
else ",".join(value)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
import requests
|
|
117
|
+
|
|
118
|
+
response = requests.get(
|
|
119
|
+
f"{self.base_url}/rest/data/{module}/find",
|
|
120
|
+
params=params,
|
|
121
|
+
headers={"Authorization": self.headers["Authorization"]},
|
|
122
|
+
)
|
|
123
|
+
response.raise_for_status()
|
|
124
|
+
result = response.json()
|
|
125
|
+
if not result.get("success", False):
|
|
126
|
+
errors = result.get("errors", [])
|
|
127
|
+
logger.error(errors)
|
|
128
|
+
raise KonectyAPIError(errors)
|
|
129
|
+
data = result.get("data", [])
|
|
130
|
+
return cast(List[KonectyDict], data)
|
|
131
|
+
|
|
132
|
+
def find_one_sync(
|
|
133
|
+
self, module: str, filter_params: KonectyFilter
|
|
134
|
+
) -> Optional[KonectyDict]:
|
|
135
|
+
"""Versão síncrona de find_one."""
|
|
136
|
+
find_params = KonectyFindParams(filter=filter_params, limit=1)
|
|
137
|
+
result = self.find_sync(module, find_params)
|
|
138
|
+
if not result:
|
|
139
|
+
return None
|
|
140
|
+
return cast(KonectyDict, result[0]) if isinstance(result[0], dict) else None
|
|
141
|
+
|
|
142
|
+
async def find_by_id(self, module: str, id: str) -> Optional[KonectyDict]:
|
|
143
|
+
async with (
|
|
144
|
+
aiohttp.ClientSession() as session,
|
|
145
|
+
session.get(
|
|
146
|
+
f"{self.base_url}/rest/data/{module}/{id}",
|
|
147
|
+
headers={"Authorization": self.headers["Authorization"]},
|
|
148
|
+
) as response,
|
|
149
|
+
):
|
|
150
|
+
response.raise_for_status()
|
|
151
|
+
result = await response.json()
|
|
152
|
+
if not result.get("success", False):
|
|
153
|
+
errors = result.get("errors", [])
|
|
154
|
+
logger.error(errors)
|
|
155
|
+
raise KonectyAPIError(errors)
|
|
156
|
+
data = result.get("data", [None])
|
|
157
|
+
return get_first_dict(data)
|
|
158
|
+
|
|
159
|
+
async def find_one(
|
|
160
|
+
self, module: str, filter_params: KonectyFilter
|
|
161
|
+
) -> Optional[KonectyDict]:
|
|
162
|
+
find_params = KonectyFindParams(filter=filter_params, limit=1)
|
|
163
|
+
result = await self.find(module, find_params)
|
|
164
|
+
if not result:
|
|
165
|
+
return None
|
|
166
|
+
return cast(KonectyDict, result[0]) if isinstance(result[0], dict) else None
|
|
167
|
+
|
|
168
|
+
async def create(self, module: str, data: KonectyDict) -> Optional[KonectyDict]:
|
|
169
|
+
endpoint = f"/rest/data/{module}"
|
|
170
|
+
cleaned_data = {
|
|
171
|
+
k: v for k, v in data.items() if k not in KONECTY_CREATE_IGNORE_FIELDS
|
|
172
|
+
}
|
|
173
|
+
async with (
|
|
174
|
+
aiohttp.ClientSession(base_url=self.base_url) as session,
|
|
175
|
+
session.post(
|
|
176
|
+
endpoint,
|
|
177
|
+
headers=self.headers,
|
|
178
|
+
json=json.loads(json.dumps(cleaned_data, default=json_serial)),
|
|
179
|
+
) as response,
|
|
180
|
+
):
|
|
181
|
+
result = await response.json()
|
|
182
|
+
if not result.get("success", False):
|
|
183
|
+
errors = result.get("errors", [])
|
|
184
|
+
raise KonectyAPIError(errors)
|
|
185
|
+
result_data: list[KonectyDict] = result.get("data", [])
|
|
186
|
+
if not result_data:
|
|
187
|
+
return None
|
|
188
|
+
return result_data[0]
|
|
189
|
+
|
|
190
|
+
async def update_one(
|
|
191
|
+
self, module: str, id: str, updatedAt: datetime, data: KonectyDict
|
|
192
|
+
) -> Optional[KonectyDict]:
|
|
193
|
+
endpoint = f"/rest/data/{module}"
|
|
194
|
+
cleaned_data = {
|
|
195
|
+
k: v for k, v in data.items() if k not in KONECTY_UPDATE_IGNORE_FIELDS
|
|
196
|
+
}
|
|
197
|
+
payload = {
|
|
198
|
+
"ids": [
|
|
199
|
+
{
|
|
200
|
+
"_id": id,
|
|
201
|
+
"_updatedAt": KonectyDateTime.from_datetime(updatedAt).to_json(),
|
|
202
|
+
}
|
|
203
|
+
],
|
|
204
|
+
"data": json.loads(json.dumps(cleaned_data, default=json_serial)),
|
|
205
|
+
}
|
|
206
|
+
async with (
|
|
207
|
+
aiohttp.ClientSession(base_url=self.base_url) as session,
|
|
208
|
+
session.put(
|
|
209
|
+
endpoint,
|
|
210
|
+
headers=self.headers,
|
|
211
|
+
json=json.loads(json.dumps(payload, default=json_serial)),
|
|
212
|
+
) as response,
|
|
213
|
+
):
|
|
214
|
+
result = await response.json()
|
|
215
|
+
if not result.get("success", False):
|
|
216
|
+
errors = result.get("errors", [])
|
|
217
|
+
raise KonectyAPIError(errors)
|
|
218
|
+
return result.get("data", [None])[0]
|
|
219
|
+
|
|
220
|
+
async def delete_one(
|
|
221
|
+
self, module: str, id: str, updatedAt: datetime
|
|
222
|
+
) -> Optional[KonectyDict]:
|
|
223
|
+
endpoint = f"/rest/data/{module}"
|
|
224
|
+
payload = {
|
|
225
|
+
"ids": [
|
|
226
|
+
{
|
|
227
|
+
"_id": id,
|
|
228
|
+
"_updatedAt": KonectyDateTime.from_datetime(updatedAt).to_json(),
|
|
229
|
+
}
|
|
230
|
+
],
|
|
231
|
+
}
|
|
232
|
+
async with (
|
|
233
|
+
aiohttp.ClientSession(base_url=self.base_url) as session,
|
|
234
|
+
session.delete(endpoint, headers=self.headers, json=payload) as response,
|
|
235
|
+
):
|
|
236
|
+
result = await response.json()
|
|
237
|
+
return result.get("data", [None])[0]
|
|
238
|
+
|
|
239
|
+
async def get_document(self, document_id: str) -> Optional[KonectyDict]:
|
|
240
|
+
"""Obtém o documento do Konecty."""
|
|
241
|
+
endpoint = f"/rest/menu/documents/{document_id}"
|
|
242
|
+
async with (
|
|
243
|
+
aiohttp.ClientSession(base_url=self.base_url) as session,
|
|
244
|
+
session.get(endpoint, headers=self.headers) as response,
|
|
245
|
+
):
|
|
246
|
+
result = await response.json()
|
|
247
|
+
if result is None:
|
|
248
|
+
logger.error(f"Documento {document_id} não encontrado")
|
|
249
|
+
return None
|
|
250
|
+
if isinstance(result, dict):
|
|
251
|
+
return cast(KonectyDict, result)
|
|
252
|
+
logger.error(f"Documento {document_id} retornou formato inválido")
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
async def get_schema(self, document_id: str) -> Optional[KonectyDict]:
|
|
256
|
+
"""Obtém o schema do documento e gera um modelo Pydantic."""
|
|
257
|
+
try:
|
|
258
|
+
document = await self.get_document(document_id)
|
|
259
|
+
if document is None:
|
|
260
|
+
return None
|
|
261
|
+
return document
|
|
262
|
+
except Exception as e:
|
|
263
|
+
logger.error(f"Erro ao obter schema do documento {document_id}: {e}")
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
async def get_setting(self, key: str) -> Optional[str]:
|
|
267
|
+
"""Obtém uma configuração do Konecty."""
|
|
268
|
+
setting = await self.find_one(
|
|
269
|
+
"Setting", KonectyFilter.create().add_condition("key", "equals", key)
|
|
270
|
+
)
|
|
271
|
+
if setting is None:
|
|
272
|
+
return None
|
|
273
|
+
return cast(str, setting.get("value"))
|
|
274
|
+
|
|
275
|
+
def get_setting_sync(self, key: str) -> Optional[str]:
|
|
276
|
+
"""Versão síncrona de get_setting."""
|
|
277
|
+
setting = self.find_one_sync(
|
|
278
|
+
"Setting", KonectyFilter.create().add_condition("key", "equals", key)
|
|
279
|
+
)
|
|
280
|
+
if setting is None:
|
|
281
|
+
return None
|
|
282
|
+
return cast(str, setting.get("value"))
|
|
283
|
+
|
|
284
|
+
async def count_documents(self, module: str, filter_params: KonectyFilter) -> int:
|
|
285
|
+
params: Dict[str, str] = {}
|
|
286
|
+
options = KonectyFindParams(
|
|
287
|
+
filter=filter_params,
|
|
288
|
+
fields=["_id"],
|
|
289
|
+
limit=1,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
for key, value in options.model_dump(exclude_none=True).items():
|
|
293
|
+
params[key] = (
|
|
294
|
+
json.dumps(value, default=json_serial)
|
|
295
|
+
if key != "fields"
|
|
296
|
+
else ",".join(value)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
async with (
|
|
300
|
+
aiohttp.ClientSession() as session,
|
|
301
|
+
session.get(
|
|
302
|
+
f"{self.base_url}/rest/data/{module}/find",
|
|
303
|
+
params=params,
|
|
304
|
+
headers={"Authorization": self.headers["Authorization"]},
|
|
305
|
+
) as response,
|
|
306
|
+
):
|
|
307
|
+
response.raise_for_status()
|
|
308
|
+
result = await response.json()
|
|
309
|
+
if not result.get("success", False):
|
|
310
|
+
errors = result.get("errors", [])
|
|
311
|
+
logger.error(errors)
|
|
312
|
+
raise KonectyAPIError(errors)
|
|
313
|
+
count = result.get("total", 0)
|
|
314
|
+
return count
|
|
315
|
+
|
|
316
|
+
async def upload_file(
|
|
317
|
+
self,
|
|
318
|
+
module: str,
|
|
319
|
+
record_code: str,
|
|
320
|
+
field_name: str,
|
|
321
|
+
file: Union[bytes, str, AsyncGenerator[bytes, None]],
|
|
322
|
+
file_name: Optional[str] = None,
|
|
323
|
+
file_type: Optional[str] = None,
|
|
324
|
+
) -> str:
|
|
325
|
+
"""
|
|
326
|
+
Upload a file to a specific record field in Konecty.
|
|
327
|
+
|
|
328
|
+
Parameters
|
|
329
|
+
----------
|
|
330
|
+
module : str
|
|
331
|
+
The module name where the record is located (e.g., 'Contact', 'User').
|
|
332
|
+
record_code : str
|
|
333
|
+
The unique identifier code of the record.
|
|
334
|
+
field_name : str
|
|
335
|
+
The name of the field where the file will be uploaded.
|
|
336
|
+
file : Union[bytes, str]
|
|
337
|
+
The file to upload. Can be:
|
|
338
|
+
- bytes: Raw file content (file_name is required)
|
|
339
|
+
- str: URL to the file (file_name is optional; if not provided, will use the last segment of the URL)
|
|
340
|
+
file_name : Optional[str], default=None
|
|
341
|
+
The name to use for the file when uploaded. Required when 'file' is bytes, optional otherwise.
|
|
342
|
+
|
|
343
|
+
Returns
|
|
344
|
+
-------
|
|
345
|
+
str
|
|
346
|
+
The file key (ID) assigned by Konecty, which can be used for referencing the file in future operations.
|
|
347
|
+
|
|
348
|
+
Raises
|
|
349
|
+
------
|
|
350
|
+
ValueError
|
|
351
|
+
If file_name is not provided when file is bytes, or if the file is empty or invalid.
|
|
352
|
+
TypeError
|
|
353
|
+
If the file argument is not bytes or a URL string.
|
|
354
|
+
KonectyError
|
|
355
|
+
If the API returns an error response.
|
|
356
|
+
HTTPError
|
|
357
|
+
If there is an HTTP connection error.
|
|
358
|
+
|
|
359
|
+
Limitations
|
|
360
|
+
-----------
|
|
361
|
+
- Maximum file size: 20 MB (enforced by server configuration; see nginx.conf).
|
|
362
|
+
- Only one file per call is supported.
|
|
363
|
+
- Progress tracking is not available in this version.
|
|
364
|
+
|
|
365
|
+
Examples
|
|
366
|
+
--------
|
|
367
|
+
Upload a file using a file path as string:
|
|
368
|
+
>>> file_id = await client.upload_file(
|
|
369
|
+
... module='Contact',
|
|
370
|
+
... record_code='ABC123',
|
|
371
|
+
... field_name='attachments',
|
|
372
|
+
... file='/path/to/document.pdf'
|
|
373
|
+
... )
|
|
374
|
+
|
|
375
|
+
Upload a file using a Path object:
|
|
376
|
+
>>> from pathlib import Path
|
|
377
|
+
>>> file_path = Path('/path/to/image.jpg')
|
|
378
|
+
>>> file_id = await client.upload_file(
|
|
379
|
+
... module='Contact',
|
|
380
|
+
... record_code='ABC123',
|
|
381
|
+
... field_name='photo',
|
|
382
|
+
... file=file_path
|
|
383
|
+
... )
|
|
384
|
+
|
|
385
|
+
Upload file content from bytes with a custom filename:
|
|
386
|
+
>>> with open('document.pdf', 'rb') as f:
|
|
387
|
+
... file_content = f.read()
|
|
388
|
+
>>> file_id = await client.upload_file(
|
|
389
|
+
... module='Document',
|
|
390
|
+
... record_code='XYZ789',
|
|
391
|
+
... field_name='file',
|
|
392
|
+
... file=file_content,
|
|
393
|
+
... file_name='important_document.pdf'
|
|
394
|
+
... )
|
|
395
|
+
|
|
396
|
+
Handling errors:
|
|
397
|
+
>>> try:
|
|
398
|
+
... file_id = await client.upload_file(
|
|
399
|
+
... module='Contact',
|
|
400
|
+
... record_code='INVALID',
|
|
401
|
+
... field_name='attachments',
|
|
402
|
+
... file='/invalid/path/to/file.pdf'
|
|
403
|
+
... )
|
|
404
|
+
... except FileNotFoundError as e:
|
|
405
|
+
... print(f"File not found: {e}")
|
|
406
|
+
... except ValueError as e:
|
|
407
|
+
... print(f"Validation error: {e}")
|
|
408
|
+
... except KonectyError as e:
|
|
409
|
+
... print(f"API error: {e}")
|
|
410
|
+
|
|
411
|
+
"""
|
|
412
|
+
result = await self.file_manager.upload_file(
|
|
413
|
+
module=module,
|
|
414
|
+
record_code=record_code,
|
|
415
|
+
field_name=field_name,
|
|
416
|
+
file=file,
|
|
417
|
+
file_name=file_name,
|
|
418
|
+
file_type=file_type,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
if not result.get("success", False):
|
|
422
|
+
self.file_manager.handle_error_response(result)
|
|
423
|
+
return result.get("key", "")
|
lib/file_manager.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from os import path, unlink
|
|
3
|
+
from typing import AsyncGenerator, Optional, Union
|
|
4
|
+
from urllib.parse import quote, urlparse
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FileManager:
|
|
10
|
+
"""
|
|
11
|
+
Handles file operations such as reading files as bytes and validating file existence.
|
|
12
|
+
Designed for use with KonectyClient and other components needing file manipulation.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
DEFAULT_TIMEOUT = 60
|
|
16
|
+
|
|
17
|
+
def __init__(self, base_url: str, headers: dict) -> None:
|
|
18
|
+
self.base_url = base_url.rstrip("/")
|
|
19
|
+
self.headers = headers
|
|
20
|
+
self.logger = logging.getLogger("konecty.file_manager")
|
|
21
|
+
|
|
22
|
+
async def upload_file(
|
|
23
|
+
self,
|
|
24
|
+
module: str,
|
|
25
|
+
record_code: str,
|
|
26
|
+
field_name: str,
|
|
27
|
+
file: Union[bytes, str, AsyncGenerator[bytes, None]],
|
|
28
|
+
file_name: Optional[str],
|
|
29
|
+
file_type: Optional[str],
|
|
30
|
+
) -> dict:
|
|
31
|
+
"""
|
|
32
|
+
Upload a file to Konecty. Only bytes or a URL (str) are accepted or a Stream (AsyncGenerator[bytes, None]).
|
|
33
|
+
If a URL is provided, the file will be streamed and uploaded.
|
|
34
|
+
Args:
|
|
35
|
+
module (str): Nome do módulo.
|
|
36
|
+
record_code (str): Código do registro.
|
|
37
|
+
field_name (str): Nome do campo.
|
|
38
|
+
file (bytes | str | AsyncGenerator[bytes, None]): Arquivo a ser enviado (bytes) ou URL (str) ou Stream (AsyncGenerator[bytes, None]).
|
|
39
|
+
file_name (Optional[str]): Nome do arquivo (obrigatório se file for bytes, opcional se for URL).
|
|
40
|
+
Returns:
|
|
41
|
+
dict: Resposta JSON da API.
|
|
42
|
+
Raises:
|
|
43
|
+
aiohttp.ClientError, ValueError, TypeError
|
|
44
|
+
"""
|
|
45
|
+
import aiohttp
|
|
46
|
+
url = self._build_upload_url(module, record_code, field_name)
|
|
47
|
+
form = await self.build_multipart_form(file, file_name, file_type)
|
|
48
|
+
|
|
49
|
+
async with aiohttp.ClientSession() as session:
|
|
50
|
+
client_timeout = aiohttp.ClientTimeout(total=self.DEFAULT_TIMEOUT)
|
|
51
|
+
try:
|
|
52
|
+
async with session.post(url, data=form, headers=self.headers, timeout=client_timeout) as response:
|
|
53
|
+
response.raise_for_status()
|
|
54
|
+
return await response.json()
|
|
55
|
+
except aiohttp.ClientError as e:
|
|
56
|
+
raise
|
|
57
|
+
|
|
58
|
+
def _build_upload_url(
|
|
59
|
+
self,
|
|
60
|
+
module: str,
|
|
61
|
+
record_code: str,
|
|
62
|
+
field_name: str,
|
|
63
|
+
) -> str:
|
|
64
|
+
if not all([module, record_code, field_name]):
|
|
65
|
+
raise ValueError("All parameters (module, record_code, field_name) must be provided.")
|
|
66
|
+
# URL encode each path segment
|
|
67
|
+
module_enc = quote(str(module), safe="")
|
|
68
|
+
record_code_enc = quote(str(record_code), safe="")
|
|
69
|
+
field_name_enc = quote(str(field_name), safe="")
|
|
70
|
+
|
|
71
|
+
return f"{self.base_url}/rest/file/upload/ns/access/{module_enc}/{record_code_enc}/{field_name_enc}"
|
|
72
|
+
|
|
73
|
+
async def build_multipart_form(
|
|
74
|
+
self,
|
|
75
|
+
file: Union[bytes, str, AsyncGenerator[bytes, None]],
|
|
76
|
+
file_name: Optional[str] = None,
|
|
77
|
+
file_type: Optional[str] = None,
|
|
78
|
+
) -> "aiohttp.FormData":
|
|
79
|
+
file_bytes, name = await self.prepare_file_data(file, file_name)
|
|
80
|
+
import aiohttp
|
|
81
|
+
form = aiohttp.FormData()
|
|
82
|
+
form.add_field(
|
|
83
|
+
"file",
|
|
84
|
+
file_bytes,
|
|
85
|
+
filename=name,
|
|
86
|
+
content_type=file_type,
|
|
87
|
+
)
|
|
88
|
+
return form
|
|
89
|
+
|
|
90
|
+
async def prepare_file_data(
|
|
91
|
+
self,
|
|
92
|
+
file: Union[bytes, str, AsyncGenerator[bytes, None]],
|
|
93
|
+
file_name: Optional[str] = None,
|
|
94
|
+
) -> tuple[bytes, str]:
|
|
95
|
+
"""
|
|
96
|
+
Prepara os dados do arquivo para inclusão em um request multipart.
|
|
97
|
+
Args:
|
|
98
|
+
file (bytes | str): Conteúdo em bytes ou URL.
|
|
99
|
+
file_name (Optional[str]): Nome do arquivo (obrigatório se file for bytes, opcional se for URL).
|
|
100
|
+
Returns:
|
|
101
|
+
tuple[bytes, str, str]: (conteúdo em bytes, nome do arquivo, content-type)
|
|
102
|
+
Raises:
|
|
103
|
+
ValueError: Se file_name não for fornecido quando file for bytes.
|
|
104
|
+
TypeError: Se file não for bytes ou str.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
if isinstance(file, bytes):
|
|
108
|
+
if not file_name:
|
|
109
|
+
raise ValueError("file_name must be provided when uploading bytes")
|
|
110
|
+
file_bytes = file
|
|
111
|
+
name = file_name
|
|
112
|
+
elif isinstance(file, AsyncGenerator):
|
|
113
|
+
# Save the file stream to a temporary file and read it
|
|
114
|
+
temp_file_name = f"/app/logs/{file_name}"
|
|
115
|
+
with open(temp_file_name, "wb") as temp_file:
|
|
116
|
+
async for chunk in file:
|
|
117
|
+
self.logger.info(f"Chunk: {len(chunk)}")
|
|
118
|
+
temp_file.write(chunk)
|
|
119
|
+
|
|
120
|
+
with open(temp_file_name, "rb") as temp_file:
|
|
121
|
+
file_bytes = temp_file.read()
|
|
122
|
+
|
|
123
|
+
unlink(temp_file_name)
|
|
124
|
+
name = file_name or temp_file.name
|
|
125
|
+
elif isinstance(file, str):
|
|
126
|
+
# Treat as URL
|
|
127
|
+
async with aiohttp.ClientSession() as session:
|
|
128
|
+
async with session.get(file) as resp:
|
|
129
|
+
resp.raise_for_status()
|
|
130
|
+
file_bytes = await resp.read()
|
|
131
|
+
# Use file_name if provided, else extract from URL
|
|
132
|
+
if file_name:
|
|
133
|
+
name = file_name
|
|
134
|
+
else:
|
|
135
|
+
parsed = urlparse(file)
|
|
136
|
+
name = path.basename(parsed.path) or "downloaded_file"
|
|
137
|
+
else:
|
|
138
|
+
raise TypeError("file must be bytes or a URL string")
|
|
139
|
+
# content_type, _ = mimetypes.guess_type(name)
|
|
140
|
+
# if not content_type:
|
|
141
|
+
# content_type = "application/octet-stream"
|
|
142
|
+
return file_bytes, name
|
|
143
|
+
|
|
144
|
+
def get_auth_headers(self, headers: dict) -> dict:
|
|
145
|
+
if not headers or "Authorization" not in headers:
|
|
146
|
+
raise ValueError("Authorization header is required for file upload.")
|
|
147
|
+
return dict(headers)
|
|
148
|
+
|
|
149
|
+
async def upload_file_post(
|
|
150
|
+
self,
|
|
151
|
+
module: str,
|
|
152
|
+
record_code: str,
|
|
153
|
+
field_name: str,
|
|
154
|
+
file: Union[bytes, str],
|
|
155
|
+
file_name: Optional[str],
|
|
156
|
+
timeout: int = 60,
|
|
157
|
+
) -> "aiohttp.ClientResponse":
|
|
158
|
+
import aiohttp
|
|
159
|
+
url = self._build_upload_url(module, record_code, field_name)
|
|
160
|
+
form = await self.build_multipart_form(file, file_name)
|
|
161
|
+
async with aiohttp.ClientSession() as session:
|
|
162
|
+
try:
|
|
163
|
+
client_timeout = aiohttp.ClientTimeout(total=timeout)
|
|
164
|
+
async with session.post(url, data=form, headers=self.headers, timeout=client_timeout) as response:
|
|
165
|
+
response.raise_for_status()
|
|
166
|
+
return response
|
|
167
|
+
except aiohttp.ClientError as e:
|
|
168
|
+
raise
|
|
169
|
+
|
|
170
|
+
async def parse_json_response(self, response: aiohttp.ClientResponse) -> dict:
|
|
171
|
+
try:
|
|
172
|
+
return await response.json()
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
try:
|
|
175
|
+
text = await response.text()
|
|
176
|
+
except Exception:
|
|
177
|
+
text = "<erro ao obter texto da resposta>"
|
|
178
|
+
raise ValueError(f"Falha ao decodificar JSON da resposta: {exc}. Resposta original: {text}") from exc
|
|
179
|
+
|
|
180
|
+
def handle_error_response(self, response_json: dict) -> None:
|
|
181
|
+
errors = response_json.get("errors", [])
|
|
182
|
+
if not errors:
|
|
183
|
+
raise FileManagerUnknownError("Erro desconhecido: resposta sem array 'errors'", details=response_json)
|
|
184
|
+
|
|
185
|
+
error_message = ",\n ".join(error.get("message", "Erro desconhecido") for error in errors)
|
|
186
|
+
raise FileManagerUnknownError(error_message, details=errors[0])
|
|
187
|
+
|
|
188
|
+
async def process_api_response(self, response: aiohttp.ClientResponse) -> str:
|
|
189
|
+
"""
|
|
190
|
+
Wrapper de alto nível para processar resposta de API, tratando todos os cenários de erro e sucesso.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
response (aiohttp.ClientResponse): Resposta HTTP da API.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
str: Valor do campo 'key' em caso de sucesso.
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
FileManagerAPIError: Para erros conhecidos de API.
|
|
200
|
+
ValueError: Para erros de parsing ou formato inesperado.
|
|
201
|
+
"""
|
|
202
|
+
import logging
|
|
203
|
+
logger = logging.getLogger("konecty.file_manager")
|
|
204
|
+
try:
|
|
205
|
+
response_json = await self.parse_json_response(response)
|
|
206
|
+
except Exception as exc:
|
|
207
|
+
logger.error(f"Erro ao parsear resposta JSON: {exc}")
|
|
208
|
+
raise
|
|
209
|
+
try:
|
|
210
|
+
if response_json.get("success", False):
|
|
211
|
+
key = response_json.get("key")
|
|
212
|
+
if not key:
|
|
213
|
+
raise FileManagerUnknownError("Campo 'key' ausente ou vazio na resposta de sucesso.", details=response_json)
|
|
214
|
+
return key
|
|
215
|
+
else:
|
|
216
|
+
self.handle_error_response(response_json)
|
|
217
|
+
# Se não lançar, garantir raise
|
|
218
|
+
raise FileManagerUnknownError(
|
|
219
|
+
"Resposta de erro não mapeada corretamente.", details=response_json
|
|
220
|
+
)
|
|
221
|
+
except FileManagerAPIError as api_exc:
|
|
222
|
+
logger.error(f"Erro de API: {api_exc}")
|
|
223
|
+
raise
|
|
224
|
+
except Exception as exc:
|
|
225
|
+
logger.error(f"Erro inesperado ao processar resposta: {exc}")
|
|
226
|
+
raise FileManagerUnknownError(f"Erro inesperado ao processar resposta: {exc}", details=getattr(exc, 'details', None)) from exc
|
|
227
|
+
|
|
228
|
+
class FileManagerAPIError(Exception):
|
|
229
|
+
"""Exceção base para erros de API do FileManager."""
|
|
230
|
+
def __init__(self, message: str, details: dict | None = None) -> None:
|
|
231
|
+
super().__init__(message)
|
|
232
|
+
self.details = details or {}
|
|
233
|
+
|
|
234
|
+
class FileManagerValidationError(FileManagerAPIError):
|
|
235
|
+
"""Exceção para erros de validação de entrada."""
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
class FileManagerAuthError(FileManagerAPIError):
|
|
239
|
+
"""Exceção para erros de autenticação."""
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
class FileManagerServerError(FileManagerAPIError):
|
|
243
|
+
"""Exceção para erros de servidor/backend."""
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
class FileManagerUnknownError(FileManagerAPIError):
|
|
247
|
+
"""Exceção para erros inesperados/desconhecidos."""
|
|
248
|
+
pass
|