schemathesis 3.25.6__py3-none-any.whl → 3.39.7__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 (146) hide show
  1. schemathesis/__init__.py +6 -6
  2. schemathesis/_compat.py +2 -2
  3. schemathesis/_dependency_versions.py +4 -2
  4. schemathesis/_hypothesis.py +369 -56
  5. schemathesis/_lazy_import.py +1 -0
  6. schemathesis/_override.py +5 -4
  7. schemathesis/_patches.py +21 -0
  8. schemathesis/_rate_limiter.py +7 -0
  9. schemathesis/_xml.py +75 -22
  10. schemathesis/auths.py +78 -16
  11. schemathesis/checks.py +21 -9
  12. schemathesis/cli/__init__.py +783 -432
  13. schemathesis/cli/__main__.py +4 -0
  14. schemathesis/cli/callbacks.py +58 -13
  15. schemathesis/cli/cassettes.py +233 -47
  16. schemathesis/cli/constants.py +8 -2
  17. schemathesis/cli/context.py +22 -5
  18. schemathesis/cli/debug.py +2 -1
  19. schemathesis/cli/handlers.py +4 -1
  20. schemathesis/cli/junitxml.py +103 -22
  21. schemathesis/cli/options.py +15 -4
  22. schemathesis/cli/output/default.py +258 -112
  23. schemathesis/cli/output/short.py +23 -8
  24. schemathesis/cli/reporting.py +79 -0
  25. schemathesis/cli/sanitization.py +6 -0
  26. schemathesis/code_samples.py +5 -3
  27. schemathesis/constants.py +1 -0
  28. schemathesis/contrib/openapi/__init__.py +1 -1
  29. schemathesis/contrib/openapi/fill_missing_examples.py +3 -1
  30. schemathesis/contrib/openapi/formats/uuid.py +2 -1
  31. schemathesis/contrib/unique_data.py +3 -3
  32. schemathesis/exceptions.py +76 -65
  33. schemathesis/experimental/__init__.py +35 -0
  34. schemathesis/extra/_aiohttp.py +1 -0
  35. schemathesis/extra/_flask.py +4 -1
  36. schemathesis/extra/_server.py +1 -0
  37. schemathesis/extra/pytest_plugin.py +17 -25
  38. schemathesis/failures.py +77 -9
  39. schemathesis/filters.py +185 -8
  40. schemathesis/fixups/__init__.py +1 -0
  41. schemathesis/fixups/fast_api.py +2 -2
  42. schemathesis/fixups/utf8_bom.py +1 -2
  43. schemathesis/generation/__init__.py +20 -36
  44. schemathesis/generation/_hypothesis.py +59 -0
  45. schemathesis/generation/_methods.py +44 -0
  46. schemathesis/generation/coverage.py +931 -0
  47. schemathesis/graphql.py +0 -1
  48. schemathesis/hooks.py +89 -12
  49. schemathesis/internal/checks.py +84 -0
  50. schemathesis/internal/copy.py +22 -3
  51. schemathesis/internal/deprecation.py +6 -2
  52. schemathesis/internal/diff.py +15 -0
  53. schemathesis/internal/extensions.py +27 -0
  54. schemathesis/internal/jsonschema.py +2 -1
  55. schemathesis/internal/output.py +68 -0
  56. schemathesis/internal/result.py +1 -1
  57. schemathesis/internal/transformation.py +11 -0
  58. schemathesis/lazy.py +138 -25
  59. schemathesis/loaders.py +7 -5
  60. schemathesis/models.py +318 -211
  61. schemathesis/parameters.py +4 -0
  62. schemathesis/runner/__init__.py +50 -15
  63. schemathesis/runner/events.py +65 -5
  64. schemathesis/runner/impl/context.py +104 -0
  65. schemathesis/runner/impl/core.py +388 -177
  66. schemathesis/runner/impl/solo.py +19 -29
  67. schemathesis/runner/impl/threadpool.py +70 -79
  68. schemathesis/runner/probes.py +11 -9
  69. schemathesis/runner/serialization.py +150 -17
  70. schemathesis/sanitization.py +5 -1
  71. schemathesis/schemas.py +170 -102
  72. schemathesis/serializers.py +7 -2
  73. schemathesis/service/ci.py +1 -0
  74. schemathesis/service/client.py +39 -6
  75. schemathesis/service/events.py +5 -1
  76. schemathesis/service/extensions.py +224 -0
  77. schemathesis/service/hosts.py +6 -2
  78. schemathesis/service/metadata.py +25 -0
  79. schemathesis/service/models.py +211 -2
  80. schemathesis/service/report.py +6 -6
  81. schemathesis/service/serialization.py +45 -71
  82. schemathesis/service/usage.py +1 -0
  83. schemathesis/specs/graphql/_cache.py +26 -0
  84. schemathesis/specs/graphql/loaders.py +25 -5
  85. schemathesis/specs/graphql/nodes.py +1 -0
  86. schemathesis/specs/graphql/scalars.py +2 -2
  87. schemathesis/specs/graphql/schemas.py +130 -100
  88. schemathesis/specs/graphql/validation.py +1 -2
  89. schemathesis/specs/openapi/__init__.py +1 -0
  90. schemathesis/specs/openapi/_cache.py +123 -0
  91. schemathesis/specs/openapi/_hypothesis.py +78 -60
  92. schemathesis/specs/openapi/checks.py +504 -25
  93. schemathesis/specs/openapi/converter.py +31 -4
  94. schemathesis/specs/openapi/definitions.py +10 -17
  95. schemathesis/specs/openapi/examples.py +126 -12
  96. schemathesis/specs/openapi/expressions/__init__.py +37 -2
  97. schemathesis/specs/openapi/expressions/context.py +1 -1
  98. schemathesis/specs/openapi/expressions/extractors.py +26 -0
  99. schemathesis/specs/openapi/expressions/lexer.py +20 -18
  100. schemathesis/specs/openapi/expressions/nodes.py +29 -6
  101. schemathesis/specs/openapi/expressions/parser.py +26 -5
  102. schemathesis/specs/openapi/formats.py +44 -0
  103. schemathesis/specs/openapi/links.py +125 -42
  104. schemathesis/specs/openapi/loaders.py +77 -36
  105. schemathesis/specs/openapi/media_types.py +34 -0
  106. schemathesis/specs/openapi/negative/__init__.py +6 -3
  107. schemathesis/specs/openapi/negative/mutations.py +21 -6
  108. schemathesis/specs/openapi/parameters.py +39 -25
  109. schemathesis/specs/openapi/patterns.py +137 -0
  110. schemathesis/specs/openapi/references.py +37 -7
  111. schemathesis/specs/openapi/schemas.py +360 -241
  112. schemathesis/specs/openapi/security.py +25 -7
  113. schemathesis/specs/openapi/serialization.py +1 -0
  114. schemathesis/specs/openapi/stateful/__init__.py +198 -70
  115. schemathesis/specs/openapi/stateful/statistic.py +198 -0
  116. schemathesis/specs/openapi/stateful/types.py +14 -0
  117. schemathesis/specs/openapi/utils.py +6 -1
  118. schemathesis/specs/openapi/validation.py +1 -0
  119. schemathesis/stateful/__init__.py +35 -21
  120. schemathesis/stateful/config.py +97 -0
  121. schemathesis/stateful/context.py +135 -0
  122. schemathesis/stateful/events.py +274 -0
  123. schemathesis/stateful/runner.py +309 -0
  124. schemathesis/stateful/sink.py +68 -0
  125. schemathesis/stateful/state_machine.py +67 -38
  126. schemathesis/stateful/statistic.py +22 -0
  127. schemathesis/stateful/validation.py +100 -0
  128. schemathesis/targets.py +33 -1
  129. schemathesis/throttling.py +25 -5
  130. schemathesis/transports/__init__.py +354 -0
  131. schemathesis/transports/asgi.py +7 -0
  132. schemathesis/transports/auth.py +25 -2
  133. schemathesis/transports/content_types.py +3 -1
  134. schemathesis/transports/headers.py +2 -1
  135. schemathesis/transports/responses.py +9 -4
  136. schemathesis/types.py +9 -0
  137. schemathesis/utils.py +11 -16
  138. schemathesis-3.39.7.dist-info/METADATA +293 -0
  139. schemathesis-3.39.7.dist-info/RECORD +160 -0
  140. {schemathesis-3.25.6.dist-info → schemathesis-3.39.7.dist-info}/WHEEL +1 -1
  141. schemathesis/specs/openapi/filters.py +0 -49
  142. schemathesis/specs/openapi/stateful/links.py +0 -92
  143. schemathesis-3.25.6.dist-info/METADATA +0 -356
  144. schemathesis-3.25.6.dist-info/RECORD +0 -134
  145. {schemathesis-3.25.6.dist-info → schemathesis-3.39.7.dist-info}/entry_points.txt +0 -0
  146. {schemathesis-3.25.6.dist-info → schemathesis-3.39.7.dist-info}/licenses/LICENSE +0 -0
schemathesis/models.py CHANGED
@@ -1,4 +1,5 @@
1
1
  from __future__ import annotations
2
+
2
3
  import datetime
3
4
  import inspect
4
5
  import textwrap
@@ -6,9 +7,8 @@ from collections import Counter
6
7
  from contextlib import contextmanager
7
8
  from dataclasses import dataclass, field
8
9
  from enum import Enum
9
- from functools import partial, lru_cache
10
+ from functools import lru_cache, partial
10
11
  from itertools import chain
11
- from logging import LogRecord
12
12
  from typing import (
13
13
  TYPE_CHECKING,
14
14
  Any,
@@ -16,60 +16,71 @@ from typing import (
16
16
  Generator,
17
17
  Generic,
18
18
  Iterator,
19
+ Literal,
19
20
  NoReturn,
20
- Optional,
21
21
  Sequence,
22
22
  Type,
23
23
  TypeVar,
24
24
  cast,
25
25
  )
26
- from urllib.parse import quote, unquote, urljoin, urlparse, urlsplit, urlunsplit
27
-
28
- from urllib3.exceptions import ReadTimeoutError
26
+ from urllib.parse import quote, unquote, urljoin, urlsplit, urlunsplit
29
27
 
30
- from . import failures, serializers
28
+ from . import serializers
31
29
  from ._dependency_versions import IS_WERKZEUG_ABOVE_3
32
- from .auths import AuthStorage
30
+ from ._override import CaseOverride
33
31
  from .code_samples import CodeSampleStyle
34
- from .generation import DataGenerationMethod, GenerationConfig
35
32
  from .constants import (
36
- DEFAULT_RESPONSE_TIMEOUT,
33
+ NOT_SET,
37
34
  SCHEMATHESIS_TEST_CASE_HEADER,
38
35
  SERIALIZERS_SUGGESTION_MESSAGE,
39
36
  USER_AGENT,
40
- NOT_SET,
41
37
  )
42
38
  from .exceptions import (
43
- maybe_set_assertion_message,
44
39
  CheckFailed,
45
- FailureContext,
46
40
  OperationSchemaError,
47
41
  SerializationNotPossible,
42
+ SkipTest,
43
+ UsageError,
48
44
  deduplicate_failed_checks,
49
45
  get_grouped_exception,
50
- get_timeout_error,
51
- prepare_response_payload,
52
- SkipTest,
46
+ maybe_set_assertion_message,
53
47
  )
54
- from .internal.deprecation import deprecated_property
55
- from .internal.copy import fast_deepcopy
48
+ from .generation import DataGenerationMethod, GenerationConfig, generate_random_case_id
56
49
  from .hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher, dispatch
50
+ from .internal.checks import CheckContext
51
+ from .internal.copy import fast_deepcopy
52
+ from .internal.deprecation import deprecated_function, deprecated_property
53
+ from .internal.diff import diff
54
+ from .internal.output import prepare_response_payload
57
55
  from .parameters import Parameter, ParameterSet, PayloadAlternatives
58
56
  from .sanitization import sanitize_request, sanitize_response
59
- from .serializers import Serializer, SerializerContext
60
- from .transports import serialize_payload
57
+ from .transports import ASGITransport, RequestsTransport, WSGITransport, deserialize_payload, serialize_payload
61
58
  from .types import Body, Cookies, FormData, Headers, NotSet, PathParameters, Query
62
- from .generation import generate_random_case_id
63
59
 
64
60
  if TYPE_CHECKING:
65
- import werkzeug
66
61
  import unittest
67
- from requests.structures import CaseInsensitiveDict
68
- from hypothesis import strategies as st
62
+ from logging import LogRecord
63
+
69
64
  import requests.auth
70
- from .transports.responses import GenericResponse, WSGIResponse
65
+ import werkzeug
66
+ from hypothesis import strategies as st
67
+ from requests.structures import CaseInsensitiveDict
68
+
69
+ from .auths import AuthStorage
70
+ from .failures import FailureContext
71
+ from .internal.checks import CheckFunction
71
72
  from .schemas import BaseSchema
73
+ from .serializers import Serializer
72
74
  from .stateful import Stateful, StatefulTest
75
+ from .transports.responses import GenericResponse, WSGIResponse
76
+
77
+
78
+ @dataclass
79
+ class TransitionId:
80
+ name: str
81
+ status_code: str
82
+
83
+ __slots__ = ("name", "status_code")
73
84
 
74
85
 
75
86
  @dataclass
@@ -79,14 +90,22 @@ class CaseSource:
79
90
  case: Case
80
91
  response: GenericResponse
81
92
  elapsed: float
93
+ overrides_all_parameters: bool
94
+ transition_id: TransitionId
82
95
 
83
96
  def partial_deepcopy(self) -> CaseSource:
84
- return self.__class__(case=self.case.partial_deepcopy(), response=self.response, elapsed=self.elapsed)
97
+ return self.__class__(
98
+ case=self.case.partial_deepcopy(),
99
+ response=self.response,
100
+ elapsed=self.elapsed,
101
+ overrides_all_parameters=self.overrides_all_parameters,
102
+ transition_id=self.transition_id,
103
+ )
85
104
 
86
105
 
87
106
  def cant_serialize(media_type: str) -> NoReturn: # type: ignore
88
107
  """Reject the current example if we don't know how to send this data to the application."""
89
- from hypothesis import note, event, reject
108
+ from hypothesis import event, note, reject
90
109
 
91
110
  event_text = f"Can't serialize data to `{media_type}`."
92
111
  note(f"{event_text} {SERIALIZERS_SUGGESTION_MESSAGE}")
@@ -120,11 +139,51 @@ def prepare_request_data(kwargs: dict[str, Any]) -> PreparedRequestData:
120
139
  )
121
140
 
122
141
 
142
+ class TestPhase(str, Enum):
143
+ __test__ = False
144
+
145
+ EXPLICIT = "explicit"
146
+ COVERAGE = "coverage"
147
+ GENERATE = "generate"
148
+
149
+
150
+ @dataclass
151
+ class GenerationMetadata:
152
+ """Stores various information about how data is generated."""
153
+
154
+ query: DataGenerationMethod | None
155
+ path_parameters: DataGenerationMethod | None
156
+ headers: DataGenerationMethod | None
157
+ cookies: DataGenerationMethod | None
158
+ body: DataGenerationMethod | None
159
+ phase: TestPhase
160
+ # Temporary attributes to carry info specific to the coverage phase
161
+ description: str | None
162
+ location: str | None
163
+ parameter: str | None
164
+ parameter_location: str | None
165
+
166
+ __slots__ = (
167
+ "query",
168
+ "path_parameters",
169
+ "headers",
170
+ "cookies",
171
+ "body",
172
+ "phase",
173
+ "description",
174
+ "location",
175
+ "parameter",
176
+ "parameter_location",
177
+ )
178
+
179
+
123
180
  @dataclass(repr=False)
124
181
  class Case:
125
182
  """A single test case parameters."""
126
183
 
127
184
  operation: APIOperation
185
+ # Time spent on generation of this test case
186
+ generation_time: float
128
187
  # Unique test case identifier
129
188
  id: str = field(default_factory=generate_random_case_id, compare=False)
130
189
  path_parameters: PathParameters | None = None
@@ -138,9 +197,33 @@ class Case:
138
197
  media_type: str | None = None
139
198
  source: CaseSource | None = None
140
199
 
200
+ meta: GenerationMetadata | None = None
201
+
141
202
  # The way the case was generated (None for manually crafted ones)
142
203
  data_generation_method: DataGenerationMethod | None = None
143
204
  _auth: requests.auth.AuthBase | None = None
205
+ _has_explicit_auth: bool = False
206
+ _explicit_method: str | None = None
207
+
208
+ def __post_init__(self) -> None:
209
+ self._original_path_parameters = self.path_parameters.copy() if self.path_parameters else None
210
+ self._original_headers = self.headers.copy() if self.headers else None
211
+ self._original_cookies = self.cookies.copy() if self.cookies else None
212
+ self._original_query = self.query.copy() if self.query else None
213
+
214
+ def _has_generated_component(self, name: str) -> bool:
215
+ assert name in ["path_parameters", "headers", "cookies", "query"]
216
+ if self.meta is None:
217
+ return False
218
+ return getattr(self.meta, name) is not None
219
+
220
+ def _get_diff(self, component: Literal["path_parameters", "headers", "query", "cookies"]) -> dict[str, Any]:
221
+ original = getattr(self, f"_original_{component}")
222
+ current = getattr(self, component)
223
+ if not (current and original):
224
+ return {}
225
+ original_value = original if self._has_generated_component(component) else {}
226
+ return diff(original_value, current)
144
227
 
145
228
  def __repr__(self) -> str:
146
229
  parts = [f"{self.__class__.__name__}("]
@@ -158,7 +241,18 @@ class Case:
158
241
  def __hash__(self) -> int:
159
242
  return hash(self.as_curl_command({SCHEMATHESIS_TEST_CASE_HEADER: "0"}))
160
243
 
161
- @deprecated_property(removed_in="4.0", replacement="operation")
244
+ @property
245
+ def _override(self) -> CaseOverride:
246
+ return CaseOverride(
247
+ path_parameters=self._get_diff("path_parameters"),
248
+ headers=self._get_diff("headers"),
249
+ query=self._get_diff("query"),
250
+ cookies=self._get_diff("cookies"),
251
+ )
252
+
253
+ def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
254
+
255
+ @deprecated_property(removed_in="4.0", replacement="`operation`")
162
256
  def endpoint(self) -> APIOperation:
163
257
  return self.operation
164
258
 
@@ -172,7 +266,7 @@ class Case:
172
266
 
173
267
  @property
174
268
  def method(self) -> str:
175
- return self.operation.method.upper()
269
+ return self._explicit_method.upper() if self._explicit_method else self.operation.method.upper()
176
270
 
177
271
  @property
178
272
  def base_url(self) -> str | None:
@@ -182,8 +276,21 @@ class Case:
182
276
  def app(self) -> Any:
183
277
  return self.operation.app
184
278
 
185
- def set_source(self, response: GenericResponse, case: Case, elapsed: float) -> None:
186
- self.source = CaseSource(case=case, response=response, elapsed=elapsed)
279
+ def set_source(
280
+ self,
281
+ response: GenericResponse,
282
+ case: Case,
283
+ elapsed: float,
284
+ overrides_all_parameters: bool,
285
+ transition_id: TransitionId,
286
+ ) -> None:
287
+ self.source = CaseSource(
288
+ case=case,
289
+ response=response,
290
+ elapsed=elapsed,
291
+ overrides_all_parameters=overrides_all_parameters,
292
+ transition_id=transition_id,
293
+ )
187
294
 
188
295
  @property
189
296
  def formatted_path(self) -> str:
@@ -208,7 +315,7 @@ class Case:
208
315
 
209
316
  def prepare_code_sample_data(self, headers: dict[str, Any] | None) -> PreparedRequestData:
210
317
  base_url = self.get_full_base_url()
211
- kwargs = self.as_requests_kwargs(base_url, headers=headers)
318
+ kwargs = RequestsTransport().serialize_case(self, base_url=base_url, headers=headers)
212
319
  return prepare_request_data(kwargs)
213
320
 
214
321
  def get_code_to_reproduce(
@@ -271,53 +378,31 @@ class Case:
271
378
  final_headers.setdefault(SCHEMATHESIS_TEST_CASE_HEADER, self.id)
272
379
  return final_headers
273
380
 
274
- def _get_serializer(self) -> Serializer | None:
381
+ def _get_serializer(self, media_type: str | None = None) -> Serializer | None:
275
382
  """Get a serializer for the payload, if there is any."""
276
- if self.media_type is not None:
277
- media_type = serializers.get_first_matching_media_type(self.media_type)
383
+ input_media_type = media_type or self.media_type
384
+ if input_media_type is not None:
385
+ media_type = serializers.get_first_matching_media_type(input_media_type)
278
386
  if media_type is None:
279
387
  # This media type is set manually. Otherwise, it should have been rejected during the data generation
280
- raise SerializationNotPossible.for_media_type(self.media_type)
388
+ raise SerializationNotPossible.for_media_type(input_media_type)
281
389
  # SAFETY: It is safe to assume that serializer will be found, because `media_type` returned above
282
390
  # is registered. This intentionally ignores cases with concurrent serializers registry modification.
283
391
  cls = cast(Type[serializers.Serializer], serializers.get(media_type))
284
392
  return cls()
285
393
  return None
286
394
 
395
+ def _get_body(self) -> Body | NotSet:
396
+ return self.body
397
+
398
+ @deprecated_function(removed_in="4.0", replacement="Case.as_transport_kwargs")
287
399
  def as_requests_kwargs(self, base_url: str | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]:
288
400
  """Convert the case into a dictionary acceptable by requests."""
289
- final_headers = self._get_headers(headers)
290
- if self.media_type and self.media_type != "multipart/form-data" and not isinstance(self.body, NotSet):
291
- # `requests` will handle multipart form headers with the proper `boundary` value.
292
- if "content-type" not in {header.lower() for header in final_headers}:
293
- final_headers["Content-Type"] = self.media_type
294
- base_url = self._get_base_url(base_url)
295
- formatted_path = self.formatted_path.lstrip("/")
296
- if not base_url.endswith("/"):
297
- base_url += "/"
298
- url = unquote(urljoin(base_url, quote(formatted_path)))
299
- extra: dict[str, Any]
300
- serializer = self._get_serializer()
301
- if serializer is not None and not isinstance(self.body, NotSet):
302
- context = SerializerContext(case=self)
303
- extra = serializer.as_requests(context, self.body)
304
- else:
305
- extra = {}
306
- if self._auth is not None:
307
- extra["auth"] = self._auth
308
- additional_headers = extra.pop("headers", None)
309
- if additional_headers:
310
- # Additional headers, needed for the serializer
311
- for key, value in additional_headers.items():
312
- final_headers.setdefault(key, value)
313
- return {
314
- "method": self.method,
315
- "url": url,
316
- "cookies": self.cookies,
317
- "headers": final_headers,
318
- "params": self.query,
319
- **extra,
320
- }
401
+ return RequestsTransport().serialize_case(self, base_url=base_url, headers=headers)
402
+
403
+ def as_transport_kwargs(self, base_url: str | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]:
404
+ """Convert the test case into a dictionary acceptable by the underlying transport call."""
405
+ return self.operation.schema.transport.serialize_case(self, base_url=base_url, headers=headers)
321
406
 
322
407
  def call(
323
408
  self,
@@ -327,83 +412,21 @@ class Case:
327
412
  params: dict[str, Any] | None = None,
328
413
  cookies: dict[str, Any] | None = None,
329
414
  **kwargs: Any,
330
- ) -> requests.Response:
331
- import requests
332
-
333
- """Make a network call with `requests`."""
415
+ ) -> GenericResponse:
334
416
  hook_context = HookContext(operation=self.operation)
335
417
  dispatch("before_call", hook_context, self)
336
- data = self.as_requests_kwargs(base_url, headers)
337
- data.update(kwargs)
338
- if params is not None:
339
- _merge_dict_to(data, "params", params)
340
- if cookies is not None:
341
- _merge_dict_to(data, "cookies", cookies)
342
- data.setdefault("timeout", DEFAULT_RESPONSE_TIMEOUT / 1000)
343
- if session is None:
344
- validate_vanilla_requests_kwargs(data)
345
- session = requests.Session()
346
- close_session = True
347
- else:
348
- close_session = False
349
- verify = data.get("verify", True)
350
- try:
351
- with self.operation.schema.ratelimit():
352
- response = session.request(**data) # type: ignore
353
- except (requests.Timeout, requests.ConnectionError) as exc:
354
- if isinstance(exc, requests.ConnectionError):
355
- if not isinstance(exc.args[0], ReadTimeoutError):
356
- raise
357
- req = requests.Request(
358
- method=data["method"].upper(),
359
- url=data["url"],
360
- headers=data["headers"],
361
- files=data.get("files"),
362
- data=data.get("data") or {},
363
- json=data.get("json"),
364
- params=data.get("params") or {},
365
- auth=data.get("auth"),
366
- cookies=data["cookies"],
367
- hooks=data.get("hooks"),
368
- )
369
- request = session.prepare_request(req)
370
- else:
371
- request = cast(requests.PreparedRequest, exc.request)
372
- timeout = 1000 * data["timeout"] # It is defined and not empty, since the exception happened
373
- code_message = self._get_code_message(self.operation.schema.code_sample_style, request, verify=verify)
374
- message = f"The server failed to respond within the specified limit of {timeout:.2f}ms"
375
- raise get_timeout_error(timeout)(
376
- f"\n\n1. {failures.RequestTimeout.title}\n\n{message}\n\n{code_message}",
377
- context=failures.RequestTimeout(message=message, timeout=timeout),
378
- ) from None
379
- response.verify = verify # type: ignore[attr-defined]
418
+ response = self.operation.schema.transport.send(
419
+ self, session=session, base_url=base_url, headers=headers, params=params, cookies=cookies, **kwargs
420
+ )
380
421
  dispatch("after_call", hook_context, self, response)
381
- if close_session:
382
- session.close()
383
422
  return response
384
423
 
424
+ @deprecated_function(removed_in="4.0", replacement="Case.as_transport_kwargs")
385
425
  def as_werkzeug_kwargs(self, headers: dict[str, str] | None = None) -> dict[str, Any]:
386
426
  """Convert the case into a dictionary acceptable by werkzeug.Client."""
387
- final_headers = self._get_headers(headers)
388
- if self.media_type and not isinstance(self.body, NotSet):
389
- # If we need to send a payload, then the Content-Type header should be set
390
- final_headers["Content-Type"] = self.media_type
391
- extra: dict[str, Any]
392
- serializer = self._get_serializer()
393
- if serializer is not None and not isinstance(self.body, NotSet):
394
- context = SerializerContext(case=self)
395
- extra = serializer.as_werkzeug(context, self.body)
396
- else:
397
- extra = {}
398
- return {
399
- "method": self.method,
400
- "path": self.operation.schema.get_full_path(self.formatted_path),
401
- # Convert to a regular dictionary, as we use `CaseInsensitiveDict` which is not supported by Werkzeug
402
- "headers": dict(final_headers),
403
- "query_string": self.query,
404
- **extra,
405
- }
427
+ return WSGITransport(self.app).serialize_case(self, headers=headers)
406
428
 
429
+ @deprecated_function(removed_in="4.0", replacement="Case.call")
407
430
  def call_wsgi(
408
431
  self,
409
432
  app: Any = None,
@@ -411,10 +434,6 @@ class Case:
411
434
  query_string: dict[str, str] | None = None,
412
435
  **kwargs: Any,
413
436
  ) -> WSGIResponse:
414
- from .transports.responses import WSGIResponse
415
- import werkzeug
416
- import requests
417
-
418
437
  application = app or self.app
419
438
  if application is None:
420
439
  raise RuntimeError(
@@ -423,17 +442,11 @@ class Case:
423
442
  )
424
443
  hook_context = HookContext(operation=self.operation)
425
444
  dispatch("before_call", hook_context, self)
426
- data = self.as_werkzeug_kwargs(headers)
427
- if query_string is not None:
428
- _merge_dict_to(data, "query_string", query_string)
429
- client = werkzeug.Client(application, WSGIResponse)
430
- with cookie_handler(client, self.cookies), self.operation.schema.ratelimit():
431
- response = client.open(**data, **kwargs)
432
- requests_kwargs = self.as_requests_kwargs(base_url=self.get_full_base_url(), headers=headers)
433
- response.request = requests.Request(**requests_kwargs).prepare()
445
+ response = WSGITransport(application).send(self, headers=headers, params=query_string, **kwargs)
434
446
  dispatch("after_call", hook_context, self, response)
435
447
  return response
436
448
 
449
+ @deprecated_function(removed_in="4.0", replacement="Case.call")
437
450
  def call_asgi(
438
451
  self,
439
452
  app: Any = None,
@@ -441,19 +454,17 @@ class Case:
441
454
  headers: dict[str, str] | None = None,
442
455
  **kwargs: Any,
443
456
  ) -> requests.Response:
444
- from starlette_testclient import TestClient as ASGIClient
445
-
446
457
  application = app or self.app
447
458
  if application is None:
448
459
  raise RuntimeError(
449
460
  "ASGI application instance is required. "
450
461
  "Please, set `app` argument in the schema constructor or pass it to `call_asgi`"
451
462
  )
452
- if base_url is None:
453
- base_url = self.get_full_base_url()
454
-
455
- with ASGIClient(application) as client:
456
- return self.call(base_url=base_url, session=client, headers=headers, **kwargs)
463
+ hook_context = HookContext(operation=self.operation)
464
+ dispatch("before_call", hook_context, self)
465
+ response = ASGITransport(application).send(self, base_url=base_url, headers=headers, **kwargs)
466
+ dispatch("after_call", hook_context, self, response)
467
+ return response
457
468
 
458
469
  def validate_response(
459
470
  self,
@@ -462,6 +473,8 @@ class Case:
462
473
  additional_checks: tuple[CheckFunction, ...] = (),
463
474
  excluded_checks: tuple[CheckFunction, ...] = (),
464
475
  code_sample_style: str | None = None,
476
+ headers: dict[str, Any] | None = None,
477
+ transport_kwargs: dict[str, Any] | None = None,
465
478
  ) -> None:
466
479
  """Validate application response.
467
480
 
@@ -475,17 +488,35 @@ class Case:
475
488
  :param code_sample_style: Controls the style of code samples for failure reproduction.
476
489
  """
477
490
  __tracebackhide__ = True
491
+ from requests.structures import CaseInsensitiveDict
492
+
478
493
  from .checks import ALL_CHECKS
494
+ from .internal.checks import wrap_check
479
495
  from .transports.responses import get_payload, get_reason
480
496
 
481
- checks = checks or ALL_CHECKS
497
+ if checks:
498
+ _checks = tuple(wrap_check(check) for check in checks)
499
+ else:
500
+ _checks = checks
501
+ if additional_checks:
502
+ _additional_checks = tuple(wrap_check(check) for check in additional_checks)
503
+ else:
504
+ _additional_checks = additional_checks
505
+
506
+ checks = _checks or ALL_CHECKS
482
507
  checks = tuple(check for check in checks if check not in excluded_checks)
483
- additional_checks = tuple(check for check in additional_checks if check not in excluded_checks)
508
+ additional_checks = tuple(check for check in _additional_checks if check not in excluded_checks)
484
509
  failed_checks = []
510
+ ctx = CheckContext(
511
+ override=self._override,
512
+ auth=None,
513
+ headers=CaseInsensitiveDict(headers) if headers else None,
514
+ transport_kwargs=transport_kwargs,
515
+ )
485
516
  for check in chain(checks, additional_checks):
486
517
  copied_case = self.partial_deepcopy()
487
518
  try:
488
- check(response, copied_case)
519
+ check(ctx, response, copied_case)
489
520
  except AssertionError as exc:
490
521
  maybe_set_assertion_message(exc, check.__name__)
491
522
  failed_checks.append(exc)
@@ -515,7 +546,7 @@ class Case:
515
546
  if not payload:
516
547
  formatted += "\n\n <EMPTY>"
517
548
  else:
518
- payload = prepare_response_payload(payload)
549
+ payload = prepare_response_payload(payload, config=self.operation.schema.output_config)
519
550
  payload = textwrap.indent(f"\n`{payload}`", prefix=" ")
520
551
  formatted += f"\n{payload}"
521
552
  code_sample_style = (
@@ -550,20 +581,37 @@ class Case:
550
581
  session: requests.Session | None = None,
551
582
  headers: dict[str, Any] | None = None,
552
583
  checks: tuple[CheckFunction, ...] = (),
584
+ additional_checks: tuple[CheckFunction, ...] = (),
585
+ excluded_checks: tuple[CheckFunction, ...] = (),
553
586
  code_sample_style: str | None = None,
554
587
  **kwargs: Any,
555
588
  ) -> requests.Response:
556
589
  __tracebackhide__ = True
557
590
  response = self.call(base_url, session, headers, **kwargs)
558
- self.validate_response(response, checks, code_sample_style=code_sample_style)
591
+ self.validate_response(
592
+ response,
593
+ checks,
594
+ code_sample_style=code_sample_style,
595
+ headers=headers,
596
+ additional_checks=additional_checks,
597
+ excluded_checks=excluded_checks,
598
+ transport_kwargs=kwargs,
599
+ )
559
600
  return response
560
601
 
602
+ def _get_url(self, base_url: str | None) -> str:
603
+ base_url = self._get_base_url(base_url)
604
+ formatted_path = self.formatted_path.lstrip("/")
605
+ if not base_url.endswith("/"):
606
+ base_url += "/"
607
+ return unquote(urljoin(base_url, quote(formatted_path)))
608
+
561
609
  def get_full_url(self) -> str:
562
610
  """Make a full URL to the current API operation, including query parameters."""
563
611
  import requests
564
612
 
565
613
  base_url = self.base_url or "http://127.0.0.1"
566
- kwargs = self.as_requests_kwargs(base_url)
614
+ kwargs = RequestsTransport().serialize_case(self, base_url=base_url)
567
615
  request = requests.Request(**kwargs)
568
616
  prepared = requests.Session().prepare_request(request) # type: ignore
569
617
  return cast(str, prepared.url)
@@ -579,28 +627,12 @@ class Case:
579
627
  cookies=fast_deepcopy(self.cookies),
580
628
  query=fast_deepcopy(self.query),
581
629
  body=fast_deepcopy(self.body),
582
- )
583
-
584
-
585
- def _merge_dict_to(data: dict[str, Any], data_key: str, new: dict[str, Any]) -> None:
586
- original = data[data_key] or {}
587
- for key, value in new.items():
588
- original[key] = value
589
- data[data_key] = original
590
-
591
-
592
- def validate_vanilla_requests_kwargs(data: dict[str, Any]) -> None:
593
- """Check arguments for `requests.Session.request`.
594
-
595
- Some arguments can be valid for cases like ASGI integration, but at the same time they won't work for the regular
596
- `requests` calls. In such cases we need to avoid an obscure error message, that comes from `requests`.
597
- """
598
- url = data["url"]
599
- if not urlparse(url).netloc:
600
- raise RuntimeError(
601
- "The URL should be absolute, so Schemathesis knows where to send the data. \n"
602
- f"If you use the ASGI integration, please supply your test client "
603
- f"as the `session` argument to `call`.\nURL: {url}"
630
+ meta=self.meta,
631
+ generation_time=self.generation_time,
632
+ id=self.id,
633
+ _auth=self._auth,
634
+ _has_explicit_auth=self._has_explicit_auth,
635
+ _explicit_method=self._explicit_method,
604
636
  )
605
637
 
606
638
 
@@ -628,7 +660,7 @@ D = TypeVar("D", bound=dict)
628
660
 
629
661
 
630
662
  @dataclass
631
- class OperationDefinition(Generic[P, D]):
663
+ class OperationDefinition(Generic[D]):
632
664
  """A wrapper to store not resolved API operation definitions.
633
665
 
634
666
  To prevent recursion errors we need to store definitions without resolving references. But operation definitions
@@ -639,16 +671,10 @@ class OperationDefinition(Generic[P, D]):
639
671
  raw: D
640
672
  resolved: D
641
673
  scope: str
642
- parameters: Sequence[P]
643
-
644
- def __contains__(self, item: str | int) -> bool:
645
- return item in self.resolved
646
674
 
647
- def __getitem__(self, item: str | int) -> None | bool | float | str | list | dict[str, Any]:
648
- return self.resolved[item]
675
+ __slots__ = ("raw", "resolved", "scope")
649
676
 
650
- def get(self, item: str | int, default: Any = None) -> None | bool | float | str | list | dict[str, Any]:
651
- return self.resolved.get(item, default)
677
+ def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
652
678
 
653
679
 
654
680
  C = TypeVar("C", bound=Case)
@@ -770,9 +796,11 @@ class APIOperation(Generic[P, C]):
770
796
  def get_security_requirements(self) -> list[str]:
771
797
  return self.schema.get_security_requirements(self)
772
798
 
773
- def get_strategies_from_examples(self) -> list[st.SearchStrategy[Case]]:
799
+ def get_strategies_from_examples(
800
+ self, as_strategy_kwargs: dict[str, Any] | None = None
801
+ ) -> list[st.SearchStrategy[Case]]:
774
802
  """Get examples from the API operation."""
775
- return self.schema.get_strategies_from_examples(self)
803
+ return self.schema.get_strategies_from_examples(self, as_strategy_kwargs=as_strategy_kwargs)
776
804
 
777
805
  def get_stateful_tests(self, response: GenericResponse, stateful: Stateful | None) -> Sequence[StatefulTest]:
778
806
  return self.schema.get_stateful_tests(response, self, stateful)
@@ -791,6 +819,19 @@ class APIOperation(Generic[P, C]):
791
819
  def get_request_payload_content_types(self) -> list[str]:
792
820
  return self.schema.get_request_payload_content_types(self)
793
821
 
822
+ def _get_default_media_type(self) -> str:
823
+ # If the user wants to send payload, then there should be a media type, otherwise the payload is ignored
824
+ media_types = self.get_request_payload_content_types()
825
+ if len(media_types) == 1:
826
+ # The only available option
827
+ return media_types[0]
828
+ media_types_repr = ", ".join(media_types)
829
+ raise UsageError(
830
+ "Can not detect appropriate media type. "
831
+ "You can either specify one of the defined media types "
832
+ f"or pass any other media type available for serialization. Defined media types: {media_types_repr}"
833
+ )
834
+
794
835
  def partial_deepcopy(self) -> APIOperation:
795
836
  return self.__class__(
796
837
  path=self.path, # string, immutable
@@ -911,6 +952,7 @@ class Request:
911
952
  method: str
912
953
  uri: str
913
954
  body: str | None
955
+ body_size: int | None
914
956
  headers: Headers
915
957
 
916
958
  @classmethod
@@ -919,7 +961,7 @@ class Request:
919
961
  import requests
920
962
 
921
963
  base_url = case.get_full_base_url()
922
- kwargs = case.as_requests_kwargs(base_url)
964
+ kwargs = RequestsTransport().serialize_case(case, base_url=base_url)
923
965
  request = requests.Request(**kwargs)
924
966
  prepared = session.prepare_request(request) # type: ignore
925
967
  return cls.from_prepared_request(prepared)
@@ -941,8 +983,17 @@ class Request:
941
983
  method=method,
942
984
  headers={key: [value] for (key, value) in prepared.headers.items()},
943
985
  body=serialize_payload(body) if body is not None else body,
986
+ body_size=len(body) if body is not None else None,
944
987
  )
945
988
 
989
+ def deserialize_body(self) -> bytes | None:
990
+ """Deserialize the request body.
991
+
992
+ `Request` should be serializable to JSON, therefore body is encoded as base64 string
993
+ to support arbitrary binary data.
994
+ """
995
+ return deserialize_payload(self.body)
996
+
946
997
 
947
998
  @dataclass(repr=False)
948
999
  class Response:
@@ -952,6 +1003,7 @@ class Response:
952
1003
  message: str
953
1004
  headers: dict[str, list[str]]
954
1005
  body: str | None
1006
+ body_size: int | None
955
1007
  encoding: str | None
956
1008
  http_version: str
957
1009
  elapsed: float
@@ -978,6 +1030,7 @@ class Response:
978
1030
  status_code=response.status_code,
979
1031
  message=response.reason,
980
1032
  body=body,
1033
+ body_size=len(response.content) if body is not None else None,
981
1034
  encoding=response.encoding,
982
1035
  headers=headers,
983
1036
  http_version=http_version,
@@ -1005,6 +1058,7 @@ class Response:
1005
1058
  status_code=response.status_code,
1006
1059
  message=message,
1007
1060
  body=body,
1061
+ body_size=len(data) if body is not None else None,
1008
1062
  encoding=encoding,
1009
1063
  headers=headers,
1010
1064
  http_version="1.1",
@@ -1012,35 +1066,76 @@ class Response:
1012
1066
  verify=True,
1013
1067
  )
1014
1068
 
1069
+ def deserialize_body(self) -> bytes | None:
1070
+ """Deserialize the response body.
1071
+
1072
+ `Response` should be serializable to JSON, therefore body is encoded as base64 string
1073
+ to support arbitrary binary data.
1074
+ """
1075
+ return deserialize_payload(self.body)
1076
+
1077
+
1078
+ TIMEZONE = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo
1079
+
1015
1080
 
1016
1081
  @dataclass
1017
1082
  class Interaction:
1018
1083
  """A single interaction with the target app."""
1019
1084
 
1020
1085
  request: Request
1021
- response: Response
1086
+ response: Response | None
1022
1087
  checks: list[Check]
1023
1088
  status: Status
1024
1089
  data_generation_method: DataGenerationMethod
1025
- recorded_at: str = field(default_factory=lambda: datetime.datetime.now().isoformat())
1090
+ phase: TestPhase | None
1091
+ # `description` & `location` are related to metadata about this interaction
1092
+ # NOTE: It will be better to keep it in a separate attribute
1093
+ description: str | None
1094
+ location: str | None
1095
+ parameter: str | None
1096
+ parameter_location: str | None
1097
+ recorded_at: str = field(default_factory=lambda: datetime.datetime.now(TIMEZONE).isoformat())
1026
1098
 
1027
1099
  @classmethod
1028
- def from_requests(cls, case: Case, response: requests.Response, status: Status, checks: list[Check]) -> Interaction:
1100
+ def from_requests(
1101
+ cls,
1102
+ case: Case,
1103
+ response: requests.Response | None,
1104
+ status: Status,
1105
+ checks: list[Check],
1106
+ headers: dict[str, Any] | None,
1107
+ session: requests.Session | None,
1108
+ ) -> Interaction:
1109
+ if response is not None:
1110
+ prepared = response.request
1111
+ request = Request.from_prepared_request(prepared)
1112
+ else:
1113
+ import requests
1114
+
1115
+ if session is None:
1116
+ session = requests.Session()
1117
+ session.headers.update(headers or {})
1118
+ request = Request.from_case(case, session)
1029
1119
  return cls(
1030
- request=Request.from_prepared_request(response.request),
1031
- response=Response.from_requests(response),
1120
+ request=request,
1121
+ response=Response.from_requests(response) if response is not None else None,
1032
1122
  status=status,
1033
1123
  checks=checks,
1034
1124
  data_generation_method=cast(DataGenerationMethod, case.data_generation_method),
1125
+ phase=case.meta.phase if case.meta is not None else None,
1126
+ description=case.meta.description if case.meta is not None else None,
1127
+ location=case.meta.location if case.meta is not None else None,
1128
+ parameter=case.meta.parameter if case.meta is not None else None,
1129
+ parameter_location=case.meta.parameter_location if case.meta is not None else None,
1035
1130
  )
1036
1131
 
1037
1132
  @classmethod
1038
1133
  def from_wsgi(
1039
1134
  cls,
1040
1135
  case: Case,
1041
- response: WSGIResponse,
1136
+ response: WSGIResponse | None,
1042
1137
  headers: dict[str, Any],
1043
- elapsed: float,
1138
+ elapsed: float | None,
1044
1139
  status: Status,
1045
1140
  checks: list[Check],
1046
1141
  ) -> Interaction:
@@ -1050,10 +1145,15 @@ class Interaction:
1050
1145
  session.headers.update(headers)
1051
1146
  return cls(
1052
1147
  request=Request.from_case(case, session),
1053
- response=Response.from_wsgi(response, elapsed),
1148
+ response=Response.from_wsgi(response, elapsed) if response is not None and elapsed is not None else None,
1054
1149
  status=status,
1055
1150
  checks=checks,
1056
1151
  data_generation_method=cast(DataGenerationMethod, case.data_generation_method),
1152
+ phase=case.meta.phase if case.meta is not None else None,
1153
+ description=case.meta.description if case.meta is not None else None,
1154
+ location=case.meta.location if case.meta is not None else None,
1155
+ parameter=case.meta.parameter if case.meta is not None else None,
1156
+ parameter_location=case.meta.parameter_location if case.meta is not None else None,
1057
1157
  )
1058
1158
 
1059
1159
 
@@ -1079,6 +1179,8 @@ class TestResult:
1079
1179
  # DEPRECATED: Seed is the same per test run
1080
1180
  seed: int | None = None
1081
1181
 
1182
+ def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
1183
+
1082
1184
  def mark_errored(self) -> None:
1083
1185
  self.is_errored = True
1084
1186
 
@@ -1139,16 +1241,22 @@ class TestResult:
1139
1241
  self.errors.append(exception)
1140
1242
 
1141
1243
  def store_requests_response(
1142
- self, case: Case, response: requests.Response, status: Status, checks: list[Check]
1244
+ self,
1245
+ case: Case,
1246
+ response: requests.Response | None,
1247
+ status: Status,
1248
+ checks: list[Check],
1249
+ headers: dict[str, Any] | None,
1250
+ session: requests.Session | None,
1143
1251
  ) -> None:
1144
- self.interactions.append(Interaction.from_requests(case, response, status, checks))
1252
+ self.interactions.append(Interaction.from_requests(case, response, status, checks, headers, session))
1145
1253
 
1146
1254
  def store_wsgi_response(
1147
1255
  self,
1148
1256
  case: Case,
1149
- response: WSGIResponse,
1257
+ response: WSGIResponse | None,
1150
1258
  headers: dict[str, Any],
1151
- elapsed: float,
1259
+ elapsed: float | None,
1152
1260
  status: Status,
1153
1261
  checks: list[Check],
1154
1262
  ) -> None:
@@ -1166,6 +1274,8 @@ class TestResultSet:
1166
1274
  generic_errors: list[OperationSchemaError] = field(default_factory=list)
1167
1275
  warnings: list[str] = field(default_factory=list)
1168
1276
 
1277
+ def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
1278
+
1169
1279
  def __iter__(self) -> Iterator[TestResult]:
1170
1280
  return iter(self.results)
1171
1281
 
@@ -1229,6 +1339,3 @@ class TestResultSet:
1229
1339
  def add_warning(self, warning: str) -> None:
1230
1340
  """Add a new warning to the warnings list."""
1231
1341
  self.warnings.append(warning)
1232
-
1233
-
1234
- CheckFunction = Callable[["GenericResponse", Case], Optional[bool]]