konecty_sdk_python 2.1.1__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.
Files changed (35) hide show
  1. KonectySdkPython/__init__.py +41 -0
  2. KonectySdkPython/cli/__init__.py +38 -0
  3. KonectySdkPython/cli/apply.py +555 -0
  4. KonectySdkPython/cli/backup.py +161 -0
  5. KonectySdkPython/cli/pull.py +309 -0
  6. KonectySdkPython/lib/__init__.py +43 -0
  7. KonectySdkPython/lib/client.py +950 -0
  8. KonectySdkPython/lib/exceptions.py +26 -0
  9. KonectySdkPython/lib/feature_types/__init__.py +46 -0
  10. KonectySdkPython/lib/feature_types/cross_module_query.py +78 -0
  11. KonectySdkPython/lib/feature_types/kpi.py +21 -0
  12. KonectySdkPython/lib/feature_types/query_json.py +242 -0
  13. KonectySdkPython/lib/file_manager.py +248 -0
  14. KonectySdkPython/lib/filters.py +155 -0
  15. KonectySdkPython/lib/http.py +95 -0
  16. KonectySdkPython/lib/model.py +88 -0
  17. KonectySdkPython/lib/serialization.py +13 -0
  18. KonectySdkPython/lib/services/__init__.py +5 -0
  19. KonectySdkPython/lib/services/aggregation.py +142 -0
  20. KonectySdkPython/lib/services/auth.py +105 -0
  21. KonectySdkPython/lib/services/base.py +63 -0
  22. KonectySdkPython/lib/services/change_user.py +64 -0
  23. KonectySdkPython/lib/services/comments.py +89 -0
  24. KonectySdkPython/lib/services/export.py +54 -0
  25. KonectySdkPython/lib/services/files.py +59 -0
  26. KonectySdkPython/lib/services/notifications.py +41 -0
  27. KonectySdkPython/lib/services/query.py +192 -0
  28. KonectySdkPython/lib/services/stream.py +133 -0
  29. KonectySdkPython/lib/services/subscriptions.py +26 -0
  30. KonectySdkPython/lib/settings.py +152 -0
  31. KonectySdkPython/lib/types.py +418 -0
  32. konecty_sdk_python-2.1.1.dist-info/METADATA +155 -0
  33. konecty_sdk_python-2.1.1.dist-info/RECORD +35 -0
  34. konecty_sdk_python-2.1.1.dist-info/WHEEL +4 -0
  35. konecty_sdk_python-2.1.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,26 @@
1
+ """Exceptions for the Konecty SDK."""
2
+
3
+
4
+ class KonectyError(Exception):
5
+ """Base exception for Konecty errors."""
6
+
7
+ pass
8
+
9
+
10
+ class KonectyAPIError(KonectyError):
11
+ """Raised when the API returns success=false or a non-2xx status."""
12
+
13
+ pass
14
+
15
+
16
+ class KonectyValidationError(KonectyError):
17
+ """Raised for validation errors."""
18
+
19
+ pass
20
+
21
+
22
+ class KonectySerializationError(KonectyError):
23
+ """Raised when a value is not serializable."""
24
+
25
+ def __init__(self) -> None:
26
+ super().__init__("Tipo não serializável")
@@ -0,0 +1,46 @@
1
+ """Feature-specific types (KPI, Graph, Pivot, CrossModuleQuery, QueryJson, etc.). Re-exported for convenience."""
2
+
3
+ from .cross_module_query import (
4
+ DEFAULT_PRIMARY_LIMIT,
5
+ MAX_RELATION_LIMIT,
6
+ MAX_RELATIONS,
7
+ Aggregator,
8
+ CrossModuleQuery,
9
+ CrossModuleRelation,
10
+ )
11
+ from .kpi import KpiConfig, KpiOperation
12
+ from .query_json import (
13
+ AggregatorName,
14
+ AggregatorSpec,
15
+ ConditionValue,
16
+ ExplicitJoinCondition,
17
+ FilterMatch,
18
+ QueryFilter,
19
+ QueryFilterCondition,
20
+ QueryJson,
21
+ QueryRelation,
22
+ QuerySortItem,
23
+ SortDirection,
24
+ )
25
+
26
+ __all__ = [
27
+ "Aggregator",
28
+ "AggregatorName",
29
+ "AggregatorSpec",
30
+ "ConditionValue",
31
+ "CrossModuleQuery",
32
+ "CrossModuleRelation",
33
+ "DEFAULT_PRIMARY_LIMIT",
34
+ "ExplicitJoinCondition",
35
+ "FilterMatch",
36
+ "KpiConfig",
37
+ "KpiOperation",
38
+ "MAX_RELATION_LIMIT",
39
+ "MAX_RELATIONS",
40
+ "QueryFilter",
41
+ "QueryFilterCondition",
42
+ "QueryJson",
43
+ "QueryRelation",
44
+ "QuerySortItem",
45
+ "SortDirection",
46
+ ]
@@ -0,0 +1,78 @@
1
+ """Types for cross-module query (query/json and query/sql). Mirrors Konecty crossModuleQuery schema."""
2
+
3
+ from typing import Any, Dict, List, Optional, Union
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ MAX_RELATIONS = 10
8
+ MAX_NESTING_DEPTH = 2
9
+ MAX_RELATION_LIMIT = 100_000
10
+ DEFAULT_RELATION_LIMIT = 1000
11
+ DEFAULT_PRIMARY_LIMIT = 1000
12
+
13
+ AggregatorName = (
14
+ str # count, countDistinct, sum, avg, min, max, first, last, push, addToSet
15
+ )
16
+
17
+
18
+ class Aggregator(BaseModel):
19
+ """Aggregator in a relation or root. Field required for some (e.g. countDistinct)."""
20
+
21
+ aggregator: str
22
+ field: Optional[str] = None
23
+
24
+
25
+ class SortItem(BaseModel):
26
+ """Sort item: property and direction."""
27
+
28
+ property: str
29
+ direction: str = "ASC" # ASC | DESC
30
+
31
+
32
+ class ExplicitJoinCondition(BaseModel):
33
+ """Explicit join condition: left and right keys."""
34
+
35
+ left: str
36
+ right: str
37
+
38
+
39
+ class CrossModuleRelation(BaseModel):
40
+ """Relation in a cross-module query. At least one aggregator required."""
41
+
42
+ document: str
43
+ lookup: str
44
+ on: Optional[ExplicitJoinCondition] = None
45
+ filter: Optional[Dict[str, Any]] = None
46
+ fields: Optional[str] = None
47
+ sort: Optional[Union[str, List[SortItem]]] = None
48
+ limit: int = Field(default=DEFAULT_RELATION_LIMIT, ge=1, le=MAX_RELATION_LIMIT)
49
+ start: int = Field(default=0, ge=0)
50
+ aggregators: Dict[str, Aggregator] = Field(..., min_length=1)
51
+ relations: Optional[List["CrossModuleRelation"]] = Field(
52
+ default=None, max_length=MAX_RELATIONS
53
+ )
54
+
55
+ model_config = {"extra": "allow"}
56
+
57
+
58
+ CrossModuleRelation.model_rebuild()
59
+
60
+
61
+ class CrossModuleQuery(BaseModel):
62
+ """Body for POST /rest/query/json. Primary document + optional relations, groupBy, aggregators."""
63
+
64
+ document: str
65
+ filter: Optional[Dict[str, Any]] = None
66
+ fields: Optional[str] = None
67
+ sort: Optional[Union[str, List[SortItem]]] = None
68
+ limit: int = Field(default=DEFAULT_PRIMARY_LIMIT, ge=1, le=MAX_RELATION_LIMIT)
69
+ start: int = Field(default=0, ge=0)
70
+ relations: List[CrossModuleRelation] = Field(
71
+ default_factory=list, max_length=MAX_RELATIONS
72
+ )
73
+ groupBy: List[str] = Field(default_factory=list, alias="groupBy")
74
+ aggregators: Dict[str, Aggregator] = Field(default_factory=dict)
75
+ includeTotal: bool = Field(default=True, alias="includeTotal")
76
+ includeMeta: bool = Field(default=False, alias="includeMeta")
77
+
78
+ model_config = {"extra": "allow", "populate_by_name": True}
@@ -0,0 +1,21 @@
1
+ """KPI config for aggregation API."""
2
+
3
+ from typing import Literal, Optional
4
+
5
+ from pydantic import BaseModel, model_validator
6
+
7
+
8
+ KpiOperation = Literal["count", "sum", "avg", "min", "max", "countDistinct"]
9
+
10
+
11
+ class KpiConfig(BaseModel):
12
+ """Config for KPI aggregation. Field is required for all operations except count."""
13
+
14
+ operation: KpiOperation
15
+ field: Optional[str] = None
16
+
17
+ @model_validator(mode="after")
18
+ def count_distinct_requires_field(self) -> "KpiConfig":
19
+ if self.operation == "countDistinct" and not self.field:
20
+ raise ValueError("countDistinct requires field")
21
+ return self
@@ -0,0 +1,242 @@
1
+ """
2
+ Strongly-typed query creator for POST /rest/query/json (Konecty cross-module query).
3
+ Use classes/dataclasses with properties for full intellisense; no builder pattern.
4
+ Serializes to the exact payload Konecty expects via to_dict().
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Dict, List, Literal, Optional, Union
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Literal types (Konecty schema)
14
+ # ---------------------------------------------------------------------------
15
+
16
+ AggregatorName = Literal[
17
+ "count",
18
+ "countDistinct",
19
+ "sum",
20
+ "avg",
21
+ "min",
22
+ "max",
23
+ "first",
24
+ "last",
25
+ "push",
26
+ "addToSet",
27
+ ]
28
+ """Allowed aggregator names for relations (Konecty schema)."""
29
+
30
+ FilterMatch = Literal["and", "or"]
31
+ SortDirection = Literal["ASC", "DESC"]
32
+
33
+ # Value in a filter condition: scalar or list (e.g. for "in" operator)
34
+ ConditionValue = Union[str, int, float, bool, None, List[Union[str, int, float, bool]]]
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Filter (KonFilter)
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ @dataclass(frozen=False)
43
+ class QueryFilterCondition:
44
+ """Single condition: term, operator, optional value. Mirrors Konecty Condition."""
45
+
46
+ term: str
47
+ operator: str
48
+ value: Optional[ConditionValue] = None
49
+ editable: Optional[bool] = None
50
+ disabled: Optional[bool] = None
51
+ sort: Optional[int] = None
52
+
53
+ def to_dict(self) -> Dict[str, Any]:
54
+ out: Dict[str, Any] = {"term": self.term, "operator": self.operator}
55
+ if self.value is not None:
56
+ out["value"] = self.value
57
+ if self.editable is not None:
58
+ out["editable"] = self.editable
59
+ if self.disabled is not None:
60
+ out["disabled"] = self.disabled
61
+ if self.sort is not None:
62
+ out["sort"] = self.sort
63
+ return out
64
+
65
+
66
+ @dataclass(frozen=False)
67
+ class QueryFilter:
68
+ """Filter for query or relation. match + conditions; optional textSearch and nested filters."""
69
+
70
+ match: FilterMatch = "and"
71
+ conditions: List[QueryFilterCondition] = field(default_factory=list)
72
+ text_search: Optional[str] = None
73
+ filters: Optional[List["QueryFilter"]] = None
74
+
75
+ def to_dict(self) -> Dict[str, Any]:
76
+ out: Dict[str, Any] = {"match": self.match}
77
+ if self.conditions:
78
+ out["conditions"] = [c.to_dict() for c in self.conditions]
79
+ if self.text_search is not None:
80
+ out["textSearch"] = self.text_search
81
+ if self.filters:
82
+ out["filters"] = [f.to_dict() for f in self.filters]
83
+ return out
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Sort
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class QuerySortItem:
93
+ """Sort item: property and direction. Konecty sort element."""
94
+
95
+ property: str
96
+ direction: SortDirection = "ASC"
97
+
98
+ def to_dict(self) -> Dict[str, Any]:
99
+ return {"property": self.property, "direction": self.direction}
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Join (explicit on)
104
+ # ---------------------------------------------------------------------------
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class ExplicitJoinCondition:
109
+ """Explicit join: left and right keys. Optional in relation."""
110
+
111
+ left: str
112
+ right: str
113
+
114
+ def to_dict(self) -> Dict[str, Any]:
115
+ return {"left": self.left, "right": self.right}
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Aggregator
120
+ # ---------------------------------------------------------------------------
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class AggregatorSpec:
125
+ """Aggregator in a relation. aggregator (name); field required for countDistinct and similar."""
126
+
127
+ aggregator: AggregatorName
128
+ field: Optional[str] = None
129
+
130
+ def to_dict(self) -> Dict[str, Any]:
131
+ out: Dict[str, Any] = {"aggregator": self.aggregator}
132
+ if self.field is not None:
133
+ out["field"] = self.field
134
+ return out
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Relation (nested)
139
+ # ---------------------------------------------------------------------------
140
+
141
+ # Sort in relation: list of QuerySortItem or single string (e.g. "_createdAt DESC")
142
+ RelationSort = Union[List[QuerySortItem], str]
143
+
144
+
145
+ @dataclass(frozen=False)
146
+ class QueryRelation:
147
+ """
148
+ Relation in a cross-module query. document + lookup; at least one aggregator required.
149
+ Optional: on, filter, fields, sort, limit, start; optional nested relations.
150
+ """
151
+
152
+ document: str
153
+ lookup: str
154
+ aggregators: Dict[str, AggregatorSpec]
155
+ on: Optional[ExplicitJoinCondition] = None
156
+ filter: Optional[QueryFilter] = None
157
+ fields: Optional[str] = None
158
+ sort: Optional[RelationSort] = None
159
+ limit: int = 1000
160
+ start: int = 0
161
+ relations: Optional[List["QueryRelation"]] = None
162
+
163
+ def to_dict(self) -> Dict[str, Any]:
164
+ if not self.aggregators:
165
+ raise ValueError("At least one aggregator is required per relation")
166
+ out: Dict[str, Any] = {
167
+ "document": self.document,
168
+ "lookup": self.lookup,
169
+ "aggregators": {k: v.to_dict() for k, v in self.aggregators.items()},
170
+ "limit": self.limit,
171
+ "start": self.start,
172
+ }
173
+ if self.on is not None:
174
+ out["on"] = self.on.to_dict()
175
+ if self.filter is not None:
176
+ out["filter"] = self.filter.to_dict()
177
+ if self.fields is not None:
178
+ out["fields"] = self.fields
179
+ if self.sort is not None:
180
+ if isinstance(self.sort, str):
181
+ out["sort"] = self.sort
182
+ else:
183
+ out["sort"] = [s.to_dict() for s in self.sort]
184
+ if self.relations:
185
+ out["relations"] = [r.to_dict() for r in self.relations]
186
+ return out
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # Root query (CrossModuleQuery)
191
+ # ---------------------------------------------------------------------------
192
+
193
+ # Sort at root: list of QuerySortItem or string
194
+ QuerySort = Union[List[QuerySortItem], str]
195
+
196
+ # Limits (Konecty constants)
197
+ MAX_RELATION_LIMIT_QUERY = 100_000
198
+ DEFAULT_PRIMARY_LIMIT_QUERY = 1000
199
+
200
+
201
+ @dataclass(frozen=False)
202
+ class QueryJson:
203
+ """
204
+ Fully-typed body for POST /rest/query/json. Primary document + optional filter,
205
+ fields, sort, limit, start, relations, groupBy, aggregators, includeTotal, includeMeta.
206
+ Use to_dict() to get the API payload (camelCase where required).
207
+ """
208
+
209
+ document: str
210
+ filter: Optional[QueryFilter] = None
211
+ fields: Optional[str] = None
212
+ sort: Optional[QuerySort] = None
213
+ limit: int = DEFAULT_PRIMARY_LIMIT_QUERY
214
+ start: int = 0
215
+ relations: List[QueryRelation] = field(default_factory=list)
216
+ group_by: List[str] = field(default_factory=list)
217
+ aggregators: Dict[str, AggregatorSpec] = field(default_factory=dict)
218
+ include_total: bool = True
219
+ include_meta: bool = False
220
+
221
+ def to_dict(self) -> Dict[str, Any]:
222
+ """Payload for POST /rest/query/json (camelCase for includeTotal, includeMeta, groupBy)."""
223
+ out: Dict[str, Any] = {
224
+ "document": self.document,
225
+ "limit": max(1, min(self.limit, MAX_RELATION_LIMIT_QUERY)),
226
+ "start": max(0, self.start),
227
+ "relations": [r.to_dict() for r in self.relations],
228
+ "groupBy": self.group_by,
229
+ "aggregators": {k: v.to_dict() for k, v in self.aggregators.items()},
230
+ "includeTotal": self.include_total,
231
+ "includeMeta": self.include_meta,
232
+ }
233
+ if self.filter is not None:
234
+ out["filter"] = self.filter.to_dict()
235
+ if self.fields is not None:
236
+ out["fields"] = self.fields
237
+ if self.sort is not None:
238
+ if isinstance(self.sort, str):
239
+ out["sort"] = self.sort
240
+ else:
241
+ out["sort"] = [s.to_dict() for s in self.sort]
242
+ return out
@@ -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