anaplan-sdk 0.2.9__tar.gz → 0.2.11__tar.gz

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.
Files changed (67) hide show
  1. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/.github/workflows/tests.yml +2 -2
  2. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/PKG-INFO +1 -1
  3. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/anaplan_sdk/_async_clients/_bulk.py +1 -0
  4. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/anaplan_sdk/_async_clients/_transactional.py +63 -11
  5. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/anaplan_sdk/_auth.py +35 -72
  6. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/anaplan_sdk/_base.py +17 -0
  7. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/anaplan_sdk/_clients/_bulk.py +1 -0
  8. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/anaplan_sdk/_clients/_transactional.py +63 -10
  9. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/index.md +3 -0
  10. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/pyproject.toml +5 -3
  11. anaplan_sdk-0.2.11/tests/async/conftest.py +65 -0
  12. anaplan_sdk-0.2.11/tests/async/test_async_alm_client.py +28 -0
  13. anaplan_sdk-0.2.11/tests/async/test_async_audit_client.py +12 -0
  14. anaplan_sdk-0.2.11/tests/async/test_async_client.py +91 -0
  15. anaplan_sdk-0.2.11/tests/async/test_async_transactional_client.py +66 -0
  16. anaplan_sdk-0.2.11/tests/conftest.py +17 -0
  17. anaplan_sdk-0.2.11/tests/sync/conftest.py +65 -0
  18. anaplan_sdk-0.2.11/tests/sync/test_alm_client.py +28 -0
  19. anaplan_sdk-0.2.11/tests/sync/test_audit_client.py +14 -0
  20. {anaplan_sdk-0.2.9/tests → anaplan_sdk-0.2.11/tests/sync}/test_client.py +23 -60
  21. anaplan_sdk-0.2.11/tests/sync/test_transactional_client.py +66 -0
  22. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/uv.lock +5 -5
  23. anaplan_sdk-0.2.9/tests/test_alm_client.py +0 -46
  24. anaplan_sdk-0.2.9/tests/test_async_alm_client.py +0 -59
  25. anaplan_sdk-0.2.9/tests/test_async_client.py +0 -153
  26. anaplan_sdk-0.2.9/tests/test_async_transactional_client.py +0 -84
  27. anaplan_sdk-0.2.9/tests/test_transactional_client.py +0 -65
  28. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/.github/dependabot.yml +0 -0
  29. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/.github/workflows/docs.yml +0 -0
  30. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/.github/workflows/lint.yml +0 -0
  31. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/.gitignore +0 -0
  32. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/.pre-commit-config.yaml +0 -0
  33. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/LICENSE +0 -0
  34. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/README.md +0 -0
  35. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/anaplan_sdk/__init__.py +0 -0
  36. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/anaplan_sdk/_async_clients/__init__.py +0 -0
  37. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/anaplan_sdk/_async_clients/_alm.py +0 -0
  38. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/anaplan_sdk/_async_clients/_audit.py +0 -0
  39. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/anaplan_sdk/_clients/__init__.py +0 -0
  40. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/anaplan_sdk/_clients/_alm.py +0 -0
  41. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/anaplan_sdk/_clients/_audit.py +0 -0
  42. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/anaplan_sdk/exceptions.py +0 -0
  43. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/anaplan_sdk/models.py +0 -0
  44. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/anaplan_explained.md +0 -0
  45. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/api/alm_client.md +0 -0
  46. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/api/async_alm_client.md +0 -0
  47. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/api/async_audit_client.md +0 -0
  48. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/api/async_client.md +0 -0
  49. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/api/async_transactional_client.md +0 -0
  50. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/api/audit_client.md +0 -0
  51. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/api/client.md +0 -0
  52. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/api/exceptions.md +0 -0
  53. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/api/models.md +0 -0
  54. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/api/transactional_client.md +0 -0
  55. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/css/styles.css +0 -0
  56. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/guides/alm.md +0 -0
  57. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/guides/audit.md +0 -0
  58. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/guides/bulk.md +0 -0
  59. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/guides/bulk_vs_transactional.md +0 -0
  60. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/guides/logging.md +0 -0
  61. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/guides/multiple_models.md +0 -0
  62. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/guides/transactional.md +0 -0
  63. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/img/anaplan-overview.webp +0 -0
  64. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/img/anaplan-sdk.webp +0 -0
  65. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/installation.md +0 -0
  66. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/docs/quickstart.md +0 -0
  67. {anaplan_sdk-0.2.9 → anaplan_sdk-0.2.11}/mkdocs.yml +0 -0
@@ -18,7 +18,7 @@ jobs:
18
18
  runs-on: "ubuntu-latest"
19
19
 
20
20
  strategy:
21
- max-parallel: 1
21
+ max-parallel: 4
22
22
  matrix:
23
23
  python-version: [ "3.10.4", "3.11", "3.12", "3.13" ]
24
24
 
@@ -32,4 +32,4 @@ jobs:
32
32
  pip install uv
33
33
  uv sync
34
34
  - name: "Run tests"
35
- run: "uv run python -m pytest -n 6 --dist loadfile tests/"
35
+ run: "uv run python -m pytest -n 8 --dist loadfile tests/"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anaplan-sdk
3
- Version: 0.2.9
3
+ Version: 0.2.11
4
4
  Summary: Provides pythonic access to the Anaplan API
5
5
  Project-URL: Homepage, https://vinzenzklass.github.io/anaplan-sdk/
6
6
  Project-URL: Repository, https://github.com/VinzenzKlass/anaplan-sdk
@@ -133,6 +133,7 @@ class AsyncClient(_AsyncBaseClient):
133
133
  client._transactional_client = _AsyncTransactionalClient(
134
134
  existing._client, model_id, existing._retry_count
135
135
  )
136
+ client._alm_client = _AsyncAlmClient(existing._client, model_id, existing._retry_count)
136
137
  return client
137
138
 
138
139
  @property
@@ -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:
@@ -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
+ )
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)
123
+
124
+ return InsertionResult(
125
+ added=added, ignored=ignored, total=total, failures=list(chain.from_iterable(failures))
100
126
  )
101
127
 
102
- async def delete_list_items(self, list_id: int, items: list[dict[str, str | int]]) -> None:
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
  """
@@ -129,3 +170,14 @@ 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 add_items_to_list(
175
+ self, list_id: int, items: list[dict[str, str | int | dict]]
176
+ ) -> InsertionResult:
177
+ warnings.warn(
178
+ "`add_items_to_list()` is deprecated and will be removed in a future version. "
179
+ "Use `insert_list_items()` instead.",
180
+ DeprecationWarning,
181
+ stacklevel=1,
182
+ )
183
+ return await self.insert_list_items(list_id, items)
@@ -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 (
@@ -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:
@@ -140,6 +140,7 @@ class Client(_BaseClient):
140
140
  client._transactional_client = _TransactionalClient(
141
141
  existing._client, model_id, existing._retry_count
142
142
  )
143
+ client._alm_client = _AlmClient(existing._client, model_id, existing._retry_count)
143
144
  return client
144
145
 
145
146
  @property
@@ -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
@@ -77,28 +80,67 @@ class _TransactionalClient(_BaseClient):
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
  """
@@ -119,3 +161,14 @@ class _TransactionalClient(_BaseClient):
119
161
  """
120
162
  res = self._post(f"{self._url}/modules/{module_id}/data", json=data)
121
163
  return res if "failures" in res else res["numberOfCellsChanged"]
164
+
165
+ def add_items_to_list(
166
+ self, list_id: int, items: list[dict[str, str | int | dict]]
167
+ ) -> InsertionResult:
168
+ warnings.warn(
169
+ "`add_items_to_list()` is deprecated and will be removed in a future version. "
170
+ "Use `insert_list_items()` instead.",
171
+ DeprecationWarning,
172
+ stacklevel=1,
173
+ )
174
+ return self.insert_list_items(list_id, items)
@@ -29,3 +29,6 @@ providing both synchronous and asynchronous Clients.
29
29
 
30
30
  Head over to the [Quick Start](quickstart.md) to get started with the basics.
31
31
 
32
+ If you find any issues or feel that this SDK is not adequately covering your use case,
33
+ please [open an issue](https://github.com/VinzenzKlass/anaplan-sdk/issues/new).
34
+
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "anaplan-sdk"
3
- version = "0.2.9"
3
+ version = "0.2.11"
4
4
  description = "Provides pythonic access to the Anaplan API"
5
5
  license = "Apache-2.0"
6
6
  authors = [{ name = "Vinzenz Klass", email = "vinzenz.klass@valantic.com" }]
@@ -18,7 +18,7 @@ dev = [
18
18
  "ruff>=0.9.2",
19
19
  "pre-commit>=4.0.1",
20
20
  "pytest>=8.3.3",
21
- "pytest-asyncio>=0.24.0",
21
+ "pytest-asyncio>=0.26.0",
22
22
  "pytest-xdist>=3.6.1",
23
23
  "mkdocs>=1.6.1",
24
24
  "mkdocs-material>=9.5.41",
@@ -76,7 +76,9 @@ skip-magic-trailing-comma = false
76
76
  select = ["E", "F", "B", "I"]
77
77
 
78
78
  [tool.pytest.ini_options]
79
- asyncio_default_fixture_loop_scope = "module"
79
+ asyncio_mode = "auto"
80
+ asyncio_default_fixture_loop_scope = "session"
81
+ asyncio_default_test_loop_scope = "session"
80
82
  minversion = "8.0"
81
83
  addopts = "-ra -q"
82
84
  pythonpath = "anaplan_sdk/"
@@ -0,0 +1,65 @@
1
+ import os
2
+ import sys
3
+ from os import getenv
4
+
5
+ import pytest
6
+
7
+ import anaplan_sdk
8
+
9
+
10
+ @pytest.fixture(scope="session")
11
+ def client():
12
+ return anaplan_sdk.AsyncClient(
13
+ workspace_id=getenv("ANAPLAN_SDK_TEST_WORKSPACE_ID"),
14
+ model_id=getenv("ANAPLAN_SDK_TEST_MODEL_ID"),
15
+ certificate=getenv("ANAPLAN_SDK_TEST_CERT"),
16
+ private_key=getenv("ANAPLAN_SDK_TEST_PK"),
17
+ retry_count=3,
18
+ )
19
+
20
+
21
+ @pytest.fixture(scope="session")
22
+ def broken_client():
23
+ return anaplan_sdk.AsyncClient(
24
+ workspace_id="random",
25
+ model_id="nonsense",
26
+ certificate=os.getenv("ANAPLAN_SDK_TEST_CERT"),
27
+ private_key=os.getenv("ANAPLAN_SDK_TEST_PK"),
28
+ retry_count=1,
29
+ )
30
+
31
+
32
+ @pytest.fixture(scope="session")
33
+ def test_list():
34
+ py_version = sys.version.split(" ")[0]
35
+ if "3.10" in py_version:
36
+ return 101000000309
37
+ if "3.11" in py_version:
38
+ return 101000000310
39
+ if "3.12" in py_version:
40
+ return 101000000311
41
+ return 101000000312
42
+
43
+
44
+ @pytest.fixture(scope="session")
45
+ def test_file():
46
+ py_version = sys.version.split(" ")[0]
47
+ if "3.10" in py_version:
48
+ return 113000000061
49
+ if "3.11" in py_version:
50
+ return 113000000062
51
+ if "3.12" in py_version:
52
+ return 113000000063
53
+ return 113000000064
54
+
55
+
56
+ @pytest.fixture(scope="session")
57
+ def test_action():
58
+ py_version = sys.version.split(" ")[0]
59
+ if "3.10" in py_version:
60
+ return 118000000028
61
+ if "3.11" in py_version:
62
+ return 118000000027
63
+ if "3.12" in py_version:
64
+ return 118000000026
65
+ return 118000000025
@@ -0,0 +1,28 @@
1
+ from anaplan_sdk import AsyncClient
2
+
3
+
4
+ async def test_get_revisions(client: AsyncClient):
5
+ revs = await client.alm.get_revisions()
6
+ assert isinstance(revs, list)
7
+ assert len(revs) > 0
8
+
9
+
10
+ async def test_get_models_for_revision(client: AsyncClient):
11
+ model_revs = await client.alm.get_models_for_revision("44867AAA4DD94C6EB8A23690A0C11DF4")
12
+ assert isinstance(model_revs, list)
13
+ assert len(model_revs) > 0
14
+
15
+
16
+ async def test_get_sync_tasks(client: AsyncClient):
17
+ tasks = await client.alm.get_sync_tasks()
18
+ assert isinstance(tasks, list)
19
+
20
+
21
+ async def test_get_syncable_revisions(client: AsyncClient):
22
+ models = await client.alm.get_syncable_revisions("327F80BA66344A1C84C69AE82C006CDE")
23
+ assert isinstance(models, list)
24
+
25
+
26
+ async def test_get_latest_revision(client: AsyncClient):
27
+ revs = await client.alm.get_latest_revision()
28
+ assert isinstance(revs, list)
@@ -0,0 +1,12 @@
1
+ from anaplan_sdk import AsyncClient, models
2
+
3
+
4
+ async def test_list_users(client: AsyncClient):
5
+ users = await client.audit.list_users()
6
+ assert isinstance(users, list)
7
+ assert isinstance(users[0], models.User)
8
+
9
+
10
+ async def test_events(client: AsyncClient):
11
+ events = await client.audit.get_events(1)
12
+ assert isinstance(events, list)
@@ -0,0 +1,91 @@
1
+ from anaplan_sdk import AsyncClient
2
+ from anaplan_sdk.exceptions import InvalidIdentifierException
3
+
4
+
5
+ async def test_list_workspaces(client: AsyncClient):
6
+ workspaces = await client.list_workspaces()
7
+ assert isinstance(workspaces, list)
8
+ assert len(workspaces) > 0
9
+
10
+
11
+ async def test_broken_list_workspaces_raises_invalid_identifier_error(broken_client):
12
+ try:
13
+ await broken_client.list_workspaces()
14
+ except Exception as error:
15
+ assert isinstance(error, InvalidIdentifierException)
16
+
17
+
18
+ async def test_list_models(client: AsyncClient):
19
+ models = await client.list_models()
20
+ assert isinstance(models, list)
21
+ assert len(models) > 0
22
+
23
+
24
+ async def test_list_actions(client: AsyncClient):
25
+ actions = await client.list_actions()
26
+ assert isinstance(actions, list)
27
+ assert len(actions) > 0
28
+
29
+
30
+ async def test_list_files(client: AsyncClient):
31
+ files = await client.list_files()
32
+ assert isinstance(files, list)
33
+ assert len(files) > 0
34
+
35
+
36
+ async def test_list_processes(client: AsyncClient):
37
+ processes = await client.list_processes()
38
+ assert isinstance(processes, list)
39
+ assert len(processes) > 0
40
+
41
+
42
+ async def test_list_imports(client: AsyncClient):
43
+ imports = await client.list_imports()
44
+ assert isinstance(imports, list)
45
+ assert len(imports) > 0
46
+
47
+
48
+ async def test_list_exports(client: AsyncClient):
49
+ exports = await client.list_exports()
50
+ assert isinstance(exports, list)
51
+ assert len(exports) > 0
52
+
53
+
54
+ async def test_upload_file_stream(client, test_file):
55
+ await client.upload_file_stream(test_file, (i async for i in _async_range(10)))
56
+ out = await client.get_file(test_file)
57
+ assert out == b"0123456789"
58
+
59
+
60
+ async def test_get_file_stream(client, test_file):
61
+ async for chunk in client.get_file_stream(test_file):
62
+ assert isinstance(chunk, bytes)
63
+
64
+
65
+ async def test_upload_and_download_file(client, test_file):
66
+ await client.upload_file(test_file, "Hi!")
67
+ out = await client.get_file(test_file)
68
+ assert out == b"Hi!"
69
+
70
+
71
+ async def test_run_process(client, test_action):
72
+ await client.run_action(test_action)
73
+
74
+
75
+ async def test_invoke_action(client, test_action):
76
+ task_id = await client.invoke_action(test_action)
77
+ assert isinstance(task_id, str)
78
+ assert len(task_id) == 32
79
+
80
+
81
+ async def test_get_task_status(client, test_action):
82
+ task_status = await client.get_task_status(test_action, await client.invoke_action(test_action))
83
+ assert isinstance(task_status, dict)
84
+ assert "progress" in task_status
85
+ assert "creationTime" in task_status
86
+ assert "taskState" in task_status
87
+
88
+
89
+ async def _async_range(count: int):
90
+ for i in range(count):
91
+ yield str(i)