anaplan-sdk 0.4.5__py3-none-any.whl → 0.5.0a1__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,27 +1,72 @@
1
+ import logging
1
2
  from asyncio import gather
2
3
  from itertools import chain
3
- from typing import Any
4
+ from typing import Any, Literal, overload
4
5
 
5
6
  import httpx
6
7
 
7
- from anaplan_sdk._base import _AsyncBaseClient
8
+ from anaplan_sdk._base import (
9
+ _AsyncBaseClient,
10
+ parse_calendar_response,
11
+ parse_insertion_response,
12
+ validate_dimension_id,
13
+ )
14
+ from anaplan_sdk.exceptions import InvalidIdentifierException
8
15
  from anaplan_sdk.models import (
16
+ CurrentPeriod,
17
+ Dimension,
18
+ DimensionWithCode,
19
+ FiscalYear,
9
20
  InsertionResult,
10
21
  LineItem,
11
22
  List,
12
23
  ListItem,
13
24
  ListMetadata,
25
+ Model,
26
+ ModelCalendar,
14
27
  ModelStatus,
15
28
  Module,
29
+ View,
30
+ ViewInfo,
16
31
  )
32
+ from anaplan_sdk.models._transactional import ListDeletionResult
33
+
34
+ logger = logging.getLogger("anaplan_sdk")
17
35
 
18
36
 
19
37
  class _AsyncTransactionalClient(_AsyncBaseClient):
20
38
  def __init__(self, client: httpx.AsyncClient, model_id: str, retry_count: int) -> None:
21
39
  self._url = f"https://api.anaplan.com/2/0/models/{model_id}"
40
+ self._model_id = model_id
22
41
  super().__init__(retry_count, client)
23
42
 
24
- async def list_modules(self) -> list[Module]:
43
+ async def get_model_details(self) -> Model:
44
+ """
45
+ Retrieves the Model details for the current Model.
46
+ :return: The Model details.
47
+ """
48
+ res = await self._get(self._url, params={"modelDetails": "true"})
49
+ return Model.model_validate(res["model"])
50
+
51
+ async def get_model_status(self) -> ModelStatus:
52
+ """
53
+ Gets the current status of the Model.
54
+ :return: The current status of the Model.
55
+ """
56
+ res = await self._get(f"{self._url}/status")
57
+ return ModelStatus.model_validate(res["requestStatus"])
58
+
59
+ async def wake_model(self) -> None:
60
+ """Wake up the current model."""
61
+ await self._post_empty(f"{self._url}/open", headers={"Content-Type": "application/text"})
62
+ logger.info(f"Woke up model '{self._model_id}'.")
63
+
64
+ async def close_model(self) -> None:
65
+ """Close the current model."""
66
+ await self._post_empty(f"{self._url}/close", headers={"Content-Type": "application/text"})
67
+ logger.info(f"Closed model '{self._model_id}'.")
68
+
69
+ async def get_modules(self) -> list[Module]:
25
70
  """
26
71
  Lists all the Modules in the Model.
27
72
  :return: The List of Modules.
@@ -31,29 +76,40 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
31
76
  for e in await self._get_paginated(f"{self._url}/modules", "modules")
32
77
  ]
33
78
 
34
- async def get_model_status(self) -> ModelStatus:
79
+ async def get_views(self) -> list[View]:
35
80
  """
36
- Gets the current status of the Model.
37
- :return: The current status of the Model.
81
+ Lists all the Views in the Model. This will include all Modules and potentially other saved
82
+ views.
83
+ :return: The List of Views.
38
84
  """
39
- return ModelStatus.model_validate(
40
- (await self._get(f"{self._url}/status")).get("requestStatus")
41
- )
85
+ params = {"includesubsidiaryviews": True}
86
+ return [
87
+ View.model_validate(e)
88
+ for e in await self._get_paginated(f"{self._url}/views", "views", params=params)
89
+ ]
42
90
 
43
- async def list_line_items(self, only_module_id: int | None = None) -> list[LineItem]:
91
+ async def get_view_info(self, view_id: int) -> ViewInfo:
92
+ """
93
+ Gets the detailed information about a View.
94
+ :param view_id: The ID of the View.
95
+ :return: The information about the View.
96
+ """
97
+ return ViewInfo.model_validate((await self._get(f"{self._url}/views/{view_id}")))
98
+
99
+ async def get_line_items(self, only_module_id: int | None = None) -> list[LineItem]:
44
100
  """
45
101
  Lists all the Line Items in the Model.
46
102
  :param only_module_id: If provided, only Line Items from this Module will be returned.
47
- :return: All Line Items on this Model.
103
+ :return: All Line Items on this Model or only from the specified Module.
48
104
  """
49
- url = (
105
+ res = await self._get(
50
106
  f"{self._url}/modules/{only_module_id}/lineItems?includeAll=true"
51
107
  if only_module_id
52
108
  else f"{self._url}/lineItems?includeAll=true"
53
109
  )
54
- return [LineItem.model_validate(e) for e in (await self._get(url)).get("items", [])]
110
+ return [LineItem.model_validate(e) for e in res.get("items", [])]
55
111
 
56
- async def list_lists(self) -> list[List]:
112
+ async def get_lists(self) -> list[List]:
57
113
  """
58
114
  Lists all the Lists in the Model.
59
115
  :return: All Lists on this model.
@@ -73,18 +129,31 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
73
129
  (await self._get(f"{self._url}/lists/{list_id}")).get("metadata")
74
130
  )
75
131
 
76
- async def get_list_items(self, list_id: int) -> list[ListItem]:
132
+ @overload
133
+ async def get_list_items(
134
+ self, list_id: int, return_raw: Literal[False] = False
135
+ ) -> list[ListItem]: ...
136
+
137
+ @overload
138
+ async def get_list_items(
139
+ self, list_id: int, return_raw: Literal[True] = True
140
+ ) -> list[dict[str, Any]]: ...
141
+
142
+ async def get_list_items(
143
+ self, list_id: int, return_raw: bool = False
144
+ ) -> list[ListItem] | list[dict[str, Any]]:
77
145
  """
78
146
  Gets all the items in a List.
79
147
  :param list_id: The ID of the List.
148
+ :param return_raw: If True, returns the items as a list of dictionaries instead of ListItem
149
+ objects. If you use the result of this call in a DataFrame or you simply pass on the
150
+ data, you will want to set this to avoid unnecessary (de-)serialization.
80
151
  :return: All items in the List.
81
152
  """
82
- return [
83
- ListItem.model_validate(e)
84
- for e in (await self._get(f"{self._url}/lists/{list_id}/items?includeAll=true")).get(
85
- "listItems"
86
- )
87
- ]
153
+ res = await self._get(f"{self._url}/lists/{list_id}/items?includeAll=true")
154
+ if return_raw:
155
+ return res.get("listItems", [])
156
+ return [ListItem.model_validate(e) for e in res.get("listItems", [])]
88
157
 
89
158
  async def insert_list_items(
90
159
  self, list_id: int, items: list[dict[str, str | int | dict]]
@@ -106,30 +175,29 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
106
175
  :return: The result of the insertion, indicating how many items were added,
107
176
  ignored or failed.
108
177
  """
178
+ if not items:
179
+ return InsertionResult(added=0, ignored=0, failures=[], total=0)
109
180
  if len(items) <= 100_000:
110
- return InsertionResult.model_validate(
181
+ result = InsertionResult.model_validate(
111
182
  await self._post(
112
183
  f"{self._url}/lists/{list_id}/items?action=add", json={"items": items}
113
184
  )
114
185
  )
186
+ logger.info(f"Inserted {result.added} items into list '{list_id}'.")
187
+ return result
115
188
  responses = await gather(
116
189
  *(
117
190
  self._post(f"{self._url}/lists/{list_id}/items?action=add", json={"items": chunk})
118
191
  for chunk in (items[i : i + 100_000] for i in range(0, len(items), 100_000))
119
192
  )
120
193
  )
121
- failures, added, ignored, total = [], 0, 0, 0
122
- for res in responses:
123
- failures.append(res.get("failures", []))
124
- added += res.get("added", 0)
125
- total += res.get("total", 0)
126
- ignored += res.get("ignored", 0)
127
-
128
- return InsertionResult(
129
- added=added, ignored=ignored, total=total, failures=list(chain.from_iterable(failures))
130
- )
194
+ result = parse_insertion_response(responses)
195
+ logger.info(f"Inserted {result.added} items into list '{list_id}'.")
196
+ return result
131
197
 
132
- async def delete_list_items(self, list_id: int, items: list[dict[str, str | int]]) -> int:
198
+ async def delete_list_items(
199
+ self, list_id: int, items: list[dict[str, str | int]]
200
+ ) -> ListDeletionResult:
133
201
  """
134
202
  Deletes items from a List. If you pass a long list, it will be split into chunks of 100,000
135
203
  items, the maximum allowed by the API.
@@ -143,14 +211,18 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
143
211
 
144
212
  :param list_id: The ID of the List.
145
213
  :param items: The items to delete from the List. Must be a dict with either `code` or `id`
146
- as the keys to identify the records to delete.
214
+ as the keys to identify the records to delete. Specifying both will error.
215
+ :return: The result of the deletion, indicating how many items were deleted or failed.
147
216
  """
217
+ if not items:
218
+ return ListDeletionResult(deleted=0, failures=[])
148
219
  if len(items) <= 100_000:
149
- return (
150
- await self._post(
151
- f"{self._url}/lists/{list_id}/items?action=delete", json={"items": items}
152
- )
153
- ).get("deleted", 0)
220
+ res = await self._post(
221
+ f"{self._url}/lists/{list_id}/items?action=delete", json={"items": items}
222
+ )
223
+ info = ListDeletionResult.model_validate(res)
224
+ logger.info(f"Deleted {info.deleted} items from list '{list_id}'.")
225
+ return info
154
226
 
155
227
  responses = await gather(
156
228
  *(
@@ -160,7 +232,12 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
160
232
  for chunk in (items[i : i + 100_000] for i in range(0, len(items), 100_000))
161
233
  )
162
234
  )
163
- return sum(res.get("deleted", 0) for res in responses)
235
+ info = ListDeletionResult(
236
+ deleted=sum(res.get("deleted", 0) for res in responses),
237
+ failures=list(chain.from_iterable(res.get("failures", []) for res in responses)),
238
+ )
239
+ logger.info(f"Deleted {info} items from list '{list_id}'.")
240
+ return info
164
241
 
165
242
  async def reset_list_index(self, list_id: int) -> None:
166
243
  """
@@ -168,6 +245,7 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
168
245
  :param list_id: The ID of the List.
169
246
  """
170
247
  await self._post_empty(f"{self._url}/lists/{list_id}/resetIndex")
248
+ logger.info(f"Reset index for list '{list_id}'.")
171
249
 
172
250
  async def update_module_data(
173
251
  self, module_id: int, data: list[dict[str, Any]]
@@ -187,4 +265,117 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
187
265
  :return: The number of cells changed or the response with the according error details.
188
266
  """
189
267
  res = await self._post(f"{self._url}/modules/{module_id}/data", json=data)
268
+ if "failures" not in res:
269
+ logger.info(f"Updated {res['numberOfCellsChanged']} cells in module '{module_id}'.")
190
270
  return res if "failures" in res else res["numberOfCellsChanged"]
271
+
272
+ async def get_current_period(self) -> CurrentPeriod:
273
+ """
274
+ Gets the current period of the model.
275
+ :return: The current period of the model.
276
+ """
277
+ res = await self._get(f"{self._url}/currentPeriod")
278
+ return CurrentPeriod.model_validate(res["currentPeriod"])
279
+
280
+ async def set_current_period(self, date: str) -> CurrentPeriod:
281
+ """
282
+ Sets the current period of the model to the given date.
283
+ :param date: The date to set the current period to, in the format 'YYYY-MM-DD'.
284
+ :return: The updated current period of the model.
285
+ """
286
+ res = await self._put(f"{self._url}/currentPeriod", {"date": date})
287
+ logger.info(f"Set current period to '{date}'.")
288
+ return CurrentPeriod.model_validate(res["currentPeriod"])
289
+
290
+ async def set_current_fiscal_year(self, year: str) -> FiscalYear:
291
+ """
292
+ Sets the current fiscal year of the model.
293
+ :param year: The fiscal year to set, in the format specified in the model, e.g. FY24.
294
+ :return: The updated fiscal year of the model.
295
+ """
296
+ res = await self._put(f"{self._url}/modelCalendar/fiscalYear", {"year": year})
297
+ logger.info(f"Set current fiscal year to '{year}'.")
298
+ return FiscalYear.model_validate(res["modelCalendar"]["fiscalYear"])
299
+
300
+ async def get_model_calendar(self) -> ModelCalendar:
301
+ """
302
+ Get the calendar settings of the model.
303
+ :return: The calendar settings of the model.
304
+ """
305
+ return parse_calendar_response(await self._get(f"{self._url}/modelCalendar"))
306
+
307
+ async def get_dimension_items(self, dimension_id: int) -> list[DimensionWithCode]:
308
+ """
309
+ Get all items in a dimension. This will fail if the dimensions holds more than 1_000_000
310
+ items. Valid Dimensions are:
311
+
312
+ - Lists (101xxxxxxxxx)
313
+ - List Subsets (109xxxxxxxxx)
314
+ - Line Item Subsets (114xxxxxxxxx)
315
+ - Users (101999999999)
316
+ For lists and users, you should prefer using the `get_list_items` and `get_users` methods,
317
+ respectively, instead.
318
+ :param dimension_id: The ID of the dimension to list items for.
319
+ :return: A list of Dimension items.
320
+ """
321
+ res = await self._get(f"{self._url}/dimensions/{validate_dimension_id(dimension_id)}/items")
322
+ return [DimensionWithCode.model_validate(e) for e in res.get("items", [])]
323
+
324
+ async def lookup_dimension_items(
325
+ self, dimension_id: int, codes: list[str] = None, names: list[str] = None
326
+ ) -> list[DimensionWithCode]:
327
+ """
328
+ Looks up items in a dimension by their codes or names. If both are provided, both will be
329
+ searched for. You must provide at least one of `codes` or `names`. Valid Dimensions to
330
+ lookup are:
331
+
332
+ - Lists (101xxxxxxxxx)
333
+ - Time (20000000003)
334
+ - Version (20000000020)
335
+ - Users (101999999999)
336
+ :param dimension_id: The ID of the dimension to lookup items for.
337
+ :param codes: A list of codes to lookup in the dimension.
338
+ :param names: A list of names to lookup in the dimension.
339
+ :return: A list of Dimension items that match the provided codes or names.
340
+ """
341
+ if not codes and not names:
342
+ raise ValueError("At least one of 'codes' or 'names' must be provided.")
343
+ if not (
344
+ dimension_id == 101999999999
345
+ or 101_000_000_000 <= dimension_id < 102_000_000_000
346
+ or dimension_id == 20000000003
347
+ or dimension_id == 20000000020
348
+ ):
349
+ raise InvalidIdentifierException(
350
+ "Invalid dimension_id. Must be a List (101xxxxxxxxx), Time (20000000003), "
351
+ "Version (20000000020), or Users (101999999999)."
352
+ )
353
+ res = await self._post(
354
+ f"{self._url}/dimensions/{dimension_id}/items", json={"codes": codes, "names": names}
355
+ )
356
+ return [DimensionWithCode.model_validate(e) for e in res.get("items", [])]
357
+
358
+ async def get_view_dimension_items(self, view_id: int, dimension_id: int) -> list[Dimension]:
359
+ """
360
+ Get the members of a dimension that are part of the given View. This call returns data as
361
+ filtered by the page builder when they configure the view. This call respects hidden items,
362
+ filtering selections, and Selective Access. If the view contains hidden or filtered items,
363
+ these do not display in the response. This will fail if the dimensions holds more than
364
+ 1_000_000 items. The response returns Items within a flat list (no hierarchy) and order
365
+ is not guaranteed.
366
+ :param view_id: The ID of the View.
367
+ :param dimension_id: The ID of the Dimension to get items for.
368
+ :return: A list of Dimensions used in the View.
369
+ """
370
+ res = await self._get(f"{self._url}/views/{view_id}/dimensions/{dimension_id}/items")
371
+ return [Dimension.model_validate(e) for e in res.get("items", [])]
372
+
373
+ async def get_line_item_dimensions(self, line_item_id: int) -> list[Dimension]:
374
+ """
375
+ Get the dimensions of a Line Item. This will return all dimensions that are used in the
376
+ Line Item.
377
+ :param line_item_id: The ID of the Line Item.
378
+ :return: A list of Dimensions used in the Line Item.
379
+ """
380
+ res = await self._get(f"{self._url}/lineItems/{line_item_id}/dimensions")
381
+ return [Dimension.model_validate(e) for e in res.get("dimensions", [])]
anaplan_sdk/_base.py CHANGED
@@ -13,7 +13,15 @@ import httpx
13
13
  from httpx import HTTPError, Response
14
14
 
15
15
  from .exceptions import AnaplanException, AnaplanTimeoutException, InvalidIdentifierException
16
- from .models import AnaplanModel
16
+ from .models import (
17
+ AnaplanModel,
18
+ InsertionResult,
19
+ ModelCalendar,
20
+ MonthsQuartersYearsCalendar,
21
+ WeeksGeneralCalendar,
22
+ WeeksGroupingCalendar,
23
+ WeeksPeriodsCalendar,
24
+ )
17
25
  from .models.cloud_works import (
18
26
  AmazonS3ConnectionInput,
19
27
  AzureBlobConnectionInput,
@@ -36,61 +44,70 @@ class _BaseClient:
36
44
  def __init__(self, retry_count: int, client: httpx.Client):
37
45
  self._retry_count = retry_count
38
46
  self._client = client
47
+ logger.debug(f"Initialized BaseClient with retry_count={retry_count}.")
39
48
 
40
49
  def _get(self, url: str, **kwargs) -> dict[str, Any]:
41
- return self._run_with_retry(self._client.get, url, **kwargs).json()
50
+ return self.__run_with_retry(self._client.get, url, **kwargs).json()
42
51
 
43
52
  def _get_binary(self, url: str) -> bytes:
44
- return self._run_with_retry(self._client.get, url).content
53
+ return self.__run_with_retry(self._client.get, url).content
45
54
 
46
55
  def _post(self, url: str, json: dict | list) -> dict[str, Any]:
47
- return self._run_with_retry(self._client.post, url, headers=_json_header, json=json).json()
56
+ return self.__run_with_retry(self._client.post, url, headers=_json_header, json=json).json()
48
57
 
49
58
  def _put(self, url: str, json: dict | list) -> dict[str, Any]:
50
- return (self._run_with_retry(self._client.put, url, headers=_json_header, json=json)).json()
59
+ res = self.__run_with_retry(self._client.put, url, headers=_json_header, json=json)
60
+ return res.json() if res.num_bytes_downloaded > 0 else {}
51
61
 
52
62
  def _patch(self, url: str, json: dict | list) -> dict[str, Any]:
53
63
  return (
54
- self._run_with_retry(self._client.patch, url, headers=_json_header, json=json)
64
+ self.__run_with_retry(self._client.patch, url, headers=_json_header, json=json)
55
65
  ).json()
56
66
 
57
67
  def _delete(self, url: str) -> dict[str, Any]:
58
- return (self._run_with_retry(self._client.delete, url, headers=_json_header)).json()
68
+ return (self.__run_with_retry(self._client.delete, url, headers=_json_header)).json()
59
69
 
60
- def _post_empty(self, url: str) -> dict[str, Any]:
61
- res = self._run_with_retry(self._client.post, url)
70
+ def _post_empty(self, url: str, **kwargs) -> dict[str, Any]:
71
+ res = self.__run_with_retry(self._client.post, url, **kwargs)
62
72
  return res.json() if res.num_bytes_downloaded > 0 else {}
63
73
 
64
- def _put_binary_gzip(self, url: str, content: bytes) -> Response:
65
- return self._run_with_retry(
66
- self._client.put, url, headers=_gzip_header, content=compress(content)
67
- )
74
+ def _put_binary_gzip(self, url: str, content: str | bytes) -> Response:
75
+ content = compress(content.encode() if isinstance(content, str) else content)
76
+ return self.__run_with_retry(self._client.put, url, headers=_gzip_header, content=content)
68
77
 
69
78
  def __get_page(self, url: str, limit: int, offset: int, result_key: str, **kwargs) -> list:
79
+ logger.debug(f"Fetching page: offset={offset}, limit={limit} from {url}.")
70
80
  kwargs["params"] = kwargs.get("params") or {} | {"limit": limit, "offset": offset}
71
81
  return self._get(url, **kwargs).get(result_key, [])
72
82
 
73
83
  def __get_first_page(self, url: str, limit: int, result_key: str, **kwargs) -> tuple[list, int]:
84
+ logger.debug(f"Fetching first page with limit={limit} from {url}.")
74
85
  kwargs["params"] = kwargs.get("params") or {} | {"limit": limit}
75
86
  res = self._get(url, **kwargs)
76
- return res.get(result_key, []), res["meta"]["paging"]["totalSize"]
87
+ total_items, first_page = res["meta"]["paging"]["totalSize"], res.get(result_key, [])
88
+ logger.debug(f"Found {total_items} total items, retrieved {len(first_page)} in first page.")
89
+ return first_page, total_items
77
90
 
78
91
  def _get_paginated(
79
92
  self, url: str, result_key: str, page_size: int = 5_000, **kwargs
80
93
  ) -> Iterator[dict[str, Any]]:
94
+ logger.debug(f"Starting paginated fetch from {url} with page_size={page_size}.")
81
95
  first_page, total_items = self.__get_first_page(url, page_size, result_key, **kwargs)
82
96
  if total_items <= page_size:
97
+ logger.debug("All items fit in first page, no additional requests needed.")
83
98
  return iter(first_page)
84
99
 
100
+ pages_needed = ceil(total_items / page_size)
101
+ logger.debug(f"Fetching {pages_needed - 1} additional pages with {page_size} items each.")
85
102
  with ThreadPoolExecutor() as executor:
86
103
  pages = executor.map(
87
104
  lambda n: self.__get_page(url, page_size, n * page_size, result_key, **kwargs),
88
- range(1, ceil(total_items / page_size)),
105
+ range(1, pages_needed),
89
106
  )
90
-
107
+ logger.debug(f"Completed paginated fetch of {total_items} total items.")
91
108
  return chain(first_page, *pages)
92
109
 
93
- def _run_with_retry(self, func: Callable[..., Response], *args, **kwargs) -> Response:
110
+ def __run_with_retry(self, func: Callable[..., Response], *args, **kwargs) -> Response:
94
111
  for i in range(max(self._retry_count, 1)):
95
112
  try:
96
113
  response = func(*args, **kwargs)
@@ -98,7 +115,7 @@ class _BaseClient:
98
115
  if i >= self._retry_count - 1:
99
116
  raise AnaplanException("Rate limit exceeded.")
100
117
  backoff_time = max(i, 1) * random.randint(2, 5)
101
- logger.info(f"Rate limited. Retrying in {backoff_time} seconds.")
118
+ logger.warning(f"Rate limited. Retrying in {backoff_time} seconds.")
102
119
  time.sleep(backoff_time)
103
120
  continue
104
121
  response.raise_for_status()
@@ -116,58 +133,65 @@ class _AsyncBaseClient:
116
133
  def __init__(self, retry_count: int, client: httpx.AsyncClient):
117
134
  self._retry_count = retry_count
118
135
  self._client = client
136
+ logger.debug(f"Initialized AsyncBaseClient with retry_count={retry_count}.")
119
137
 
120
138
  async def _get(self, url: str, **kwargs) -> dict[str, Any]:
121
- return (await self._run_with_retry(self._client.get, url, **kwargs)).json()
139
+ return (await self.__run_with_retry(self._client.get, url, **kwargs)).json()
122
140
 
123
141
  async def _get_binary(self, url: str) -> bytes:
124
- return (await self._run_with_retry(self._client.get, url)).content
142
+ return (await self.__run_with_retry(self._client.get, url)).content
125
143
 
126
144
  async def _post(self, url: str, json: dict | list) -> dict[str, Any]:
127
145
  return (
128
- await self._run_with_retry(self._client.post, url, headers=_json_header, json=json)
146
+ await self.__run_with_retry(self._client.post, url, headers=_json_header, json=json)
129
147
  ).json()
130
148
 
131
149
  async def _put(self, url: str, json: dict | list) -> dict[str, Any]:
132
- return (
133
- await self._run_with_retry(self._client.put, url, headers=_json_header, json=json)
134
- ).json()
150
+ res = await self.__run_with_retry(self._client.put, url, headers=_json_header, json=json)
151
+ return res.json() if res.num_bytes_downloaded > 0 else {}
135
152
 
136
153
  async def _patch(self, url: str, json: dict | list) -> dict[str, Any]:
137
154
  return (
138
- await self._run_with_retry(self._client.patch, url, headers=_json_header, json=json)
155
+ await self.__run_with_retry(self._client.patch, url, headers=_json_header, json=json)
139
156
  ).json()
140
157
 
141
158
  async def _delete(self, url: str) -> dict[str, Any]:
142
- return (await self._run_with_retry(self._client.delete, url, headers=_json_header)).json()
159
+ return (await self.__run_with_retry(self._client.delete, url, headers=_json_header)).json()
143
160
 
144
- async def _post_empty(self, url: str) -> dict[str, Any]:
145
- res = await self._run_with_retry(self._client.post, url)
161
+ async def _post_empty(self, url: str, **kwargs) -> dict[str, Any]:
162
+ res = await self.__run_with_retry(self._client.post, url, **kwargs)
146
163
  return res.json() if res.num_bytes_downloaded > 0 else {}
147
164
 
148
- async def _put_binary_gzip(self, url: str, content: bytes) -> Response:
149
- return await self._run_with_retry(
150
- self._client.put, url, headers=_gzip_header, content=compress(content)
165
+ async def _put_binary_gzip(self, url: str, content: str | bytes) -> Response:
166
+ content = compress(content.encode() if isinstance(content, str) else content)
167
+ return await self.__run_with_retry(
168
+ self._client.put, url, headers=_gzip_header, content=content
151
169
  )
152
170
 
153
171
  async def __get_page(
154
172
  self, url: str, limit: int, offset: int, result_key: str, **kwargs
155
173
  ) -> list:
174
+ logger.debug(f"Fetching page: offset={offset}, limit={limit} from {url}.")
156
175
  kwargs["params"] = kwargs.get("params") or {} | {"limit": limit, "offset": offset}
157
176
  return (await self._get(url, **kwargs)).get(result_key, [])
158
177
 
159
178
  async def __get_first_page(
160
179
  self, url: str, limit: int, result_key: str, **kwargs
161
180
  ) -> tuple[list, int]:
181
+ logger.debug(f"Fetching first page with limit={limit} from {url}.")
162
182
  kwargs["params"] = kwargs.get("params") or {} | {"limit": limit}
163
183
  res = await self._get(url, **kwargs)
164
- return res.get(result_key, []), res["meta"]["paging"]["totalSize"]
184
+ total_items, first_page = res["meta"]["paging"]["totalSize"], res.get(result_key, [])
185
+ logger.debug(f"Found {total_items} total items, retrieved {len(first_page)} in first page.")
186
+ return first_page, total_items
165
187
 
166
188
  async def _get_paginated(
167
189
  self, url: str, result_key: str, page_size: int = 5_000, **kwargs
168
190
  ) -> Iterator[dict[str, Any]]:
191
+ logger.debug(f"Starting paginated fetch from {url} with page_size={page_size}.")
169
192
  first_page, total_items = await self.__get_first_page(url, page_size, result_key, **kwargs)
170
193
  if total_items <= page_size:
194
+ logger.debug("All items fit in first page, no additional requests needed.")
171
195
  return iter(first_page)
172
196
  pages = await gather(
173
197
  *(
@@ -175,9 +199,10 @@ class _AsyncBaseClient:
175
199
  for n in range(1, ceil(total_items / page_size))
176
200
  )
177
201
  )
202
+ logger.info(f"Completed paginated fetch of {total_items} total items.")
178
203
  return chain(first_page, *pages)
179
204
 
180
- async def _run_with_retry(
205
+ async def __run_with_retry(
181
206
  self, func: Callable[..., Coroutine[Any, Any, Response]], *args, **kwargs
182
207
  ) -> Response:
183
208
  for i in range(max(self._retry_count, 1)):
@@ -187,7 +212,7 @@ class _AsyncBaseClient:
187
212
  if i >= self._retry_count - 1:
188
213
  raise AnaplanException("Rate limit exceeded.")
189
214
  backoff_time = (i + 1) * random.randint(3, 5)
190
- logger.info(f"Rate limited. Retrying in {backoff_time} seconds.")
215
+ logger.warning(f"Rate limited. Retrying in {backoff_time} seconds.")
191
216
  await asyncio.sleep(backoff_time)
192
217
  continue
193
218
  response.raise_for_status()
@@ -295,3 +320,59 @@ def raise_error(error: HTTPError) -> None:
295
320
 
296
321
  logger.error(f"Error: {error}")
297
322
  raise AnaplanException from error
323
+
324
+
325
+ def parse_calendar_response(data: dict) -> ModelCalendar:
326
+ """
327
+ Parse calendar response and return appropriate calendar model.
328
+ :param data: The calendar data from the API response.
329
+ :return: The calendar settings of the model based on calendar type.
330
+ """
331
+ calendar_data = data["modelCalendar"]
332
+ cal_type = calendar_data["calendarType"]
333
+ if cal_type == "Calendar Months/Quarters/Years":
334
+ return MonthsQuartersYearsCalendar.model_validate(calendar_data)
335
+ if cal_type == "Weeks: 4-4-5, 4-5-4 or 5-4-4":
336
+ return WeeksGroupingCalendar.model_validate(calendar_data)
337
+ if cal_type == "Weeks: General":
338
+ return WeeksGeneralCalendar.model_validate(calendar_data)
339
+ if cal_type == "Weeks: 13 4-week Periods":
340
+ return WeeksPeriodsCalendar.model_validate(calendar_data)
341
+ raise AnaplanException(
342
+ "Unknown calendar type encountered. Please report this issue: "
343
+ "https://github.com/VinzenzKlass/anaplan-sdk/issues/new"
344
+ )
345
+
346
+
347
+ def parse_insertion_response(data: list[dict]) -> InsertionResult:
348
+ failures, added, ignored, total = [], 0, 0, 0
349
+ for res in data:
350
+ failures.append(res.get("failures", []))
351
+ added += res.get("added", 0)
352
+ total += res.get("total", 0)
353
+ ignored += res.get("ignored", 0)
354
+ return InsertionResult(
355
+ added=added, ignored=ignored, total=total, failures=list(chain.from_iterable(failures))
356
+ )
357
+
358
+
359
+ def validate_dimension_id(dimension_id: int) -> int:
360
+ if not (
361
+ dimension_id == 101999999999
362
+ or 101_000_000_000 <= dimension_id < 102_000_000_000
363
+ or 109_000_000_000 <= dimension_id < 110_000_000_000
364
+ or 114_000_000_000 <= dimension_id < 115_000_000_000
365
+ ):
366
+ raise InvalidIdentifierException(
367
+ "Invalid dimension_id. Must be a List (101xxxxxxxxx), List Subset (109xxxxxxxxx), "
368
+ "Line Item Subset (114xxxxxxxxx), or Users (101999999999)."
369
+ )
370
+ msg = (
371
+ "Using `get_dimension_items` for {} is discouraged. "
372
+ "Prefer `{}` for better performance and more details on the members."
373
+ )
374
+ if dimension_id == 101999999999:
375
+ logger.warning(msg.format("Users", "get_users"))
376
+ if 101000000000 <= dimension_id < 102000000000:
377
+ logger.warning(msg.format("Lists", "get_list_items"))
378
+ return dimension_id