anaplan-sdk 0.2.10__py3-none-any.whl → 0.3.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.
@@ -3,8 +3,7 @@ Asynchronous Client.
3
3
  """
4
4
 
5
5
  import logging
6
- import time
7
- from asyncio import gather
6
+ from asyncio import gather, sleep
8
7
  from copy import copy
9
8
  from typing import AsyncIterator, Iterator
10
9
 
@@ -14,7 +13,17 @@ from typing_extensions import Self
14
13
  from anaplan_sdk._auth import AnaplanBasicAuth, AnaplanCertAuth, get_certificate, get_private_key
15
14
  from anaplan_sdk._base import _AsyncBaseClient, action_url
16
15
  from anaplan_sdk.exceptions import AnaplanActionError, InvalidIdentifierException
17
- from anaplan_sdk.models import Action, Export, File, Import, Model, Process, Workspace
16
+ from anaplan_sdk.models import (
17
+ Action,
18
+ Export,
19
+ File,
20
+ Import,
21
+ Model,
22
+ Process,
23
+ TaskStatus,
24
+ TaskSummary,
25
+ Workspace,
26
+ )
18
27
 
19
28
  from ._alm import _AsyncAlmClient
20
29
  from ._audit import _AsyncAuditClient
@@ -250,7 +259,7 @@ class AsyncClient(_AsyncBaseClient):
250
259
  for e in (await self._get(f"{self._url}/exports")).get("exports", [])
251
260
  ]
252
261
 
253
- async def run_action(self, action_id: int) -> None:
262
+ async def run_action(self, action_id: int) -> TaskStatus:
254
263
  """
255
264
  Runs the specified Anaplan Action and validates the spawned task. If the Action fails or
256
265
  completes with errors, will raise an :py:class:`AnaplanActionError`. Failed Tasks are
@@ -268,16 +277,15 @@ class AsyncClient(_AsyncBaseClient):
268
277
  task_id = await self.invoke_action(action_id)
269
278
  task_status = await self.get_task_status(action_id, task_id)
270
279
 
271
- while "COMPLETE" not in task_status.get("taskState"):
272
- time.sleep(self.status_poll_delay)
280
+ while task_status.task_state != "COMPLETE":
281
+ await sleep(self.status_poll_delay)
273
282
  task_status = await self.get_task_status(action_id, task_id)
274
283
 
275
- if task_status.get("taskState") == "COMPLETE" and not task_status.get("result").get(
276
- "successful"
277
- ):
284
+ if task_status.task_state == "COMPLETE" and not task_status.result.successful:
278
285
  raise AnaplanActionError(f"Task '{task_id}' completed with errors.")
279
286
 
280
287
  logger.info(f"Task '{task_id}' completed successfully.")
288
+ return task_status
281
289
 
282
290
  async def get_file(self, file_id: int) -> bytes:
283
291
  """
@@ -390,29 +398,31 @@ class AsyncClient(_AsyncBaseClient):
390
398
  await self.run_action(action_id)
391
399
  return await self.get_file(action_id)
392
400
 
393
- async def list_task_status(self, action_id: int) -> list:
401
+ async def list_task_status(self, action_id: int) -> list[TaskSummary]:
394
402
  """
395
403
  Retrieves the status of all tasks spawned by the specified action.
396
404
  :param action_id: The identifier of the action that was invoked.
397
405
  :return: The list of tasks spawned by the action.
398
406
  """
399
- return (await self._get(f"{self._url}/{action_url(action_id)}/{action_id}/tasks")).get(
400
- "tasks", []
401
- )
407
+ return [
408
+ TaskSummary.model_validate(e)
409
+ for e in (
410
+ await self._get(f"{self._url}/{action_url(action_id)}/{action_id}/tasks")
411
+ ).get("tasks", [])
412
+ ]
402
413
 
403
- async def get_task_status(
404
- self, action_id: int, task_id: str
405
- ) -> dict[str, float | int | str | list | dict | bool]:
414
+ async def get_task_status(self, action_id: int, task_id: str) -> TaskStatus:
406
415
  """
407
416
  Retrieves the status of the specified task.
408
417
  :param action_id: The identifier of the action that was invoked.
409
418
  :param task_id: The identifier of the spawned task.
410
- :return: The status of the task as returned by the API. For more information
411
- see: https://anaplan.docs.apiary.io.
419
+ :return: The status of the task.
412
420
  """
413
- return (
414
- await self._get(f"{self._url}/{action_url(action_id)}/{action_id}/tasks/{task_id}")
415
- ).get("task")
421
+ return TaskStatus.model_validate(
422
+ (
423
+ await self._get(f"{self._url}/{action_url(action_id)}/{action_id}/tasks/{task_id}")
424
+ ).get("task")
425
+ )
416
426
 
417
427
  async def invoke_action(self, action_id: int) -> str:
418
428
  """
@@ -1,3 +1,6 @@
1
+ import warnings
2
+ from asyncio import gather
3
+ from itertools import chain
1
4
  from typing import Any
2
5
 
3
6
  import httpx
@@ -13,6 +16,8 @@ from anaplan_sdk.models import (
13
16
  Module,
14
17
  )
15
18
 
19
+ warnings.filterwarnings("always", category=DeprecationWarning)
20
+
16
21
 
17
22
  class _AsyncTransactionalClient(_AsyncBaseClient):
18
23
  def __init__(self, client: httpx.AsyncClient, model_id: str, retry_count: int) -> None:
@@ -27,7 +32,7 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
27
32
  """
28
33
  return [
29
34
  Module.model_validate(e)
30
- for e in (await self._get(f"{self._url}/modules")).get("modules")
35
+ for e in (await self._get(f"{self._url}/modules")).get("modules", [])
31
36
  ]
32
37
 
33
38
  async def get_model_status(self) -> ModelStatus:
@@ -50,7 +55,7 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
50
55
  if only_module_id
51
56
  else f"{self._url}/lineItems?includeAll=true"
52
57
  )
53
- return [LineItem.model_validate(e) for e in (await self._get(url)).get("items")]
58
+ return [LineItem.model_validate(e) for e in (await self._get(url)).get("items", [])]
54
59
 
55
60
  async def list_lists(self) -> list[List]:
56
61
  """
@@ -58,7 +63,7 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
58
63
  :return: All Lists on this model.
59
64
  """
60
65
  return [
61
- List.model_validate(e) for e in (await self._get(f"{self._url}/lists")).get("lists")
66
+ List.model_validate(e) for e in (await self._get(f"{self._url}/lists")).get("lists", [])
62
67
  ]
63
68
 
64
69
  async def get_list_metadata(self, list_id: int) -> ListMetadata:
@@ -85,28 +90,64 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
85
90
  )
86
91
  ]
87
92
 
88
- async def add_items_to_list(
89
- self, list_id: int, items: list[dict[str, str | dict]]
93
+ async def insert_list_items(
94
+ self, list_id: int, items: list[dict[str, str | int | dict]]
90
95
  ) -> InsertionResult:
91
96
  """
92
- Adds items to a List.
97
+ Insert new items to the given list. The items must be a list of dictionaries with at least
98
+ the keys `code` and `name`. You can optionally pass further keys for parents, extra
99
+ properties etc.
93
100
  :param list_id: The ID of the List.
94
- :param items: The items to add to the List.
95
- :return: The result of the insertion.
96
- """
97
- # TODO: Paginate by 100k records.
98
- return InsertionResult.model_validate(
99
- await self._post(f"{self._url}/lists/{list_id}/items?action=add", json={"items": items})
101
+ :param items: The items to insert into the List.
102
+ :return: The result of the insertion, indicating how many items were added,
103
+ ignored or failed.
104
+ """
105
+ if len(items) <= 100_000:
106
+ return InsertionResult.model_validate(
107
+ await self._post(
108
+ f"{self._url}/lists/{list_id}/items?action=add", json={"items": items}
109
+ )
110
+ )
111
+ responses = await gather(
112
+ *(
113
+ self._post(f"{self._url}/lists/{list_id}/items?action=add", json={"items": chunk})
114
+ for chunk in (items[i : i + 100_000] for i in range(0, len(items), 100_000))
115
+ )
100
116
  )
117
+ failures, added, ignored, total = [], 0, 0, 0
118
+ for res in responses:
119
+ failures.append(res.get("failures", []))
120
+ added += res.get("added", 0)
121
+ total += res.get("total", 0)
122
+ ignored += res.get("ignored", 0)
101
123
 
102
- async def delete_list_items(self, list_id: int, items: list[dict[str, str | int]]) -> None:
124
+ return InsertionResult(
125
+ added=added, ignored=ignored, total=total, failures=list(chain.from_iterable(failures))
126
+ )
127
+
128
+ async def delete_list_items(self, list_id: int, items: list[dict[str, str | int]]) -> int:
103
129
  """
104
130
  Deletes items from a List.
105
131
  :param list_id: The ID of the List.
106
132
  :param items: The items to delete from the List. Must be a dict with either `code` or `id`
107
133
  as the keys to identify the records to delete.
108
134
  """
109
- await self._post(f"{self._url}/lists/{list_id}/items?action=delete", json={"items": items})
135
+ if len(items) <= 100_000:
136
+ return (
137
+ await self._post(
138
+ f"{self._url}/lists/{list_id}/items?action=delete", json={"items": items}
139
+ )
140
+ ).get("deleted", 0)
141
+
142
+ responses = await gather(
143
+ *(
144
+ self._post(
145
+ f"{self._url}/lists/{list_id}/items?action=delete", json={"items": chunk}
146
+ )
147
+ for chunk in (items[i : i + 100_000] for i in range(0, len(items), 100_000))
148
+ )
149
+ )
150
+ return sum(res.get("deleted", 0) for res in responses)
110
151
 
111
152
  async def reset_list_index(self, list_id: int) -> None:
112
153
  """
@@ -115,7 +156,7 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
115
156
  """
116
157
  await self._post_empty(f"{self._url}/lists/{list_id}/resetIndex")
117
158
 
118
- async def write_to_module(
159
+ async def update_module_data(
119
160
  self, module_id: int, data: list[dict[str, Any]]
120
161
  ) -> int | dict[str, Any]:
121
162
  """
@@ -129,3 +170,25 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
129
170
  """
130
171
  res = await self._post(f"{self._url}/modules/{module_id}/data", json=data)
131
172
  return res if "failures" in res else res["numberOfCellsChanged"]
173
+
174
+ async def write_to_module(
175
+ self, module_id: int, data: list[dict[str, Any]]
176
+ ) -> int | dict[str, Any]:
177
+ warnings.warn(
178
+ "`write_to_module()` is deprecated and will be removed in a future version. "
179
+ "Use `update_module_data()` instead.",
180
+ DeprecationWarning,
181
+ stacklevel=1,
182
+ )
183
+ return await self.update_module_data(module_id, data)
184
+
185
+ async def add_items_to_list(
186
+ self, list_id: int, items: list[dict[str, str | int | dict]]
187
+ ) -> InsertionResult:
188
+ warnings.warn(
189
+ "`add_items_to_list()` is deprecated and will be removed in a future version. "
190
+ "Use `insert_list_items()` instead.",
191
+ DeprecationWarning,
192
+ stacklevel=1,
193
+ )
194
+ return await self.insert_list_items(list_id, items)
anaplan_sdk/_auth.py CHANGED
@@ -12,86 +12,67 @@ from cryptography.hazmat.backends import default_backend
12
12
  from cryptography.hazmat.primitives import hashes, serialization
13
13
  from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
14
14
  from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
15
- from httpx import HTTPError
16
15
 
17
- from .exceptions import InvalidCredentialsException, InvalidPrivateKeyException
16
+ from .exceptions import AnaplanException, InvalidCredentialsException, InvalidPrivateKeyException
18
17
 
19
18
  logger = logging.getLogger("anaplan_sdk")
20
19
 
21
20
 
22
- class AnaplanBasicAuth(httpx.Auth):
21
+ class _AnaplanAuth(httpx.Auth):
23
22
  requires_response_body = True
24
23
 
25
- def __init__(
26
- self,
27
- user_email: str,
28
- password: str,
29
- ):
30
- self._user_email = user_email
31
- self._password = password
32
- self._token = self._init_token()
24
+ def __init__(self):
25
+ self._auth_request = self._build_auth_request()
26
+ logger.info("Creating Authentication Token.")
27
+ with httpx.Client(timeout=15.0) as client:
28
+ res = client.send(self._auth_request)
29
+ self._parse_auth_response(res)
30
+
31
+ def _build_auth_request(self) -> httpx.Request:
32
+ raise NotImplementedError("Must be implemented in subclass.")
33
33
 
34
34
  def auth_flow(self, request):
35
35
  request.headers["Authorization"] = f"AnaplanAuthToken {self._token}"
36
36
  response = yield request
37
37
  if response.status_code == 401:
38
38
  logger.info("Token expired, refreshing.")
39
- response = yield self._basic_auth_request()
40
- if "tokenInfo" not in response.json():
41
- raise InvalidCredentialsException
42
- self._token = response.json().get("tokenInfo").get("tokenValue")
39
+ auth_res = yield self._auth_request
40
+ self._parse_auth_response(auth_res)
43
41
  request.headers["Authorization"] = f"AnaplanAuthToken {self._token}"
44
42
  yield request
45
43
 
46
- def _basic_auth_request(self):
47
- credentials = b64encode(f"{self._user_email}:{self._password}".encode()).decode()
44
+ def _parse_auth_response(self, response: httpx.Response) -> None:
45
+ if response.status_code == 401:
46
+ raise InvalidCredentialsException
47
+ if not response.is_success:
48
+ raise AnaplanException(f"Authentication failed: {response.status_code} {response.text}")
49
+ self._token: str = response.json()["tokenInfo"]["tokenValue"]
50
+
51
+
52
+ class AnaplanBasicAuth(_AnaplanAuth):
53
+ def __init__(self, user_email: str, password: str):
54
+ self.user_email = user_email
55
+ self.password = password
56
+ super().__init__()
57
+
58
+ def _build_auth_request(self) -> httpx.Request:
59
+ cred = b64encode(f"{self.user_email}:{self.password}".encode()).decode()
48
60
  return httpx.Request(
49
61
  method="post",
50
62
  url="https://auth.anaplan.com/token/authenticate",
51
- headers={"Authorization": f"Basic {credentials}"},
63
+ headers={"Authorization": f"Basic {cred}"},
52
64
  )
53
65
 
54
- def _init_token(self) -> str:
55
- try:
56
- logger.info("Creating Authentication Token.")
57
- credentials = b64encode(f"{self._user_email}:{self._password}".encode()).decode()
58
- res = httpx.post(
59
- url="https://auth.anaplan.com/token/authenticate",
60
- headers={"Authorization": f"Basic {credentials}"},
61
- timeout=15,
62
- )
63
- res.raise_for_status()
64
- return res.json().get("tokenInfo").get("tokenValue")
65
- except HTTPError as error:
66
- raise InvalidCredentialsException from error
67
-
68
-
69
- class AnaplanCertAuth(httpx.Auth):
70
- requires_response_body = True
66
+
67
+ class AnaplanCertAuth(_AnaplanAuth):
71
68
  requires_request_body = True
72
69
 
73
- def __init__(
74
- self,
75
- certificate: bytes,
76
- private_key: RSAPrivateKey,
77
- ):
70
+ def __init__(self, certificate: bytes, private_key: RSAPrivateKey):
78
71
  self._certificate = certificate
79
72
  self._private_key = private_key
80
- self._token = self._init_token()
73
+ super().__init__()
81
74
 
82
- def auth_flow(self, request):
83
- request.headers["Authorization"] = f"AnaplanAuthToken {self._token}"
84
- response = yield request
85
- if response.status_code == 401:
86
- logger.info("Token expired or invalid, refreshing.")
87
- response = yield self._cert_auth_request()
88
- if "tokenInfo" not in response.json():
89
- raise InvalidCredentialsException
90
- self._token = response.json().get("tokenInfo").get("tokenValue")
91
- request.headers["Authorization"] = f"AnaplanAuthToken {self._token}"
92
- yield request
93
-
94
- def _cert_auth_request(self):
75
+ def _build_auth_request(self) -> httpx.Request:
95
76
  encoded_cert, encoded_string, encoded_signed_string = self._prep_credentials()
96
77
  return httpx.Request(
97
78
  method="post",
@@ -103,24 +84,6 @@ class AnaplanCertAuth(httpx.Auth):
103
84
  json={"encodedData": encoded_string, "encodedSignedData": encoded_signed_string},
104
85
  )
105
86
 
106
- def _init_token(self) -> str:
107
- try:
108
- logger.info("Creating Authentication Token.")
109
- encoded_cert, encoded_string, encoded_signed_string = self._prep_credentials()
110
- res = httpx.post(
111
- url="https://auth.anaplan.com/token/authenticate",
112
- headers={
113
- "Authorization": f"CACertificate {encoded_cert}",
114
- "Content-Type": "application/json",
115
- },
116
- json={"encodedData": encoded_string, "encodedSignedData": encoded_signed_string},
117
- timeout=15,
118
- )
119
- res.raise_for_status()
120
- return res.json().get("tokenInfo").get("tokenValue")
121
- except HTTPError as error:
122
- raise InvalidCredentialsException from error
123
-
124
87
  def _prep_credentials(self) -> tuple[str, str, str]:
125
88
  message = os.urandom(150)
126
89
  return (
anaplan_sdk/_base.py CHANGED
@@ -2,7 +2,10 @@
2
2
  Provides Base Classes for this project.
3
3
  """
4
4
 
5
+ import asyncio
5
6
  import logging
7
+ import random
8
+ import time
6
9
  from gzip import compress
7
10
  from typing import Any, Callable, Coroutine, Literal
8
11
 
@@ -51,6 +54,13 @@ class _BaseClient:
51
54
  for i in range(max(self._retry_count, 1)):
52
55
  try:
53
56
  response = func(*args, **kwargs)
57
+ if response.status_code == 429:
58
+ if i >= self._retry_count - 1:
59
+ raise AnaplanException("Rate limit exceeded.")
60
+ backoff_time = max(i, 1) * random.randint(2, 5)
61
+ logger.info(f"Rate limited. Retrying in {backoff_time} seconds.")
62
+ time.sleep(backoff_time)
63
+ continue
54
64
  response.raise_for_status()
55
65
  return response
56
66
  except HTTPError as error:
@@ -99,6 +109,13 @@ class _AsyncBaseClient:
99
109
  for i in range(max(self._retry_count, 1)):
100
110
  try:
101
111
  response = await func(*args, **kwargs)
112
+ if response.status_code == 429:
113
+ if i >= self._retry_count - 1:
114
+ raise AnaplanException("Rate limit exceeded.")
115
+ backoff_time = (i + 1) * random.randint(3, 5)
116
+ logger.info(f"Rate limited. Retrying in {backoff_time} seconds.")
117
+ await asyncio.sleep(backoff_time)
118
+ continue
102
119
  response.raise_for_status()
103
120
  return response
104
121
  except HTTPError as error:
@@ -15,7 +15,17 @@ from typing_extensions import Self
15
15
  from anaplan_sdk._auth import AnaplanBasicAuth, AnaplanCertAuth, get_certificate, get_private_key
16
16
  from anaplan_sdk._base import _BaseClient, action_url
17
17
  from anaplan_sdk.exceptions import AnaplanActionError, InvalidIdentifierException
18
- from anaplan_sdk.models import Action, Export, File, Import, Model, Process, Workspace
18
+ from anaplan_sdk.models import (
19
+ Action,
20
+ Export,
21
+ File,
22
+ Import,
23
+ Model,
24
+ Process,
25
+ TaskStatus,
26
+ TaskSummary,
27
+ Workspace,
28
+ )
19
29
 
20
30
  from ._alm import _AlmClient
21
31
  from ._audit import _AuditClient
@@ -252,7 +262,7 @@ class Client(_BaseClient):
252
262
  Export.model_validate(e) for e in (self._get(f"{self._url}/exports")).get("exports", [])
253
263
  ]
254
264
 
255
- def run_action(self, action_id: int) -> None:
265
+ def run_action(self, action_id: int) -> TaskStatus:
256
266
  """
257
267
  Runs the specified Anaplan Action and validates the spawned task. If the Action fails or
258
268
  completes with errors, will raise an :py:class:`AnaplanActionError`. Failed Tasks are
@@ -270,16 +280,15 @@ class Client(_BaseClient):
270
280
  task_id = self.invoke_action(action_id)
271
281
  task_status = self.get_task_status(action_id, task_id)
272
282
 
273
- while "COMPLETE" not in task_status.get("taskState"):
283
+ while task_status.task_state != "COMPLETE":
274
284
  time.sleep(self.status_poll_delay)
275
285
  task_status = self.get_task_status(action_id, task_id)
276
286
 
277
- if task_status.get("taskState") == "COMPLETE" and not task_status.get("result").get(
278
- "successful"
279
- ):
287
+ if task_status.task_state == "COMPLETE" and not task_status.result.successful:
280
288
  raise AnaplanActionError(f"Task '{task_id}' completed with errors.")
281
289
 
282
290
  logger.info(f"Task '{task_id}' completed successfully.")
291
+ return task_status
283
292
 
284
293
  def get_file(self, file_id: int) -> bytes:
285
294
  """
@@ -383,26 +392,30 @@ class Client(_BaseClient):
383
392
  self.run_action(action_id)
384
393
  return self.get_file(action_id)
385
394
 
386
- def list_task_status(self, action_id: int) -> list:
395
+ def list_task_status(self, action_id: int) -> list[TaskSummary]:
387
396
  """
388
397
  Retrieves the status of all tasks spawned by the specified action.
389
398
  :param action_id: The identifier of the action that was invoked.
390
399
  :return: The list of tasks spawned by the action.
391
400
  """
392
- return self._get(f"{self._url}/{action_url(action_id)}/{action_id}/tasks").get("tasks", [])
401
+ return [
402
+ TaskSummary.model_validate(e)
403
+ for e in self._get(f"{self._url}/{action_url(action_id)}/{action_id}/tasks").get(
404
+ "tasks", []
405
+ )
406
+ ]
393
407
 
394
- def get_task_status(
395
- self, action_id: int, task_id: str
396
- ) -> dict[str, float | int | str | list | dict | bool]:
408
+ def get_task_status(self, action_id: int, task_id: str) -> TaskStatus:
397
409
  """
398
410
  Retrieves the status of the specified task.
399
411
  :param action_id: The identifier of the action that was invoked.
400
412
  :param task_id: The identifier of the spawned task.
401
- :return: The status of the task as returned by the API. For more information
402
- see: https://anaplan.docs.apiary.io.
413
+ :return: The status of the task.
403
414
  """
404
- return self._get(f"{self._url}/{action_url(action_id)}/{action_id}/tasks/{task_id}").get(
405
- "task"
415
+ return TaskStatus.model_validate(
416
+ self._get(f"{self._url}/{action_url(action_id)}/{action_id}/tasks/{task_id}").get(
417
+ "task"
418
+ )
406
419
  )
407
420
 
408
421
  def invoke_action(self, action_id: int) -> str:
@@ -1,3 +1,6 @@
1
+ import warnings
2
+ from concurrent.futures import ThreadPoolExecutor
3
+ from itertools import chain
1
4
  from typing import Any
2
5
 
3
6
  import httpx
@@ -45,14 +48,14 @@ class _TransactionalClient(_BaseClient):
45
48
  if only_module_id
46
49
  else f"{self._url}/lineItems?includeAll=true"
47
50
  )
48
- return [LineItem.model_validate(e) for e in self._get(url).get("items")]
51
+ return [LineItem.model_validate(e) for e in self._get(url).get("items", [])]
49
52
 
50
53
  def list_lists(self) -> list[List]:
51
54
  """
52
55
  Lists all the Lists in the Model.
53
56
  :return: All Lists on this Model.
54
57
  """
55
- return [List.model_validate(e) for e in self._get(f"{self._url}/lists").get("lists")]
58
+ return [List.model_validate(e) for e in self._get(f"{self._url}/lists").get("lists", [])]
56
59
 
57
60
  def get_list_metadata(self, list_id: int) -> ListMetadata:
58
61
  """
@@ -73,32 +76,71 @@ class _TransactionalClient(_BaseClient):
73
76
  return [
74
77
  ListItem.model_validate(e)
75
78
  for e in self._get(f"{self._url}/lists/{list_id}/items?includeAll=true").get(
76
- "listItems"
79
+ "listItems", []
77
80
  )
78
81
  ]
79
82
 
80
- def add_items_to_list(
81
- self, list_id: int, items: list[dict[str, str | dict]]
83
+ def insert_list_items(
84
+ self, list_id: int, items: list[dict[str, str | int | dict]]
82
85
  ) -> InsertionResult:
83
86
  """
84
- Adds items to a List.
87
+ Insert new items to the given list. The items must be a list of dictionaries with at least
88
+ the keys `code` and `name`. You can optionally pass further keys for parents, extra
89
+ properties etc.
85
90
  :param list_id: The ID of the List.
86
- :param items: The items to add to the List.
87
- :return: The result of the insertion.
91
+ :param items: The items to insert into the List.
92
+ :return: The result of the insertion, indicating how many items were added,
93
+ ignored or failed.
88
94
  """
89
- # TODO: Paginate by 100k records.
90
- return InsertionResult.model_validate(
91
- self._post(f"{self._url}/lists/{list_id}/items?action=add", json={"items": items})
95
+ if len(items) <= 100_000:
96
+ return InsertionResult.model_validate(
97
+ self._post(f"{self._url}/lists/{list_id}/items?action=add", json={"items": items})
98
+ )
99
+
100
+ with ThreadPoolExecutor() as executor:
101
+ responses = list(
102
+ executor.map(
103
+ lambda chunk: self._post(
104
+ f"{self._url}/lists/{list_id}/items?action=add", json={"items": chunk}
105
+ ),
106
+ [items[i : i + 100_000] for i in range(0, len(items), 100_000)],
107
+ )
108
+ )
109
+
110
+ failures, added, ignored, total = [], 0, 0, 0
111
+ for res in responses:
112
+ failures.append(res.get("failures", []))
113
+ added += res.get("added", 0)
114
+ total += res.get("total", 0)
115
+ ignored += res.get("ignored", 0)
116
+
117
+ return InsertionResult(
118
+ added=added, ignored=ignored, total=total, failures=list(chain.from_iterable(failures))
92
119
  )
93
120
 
94
- def delete_list_items(self, list_id: int, items: list[dict[str, str | int]]) -> None:
121
+ def delete_list_items(self, list_id: int, items: list[dict[str, str | int]]) -> int:
95
122
  """
96
123
  Deletes items from a List.
97
124
  :param list_id: The ID of the List.
98
125
  :param items: The items to delete from the List. Must be a dict with either `code` or `id`
99
126
  as the keys to identify the records to delete.
100
127
  """
101
- self._post(f"{self._url}/lists/{list_id}/items?action=delete", json={"items": items})
128
+ if len(items) <= 100_000:
129
+ return self._post(
130
+ f"{self._url}/lists/{list_id}/items?action=delete", json={"items": items}
131
+ ).get("deleted", 0)
132
+
133
+ with ThreadPoolExecutor() as executor:
134
+ responses = list(
135
+ executor.map(
136
+ lambda chunk: self._post(
137
+ f"{self._url}/lists/{list_id}/items?action=delete", json={"items": chunk}
138
+ ),
139
+ [items[i : i + 100_000] for i in range(0, len(items), 100_000)],
140
+ )
141
+ )
142
+
143
+ return sum(res.get("deleted", 0) for res in responses)
102
144
 
103
145
  def reset_list_index(self, list_id: int) -> None:
104
146
  """
@@ -107,7 +149,9 @@ class _TransactionalClient(_BaseClient):
107
149
  """
108
150
  self._post_empty(f"{self._url}/lists/{list_id}/resetIndex")
109
151
 
110
- def write_to_module(self, module_id: int, data: list[dict[str, Any]]) -> int | dict[str, Any]:
152
+ def update_module_data(
153
+ self, module_id: int, data: list[dict[str, Any]]
154
+ ) -> int | dict[str, Any]:
111
155
  """
112
156
  Write the passed items to the specified module. If successful, the number of cells changed
113
157
  is returned, if only partially successful or unsuccessful, the response with the according
@@ -119,3 +163,23 @@ class _TransactionalClient(_BaseClient):
119
163
  """
120
164
  res = self._post(f"{self._url}/modules/{module_id}/data", json=data)
121
165
  return res if "failures" in res else res["numberOfCellsChanged"]
166
+
167
+ def write_to_module(self, module_id: int, data: list[dict[str, Any]]) -> int | dict[str, Any]:
168
+ warnings.warn(
169
+ "`write_to_module()` is deprecated and will be removed in a future version. "
170
+ "Use `update_module_data()` instead.",
171
+ DeprecationWarning,
172
+ stacklevel=1,
173
+ )
174
+ return self.update_module_data(module_id, data)
175
+
176
+ def add_items_to_list(
177
+ self, list_id: int, items: list[dict[str, str | int | dict]]
178
+ ) -> InsertionResult:
179
+ warnings.warn(
180
+ "`add_items_to_list()` is deprecated and will be removed in a future version. "
181
+ "Use `insert_list_items()` instead.",
182
+ DeprecationWarning,
183
+ stacklevel=1,
184
+ )
185
+ return self.insert_list_items(list_id, items)