fluidattacks_zoho_sdk 1.0.0__py3-none-any.whl → 2.0.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.
- fluidattacks_zoho_sdk/__init__.py +3 -5
- fluidattacks_zoho_sdk/_decoders.py +115 -31
- fluidattacks_zoho_sdk/_http_client/__init__.py +17 -0
- fluidattacks_zoho_sdk/_http_client/_client.py +348 -0
- fluidattacks_zoho_sdk/_http_client/_core.py +151 -0
- fluidattacks_zoho_sdk/_http_client/paginate.py +35 -0
- fluidattacks_zoho_sdk/bulk_export/__init__.py +29 -0
- fluidattacks_zoho_sdk/bulk_export/_client.py +195 -0
- fluidattacks_zoho_sdk/bulk_export/_decode.py +41 -0
- fluidattacks_zoho_sdk/bulk_export/core.py +97 -0
- fluidattacks_zoho_sdk/bulk_export/utils.py +72 -0
- fluidattacks_zoho_sdk/ids.py +8 -4
- fluidattacks_zoho_sdk/zoho_desk/__init__.py +51 -0
- fluidattacks_zoho_sdk/zoho_desk/_client.py +88 -0
- fluidattacks_zoho_sdk/zoho_desk/_decode.py +201 -86
- fluidattacks_zoho_sdk/zoho_desk/core.py +41 -24
- {fluidattacks_zoho_sdk-1.0.0.dist-info → fluidattacks_zoho_sdk-2.0.0.dist-info}/METADATA +2 -2
- fluidattacks_zoho_sdk-2.0.0.dist-info/RECORD +26 -0
- fluidattacks_zoho_sdk-1.0.0.dist-info/RECORD +0 -16
- {fluidattacks_zoho_sdk-1.0.0.dist-info → fluidattacks_zoho_sdk-2.0.0.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import (
|
|
2
|
+
annotations,
|
|
3
|
+
)
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import (
|
|
7
|
+
dataclass,
|
|
8
|
+
field,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from fa_purity import Cmd, Coproduct, FrozenList, Result, ResultE, UnitType, unit
|
|
12
|
+
from fa_purity.json import (
|
|
13
|
+
JsonObj,
|
|
14
|
+
)
|
|
15
|
+
from pure_requests.retry import (
|
|
16
|
+
MaxRetriesReached,
|
|
17
|
+
)
|
|
18
|
+
from requests import Response
|
|
19
|
+
from requests.exceptions import (
|
|
20
|
+
ChunkedEncodingError as RawChunkedEncodingError,
|
|
21
|
+
)
|
|
22
|
+
from requests.exceptions import (
|
|
23
|
+
ConnectionError as RawConnectionError,
|
|
24
|
+
)
|
|
25
|
+
from requests.exceptions import (
|
|
26
|
+
HTTPError as RawHTTPError,
|
|
27
|
+
)
|
|
28
|
+
from requests.exceptions import (
|
|
29
|
+
JSONDecodeError as RawJSONDecodeError,
|
|
30
|
+
)
|
|
31
|
+
from requests.exceptions import (
|
|
32
|
+
RequestException as RawRequestException,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
from fluidattacks_zoho_sdk.auth import Token
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class UnhandledErrors:
|
|
40
|
+
error: Coproduct[JSONDecodeError, Coproduct[HTTPError, RequestException]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class HandledErrors:
|
|
45
|
+
error: Coproduct[
|
|
46
|
+
HTTPError,
|
|
47
|
+
Coproduct[ChunkedEncodingError, RequestsConnectionError],
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class _Private:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class HTTPError:
|
|
58
|
+
raw: RawHTTPError
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class JSONDecodeError:
|
|
63
|
+
raw: RawJSONDecodeError
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class ChunkedEncodingError:
|
|
68
|
+
_private: _Private = field(repr=False, hash=False, compare=False)
|
|
69
|
+
raw: RawChunkedEncodingError
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class RequestsConnectionError:
|
|
74
|
+
_private: _Private = field(repr=False, hash=False, compare=False)
|
|
75
|
+
raw: RawConnectionError
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class RequestException:
|
|
80
|
+
raw: RawRequestException
|
|
81
|
+
|
|
82
|
+
def to_chunk_error(self) -> ResultE[ChunkedEncodingError]:
|
|
83
|
+
if isinstance(self.raw, RawChunkedEncodingError):
|
|
84
|
+
return Result.success(ChunkedEncodingError(_Private(), self.raw))
|
|
85
|
+
return Result.failure(ValueError("Not a ChunkedEncodingError"))
|
|
86
|
+
|
|
87
|
+
def to_connection_error(self) -> ResultE[RequestsConnectionError]:
|
|
88
|
+
if isinstance(self.raw, RawConnectionError):
|
|
89
|
+
return Result.success(RequestsConnectionError(_Private(), self.raw))
|
|
90
|
+
return Result.failure(ValueError("Not a RequestsConnectionError"))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class RelativeEndpoint:
|
|
95
|
+
paths: FrozenList[str]
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def new(*args: str) -> RelativeEndpoint:
|
|
99
|
+
return RelativeEndpoint(tuple(args))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class HttpJsonClient:
|
|
104
|
+
get: Callable[
|
|
105
|
+
[RelativeEndpoint, JsonObj], # Token
|
|
106
|
+
Cmd[
|
|
107
|
+
Result[
|
|
108
|
+
Coproduct[JsonObj, FrozenList[JsonObj]],
|
|
109
|
+
Coproduct[UnhandledErrors, MaxRetriesReached],
|
|
110
|
+
]
|
|
111
|
+
],
|
|
112
|
+
]
|
|
113
|
+
post: Callable[
|
|
114
|
+
[RelativeEndpoint, JsonObj],
|
|
115
|
+
Cmd[
|
|
116
|
+
Result[
|
|
117
|
+
Coproduct[JsonObj, FrozenList[JsonObj]],
|
|
118
|
+
Coproduct[UnhandledErrors, MaxRetriesReached],
|
|
119
|
+
]
|
|
120
|
+
],
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
get_response: Callable[
|
|
124
|
+
[RelativeEndpoint, JsonObj],
|
|
125
|
+
Cmd[Result[Response, Coproduct[UnhandledErrors, MaxRetriesReached]]],
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class TokenManager:
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class _Private:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
_private: TokenManager._Private = field(repr=False, hash=False, compare=False)
|
|
136
|
+
_inner: dict[UnitType, Token]
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def new(token: Token) -> Cmd[TokenManager]:
|
|
140
|
+
return Cmd.wrap_impure(lambda: TokenManager(TokenManager._Private(), {unit: token}))
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def get(self) -> Cmd[Token]:
|
|
144
|
+
return Cmd.wrap_impure(lambda: self._inner[unit])
|
|
145
|
+
|
|
146
|
+
def update(self, token: Token) -> Cmd[UnitType]:
|
|
147
|
+
def _action() -> UnitType:
|
|
148
|
+
self._inner[unit] = token
|
|
149
|
+
return unit
|
|
150
|
+
|
|
151
|
+
return Cmd.wrap_impure(_action)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import NewType, TypeVar
|
|
3
|
+
|
|
4
|
+
from fa_purity import Cmd, FrozenList, Maybe, Result, ResultE
|
|
5
|
+
from fa_purity._core.utils import raise_exception
|
|
6
|
+
|
|
7
|
+
from ._core import HttpJsonClient
|
|
8
|
+
|
|
9
|
+
_T = TypeVar("_T")
|
|
10
|
+
FromIndex = NewType("FromIndex", int)
|
|
11
|
+
Limit = NewType("Limit", int)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def validate_next_page(
|
|
15
|
+
from_index: FromIndex,
|
|
16
|
+
items: FrozenList[_T],
|
|
17
|
+
limit: Limit,
|
|
18
|
+
) -> ResultE[tuple[FrozenList[_T], Maybe[FromIndex]]]:
|
|
19
|
+
return Result.success(
|
|
20
|
+
(items, Maybe.some(FromIndex(from_index + limit)) if len(items) > 0 else Maybe.empty()),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_page(
|
|
25
|
+
client: HttpJsonClient,
|
|
26
|
+
from_index: Maybe[FromIndex],
|
|
27
|
+
limit: Limit,
|
|
28
|
+
get_endpoint: Callable[
|
|
29
|
+
[HttpJsonClient, Maybe[FromIndex], Limit],
|
|
30
|
+
Cmd[ResultE[tuple[FrozenList[_T], Maybe[FromIndex]]]],
|
|
31
|
+
],
|
|
32
|
+
) -> Cmd[tuple[FrozenList[_T], Maybe[FromIndex]]]:
|
|
33
|
+
return get_endpoint(client, from_index, limit).map(
|
|
34
|
+
lambda r: r.alt(raise_exception).to_union(),
|
|
35
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from fluidattacks_zoho_sdk._http_client import ClientFactory, HttpJsonClient, TokenManager
|
|
6
|
+
from fluidattacks_zoho_sdk.auth import Credentials
|
|
7
|
+
from fluidattacks_zoho_sdk.ids import OrgId
|
|
8
|
+
|
|
9
|
+
from . import _client
|
|
10
|
+
from .core import BulkClient, BulkEndpoint, FileName, ModuleName
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _from_client(client: HttpJsonClient) -> BulkClient:
|
|
14
|
+
return BulkClient(
|
|
15
|
+
lambda m, i: _client.create_bulk_export(client, m, i),
|
|
16
|
+
lambda obj: _client.get_status_bulk_export(client, obj),
|
|
17
|
+
lambda b, f: _client.download_bulk(client, b, f),
|
|
18
|
+
lambda m, e, f: _client.fetch_bulk(client, m, e, f),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class BulkApiFactory:
|
|
24
|
+
@staticmethod
|
|
25
|
+
def new(creds: Credentials, org_id: OrgId, token: TokenManager) -> BulkClient:
|
|
26
|
+
return _from_client(ClientFactory.new(creds, org_id, token))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = ["BulkApiFactory", "BulkClient", "BulkEndpoint", "FileName", "ModuleName"]
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from fa_purity import Cmd, FrozenList, Result, ResultE, Unsafe, cast_exception
|
|
5
|
+
from fa_purity.json import JsonObj, Primitive, UnfoldedFactory
|
|
6
|
+
from fluidattacks_etl_utils.bug import Bug
|
|
7
|
+
from fluidattacks_etl_utils.retry import retry_cmd
|
|
8
|
+
from requests import Response
|
|
9
|
+
|
|
10
|
+
from fluidattacks_zoho_sdk._decoders import assert_single
|
|
11
|
+
from fluidattacks_zoho_sdk._http_client import HttpJsonClient, RelativeEndpoint
|
|
12
|
+
|
|
13
|
+
from ._decode import decode_bulk_info, transform_columns
|
|
14
|
+
from .core import (
|
|
15
|
+
BulkData,
|
|
16
|
+
BulkEndpoint,
|
|
17
|
+
BulkExportObj,
|
|
18
|
+
BulkStatus,
|
|
19
|
+
FileName,
|
|
20
|
+
ModuleName,
|
|
21
|
+
ShouldRetry,
|
|
22
|
+
)
|
|
23
|
+
from .utils import _handle_status, _wait_job, unzip_data
|
|
24
|
+
|
|
25
|
+
LOG = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _retry_waiting_step(
|
|
29
|
+
retry: int,
|
|
30
|
+
result: ResultE[BulkStatus],
|
|
31
|
+
job: BulkExportObj,
|
|
32
|
+
client: HttpJsonClient,
|
|
33
|
+
) -> Cmd[ResultE[BulkStatus]]:
|
|
34
|
+
error_or_ok = result.to_coproduct().map(lambda _s: False, lambda e: isinstance(e, ShouldRetry))
|
|
35
|
+
if error_or_ok is True:
|
|
36
|
+
return _wait_job(retry, job.id_bulk).bind(
|
|
37
|
+
lambda _: get_status_bulk_export(client, job).map( # token
|
|
38
|
+
lambda r: r.bind(lambda tup: _handle_status(tup[1])),
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return Cmd.wrap_value(result)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def create_bulk_export(
|
|
46
|
+
client: HttpJsonClient,
|
|
47
|
+
# access_token: Token,
|
|
48
|
+
module: ModuleName,
|
|
49
|
+
endpoint_bulk: BulkEndpoint,
|
|
50
|
+
) -> Cmd[ResultE[tuple[BulkExportObj, BulkStatus]]]:
|
|
51
|
+
endpoint = RelativeEndpoint.new(endpoint_bulk.route)
|
|
52
|
+
params: dict[str, Primitive] = {"module": module.module}
|
|
53
|
+
|
|
54
|
+
return client.post(endpoint, UnfoldedFactory.from_dict(params)).map(
|
|
55
|
+
lambda result: (
|
|
56
|
+
result.alt(
|
|
57
|
+
lambda e: cast_exception(
|
|
58
|
+
Bug.new("_create_bulk_export", inspect.currentframe(), e, ()),
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
.bind(assert_single)
|
|
62
|
+
.bind(decode_bulk_info)
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_status_retry(
|
|
68
|
+
client: HttpJsonClient,
|
|
69
|
+
bulk: BulkExportObj,
|
|
70
|
+
max_attempts: int,
|
|
71
|
+
) -> Cmd[ResultE[BulkStatus]]:
|
|
72
|
+
def get_attempt(
|
|
73
|
+
obj: BulkExportObj,
|
|
74
|
+
client: HttpJsonClient,
|
|
75
|
+
) -> Cmd[ResultE[BulkStatus]]:
|
|
76
|
+
return get_status_bulk_export(client, obj).map(
|
|
77
|
+
lambda result: (
|
|
78
|
+
result.alt(
|
|
79
|
+
lambda e: cast_exception(
|
|
80
|
+
Bug.new("_get_bulk_export_status", inspect.currentframe(), e, ()),
|
|
81
|
+
),
|
|
82
|
+
).bind(lambda tup: _handle_status(tup[1]))
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
log = Cmd.wrap_impure(lambda: LOG.info("[API]: Starting read status of bulk export"))
|
|
87
|
+
handled = log + get_attempt(bulk, client) # token
|
|
88
|
+
|
|
89
|
+
return retry_cmd(
|
|
90
|
+
handled,
|
|
91
|
+
lambda retry, result: _retry_waiting_step(retry, result, bulk, client), # token
|
|
92
|
+
max_attempts,
|
|
93
|
+
).map(lambda r: r.alt(cast_exception))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_status_bulk_export(
|
|
97
|
+
client: HttpJsonClient,
|
|
98
|
+
# access_token: Token,
|
|
99
|
+
bulk: BulkExportObj,
|
|
100
|
+
) -> Cmd[ResultE[tuple[BulkExportObj, BulkStatus]]]:
|
|
101
|
+
endpoint = RelativeEndpoint.new("bulkExport", bulk.id_bulk.id_bulk)
|
|
102
|
+
params: dict[str, Primitive] = {}
|
|
103
|
+
|
|
104
|
+
return client.get(endpoint, UnfoldedFactory.from_dict(params)).map( # access_token
|
|
105
|
+
lambda result: (
|
|
106
|
+
result.alt(
|
|
107
|
+
lambda e: cast_exception(
|
|
108
|
+
Bug.new("_get_bulk_export_status", inspect.currentframe(), e, ()),
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
.bind(assert_single)
|
|
112
|
+
.bind(decode_bulk_info)
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _unwrap_and_unzip(
|
|
118
|
+
result: Result[Response, Exception],
|
|
119
|
+
file_name: FileName,
|
|
120
|
+
) -> Cmd[ResultE[BulkData]]:
|
|
121
|
+
return result.to_coproduct().map(
|
|
122
|
+
lambda inl: unzip_data(inl, file_name),
|
|
123
|
+
lambda ri: Cmd.wrap_value(Result.failure(ri)),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def download_bulk(
|
|
128
|
+
client: HttpJsonClient,
|
|
129
|
+
# access_token: Token,
|
|
130
|
+
bulk: BulkExportObj,
|
|
131
|
+
file_name: FileName,
|
|
132
|
+
) -> Cmd[ResultE[BulkData]]:
|
|
133
|
+
endpoint = RelativeEndpoint.new("downloadBulkExportFile")
|
|
134
|
+
params: dict[str, Primitive] = {"exportId": bulk.id_bulk.id_bulk}
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
client.get_response(endpoint, UnfoldedFactory.from_dict(params))
|
|
138
|
+
.map(
|
|
139
|
+
lambda result: result.alt(
|
|
140
|
+
lambda e: cast_exception(
|
|
141
|
+
Bug.new("_download_bulk_export", inspect.currentframe(), e, ()),
|
|
142
|
+
),
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
.bind(lambda v: _unwrap_and_unzip(v, file_name))
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _wait_and_download(
|
|
150
|
+
bulk_obj: BulkExportObj,
|
|
151
|
+
client: HttpJsonClient,
|
|
152
|
+
file_name: FileName,
|
|
153
|
+
) -> Cmd[ResultE[BulkData]]:
|
|
154
|
+
return (
|
|
155
|
+
get_status_retry(client, bulk_obj, 10) # token
|
|
156
|
+
.map(lambda r: r.map(lambda _: bulk_obj))
|
|
157
|
+
.map(lambda r: r.alt(Unsafe.raise_exception).to_union())
|
|
158
|
+
.bind(lambda bulk: download_bulk(client, bulk, file_name))
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def transform_csv_phase(csv: ResultE[BulkData]) -> ResultE[FrozenList[JsonObj]]:
|
|
163
|
+
bulk_data = csv.alt(Unsafe.raise_exception).to_union()
|
|
164
|
+
return transform_columns(bulk_data)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def fetch_bulk(
|
|
168
|
+
client: HttpJsonClient,
|
|
169
|
+
# access_token: Token,
|
|
170
|
+
module: ModuleName,
|
|
171
|
+
endpoint_bulk: BulkEndpoint,
|
|
172
|
+
file_name: FileName,
|
|
173
|
+
) -> Cmd[ResultE[FrozenList[JsonObj]]]:
|
|
174
|
+
create_bulk: Cmd[ResultE[tuple[BulkExportObj, BulkStatus]]] = create_bulk_export(
|
|
175
|
+
client,
|
|
176
|
+
# access_token,
|
|
177
|
+
module,
|
|
178
|
+
endpoint_bulk,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def on_created(
|
|
182
|
+
result: ResultE[tuple[BulkExportObj, BulkStatus]],
|
|
183
|
+
) -> Cmd[ResultE[FrozenList[JsonObj]]]:
|
|
184
|
+
return (
|
|
185
|
+
result.map(lambda pair: pair[0])
|
|
186
|
+
.to_coproduct()
|
|
187
|
+
.map(
|
|
188
|
+
lambda bulk_obj: _wait_and_download(bulk_obj, client, file_name).map(
|
|
189
|
+
lambda data: transform_csv_phase(data),
|
|
190
|
+
),
|
|
191
|
+
lambda err: Cmd.wrap_value(Result.failure(err)),
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
return create_bulk.bind(on_created)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
|
|
3
|
+
from fa_purity import FrozenList, ResultE
|
|
4
|
+
from fa_purity.json import JsonObj, JsonUnfolder, JsonValueFactory, Unfolder
|
|
5
|
+
from fluidattacks_etl_utils import smash
|
|
6
|
+
from fluidattacks_etl_utils.decode import DecodeUtils
|
|
7
|
+
|
|
8
|
+
from .core import BulkData, BulkExportId, BulkExportObj, BulkStatus, ViewId
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def decode_id_bulk(raw: JsonObj) -> ResultE[BulkExportId]:
|
|
12
|
+
return JsonUnfolder.require(raw, "exportId", DecodeUtils.to_str).map(lambda v: BulkExportId(v))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def decode_id_view(raw: JsonObj) -> ResultE[ViewId]:
|
|
16
|
+
return JsonUnfolder.require(raw, "viewId", DecodeUtils.to_str).map(lambda v: ViewId(v))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def decode_status_bulk(raw: JsonObj) -> ResultE[BulkStatus]:
|
|
20
|
+
return JsonUnfolder.require(raw, "status", DecodeUtils.to_str).bind(
|
|
21
|
+
lambda v: BulkStatus.from_raw(v),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def decode_bulk_export(raw: JsonObj) -> ResultE[BulkExportObj]:
|
|
26
|
+
return smash.smash_result_2(
|
|
27
|
+
decode_id_bulk(raw),
|
|
28
|
+
JsonUnfolder.require(raw, "module", DecodeUtils.to_str),
|
|
29
|
+
).map(lambda bulk: BulkExportObj(*bulk))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def decode_bulk_info(raw: JsonObj) -> ResultE[tuple[BulkExportObj, BulkStatus]]:
|
|
33
|
+
return smash.smash_result_2(decode_bulk_export(raw), decode_status_bulk(raw))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def transform_columns(bulk: BulkData) -> ResultE[FrozenList[JsonObj]]:
|
|
37
|
+
bulk.file.seek(0)
|
|
38
|
+
reader = csv.DictReader(bulk.file)
|
|
39
|
+
rows: list[dict[str, str | None]] = list(reader)
|
|
40
|
+
|
|
41
|
+
return JsonValueFactory.from_any(rows).bind(lambda v: Unfolder.to_list_of(v, Unfolder.to_json))
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import IO, Final, final
|
|
7
|
+
|
|
8
|
+
from fa_purity import Cmd, FrozenList, ResultE
|
|
9
|
+
from fa_purity.json import JsonObj
|
|
10
|
+
from fluidattacks_etl_utils.handle_errors import handle_value_error
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class BulkExportId:
|
|
15
|
+
id_bulk: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ViewId:
|
|
20
|
+
id_bulk: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ModuleName:
|
|
25
|
+
module: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@final
|
|
29
|
+
class BulkStatus(Enum):
|
|
30
|
+
QUEUED = "QUEUED"
|
|
31
|
+
INITIATED = "INITIATED"
|
|
32
|
+
IN_PROGRESS = "IN_PROGRESS"
|
|
33
|
+
COMPLETED = "COMPLETED"
|
|
34
|
+
FAILED = "FAILED"
|
|
35
|
+
EXPIRED = "EXPIRED"
|
|
36
|
+
CANCELLED = "CANCELLED"
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def from_raw(raw: str) -> ResultE[BulkStatus]:
|
|
40
|
+
return handle_value_error(lambda: BulkStatus(raw))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
NonTerminalStatus: Final[tuple[BulkStatus, ...]] = (
|
|
44
|
+
BulkStatus.QUEUED,
|
|
45
|
+
BulkStatus.IN_PROGRESS,
|
|
46
|
+
BulkStatus.INITIATED,
|
|
47
|
+
)
|
|
48
|
+
TerminalStatus: Final[tuple[BulkStatus, ...]] = (
|
|
49
|
+
BulkStatus.CANCELLED,
|
|
50
|
+
BulkStatus.FAILED,
|
|
51
|
+
BulkStatus.EXPIRED,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class BulkExportObj:
|
|
57
|
+
id_bulk: BulkExportId
|
|
58
|
+
module: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class BulkEndpoint:
|
|
63
|
+
route: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class ShouldRetry(Exception):
|
|
68
|
+
status: BulkStatus
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class MustRestart(Exception):
|
|
73
|
+
status: BulkStatus
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class BulkClient:
|
|
78
|
+
create_bulk: Callable[
|
|
79
|
+
[ModuleName, BulkEndpoint],
|
|
80
|
+
Cmd[ResultE[tuple[BulkExportObj, BulkStatus]]],
|
|
81
|
+
]
|
|
82
|
+
get_status: Callable[[BulkExportObj], Cmd[ResultE[tuple[BulkExportObj, BulkStatus]]]]
|
|
83
|
+
download_bulk: Callable[[BulkExportObj, FileName], Cmd[ResultE[BulkData]]]
|
|
84
|
+
fetch_bulk: Callable[
|
|
85
|
+
[ModuleName, BulkEndpoint, FileName],
|
|
86
|
+
Cmd[ResultE[FrozenList[JsonObj]]],
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class BulkData:
|
|
92
|
+
file: IO[str]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class FileName:
|
|
97
|
+
name: str
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import tempfile
|
|
3
|
+
from typing import IO
|
|
4
|
+
from zipfile import ZipFile
|
|
5
|
+
|
|
6
|
+
from fa_purity import Cmd, Result, ResultE, cast_exception
|
|
7
|
+
from pure_requests.retry import sleep_cmd
|
|
8
|
+
from requests import Response
|
|
9
|
+
|
|
10
|
+
from .core import (
|
|
11
|
+
BulkData,
|
|
12
|
+
BulkExportId,
|
|
13
|
+
BulkStatus,
|
|
14
|
+
FileName,
|
|
15
|
+
MustRestart,
|
|
16
|
+
NonTerminalStatus,
|
|
17
|
+
ShouldRetry,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
LOG = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _top_truncation(value: float, limit: float) -> float:
|
|
24
|
+
if value > limit:
|
|
25
|
+
return limit
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _waiting_msg(job: BulkExportId, time: float) -> Cmd[None]:
|
|
30
|
+
return Cmd.wrap_impure(
|
|
31
|
+
lambda: LOG.info("Waiting bulk export %s to be ready (%s)", job.id_bulk, int(time)),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _wait_job(retry: int, job: BulkExportId) -> Cmd[None]:
|
|
36
|
+
wait_time = _top_truncation(60 * retry, 5 * 50)
|
|
37
|
+
return _waiting_msg(job, wait_time) + sleep_cmd(wait_time)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _handle_status(status_bulk: BulkStatus) -> ResultE[BulkStatus]:
|
|
41
|
+
if status_bulk == BulkStatus.COMPLETED:
|
|
42
|
+
return Result.success(status_bulk)
|
|
43
|
+
if status_bulk in NonTerminalStatus:
|
|
44
|
+
return Result.failure(ShouldRetry(status_bulk))
|
|
45
|
+
return Result.failure(MustRestart(status_bulk))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def unzip_data(response: Response, file_name: FileName) -> Cmd[ResultE[BulkData]]:
|
|
49
|
+
def _action() -> ResultE[BulkData]:
|
|
50
|
+
# pylint: disable=consider-using-with
|
|
51
|
+
# need refac of BulkData for enabling the above check
|
|
52
|
+
name_file = file_name.name
|
|
53
|
+
tmp_zipdir = tempfile.mkdtemp()
|
|
54
|
+
file_zip: IO[bytes] = tempfile.NamedTemporaryFile(mode="wb+") # noqa: SIM115
|
|
55
|
+
file_unzip: IO[str] = tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") # noqa: SIM115
|
|
56
|
+
file_zip.write(response.content)
|
|
57
|
+
file_zip.seek(0)
|
|
58
|
+
LOG.debug("Unzipping file")
|
|
59
|
+
with ZipFile(file_zip, "r") as zip_obj:
|
|
60
|
+
files = zip_obj.namelist()
|
|
61
|
+
|
|
62
|
+
if name_file not in files:
|
|
63
|
+
err = ValueError(f"Expected {name_file} file. Decompressed {len(files)} files.")
|
|
64
|
+
return Result.failure(cast_exception(err))
|
|
65
|
+
zip_obj.extract(name_file, tmp_zipdir)
|
|
66
|
+
LOG.debug("Generating BulkData")
|
|
67
|
+
with open(tmp_zipdir + f"/{name_file}", encoding="UTF-8") as unzipped: # noqa: PTH123
|
|
68
|
+
file_unzip.write(unzipped.read())
|
|
69
|
+
LOG.debug("Unzipped size: %s", file_unzip.tell())
|
|
70
|
+
return Result.success(BulkData(file_unzip))
|
|
71
|
+
|
|
72
|
+
return Cmd.wrap_impure(_action)
|
fluidattacks_zoho_sdk/ids.py
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
from dataclasses import dataclass
|
|
2
2
|
|
|
3
|
-
from fa_purity import Maybe
|
|
4
3
|
from fluidattacks_etl_utils.natural import Natural
|
|
5
4
|
|
|
6
5
|
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class OrgId:
|
|
8
|
+
org_id: Natural
|
|
9
|
+
|
|
10
|
+
|
|
7
11
|
@dataclass(frozen=True)
|
|
8
12
|
class UserId:
|
|
9
13
|
user_id: Natural
|
|
@@ -11,7 +15,7 @@ class UserId:
|
|
|
11
15
|
|
|
12
16
|
@dataclass(frozen=True)
|
|
13
17
|
class AccountId:
|
|
14
|
-
|
|
18
|
+
account_id: Natural
|
|
15
19
|
|
|
16
20
|
|
|
17
21
|
@dataclass(frozen=True)
|
|
@@ -21,7 +25,7 @@ class CrmId:
|
|
|
21
25
|
|
|
22
26
|
@dataclass(frozen=True)
|
|
23
27
|
class DeparmentId:
|
|
24
|
-
id_deparment:
|
|
28
|
+
id_deparment: Natural
|
|
25
29
|
|
|
26
30
|
|
|
27
31
|
@dataclass(frozen=True)
|
|
@@ -46,7 +50,7 @@ class RoleId:
|
|
|
46
50
|
|
|
47
51
|
@dataclass(frozen=True)
|
|
48
52
|
class ProductId:
|
|
49
|
-
id_product:
|
|
53
|
+
id_product: Natural
|
|
50
54
|
|
|
51
55
|
|
|
52
56
|
@dataclass(frozen=True)
|