schemathesis 3.25.6__py3-none-any.whl → 4.0.0a1__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 (221) hide show
  1. schemathesis/__init__.py +27 -65
  2. schemathesis/auths.py +102 -82
  3. schemathesis/checks.py +126 -46
  4. schemathesis/cli/__init__.py +11 -1760
  5. schemathesis/cli/__main__.py +4 -0
  6. schemathesis/cli/commands/__init__.py +37 -0
  7. schemathesis/cli/commands/run/__init__.py +662 -0
  8. schemathesis/cli/commands/run/checks.py +80 -0
  9. schemathesis/cli/commands/run/context.py +117 -0
  10. schemathesis/cli/commands/run/events.py +35 -0
  11. schemathesis/cli/commands/run/executor.py +138 -0
  12. schemathesis/cli/commands/run/filters.py +194 -0
  13. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  14. schemathesis/cli/commands/run/handlers/base.py +18 -0
  15. schemathesis/cli/commands/run/handlers/cassettes.py +494 -0
  16. schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
  17. schemathesis/cli/commands/run/handlers/output.py +746 -0
  18. schemathesis/cli/commands/run/hypothesis.py +105 -0
  19. schemathesis/cli/commands/run/loaders.py +129 -0
  20. schemathesis/cli/{callbacks.py → commands/run/validation.py} +103 -174
  21. schemathesis/cli/constants.py +5 -52
  22. schemathesis/cli/core.py +17 -0
  23. schemathesis/cli/ext/fs.py +14 -0
  24. schemathesis/cli/ext/groups.py +55 -0
  25. schemathesis/cli/{options.py → ext/options.py} +39 -10
  26. schemathesis/cli/hooks.py +36 -0
  27. schemathesis/contrib/__init__.py +1 -3
  28. schemathesis/contrib/openapi/__init__.py +1 -3
  29. schemathesis/contrib/openapi/fill_missing_examples.py +3 -5
  30. schemathesis/core/__init__.py +58 -0
  31. schemathesis/core/compat.py +25 -0
  32. schemathesis/core/control.py +2 -0
  33. schemathesis/core/curl.py +58 -0
  34. schemathesis/core/deserialization.py +65 -0
  35. schemathesis/core/errors.py +370 -0
  36. schemathesis/core/failures.py +285 -0
  37. schemathesis/core/fs.py +19 -0
  38. schemathesis/{_lazy_import.py → core/lazy_import.py} +1 -0
  39. schemathesis/core/loaders.py +104 -0
  40. schemathesis/core/marks.py +66 -0
  41. schemathesis/{transports/content_types.py → core/media_types.py} +17 -13
  42. schemathesis/core/output/__init__.py +69 -0
  43. schemathesis/core/output/sanitization.py +197 -0
  44. schemathesis/core/rate_limit.py +60 -0
  45. schemathesis/core/registries.py +31 -0
  46. schemathesis/{internal → core}/result.py +1 -1
  47. schemathesis/core/transforms.py +113 -0
  48. schemathesis/core/transport.py +108 -0
  49. schemathesis/core/validation.py +38 -0
  50. schemathesis/core/version.py +7 -0
  51. schemathesis/engine/__init__.py +30 -0
  52. schemathesis/engine/config.py +59 -0
  53. schemathesis/engine/context.py +119 -0
  54. schemathesis/engine/control.py +36 -0
  55. schemathesis/engine/core.py +157 -0
  56. schemathesis/engine/errors.py +394 -0
  57. schemathesis/engine/events.py +337 -0
  58. schemathesis/engine/phases/__init__.py +66 -0
  59. schemathesis/{runner → engine/phases}/probes.py +50 -67
  60. schemathesis/engine/phases/stateful/__init__.py +65 -0
  61. schemathesis/engine/phases/stateful/_executor.py +326 -0
  62. schemathesis/engine/phases/stateful/context.py +85 -0
  63. schemathesis/engine/phases/unit/__init__.py +174 -0
  64. schemathesis/engine/phases/unit/_executor.py +321 -0
  65. schemathesis/engine/phases/unit/_pool.py +74 -0
  66. schemathesis/engine/recorder.py +241 -0
  67. schemathesis/errors.py +31 -0
  68. schemathesis/experimental/__init__.py +18 -14
  69. schemathesis/filters.py +103 -14
  70. schemathesis/generation/__init__.py +21 -37
  71. schemathesis/generation/case.py +190 -0
  72. schemathesis/generation/coverage.py +931 -0
  73. schemathesis/generation/hypothesis/__init__.py +30 -0
  74. schemathesis/generation/hypothesis/builder.py +585 -0
  75. schemathesis/generation/hypothesis/examples.py +50 -0
  76. schemathesis/generation/hypothesis/given.py +66 -0
  77. schemathesis/generation/hypothesis/reporting.py +14 -0
  78. schemathesis/generation/hypothesis/strategies.py +16 -0
  79. schemathesis/generation/meta.py +115 -0
  80. schemathesis/generation/modes.py +28 -0
  81. schemathesis/generation/overrides.py +96 -0
  82. schemathesis/generation/stateful/__init__.py +20 -0
  83. schemathesis/{stateful → generation/stateful}/state_machine.py +68 -81
  84. schemathesis/generation/targets.py +69 -0
  85. schemathesis/graphql/__init__.py +15 -0
  86. schemathesis/graphql/checks.py +115 -0
  87. schemathesis/graphql/loaders.py +131 -0
  88. schemathesis/hooks.py +99 -67
  89. schemathesis/openapi/__init__.py +13 -0
  90. schemathesis/openapi/checks.py +412 -0
  91. schemathesis/openapi/generation/__init__.py +0 -0
  92. schemathesis/openapi/generation/filters.py +63 -0
  93. schemathesis/openapi/loaders.py +178 -0
  94. schemathesis/pytest/__init__.py +5 -0
  95. schemathesis/pytest/control_flow.py +7 -0
  96. schemathesis/pytest/lazy.py +273 -0
  97. schemathesis/pytest/loaders.py +12 -0
  98. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +106 -127
  99. schemathesis/python/__init__.py +0 -0
  100. schemathesis/python/asgi.py +12 -0
  101. schemathesis/python/wsgi.py +12 -0
  102. schemathesis/schemas.py +537 -261
  103. schemathesis/specs/graphql/__init__.py +0 -1
  104. schemathesis/specs/graphql/_cache.py +25 -0
  105. schemathesis/specs/graphql/nodes.py +1 -0
  106. schemathesis/specs/graphql/scalars.py +7 -5
  107. schemathesis/specs/graphql/schemas.py +215 -187
  108. schemathesis/specs/graphql/validation.py +11 -18
  109. schemathesis/specs/openapi/__init__.py +7 -1
  110. schemathesis/specs/openapi/_cache.py +122 -0
  111. schemathesis/specs/openapi/_hypothesis.py +146 -165
  112. schemathesis/specs/openapi/checks.py +565 -67
  113. schemathesis/specs/openapi/converter.py +33 -6
  114. schemathesis/specs/openapi/definitions.py +11 -18
  115. schemathesis/specs/openapi/examples.py +139 -23
  116. schemathesis/specs/openapi/expressions/__init__.py +37 -2
  117. schemathesis/specs/openapi/expressions/context.py +4 -6
  118. schemathesis/specs/openapi/expressions/extractors.py +23 -0
  119. schemathesis/specs/openapi/expressions/lexer.py +20 -18
  120. schemathesis/specs/openapi/expressions/nodes.py +38 -14
  121. schemathesis/specs/openapi/expressions/parser.py +26 -5
  122. schemathesis/specs/openapi/formats.py +45 -0
  123. schemathesis/specs/openapi/links.py +65 -165
  124. schemathesis/specs/openapi/media_types.py +32 -0
  125. schemathesis/specs/openapi/negative/__init__.py +7 -3
  126. schemathesis/specs/openapi/negative/mutations.py +24 -8
  127. schemathesis/specs/openapi/parameters.py +46 -30
  128. schemathesis/specs/openapi/patterns.py +137 -0
  129. schemathesis/specs/openapi/references.py +47 -57
  130. schemathesis/specs/openapi/schemas.py +478 -369
  131. schemathesis/specs/openapi/security.py +25 -7
  132. schemathesis/specs/openapi/serialization.py +11 -6
  133. schemathesis/specs/openapi/stateful/__init__.py +185 -73
  134. schemathesis/specs/openapi/utils.py +6 -1
  135. schemathesis/transport/__init__.py +104 -0
  136. schemathesis/transport/asgi.py +26 -0
  137. schemathesis/transport/prepare.py +99 -0
  138. schemathesis/transport/requests.py +221 -0
  139. schemathesis/{_xml.py → transport/serialization.py} +143 -28
  140. schemathesis/transport/wsgi.py +165 -0
  141. schemathesis-4.0.0a1.dist-info/METADATA +297 -0
  142. schemathesis-4.0.0a1.dist-info/RECORD +152 -0
  143. {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
  144. {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/entry_points.txt +1 -1
  145. schemathesis/_compat.py +0 -74
  146. schemathesis/_dependency_versions.py +0 -17
  147. schemathesis/_hypothesis.py +0 -246
  148. schemathesis/_override.py +0 -49
  149. schemathesis/cli/cassettes.py +0 -375
  150. schemathesis/cli/context.py +0 -58
  151. schemathesis/cli/debug.py +0 -26
  152. schemathesis/cli/handlers.py +0 -16
  153. schemathesis/cli/junitxml.py +0 -43
  154. schemathesis/cli/output/__init__.py +0 -1
  155. schemathesis/cli/output/default.py +0 -790
  156. schemathesis/cli/output/short.py +0 -44
  157. schemathesis/cli/sanitization.py +0 -20
  158. schemathesis/code_samples.py +0 -149
  159. schemathesis/constants.py +0 -55
  160. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  161. schemathesis/contrib/openapi/formats/uuid.py +0 -15
  162. schemathesis/contrib/unique_data.py +0 -41
  163. schemathesis/exceptions.py +0 -560
  164. schemathesis/extra/_aiohttp.py +0 -27
  165. schemathesis/extra/_flask.py +0 -10
  166. schemathesis/extra/_server.py +0 -17
  167. schemathesis/failures.py +0 -209
  168. schemathesis/fixups/__init__.py +0 -36
  169. schemathesis/fixups/fast_api.py +0 -41
  170. schemathesis/fixups/utf8_bom.py +0 -29
  171. schemathesis/graphql.py +0 -4
  172. schemathesis/internal/__init__.py +0 -7
  173. schemathesis/internal/copy.py +0 -13
  174. schemathesis/internal/datetime.py +0 -5
  175. schemathesis/internal/deprecation.py +0 -34
  176. schemathesis/internal/jsonschema.py +0 -35
  177. schemathesis/internal/transformation.py +0 -15
  178. schemathesis/internal/validation.py +0 -34
  179. schemathesis/lazy.py +0 -361
  180. schemathesis/loaders.py +0 -120
  181. schemathesis/models.py +0 -1234
  182. schemathesis/parameters.py +0 -86
  183. schemathesis/runner/__init__.py +0 -570
  184. schemathesis/runner/events.py +0 -329
  185. schemathesis/runner/impl/__init__.py +0 -3
  186. schemathesis/runner/impl/core.py +0 -1035
  187. schemathesis/runner/impl/solo.py +0 -90
  188. schemathesis/runner/impl/threadpool.py +0 -400
  189. schemathesis/runner/serialization.py +0 -411
  190. schemathesis/sanitization.py +0 -248
  191. schemathesis/serializers.py +0 -323
  192. schemathesis/service/__init__.py +0 -18
  193. schemathesis/service/auth.py +0 -11
  194. schemathesis/service/ci.py +0 -201
  195. schemathesis/service/client.py +0 -100
  196. schemathesis/service/constants.py +0 -38
  197. schemathesis/service/events.py +0 -57
  198. schemathesis/service/hosts.py +0 -107
  199. schemathesis/service/metadata.py +0 -46
  200. schemathesis/service/models.py +0 -49
  201. schemathesis/service/report.py +0 -255
  202. schemathesis/service/serialization.py +0 -199
  203. schemathesis/service/usage.py +0 -65
  204. schemathesis/specs/graphql/loaders.py +0 -344
  205. schemathesis/specs/openapi/filters.py +0 -49
  206. schemathesis/specs/openapi/loaders.py +0 -667
  207. schemathesis/specs/openapi/stateful/links.py +0 -92
  208. schemathesis/specs/openapi/validation.py +0 -25
  209. schemathesis/stateful/__init__.py +0 -133
  210. schemathesis/targets.py +0 -45
  211. schemathesis/throttling.py +0 -41
  212. schemathesis/transports/__init__.py +0 -5
  213. schemathesis/transports/auth.py +0 -15
  214. schemathesis/transports/headers.py +0 -35
  215. schemathesis/transports/responses.py +0 -52
  216. schemathesis/types.py +0 -35
  217. schemathesis/utils.py +0 -169
  218. schemathesis-3.25.6.dist-info/METADATA +0 -356
  219. schemathesis-3.25.6.dist-info/RECORD +0 -134
  220. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  221. {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,321 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import unittest
5
+ import uuid
6
+ from typing import TYPE_CHECKING, Callable, Iterable
7
+ from warnings import WarningMessage, catch_warnings
8
+
9
+ import requests
10
+ from hypothesis.errors import InvalidArgument
11
+ from hypothesis_jsonschema._canonicalise import HypothesisRefResolutionError
12
+ from jsonschema.exceptions import SchemaError as JsonSchemaError
13
+ from jsonschema.exceptions import ValidationError
14
+
15
+ from schemathesis.checks import CheckContext, CheckFunction, run_checks
16
+ from schemathesis.core.compat import BaseExceptionGroup
17
+ from schemathesis.core.control import SkipTest
18
+ from schemathesis.core.errors import (
19
+ SERIALIZERS_SUGGESTION_MESSAGE,
20
+ InternalError,
21
+ InvalidHeadersExample,
22
+ InvalidRegexPattern,
23
+ InvalidRegexType,
24
+ InvalidSchema,
25
+ MalformedMediaType,
26
+ SerializationNotPossible,
27
+ )
28
+ from schemathesis.core.failures import Failure, FailureGroup
29
+ from schemathesis.core.transport import Response
30
+ from schemathesis.engine import Status, events
31
+ from schemathesis.engine.context import EngineContext
32
+ from schemathesis.engine.errors import (
33
+ DeadlineExceeded,
34
+ UnexpectedError,
35
+ UnsupportedRecursiveReference,
36
+ deduplicate_errors,
37
+ )
38
+ from schemathesis.engine.phases import PhaseName
39
+ from schemathesis.engine.recorder import ScenarioRecorder
40
+ from schemathesis.generation import targets
41
+ from schemathesis.generation.case import Case
42
+ from schemathesis.generation.hypothesis.builder import (
43
+ InvalidHeadersExampleMark,
44
+ InvalidRegexMark,
45
+ NonSerializableMark,
46
+ UnsatisfiableExampleMark,
47
+ )
48
+ from schemathesis.generation.hypothesis.reporting import ignore_hypothesis_output
49
+
50
+ if TYPE_CHECKING:
51
+ from schemathesis.schemas import APIOperation
52
+
53
+
54
+ def run_test(
55
+ *, operation: APIOperation, test_function: Callable, ctx: EngineContext, suite_id: uuid.UUID
56
+ ) -> events.EventGenerator:
57
+ """A single test run with all error handling needed."""
58
+ import hypothesis.errors
59
+
60
+ # To simplify connecting `before` and `after` events in external systems
61
+ scenario_started = events.ScenarioStarted(label=operation.label, phase=PhaseName.UNIT_TESTING, suite_id=suite_id)
62
+ yield scenario_started
63
+ errors: list[Exception] = []
64
+ skip_reason = None
65
+ test_start_time = time.monotonic()
66
+ recorder = ScenarioRecorder(label=operation.label)
67
+
68
+ def non_fatal_error(error: Exception) -> events.NonFatalError:
69
+ return events.NonFatalError(
70
+ error=error, phase=PhaseName.UNIT_TESTING, label=operation.label, related_to_operation=True
71
+ )
72
+
73
+ try:
74
+ setup_hypothesis_database_key(test_function, operation)
75
+ with catch_warnings(record=True) as warnings, ignore_hypothesis_output():
76
+ test_function(ctx=ctx, errors=errors, recorder=recorder)
77
+ # Test body was not executed at all - Hypothesis did not generate any tests, but there is no error
78
+ status = Status.SUCCESS
79
+ except (SkipTest, unittest.case.SkipTest) as exc:
80
+ status = Status.SKIP
81
+ skip_reason = {"Hypothesis has been told to run no examples for this test.": "No examples in schema"}.get(
82
+ str(exc), str(exc)
83
+ )
84
+ except (FailureGroup, Failure):
85
+ status = Status.FAILURE
86
+ except UnexpectedError:
87
+ # It could be an error in user-defined extensions, network errors or internal Schemathesis errors
88
+ status = Status.ERROR
89
+ for idx, err in enumerate(errors):
90
+ if isinstance(err, MalformedMediaType):
91
+ errors[idx] = InvalidSchema(str(err))
92
+ except hypothesis.errors.Flaky as exc:
93
+ if isinstance(exc.__cause__, hypothesis.errors.DeadlineExceeded):
94
+ status = Status.ERROR
95
+ yield non_fatal_error(DeadlineExceeded.from_exc(exc.__cause__))
96
+ elif isinstance(exc, hypothesis.errors.FlakyFailure) and any(
97
+ isinstance(subexc, hypothesis.errors.DeadlineExceeded) for subexc in exc.exceptions
98
+ ):
99
+ for sub_exc in exc.exceptions:
100
+ if isinstance(sub_exc, hypothesis.errors.DeadlineExceeded):
101
+ yield non_fatal_error(DeadlineExceeded.from_exc(sub_exc))
102
+ status = Status.ERROR
103
+ elif errors:
104
+ status = Status.ERROR
105
+ else:
106
+ status = Status.FAILURE
107
+ except BaseExceptionGroup:
108
+ status = Status.ERROR
109
+ except hypothesis.errors.Unsatisfiable:
110
+ # We need more clear error message here
111
+ status = Status.ERROR
112
+ yield non_fatal_error(hypothesis.errors.Unsatisfiable("Failed to generate test cases for this API operation"))
113
+ except KeyboardInterrupt:
114
+ yield events.Interrupted(phase=PhaseName.UNIT_TESTING)
115
+ return
116
+ except AssertionError as exc: # May come from `hypothesis-jsonschema` or `hypothesis`
117
+ status = Status.ERROR
118
+ try:
119
+ operation.schema.validate()
120
+ msg = "Unexpected error during testing of this API operation"
121
+ exc_msg = str(exc)
122
+ if exc_msg:
123
+ msg += f": {exc_msg}"
124
+ try:
125
+ raise InternalError(msg) from exc
126
+ except InternalError as exc:
127
+ yield non_fatal_error(exc)
128
+ except ValidationError as exc:
129
+ yield non_fatal_error(
130
+ InvalidSchema.from_jsonschema_error(
131
+ exc,
132
+ path=operation.path,
133
+ method=operation.method,
134
+ full_path=operation.schema.get_full_path(operation.path),
135
+ )
136
+ )
137
+ except HypothesisRefResolutionError:
138
+ status = Status.ERROR
139
+ yield non_fatal_error(UnsupportedRecursiveReference())
140
+ except InvalidArgument as exc:
141
+ status = Status.ERROR
142
+ message = get_invalid_regular_expression_message(warnings)
143
+ if message:
144
+ # `hypothesis-jsonschema` emits a warning on invalid regular expression syntax
145
+ yield non_fatal_error(InvalidRegexPattern.from_hypothesis_jsonschema_message(message))
146
+ else:
147
+ yield non_fatal_error(exc)
148
+ except hypothesis.errors.DeadlineExceeded as exc:
149
+ status = Status.ERROR
150
+ yield non_fatal_error(DeadlineExceeded.from_exc(exc))
151
+ except JsonSchemaError as exc:
152
+ status = Status.ERROR
153
+ yield non_fatal_error(InvalidRegexPattern.from_schema_error(exc, from_examples=False))
154
+ except Exception as exc:
155
+ status = Status.ERROR
156
+ # Likely a YAML parsing issue. E.g. `00:00:00.00` (without quotes) is parsed as float `0.0`
157
+ if str(exc) == "first argument must be string or compiled pattern":
158
+ yield non_fatal_error(
159
+ InvalidRegexType(
160
+ "Invalid `pattern` value: expected a string. "
161
+ "If your schema is in YAML, ensure `pattern` values are quoted",
162
+ )
163
+ )
164
+ else:
165
+ yield non_fatal_error(exc)
166
+ if (
167
+ status == Status.SUCCESS
168
+ and ctx.config.execution.no_failfast
169
+ and any(check.status == Status.FAILURE for checks in recorder.checks.values() for check in checks)
170
+ ):
171
+ status = Status.FAILURE
172
+ if UnsatisfiableExampleMark.is_set(test_function):
173
+ status = Status.ERROR
174
+ yield non_fatal_error(
175
+ hypothesis.errors.Unsatisfiable("Failed to generate test cases from examples for this API operation")
176
+ )
177
+ non_serializable = NonSerializableMark.get(test_function)
178
+ if non_serializable is not None and status != Status.ERROR:
179
+ status = Status.ERROR
180
+ media_types = ", ".join(non_serializable.media_types)
181
+ yield non_fatal_error(
182
+ SerializationNotPossible(
183
+ "Failed to generate test cases from examples for this API operation because of"
184
+ f" unsupported payload media types: {media_types}\n{SERIALIZERS_SUGGESTION_MESSAGE}",
185
+ media_types=non_serializable.media_types,
186
+ )
187
+ )
188
+
189
+ invalid_regex = InvalidRegexMark.get(test_function)
190
+ if invalid_regex is not None and status != Status.ERROR:
191
+ status = Status.ERROR
192
+ yield non_fatal_error(InvalidRegexPattern.from_schema_error(invalid_regex, from_examples=True))
193
+ invalid_headers = InvalidHeadersExampleMark.get(test_function)
194
+ if invalid_headers:
195
+ status = Status.ERROR
196
+ yield non_fatal_error(InvalidHeadersExample.from_headers(invalid_headers))
197
+ test_elapsed_time = time.monotonic() - test_start_time
198
+ for error in deduplicate_errors(errors):
199
+ yield non_fatal_error(error)
200
+ yield events.ScenarioFinished(
201
+ id=scenario_started.id,
202
+ suite_id=suite_id,
203
+ phase=PhaseName.UNIT_TESTING,
204
+ recorder=recorder,
205
+ status=status,
206
+ elapsed_time=test_elapsed_time,
207
+ skip_reason=skip_reason,
208
+ is_final=False,
209
+ )
210
+
211
+
212
+ def setup_hypothesis_database_key(test: Callable, operation: APIOperation) -> None:
213
+ """Make Hypothesis use separate database entries for every API operation.
214
+
215
+ It increases the effectiveness of the Hypothesis database in the CLI.
216
+ """
217
+ # Hypothesis's function digest depends on the test function signature. To reflect it for the web API case,
218
+ # we use all API operation parameters in the digest.
219
+ extra = operation.label.encode("utf8")
220
+ for parameter in operation.iter_parameters():
221
+ extra += parameter.serialize(operation).encode("utf8")
222
+ test.hypothesis.inner_test._hypothesis_internal_add_digest = extra # type: ignore
223
+
224
+
225
+ def get_invalid_regular_expression_message(warnings: list[WarningMessage]) -> str | None:
226
+ for warning in warnings:
227
+ message = str(warning.message)
228
+ if "is not valid syntax for a Python regular expression" in message:
229
+ return message
230
+ return None
231
+
232
+
233
+ def cached_test_func(f: Callable) -> Callable:
234
+ def wrapped(*, ctx: EngineContext, case: Case, errors: list[Exception], recorder: ScenarioRecorder) -> None:
235
+ try:
236
+ if ctx.has_to_stop:
237
+ raise KeyboardInterrupt
238
+ if ctx.config.execution.unique_inputs:
239
+ cached = ctx.get_cached_outcome(case)
240
+ if isinstance(cached, BaseException):
241
+ raise cached
242
+ elif cached is None:
243
+ return None
244
+ try:
245
+ f(ctx=ctx, case=case, recorder=recorder)
246
+ except BaseException as exc:
247
+ ctx.cache_outcome(case, exc)
248
+ raise
249
+ else:
250
+ ctx.cache_outcome(case, None)
251
+ else:
252
+ f(ctx=ctx, case=case, recorder=recorder)
253
+ except (KeyboardInterrupt, Failure):
254
+ raise
255
+ except Exception as exc:
256
+ errors.append(exc)
257
+ raise UnexpectedError from None
258
+
259
+ wrapped.__name__ = f.__name__
260
+
261
+ return wrapped
262
+
263
+
264
+ @cached_test_func
265
+ def test_func(*, ctx: EngineContext, case: Case, recorder: ScenarioRecorder) -> None:
266
+ recorder.record_case(parent_id=None, case=case)
267
+ try:
268
+ response = case.call(**ctx.transport_kwargs)
269
+ except (requests.Timeout, requests.ConnectionError) as error:
270
+ if isinstance(error.request, requests.Request):
271
+ recorder.record_request(case_id=case.id, request=error.request.prepare())
272
+ elif isinstance(error.request, requests.PreparedRequest):
273
+ recorder.record_request(case_id=case.id, request=error.request)
274
+ raise
275
+ recorder.record_response(case_id=case.id, response=response)
276
+ targets.run(ctx.config.execution.targets, case=case, response=response)
277
+ validate_response(
278
+ case=case,
279
+ ctx=ctx.get_check_context(recorder),
280
+ checks=ctx.config.execution.checks,
281
+ response=response,
282
+ no_failfast=ctx.config.execution.no_failfast,
283
+ recorder=recorder,
284
+ )
285
+
286
+
287
+ def validate_response(
288
+ *,
289
+ case: Case,
290
+ ctx: CheckContext,
291
+ checks: Iterable[CheckFunction],
292
+ response: Response,
293
+ no_failfast: bool,
294
+ recorder: ScenarioRecorder,
295
+ ) -> None:
296
+ failures = set()
297
+
298
+ def on_failure(name: str, collected: set[Failure], failure: Failure) -> None:
299
+ collected.add(failure)
300
+ failure_data = recorder.find_failure_data(parent_id=case.id, failure=failure)
301
+ recorder.record_check_failure(
302
+ name=name,
303
+ case_id=failure_data.case.id,
304
+ code_sample=failure_data.case.as_curl_command(headers=failure_data.headers, verify=failure_data.verify),
305
+ failure=failure,
306
+ )
307
+
308
+ def on_success(name: str, _case: Case) -> None:
309
+ recorder.record_check_success(name=name, case_id=_case.id)
310
+
311
+ failures = run_checks(
312
+ case=case,
313
+ response=response,
314
+ ctx=ctx,
315
+ checks=checks,
316
+ on_failure=on_failure,
317
+ on_success=on_success,
318
+ )
319
+
320
+ if failures and not no_failfast:
321
+ raise FailureGroup(list(failures)) from None
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import uuid
5
+ from queue import Queue
6
+ from types import TracebackType
7
+ from typing import TYPE_CHECKING, Callable
8
+
9
+ from schemathesis.core.result import Result
10
+
11
+ if TYPE_CHECKING:
12
+ from schemathesis.engine.context import EngineContext
13
+
14
+
15
+ class TaskProducer:
16
+ """Produces test tasks for workers to execute."""
17
+
18
+ def __init__(self, ctx: EngineContext) -> None:
19
+ self.operations = ctx.schema.get_all_operations(generation_config=ctx.config.execution.generation)
20
+ self.lock = threading.Lock()
21
+
22
+ def next_operation(self) -> Result | None:
23
+ """Get next API operation in a thread-safe manner."""
24
+ with self.lock:
25
+ return next(self.operations, None)
26
+
27
+
28
+ class WorkerPool:
29
+ """Manages a pool of worker threads."""
30
+
31
+ def __init__(
32
+ self,
33
+ workers_num: int,
34
+ producer: TaskProducer,
35
+ worker_factory: Callable,
36
+ ctx: EngineContext,
37
+ suite_id: uuid.UUID,
38
+ ) -> None:
39
+ self.workers_num = workers_num
40
+ self.producer = producer
41
+ self.worker_factory = worker_factory
42
+ self.ctx = ctx
43
+ self.suite_id = suite_id
44
+ self.workers: list[threading.Thread] = []
45
+ self.events_queue: Queue = Queue()
46
+
47
+ def start(self) -> None:
48
+ """Start all worker threads."""
49
+ for i in range(self.workers_num):
50
+ worker = threading.Thread(
51
+ target=self.worker_factory,
52
+ kwargs={
53
+ "ctx": self.ctx,
54
+ "events_queue": self.events_queue,
55
+ "producer": self.producer,
56
+ "suite_id": self.suite_id,
57
+ },
58
+ name=f"schemathesis_{i}",
59
+ daemon=True,
60
+ )
61
+ self.workers.append(worker)
62
+ worker.start()
63
+
64
+ def stop(self) -> None:
65
+ """Stop all workers gracefully."""
66
+ for worker in self.workers:
67
+ worker.join()
68
+
69
+ def __enter__(self) -> WorkerPool:
70
+ self.start()
71
+ return self
72
+
73
+ def __exit__(self, ty: type[BaseException] | None, value: BaseException | None, tb: TracebackType | None) -> None:
74
+ self.stop()
@@ -0,0 +1,241 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import time
5
+ import uuid
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, Iterator, cast
8
+
9
+ from schemathesis.core.failures import Failure
10
+ from schemathesis.core.transport import Response
11
+ from schemathesis.engine import Status
12
+ from schemathesis.generation.case import Case
13
+
14
+ if TYPE_CHECKING:
15
+ import requests
16
+
17
+
18
+ @dataclass
19
+ class ScenarioRecorder:
20
+ """Tracks and organizes all data related to a logical block of testing.
21
+
22
+ Records test cases, their hierarchy, API interactions, and results of checks performed during execution.
23
+ """
24
+
25
+ id: uuid.UUID
26
+ # Human-readable label
27
+ label: str
28
+
29
+ # Recorded test cases
30
+ cases: dict[str, CaseNode]
31
+ # Results of checks categorized by test case ID
32
+ checks: dict[str, list[CheckNode]]
33
+ # Network interactions by test case ID
34
+ interactions: dict[str, Interaction]
35
+
36
+ __slots__ = ("id", "label", "status", "roots", "cases", "checks", "interactions")
37
+
38
+ def __init__(self, *, label: str) -> None:
39
+ self.id = uuid.uuid4()
40
+ self.label = label
41
+ self.cases = {}
42
+ self.checks = {}
43
+ self.interactions = {}
44
+
45
+ def record_case(self, *, parent_id: str | None, case: Case) -> None:
46
+ """Record a test case and its relationship to a parent, if applicable."""
47
+ self.cases[case.id] = CaseNode(value=case, parent_id=parent_id)
48
+
49
+ def record_response(self, *, case_id: str, response: Response) -> None:
50
+ """Record the API response for a given test case."""
51
+ request = Request.from_prepared_request(response.request)
52
+ self.interactions[case_id] = Interaction(request=request, response=response)
53
+
54
+ def record_request(self, *, case_id: str, request: requests.PreparedRequest) -> None:
55
+ """Record a network-level error for a given test case."""
56
+ self.interactions[case_id] = Interaction(request=Request.from_prepared_request(request), response=None)
57
+
58
+ def record_check_failure(self, *, name: str, case_id: str, code_sample: str, failure: Failure) -> None:
59
+ """Record a failure of a check for a given test case."""
60
+ self.checks.setdefault(case_id, []).append(
61
+ CheckNode(
62
+ name=name,
63
+ status=Status.FAILURE,
64
+ failure_info=CheckFailureInfo(code_sample=code_sample, failure=failure),
65
+ )
66
+ )
67
+
68
+ def record_check_success(self, *, name: str, case_id: str) -> None:
69
+ """Record a successful pass of a check for a given test case."""
70
+ self.checks.setdefault(case_id, []).append(CheckNode(name=name, status=Status.SUCCESS, failure_info=None))
71
+
72
+ def find_failure_data(self, *, parent_id: str, failure: Failure) -> FailureData:
73
+ """Retrieve the relevant test case & interaction data for a failure.
74
+
75
+ It may happen that a failure comes from a different test case if a check generated some additional
76
+ test cases & interactions.
77
+ """
78
+ case_id = failure.case_id or parent_id
79
+ case = self.cases[case_id].value
80
+ request = self.interactions[case_id].request
81
+ response = self.interactions[case_id].response
82
+ assert isinstance(response, Response)
83
+ headers = {key: value[0] for key, value in request.headers.items()}
84
+ return FailureData(case=case, headers=headers, verify=response.verify)
85
+
86
+ def find_parent(self, *, case_id: str) -> Case | None:
87
+ """Find the parent case of a given test case, if it exists."""
88
+ case = self.cases.get(case_id)
89
+ if case is not None and case.parent_id is not None:
90
+ parent = self.cases.get(case.parent_id)
91
+ # The recorder state should always be consistent
92
+ assert parent is not None, "Parent does not exist"
93
+ return parent.value
94
+ return None
95
+
96
+ def find_related(self, *, case_id: str) -> Iterator[Case]:
97
+ """Iterate over all ancestors and their children for a given case."""
98
+ current_id = case_id
99
+ seen = {current_id}
100
+
101
+ while True:
102
+ current_node = self.cases.get(current_id)
103
+ if current_node is None or current_node.parent_id is None:
104
+ break
105
+
106
+ # Get all children of the parent (siblings of the current case)
107
+ parent_id = current_node.parent_id
108
+ for case_id, maybe_child in self.cases.items():
109
+ # If this case has the same parent and we haven't seen it yet
110
+ if parent_id == maybe_child.parent_id and case_id not in seen:
111
+ seen.add(case_id)
112
+ yield maybe_child.value
113
+
114
+ # Move up to the parent
115
+ current_id = parent_id
116
+ if current_id not in seen:
117
+ seen.add(current_id)
118
+ parent_node = self.cases.get(current_id)
119
+ if parent_node:
120
+ yield parent_node.value
121
+
122
+ def find_response(self, *, case_id: str) -> Response | None:
123
+ """Retrieve the API response for a given test case, if available."""
124
+ interaction = self.interactions.get(case_id)
125
+ if interaction is None or interaction.response is None:
126
+ return None
127
+ return interaction.response
128
+
129
+
130
+ @dataclass
131
+ class CaseNode:
132
+ """Represents a test case and its parent-child relationship."""
133
+
134
+ value: Case
135
+ parent_id: str | None
136
+
137
+ __slots__ = ("value", "parent_id")
138
+
139
+
140
+ @dataclass
141
+ class CheckNode:
142
+ name: str
143
+ status: Status
144
+ failure_info: CheckFailureInfo | None
145
+
146
+ __slots__ = ("name", "status", "failure_info")
147
+
148
+
149
+ @dataclass
150
+ class CheckFailureInfo:
151
+ code_sample: str
152
+ failure: Failure
153
+
154
+ __slots__ = ("code_sample", "failure")
155
+
156
+
157
+ def serialize_payload(payload: bytes) -> str:
158
+ return base64.b64encode(payload).decode()
159
+
160
+
161
+ @dataclass(repr=False)
162
+ class Request:
163
+ """Request data extracted from `Case`."""
164
+
165
+ method: str
166
+ uri: str
167
+ body: bytes | None
168
+ body_size: int | None
169
+ headers: dict[str, list[str]]
170
+
171
+ __slots__ = ("method", "uri", "body", "body_size", "headers", "_encoded_body_cache")
172
+
173
+ def __init__(
174
+ self,
175
+ method: str,
176
+ uri: str,
177
+ body: bytes | None,
178
+ body_size: int | None,
179
+ headers: dict[str, list[str]],
180
+ ):
181
+ self.method = method
182
+ self.uri = uri
183
+ self.body = body
184
+ self.body_size = body_size
185
+ self.headers = headers
186
+ self._encoded_body_cache: str | None = None
187
+
188
+ @classmethod
189
+ def from_prepared_request(cls, prepared: requests.PreparedRequest) -> Request:
190
+ """A prepared request version is already stored in `requests.Response`."""
191
+ body = prepared.body
192
+
193
+ if isinstance(body, str):
194
+ # can be a string for `application/x-www-form-urlencoded`
195
+ body = body.encode("utf-8")
196
+
197
+ # these values have `str` type at this point
198
+ uri = cast(str, prepared.url)
199
+ method = cast(str, prepared.method)
200
+ return cls(
201
+ uri=uri,
202
+ method=method,
203
+ headers={key: [value] for (key, value) in prepared.headers.items()},
204
+ body=body,
205
+ body_size=len(body) if body is not None else None,
206
+ )
207
+
208
+ @property
209
+ def encoded_body(self) -> str | None:
210
+ if self.body is not None:
211
+ if self._encoded_body_cache is None:
212
+ self._encoded_body_cache = serialize_payload(self.body)
213
+ return self._encoded_body_cache
214
+ return None
215
+
216
+
217
+ @dataclass
218
+ class Interaction:
219
+ """Represents a single interaction with the tested application."""
220
+
221
+ request: Request
222
+ response: Response | None
223
+ timestamp: float
224
+
225
+ __slots__ = ("request", "response", "timestamp")
226
+
227
+ def __init__(self, request: Request, response: Response | None) -> None:
228
+ self.request = request
229
+ self.response = response
230
+ self.timestamp = time.time()
231
+
232
+
233
+ @dataclass
234
+ class FailureData:
235
+ """Details about a test failure, including the case and its context."""
236
+
237
+ case: Case
238
+ headers: dict[str, str]
239
+ verify: bool
240
+
241
+ __slots__ = ("case", "headers", "verify")
schemathesis/errors.py ADDED
@@ -0,0 +1,31 @@
1
+ """Public Schemathesis errors."""
2
+
3
+ from schemathesis.core.errors import IncorrectUsage as IncorrectUsage
4
+ from schemathesis.core.errors import InternalError as InternalError
5
+ from schemathesis.core.errors import InvalidHeadersExample as InvalidHeadersExample
6
+ from schemathesis.core.errors import InvalidRateLimit as InvalidRateLimit
7
+ from schemathesis.core.errors import InvalidRegexPattern as InvalidRegexPattern
8
+ from schemathesis.core.errors import InvalidRegexType as InvalidRegexType
9
+ from schemathesis.core.errors import InvalidSchema as InvalidSchema
10
+ from schemathesis.core.errors import LoaderError as LoaderError
11
+ from schemathesis.core.errors import OperationNotFound as OperationNotFound
12
+ from schemathesis.core.errors import SchemathesisError as SchemathesisError
13
+ from schemathesis.core.errors import SerializationError as SerializationError
14
+ from schemathesis.core.errors import SerializationNotPossible as SerializationNotPossible
15
+ from schemathesis.core.errors import UnboundPrefix as UnboundPrefix
16
+
17
+ __all__ = [
18
+ "IncorrectUsage",
19
+ "InternalError",
20
+ "InvalidHeadersExample",
21
+ "InvalidRateLimit",
22
+ "InvalidRegexPattern",
23
+ "InvalidRegexType",
24
+ "InvalidSchema",
25
+ "LoaderError",
26
+ "OperationNotFound",
27
+ "SchemathesisError",
28
+ "SerializationError",
29
+ "SerializationNotPossible",
30
+ "UnboundPrefix",
31
+ ]