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.
- KonectySdkPython/__init__.py +41 -0
- KonectySdkPython/cli/__init__.py +38 -0
- KonectySdkPython/cli/apply.py +555 -0
- KonectySdkPython/cli/backup.py +161 -0
- KonectySdkPython/cli/pull.py +309 -0
- KonectySdkPython/lib/__init__.py +43 -0
- KonectySdkPython/lib/client.py +950 -0
- KonectySdkPython/lib/exceptions.py +26 -0
- KonectySdkPython/lib/feature_types/__init__.py +46 -0
- KonectySdkPython/lib/feature_types/cross_module_query.py +78 -0
- KonectySdkPython/lib/feature_types/kpi.py +21 -0
- KonectySdkPython/lib/feature_types/query_json.py +242 -0
- KonectySdkPython/lib/file_manager.py +248 -0
- KonectySdkPython/lib/filters.py +155 -0
- KonectySdkPython/lib/http.py +95 -0
- KonectySdkPython/lib/model.py +88 -0
- KonectySdkPython/lib/serialization.py +13 -0
- KonectySdkPython/lib/services/__init__.py +5 -0
- KonectySdkPython/lib/services/aggregation.py +142 -0
- KonectySdkPython/lib/services/auth.py +105 -0
- KonectySdkPython/lib/services/base.py +63 -0
- KonectySdkPython/lib/services/change_user.py +64 -0
- KonectySdkPython/lib/services/comments.py +89 -0
- KonectySdkPython/lib/services/export.py +54 -0
- KonectySdkPython/lib/services/files.py +59 -0
- KonectySdkPython/lib/services/notifications.py +41 -0
- KonectySdkPython/lib/services/query.py +192 -0
- KonectySdkPython/lib/services/stream.py +133 -0
- KonectySdkPython/lib/services/subscriptions.py +26 -0
- KonectySdkPython/lib/settings.py +152 -0
- KonectySdkPython/lib/types.py +418 -0
- konecty_sdk_python-2.1.1.dist-info/METADATA +155 -0
- konecty_sdk_python-2.1.1.dist-info/RECORD +35 -0
- konecty_sdk_python-2.1.1.dist-info/WHEEL +4 -0
- 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
|