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.
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