anaplan-sdk 0.4.5__py3-none-any.whl → 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,61 +1,134 @@
1
+ import logging
1
2
  from concurrent.futures import ThreadPoolExecutor
2
3
  from itertools import chain
3
- from typing import Any
4
+ from typing import Any, Literal, overload
4
5
 
5
- import httpx
6
-
7
- from anaplan_sdk._base import _BaseClient
6
+ from anaplan_sdk._services import _HttpService
7
+ from anaplan_sdk._utils import (
8
+ parse_calendar_response,
9
+ parse_insertion_response,
10
+ sort_params,
11
+ validate_dimension_id,
12
+ )
13
+ from anaplan_sdk.exceptions import InvalidIdentifierException
8
14
  from anaplan_sdk.models import (
15
+ CurrentPeriod,
16
+ Dimension,
17
+ DimensionWithCode,
18
+ FiscalYear,
9
19
  InsertionResult,
10
20
  LineItem,
11
21
  List,
22
+ ListDeletionResult,
12
23
  ListItem,
13
24
  ListMetadata,
25
+ Model,
26
+ ModelCalendar,
14
27
  ModelStatus,
15
28
  Module,
29
+ View,
30
+ ViewInfo,
16
31
  )
17
32
 
33
+ SortBy = Literal["id", "name"] | None
34
+
35
+ logger = logging.getLogger("anaplan_sdk")
18
36
 
19
- class _TransactionalClient(_BaseClient):
20
- def __init__(self, client: httpx.Client, model_id: str, retry_count: int) -> None:
37
+
38
+ class _TransactionalClient:
39
+ def __init__(self, http: _HttpService, model_id: str) -> None:
40
+ self._http = http
21
41
  self._url = f"https://api.anaplan.com/2/0/models/{model_id}"
22
- super().__init__(retry_count, client)
42
+ self._model_id = model_id
43
+
44
+ def get_model_details(self) -> Model:
45
+ """
46
+ Retrieves the Model details for the current Model.
47
+ :return: The Model details.
48
+ """
49
+ return Model.model_validate(
50
+ self._http.get(self._url, params={"modelDetails": "true"})["model"]
51
+ )
52
+
53
+ def get_model_status(self) -> ModelStatus:
54
+ """
55
+ Gets the current status of the Model.
56
+ :return: The current status of the Mode.
57
+ """
58
+ return ModelStatus.model_validate(
59
+ self._http.get(f"{self._url}/status").get("requestStatus")
60
+ )
61
+
62
+ def wake_model(self) -> None:
63
+ """Wake up the current model."""
64
+ self._http.post_empty(f"{self._url}/open", headers={"Content-Type": "application/text"})
65
+ logger.info(f"Woke up model '{self._model_id}'.")
66
+
67
+ def close_model(self) -> None:
68
+ """Close the current model."""
69
+ self._http.post_empty(f"{self._url}/close", headers={"Content-Type": "application/text"})
70
+ logger.info(f"Closed model '{self._model_id}'.")
23
71
 
24
- def list_modules(self) -> list[Module]:
72
+ def get_modules(self, sort_by: SortBy = None, descending: bool = False) -> list[Module]:
25
73
  """
26
74
  Lists all the Modules in the Model.
75
+ :param sort_by: The field to sort the results by.
76
+ :param descending: If True, the results will be sorted in descending order.
27
77
  :return: The List of Modules.
28
78
  """
79
+ res = self._http.get_paginated(
80
+ f"{self._url}/modules", "modules", params=sort_params(sort_by, descending)
81
+ )
82
+ return [Module.model_validate(e) for e in res]
83
+
84
+ def get_views(
85
+ self, sort_by: Literal["id", "module_id", "name"] | None = None, descending: bool = False
86
+ ) -> list[View]:
87
+ """
88
+ Lists all the Views in the Model. This will include all Modules and potentially other saved
89
+ views.
90
+ :param sort_by: The field to sort the results by.
91
+ :param descending: If True, the results will be sorted in descending order.
92
+ :return: The List of Views.
93
+ """
94
+ params = {"includesubsidiaryviews": True} | sort_params(sort_by, descending)
29
95
  return [
30
- Module.model_validate(e) for e in self._get_paginated(f"{self._url}/modules", "modules")
96
+ View.model_validate(e)
97
+ for e in self._http.get_paginated(f"{self._url}/views", "views", params=params)
31
98
  ]
32
99
 
33
- def get_model_status(self) -> ModelStatus:
100
+ def get_view_info(self, view_id: int) -> ViewInfo:
34
101
  """
35
- Gets the current status of the Model.
36
- :return: The current status of the Mode.
102
+ Gets the detailed information about a View.
103
+ :param view_id: The ID of the View.
104
+ :return: The information about the View.
37
105
  """
38
- return ModelStatus.model_validate(self._get(f"{self._url}/status").get("requestStatus"))
106
+ return ViewInfo.model_validate(self._http.get(f"{self._url}/views/{view_id}"))
39
107
 
40
- def list_line_items(self, only_module_id: int | None = None) -> list[LineItem]:
108
+ def get_line_items(self, only_module_id: int | None = None) -> list[LineItem]:
41
109
  """
42
110
  Lists all the Line Items in the Model.
43
111
  :param only_module_id: If provided, only Line Items from this Module will be returned.
44
- :return: The List of Line Items.
112
+ :return: All Line Items on this Model or only from the specified Module.
45
113
  """
46
114
  url = (
47
115
  f"{self._url}/modules/{only_module_id}/lineItems?includeAll=true"
48
116
  if only_module_id
49
117
  else f"{self._url}/lineItems?includeAll=true"
50
118
  )
51
- return [LineItem.model_validate(e) for e in self._get(url).get("items", [])]
119
+ return [LineItem.model_validate(e) for e in self._http.get(url).get("items", [])]
52
120
 
53
- def list_lists(self) -> list[List]:
121
+ def get_lists(self, sort_by: SortBy = None, descending: bool = False) -> list[List]:
54
122
  """
55
123
  Lists all the Lists in the Model.
124
+ :param sort_by: The field to sort the results by.
125
+ :param descending: If True, the results will be sorted in descending order.
56
126
  :return: All Lists on this model.
57
127
  """
58
- return [List.model_validate(e) for e in self._get_paginated(f"{self._url}/lists", "lists")]
128
+ res = self._http.get_paginated(
129
+ f"{self._url}/lists", "lists", params=sort_params(sort_by, descending)
130
+ )
131
+ return [List.model_validate(e) for e in res]
59
132
 
60
133
  def get_list_metadata(self, list_id: int) -> ListMetadata:
61
134
  """
@@ -64,21 +137,34 @@ class _TransactionalClient(_BaseClient):
64
137
  :return: The Metadata for the List.
65
138
  """
66
139
  return ListMetadata.model_validate(
67
- self._get(f"{self._url}/lists/{list_id}").get("metadata")
140
+ self._http.get(f"{self._url}/lists/{list_id}").get("metadata")
68
141
  )
69
142
 
70
- def get_list_items(self, list_id: int) -> list[ListItem]:
143
+ @overload
144
+ def get_list_items(
145
+ self, list_id: int, return_raw: Literal[False] = False
146
+ ) -> list[ListItem]: ...
147
+
148
+ @overload
149
+ def get_list_items(
150
+ self, list_id: int, return_raw: Literal[True] = True
151
+ ) -> list[dict[str, Any]]: ...
152
+
153
+ def get_list_items(
154
+ self, list_id: int, return_raw: bool = False
155
+ ) -> list[ListItem] | list[dict[str, Any]]:
71
156
  """
72
157
  Gets all the items in a List.
73
158
  :param list_id: The ID of the List.
74
- :return: The List of Items.
159
+ :param return_raw: If True, returns the items as a list of dictionaries instead of ListItem
160
+ objects. If you use the result of this call in a DataFrame or you simply pass on the
161
+ data, you will want to set this to avoid unnecessary (de-)serialization.
162
+ :return: All items in the List.
75
163
  """
76
- return [
77
- ListItem.model_validate(e)
78
- for e in self._get(f"{self._url}/lists/{list_id}/items?includeAll=true").get(
79
- "listItems", []
80
- )
81
- ]
164
+ res = self._http.get(f"{self._url}/lists/{list_id}/items?includeAll=true")
165
+ if return_raw:
166
+ return res.get("listItems", [])
167
+ return [ListItem.model_validate(e) for e in res.get("listItems", [])]
82
168
 
83
169
  def insert_list_items(
84
170
  self, list_id: int, items: list[dict[str, str | int | dict]]
@@ -100,33 +186,33 @@ class _TransactionalClient(_BaseClient):
100
186
  :return: The result of the insertion, indicating how many items were added,
101
187
  ignored or failed.
102
188
  """
189
+ if not items:
190
+ return InsertionResult(added=0, ignored=0, failures=[], total=0)
103
191
  if len(items) <= 100_000:
104
- return InsertionResult.model_validate(
105
- self._post(f"{self._url}/lists/{list_id}/items?action=add", json={"items": items})
192
+ result = InsertionResult.model_validate(
193
+ self._http.post(
194
+ f"{self._url}/lists/{list_id}/items?action=add", json={"items": items}
195
+ )
106
196
  )
197
+ logger.info(f"Inserted {result.added} items into list '{list_id}'.")
198
+ return result
107
199
 
108
200
  with ThreadPoolExecutor() as executor:
109
201
  responses = list(
110
202
  executor.map(
111
- lambda chunk: self._post(
203
+ lambda chunk: self._http.post(
112
204
  f"{self._url}/lists/{list_id}/items?action=add", json={"items": chunk}
113
205
  ),
114
206
  [items[i : i + 100_000] for i in range(0, len(items), 100_000)],
115
207
  )
116
208
  )
209
+ result = parse_insertion_response(responses)
210
+ logger.info(f"Inserted {result.added} items into list '{list_id}'.")
211
+ return result
117
212
 
118
- failures, added, ignored, total = [], 0, 0, 0
119
- for res in responses:
120
- failures.append(res.get("failures", []))
121
- added += res.get("added", 0)
122
- total += res.get("total", 0)
123
- ignored += res.get("ignored", 0)
124
-
125
- return InsertionResult(
126
- added=added, ignored=ignored, total=total, failures=list(chain.from_iterable(failures))
127
- )
128
-
129
- def delete_list_items(self, list_id: int, items: list[dict[str, str | int]]) -> int:
213
+ def delete_list_items(
214
+ self, list_id: int, items: list[dict[str, str | int]]
215
+ ) -> ListDeletionResult:
130
216
  """
131
217
  Deletes items from a List. If you pass a long list, it will be split into chunks of 100,000
132
218
  items, the maximum allowed by the API.
@@ -140,31 +226,42 @@ class _TransactionalClient(_BaseClient):
140
226
 
141
227
  :param list_id: The ID of the List.
142
228
  :param items: The items to delete from the List. Must be a dict with either `code` or `id`
143
- as the keys to identify the records to delete.
229
+ as the keys to identify the records to delete. Specifying both will error.
230
+ :return: The result of the deletion, indicating how many items were deleted or failed.
144
231
  """
232
+ if not items:
233
+ return ListDeletionResult(deleted=0, failures=[])
145
234
  if len(items) <= 100_000:
146
- return self._post(
235
+ res = self._http.post(
147
236
  f"{self._url}/lists/{list_id}/items?action=delete", json={"items": items}
148
- ).get("deleted", 0)
237
+ )
238
+ info = ListDeletionResult.model_validate(res)
239
+ logger.info(f"Deleted {info.deleted} items from list '{list_id}'.")
240
+ return info
149
241
 
150
242
  with ThreadPoolExecutor() as executor:
151
243
  responses = list(
152
244
  executor.map(
153
- lambda chunk: self._post(
245
+ lambda chunk: self._http.post(
154
246
  f"{self._url}/lists/{list_id}/items?action=delete", json={"items": chunk}
155
247
  ),
156
248
  [items[i : i + 100_000] for i in range(0, len(items), 100_000)],
157
249
  )
158
250
  )
159
-
160
- return sum(res.get("deleted", 0) for res in responses)
251
+ info = ListDeletionResult(
252
+ deleted=sum(res.get("deleted", 0) for res in responses),
253
+ failures=list(chain.from_iterable(res.get("failures", []) for res in responses)),
254
+ )
255
+ logger.info(f"Deleted {info} items from list '{list_id}'.")
256
+ return info
161
257
 
162
258
  def reset_list_index(self, list_id: int) -> None:
163
259
  """
164
260
  Resets the index of a List. The List must be empty to do so.
165
261
  :param list_id: The ID of the List.
166
262
  """
167
- self._post_empty(f"{self._url}/lists/{list_id}/resetIndex")
263
+ self._http.post_empty(f"{self._url}/lists/{list_id}/resetIndex")
264
+ logger.info(f"Reset index for list '{list_id}'.")
168
265
 
169
266
  def update_module_data(
170
267
  self, module_id: int, data: list[dict[str, Any]]
@@ -183,5 +280,118 @@ class _TransactionalClient(_BaseClient):
183
280
  :param data: The data to write to the Module.
184
281
  :return: The number of cells changed or the response with the according error details.
185
282
  """
186
- res = self._post(f"{self._url}/modules/{module_id}/data", json=data)
283
+ res = self._http.post(f"{self._url}/modules/{module_id}/data", json=data)
284
+ if "failures" not in res:
285
+ logger.info(f"Updated {res['numberOfCellsChanged']} cells in module '{module_id}'.")
187
286
  return res if "failures" in res else res["numberOfCellsChanged"]
287
+
288
+ def get_current_period(self) -> CurrentPeriod:
289
+ """
290
+ Gets the current period of the model.
291
+ :return: The current period of the model.
292
+ """
293
+ res = self._http.get(f"{self._url}/currentPeriod")
294
+ return CurrentPeriod.model_validate(res["currentPeriod"])
295
+
296
+ def set_current_period(self, date: str) -> CurrentPeriod:
297
+ """
298
+ Sets the current period of the model to the given date.
299
+ :param date: The date to set the current period to, in the format 'YYYY-MM-DD'.
300
+ :return: The updated current period of the model.
301
+ """
302
+ res = self._http.put(f"{self._url}/currentPeriod", {"date": date})
303
+ logger.info(f"Set current period to '{date}'.")
304
+ return CurrentPeriod.model_validate(res["currentPeriod"])
305
+
306
+ def set_current_fiscal_year(self, year: str) -> FiscalYear:
307
+ """
308
+ Sets the current fiscal year of the model.
309
+ :param year: The fiscal year to set, in the format specified in the model, e.g. FY24.
310
+ :return: The updated fiscal year of the model.
311
+ """
312
+ res = self._http.put(f"{self._url}/modelCalendar/fiscalYear", {"year": year})
313
+ logger.info(f"Set current fiscal year to '{year}'.")
314
+ return FiscalYear.model_validate(res["modelCalendar"]["fiscalYear"])
315
+
316
+ def get_model_calendar(self) -> ModelCalendar:
317
+ """
318
+ Get the calendar settings of the model.
319
+ :return: The calendar settings of the model.
320
+ """
321
+ return parse_calendar_response(self._http.get(f"{self._url}/modelCalendar"))
322
+
323
+ def get_dimension_items(self, dimension_id: int) -> list[DimensionWithCode]:
324
+ """
325
+ Get all items in a dimension. This will fail if the dimensions holds more than 1_000_000
326
+ items. Valid Dimensions are:
327
+
328
+ - Lists (101xxxxxxxxx)
329
+ - List Subsets (109xxxxxxxxx)
330
+ - Line Item Subsets (114xxxxxxxxx)
331
+ - Users (101999999999)
332
+ For lists and users, you should prefer using the `get_list_items` and `get_users` methods,
333
+ respectively, instead.
334
+ :param dimension_id: The ID of the dimension to list items for.
335
+ :return: A list of Dimension items.
336
+ """
337
+ res = self._http.get(f"{self._url}/dimensions/{validate_dimension_id(dimension_id)}/items")
338
+ return [DimensionWithCode.model_validate(e) for e in res.get("items", [])]
339
+
340
+ def lookup_dimension_items(
341
+ self, dimension_id: int, codes: list[str] = None, names: list[str] = None
342
+ ) -> list[DimensionWithCode]:
343
+ """
344
+ Looks up items in a dimension by their codes or names. If both are provided, both will be
345
+ searched for. You must provide at least one of `codes` or `names`. Valid Dimensions to
346
+ lookup are:
347
+
348
+ - Lists (101xxxxxxxxx)
349
+ - Time (20000000003)
350
+ - Version (20000000020)
351
+ - Users (101999999999)
352
+ :param dimension_id: The ID of the dimension to lookup items for.
353
+ :param codes: A list of codes to lookup in the dimension.
354
+ :param names: A list of names to lookup in the dimension.
355
+ :return: A list of Dimension items that match the provided codes or names.
356
+ """
357
+ if not codes and not names:
358
+ raise ValueError("At least one of 'codes' or 'names' must be provided.")
359
+ if not (
360
+ dimension_id == 101999999999
361
+ or 101000000000 <= dimension_id < 102000000000
362
+ or dimension_id == 20000000003
363
+ or dimension_id == 20000000020
364
+ ):
365
+ raise InvalidIdentifierException(
366
+ "Invalid dimension_id. Must be a List (101xxxxxxxxx), Time (20000000003), "
367
+ "Version (20000000020), or Users (101999999999)."
368
+ )
369
+ res = self._http.post(
370
+ f"{self._url}/dimensions/{dimension_id}/items", json={"codes": codes, "names": names}
371
+ )
372
+ return [DimensionWithCode.model_validate(e) for e in res.get("items", [])]
373
+
374
+ def get_view_dimension_items(self, view_id: int, dimension_id: int) -> list[Dimension]:
375
+ """
376
+ Get the members of a dimension that are part of the given View. This call returns data as
377
+ filtered by the page builder when they configure the view. This call respects hidden items,
378
+ filtering selections, and Selective Access. If the view contains hidden or filtered items,
379
+ these do not display in the response. This will fail if the dimensions holds more than
380
+ 1_000_000 items. The response returns Items within a flat list (no hierarchy) and order
381
+ is not guaranteed.
382
+ :param view_id: The ID of the View.
383
+ :param dimension_id: The ID of the Dimension to get items for.
384
+ :return: A list of Dimensions used in the View.
385
+ """
386
+ res = self._http.get(f"{self._url}/views/{view_id}/dimensions/{dimension_id}/items")
387
+ return [Dimension.model_validate(e) for e in res.get("items", [])]
388
+
389
+ def get_line_item_dimensions(self, line_item_id: int) -> list[Dimension]:
390
+ """
391
+ Get the dimensions of a Line Item. This will return all dimensions that are used in the
392
+ Line Item.
393
+ :param line_item_id: The ID of the Line Item.
394
+ :return: A list of Dimensions used in the Line Item.
395
+ """
396
+ res = self._http.get(f"{self._url}/lineItems/{line_item_id}/dimensions")
397
+ return [Dimension.model_validate(e) for e in res.get("dimensions", [])]