pycarlo 0.12.24__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 pycarlo might be problematic. Click here for more details.
- pycarlo/__init__.py +0 -0
- pycarlo/common/__init__.py +31 -0
- pycarlo/common/errors.py +31 -0
- pycarlo/common/files.py +78 -0
- pycarlo/common/http.py +36 -0
- pycarlo/common/mcon.py +26 -0
- pycarlo/common/retries.py +129 -0
- pycarlo/common/settings.py +89 -0
- pycarlo/common/utils.py +51 -0
- pycarlo/core/__init__.py +10 -0
- pycarlo/core/client.py +267 -0
- pycarlo/core/endpoint.py +289 -0
- pycarlo/core/operations.py +25 -0
- pycarlo/core/session.py +127 -0
- pycarlo/features/__init__.py +10 -0
- pycarlo/features/circuit_breakers/__init__.py +3 -0
- pycarlo/features/circuit_breakers/exceptions.py +10 -0
- pycarlo/features/circuit_breakers/service.py +346 -0
- pycarlo/features/dbt/__init__.py +3 -0
- pycarlo/features/dbt/dbt_importer.py +208 -0
- pycarlo/features/dbt/queries.py +31 -0
- pycarlo/features/exceptions.py +18 -0
- pycarlo/features/metadata/__init__.py +32 -0
- pycarlo/features/metadata/asset_allow_block_list.py +22 -0
- pycarlo/features/metadata/asset_filters_container.py +79 -0
- pycarlo/features/metadata/base_allow_block_list.py +137 -0
- pycarlo/features/metadata/metadata_allow_block_list.py +94 -0
- pycarlo/features/metadata/metadata_filters_container.py +262 -0
- pycarlo/features/pii/__init__.py +5 -0
- pycarlo/features/pii/constants.py +3 -0
- pycarlo/features/pii/pii_filterer.py +179 -0
- pycarlo/features/pii/queries.py +20 -0
- pycarlo/features/pii/service.py +56 -0
- pycarlo/features/user/__init__.py +4 -0
- pycarlo/features/user/exceptions.py +10 -0
- pycarlo/features/user/models.py +9 -0
- pycarlo/features/user/queries.py +13 -0
- pycarlo/features/user/service.py +71 -0
- pycarlo/lib/README.md +35 -0
- pycarlo/lib/__init__.py +0 -0
- pycarlo/lib/schema.json +210020 -0
- pycarlo/lib/schema.py +82620 -0
- pycarlo/lib/types.py +68 -0
- pycarlo-0.12.24.dist-info/LICENSE +201 -0
- pycarlo-0.12.24.dist-info/METADATA +249 -0
- pycarlo-0.12.24.dist-info/RECORD +48 -0
- pycarlo-0.12.24.dist-info/WHEEL +5 -0
- pycarlo-0.12.24.dist-info/top_level.txt +1 -0
pycarlo/core/client.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from typing import Callable, Dict, Optional, Union, overload
|
|
3
|
+
from urllib.parse import urljoin
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from box import Box, BoxList
|
|
7
|
+
from requests import HTTPError, Timeout
|
|
8
|
+
|
|
9
|
+
from pycarlo.common import get_logger
|
|
10
|
+
from pycarlo.common.errors import InvalidSessionError
|
|
11
|
+
from pycarlo.common.retries import Backoff, ExponentialBackoffJitter, retry_with_backoff
|
|
12
|
+
from pycarlo.common.settings import (
|
|
13
|
+
DEFAULT_IGW_TIMEOUT_SECS,
|
|
14
|
+
DEFAULT_MCD_API_ID_HEADER,
|
|
15
|
+
DEFAULT_MCD_API_TOKEN_HEADER,
|
|
16
|
+
DEFAULT_MCD_SESSION_ID,
|
|
17
|
+
DEFAULT_MCD_TRACE_ID,
|
|
18
|
+
DEFAULT_MCD_USER_ID_HEADER,
|
|
19
|
+
DEFAULT_RETRY_INITIAL_WAIT_TIME,
|
|
20
|
+
DEFAULT_RETRY_MAX_WAIT_TIME,
|
|
21
|
+
HEADER_MCD_TELEMETRY_REASON,
|
|
22
|
+
RequestReason,
|
|
23
|
+
)
|
|
24
|
+
from pycarlo.core.endpoint import Endpoint
|
|
25
|
+
from pycarlo.core.operations import Mutation, Query
|
|
26
|
+
from pycarlo.core.session import Session
|
|
27
|
+
|
|
28
|
+
logger = get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Client:
|
|
32
|
+
def __init__(self, session: Optional[Session] = None):
|
|
33
|
+
"""
|
|
34
|
+
Create a client for making requests to the MCD API.
|
|
35
|
+
|
|
36
|
+
:param session: Specify a session. Otherwise, a session is created using the
|
|
37
|
+
default profile.
|
|
38
|
+
"""
|
|
39
|
+
self._session = session or Session()
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def session_id(self) -> str:
|
|
43
|
+
"""
|
|
44
|
+
Retrieves the MCD API ID from the client's current session. For helping to identify
|
|
45
|
+
requester client-side.
|
|
46
|
+
"""
|
|
47
|
+
return self._session.id
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def session_name(self) -> str:
|
|
51
|
+
"""
|
|
52
|
+
Retrieves the session name from the client's current session. For helping trace
|
|
53
|
+
requests downstream.
|
|
54
|
+
"""
|
|
55
|
+
return self._session.session_name
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def session_endpoint(self) -> str:
|
|
59
|
+
"""
|
|
60
|
+
Retrieves the session MCD endpoint from the client's current session. By default,
|
|
61
|
+
uses MCD_API_ENDPOINT.
|
|
62
|
+
"""
|
|
63
|
+
return self._session.endpoint
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def session_scope(self):
|
|
67
|
+
"""
|
|
68
|
+
Retrieves the scope from the client's current session, when a scope is set the client
|
|
69
|
+
can be used only to call REST endpoints and not GraphQL endpoints.
|
|
70
|
+
"""
|
|
71
|
+
return self._session.scope
|
|
72
|
+
|
|
73
|
+
def _get_headers(self) -> Dict:
|
|
74
|
+
"""
|
|
75
|
+
Gets headers from session for using the MCD API.
|
|
76
|
+
|
|
77
|
+
Generates a trace ID to help trace (e.g. debug) specific requests downstream.
|
|
78
|
+
Enable verbose logging to echo.
|
|
79
|
+
"""
|
|
80
|
+
headers = {
|
|
81
|
+
DEFAULT_MCD_API_ID_HEADER: self.session_id,
|
|
82
|
+
DEFAULT_MCD_API_TOKEN_HEADER: self._session.token,
|
|
83
|
+
DEFAULT_MCD_SESSION_ID: self.session_name,
|
|
84
|
+
DEFAULT_MCD_TRACE_ID: str(uuid.uuid4()),
|
|
85
|
+
# By default let's assume the request is user initiated, this will be
|
|
86
|
+
# overridden by additional headers if this is not the case:
|
|
87
|
+
HEADER_MCD_TELEMETRY_REASON: RequestReason.USER.value,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if self._session.user_id:
|
|
91
|
+
headers[DEFAULT_MCD_USER_ID_HEADER] = self._session.user_id
|
|
92
|
+
|
|
93
|
+
return headers
|
|
94
|
+
|
|
95
|
+
@overload
|
|
96
|
+
def __call__(
|
|
97
|
+
self,
|
|
98
|
+
query: Query,
|
|
99
|
+
variables: Optional[Dict] = None,
|
|
100
|
+
operation_name: Optional[str] = None,
|
|
101
|
+
retry_backoff: Backoff = ExponentialBackoffJitter(
|
|
102
|
+
DEFAULT_RETRY_INITIAL_WAIT_TIME,
|
|
103
|
+
DEFAULT_RETRY_MAX_WAIT_TIME,
|
|
104
|
+
),
|
|
105
|
+
timeout_in_seconds: int = 30,
|
|
106
|
+
idempotent_request_id: Optional[str] = None,
|
|
107
|
+
idempotent_retry_backoff: Optional[Backoff] = None,
|
|
108
|
+
response_type: Optional[str] = None,
|
|
109
|
+
additional_headers: Optional[Dict] = None,
|
|
110
|
+
) -> Query: ...
|
|
111
|
+
|
|
112
|
+
@overload
|
|
113
|
+
def __call__(
|
|
114
|
+
self,
|
|
115
|
+
query: Mutation,
|
|
116
|
+
variables: Optional[Dict] = None,
|
|
117
|
+
operation_name: Optional[str] = None,
|
|
118
|
+
retry_backoff: Backoff = ExponentialBackoffJitter(
|
|
119
|
+
DEFAULT_RETRY_INITIAL_WAIT_TIME,
|
|
120
|
+
DEFAULT_RETRY_MAX_WAIT_TIME,
|
|
121
|
+
),
|
|
122
|
+
timeout_in_seconds: int = 30,
|
|
123
|
+
idempotent_request_id: Optional[str] = None,
|
|
124
|
+
idempotent_retry_backoff: Optional[Backoff] = None,
|
|
125
|
+
response_type: Optional[str] = None,
|
|
126
|
+
additional_headers: Optional[Dict] = None,
|
|
127
|
+
) -> Mutation: ...
|
|
128
|
+
|
|
129
|
+
@overload
|
|
130
|
+
def __call__(
|
|
131
|
+
self,
|
|
132
|
+
query: str,
|
|
133
|
+
variables: Optional[Dict] = None,
|
|
134
|
+
operation_name: Optional[str] = None,
|
|
135
|
+
retry_backoff: Backoff = ExponentialBackoffJitter(
|
|
136
|
+
DEFAULT_RETRY_INITIAL_WAIT_TIME,
|
|
137
|
+
DEFAULT_RETRY_MAX_WAIT_TIME,
|
|
138
|
+
),
|
|
139
|
+
timeout_in_seconds: int = 30,
|
|
140
|
+
idempotent_request_id: Optional[str] = None,
|
|
141
|
+
idempotent_retry_backoff: Optional[Backoff] = None,
|
|
142
|
+
response_type: Optional[str] = None,
|
|
143
|
+
additional_headers: Optional[Dict] = None,
|
|
144
|
+
) -> Union[Query, Mutation, Box, BoxList]: ...
|
|
145
|
+
|
|
146
|
+
def __call__(
|
|
147
|
+
self,
|
|
148
|
+
query: Union[Query, Mutation, str],
|
|
149
|
+
variables: Optional[Dict] = None,
|
|
150
|
+
operation_name: Optional[str] = None,
|
|
151
|
+
retry_backoff: Backoff = ExponentialBackoffJitter(
|
|
152
|
+
DEFAULT_RETRY_INITIAL_WAIT_TIME,
|
|
153
|
+
DEFAULT_RETRY_MAX_WAIT_TIME,
|
|
154
|
+
),
|
|
155
|
+
timeout_in_seconds: int = 30,
|
|
156
|
+
idempotent_request_id: Optional[str] = None,
|
|
157
|
+
idempotent_retry_backoff: Optional[Backoff] = None,
|
|
158
|
+
response_type: Optional[str] = None,
|
|
159
|
+
additional_headers: Optional[Dict] = None,
|
|
160
|
+
) -> Union[Query, Mutation, Box, BoxList]:
|
|
161
|
+
"""
|
|
162
|
+
Make a request to the MCD API.
|
|
163
|
+
|
|
164
|
+
:param query: GraphQL query or mutation to execute. Can pass a string or
|
|
165
|
+
Query/Mutation object.
|
|
166
|
+
:param variables: Any variables to use with the query.
|
|
167
|
+
:param operation_name: Name of the operation.
|
|
168
|
+
:param retry_backoff: Set the retry backoff strategy. Defaults to an exponential
|
|
169
|
+
backoff strategy with jitter.
|
|
170
|
+
:param timeout_in_seconds: Set timeout of request. Requests cannot exceed 30 seconds.
|
|
171
|
+
:param additional_headers: Additional headers to include in the request.
|
|
172
|
+
|
|
173
|
+
:return: Returns a Query or Mutation object with the response if the input query was a
|
|
174
|
+
Query or Mutation object. If the input was a string a Box object containing the
|
|
175
|
+
response is returned. Raises GqlError if any errors are found in the response.
|
|
176
|
+
It will continually retry requests with errors using the provided `retry_backoff`
|
|
177
|
+
parameter.
|
|
178
|
+
|
|
179
|
+
Box is a transparent replacement for a dictionary - converting CamelCase to snake_case
|
|
180
|
+
and allowing using dot notation in lookups. Can use .to_dict() to get a regular dictionary.
|
|
181
|
+
"""
|
|
182
|
+
if self._session.scope:
|
|
183
|
+
raise InvalidSessionError(
|
|
184
|
+
"A session initialized with a scope cannot be used for GraphQL calls"
|
|
185
|
+
)
|
|
186
|
+
headers = {
|
|
187
|
+
**self._get_headers(),
|
|
188
|
+
**(additional_headers or {}), # override default headers with additional headers
|
|
189
|
+
}
|
|
190
|
+
request_info = (
|
|
191
|
+
f"idempotent request (id={idempotent_request_id})"
|
|
192
|
+
if idempotent_request_id
|
|
193
|
+
else "request"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
logger.info(
|
|
197
|
+
f"Sending {request_info} to '{self.session_endpoint}' with trace ID "
|
|
198
|
+
f"'{headers[DEFAULT_MCD_TRACE_ID]}' in named "
|
|
199
|
+
f"session '{headers[DEFAULT_MCD_SESSION_ID]}'."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
request = Endpoint(
|
|
203
|
+
url=self.session_endpoint,
|
|
204
|
+
base_headers=headers,
|
|
205
|
+
timeout=timeout_in_seconds,
|
|
206
|
+
retry_backoff=retry_backoff,
|
|
207
|
+
idempotent_retry_backoff=idempotent_retry_backoff,
|
|
208
|
+
)
|
|
209
|
+
response = request(
|
|
210
|
+
query,
|
|
211
|
+
variables=variables,
|
|
212
|
+
operation_name=operation_name,
|
|
213
|
+
idempotent_request_id=idempotent_request_id,
|
|
214
|
+
response_type=response_type,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if not isinstance(query, str):
|
|
218
|
+
return query + response
|
|
219
|
+
return Box(response, camel_killer_box=True).data
|
|
220
|
+
|
|
221
|
+
def make_request(
|
|
222
|
+
self,
|
|
223
|
+
path: str,
|
|
224
|
+
method: str = "POST",
|
|
225
|
+
body: Optional[Dict] = None,
|
|
226
|
+
retry_backoff: Backoff = ExponentialBackoffJitter(
|
|
227
|
+
DEFAULT_RETRY_INITIAL_WAIT_TIME,
|
|
228
|
+
DEFAULT_RETRY_MAX_WAIT_TIME,
|
|
229
|
+
),
|
|
230
|
+
timeout_in_seconds: int = DEFAULT_IGW_TIMEOUT_SECS,
|
|
231
|
+
should_retry: Optional[Callable[[Exception], bool]] = None,
|
|
232
|
+
) -> Optional[Dict]:
|
|
233
|
+
"""
|
|
234
|
+
Make a request to the REST API exposed by the MCD Gateway, the Session object used to
|
|
235
|
+
initialize this client must be created with a "scope" parameter.
|
|
236
|
+
|
|
237
|
+
:param path: the path in the gateway for the endpoint, for example /airflow/callbacks
|
|
238
|
+
:param method: the HTTP method to use, defaults to POST
|
|
239
|
+
:param body: the dictionary to send as the body of the request, defaults to None
|
|
240
|
+
:param retry_backoff: Set the retry backoff strategy. Defaults to an exponential backoff
|
|
241
|
+
strategy with jitter.
|
|
242
|
+
:param timeout_in_seconds: Set timeout of request, defaults to 10 seconds.
|
|
243
|
+
|
|
244
|
+
:return: Returns the JSON dictionary returned by the endpoint or None if the
|
|
245
|
+
response was empty.
|
|
246
|
+
"""
|
|
247
|
+
if not self._session.scope:
|
|
248
|
+
raise InvalidSessionError(
|
|
249
|
+
"A session initialized with a scope is required to call REST endpoints"
|
|
250
|
+
)
|
|
251
|
+
url = urljoin(self._session.endpoint, path)
|
|
252
|
+
|
|
253
|
+
@retry_with_backoff(
|
|
254
|
+
backoff=retry_backoff, exceptions=(HTTPError, Timeout), should_retry=should_retry
|
|
255
|
+
)
|
|
256
|
+
def action() -> Optional[Dict]:
|
|
257
|
+
response = requests.request(
|
|
258
|
+
url=url,
|
|
259
|
+
method=method,
|
|
260
|
+
json=body,
|
|
261
|
+
headers=self._get_headers(),
|
|
262
|
+
timeout=timeout_in_seconds,
|
|
263
|
+
)
|
|
264
|
+
response.raise_for_status()
|
|
265
|
+
return response.json() if response.content else None
|
|
266
|
+
|
|
267
|
+
return action()
|
pycarlo/core/endpoint.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
from typing import Any, Dict, Optional, Union, cast
|
|
4
|
+
|
|
5
|
+
from requests import Request, Timeout
|
|
6
|
+
from requests.exceptions import HTTPError
|
|
7
|
+
from sgqlc.endpoint.requests import RequestsEndpoint
|
|
8
|
+
|
|
9
|
+
from pycarlo.common.errors import GqlError
|
|
10
|
+
from pycarlo.common.retries import Backoff, ExponentialBackoffJitter, retry_with_backoff
|
|
11
|
+
from pycarlo.common.settings import (
|
|
12
|
+
DEFAULT_IDEMPOTENT_RETRY_INITIAL_WAIT_TIME,
|
|
13
|
+
DEFAULT_IDEMPOTENT_RETRY_MAX_WAIT_TIME,
|
|
14
|
+
)
|
|
15
|
+
from pycarlo.core.operations import Mutation, Query
|
|
16
|
+
|
|
17
|
+
X_MCD_IDEMPOTENT_ID = "x-mcd-idempotent-id"
|
|
18
|
+
X_MCD_RESPONSE_CONTENT_TYPE = "x-mcd-response-content-type"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GqlIdempotentRequestRunningError(Exception):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Endpoint(RequestsEndpoint):
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
url: str,
|
|
29
|
+
base_headers: dict,
|
|
30
|
+
timeout: float,
|
|
31
|
+
retry_backoff: Backoff,
|
|
32
|
+
idempotent_retry_backoff: Optional[Backoff] = None,
|
|
33
|
+
):
|
|
34
|
+
super(Endpoint, self).__init__(url, base_headers=base_headers, timeout=timeout)
|
|
35
|
+
self.retry_backoff = retry_backoff
|
|
36
|
+
self._idempotent_retry_backoff = idempotent_retry_backoff or ExponentialBackoffJitter(
|
|
37
|
+
DEFAULT_IDEMPOTENT_RETRY_INITIAL_WAIT_TIME,
|
|
38
|
+
DEFAULT_IDEMPOTENT_RETRY_MAX_WAIT_TIME,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def __call__(
|
|
42
|
+
self,
|
|
43
|
+
query: Union[Query, Mutation, str],
|
|
44
|
+
variables: Optional[Dict] = None,
|
|
45
|
+
operation_name: Optional[str] = None,
|
|
46
|
+
extra_headers: Optional[Dict] = None,
|
|
47
|
+
timeout: Optional[int] = None,
|
|
48
|
+
idempotent_request_id: Optional[str] = None,
|
|
49
|
+
response_type: Optional[str] = None,
|
|
50
|
+
):
|
|
51
|
+
"""
|
|
52
|
+
Overloads the inherited `__call__` method that calls the GraphQL endpoint.
|
|
53
|
+
This overload is necessary to wrap the endpoint call with the caller-specified
|
|
54
|
+
retry strategy.
|
|
55
|
+
|
|
56
|
+
:param query: the GraphQL query or mutation to execute. Note
|
|
57
|
+
that this is converted using ``bytes()``, thus one may pass
|
|
58
|
+
an object implementing ``__bytes__()`` method to return the
|
|
59
|
+
query, eventually in more compact form (no indentation, etc).
|
|
60
|
+
:type query: :class:`str` or :class:`bytes`.
|
|
61
|
+
|
|
62
|
+
:param variables: variables (dict) to use with
|
|
63
|
+
``query``. This is only useful if the query or
|
|
64
|
+
mutation contains ``$variableName``.
|
|
65
|
+
Must be a **plain JSON-serializeable object**
|
|
66
|
+
(dict with string keys and values being one of dict, list, tuple,
|
|
67
|
+
str, int, float, bool, None... -- :func:`json.dumps` is used)
|
|
68
|
+
and the keys must **match exactly** the variable names (no name
|
|
69
|
+
conversion is done, no dollar-sign prefix ``$`` should be used).
|
|
70
|
+
:type variables: dict
|
|
71
|
+
|
|
72
|
+
:param operation_name: if more than one operation is listed in
|
|
73
|
+
``query``, then it should specify the one to be executed.
|
|
74
|
+
:type operation_name: str
|
|
75
|
+
|
|
76
|
+
:param extra_headers: dict with extra HTTP headers to use.
|
|
77
|
+
:type extra_headers: dict
|
|
78
|
+
|
|
79
|
+
:param timeout: overrides the default timeout.
|
|
80
|
+
:type timeout: float
|
|
81
|
+
|
|
82
|
+
:return: dict with optional fields ``data`` containing the GraphQL
|
|
83
|
+
returned data as nested dict and ``errors`` with an array of
|
|
84
|
+
errors. Note that both ``data`` and ``errors`` may be returned!
|
|
85
|
+
:rtype: dict
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
if idempotent_request_id:
|
|
89
|
+
extra_headers = deepcopy(extra_headers) if extra_headers else {}
|
|
90
|
+
extra_headers[X_MCD_IDEMPOTENT_ID] = idempotent_request_id
|
|
91
|
+
|
|
92
|
+
if response_type:
|
|
93
|
+
extra_headers = deepcopy(extra_headers) if extra_headers else {}
|
|
94
|
+
extra_headers[X_MCD_RESPONSE_CONTENT_TYPE] = response_type
|
|
95
|
+
|
|
96
|
+
@retry_with_backoff(backoff=self.retry_backoff, exceptions=(GqlError, Timeout))
|
|
97
|
+
def action():
|
|
98
|
+
return super(Endpoint, self).__call__(
|
|
99
|
+
query,
|
|
100
|
+
variables=variables,
|
|
101
|
+
operation_name=operation_name,
|
|
102
|
+
extra_headers=extra_headers,
|
|
103
|
+
timeout=timeout,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if idempotent_request_id:
|
|
107
|
+
# wrap to keep retrying while the idempotent request is still running
|
|
108
|
+
# retry policy is different as we need to wait more time, so we use an additional
|
|
109
|
+
# retry wrapper
|
|
110
|
+
|
|
111
|
+
@retry_with_backoff(
|
|
112
|
+
backoff=self._idempotent_retry_backoff, exceptions=GqlIdempotentRequestRunningError
|
|
113
|
+
)
|
|
114
|
+
def idempotent_action():
|
|
115
|
+
self.logger.debug(f"Sending idempotent request with id={idempotent_request_id}")
|
|
116
|
+
return action()
|
|
117
|
+
|
|
118
|
+
return idempotent_action()
|
|
119
|
+
else:
|
|
120
|
+
return action()
|
|
121
|
+
|
|
122
|
+
def _log_graphql_error(self, query: str, data: Dict):
|
|
123
|
+
"""
|
|
124
|
+
Overwrites `_log_graphql_error` from :class:`sgqlc.endpoint.BaseEndpoint` in order to better
|
|
125
|
+
handle errors returned from the GraphQL response.
|
|
126
|
+
This implementation raises a :exc:`pycarlo.common.errors.GqlError` exception that wraps the
|
|
127
|
+
errors returned to allow the caller of the endpoint to decide the level of detail they'd
|
|
128
|
+
like. If there are multiple errors, the GqlError message is newline-delimited to show each
|
|
129
|
+
one. It still keeps the same logging behavior from the parent.
|
|
130
|
+
|
|
131
|
+
:param query: the GraphQL query that triggered the result.
|
|
132
|
+
:type query: str
|
|
133
|
+
|
|
134
|
+
:param data: the decoded JSON object.
|
|
135
|
+
:type data: dict
|
|
136
|
+
|
|
137
|
+
:return: the input ``data``
|
|
138
|
+
:rtype: dict
|
|
139
|
+
|
|
140
|
+
:raises: :exc:`pycarlo.common.errors.GqlError`
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
if isinstance(query, bytes): # pragma: no cover
|
|
144
|
+
query = query.decode("utf-8")
|
|
145
|
+
elif not isinstance(query, str): # pragma: no cover
|
|
146
|
+
# allows sgqlc.operation.Operation to be passed
|
|
147
|
+
# and generate compact representation of the queries
|
|
148
|
+
query = bytes(query).decode("utf-8")
|
|
149
|
+
|
|
150
|
+
data = self._fixup_graphql_error(data)
|
|
151
|
+
errors = data["errors"]
|
|
152
|
+
for i, error in enumerate(errors):
|
|
153
|
+
paths = error.get("path")
|
|
154
|
+
if paths:
|
|
155
|
+
paths = " " + "/".join(str(path) for path in paths)
|
|
156
|
+
else:
|
|
157
|
+
paths = ""
|
|
158
|
+
self.logger.info("Error #{}{}:".format(i, paths))
|
|
159
|
+
for line in error.get("message", "").split("\n"):
|
|
160
|
+
self.logger.info(" | {}".format(line))
|
|
161
|
+
|
|
162
|
+
locations = self.snippet(query, error.get("locations"))
|
|
163
|
+
if locations:
|
|
164
|
+
self.logger.info(" -")
|
|
165
|
+
self.logger.info(" | Locations:")
|
|
166
|
+
for line in locations:
|
|
167
|
+
self.logger.info(" | {}".format(line))
|
|
168
|
+
|
|
169
|
+
errors = data["errors"]
|
|
170
|
+
if isinstance(errors, list):
|
|
171
|
+
message = "\n".join([str(error["message"]) for error in errors])
|
|
172
|
+
elif isinstance(errors, dict):
|
|
173
|
+
message = str(errors["message"])
|
|
174
|
+
else:
|
|
175
|
+
message = str(errors)
|
|
176
|
+
error_code = self._get_error_code(errors)
|
|
177
|
+
if error_code == "REQUEST_IN_PROGRESS":
|
|
178
|
+
raise GqlIdempotentRequestRunningError(message)
|
|
179
|
+
if error_code == "REQUEST_TIMEOUT":
|
|
180
|
+
self.logger.error("GraphQL request timed out")
|
|
181
|
+
raise GqlError(
|
|
182
|
+
body=errors, # type: ignore
|
|
183
|
+
headers={},
|
|
184
|
+
message=message,
|
|
185
|
+
status_code=200,
|
|
186
|
+
summary=message,
|
|
187
|
+
retryable=True,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
self.logger.error("GraphQL request failed with %s errors", len(errors))
|
|
191
|
+
raise GqlError(
|
|
192
|
+
body=errors, # type: ignore
|
|
193
|
+
headers={},
|
|
194
|
+
message=message,
|
|
195
|
+
status_code=200,
|
|
196
|
+
summary=message,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def _get_error_code(body: Any) -> Optional[str]:
|
|
201
|
+
error: Optional[Dict] = None
|
|
202
|
+
if isinstance(body, list):
|
|
203
|
+
error = body[0]
|
|
204
|
+
elif isinstance(body, dict):
|
|
205
|
+
error = body
|
|
206
|
+
if not error:
|
|
207
|
+
return None
|
|
208
|
+
extensions = error.get("extensions")
|
|
209
|
+
if isinstance(extensions, dict):
|
|
210
|
+
return extensions.get("code")
|
|
211
|
+
return error.get("code")
|
|
212
|
+
|
|
213
|
+
def _log_http_error(self, query: str, request: Request, exception: HTTPError):
|
|
214
|
+
"""
|
|
215
|
+
Overwrites `_log_http_error` from :class:`sgqlc.endpoint.requests.RequestsEndpoint`
|
|
216
|
+
in order to better customize our desired way of handling
|
|
217
|
+
:exc:`requests.exceptions.HTTPError`. This implementation raises a
|
|
218
|
+
:exc:`pycarlo.common.errors.GqlError` exception in each scenario to allow
|
|
219
|
+
the caller of the endpoint to decide the level of detail they'd like of the error.
|
|
220
|
+
It still keeps the same logging behavior from the parent.
|
|
221
|
+
|
|
222
|
+
:param query: the GraphQL query that triggered the result.
|
|
223
|
+
:type query: str
|
|
224
|
+
|
|
225
|
+
:param request: :class:`requests.Request` instance that was opened.
|
|
226
|
+
:type request: :class:`requests.Request`
|
|
227
|
+
|
|
228
|
+
:param exception: :exc:`requests.exceptions.HTTPError` instance
|
|
229
|
+
:type exception: :exc:`requests.exceptions.HTTPError`
|
|
230
|
+
|
|
231
|
+
:return: GraphQL-compliant dict with keys ``data`` and ``errors``.
|
|
232
|
+
:rtype: dict
|
|
233
|
+
|
|
234
|
+
:raises: :exc:`pycarlo.common.errors.GqlError`
|
|
235
|
+
"""
|
|
236
|
+
is_timeout = exception.response.status_code == 504
|
|
237
|
+
is_idempotent = X_MCD_IDEMPOTENT_ID in request.headers
|
|
238
|
+
if not is_timeout or not is_idempotent:
|
|
239
|
+
# don't log the exception for a timeout if we sent an idempotent request, we'll retry
|
|
240
|
+
self.logger.error("log_error - %s: %s", request.url, exception)
|
|
241
|
+
|
|
242
|
+
for header in sorted(exception.response.headers):
|
|
243
|
+
self.logger.info("Response header: %s: %s", header, exception.response.headers[header])
|
|
244
|
+
|
|
245
|
+
body = cast(str, exception.response.text)
|
|
246
|
+
content_type = exception.response.headers.get("Content-Type", "")
|
|
247
|
+
self.logger.info("Response [%s]:\n%s", content_type, body)
|
|
248
|
+
if not content_type.startswith("application/json"):
|
|
249
|
+
raise GqlError(
|
|
250
|
+
body=body,
|
|
251
|
+
headers=exception.response.headers,
|
|
252
|
+
message=str(body),
|
|
253
|
+
status_code=exception.response.status_code,
|
|
254
|
+
summary=str(exception),
|
|
255
|
+
)
|
|
256
|
+
try:
|
|
257
|
+
data = json.loads(body)
|
|
258
|
+
except json.JSONDecodeError as err:
|
|
259
|
+
raise GqlError(
|
|
260
|
+
body=body,
|
|
261
|
+
headers=exception.response.headers,
|
|
262
|
+
message=str(err),
|
|
263
|
+
status_code=exception.response.status_code,
|
|
264
|
+
summary=str(err),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
if isinstance(data, dict) and data.get("errors"):
|
|
268
|
+
data.update(
|
|
269
|
+
{
|
|
270
|
+
"exception": exception,
|
|
271
|
+
"status": exception.response.status_code,
|
|
272
|
+
"headers": exception.response.headers,
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
return self._log_graphql_error(query, data)
|
|
276
|
+
|
|
277
|
+
message = cast(
|
|
278
|
+
str,
|
|
279
|
+
data.get("message")
|
|
280
|
+
if isinstance(data, dict) and data.get("message")
|
|
281
|
+
else str(exception),
|
|
282
|
+
)
|
|
283
|
+
raise GqlError(
|
|
284
|
+
body=body,
|
|
285
|
+
headers=exception.response.headers,
|
|
286
|
+
message=message,
|
|
287
|
+
status_code=exception.response.status_code,
|
|
288
|
+
summary=str(exception),
|
|
289
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from sgqlc.operation import Operation
|
|
4
|
+
|
|
5
|
+
from pycarlo.lib import schema
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Query(Operation):
|
|
9
|
+
def __init__(self, *args: Any, **kwargs: Any):
|
|
10
|
+
"""
|
|
11
|
+
An MCD Query operation.
|
|
12
|
+
|
|
13
|
+
Supports all Operation params and functionality.
|
|
14
|
+
"""
|
|
15
|
+
super().__init__(typ=schema.Query, *args, **kwargs)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Mutation(Operation):
|
|
19
|
+
def __init__(self, *args: Any, **kwargs: Any):
|
|
20
|
+
"""
|
|
21
|
+
An MCD Mutation operation.
|
|
22
|
+
|
|
23
|
+
Supports all Operation params and functionality.
|
|
24
|
+
"""
|
|
25
|
+
super().__init__(typ=schema.Mutation, *args, **kwargs)
|