anaplan-sdk 0.2.10__py3-none-any.whl → 0.2.11__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,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)
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:
@@ -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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anaplan-sdk
3
- Version: 0.2.10
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
@@ -1,19 +1,19 @@
1
1
  anaplan_sdk/__init__.py,sha256=5fr-SZSsH6f3vkRUTDoK6xdAN31cCpe9Mwz2VNu47Uw,134
2
- anaplan_sdk/_auth.py,sha256=wRcMpdDiHuV-dtiGAKElDiwzfZAEeTOFWfSfaLwNPoU,6597
3
- anaplan_sdk/_base.py,sha256=2Te7rg_o8_1KD64NfKsDiPLladaoDxMuzk0PaAUNSr8,5299
2
+ anaplan_sdk/_auth.py,sha256=0d495G_iU8vfpk29BJow7Jw2staf18nXqpJlSfaL9h8,5123
3
+ anaplan_sdk/_base.py,sha256=MEE6LpL788QTkrpAVsYI5hu3RfbzSMLGUj-QSW8-OU0,6160
4
4
  anaplan_sdk/exceptions.py,sha256=ALkA9fBF0NQ7dufFxV6AivjmHyuJk9DOQ9jtJV2n7f0,1809
5
5
  anaplan_sdk/models.py,sha256=ceMaVctpjwQHk7a71Io_-1YcCQshx5i1YYnqxS51nYw,12491
6
6
  anaplan_sdk/_async_clients/__init__.py,sha256=wT6qfi4f_4vLFWTJQTsBw8r3DrHtoTIVqi88p5_j-Cg,259
7
7
  anaplan_sdk/_async_clients/_alm.py,sha256=HtpwKNCc5eb6DUgS8nqNocxzaoaHOAMQPo0SaTMaD-A,4021
8
8
  anaplan_sdk/_async_clients/_audit.py,sha256=wgJx58aDksWJLu4MU-tOz76KjG41AVzBW0v3jAEv9GE,2897
9
9
  anaplan_sdk/_async_clients/_bulk.py,sha256=kviZxMbTolz9ZmbtE_hhOxbnmZghToRo3mxIhVADzho,22299
10
- anaplan_sdk/_async_clients/_transactional.py,sha256=wX_1U5YS4uwrr8D8MfNkfeA-ylFERMb-6xvewG799xY,4961
10
+ anaplan_sdk/_async_clients/_transactional.py,sha256=yhBt5Fzpt07IGIhvMZWcMoXEIGUlSJwAsxvzaq2vT9c,7056
11
11
  anaplan_sdk/_clients/__init__.py,sha256=FsbwvZC1FHrxuRXwbPxUzbhz_lO1DpXIxEOjx6-3QuA,219
12
12
  anaplan_sdk/_clients/_alm.py,sha256=wzhibRuNzsK3PZM2EOI3OnSGHfv8CG2fuY5zLbnSqog,3912
13
13
  anaplan_sdk/_clients/_audit.py,sha256=jqj_sTGNUaM2jAu91Us747pVULntPUkL_qkA4nKc8so,2981
14
14
  anaplan_sdk/_clients/_bulk.py,sha256=hTepWQZ-IxAs2WPqsuTfWF3ExuWWrNiMD9I4viQpUAQ,22314
15
- anaplan_sdk/_clients/_transactional.py,sha256=4NYhq2HdxNh4K-HqMyYoyAEqKih2eTtFiXDZi8xOaf8,4695
16
- anaplan_sdk-0.2.10.dist-info/METADATA,sha256=MmE2KsCg0OfKUgnE_hc4TOCRQROmIY35hKAZ3fCRQD0,3618
17
- anaplan_sdk-0.2.10.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
18
- anaplan_sdk-0.2.10.dist-info/licenses/LICENSE,sha256=HrhfyXIkWY2tGFK11kg7vPCqhgh5DcxleloqdhrpyMY,11558
19
- anaplan_sdk-0.2.10.dist-info/RECORD,,
15
+ anaplan_sdk/_clients/_transactional.py,sha256=-DuNhhfp2h9fc-ENJQRh9qwXsZiBi3OV1LeH3S7Jzsc,6871
16
+ anaplan_sdk-0.2.11.dist-info/METADATA,sha256=1GMwYl98joRinRzgF3edxbhnY0FeDFE236OnrYNK8CY,3618
17
+ anaplan_sdk-0.2.11.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
18
+ anaplan_sdk-0.2.11.dist-info/licenses/LICENSE,sha256=HrhfyXIkWY2tGFK11kg7vPCqhgh5DcxleloqdhrpyMY,11558
19
+ anaplan_sdk-0.2.11.dist-info/RECORD,,