anaplan-sdk 0.3.1b1__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,80 @@
1
+ from typing import Any
2
+
3
+ import httpx
4
+
5
+ from anaplan_sdk._base import _AsyncBaseClient, construct_payload
6
+ from anaplan_sdk.models.flows import Flow, FlowInput, FlowSummary
7
+
8
+
9
+ class _AsyncFlowClient(_AsyncBaseClient):
10
+ def __init__(self, client: httpx.AsyncClient, retry_count: int) -> None:
11
+ self._url = "https://api.cloudworks.anaplan.com/2/0/integrationflows"
12
+ super().__init__(retry_count, client)
13
+
14
+ async def list_flows(self, current_user_only: bool = False) -> list[FlowSummary]:
15
+ """
16
+ List all flows in CloudWorks.
17
+ :param current_user_only: Filters the flows to only those created by the current user.
18
+ :return: A list of FlowSummaries.
19
+ """
20
+ params = {"myIntegrations": 1 if current_user_only else 0}
21
+ return [
22
+ FlowSummary.model_validate(e)
23
+ for e in await self._get_paginated(
24
+ self._url, "integrationFlows", page_size=25, params=params
25
+ )
26
+ ]
27
+
28
+ async def get_flow(self, flow_id: str) -> Flow:
29
+ """
30
+ Get a flow by its ID. This returns the full flow object, including the contained steps and
31
+ continuation behavior.
32
+ :param flow_id: The ID of the flow to get.
33
+ :return: The Flow object.
34
+ """
35
+ return Flow.model_validate((await self._get(f"{self._url}/{flow_id}"))["integrationFlow"])
36
+
37
+ async def run_flow(self, flow_id: str, only_steps: list[str] = None) -> str:
38
+ """
39
+ Run a flow by its ID. Make sure that neither the flow nor any of its contained are running.
40
+ If this is the case, the task will error. Anaplan neither schedules these tasks nor can it
41
+ handle concurrent executions.
42
+ :param flow_id: The ID of the flow to run.
43
+ :param only_steps: A list of step IDs to run. If not provided, only these will be run.
44
+ :return: The ID of the run.
45
+ """
46
+ url = f"{self._url}/{flow_id}/run"
47
+ res = await (
48
+ self._post(url, json={"stepsToRun": only_steps})
49
+ if only_steps
50
+ else self._post_empty(url)
51
+ )
52
+ return res["run"]["id"]
53
+
54
+ async def create_flow(self, flow: FlowInput | dict[str, Any]) -> str:
55
+ """
56
+ Create a new flow in CloudWorks. Be careful not to omit the `depends_on` field. Anaplan
57
+ will accept these values, but an invalid, corrupted flow will be created, as all Flows must
58
+ have at least 2 Steps, and they must always be sequential
59
+ :param flow: The flow to create. This can be a FlowInput object or a dictionary.
60
+ :return: The ID of the created flow.
61
+ """
62
+ res = await self._post(self._url, json=construct_payload(FlowInput, flow))
63
+ return res["integrationFlow"]["integrationFlowId"]
64
+
65
+ async def update_flow(self, flow_id: str, flow: FlowInput | dict[str, Any]) -> None:
66
+ """
67
+ Update a flow in CloudWorks. You must provide the full flow object, partial updates are not
68
+ supported.
69
+ :param flow_id: The ID of the flow to update.
70
+ :param flow: The flow to update. This can be a FlowInput object or a dictionary.
71
+ """
72
+ await self._put(f"{self._url}/{flow_id}", json=construct_payload(FlowInput, flow))
73
+
74
+ async def delete_flow(self, flow_id: str) -> None:
75
+ """
76
+ Delete a flow in CloudWorks. This will not delete its contained steps. This will fail if
77
+ the flow is running or if it has any running steps.
78
+ :param flow_id: The ID of the flow to delete.
79
+ """
80
+ await self._delete(f"{self._url}/{flow_id}")
@@ -1,4 +1,3 @@
1
- import warnings
2
1
  from asyncio import gather
3
2
  from itertools import chain
4
3
  from typing import Any
@@ -16,12 +15,9 @@ from anaplan_sdk.models import (
16
15
  Module,
17
16
  )
18
17
 
19
- warnings.filterwarnings("always", category=DeprecationWarning)
20
-
21
18
 
22
19
  class _AsyncTransactionalClient(_AsyncBaseClient):
23
20
  def __init__(self, client: httpx.AsyncClient, model_id: str, retry_count: int) -> None:
24
- self._client = client
25
21
  self._url = f"https://api.anaplan.com/2/0/models/{model_id}"
26
22
  super().__init__(retry_count, client)
27
23
 
@@ -96,7 +92,15 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
96
92
  """
97
93
  Insert new items to the given list. The items must be a list of dictionaries with at least
98
94
  the keys `code` and `name`. You can optionally pass further keys for parents, extra
99
- properties etc.
95
+ properties etc. If you pass a long list, it will be split into chunks of 100,000 items, the
96
+ maximum allowed by the API.
97
+
98
+ **Warning**: If one or some of the requests timeout during large batch operations, the
99
+ operation may actually complete on the server. Retries for these chunks will then report
100
+ these items as "ignored" rather than "added", leading to misleading results. The results in
101
+ Anaplan will be correct, but this function may report otherwise. Be generous with your
102
+ timeouts and retries if you are using this function for large batch operations.
103
+
100
104
  :param list_id: The ID of the List.
101
105
  :param items: The items to insert into the List.
102
106
  :return: The result of the insertion, indicating how many items were added,
@@ -127,7 +131,16 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
127
131
 
128
132
  async def delete_list_items(self, list_id: int, items: list[dict[str, str | int]]) -> int:
129
133
  """
130
- Deletes items from a List.
134
+ Deletes items from a List. If you pass a long list, it will be split into chunks of 100,000
135
+ items, the maximum allowed by the API.
136
+
137
+ **Warning**: If one or some of the requests timeout during large batch operations, the
138
+ operation may actually complete on the server. Retries for these chunks will then report
139
+ none of these items as deleted, since on the retry none are removed, leading to misleading
140
+ results. The results in Anaplan will be correct, but this function may report otherwise.
141
+ Be generous with your timeouts and retries if you are using this function for large batch
142
+ operations.
143
+
131
144
  :param list_id: The ID of the List.
132
145
  :param items: The items to delete from the List. Must be a dict with either `code` or `id`
133
146
  as the keys to identify the records to delete.
@@ -162,33 +175,16 @@ class _AsyncTransactionalClient(_AsyncBaseClient):
162
175
  """
163
176
  Write the passed items to the specified module. If successful, the number of cells changed
164
177
  is returned, if only partially successful or unsuccessful, the response with the according
165
- details is returned instead. For more details,
166
- see: https://anaplan.docs.apiary.io/#UpdateModuleCellData.
178
+ details is returned instead.
179
+
180
+ **You can update a maximum of 100,000 cells or 15 MB of data (whichever is lower) in a
181
+ single request.** You must chunk your data accordingly. This is not done by this SDK,
182
+ since it is discouraged. For larger imports, you should use the Bulk API instead.
183
+
184
+ For more details see: https://anaplan.docs.apiary.io/#UpdateModuleCellData.
167
185
  :param module_id: The ID of the Module.
168
186
  :param data: The data to write to the Module.
169
187
  :return: The number of cells changed or the response with the according error details.
170
188
  """
171
189
  res = await self._post(f"{self._url}/modules/{module_id}/data", json=data)
172
190
  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)
anaplan_sdk/_auth.py CHANGED
@@ -1,17 +1,9 @@
1
- """
2
- Custom Authentication class to pass to httpx alongside some helper functions.
3
- """
4
-
5
1
  import logging
6
2
  import os
7
3
  from base64 import b64encode
4
+ from typing import Callable
8
5
 
9
6
  import httpx
10
- from cryptography.exceptions import InvalidKey, UnsupportedAlgorithm
11
- from cryptography.hazmat.backends import default_backend
12
- from cryptography.hazmat.primitives import hashes, serialization
13
- from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
14
- from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
15
7
 
16
8
  from .exceptions import AnaplanException, InvalidCredentialsException, InvalidPrivateKeyException
17
9
 
@@ -21,12 +13,12 @@ logger = logging.getLogger("anaplan_sdk")
21
13
  class _AnaplanAuth(httpx.Auth):
22
14
  requires_response_body = True
23
15
 
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)
16
+ def __init__(self, pre_authed: bool = False):
17
+ if not pre_authed:
18
+ logger.info("Creating Authentication Token.")
19
+ with httpx.Client(timeout=15.0) as client:
20
+ res = client.send(self._build_auth_request())
21
+ self._parse_auth_response(res)
30
22
 
31
23
  def _build_auth_request(self) -> httpx.Request:
32
24
  raise NotImplementedError("Must be implemented in subclass.")
@@ -36,7 +28,7 @@ class _AnaplanAuth(httpx.Auth):
36
28
  response = yield request
37
29
  if response.status_code == 401:
38
30
  logger.info("Token expired, refreshing.")
39
- auth_res = yield self._auth_request
31
+ auth_res = yield self._build_auth_request()
40
32
  self._parse_auth_response(auth_res)
41
33
  request.headers["Authorization"] = f"AnaplanAuthToken {self._token}"
42
34
  yield request
@@ -67,9 +59,14 @@ class AnaplanBasicAuth(_AnaplanAuth):
67
59
  class AnaplanCertAuth(_AnaplanAuth):
68
60
  requires_request_body = True
69
61
 
70
- def __init__(self, certificate: bytes, private_key: RSAPrivateKey):
71
- self._certificate = certificate
72
- self._private_key = private_key
62
+ def __init__(
63
+ self,
64
+ certificate: str | bytes,
65
+ private_key: str | bytes,
66
+ private_key_password: str | bytes | None = None,
67
+ ):
68
+ self.__set_certificate(certificate)
69
+ self.__set_private_key(private_key, private_key_password)
73
70
  super().__init__()
74
71
 
75
72
  def _build_auth_request(self) -> httpx.Request:
@@ -85,6 +82,9 @@ class AnaplanCertAuth(_AnaplanAuth):
85
82
  )
86
83
 
87
84
  def _prep_credentials(self) -> tuple[str, str, str]:
85
+ from cryptography.hazmat.primitives import hashes
86
+ from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
87
+
88
88
  message = os.urandom(150)
89
89
  return (
90
90
  b64encode(self._certificate).decode(),
@@ -92,44 +92,157 @@ class AnaplanCertAuth(_AnaplanAuth):
92
92
  b64encode(self._private_key.sign(message, PKCS1v15(), hashes.SHA512())).decode(),
93
93
  )
94
94
 
95
-
96
- def get_certificate(certificate: str | bytes) -> bytes:
97
- """
98
- Get the certificate from a file or string.
99
- :param certificate: The certificate to use.
100
- :return: The certificate as bytes.
101
- """
102
- if isinstance(certificate, str):
103
- if os.path.isfile(certificate):
104
- with open(certificate, "rb") as f:
105
- return f.read()
106
- return certificate.encode()
107
- return certificate
108
-
109
-
110
- def get_private_key(private_key: str | bytes, private_key_password: str | bytes) -> RSAPrivateKey:
111
- """
112
- Get the private key from a file or string.
113
- :param private_key: The private key to use.
114
- :param private_key_password: The password for the private key.
115
- :return: The private key as an RSAPrivateKey object.
116
- """
117
- try:
118
- if isinstance(private_key, str):
119
- if os.path.isfile(private_key):
120
- with open(private_key, "rb") as f:
121
- data = f.read()
95
+ def __set_certificate(self, certificate: str | bytes) -> None:
96
+ if isinstance(certificate, str):
97
+ if os.path.isfile(certificate):
98
+ with open(certificate, "rb") as f:
99
+ self._certificate = f.read()
122
100
  else:
123
- data = private_key.encode()
101
+ self._certificate = certificate.encode()
124
102
  else:
125
- data = private_key
126
-
127
- password = None
128
- if private_key_password:
129
- if isinstance(private_key_password, str):
130
- password = private_key_password.encode()
103
+ self._certificate = certificate
104
+
105
+ def __set_private_key(
106
+ self, private_key: str | bytes, private_key_password: str | bytes
107
+ ) -> None:
108
+ try:
109
+ from cryptography.exceptions import InvalidKey, UnsupportedAlgorithm
110
+ from cryptography.hazmat.backends import default_backend
111
+ from cryptography.hazmat.primitives import serialization
112
+ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
113
+ except ImportError as e:
114
+ raise AnaplanException(
115
+ "cryptography is not available. Please install anaplan-sdk with the cert extra "
116
+ "`pip install anaplan-sdk[cert]` or install cryptography separately."
117
+ ) from e
118
+ try:
119
+ if isinstance(private_key, str):
120
+ if os.path.isfile(private_key):
121
+ with open(private_key, "rb") as f:
122
+ data = f.read()
123
+ else:
124
+ data = private_key.encode()
131
125
  else:
132
- password = private_key_password
133
- return serialization.load_pem_private_key(data, password, backend=default_backend())
134
- except (IOError, InvalidKey, UnsupportedAlgorithm) as error:
135
- raise InvalidPrivateKeyException from error
126
+ data = private_key
127
+ password = (
128
+ private_key_password.encode()
129
+ if isinstance(private_key_password, str)
130
+ else private_key_password
131
+ )
132
+ self._private_key: RSAPrivateKey = serialization.load_pem_private_key(
133
+ data, password, backend=default_backend()
134
+ )
135
+ except (IOError, InvalidKey, UnsupportedAlgorithm) as error:
136
+ raise InvalidPrivateKeyException from error
137
+
138
+
139
+ class AnaplanOauth2AuthCodeAuth(_AnaplanAuth):
140
+ def __init__(
141
+ self,
142
+ client_id: str,
143
+ client_secret: str,
144
+ redirect_uri: str,
145
+ refresh_token: str | None = None,
146
+ scope: str = "openid profile email offline_access",
147
+ on_token_refresh: Callable[[dict[str, str]], None] | None = None,
148
+ ):
149
+ try:
150
+ from oauthlib.oauth2 import WebApplicationClient
151
+ except ImportError as e:
152
+ raise AnaplanException(
153
+ "oauthlib is not available. Please install anaplan-sdk with the oauth extra "
154
+ "`pip install anaplan-sdk[oauth]` or install oauthlib separately."
155
+ ) from e
156
+ self._oauth = WebApplicationClient(
157
+ client_id=client_id, client_secret=client_secret, refresh_token=refresh_token
158
+ )
159
+ self._token_url = "https://us1a.app.anaplan.com/oauth/token"
160
+ self._client_id = client_id
161
+ self._client_secret = client_secret
162
+ self._redirect_uri = redirect_uri
163
+ self._refresh_token = refresh_token
164
+ self._scope = scope
165
+ self._id_token = None
166
+ self._on_token_refresh = on_token_refresh
167
+ if not refresh_token:
168
+ self.__auth_code_flow()
169
+ super().__init__(pre_authed=not refresh_token)
170
+
171
+ def _build_auth_request(self) -> httpx.Request:
172
+ url, headers, body = self._oauth.prepare_refresh_token_request(
173
+ token_url=self._token_url,
174
+ refresh_token=self._refresh_token,
175
+ client_secret=self._client_secret,
176
+ client_id=self._client_id,
177
+ )
178
+ return httpx.Request(method="post", url=url, headers=headers, content=body)
179
+
180
+ def _parse_auth_response(self, response: httpx.Response) -> None:
181
+ if response.status_code == 401:
182
+ raise InvalidCredentialsException
183
+ if not response.is_success:
184
+ raise AnaplanException(f"Authentication failed: {response.status_code} {response.text}")
185
+ token = response.json()
186
+ self._token = token["access_token"]
187
+ self._refresh_token = token["refresh_token"]
188
+ if self._on_token_refresh:
189
+ self._on_token_refresh(token)
190
+ self._id_token = token.get("id_token")
191
+
192
+ def __auth_code_flow(self):
193
+ from oauthlib.oauth2 import OAuth2Error
194
+
195
+ try:
196
+ logger.info("Creating Authentication Token with OAuth2 Authorization Code Flow.")
197
+ url, _, _ = self._oauth.prepare_authorization_request(
198
+ "https://us1a.app.anaplan.com/auth/authorize",
199
+ redirect_url=self._redirect_uri,
200
+ scope=self._scope,
201
+ )
202
+ authorization_response = input(
203
+ f"Please go to {url} and authorize the app.\n"
204
+ "Then paste the entire redirect URL here: "
205
+ )
206
+ url, headers, body = self._oauth.prepare_token_request(
207
+ token_url=self._token_url,
208
+ redirect_url=self._redirect_uri,
209
+ authorization_response=authorization_response,
210
+ client_secret=self._client_secret,
211
+ )
212
+ self._parse_auth_response(httpx.post(url=url, headers=headers, content=body))
213
+ except (httpx.HTTPError, ValueError, TypeError, OAuth2Error) as error:
214
+ raise AnaplanException("Error during OAuth2 authorization flow.") from error
215
+
216
+
217
+ def create_auth(
218
+ user_email: str | None = None,
219
+ password: str | None = None,
220
+ certificate: str | bytes | None = None,
221
+ private_key: str | bytes | None = None,
222
+ private_key_password: str | bytes | None = None,
223
+ client_id: str | None = None,
224
+ client_secret: str | None = None,
225
+ redirect_uri: str | None = None,
226
+ refresh_token: str | None = None,
227
+ oauth2_scope: str = "openid profile email offline_access",
228
+ on_token_refresh: Callable[[dict[str, str]], None] | None = None,
229
+ ) -> _AnaplanAuth:
230
+ if certificate and private_key:
231
+ return AnaplanCertAuth(certificate, private_key, private_key_password)
232
+ if user_email and password:
233
+ return AnaplanBasicAuth(user_email=user_email, password=password)
234
+ if client_id and client_secret and redirect_uri:
235
+ return AnaplanOauth2AuthCodeAuth(
236
+ client_id=client_id,
237
+ client_secret=client_secret,
238
+ redirect_uri=redirect_uri,
239
+ refresh_token=refresh_token,
240
+ scope=oauth2_scope,
241
+ on_token_refresh=on_token_refresh,
242
+ )
243
+ raise ValueError(
244
+ "No valid authentication parameters provided. Please provide either:\n"
245
+ "- user_email and password, or\n"
246
+ "- certificate and private_key, or\n"
247
+ "- client_id, client_secret, and redirect_uri"
248
+ )
anaplan_sdk/_base.py CHANGED
@@ -1,7 +1,3 @@
1
- """
2
- Provides Base Classes for this project.
3
- """
4
-
5
1
  import asyncio
6
2
  import logging
7
3
  import random
@@ -11,19 +7,30 @@ from concurrent.futures import ThreadPoolExecutor
11
7
  from gzip import compress
12
8
  from itertools import chain
13
9
  from math import ceil
14
- from typing import Any, Callable, Coroutine, Iterator, Literal
10
+ from typing import Any, Callable, Coroutine, Iterator, Literal, Type, TypeVar
15
11
 
16
12
  import httpx
17
13
  from httpx import HTTPError, Response
18
14
 
19
- from anaplan_sdk.exceptions import (
20
- AnaplanException,
21
- AnaplanTimeoutException,
22
- InvalidIdentifierException,
15
+ from .exceptions import AnaplanException, AnaplanTimeoutException, InvalidIdentifierException
16
+ from .models import AnaplanModel
17
+ from .models.cloud_works import (
18
+ AmazonS3ConnectionInput,
19
+ AzureBlobConnectionInput,
20
+ ConnectionBody,
21
+ GoogleBigQueryConnectionInput,
22
+ IntegrationInput,
23
+ IntegrationProcessInput,
24
+ ScheduleInput,
23
25
  )
24
26
 
25
27
  logger = logging.getLogger("anaplan_sdk")
26
28
 
29
+ _json_header = {"Content-Type": "application/json"}
30
+ _gzip_header = {"Content-Type": "application/x-gzip"}
31
+
32
+ T = TypeVar("T", bound=AnaplanModel)
33
+
27
34
 
28
35
  class _BaseClient:
29
36
  def __init__(self, retry_count: int, client: httpx.Client):
@@ -37,27 +44,34 @@ class _BaseClient:
37
44
  return self._run_with_retry(self._client.get, url).content
38
45
 
39
46
  def _post(self, url: str, json: dict | list) -> dict[str, Any]:
40
- return self._run_with_retry(
41
- self._client.post, url, headers={"Content-Type": "application/json"}, json=json
47
+ return self._run_with_retry(self._client.post, url, headers=_json_header, json=json).json()
48
+
49
+ def _put(self, url: str, json: dict | list) -> dict[str, Any]:
50
+ return (self._run_with_retry(self._client.put, url, headers=_json_header, json=json)).json()
51
+
52
+ def _patch(self, url: str, json: dict | list) -> dict[str, Any]:
53
+ return (
54
+ self._run_with_retry(self._client.patch, url, headers=_json_header, json=json)
42
55
  ).json()
43
56
 
44
- def _post_empty(self, url: str) -> None:
45
- self._run_with_retry(self._client.post, url)
57
+ def _delete(self, url: str) -> dict[str, Any]:
58
+ return (self._run_with_retry(self._client.delete, url, headers=_json_header)).json()
59
+
60
+ def _post_empty(self, url: str) -> dict[str, Any]:
61
+ res = self._run_with_retry(self._client.post, url)
62
+ return res.json() if res.num_bytes_downloaded > 0 else {}
46
63
 
47
64
  def _put_binary_gzip(self, url: str, content: bytes) -> Response:
48
65
  return self._run_with_retry(
49
- self._client.put,
50
- url,
51
- headers={"Content-Type": "application/x-gzip"},
52
- content=compress(content),
66
+ self._client.put, url, headers=_gzip_header, content=compress(content)
53
67
  )
54
68
 
55
69
  def __get_page(self, url: str, limit: int, offset: int, result_key: str, **kwargs) -> list:
56
- kwargs["params"] = kwargs.get("params", {}) | {"limit": limit, "offset": offset}
70
+ kwargs["params"] = kwargs.get("params") or {} | {"limit": limit, "offset": offset}
57
71
  return self._get(url, **kwargs).get(result_key, [])
58
72
 
59
73
  def __get_first_page(self, url: str, limit: int, result_key: str, **kwargs) -> tuple[list, int]:
60
- kwargs["params"] = kwargs.get("params", {}) | {"limit": limit}
74
+ kwargs["params"] = kwargs.get("params") or {} | {"limit": limit}
61
75
  res = self._get(url, **kwargs)
62
76
  return res.get(result_key, []), res["meta"]["paging"]["totalSize"]
63
77
 
@@ -111,32 +125,41 @@ class _AsyncBaseClient:
111
125
 
112
126
  async def _post(self, url: str, json: dict | list) -> dict[str, Any]:
113
127
  return (
114
- await self._run_with_retry(
115
- self._client.post, url, headers={"Content-Type": "application/json"}, json=json
116
- )
128
+ await self._run_with_retry(self._client.post, url, headers=_json_header, json=json)
129
+ ).json()
130
+
131
+ async def _put(self, url: str, json: dict | list) -> dict[str, Any]:
132
+ return (
133
+ await self._run_with_retry(self._client.put, url, headers=_json_header, json=json)
117
134
  ).json()
118
135
 
119
- async def _post_empty(self, url: str) -> None:
120
- await self._run_with_retry(self._client.post, url)
136
+ async def _patch(self, url: str, json: dict | list) -> dict[str, Any]:
137
+ return (
138
+ await self._run_with_retry(self._client.patch, url, headers=_json_header, json=json)
139
+ ).json()
140
+
141
+ async def _delete(self, url: str) -> dict[str, Any]:
142
+ return (await self._run_with_retry(self._client.delete, url, headers=_json_header)).json()
143
+
144
+ async def _post_empty(self, url: str) -> dict[str, Any]:
145
+ res = await self._run_with_retry(self._client.post, url)
146
+ return res.json() if res.num_bytes_downloaded > 0 else {}
121
147
 
122
148
  async def _put_binary_gzip(self, url: str, content: bytes) -> Response:
123
149
  return await self._run_with_retry(
124
- self._client.put,
125
- url,
126
- headers={"Content-Type": "application/x-gzip"},
127
- content=compress(content),
150
+ self._client.put, url, headers=_gzip_header, content=compress(content)
128
151
  )
129
152
 
130
153
  async def __get_page(
131
154
  self, url: str, limit: int, offset: int, result_key: str, **kwargs
132
155
  ) -> list:
133
- kwargs["params"] = kwargs.get("params", {}) | {"limit": limit, "offset": offset}
156
+ kwargs["params"] = kwargs.get("params") or {} | {"limit": limit, "offset": offset}
134
157
  return (await self._get(url, **kwargs)).get(result_key, [])
135
158
 
136
159
  async def __get_first_page(
137
160
  self, url: str, limit: int, result_key: str, **kwargs
138
161
  ) -> tuple[list, int]:
139
- kwargs["params"] = kwargs.get("params", {}) | {"limit": limit}
162
+ kwargs["params"] = kwargs.get("params") or {} | {"limit": limit}
140
163
  res = await self._get(url, **kwargs)
141
164
  return res.get(result_key, []), res["meta"]["paging"]["totalSize"]
142
165
 
@@ -178,6 +201,68 @@ class _AsyncBaseClient:
178
201
  raise AnaplanException("Exhausted all retries without a successful response or Error.")
179
202
 
180
203
 
204
+ def construct_payload(model: Type[T], body: T | dict[str, Any]) -> dict[str, Any]:
205
+ """
206
+ Construct a payload for the given model and body.
207
+ :param model: The model class to use for validation.
208
+ :param body: The body to validate and optionally convert to a dictionary.
209
+ :return: A dictionary representation of the validated body.
210
+ """
211
+ if isinstance(body, dict):
212
+ body = model.model_validate(body)
213
+ return body.model_dump(exclude_none=True, by_alias=True)
214
+
215
+
216
+ def connection_body_payload(body: ConnectionBody | dict[str, Any]) -> dict[str, Any]:
217
+ """
218
+ Construct a payload for the given integration body.
219
+ :param body: The body to validate and optionally convert to a dictionary.
220
+ :return: A dictionary representation of the validated body.
221
+ """
222
+ if isinstance(body, dict):
223
+ if "sasToken" in body:
224
+ body = AzureBlobConnectionInput.model_validate(body)
225
+ elif "secretAccessKey" in body:
226
+ body = AmazonS3ConnectionInput.model_validate(body)
227
+ else:
228
+ body = GoogleBigQueryConnectionInput.model_validate(body)
229
+ return body.model_dump(exclude_none=True, by_alias=True)
230
+
231
+
232
+ def integration_payload(
233
+ body: IntegrationInput | IntegrationProcessInput | dict[str, Any],
234
+ ) -> dict[str, Any]:
235
+ """
236
+ Construct a payload for the given integration body.
237
+ :param body: The body to validate and optionally convert to a dictionary.
238
+ :return: A dictionary representation of the validated body.
239
+ """
240
+ if isinstance(body, dict):
241
+ body = (
242
+ IntegrationInput.model_validate(body)
243
+ if "jobs" in body
244
+ else IntegrationProcessInput.model_validate(body)
245
+ )
246
+ return body.model_dump(exclude_none=True, by_alias=True)
247
+
248
+
249
+ def schedule_payload(
250
+ integration_id: str, schedule: ScheduleInput | dict[str, Any]
251
+ ) -> dict[str, Any]:
252
+ """
253
+ Construct a payload for the given integration ID and schedule.
254
+ :param integration_id: The ID of the integration.
255
+ :param schedule: The schedule to validate and optionally convert to a dictionary.
256
+ :return: A dictionary representation of the validated schedule.
257
+ """
258
+ if isinstance(schedule, dict):
259
+ schedule = ScheduleInput.model_validate(schedule)
260
+ return {
261
+ "integrationId": integration_id,
262
+ "schedule": schedule.model_dump(exclude_none=True, by_alias=True),
263
+ }
264
+
265
+
181
266
  def action_url(action_id: int) -> Literal["imports", "exports", "actions", "processes"]:
182
267
  """
183
268
  Determine the type of action based on its identifier.