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.

Files changed (48) hide show
  1. pycarlo/__init__.py +0 -0
  2. pycarlo/common/__init__.py +31 -0
  3. pycarlo/common/errors.py +31 -0
  4. pycarlo/common/files.py +78 -0
  5. pycarlo/common/http.py +36 -0
  6. pycarlo/common/mcon.py +26 -0
  7. pycarlo/common/retries.py +129 -0
  8. pycarlo/common/settings.py +89 -0
  9. pycarlo/common/utils.py +51 -0
  10. pycarlo/core/__init__.py +10 -0
  11. pycarlo/core/client.py +267 -0
  12. pycarlo/core/endpoint.py +289 -0
  13. pycarlo/core/operations.py +25 -0
  14. pycarlo/core/session.py +127 -0
  15. pycarlo/features/__init__.py +10 -0
  16. pycarlo/features/circuit_breakers/__init__.py +3 -0
  17. pycarlo/features/circuit_breakers/exceptions.py +10 -0
  18. pycarlo/features/circuit_breakers/service.py +346 -0
  19. pycarlo/features/dbt/__init__.py +3 -0
  20. pycarlo/features/dbt/dbt_importer.py +208 -0
  21. pycarlo/features/dbt/queries.py +31 -0
  22. pycarlo/features/exceptions.py +18 -0
  23. pycarlo/features/metadata/__init__.py +32 -0
  24. pycarlo/features/metadata/asset_allow_block_list.py +22 -0
  25. pycarlo/features/metadata/asset_filters_container.py +79 -0
  26. pycarlo/features/metadata/base_allow_block_list.py +137 -0
  27. pycarlo/features/metadata/metadata_allow_block_list.py +94 -0
  28. pycarlo/features/metadata/metadata_filters_container.py +262 -0
  29. pycarlo/features/pii/__init__.py +5 -0
  30. pycarlo/features/pii/constants.py +3 -0
  31. pycarlo/features/pii/pii_filterer.py +179 -0
  32. pycarlo/features/pii/queries.py +20 -0
  33. pycarlo/features/pii/service.py +56 -0
  34. pycarlo/features/user/__init__.py +4 -0
  35. pycarlo/features/user/exceptions.py +10 -0
  36. pycarlo/features/user/models.py +9 -0
  37. pycarlo/features/user/queries.py +13 -0
  38. pycarlo/features/user/service.py +71 -0
  39. pycarlo/lib/README.md +35 -0
  40. pycarlo/lib/__init__.py +0 -0
  41. pycarlo/lib/schema.json +210020 -0
  42. pycarlo/lib/schema.py +82620 -0
  43. pycarlo/lib/types.py +68 -0
  44. pycarlo-0.12.24.dist-info/LICENSE +201 -0
  45. pycarlo-0.12.24.dist-info/METADATA +249 -0
  46. pycarlo-0.12.24.dist-info/RECORD +48 -0
  47. pycarlo-0.12.24.dist-info/WHEEL +5 -0
  48. 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()
@@ -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)