anaplan-sdk 0.2.10__tar.gz → 0.3.0__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.
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/.gitignore +1 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/PKG-INFO +2 -2
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/README.md +1 -1
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/anaplan_sdk/_async_clients/_bulk.py +31 -21
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/anaplan_sdk/_async_clients/_transactional.py +78 -15
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/anaplan_sdk/_auth.py +35 -72
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/anaplan_sdk/_base.py +17 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/anaplan_sdk/_clients/_bulk.py +28 -15
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/anaplan_sdk/_clients/_transactional.py +78 -14
- anaplan_sdk-0.3.0/anaplan_sdk/models.py +329 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/api/async_transactional_client.md +2 -2
- anaplan_sdk-0.3.0/docs/css/styles.css +64 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/guides/bulk.md +62 -13
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/guides/logging.md +1 -1
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/guides/multiple_models.md +2 -2
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/guides/transactional.md +17 -17
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/index.md +1 -1
- anaplan_sdk-0.3.0/docs/js/assets/hljs.js +1242 -0
- anaplan_sdk-0.3.0/docs/js/assets/python.js +334 -0
- anaplan_sdk-0.3.0/docs/js/highlight.js +12 -0
- anaplan_sdk-0.3.0/docs/quickstart.md +162 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/mkdocs.yml +9 -2
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/pyproject.toml +2 -1
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/tests/async/conftest.py +14 -7
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/tests/async/test_async_client.py +36 -10
- anaplan_sdk-0.3.0/tests/async/test_async_transactional_client.py +66 -0
- anaplan_sdk-0.3.0/tests/conftest.py +17 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/tests/sync/conftest.py +14 -7
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/tests/sync/test_client.py +36 -10
- anaplan_sdk-0.3.0/tests/sync/test_transactional_client.py +66 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/uv.lock +179 -127
- anaplan_sdk-0.2.10/anaplan_sdk/models.py +0 -318
- anaplan_sdk-0.2.10/docs/css/styles.css +0 -13
- anaplan_sdk-0.2.10/docs/quickstart.md +0 -102
- anaplan_sdk-0.2.10/tests/async/test_async_transactional_client.py +0 -60
- anaplan_sdk-0.2.10/tests/sync/test_transactional_client.py +0 -58
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/.github/dependabot.yml +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/.github/workflows/docs.yml +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/.github/workflows/lint.yml +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/.github/workflows/tests.yml +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/.pre-commit-config.yaml +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/LICENSE +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/anaplan_sdk/__init__.py +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/anaplan_sdk/_async_clients/__init__.py +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/anaplan_sdk/_async_clients/_alm.py +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/anaplan_sdk/_async_clients/_audit.py +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/anaplan_sdk/_clients/__init__.py +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/anaplan_sdk/_clients/_alm.py +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/anaplan_sdk/_clients/_audit.py +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/anaplan_sdk/exceptions.py +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/anaplan_explained.md +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/api/alm_client.md +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/api/async_alm_client.md +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/api/async_audit_client.md +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/api/async_client.md +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/api/audit_client.md +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/api/client.md +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/api/exceptions.md +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/api/models.md +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/api/transactional_client.md +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/guides/alm.md +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/guides/audit.md +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/guides/bulk_vs_transactional.md +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/img/anaplan-overview.webp +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/img/anaplan-sdk.webp +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/docs/installation.md +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/tests/async/test_async_alm_client.py +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/tests/async/test_async_audit_client.py +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/tests/sync/test_alm_client.py +0 -0
- {anaplan_sdk-0.2.10 → anaplan_sdk-0.3.0}/tests/sync/test_audit_client.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: anaplan-sdk
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.3.0
|
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
|
@@ -38,7 +38,7 @@ implementation details like authentication, error handling, chunking, compressio
|
|
38
38
|
This Projects supports
|
39
39
|
the [Bulk APIs](https://help.anaplan.com/use-the-bulk-apis-93218e5e-00e5-406e-8361-09ab861889a7),
|
40
40
|
the [Transactional APIs](https://help.anaplan.com/use-the-transactional-apis-cc1c1e91-39fc-4272-a4b5-16bc91e9c313) and
|
41
|
-
the [ALM
|
41
|
+
the [ALM APIs](https://help.anaplan.com/application-lifecycle-management-api-2565cfa6-e0c2-4e24-884e-d0df957184d6),
|
42
42
|
the [Audit APIs](https://auditservice.docs.apiary.io/#),
|
43
43
|
providing both synchronous and asynchronous Clients.
|
44
44
|
|
@@ -21,7 +21,7 @@ implementation details like authentication, error handling, chunking, compressio
|
|
21
21
|
This Projects supports
|
22
22
|
the [Bulk APIs](https://help.anaplan.com/use-the-bulk-apis-93218e5e-00e5-406e-8361-09ab861889a7),
|
23
23
|
the [Transactional APIs](https://help.anaplan.com/use-the-transactional-apis-cc1c1e91-39fc-4272-a4b5-16bc91e9c313) and
|
24
|
-
the [ALM
|
24
|
+
the [ALM APIs](https://help.anaplan.com/application-lifecycle-management-api-2565cfa6-e0c2-4e24-884e-d0df957184d6),
|
25
25
|
the [Audit APIs](https://auditservice.docs.apiary.io/#),
|
26
26
|
providing both synchronous and asynchronous Clients.
|
27
27
|
|
@@ -3,8 +3,7 @@ Asynchronous Client.
|
|
3
3
|
"""
|
4
4
|
|
5
5
|
import logging
|
6
|
-
import
|
7
|
-
from asyncio import gather
|
6
|
+
from asyncio import gather, sleep
|
8
7
|
from copy import copy
|
9
8
|
from typing import AsyncIterator, Iterator
|
10
9
|
|
@@ -14,7 +13,17 @@ from typing_extensions import Self
|
|
14
13
|
from anaplan_sdk._auth import AnaplanBasicAuth, AnaplanCertAuth, get_certificate, get_private_key
|
15
14
|
from anaplan_sdk._base import _AsyncBaseClient, action_url
|
16
15
|
from anaplan_sdk.exceptions import AnaplanActionError, InvalidIdentifierException
|
17
|
-
from anaplan_sdk.models import
|
16
|
+
from anaplan_sdk.models import (
|
17
|
+
Action,
|
18
|
+
Export,
|
19
|
+
File,
|
20
|
+
Import,
|
21
|
+
Model,
|
22
|
+
Process,
|
23
|
+
TaskStatus,
|
24
|
+
TaskSummary,
|
25
|
+
Workspace,
|
26
|
+
)
|
18
27
|
|
19
28
|
from ._alm import _AsyncAlmClient
|
20
29
|
from ._audit import _AsyncAuditClient
|
@@ -250,7 +259,7 @@ class AsyncClient(_AsyncBaseClient):
|
|
250
259
|
for e in (await self._get(f"{self._url}/exports")).get("exports", [])
|
251
260
|
]
|
252
261
|
|
253
|
-
async def run_action(self, action_id: int) ->
|
262
|
+
async def run_action(self, action_id: int) -> TaskStatus:
|
254
263
|
"""
|
255
264
|
Runs the specified Anaplan Action and validates the spawned task. If the Action fails or
|
256
265
|
completes with errors, will raise an :py:class:`AnaplanActionError`. Failed Tasks are
|
@@ -268,16 +277,15 @@ class AsyncClient(_AsyncBaseClient):
|
|
268
277
|
task_id = await self.invoke_action(action_id)
|
269
278
|
task_status = await self.get_task_status(action_id, task_id)
|
270
279
|
|
271
|
-
while
|
272
|
-
|
280
|
+
while task_status.task_state != "COMPLETE":
|
281
|
+
await sleep(self.status_poll_delay)
|
273
282
|
task_status = await self.get_task_status(action_id, task_id)
|
274
283
|
|
275
|
-
if task_status.
|
276
|
-
"successful"
|
277
|
-
):
|
284
|
+
if task_status.task_state == "COMPLETE" and not task_status.result.successful:
|
278
285
|
raise AnaplanActionError(f"Task '{task_id}' completed with errors.")
|
279
286
|
|
280
287
|
logger.info(f"Task '{task_id}' completed successfully.")
|
288
|
+
return task_status
|
281
289
|
|
282
290
|
async def get_file(self, file_id: int) -> bytes:
|
283
291
|
"""
|
@@ -390,29 +398,31 @@ class AsyncClient(_AsyncBaseClient):
|
|
390
398
|
await self.run_action(action_id)
|
391
399
|
return await self.get_file(action_id)
|
392
400
|
|
393
|
-
async def list_task_status(self, action_id: int) -> list:
|
401
|
+
async def list_task_status(self, action_id: int) -> list[TaskSummary]:
|
394
402
|
"""
|
395
403
|
Retrieves the status of all tasks spawned by the specified action.
|
396
404
|
:param action_id: The identifier of the action that was invoked.
|
397
405
|
:return: The list of tasks spawned by the action.
|
398
406
|
"""
|
399
|
-
return
|
400
|
-
|
401
|
-
|
407
|
+
return [
|
408
|
+
TaskSummary.model_validate(e)
|
409
|
+
for e in (
|
410
|
+
await self._get(f"{self._url}/{action_url(action_id)}/{action_id}/tasks")
|
411
|
+
).get("tasks", [])
|
412
|
+
]
|
402
413
|
|
403
|
-
async def get_task_status(
|
404
|
-
self, action_id: int, task_id: str
|
405
|
-
) -> dict[str, float | int | str | list | dict | bool]:
|
414
|
+
async def get_task_status(self, action_id: int, task_id: str) -> TaskStatus:
|
406
415
|
"""
|
407
416
|
Retrieves the status of the specified task.
|
408
417
|
:param action_id: The identifier of the action that was invoked.
|
409
418
|
:param task_id: The identifier of the spawned task.
|
410
|
-
:return: The status of the task
|
411
|
-
see: https://anaplan.docs.apiary.io.
|
419
|
+
:return: The status of the task.
|
412
420
|
"""
|
413
|
-
return (
|
414
|
-
|
415
|
-
|
421
|
+
return TaskStatus.model_validate(
|
422
|
+
(
|
423
|
+
await self._get(f"{self._url}/{action_url(action_id)}/{action_id}/tasks/{task_id}")
|
424
|
+
).get("task")
|
425
|
+
)
|
416
426
|
|
417
427
|
async def invoke_action(self, action_id: int) -> str:
|
418
428
|
"""
|
@@ -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:
|
@@ -27,7 +32,7 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
|
|
27
32
|
"""
|
28
33
|
return [
|
29
34
|
Module.model_validate(e)
|
30
|
-
for e in (await self._get(f"{self._url}/modules")).get("modules")
|
35
|
+
for e in (await self._get(f"{self._url}/modules")).get("modules", [])
|
31
36
|
]
|
32
37
|
|
33
38
|
async def get_model_status(self) -> ModelStatus:
|
@@ -50,7 +55,7 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
|
|
50
55
|
if only_module_id
|
51
56
|
else f"{self._url}/lineItems?includeAll=true"
|
52
57
|
)
|
53
|
-
return [LineItem.model_validate(e) for e in (await self._get(url)).get("items")]
|
58
|
+
return [LineItem.model_validate(e) for e in (await self._get(url)).get("items", [])]
|
54
59
|
|
55
60
|
async def list_lists(self) -> list[List]:
|
56
61
|
"""
|
@@ -58,7 +63,7 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
|
|
58
63
|
:return: All Lists on this model.
|
59
64
|
"""
|
60
65
|
return [
|
61
|
-
List.model_validate(e) for e in (await self._get(f"{self._url}/lists")).get("lists")
|
66
|
+
List.model_validate(e) for e in (await self._get(f"{self._url}/lists")).get("lists", [])
|
62
67
|
]
|
63
68
|
|
64
69
|
async def get_list_metadata(self, list_id: int) -> ListMetadata:
|
@@ -85,28 +90,64 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
|
|
85
90
|
)
|
86
91
|
]
|
87
92
|
|
88
|
-
async def
|
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
|
-
|
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
|
95
|
-
:return: The result of the insertion
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
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
|
+
)
|
100
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)
|
101
123
|
|
102
|
-
|
124
|
+
return InsertionResult(
|
125
|
+
added=added, ignored=ignored, total=total, failures=list(chain.from_iterable(failures))
|
126
|
+
)
|
127
|
+
|
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
|
-
|
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
|
"""
|
@@ -115,7 +156,7 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
|
|
115
156
|
"""
|
116
157
|
await self._post_empty(f"{self._url}/lists/{list_id}/resetIndex")
|
117
158
|
|
118
|
-
async def
|
159
|
+
async def update_module_data(
|
119
160
|
self, module_id: int, data: list[dict[str, Any]]
|
120
161
|
) -> int | dict[str, Any]:
|
121
162
|
"""
|
@@ -129,3 +170,25 @@ 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 write_to_module(
|
175
|
+
self, module_id: int, data: list[dict[str, Any]]
|
176
|
+
) -> int | dict[str, Any]:
|
177
|
+
warnings.warn(
|
178
|
+
"`write_to_module()` is deprecated and will be removed in a future version. "
|
179
|
+
"Use `update_module_data()` instead.",
|
180
|
+
DeprecationWarning,
|
181
|
+
stacklevel=1,
|
182
|
+
)
|
183
|
+
return await self.update_module_data(module_id, data)
|
184
|
+
|
185
|
+
async def add_items_to_list(
|
186
|
+
self, list_id: int, items: list[dict[str, str | int | dict]]
|
187
|
+
) -> InsertionResult:
|
188
|
+
warnings.warn(
|
189
|
+
"`add_items_to_list()` is deprecated and will be removed in a future version. "
|
190
|
+
"Use `insert_list_items()` instead.",
|
191
|
+
DeprecationWarning,
|
192
|
+
stacklevel=1,
|
193
|
+
)
|
194
|
+
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
|
21
|
+
class _AnaplanAuth(httpx.Auth):
|
23
22
|
requires_response_body = True
|
24
23
|
|
25
|
-
def __init__(
|
26
|
-
self
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
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
|
-
|
40
|
-
|
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
|
47
|
-
|
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 {
|
63
|
+
headers={"Authorization": f"Basic {cred}"},
|
52
64
|
)
|
53
65
|
|
54
|
-
|
55
|
-
|
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
|
-
|
73
|
+
super().__init__()
|
81
74
|
|
82
|
-
def
|
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:
|
@@ -15,7 +15,17 @@ from typing_extensions import Self
|
|
15
15
|
from anaplan_sdk._auth import AnaplanBasicAuth, AnaplanCertAuth, get_certificate, get_private_key
|
16
16
|
from anaplan_sdk._base import _BaseClient, action_url
|
17
17
|
from anaplan_sdk.exceptions import AnaplanActionError, InvalidIdentifierException
|
18
|
-
from anaplan_sdk.models import
|
18
|
+
from anaplan_sdk.models import (
|
19
|
+
Action,
|
20
|
+
Export,
|
21
|
+
File,
|
22
|
+
Import,
|
23
|
+
Model,
|
24
|
+
Process,
|
25
|
+
TaskStatus,
|
26
|
+
TaskSummary,
|
27
|
+
Workspace,
|
28
|
+
)
|
19
29
|
|
20
30
|
from ._alm import _AlmClient
|
21
31
|
from ._audit import _AuditClient
|
@@ -252,7 +262,7 @@ class Client(_BaseClient):
|
|
252
262
|
Export.model_validate(e) for e in (self._get(f"{self._url}/exports")).get("exports", [])
|
253
263
|
]
|
254
264
|
|
255
|
-
def run_action(self, action_id: int) ->
|
265
|
+
def run_action(self, action_id: int) -> TaskStatus:
|
256
266
|
"""
|
257
267
|
Runs the specified Anaplan Action and validates the spawned task. If the Action fails or
|
258
268
|
completes with errors, will raise an :py:class:`AnaplanActionError`. Failed Tasks are
|
@@ -270,16 +280,15 @@ class Client(_BaseClient):
|
|
270
280
|
task_id = self.invoke_action(action_id)
|
271
281
|
task_status = self.get_task_status(action_id, task_id)
|
272
282
|
|
273
|
-
while
|
283
|
+
while task_status.task_state != "COMPLETE":
|
274
284
|
time.sleep(self.status_poll_delay)
|
275
285
|
task_status = self.get_task_status(action_id, task_id)
|
276
286
|
|
277
|
-
if task_status.
|
278
|
-
"successful"
|
279
|
-
):
|
287
|
+
if task_status.task_state == "COMPLETE" and not task_status.result.successful:
|
280
288
|
raise AnaplanActionError(f"Task '{task_id}' completed with errors.")
|
281
289
|
|
282
290
|
logger.info(f"Task '{task_id}' completed successfully.")
|
291
|
+
return task_status
|
283
292
|
|
284
293
|
def get_file(self, file_id: int) -> bytes:
|
285
294
|
"""
|
@@ -383,26 +392,30 @@ class Client(_BaseClient):
|
|
383
392
|
self.run_action(action_id)
|
384
393
|
return self.get_file(action_id)
|
385
394
|
|
386
|
-
def list_task_status(self, action_id: int) -> list:
|
395
|
+
def list_task_status(self, action_id: int) -> list[TaskSummary]:
|
387
396
|
"""
|
388
397
|
Retrieves the status of all tasks spawned by the specified action.
|
389
398
|
:param action_id: The identifier of the action that was invoked.
|
390
399
|
:return: The list of tasks spawned by the action.
|
391
400
|
"""
|
392
|
-
return
|
401
|
+
return [
|
402
|
+
TaskSummary.model_validate(e)
|
403
|
+
for e in self._get(f"{self._url}/{action_url(action_id)}/{action_id}/tasks").get(
|
404
|
+
"tasks", []
|
405
|
+
)
|
406
|
+
]
|
393
407
|
|
394
|
-
def get_task_status(
|
395
|
-
self, action_id: int, task_id: str
|
396
|
-
) -> dict[str, float | int | str | list | dict | bool]:
|
408
|
+
def get_task_status(self, action_id: int, task_id: str) -> TaskStatus:
|
397
409
|
"""
|
398
410
|
Retrieves the status of the specified task.
|
399
411
|
:param action_id: The identifier of the action that was invoked.
|
400
412
|
:param task_id: The identifier of the spawned task.
|
401
|
-
:return: The status of the task
|
402
|
-
see: https://anaplan.docs.apiary.io.
|
413
|
+
:return: The status of the task.
|
403
414
|
"""
|
404
|
-
return
|
405
|
-
"
|
415
|
+
return TaskStatus.model_validate(
|
416
|
+
self._get(f"{self._url}/{action_url(action_id)}/{action_id}/tasks/{task_id}").get(
|
417
|
+
"task"
|
418
|
+
)
|
406
419
|
)
|
407
420
|
|
408
421
|
def invoke_action(self, action_id: int) -> str:
|