anaplan-sdk 0.3.1__py3-none-any.whl → 0.4.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/_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,34 @@ 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 (
15
+ from .exceptions import (
20
16
  AnaplanException,
21
17
  AnaplanTimeoutException,
22
18
  InvalidIdentifierException,
23
19
  )
20
+ from .models import AnaplanModel
21
+ from .models.cloud_works import (
22
+ AmazonS3ConnectionInput,
23
+ AzureBlobConnectionInput,
24
+ ConnectionBody,
25
+ GoogleBigQueryConnectionInput,
26
+ IntegrationInput,
27
+ IntegrationProcessInput,
28
+ ScheduleInput,
29
+ )
24
30
 
25
31
  logger = logging.getLogger("anaplan_sdk")
26
32
 
33
+ _json_header = {"Content-Type": "application/json"}
34
+ _gzip_header = {"Content-Type": "application/x-gzip"}
35
+
36
+ T = TypeVar("T", bound=AnaplanModel)
37
+
27
38
 
28
39
  class _BaseClient:
29
40
  def __init__(self, retry_count: int, client: httpx.Client):
@@ -37,27 +48,34 @@ class _BaseClient:
37
48
  return self._run_with_retry(self._client.get, url).content
38
49
 
39
50
  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
51
+ return self._run_with_retry(self._client.post, url, headers=_json_header, json=json).json()
52
+
53
+ def _put(self, url: str, json: dict | list) -> dict[str, Any]:
54
+ return (self._run_with_retry(self._client.put, url, headers=_json_header, json=json)).json()
55
+
56
+ def _patch(self, url: str, json: dict | list) -> dict[str, Any]:
57
+ return (
58
+ self._run_with_retry(self._client.patch, url, headers=_json_header, json=json)
42
59
  ).json()
43
60
 
44
- def _post_empty(self, url: str) -> None:
45
- self._run_with_retry(self._client.post, url)
61
+ def _delete(self, url: str) -> dict[str, Any]:
62
+ return (self._run_with_retry(self._client.delete, url, headers=_json_header)).json()
63
+
64
+ def _post_empty(self, url: str) -> dict[str, Any]:
65
+ res = self._run_with_retry(self._client.post, url)
66
+ return res.json() if res.num_bytes_downloaded > 0 else {}
46
67
 
47
68
  def _put_binary_gzip(self, url: str, content: bytes) -> Response:
48
69
  return self._run_with_retry(
49
- self._client.put,
50
- url,
51
- headers={"Content-Type": "application/x-gzip"},
52
- content=compress(content),
70
+ self._client.put, url, headers=_gzip_header, content=compress(content)
53
71
  )
54
72
 
55
73
  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}
74
+ kwargs["params"] = kwargs.get("params") or {} | {"limit": limit, "offset": offset}
57
75
  return self._get(url, **kwargs).get(result_key, [])
58
76
 
59
77
  def __get_first_page(self, url: str, limit: int, result_key: str, **kwargs) -> tuple[list, int]:
60
- kwargs["params"] = kwargs.get("params", {}) | {"limit": limit}
78
+ kwargs["params"] = kwargs.get("params") or {} | {"limit": limit}
61
79
  res = self._get(url, **kwargs)
62
80
  return res.get(result_key, []), res["meta"]["paging"]["totalSize"]
63
81
 
@@ -111,32 +129,41 @@ class _AsyncBaseClient:
111
129
 
112
130
  async def _post(self, url: str, json: dict | list) -> dict[str, Any]:
113
131
  return (
114
- await self._run_with_retry(
115
- self._client.post, url, headers={"Content-Type": "application/json"}, json=json
116
- )
132
+ await self._run_with_retry(self._client.post, url, headers=_json_header, json=json)
133
+ ).json()
134
+
135
+ async def _put(self, url: str, json: dict | list) -> dict[str, Any]:
136
+ return (
137
+ await self._run_with_retry(self._client.put, url, headers=_json_header, json=json)
138
+ ).json()
139
+
140
+ async def _patch(self, url: str, json: dict | list) -> dict[str, Any]:
141
+ return (
142
+ await self._run_with_retry(self._client.patch, url, headers=_json_header, json=json)
117
143
  ).json()
118
144
 
119
- async def _post_empty(self, url: str) -> None:
120
- await self._run_with_retry(self._client.post, url)
145
+ async def _delete(self, url: str) -> dict[str, Any]:
146
+ return (await self._run_with_retry(self._client.delete, url, headers=_json_header)).json()
147
+
148
+ async def _post_empty(self, url: str) -> dict[str, Any]:
149
+ res = await self._run_with_retry(self._client.post, url)
150
+ return res.json() if res.num_bytes_downloaded > 0 else {}
121
151
 
122
152
  async def _put_binary_gzip(self, url: str, content: bytes) -> Response:
123
153
  return await self._run_with_retry(
124
- self._client.put,
125
- url,
126
- headers={"Content-Type": "application/x-gzip"},
127
- content=compress(content),
154
+ self._client.put, url, headers=_gzip_header, content=compress(content)
128
155
  )
129
156
 
130
157
  async def __get_page(
131
158
  self, url: str, limit: int, offset: int, result_key: str, **kwargs
132
159
  ) -> list:
133
- kwargs["params"] = kwargs.get("params", {}) | {"limit": limit, "offset": offset}
160
+ kwargs["params"] = kwargs.get("params") or {} | {"limit": limit, "offset": offset}
134
161
  return (await self._get(url, **kwargs)).get(result_key, [])
135
162
 
136
163
  async def __get_first_page(
137
164
  self, url: str, limit: int, result_key: str, **kwargs
138
165
  ) -> tuple[list, int]:
139
- kwargs["params"] = kwargs.get("params", {}) | {"limit": limit}
166
+ kwargs["params"] = kwargs.get("params") or {} | {"limit": limit}
140
167
  res = await self._get(url, **kwargs)
141
168
  return res.get(result_key, []), res["meta"]["paging"]["totalSize"]
142
169
 
@@ -178,6 +205,68 @@ class _AsyncBaseClient:
178
205
  raise AnaplanException("Exhausted all retries without a successful response or Error.")
179
206
 
180
207
 
208
+ def construct_payload(model: Type[T], body: T | dict[str, Any]) -> dict[str, Any]:
209
+ """
210
+ Construct a payload for the given model and body.
211
+ :param model: The model class to use for validation.
212
+ :param body: The body to validate and optionally convert to a dictionary.
213
+ :return: A dictionary representation of the validated body.
214
+ """
215
+ if isinstance(body, dict):
216
+ body = model.model_validate(body)
217
+ return body.model_dump(exclude_none=True, by_alias=True)
218
+
219
+
220
+ def connection_body_payload(body: ConnectionBody | dict[str, Any]) -> dict[str, Any]:
221
+ """
222
+ Construct a payload for the given integration body.
223
+ :param body: The body to validate and optionally convert to a dictionary.
224
+ :return: A dictionary representation of the validated body.
225
+ """
226
+ if isinstance(body, dict):
227
+ if "sasToken" in body:
228
+ body = AzureBlobConnectionInput.model_validate(body)
229
+ elif "secretAccessKey" in body:
230
+ body = AmazonS3ConnectionInput.model_validate(body)
231
+ else:
232
+ body = GoogleBigQueryConnectionInput.model_validate(body)
233
+ return body.model_dump(exclude_none=True, by_alias=True)
234
+
235
+
236
+ def integration_payload(
237
+ body: IntegrationInput | IntegrationProcessInput | dict[str, Any],
238
+ ) -> dict[str, Any]:
239
+ """
240
+ Construct a payload for the given integration body.
241
+ :param body: The body to validate and optionally convert to a dictionary.
242
+ :return: A dictionary representation of the validated body.
243
+ """
244
+ if isinstance(body, dict):
245
+ body = (
246
+ IntegrationInput.model_validate(body)
247
+ if "jobs" in body
248
+ else IntegrationProcessInput.model_validate(body)
249
+ )
250
+ return body.model_dump(exclude_none=True, by_alias=True)
251
+
252
+
253
+ def schedule_payload(
254
+ integration_id: str, schedule: ScheduleInput | dict[str, Any]
255
+ ) -> dict[str, Any]:
256
+ """
257
+ Construct a payload for the given integration ID and schedule.
258
+ :param integration_id: The ID of the integration.
259
+ :param schedule: The schedule to validate and optionally convert to a dictionary.
260
+ :return: A dictionary representation of the validated schedule.
261
+ """
262
+ if isinstance(schedule, dict):
263
+ schedule = ScheduleInput.model_validate(schedule)
264
+ return {
265
+ "integrationId": integration_id,
266
+ "schedule": schedule.model_dump(exclude_none=True, by_alias=True),
267
+ }
268
+
269
+
181
270
  def action_url(action_id: int) -> Literal["imports", "exports", "actions", "processes"]:
182
271
  """
183
272
  Determine the type of action based on its identifier.
@@ -10,7 +10,6 @@ warnings.filterwarnings("always", category=DeprecationWarning)
10
10
 
11
11
  class _AlmClient(_BaseClient):
12
12
  def __init__(self, client: httpx.Client, model_id: str, retry_count: int) -> None:
13
- self._client = client
14
13
  self._url = f"https://api.anaplan.com/2/0/models/{model_id}/alm"
15
14
  super().__init__(retry_count, client)
16
15
 
@@ -10,7 +10,6 @@ Event = Literal["all", "byok", "user_activity"]
10
10
 
11
11
  class _AuditClient(_BaseClient):
12
12
  def __init__(self, client: httpx.Client, retry_count: int, thread_count: int) -> None:
13
- self._client = client
14
13
  self._limit = 10_000
15
14
  self._thread_count = thread_count
16
15
  self._url = "https://audit.anaplan.com/audit/api/1/events"
@@ -26,6 +25,15 @@ class _AuditClient(_BaseClient):
26
25
  for e in self._get_paginated("https://api.anaplan.com/2/0/users", "users")
27
26
  ]
28
27
 
28
+ def get_user(self, user_id: str = "me") -> User:
29
+ """
30
+ Retrieves information about the specified user, or the authenticated user if none specified.
31
+ :return: The requested or currently authenticated User.
32
+ """
33
+ return User.model_validate(
34
+ self._get(f"https://api.anaplan.com/2/0/users/{user_id}").get("user")
35
+ )
36
+
29
37
  def get_events(self, days_into_past: int = 30, event_type: Event = "all") -> list:
30
38
  """
31
39
  Get audit events from Anaplan Audit API.
@@ -1,18 +1,14 @@
1
- """
2
- Synchronous Client.
3
- """
4
-
5
1
  import logging
6
2
  import multiprocessing
7
3
  import time
8
4
  from concurrent.futures import ThreadPoolExecutor
9
5
  from copy import copy
10
- from typing import Iterator
6
+ from typing import Callable, Iterator
11
7
 
12
8
  import httpx
13
9
  from typing_extensions import Self
14
10
 
15
- from anaplan_sdk._auth import AnaplanBasicAuth, AnaplanCertAuth, get_certificate, get_private_key
11
+ from anaplan_sdk._auth import create_auth
16
12
  from anaplan_sdk._base import _BaseClient, action_url
17
13
  from anaplan_sdk.exceptions import AnaplanActionError, InvalidIdentifierException
18
14
  from anaplan_sdk.models import (
@@ -29,6 +25,7 @@ from anaplan_sdk.models import (
29
25
 
30
26
  from ._alm import _AlmClient
31
27
  from ._audit import _AuditClient
28
+ from ._cloud_works import _CloudWorksClient
32
29
  from ._transactional import _TransactionalClient
33
30
 
34
31
  logging.getLogger("httpx").setLevel(logging.CRITICAL)
@@ -56,7 +53,13 @@ class Client(_BaseClient):
56
53
  certificate: str | bytes | None = None,
57
54
  private_key: str | bytes | None = None,
58
55
  private_key_password: str | bytes | None = None,
59
- timeout: float = 30,
56
+ client_id: str | None = None,
57
+ client_secret: str | None = None,
58
+ redirect_uri: str | None = None,
59
+ refresh_token: str | None = None,
60
+ oauth2_scope: str = "openid profile email offline_access",
61
+ on_token_refresh: Callable[[dict[str, str]], None] | None = None,
62
+ timeout: float | httpx.Timeout = 30,
60
63
  retry_count: int = 2,
61
64
  status_poll_delay: int = 1,
62
65
  upload_parallel: bool = True,
@@ -85,7 +88,19 @@ class Client(_BaseClient):
85
88
  itself.
86
89
  :param private_key: The absolute path to the private key file or the private key itself.
87
90
  :param private_key_password: The password to access the private key if there is one.
88
- :param timeout: The timeout in seconds for the HTTP requests.
91
+ :param client_id: The client Id of the Oauth2 Anaplan Client.
92
+ :param client_secret: The client secret for your Oauth2 Anaplan Client.
93
+ :param redirect_uri: The redirect URI for your Oauth2 Anaplan Client.
94
+ :param refresh_token: If you have a valid refresh token, you can pass it to skip the
95
+ interactive authentication code step.
96
+ :param oauth2_scope: The scope of the Oauth2 token, if you want to narrow it.
97
+ :param on_token_refresh: A callback function that is called whenever the token is refreshed.
98
+ With this you can for example securely store the token in your
99
+ application or on your server for later reuse. The function
100
+ must accept a single argument, which is the token dictionary
101
+ returned by the Oauth2 token endpoint.
102
+ :param timeout: The timeout in seconds for the HTTP requests. Alternatively, you can pass
103
+ an instance of `httpx.Timeout` to set the timeout for the HTTP requests.
89
104
  :param retry_count: The number of times to retry an HTTP request if it fails. Set this to 0
90
105
  to never retry. Defaults to 2, meaning each HTTP Operation will be
91
106
  tried a total number of 2 times.
@@ -102,36 +117,38 @@ class Client(_BaseClient):
102
117
  manually assigned so there is typically no value in dynamically
103
118
  creating new files and uploading content to them.
104
119
  """
105
- if not ((user_email and password) or (certificate and private_key)):
106
- raise ValueError(
107
- "Either `certificate` and `private_key` or `user_email` and `password` must be "
108
- "provided."
109
- )
110
- self._client = httpx.Client(
120
+ _client = httpx.Client(
111
121
  auth=(
112
- AnaplanCertAuth(
113
- get_certificate(certificate), get_private_key(private_key, private_key_password)
122
+ create_auth(
123
+ user_email=user_email,
124
+ password=password,
125
+ certificate=certificate,
126
+ private_key=private_key,
127
+ private_key_password=private_key_password,
128
+ client_id=client_id,
129
+ client_secret=client_secret,
130
+ redirect_uri=redirect_uri,
131
+ refresh_token=refresh_token,
132
+ oauth2_scope=oauth2_scope,
133
+ on_token_refresh=on_token_refresh,
114
134
  )
115
- if certificate
116
- else AnaplanBasicAuth(user_email=user_email, password=password)
117
135
  ),
118
136
  timeout=timeout,
119
137
  )
120
138
  self._retry_count = retry_count
121
139
  self._url = f"https://api.anaplan.com/2/0/workspaces/{workspace_id}/models/{model_id}"
122
140
  self._transactional_client = (
123
- _TransactionalClient(self._client, model_id, self._retry_count) if model_id else None
124
- )
125
- self._alm_client = (
126
- _AlmClient(self._client, model_id, self._retry_count) if model_id else None
141
+ _TransactionalClient(_client, model_id, self._retry_count) if model_id else None
127
142
  )
143
+ self._alm_client = _AlmClient(_client, model_id, self._retry_count) if model_id else None
144
+ self._cloud_works = _CloudWorksClient(_client, self._retry_count)
128
145
  self._thread_count = multiprocessing.cpu_count()
129
- self.audit = _AuditClient(self._client, self._retry_count, self._thread_count)
146
+ self._audit = _AuditClient(_client, self._retry_count, self._thread_count)
130
147
  self.status_poll_delay = status_poll_delay
131
148
  self.upload_parallel = upload_parallel
132
149
  self.upload_chunk_size = upload_chunk_size
133
150
  self.allow_file_creation = allow_file_creation
134
- super().__init__(self._retry_count, self._client)
151
+ super().__init__(self._retry_count, _client)
135
152
 
136
153
  @classmethod
137
154
  def from_existing(cls, existing: Self, workspace_id: str, model_id: str) -> Self:
@@ -153,6 +170,22 @@ class Client(_BaseClient):
153
170
  client._alm_client = _AlmClient(existing._client, model_id, existing._retry_count)
154
171
  return client
155
172
 
173
+ @property
174
+ def audit(self) -> _AuditClient:
175
+ """
176
+ The Audit Client provides access to the Anaplan Audit API.
177
+ For details, see https://vinzenzklass.github.io/anaplan-sdk/guides/audit/.
178
+ """
179
+ return self._audit
180
+
181
+ @property
182
+ def cw(self) -> _CloudWorksClient:
183
+ """
184
+ The Cloud Works Client provides access to the Anaplan Cloud Works API.
185
+ For details, see https://vinzenzklass.github.io/anaplan-sdk/guides/cloud_works/.
186
+ """
187
+ return self._cloud_works
188
+
156
189
  @property
157
190
  def transactional(self) -> _TransactionalClient:
158
191
  """