anaplan-sdk 0.4.4a4__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.
- anaplan_sdk/_async_clients/_alm.py +251 -40
- anaplan_sdk/_async_clients/_audit.py +6 -4
- anaplan_sdk/_async_clients/_bulk.py +137 -82
- anaplan_sdk/_async_clients/_cloud_works.py +23 -6
- anaplan_sdk/_async_clients/_cw_flow.py +12 -3
- anaplan_sdk/_async_clients/_transactional.py +231 -40
- anaplan_sdk/_auth.py +5 -4
- anaplan_sdk/_base.py +115 -34
- anaplan_sdk/_clients/_alm.py +251 -41
- anaplan_sdk/_clients/_audit.py +6 -4
- anaplan_sdk/_clients/_bulk.py +143 -76
- anaplan_sdk/_clients/_cloud_works.py +24 -6
- anaplan_sdk/_clients/_cw_flow.py +12 -3
- anaplan_sdk/_clients/_transactional.py +223 -36
- anaplan_sdk/models/__init__.py +47 -2
- anaplan_sdk/models/_alm.py +64 -6
- anaplan_sdk/models/_bulk.py +14 -0
- anaplan_sdk/models/_transactional.py +221 -4
- {anaplan_sdk-0.4.4a4.dist-info → anaplan_sdk-0.5.0a1.dist-info}/METADATA +3 -2
- anaplan_sdk-0.5.0a1.dist-info/RECORD +30 -0
- anaplan_sdk-0.4.4a4.dist-info/RECORD +0 -30
- {anaplan_sdk-0.4.4a4.dist-info → anaplan_sdk-0.5.0a1.dist-info}/WHEEL +0 -0
- {anaplan_sdk-0.4.4a4.dist-info → anaplan_sdk-0.5.0a1.dist-info}/licenses/LICENSE +0 -0
@@ -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
|
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
|
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
|
79
|
+
async def get_views(self) -> list[View]:
|
35
80
|
"""
|
36
|
-
|
37
|
-
|
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
|
-
|
40
|
-
|
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
|
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
|
-
|
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
|
110
|
+
return [LineItem.model_validate(e) for e in res.get("items", [])]
|
55
111
|
|
56
|
-
async def
|
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
|
-
|
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
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
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
|
-
|
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
|
-
|
122
|
-
|
123
|
-
|
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(
|
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
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
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
|
-
|
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/_auth.py
CHANGED
@@ -4,7 +4,6 @@ from base64 import b64encode
|
|
4
4
|
from typing import Callable
|
5
5
|
|
6
6
|
import httpx
|
7
|
-
import keyring
|
8
7
|
|
9
8
|
from ._oauth import _OAuthRequestFactory
|
10
9
|
from .exceptions import AnaplanException, InvalidCredentialsException, InvalidPrivateKeyException
|
@@ -205,7 +204,7 @@ class AnaplanLocalOAuth(_AnaplanAuth):
|
|
205
204
|
if stored:
|
206
205
|
logger.info("Using persisted OAuth refresh token.")
|
207
206
|
self._oauth_token = {"refresh_token": stored}
|
208
|
-
self._token =
|
207
|
+
self._token = "" # Set to blank to trigger the super().__init__ auth request.
|
209
208
|
except ImportError as e:
|
210
209
|
raise AnaplanException(
|
211
210
|
"keyring is not available. Please install anaplan-sdk with the keyring extra "
|
@@ -246,6 +245,8 @@ class AnaplanLocalOAuth(_AnaplanAuth):
|
|
246
245
|
raise AnaplanException(f"Authentication failed: {response.status_code} {response.text}")
|
247
246
|
self._oauth_token = response.json()
|
248
247
|
if self._persist_token:
|
248
|
+
import keyring
|
249
|
+
|
249
250
|
keyring.set_password(
|
250
251
|
self._service_name, self._service_name, self._oauth_token["refresh_token"]
|
251
252
|
)
|
@@ -304,7 +305,7 @@ class AnaplanRefreshTokenAuth(_AnaplanAuth):
|
|
304
305
|
:param token_url: The URL to post the refresh token request to in order to fetch the access
|
305
306
|
token.
|
306
307
|
"""
|
307
|
-
if not isinstance(token, dict)
|
308
|
+
if not isinstance(token, dict) or not all(
|
308
309
|
key in token for key in ("access_token", "refresh_token")
|
309
310
|
):
|
310
311
|
raise ValueError(
|
@@ -322,7 +323,7 @@ class AnaplanRefreshTokenAuth(_AnaplanAuth):
|
|
322
323
|
@property
|
323
324
|
def token(self) -> dict[str, str]:
|
324
325
|
"""
|
325
|
-
Returns the current token
|
326
|
+
Returns the current OAuth token. You can safely use the `access_token`, but you
|
326
327
|
must not use the `refresh_token` outside of this class, if you expect to use this instance
|
327
328
|
further. If you do use the `refresh_token` outside of this class, this will error on the
|
328
329
|
next attempt to refresh the token, as the `refresh_token` can only be used once.
|