hotglue-singer-sdk 1.0.2__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 (53) hide show
  1. hotglue_singer_sdk/__init__.py +34 -0
  2. hotglue_singer_sdk/authenticators.py +554 -0
  3. hotglue_singer_sdk/cli/__init__.py +1 -0
  4. hotglue_singer_sdk/cli/common_options.py +37 -0
  5. hotglue_singer_sdk/configuration/__init__.py +1 -0
  6. hotglue_singer_sdk/configuration/_dict_config.py +101 -0
  7. hotglue_singer_sdk/exceptions.py +52 -0
  8. hotglue_singer_sdk/helpers/__init__.py +1 -0
  9. hotglue_singer_sdk/helpers/_catalog.py +122 -0
  10. hotglue_singer_sdk/helpers/_classproperty.py +18 -0
  11. hotglue_singer_sdk/helpers/_compat.py +15 -0
  12. hotglue_singer_sdk/helpers/_flattening.py +374 -0
  13. hotglue_singer_sdk/helpers/_schema.py +100 -0
  14. hotglue_singer_sdk/helpers/_secrets.py +41 -0
  15. hotglue_singer_sdk/helpers/_simpleeval.py +678 -0
  16. hotglue_singer_sdk/helpers/_singer.py +280 -0
  17. hotglue_singer_sdk/helpers/_state.py +282 -0
  18. hotglue_singer_sdk/helpers/_typing.py +231 -0
  19. hotglue_singer_sdk/helpers/_util.py +27 -0
  20. hotglue_singer_sdk/helpers/capabilities.py +240 -0
  21. hotglue_singer_sdk/helpers/jsonpath.py +39 -0
  22. hotglue_singer_sdk/io_base.py +134 -0
  23. hotglue_singer_sdk/mapper.py +691 -0
  24. hotglue_singer_sdk/mapper_base.py +156 -0
  25. hotglue_singer_sdk/plugin_base.py +415 -0
  26. hotglue_singer_sdk/py.typed +0 -0
  27. hotglue_singer_sdk/sinks/__init__.py +14 -0
  28. hotglue_singer_sdk/sinks/batch.py +90 -0
  29. hotglue_singer_sdk/sinks/core.py +412 -0
  30. hotglue_singer_sdk/sinks/record.py +66 -0
  31. hotglue_singer_sdk/sinks/sql.py +299 -0
  32. hotglue_singer_sdk/streams/__init__.py +14 -0
  33. hotglue_singer_sdk/streams/core.py +1294 -0
  34. hotglue_singer_sdk/streams/graphql.py +74 -0
  35. hotglue_singer_sdk/streams/rest.py +611 -0
  36. hotglue_singer_sdk/streams/sql.py +1023 -0
  37. hotglue_singer_sdk/tap_base.py +580 -0
  38. hotglue_singer_sdk/target_base.py +554 -0
  39. hotglue_singer_sdk/target_sdk/__init__.py +0 -0
  40. hotglue_singer_sdk/target_sdk/auth.py +124 -0
  41. hotglue_singer_sdk/target_sdk/client.py +286 -0
  42. hotglue_singer_sdk/target_sdk/common.py +13 -0
  43. hotglue_singer_sdk/target_sdk/lambda.py +121 -0
  44. hotglue_singer_sdk/target_sdk/rest.py +108 -0
  45. hotglue_singer_sdk/target_sdk/sinks.py +16 -0
  46. hotglue_singer_sdk/target_sdk/target.py +570 -0
  47. hotglue_singer_sdk/target_sdk/target_base.py +627 -0
  48. hotglue_singer_sdk/testing.py +198 -0
  49. hotglue_singer_sdk/typing.py +603 -0
  50. hotglue_singer_sdk-1.0.2.dist-info/METADATA +53 -0
  51. hotglue_singer_sdk-1.0.2.dist-info/RECORD +53 -0
  52. hotglue_singer_sdk-1.0.2.dist-info/WHEEL +4 -0
  53. hotglue_singer_sdk-1.0.2.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,74 @@
1
+ """Abstract base class for API-type streams."""
2
+
3
+ import abc
4
+ from typing import Any, Optional
5
+
6
+ from hotglue_singer_sdk.helpers._classproperty import classproperty
7
+ from hotglue_singer_sdk.streams.rest import RESTStream
8
+
9
+
10
+ class GraphQLStream(RESTStream, metaclass=abc.ABCMeta):
11
+ """Abstract base class for API-type streams.
12
+
13
+ GraphQL streams inherit from the class `GraphQLStream`, which in turn inherits from
14
+ the `RESTStream` class. GraphQL streams are very similar to REST API-based streams,
15
+ but instead of specifying a `path` and `url_params`, developers override the
16
+ GraphQL query text.
17
+ """
18
+
19
+ path = ""
20
+ rest_method = "POST"
21
+
22
+ @classproperty
23
+ def records_jsonpath(cls) -> str: # type: ignore # OK: str vs @classproperty
24
+ """Get the JSONPath expression to extract records from an API response.
25
+
26
+ Returns:
27
+ JSONPath expression string
28
+ """
29
+ return f"$.data.{cls.name}[*]"
30
+
31
+ @property
32
+ def query(self) -> str:
33
+ """Set or return the GraphQL query string.
34
+
35
+ Raises:
36
+ NotImplementedError: If the derived class doesn't define this property.
37
+ """
38
+ raise NotImplementedError("GraphQLStream `query` is not defined.")
39
+
40
+ def prepare_request_payload(
41
+ self, context: Optional[dict], next_page_token: Optional[Any]
42
+ ) -> Optional[dict]:
43
+ """Prepare the data payload for the GraphQL API request.
44
+
45
+ Developers generally should generally not need to override this method.
46
+ Instead, developers set the payload by properly configuring the `query`
47
+ attribute.
48
+
49
+ Args:
50
+ context: Stream partition or context dictionary.
51
+ next_page_token: Token, page number or any request argument to request the
52
+ next page of data.
53
+
54
+ Returns:
55
+ Dictionary with the body to use for the request.
56
+
57
+ Raises:
58
+ ValueError: If the `query` property is not set in the request body.
59
+ """
60
+ params = self.get_url_params(context, next_page_token)
61
+ if self.query is None:
62
+ raise ValueError("Graphql `query` property not set.")
63
+ else:
64
+ query = self.query
65
+ if not query.lstrip().startswith("query"):
66
+ # Wrap text in "query { }" if not already wrapped
67
+ query = "query { " + query + " }"
68
+ query = query.lstrip()
69
+ request_data = {
70
+ "query": (" ".join([line.strip() for line in query.splitlines()])),
71
+ "variables": params,
72
+ }
73
+ self.logger.debug(f"Attempting query:\n{query}")
74
+ return request_data
@@ -0,0 +1,611 @@
1
+ """Abstract base class for API-type streams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import abc
6
+ import copy
7
+ import logging
8
+ from datetime import datetime
9
+ from typing import Any, Callable, Generator, Generic, Iterable, TypeVar, Union
10
+ from urllib.parse import urlparse
11
+
12
+ import backoff
13
+ import requests
14
+ from singer.schema import Schema
15
+
16
+ from hotglue_singer_sdk.authenticators import APIAuthenticatorBase, SimpleAuthenticator
17
+ from hotglue_singer_sdk.exceptions import FatalAPIError, RetriableAPIError
18
+ from hotglue_singer_sdk.helpers.jsonpath import extract_jsonpath
19
+ from hotglue_singer_sdk.plugin_base import PluginBase as TapBaseClass
20
+ from hotglue_singer_sdk.streams.core import Stream
21
+
22
+ DEFAULT_PAGE_SIZE = 1000
23
+ DEFAULT_REQUEST_TIMEOUT = 300 # 5 minutes
24
+
25
+ _TToken = TypeVar("_TToken")
26
+ _T = TypeVar("_T")
27
+ _MaybeCallable = Union[_T, Callable[[], _T]]
28
+
29
+
30
+ class RESTStream(Stream, Generic[_TToken], metaclass=abc.ABCMeta):
31
+ """Abstract base class for REST API streams."""
32
+
33
+ _page_size: int = DEFAULT_PAGE_SIZE
34
+ _requests_session: requests.Session | None
35
+ rest_method = "GET"
36
+
37
+ #: JSONPath expression to extract records from the API response.
38
+ records_jsonpath: str = "$[*]"
39
+
40
+ #: Response code reference for rate limit retries
41
+ extra_retry_statuses: list[int] = [429]
42
+
43
+ #: Optional JSONPath expression to extract a pagination token from the API response.
44
+ #: Example: `"$.next_page"`
45
+ next_page_token_jsonpath: str | None = None
46
+
47
+ # Private constants. May not be supported in future releases:
48
+ _LOG_REQUEST_METRICS: bool = True
49
+ # Disabled by default for safety:
50
+ _LOG_REQUEST_METRIC_URLS: bool = False
51
+
52
+ @property
53
+ @abc.abstractmethod
54
+ def url_base(self) -> str:
55
+ """Return the base url, e.g. ``https://api.mysite.com/v3/``."""
56
+ pass
57
+
58
+ def __init__(
59
+ self,
60
+ tap: TapBaseClass,
61
+ name: str | None = None,
62
+ schema: dict[str, Any] | Schema | None = None,
63
+ path: str | None = None,
64
+ ) -> None:
65
+ """Initialize the REST stream.
66
+
67
+ Args:
68
+ tap: Singer Tap this stream belongs to.
69
+ schema: JSON schema for records in this stream.
70
+ name: Name of this stream.
71
+ path: URL path for this entity stream.
72
+ """
73
+ super().__init__(name=name, schema=schema, tap=tap)
74
+ if path:
75
+ self.path = path
76
+ self._http_headers: dict = {}
77
+ self._requests_session = requests.Session()
78
+ self._compiled_jsonpath = None
79
+ self._next_page_token_compiled_jsonpath = None
80
+
81
+ @staticmethod
82
+ def _url_encode(val: str | datetime | bool | int | list[str]) -> str:
83
+ """Encode the val argument as url-compatible string.
84
+
85
+ Args:
86
+ val: TODO
87
+
88
+ Returns:
89
+ TODO
90
+ """
91
+ if isinstance(val, str):
92
+ result = val.replace("/", "%2F")
93
+ else:
94
+ result = str(val)
95
+ return result
96
+
97
+ def get_url(self, context: dict | None) -> str:
98
+ """Get stream entity URL.
99
+
100
+ Developers override this method to perform dynamic URL generation.
101
+
102
+ Args:
103
+ context: Stream partition or context dictionary.
104
+
105
+ Returns:
106
+ A URL, optionally targeted to a specific partition or context.
107
+ """
108
+ url = "".join([self.url_base, self.path or ""])
109
+ vals = copy.copy(dict(self.config))
110
+ vals.update(context or {})
111
+ for k, v in vals.items():
112
+ search_text = "".join(["{", k, "}"])
113
+ if search_text in url:
114
+ url = url.replace(search_text, self._url_encode(v))
115
+ return url
116
+
117
+ # HTTP Request functions
118
+
119
+ @property
120
+ def requests_session(self) -> requests.Session:
121
+ """Get requests session.
122
+
123
+ Returns:
124
+ The `requests.Session`_ object for HTTP requests.
125
+
126
+ .. _requests.Session:
127
+ https://requests.readthedocs.io/en/latest/api/#request-sessions
128
+ """
129
+ if not self._requests_session:
130
+ self._requests_session = requests.Session()
131
+ return self._requests_session
132
+
133
+ def validate_response(self, response: requests.Response) -> None:
134
+ """Validate HTTP response.
135
+
136
+ Checks for error status codes and wether they are fatal or retriable.
137
+
138
+ In case an error is deemed transient and can be safely retried, then this
139
+ method should raise an :class:`hotglue_singer_sdk.exceptions.RetriableAPIError`.
140
+ By default this applies to 5xx error codes, along with values set in:
141
+ :attr:`~hotglue_singer_sdk.RESTStream.extra_retry_statuses`
142
+
143
+ In case an error is unrecoverable raises a
144
+ :class:`hotglue_singer_sdk.exceptions.FatalAPIError`. By default, this applies to
145
+ 4xx errors, excluding values found in:
146
+ :attr:`~hotglue_singer_sdk.RESTStream.extra_retry_statuses`
147
+
148
+ Tap developers are encouraged to override this method if their APIs use HTTP
149
+ status codes in non-conventional ways, or if they communicate errors
150
+ differently (e.g. in the response body).
151
+
152
+ .. image:: ../images/200.png
153
+
154
+ Args:
155
+ response: A `requests.Response`_ object.
156
+
157
+ Raises:
158
+ FatalAPIError: If the request is not retriable.
159
+ RetriableAPIError: If the request is retriable.
160
+
161
+ .. _requests.Response:
162
+ https://requests.readthedocs.io/en/latest/api/#requests.Response
163
+ """
164
+ if (
165
+ response.status_code in self.extra_retry_statuses
166
+ or 500 <= response.status_code < 600
167
+ ):
168
+ msg = self.response_error_message(response)
169
+ raise RetriableAPIError(msg, response)
170
+ elif 400 <= response.status_code < 500:
171
+ msg = self.response_error_message(response)
172
+ raise FatalAPIError(msg)
173
+
174
+ def response_error_message(self, response: requests.Response) -> str:
175
+ """Build error message for invalid http statuses.
176
+
177
+ WARNING - Override this method when the URL path may contain secrets or PII
178
+
179
+ Args:
180
+ response: A `requests.Response`_ object.
181
+
182
+ Returns:
183
+ str: The error message
184
+ """
185
+ full_path = urlparse(response.url).path or self.path
186
+ if 400 <= response.status_code < 500:
187
+ error_type = "Client"
188
+ else:
189
+ error_type = "Server"
190
+
191
+ return (
192
+ f"{response.status_code} {error_type} Error: "
193
+ f"{response.reason} for path: {full_path}"
194
+ )
195
+
196
+ def request_decorator(self, func: Callable) -> Callable:
197
+ """Instantiate a decorator for handling request failures.
198
+
199
+ Uses a wait generator defined in `backoff_wait_generator` to
200
+ determine backoff behaviour. Try limit is defined in
201
+ `backoff_max_tries`, and will trigger the event defined in
202
+ `backoff_handler` before retrying. Developers may override one or
203
+ all of these methods to provide custom backoff or retry handling.
204
+
205
+ Args:
206
+ func: Function to decorate.
207
+
208
+ Returns:
209
+ A decorated method.
210
+ """
211
+ decorator: Callable = backoff.on_exception(
212
+ self.backoff_wait_generator,
213
+ (
214
+ RetriableAPIError,
215
+ requests.exceptions.ReadTimeout,
216
+ requests.exceptions.ConnectionError,
217
+ ),
218
+ max_tries=self.backoff_max_tries,
219
+ on_backoff=self.backoff_handler,
220
+ )(func)
221
+ return decorator
222
+
223
+ def _request(
224
+ self, prepared_request: requests.PreparedRequest, context: dict | None
225
+ ) -> requests.Response:
226
+ """TODO.
227
+
228
+ Args:
229
+ prepared_request: TODO
230
+ context: Stream partition or context dictionary.
231
+
232
+ Returns:
233
+ TODO
234
+ """
235
+ response = self.requests_session.send(prepared_request, timeout=self.timeout)
236
+ if self._LOG_REQUEST_METRICS:
237
+ extra_tags = {}
238
+ if self._LOG_REQUEST_METRIC_URLS:
239
+ extra_tags["url"] = prepared_request.path_url
240
+ self._write_request_duration_log(
241
+ endpoint=self.path,
242
+ response=response,
243
+ context=context,
244
+ extra_tags=extra_tags,
245
+ )
246
+ self.validate_response(response)
247
+ logging.debug("Response received successfully.")
248
+ return response
249
+
250
+ def get_url_params(
251
+ self, context: dict | None, next_page_token: _TToken | None
252
+ ) -> dict[str, Any]:
253
+ """Return a dictionary of values to be used in URL parameterization.
254
+
255
+ If paging is supported, developers may override with specific paging logic.
256
+
257
+ Args:
258
+ context: Stream partition or context dictionary.
259
+ next_page_token: Token, page number or any request argument to request the
260
+ next page of data.
261
+
262
+ Returns:
263
+ Dictionary of URL query parameters to use in the request.
264
+ """
265
+ return {}
266
+
267
+ def build_prepared_request(
268
+ self,
269
+ *args: Any,
270
+ **kwargs: Any,
271
+ ) -> requests.PreparedRequest:
272
+ """Build a generic but authenticated request.
273
+
274
+ Uses the authenticator instance to mutate the request with authentication.
275
+
276
+ Args:
277
+ *args: Arguments to pass to `requests.Request`_.
278
+ **kwargs: Keyword arguments to pass to `requests.Request`_.
279
+
280
+ Returns:
281
+ A `requests.PreparedRequest`_ object.
282
+
283
+ .. _requests.PreparedRequest:
284
+ https://requests.readthedocs.io/en/latest/api/#requests.PreparedRequest
285
+ .. _requests.Request:
286
+ https://requests.readthedocs.io/en/latest/api/#requests.Request
287
+ """
288
+ request = requests.Request(*args, **kwargs)
289
+
290
+ if self.authenticator:
291
+ authenticator = self.authenticator
292
+ authenticator.authenticate_request(request)
293
+
294
+ return self.requests_session.prepare_request(request)
295
+
296
+ def prepare_request(
297
+ self, context: dict | None, next_page_token: _TToken | None
298
+ ) -> requests.PreparedRequest:
299
+ """Prepare a request object for this stream.
300
+
301
+ If partitioning is supported, the `context` object will contain the partition
302
+ definitions. Pagination information can be parsed from `next_page_token` if
303
+ `next_page_token` is not None.
304
+
305
+ Args:
306
+ context: Stream partition or context dictionary.
307
+ next_page_token: Token, page number or any request argument to request the
308
+ next page of data.
309
+
310
+ Returns:
311
+ Build a request with the stream's URL, path, query parameters,
312
+ HTTP headers and authenticator.
313
+ """
314
+ http_method = self.rest_method
315
+ url: str = self.get_url(context)
316
+ params: dict = self.get_url_params(context, next_page_token)
317
+ request_data = self.prepare_request_payload(context, next_page_token)
318
+ headers = self.http_headers
319
+
320
+ return self.build_prepared_request(
321
+ method=http_method,
322
+ url=url,
323
+ params=params,
324
+ headers=headers,
325
+ json=request_data,
326
+ )
327
+
328
+ def request_records(self, context: dict | None) -> Iterable[dict]:
329
+ """Request records from REST endpoint(s), returning response records.
330
+
331
+ If pagination is detected, pages will be recursed automatically.
332
+
333
+ Args:
334
+ context: Stream partition or context dictionary.
335
+
336
+ Yields:
337
+ An item for every record in the response.
338
+
339
+ Raises:
340
+ RuntimeError: If a loop in pagination is detected. That is, when two
341
+ consecutive pagination tokens are identical.
342
+ """
343
+ next_page_token: _TToken | None = None
344
+ finished = False
345
+ decorated_request = self.request_decorator(self._request)
346
+
347
+ while not finished:
348
+ prepared_request = self.prepare_request(
349
+ context, next_page_token=next_page_token
350
+ )
351
+ resp = decorated_request(prepared_request, context)
352
+ self.update_sync_costs(prepared_request, resp, context)
353
+ yield from self.parse_response(resp)
354
+ previous_token = copy.deepcopy(next_page_token)
355
+ next_page_token = self.get_next_page_token(
356
+ response=resp, previous_token=previous_token
357
+ )
358
+ if next_page_token and next_page_token == previous_token:
359
+ raise RuntimeError(
360
+ f"Loop detected in pagination. "
361
+ f"Pagination token {next_page_token} is identical to prior token."
362
+ )
363
+ # Cycle until get_next_page_token() no longer returns a value
364
+ finished = not next_page_token
365
+
366
+ def update_sync_costs(
367
+ self,
368
+ request: requests.PreparedRequest,
369
+ response: requests.Response,
370
+ context: dict | None,
371
+ ) -> dict[str, int]:
372
+ """Update internal calculation of Sync costs.
373
+
374
+ Args:
375
+ request: the Request object that was just called.
376
+ response: the `requests.Response` object
377
+ context: the context passed to the call
378
+
379
+ Returns:
380
+ A dict of costs (for the single request) whose keys are
381
+ the "cost domains". See `calculate_sync_cost` for details.
382
+ """
383
+ call_costs = self.calculate_sync_cost(request, response, context)
384
+ self._sync_costs = {
385
+ k: self._sync_costs.get(k, 0) + call_costs.get(k, 0)
386
+ for k in call_costs.keys()
387
+ }
388
+ return self._sync_costs
389
+
390
+ # Overridable:
391
+
392
+ def calculate_sync_cost(
393
+ self,
394
+ request: requests.PreparedRequest,
395
+ response: requests.Response,
396
+ context: dict | None,
397
+ ) -> dict[str, int]:
398
+ """Calculate the cost of the last API call made.
399
+
400
+ This method can optionally be implemented in streams to calculate
401
+ the costs (in arbitrary units to be defined by the tap developer)
402
+ associated with a single API/network call. The request and response objects
403
+ are available in the callback, as well as the context.
404
+
405
+ The method returns a dict where the keys are arbitrary cost dimensions,
406
+ and the values the cost along each dimension for this one call. For
407
+ instance: { "rest": 0, "graphql": 42 } for a call to github's graphql API.
408
+ All keys should be present in the dict.
409
+
410
+ This method can be overridden by tap streams. By default it won't do
411
+ anything.
412
+
413
+ Args:
414
+ request: the API Request object that was just called.
415
+ response: the `requests.Response` object
416
+ context: the context passed to the call
417
+
418
+ Returns:
419
+ A dict of accumulated costs whose keys are the "cost domains".
420
+ """
421
+ return {}
422
+
423
+ def prepare_request_payload(
424
+ self, context: dict | None, next_page_token: _TToken | None
425
+ ) -> dict | None:
426
+ """Prepare the data payload for the REST API request.
427
+
428
+ By default, no payload will be sent (return None).
429
+
430
+ Developers may override this method if the API requires a custom payload along
431
+ with the request. (This is generally not required for APIs which use the
432
+ HTTP 'GET' method.)
433
+
434
+ Args:
435
+ context: Stream partition or context dictionary.
436
+ next_page_token: Token, page number or any request argument to request the
437
+ next page of data.
438
+
439
+ Returns:
440
+ Dictionary with the body to use for the request.
441
+ """
442
+ return None
443
+
444
+ def get_next_page_token(
445
+ self,
446
+ response: requests.Response,
447
+ previous_token: _TToken | None,
448
+ ) -> _TToken | None:
449
+ """Return token identifying next page or None if all records have been read.
450
+
451
+ Args:
452
+ response: A raw `requests.Response`_ object.
453
+ previous_token: Previous pagination reference.
454
+
455
+ Returns:
456
+ Reference value to retrieve next page.
457
+
458
+ .. _requests.Response:
459
+ https://requests.readthedocs.io/en/latest/api/#requests.Response
460
+ """
461
+ if self.next_page_token_jsonpath:
462
+ all_matches = extract_jsonpath(
463
+ self.next_page_token_jsonpath, response.json()
464
+ )
465
+ first_match = next(iter(all_matches), None)
466
+ next_page_token = first_match
467
+ else:
468
+ next_page_token = response.headers.get("X-Next-Page", None)
469
+
470
+ return next_page_token
471
+
472
+ @property
473
+ def http_headers(self) -> dict:
474
+ """Return headers dict to be used for HTTP requests.
475
+
476
+ If an authenticator is also specified, the authenticator's headers will be
477
+ combined with `http_headers` when making HTTP requests.
478
+
479
+ Returns:
480
+ Dictionary of HTTP headers to use as a base for every request.
481
+ """
482
+ result = self._http_headers
483
+ if "user_agent" in self.config:
484
+ result["User-Agent"] = self.config.get("user_agent")
485
+ return result
486
+
487
+ @property
488
+ def timeout(self) -> int:
489
+ """Return the request timeout limit in seconds.
490
+
491
+ The default timeout is 300 seconds, or as defined by DEFAULT_REQUEST_TIMEOUT.
492
+
493
+ Returns:
494
+ The request timeout limit as number of seconds.
495
+ """
496
+ return DEFAULT_REQUEST_TIMEOUT
497
+
498
+ # Records iterator
499
+
500
+ def get_records(self, context: dict | None) -> Iterable[dict[str, Any]]:
501
+ """Return a generator of row-type dictionary objects.
502
+
503
+ Each row emitted should be a dictionary of property names to their values.
504
+
505
+ Args:
506
+ context: Stream partition or context dictionary.
507
+
508
+ Yields:
509
+ One item per (possibly processed) record in the API.
510
+ """
511
+ context = context or {}
512
+ paging_windows = self.get_paging_windows(context) or [{}]
513
+ for paging_window in paging_windows:
514
+ window_context = context.copy()
515
+ window_context.update(paging_window)
516
+ for record in self.request_records(window_context):
517
+ transformed_record = self.post_process(record, window_context)
518
+ if transformed_record is None:
519
+ # Record filtered out during post_process()
520
+ continue
521
+ yield transformed_record
522
+
523
+ def parse_response(self, response: requests.Response) -> Iterable[dict]:
524
+ """Parse the response and return an iterator of result rows.
525
+
526
+ Args:
527
+ response: A raw `requests.Response`_ object.
528
+
529
+ Yields:
530
+ One item for every item found in the response.
531
+
532
+ .. _requests.Response:
533
+ https://requests.readthedocs.io/en/latest/api/#requests.Response
534
+ """
535
+ yield from extract_jsonpath(self.records_jsonpath, input=response.json())
536
+
537
+ # Abstract methods:
538
+
539
+ @property
540
+ def authenticator(self) -> APIAuthenticatorBase | None:
541
+ """Return or set the authenticator for managing HTTP auth headers.
542
+
543
+ If an authenticator is not specified, REST-based taps will simply pass
544
+ `http_headers` as defined in the stream class.
545
+
546
+ Returns:
547
+ Authenticator instance that will be used to authenticate all outgoing
548
+ requests.
549
+ """
550
+ return SimpleAuthenticator(stream=self)
551
+
552
+ def backoff_wait_generator(self) -> Callable[..., Generator[int, Any, None]]:
553
+ """The wait generator used by the backoff decorator on request failure.
554
+
555
+ See for options:
556
+ https://github.com/litl/backoff/blob/master/backoff/_wait_gen.py
557
+
558
+ And see for examples: `Code Samples <../code_samples.html#custom-backoff>`_
559
+
560
+ Returns:
561
+ The wait generator
562
+ """
563
+ return backoff.expo(factor=2) # type: ignore # ignore 'Returning Any'
564
+
565
+ def backoff_max_tries(self) -> _MaybeCallable[int] | None:
566
+ """The number of attempts before giving up when retrying requests.
567
+
568
+ Can be an integer, a zero-argument callable that returns an integer,
569
+ or ``None`` to retry indefinitely.
570
+
571
+ Returns:
572
+ int | Callable[[], int] | None: Number of max retries, callable or
573
+ ``None``.
574
+ """
575
+ return 5
576
+
577
+ def backoff_handler(self, details: dict) -> None:
578
+ """Adds additional behaviour prior to retry.
579
+
580
+ By default will log out backoff details, developers can override
581
+ to extend or change this behaviour.
582
+
583
+ Args:
584
+ details: backoff invocation details
585
+ https://github.com/litl/backoff#event-handlers
586
+ """
587
+ logging.error(
588
+ "Backing off {wait:0.1f} seconds after {tries} tries "
589
+ "calling function {target} with args {args} and kwargs "
590
+ "{kwargs}".format(**details)
591
+ )
592
+
593
+ def backoff_runtime(
594
+ self, *, value: Callable[[Any], int]
595
+ ) -> Generator[int, None, None]:
596
+ """Optional backoff wait generator that can replace the default `backoff.expo`.
597
+
598
+ It is based on parsing the thrown exception of the decorated method, making it
599
+ possible for response values to be in scope.
600
+
601
+ Args:
602
+ value: a callable which takes as input the decorated
603
+ function's thrown exception and determines how
604
+ long to wait.
605
+
606
+ Yields:
607
+ The thrown exception
608
+ """
609
+ exception = yield # type: ignore[misc]
610
+ while True:
611
+ exception = yield value(exception)