anaplan-sdk 0.5.0a2__py3-none-any.whl → 0.5.0a4__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,7 +1,7 @@
1
1
  import logging
2
2
  from typing import Literal, overload
3
3
 
4
- from anaplan_sdk._services import _AsyncHttpService
4
+ from anaplan_sdk._services import _AsyncHttpService, sort_params
5
5
  from anaplan_sdk.exceptions import AnaplanActionError
6
6
  from anaplan_sdk.models import (
7
7
  ModelRevision,
@@ -29,13 +29,21 @@ class _AsyncAlmClient:
29
29
  logger.info(f"Changed model status to '{status}' for model {self._model_id}.")
30
30
  await self._http.put(f"{self._url}/onlineStatus", json={"status": status})
31
31
 
32
- async def get_revisions(self) -> list[Revision]:
32
+ async def get_revisions(
33
+ self,
34
+ sort_by: Literal["id", "name", "applied_on", "created_on"] | None = None,
35
+ descending: bool = False,
36
+ ) -> list[Revision]:
33
37
  """
34
38
  Use this call to return a list of revisions for a specific model.
39
+ :param sort_by: The field to sort the results by.
40
+ :param descending: If True, the results will be sorted in descending order.
35
41
  :return: A list of revisions for a specific model.
36
42
  """
37
- res = await self._http.get(f"{self._url}/alm/revisions")
38
- return [Revision.model_validate(e) for e in res.get("revisions", [])]
43
+ res = await self._http.get_paginated(
44
+ f"{self._url}/alm/revisions", "revisions", params=sort_params(sort_by, descending)
45
+ )
46
+ return [Revision.model_validate(e) for e in res]
39
47
 
40
48
  async def get_latest_revision(self) -> Revision | None:
41
49
  """
@@ -1,9 +1,10 @@
1
1
  from typing import Any, Literal
2
2
 
3
- from anaplan_sdk._services import _AsyncHttpService
3
+ from anaplan_sdk._services import _AsyncHttpService, sort_params
4
4
  from anaplan_sdk.models import User
5
5
 
6
6
  Event = Literal["all", "byok", "user_activity"]
7
+ UserSortBy = Literal["first_name", "last_name", "email", "active", "last_login_date"] | None
7
8
 
8
9
 
9
10
  class _AsyncAuditClient:
@@ -12,22 +13,29 @@ class _AsyncAuditClient:
12
13
  self._limit = 10_000
13
14
  self._url = "https://audit.anaplan.com/audit/api/1/events"
14
15
 
15
- async def get_users(self, search_pattern: str | None = None) -> list[User]:
16
+ async def get_users(
17
+ self,
18
+ search_pattern: str | None = None,
19
+ sort_by: UserSortBy = None,
20
+ descending: bool = False,
21
+ ) -> list[User]:
16
22
  """
17
23
  Lists all the Users in the authenticated users default tenant.
18
24
  :param search_pattern: Optionally filter for specific users. When provided,
19
25
  case-insensitive matches users with emails or names containing this string.
20
26
  You can use the wildcards `%` for 0-n characters, and `_` for exactly 1 character.
21
27
  When None (default), returns all users.
28
+ :param sort_by: The field to sort the results by.
29
+ :param descending: If True, the results will be sorted in descending order.
22
30
  :return: The List of Users.
23
31
  """
24
- params = {"s": search_pattern} if search_pattern else None
25
- return [
26
- User.model_validate(e)
27
- for e in await self._http.get_paginated(
28
- "https://api.anaplan.com/2/0/users", "users", params=params
29
- )
30
- ]
32
+ params = sort_params(sort_by, descending)
33
+ if search_pattern:
34
+ params["s"] = search_pattern
35
+ res = await self._http.get_paginated(
36
+ "https://api.anaplan.com/2/0/users", "users", params=params
37
+ )
38
+ return [User.model_validate(e) for e in res]
31
39
 
32
40
  async def get_user(self, user_id: str = "me") -> User:
33
41
  """
@@ -1,13 +1,13 @@
1
1
  import logging
2
2
  from asyncio import gather
3
3
  from copy import copy
4
- from typing import AsyncIterator, Iterator
4
+ from typing import AsyncIterator, Iterator, Literal
5
5
 
6
6
  import httpx
7
7
  from typing_extensions import Self
8
8
 
9
9
  from anaplan_sdk._auth import _create_auth
10
- from anaplan_sdk._services import _AsyncHttpService, action_url
10
+ from anaplan_sdk._services import _AsyncHttpService, action_url, sort_params
11
11
  from anaplan_sdk.exceptions import AnaplanActionError, InvalidIdentifierException
12
12
  from anaplan_sdk.models import (
13
13
  Action,
@@ -27,6 +27,8 @@ from ._audit import _AsyncAuditClient
27
27
  from ._cloud_works import _AsyncCloudWorksClient
28
28
  from ._transactional import _AsyncTransactionalClient
29
29
 
30
+ SortBy = Literal["id", "name"] | None
31
+
30
32
  logger = logging.getLogger("anaplan_sdk")
31
33
 
32
34
 
@@ -49,6 +51,8 @@ class AsyncClient:
49
51
  auth: httpx.Auth | None = None,
50
52
  timeout: float | httpx.Timeout = 30,
51
53
  retry_count: int = 2,
54
+ backoff: float = 1.0,
55
+ backoff_factor: float = 2.0,
52
56
  page_size: int = 5_000,
53
57
  status_poll_delay: int = 1,
54
58
  upload_chunk_size: int = 25_000_000,
@@ -86,6 +90,11 @@ class AsyncClient:
86
90
  :param retry_count: The number of times to retry an HTTP request if it fails. Set this to 0
87
91
  to never retry. Defaults to 2, meaning each HTTP Operation will be tried a total
88
92
  number of 2 times.
93
+ :param backoff: The initial backoff time in seconds for the retry mechanism. This is the
94
+ time to wait before the first retry.
95
+ :param backoff_factor: The factor by which the backoff time is multiplied after each retry.
96
+ For example, if the initial backoff is 1 second and the factor is 2, the second
97
+ retry will wait 2 seconds, the third retry will wait 4 seconds, and so on.
89
98
  :param page_size: The number of items to return per page when paginating through results.
90
99
  Defaults to 5000. This is the maximum number of items that can be returned per
91
100
  request. If you pass a value greater than 5000, it will be capped to 5000.
@@ -110,7 +119,14 @@ class AsyncClient:
110
119
  private_key_password=private_key_password,
111
120
  )
112
121
  _client = httpx.AsyncClient(auth=_auth, timeout=timeout, **httpx_kwargs)
113
- self._http = _AsyncHttpService(_client, retry_count, page_size, status_poll_delay)
122
+ self._http = _AsyncHttpService(
123
+ _client,
124
+ retry_count=retry_count,
125
+ backoff=backoff,
126
+ backoff_factor=backoff_factor,
127
+ page_size=page_size,
128
+ poll_delay=status_poll_delay,
129
+ )
114
130
  self._workspace_id = workspace_id
115
131
  self._model_id = model_id
116
132
  self._url = f"https://api.anaplan.com/2/0/workspaces/{workspace_id}/models/{model_id}"
@@ -122,6 +138,9 @@ class AsyncClient:
122
138
  self._cloud_works = _AsyncCloudWorksClient(self._http)
123
139
  self.upload_chunk_size = upload_chunk_size
124
140
  self.allow_file_creation = allow_file_creation
141
+ logger.debug(
142
+ f"Initialized AsyncClient with workspace_id={workspace_id}, model_id={model_id}"
143
+ )
125
144
 
126
145
  @classmethod
127
146
  def from_existing(
@@ -208,43 +227,53 @@ class AsyncClient:
208
227
  )
209
228
  return self._alm_client
210
229
 
211
- async def get_workspaces(self, search_pattern: str | None = None) -> list[Workspace]:
230
+ async def get_workspaces(
231
+ self,
232
+ search_pattern: str | None = None,
233
+ sort_by: Literal["size_allowance", "name"] | None = None,
234
+ descending: bool = False,
235
+ ) -> list[Workspace]:
212
236
  """
213
237
  Lists all the Workspaces the authenticated user has access to.
214
238
  :param search_pattern: Optionally filter for specific workspaces. When provided,
215
239
  case-insensitive matches workspaces with names containing this string.
216
240
  You can use the wildcards `%` for 0-n characters, and `_` for exactly 1 character.
217
241
  When None (default), returns all users.
242
+ :param sort_by: The field to sort the results by.
243
+ :param descending: If True, the results will be sorted in descending order.
218
244
  :return: The List of Workspaces.
219
245
  """
220
- params = {"tenantDetails": "true"}
246
+ params = {"tenantDetails": "true"} | sort_params(sort_by, descending)
221
247
  if search_pattern:
222
248
  params["s"] = search_pattern
223
- return [
224
- Workspace.model_validate(e)
225
- for e in await self._http.get_paginated(
226
- "https://api.anaplan.com/2/0/workspaces", "workspaces", params=params
227
- )
228
- ]
249
+ res = await self._http.get_paginated(
250
+ "https://api.anaplan.com/2/0/workspaces", "workspaces", params=params
251
+ )
252
+ return [Workspace.model_validate(e) for e in res]
229
253
 
230
- async def get_models(self, search_pattern: str | None = None) -> list[Model]:
254
+ async def get_models(
255
+ self,
256
+ search_pattern: str | None = None,
257
+ sort_by: Literal["active_state", "name"] | None = None,
258
+ descending: bool = False,
259
+ ) -> list[Model]:
231
260
  """
232
261
  Lists all the Models the authenticated user has access to.
233
262
  :param search_pattern: Optionally filter for specific models. When provided,
234
263
  case-insensitive matches model names containing this string.
235
264
  You can use the wildcards `%` for 0-n characters, and `_` for exactly 1 character.
236
265
  When None (default), returns all models.
266
+ :param sort_by: The field to sort the results by.
267
+ :param descending: If True, the results will be sorted in descending order.
237
268
  :return: The List of Models.
238
269
  """
239
- params = {"modelDetails": "true"}
270
+ params = {"modelDetails": "true"} | sort_params(sort_by, descending)
240
271
  if search_pattern:
241
272
  params["s"] = search_pattern
242
- return [
243
- Model.model_validate(e)
244
- for e in await self._http.get_paginated(
245
- "https://api.anaplan.com/2/0/models", "models", params=params
246
- )
247
- ]
273
+ res = await self._http.get_paginated(
274
+ "https://api.anaplan.com/2/0/models", "models", params=params
275
+ )
276
+ return [Model.model_validate(e) for e in res]
248
277
 
249
278
  async def delete_models(self, model_ids: list[str]) -> ModelDeletionResult:
250
279
  """
@@ -260,57 +289,69 @@ class AsyncClient:
260
289
  )
261
290
  return ModelDeletionResult.model_validate(res)
262
291
 
263
- async def get_files(self) -> list[File]:
292
+ async def get_files(self, sort_by: SortBy = None, descending: bool = False) -> list[File]:
264
293
  """
265
294
  Lists all the Files in the Model.
295
+ :param sort_by: The field to sort the results by.
296
+ :param descending: If True, the results will be sorted in descending order.
266
297
  :return: The List of Files.
267
298
  """
268
- return [
269
- File.model_validate(e)
270
- for e in await self._http.get_paginated(f"{self._url}/files", "files")
271
- ]
299
+ res = await self._http.get_paginated(
300
+ f"{self._url}/files", "files", params=sort_params(sort_by, descending)
301
+ )
302
+ return [File.model_validate(e) for e in res]
272
303
 
273
- async def get_actions(self) -> list[Action]:
304
+ async def get_actions(self, sort_by: SortBy = None, descending: bool = False) -> list[Action]:
274
305
  """
275
306
  Lists all the Actions in the Model. This will only return the Actions listed under
276
307
  `Other Actions` in Anaplan. For Imports, exports, and processes, see their respective
277
308
  methods instead.
309
+ :param sort_by: The field to sort the results by.
310
+ :param descending: If True, the results will be sorted in descending order.
278
311
  :return: The List of Actions.
279
312
  """
280
- return [
281
- Action.model_validate(e)
282
- for e in await self._http.get_paginated(f"{self._url}/actions", "actions")
283
- ]
313
+ res = await self._http.get_paginated(
314
+ f"{self._url}/actions", "actions", params=sort_params(sort_by, descending)
315
+ )
316
+ return [Action.model_validate(e) for e in res]
284
317
 
285
- async def get_processes(self) -> list[Process]:
318
+ async def get_processes(
319
+ self, sort_by: SortBy = None, descending: bool = False
320
+ ) -> list[Process]:
286
321
  """
287
322
  Lists all the Processes in the Model.
323
+ :param sort_by: The field to sort the results by.
324
+ :param descending: If True, the results will be sorted in descending order.
288
325
  :return: The List of Processes.
289
326
  """
290
- return [
291
- Process.model_validate(e)
292
- for e in await self._http.get_paginated(f"{self._url}/processes", "processes")
293
- ]
327
+ res = await self._http.get_paginated(
328
+ f"{self._url}/processes", "processes", params=sort_params(sort_by, descending)
329
+ )
330
+ return [Process.model_validate(e) for e in res]
294
331
 
295
- async def get_imports(self) -> list[Import]:
332
+ async def get_imports(self, sort_by: SortBy = None, descending: bool = False) -> list[Import]:
296
333
  """
297
334
  Lists all the Imports in the Model.
335
+ :param sort_by: The field to sort the results by.
336
+ :param descending: If True, the results will be sorted in descending order.
298
337
  :return: The List of Imports.
299
338
  """
300
- return [
301
- Import.model_validate(e)
302
- for e in await self._http.get_paginated(f"{self._url}/imports", "imports")
303
- ]
339
+ res = await self._http.get_paginated(
340
+ f"{self._url}/imports", "imports", params=sort_params(sort_by, descending)
341
+ )
342
+ return [Import.model_validate(e) for e in res]
304
343
 
305
- async def get_exports(self) -> list[Export]:
344
+ async def get_exports(self, sort_by: SortBy = None, descending: bool = False) -> list[Export]:
306
345
  """
307
346
  Lists all the Exports in the Model.
347
+ :param sort_by: The field to sort the results by.
348
+ :param descending: If True, the results will be sorted in descending order.
308
349
  :return: The List of Exports.
309
350
  """
310
- return [
311
- Export.model_validate(e)
312
- for e in await self._http.get_paginated(f"{self._url}/exports", "exports")
313
- ]
351
+ res = await self._http.get_paginated(
352
+ f"{self._url}/exports", "exports", params=sort_params(sort_by, descending)
353
+ )
354
+ return [Export.model_validate(e) for e in res]
314
355
 
315
356
  async def run_action(self, action_id: int, wait_for_completion: bool = True) -> TaskStatus:
316
357
  """
@@ -99,14 +99,15 @@ class _AsyncCloudWorksClient:
99
99
  logger.info(f"Deleted connection '{con_id}'.")
100
100
 
101
101
  async def get_integrations(
102
- self, sort_by_name: Literal["ascending", "descending"] = "ascending"
102
+ self, sort_by: Literal["name"] | None = None, descending: bool = False
103
103
  ) -> list[Integration]:
104
104
  """
105
105
  List all integrations in CloudWorks.
106
- :param sort_by_name: Sort the integrations by name in ascending or descending order.
106
+ :param sort_by: The field to sort the results by.
107
+ :param descending: If True, the results will be sorted in descending order.
107
108
  :return: A list of integrations.
108
109
  """
109
- params = {"sortBy": "name" if sort_by_name == "ascending" else "-name"}
110
+ params = {"sortBy": f"{'-' if descending else ''}{sort_by}"} if sort_by else None
110
111
  return [
111
112
  Integration.model_validate(e)
112
113
  for e in await self._http.get_paginated(f"{self._url}", "integrations", params=params)
@@ -21,9 +21,7 @@ class _AsyncFlowClient:
21
21
  params = {"myIntegrations": 1 if current_user_only else 0}
22
22
  return [
23
23
  FlowSummary.model_validate(e)
24
- for e in await self._http.get_paginated(
25
- self._url, "integrationFlows", page_size=25, params=params
26
- )
24
+ for e in await self._http.get_paginated(self._url, "integrationFlows", params=params)
27
25
  ]
28
26
 
29
27
  async def get_flow(self, flow_id: str) -> Flow:
@@ -7,6 +7,7 @@ from anaplan_sdk._services import (
7
7
  _AsyncHttpService,
8
8
  parse_calendar_response,
9
9
  parse_insertion_response,
10
+ sort_params,
10
11
  validate_dimension_id,
11
12
  )
12
13
  from anaplan_sdk.exceptions import InvalidIdentifierException
@@ -29,6 +30,8 @@ from anaplan_sdk.models import (
29
30
  ViewInfo,
30
31
  )
31
32
 
33
+ SortBy = Literal["id", "name"] | None
34
+
32
35
  logger = logging.getLogger("anaplan_sdk")
33
36
 
34
37
 
@@ -68,23 +71,29 @@ class _AsyncTransactionalClient:
68
71
  )
69
72
  logger.info(f"Closed model '{self._model_id}'.")
70
73
 
71
- async def get_modules(self) -> list[Module]:
74
+ async def get_modules(self, sort_by: SortBy = None, descending: bool = False) -> list[Module]:
72
75
  """
73
76
  Lists all the Modules in the Model.
77
+ :param sort_by: The field to sort the results by.
78
+ :param descending: If True, the results will be sorted in descending order.
74
79
  :return: The List of Modules.
75
80
  """
76
- return [
77
- Module.model_validate(e)
78
- for e in await self._http.get_paginated(f"{self._url}/modules", "modules")
79
- ]
81
+ res = await self._http.get_paginated(
82
+ f"{self._url}/modules", "modules", params=sort_params(sort_by, descending)
83
+ )
84
+ return [Module.model_validate(e) for e in res]
80
85
 
81
- async def get_views(self) -> list[View]:
86
+ async def get_views(
87
+ self, sort_by: Literal["id", "module_id", "name"] | None = None, descending: bool = False
88
+ ) -> list[View]:
82
89
  """
83
90
  Lists all the Views in the Model. This will include all Modules and potentially other saved
84
91
  views.
92
+ :param sort_by: The field to sort the results by.
93
+ :param descending: If True, the results will be sorted in descending order.
85
94
  :return: The List of Views.
86
95
  """
87
- params = {"includesubsidiaryviews": True}
96
+ params = {"includesubsidiaryviews": True} | sort_params(sort_by, descending)
88
97
  return [
89
98
  View.model_validate(e)
90
99
  for e in await self._http.get_paginated(f"{self._url}/views", "views", params=params)
@@ -111,15 +120,17 @@ class _AsyncTransactionalClient:
111
120
  )
112
121
  return [LineItem.model_validate(e) for e in res.get("items", [])]
113
122
 
114
- async def get_lists(self) -> list[List]:
123
+ async def get_lists(self, sort_by: SortBy = None, descending: bool = False) -> list[List]:
115
124
  """
116
125
  Lists all the Lists in the Model.
126
+ :param sort_by: The field to sort the results by.
127
+ :param descending: If True, the results will be sorted in descending order.
117
128
  :return: All Lists on this model.
118
129
  """
119
- return [
120
- List.model_validate(e)
121
- for e in await self._http.get_paginated(f"{self._url}/lists", "lists")
122
- ]
130
+ res = await self._http.get_paginated(
131
+ f"{self._url}/lists", "lists", params=sort_params(sort_by, descending)
132
+ )
133
+ return [List.model_validate(e) for e in res]
123
134
 
124
135
  async def get_list_metadata(self, list_id: int) -> ListMetadata:
125
136
  """
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
  from typing import Literal, overload
3
3
 
4
- from anaplan_sdk._services import _HttpService
4
+ from anaplan_sdk._services import _HttpService, sort_params
5
5
  from anaplan_sdk.exceptions import AnaplanActionError
6
6
  from anaplan_sdk.models import (
7
7
  ModelRevision,
@@ -29,13 +29,21 @@ class _AlmClient:
29
29
  logger.info(f"Changed model status to '{status}' for model {self._model_id}.")
30
30
  self._http.put(f"{self._url}/onlineStatus", json={"status": status})
31
31
 
32
- def get_revisions(self) -> list[Revision]:
32
+ def get_revisions(
33
+ self,
34
+ sort_by: Literal["id", "name", "applied_on", "created_on"] | None = None,
35
+ descending: bool = False,
36
+ ) -> list[Revision]:
33
37
  """
34
38
  Use this call to return a list of revisions for a specific model.
39
+ :param sort_by: The field to sort the results by.
40
+ :param descending: If True, the results will be sorted in descending order.
35
41
  :return: A list of revisions for a specific model.
36
42
  """
37
- res = self._http.get(f"{self._url}/alm/revisions")
38
- return [Revision.model_validate(e) for e in res.get("revisions", [])]
43
+ res = self._http.get_paginated(
44
+ f"{self._url}/alm/revisions", "revisions", params=sort_params(sort_by, descending)
45
+ )
46
+ return [Revision.model_validate(e) for e in res]
39
47
 
40
48
  def get_latest_revision(self) -> Revision | None:
41
49
  """
@@ -1,9 +1,10 @@
1
1
  from typing import Any, Literal
2
2
 
3
- from anaplan_sdk._services import _HttpService
3
+ from anaplan_sdk._services import _HttpService, sort_params
4
4
  from anaplan_sdk.models import User
5
5
 
6
6
  Event = Literal["all", "byok", "user_activity"]
7
+ UserSortBy = Literal["first_name", "last_name", "email", "active", "last_login_date"] | None
7
8
 
8
9
 
9
10
  class _AuditClient:
@@ -12,20 +13,27 @@ class _AuditClient:
12
13
  self._limit = 10_000
13
14
  self._url = "https://audit.anaplan.com/audit/api/1/events"
14
15
 
15
- def get_users(self, search_pattern: str | None = None) -> list[User]:
16
+ def get_users(
17
+ self,
18
+ search_pattern: str | None = None,
19
+ sort_by: UserSortBy = None,
20
+ descending: bool = False,
21
+ ) -> list[User]:
16
22
  """
17
23
  Lists all the Users in the authenticated users default tenant.
18
- :param search_pattern: Optional filter for users. When provided, case-insensitive matches
19
- users with emails containing this string. When None (default), returns all users.
24
+ :param search_pattern: Optionally filter for specific users. When provided,
25
+ case-insensitive matches users with emails or names containing this string.
26
+ You can use the wildcards `%` for 0-n characters, and `_` for exactly 1 character.
27
+ When None (default), returns all users.
28
+ :param sort_by: The field to sort the results by.
29
+ :param descending: If True, the results will be sorted in descending order.
20
30
  :return: The List of Users.
21
31
  """
22
- params = {"s": search_pattern} if search_pattern else None
23
- return [
24
- User.model_validate(e)
25
- for e in self._http.get_paginated(
26
- "https://api.anaplan.com/2/0/users", "users", params=params
27
- )
28
- ]
32
+ params = sort_params(sort_by, descending)
33
+ if search_pattern:
34
+ params["s"] = search_pattern
35
+ res = self._http.get_paginated("https://api.anaplan.com/2/0/users", "users", params=params)
36
+ return [User.model_validate(e) for e in res]
29
37
 
30
38
  def get_user(self, user_id: str = "me") -> User:
31
39
  """
@@ -2,13 +2,13 @@ import logging
2
2
  import multiprocessing
3
3
  from concurrent.futures import ThreadPoolExecutor
4
4
  from copy import copy
5
- from typing import Iterator
5
+ from typing import Iterator, Literal
6
6
 
7
7
  import httpx
8
8
  from typing_extensions import Self
9
9
 
10
10
  from anaplan_sdk._auth import _create_auth
11
- from anaplan_sdk._services import _HttpService, action_url
11
+ from anaplan_sdk._services import _HttpService, action_url, sort_params
12
12
  from anaplan_sdk.exceptions import AnaplanActionError, InvalidIdentifierException
13
13
  from anaplan_sdk.models import (
14
14
  Action,
@@ -28,6 +28,8 @@ from ._audit import _AuditClient
28
28
  from ._cloud_works import _CloudWorksClient
29
29
  from ._transactional import _TransactionalClient
30
30
 
31
+ SortBy = Literal["id", "name"] | None
32
+
31
33
  logger = logging.getLogger("anaplan_sdk")
32
34
 
33
35
 
@@ -50,6 +52,8 @@ class Client:
50
52
  auth: httpx.Auth | None = None,
51
53
  timeout: float | httpx.Timeout = 30,
52
54
  retry_count: int = 2,
55
+ backoff: float = 1.0,
56
+ backoff_factor: float = 2.0,
53
57
  page_size: int = 5_000,
54
58
  status_poll_delay: int = 1,
55
59
  upload_parallel: bool = True,
@@ -87,6 +91,11 @@ class Client:
87
91
  an instance of `httpx.Timeout` to set the timeout for the HTTP requests.
88
92
  :param retry_count: The number of times to retry an HTTP request if it fails. Set this to 0
89
93
  to never retry. Defaults to 2, meaning each HTTP Operation will be tried a total
94
+ :param backoff: The initial backoff time in seconds for the retry mechanism. This is the
95
+ time to wait before the first retry.
96
+ :param backoff_factor: The factor by which the backoff time is multiplied after each retry.
97
+ For example, if the initial backoff is 1 second and the factor is 2, the second
98
+ retry will wait 2 seconds, the third retry will wait 4 seconds, and so on.
90
99
  number of 2 times.
91
100
  :param page_size: The number of items to return per page when paginating through results.
92
101
  Defaults to 5000. This is the maximum number of items that can be returned per
@@ -113,7 +122,14 @@ class Client:
113
122
  private_key_password=private_key_password,
114
123
  )
115
124
  _client = httpx.Client(auth=auth, timeout=timeout, **httpx_kwargs)
116
- self._http = _HttpService(_client, retry_count, page_size, status_poll_delay)
125
+ self._http = _HttpService(
126
+ _client,
127
+ retry_count=retry_count,
128
+ backoff=backoff,
129
+ backoff_factor=backoff_factor,
130
+ page_size=page_size,
131
+ poll_delay=status_poll_delay,
132
+ )
117
133
  self._retry_count = retry_count
118
134
  self._workspace_id = workspace_id
119
135
  self._model_id = model_id
@@ -129,6 +145,7 @@ class Client:
129
145
  self.upload_parallel = upload_parallel
130
146
  self.upload_chunk_size = upload_chunk_size
131
147
  self.allow_file_creation = allow_file_creation
148
+ logger.debug(f"Initialized Client with workspace_id={workspace_id}, model_id={model_id}")
132
149
 
133
150
  @classmethod
134
151
  def from_existing(
@@ -215,42 +232,53 @@ class Client:
215
232
  )
216
233
  return self._alm_client
217
234
 
218
- def get_workspaces(self, search_pattern: str | None = None) -> list[Workspace]:
235
+ def get_workspaces(
236
+ self,
237
+ search_pattern: str | None = None,
238
+ sort_by: Literal["size_allowance", "name"] | None = None,
239
+ descending: bool = False,
240
+ ) -> list[Workspace]:
219
241
  """
220
242
  Lists all the Workspaces the authenticated user has access to.
221
- :param search_pattern: Optional filter for workspaces. When provided, case-insensitive
222
- matches workspaces with names containing this string. When None (default),
223
- returns all workspaces.
243
+ :param search_pattern: Optionally filter for specific workspaces. When provided,
244
+ case-insensitive matches workspaces with names containing this string.
245
+ You can use the wildcards `%` for 0-n characters, and `_` for exactly 1 character.
246
+ When None (default), returns all users.
247
+ :param sort_by: The field to sort the results by.
248
+ :param descending: If True, the results will be sorted in descending order.
224
249
  :return: The List of Workspaces.
225
250
  """
226
- params = {"tenantDetails": "true"}
251
+ params = {"tenantDetails": "true"} | sort_params(sort_by, descending)
227
252
  if search_pattern:
228
253
  params["s"] = search_pattern
229
- return [
230
- Workspace.model_validate(e)
231
- for e in self._http.get_paginated(
232
- "https://api.anaplan.com/2/0/workspaces", "workspaces", params=params
233
- )
234
- ]
254
+ res = self._http.get_paginated(
255
+ "https://api.anaplan.com/2/0/workspaces", "workspaces", params=params
256
+ )
257
+ return [Workspace.model_validate(e) for e in res]
235
258
 
236
- def get_models(self, search_pattern: str | None = None) -> list[Model]:
259
+ def get_models(
260
+ self,
261
+ search_pattern: str | None = None,
262
+ sort_by: Literal["active_state", "name"] | None = None,
263
+ descending: bool = False,
264
+ ) -> list[Model]:
237
265
  """
238
266
  Lists all the Models the authenticated user has access to.
239
267
  :param search_pattern: Optionally filter for specific models. When provided,
240
268
  case-insensitive matches model names containing this string.
241
269
  You can use the wildcards `%` for 0-n characters, and `_` for exactly 1 character.
242
270
  When None (default), returns all models.
271
+ :param sort_by: The field to sort the results by.
272
+ :param descending: If True, the results will be sorted in descending order.
243
273
  :return: The List of Models.
244
274
  """
245
- params = {"modelDetails": "true"}
275
+ params = {"modelDetails": "true"} | sort_params(sort_by, descending)
246
276
  if search_pattern:
247
277
  params["s"] = search_pattern
248
- return [
249
- Model.model_validate(e)
250
- for e in self._http.get_paginated(
251
- "https://api.anaplan.com/2/0/models", "models", params=params
252
- )
253
- ]
278
+ res = self._http.get_paginated(
279
+ "https://api.anaplan.com/2/0/models", "models", params=params
280
+ )
281
+ return [Model.model_validate(e) for e in res]
254
282
 
255
283
  def delete_models(self, model_ids: list[str]) -> ModelDeletionResult:
256
284
  """
@@ -266,56 +294,69 @@ class Client:
266
294
  )
267
295
  return ModelDeletionResult.model_validate(res)
268
296
 
269
- def get_files(self) -> list[File]:
297
+ def get_files(
298
+ self, sort_by: Literal["id", "name"] = "id", descending: bool = False
299
+ ) -> list[File]:
270
300
  """
271
301
  Lists all the Files in the Model.
302
+ :param sort_by: The field to sort the results by.
303
+ :param descending: If True, the results will be sorted in descending order.
272
304
  :return: The List of Files.
273
305
  """
274
- return [
275
- File.model_validate(e) for e in self._http.get_paginated(f"{self._url}/files", "files")
276
- ]
306
+ res = self._http.get_paginated(
307
+ f"{self._url}/files", "files", params=sort_params(sort_by, descending)
308
+ )
309
+ return [File.model_validate(e) for e in res]
277
310
 
278
- def get_actions(self) -> list[Action]:
311
+ def get_actions(self, sort_by: SortBy = None, descending: bool = False) -> list[Action]:
279
312
  """
280
313
  Lists all the Actions in the Model. This will only return the Actions listed under
281
314
  `Other Actions` in Anaplan. For Imports, exports, and processes, see their respective
282
315
  methods instead.
316
+ :param sort_by: The field to sort the results by.
317
+ :param descending: If True, the results will be sorted in descending order.
283
318
  :return: The List of Actions.
284
319
  """
285
- return [
286
- Action.model_validate(e)
287
- for e in self._http.get_paginated(f"{self._url}/actions", "actions")
288
- ]
320
+ res = self._http.get_paginated(
321
+ f"{self._url}/actions", "actions", params=sort_params(sort_by, descending)
322
+ )
323
+ return [Action.model_validate(e) for e in res]
289
324
 
290
- def get_processes(self) -> list[Process]:
325
+ def get_processes(self, sort_by: SortBy = None, descending: bool = False) -> list[Process]:
291
326
  """
292
327
  Lists all the Processes in the Model.
328
+ :param sort_by: The field to sort the results by.
329
+ :param descending: If True, the results will be sorted in descending order.
293
330
  :return: The List of Processes.
294
331
  """
295
- return [
296
- Process.model_validate(e)
297
- for e in self._http.get_paginated(f"{self._url}/processes", "processes")
298
- ]
332
+ res = self._http.get_paginated(
333
+ f"{self._url}/processes", "processes", params=sort_params(sort_by, descending)
334
+ )
335
+ return [Process.model_validate(e) for e in res]
299
336
 
300
- def get_imports(self) -> list[Import]:
337
+ def get_imports(self, sort_by: SortBy = None, descending: bool = False) -> list[Import]:
301
338
  """
302
339
  Lists all the Imports in the Model.
340
+ :param sort_by: The field to sort the results by.
341
+ :param descending: If True, the results will be sorted in descending order.
303
342
  :return: The List of Imports.
304
343
  """
305
- return [
306
- Import.model_validate(e)
307
- for e in self._http.get_paginated(f"{self._url}/imports", "imports")
308
- ]
344
+ res = self._http.get_paginated(
345
+ f"{self._url}/imports", "imports", params=sort_params(sort_by, descending)
346
+ )
347
+ return [Import.model_validate(e) for e in res]
309
348
 
310
- def get_exports(self) -> list[Export]:
349
+ def get_exports(self, sort_by: SortBy = None, descending: bool = False) -> list[Export]:
311
350
  """
312
351
  Lists all the Exports in the Model.
352
+ :param sort_by: The field to sort the results by.
353
+ :param descending: If True, the results will be sorted in descending order.
313
354
  :return: The List of Exports.
314
355
  """
315
- return [
316
- Export.model_validate(e)
317
- for e in (self._http.get(f"{self._url}/exports")).get("exports", [])
318
- ]
356
+ res = self._http.get_paginated(
357
+ f"{self._url}/exports", "exports", params=sort_params(sort_by, descending)
358
+ )
359
+ return [Export.model_validate(e) for e in res]
319
360
 
320
361
  def run_action(self, action_id: int, wait_for_completion: bool = True) -> TaskStatus:
321
362
  """
@@ -95,14 +95,15 @@ class _CloudWorksClient:
95
95
  logger.info(f"Deleted connection '{con_id}'.")
96
96
 
97
97
  def get_integrations(
98
- self, sort_by_name: Literal["ascending", "descending"] = "ascending"
98
+ self, sort_by: Literal["name"] | None = None, descending: bool = False
99
99
  ) -> list[Integration]:
100
100
  """
101
101
  List all integrations in CloudWorks.
102
- :param sort_by_name: Sort the integrations by name in ascending or descending order.
102
+ :param sort_by: The field to sort the results by.
103
+ :param descending: If True, the results will be sorted in descending order.
103
104
  :return: A list of integrations.
104
105
  """
105
- params = {"sortBy": "name" if sort_by_name == "ascending" else "-name"}
106
+ params = {"sortBy": f"{'-' if descending else ''}{sort_by}"} if sort_by else None
106
107
  return [
107
108
  Integration.model_validate(e)
108
109
  for e in self._http.get_paginated(f"{self._url}", "integrations", params=params)
@@ -307,7 +308,7 @@ class _CloudWorksClient:
307
308
  ) -> None:
308
309
  """
309
310
  Update a notification configuration for an integration in CloudWorks. You cannot pass empty
310
- values or nulls to any of the fields If you want to for e.g. override an existing list of
311
+ values or nulls to any of the fields If you want to for e.g. override an existing list of
311
312
  users with an empty one, you must delete the notification configuration and create a new
312
313
  one with only the values you want to keep.
313
314
  :param notification_id: The ID of the notification configuration to update.
@@ -21,9 +21,7 @@ class _FlowClient:
21
21
  params = {"myIntegrations": 1 if current_user_only else 0}
22
22
  return [
23
23
  FlowSummary.model_validate(e)
24
- for e in self._http.get_paginated(
25
- self._url, "integrationFlows", page_size=25, params=params
26
- )
24
+ for e in self._http.get_paginated(self._url, "integrationFlows", params=params)
27
25
  ]
28
26
 
29
27
  def get_flow(self, flow_id: str) -> Flow:
@@ -7,6 +7,7 @@ from anaplan_sdk._services import (
7
7
  _HttpService,
8
8
  parse_calendar_response,
9
9
  parse_insertion_response,
10
+ sort_params,
10
11
  validate_dimension_id,
11
12
  )
12
13
  from anaplan_sdk.exceptions import InvalidIdentifierException
@@ -29,6 +30,8 @@ from anaplan_sdk.models import (
29
30
  ViewInfo,
30
31
  )
31
32
 
33
+ SortBy = Literal["id", "name"] | None
34
+
32
35
  logger = logging.getLogger("anaplan_sdk")
33
36
 
34
37
 
@@ -66,24 +69,32 @@ class _TransactionalClient:
66
69
  self._http.post_empty(f"{self._url}/close", headers={"Content-Type": "application/text"})
67
70
  logger.info(f"Closed model '{self._model_id}'.")
68
71
 
69
- def get_modules(self) -> list[Module]:
72
+ def get_modules(self, sort_by: SortBy = None, descending: bool = False) -> list[Module]:
70
73
  """
71
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.
72
77
  :return: The List of Modules.
73
78
  """
74
- return [
75
- Module.model_validate(e)
76
- for e in self._http.get_paginated(f"{self._url}/modules", "modules")
77
- ]
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]
78
83
 
79
- def get_views(self) -> list[View]:
84
+ def get_views(
85
+ self, sort_by: Literal["id", "module_id", "name"] | None = None, descending: bool = False
86
+ ) -> list[View]:
80
87
  """
81
88
  Lists all the Views in the Model. This will include all Modules and potentially other saved
82
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.
83
92
  :return: The List of Views.
84
93
  """
94
+ params = {"includesubsidiaryviews": True} | sort_params(sort_by, descending)
85
95
  return [
86
- View.model_validate(e) for e in self._http.get_paginated(f"{self._url}/views", "views")
96
+ View.model_validate(e)
97
+ for e in self._http.get_paginated(f"{self._url}/views", "views", params=params)
87
98
  ]
88
99
 
89
100
  def get_view_info(self, view_id: int) -> ViewInfo:
@@ -107,14 +118,17 @@ class _TransactionalClient:
107
118
  )
108
119
  return [LineItem.model_validate(e) for e in self._http.get(url).get("items", [])]
109
120
 
110
- def get_lists(self) -> list[List]:
121
+ def get_lists(self, sort_by: SortBy = None, descending: bool = False) -> list[List]:
111
122
  """
112
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.
113
126
  :return: All Lists on this model.
114
127
  """
115
- return [
116
- List.model_validate(e) for e in self._http.get_paginated(f"{self._url}/lists", "lists")
117
- ]
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]
118
132
 
119
133
  def get_list_metadata(self, list_id: int) -> ListMetadata:
120
134
  """
anaplan_sdk/_services.py CHANGED
@@ -1,6 +1,5 @@
1
1
  import asyncio
2
2
  import logging
3
- import random
4
3
  import time
5
4
  from asyncio import gather, sleep
6
5
  from concurrent.futures import ThreadPoolExecutor
@@ -11,6 +10,7 @@ from typing import Any, Awaitable, Callable, Coroutine, Iterator, Literal, Type,
11
10
 
12
11
  import httpx
13
12
  from httpx import HTTPError, Response
13
+ from pydantic.alias_generators import to_camel
14
14
 
15
15
  from .exceptions import AnaplanException, AnaplanTimeoutException, InvalidIdentifierException
16
16
  from .models import (
@@ -33,6 +33,15 @@ from .models.cloud_works import (
33
33
  ScheduleInput,
34
34
  )
35
35
 
36
+ SORT_WARNING = (
37
+ "If you are sorting by a field that is potentially ambiguous (e.g., name), the order of "
38
+ "results is not guaranteed to be internally consistent across multiple requests. This will "
39
+ "lead to wrong results when paginating through result sets where the ambiguous order can cause "
40
+ "records to slip between pages or be duplicated on multiple pages. The only way to ensure "
41
+ "correct results when sorting is to make sure the entire result set fits in one page, or to "
42
+ "sort by a field that is guaranteed to be unique (e.g., id)."
43
+ )
44
+
36
45
  logger = logging.getLogger("anaplan_sdk")
37
46
 
38
47
  _json_header = {"Content-Type": "application/json"}
@@ -43,9 +52,24 @@ Task = TypeVar("Task", bound=TaskSummary)
43
52
 
44
53
 
45
54
  class _HttpService:
46
- def __init__(self, client: httpx.Client, retry_count: int, page_size: int, poll_delay: int):
55
+ def __init__(
56
+ self,
57
+ client: httpx.Client,
58
+ *,
59
+ retry_count: int,
60
+ backoff: float,
61
+ backoff_factor: float,
62
+ page_size: int,
63
+ poll_delay: int,
64
+ ):
65
+ logger.debug(
66
+ f"Initializing HttpService with retry_count={retry_count}, "
67
+ f"page_size={page_size}, poll_delay={poll_delay}."
68
+ )
47
69
  self._client = client
48
70
  self._retry_count = retry_count
71
+ self._backoff = backoff
72
+ self._backoff_factor = backoff_factor
49
73
  self._poll_delay = poll_delay
50
74
  self._page_size = min(page_size, 5_000)
51
75
 
@@ -78,42 +102,47 @@ class _HttpService:
78
102
  content = compress(content.encode() if isinstance(content, str) else content)
79
103
  return self.__run_with_retry(self._client.put, url, headers=_gzip_header, content=content)
80
104
 
81
- def get_paginated(
82
- self, url: str, result_key: str, page_size: int = 5_000, **kwargs
83
- ) -> Iterator[dict[str, Any]]:
84
- logger.debug(f"Starting paginated fetch from {url} with page_size={page_size}.")
85
- first_page, total_items = self._get_first_page(url, page_size, result_key, **kwargs)
86
- if total_items <= page_size:
105
+ def poll_task(self, func: Callable[..., Task], *args) -> Task:
106
+ while (result := func(*args)).task_state != "COMPLETE":
107
+ time.sleep(self._poll_delay)
108
+ return result
109
+
110
+ def get_paginated(self, url: str, result_key: str, **kwargs) -> Iterator[dict[str, Any]]:
111
+ logger.debug(f"Starting paginated fetch from {url} with page_size={self._page_size}.")
112
+ first_page, total_items, actual_size = self._get_first_page(url, result_key, **kwargs)
113
+ if total_items <= self._page_size:
87
114
  logger.debug("All items fit in first page, no additional requests needed.")
88
115
  return iter(first_page)
89
-
90
- pages_needed = ceil(total_items / page_size)
91
- logger.debug(f"Fetching {pages_needed - 1} additional pages with {page_size} items each.")
116
+ if kwargs and (kwargs.get("params") or {}).get("sort", None):
117
+ logger.warning(SORT_WARNING)
118
+ pages_needed = ceil(total_items / actual_size)
119
+ logger.debug(f"Fetching {pages_needed - 1} additional pages with {actual_size} items each.")
92
120
  with ThreadPoolExecutor() as executor:
93
121
  pages = executor.map(
94
- lambda n: self._get_page(url, page_size, n * page_size, result_key, **kwargs),
122
+ lambda n: self._get_page(url, actual_size, n * actual_size, result_key, **kwargs),
95
123
  range(1, pages_needed),
96
124
  )
97
125
  logger.debug(f"Completed paginated fetch of {total_items} total items.")
98
126
  return chain(first_page, *pages)
99
127
 
100
- def poll_task(self, func: Callable[..., Task], *args) -> Task:
101
- while (result := func(*args)).task_state != "COMPLETE":
102
- time.sleep(self._poll_delay)
103
- return result
104
-
105
128
  def _get_page(self, url: str, limit: int, offset: int, result_key: str, **kwargs) -> list:
106
129
  logger.debug(f"Fetching page: offset={offset}, limit={limit} from {url}.")
107
- kwargs["params"] = kwargs.get("params") or {} | {"limit": limit, "offset": offset}
130
+ kwargs["params"] = (kwargs.get("params") or {}) | {"limit": limit, "offset": offset}
108
131
  return self.get(url, **kwargs).get(result_key, [])
109
132
 
110
- def _get_first_page(self, url: str, limit: int, result_key: str, **kwargs) -> tuple[list, int]:
111
- logger.debug(f"Fetching first page with limit={limit} from {url}.")
112
- kwargs["params"] = kwargs.get("params") or {} | {"limit": limit}
133
+ def _get_first_page(self, url: str, result_key: str, **kwargs) -> tuple[list, int, int]:
134
+ logger.debug(f"Fetching first page with limit={self._page_size} from {url}.")
135
+ kwargs["params"] = (kwargs.get("params") or {}) | {"limit": self._page_size}
113
136
  res = self.get(url, **kwargs)
114
137
  total_items, first_page = res["meta"]["paging"]["totalSize"], res.get(result_key, [])
138
+ actual_page_size = res["meta"]["paging"]["currentPageSize"]
139
+ if actual_page_size < self._page_size and not actual_page_size == total_items:
140
+ logger.warning(
141
+ f"Page size {self._page_size} was silently truncated to {actual_page_size}."
142
+ f"Using the server-side enforced page size {actual_page_size} for further requests."
143
+ )
115
144
  logger.debug(f"Found {total_items} total items, retrieved {len(first_page)} in first page.")
116
- return first_page, total_items
145
+ return first_page, total_items, actual_page_size
117
146
 
118
147
  def __run_with_retry(self, func: Callable[..., Response], *args, **kwargs) -> Response:
119
148
  for i in range(max(self._retry_count, 1)):
@@ -122,7 +151,7 @@ class _HttpService:
122
151
  if response.status_code == 429:
123
152
  if i >= self._retry_count - 1:
124
153
  raise AnaplanException("Rate limit exceeded.")
125
- backoff_time = max(i, 1) * random.randint(2, 5)
154
+ backoff_time = self._backoff * (self._backoff_factor if i > 0 else 1)
126
155
  logger.warning(f"Rate limited. Retrying in {backoff_time} seconds.")
127
156
  time.sleep(backoff_time)
128
157
  continue
@@ -139,10 +168,23 @@ class _HttpService:
139
168
 
140
169
  class _AsyncHttpService:
141
170
  def __init__(
142
- self, client: httpx.AsyncClient, retry_count: int, page_size: int, poll_delay: int
171
+ self,
172
+ client: httpx.AsyncClient,
173
+ *,
174
+ retry_count: int,
175
+ backoff: float,
176
+ backoff_factor: float,
177
+ page_size: int,
178
+ poll_delay: int,
143
179
  ):
180
+ logger.debug(
181
+ f"Initializing AsyncHttpService with retry_count={retry_count}, "
182
+ f"page_size={page_size}, poll_delay={poll_delay}."
183
+ )
144
184
  self._client = client
145
185
  self._retry_count = retry_count
186
+ self._backoff = backoff
187
+ self._backoff_factor = backoff_factor
146
188
  self._poll_delay = poll_delay
147
189
  self._page_size = min(page_size, 5_000)
148
190
 
@@ -179,42 +221,46 @@ class _AsyncHttpService:
179
221
  self._client.put, url, headers=_gzip_header, content=content
180
222
  )
181
223
 
182
- async def get_paginated(
183
- self, url: str, result_key: str, page_size: int = 5_000, **kwargs
184
- ) -> Iterator[dict[str, Any]]:
185
- logger.debug(f"Starting paginated fetch from {url} with page_size={page_size}.")
186
- first_page, total_items = await self._get_first_page(url, page_size, result_key, **kwargs)
187
- if total_items <= page_size:
224
+ async def poll_task(self, func: Callable[..., Awaitable[Task]], *args) -> Task:
225
+ while (result := await func(*args)).task_state != "COMPLETE":
226
+ await sleep(self._poll_delay)
227
+ return result
228
+
229
+ async def get_paginated(self, url: str, result_key: str, **kwargs) -> Iterator[dict[str, Any]]:
230
+ logger.debug(f"Starting paginated fetch from {url} with page_size={self._page_size}.")
231
+ first_page, total_items, actual_size = await self._get_first_page(url, result_key, **kwargs)
232
+ if total_items <= self._page_size:
188
233
  logger.debug("All items fit in first page, no additional requests needed.")
189
234
  return iter(first_page)
235
+ if kwargs and (kwargs.get("params") or {}).get("sort", None):
236
+ logger.warning(SORT_WARNING)
190
237
  pages = await gather(
191
238
  *(
192
- self._get_page(url, page_size, n * page_size, result_key, **kwargs)
193
- for n in range(1, ceil(total_items / page_size))
239
+ self._get_page(url, actual_size, n * actual_size, result_key, **kwargs)
240
+ for n in range(1, ceil(total_items / actual_size))
194
241
  )
195
242
  )
196
243
  logger.debug(f"Completed paginated fetch of {total_items} total items.")
197
244
  return chain(first_page, *pages)
198
245
 
199
- async def poll_task(self, func: Callable[..., Awaitable[Task]], *args) -> Task:
200
- while (result := await func(*args)).task_state != "COMPLETE":
201
- await sleep(self._poll_delay)
202
- return result
203
-
204
246
  async def _get_page(self, url: str, limit: int, offset: int, result_key: str, **kwargs) -> list:
205
247
  logger.debug(f"Fetching page: offset={offset}, limit={limit} from {url}.")
206
- kwargs["params"] = kwargs.get("params") or {} | {"limit": limit, "offset": offset}
248
+ kwargs["params"] = (kwargs.get("params") or {}) | {"limit": limit, "offset": offset}
207
249
  return (await self.get(url, **kwargs)).get(result_key, [])
208
250
 
209
- async def _get_first_page(
210
- self, url: str, limit: int, result_key: str, **kwargs
211
- ) -> tuple[list, int]:
212
- logger.debug(f"Fetching first page with limit={limit} from {url}.")
213
- kwargs["params"] = kwargs.get("params") or {} | {"limit": limit}
251
+ async def _get_first_page(self, url: str, result_key: str, **kwargs) -> tuple[list, int, int]:
252
+ logger.debug(f"Fetching first page with limit={self._page_size} from {url}.")
253
+ kwargs["params"] = (kwargs.get("params") or {}) | {"limit": self._page_size}
214
254
  res = await self.get(url, **kwargs)
215
255
  total_items, first_page = res["meta"]["paging"]["totalSize"], res.get(result_key, [])
256
+ actual_page_size = res["meta"]["paging"]["currentPageSize"]
257
+ if actual_page_size < self._page_size and not actual_page_size == total_items:
258
+ logger.warning(
259
+ f"Page size {self._page_size} was silently truncated to {actual_page_size}."
260
+ f"Using the server-side enforced page size {actual_page_size} for further requests."
261
+ )
216
262
  logger.debug(f"Found {total_items} total items, retrieved {len(first_page)} in first page.")
217
- return first_page, total_items
263
+ return first_page, total_items, actual_page_size
218
264
 
219
265
  async def _run_with_retry(
220
266
  self, func: Callable[..., Coroutine[Any, Any, Response]], *args, **kwargs
@@ -225,7 +271,7 @@ class _AsyncHttpService:
225
271
  if response.status_code == 429:
226
272
  if i >= self._retry_count - 1:
227
273
  raise AnaplanException("Rate limit exceeded.")
228
- backoff_time = (i + 1) * random.randint(3, 5)
274
+ backoff_time = self._backoff * (self._backoff_factor if i > 0 else 1)
229
275
  logger.warning(f"Rate limited. Retrying in {backoff_time} seconds.")
230
276
  await asyncio.sleep(backoff_time)
231
277
  continue
@@ -240,6 +286,18 @@ class _AsyncHttpService:
240
286
  raise AnaplanException("Exhausted all retries without a successful response or Error.")
241
287
 
242
288
 
289
+ def sort_params(sort_by: str | None, descending: bool) -> dict[str, str | bool]:
290
+ """
291
+ Construct search parameters for sorting. This also converts snake_case to camelCase.
292
+ :param sort_by: The field to sort by, optionally in snake_case.
293
+ :param descending: Whether to sort in descending order.
294
+ :return: A dictionary of search parameters in Anaplan's expected format.
295
+ """
296
+ if not sort_by:
297
+ return {}
298
+ return {"sort": f"{'-' if descending else '+'}{to_camel(sort_by)}"}
299
+
300
+
243
301
  def construct_payload(model: Type[T], body: T | dict[str, Any]) -> dict[str, Any]:
244
302
  """
245
303
  Construct a payload for the given model and body.
@@ -141,7 +141,9 @@ class ConnectionInput(AnaplanModel):
141
141
 
142
142
 
143
143
  class Connection(_VersionedBaseModel):
144
- connection_id: str = Field(description="The unique identifier of this connection.")
144
+ id: str = Field(
145
+ validation_alias="connectionId", description="The unique identifier of this connection."
146
+ )
145
147
  connection_type: ConnectionType = Field(description="The type of this connection.")
146
148
  body: AzureBlobConnectionInfo | AmazonS3ConnectionInfo | GoogleBigQueryConnectionInfo = Field(
147
149
  description="Connection information."
@@ -237,7 +239,9 @@ class _BaseIntegration(_VersionedBaseModel):
237
239
 
238
240
 
239
241
  class Integration(_BaseIntegration):
240
- integration_id: str = Field(description="The unique identifier of this integration.")
242
+ id: str = Field(
243
+ validation_alias="integrationId", description="The unique identifier of this integration."
244
+ )
241
245
  integration_type: Literal["Import", "Export", "Process"] = Field(
242
246
  description="The type of this integration."
243
247
  )
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anaplan-sdk
3
- Version: 0.5.0a2
4
- Summary: Streamlined Python Interface for Anaplan
3
+ Version: 0.5.0a4
4
+ Summary: Streamlined Python Interface for 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
7
7
  Project-URL: Documentation, https://vinzenzklass.github.io/anaplan-sdk/
@@ -0,0 +1,30 @@
1
+ anaplan_sdk/__init__.py,sha256=WScEKtXlnRLjCb-j3qW9W4kEACTyPsTLFs-L54et2TQ,351
2
+ anaplan_sdk/_auth.py,sha256=l5z2WCcfQ05OkuQ1dcmikp6dB87Rw1qy2zu8bbaAQTs,16620
3
+ anaplan_sdk/_oauth.py,sha256=AynlJDrGIinQT0jwxI2RSvtU4D7Wasyw3H1uicdlLVI,12672
4
+ anaplan_sdk/_services.py,sha256=isBMmeoxgQi2Xoc5wdD7qtraIHBqYJNAp-CEKWyhqj4,20104
5
+ anaplan_sdk/exceptions.py,sha256=ALkA9fBF0NQ7dufFxV6AivjmHyuJk9DOQ9jtJV2n7f0,1809
6
+ anaplan_sdk/_async_clients/__init__.py,sha256=pZXgMMg4S9Aj_pxQCaSiPuNG-sePVGBtNJ0133VjqW4,364
7
+ anaplan_sdk/_async_clients/_alm.py,sha256=zvKEvXlxNkcQim_XvyZLCbDafFldljg8APHqhAAIfvw,13147
8
+ anaplan_sdk/_async_clients/_audit.py,sha256=j9CeWzIuGsZrVBbjS_T8w6le2cjieW7NW6fDiCY34TA,2718
9
+ anaplan_sdk/_async_clients/_bulk.py,sha256=TTdTMXu1-3iNlD9oxGAbh58JXSRHiFAxnMt70zLz8kk,29929
10
+ anaplan_sdk/_async_clients/_cloud_works.py,sha256=VB4l93426A0Xes5dZ6DsDu0go-BVNhs2RZn2zX5DSOc,17675
11
+ anaplan_sdk/_async_clients/_cw_flow.py,sha256=_allKIOP-qb33wrOj6GV5VAOvrCXOVJ1QXvck-jsocQ,3935
12
+ anaplan_sdk/_async_clients/_transactional.py,sha256=U6X5pW7By387JOgvHx-GmgVRi7MRJKALpx0lWI6xRMo,18024
13
+ anaplan_sdk/_clients/__init__.py,sha256=FsbwvZC1FHrxuRXwbPxUzbhz_lO1DpXIxEOjx6-3QuA,219
14
+ anaplan_sdk/_clients/_alm.py,sha256=3U7Cy5U5TsePF1YPogXvsOzNeQlQm_ezO5TlmD-Xbbs,12874
15
+ anaplan_sdk/_clients/_audit.py,sha256=b7l9xNbUGLceeNlS2No53RKJmksUeIICDTCuYPXgyV0,2641
16
+ anaplan_sdk/_clients/_bulk.py,sha256=lZhsbw-Zqtrwofgzds7Lct1HnUfN5M4QDxWP-PocotE,30085
17
+ anaplan_sdk/_clients/_cloud_works.py,sha256=FsCp2wPxIoArAN1vcIfOI6ANNkK2ZebQ4MWJZB-nFJU,17466
18
+ anaplan_sdk/_clients/_cw_flow.py,sha256=O6t4utbDZdSVXGC0PXUcPpQ4oXrPohU9_8SUBCpxTXw,3824
19
+ anaplan_sdk/_clients/_transactional.py,sha256=SaHAnaGLZrhXmM8d6JnWWkwf-sVCEDW0nL2a4_wvjfk,17849
20
+ anaplan_sdk/models/__init__.py,sha256=zfwDQJQrXuLEXSpbJcm08a_YK1P7a7u-kMhwtJiJFmA,1783
21
+ anaplan_sdk/models/_alm.py,sha256=oeENd0YM7-LoIRBq2uATIQTxVgIP9rXx3UZE2UnQAp0,4670
22
+ anaplan_sdk/models/_base.py,sha256=6AZc9CfireUKgpZfMxYKu4MbwiyHQOsGLjKrxGXBLic,508
23
+ anaplan_sdk/models/_bulk.py,sha256=S72qujNr5STdiyKaCEvrQjKYHik_aemiJFNKE7docpI,8405
24
+ anaplan_sdk/models/_transactional.py,sha256=2bH10zvtMb5Lfh6DC7iQk72aEwq6tyLQ-XnH_0wYSqI,14172
25
+ anaplan_sdk/models/cloud_works.py,sha256=APUGDt_e-JshtXkba5cQh5rZkXOZBz0Aix0qVNdEWgw,19501
26
+ anaplan_sdk/models/flows.py,sha256=SuLgNj5-2SeE3U1i8iY8cq2IkjuUgd_3M1n2ENructk,3625
27
+ anaplan_sdk-0.5.0a4.dist-info/METADATA,sha256=GQk0AT6_rIKYU7o79QBL1ur0DKfYGZ4fqxKv_hqajsg,3678
28
+ anaplan_sdk-0.5.0a4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
29
+ anaplan_sdk-0.5.0a4.dist-info/licenses/LICENSE,sha256=HrhfyXIkWY2tGFK11kg7vPCqhgh5DcxleloqdhrpyMY,11558
30
+ anaplan_sdk-0.5.0a4.dist-info/RECORD,,
@@ -1,30 +0,0 @@
1
- anaplan_sdk/__init__.py,sha256=WScEKtXlnRLjCb-j3qW9W4kEACTyPsTLFs-L54et2TQ,351
2
- anaplan_sdk/_auth.py,sha256=l5z2WCcfQ05OkuQ1dcmikp6dB87Rw1qy2zu8bbaAQTs,16620
3
- anaplan_sdk/_oauth.py,sha256=AynlJDrGIinQT0jwxI2RSvtU4D7Wasyw3H1uicdlLVI,12672
4
- anaplan_sdk/_services.py,sha256=slsFFx_fCQSfKZo_G0HzEFGErvmEerSMyaVwLXUPCtU,17158
5
- anaplan_sdk/exceptions.py,sha256=ALkA9fBF0NQ7dufFxV6AivjmHyuJk9DOQ9jtJV2n7f0,1809
6
- anaplan_sdk/_async_clients/__init__.py,sha256=pZXgMMg4S9Aj_pxQCaSiPuNG-sePVGBtNJ0133VjqW4,364
7
- anaplan_sdk/_async_clients/_alm.py,sha256=rhVhykUo6wIvA1SBQkpEAviSsVLURumi_3XQlxTf7z8,12788
8
- anaplan_sdk/_async_clients/_audit.py,sha256=dipSzp4jMvRCHJAVMQfO854_wpmIcYEDinEPSGdoms4,2342
9
- anaplan_sdk/_async_clients/_bulk.py,sha256=sUDWT01JcN-IWc5RY0thcAA45k54x6lO-lN2WMoCrZE,27278
10
- anaplan_sdk/_async_clients/_cloud_works.py,sha256=ecm7DqT39J56xQwYxJMKd_ZVqxzXZdpmagwJUvqKBj4,17613
11
- anaplan_sdk/_async_clients/_cw_flow.py,sha256=PTi-jKeRtYgibqrcE7SCsEgus18nE7ZCs7Awzk4u6Dk,3981
12
- anaplan_sdk/_async_clients/_transactional.py,sha256=dRh4poYLWcV0kJyCG0MSFzJCzLW7fZ_1-j3c_Lx7zHY,17203
13
- anaplan_sdk/_clients/__init__.py,sha256=FsbwvZC1FHrxuRXwbPxUzbhz_lO1DpXIxEOjx6-3QuA,219
14
- anaplan_sdk/_clients/_alm.py,sha256=_LlZIRCE3HxZ4OzU13LOGnX4MQ26j2puSPTy9WGJa3o,12515
15
- anaplan_sdk/_clients/_audit.py,sha256=9mq7VGYsl6wOdIU7G3GvzP3O7r1ZDCFg5eAu7k4RgxM,2154
16
- anaplan_sdk/_clients/_bulk.py,sha256=Aw2cu8fAQ0lb4kc0kuYtVXr1BMuEry2yR7dlPR8CBEc,27330
17
- anaplan_sdk/_clients/_cloud_works.py,sha256=b7LpFcRbUxcN7_9_5GgVIlGf1X1ZmQWFZgnCQKaU55s,17405
18
- anaplan_sdk/_clients/_cw_flow.py,sha256=rwoQRdtxaigdBavr4LHtnrWsNpAgzt2zuIJOFV9ZE50,3870
19
- anaplan_sdk/_clients/_transactional.py,sha256=avqww59ccM3FqYMeK1oE-8UH4jyk_pKSCETzhSGKyxA,16936
20
- anaplan_sdk/models/__init__.py,sha256=zfwDQJQrXuLEXSpbJcm08a_YK1P7a7u-kMhwtJiJFmA,1783
21
- anaplan_sdk/models/_alm.py,sha256=oeENd0YM7-LoIRBq2uATIQTxVgIP9rXx3UZE2UnQAp0,4670
22
- anaplan_sdk/models/_base.py,sha256=6AZc9CfireUKgpZfMxYKu4MbwiyHQOsGLjKrxGXBLic,508
23
- anaplan_sdk/models/_bulk.py,sha256=S72qujNr5STdiyKaCEvrQjKYHik_aemiJFNKE7docpI,8405
24
- anaplan_sdk/models/_transactional.py,sha256=2bH10zvtMb5Lfh6DC7iQk72aEwq6tyLQ-XnH_0wYSqI,14172
25
- anaplan_sdk/models/cloud_works.py,sha256=nfn_LHPR-KmW7Tpvz-5qNCzmR8SYgvsVV-lx5iDlyqI,19425
26
- anaplan_sdk/models/flows.py,sha256=SuLgNj5-2SeE3U1i8iY8cq2IkjuUgd_3M1n2ENructk,3625
27
- anaplan_sdk-0.5.0a2.dist-info/METADATA,sha256=82p3Dx0kw_5MNBdZYtbNzsCC8s7GM6hnFqpdpan-Ggo,3669
28
- anaplan_sdk-0.5.0a2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
29
- anaplan_sdk-0.5.0a2.dist-info/licenses/LICENSE,sha256=HrhfyXIkWY2tGFK11kg7vPCqhgh5DcxleloqdhrpyMY,11558
30
- anaplan_sdk-0.5.0a2.dist-info/RECORD,,