digitalhub 0.12.0__py3-none-any.whl → 0.13.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.

Potentially problematic release.


This version of digitalhub might be problematic. Click here for more details.

Files changed (79) hide show
  1. digitalhub/__init__.py +1 -1
  2. digitalhub/context/api.py +5 -5
  3. digitalhub/context/builder.py +3 -5
  4. digitalhub/context/context.py +9 -1
  5. digitalhub/entities/_base/executable/entity.py +105 -57
  6. digitalhub/entities/_base/material/entity.py +11 -18
  7. digitalhub/entities/_base/material/utils.py +1 -1
  8. digitalhub/entities/_commons/metrics.py +64 -30
  9. digitalhub/entities/_commons/utils.py +36 -9
  10. digitalhub/entities/_processors/base.py +150 -79
  11. digitalhub/entities/_processors/context.py +366 -215
  12. digitalhub/entities/_processors/utils.py +74 -30
  13. digitalhub/entities/artifact/crud.py +4 -0
  14. digitalhub/entities/artifact/utils.py +28 -13
  15. digitalhub/entities/dataitem/crud.py +14 -2
  16. digitalhub/entities/dataitem/table/entity.py +3 -3
  17. digitalhub/entities/dataitem/utils.py +84 -35
  18. digitalhub/entities/model/crud.py +4 -0
  19. digitalhub/entities/model/utils.py +28 -13
  20. digitalhub/entities/project/_base/entity.py +0 -2
  21. digitalhub/entities/run/_base/entity.py +2 -2
  22. digitalhub/entities/task/_base/models.py +12 -3
  23. digitalhub/entities/trigger/_base/entity.py +11 -0
  24. digitalhub/factory/factory.py +25 -3
  25. digitalhub/factory/utils.py +11 -3
  26. digitalhub/runtimes/_base.py +1 -1
  27. digitalhub/runtimes/builder.py +18 -1
  28. digitalhub/stores/client/__init__.py +12 -0
  29. digitalhub/stores/client/_base/api_builder.py +14 -0
  30. digitalhub/stores/client/_base/client.py +93 -0
  31. digitalhub/stores/client/_base/key_builder.py +28 -0
  32. digitalhub/stores/client/_base/params_builder.py +14 -0
  33. digitalhub/stores/client/api.py +10 -5
  34. digitalhub/stores/client/builder.py +3 -1
  35. digitalhub/stores/client/dhcore/api_builder.py +17 -0
  36. digitalhub/stores/client/dhcore/client.py +325 -70
  37. digitalhub/stores/client/dhcore/configurator.py +485 -193
  38. digitalhub/stores/client/dhcore/enums.py +3 -0
  39. digitalhub/stores/client/dhcore/error_parser.py +35 -1
  40. digitalhub/stores/client/dhcore/params_builder.py +113 -17
  41. digitalhub/stores/client/dhcore/utils.py +40 -22
  42. digitalhub/stores/client/local/api_builder.py +17 -0
  43. digitalhub/stores/client/local/client.py +6 -8
  44. digitalhub/stores/credentials/api.py +35 -0
  45. digitalhub/stores/credentials/configurator.py +210 -0
  46. digitalhub/stores/credentials/enums.py +68 -0
  47. digitalhub/stores/credentials/handler.py +176 -0
  48. digitalhub/stores/{configurator → credentials}/ini_module.py +60 -28
  49. digitalhub/stores/credentials/store.py +81 -0
  50. digitalhub/stores/data/_base/store.py +27 -9
  51. digitalhub/stores/data/api.py +49 -9
  52. digitalhub/stores/data/builder.py +90 -41
  53. digitalhub/stores/data/local/store.py +4 -7
  54. digitalhub/stores/data/remote/store.py +4 -7
  55. digitalhub/stores/data/s3/configurator.py +65 -80
  56. digitalhub/stores/data/s3/store.py +69 -81
  57. digitalhub/stores/data/s3/utils.py +10 -10
  58. digitalhub/stores/data/sql/configurator.py +76 -73
  59. digitalhub/stores/data/sql/store.py +191 -102
  60. digitalhub/utils/exceptions.py +6 -0
  61. digitalhub/utils/file_utils.py +53 -30
  62. digitalhub/utils/generic_utils.py +41 -33
  63. digitalhub/utils/git_utils.py +24 -14
  64. digitalhub/utils/io_utils.py +19 -18
  65. digitalhub/utils/uri_utils.py +31 -31
  66. {digitalhub-0.12.0.dist-info → digitalhub-0.13.0.dist-info}/METADATA +1 -1
  67. {digitalhub-0.12.0.dist-info → digitalhub-0.13.0.dist-info}/RECORD +71 -74
  68. digitalhub/entities/_commons/types.py +0 -9
  69. digitalhub/stores/configurator/api.py +0 -35
  70. digitalhub/stores/configurator/configurator.py +0 -202
  71. digitalhub/stores/configurator/credentials_store.py +0 -69
  72. digitalhub/stores/configurator/enums.py +0 -25
  73. digitalhub/stores/data/s3/enums.py +0 -20
  74. digitalhub/stores/data/sql/enums.py +0 -20
  75. digitalhub/stores/data/utils.py +0 -38
  76. /digitalhub/stores/{configurator → credentials}/__init__.py +0 -0
  77. {digitalhub-0.12.0.dist-info → digitalhub-0.13.0.dist-info}/WHEEL +0 -0
  78. {digitalhub-0.12.0.dist-info → digitalhub-0.13.0.dist-info}/licenses/AUTHORS +0 -0
  79. {digitalhub-0.12.0.dist-info → digitalhub-0.13.0.dist-info}/licenses/LICENSE +0 -0
@@ -5,14 +5,13 @@
5
5
  from __future__ import annotations
6
6
 
7
7
  import typing
8
- from warnings import warn
9
8
 
10
9
  from requests import request
11
10
 
12
- from digitalhub.stores.client.dhcore.enums import AuthType, DhcoreEnvVar
13
- from digitalhub.stores.configurator.configurator import configurator
14
- from digitalhub.stores.data.s3.enums import S3StoreEnv
15
- from digitalhub.stores.data.sql.enums import SqlStoreEnv
11
+ from digitalhub.stores.client.dhcore.enums import AuthType
12
+ from digitalhub.stores.credentials.configurator import Configurator
13
+ from digitalhub.stores.credentials.enums import CredsEnvVar
14
+ from digitalhub.stores.credentials.handler import creds_handler
16
15
  from digitalhub.utils.exceptions import ClientError
17
16
  from digitalhub.utils.generic_utils import list_enum
18
17
  from digitalhub.utils.uri_utils import has_remote_scheme
@@ -21,288 +20,503 @@ if typing.TYPE_CHECKING:
21
20
  from requests import Response
22
21
 
23
22
 
24
- # Default key used to store authentication information
25
- AUTH_KEY = "_auth"
26
-
27
- # API levels that are supported
28
- MAX_API_LEVEL = 20
29
- MIN_API_LEVEL = 11
30
- LIB_VERSION = 11
31
-
32
-
33
- class ClientDHCoreConfigurator:
23
+ class ClientDHCoreConfigurator(Configurator):
34
24
  """
35
25
  Configurator object used to configure the client.
26
+
27
+ The configurator starts reading the credentials from the
28
+ environment and from the ini file and stores them into the
29
+ creds_handler object.
30
+
31
+ While reading the credentials from the two sources (environment and file),
32
+ the configurator evaluate if the required keys are present in both sources.
33
+ If the required keys are not present in both sources, the configurator
34
+ will rise an error, otherwise decide which source to use.
35
+
36
+ Once the credentials are read, the configurator check the current profile
37
+ name from the ini file, and set it. The default one is __default. The
38
+ profile is used to discriminate a set of credentials inside the ini file.
39
+
40
+ The configurator finally set the authentication type based on the credentials.
41
+ The logic is the following:
42
+
43
+ 1. Check for a personal access token. Use it immediately to
44
+ require a timed access token in an exchange endpoint.
45
+ Switche then the origin to file and .
46
+ Set the auth type to EXCHANGE.
47
+ 2. Check for an access token and a refresh token.
48
+ Set the auth type to OAUTH2.
49
+ 3. Check for username and password.
50
+ Set the auth type to BASIC.
51
+ 4. If none of the above is true, leave the auth type to None.
36
52
  """
37
53
 
54
+ keys = [*list_enum(CredsEnvVar)]
55
+ required_keys = [CredsEnvVar.DHCORE_ENDPOINT.value]
56
+ keys_to_unprefix = [
57
+ CredsEnvVar.DHCORE_REFRESH_TOKEN.value,
58
+ CredsEnvVar.DHCORE_ACCESS_TOKEN.value,
59
+ CredsEnvVar.DHCORE_ISSUER.value,
60
+ CredsEnvVar.DHCORE_CLIENT_ID.value,
61
+ ]
62
+
38
63
  def __init__(self) -> None:
39
- self._current_env = configurator.get_current_env()
64
+ """
65
+ Initialize the DHCore configurator.
66
+
67
+ Sets up the configurator by calling the parent constructor and
68
+ initializing the authentication type evaluation process.
69
+
70
+ Returns
71
+ -------
72
+ None
73
+ """
74
+ super().__init__()
75
+ self._auth_type: str | None = None
76
+ self.set_auth_type()
40
77
 
41
78
  ##############################
42
- # Configuration methods
79
+ # Credentials methods
43
80
  ##############################
44
81
 
45
- def check_config(self) -> None:
82
+ def load_env_vars(self) -> None:
46
83
  """
47
- Check if the config is valid.
84
+ Load credentials from environment variables.
48
85
 
49
- Parameters
50
- ----------
51
- config : dict
52
- Configuration dictionary.
86
+ Retrieves DHCore credentials from environment variables, sanitizes
87
+ them (particularly endpoint URLs), and stores them in the credentials
88
+ handler for the environment origin.
53
89
 
54
90
  Returns
55
91
  -------
56
92
  None
93
+
94
+ Notes
95
+ -----
96
+ This method sanitizes endpoint and issuer URLs to ensure they have
97
+ proper schemes and removes trailing slashes.
57
98
  """
58
- if configurator.get_current_env() != self._current_env:
59
- self.configure()
99
+ env_creds = self._creds_handler.load_from_env(self.keys)
100
+ env_creds = self._sanitize_env_vars(env_creds)
101
+ self._creds_handler.set_credentials(self._env, env_creds)
60
102
 
61
- def configure(self, config: dict | None = None) -> None:
103
+ def _sanitize_env_vars(self, creds: dict) -> dict:
62
104
  """
63
- Configure the client attributes with config (given or from
64
- environment).
65
- Regarding authentication parameters, the config parameter
66
- takes precedence over the env variables, and the token
67
- over the basic auth. Furthermore, the config parameter is
68
- validated against the proper pydantic model.
105
+ Sanitize credentials loaded from environment variables.
106
+
107
+ Validates and normalizes endpoint and issuer URLs from environment
108
+ variables. Ensures URLs have proper schemes and removes trailing slashes.
69
109
 
70
110
  Parameters
71
111
  ----------
72
- config : dict
73
- Configuration dictionary.
112
+ creds : dict
113
+ Raw credentials dictionary loaded from environment variables.
74
114
 
75
115
  Returns
76
116
  -------
77
- None
117
+ dict
118
+ Sanitized credentials dictionary with normalized URLs.
119
+
120
+ Raises
121
+ ------
122
+ ClientError
123
+ If endpoint or issuer URLs have invalid schemes.
124
+
125
+ Notes
126
+ -----
127
+ Environment variables are expected to have the full "DHCORE_" prefix
128
+ for issuer endpoints.
78
129
  """
79
- self._get_core_endpoint()
80
- self._get_auth_vars()
130
+ creds[CredsEnvVar.DHCORE_ENDPOINT.value] = self._sanitize_endpoint(creds[CredsEnvVar.DHCORE_ENDPOINT.value])
131
+ creds[CredsEnvVar.DHCORE_ISSUER.value] = self._sanitize_endpoint(creds[CredsEnvVar.DHCORE_ISSUER.value])
132
+ return creds
81
133
 
82
- def check_core_version(self, response: Response) -> None:
134
+ def load_file_vars(self) -> None:
83
135
  """
84
- Raise an exception if DHCore API version is not supported.
136
+ Load credentials from configuration file.
85
137
 
86
- Parameters
87
- ----------
88
- response : Response
89
- The response object.
138
+ Retrieves DHCore credentials from the .dhcore.ini file, handles
139
+ compatibility with CLI format (keys without DHCORE_ prefix), and
140
+ falls back to environment variables for missing endpoint and
141
+ personal access token values.
90
142
 
91
143
  Returns
92
144
  -------
93
145
  None
146
+
147
+ Notes
148
+ -----
149
+ This method handles the case where:
150
+ - Endpoint might not be present in file response, falls back to env
151
+ - Personal access token might not be present, falls back to env
152
+ - File format uses keys without "DHCORE_" prefix for compatibility
94
153
  """
95
- if "X-Api-Level" in response.headers:
96
- core_api_level = int(response.headers["X-Api-Level"])
97
- if not (MIN_API_LEVEL <= core_api_level <= MAX_API_LEVEL):
98
- raise ClientError("Backend API level not supported.")
99
- if LIB_VERSION < core_api_level:
100
- warn("Backend API level is higher than library version. You should consider updating the library.")
154
+ keys = [*self._remove_prefix_dhcore()]
155
+ file_creds = self._creds_handler.load_from_file(keys)
156
+ env_creds = self._creds_handler.load_from_env(self.keys)
157
+
158
+ # Because in the response there is no endpoint
159
+ if file_creds[CredsEnvVar.DHCORE_ENDPOINT.value] is None:
160
+ file_creds[CredsEnvVar.DHCORE_ENDPOINT.value] = env_creds.get(CredsEnvVar.DHCORE_ENDPOINT.value)
161
+
162
+ # Because in the response there is no personal access token
163
+ if file_creds[CredsEnvVar.DHCORE_PERSONAL_ACCESS_TOKEN.value] is None:
164
+ file_creds[CredsEnvVar.DHCORE_PERSONAL_ACCESS_TOKEN.value] = env_creds.get(
165
+ CredsEnvVar.DHCORE_PERSONAL_ACCESS_TOKEN.value
166
+ )
101
167
 
102
- def build_url(self, api: str) -> str:
168
+ file_creds = self._sanitize_file_vars(file_creds)
169
+ self._creds_handler.set_credentials(self._file, file_creds)
170
+
171
+ def _sanitize_file_vars(self, creds: dict) -> dict:
103
172
  """
104
- Build the url.
173
+ Sanitize credentials loaded from configuration file.
174
+
175
+ Handles the different key formats used in configuration files compared
176
+ to environment variables. File format omits "DHCORE_" prefix for
177
+ certain keys for CLI compatibility.
105
178
 
106
179
  Parameters
107
180
  ----------
108
- api : str
109
- The api to call.
181
+ creds : dict
182
+ Raw credentials dictionary loaded from configuration file.
110
183
 
111
184
  Returns
112
185
  -------
113
- str
114
- The url.
115
- """
116
- api = api.removeprefix("/")
117
- return f"{configurator.get_credential(DhcoreEnvVar.ENDPOINT.value)}/{api}"
186
+ dict
187
+ Sanitized credentials dictionary with standardized key names
188
+ and normalized URLs, filtered to include only valid keys.
118
189
 
119
- ##############################
120
- # Private methods
121
- ##############################
190
+ Raises
191
+ ------
192
+ ClientError
193
+ If endpoint or issuer URLs have invalid schemes.
194
+
195
+ Notes
196
+ -----
197
+ File format expects these keys without "DHCORE_" prefix:
198
+ - issuer, client_id, access_token, refresh_token
199
+ But uses full names for: endpoint, user, password, personal_access_token
200
+ """
201
+ creds[CredsEnvVar.DHCORE_ENDPOINT.value] = self._sanitize_endpoint(creds[CredsEnvVar.DHCORE_ENDPOINT.value])
202
+ creds[CredsEnvVar.DHCORE_ISSUER.value] = self._sanitize_endpoint(
203
+ creds[CredsEnvVar.DHCORE_ISSUER.value.removeprefix("DHCORE_")]
204
+ )
205
+ creds[CredsEnvVar.DHCORE_REFRESH_TOKEN.value] = creds[
206
+ CredsEnvVar.DHCORE_REFRESH_TOKEN.value.removeprefix("DHCORE_")
207
+ ]
208
+ creds[CredsEnvVar.DHCORE_ACCESS_TOKEN.value] = creds[
209
+ CredsEnvVar.DHCORE_ACCESS_TOKEN.value.removeprefix("DHCORE_")
210
+ ]
211
+ creds[CredsEnvVar.DHCORE_CLIENT_ID.value] = creds[CredsEnvVar.DHCORE_CLIENT_ID.value.removeprefix("DHCORE_")]
212
+ return {k: v for k, v in creds.items() if k in self.keys}
122
213
 
123
214
  @staticmethod
124
- def _sanitize_endpoint(endpoint: str) -> str:
215
+ def _sanitize_endpoint(endpoint: str | None = None) -> str | None:
125
216
  """
126
- Sanitize the endpoint.
217
+ Sanitize and validate endpoint URL.
218
+
219
+ Validates that the endpoint URL has a proper HTTP/HTTPS scheme,
220
+ trims whitespace, and removes trailing slashes for consistency.
221
+
222
+ Parameters
223
+ ----------
224
+ endpoint : str, optional
225
+ The endpoint URL to sanitize. If None, returns None.
127
226
 
128
227
  Returns
129
228
  -------
130
- None
229
+ str or None
230
+ The sanitized endpoint URL with trailing slash removed,
231
+ or None if input was None.
232
+
233
+ Raises
234
+ ------
235
+ ClientError
236
+ If the endpoint does not start with http:// or https://.
237
+
238
+ Notes
239
+ -----
240
+ This method ensures endpoint URLs are properly formatted for
241
+ HTTP requests and prevents common URL formatting issues.
131
242
  """
243
+ if endpoint is None:
244
+ return
132
245
  if not has_remote_scheme(endpoint):
133
246
  raise ClientError("Invalid endpoint scheme. Must start with http:// or https://.")
134
247
 
135
248
  endpoint = endpoint.strip()
136
249
  return endpoint.removesuffix("/")
137
250
 
138
- def _get_core_endpoint(self) -> None:
251
+ def get_endpoint(self) -> str:
139
252
  """
140
- Get the DHCore endpoint from env.
253
+ Get the configured DHCore backend endpoint.
254
+
255
+ Retrieves the DHCore endpoint URL from the current credential source
256
+ (environment or file based on current origin).
141
257
 
142
258
  Returns
143
259
  -------
144
- None
260
+ str
261
+ The DHCore backend endpoint URL.
145
262
 
146
263
  Raises
147
264
  ------
148
- Exception
149
- If the endpoint of DHCore is not set in the env variables.
265
+ KeyError
266
+ If the endpoint is not configured in the current credential source.
267
+
268
+ Notes
269
+ -----
270
+ The endpoint returned is already sanitized and validated during
271
+ the credential loading process.
150
272
  """
151
- endpoint = configurator.load_var(DhcoreEnvVar.ENDPOINT.value)
152
- if endpoint is None:
153
- raise ClientError("Endpoint not set as environment variables.")
154
- endpoint = self._sanitize_endpoint(endpoint)
155
- configurator.set_credential(DhcoreEnvVar.ENDPOINT.value, endpoint)
273
+ creds = self._creds_handler.get_credentials(self._origin)
274
+ return creds[CredsEnvVar.DHCORE_ENDPOINT.value]
156
275
 
157
- def _get_auth_vars(self) -> None:
276
+ ##############################
277
+ # Origin methods
278
+ ##############################
279
+
280
+ def change_origin(self) -> None:
158
281
  """
159
- Get authentication parameters from the env.
282
+ Change the credentials origin and re-evaluate authentication type.
283
+
284
+ Switches the credential source (between environment and file) and
285
+ re-evaluates the authentication type based on the new credential set.
286
+ This is typically called when the current credential source fails
287
+ or when switching contexts.
160
288
 
161
289
  Returns
162
290
  -------
163
291
  None
292
+
293
+ Notes
294
+ -----
295
+ This method extends the parent class behavior by also re-evaluating
296
+ the authentication type, which may change based on different
297
+ credentials available in the new source.
164
298
  """
165
- # Give priority to access token
166
- access_token = self._load_dhcore_oauth_vars(DhcoreEnvVar.ACCESS_TOKEN.value)
167
- if access_token is not None:
168
- configurator.set_credential(AUTH_KEY, AuthType.OAUTH2.value)
169
- configurator.set_credential(DhcoreEnvVar.ACCESS_TOKEN.value.removeprefix("DHCORE_"), access_token)
170
-
171
- # Fallback to basic
172
- else:
173
- user = configurator.load_var(DhcoreEnvVar.USER.value)
174
- password = configurator.load_var(DhcoreEnvVar.PASSWORD.value)
175
- if user is not None and password is not None:
176
- configurator.set_credential(AUTH_KEY, AuthType.BASIC.value)
177
- configurator.set_credential(DhcoreEnvVar.USER.value, user)
178
- configurator.set_credential(DhcoreEnvVar.PASSWORD.value, password)
299
+ super().change_origin()
300
+
301
+ # Re-evaluate the auth type
302
+ self.set_auth_type()
179
303
 
180
304
  ##############################
181
305
  # Auth methods
182
306
  ##############################
183
307
 
184
- def basic_auth(self) -> bool:
308
+ def set_auth_type(self) -> None:
185
309
  """
186
- Get basic auth.
310
+ Evaluate and set the authentication type from available credentials.
311
+
312
+ Analyzes the available credentials and determines the appropriate
313
+ authentication method based on the following priority:
314
+ 1. EXCHANGE - Personal access token available
315
+ 2. OAUTH2 - Access token and refresh token available
316
+ 3. ACCESS_TOKEN - Only access token available
317
+ 4. BASIC - Username and password available
318
+ 5. None - No valid credentials found
319
+
320
+ For EXCHANGE authentication, automatically performs token exchange
321
+ and switches to file-based credential storage.
187
322
 
188
323
  Returns
189
324
  -------
190
- bool
325
+ None
326
+
327
+ Notes
328
+ -----
329
+ When EXCHANGE authentication is detected, this method automatically:
330
+ - Performs credential refresh to exchange the personal access token
331
+ - Changes origin to file-based storage for the new tokens
332
+ - Updates the authentication type accordingly
191
333
  """
192
- auth_type = configurator.get_credential(AUTH_KEY)
193
- return auth_type == AuthType.BASIC.value
334
+ creds = creds_handler.get_credentials(self._origin)
335
+ self._auth_type = self._eval_auth_type(creds)
336
+ # If we have an exchange token, we need to get a new access token.
337
+ # Therefore, we change the origin to file, where the refresh token is written.
338
+ # We also try to fetch the PAT from both env and file
339
+ if self._auth_type == AuthType.EXCHANGE.value:
340
+ self.refresh_credentials(change_origin=True)
341
+ # Just to ensure we get the right source from file
342
+ self.change_to_file()
194
343
 
195
- def oauth2_auth(self) -> bool:
344
+ def refreshable_auth_types(self) -> bool:
196
345
  """
197
- Get oauth2 auth.
346
+ Check if the current authentication type supports token refresh.
347
+
348
+ Determines whether the current authentication method supports
349
+ automatic token refresh capabilities.
198
350
 
199
351
  Returns
200
352
  -------
201
353
  bool
354
+ True if the authentication type supports refresh (OAUTH2 or EXCHANGE),
355
+ False otherwise (BASIC or ACCESS_TOKEN).
356
+
357
+ Notes
358
+ -----
359
+ Only OAUTH2 and EXCHANGE authentication types support refresh:
360
+ - OAUTH2: Uses refresh token to get new access tokens
361
+ - EXCHANGE: Uses personal access token for token exchange
362
+ - BASIC and ACCESS_TOKEN do not support refresh
202
363
  """
203
- auth_type = configurator.get_credential(AUTH_KEY)
204
- return auth_type == AuthType.OAUTH2.value
364
+ return self._auth_type in [AuthType.OAUTH2.value, AuthType.EXCHANGE.value]
205
365
 
206
- def set_request_auth(self, kwargs: dict) -> dict:
366
+ def get_auth_parameters(self, kwargs: dict) -> dict:
207
367
  """
208
- Get the authentication header.
368
+ Add authentication parameters to HTTP request arguments.
369
+
370
+ Modifies the provided kwargs dictionary to include the appropriate
371
+ authentication headers or parameters based on the current authentication
372
+ type and available credentials.
209
373
 
210
374
  Parameters
211
375
  ----------
212
376
  kwargs : dict
213
- Keyword arguments to pass to the request.
377
+ HTTP request keyword arguments to be modified with authentication.
214
378
 
215
379
  Returns
216
380
  -------
217
381
  dict
218
- Authentication header.
219
- """
220
- creds = configurator.get_all_credentials()
221
- if AUTH_KEY not in creds:
222
- return kwargs
223
- if self.basic_auth():
224
- user = creds[DhcoreEnvVar.USER.value]
225
- password = creds[DhcoreEnvVar.PASSWORD.value]
226
- kwargs["auth"] = (user, password)
227
- elif self.oauth2_auth():
382
+ The modified kwargs dictionary with authentication parameters added.
383
+
384
+ Notes
385
+ -----
386
+ Authentication is added based on auth type:
387
+ - OAUTH2/EXCHANGE/ACCESS_TOKEN: Adds Authorization Bearer header
388
+ - BASIC: Adds auth tuple with username/password
389
+ - None: No authentication added
390
+
391
+ The method assumes that:
392
+ - Authentication type has been properly set
393
+ - For EXCHANGE type, refresh token has been obtained
394
+ - Required credentials are available for the current auth type
395
+ """
396
+ creds = creds_handler.get_credentials(self._origin)
397
+ if self._auth_type in (
398
+ AuthType.EXCHANGE.value,
399
+ AuthType.OAUTH2.value,
400
+ AuthType.ACCESS_TOKEN.value,
401
+ ):
402
+ access_token = creds[CredsEnvVar.DHCORE_ACCESS_TOKEN.value]
228
403
  if "headers" not in kwargs:
229
404
  kwargs["headers"] = {}
230
- access_token = creds[DhcoreEnvVar.ACCESS_TOKEN.value.removeprefix("DHCORE_")]
231
405
  kwargs["headers"]["Authorization"] = f"Bearer {access_token}"
406
+ elif self._auth_type == AuthType.BASIC.value:
407
+ user = creds[CredsEnvVar.DHCORE_USER.value]
408
+ password = creds[CredsEnvVar.DHCORE_PASSWORD.value]
409
+ kwargs["auth"] = (user, password)
232
410
  return kwargs
233
411
 
234
- def get_new_access_token(self) -> None:
412
+ def refresh_credentials(self, change_origin: bool = False) -> None:
235
413
  """
236
- Get a new access token.
414
+ Refresh authentication credentials by obtaining new access tokens.
415
+
416
+ Performs credential refresh using either OAuth2 refresh token flow
417
+ or personal access token exchange, depending on the current
418
+ authentication type. Updates stored credentials with new tokens.
419
+
420
+ Parameters
421
+ ----------
422
+ change_origin : bool, default False
423
+ Whether to allow changing credential source if refresh fails.
424
+ If True and refresh fails, attempts to switch credential sources
425
+ and retry once.
237
426
 
238
427
  Returns
239
428
  -------
240
429
  None
430
+
431
+ Raises
432
+ ------
433
+ ClientError
434
+ If the authentication type doesn't support refresh, if required
435
+ credentials are missing, or if refresh fails and change_origin
436
+ is False.
437
+
438
+ Notes
439
+ -----
440
+ Refresh behavior by authentication type:
441
+ - OAUTH2: Uses refresh_token grant to get new access/refresh tokens
442
+ - EXCHANGE: Uses token exchange with personal access token
443
+
444
+ If refresh fails with 400/401/403 status and change_origin=True,
445
+ attempts to switch credential sources and retry once.
446
+
447
+ New credentials are automatically saved to the configuration file
448
+ and the origin is switched to file-based storage.
241
449
  """
242
- # Call issuer and get endpoint for
243
- # refreshing access token
450
+ if not self.refreshable_auth_types():
451
+ raise ClientError(f"Auth type {self._auth_type} does not support refresh.")
452
+
453
+ # Get refresh endpoint
244
454
  url = self._get_refresh_endpoint()
245
455
 
246
- # Call refresh token endpoint
247
- # Try token from env
248
- refresh_token = configurator.load_from_env(DhcoreEnvVar.REFRESH_TOKEN.value)
249
- response = self._call_refresh_token_endpoint(url, refresh_token)
456
+ # Get credentials
457
+ creds = self._creds_handler.get_credentials(self._origin)
458
+
459
+ # Get client id
460
+ if (client_id := creds.get(CredsEnvVar.DHCORE_CLIENT_ID.value)) is None:
461
+ raise ClientError("Client id not set.")
250
462
 
251
- # Otherwise try token from file
463
+ # Handling of token exchange or refresh
464
+ if self._auth_type == AuthType.OAUTH2.value:
465
+ response = self._call_refresh_endpoint(
466
+ url,
467
+ client_id=client_id,
468
+ refresh_token=creds.get(CredsEnvVar.DHCORE_REFRESH_TOKEN.value),
469
+ grant_type="refresh_token",
470
+ scope="credentials",
471
+ )
472
+ elif self._auth_type == AuthType.EXCHANGE.value:
473
+ response = self._call_refresh_endpoint(
474
+ url,
475
+ client_id=client_id,
476
+ subject_token=creds.get(CredsEnvVar.DHCORE_PERSONAL_ACCESS_TOKEN.value),
477
+ subject_token_type="urn:ietf:params:oauth:token-type:pat",
478
+ grant_type="urn:ietf:params:oauth:grant-type:token-exchange",
479
+ scope="credentials",
480
+ )
481
+
482
+ # Change origin of creds if needed
252
483
  if response.status_code in (400, 401, 403):
253
- refresh_token = configurator.load_from_file(DhcoreEnvVar.REFRESH_TOKEN.value.removeprefix("DHCORE_"))
254
- response = self._call_refresh_token_endpoint(url, refresh_token)
484
+ if not change_origin:
485
+ raise ClientError("Unable to refresh credentials. Please check your credentials.")
486
+ self.eval_change_origin()
487
+ self.refresh_credentials(change_origin=False)
255
488
 
256
489
  response.raise_for_status()
257
490
 
258
491
  # Read new credentials and propagate to config file
259
- self._set_creds(response.json())
492
+ self._export_new_creds(response.json())
260
493
 
261
- def _set_creds(self, response: dict) -> None:
494
+ def _remove_prefix_dhcore(self) -> list[str]:
262
495
  """
263
- Set new credentials.
496
+ Remove DHCORE_ prefix from selected credential keys for CLI compatibility.
264
497
 
265
- Parameters
266
- ----------
267
- response : dict
268
- Response from refresh token endpoint.
498
+ Creates a list of credential key names with "DHCORE_" prefix removed
499
+ from specific keys that are stored without the prefix in configuration
500
+ files for compatibility with CLI tools.
269
501
 
270
502
  Returns
271
503
  -------
272
- None
273
- """
274
- keys = [
275
- *self._remove_prefix_dhcore(list_enum(DhcoreEnvVar)),
276
- *list_enum(S3StoreEnv),
277
- *list_enum(SqlStoreEnv),
278
- ]
279
- for key in keys:
280
- if (value := response.get(key.lower())) is not None:
281
- configurator.set_credential(key, value)
282
- configurator.write_env(keys)
283
-
284
- def _remove_prefix_dhcore(self, keys: list[str]) -> list[str]:
285
- """
286
- Remove prefix from selected keys. (Compatibility with CLI)
504
+ list[str]
505
+ List of credential keys with selective prefix removal applied.
287
506
 
288
- Parameters
289
- ----------
290
- keys : list[str]
291
- List of keys.
507
+ Notes
508
+ -----
509
+ Keys that have prefix removed (defined in keys_to_unprefix):
510
+ - DHCORE_REFRESH_TOKEN -> refresh_token
511
+ - DHCORE_ACCESS_TOKEN -> access_token
512
+ - DHCORE_ISSUER -> issuer
513
+ - DHCORE_CLIENT_ID -> client_id
292
514
 
293
- Returns
294
- -------
295
- list[str]
296
- List of keys without prefix.
515
+ Other keys retain their full names for consistency.
297
516
  """
298
517
  new_list = []
299
- for key in keys:
300
- if key in (
301
- DhcoreEnvVar.REFRESH_TOKEN.value,
302
- DhcoreEnvVar.ACCESS_TOKEN.value,
303
- DhcoreEnvVar.ISSUER.value,
304
- DhcoreEnvVar.CLIENT_ID.value,
305
- ):
518
+ for key in self.keys:
519
+ if key in self.keys_to_unprefix:
306
520
  new_list.append(key.removeprefix("DHCORE_"))
307
521
  else:
308
522
  new_list.append(key)
@@ -310,19 +524,37 @@ class ClientDHCoreConfigurator:
310
524
 
311
525
  def _get_refresh_endpoint(self) -> str:
312
526
  """
313
- Get the refresh endpoint.
527
+ Discover the OAuth2 token refresh endpoint from the issuer.
528
+
529
+ Queries the OAuth2 issuer's well-known configuration endpoint to
530
+ discover the token endpoint used for credential refresh operations.
314
531
 
315
532
  Returns
316
533
  -------
317
534
  str
318
- Refresh endpoint.
535
+ The token endpoint URL for credential refresh.
536
+
537
+ Raises
538
+ ------
539
+ ClientError
540
+ If the issuer endpoint is not configured.
541
+ HTTPError
542
+ If the well-known configuration endpoint is not accessible.
543
+ KeyError
544
+ If the token_endpoint is not found in the issuer configuration.
545
+
546
+ Notes
547
+ -----
548
+ This method follows the OAuth2/OpenID Connect discovery standard by:
549
+ 1. Accessing the issuer's /.well-known/openid-configuration endpoint
550
+ 2. Extracting the token_endpoint from the configuration
551
+ 3. Using this endpoint for subsequent token refresh operations
319
552
  """
320
553
  # Get issuer endpoint
321
- endpoint_issuer = self._load_dhcore_oauth_vars(DhcoreEnvVar.ISSUER.value)
554
+ creds = self._creds_handler.get_credentials(self._origin)
555
+ endpoint_issuer = creds.get(CredsEnvVar.DHCORE_ISSUER.value)
322
556
  if endpoint_issuer is None:
323
557
  raise ClientError("Issuer endpoint not set.")
324
- endpoint_issuer = self._sanitize_endpoint(endpoint_issuer)
325
- configurator.set_credential(DhcoreEnvVar.ISSUER.value.removeprefix("DHCORE_"), endpoint_issuer)
326
558
 
327
559
  # Standard issuer endpoint path
328
560
  url = endpoint_issuer + "/.well-known/openid-configuration"
@@ -332,52 +564,112 @@ class ClientDHCoreConfigurator:
332
564
  r.raise_for_status()
333
565
  return r.json().get("token_endpoint")
334
566
 
335
- def _call_refresh_token_endpoint(self, url: str, refresh_token: str) -> Response:
567
+ def _call_refresh_endpoint(
568
+ self,
569
+ url: str,
570
+ **kwargs,
571
+ ) -> Response:
336
572
  """
337
- Call the refresh token endpoint.
573
+ Make HTTP request to OAuth2 token refresh endpoint.
574
+
575
+ Performs a POST request to the OAuth2 token endpoint with the
576
+ appropriate form-encoded payload for token refresh or exchange.
338
577
 
339
578
  Parameters
340
579
  ----------
341
580
  url : str
342
- Refresh token endpoint.
343
- refresh_token : str
344
- Refresh token.
581
+ The token endpoint URL to call.
582
+ **kwargs : dict
583
+ Token request parameters such as grant_type, client_id,
584
+ refresh_token, subject_token, etc.
345
585
 
346
586
  Returns
347
587
  -------
348
588
  Response
349
- Response object.
350
- """
351
- # Get client id
352
- client_id = self._load_dhcore_oauth_vars(DhcoreEnvVar.CLIENT_ID.value)
353
- if client_id is None:
354
- raise ClientError("Client id not set.")
589
+ The HTTP response object from the token endpoint.
355
590
 
591
+ Notes
592
+ -----
593
+ This method:
594
+ - Uses application/x-www-form-urlencoded content type as required by OAuth2
595
+ - Sets a 60-second timeout for the request
596
+ - Returns the raw response for caller to handle status and parsing
597
+ """
356
598
  # Send request to get new access token
357
- payload = {
358
- "grant_type": "refresh_token",
359
- "client_id": client_id,
360
- "refresh_token": refresh_token,
361
- "scope": "openid credentials offline_access",
362
- }
599
+ payload = {**kwargs}
363
600
  headers = {"Content-Type": "application/x-www-form-urlencoded"}
364
601
  return request("POST", url, data=payload, headers=headers, timeout=60)
365
602
 
366
- def _load_dhcore_oauth_vars(self, oauth_var: str) -> str | None:
603
+ def _eval_auth_type(self, creds: dict) -> str | None:
367
604
  """
368
- Load DHCore oauth variables.
605
+ Evaluate authentication type based on available credentials.
606
+
607
+ Analyzes the provided credentials and determines the most appropriate
608
+ authentication method based on which credential types are available.
369
609
 
370
610
  Parameters
371
611
  ----------
372
- oauth_var : str
373
- The oauth variable to load.
612
+ creds : dict
613
+ Dictionary containing credential values.
374
614
 
375
615
  Returns
376
616
  -------
377
- str
378
- The oauth variable.
617
+ str or None
618
+ The determined authentication type from AuthType enum, or None
619
+ if no valid authentication method can be determined.
620
+
621
+ Notes
622
+ -----
623
+ Authentication type priority (checked in order):
624
+ 1. EXCHANGE - Personal access token is available
625
+ 2. OAUTH2 - Both access token and refresh token are available
626
+ 3. ACCESS_TOKEN - Only access token is available
627
+ 4. BASIC - Both username and password are available
628
+ 5. None - No valid credential combination found
629
+ """
630
+ if creds[CredsEnvVar.DHCORE_PERSONAL_ACCESS_TOKEN.value] is not None:
631
+ return AuthType.EXCHANGE.value
632
+ if (
633
+ creds[CredsEnvVar.DHCORE_ACCESS_TOKEN.value] is not None
634
+ and creds[CredsEnvVar.DHCORE_REFRESH_TOKEN.value] is not None
635
+ ):
636
+ return AuthType.OAUTH2.value
637
+ if creds[CredsEnvVar.DHCORE_ACCESS_TOKEN.value] is not None:
638
+ return AuthType.ACCESS_TOKEN.value
639
+ if creds[CredsEnvVar.DHCORE_USER.value] is not None and creds[CredsEnvVar.DHCORE_PASSWORD.value] is not None:
640
+ return AuthType.BASIC.value
641
+ return None
642
+
643
+ def _export_new_creds(self, response: dict) -> None:
644
+ """
645
+ Save new credentials from token refresh response.
646
+
647
+ Takes the response from a successful token refresh operation and
648
+ persists the new credentials to the configuration file, then
649
+ reloads file-based credentials and switches to file origin.
650
+
651
+ Parameters
652
+ ----------
653
+ response : dict
654
+ Token response containing new access_token, refresh_token,
655
+ and other credential information.
656
+
657
+ Returns
658
+ -------
659
+ None
660
+
661
+ Notes
662
+ -----
663
+ This method:
664
+ 1. Writes new credentials to the configuration file
665
+ 2. Reloads file-based credentials to ensure consistency
666
+ 3. Changes current origin to file since new tokens are file-based
667
+
668
+ The response typically contains access_token, refresh_token,
669
+ token_type, expires_in, and other OAuth2 standard fields.
379
670
  """
380
- read_var = configurator.load_from_env(oauth_var)
381
- if read_var is None:
382
- read_var = configurator.load_from_file(oauth_var.removeprefix("DHCORE_"))
383
- return read_var
671
+ creds_handler.write_env(response)
672
+ self.load_file_vars()
673
+
674
+ # Change current origin to file because of refresh
675
+ self.change_to_file()