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.
- hotglue_singer_sdk/__init__.py +34 -0
- hotglue_singer_sdk/authenticators.py +554 -0
- hotglue_singer_sdk/cli/__init__.py +1 -0
- hotglue_singer_sdk/cli/common_options.py +37 -0
- hotglue_singer_sdk/configuration/__init__.py +1 -0
- hotglue_singer_sdk/configuration/_dict_config.py +101 -0
- hotglue_singer_sdk/exceptions.py +52 -0
- hotglue_singer_sdk/helpers/__init__.py +1 -0
- hotglue_singer_sdk/helpers/_catalog.py +122 -0
- hotglue_singer_sdk/helpers/_classproperty.py +18 -0
- hotglue_singer_sdk/helpers/_compat.py +15 -0
- hotglue_singer_sdk/helpers/_flattening.py +374 -0
- hotglue_singer_sdk/helpers/_schema.py +100 -0
- hotglue_singer_sdk/helpers/_secrets.py +41 -0
- hotglue_singer_sdk/helpers/_simpleeval.py +678 -0
- hotglue_singer_sdk/helpers/_singer.py +280 -0
- hotglue_singer_sdk/helpers/_state.py +282 -0
- hotglue_singer_sdk/helpers/_typing.py +231 -0
- hotglue_singer_sdk/helpers/_util.py +27 -0
- hotglue_singer_sdk/helpers/capabilities.py +240 -0
- hotglue_singer_sdk/helpers/jsonpath.py +39 -0
- hotglue_singer_sdk/io_base.py +134 -0
- hotglue_singer_sdk/mapper.py +691 -0
- hotglue_singer_sdk/mapper_base.py +156 -0
- hotglue_singer_sdk/plugin_base.py +415 -0
- hotglue_singer_sdk/py.typed +0 -0
- hotglue_singer_sdk/sinks/__init__.py +14 -0
- hotglue_singer_sdk/sinks/batch.py +90 -0
- hotglue_singer_sdk/sinks/core.py +412 -0
- hotglue_singer_sdk/sinks/record.py +66 -0
- hotglue_singer_sdk/sinks/sql.py +299 -0
- hotglue_singer_sdk/streams/__init__.py +14 -0
- hotglue_singer_sdk/streams/core.py +1294 -0
- hotglue_singer_sdk/streams/graphql.py +74 -0
- hotglue_singer_sdk/streams/rest.py +611 -0
- hotglue_singer_sdk/streams/sql.py +1023 -0
- hotglue_singer_sdk/tap_base.py +580 -0
- hotglue_singer_sdk/target_base.py +554 -0
- hotglue_singer_sdk/target_sdk/__init__.py +0 -0
- hotglue_singer_sdk/target_sdk/auth.py +124 -0
- hotglue_singer_sdk/target_sdk/client.py +286 -0
- hotglue_singer_sdk/target_sdk/common.py +13 -0
- hotglue_singer_sdk/target_sdk/lambda.py +121 -0
- hotglue_singer_sdk/target_sdk/rest.py +108 -0
- hotglue_singer_sdk/target_sdk/sinks.py +16 -0
- hotglue_singer_sdk/target_sdk/target.py +570 -0
- hotglue_singer_sdk/target_sdk/target_base.py +627 -0
- hotglue_singer_sdk/testing.py +198 -0
- hotglue_singer_sdk/typing.py +603 -0
- hotglue_singer_sdk-1.0.2.dist-info/METADATA +53 -0
- hotglue_singer_sdk-1.0.2.dist-info/RECORD +53 -0
- hotglue_singer_sdk-1.0.2.dist-info/WHEEL +4 -0
- 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)
|