airbyte-cdk 6.34.1.dev0__py3-none-any.whl → 6.35.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.
Files changed (69) hide show
  1. airbyte_cdk/connector_builder/connector_builder_handler.py +16 -12
  2. airbyte_cdk/connector_builder/test_reader/__init__.py +7 -0
  3. airbyte_cdk/connector_builder/test_reader/helpers.py +591 -0
  4. airbyte_cdk/connector_builder/test_reader/message_grouper.py +160 -0
  5. airbyte_cdk/connector_builder/test_reader/reader.py +441 -0
  6. airbyte_cdk/connector_builder/test_reader/types.py +75 -0
  7. airbyte_cdk/sources/declarative/async_job/job_orchestrator.py +7 -7
  8. airbyte_cdk/sources/declarative/auth/jwt.py +17 -11
  9. airbyte_cdk/sources/declarative/auth/oauth.py +6 -1
  10. airbyte_cdk/sources/declarative/auth/token.py +3 -8
  11. airbyte_cdk/sources/declarative/concurrent_declarative_source.py +30 -79
  12. airbyte_cdk/sources/declarative/declarative_component_schema.yaml +213 -100
  13. airbyte_cdk/sources/declarative/declarative_stream.py +3 -1
  14. airbyte_cdk/sources/declarative/decoders/__init__.py +0 -4
  15. airbyte_cdk/sources/declarative/decoders/composite_raw_decoder.py +18 -3
  16. airbyte_cdk/sources/declarative/decoders/json_decoder.py +12 -58
  17. airbyte_cdk/sources/declarative/extractors/record_selector.py +12 -3
  18. airbyte_cdk/sources/declarative/incremental/concurrent_partition_cursor.py +56 -25
  19. airbyte_cdk/sources/declarative/incremental/datetime_based_cursor.py +12 -6
  20. airbyte_cdk/sources/declarative/incremental/global_substream_cursor.py +6 -2
  21. airbyte_cdk/sources/declarative/interpolation/__init__.py +1 -1
  22. airbyte_cdk/sources/declarative/interpolation/filters.py +2 -1
  23. airbyte_cdk/sources/declarative/interpolation/interpolated_boolean.py +1 -1
  24. airbyte_cdk/sources/declarative/interpolation/interpolated_mapping.py +1 -1
  25. airbyte_cdk/sources/declarative/interpolation/interpolated_nested_mapping.py +1 -1
  26. airbyte_cdk/sources/declarative/interpolation/interpolated_string.py +1 -1
  27. airbyte_cdk/sources/declarative/interpolation/interpolation.py +2 -1
  28. airbyte_cdk/sources/declarative/interpolation/jinja.py +14 -1
  29. airbyte_cdk/sources/declarative/interpolation/macros.py +19 -4
  30. airbyte_cdk/sources/declarative/manifest_declarative_source.py +9 -0
  31. airbyte_cdk/sources/declarative/models/declarative_component_schema.py +150 -41
  32. airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py +234 -84
  33. airbyte_cdk/sources/declarative/partition_routers/async_job_partition_router.py +5 -5
  34. airbyte_cdk/sources/declarative/partition_routers/list_partition_router.py +4 -2
  35. airbyte_cdk/sources/declarative/partition_routers/substream_partition_router.py +26 -18
  36. airbyte_cdk/sources/declarative/requesters/http_requester.py +8 -2
  37. airbyte_cdk/sources/declarative/requesters/paginators/default_paginator.py +16 -5
  38. airbyte_cdk/sources/declarative/requesters/request_option.py +83 -4
  39. airbyte_cdk/sources/declarative/requesters/request_options/datetime_based_request_options_provider.py +7 -6
  40. airbyte_cdk/sources/declarative/requesters/request_options/interpolated_nested_request_input_provider.py +1 -4
  41. airbyte_cdk/sources/declarative/requesters/request_options/interpolated_request_input_provider.py +0 -3
  42. airbyte_cdk/sources/declarative/requesters/request_options/interpolated_request_options_provider.py +2 -47
  43. airbyte_cdk/sources/declarative/retrievers/async_retriever.py +6 -12
  44. airbyte_cdk/sources/declarative/retrievers/simple_retriever.py +4 -3
  45. airbyte_cdk/sources/declarative/transformations/add_fields.py +4 -4
  46. airbyte_cdk/sources/file_based/config/abstract_file_based_spec.py +2 -1
  47. airbyte_cdk/sources/file_based/config/validate_config_transfer_modes.py +81 -0
  48. airbyte_cdk/sources/file_based/file_based_source.py +70 -37
  49. airbyte_cdk/sources/file_based/file_based_stream_reader.py +107 -12
  50. airbyte_cdk/sources/file_based/stream/__init__.py +10 -1
  51. airbyte_cdk/sources/file_based/stream/identities_stream.py +47 -0
  52. airbyte_cdk/sources/file_based/stream/permissions_file_based_stream.py +85 -0
  53. airbyte_cdk/sources/specs/transfer_modes.py +26 -0
  54. airbyte_cdk/sources/streams/call_rate.py +185 -47
  55. airbyte_cdk/sources/streams/http/http.py +1 -2
  56. airbyte_cdk/sources/streams/http/requests_native_auth/abstract_oauth.py +217 -56
  57. airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py +144 -73
  58. airbyte_cdk/sources/streams/permissions/identities_stream.py +75 -0
  59. airbyte_cdk/test/mock_http/mocker.py +9 -1
  60. airbyte_cdk/test/mock_http/response.py +6 -3
  61. airbyte_cdk/utils/datetime_helpers.py +48 -66
  62. airbyte_cdk/utils/mapping_helpers.py +126 -26
  63. {airbyte_cdk-6.34.1.dev0.dist-info → airbyte_cdk-6.35.0.dist-info}/METADATA +1 -1
  64. {airbyte_cdk-6.34.1.dev0.dist-info → airbyte_cdk-6.35.0.dist-info}/RECORD +68 -59
  65. airbyte_cdk/connector_builder/message_grouper.py +0 -448
  66. {airbyte_cdk-6.34.1.dev0.dist-info → airbyte_cdk-6.35.0.dist-info}/LICENSE.txt +0 -0
  67. {airbyte_cdk-6.34.1.dev0.dist-info → airbyte_cdk-6.35.0.dist-info}/LICENSE_SHORT +0 -0
  68. {airbyte_cdk-6.34.1.dev0.dist-info → airbyte_cdk-6.35.0.dist-info}/WHEEL +0 -0
  69. {airbyte_cdk-6.34.1.dev0.dist-info → airbyte_cdk-6.35.0.dist-info}/entry_points.txt +0 -0
@@ -25,6 +25,13 @@ logger = logging.getLogger("airbyte")
25
25
  _NOOP_MESSAGE_REPOSITORY = NoopMessageRepository()
26
26
 
27
27
 
28
+ class ResponseKeysMaxRecurtionReached(AirbyteTracedException):
29
+ """
30
+ Raised when the max level of recursion is reached, when trying to
31
+ find-and-get the target key, during the `_make_handled_request`
32
+ """
33
+
34
+
28
35
  class AbstractOauth2Authenticator(AuthBase):
29
36
  """
30
37
  Abstract class for an OAuth authenticators that implements the OAuth token refresh flow. The authenticator
@@ -53,15 +60,31 @@ class AbstractOauth2Authenticator(AuthBase):
53
60
  request.headers.update(self.get_auth_header())
54
61
  return request
55
62
 
63
+ @property
64
+ def _is_access_token_flow(self) -> bool:
65
+ return self.get_token_refresh_endpoint() is None and self.access_token is not None
66
+
67
+ @property
68
+ def token_expiry_is_time_of_expiration(self) -> bool:
69
+ """
70
+ Indicates that the Token Expiry returns the date until which the token will be valid, not the amount of time it will be valid.
71
+ """
72
+
73
+ return False
74
+
75
+ @property
76
+ def token_expiry_date_format(self) -> Optional[str]:
77
+ """
78
+ Format of the datetime; exists it if expires_in is returned as the expiration datetime instead of seconds until it expires
79
+ """
80
+
81
+ return None
82
+
56
83
  def get_auth_header(self) -> Mapping[str, Any]:
57
84
  """HTTP header to set on the requests"""
58
85
  token = self.access_token if self._is_access_token_flow else self.get_access_token()
59
86
  return {"Authorization": f"Bearer {token}"}
60
87
 
61
- @property
62
- def _is_access_token_flow(self) -> bool:
63
- return self.get_token_refresh_endpoint() is None and self.access_token is not None
64
-
65
88
  def get_access_token(self) -> str:
66
89
  """Returns the access token"""
67
90
  if self.token_has_expired():
@@ -107,9 +130,39 @@ class AbstractOauth2Authenticator(AuthBase):
107
130
  headers = self.get_refresh_request_headers()
108
131
  return headers if headers else None
109
132
 
133
+ def refresh_access_token(self) -> Tuple[str, Union[str, int]]:
134
+ """
135
+ Returns the refresh token and its expiration datetime
136
+
137
+ :return: a tuple of (access_token, token_lifespan)
138
+ """
139
+ response_json = self._make_handled_request()
140
+ self._ensure_access_token_in_response(response_json)
141
+
142
+ return (
143
+ self._extract_access_token(response_json),
144
+ self._extract_token_expiry_date(response_json),
145
+ )
146
+
147
+ # ----------------
148
+ # PRIVATE METHODS
149
+ # ----------------
150
+
110
151
  def _wrap_refresh_token_exception(
111
152
  self, exception: requests.exceptions.RequestException
112
153
  ) -> bool:
154
+ """
155
+ Wraps and handles exceptions that occur during the refresh token process.
156
+
157
+ This method checks if the provided exception is related to a refresh token error
158
+ by examining the response status code and specific error content.
159
+
160
+ Args:
161
+ exception (requests.exceptions.RequestException): The exception raised during the request.
162
+
163
+ Returns:
164
+ bool: True if the exception is related to a refresh token error, False otherwise.
165
+ """
113
166
  try:
114
167
  if exception.response is not None:
115
168
  exception_content = exception.response.json()
@@ -131,7 +184,24 @@ class AbstractOauth2Authenticator(AuthBase):
131
184
  ),
132
185
  max_time=300,
133
186
  )
134
- def _get_refresh_access_token_response(self) -> Any:
187
+ def _make_handled_request(self) -> Any:
188
+ """
189
+ Makes a handled HTTP request to refresh an OAuth token.
190
+
191
+ This method sends a POST request to the token refresh endpoint with the necessary
192
+ headers and body to obtain a new access token. It handles various exceptions that
193
+ may occur during the request and logs the response for troubleshooting purposes.
194
+
195
+ Returns:
196
+ Mapping[str, Any]: The JSON response from the token refresh endpoint.
197
+
198
+ Raises:
199
+ DefaultBackoffException: If the response status code is 429 (Too Many Requests)
200
+ or any 5xx server error.
201
+ AirbyteTracedException: If the refresh token is invalid or expired, prompting
202
+ re-authentication.
203
+ Exception: For any other exceptions that occur during the request.
204
+ """
135
205
  try:
136
206
  response = requests.request(
137
207
  method="POST",
@@ -139,22 +209,10 @@ class AbstractOauth2Authenticator(AuthBase):
139
209
  data=self.build_refresh_request_body(),
140
210
  headers=self.build_refresh_request_headers(),
141
211
  )
142
- if response.ok:
143
- response_json = response.json()
144
- # Add the access token to the list of secrets so it is replaced before logging the response
145
- # An argument could be made to remove the prevous access key from the list of secrets, but unmasking values seems like a security incident waiting to happen...
146
- access_key = response_json.get(self.get_access_token_name())
147
- if not access_key:
148
- raise Exception(
149
- "Token refresh API response was missing access token {self.get_access_token_name()}"
150
- )
151
- add_to_secrets(access_key)
152
- self._log_response(response)
153
- return response_json
154
- else:
155
- # log the response even if the request failed for troubleshooting purposes
156
- self._log_response(response)
157
- response.raise_for_status()
212
+ # log the response even if the request failed for troubleshooting purposes
213
+ self._log_response(response)
214
+ response.raise_for_status()
215
+ return response.json()
158
216
  except requests.exceptions.RequestException as e:
159
217
  if e.response is not None:
160
218
  if e.response.status_code == 429 or e.response.status_code >= 500:
@@ -168,17 +226,34 @@ class AbstractOauth2Authenticator(AuthBase):
168
226
  except Exception as e:
169
227
  raise Exception(f"Error while refreshing access token: {e}") from e
170
228
 
171
- def refresh_access_token(self) -> Tuple[str, Union[str, int]]:
229
+ def _ensure_access_token_in_response(self, response_data: Mapping[str, Any]) -> None:
172
230
  """
173
- Returns the refresh token and its expiration datetime
231
+ Ensures that the access token is present in the response data.
174
232
 
175
- :return: a tuple of (access_token, token_lifespan)
176
- """
177
- response_json = self._get_refresh_access_token_response()
233
+ This method attempts to extract the access token from the provided response data.
234
+ If the access token is not found, it raises an exception indicating that the token
235
+ refresh API response was missing the access token. If the access token is found,
236
+ it adds the token to the list of secrets to ensure it is replaced before logging
237
+ the response.
238
+
239
+ Args:
240
+ response_data (Mapping[str, Any]): The response data from which to extract the access token.
178
241
 
179
- return response_json[self.get_access_token_name()], response_json[
180
- self.get_expires_in_name()
181
- ]
242
+ Raises:
243
+ Exception: If the access token is not found in the response data.
244
+ ResponseKeysMaxRecurtionReached: If the maximum recursion depth is reached while extracting the access token.
245
+ """
246
+ try:
247
+ access_key = self._extract_access_token(response_data)
248
+ if not access_key:
249
+ raise Exception(
250
+ "Token refresh API response was missing access token {self.get_access_token_name()}"
251
+ )
252
+ # Add the access token to the list of secrets so it is replaced before logging the response
253
+ # An argument could be made to remove the prevous access key from the list of secrets, but unmasking values seems like a security incident waiting to happen...
254
+ add_to_secrets(access_key)
255
+ except ResponseKeysMaxRecurtionReached as e:
256
+ raise e
182
257
 
183
258
  def _parse_token_expiration_date(self, value: Union[str, int]) -> AirbyteDateTime:
184
259
  """
@@ -186,6 +261,9 @@ class AbstractOauth2Authenticator(AuthBase):
186
261
 
187
262
  :return: expiration datetime
188
263
  """
264
+ if not value and not self.token_has_expired():
265
+ # No expiry token was provided but the previous one is not expired so it's fine
266
+ return self.get_token_expiry_date()
189
267
 
190
268
  if self.token_expiry_is_time_of_expiration:
191
269
  if not self.token_expiry_date_format:
@@ -206,22 +284,125 @@ class AbstractOauth2Authenticator(AuthBase):
206
284
  f"Invalid expires_in value: {value}. Expected number of seconds when no format specified."
207
285
  )
208
286
 
209
- @property
210
- def token_expiry_is_time_of_expiration(self) -> bool:
287
+ def _extract_access_token(self, response_data: Mapping[str, Any]) -> Any:
211
288
  """
212
- Indicates that the Token Expiry returns the date until which the token will be valid, not the amount of time it will be valid.
289
+ Extracts the access token from the given response data.
290
+
291
+ Args:
292
+ response_data (Mapping[str, Any]): The response data from which to extract the access token.
293
+
294
+ Returns:
295
+ str: The extracted access token.
213
296
  """
297
+ return self._find_and_get_value_from_response(response_data, self.get_access_token_name())
214
298
 
215
- return False
299
+ def _extract_refresh_token(self, response_data: Mapping[str, Any]) -> Any:
300
+ """
301
+ Extracts the refresh token from the given response data.
216
302
 
217
- @property
218
- def token_expiry_date_format(self) -> Optional[str]:
303
+ Args:
304
+ response_data (Mapping[str, Any]): The response data from which to extract the refresh token.
305
+
306
+ Returns:
307
+ str: The extracted refresh token.
219
308
  """
220
- Format of the datetime; exists it if expires_in is returned as the expiration datetime instead of seconds until it expires
309
+ return self._find_and_get_value_from_response(response_data, self.get_refresh_token_name())
310
+
311
+ def _extract_token_expiry_date(self, response_data: Mapping[str, Any]) -> Any:
312
+ """
313
+ Extracts the token_expiry_date, like `expires_in` or `expires_at`, etc from the given response data.
314
+
315
+ Args:
316
+ response_data (Mapping[str, Any]): The response data from which to extract the token_expiry_date.
317
+
318
+ Returns:
319
+ str: The extracted token_expiry_date.
221
320
  """
321
+ return self._find_and_get_value_from_response(response_data, self.get_expires_in_name())
322
+
323
+ def _find_and_get_value_from_response(
324
+ self,
325
+ response_data: Mapping[str, Any],
326
+ key_name: str,
327
+ max_depth: int = 5,
328
+ current_depth: int = 0,
329
+ ) -> Any:
330
+ """
331
+ Recursively searches for a specified key in a nested dictionary or list and returns its value if found.
332
+
333
+ Args:
334
+ response_data (Mapping[str, Any]): The response data to search through, which can be a dictionary or a list.
335
+ key_name (str): The key to search for in the response data.
336
+ max_depth (int, optional): The maximum depth to search for the key to avoid infinite recursion. Defaults to 5.
337
+ current_depth (int, optional): The current depth of the recursion. Defaults to 0.
338
+
339
+ Returns:
340
+ Any: The value associated with the specified key if found, otherwise None.
341
+
342
+ Raises:
343
+ AirbyteTracedException: If the maximum recursion depth is reached without finding the key.
344
+ """
345
+ if current_depth > max_depth:
346
+ # this is needed to avoid an inf loop, possible with a very deep nesting observed.
347
+ message = f"The maximum level of recursion is reached. Couldn't find the speficied `{key_name}` in the response."
348
+ raise ResponseKeysMaxRecurtionReached(
349
+ internal_message=message, message=message, failure_type=FailureType.config_error
350
+ )
351
+
352
+ if isinstance(response_data, dict):
353
+ # get from the root level
354
+ if key_name in response_data:
355
+ return response_data[key_name]
356
+
357
+ # get from the nested object
358
+ for _, value in response_data.items():
359
+ result = self._find_and_get_value_from_response(
360
+ value, key_name, max_depth, current_depth + 1
361
+ )
362
+ if result is not None:
363
+ return result
364
+
365
+ # get from the nested array object
366
+ elif isinstance(response_data, list):
367
+ for item in response_data:
368
+ result = self._find_and_get_value_from_response(
369
+ item, key_name, max_depth, current_depth + 1
370
+ )
371
+ if result is not None:
372
+ return result
222
373
 
223
374
  return None
224
375
 
376
+ @property
377
+ def _message_repository(self) -> Optional[MessageRepository]:
378
+ """
379
+ The implementation can define a message_repository if it wants debugging logs for HTTP requests
380
+ """
381
+ return _NOOP_MESSAGE_REPOSITORY
382
+
383
+ def _log_response(self, response: requests.Response) -> None:
384
+ """
385
+ Logs the HTTP response using the message repository if it is available.
386
+
387
+ Args:
388
+ response (requests.Response): The HTTP response to log.
389
+ """
390
+ if self._message_repository:
391
+ self._message_repository.log_message(
392
+ Level.DEBUG,
393
+ lambda: format_http_message(
394
+ response,
395
+ "Refresh token",
396
+ "Obtains access token",
397
+ self._NO_STREAM_NAME,
398
+ is_auxiliary=True,
399
+ ),
400
+ )
401
+
402
+ # ----------------
403
+ # ABSTR METHODS
404
+ # ----------------
405
+
225
406
  @abstractmethod
226
407
  def get_token_refresh_endpoint(self) -> Optional[str]:
227
408
  """Returns the endpoint to refresh the access token"""
@@ -295,23 +476,3 @@ class AbstractOauth2Authenticator(AuthBase):
295
476
  @abstractmethod
296
477
  def access_token(self, value: str) -> str:
297
478
  """Setter for the access token"""
298
-
299
- @property
300
- def _message_repository(self) -> Optional[MessageRepository]:
301
- """
302
- The implementation can define a message_repository if it wants debugging logs for HTTP requests
303
- """
304
- return _NOOP_MESSAGE_REPOSITORY
305
-
306
- def _log_response(self, response: requests.Response) -> None:
307
- if self._message_repository:
308
- self._message_repository.log_message(
309
- Level.DEBUG,
310
- lambda: format_http_message(
311
- response,
312
- "Refresh token",
313
- "Obtains access token",
314
- self._NO_STREAM_NAME,
315
- is_auxiliary=True,
316
- ),
317
- )
@@ -51,7 +51,7 @@ class Oauth2Authenticator(AbstractOauth2Authenticator):
51
51
  refresh_token_error_status_codes: Tuple[int, ...] = (),
52
52
  refresh_token_error_key: str = "",
53
53
  refresh_token_error_values: Tuple[str, ...] = (),
54
- ):
54
+ ) -> None:
55
55
  self._token_refresh_endpoint = token_refresh_endpoint
56
56
  self._client_secret_name = client_secret_name
57
57
  self._client_secret = client_secret
@@ -175,7 +175,7 @@ class SingleUseRefreshTokenOauth2Authenticator(Oauth2Authenticator):
175
175
  refresh_token_error_status_codes: Tuple[int, ...] = (),
176
176
  refresh_token_error_key: str = "",
177
177
  refresh_token_error_values: Tuple[str, ...] = (),
178
- ):
178
+ ) -> None:
179
179
  """
180
180
  Args:
181
181
  connector_config (Mapping[str, Any]): The full connector configuration
@@ -196,18 +196,12 @@ class SingleUseRefreshTokenOauth2Authenticator(Oauth2Authenticator):
196
196
  token_expiry_is_time_of_expiration bool: set True it if expires_in is returned as time of expiration instead of the number seconds until expiration
197
197
  message_repository (MessageRepository): the message repository used to emit logs on HTTP requests and control message on config update
198
198
  """
199
- self._client_id = (
200
- client_id # type: ignore[assignment] # Incorrect type for assignment
201
- if client_id is not None
202
- else dpath.get(connector_config, ("credentials", "client_id")) # type: ignore[arg-type]
199
+ self._connector_config = connector_config
200
+ self._client_id: str = self._get_config_value_by_path(
201
+ ("credentials", "client_id"), client_id
203
202
  )
204
- self._client_secret = (
205
- client_secret # type: ignore[assignment] # Incorrect type for assignment
206
- if client_secret is not None
207
- else dpath.get(
208
- connector_config, # type: ignore[arg-type]
209
- ("credentials", "client_secret"),
210
- )
203
+ self._client_secret: str = self._get_config_value_by_path(
204
+ ("credentials", "client_secret"), client_secret
211
205
  )
212
206
  self._client_id_name = client_id_name
213
207
  self._client_secret_name = client_secret_name
@@ -222,9 +216,9 @@ class SingleUseRefreshTokenOauth2Authenticator(Oauth2Authenticator):
222
216
  super().__init__(
223
217
  token_refresh_endpoint=token_refresh_endpoint,
224
218
  client_id_name=self._client_id_name,
225
- client_id=self.get_client_id(),
219
+ client_id=self._client_id,
226
220
  client_secret_name=self._client_secret_name,
227
- client_secret=self.get_client_secret(),
221
+ client_secret=self._client_secret,
228
222
  refresh_token=self.get_refresh_token(),
229
223
  refresh_token_name=self._refresh_token_name,
230
224
  scopes=scopes,
@@ -242,51 +236,62 @@ class SingleUseRefreshTokenOauth2Authenticator(Oauth2Authenticator):
242
236
  refresh_token_error_values=refresh_token_error_values,
243
237
  )
244
238
 
245
- def get_refresh_token_name(self) -> str:
246
- return self._refresh_token_name
247
-
248
- def get_client_id(self) -> str:
249
- return self._client_id
250
-
251
- def get_client_secret(self) -> str:
252
- return self._client_secret
253
-
254
239
  @property
255
240
  def access_token(self) -> str:
256
- return dpath.get( # type: ignore[return-value]
257
- self._connector_config, # type: ignore[arg-type]
258
- self._access_token_config_path,
259
- default="",
260
- )
241
+ """
242
+ Retrieve the access token from the configuration.
243
+
244
+ Returns:
245
+ str: The access token.
246
+ """
247
+ return self._get_config_value_by_path(self._access_token_config_path) # type: ignore[return-value]
261
248
 
262
249
  @access_token.setter
263
250
  def access_token(self, new_access_token: str) -> None:
264
- dpath.new(
265
- self._connector_config, # type: ignore[arg-type]
266
- self._access_token_config_path,
267
- new_access_token,
268
- )
251
+ """
252
+ Sets a new access token.
253
+
254
+ Args:
255
+ new_access_token (str): The new access token to be set.
256
+ """
257
+ self._set_config_value_by_path(self._access_token_config_path, new_access_token)
269
258
 
270
259
  def get_refresh_token(self) -> str:
271
- return dpath.get( # type: ignore[return-value]
272
- self._connector_config, # type: ignore[arg-type]
273
- self._refresh_token_config_path,
274
- default="",
275
- )
260
+ """
261
+ Retrieve the refresh token from the configuration.
262
+
263
+ This method fetches the refresh token using the configuration path specified
264
+ by `_refresh_token_config_path`.
265
+
266
+ Returns:
267
+ str: The refresh token as a string.
268
+ """
269
+ return self._get_config_value_by_path(self._refresh_token_config_path) # type: ignore[return-value]
276
270
 
277
271
  def set_refresh_token(self, new_refresh_token: str) -> None:
278
- dpath.new(
279
- self._connector_config, # type: ignore[arg-type]
280
- self._refresh_token_config_path,
281
- new_refresh_token,
282
- )
272
+ """
273
+ Updates the refresh token in the configuration.
274
+
275
+ Args:
276
+ new_refresh_token (str): The new refresh token to be set.
277
+ """
278
+ self._set_config_value_by_path(self._refresh_token_config_path, new_refresh_token)
283
279
 
284
280
  def get_token_expiry_date(self) -> AirbyteDateTime:
285
- expiry_date = dpath.get(
286
- self._connector_config, # type: ignore[arg-type]
287
- self._token_expiry_date_config_path,
288
- default="",
289
- )
281
+ """
282
+ Retrieves the token expiry date from the configuration.
283
+
284
+ This method fetches the token expiry date from the configuration using the specified path.
285
+ If the expiry date is an empty string, it returns the current date and time minus one day.
286
+ Otherwise, it parses the expiry date string into an AirbyteDateTime object.
287
+
288
+ Returns:
289
+ AirbyteDateTime: The parsed or calculated token expiry date.
290
+
291
+ Raises:
292
+ TypeError: If the result is not an instance of AirbyteDateTime.
293
+ """
294
+ expiry_date = self._get_config_value_by_path(self._token_expiry_date_config_path)
290
295
  result = (
291
296
  ab_datetime_now() - timedelta(days=1)
292
297
  if expiry_date == ""
@@ -296,14 +301,15 @@ class SingleUseRefreshTokenOauth2Authenticator(Oauth2Authenticator):
296
301
  return result
297
302
  raise TypeError("Invalid datetime conversion")
298
303
 
299
- def set_token_expiry_date( # type: ignore[override]
300
- self,
301
- new_token_expiry_date: AirbyteDateTime,
302
- ) -> None:
303
- dpath.new(
304
- self._connector_config, # type: ignore[arg-type]
305
- self._token_expiry_date_config_path,
306
- str(new_token_expiry_date),
304
+ def set_token_expiry_date(self, new_token_expiry_date: AirbyteDateTime) -> None: # type: ignore[override]
305
+ """
306
+ Sets the token expiry date in the configuration.
307
+
308
+ Args:
309
+ new_token_expiry_date (AirbyteDateTime): The new expiry date for the token.
310
+ """
311
+ self._set_config_value_by_path(
312
+ self._token_expiry_date_config_path, str(new_token_expiry_date)
307
313
  )
308
314
 
309
315
  def token_has_expired(self) -> bool:
@@ -315,6 +321,16 @@ class SingleUseRefreshTokenOauth2Authenticator(Oauth2Authenticator):
315
321
  access_token_expires_in: str,
316
322
  token_expiry_date_format: str | None = None,
317
323
  ) -> AirbyteDateTime:
324
+ """
325
+ Calculate the new token expiry date based on the provided expiration duration or format.
326
+
327
+ Args:
328
+ access_token_expires_in (str): The duration (in seconds) until the access token expires, or the expiry date in a specific format.
329
+ token_expiry_date_format (str | None, optional): The format of the expiry date if provided. Defaults to None.
330
+
331
+ Returns:
332
+ AirbyteDateTime: The calculated expiry date of the access token.
333
+ """
318
334
  if token_expiry_date_format:
319
335
  return ab_datetime_parse(access_token_expires_in)
320
336
  else:
@@ -336,27 +352,82 @@ class SingleUseRefreshTokenOauth2Authenticator(Oauth2Authenticator):
336
352
  self.access_token = new_access_token
337
353
  self.set_refresh_token(new_refresh_token)
338
354
  self.set_token_expiry_date(new_token_expiry_date)
339
- # FIXME emit_configuration_as_airbyte_control_message as been deprecated in favor of package airbyte_cdk.sources.message
340
- # Usually, a class shouldn't care about the implementation details but to keep backward compatibility where we print the
341
- # message directly in the console, this is needed
342
- if not isinstance(self._message_repository, NoopMessageRepository):
343
- self._message_repository.emit_message(
344
- create_connector_config_control_message(self._connector_config) # type: ignore[arg-type]
345
- )
346
- else:
347
- emit_configuration_as_airbyte_control_message(self._connector_config) # type: ignore[arg-type]
355
+ self._emit_control_message()
348
356
  return self.access_token
349
357
 
350
- def refresh_access_token( # type: ignore[override] # Signature doesn't match base class
351
- self,
352
- ) -> Tuple[str, str, str]:
353
- response_json = self._get_refresh_access_token_response()
358
+ def refresh_access_token(self) -> Tuple[str, str, str]: # type: ignore[override]
359
+ """
360
+ Refreshes the access token by making a handled request and extracting the necessary token information.
361
+
362
+ Returns:
363
+ Tuple[str, str, str]: A tuple containing the new access token, token expiry date, and refresh token.
364
+ """
365
+ response_json = self._make_handled_request()
354
366
  return (
355
- response_json[self.get_access_token_name()],
356
- response_json[self.get_expires_in_name()],
357
- response_json[self.get_refresh_token_name()],
367
+ self._extract_access_token(response_json),
368
+ self._extract_token_expiry_date(response_json),
369
+ self._extract_refresh_token(response_json),
370
+ )
371
+
372
+ def _set_config_value_by_path(self, config_path: Union[str, Sequence[str]], value: Any) -> None:
373
+ """
374
+ Set a value in the connector configuration at the specified path.
375
+
376
+ Args:
377
+ config_path (Union[str, Sequence[str]]): The path within the configuration where the value should be set.
378
+ This can be a string representing a single key or a sequence of strings representing a nested path.
379
+ value (Any): The value to set at the specified path in the configuration.
380
+
381
+ Returns:
382
+ None
383
+ """
384
+ dpath.new(self._connector_config, config_path, value) # type: ignore[arg-type]
385
+
386
+ def _get_config_value_by_path(
387
+ self, config_path: Union[str, Sequence[str]], default: Optional[str] = None
388
+ ) -> str | Any:
389
+ """
390
+ Retrieve a value from the connector configuration using a specified path.
391
+
392
+ Args:
393
+ config_path (Union[str, Sequence[str]]): The path to the desired configuration value. This can be a string or a sequence of strings.
394
+ default (Optional[str], optional): The default value to return if the specified path does not exist in the configuration. Defaults to None.
395
+
396
+ Returns:
397
+ Any: The value from the configuration at the specified path, or the default value if the path does not exist.
398
+ """
399
+ return dpath.get(
400
+ self._connector_config, # type: ignore[arg-type]
401
+ config_path,
402
+ default=default if default is not None else "",
358
403
  )
359
404
 
405
+ def _emit_control_message(self) -> None:
406
+ """
407
+ Emits a control message based on the connector configuration.
408
+
409
+ This method checks if the message repository is not a NoopMessageRepository.
410
+ If it is not, it emits a message using the message repository. Otherwise,
411
+ it falls back to emitting the configuration as an Airbyte control message
412
+ directly to the console for backward compatibility.
413
+
414
+ Note:
415
+ The function `emit_configuration_as_airbyte_control_message` has been deprecated
416
+ in favor of the package `airbyte_cdk.sources.message`.
417
+
418
+ Raises:
419
+ TypeError: If the argument types are incorrect.
420
+ """
421
+ # FIXME emit_configuration_as_airbyte_control_message as been deprecated in favor of package airbyte_cdk.sources.message
422
+ # Usually, a class shouldn't care about the implementation details but to keep backward compatibility where we print the
423
+ # message directly in the console, this is needed
424
+ if not isinstance(self._message_repository, NoopMessageRepository):
425
+ self._message_repository.emit_message(
426
+ create_connector_config_control_message(self._connector_config) # type: ignore[arg-type]
427
+ )
428
+ else:
429
+ emit_configuration_as_airbyte_control_message(self._connector_config) # type: ignore[arg-type]
430
+
360
431
  @property
361
432
  def _message_repository(self) -> MessageRepository:
362
433
  """