anaplan-sdk 0.4.5__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.
@@ -1,13 +1,14 @@
1
1
  import logging
2
- from asyncio import gather, sleep
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._base import _AsyncBaseClient, action_url
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
- logging.getLogger("httpx").setLevel(logging.CRITICAL)
32
+ SortBy = Literal["id", "name"] | None
33
+
30
34
  logger = logging.getLogger("anaplan_sdk")
31
35
 
32
36
 
33
- class AsyncClient(_AsyncBaseClient):
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
- _client = httpx.AsyncClient(
97
- auth=(
98
- auth
99
- or _create_auth(
100
- token=token,
101
- user_email=user_email,
102
- password=password,
103
- certificate=certificate,
104
- private_key=private_key,
105
- private_key_password=private_key_password,
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
- self._retry_count = retry_count
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(_client, model_id, retry_count) if model_id else None
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._audit = _AsyncAuditClient(_client, self._retry_count)
119
- self._cloud_works = _AsyncCloudWorksClient(_client, self._retry_count)
120
- self.status_poll_delay = status_poll_delay
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
- super().__init__(retry_count, _client)
124
-
125
- @classmethod
126
- def from_existing(cls, existing: Self, workspace_id: str, model_id: str) -> Self:
127
- """
128
- Create a new instance of the Client from an existing instance. This is useful if you want
129
- to interact with multiple models or workspaces in the same script but share the same
130
- authentication and configuration. This creates a shallow copy of the existing client and
131
- update the relevant attributes to the new workspace and model.
132
- :param existing: The existing instance to copy.
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(existing)
138
- client._url = f"https://api.anaplan.com/2/0/workspaces/{workspace_id}/models/{model_id}"
139
- client._transactional_client = _AsyncTransactionalClient(
140
- existing._client, model_id, existing._retry_count
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._alm_client = _AsyncAlmClient(existing._client, model_id, existing._retry_count)
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._audit
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 transactional(self) -> _AsyncTransactionalClient:
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
- :py:class:`ValueError`, since none of the endpoints can be invoked without the model Id.
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
- async def list_workspaces(self, search_pattern: str | None = None) -> list[Workspace]:
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: Optionally filter for specific workspaces. When provided,
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
- return [
213
- Workspace.model_validate(e)
214
- for e in await self._get_paginated(
215
- "https://api.anaplan.com/2/0/workspaces", "workspaces", params=params
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 list_models(self, search_pattern: str | None = None) -> list[Model]:
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 search_pattern: Optionally filter for specific models. When provided,
223
- case-insensitive matches models names containing this string.
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 users.
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
- return [
232
- Model.model_validate(e)
233
- for e in await self._get_paginated(
234
- "https://api.anaplan.com/2/0/models", "models", params=params
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 list_files(self) -> list[File]:
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
- return [
244
- File.model_validate(e) for e in await self._get_paginated(f"{self._url}/files", "files")
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 list_actions(self) -> list[Action]:
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
- return [
255
- Action.model_validate(e)
256
- for e in await self._get_paginated(f"{self._url}/actions", "actions")
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 list_processes(self) -> list[Process]:
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
- return [
265
- Process.model_validate(e)
266
- for e in await self._get_paginated(f"{self._url}/processes", "processes")
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 list_imports(self) -> list[Import]:
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
- return [
275
- Import.model_validate(e)
276
- for e in await self._get_paginated(f"{self._url}/imports", "imports")
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 list_exports(self) -> list[Export]:
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
- return [
285
- Export.model_validate(e)
286
- for e in await self._get_paginated(f"{self._url}/exports", "exports")
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 specified Anaplan Action and validates the spawned task. If the Action fails or
292
- completes with errors, will raise an :py:class:`AnaplanActionError`. Failed Tasks are
293
- usually not something you can recover from at runtime and often require manual changes in
294
- Anaplan, i.e. updating the mapping of an Import or similar. So, for convenience, this will
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
- Processes, Imports, Exports, Other Actions.
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
- task_id = await self.invoke_action(action_id)
305
- task_status = await self.get_task_status(action_id, task_id)
306
-
307
- while task_status.task_state != "COMPLETE":
308
- await sleep(self.status_poll_delay)
309
- task_status = await self.get_task_status(action_id, task_id)
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 task_status.task_state == "COMPLETE" and not task_status.result.successful:
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 task_status
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._get_binary(f"{self._url}/files/{file_id}/chunks/{i}")
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._get_binary(f"{self._url}/files/{file_id}")
427
+ yield await self._http.get_binary(f"{self._url}/files/{file_id}")
347
428
  return
348
- logger.info(f"File {file_id} has {chunk_count} chunks.")
349
- for i in range(chunk_count):
350
- yield await self._get_binary(f"{self._url}/files/{file_id}/chunks/{i}")
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 concurrent.
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
- If you are passing the Input as bytes, pass it uncompressed to avoid
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
- *[self._upload_chunk(file_id, index, chunk) for index, chunk in enumerate(chunks)]
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, file_id: int, content: AsyncIterator[bytes | str] | Iterator[str | bytes]
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
- (Most likely a generator).
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
- await self._upload_chunk(
392
- file_id, index, chunk.encode() if isinstance(chunk, str) else chunk
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
- await self._upload_chunk(
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
- await self._post(f"{self._url}/files/{file_id}/complete", json={"id": file_id})
403
- logger.info(f"Marked all chunks as complete for file '{file_id}'.")
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
- If you are passing the Input as bytes, pass it uncompressed to avoid
412
- redundant work.**
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 list_task_status(self, action_id: int) -> list[TaskSummary]:
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._get_paginated(
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,7 +559,9 @@ class AsyncClient(_AsyncBaseClient):
447
559
  """
448
560
  return TaskStatus.model_validate(
449
561
  (
450
- await self._get(f"{self._url}/{action_url(action_id)}/{action_id}/tasks/{task_id}")
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
 
@@ -458,38 +572,19 @@ class AsyncClient(_AsyncBaseClient):
458
572
  :param task_id: The Task identifier, sometimes also referred to as the Correlation Id.
459
573
  :return: The content of the solution logs.
460
574
  """
461
- return await self._get_binary(
575
+ return await self._http.get_binary(
462
576
  f"{self._url}/optimizeActions/{action_id}/tasks/{task_id}/solutionLogs"
463
577
  )
464
578
 
465
- async def invoke_action(self, action_id: int) -> str:
466
- """
467
- You may want to consider using `run_action()` instead.
468
-
469
- Invokes the specified Anaplan Action and returns the spawned Task identifier. This is
470
- useful if you want to handle the Task status yourself or if you want to run multiple
471
- Actions in parallel.
472
- :param action_id: The identifier of the Action to run. Can be any Anaplan Invokable.
473
- :return: The identifier of the spawned Task.
474
- """
475
- response = await self._post(
476
- f"{self._url}/{action_url(action_id)}/{action_id}/tasks", json={"localeName": "en_US"}
477
- )
478
- task_id = response.get("task").get("taskId")
479
- logger.info(f"Invoked Action '{action_id}', spawned Task: '{task_id}'.")
480
- return task_id
481
-
482
579
  async def _file_pre_check(self, file_id: int) -> int:
483
- file = next(filter(lambda f: f.id == file_id, await self.list_files()), None)
580
+ file = next((f for f in await self.get_files() if f.id == file_id), None)
484
581
  if not file:
485
582
  raise InvalidIdentifierException(f"File {file_id} not found.")
486
583
  return file.chunk_count
487
584
 
488
- async def _upload_chunk(self, file_id: int, index: int, chunk: bytes) -> None:
489
- await self._run_with_retry(
490
- self._put_binary_gzip, f"{self._url}/files/{file_id}/chunks/{index}", content=chunk
491
- )
492
- 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}'.")
493
588
 
494
589
  async def _set_chunk_count(self, file_id: int, num_chunks: int) -> None:
495
590
  if not self.allow_file_creation and not (113000000000 <= file_id <= 113999999999):
@@ -498,7 +593,9 @@ class AsyncClient(_AsyncBaseClient):
498
593
  "to avoid this error, set `allow_file_creation=True` on the calling instance. "
499
594
  "Make sure you have understood the implications of this before doing so. "
500
595
  )
501
- response = await self._post(f"{self._url}/files/{file_id}", json={"chunkCount": num_chunks})
596
+ response = await self._http.post(
597
+ f"{self._url}/files/{file_id}", json={"chunkCount": num_chunks}
598
+ )
502
599
  optionally_new_file = int(response.get("file").get("id"))
503
600
  if optionally_new_file != file_id:
504
601
  if self.allow_file_creation: