anaplan-sdk 0.4.3a2__tar.gz → 0.4.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/PKG-INFO +5 -2
  2. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/README.md +2 -1
  3. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/__init__.py +2 -2
  4. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/_auth.py +89 -21
  5. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/_oauth.py +12 -12
  6. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/models/_bulk.py +2 -2
  7. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/guides/authentication.md +98 -15
  8. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/guides/transactional.md +2 -2
  9. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/pyproject.toml +103 -101
  10. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/uv.lock +1294 -1164
  11. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/.github/dependabot.yml +0 -0
  12. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/.github/workflows/docs.yml +0 -0
  13. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/.github/workflows/lint.yml +0 -0
  14. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/.github/workflows/tests.yml +0 -0
  15. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/.gitignore +0 -0
  16. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/.pre-commit-config.yaml +0 -0
  17. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/LICENSE +0 -0
  18. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/_async_clients/__init__.py +0 -0
  19. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/_async_clients/_alm.py +0 -0
  20. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/_async_clients/_audit.py +0 -0
  21. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/_async_clients/_bulk.py +0 -0
  22. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/_async_clients/_cloud_works.py +0 -0
  23. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/_async_clients/_cw_flow.py +0 -0
  24. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/_async_clients/_transactional.py +0 -0
  25. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/_base.py +0 -0
  26. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/_clients/__init__.py +0 -0
  27. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/_clients/_alm.py +0 -0
  28. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/_clients/_audit.py +0 -0
  29. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/_clients/_bulk.py +0 -0
  30. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/_clients/_cloud_works.py +0 -0
  31. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/_clients/_cw_flow.py +0 -0
  32. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/_clients/_transactional.py +0 -0
  33. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/exceptions.py +0 -0
  34. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/models/__init__.py +0 -0
  35. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/models/_alm.py +0 -0
  36. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/models/_base.py +0 -0
  37. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/models/_transactional.py +0 -0
  38. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/models/cloud_works.py +0 -0
  39. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/anaplan_sdk/models/flows.py +0 -0
  40. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/anaplan_explained.md +0 -0
  41. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/api/async/async_alm_client.md +0 -0
  42. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/api/async/async_audit_client.md +0 -0
  43. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/api/async/async_client.md +0 -0
  44. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/api/async/async_cw_client.md +0 -0
  45. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/api/async/async_flows_client.md +0 -0
  46. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/api/async/async_oauth_client.md +0 -0
  47. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/api/async/async_transactional_client.md +0 -0
  48. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/api/exceptions.md +0 -0
  49. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/api/models/alm.md +0 -0
  50. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/api/models/bulk.md +0 -0
  51. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/api/models/cloud_works.md +0 -0
  52. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/api/models/flows.md +0 -0
  53. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/api/models/transactional.md +0 -0
  54. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/api/sync/sync_alm_client.md +0 -0
  55. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/api/sync/sync_audit_client.md +0 -0
  56. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/api/sync/sync_client.md +0 -0
  57. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/api/sync/sync_cw_client.md +0 -0
  58. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/api/sync/sync_flows_client.md +0 -0
  59. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/api/sync/sync_oauth_client.md +0 -0
  60. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/api/sync/sync_transactional_client.md +0 -0
  61. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/assets/overview.html +0 -0
  62. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/css/styles.css +0 -0
  63. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/guides/alm.md +0 -0
  64. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/guides/audit.md +0 -0
  65. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/guides/bulk.md +0 -0
  66. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/guides/bulk_vs_transactional.md +0 -0
  67. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/guides/cloud_works.md +0 -0
  68. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/guides/index.md +0 -0
  69. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/guides/logging.md +0 -0
  70. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/guides/multiple_models.md +0 -0
  71. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/img/anaplan-sdk.webp +0 -0
  72. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/index.md +0 -0
  73. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/installation.md +0 -0
  74. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/js/assets/hljs.js +0 -0
  75. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/js/assets/hljs.min.js +0 -0
  76. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/js/assets/python.js +0 -0
  77. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/js/assets/python.min.js +0 -0
  78. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/js/highlight.js +0 -0
  79. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/js/highlight.min.js +0 -0
  80. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/docs/quickstart.md +0 -0
  81. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/mkdocs.yml +0 -0
  82. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/tests/async/conftest.py +0 -0
  83. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/tests/async/test_async_alm_client.py +0 -0
  84. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/tests/async/test_async_audit_client.py +0 -0
  85. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/tests/async/test_async_client.py +0 -0
  86. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/tests/async/test_async_cloud_works_client.py +0 -0
  87. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/tests/async/test_async_flows_client.py +0 -0
  88. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/tests/async/test_async_transactional_client.py +0 -0
  89. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/tests/conftest.py +0 -0
  90. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/tests/sync/conftest.py +0 -0
  91. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/tests/sync/test_alm_client.py +0 -0
  92. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/tests/sync/test_audit_client.py +0 -0
  93. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/tests/sync/test_client.py +0 -0
  94. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/tests/sync/test_cloud_works_client.py +0 -0
  95. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/tests/sync/test_flows_client.py +0 -0
  96. {anaplan_sdk-0.4.3a2 → anaplan_sdk-0.4.4}/tests/sync/test_transactional_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anaplan-sdk
3
- Version: 0.4.3a2
3
+ Version: 0.4.4
4
4
  Summary: Streamlined Python Interface for Anaplan
5
5
  Project-URL: Homepage, https://vinzenzklass.github.io/anaplan-sdk/
6
6
  Project-URL: Repository, https://github.com/VinzenzKlass/anaplan-sdk
@@ -14,6 +14,8 @@ Requires-Dist: httpx<1.0.0,>=0.27.0
14
14
  Requires-Dist: pydantic<3.0.0,>=2.7.2
15
15
  Provides-Extra: cert
16
16
  Requires-Dist: cryptography<46.0.0,>=42.0.7; extra == 'cert'
17
+ Provides-Extra: keyring
18
+ Requires-Dist: keyring<26.0.0,>=25.6.0; extra == 'keyring'
17
19
  Provides-Extra: oauth
18
20
  Requires-Dist: oauthlib<4.0.0,>=3.0.0; extra == 'oauth'
19
21
  Description-Content-Type: text/markdown
@@ -58,7 +60,8 @@ abstractions over all Anaplan APIs, allowing you to focus on business requiremen
58
60
 
59
61
  ## Getting Started
60
62
 
61
- Head over to the [Quick Start](quickstart.md) for basic usage instructions and examples.
63
+ Head over to the [Quick Start](https://vinzenzklass.github.io/anaplan-sdk/quickstart/) for basic usage instructions and
64
+ examples.
62
65
 
63
66
  ## Contributing
64
67
 
@@ -38,7 +38,8 @@ abstractions over all Anaplan APIs, allowing you to focus on business requiremen
38
38
 
39
39
  ## Getting Started
40
40
 
41
- Head over to the [Quick Start](quickstart.md) for basic usage instructions and examples.
41
+ Head over to the [Quick Start](https://vinzenzklass.github.io/anaplan-sdk/quickstart/) for basic usage instructions and
42
+ examples.
42
43
 
43
44
  ## Contributing
44
45
 
@@ -1,12 +1,12 @@
1
1
  from ._async_clients import AsyncClient
2
- from ._auth import AnaplanOAuthCodeAuth, AnaplanRefreshTokenAuth
2
+ from ._auth import AnaplanLocalOAuth, AnaplanRefreshTokenAuth
3
3
  from ._clients import Client
4
4
  from ._oauth import AsyncOauth, Oauth
5
5
 
6
6
  __all__ = [
7
7
  "AsyncClient",
8
8
  "Client",
9
- "AnaplanOAuthCodeAuth",
9
+ "AnaplanLocalOAuth",
10
10
  "AnaplanRefreshTokenAuth",
11
11
  "AsyncOauth",
12
12
  "Oauth",
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
  import os
3
3
  from base64 import b64encode
4
- from typing import Awaitable, Callable
4
+ from typing import Callable
5
5
 
6
6
  import httpx
7
7
 
@@ -10,11 +10,6 @@ from .exceptions import AnaplanException, InvalidCredentialsException, InvalidPr
10
10
 
11
11
  logger = logging.getLogger("anaplan_sdk")
12
12
 
13
- AuthCodeCallback = (Callable[[str], str] | Callable[[str], Awaitable[str]]) | None
14
- AuthTokenRefreshCallback = (
15
- Callable[[dict[str, str]], None] | Callable[[dict[str, str]], Awaitable[None]]
16
- ) | None
17
-
18
13
 
19
14
  class _AnaplanAuth(httpx.Auth):
20
15
  requires_response_body = True
@@ -154,13 +149,14 @@ class _AnaplanCertAuth(_AnaplanAuth):
154
149
  raise InvalidPrivateKeyException from error
155
150
 
156
151
 
157
- class AnaplanOAuthCodeAuth(_AnaplanAuth):
152
+ class AnaplanLocalOAuth(_AnaplanAuth):
158
153
  def __init__(
159
154
  self,
160
155
  client_id: str,
161
156
  client_secret: str,
162
- redirect_url: str,
157
+ redirect_uri: str,
163
158
  token: dict[str, str] | None = None,
159
+ persist_token: bool = False,
164
160
  authorization_url: str = "https://us1a.app.anaplan.com/auth/prelogin",
165
161
  token_url: str = "https://us1a.app.anaplan.com/oauth/token",
166
162
  validation_url: str = "https://auth.anaplan.com/token/validate",
@@ -168,15 +164,24 @@ class AnaplanOAuthCodeAuth(_AnaplanAuth):
168
164
  state_generator: Callable[[], str] | None = None,
169
165
  ):
170
166
  """
171
- Initializes the AnaplanOAuthCodeAuth class for OAuth2 authentication using the
167
+ Initializes the AnaplanLocalOAuth class for OAuth2 authentication using the
172
168
  Authorization Code Flow. This is a utility class for local development and requires user
173
169
  interaction. For Web Applications and other scenarios, refer to `Oauth` or `AsyncOauth`.
174
170
  This class will refresh the access token automatically when it expires.
175
171
  :param client_id: The client ID of your Anaplan Oauth 2.0 application. This Application
176
172
  must be an Authorization Code Grant application.
177
173
  :param client_secret: The client secret of your Anaplan Oauth 2.0 application.
178
- :param redirect_url: The URL to which the user will be redirected after authorizing the
174
+ :param redirect_uri: The URL to which the user will be redirected after authorizing the
179
175
  application.
176
+ :param token: The OAuth token dictionary containing at least the `access_token` and
177
+ `refresh_token`. If not provided, the user will be prompted to interactive
178
+ authorize the application, if `persist_token` is set to False or no valid refresh
179
+ token is found in the keyring.
180
+ :param persist_token: If set to True, the refresh token will be stored in the system's
181
+ keyring, allowing the application to use the same refresh token across multiple
182
+ runs. If set to False, the user will be prompted to authorize the application each
183
+ time. This requires the `keyring` extra to be installed. If a valid refresh token
184
+ is found in the keyring, it will be used instead of the given `token` parameter.
180
185
  :param authorization_url: The URL to which the user will be redirected to authorize the
181
186
  application. Defaults to the Anaplan Prelogin Page, where the user can select the
182
187
  login method.
@@ -185,25 +190,51 @@ class AnaplanOAuthCodeAuth(_AnaplanAuth):
185
190
  :param validation_url: The URL to validate the access token.
186
191
  :param scope: The scope of the access request.
187
192
  :param state_generator: A callable that generates a random state string. You can optionally
188
- pass this if you need to customize the state generation logic. If not provided,
189
- the state will be generated by `oauthlib`.
193
+ pass this if you need to customize the state generation logic. If not provided,
194
+ the state will be generated by `oauthlib`.
190
195
  """
191
-
192
196
  self._oauth_token = token or {}
197
+ self._service_name = "anaplan_sdk"
198
+
199
+ if persist_token:
200
+ try:
201
+ import keyring
202
+
203
+ stored = keyring.get_password(self._service_name, self._service_name)
204
+ if stored:
205
+ logger.info("Using persisted OAuth refresh token.")
206
+ self._oauth_token = {"refresh_token": stored}
207
+ self._token = "" # Set to blank to trigger the super().__init__ auth request.
208
+ except ImportError as e:
209
+ raise AnaplanException(
210
+ "keyring is not available. Please install anaplan-sdk with the keyring extra "
211
+ "`pip install anaplan-sdk[keyring]` or install keyring separately."
212
+ ) from e
213
+ self._persist_token = persist_token
193
214
  self._oauth = _OAuthRequestFactory(
194
215
  client_id=client_id,
195
216
  client_secret=client_secret,
196
- redirect_url=redirect_url,
217
+ redirect_uri=redirect_uri,
197
218
  scope=scope,
198
219
  authorization_url=authorization_url,
199
220
  token_url=token_url,
200
221
  validation_url=validation_url,
201
222
  state_generator=state_generator,
202
223
  )
203
- if not token:
224
+ if not self._oauth_token:
204
225
  self.__auth_code_flow()
205
226
  super().__init__(self._token)
206
227
 
228
+ @property
229
+ def token(self) -> dict[str, str]:
230
+ """
231
+ Returns the current token dictionary. You can safely use the `access_token`, but if you
232
+ must not use the `refresh_token` outside of this class, if you expect to use this instance
233
+ further. If you do use the `refresh_token` outside of this class, this will error on the
234
+ next attempt to refresh the token, as the `refresh_token` can only be used once.
235
+ """
236
+ return self._oauth_token
237
+
207
238
  def _build_auth_request(self) -> httpx.Request:
208
239
  return self._oauth.refresh_token_request(self._oauth_token["refresh_token"])
209
240
 
@@ -213,6 +244,12 @@ class AnaplanOAuthCodeAuth(_AnaplanAuth):
213
244
  if not response.is_success:
214
245
  raise AnaplanException(f"Authentication failed: {response.status_code} {response.text}")
215
246
  self._oauth_token = response.json()
247
+ if self._persist_token:
248
+ import keyring
249
+
250
+ keyring.set_password(
251
+ self._service_name, self._service_name, self._oauth_token["refresh_token"]
252
+ )
216
253
  self._token: str = self._oauth_token["access_token"]
217
254
 
218
255
  def __auth_code_flow(self):
@@ -237,30 +274,61 @@ class AnaplanRefreshTokenAuth(_AnaplanAuth):
237
274
  self,
238
275
  client_id: str,
239
276
  client_secret: str,
240
- redirect_url: str,
277
+ redirect_uri: str,
241
278
  token: dict[str, str],
242
279
  token_url: str = "https://us1a.app.anaplan.com/oauth/token",
243
280
  ):
244
281
  """
245
282
  This class is a utility class for long-lived `Client` or `AsyncClient` instances that use
246
- OAuth. It expects that you have a valid OAuth token with a refresh token, which will be used
247
- to refresh the access token when it expires.
283
+ OAuth. This class will use the `access_token` until the first request fails with a 401
284
+ Unauthorized error, at which point it will attempt to refresh the `access_token` using the
285
+ `refresh_token`. If the refresh fails, it will raise an `InvalidCredentialsException`. The
286
+ `expires_in` field in the token dictionary is not considered. Manipulating any of the
287
+ fields in the token dictionary is not recommended and will likely have no effect.
288
+
289
+ **For its entire lifetime, you are ceding control of the token to this class.**
290
+ You must not use the same token simultaneously in multiple instances of this class or
291
+ outside of it, as this may lead to unexpected behavior when e.g. the refresh token is
292
+ used, which can only happen once and will lead to errors when attempting to use the same
293
+ refresh token again elsewhere.
294
+
295
+ If you need the token back before this instance is destroyed, you can use the `token`
296
+ method.
297
+
248
298
  :param client_id: The client ID of your Anaplan Oauth 2.0 application. This Application
249
299
  must be an Authorization Code Grant application.
250
300
  :param client_secret: The client secret of your Anaplan Oauth 2.0 application.
251
- :param redirect_url: The URL to which the user will be redirected after authorizing the
301
+ :param redirect_uri: The URL to which the user will be redirected after authorizing the
252
302
  application.
303
+ :param token: The OAuth token dictionary containing at least the `access_token` and
304
+ `refresh_token`.
253
305
  :param token_url: The URL to post the refresh token request to in order to fetch the access
254
306
  token.
255
307
  """
308
+ if not isinstance(token, dict) or not all(
309
+ key in token for key in ("access_token", "refresh_token")
310
+ ):
311
+ raise ValueError(
312
+ "The token must at least contain 'access_token' and 'refresh_token' keys."
313
+ )
256
314
  self._oauth_token = token
257
315
  self._oauth = _OAuthRequestFactory(
258
316
  client_id=client_id,
259
317
  client_secret=client_secret,
260
- redirect_url=redirect_url,
318
+ redirect_uri=redirect_uri,
261
319
  token_url=token_url,
262
320
  )
263
- super().__init__(self._token)
321
+ super().__init__(self._oauth_token["access_token"])
322
+
323
+ @property
324
+ def token(self) -> dict[str, str]:
325
+ """
326
+ Returns the current OAuth token. You can safely use the `access_token`, but you
327
+ must not use the `refresh_token` outside of this class, if you expect to use this instance
328
+ further. If you do use the `refresh_token` outside of this class, this will error on the
329
+ next attempt to refresh the token, as the `refresh_token` can only be used once.
330
+ """
331
+ return self._oauth_token
264
332
 
265
333
  def _build_auth_request(self) -> httpx.Request:
266
334
  return self._oauth.refresh_token_request(self._oauth_token["refresh_token"])
@@ -13,7 +13,7 @@ class _BaseOauth:
13
13
  self,
14
14
  client_id: str,
15
15
  client_secret: str,
16
- redirect_url: str,
16
+ redirect_uri: str,
17
17
  authorization_url: str = "https://us1a.app.anaplan.com/auth/prelogin",
18
18
  token_url: str = "https://us1a.app.anaplan.com/oauth/token",
19
19
  validation_url: str = "https://auth.anaplan.com/token/validate",
@@ -38,7 +38,7 @@ class _BaseOauth:
38
38
  :param client_id: The client ID of your Anaplan Oauth 2.0 application. This Application
39
39
  must be an Authorization Code Grant application.
40
40
  :param client_secret: The client secret of your Anaplan Oauth 2.0 application.
41
- :param redirect_url: The URL to which the user will be redirected after authorizing the
41
+ :param redirect_uri: The URL to which the user will be redirected after authorizing the
42
42
  application.
43
43
  :param authorization_url: The URL to which the user will be redirected to authorize the
44
44
  application. Defaults to the Anaplan Prelogin Page, where the user can select the
@@ -53,7 +53,7 @@ class _BaseOauth:
53
53
  """
54
54
  self._client_id = client_id
55
55
  self._client_secret = client_secret
56
- self._redirect_url = redirect_url
56
+ self._redirect_uri = redirect_uri
57
57
  self._authorization_url = authorization_url
58
58
  self._token_url = token_url
59
59
  self._validation_url = validation_url
@@ -86,7 +86,7 @@ class _BaseOauth:
86
86
  auth_url = authorization_url or self._authorization_url
87
87
  state = state or self._state_generator()
88
88
  url, _, _ = self._oauth.prepare_authorization_request(
89
- auth_url, state, self._redirect_url, self._scope
89
+ auth_url, state, self._redirect_uri, self._scope
90
90
  )
91
91
  return url, state
92
92
 
@@ -94,7 +94,7 @@ class _BaseOauth:
94
94
  url, headers, body = self._oauth.prepare_token_request(
95
95
  authorization_response=authorization_response,
96
96
  token_url=self._token_url,
97
- redirect_url=self._redirect_url,
97
+ redirect_url=self._redirect_uri,
98
98
  client_secret=self._client_secret,
99
99
  )
100
100
  return httpx.Request(method="POST", url=url, headers=headers, content=body)
@@ -133,7 +133,7 @@ class AsyncOauth(_BaseOauth):
133
133
  Applications.
134
134
  """
135
135
 
136
- async def fetch_token(self, authorization_response: str) -> dict[str, str]:
136
+ async def fetch_token(self, authorization_response: str) -> dict[str, str | int]:
137
137
  """
138
138
  Fetches the token using the authorization response from the OAuth 2.0 flow.
139
139
  :param authorization_response: The full URL that the user was redirected to after
@@ -151,7 +151,7 @@ class AsyncOauth(_BaseOauth):
151
151
  logger.error(error)
152
152
  raise AnaplanException("Error during token creation.") from error
153
153
 
154
- async def validate_token(self, token: str) -> dict[str, str | dict[str, str]]:
154
+ async def validate_token(self, token: str) -> dict[str, str | dict[str, str | int]]:
155
155
  """
156
156
  Validates the provided token by checking its validity with the Anaplan Authentication API.
157
157
  If the token is not valid, an `InvalidCredentialsException` is raised.
@@ -168,7 +168,7 @@ class AsyncOauth(_BaseOauth):
168
168
  logger.error(error)
169
169
  raise AnaplanException("Error during token validation.") from error
170
170
 
171
- async def refresh_token(self, refresh_token: str) -> dict[str, str]:
171
+ async def refresh_token(self, refresh_token: str) -> dict[str, str | int]:
172
172
  """
173
173
  Refreshes the token using a refresh token.
174
174
  :param refresh_token: The refresh token to use for refreshing the access token.
@@ -192,7 +192,7 @@ class Oauth(_BaseOauth):
192
192
  Applications.
193
193
  """
194
194
 
195
- def fetch_token(self, authorization_response: str) -> dict[str, str]:
195
+ def fetch_token(self, authorization_response: str) -> dict[str, str | int]:
196
196
  """
197
197
  Fetches the token using the authorization response from the OAuth 2.0 flow.
198
198
  :param authorization_response: The full URL that the user was redirected to after
@@ -206,7 +206,7 @@ class Oauth(_BaseOauth):
206
206
  url, headers, body = self._oauth.prepare_token_request(
207
207
  authorization_response=authorization_response,
208
208
  token_url=self._token_url,
209
- redirect_url=self._redirect_url,
209
+ redirect_url=self._redirect_uri,
210
210
  client_secret=self._client_secret,
211
211
  )
212
212
  with httpx.Client() as client:
@@ -216,7 +216,7 @@ class Oauth(_BaseOauth):
216
216
  logger.error(error)
217
217
  raise AnaplanException("Error during token creation.") from error
218
218
 
219
- def validate_token(self, token: str) -> dict[str, str | dict[str, str]]:
219
+ def validate_token(self, token: str) -> dict[str, str | dict[str, str | int]]:
220
220
  """
221
221
  Validates the provided token by checking its validity with the Anaplan Authentication API.
222
222
  If the token is not valid, an `InvalidCredentialsException` is raised.
@@ -233,7 +233,7 @@ class Oauth(_BaseOauth):
233
233
  logger.error(error)
234
234
  raise AnaplanException("Error during token validation.") from error
235
235
 
236
- def refresh_token(self, refresh_token: str) -> dict[str, str]:
236
+ def refresh_token(self, refresh_token: str) -> dict[str, str | int]:
237
237
  """
238
238
  Refreshes the token using a refresh token.
239
239
  :param refresh_token: The refresh token to use for refreshing the access token.
@@ -145,10 +145,10 @@ class TaskSummary(AnaplanModel):
145
145
 
146
146
 
147
147
  class TaskResultDetail(AnaplanModel):
148
- local_message_text: str = Field(description="Error message text.")
148
+ local_message_text: str | None = Field(None, description="Error message text.")
149
149
  occurrences: int = Field(0, description="The number of occurrences of this error.")
150
150
  type: str = Field(description="The type of this error.")
151
- values: list[str] = Field([], description="Further error information if available.")
151
+ values: list[str | None] = Field([], description="Further error information if available.")
152
152
 
153
153
 
154
154
  class TaskResult(AnaplanModel):
@@ -7,9 +7,9 @@ There are three main ways to authenticate with Anaplan.
7
7
  - OAuth2
8
8
 
9
9
  Anaplan SDK supports all of them, though Basic Authentication is strictly not recommended for production use.
10
- Certificate
11
- Authentication is currently the most suitable for production use, since the Anaplan OAuth 2.0 implementation does not
12
- support the `client_credentials` grant type. This means you will have to manually manage the Refresh Token.
10
+ Certificate Authentication is currently the most suitable for production use, since the Anaplan OAuth 2.0
11
+ implementation does not support the `client_credentials` grant type. This means you will have to manually manage the
12
+ refresh Token if you choose to use OAuth2.
13
13
 
14
14
  ## Basic Authentication
15
15
 
@@ -45,7 +45,7 @@ maintain and error-prone.
45
45
 
46
46
  Certificate Authentication is the most suitable for production use. It uses an X.509 S/MIME Certificate (aka. Client Certificate or HTTPS-Certificate) and Private Key. The Process of acquiring such a certificate is well [documented](https://help.anaplan.com/procure-ca-certificates-47842267-2cb3-4e38-90bf-13b1632bcd44). Anaplan does not support self-signed certificates, so you will need to procure a certificate from a trusted Certificate Authority (CA).
47
47
 
48
- ??? tip "Requires Extra"
48
+ ???+ tip "Requires Extra"
49
49
  If you want to use certificate authentication, you need to install the `cert` extra:
50
50
  === "pip"
51
51
  ```shell
@@ -96,7 +96,8 @@ in which the authentication flow must occur outside the SDK for the user to log
96
96
 
97
97
  These Classes exist for convenience only, and you can use any other Library to handle the Oauth2 flow.
98
98
 
99
- An example for FastAPI is shown below, but you can use any other Web Framework.
99
+ A minimal, illustrative example for FastAPI is shown below, but you can use any other Web Framework. This will not run
100
+ until you implement the TODOs in a suitable way for your purpose.
100
101
 
101
102
  ??? tip "Requires Extra"
102
103
  If you want to use OAuth2 authentication, you need to install the `oauth` extra:
@@ -114,7 +115,6 @@ An example for FastAPI is shown below, but you can use any other Web Framework.
114
115
  ```
115
116
  This will install [OAuthLib](https://oauthlib.readthedocs.io/en/latest/index.html) to securely construct the authentication request.
116
117
 
117
-
118
118
  ```python
119
119
  import os
120
120
  from typing import Annotated
@@ -127,7 +127,7 @@ from anaplan_sdk import AsyncClient, AsyncOauth, exceptions
127
127
  _oauth = AsyncOauth(
128
128
  client_id=os.environ["OAUTH_CLIENT_ID"],
129
129
  client_secret=os.environ["OAUTH_CLIENT_SECRET"],
130
- redirect_url="https://vinzenzklass.github.io/anaplan-sdk/oauth/callback",
130
+ redirect_uri="https://vinzenzklass.github.io/anaplan-sdk/oauth/callback",
131
131
  )
132
132
 
133
133
  app = FastAPI()
@@ -182,7 +182,7 @@ when it expires.
182
182
  token=token,
183
183
  client_id=os.environ["OAUTH_CLIENT_ID"],
184
184
  client_secret=os.environ["OAUTH_CLIENT_SECRET"],
185
- redirect_url="https://vinzenzklass.github.io/anaplan-sdk",
185
+ redirect_uri="https://vinzenzklass.github.io/anaplan-sdk",
186
186
  )
187
187
  )
188
188
  ```
@@ -193,7 +193,7 @@ when it expires.
193
193
  token=token,
194
194
  client_id=os.environ["OAUTH_CLIENT_ID"],
195
195
  client_secret=os.environ["OAUTH_CLIENT_SECRET"],
196
- redirect_url="https://vinzenzklass.github.io/anaplan-sdk",
196
+ redirect_uri="https://vinzenzklass.github.io/anaplan-sdk",
197
197
  )
198
198
  )
199
199
  ```
@@ -201,10 +201,10 @@ when it expires.
201
201
 
202
202
  ## OAuth for Local Applications
203
203
 
204
- For local applications, you can use `AnaplanOAuthCodeAuth` Class to handle the initial Oauth2 `authorization_code` flow
204
+ For local applications, you can use `AnaplanLocalOAuth` Class to handle the initial Oauth2 `authorization_code` flow
205
205
  and the subsequent token refreshes.
206
206
 
207
- ??? tip "Requires Extra"
207
+ ???+ tip "Requires Extra"
208
208
  If you want to use OAuth2 authentication, you need to install the `oauth` extra:
209
209
  === "pip"
210
210
  ```shell
@@ -223,20 +223,20 @@ and the subsequent token refreshes.
223
223
  === "Synchronous"
224
224
  ```python
225
225
  anaplan = Client(
226
- auth=AnaplanOAuthCodeAuth(
226
+ auth=AnaplanLocalOAuth(
227
227
  client_id=os.environ["OAUTH_CLIENT_ID"],
228
228
  client_secret=os.environ["OAUTH_CLIENT_SECRET"],
229
- redirect_url="https://vinzenzklass.github.io/anaplan-sdk",
229
+ redirect_uri="https://vinzenzklass.github.io/anaplan-sdk",
230
230
  )
231
231
  )
232
232
  ```
233
233
  === "Asynchronous"
234
234
  ```python
235
235
  anaplan = AsyncClient(
236
- auth=AnaplanOAuthCodeAuth(
236
+ auth=AnaplanLocalOAuth(
237
237
  client_id=os.environ["OAUTH_CLIENT_ID"],
238
238
  client_secret=os.environ["OAUTH_CLIENT_SECRET"],
239
- redirect_url="https://vinzenzklass.github.io/anaplan-sdk",
239
+ redirect_uri="https://vinzenzklass.github.io/anaplan-sdk",
240
240
  )
241
241
  )
242
242
  ```
@@ -248,6 +248,89 @@ will need to copy the entire redirect URI from your browser and paste it into th
248
248
  Unfortunately, registering localhost redirect URIs is not supported by Anaplan. This means we cannot intercept the
249
249
  redirect URI and extract the `authorization_code` automatically. This is a limitation of Anaplan's OAuth2 implementation. See [this Community Note](https://community.anaplan.com/discussion/156599/oauth-rediredt-url-port-for-desktop-apps).
250
250
 
251
+ ## Persisting OAuth Tokens
252
+
253
+ The SDK provides the ability to persist OAuth refresh tokens between sessions using the system's secure keyring for
254
+ local applications. This allows you to avoid having to re-authenticate every time you run your application while using
255
+ OAuth2.
256
+
257
+ ???+ tip "Requires Extras"
258
+ If you want to use persisting Tokens, you need to additionally install the `keyring` extra:
259
+ === "pip"
260
+ ```shell
261
+ pip install anaplan-sdk[oauth,keyring]
262
+ ```
263
+ ===+ "uv"
264
+ ```shell
265
+ uv add anaplan-sdk[oauth,keyring]
266
+ ```
267
+ === "Poetry"
268
+ ```shell
269
+ poetry add anaplan-sdk[oauth,keyring]
270
+ ```
271
+
272
+ This will install [Keyring](https://github.com/jaraco/keyring) to securely store refresh tokens.
273
+
274
+ To enable token persistence, set the `persist_token=True` parameter when creating an `AnaplanLocalOAuth` instance:
275
+
276
+ === "Synchronous"
277
+ ```python
278
+ anaplan = Client(
279
+ auth=AnaplanLocalOAuth(
280
+ client_id=os.environ["OAUTH_CLIENT_ID"],
281
+ client_secret=os.environ["OAUTH_CLIENT_SECRET"],
282
+ redirect_uri="https://vinzenzklass.github.io/anaplan-sdk",
283
+ persist_token=True,
284
+ )
285
+ )
286
+ ```
287
+ === "Asynchronous"
288
+ ```python
289
+ anaplan = AsyncClient(
290
+ auth=AnaplanLocalOAuth(
291
+ client_id=os.environ["OAUTH_CLIENT_ID"],
292
+ client_secret=os.environ["OAUTH_CLIENT_SECRET"],
293
+ redirect_uri="https://vinzenzklass.github.io/anaplan-sdk",
294
+ persist_token=True,
295
+ )
296
+ )
297
+ ```
298
+ When `persist_token` is set to True, the SDK will:
299
+
300
+ - Look for a stored refresh token in the system's keyring
301
+ - If found, use it to obtain a new access token. If also given, this will overwrite the passed `token` parameter.
302
+ - If not found or if the token is invalid, prompt the user for authentication
303
+ - After authentication, store the new refresh token in the keyring
304
+
305
+ ??? note "Keyring Configuration"
306
+ The keyring library may require additional configuration depending on your environment:
307
+
308
+ - In headless environments, you may need to explicitely configure a different keyring backend
309
+ - Some Linux distributions may require additional packages or configuration
310
+
311
+ Configuring the keyring backend is your responsibility as it depends on your specific environment.
312
+
313
+ For example, to use the libsecret file backend:
314
+
315
+ ```python
316
+ import keyring
317
+ from keyring.backends import libsecret
318
+
319
+ keyring.set_keyring(libsecret.Keyring())
320
+ ```
321
+
322
+ For more information, refer to the [keyring documentation](https://github.com/jaraco/keyring).
323
+
324
+ ## OAuth Token Ownership
325
+
326
+ Instances of both `AnaplanLocalOAuth` and `AnaplanRefreshTokenAuth` assert ownership of the token you pass to them
327
+ for their entire lifetime. This means that you should not use the token outside of these classes, as it may lead to
328
+ errors when attempting to use the same refresh token in multiple places. You can access the current token by using the
329
+ `token` property, but you should not use anything other than the `access_token`. You can use this property to
330
+ reassert control of the OAuth token when the instance is nor longer needed. If you do need to use the token in several
331
+ places simultaneously, you should use a [custom scheme](#custom-authentication-schemes) to do so and handle all
332
+ potential conflicts appropriately.
333
+
251
334
 
252
335
  ## Custom Authentication Schemes
253
336
 
@@ -175,7 +175,7 @@ Warning. To automate this tedious task without losing any data, we can perform f
175
175
  )
176
176
  anaplan.transactional.reset_list_index(101000000000)
177
177
  result = anaplan.transactional.insert_list_items(
178
- 101000000008, [e.model_dump() for e in items] # Reimport all fields.
178
+ 101000000000, [e.model_dump() for e in items] # Reimport all fields.
179
179
  )
180
180
  ```
181
181
  === "Asynchronous"
@@ -186,6 +186,6 @@ Warning. To automate this tedious task without losing any data, we can perform f
186
186
  )
187
187
  await anaplan.transactional.reset_list_index(101000000000)
188
188
  result = await anaplan.transactional.insert_list_items(
189
- 101000000008, [e.model_dump() for e in items] # Reimport all fields.
189
+ 101000000000, [e.model_dump() for e in items] # Reimport all fields.
190
190
  )
191
191
  ```