schemathesis 3.19.7__py3-none-any.whl → 3.20.1__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 (46) hide show
  1. schemathesis/_compat.py +3 -2
  2. schemathesis/_hypothesis.py +21 -6
  3. schemathesis/_xml.py +177 -0
  4. schemathesis/auths.py +48 -10
  5. schemathesis/cli/__init__.py +77 -19
  6. schemathesis/cli/callbacks.py +42 -18
  7. schemathesis/cli/context.py +2 -1
  8. schemathesis/cli/output/default.py +102 -34
  9. schemathesis/cli/sanitization.py +15 -0
  10. schemathesis/code_samples.py +141 -0
  11. schemathesis/constants.py +1 -24
  12. schemathesis/exceptions.py +127 -26
  13. schemathesis/experimental/__init__.py +85 -0
  14. schemathesis/extra/pytest_plugin.py +10 -4
  15. schemathesis/fixups/__init__.py +8 -2
  16. schemathesis/fixups/fast_api.py +11 -1
  17. schemathesis/fixups/utf8_bom.py +7 -1
  18. schemathesis/hooks.py +63 -0
  19. schemathesis/lazy.py +10 -4
  20. schemathesis/loaders.py +57 -0
  21. schemathesis/models.py +120 -96
  22. schemathesis/parameters.py +3 -0
  23. schemathesis/runner/__init__.py +3 -0
  24. schemathesis/runner/events.py +55 -20
  25. schemathesis/runner/impl/core.py +54 -54
  26. schemathesis/runner/serialization.py +75 -34
  27. schemathesis/sanitization.py +248 -0
  28. schemathesis/schemas.py +21 -6
  29. schemathesis/serializers.py +32 -3
  30. schemathesis/service/serialization.py +5 -1
  31. schemathesis/specs/graphql/loaders.py +44 -13
  32. schemathesis/specs/graphql/schemas.py +56 -25
  33. schemathesis/specs/openapi/_hypothesis.py +11 -23
  34. schemathesis/specs/openapi/definitions.py +572 -0
  35. schemathesis/specs/openapi/loaders.py +100 -49
  36. schemathesis/specs/openapi/parameters.py +2 -2
  37. schemathesis/specs/openapi/schemas.py +87 -13
  38. schemathesis/specs/openapi/security.py +1 -0
  39. schemathesis/stateful.py +2 -2
  40. schemathesis/utils.py +30 -9
  41. schemathesis-3.20.1.dist-info/METADATA +342 -0
  42. {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/RECORD +45 -39
  43. schemathesis-3.19.7.dist-info/METADATA +0 -291
  44. {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/WHEEL +0 -0
  45. {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/entry_points.txt +0 -0
  46. {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/licenses/LICENSE +0 -0
schemathesis/models.py CHANGED
@@ -1,10 +1,12 @@
1
1
  import base64
2
2
  import datetime
3
3
  import http
4
+ import inspect
4
5
  from collections import Counter
5
6
  from contextlib import contextmanager
6
7
  from dataclasses import dataclass, field
7
8
  from enum import Enum
9
+ from functools import partial
8
10
  from itertools import chain
9
11
  from logging import LogRecord
10
12
  from typing import (
@@ -26,9 +28,7 @@ from typing import (
26
28
  cast,
27
29
  )
28
30
  from urllib.parse import quote, unquote, urljoin, urlparse, urlsplit, urlunsplit
29
- from uuid import uuid4
30
31
 
31
- import curlify
32
32
  import requests.auth
33
33
  import werkzeug
34
34
  from hypothesis import event, note, reject
@@ -37,19 +37,20 @@ from requests.structures import CaseInsensitiveDict
37
37
  from starlette_testclient import TestClient as ASGIClient
38
38
 
39
39
  from . import failures, serializers
40
+ from ._compat import IS_WERKZEUG_ABOVE_3
40
41
  from .auths import AuthStorage
42
+ from .code_samples import CodeSampleStyle
41
43
  from .constants import (
42
44
  DEFAULT_RESPONSE_TIMEOUT,
43
45
  SCHEMATHESIS_TEST_CASE_HEADER,
44
46
  SERIALIZERS_SUGGESTION_MESSAGE,
45
47
  USER_AGENT,
46
- CodeSampleStyle,
47
48
  DataGenerationMethod,
48
49
  )
49
50
  from .exceptions import (
50
51
  CheckFailed,
51
52
  FailureContext,
52
- InvalidSchema,
53
+ OperationSchemaError,
53
54
  SerializationNotPossible,
54
55
  deduplicate_failed_checks,
55
56
  get_grouped_exception,
@@ -57,16 +58,17 @@ from .exceptions import (
57
58
  )
58
59
  from .hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher, dispatch
59
60
  from .parameters import Parameter, ParameterSet, PayloadAlternatives
61
+ from .sanitization import sanitize_request, sanitize_response
60
62
  from .serializers import Serializer, SerializerContext
61
63
  from .types import Body, Cookies, FormData, Headers, NotSet, PathParameters, Query
62
64
  from .utils import (
63
- IGNORED_HEADERS,
64
65
  NOT_SET,
65
66
  GenericResponse,
66
67
  WSGIResponse,
67
68
  copy_response,
68
69
  deprecated_property,
69
70
  fast_deepcopy,
71
+ generate_random_case_id,
70
72
  get_response_payload,
71
73
  maybe_set_assertion_message,
72
74
  )
@@ -98,11 +100,33 @@ def cant_serialize(media_type: str) -> NoReturn: # type: ignore
98
100
  reject() # type: ignore
99
101
 
100
102
 
103
+ REQUEST_SIGNATURE = inspect.signature(requests.Request)
104
+
105
+
106
+ @dataclass()
107
+ class PreparedRequestData:
108
+ method: str
109
+ url: str
110
+ body: Optional[Union[str, bytes]]
111
+ headers: Headers
112
+
113
+
114
+ def prepare_request_data(kwargs: Dict[str, Any]) -> PreparedRequestData:
115
+ """Prepare request data for generating code samples."""
116
+ kwargs = {key: value for key, value in kwargs.items() if key in REQUEST_SIGNATURE.parameters}
117
+ request = requests.Request(**kwargs).prepare()
118
+ return PreparedRequestData(
119
+ method=str(request.method), url=str(request.url), body=request.body, headers=dict(request.headers)
120
+ )
121
+
122
+
101
123
  @dataclass(repr=False)
102
124
  class Case:
103
125
  """A single test case parameters."""
104
126
 
105
127
  operation: "APIOperation"
128
+ # Unique test case identifier
129
+ id: str = field(default_factory=generate_random_case_id, compare=False)
106
130
  path_parameters: Optional[PathParameters] = None
107
131
  headers: Optional[CaseInsensitiveDict] = None
108
132
  cookies: Optional[Cookies] = None
@@ -110,14 +134,12 @@ class Case:
110
134
  # By default, there is no body, but we can't use `None` as the default value because it clashes with `null`
111
135
  # which is a valid payload.
112
136
  body: Union[Body, NotSet] = NOT_SET
113
-
114
- source: Optional[CaseSource] = None
115
137
  # The media type for cases with a payload. For example, "application/json"
116
138
  media_type: Optional[str] = None
139
+ source: Optional[CaseSource] = None
140
+
117
141
  # The way the case was generated (None for manually crafted ones)
118
142
  data_generation_method: Optional[DataGenerationMethod] = None
119
- # Unique test case identifier
120
- id: str = field(default_factory=lambda: uuid4().hex, compare=False)
121
143
  _auth: Optional[requests.auth.AuthBase] = None
122
144
 
123
145
  def __repr__(self) -> str:
@@ -171,10 +193,10 @@ class Case:
171
193
  # This may happen when a path template has a placeholder for variable "X", but parameter "X" is not defined
172
194
  # in the parameters list.
173
195
  # When `exc` is formatted, it is the missing key name in quotes. E.g. 'id'
174
- raise InvalidSchema(f"Path parameter {exc} is not defined") from exc
196
+ raise OperationSchemaError(f"Path parameter {exc} is not defined") from exc
175
197
  except ValueError as exc:
176
198
  # A single unmatched `}` inside the path template may cause this
177
- raise InvalidSchema(f"Malformed path template: `{self.path}`\n\n {exc}") from exc
199
+ raise OperationSchemaError(f"Malformed path template: `{self.path}`\n\n {exc}") from exc
178
200
 
179
201
  def get_full_base_url(self) -> Optional[str]:
180
202
  """Create a full base url, adding "localhost" for WSGI apps."""
@@ -184,59 +206,49 @@ class Case:
184
206
  return urlunsplit(("http", "localhost", path or "", "", ""))
185
207
  return self.base_url
186
208
 
209
+ def prepare_code_sample_data(self, headers: Optional[Dict[str, Any]]) -> PreparedRequestData:
210
+ base_url = self.get_full_base_url()
211
+ kwargs = self.as_requests_kwargs(base_url, headers=headers)
212
+ return prepare_request_data(kwargs)
213
+
187
214
  def get_code_to_reproduce(
188
- self, headers: Optional[Dict[str, Any]] = None, request: Optional[requests.PreparedRequest] = None
215
+ self,
216
+ headers: Optional[Dict[str, Any]] = None,
217
+ request: Optional[requests.PreparedRequest] = None,
218
+ verify: bool = True,
189
219
  ) -> str:
190
220
  """Construct a Python code to reproduce this case with `requests`."""
191
221
  if request is not None:
192
- kwargs: Dict[str, Any] = {
193
- "method": request.method,
194
- "url": request.url,
195
- "headers": request.headers,
196
- "data": request.body,
197
- }
222
+ request_data = prepare_request_data(
223
+ {
224
+ "method": request.method,
225
+ "url": request.url,
226
+ "headers": request.headers,
227
+ "data": request.body,
228
+ }
229
+ )
198
230
  else:
199
- base_url = self.get_full_base_url()
200
- kwargs = self.as_requests_kwargs(base_url)
201
- if headers:
202
- final_headers = kwargs["headers"] or {}
203
- final_headers.update(headers)
204
- kwargs["headers"] = final_headers
205
- kwargs["headers"] = {key: value for key, value in kwargs["headers"].items() if key not in IGNORED_HEADERS}
206
- method = kwargs["method"].lower()
207
-
208
- def should_display(key: str, value: Any) -> bool:
209
- if key in ("method", "url"):
210
- return False
211
- # Parameters are either absent because they are not defined or are optional
212
- return value not in (None, {})
213
-
214
- printed_kwargs = ", ".join(
215
- f"{key}={repr(value)}" for key, value in kwargs.items() if should_display(key, value)
231
+ request_data = self.prepare_code_sample_data(headers)
232
+ return CodeSampleStyle.python.generate(
233
+ method=request_data.method,
234
+ url=request_data.url,
235
+ body=request_data.body,
236
+ headers=dict(self.headers) if self.headers is not None else None,
237
+ verify=verify,
238
+ extra_headers=request_data.headers,
216
239
  )
217
- url = _escape_single_quotes(kwargs["url"])
218
- args_repr = f"'{url}'"
219
- if printed_kwargs:
220
- args_repr += f", {printed_kwargs}"
221
- return f"requests.{method}({args_repr})"
222
240
 
223
- def as_curl_command(self, headers: Optional[Dict[str, Any]] = None) -> str:
241
+ def as_curl_command(self, headers: Optional[Dict[str, Any]] = None, verify: bool = True) -> str:
224
242
  """Construct a curl command for a given case."""
225
- base_url = self.get_full_base_url()
226
- kwargs = self.as_requests_kwargs(base_url)
227
- if headers:
228
- final_headers = kwargs["headers"] or {}
229
- final_headers.update(headers)
230
- kwargs["headers"] = final_headers
231
- request = requests.Request(**kwargs)
232
- prepared = request.prepare()
233
- if isinstance(prepared.body, bytes):
234
- # Note, it may be not sufficient to reproduce the error :(
235
- prepared.body = prepared.body.decode("utf-8", errors="replace")
236
- for header in tuple(prepared.headers):
237
- if header in IGNORED_HEADERS and header not in (self.headers or {}):
238
- del prepared.headers[header]
239
- return curlify.to_curl(prepared)
243
+ request_data = self.prepare_code_sample_data(headers)
244
+ return CodeSampleStyle.curl.generate(
245
+ method=request_data.method,
246
+ url=request_data.url,
247
+ body=request_data.body,
248
+ headers=dict(self.headers) if self.headers is not None else None,
249
+ verify=verify,
250
+ extra_headers=request_data.headers,
251
+ )
240
252
 
241
253
  def _get_base_url(self, base_url: Optional[str] = None) -> str:
242
254
  if base_url is None:
@@ -332,16 +344,19 @@ class Case:
332
344
  close_session = True
333
345
  else:
334
346
  close_session = False
347
+ verify = data.get("verify", True)
335
348
  try:
336
349
  with self.operation.schema.ratelimit():
337
350
  response = session.request(**data) # type: ignore
338
351
  except requests.Timeout as exc:
339
352
  timeout = 1000 * data["timeout"] # It is defined and not empty, since the exception happened
340
- code_message = self._get_code_message(self.operation.schema.code_sample_style, exc.request)
353
+ request = cast(requests.PreparedRequest, exc.request)
354
+ code_message = self._get_code_message(self.operation.schema.code_sample_style, request, verify=verify)
341
355
  raise get_timeout_error(timeout)(
342
356
  f"\n\n1. Request timed out after {timeout:.2f}ms\n\n----------\n\n{code_message}",
343
357
  context=failures.RequestTimeout(timeout=timeout),
344
358
  ) from None
359
+ response.verify = verify # type: ignore[attr-defined]
345
360
  dispatch("after_call", hook_context, self, response)
346
361
  if close_session:
347
362
  session.close()
@@ -410,9 +425,9 @@ class Case:
410
425
  )
411
426
  if base_url is None:
412
427
  base_url = self.get_full_base_url()
413
- client = ASGIClient(application)
414
428
 
415
- return self.call(base_url=base_url, session=client, headers=headers, **kwargs)
429
+ with ASGIClient(application) as client:
430
+ return self.call(base_url=base_url, session=client, headers=headers, **kwargs)
416
431
 
417
432
  def validate_response(
418
433
  self,
@@ -430,6 +445,7 @@ class Case:
430
445
  :param checks: A tuple of check functions that accept ``response`` and ``case``.
431
446
  :param additional_checks: A tuple of additional checks that will be executed after ones from the ``checks``
432
447
  argument.
448
+ :param excluded_checks: Checks excluded from the default ones.
433
449
  :param code_sample_style: Controls the style of code samples for failure reproduction.
434
450
  """
435
451
  __tracebackhide__ = True
@@ -456,7 +472,11 @@ class Case:
456
472
  if code_sample_style is not None
457
473
  else self.operation.schema.code_sample_style
458
474
  )
459
- code_message = self._get_code_message(code_sample_style, response.request)
475
+ verify = getattr(response, "verify", True)
476
+ if self.operation.schema.sanitize_output:
477
+ sanitize_request(response.request)
478
+ sanitize_response(response)
479
+ code_message = self._get_code_message(code_sample_style, response.request, verify=verify)
460
480
  payload = get_response_payload(response)
461
481
  raise exception_cls(
462
482
  f"\n\n{formatted_failures}\n\n"
@@ -467,12 +487,14 @@ class Case:
467
487
  causes=tuple(failed_checks),
468
488
  )
469
489
 
470
- def _get_code_message(self, code_sample_style: CodeSampleStyle, request: requests.PreparedRequest) -> str:
490
+ def _get_code_message(
491
+ self, code_sample_style: CodeSampleStyle, request: requests.PreparedRequest, verify: bool
492
+ ) -> str:
471
493
  if code_sample_style == CodeSampleStyle.python:
472
- code = self.get_code_to_reproduce(request=request)
494
+ code = self.get_code_to_reproduce(request=request, verify=verify)
473
495
  return f"Run this Python code to reproduce this response: \n\n {code}\n"
474
496
  if code_sample_style == CodeSampleStyle.curl:
475
- code = self.as_curl_command(headers=dict(request.headers))
497
+ code = self.as_curl_command(headers=dict(request.headers), verify=verify)
476
498
  return f"Run this cURL command to reproduce this response: \n\n {code}\n"
477
499
  raise ValueError(f"Unknown code sample style: {code_sample_style.name}")
478
500
 
@@ -492,7 +514,7 @@ class Case:
492
514
 
493
515
  def get_full_url(self) -> str:
494
516
  """Make a full URL to the current API operation, including query parameters."""
495
- base_url = self.base_url or "http://localhost"
517
+ base_url = self.base_url or "http://127.0.0.1"
496
518
  kwargs = self.as_requests_kwargs(base_url)
497
519
  request = requests.Request(**kwargs)
498
520
  prepared = requests.Session().prepare_request(request) # type: ignore
@@ -534,27 +556,6 @@ def validate_vanilla_requests_kwargs(data: Dict[str, Any]) -> None:
534
556
  )
535
557
 
536
558
 
537
- def _escape_single_quotes(url: str) -> str:
538
- """Escape single quotes in a string, so it is usable as in generated Python code.
539
-
540
- The usual ``str.replace`` is not suitable as it may convert already escaped quotes to not-escaped.
541
- """
542
- result = []
543
- escape = False
544
- for char in url:
545
- if escape:
546
- result.append(char)
547
- escape = False
548
- elif char == "\\":
549
- result.append(char)
550
- escape = True
551
- elif char == "'":
552
- result.append("\\'")
553
- else:
554
- result.append(char)
555
- return "".join(result)
556
-
557
-
558
559
  @contextmanager
559
560
  def cookie_handler(client: werkzeug.Client, cookies: Optional[Cookies]) -> Generator[None, None, None]:
560
561
  """Set cookies required for a call."""
@@ -562,10 +563,16 @@ def cookie_handler(client: werkzeug.Client, cookies: Optional[Cookies]) -> Gener
562
563
  yield
563
564
  else:
564
565
  for key, value in cookies.items():
565
- client.set_cookie("localhost", key, value)
566
+ if IS_WERKZEUG_ABOVE_3:
567
+ client.set_cookie(key=key, value=value, domain="localhost")
568
+ else:
569
+ client.set_cookie("localhost", key=key, value=value)
566
570
  yield
567
571
  for key in cookies:
568
- client.delete_cookie("localhost", key)
572
+ if IS_WERKZEUG_ABOVE_3:
573
+ client.delete_cookie(key=key, domain="localhost")
574
+ else:
575
+ client.delete_cookie("localhost", key=key)
569
576
 
570
577
 
571
578
  P = TypeVar("P", bound=Parameter)
@@ -685,8 +692,18 @@ class APIOperation(Generic[P, C]):
685
692
  strategy = self.schema.get_case_strategy(self, hooks, auth_storage, data_generation_method, **kwargs)
686
693
 
687
694
  def _apply_hooks(dispatcher: HookDispatcher, _strategy: st.SearchStrategy[Case]) -> st.SearchStrategy[Case]:
695
+ context = HookContext(self)
688
696
  for hook in dispatcher.get_all_by_name("before_generate_case"):
689
- _strategy = hook(HookContext(self), _strategy)
697
+ _strategy = hook(context, _strategy)
698
+ for hook in dispatcher.get_all_by_name("filter_case"):
699
+ hook = partial(hook, context)
700
+ _strategy = _strategy.filter(hook)
701
+ for hook in dispatcher.get_all_by_name("map_case"):
702
+ hook = partial(hook, context)
703
+ _strategy = _strategy.map(hook)
704
+ for hook in dispatcher.get_all_by_name("flatmap_case"):
705
+ hook = partial(hook, context)
706
+ _strategy = _strategy.flatmap(hook)
690
707
  return _strategy
691
708
 
692
709
  strategy = _apply_hooks(GLOBAL_HOOK_DISPATCHER, strategy)
@@ -797,6 +814,12 @@ class APIOperation(Generic[P, C]):
797
814
  except CheckFailed:
798
815
  return False
799
816
 
817
+ def get_raw_payload_schema(self, media_type: str) -> Optional[Dict[str, Any]]:
818
+ return self.schema._get_payload_schema(self.definition.raw, media_type)
819
+
820
+ def get_resolved_payload_schema(self, media_type: str) -> Optional[Dict[str, Any]]:
821
+ return self.schema._get_payload_schema(self.definition.resolved, media_type)
822
+
800
823
 
801
824
  # backward-compatibility
802
825
  Endpoint = APIOperation
@@ -879,6 +902,7 @@ class Response:
879
902
  encoding: Optional[str]
880
903
  http_version: str
881
904
  elapsed: float
905
+ verify: bool
882
906
 
883
907
  @classmethod
884
908
  def from_requests(cls, response: requests.Response) -> "Response":
@@ -902,6 +926,7 @@ class Response:
902
926
  headers=headers,
903
927
  http_version=http_version,
904
928
  elapsed=response.elapsed.total_seconds(),
929
+ verify=getattr(response, "verify", True),
905
930
  )
906
931
 
907
932
  @classmethod
@@ -914,7 +939,8 @@ class Response:
914
939
  body = None if response.response == [] else serialize_payload(data)
915
940
  encoding: Optional[str]
916
941
  if body is not None:
917
- encoding = response.mimetype_params.get("charset", response.charset)
942
+ # Werkzeug <3.0 had `charset` attr, newer versions always have UTF-8
943
+ encoding = response.mimetype_params.get("charset", getattr(response, "charset", "utf-8"))
918
944
  else:
919
945
  encoding = None
920
946
  return cls(
@@ -925,6 +951,7 @@ class Response:
925
951
  headers=headers,
926
952
  http_version="1.1",
927
953
  elapsed=elapsed,
954
+ verify=True,
928
955
  )
929
956
 
930
957
 
@@ -983,7 +1010,7 @@ class TestResult:
983
1010
  verbose_name: str
984
1011
  data_generation_method: List[DataGenerationMethod]
985
1012
  checks: List[Check] = field(default_factory=list)
986
- errors: List[Tuple[Exception, Optional[Case]]] = field(default_factory=list)
1013
+ errors: List[Exception] = field(default_factory=list)
987
1014
  interactions: List[Interaction] = field(default_factory=list)
988
1015
  logs: List[LogRecord] = field(default_factory=list)
989
1016
  is_errored: bool = False
@@ -991,9 +1018,6 @@ class TestResult:
991
1018
  is_skipped: bool = False
992
1019
  is_executed: bool = False
993
1020
  seed: Optional[int] = None
994
- # To show a proper reproduction code if an error happens and there is no way to get actual headers that were
995
- # sent over the network. Or there could be no actual requests at all
996
- overridden_headers: Optional[Dict[str, Any]] = None
997
1021
 
998
1022
  def mark_errored(self) -> None:
999
1023
  self.is_errored = True
@@ -1049,8 +1073,8 @@ class TestResult:
1049
1073
  self.checks.append(check)
1050
1074
  return check
1051
1075
 
1052
- def add_error(self, exception: Exception, example: Optional[Case] = None) -> None:
1053
- self.errors.append((exception, example))
1076
+ def add_error(self, exception: Exception) -> None:
1077
+ self.errors.append(exception)
1054
1078
 
1055
1079
  def store_requests_response(
1056
1080
  self, case: Case, response: requests.Response, status: Status, checks: List[Check]
@@ -1076,7 +1100,7 @@ class TestResultSet:
1076
1100
  __test__ = False
1077
1101
 
1078
1102
  results: List[TestResult] = field(default_factory=list)
1079
- generic_errors: List[InvalidSchema] = field(default_factory=list)
1103
+ generic_errors: List[OperationSchemaError] = field(default_factory=list)
1080
1104
  warnings: List[str] = field(default_factory=list)
1081
1105
 
1082
1106
  def __iter__(self) -> Iterator[TestResult]:
@@ -67,6 +67,9 @@ class ParameterSet(Generic[P]):
67
67
  return parameter
68
68
  return None
69
69
 
70
+ def contains(self, name: str) -> bool:
71
+ return self.get(name) is not None
72
+
70
73
  @property
71
74
  def example(self) -> Dict[str, Any]:
72
75
  """Composite example gathered from individual parameters."""
@@ -13,6 +13,7 @@ from ..constants import (
13
13
  HYPOTHESIS_IN_MEMORY_DATABASE_IDENTIFIER,
14
14
  DataGenerationMethod,
15
15
  )
16
+ from ..exceptions import SchemaError
16
17
  from ..models import CheckFunction
17
18
  from ..schemas import BaseSchema
18
19
  from ..specs.graphql import loaders as gql_loaders
@@ -236,6 +237,8 @@ def execute_from_schema(
236
237
  stateful_recursion_limit=stateful_recursion_limit,
237
238
  count_operations=count_operations,
238
239
  ).execute()
240
+ except SchemaError as error:
241
+ yield events.InternalError.from_schema_error(error)
239
242
  except Exception as exc:
240
243
  yield events.InternalError.from_exc(exc)
241
244
 
@@ -1,12 +1,11 @@
1
+ import enum
1
2
  import threading
2
3
  import time
3
4
  from dataclasses import asdict, dataclass, field
4
5
  from typing import Any, Dict, List, Optional, Union
5
6
 
6
- from requests import exceptions
7
-
8
- from ..constants import USE_WAIT_FOR_SCHEMA_SUGGESTION_MESSAGE, DataGenerationMethod
9
- from ..exceptions import HTTPError
7
+ from ..constants import DataGenerationMethod
8
+ from ..exceptions import SchemaError, SchemaErrorType
10
9
  from ..models import APIOperation, Status, TestResult, TestResultSet
11
10
  from ..schemas import BaseSchema
12
11
  from ..utils import current_datetime, format_exception
@@ -168,37 +167,73 @@ class Interrupted(ExecutionEvent):
168
167
  thread_id: int = field(default_factory=threading.get_ident)
169
168
 
170
169
 
170
+ @enum.unique
171
+ class InternalErrorType(str, enum.Enum):
172
+ SCHEMA = "schema"
173
+ OTHER = "other"
174
+
175
+
171
176
  @dataclass
172
177
  class InternalError(ExecutionEvent):
173
178
  """An error that happened inside the runner."""
174
179
 
175
180
  is_terminal = True
176
181
 
182
+ # Main error info
183
+ type: InternalErrorType
184
+ subtype: Optional[SchemaErrorType]
185
+ title: str
177
186
  message: str
187
+ extras: List[str]
188
+
189
+ # Exception info
178
190
  exception_type: str
179
- exception: Optional[str] = None
180
- exception_with_traceback: Optional[str] = None
191
+ exception: str
192
+ exception_with_traceback: str
193
+ # Auxiliary data
181
194
  thread_id: int = field(default_factory=threading.get_ident)
182
195
 
183
196
  @classmethod
184
- def from_exc(cls, exc: Exception, wait_for_schema: Optional[float] = None) -> "InternalError":
197
+ def from_schema_error(cls, error: SchemaError) -> "InternalError":
198
+ return cls.with_exception(
199
+ error,
200
+ type_=InternalErrorType.SCHEMA,
201
+ subtype=error.type,
202
+ title="Schema Loading Error",
203
+ message=error.message,
204
+ extra=error.extras,
205
+ )
206
+
207
+ @classmethod
208
+ def from_exc(cls, exc: Exception) -> "InternalError":
209
+ return cls.with_exception(
210
+ exc,
211
+ type_=InternalErrorType.OTHER,
212
+ subtype=None,
213
+ title="Test Execution Error",
214
+ message="An internal error occurred during the test run",
215
+ extra=[],
216
+ )
217
+
218
+ @classmethod
219
+ def with_exception(
220
+ cls,
221
+ exc: Exception,
222
+ type_: InternalErrorType,
223
+ subtype: Optional[SchemaErrorType],
224
+ title: str,
225
+ message: str,
226
+ extra: List[str],
227
+ ) -> "InternalError":
185
228
  exception_type = f"{exc.__class__.__module__}.{exc.__class__.__qualname__}"
186
- if isinstance(exc, HTTPError):
187
- if exc.response.status_code == 404:
188
- message = f"Schema was not found at {exc.url}"
189
- else:
190
- message = f"Failed to load schema, code {exc.response.status_code} was returned from {exc.url}"
191
- return cls(message=message, exception_type=exception_type)
192
229
  exception = format_exception(exc)
193
230
  exception_with_traceback = format_exception(exc, include_traceback=True)
194
- if isinstance(exc, exceptions.ConnectionError):
195
- message = f"Failed to load schema from {exc.request.url}"
196
- if wait_for_schema is None:
197
- message += f"\n{USE_WAIT_FOR_SCHEMA_SUGGESTION_MESSAGE}"
198
- else:
199
- message = "An internal error happened during a test run"
200
231
  return cls(
232
+ type=type_,
233
+ subtype=subtype,
234
+ title=title,
201
235
  message=message,
236
+ extras=extra,
202
237
  exception_type=exception_type,
203
238
  exception=exception,
204
239
  exception_with_traceback=exception_with_traceback,
@@ -245,7 +280,7 @@ class Finished(ExecutionEvent):
245
280
  is_empty=results.is_empty,
246
281
  total=results.total,
247
282
  generic_errors=[
248
- SerializedError.from_error(error, None, None, error.full_path) for error in results.generic_errors
283
+ SerializedError.from_error(exception=error, title=error.full_path) for error in results.generic_errors
249
284
  ],
250
285
  warnings=results.warnings,
251
286
  running_time=running_time,