schemathesis 3.39.15__py3-none-any.whl → 4.0.0__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 (255) hide show
  1. schemathesis/__init__.py +41 -79
  2. schemathesis/auths.py +111 -122
  3. schemathesis/checks.py +169 -60
  4. schemathesis/cli/__init__.py +15 -2117
  5. schemathesis/cli/commands/__init__.py +85 -0
  6. schemathesis/cli/commands/data.py +10 -0
  7. schemathesis/cli/commands/run/__init__.py +590 -0
  8. schemathesis/cli/commands/run/context.py +204 -0
  9. schemathesis/cli/commands/run/events.py +60 -0
  10. schemathesis/cli/commands/run/executor.py +157 -0
  11. schemathesis/cli/commands/run/filters.py +53 -0
  12. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  13. schemathesis/cli/commands/run/handlers/base.py +18 -0
  14. schemathesis/cli/commands/run/handlers/cassettes.py +474 -0
  15. schemathesis/cli/commands/run/handlers/junitxml.py +55 -0
  16. schemathesis/cli/commands/run/handlers/output.py +1628 -0
  17. schemathesis/cli/commands/run/loaders.py +114 -0
  18. schemathesis/cli/commands/run/validation.py +246 -0
  19. schemathesis/cli/constants.py +5 -58
  20. schemathesis/cli/core.py +19 -0
  21. schemathesis/cli/ext/fs.py +16 -0
  22. schemathesis/cli/ext/groups.py +84 -0
  23. schemathesis/cli/{options.py → ext/options.py} +36 -34
  24. schemathesis/config/__init__.py +189 -0
  25. schemathesis/config/_auth.py +51 -0
  26. schemathesis/config/_checks.py +268 -0
  27. schemathesis/config/_diff_base.py +99 -0
  28. schemathesis/config/_env.py +21 -0
  29. schemathesis/config/_error.py +156 -0
  30. schemathesis/config/_generation.py +149 -0
  31. schemathesis/config/_health_check.py +24 -0
  32. schemathesis/config/_operations.py +327 -0
  33. schemathesis/config/_output.py +171 -0
  34. schemathesis/config/_parameters.py +19 -0
  35. schemathesis/config/_phases.py +187 -0
  36. schemathesis/config/_projects.py +527 -0
  37. schemathesis/config/_rate_limit.py +17 -0
  38. schemathesis/config/_report.py +120 -0
  39. schemathesis/config/_validator.py +9 -0
  40. schemathesis/config/_warnings.py +25 -0
  41. schemathesis/config/schema.json +885 -0
  42. schemathesis/core/__init__.py +67 -0
  43. schemathesis/core/compat.py +32 -0
  44. schemathesis/core/control.py +2 -0
  45. schemathesis/core/curl.py +58 -0
  46. schemathesis/core/deserialization.py +65 -0
  47. schemathesis/core/errors.py +459 -0
  48. schemathesis/core/failures.py +315 -0
  49. schemathesis/core/fs.py +19 -0
  50. schemathesis/core/hooks.py +20 -0
  51. schemathesis/core/loaders.py +104 -0
  52. schemathesis/core/marks.py +66 -0
  53. schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
  54. schemathesis/core/output/__init__.py +46 -0
  55. schemathesis/core/output/sanitization.py +54 -0
  56. schemathesis/{throttling.py → core/rate_limit.py} +16 -17
  57. schemathesis/core/registries.py +31 -0
  58. schemathesis/core/transforms.py +113 -0
  59. schemathesis/core/transport.py +223 -0
  60. schemathesis/core/validation.py +54 -0
  61. schemathesis/core/version.py +7 -0
  62. schemathesis/engine/__init__.py +28 -0
  63. schemathesis/engine/context.py +118 -0
  64. schemathesis/engine/control.py +36 -0
  65. schemathesis/engine/core.py +169 -0
  66. schemathesis/engine/errors.py +464 -0
  67. schemathesis/engine/events.py +258 -0
  68. schemathesis/engine/phases/__init__.py +88 -0
  69. schemathesis/{runner → engine/phases}/probes.py +52 -68
  70. schemathesis/engine/phases/stateful/__init__.py +68 -0
  71. schemathesis/engine/phases/stateful/_executor.py +356 -0
  72. schemathesis/engine/phases/stateful/context.py +85 -0
  73. schemathesis/engine/phases/unit/__init__.py +212 -0
  74. schemathesis/engine/phases/unit/_executor.py +416 -0
  75. schemathesis/engine/phases/unit/_pool.py +82 -0
  76. schemathesis/engine/recorder.py +247 -0
  77. schemathesis/errors.py +43 -0
  78. schemathesis/filters.py +17 -98
  79. schemathesis/generation/__init__.py +5 -33
  80. schemathesis/generation/case.py +317 -0
  81. schemathesis/generation/coverage.py +282 -175
  82. schemathesis/generation/hypothesis/__init__.py +36 -0
  83. schemathesis/generation/hypothesis/builder.py +800 -0
  84. schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
  85. schemathesis/generation/hypothesis/given.py +66 -0
  86. schemathesis/generation/hypothesis/reporting.py +14 -0
  87. schemathesis/generation/hypothesis/strategies.py +16 -0
  88. schemathesis/generation/meta.py +115 -0
  89. schemathesis/generation/metrics.py +93 -0
  90. schemathesis/generation/modes.py +20 -0
  91. schemathesis/generation/overrides.py +116 -0
  92. schemathesis/generation/stateful/__init__.py +37 -0
  93. schemathesis/generation/stateful/state_machine.py +278 -0
  94. schemathesis/graphql/__init__.py +15 -0
  95. schemathesis/graphql/checks.py +109 -0
  96. schemathesis/graphql/loaders.py +284 -0
  97. schemathesis/hooks.py +80 -101
  98. schemathesis/openapi/__init__.py +13 -0
  99. schemathesis/openapi/checks.py +455 -0
  100. schemathesis/openapi/generation/__init__.py +0 -0
  101. schemathesis/openapi/generation/filters.py +72 -0
  102. schemathesis/openapi/loaders.py +313 -0
  103. schemathesis/pytest/__init__.py +5 -0
  104. schemathesis/pytest/control_flow.py +7 -0
  105. schemathesis/pytest/lazy.py +281 -0
  106. schemathesis/pytest/loaders.py +36 -0
  107. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +128 -108
  108. schemathesis/python/__init__.py +0 -0
  109. schemathesis/python/asgi.py +12 -0
  110. schemathesis/python/wsgi.py +12 -0
  111. schemathesis/schemas.py +537 -273
  112. schemathesis/specs/graphql/__init__.py +0 -1
  113. schemathesis/specs/graphql/_cache.py +1 -2
  114. schemathesis/specs/graphql/scalars.py +42 -6
  115. schemathesis/specs/graphql/schemas.py +141 -137
  116. schemathesis/specs/graphql/validation.py +11 -17
  117. schemathesis/specs/openapi/__init__.py +6 -1
  118. schemathesis/specs/openapi/_cache.py +1 -2
  119. schemathesis/specs/openapi/_hypothesis.py +142 -156
  120. schemathesis/specs/openapi/checks.py +368 -257
  121. schemathesis/specs/openapi/converter.py +4 -4
  122. schemathesis/specs/openapi/definitions.py +1 -1
  123. schemathesis/specs/openapi/examples.py +23 -21
  124. schemathesis/specs/openapi/expressions/__init__.py +31 -19
  125. schemathesis/specs/openapi/expressions/extractors.py +1 -4
  126. schemathesis/specs/openapi/expressions/lexer.py +1 -1
  127. schemathesis/specs/openapi/expressions/nodes.py +36 -41
  128. schemathesis/specs/openapi/expressions/parser.py +1 -1
  129. schemathesis/specs/openapi/formats.py +35 -7
  130. schemathesis/specs/openapi/media_types.py +53 -12
  131. schemathesis/specs/openapi/negative/__init__.py +7 -4
  132. schemathesis/specs/openapi/negative/mutations.py +6 -5
  133. schemathesis/specs/openapi/parameters.py +7 -10
  134. schemathesis/specs/openapi/patterns.py +94 -31
  135. schemathesis/specs/openapi/references.py +12 -53
  136. schemathesis/specs/openapi/schemas.py +238 -308
  137. schemathesis/specs/openapi/security.py +1 -1
  138. schemathesis/specs/openapi/serialization.py +12 -6
  139. schemathesis/specs/openapi/stateful/__init__.py +268 -133
  140. schemathesis/specs/openapi/stateful/control.py +87 -0
  141. schemathesis/specs/openapi/stateful/links.py +209 -0
  142. schemathesis/transport/__init__.py +142 -0
  143. schemathesis/transport/asgi.py +26 -0
  144. schemathesis/transport/prepare.py +124 -0
  145. schemathesis/transport/requests.py +244 -0
  146. schemathesis/{_xml.py → transport/serialization.py} +69 -11
  147. schemathesis/transport/wsgi.py +171 -0
  148. schemathesis-4.0.0.dist-info/METADATA +204 -0
  149. schemathesis-4.0.0.dist-info/RECORD +164 -0
  150. {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/entry_points.txt +1 -1
  151. {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/licenses/LICENSE +1 -1
  152. schemathesis/_compat.py +0 -74
  153. schemathesis/_dependency_versions.py +0 -19
  154. schemathesis/_hypothesis.py +0 -712
  155. schemathesis/_override.py +0 -50
  156. schemathesis/_patches.py +0 -21
  157. schemathesis/_rate_limiter.py +0 -7
  158. schemathesis/cli/callbacks.py +0 -466
  159. schemathesis/cli/cassettes.py +0 -561
  160. schemathesis/cli/context.py +0 -75
  161. schemathesis/cli/debug.py +0 -27
  162. schemathesis/cli/handlers.py +0 -19
  163. schemathesis/cli/junitxml.py +0 -124
  164. schemathesis/cli/output/__init__.py +0 -1
  165. schemathesis/cli/output/default.py +0 -920
  166. schemathesis/cli/output/short.py +0 -59
  167. schemathesis/cli/reporting.py +0 -79
  168. schemathesis/cli/sanitization.py +0 -26
  169. schemathesis/code_samples.py +0 -151
  170. schemathesis/constants.py +0 -54
  171. schemathesis/contrib/__init__.py +0 -11
  172. schemathesis/contrib/openapi/__init__.py +0 -11
  173. schemathesis/contrib/openapi/fill_missing_examples.py +0 -24
  174. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  175. schemathesis/contrib/openapi/formats/uuid.py +0 -16
  176. schemathesis/contrib/unique_data.py +0 -41
  177. schemathesis/exceptions.py +0 -571
  178. schemathesis/experimental/__init__.py +0 -109
  179. schemathesis/extra/_aiohttp.py +0 -28
  180. schemathesis/extra/_flask.py +0 -13
  181. schemathesis/extra/_server.py +0 -18
  182. schemathesis/failures.py +0 -284
  183. schemathesis/fixups/__init__.py +0 -37
  184. schemathesis/fixups/fast_api.py +0 -41
  185. schemathesis/fixups/utf8_bom.py +0 -28
  186. schemathesis/generation/_methods.py +0 -44
  187. schemathesis/graphql.py +0 -3
  188. schemathesis/internal/__init__.py +0 -7
  189. schemathesis/internal/checks.py +0 -86
  190. schemathesis/internal/copy.py +0 -32
  191. schemathesis/internal/datetime.py +0 -5
  192. schemathesis/internal/deprecation.py +0 -37
  193. schemathesis/internal/diff.py +0 -15
  194. schemathesis/internal/extensions.py +0 -27
  195. schemathesis/internal/jsonschema.py +0 -36
  196. schemathesis/internal/output.py +0 -68
  197. schemathesis/internal/transformation.py +0 -26
  198. schemathesis/internal/validation.py +0 -34
  199. schemathesis/lazy.py +0 -474
  200. schemathesis/loaders.py +0 -122
  201. schemathesis/models.py +0 -1341
  202. schemathesis/parameters.py +0 -90
  203. schemathesis/runner/__init__.py +0 -605
  204. schemathesis/runner/events.py +0 -389
  205. schemathesis/runner/impl/__init__.py +0 -3
  206. schemathesis/runner/impl/context.py +0 -88
  207. schemathesis/runner/impl/core.py +0 -1280
  208. schemathesis/runner/impl/solo.py +0 -80
  209. schemathesis/runner/impl/threadpool.py +0 -391
  210. schemathesis/runner/serialization.py +0 -544
  211. schemathesis/sanitization.py +0 -252
  212. schemathesis/serializers.py +0 -328
  213. schemathesis/service/__init__.py +0 -18
  214. schemathesis/service/auth.py +0 -11
  215. schemathesis/service/ci.py +0 -202
  216. schemathesis/service/client.py +0 -133
  217. schemathesis/service/constants.py +0 -38
  218. schemathesis/service/events.py +0 -61
  219. schemathesis/service/extensions.py +0 -224
  220. schemathesis/service/hosts.py +0 -111
  221. schemathesis/service/metadata.py +0 -71
  222. schemathesis/service/models.py +0 -258
  223. schemathesis/service/report.py +0 -255
  224. schemathesis/service/serialization.py +0 -173
  225. schemathesis/service/usage.py +0 -66
  226. schemathesis/specs/graphql/loaders.py +0 -364
  227. schemathesis/specs/openapi/expressions/context.py +0 -16
  228. schemathesis/specs/openapi/links.py +0 -389
  229. schemathesis/specs/openapi/loaders.py +0 -707
  230. schemathesis/specs/openapi/stateful/statistic.py +0 -198
  231. schemathesis/specs/openapi/stateful/types.py +0 -14
  232. schemathesis/specs/openapi/validation.py +0 -26
  233. schemathesis/stateful/__init__.py +0 -147
  234. schemathesis/stateful/config.py +0 -97
  235. schemathesis/stateful/context.py +0 -135
  236. schemathesis/stateful/events.py +0 -274
  237. schemathesis/stateful/runner.py +0 -309
  238. schemathesis/stateful/sink.py +0 -68
  239. schemathesis/stateful/state_machine.py +0 -328
  240. schemathesis/stateful/statistic.py +0 -22
  241. schemathesis/stateful/validation.py +0 -100
  242. schemathesis/targets.py +0 -77
  243. schemathesis/transports/__init__.py +0 -369
  244. schemathesis/transports/asgi.py +0 -7
  245. schemathesis/transports/auth.py +0 -38
  246. schemathesis/transports/headers.py +0 -36
  247. schemathesis/transports/responses.py +0 -57
  248. schemathesis/types.py +0 -44
  249. schemathesis/utils.py +0 -164
  250. schemathesis-3.39.15.dist-info/METADATA +0 -293
  251. schemathesis-3.39.15.dist-info/RECORD +0 -160
  252. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  253. /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
  254. /schemathesis/{internal → core}/result.py +0 -0
  255. {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,204 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import TYPE_CHECKING, Callable, Generator
5
+
6
+ from schemathesis.cli.commands.run.events import LoadingFinished
7
+ from schemathesis.config import ProjectConfig
8
+ from schemathesis.core.failures import Failure
9
+ from schemathesis.core.result import Err, Ok
10
+ from schemathesis.core.transforms import UNRESOLVABLE
11
+ from schemathesis.core.transport import Response
12
+ from schemathesis.engine import Status, events
13
+ from schemathesis.engine.recorder import CaseNode, ScenarioRecorder
14
+ from schemathesis.generation.case import Case
15
+ from schemathesis.schemas import APIOperation
16
+
17
+ if TYPE_CHECKING:
18
+ from schemathesis.generation.stateful.state_machine import ExtractionFailure
19
+
20
+
21
+ @dataclass
22
+ class Statistic:
23
+ """Running statistics about test execution."""
24
+
25
+ failures: dict[str, dict[str, GroupedFailures]]
26
+ # Track first case_id where each unique failure was found
27
+ unique_failures_map: dict[Failure, str]
28
+
29
+ extraction_failures: set[ExtractionFailure]
30
+
31
+ tested_operations: set[str]
32
+
33
+ total_cases: int
34
+ cases_with_failures: int
35
+ cases_without_checks: int
36
+
37
+ __slots__ = (
38
+ "failures",
39
+ "unique_failures_map",
40
+ "extraction_failures",
41
+ "tested_operations",
42
+ "total_cases",
43
+ "cases_with_failures",
44
+ "cases_without_checks",
45
+ )
46
+
47
+ def __init__(self) -> None:
48
+ self.failures = {}
49
+ self.unique_failures_map = {}
50
+ self.extraction_failures = set()
51
+ self.tested_operations = set()
52
+ self.total_cases = 0
53
+ self.cases_with_failures = 0
54
+ self.cases_without_checks = 0
55
+
56
+ def on_scenario_finished(self, recorder: ScenarioRecorder) -> None:
57
+ """Update statistics and store failures from a new batch of checks."""
58
+ from schemathesis.generation.stateful.state_machine import ExtractionFailure
59
+
60
+ failures = self.failures.get(recorder.label, {})
61
+
62
+ self.total_cases += len(recorder.cases)
63
+
64
+ extraction_failures = set()
65
+
66
+ def collect_history(node: CaseNode, response: Response) -> list[tuple[Case, Response]]:
67
+ history = [(node.value, response)]
68
+ current = node
69
+ while current.parent_id is not None:
70
+ current_response = recorder.find_response(case_id=current.parent_id)
71
+ # We need a response to get there, so it should be present
72
+ assert current_response is not None
73
+ current = recorder.cases[current.parent_id]
74
+ history.append((current.value, current_response))
75
+ return history
76
+
77
+ for case_id, case in recorder.cases.items():
78
+ checks = recorder.checks.get(case_id, [])
79
+
80
+ if not checks:
81
+ self.cases_without_checks += 1
82
+ continue
83
+
84
+ self.tested_operations.add(case.value.operation.label)
85
+ has_failures = False
86
+ current_case_failures = []
87
+ last_failure_info = None
88
+
89
+ for check in checks:
90
+ if check.failure_info is not None:
91
+ failure = check.failure_info.failure
92
+
93
+ # Check if this is a new unique failure
94
+ if failure not in self.unique_failures_map:
95
+ last_failure_info = check.failure_info
96
+ self.unique_failures_map[failure] = case_id
97
+ current_case_failures.append(failure)
98
+ has_failures = True
99
+ else:
100
+ # This failure was already seen - skip it
101
+ continue
102
+
103
+ if current_case_failures:
104
+ assert last_failure_info is not None
105
+ failures[case_id] = GroupedFailures(
106
+ case_id=case_id,
107
+ code_sample=last_failure_info.code_sample,
108
+ failures=current_case_failures,
109
+ response=recorder.interactions[case_id].response,
110
+ )
111
+
112
+ if has_failures:
113
+ self.cases_with_failures += 1
114
+
115
+ if case.transition is None:
116
+ continue
117
+ transition = case.transition
118
+ parent = recorder.cases[transition.parent_id]
119
+ response = recorder.find_response(case_id=parent.value.id)
120
+ # We need a response to get there, so it should be present
121
+ assert response is not None
122
+
123
+ for params in transition.parameters.values():
124
+ for parameter, extracted in params.items():
125
+ if isinstance(extracted.value, Ok) and extracted.value.ok() is UNRESOLVABLE:
126
+ history = collect_history(parent, response)
127
+ extraction_failures.add(
128
+ ExtractionFailure(
129
+ id=transition.id,
130
+ case_id=case_id,
131
+ source=parent.value.operation.label,
132
+ target=case.value.operation.label,
133
+ parameter_name=parameter,
134
+ expression=extracted.definition,
135
+ history=history,
136
+ response=response,
137
+ error=None,
138
+ )
139
+ )
140
+ elif isinstance(extracted.value, Err):
141
+ history = collect_history(parent, response)
142
+ extraction_failures.add(
143
+ ExtractionFailure(
144
+ id=transition.id,
145
+ case_id=case_id,
146
+ source=parent.value.operation.label,
147
+ target=case.value.operation.label,
148
+ parameter_name=parameter,
149
+ expression=extracted.definition,
150
+ history=history,
151
+ response=response,
152
+ error=extracted.value.err(),
153
+ )
154
+ )
155
+
156
+ if failures:
157
+ for group in failures.values():
158
+ group.failures = sorted(set(group.failures))
159
+ self.failures[recorder.label] = failures
160
+
161
+ if extraction_failures:
162
+ self.extraction_failures.update(extraction_failures)
163
+
164
+
165
+ @dataclass
166
+ class GroupedFailures:
167
+ """Represents failures grouped by case ID."""
168
+
169
+ case_id: str
170
+ code_sample: str
171
+ failures: list[Failure]
172
+ response: Response | None
173
+
174
+ __slots__ = ("case_id", "code_sample", "failures", "response")
175
+
176
+
177
+ @dataclass
178
+ class ExecutionContext:
179
+ """Storage for the current context of the execution."""
180
+
181
+ config: ProjectConfig
182
+ find_operation_by_label: Callable[[str], APIOperation | None] | None = None
183
+ statistic: Statistic = field(default_factory=Statistic)
184
+ exit_code: int = 0
185
+ initialization_lines: list[str | Generator[str, None, None]] = field(default_factory=list)
186
+ summary_lines: list[str | Generator[str, None, None]] = field(default_factory=list)
187
+
188
+ def add_initialization_line(self, line: str | Generator[str, None, None]) -> None:
189
+ self.initialization_lines.append(line)
190
+
191
+ def add_summary_line(self, line: str | Generator[str, None, None]) -> None:
192
+ self.summary_lines.append(line)
193
+
194
+ def on_event(self, event: events.EngineEvent) -> None:
195
+ if isinstance(event, LoadingFinished):
196
+ self.find_operation_by_label = event.find_operation_by_label
197
+ if isinstance(event, events.ScenarioFinished):
198
+ self.statistic.on_scenario_finished(event.recorder)
199
+ elif isinstance(event, events.NonFatalError) or (
200
+ isinstance(event, events.PhaseFinished)
201
+ and event.phase.is_enabled
202
+ and event.status in (Status.FAILURE, Status.ERROR)
203
+ ):
204
+ 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, path=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) # type: ignore[arg-type]
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,18 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ if TYPE_CHECKING:
6
+ from schemathesis.cli.commands.run.context import ExecutionContext
7
+ from schemathesis.engine import events
8
+
9
+
10
+ class EventHandler:
11
+ def __init__(self, *args: Any, **params: Any) -> None: ...
12
+
13
+ def handle_event(self, ctx: ExecutionContext, event: events.EngineEvent) -> None:
14
+ raise NotImplementedError
15
+
16
+ def start(self, ctx: ExecutionContext) -> None: ...
17
+
18
+ def shutdown(self, ctx: ExecutionContext) -> None: ...