anaplan-sdk 0.4.4a4__py3-none-any.whl → 0.5.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.
- anaplan_sdk/__init__.py +2 -0
- anaplan_sdk/_async_clients/__init__.py +4 -0
- anaplan_sdk/_async_clients/_alm.py +257 -44
- anaplan_sdk/_async_clients/_audit.py +31 -21
- anaplan_sdk/_async_clients/_bulk.py +276 -168
- anaplan_sdk/_async_clients/_cloud_works.py +61 -41
- anaplan_sdk/_async_clients/_cw_flow.py +26 -18
- anaplan_sdk/_async_clients/_scim.py +148 -0
- anaplan_sdk/_async_clients/_transactional.py +265 -56
- anaplan_sdk/_auth.py +5 -4
- anaplan_sdk/_clients/__init__.py +12 -1
- anaplan_sdk/_clients/_alm.py +255 -45
- anaplan_sdk/_clients/_audit.py +32 -22
- anaplan_sdk/_clients/_bulk.py +271 -152
- anaplan_sdk/_clients/_cloud_works.py +59 -40
- anaplan_sdk/_clients/_cw_flow.py +24 -16
- anaplan_sdk/_clients/_scim.py +145 -0
- anaplan_sdk/_clients/_transactional.py +260 -50
- anaplan_sdk/_services.py +277 -0
- anaplan_sdk/_utils.py +188 -0
- anaplan_sdk/models/__init__.py +49 -2
- anaplan_sdk/models/_alm.py +64 -6
- anaplan_sdk/models/_bulk.py +22 -13
- anaplan_sdk/models/_transactional.py +221 -4
- anaplan_sdk/models/cloud_works.py +6 -2
- anaplan_sdk/models/scim.py +282 -0
- {anaplan_sdk-0.4.4a4.dist-info → anaplan_sdk-0.5.0.dist-info}/METADATA +4 -3
- anaplan_sdk-0.5.0.dist-info/RECORD +34 -0
- anaplan_sdk/_base.py +0 -297
- anaplan_sdk-0.4.4a4.dist-info/RECORD +0 -30
- {anaplan_sdk-0.4.4a4.dist-info → anaplan_sdk-0.5.0.dist-info}/WHEEL +0 -0
- {anaplan_sdk-0.4.4a4.dist-info → anaplan_sdk-0.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,13 +1,14 @@
|
|
1
1
|
import logging
|
2
|
-
from asyncio import gather
|
2
|
+
from asyncio import gather
|
3
3
|
from copy import copy
|
4
|
-
from typing import AsyncIterator, Iterator
|
4
|
+
from typing import AsyncIterator, Iterator, Literal
|
5
5
|
|
6
6
|
import httpx
|
7
7
|
from typing_extensions import Self
|
8
8
|
|
9
9
|
from anaplan_sdk._auth import _create_auth
|
10
|
-
from anaplan_sdk.
|
10
|
+
from anaplan_sdk._services import _AsyncHttpService
|
11
|
+
from anaplan_sdk._utils import action_url, models_url, sort_params
|
11
12
|
from anaplan_sdk.exceptions import AnaplanActionError, InvalidIdentifierException
|
12
13
|
from anaplan_sdk.models import (
|
13
14
|
Action,
|
@@ -15,6 +16,7 @@ from anaplan_sdk.models import (
|
|
15
16
|
File,
|
16
17
|
Import,
|
17
18
|
Model,
|
19
|
+
ModelDeletionResult,
|
18
20
|
Process,
|
19
21
|
TaskStatus,
|
20
22
|
TaskSummary,
|
@@ -24,13 +26,15 @@ from anaplan_sdk.models import (
|
|
24
26
|
from ._alm import _AsyncAlmClient
|
25
27
|
from ._audit import _AsyncAuditClient
|
26
28
|
from ._cloud_works import _AsyncCloudWorksClient
|
29
|
+
from ._scim import _AsyncScimClient
|
27
30
|
from ._transactional import _AsyncTransactionalClient
|
28
31
|
|
29
|
-
|
32
|
+
SortBy = Literal["id", "name"] | None
|
33
|
+
|
30
34
|
logger = logging.getLogger("anaplan_sdk")
|
31
35
|
|
32
36
|
|
33
|
-
class AsyncClient
|
37
|
+
class AsyncClient:
|
34
38
|
"""
|
35
39
|
Asynchronous Anaplan Client. For guides and examples
|
36
40
|
refer to https://vinzenzklass.github.io/anaplan-sdk.
|
@@ -49,9 +53,13 @@ class AsyncClient(_AsyncBaseClient):
|
|
49
53
|
auth: httpx.Auth | None = None,
|
50
54
|
timeout: float | httpx.Timeout = 30,
|
51
55
|
retry_count: int = 2,
|
56
|
+
backoff: float = 1.0,
|
57
|
+
backoff_factor: float = 2.0,
|
58
|
+
page_size: int = 5_000,
|
52
59
|
status_poll_delay: int = 1,
|
53
60
|
upload_chunk_size: int = 25_000_000,
|
54
61
|
allow_file_creation: bool = False,
|
62
|
+
**httpx_kwargs,
|
55
63
|
) -> None:
|
56
64
|
"""
|
57
65
|
Asynchronous Anaplan Client. For guides and examples
|
@@ -84,6 +92,14 @@ class AsyncClient(_AsyncBaseClient):
|
|
84
92
|
:param retry_count: The number of times to retry an HTTP request if it fails. Set this to 0
|
85
93
|
to never retry. Defaults to 2, meaning each HTTP Operation will be tried a total
|
86
94
|
number of 2 times.
|
95
|
+
:param backoff: The initial backoff time in seconds for the retry mechanism. This is the
|
96
|
+
time to wait before the first retry.
|
97
|
+
:param backoff_factor: The factor by which the backoff time is multiplied after each retry.
|
98
|
+
For example, if the initial backoff is 1 second and the factor is 2, the second
|
99
|
+
retry will wait 2 seconds, the third retry will wait 4 seconds, and so on.
|
100
|
+
:param page_size: The number of items to return per page when paginating through results.
|
101
|
+
Defaults to 5000. This is the maximum number of items that can be returned per
|
102
|
+
request. If you pass a value greater than 5000, it will be capped to 5000.
|
87
103
|
:param status_poll_delay: The delay between polling the status of a task.
|
88
104
|
:param upload_chunk_size: The size of the chunks to upload. This is the maximum size of
|
89
105
|
each chunk. Defaults to 25MB.
|
@@ -92,54 +108,61 @@ class AsyncClient(_AsyncBaseClient):
|
|
92
108
|
altogether. A file that is created this way will not be referenced by any action in
|
93
109
|
anaplan until manually assigned so there is typically no value in dynamically
|
94
110
|
creating new files and uploading content to them.
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
)
|
107
|
-
),
|
108
|
-
timeout=timeout,
|
111
|
+
:param httpx_kwargs: Additional keyword arguments to pass to the `httpx.AsyncClient`.
|
112
|
+
This can be used to set additional options such as proxies, headers, etc. See
|
113
|
+
https://www.python-httpx.org/api/#asyncclient for the full list of arguments.
|
114
|
+
"""
|
115
|
+
_auth = auth or _create_auth(
|
116
|
+
token=token,
|
117
|
+
user_email=user_email,
|
118
|
+
password=password,
|
119
|
+
certificate=certificate,
|
120
|
+
private_key=private_key,
|
121
|
+
private_key_password=private_key_password,
|
109
122
|
)
|
110
|
-
|
123
|
+
_client = httpx.AsyncClient(auth=_auth, timeout=timeout, **httpx_kwargs)
|
124
|
+
self._http = _AsyncHttpService(
|
125
|
+
_client,
|
126
|
+
retry_count=retry_count,
|
127
|
+
backoff=backoff,
|
128
|
+
backoff_factor=backoff_factor,
|
129
|
+
page_size=page_size,
|
130
|
+
poll_delay=status_poll_delay,
|
131
|
+
)
|
132
|
+
self._workspace_id = workspace_id
|
133
|
+
self._model_id = model_id
|
111
134
|
self._url = f"https://api.anaplan.com/2/0/workspaces/{workspace_id}/models/{model_id}"
|
112
135
|
self._transactional_client = (
|
113
|
-
_AsyncTransactionalClient(
|
114
|
-
)
|
115
|
-
self._alm_client = (
|
116
|
-
_AsyncAlmClient(_client, model_id, self._retry_count) if model_id else None
|
136
|
+
_AsyncTransactionalClient(self._http, model_id) if model_id else None
|
117
137
|
)
|
118
|
-
self.
|
119
|
-
self.
|
120
|
-
self.
|
138
|
+
self._alm_client = _AsyncAlmClient(self._http, model_id) if model_id else None
|
139
|
+
self._audit_client = _AsyncAuditClient(self._http)
|
140
|
+
self._scim_client = _AsyncScimClient(self._http)
|
141
|
+
self._cloud_works = _AsyncCloudWorksClient(self._http)
|
121
142
|
self.upload_chunk_size = upload_chunk_size
|
122
143
|
self.allow_file_creation = allow_file_creation
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
:param
|
133
|
-
:param workspace_id: The workspace Id to use.
|
134
|
-
:param model_id: The model Id to use.
|
144
|
+
logger.debug(
|
145
|
+
f"Initialized AsyncClient with workspace_id={workspace_id}, model_id={model_id}"
|
146
|
+
)
|
147
|
+
|
148
|
+
def with_model(self, model_id: str | None = None, workspace_id: str | None = None) -> Self:
|
149
|
+
"""
|
150
|
+
Create a new instance of the Client with the given model and workspace Ids. **This creates
|
151
|
+
a copy of the current client. The current instance remains unchanged.**
|
152
|
+
:param workspace_id: The workspace Id to use or None to use the existing workspace Id.
|
153
|
+
:param model_id: The model Id to use or None to use the existing model Id.
|
135
154
|
:return: A new instance of the Client.
|
136
155
|
"""
|
137
|
-
client = copy(
|
138
|
-
|
139
|
-
|
140
|
-
|
156
|
+
client = copy(self)
|
157
|
+
new_ws_id = workspace_id or self._workspace_id
|
158
|
+
new_model_id = model_id or self._model_id
|
159
|
+
logger.debug(
|
160
|
+
f"Creating a new AsyncClient from existing instance "
|
161
|
+
f"with workspace_id={new_ws_id}, model_id={new_model_id}."
|
141
162
|
)
|
142
|
-
client.
|
163
|
+
client._url = f"https://api.anaplan.com/2/0/workspaces/{new_ws_id}/models/{new_model_id}"
|
164
|
+
client._transactional_client = _AsyncTransactionalClient(self._http, new_model_id)
|
165
|
+
client._alm_client = _AsyncAlmClient(self._http, new_model_id)
|
143
166
|
return client
|
144
167
|
|
145
168
|
@property
|
@@ -148,7 +171,7 @@ class AsyncClient(_AsyncBaseClient):
|
|
148
171
|
The Audit Client provides access to the Anaplan Audit API.
|
149
172
|
For details, see https://vinzenzklass.github.io/anaplan-sdk/guides/audit/.
|
150
173
|
"""
|
151
|
-
return self.
|
174
|
+
return self._audit_client
|
152
175
|
|
153
176
|
@property
|
154
177
|
def cw(self) -> _AsyncCloudWorksClient:
|
@@ -159,14 +182,14 @@ class AsyncClient(_AsyncBaseClient):
|
|
159
182
|
return self._cloud_works
|
160
183
|
|
161
184
|
@property
|
162
|
-
def
|
185
|
+
def tr(self) -> _AsyncTransactionalClient:
|
163
186
|
"""
|
164
187
|
The Transactional Client provides access to the Anaplan Transactional API. This is useful
|
165
188
|
for more advanced use cases where you need to interact with the Anaplan Model in a more
|
166
189
|
granular way.
|
167
190
|
|
168
191
|
If you instantiated the client without the field `model_id`, this will raise a
|
169
|
-
|
192
|
+
`ValueError`, since none of the endpoints can be invoked without the model Id.
|
170
193
|
:return: The Transactional Client.
|
171
194
|
"""
|
172
195
|
if not self._transactional_client:
|
@@ -197,122 +220,176 @@ class AsyncClient(_AsyncBaseClient):
|
|
197
220
|
)
|
198
221
|
return self._alm_client
|
199
222
|
|
200
|
-
|
223
|
+
@property
|
224
|
+
def scim(self) -> _AsyncScimClient:
|
225
|
+
"""
|
226
|
+
To use the SCIM API, you must be User Admin. The SCIM API allows managing internal users.
|
227
|
+
Visiting users are excluded from the SCIM API.
|
228
|
+
:return: The SCIM Client.
|
229
|
+
"""
|
230
|
+
return self._scim_client
|
231
|
+
|
232
|
+
async def get_workspaces(
|
233
|
+
self,
|
234
|
+
search_pattern: str | None = None,
|
235
|
+
sort_by: Literal["size_allowance", "name"] | None = None,
|
236
|
+
descending: bool = False,
|
237
|
+
) -> list[Workspace]:
|
201
238
|
"""
|
202
239
|
Lists all the Workspaces the authenticated user has access to.
|
203
|
-
:param search_pattern:
|
240
|
+
:param search_pattern: **Caution: This is an undocumented Feature and may behave
|
241
|
+
unpredictably. It requires the Tenant Admin role. For non-admin users, it is
|
242
|
+
ignored.** Optionally filter for specific workspaces. When provided,
|
204
243
|
case-insensitive matches workspaces with names containing this string.
|
205
244
|
You can use the wildcards `%` for 0-n characters, and `_` for exactly 1 character.
|
206
245
|
When None (default), returns all users.
|
246
|
+
:param sort_by: The field to sort the results by.
|
247
|
+
:param descending: If True, the results will be sorted in descending order.
|
207
248
|
:return: The List of Workspaces.
|
208
249
|
"""
|
209
|
-
params = {"tenantDetails": "true"}
|
250
|
+
params = {"tenantDetails": "true"} | sort_params(sort_by, descending)
|
210
251
|
if search_pattern:
|
211
252
|
params["s"] = search_pattern
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
)
|
217
|
-
]
|
253
|
+
res = await self._http.get_paginated(
|
254
|
+
"https://api.anaplan.com/2/0/workspaces", "workspaces", params=params
|
255
|
+
)
|
256
|
+
return [Workspace.model_validate(e) for e in res]
|
218
257
|
|
219
|
-
async def
|
258
|
+
async def get_models(
|
259
|
+
self,
|
260
|
+
only_in_workspace: bool | str = False,
|
261
|
+
search_pattern: str | None = None,
|
262
|
+
sort_by: Literal["active_state", "name"] | None = None,
|
263
|
+
descending: bool = False,
|
264
|
+
) -> list[Model]:
|
220
265
|
"""
|
221
266
|
Lists all the Models the authenticated user has access to.
|
222
|
-
:param
|
223
|
-
|
267
|
+
:param only_in_workspace: If True, only lists models in the workspace provided when
|
268
|
+
instantiating the client. If a string is provided, only lists models in the workspace
|
269
|
+
with the given Id. If False (default), lists models in all workspaces the user
|
270
|
+
:param search_pattern: **Caution: This is an undocumented Feature and may behave
|
271
|
+
unpredictably. It requires the Tenant Admin role. For non-admin users, it is
|
272
|
+
ignored.** Optionally filter for specific models. When provided,
|
273
|
+
case-insensitive matches model names containing this string.
|
224
274
|
You can use the wildcards `%` for 0-n characters, and `_` for exactly 1 character.
|
225
|
-
When None (default), returns all
|
275
|
+
When None (default), returns all models.
|
276
|
+
:param sort_by: The field to sort the results by.
|
277
|
+
:param descending: If True, the results will be sorted in descending order.
|
226
278
|
:return: The List of Models.
|
227
279
|
"""
|
228
|
-
params = {"modelDetails": "true"}
|
280
|
+
params = {"modelDetails": "true"} | sort_params(sort_by, descending)
|
229
281
|
if search_pattern:
|
230
282
|
params["s"] = search_pattern
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
283
|
+
res = await self._http.get_paginated(
|
284
|
+
models_url(only_in_workspace, self._workspace_id), "models", params=params
|
285
|
+
)
|
286
|
+
return [Model.model_validate(e) for e in res]
|
287
|
+
|
288
|
+
async def delete_models(self, model_ids: list[str]) -> ModelDeletionResult:
|
289
|
+
"""
|
290
|
+
Delete the given Models. Models need to be closed before they can be deleted. If one of the
|
291
|
+
deletions fails, the other deletions will still be attempted and may complete.
|
292
|
+
:param model_ids: The list of Model identifiers to delete.
|
293
|
+
:return:
|
294
|
+
"""
|
295
|
+
logger.info(f"Deleting Models: {', '.join(model_ids)}.")
|
296
|
+
res = await self._http.post(
|
297
|
+
f"https://api.anaplan.com/2/0/workspaces/{self._workspace_id}/bulkDeleteModels",
|
298
|
+
json={"modelIdsToDelete": model_ids},
|
299
|
+
)
|
300
|
+
return ModelDeletionResult.model_validate(res)
|
237
301
|
|
238
|
-
async def
|
302
|
+
async def get_files(self, sort_by: SortBy = None, descending: bool = False) -> list[File]:
|
239
303
|
"""
|
240
304
|
Lists all the Files in the Model.
|
305
|
+
:param sort_by: The field to sort the results by.
|
306
|
+
:param descending: If True, the results will be sorted in descending order.
|
241
307
|
:return: The List of Files.
|
242
308
|
"""
|
243
|
-
|
244
|
-
|
245
|
-
|
309
|
+
res = await self._http.get_paginated(
|
310
|
+
f"{self._url}/files", "files", params=sort_params(sort_by, descending)
|
311
|
+
)
|
312
|
+
return [File.model_validate(e) for e in res]
|
246
313
|
|
247
|
-
async def
|
314
|
+
async def get_actions(self, sort_by: SortBy = None, descending: bool = False) -> list[Action]:
|
248
315
|
"""
|
249
316
|
Lists all the Actions in the Model. This will only return the Actions listed under
|
250
317
|
`Other Actions` in Anaplan. For Imports, exports, and processes, see their respective
|
251
318
|
methods instead.
|
319
|
+
:param sort_by: The field to sort the results by.
|
320
|
+
:param descending: If True, the results will be sorted in descending order.
|
252
321
|
:return: The List of Actions.
|
253
322
|
"""
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
]
|
323
|
+
res = await self._http.get_paginated(
|
324
|
+
f"{self._url}/actions", "actions", params=sort_params(sort_by, descending)
|
325
|
+
)
|
326
|
+
return [Action.model_validate(e) for e in res]
|
258
327
|
|
259
|
-
async def
|
328
|
+
async def get_processes(
|
329
|
+
self, sort_by: SortBy = None, descending: bool = False
|
330
|
+
) -> list[Process]:
|
260
331
|
"""
|
261
332
|
Lists all the Processes in the Model.
|
333
|
+
:param sort_by: The field to sort the results by.
|
334
|
+
:param descending: If True, the results will be sorted in descending order.
|
262
335
|
:return: The List of Processes.
|
263
336
|
"""
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
]
|
337
|
+
res = await self._http.get_paginated(
|
338
|
+
f"{self._url}/processes", "processes", params=sort_params(sort_by, descending)
|
339
|
+
)
|
340
|
+
return [Process.model_validate(e) for e in res]
|
268
341
|
|
269
|
-
async def
|
342
|
+
async def get_imports(self, sort_by: SortBy = None, descending: bool = False) -> list[Import]:
|
270
343
|
"""
|
271
344
|
Lists all the Imports in the Model.
|
345
|
+
:param sort_by: The field to sort the results by.
|
346
|
+
:param descending: If True, the results will be sorted in descending order.
|
272
347
|
:return: The List of Imports.
|
273
348
|
"""
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
]
|
349
|
+
res = await self._http.get_paginated(
|
350
|
+
f"{self._url}/imports", "imports", params=sort_params(sort_by, descending)
|
351
|
+
)
|
352
|
+
return [Import.model_validate(e) for e in res]
|
278
353
|
|
279
|
-
async def
|
354
|
+
async def get_exports(self, sort_by: SortBy = None, descending: bool = False) -> list[Export]:
|
280
355
|
"""
|
281
356
|
Lists all the Exports in the Model.
|
357
|
+
:param sort_by: The field to sort the results by.
|
358
|
+
:param descending: If True, the results will be sorted in descending order.
|
282
359
|
:return: The List of Exports.
|
283
360
|
"""
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
]
|
361
|
+
res = await self._http.get_paginated(
|
362
|
+
f"{self._url}/exports", "exports", params=sort_params(sort_by, descending)
|
363
|
+
)
|
364
|
+
return [Export.model_validate(e) for e in res]
|
288
365
|
|
289
|
-
async def run_action(self, action_id: int) -> TaskStatus:
|
366
|
+
async def run_action(self, action_id: int, wait_for_completion: bool = True) -> TaskStatus:
|
290
367
|
"""
|
291
|
-
Runs the
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
raise an Exception to handle - if you for e.g. think that one of the uploaded chunks may
|
296
|
-
have been dropped and simply retrying with new data may help - and not return the task
|
297
|
-
status information that needs to be handled by the caller.
|
298
|
-
|
299
|
-
If you need more information or control, you can use `invoke_action()` and
|
300
|
-
`get_task_status()`.
|
368
|
+
Runs the Action and validates the spawned task. If the Action fails or completes with
|
369
|
+
errors, this will raise an AnaplanActionError. Failed Tasks are often not something you
|
370
|
+
can recover from at runtime and often require manual changes in Anaplan, i.e. updating the
|
371
|
+
mapping of an Import or similar.
|
301
372
|
:param action_id: The identifier of the Action to run. Can be any Anaplan Invokable;
|
302
|
-
|
373
|
+
Processes, Imports, Exports, Other Actions.
|
374
|
+
:param wait_for_completion: If True, the method will poll the task status and not return
|
375
|
+
until the task is complete. If False, it will spawn the task and return immediately.
|
303
376
|
"""
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
377
|
+
body = {"localeName": "en_US"}
|
378
|
+
res = await self._http.post(
|
379
|
+
f"{self._url}/{action_url(action_id)}/{action_id}/tasks", json=body
|
380
|
+
)
|
381
|
+
task_id = res["task"]["taskId"]
|
382
|
+
logger.info(f"Invoked Action '{action_id}', spawned Task: '{task_id}'.")
|
310
383
|
|
311
|
-
if
|
384
|
+
if not wait_for_completion:
|
385
|
+
return TaskStatus.model_validate(await self.get_task_status(action_id, task_id))
|
386
|
+
status = await self._http.poll_task(self.get_task_status, action_id, task_id)
|
387
|
+
if status.task_state == "COMPLETE" and not status.result.successful:
|
388
|
+
logger.error(f"Task '{task_id}' completed with errors.")
|
312
389
|
raise AnaplanActionError(f"Task '{task_id}' completed with errors.")
|
313
390
|
|
314
|
-
logger.info(f"Task '{task_id}' completed successfully.")
|
315
|
-
return
|
391
|
+
logger.info(f"Task '{task_id}' of '{action_id}' completed successfully.")
|
392
|
+
return status
|
316
393
|
|
317
394
|
async def get_file(self, file_id: int) -> bytes:
|
318
395
|
"""
|
@@ -321,58 +398,71 @@ class AsyncClient(_AsyncBaseClient):
|
|
321
398
|
:return: The content of the file.
|
322
399
|
"""
|
323
400
|
chunk_count = await self._file_pre_check(file_id)
|
324
|
-
if chunk_count <= 1:
|
325
|
-
return await self._get_binary(f"{self._url}/files/{file_id}")
|
326
401
|
logger.info(f"File {file_id} has {chunk_count} chunks.")
|
402
|
+
if chunk_count <= 1:
|
403
|
+
return await self._http.get_binary(f"{self._url}/files/{file_id}")
|
327
404
|
return b"".join(
|
328
405
|
await gather(
|
329
|
-
*
|
330
|
-
self.
|
406
|
+
*(
|
407
|
+
self._http.get_binary(f"{self._url}/files/{file_id}/chunks/{i}")
|
331
408
|
for i in range(chunk_count)
|
332
|
-
|
409
|
+
)
|
333
410
|
)
|
334
411
|
)
|
335
412
|
|
336
|
-
async def get_file_stream(self, file_id: int) -> AsyncIterator[bytes]:
|
413
|
+
async def get_file_stream(self, file_id: int, batch_size: int = 1) -> AsyncIterator[bytes]:
|
337
414
|
"""
|
338
415
|
Retrieves the content of the specified file as a stream of chunks. The chunks are yielded
|
339
416
|
one by one, so you can process them as they arrive. This is useful for large files where
|
340
417
|
you don't want to or cannot load the entire file into memory at once.
|
341
418
|
:param file_id: The identifier of the file to retrieve.
|
419
|
+
:param batch_size: Number of chunks to fetch concurrently. If > 1, n chunks will be fetched
|
420
|
+
concurrently. This still yields each chunk individually, only the requests are
|
421
|
+
batched. If 1 (default), each chunk is fetched sequentially.
|
342
422
|
:return: A generator yielding the chunks of the file.
|
343
423
|
"""
|
344
424
|
chunk_count = await self._file_pre_check(file_id)
|
425
|
+
logger.info(f"File {file_id} has {chunk_count} chunks.")
|
345
426
|
if chunk_count <= 1:
|
346
|
-
yield await self.
|
427
|
+
yield await self._http.get_binary(f"{self._url}/files/{file_id}")
|
347
428
|
return
|
348
|
-
|
349
|
-
for
|
350
|
-
|
429
|
+
|
430
|
+
for batch_start in range(0, chunk_count, batch_size):
|
431
|
+
batch_chunks = await gather(
|
432
|
+
*(
|
433
|
+
self._http.get_binary(f"{self._url}/files/{file_id}/chunks/{i}")
|
434
|
+
for i in range(batch_start, min(batch_start + batch_size, chunk_count))
|
435
|
+
)
|
436
|
+
)
|
437
|
+
for chunk in batch_chunks:
|
438
|
+
yield chunk
|
351
439
|
|
352
440
|
async def upload_file(self, file_id: int, content: str | bytes) -> None:
|
353
441
|
"""
|
354
442
|
Uploads the content to the specified file. If there are several chunks, upload of
|
355
|
-
individual chunks are
|
443
|
+
individual chunks are uploaded concurrently.
|
356
444
|
|
357
445
|
:param file_id: The identifier of the file to upload to.
|
358
446
|
:param content: The content to upload. **This Content will be compressed before uploading.
|
359
|
-
|
360
|
-
redundant work.**
|
447
|
+
If you are passing the Input as bytes, pass it uncompressed.**
|
361
448
|
"""
|
362
|
-
if isinstance(content, str):
|
363
|
-
content = content.encode()
|
364
449
|
chunks = [
|
365
450
|
content[i : i + self.upload_chunk_size]
|
366
451
|
for i in range(0, len(content), self.upload_chunk_size)
|
367
452
|
]
|
368
|
-
logger.info(f"Content will be uploaded in {len(chunks)} chunks.")
|
453
|
+
logger.info(f"Content for file '{file_id}' will be uploaded in {len(chunks)} chunks.")
|
369
454
|
await self._set_chunk_count(file_id, len(chunks))
|
370
455
|
await gather(
|
371
|
-
*
|
456
|
+
*(self._upload_chunk(file_id, index, chunk) for index, chunk in enumerate(chunks))
|
372
457
|
)
|
373
458
|
|
459
|
+
logger.info(f"Completed upload for file '{file_id}'.")
|
460
|
+
|
374
461
|
async def upload_file_stream(
|
375
|
-
self,
|
462
|
+
self,
|
463
|
+
file_id: int,
|
464
|
+
content: AsyncIterator[bytes | str] | Iterator[str | bytes],
|
465
|
+
batch_size: int = 1,
|
376
466
|
) -> None:
|
377
467
|
"""
|
378
468
|
Uploads the content to the specified file as a stream of chunks. This is useful either for
|
@@ -382,38 +472,60 @@ class AsyncClient(_AsyncBaseClient):
|
|
382
472
|
generator that yields the chunks of the file one by one to this method.
|
383
473
|
|
384
474
|
:param file_id: The identifier of the file to upload to.
|
385
|
-
:param content: An Iterator or AsyncIterator yielding the chunks of the file.
|
386
|
-
|
475
|
+
:param content: An Iterator or AsyncIterator yielding the chunks of the file. You can pass
|
476
|
+
any Iterator, but you will most likely want to pass a Generator.
|
477
|
+
:param batch_size: Number of chunks to upload concurrently. If > 1, n chunks will be
|
478
|
+
uploaded concurrently. This can be useful if you either do not control the chunk
|
479
|
+
size, or if you want to keep the chunk size small but still want some concurrency.
|
387
480
|
"""
|
481
|
+
logger.info(f"Starting upload stream for file '{file_id}' with batch size {batch_size}.")
|
388
482
|
await self._set_chunk_count(file_id, -1)
|
483
|
+
tasks = []
|
389
484
|
if isinstance(content, Iterator):
|
390
485
|
for index, chunk in enumerate(content):
|
391
|
-
|
392
|
-
|
393
|
-
|
486
|
+
tasks.append(self._upload_chunk(file_id, index, chunk))
|
487
|
+
if len(tasks) == max(batch_size, 1):
|
488
|
+
await gather(*tasks)
|
489
|
+
logger.info(
|
490
|
+
f"Completed upload stream batch of size {batch_size} for file {file_id}."
|
491
|
+
)
|
492
|
+
tasks = []
|
394
493
|
else:
|
395
494
|
index = 0
|
396
495
|
async for chunk in content:
|
397
|
-
|
398
|
-
file_id, index, chunk.encode() if isinstance(chunk, str) else chunk
|
399
|
-
)
|
496
|
+
tasks.append(self._upload_chunk(file_id, index, chunk))
|
400
497
|
index += 1
|
498
|
+
if len(tasks) == max(batch_size, 1):
|
499
|
+
await gather(*tasks)
|
500
|
+
logger.info(
|
501
|
+
f"Completed upload stream batch of size {batch_size} for file {file_id}."
|
502
|
+
)
|
503
|
+
tasks = []
|
504
|
+
if tasks:
|
505
|
+
await gather(*tasks)
|
506
|
+
logger.info(
|
507
|
+
f"Completed final upload stream batch of size {len(tasks)} for file {file_id}."
|
508
|
+
)
|
509
|
+
await self._http.post(f"{self._url}/files/{file_id}/complete", json={"id": file_id})
|
510
|
+
logger.info(f"Completed upload stream for '{file_id}'.")
|
401
511
|
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
async def upload_and_import(self, file_id: int, content: str | bytes, action_id: int) -> None:
|
512
|
+
async def upload_and_import(
|
513
|
+
self, file_id: int, content: str | bytes, action_id: int, wait_for_completion: bool = True
|
514
|
+
) -> TaskStatus:
|
406
515
|
"""
|
407
516
|
Convenience wrapper around `upload_file()` and `run_action()` to upload content to a file
|
408
517
|
and run an import action in one call.
|
409
518
|
:param file_id: The identifier of the file to upload to.
|
410
519
|
:param content: The content to upload. **This Content will be compressed before uploading.
|
411
|
-
|
412
|
-
|
520
|
+
If you are passing the Input as bytes, pass it uncompressed to avoid redundant
|
521
|
+
work.**
|
413
522
|
:param action_id: The identifier of the action to run after uploading the content.
|
523
|
+
:param wait_for_completion: If True, the method will poll the import task status and not
|
524
|
+
return until the task is complete. If False, it will spawn the import task and
|
525
|
+
return immediately.
|
414
526
|
"""
|
415
527
|
await self.upload_file(file_id, content)
|
416
|
-
await self.run_action(action_id)
|
528
|
+
return await self.run_action(action_id, wait_for_completion)
|
417
529
|
|
418
530
|
async def export_and_download(self, action_id: int) -> bytes:
|
419
531
|
"""
|
@@ -425,7 +537,7 @@ class AsyncClient(_AsyncBaseClient):
|
|
425
537
|
await self.run_action(action_id)
|
426
538
|
return await self.get_file(action_id)
|
427
539
|
|
428
|
-
async def
|
540
|
+
async def get_task_summaries(self, action_id: int) -> list[TaskSummary]:
|
429
541
|
"""
|
430
542
|
Retrieves the status of all tasks spawned by the specified action.
|
431
543
|
:param action_id: The identifier of the action that was invoked.
|
@@ -433,7 +545,7 @@ class AsyncClient(_AsyncBaseClient):
|
|
433
545
|
"""
|
434
546
|
return [
|
435
547
|
TaskSummary.model_validate(e)
|
436
|
-
for e in await self.
|
548
|
+
for e in await self._http.get_paginated(
|
437
549
|
f"{self._url}/{action_url(action_id)}/{action_id}/tasks", "tasks"
|
438
550
|
)
|
439
551
|
]
|
@@ -447,38 +559,32 @@ class AsyncClient(_AsyncBaseClient):
|
|
447
559
|
"""
|
448
560
|
return TaskStatus.model_validate(
|
449
561
|
(
|
450
|
-
await self.
|
562
|
+
await self._http.get(
|
563
|
+
f"{self._url}/{action_url(action_id)}/{action_id}/tasks/{task_id}"
|
564
|
+
)
|
451
565
|
).get("task")
|
452
566
|
)
|
453
567
|
|
454
|
-
async def
|
568
|
+
async def get_optimizer_log(self, action_id: int, task_id: str) -> bytes:
|
455
569
|
"""
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
Actions in parallel.
|
461
|
-
:param action_id: The identifier of the Action to run. Can be any Anaplan Invokable.
|
462
|
-
:return: The identifier of the spawned Task.
|
570
|
+
Retrieves the solution logs of the specified optimization action task.
|
571
|
+
:param action_id: The identifier of the optimization action that was invoked.
|
572
|
+
:param task_id: The Task identifier, sometimes also referred to as the Correlation Id.
|
573
|
+
:return: The content of the solution logs.
|
463
574
|
"""
|
464
|
-
|
465
|
-
f"{self._url}/{
|
575
|
+
return await self._http.get_binary(
|
576
|
+
f"{self._url}/optimizeActions/{action_id}/tasks/{task_id}/solutionLogs"
|
466
577
|
)
|
467
|
-
task_id = response.get("task").get("taskId")
|
468
|
-
logger.info(f"Invoked Action '{action_id}', spawned Task: '{task_id}'.")
|
469
|
-
return task_id
|
470
578
|
|
471
579
|
async def _file_pre_check(self, file_id: int) -> int:
|
472
|
-
file = next(
|
580
|
+
file = next((f for f in await self.get_files() if f.id == file_id), None)
|
473
581
|
if not file:
|
474
582
|
raise InvalidIdentifierException(f"File {file_id} not found.")
|
475
583
|
return file.chunk_count
|
476
584
|
|
477
|
-
async def _upload_chunk(self, file_id: int, index: int, chunk: bytes) -> None:
|
478
|
-
await self.
|
479
|
-
|
480
|
-
)
|
481
|
-
logger.info(f"Chunk {index} loaded to file '{file_id}'.")
|
585
|
+
async def _upload_chunk(self, file_id: int, index: int, chunk: str | bytes) -> None:
|
586
|
+
await self._http.put_binary_gzip(f"{self._url}/files/{file_id}/chunks/{index}", chunk)
|
587
|
+
logger.debug(f"Chunk {index} loaded to file '{file_id}'.")
|
482
588
|
|
483
589
|
async def _set_chunk_count(self, file_id: int, num_chunks: int) -> None:
|
484
590
|
if not self.allow_file_creation and not (113000000000 <= file_id <= 113999999999):
|
@@ -487,7 +593,9 @@ class AsyncClient(_AsyncBaseClient):
|
|
487
593
|
"to avoid this error, set `allow_file_creation=True` on the calling instance. "
|
488
594
|
"Make sure you have understood the implications of this before doing so. "
|
489
595
|
)
|
490
|
-
response = await self.
|
596
|
+
response = await self._http.post(
|
597
|
+
f"{self._url}/files/{file_id}", json={"chunkCount": num_chunks}
|
598
|
+
)
|
491
599
|
optionally_new_file = int(response.get("file").get("id"))
|
492
600
|
if optionally_new_file != file_id:
|
493
601
|
if self.allow_file_creation:
|