schemathesis 3.15.4__py3-none-any.whl → 4.4.2__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 (251) hide show
  1. schemathesis/__init__.py +53 -25
  2. schemathesis/auths.py +507 -0
  3. schemathesis/checks.py +190 -25
  4. schemathesis/cli/__init__.py +27 -1219
  5. schemathesis/cli/__main__.py +4 -0
  6. schemathesis/cli/commands/__init__.py +133 -0
  7. schemathesis/cli/commands/data.py +10 -0
  8. schemathesis/cli/commands/run/__init__.py +602 -0
  9. schemathesis/cli/commands/run/context.py +228 -0
  10. schemathesis/cli/commands/run/events.py +60 -0
  11. schemathesis/cli/commands/run/executor.py +157 -0
  12. schemathesis/cli/commands/run/filters.py +53 -0
  13. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  14. schemathesis/cli/commands/run/handlers/base.py +45 -0
  15. schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
  16. schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
  17. schemathesis/cli/commands/run/handlers/output.py +1750 -0
  18. schemathesis/cli/commands/run/loaders.py +118 -0
  19. schemathesis/cli/commands/run/validation.py +256 -0
  20. schemathesis/cli/constants.py +5 -0
  21. schemathesis/cli/core.py +19 -0
  22. schemathesis/cli/ext/fs.py +16 -0
  23. schemathesis/cli/ext/groups.py +203 -0
  24. schemathesis/cli/ext/options.py +81 -0
  25. schemathesis/config/__init__.py +202 -0
  26. schemathesis/config/_auth.py +51 -0
  27. schemathesis/config/_checks.py +268 -0
  28. schemathesis/config/_diff_base.py +101 -0
  29. schemathesis/config/_env.py +21 -0
  30. schemathesis/config/_error.py +163 -0
  31. schemathesis/config/_generation.py +157 -0
  32. schemathesis/config/_health_check.py +24 -0
  33. schemathesis/config/_operations.py +335 -0
  34. schemathesis/config/_output.py +171 -0
  35. schemathesis/config/_parameters.py +19 -0
  36. schemathesis/config/_phases.py +253 -0
  37. schemathesis/config/_projects.py +543 -0
  38. schemathesis/config/_rate_limit.py +17 -0
  39. schemathesis/config/_report.py +120 -0
  40. schemathesis/config/_validator.py +9 -0
  41. schemathesis/config/_warnings.py +89 -0
  42. schemathesis/config/schema.json +975 -0
  43. schemathesis/core/__init__.py +72 -0
  44. schemathesis/core/adapter.py +34 -0
  45. schemathesis/core/compat.py +32 -0
  46. schemathesis/core/control.py +2 -0
  47. schemathesis/core/curl.py +100 -0
  48. schemathesis/core/deserialization.py +210 -0
  49. schemathesis/core/errors.py +588 -0
  50. schemathesis/core/failures.py +316 -0
  51. schemathesis/core/fs.py +19 -0
  52. schemathesis/core/hooks.py +20 -0
  53. schemathesis/core/jsonschema/__init__.py +13 -0
  54. schemathesis/core/jsonschema/bundler.py +183 -0
  55. schemathesis/core/jsonschema/keywords.py +40 -0
  56. schemathesis/core/jsonschema/references.py +222 -0
  57. schemathesis/core/jsonschema/types.py +41 -0
  58. schemathesis/core/lazy_import.py +15 -0
  59. schemathesis/core/loaders.py +107 -0
  60. schemathesis/core/marks.py +66 -0
  61. schemathesis/core/media_types.py +79 -0
  62. schemathesis/core/output/__init__.py +46 -0
  63. schemathesis/core/output/sanitization.py +54 -0
  64. schemathesis/core/parameters.py +45 -0
  65. schemathesis/core/rate_limit.py +60 -0
  66. schemathesis/core/registries.py +34 -0
  67. schemathesis/core/result.py +27 -0
  68. schemathesis/core/schema_analysis.py +17 -0
  69. schemathesis/core/shell.py +203 -0
  70. schemathesis/core/transforms.py +144 -0
  71. schemathesis/core/transport.py +223 -0
  72. schemathesis/core/validation.py +73 -0
  73. schemathesis/core/version.py +7 -0
  74. schemathesis/engine/__init__.py +28 -0
  75. schemathesis/engine/context.py +152 -0
  76. schemathesis/engine/control.py +44 -0
  77. schemathesis/engine/core.py +201 -0
  78. schemathesis/engine/errors.py +446 -0
  79. schemathesis/engine/events.py +284 -0
  80. schemathesis/engine/observations.py +42 -0
  81. schemathesis/engine/phases/__init__.py +108 -0
  82. schemathesis/engine/phases/analysis.py +28 -0
  83. schemathesis/engine/phases/probes.py +172 -0
  84. schemathesis/engine/phases/stateful/__init__.py +68 -0
  85. schemathesis/engine/phases/stateful/_executor.py +364 -0
  86. schemathesis/engine/phases/stateful/context.py +85 -0
  87. schemathesis/engine/phases/unit/__init__.py +220 -0
  88. schemathesis/engine/phases/unit/_executor.py +459 -0
  89. schemathesis/engine/phases/unit/_pool.py +82 -0
  90. schemathesis/engine/recorder.py +254 -0
  91. schemathesis/errors.py +47 -0
  92. schemathesis/filters.py +395 -0
  93. schemathesis/generation/__init__.py +25 -0
  94. schemathesis/generation/case.py +478 -0
  95. schemathesis/generation/coverage.py +1528 -0
  96. schemathesis/generation/hypothesis/__init__.py +121 -0
  97. schemathesis/generation/hypothesis/builder.py +992 -0
  98. schemathesis/generation/hypothesis/examples.py +56 -0
  99. schemathesis/generation/hypothesis/given.py +66 -0
  100. schemathesis/generation/hypothesis/reporting.py +285 -0
  101. schemathesis/generation/meta.py +227 -0
  102. schemathesis/generation/metrics.py +93 -0
  103. schemathesis/generation/modes.py +20 -0
  104. schemathesis/generation/overrides.py +127 -0
  105. schemathesis/generation/stateful/__init__.py +37 -0
  106. schemathesis/generation/stateful/state_machine.py +294 -0
  107. schemathesis/graphql/__init__.py +15 -0
  108. schemathesis/graphql/checks.py +109 -0
  109. schemathesis/graphql/loaders.py +285 -0
  110. schemathesis/hooks.py +270 -91
  111. schemathesis/openapi/__init__.py +13 -0
  112. schemathesis/openapi/checks.py +467 -0
  113. schemathesis/openapi/generation/__init__.py +0 -0
  114. schemathesis/openapi/generation/filters.py +72 -0
  115. schemathesis/openapi/loaders.py +315 -0
  116. schemathesis/pytest/__init__.py +5 -0
  117. schemathesis/pytest/control_flow.py +7 -0
  118. schemathesis/pytest/lazy.py +341 -0
  119. schemathesis/pytest/loaders.py +36 -0
  120. schemathesis/pytest/plugin.py +357 -0
  121. schemathesis/python/__init__.py +0 -0
  122. schemathesis/python/asgi.py +12 -0
  123. schemathesis/python/wsgi.py +12 -0
  124. schemathesis/schemas.py +682 -257
  125. schemathesis/specs/graphql/__init__.py +0 -1
  126. schemathesis/specs/graphql/nodes.py +26 -2
  127. schemathesis/specs/graphql/scalars.py +77 -12
  128. schemathesis/specs/graphql/schemas.py +367 -148
  129. schemathesis/specs/graphql/validation.py +33 -0
  130. schemathesis/specs/openapi/__init__.py +9 -1
  131. schemathesis/specs/openapi/_hypothesis.py +555 -318
  132. schemathesis/specs/openapi/adapter/__init__.py +10 -0
  133. schemathesis/specs/openapi/adapter/parameters.py +729 -0
  134. schemathesis/specs/openapi/adapter/protocol.py +59 -0
  135. schemathesis/specs/openapi/adapter/references.py +19 -0
  136. schemathesis/specs/openapi/adapter/responses.py +368 -0
  137. schemathesis/specs/openapi/adapter/security.py +144 -0
  138. schemathesis/specs/openapi/adapter/v2.py +30 -0
  139. schemathesis/specs/openapi/adapter/v3_0.py +30 -0
  140. schemathesis/specs/openapi/adapter/v3_1.py +30 -0
  141. schemathesis/specs/openapi/analysis.py +96 -0
  142. schemathesis/specs/openapi/checks.py +748 -82
  143. schemathesis/specs/openapi/converter.py +176 -37
  144. schemathesis/specs/openapi/definitions.py +599 -4
  145. schemathesis/specs/openapi/examples.py +581 -165
  146. schemathesis/specs/openapi/expressions/__init__.py +52 -5
  147. schemathesis/specs/openapi/expressions/extractors.py +25 -0
  148. schemathesis/specs/openapi/expressions/lexer.py +34 -31
  149. schemathesis/specs/openapi/expressions/nodes.py +97 -46
  150. schemathesis/specs/openapi/expressions/parser.py +35 -13
  151. schemathesis/specs/openapi/formats.py +122 -0
  152. schemathesis/specs/openapi/media_types.py +75 -0
  153. schemathesis/specs/openapi/negative/__init__.py +93 -73
  154. schemathesis/specs/openapi/negative/mutations.py +294 -103
  155. schemathesis/specs/openapi/negative/utils.py +0 -9
  156. schemathesis/specs/openapi/patterns.py +458 -0
  157. schemathesis/specs/openapi/references.py +60 -81
  158. schemathesis/specs/openapi/schemas.py +647 -666
  159. schemathesis/specs/openapi/serialization.py +53 -30
  160. schemathesis/specs/openapi/stateful/__init__.py +403 -68
  161. schemathesis/specs/openapi/stateful/control.py +87 -0
  162. schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
  163. schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
  164. schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
  165. schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
  166. schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
  167. schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
  168. schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
  169. schemathesis/specs/openapi/stateful/inference.py +254 -0
  170. schemathesis/specs/openapi/stateful/links.py +219 -78
  171. schemathesis/specs/openapi/types/__init__.py +3 -0
  172. schemathesis/specs/openapi/types/common.py +23 -0
  173. schemathesis/specs/openapi/types/v2.py +129 -0
  174. schemathesis/specs/openapi/types/v3.py +134 -0
  175. schemathesis/specs/openapi/utils.py +7 -6
  176. schemathesis/specs/openapi/warnings.py +75 -0
  177. schemathesis/transport/__init__.py +224 -0
  178. schemathesis/transport/asgi.py +26 -0
  179. schemathesis/transport/prepare.py +126 -0
  180. schemathesis/transport/requests.py +278 -0
  181. schemathesis/transport/serialization.py +329 -0
  182. schemathesis/transport/wsgi.py +175 -0
  183. schemathesis-4.4.2.dist-info/METADATA +213 -0
  184. schemathesis-4.4.2.dist-info/RECORD +192 -0
  185. {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
  186. schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
  187. {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
  188. schemathesis/_compat.py +0 -57
  189. schemathesis/_hypothesis.py +0 -123
  190. schemathesis/auth.py +0 -214
  191. schemathesis/cli/callbacks.py +0 -240
  192. schemathesis/cli/cassettes.py +0 -351
  193. schemathesis/cli/context.py +0 -38
  194. schemathesis/cli/debug.py +0 -21
  195. schemathesis/cli/handlers.py +0 -11
  196. schemathesis/cli/junitxml.py +0 -41
  197. schemathesis/cli/options.py +0 -70
  198. schemathesis/cli/output/__init__.py +0 -1
  199. schemathesis/cli/output/default.py +0 -521
  200. schemathesis/cli/output/short.py +0 -40
  201. schemathesis/constants.py +0 -88
  202. schemathesis/exceptions.py +0 -257
  203. schemathesis/extra/_aiohttp.py +0 -27
  204. schemathesis/extra/_flask.py +0 -10
  205. schemathesis/extra/_server.py +0 -16
  206. schemathesis/extra/pytest_plugin.py +0 -251
  207. schemathesis/failures.py +0 -145
  208. schemathesis/fixups/__init__.py +0 -29
  209. schemathesis/fixups/fast_api.py +0 -30
  210. schemathesis/graphql.py +0 -5
  211. schemathesis/internal.py +0 -6
  212. schemathesis/lazy.py +0 -301
  213. schemathesis/models.py +0 -1113
  214. schemathesis/parameters.py +0 -91
  215. schemathesis/runner/__init__.py +0 -470
  216. schemathesis/runner/events.py +0 -242
  217. schemathesis/runner/impl/__init__.py +0 -3
  218. schemathesis/runner/impl/core.py +0 -791
  219. schemathesis/runner/impl/solo.py +0 -85
  220. schemathesis/runner/impl/threadpool.py +0 -367
  221. schemathesis/runner/serialization.py +0 -206
  222. schemathesis/serializers.py +0 -253
  223. schemathesis/service/__init__.py +0 -18
  224. schemathesis/service/auth.py +0 -10
  225. schemathesis/service/client.py +0 -62
  226. schemathesis/service/constants.py +0 -25
  227. schemathesis/service/events.py +0 -39
  228. schemathesis/service/handler.py +0 -46
  229. schemathesis/service/hosts.py +0 -74
  230. schemathesis/service/metadata.py +0 -42
  231. schemathesis/service/models.py +0 -21
  232. schemathesis/service/serialization.py +0 -184
  233. schemathesis/service/worker.py +0 -39
  234. schemathesis/specs/graphql/loaders.py +0 -215
  235. schemathesis/specs/openapi/constants.py +0 -7
  236. schemathesis/specs/openapi/expressions/context.py +0 -12
  237. schemathesis/specs/openapi/expressions/pointers.py +0 -29
  238. schemathesis/specs/openapi/filters.py +0 -44
  239. schemathesis/specs/openapi/links.py +0 -303
  240. schemathesis/specs/openapi/loaders.py +0 -453
  241. schemathesis/specs/openapi/parameters.py +0 -430
  242. schemathesis/specs/openapi/security.py +0 -129
  243. schemathesis/specs/openapi/validation.py +0 -24
  244. schemathesis/stateful.py +0 -358
  245. schemathesis/targets.py +0 -32
  246. schemathesis/types.py +0 -38
  247. schemathesis/utils.py +0 -475
  248. schemathesis-3.15.4.dist-info/METADATA +0 -202
  249. schemathesis-3.15.4.dist-info/RECORD +0 -99
  250. schemathesis-3.15.4.dist-info/entry_points.txt +0 -7
  251. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
@@ -0,0 +1,228 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from typing import TYPE_CHECKING, Callable, Generator
6
+
7
+ from schemathesis.cli.commands.run.events import LoadingFinished
8
+ from schemathesis.config import ProjectConfig
9
+ from schemathesis.core.failures import Failure
10
+ from schemathesis.core.result import Err, Ok
11
+ from schemathesis.core.transforms import UNRESOLVABLE
12
+ from schemathesis.core.transport import Response
13
+ from schemathesis.engine import Status, events
14
+ from schemathesis.engine.recorder import CaseNode, ScenarioRecorder
15
+ from schemathesis.generation.case import Case
16
+ from schemathesis.schemas import APIOperation
17
+
18
+ if TYPE_CHECKING:
19
+ from schemathesis.generation.stateful.state_machine import ExtractionFailure
20
+
21
+
22
+ @dataclass
23
+ class Statistic:
24
+ """Running statistics about test execution."""
25
+
26
+ failures: dict[str, dict[str, GroupedFailures]]
27
+ # Track first case_id where each unique failure was found
28
+ unique_failures_map: dict[Failure, str]
29
+
30
+ extraction_failures: set[ExtractionFailure]
31
+
32
+ tested_operations: set[str]
33
+
34
+ total_cases: int
35
+ cases_with_failures: int
36
+ cases_without_checks: int
37
+
38
+ __slots__ = (
39
+ "failures",
40
+ "unique_failures_map",
41
+ "extraction_failures",
42
+ "tested_operations",
43
+ "total_cases",
44
+ "cases_with_failures",
45
+ "cases_without_checks",
46
+ )
47
+
48
+ def __init__(self) -> None:
49
+ self.failures = {}
50
+ self.unique_failures_map = {}
51
+ self.extraction_failures = set()
52
+ self.tested_operations = set()
53
+ self.total_cases = 0
54
+ self.cases_with_failures = 0
55
+ self.cases_without_checks = 0
56
+
57
+ def on_scenario_finished(self, recorder: ScenarioRecorder) -> None:
58
+ """Update statistics and store failures from a new batch of checks."""
59
+ from schemathesis.generation.stateful.state_machine import ExtractionFailure
60
+
61
+ failures = self.failures.get(recorder.label, {})
62
+
63
+ self.total_cases += len(recorder.cases)
64
+
65
+ extraction_failures = set()
66
+
67
+ def collect_history(node: CaseNode, response: Response) -> list[tuple[Case, Response]]:
68
+ history = [(node.value, response)]
69
+ current = node
70
+ while current.parent_id is not None:
71
+ current_response = recorder.find_response(case_id=current.parent_id)
72
+ # We need a response to get there, so it should be present
73
+ assert current_response is not None
74
+ current = recorder.cases[current.parent_id]
75
+ history.append((current.value, current_response))
76
+ return history
77
+
78
+ for case_id, case in recorder.cases.items():
79
+ checks = recorder.checks.get(case_id, [])
80
+
81
+ if not checks:
82
+ self.cases_without_checks += 1
83
+ continue
84
+
85
+ self.tested_operations.add(case.value.operation.label)
86
+ has_failures = False
87
+ current_case_failures = []
88
+ last_failure_info = None
89
+
90
+ for check in checks:
91
+ if check.failure_info is not None:
92
+ failure = check.failure_info.failure
93
+
94
+ # Check if this is a new unique failure
95
+ if failure not in self.unique_failures_map:
96
+ last_failure_info = check.failure_info
97
+ self.unique_failures_map[failure] = case_id
98
+ current_case_failures.append(failure)
99
+ has_failures = True
100
+ else:
101
+ # This failure was already seen - skip it
102
+ continue
103
+
104
+ if current_case_failures:
105
+ assert last_failure_info is not None
106
+ failures[case_id] = GroupedFailures(
107
+ case_id=case_id,
108
+ code_sample=last_failure_info.code_sample,
109
+ failures=current_case_failures,
110
+ response=recorder.interactions[case_id].response,
111
+ )
112
+
113
+ if has_failures:
114
+ self.cases_with_failures += 1
115
+
116
+ # Don't report extraction failures for inferred transitions
117
+ if case.transition is None or case.transition.is_inferred:
118
+ continue
119
+ transition = case.transition
120
+ parent = recorder.cases[transition.parent_id]
121
+ response = recorder.find_response(case_id=parent.value.id)
122
+ # We need a response to get there, so it should be present
123
+ assert response is not None
124
+
125
+ history = None
126
+
127
+ if (
128
+ transition.request_body is not None
129
+ and isinstance(transition.request_body.value, Ok)
130
+ and transition.request_body.value.ok() is UNRESOLVABLE
131
+ ):
132
+ history = collect_history(parent, response)
133
+ extraction_failures.add(
134
+ ExtractionFailure(
135
+ id=transition.id,
136
+ case_id=case_id,
137
+ source=parent.value.operation.label,
138
+ target=case.value.operation.label,
139
+ parameter_name="body",
140
+ expression=json.dumps(transition.request_body.definition),
141
+ history=history,
142
+ response=response,
143
+ error=None,
144
+ )
145
+ )
146
+
147
+ for params in transition.parameters.values():
148
+ for parameter, extracted in params.items():
149
+ if isinstance(extracted.value, Ok) and extracted.value.ok() is UNRESOLVABLE:
150
+ history = history or collect_history(parent, response)
151
+ extraction_failures.add(
152
+ ExtractionFailure(
153
+ id=transition.id,
154
+ case_id=case_id,
155
+ source=parent.value.operation.label,
156
+ target=case.value.operation.label,
157
+ parameter_name=parameter,
158
+ expression=extracted.definition,
159
+ history=history,
160
+ response=response,
161
+ error=None,
162
+ )
163
+ )
164
+ elif isinstance(extracted.value, Err):
165
+ history = history or collect_history(parent, response)
166
+ extraction_failures.add(
167
+ ExtractionFailure(
168
+ id=transition.id,
169
+ case_id=case_id,
170
+ source=parent.value.operation.label,
171
+ target=case.value.operation.label,
172
+ parameter_name=parameter,
173
+ expression=extracted.definition,
174
+ history=history,
175
+ response=response,
176
+ error=extracted.value.err(),
177
+ )
178
+ )
179
+
180
+ if failures:
181
+ for group in failures.values():
182
+ group.failures = sorted(set(group.failures))
183
+ self.failures[recorder.label] = failures
184
+
185
+ if extraction_failures:
186
+ self.extraction_failures.update(extraction_failures)
187
+
188
+
189
+ @dataclass
190
+ class GroupedFailures:
191
+ """Represents failures grouped by case ID."""
192
+
193
+ case_id: str
194
+ code_sample: str
195
+ failures: list[Failure]
196
+ response: Response | None
197
+
198
+ __slots__ = ("case_id", "code_sample", "failures", "response")
199
+
200
+
201
+ @dataclass
202
+ class ExecutionContext:
203
+ """Storage for the current context of the execution."""
204
+
205
+ config: ProjectConfig
206
+ find_operation_by_label: Callable[[str], APIOperation | None] | None = None
207
+ statistic: Statistic = field(default_factory=Statistic)
208
+ exit_code: int = 0
209
+ initialization_lines: list[str | Generator[str, None, None]] = field(default_factory=list)
210
+ summary_lines: list[str | Generator[str, None, None]] = field(default_factory=list)
211
+
212
+ def add_initialization_line(self, line: str | Generator[str, None, None]) -> None:
213
+ self.initialization_lines.append(line)
214
+
215
+ def add_summary_line(self, line: str | Generator[str, None, None]) -> None:
216
+ self.summary_lines.append(line)
217
+
218
+ def on_event(self, event: events.EngineEvent) -> None:
219
+ if isinstance(event, LoadingFinished):
220
+ self.find_operation_by_label = event.find_operation_by_label
221
+ if isinstance(event, events.ScenarioFinished):
222
+ self.statistic.on_scenario_finished(event.recorder)
223
+ elif isinstance(event, events.NonFatalError) or (
224
+ isinstance(event, events.PhaseFinished)
225
+ and event.phase.is_enabled
226
+ and event.status in (Status.FAILURE, Status.ERROR)
227
+ ):
228
+ self.exit_code = 1
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import uuid
5
+ from typing import Callable
6
+
7
+ from schemathesis.config import ProjectConfig
8
+ from schemathesis.core import Specification
9
+ from schemathesis.engine import events
10
+ from schemathesis.schemas import APIOperation, ApiStatistic
11
+
12
+
13
+ class LoadingStarted(events.EngineEvent):
14
+ __slots__ = ("id", "timestamp", "location")
15
+
16
+ def __init__(self, *, location: str) -> None:
17
+ self.id = uuid.uuid4()
18
+ self.timestamp = time.time()
19
+ self.location = location
20
+
21
+
22
+ class LoadingFinished(events.EngineEvent):
23
+ __slots__ = (
24
+ "id",
25
+ "timestamp",
26
+ "location",
27
+ "duration",
28
+ "base_url",
29
+ "base_path",
30
+ "specification",
31
+ "statistic",
32
+ "schema",
33
+ "config",
34
+ "find_operation_by_label",
35
+ )
36
+
37
+ def __init__(
38
+ self,
39
+ *,
40
+ location: str,
41
+ start_time: float,
42
+ base_url: str,
43
+ base_path: str,
44
+ specification: Specification,
45
+ statistic: ApiStatistic,
46
+ schema: dict,
47
+ config: ProjectConfig,
48
+ find_operation_by_label: Callable[[str], APIOperation | None],
49
+ ) -> None:
50
+ self.id = uuid.uuid4()
51
+ self.timestamp = time.time()
52
+ self.location = location
53
+ self.duration = self.timestamp - start_time
54
+ self.base_url = base_url
55
+ self.specification = specification
56
+ self.statistic = statistic
57
+ self.schema = schema
58
+ self.base_path = base_path
59
+ self.config = config
60
+ self.find_operation_by_label = find_operation_by_label
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import Any, Callable
5
+
6
+ import click
7
+
8
+ from schemathesis.cli.commands.run.context import ExecutionContext
9
+ from schemathesis.cli.commands.run.events import LoadingFinished, LoadingStarted
10
+ from schemathesis.cli.commands.run.handlers import display_handler_error
11
+ from schemathesis.cli.commands.run.handlers.base import EventHandler
12
+ from schemathesis.cli.commands.run.handlers.cassettes import CassetteWriter
13
+ from schemathesis.cli.commands.run.handlers.junitxml import JunitXMLHandler
14
+ from schemathesis.cli.commands.run.handlers.output import OutputHandler
15
+ from schemathesis.cli.commands.run.loaders import load_schema
16
+ from schemathesis.cli.ext.fs import open_file
17
+ from schemathesis.config import ProjectConfig, ReportFormat
18
+ from schemathesis.core.errors import LoaderError
19
+ from schemathesis.core.fs import file_exists
20
+ from schemathesis.engine import from_schema
21
+ from schemathesis.engine.events import EventGenerator, FatalError, Interrupted
22
+
23
+ CUSTOM_HANDLERS: list[type[EventHandler]] = []
24
+
25
+
26
+ def handler() -> Callable[[type], None]:
27
+ """Register a new CLI event handler."""
28
+
29
+ def _wrapper(cls: type) -> None:
30
+ CUSTOM_HANDLERS.append(cls)
31
+
32
+ return _wrapper
33
+
34
+
35
+ def execute(
36
+ *,
37
+ location: str,
38
+ config: ProjectConfig,
39
+ filter_set: dict[str, Any],
40
+ args: list[str],
41
+ params: dict[str, Any],
42
+ ) -> None:
43
+ event_stream = into_event_stream(location=location, config=config, filter_set=filter_set)
44
+ _execute(event_stream, config=config, args=args, params=params)
45
+
46
+
47
+ MISSING_BASE_URL_MESSAGE = "The `--url` option is required when specifying a schema via a file."
48
+
49
+
50
+ def into_event_stream(*, location: str, config: ProjectConfig, filter_set: dict[str, Any]) -> EventGenerator:
51
+ # The whole engine idea is that it communicates with the outside via events, so handlers can react to them
52
+ # For this reason, even schema loading is done via a separate set of events.
53
+ loading_started = LoadingStarted(location=location)
54
+ yield loading_started
55
+
56
+ try:
57
+ schema = load_schema(location=location, config=config)
58
+ # Schemas don't (yet?) use configs for deciding what operations should be tested, so
59
+ # a separate FilterSet passed there. It combines both config file filters + CLI options
60
+ schema.filter_set = schema.config.operations.create_filter_set(**filter_set)
61
+ if file_exists(location) and schema.config.base_url is None:
62
+ raise click.UsageError(MISSING_BASE_URL_MESSAGE)
63
+ except KeyboardInterrupt:
64
+ yield Interrupted(phase=None)
65
+ return
66
+ except LoaderError as exc:
67
+ yield FatalError(exception=exc)
68
+ return
69
+
70
+ yield LoadingFinished(
71
+ location=location,
72
+ start_time=loading_started.timestamp,
73
+ base_url=schema.get_base_url(),
74
+ specification=schema.specification,
75
+ statistic=schema.statistic,
76
+ schema=schema.raw_schema,
77
+ config=schema.config,
78
+ base_path=schema.base_path,
79
+ find_operation_by_label=schema.find_operation_by_label,
80
+ )
81
+
82
+ try:
83
+ yield from from_schema(schema).execute()
84
+ except Exception as exc:
85
+ yield FatalError(exception=exc)
86
+
87
+
88
+ def initialize_handlers(
89
+ *,
90
+ config: ProjectConfig,
91
+ args: list[str],
92
+ params: dict[str, Any],
93
+ ) -> list[EventHandler]:
94
+ """Create event handlers based on run configuration."""
95
+ handlers: list[EventHandler] = []
96
+
97
+ if config.reports.junit.enabled:
98
+ path = config.reports.get_path(ReportFormat.JUNIT)
99
+ open_file(path)
100
+ handlers.append(JunitXMLHandler(path))
101
+ for format, report in (
102
+ (ReportFormat.VCR, config.reports.vcr),
103
+ (ReportFormat.HAR, config.reports.har),
104
+ ):
105
+ if report.enabled:
106
+ path = config.reports.get_path(format)
107
+ open_file(path)
108
+ handlers.append(CassetteWriter(format=format, output=path, config=config))
109
+
110
+ for custom_handler in CUSTOM_HANDLERS:
111
+ handlers.append(custom_handler(*args, **params))
112
+
113
+ handlers.append(OutputHandler(config=config))
114
+
115
+ return handlers
116
+
117
+
118
+ def _execute(
119
+ event_stream: EventGenerator,
120
+ *,
121
+ config: ProjectConfig,
122
+ args: list[str],
123
+ params: dict[str, Any],
124
+ ) -> None:
125
+ handlers: list[EventHandler] = []
126
+ ctx: ExecutionContext | None = None
127
+
128
+ def shutdown() -> None:
129
+ if ctx is not None:
130
+ for _handler in handlers:
131
+ _handler.shutdown(ctx)
132
+
133
+ try:
134
+ handlers = initialize_handlers(config=config, args=args, params=params)
135
+ ctx = ExecutionContext(config=config)
136
+
137
+ for handler in handlers:
138
+ handler.start(ctx)
139
+
140
+ for event in event_stream:
141
+ ctx.on_event(event)
142
+ for handler in handlers:
143
+ try:
144
+ handler.handle_event(ctx, event)
145
+ except Exception as exc:
146
+ # `Abort` is used for handled errors
147
+ if not isinstance(exc, click.Abort):
148
+ display_handler_error(handler, exc)
149
+ raise
150
+ except Exception as exc:
151
+ if isinstance(exc, click.Abort):
152
+ # To avoid showing "Aborted!" message, which is the default behavior in Click
153
+ sys.exit(1)
154
+ raise
155
+ finally:
156
+ shutdown()
157
+ sys.exit(ctx.exit_code)
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import partial
4
+ from typing import Callable, Literal
5
+
6
+ import click
7
+
8
+ from schemathesis.cli.ext.groups import grouped_option
9
+
10
+
11
+ def _with_filter(*, by: str, mode: Literal["include", "exclude"], modifier: Literal["regex"] | None) -> Callable:
12
+ """Generate a CLI option for filtering API operations."""
13
+ param = f"--{mode}-{by}"
14
+ action = "include in" if mode == "include" else "exclude from"
15
+ prop = {
16
+ "operation-id": "ID",
17
+ "name": "Operation name",
18
+ }.get(by, by.capitalize())
19
+ callback = None
20
+ if modifier:
21
+ param += f"-{modifier}"
22
+ prop += " pattern"
23
+ else:
24
+ callback = partial(validate_filter, arg_name=param)
25
+ help_text = f"{prop} to {action} testing."
26
+ return grouped_option(
27
+ param,
28
+ help=help_text,
29
+ type=str,
30
+ multiple=modifier is None,
31
+ callback=callback,
32
+ hidden=True,
33
+ )
34
+
35
+
36
+ def validate_filter(
37
+ ctx: click.core.Context, param: click.core.Parameter, raw_value: list[str], arg_name: str
38
+ ) -> list[str]:
39
+ if len(raw_value) != len(set(raw_value)):
40
+ duplicates = ",".join(sorted({value for value in raw_value if raw_value.count(value) > 1}))
41
+ raise click.UsageError(f"Duplicate values are not allowed for `{arg_name}`: {duplicates}")
42
+ return raw_value
43
+
44
+
45
+ _BY_VALUES = ("operation-id", "tag", "name", "method", "path")
46
+
47
+
48
+ def with_filters(command: Callable) -> Callable:
49
+ for by in _BY_VALUES:
50
+ for mode in ("exclude", "include"):
51
+ for modifier in ("regex", None):
52
+ command = _with_filter(by=by, mode=mode, modifier=modifier)(command)
53
+ return command
@@ -0,0 +1,46 @@
1
+ import click
2
+
3
+ from schemathesis.cli.commands.run.handlers.base import EventHandler
4
+ from schemathesis.cli.commands.run.handlers.cassettes import CassetteWriter
5
+ from schemathesis.cli.commands.run.handlers.junitxml import JunitXMLHandler
6
+ from schemathesis.cli.commands.run.handlers.output import OutputHandler
7
+ from schemathesis.cli.constants import EXTENSIONS_DOCUMENTATION_URL, ISSUE_TRACKER_URL
8
+ from schemathesis.core.errors import format_exception
9
+
10
+ __all__ = [
11
+ "EventHandler",
12
+ "CassetteWriter",
13
+ "JunitXMLHandler",
14
+ "OutputHandler",
15
+ "display_handler_error",
16
+ ]
17
+
18
+
19
+ def is_built_in_handler(handler: EventHandler) -> bool:
20
+ # Look for exact instances, not subclasses
21
+ return any(type(handler) is class_ for class_ in (CassetteWriter, JunitXMLHandler, OutputHandler))
22
+
23
+
24
+ def display_handler_error(handler: EventHandler, exc: Exception) -> None:
25
+ """Display error that happened within."""
26
+ is_built_in = is_built_in_handler(handler)
27
+ if is_built_in:
28
+ click.secho("Internal Error", fg="red", bold=True)
29
+ click.secho("\nSchemathesis encountered an unexpected issue.")
30
+ message = format_exception(exc, with_traceback=True)
31
+ else:
32
+ click.secho("CLI Handler Error", fg="red", bold=True)
33
+ click.echo(
34
+ f"\nAn error occurred within your custom CLI handler `{click.style(handler.__class__.__name__, bold=True)}`."
35
+ )
36
+ message = format_exception(exc, with_traceback=True, skip_frames=1)
37
+ click.secho(f"\n{message}", fg="red")
38
+ if is_built_in:
39
+ click.echo(
40
+ f"\nWe apologize for the inconvenience. This appears to be an internal issue.\n"
41
+ f"Please consider reporting this error to our issue tracker:\n\n {ISSUE_TRACKER_URL}."
42
+ )
43
+ else:
44
+ click.echo(
45
+ f"\nFor more information on implementing extensions for Schemathesis CLI, visit {EXTENSIONS_DOCUMENTATION_URL}"
46
+ )
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ from io import StringIO
5
+ from pathlib import Path
6
+ from typing import IO, TYPE_CHECKING, Any, Generator, Protocol, Union
7
+
8
+ if TYPE_CHECKING:
9
+ from schemathesis.cli.commands.run.context import ExecutionContext
10
+ from schemathesis.engine import events
11
+
12
+
13
+ class EventHandler:
14
+ def __init__(self, *args: Any, **params: Any) -> None: ...
15
+
16
+ def handle_event(self, ctx: ExecutionContext, event: events.EngineEvent) -> None:
17
+ raise NotImplementedError
18
+
19
+ def start(self, ctx: ExecutionContext) -> None: ...
20
+
21
+ def shutdown(self, ctx: ExecutionContext) -> None: ...
22
+
23
+
24
+ class WritableText(Protocol):
25
+ """Protocol for text-writable file-like objects."""
26
+
27
+ def write(self, s: str) -> int: ... # pragma: no cover
28
+ def flush(self) -> None: ... # pragma: no cover
29
+
30
+
31
+ TextOutput = Union[IO[str], StringIO, Path]
32
+
33
+
34
+ @contextmanager
35
+ def open_text_output(output: TextOutput) -> Generator[IO[str]]:
36
+ """Open a text output, handling both Path and file-like objects."""
37
+ if isinstance(output, Path):
38
+ f = open(output, "w", encoding="utf-8")
39
+ try:
40
+ yield f
41
+ finally:
42
+ f.close()
43
+ else:
44
+ # Assume it's already a file-like object
45
+ yield output