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
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
|
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.
|
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.
|
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.
|
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
|
-
|
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.
|
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.
|
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.
|
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
|
-
|
66
|
-
|
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
|
-
|
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,
|
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
|
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.
|
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.
|
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.
|
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.
|
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
|
-
|
133
|
-
|
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.
|
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.
|
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.
|
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
|
-
|
150
|
-
|
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
|
-
|
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
|
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.
|
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
|
anaplan_sdk/_clients/_alm.py
CHANGED
@@ -1,14 +1,60 @@
|
|
1
|
+
import logging
|
2
|
+
from time import sleep
|
3
|
+
from typing import Literal, overload
|
4
|
+
|
1
5
|
import httpx
|
2
6
|
|
3
7
|
from anaplan_sdk._base import _BaseClient
|
4
|
-
from anaplan_sdk.
|
8
|
+
from anaplan_sdk.exceptions import AnaplanActionError
|
9
|
+
from anaplan_sdk.models import (
|
10
|
+
ModelRevision,
|
11
|
+
ReportTask,
|
12
|
+
Revision,
|
13
|
+
SummaryReport,
|
14
|
+
SyncTask,
|
15
|
+
TaskSummary,
|
16
|
+
)
|
17
|
+
|
18
|
+
logger = logging.getLogger("anaplan_sdk")
|
5
19
|
|
6
20
|
|
7
21
|
class _AlmClient(_BaseClient):
|
8
|
-
def __init__(
|
9
|
-
self
|
22
|
+
def __init__(
|
23
|
+
self, client: httpx.Client, model_id: str, retry_count: int, status_poll_delay: int
|
24
|
+
) -> None:
|
25
|
+
self.status_poll_delay = status_poll_delay
|
26
|
+
self._model_id = model_id
|
27
|
+
self._url = f"https://api.anaplan.com/2/0/models/{model_id}"
|
10
28
|
super().__init__(retry_count, client)
|
11
29
|
|
30
|
+
def change_model_status(self, status: Literal["online", "offline"]) -> None:
|
31
|
+
"""
|
32
|
+
Use this call to change the status of a model.
|
33
|
+
:param status: The status of the model. Can be either "online" or "offline".
|
34
|
+
"""
|
35
|
+
logger.info(f"Changed model status to '{status}' for model {self._model_id}.")
|
36
|
+
self._put(f"{self._url}/onlineStatus", json={"status": status})
|
37
|
+
|
38
|
+
def get_revisions(self) -> list[Revision]:
|
39
|
+
"""
|
40
|
+
Use this call to return a list of revisions for a specific model.
|
41
|
+
:return: A list of revisions for a specific model.
|
42
|
+
"""
|
43
|
+
res = self._get(f"{self._url}/alm/revisions")
|
44
|
+
return [Revision.model_validate(e) for e in res.get("revisions", [])]
|
45
|
+
|
46
|
+
def get_latest_revision(self) -> Revision | None:
|
47
|
+
"""
|
48
|
+
Use this call to return the latest revision for a specific model. The response is in the
|
49
|
+
same format as in Getting a list of syncable revisions between two models.
|
50
|
+
|
51
|
+
If a revision exists, the return list should contain one element only which is the
|
52
|
+
latest revision.
|
53
|
+
:return: The latest revision for a specific model, or None if no revisions exist.
|
54
|
+
"""
|
55
|
+
res = (self._get(f"{self._url}/alm/latestRevision")).get("revisions")
|
56
|
+
return Revision.model_validate(res[0]) if res else None
|
57
|
+
|
12
58
|
def get_syncable_revisions(self, source_model_id: str) -> list[Revision]:
|
13
59
|
"""
|
14
60
|
Use this call to return the list of revisions from your source model that can be
|
@@ -19,48 +65,80 @@ class _AlmClient(_BaseClient):
|
|
19
65
|
:param source_model_id: The ID of the source model.
|
20
66
|
:return: A list of revisions that can be synchronized to the target model.
|
21
67
|
"""
|
22
|
-
|
23
|
-
|
24
|
-
for e in self._get(
|
25
|
-
f"{self._url}/syncableRevisions?sourceModelId={source_model_id}"
|
26
|
-
).get("revisions", [])
|
27
|
-
]
|
68
|
+
res = self._get(f"{self._url}/alm/syncableRevisions?sourceModelId={source_model_id}")
|
69
|
+
return [Revision.model_validate(e) for e in res.get("revisions", [])]
|
28
70
|
|
29
|
-
def
|
71
|
+
def create_revision(self, name: str, description: str) -> Revision:
|
30
72
|
"""
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
latest revision.
|
36
|
-
:return: The latest revision for a specific model.
|
73
|
+
Create a new revision for the model.
|
74
|
+
:param name: The name (title) of the revision.
|
75
|
+
:param description: The description of the revision.
|
76
|
+
:return: The created Revision Info.
|
37
77
|
"""
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
]
|
78
|
+
res = self._post(
|
79
|
+
f"{self._url}/alm/revisions", json={"name": name, "description": description}
|
80
|
+
)
|
81
|
+
rev = Revision.model_validate(res["revision"])
|
82
|
+
logger.info(f"Created revision '{name} ({rev.id})'for model {self._model_id}.")
|
83
|
+
return rev
|
42
84
|
|
43
|
-
def get_sync_tasks(self) -> list[
|
85
|
+
def get_sync_tasks(self) -> list[TaskSummary]:
|
86
|
+
"""
|
87
|
+
List the sync tasks for a target mode. The returned the tasks are either in progress, or
|
88
|
+
they completed within the last 48 hours.
|
89
|
+
:return: A list of sync tasks in descending order of creation time.
|
44
90
|
"""
|
45
|
-
|
46
|
-
|
91
|
+
res = self._get(f"{self._url}/alm/syncTasks")
|
92
|
+
return [TaskSummary.model_validate(e) for e in res.get("tasks", [])]
|
47
93
|
|
48
|
-
|
49
|
-
|
94
|
+
def get_sync_task(self, task_id: str) -> SyncTask:
|
95
|
+
"""
|
96
|
+
Get the information for a specific sync task.
|
97
|
+
:param task_id: The ID of the sync task.
|
98
|
+
:return: The sync task information.
|
50
99
|
"""
|
51
|
-
|
52
|
-
|
53
|
-
]
|
100
|
+
res = self._get(f"{self._url}/alm/syncTasks/{task_id}")
|
101
|
+
return SyncTask.model_validate(res["task"])
|
54
102
|
|
55
|
-
def
|
103
|
+
def sync_models(
|
104
|
+
self,
|
105
|
+
source_revision_id: str,
|
106
|
+
source_model_id: str,
|
107
|
+
target_revision_id: str,
|
108
|
+
wait_for_completion: bool = True,
|
109
|
+
) -> SyncTask:
|
56
110
|
"""
|
57
|
-
|
58
|
-
|
111
|
+
Create a synchronization task between two revisions. This will synchronize the
|
112
|
+
source revision of the source model to the target revision of the target model. This will
|
113
|
+
fail if the source revision is incompatible with the target revision.
|
114
|
+
:param source_revision_id: The ID of the source revision.
|
115
|
+
:param source_model_id: The ID of the source model.
|
116
|
+
:param target_revision_id: The ID of the target revision.
|
117
|
+
:param wait_for_completion: If True, the method will poll the task status and not return
|
118
|
+
until the task is complete. If False, it will spawn the task and return immediately.
|
119
|
+
:return: The created sync task.
|
59
120
|
"""
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
121
|
+
payload = {
|
122
|
+
"sourceRevisionId": source_revision_id,
|
123
|
+
"sourceModelId": source_model_id,
|
124
|
+
"targetRevisionId": target_revision_id,
|
125
|
+
}
|
126
|
+
res = self._post(f"{self._url}/alm/syncTasks", json=payload)
|
127
|
+
task = self.get_sync_task(res["task"]["taskId"])
|
128
|
+
logger.info(
|
129
|
+
f"Started sync task '{task.id}' from Model '{source_model_id}' "
|
130
|
+
f"(Revision '{source_revision_id}') to Model '{self._model_id}'."
|
131
|
+
)
|
132
|
+
if not wait_for_completion:
|
133
|
+
return task
|
134
|
+
while (task := self.get_sync_task(task.id)).task_state != "COMPLETE":
|
135
|
+
sleep(self.status_poll_delay)
|
136
|
+
if not task.result.successful:
|
137
|
+
msg = f"Sync task {task.id} completed with errors: {task.result.error}."
|
138
|
+
logger.error(msg)
|
139
|
+
raise AnaplanActionError(msg)
|
140
|
+
logger.info(f"Sync task {task.id} completed successfully.")
|
141
|
+
return task
|
64
142
|
|
65
143
|
def get_models_for_revision(self, revision_id: str) -> list[ModelRevision]:
|
66
144
|
"""
|
@@ -69,9 +147,141 @@ class _AlmClient(_BaseClient):
|
|
69
147
|
:param revision_id: The ID of the revision.
|
70
148
|
:return: A list of models that had a specific revision applied to them.
|
71
149
|
"""
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
150
|
+
res = self._get(f"{self._url}/alm/revisions/{revision_id}/appliedToModels")
|
151
|
+
return [ModelRevision.model_validate(e) for e in res.get("appliedToModels", [])]
|
152
|
+
|
153
|
+
def create_comparison_report(
|
154
|
+
self,
|
155
|
+
source_revision_id: str,
|
156
|
+
source_model_id: str,
|
157
|
+
target_revision_id: str,
|
158
|
+
wait_for_completion: bool = True,
|
159
|
+
) -> ReportTask:
|
160
|
+
"""
|
161
|
+
Generate a full comparison report between two revisions. This will list all the changes made
|
162
|
+
to the source revision compared to the target revision.
|
163
|
+
:param source_revision_id: The ID of the source revision.
|
164
|
+
:param source_model_id: The ID of the source model.
|
165
|
+
:param target_revision_id: The ID of the target revision.
|
166
|
+
:param wait_for_completion: If True, the method will poll the task status and not return
|
167
|
+
until the task is complete. If False, it will spawn the task and return immediately.
|
168
|
+
:return: The created report task summary.
|
169
|
+
"""
|
170
|
+
payload = {
|
171
|
+
"sourceRevisionId": source_revision_id,
|
172
|
+
"sourceModelId": source_model_id,
|
173
|
+
"targetRevisionId": target_revision_id,
|
174
|
+
}
|
175
|
+
res = self._post(f"{self._url}/alm/comparisonReportTasks", json=payload)
|
176
|
+
task = self.get_comparison_report_task(res["task"]["taskId"])
|
177
|
+
logger.info(
|
178
|
+
f"Started Comparison Report task '{task.id}' between Model '{source_model_id}' "
|
179
|
+
f"(Revision '{source_revision_id}') and Model '{self._model_id}'."
|
180
|
+
)
|
181
|
+
if not wait_for_completion:
|
182
|
+
return task
|
183
|
+
while (task := self.get_comparison_report_task(task.id)).task_state != "COMPLETE":
|
184
|
+
sleep(self.status_poll_delay)
|
185
|
+
if not task.result.successful:
|
186
|
+
msg = f"Comparison Report task {task.id} completed with errors: {task.result.error}."
|
187
|
+
logger.error(msg)
|
188
|
+
raise AnaplanActionError(msg)
|
189
|
+
logger.info(f"Comparison Report task {task.id} completed successfully.")
|
190
|
+
return task
|
191
|
+
|
192
|
+
def get_comparison_report_task(self, task_id: str) -> ReportTask:
|
193
|
+
"""
|
194
|
+
Get the task information for a comparison report task.
|
195
|
+
:param task_id: The ID of the comparison report task.
|
196
|
+
:return: The report task information.
|
197
|
+
"""
|
198
|
+
res = self._get(f"{self._url}/alm/comparisonReportTasks/{task_id}")
|
199
|
+
return ReportTask.model_validate(res["task"])
|
200
|
+
|
201
|
+
def get_comparison_report(self, task: ReportTask) -> bytes:
|
202
|
+
"""
|
203
|
+
Get the report for a specific task.
|
204
|
+
:param task: The report task object containing the task ID.
|
205
|
+
:return: The binary content of the comparison report.
|
206
|
+
"""
|
207
|
+
return self._get_binary(
|
208
|
+
f"{self._url}/alm/comparisonReports/"
|
209
|
+
f"{task.result.target_revision_id}/{task.result.source_revision_id}"
|
210
|
+
)
|
211
|
+
|
212
|
+
@overload
|
213
|
+
def create_comparison_summary(
|
214
|
+
self,
|
215
|
+
source_revision_id: str,
|
216
|
+
source_model_id: str,
|
217
|
+
target_revision_id: str,
|
218
|
+
wait_for_completion: Literal[True] = True,
|
219
|
+
) -> SummaryReport: ...
|
220
|
+
|
221
|
+
@overload
|
222
|
+
def create_comparison_summary(
|
223
|
+
self,
|
224
|
+
source_revision_id: str,
|
225
|
+
source_model_id: str,
|
226
|
+
target_revision_id: str,
|
227
|
+
wait_for_completion: Literal[False] = False,
|
228
|
+
) -> ReportTask: ...
|
229
|
+
|
230
|
+
def create_comparison_summary(
|
231
|
+
self,
|
232
|
+
source_revision_id: str,
|
233
|
+
source_model_id: str,
|
234
|
+
target_revision_id: str,
|
235
|
+
wait_for_completion: bool = True,
|
236
|
+
) -> ReportTask | SummaryReport:
|
237
|
+
"""
|
238
|
+
Generate a comparison summary between two revisions.
|
239
|
+
:param source_revision_id: The ID of the source revision.
|
240
|
+
:param source_model_id: The ID of the source model.
|
241
|
+
:param target_revision_id: The ID of the target revision.
|
242
|
+
:param wait_for_completion: If True, the method will poll the task status and not return
|
243
|
+
until the task is complete. If False, it will spawn the task and return immediately.
|
244
|
+
:return: The created summary task or the summary report, if `wait_for_completion` is True.
|
245
|
+
"""
|
246
|
+
payload = {
|
247
|
+
"sourceRevisionId": source_revision_id,
|
248
|
+
"sourceModelId": source_model_id,
|
249
|
+
"targetRevisionId": target_revision_id,
|
250
|
+
}
|
251
|
+
res = self._post(f"{self._url}/alm/summaryReportTasks", json=payload)
|
252
|
+
task = self.get_comparison_summary_task(res["task"]["taskId"])
|
253
|
+
logger.info(
|
254
|
+
f"Started Comparison Summary task '{task.id}' between Model '{source_model_id}' "
|
255
|
+
f"(Revision '{source_revision_id}') and Model '{self._model_id}'."
|
256
|
+
)
|
257
|
+
if not wait_for_completion:
|
258
|
+
return task
|
259
|
+
while (task := self.get_comparison_summary_task(task.id)).task_state != "COMPLETE":
|
260
|
+
sleep(self.status_poll_delay)
|
261
|
+
if not task.result.successful:
|
262
|
+
msg = f"Comparison Summary task {task.id} completed with errors: {task.result.error}."
|
263
|
+
logger.error(msg)
|
264
|
+
raise AnaplanActionError(msg)
|
265
|
+
logger.info(f"Comparison Summary task {task.id} completed successfully.")
|
266
|
+
return self.get_comparison_summary(task)
|
267
|
+
|
268
|
+
def get_comparison_summary_task(self, task_id: str) -> ReportTask:
|
269
|
+
"""
|
270
|
+
Get the task information for a comparison summary task.
|
271
|
+
:param task_id: The ID of the comparison summary task.
|
272
|
+
:return: The report task information.
|
273
|
+
"""
|
274
|
+
res = self._get(f"{self._url}/alm/summaryReportTasks/{task_id}")
|
275
|
+
return ReportTask.model_validate(res["task"])
|
276
|
+
|
277
|
+
def get_comparison_summary(self, task: ReportTask) -> SummaryReport:
|
278
|
+
"""
|
279
|
+
Get the comparison summary for a specific task.
|
280
|
+
:param task: The summary task object containing the task ID.
|
281
|
+
:return: The binary content of the comparison summary.
|
282
|
+
"""
|
283
|
+
res = self._get(
|
284
|
+
f"{self._url}/alm/summaryReports/"
|
285
|
+
f"{task.result.target_revision_id}/{task.result.source_revision_id}"
|
286
|
+
)
|
287
|
+
return SummaryReport.model_validate(res["summaryReport"])
|
anaplan_sdk/_clients/_audit.py
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
from typing import Literal
|
1
|
+
from typing import Any, Literal
|
2
2
|
|
3
3
|
import httpx
|
4
4
|
|
@@ -15,7 +15,7 @@ class _AuditClient(_BaseClient):
|
|
15
15
|
self._url = "https://audit.anaplan.com/audit/api/1/events"
|
16
16
|
super().__init__(retry_count, client)
|
17
17
|
|
18
|
-
def
|
18
|
+
def get_users(self, search_pattern: str | None = None) -> list[User]:
|
19
19
|
"""
|
20
20
|
Lists all the Users in the authenticated users default tenant.
|
21
21
|
:param search_pattern: Optional filter for users. When provided, case-insensitive matches
|
@@ -39,13 +39,15 @@ class _AuditClient(_BaseClient):
|
|
39
39
|
self._get(f"https://api.anaplan.com/2/0/users/{user_id}").get("user")
|
40
40
|
)
|
41
41
|
|
42
|
-
def get_events(
|
42
|
+
def get_events(
|
43
|
+
self, days_into_past: int = 30, event_type: Event = "all"
|
44
|
+
) -> list[dict[str, Any]]:
|
43
45
|
"""
|
44
46
|
Get audit events from Anaplan Audit API.
|
45
47
|
:param days_into_past: The nuber of days into the past to get events for. The API provides
|
46
48
|
data for up to 30 days.
|
47
49
|
:param event_type: The type of events to get.
|
48
|
-
:return: A list of
|
50
|
+
:return: A list of log entries, each containing a dictionary with event details.
|
49
51
|
"""
|
50
52
|
return list(
|
51
53
|
self._get_paginated(
|