schemathesis 3.25.5__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 -1766
  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/{cli → engine/phases}/probes.py +63 -70
  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 +153 -39
  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 +483 -367
  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.5.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
  144. {schemathesis-3.25.5.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 -55
  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 -765
  156. schemathesis/cli/output/short.py +0 -40
  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 -1231
  182. schemathesis/parameters.py +0 -86
  183. schemathesis/runner/__init__.py +0 -555
  184. schemathesis/runner/events.py +0 -309
  185. schemathesis/runner/impl/__init__.py +0 -3
  186. schemathesis/runner/impl/core.py +0 -986
  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 -315
  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 -184
  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.5.dist-info/METADATA +0 -356
  219. schemathesis-3.25.5.dist-info/RECORD +0 -134
  220. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  221. {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,746 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from types import GeneratorType
6
+ from typing import TYPE_CHECKING, Any, Generator, Iterable
7
+
8
+ import click
9
+
10
+ from schemathesis.cli.commands.run.context import ExecutionContext, GroupedFailures
11
+ from schemathesis.cli.commands.run.events import LoadingFinished, LoadingStarted
12
+ from schemathesis.cli.commands.run.handlers.base import EventHandler
13
+ from schemathesis.cli.commands.run.handlers.cassettes import CassetteConfig
14
+ from schemathesis.cli.constants import ISSUE_TRACKER_URL
15
+ from schemathesis.cli.core import get_terminal_width
16
+ from schemathesis.core.errors import LoaderError, LoaderErrorKind, format_exception, split_traceback
17
+ from schemathesis.core.failures import MessageBlock, Severity, format_failures
18
+ from schemathesis.core.result import Err, Ok
19
+ from schemathesis.core.version import SCHEMATHESIS_VERSION
20
+ from schemathesis.engine import Status, events
21
+ from schemathesis.engine.errors import EngineErrorInfo
22
+ from schemathesis.engine.phases import PhaseName, PhaseSkipReason
23
+ from schemathesis.engine.phases.probes import ProbeOutcome
24
+ from schemathesis.engine.recorder import Interaction
25
+ from schemathesis.experimental import GLOBAL_EXPERIMENTS
26
+ from schemathesis.schemas import ApiOperationsCount
27
+
28
+ if TYPE_CHECKING:
29
+ from rich.console import Console
30
+ from rich.progress import Progress, TaskID
31
+
32
+ IO_ENCODING = os.getenv("PYTHONIOENCODING", "utf-8")
33
+ DISCORD_LINK = "https://discord.gg/R9ASRAmHnA"
34
+
35
+
36
+ def display_section_name(title: str, separator: str = "=", **kwargs: Any) -> None:
37
+ """Print section name with separators in terminal with the given title nicely centered."""
38
+ message = f" {title} ".center(get_terminal_width(), separator)
39
+ kwargs.setdefault("bold", True)
40
+ click.secho(message, **kwargs)
41
+
42
+
43
+ def get_percentage(position: int, length: int) -> str:
44
+ """Format completion percentage in square brackets."""
45
+ percentage_message = f"{position * 100 // length}%".rjust(4)
46
+ return f"[{percentage_message}]"
47
+
48
+
49
+ def bold(option: str) -> str:
50
+ return click.style(option, bold=True)
51
+
52
+
53
+ def display_failures(ctx: ExecutionContext) -> None:
54
+ """Display all failures in the test run."""
55
+ if not ctx.statistic.failures:
56
+ return
57
+
58
+ display_section_name("FAILURES")
59
+ for label, failures in ctx.statistic.failures.items():
60
+ display_failures_for_single_test(ctx, label, failures.values())
61
+
62
+
63
+ if IO_ENCODING != "utf-8":
64
+
65
+ def _style(text: str, **kwargs: Any) -> str:
66
+ text = text.encode(IO_ENCODING, errors="replace").decode("utf-8")
67
+ return click.style(text, **kwargs)
68
+
69
+ else:
70
+
71
+ def _style(text: str, **kwargs: Any) -> str:
72
+ return click.style(text, **kwargs)
73
+
74
+
75
+ def failure_formatter(block: MessageBlock, content: str) -> str:
76
+ if block == MessageBlock.CASE_ID:
77
+ return _style(content, bold=True)
78
+ if block == MessageBlock.FAILURE:
79
+ return _style(content, fg="red", bold=True)
80
+ if block == MessageBlock.STATUS:
81
+ return _style(content, bold=True)
82
+ assert block == MessageBlock.CURL
83
+ return _style(content.replace("Reproduce with", bold("Reproduce with")))
84
+
85
+
86
+ def display_failures_for_single_test(ctx: ExecutionContext, label: str, checks: Iterable[GroupedFailures]) -> None:
87
+ """Display a failure for a single method / path."""
88
+ display_section_name(label, "_", fg="red")
89
+ for idx, group in enumerate(checks, 1):
90
+ click.echo(
91
+ format_failures(
92
+ case_id=f"{idx}. Test Case ID: {group.case_id}",
93
+ response=group.response,
94
+ failures=group.failures,
95
+ curl=group.code_sample,
96
+ formatter=failure_formatter,
97
+ config=ctx.output_config,
98
+ )
99
+ )
100
+ click.echo()
101
+
102
+
103
+ VERIFY_URL_SUGGESTION = "Verify that the URL points directly to the Open API schema"
104
+ DISABLE_SSL_SUGGESTION = f"Bypass SSL verification with {bold('`--request-tls-verify=false`')}."
105
+ LOADER_ERROR_SUGGESTIONS = {
106
+ # SSL-specific connection issue
107
+ LoaderErrorKind.CONNECTION_SSL: DISABLE_SSL_SUGGESTION,
108
+ # Other connection problems
109
+ LoaderErrorKind.CONNECTION_OTHER: f"Use {bold('`--wait-for-schema=NUM`')} to wait up to NUM seconds for schema availability.",
110
+ # Response issues
111
+ LoaderErrorKind.UNEXPECTED_CONTENT_TYPE: VERIFY_URL_SUGGESTION,
112
+ LoaderErrorKind.HTTP_FORBIDDEN: "Verify your API keys or authentication headers.",
113
+ LoaderErrorKind.HTTP_NOT_FOUND: VERIFY_URL_SUGGESTION,
114
+ # OpenAPI specification issues
115
+ LoaderErrorKind.OPEN_API_UNSPECIFIED_VERSION: "Include the version in the schema.",
116
+ # YAML specific issues
117
+ LoaderErrorKind.YAML_NUMERIC_STATUS_CODES: "Convert numeric status codes to strings.",
118
+ LoaderErrorKind.YAML_NON_STRING_KEYS: "Convert non-string keys to strings.",
119
+ # Unclassified
120
+ LoaderErrorKind.UNCLASSIFIED: f"If you suspect this is a Schemathesis issue and the schema is valid, please report it and include the schema if you can:\n\n {ISSUE_TRACKER_URL}",
121
+ }
122
+
123
+
124
+ def _display_extras(extras: list[str]) -> None:
125
+ if extras:
126
+ click.echo()
127
+ for extra in extras:
128
+ click.secho(f" {extra}")
129
+
130
+
131
+ def _maybe_display_tip(suggestion: str | None) -> None:
132
+ # Display suggestion if any
133
+ if suggestion is not None:
134
+ click.secho(f"\n{click.style('Tip:', bold=True, fg='green')} {suggestion}")
135
+
136
+
137
+ def display_header(version: str) -> None:
138
+ prefix = "v" if version != "dev" else ""
139
+ header = f"Schemathesis {prefix}{version}"
140
+ click.secho(header, bold=True)
141
+ click.secho("━" * len(header), bold=True)
142
+ click.echo()
143
+
144
+
145
+ DEFAULT_INTERNAL_ERROR_MESSAGE = "An internal error occurred during the test run"
146
+ TRUNCATION_PLACEHOLDER = "[...]"
147
+
148
+
149
+ def _print_lines(lines: list[str | Generator[str, None, None]]) -> None:
150
+ for entry in lines:
151
+ if isinstance(entry, str):
152
+ click.echo(entry)
153
+ elif isinstance(entry, GeneratorType):
154
+ for line in entry:
155
+ click.echo(line)
156
+
157
+
158
+ def _default_console() -> Console:
159
+ from rich.console import Console
160
+
161
+ kwargs = {}
162
+ # For stdout recording in tests
163
+ if "PYTEST_VERSION" in os.environ:
164
+ kwargs["width"] = 240
165
+ return Console(**kwargs)
166
+
167
+
168
+ BLOCK_PADDING = (0, 1, 0, 1)
169
+
170
+
171
+ @dataclass
172
+ class WarningData:
173
+ missing_auth: dict[int, list[str]] = field(default_factory=dict)
174
+
175
+
176
+ @dataclass
177
+ class OutputHandler(EventHandler):
178
+ workers_num: int
179
+ rate_limit: str | None
180
+ wait_for_schema: float | None
181
+ progress: Progress | None = None
182
+ progress_task_id: TaskID | None = None
183
+ operations_processed: int = 0
184
+ operations_count: ApiOperationsCount | None = None
185
+ skip_reasons: list[str] = field(default_factory=list)
186
+ current_line_length: int = 0
187
+ cassette_config: CassetteConfig | None = None
188
+ junit_xml_file: str | None = None
189
+ warnings: WarningData = field(default_factory=WarningData)
190
+ errors: list[events.NonFatalError] = field(default_factory=list)
191
+ phases: dict[PhaseName, tuple[Status, PhaseSkipReason | None]] = field(
192
+ default_factory=lambda: {phase: (Status.SKIP, None) for phase in PhaseName}
193
+ )
194
+ console: Console = field(default_factory=_default_console)
195
+
196
+ def handle_event(self, ctx: ExecutionContext, event: events.EngineEvent) -> None:
197
+ if isinstance(event, events.PhaseStarted):
198
+ self._on_phase_started(event)
199
+ elif isinstance(event, events.PhaseFinished):
200
+ self._on_phase_finished(event)
201
+ elif isinstance(event, events.ScenarioStarted):
202
+ self._on_scenario_started(event)
203
+ elif isinstance(event, events.ScenarioFinished):
204
+ self._on_scenario_finished(event)
205
+ if isinstance(event, events.EngineFinished):
206
+ self._on_engine_finished(ctx, event)
207
+ elif isinstance(event, events.Interrupted):
208
+ self._on_interrupted()
209
+ elif isinstance(event, events.FatalError):
210
+ self._on_fatal_error(event)
211
+ elif isinstance(event, events.NonFatalError):
212
+ self.errors.append(event)
213
+ elif isinstance(event, LoadingStarted):
214
+ self._on_loading_started(event)
215
+ elif isinstance(event, LoadingFinished):
216
+ self._on_loading_finished(ctx, event)
217
+
218
+ def start(self, ctx: ExecutionContext) -> None:
219
+ display_header(SCHEMATHESIS_VERSION)
220
+
221
+ def shutdown(self) -> None:
222
+ if self.progress is not None and self.progress_task_id is not None:
223
+ self.progress.stop_task(self.progress_task_id)
224
+ self.progress.stop()
225
+
226
+ def _start_progress(self, name: str) -> None:
227
+ assert self.progress is not None
228
+ self.progress_task_id = self.progress.add_task(name, total=None)
229
+ self.progress.start()
230
+
231
+ def _stop_progress(self) -> None:
232
+ assert self.progress is not None
233
+ assert self.progress_task_id is not None
234
+ self.progress.stop_task(self.progress_task_id)
235
+ self.progress.stop()
236
+ self.progress = None
237
+ self.progress_task_id = None
238
+
239
+ def _on_loading_started(self, event: LoadingStarted) -> None:
240
+ from rich.progress import Progress, RenderableColumn, SpinnerColumn, TextColumn
241
+ from rich.style import Style
242
+ from rich.text import Text
243
+
244
+ progress_message = Text.assemble(
245
+ ("Loading specification from ", Style(color="white")),
246
+ (event.location, Style(color="cyan")),
247
+ )
248
+ self.progress = Progress(
249
+ TextColumn(""),
250
+ SpinnerColumn("clock"),
251
+ RenderableColumn(progress_message),
252
+ console=self.console,
253
+ transient=True,
254
+ )
255
+ self._start_progress("Loading")
256
+
257
+ def _on_loading_finished(self, ctx: ExecutionContext, event: LoadingFinished) -> None:
258
+ from rich.padding import Padding
259
+ from rich.style import Style
260
+ from rich.table import Table
261
+ from rich.text import Text
262
+
263
+ self._stop_progress()
264
+ self.operations_count = event.operations_count
265
+
266
+ duration_ms = int(event.duration * 1000)
267
+ message = Padding(
268
+ Text.assemble(
269
+ ("✅ ", Style(color="green")),
270
+ ("Loaded specification from ", Style(color="white")),
271
+ (event.location, Style(color="cyan")),
272
+ (f" (in {duration_ms} ms)", Style(color="white")),
273
+ ),
274
+ BLOCK_PADDING,
275
+ )
276
+ self.console.print(message)
277
+ self.console.print()
278
+
279
+ table = Table(
280
+ show_header=False,
281
+ box=None,
282
+ padding=(0, 4),
283
+ collapse_padding=True,
284
+ )
285
+ table.add_column("Field", style=Style(color="bright_white", bold=True))
286
+ table.add_column("Value", style="cyan")
287
+
288
+ table.add_row("Base URL:", event.base_url)
289
+ table.add_row("Specification:", event.specification.name)
290
+ table.add_row("Operations:", str(event.operations_count.total))
291
+
292
+ message = Padding(table, BLOCK_PADDING)
293
+ self.console.print(message)
294
+ self.console.print()
295
+
296
+ if ctx.initialization_lines:
297
+ _print_lines(ctx.initialization_lines)
298
+
299
+ def _on_phase_started(self, event: events.PhaseStarted) -> None:
300
+ phase = event.phase
301
+ if phase.name == PhaseName.PROBING and phase.is_enabled:
302
+ self._start_probing()
303
+ elif phase.name == PhaseName.STATEFUL_TESTING and phase.is_enabled and phase.skip_reason is None:
304
+ click.secho("Stateful tests\n", bold=True)
305
+
306
+ def _start_probing(self) -> None:
307
+ from rich.progress import Progress, RenderableColumn, SpinnerColumn, TextColumn
308
+ from rich.text import Text
309
+
310
+ progress_message = Text("Probing API capabilities")
311
+ self.progress = Progress(
312
+ TextColumn(""),
313
+ SpinnerColumn("clock"),
314
+ RenderableColumn(progress_message),
315
+ transient=True,
316
+ console=self.console,
317
+ )
318
+ self._start_progress("Probing")
319
+
320
+ def _on_phase_finished(self, event: events.PhaseFinished) -> None:
321
+ from rich.padding import Padding
322
+ from rich.style import Style
323
+ from rich.table import Table
324
+ from rich.text import Text
325
+
326
+ phase = event.phase
327
+ self.phases[phase.name] = (event.status, phase.skip_reason)
328
+
329
+ if phase.name == PhaseName.PROBING:
330
+ self._stop_progress()
331
+
332
+ if event.status == Status.SUCCESS:
333
+ assert isinstance(event.payload, Ok)
334
+ payload = event.payload.ok()
335
+ self.console.print(
336
+ Padding(
337
+ Text.assemble(
338
+ ("✅ ", Style(color="green")),
339
+ ("API capabilities:", Style(color="white", bold=True)),
340
+ ),
341
+ BLOCK_PADDING,
342
+ )
343
+ )
344
+ self.console.print()
345
+
346
+ table = Table(
347
+ show_header=False,
348
+ box=None,
349
+ padding=(0, 4),
350
+ collapse_padding=True,
351
+ )
352
+ table.add_column("Capability", style=Style(color="bright_white", bold=True))
353
+ table.add_column("Status", style="cyan")
354
+ for probe_run in payload.probes:
355
+ icon, style = {
356
+ ProbeOutcome.SUCCESS: ("✓", Style(color="green")),
357
+ ProbeOutcome.FAILURE: ("✘", Style(color="red")),
358
+ ProbeOutcome.SKIP: ("⊘", Style(color="yellow")),
359
+ ProbeOutcome.ERROR: ("⚠", Style(color="yellow")),
360
+ }[probe_run.outcome]
361
+
362
+ table.add_row(f"{probe_run.probe.name}:", Text(icon, style=style))
363
+
364
+ message = Padding(table, BLOCK_PADDING)
365
+ elif event.status == Status.SKIP:
366
+ message = Padding(
367
+ Text.assemble(
368
+ ("⏭️ ", ""),
369
+ ("API probing skipped", Style(color="yellow")),
370
+ ),
371
+ BLOCK_PADDING,
372
+ )
373
+ else:
374
+ assert event.status == Status.ERROR
375
+ assert isinstance(event.payload, Err)
376
+ error = EngineErrorInfo(event.payload.err())
377
+ message = Padding(
378
+ Text.assemble(
379
+ ("🚫 ", ""),
380
+ (f"API probing failed: {error.message}", Style(color="red")),
381
+ ),
382
+ BLOCK_PADDING,
383
+ )
384
+ self.console.print(message)
385
+ self.console.print()
386
+ elif phase.name == PhaseName.STATEFUL_TESTING and phase.is_enabled:
387
+ if event.status != Status.INTERRUPTED:
388
+ click.echo("\n")
389
+ elif phase.name == PhaseName.UNIT_TESTING and phase.is_enabled:
390
+ if event.status != Status.INTERRUPTED:
391
+ click.echo()
392
+ if self.workers_num > 1:
393
+ click.echo()
394
+
395
+ def _on_scenario_started(self, event: events.ScenarioStarted) -> None:
396
+ if event.phase == PhaseName.UNIT_TESTING and self.workers_num == 1:
397
+ # We should display execution result + percentage in the end. For example:
398
+ assert event.label is not None
399
+ max_length = get_terminal_width() - len(" . [XXX%]") - len(TRUNCATION_PLACEHOLDER)
400
+ message = event.label
401
+ message = message[:max_length] + (message[max_length:] and "[...]") + " "
402
+ self.current_line_length = len(message)
403
+ click.echo(message, nl=False)
404
+
405
+ def _on_scenario_finished(self, event: events.ScenarioFinished) -> None:
406
+ self.operations_processed += 1
407
+ if event.phase == PhaseName.UNIT_TESTING:
408
+ if event.status == Status.SKIP and event.skip_reason is not None:
409
+ self.skip_reasons.append(event.skip_reason)
410
+ self._display_execution_result(event.status)
411
+ self._check_warnings(event)
412
+ if self.workers_num == 1:
413
+ self.display_percentage()
414
+ elif (
415
+ event.phase == PhaseName.STATEFUL_TESTING
416
+ and not event.is_final
417
+ and event.status != Status.INTERRUPTED
418
+ and event.status is not None
419
+ ):
420
+ self._display_execution_result(event.status)
421
+
422
+ def _check_warnings(self, event: events.ScenarioFinished) -> None:
423
+ for status_code in (401, 403):
424
+ if has_too_many_responses_with_status(event.recorder.interactions.values(), status_code):
425
+ self.warnings.missing_auth.setdefault(status_code, []).append(event.recorder.label)
426
+
427
+ def _display_execution_result(self, status: Status) -> None:
428
+ """Display an appropriate symbol for the given event's execution result."""
429
+ symbol, color = {
430
+ Status.SUCCESS: (".", "green"),
431
+ Status.FAILURE: ("F", "red"),
432
+ Status.ERROR: ("E", "red"),
433
+ Status.SKIP: ("S", "yellow"),
434
+ Status.INTERRUPTED: ("S", "yellow"),
435
+ }[status]
436
+ self.current_line_length += len(symbol)
437
+ click.secho(symbol, nl=False, fg=color)
438
+
439
+ def _on_interrupted(self) -> None:
440
+ click.echo()
441
+ display_section_name("KeyboardInterrupt", "!", bold=False)
442
+ click.echo()
443
+
444
+ def _on_fatal_error(self, event: events.FatalError) -> None:
445
+ if isinstance(event.exception, LoaderError):
446
+ title = "Schema Loading Error"
447
+ message = event.exception.message
448
+ extras = event.exception.extras
449
+ suggestion = LOADER_ERROR_SUGGESTIONS.get(event.exception.kind)
450
+ else:
451
+ title = "Test Execution Error"
452
+ message = DEFAULT_INTERNAL_ERROR_MESSAGE
453
+ traceback = format_exception(event.exception, with_traceback=True)
454
+ extras = split_traceback(traceback)
455
+ suggestion = (
456
+ f"Please consider reporting the traceback above to our issue tracker:\n\n {ISSUE_TRACKER_URL}."
457
+ )
458
+ click.secho(title, fg="red", bold=True)
459
+ click.echo()
460
+ click.secho(message)
461
+ _display_extras(extras)
462
+ if not (
463
+ isinstance(event.exception, LoaderError)
464
+ and event.exception.kind == LoaderErrorKind.CONNECTION_OTHER
465
+ and self.wait_for_schema is not None
466
+ ):
467
+ _maybe_display_tip(suggestion)
468
+
469
+ raise click.Abort
470
+
471
+ def display_warnings(self) -> None:
472
+ display_section_name("WARNINGS")
473
+ total = sum(len(endpoints) for endpoints in self.warnings.missing_auth.values())
474
+ suffix = "" if total == 1 else "s"
475
+ click.secho(
476
+ f"\nMissing or invalid API credentials: {total} API operation{suffix} returned authentication errors\n",
477
+ fg="yellow",
478
+ )
479
+
480
+ for status_code, operations in self.warnings.missing_auth.items():
481
+ status_text = "Unauthorized" if status_code == 401 else "Forbidden"
482
+ count = len(operations)
483
+ suffix = "" if count == 1 else "s"
484
+ click.secho(
485
+ f"{status_code} {status_text} ({count} operation{suffix}):",
486
+ fg="yellow",
487
+ )
488
+ # Show first few API operations
489
+ for endpoint in operations[:3]:
490
+ click.secho(f" • {endpoint}", fg="yellow")
491
+ if len(operations) > 3:
492
+ click.secho(f" + {len(operations) - 3} more", fg="yellow")
493
+ click.echo()
494
+ click.secho("Tip: ", bold=True, fg="yellow", nl=False)
495
+ click.secho(f"Use {bold('--auth')} ", fg="yellow", nl=False)
496
+ click.secho(f"or {bold('-H')} ", fg="yellow", nl=False)
497
+ click.secho("to provide authentication credentials", fg="yellow")
498
+ click.echo()
499
+
500
+ def display_experiments(self) -> None:
501
+ display_section_name("EXPERIMENTS")
502
+
503
+ click.echo()
504
+ for experiment in sorted(GLOBAL_EXPERIMENTS.enabled, key=lambda e: e.name):
505
+ click.secho(f"🧪 {experiment.name}: ", bold=True, nl=False)
506
+ click.secho(experiment.description)
507
+ click.secho(f" Feedback: {experiment.discussion_url}")
508
+ click.echo()
509
+
510
+ click.secho(
511
+ "Your feedback is crucial for experimental features. "
512
+ "Please visit the provided URL(s) to share your thoughts.",
513
+ dim=True,
514
+ )
515
+ click.echo()
516
+
517
+ def display_api_operations(self, ctx: ExecutionContext) -> None:
518
+ assert self.operations_count is not None
519
+ click.secho("API Operations:", bold=True)
520
+ click.secho(
521
+ f" Selected: {click.style(str(self.operations_count.selected), bold=True)}/"
522
+ f"{click.style(str(self.operations_count.total), bold=True)}"
523
+ )
524
+ click.secho(f" Tested: {click.style(str(len(ctx.statistic.tested_operations)), bold=True)}")
525
+ errors = len(
526
+ {
527
+ err.label
528
+ for err in self.errors
529
+ # Some API operations may have some tests before they have an error
530
+ if err.phase == PhaseName.UNIT_TESTING
531
+ and err.label not in ctx.statistic.tested_operations
532
+ and err.related_to_operation
533
+ }
534
+ )
535
+ if errors:
536
+ click.secho(f" Errored: {click.style(str(errors), bold=True)}")
537
+
538
+ # API operations that are skipped due to fail-fast are counted here as well
539
+ total_skips = self.operations_count.selected - len(ctx.statistic.tested_operations) - errors
540
+ if total_skips:
541
+ click.secho(f" Skipped: {click.style(str(total_skips), bold=True)}")
542
+ for reason in sorted(set(self.skip_reasons)):
543
+ click.secho(f" - {reason.rstrip('.')}")
544
+ click.echo()
545
+
546
+ def display_phases(self) -> None:
547
+ click.secho("Test Phases:", bold=True)
548
+
549
+ for phase in PhaseName:
550
+ status, skip_reason = self.phases[phase]
551
+
552
+ if status == Status.SKIP:
553
+ click.secho(f" ⏭️ {phase.value}", fg="yellow", nl=False)
554
+ if skip_reason:
555
+ click.secho(f" ({skip_reason.value})", fg="yellow")
556
+ else:
557
+ click.echo()
558
+ elif status == Status.SUCCESS:
559
+ click.secho(f" ✅ {phase.value}", fg="green")
560
+ elif status == Status.FAILURE:
561
+ click.secho(f" ❌ {phase.value}", fg="red")
562
+ elif status == Status.ERROR:
563
+ click.secho(f" 🚫 {phase.value}", fg="red")
564
+ elif status == Status.INTERRUPTED:
565
+ click.secho(f" ⚡ {phase.value}", fg="yellow")
566
+ click.echo()
567
+
568
+ def display_test_cases(self, ctx: ExecutionContext) -> None:
569
+ if ctx.statistic.total_cases == 0:
570
+ click.secho("Test cases:", bold=True)
571
+ click.secho(" No test cases were generated\n")
572
+ return
573
+
574
+ unique_failures = sum(
575
+ len(group.failures) for grouped in ctx.statistic.failures.values() for group in grouped.values()
576
+ )
577
+ click.secho("Test cases:", bold=True)
578
+
579
+ parts = [f" {click.style(str(ctx.statistic.total_cases), bold=True)} generated"]
580
+
581
+ # Don't show pass/fail status if all cases were skipped
582
+ if ctx.statistic.cases_without_checks == ctx.statistic.total_cases:
583
+ parts.append(f"{click.style(str(ctx.statistic.cases_without_checks), bold=True)} skipped")
584
+ else:
585
+ if unique_failures > 0:
586
+ parts.append(
587
+ f"{click.style(str(ctx.statistic.cases_with_failures), bold=True)} found "
588
+ f"{click.style(str(unique_failures), bold=True)} unique failures"
589
+ )
590
+ else:
591
+ parts.append(f"{click.style(str(ctx.statistic.total_cases), bold=True)} passed")
592
+
593
+ if ctx.statistic.cases_without_checks > 0:
594
+ parts.append(f"{click.style(str(ctx.statistic.cases_without_checks), bold=True)} skipped")
595
+
596
+ click.secho(", ".join(parts) + "\n")
597
+
598
+ def display_failures_summary(self, ctx: ExecutionContext) -> None:
599
+ # Collect all unique failures and their counts by title
600
+ failure_counts: dict[str, tuple[Severity, int]] = {}
601
+ for grouped in ctx.statistic.failures.values():
602
+ for group in grouped.values():
603
+ for failure in group.failures:
604
+ data = failure_counts.get(failure.title, (failure.severity, 0))
605
+ failure_counts[failure.title] = (failure.severity, data[1] + 1)
606
+
607
+ click.secho("Failures:", bold=True)
608
+
609
+ # Sort by severity first, then by title
610
+ sorted_failures = sorted(failure_counts.items(), key=lambda x: (x[1][0], x[0]))
611
+
612
+ for title, (_, count) in sorted_failures:
613
+ click.secho(f" ❌ {title}: ", nl=False)
614
+ click.secho(str(count), bold=True)
615
+ click.echo()
616
+
617
+ def display_errors_summary(self) -> None:
618
+ # Group errors by title and count occurrences
619
+ error_counts: dict[str, int] = {}
620
+ for error in self.errors:
621
+ title = error.info.title
622
+ error_counts[title] = error_counts.get(title, 0) + 1
623
+
624
+ click.secho("Errors:", bold=True)
625
+
626
+ for title in sorted(error_counts):
627
+ click.secho(f" 🚫 {title}: ", nl=False)
628
+ click.secho(str(error_counts[title]), bold=True)
629
+ click.echo()
630
+
631
+ def display_final_line(self, ctx: ExecutionContext, event: events.EngineFinished) -> None:
632
+ parts = []
633
+
634
+ unique_failures = sum(
635
+ len(group.failures) for grouped in ctx.statistic.failures.values() for group in grouped.values()
636
+ )
637
+ if unique_failures:
638
+ parts.append(f"{unique_failures} failures")
639
+
640
+ if self.errors:
641
+ parts.append(f"{len(self.errors)} errors")
642
+
643
+ total_warnings = sum(len(endpoints) for endpoints in self.warnings.missing_auth.values())
644
+ if total_warnings:
645
+ parts.append(f"{total_warnings} warnings")
646
+
647
+ if parts:
648
+ message = f"{', '.join(parts)} in {event.running_time:.2f}s"
649
+ color = "red" if (unique_failures or self.errors) else "yellow"
650
+ elif ctx.statistic.total_cases == 0:
651
+ message = "Empty test suite"
652
+ color = "yellow"
653
+ else:
654
+ message = f"No issues found in {event.running_time:.2f}s"
655
+ color = "green"
656
+
657
+ display_section_name(message, fg=color)
658
+
659
+ def display_reports(self) -> None:
660
+ reports = []
661
+ if self.cassette_config is not None:
662
+ format_name = self.cassette_config.format.name.upper()
663
+ reports.append((format_name, self.cassette_config.path.name))
664
+ if self.junit_xml_file is not None:
665
+ reports.append(("JUnit XML", self.junit_xml_file))
666
+
667
+ if reports:
668
+ click.secho("Reports:", bold=True)
669
+ for report_type, path in reports:
670
+ click.secho(f" • {report_type}: {path}")
671
+ click.echo()
672
+
673
+ def _on_engine_finished(self, ctx: ExecutionContext, event: events.EngineFinished) -> None:
674
+ if self.errors:
675
+ display_section_name("ERRORS")
676
+ errors = sorted(self.errors, key=lambda r: (r.phase.value, r.label))
677
+ for error in errors:
678
+ display_section_name(error.label, "_", fg="red")
679
+ click.echo(error.info.format(bold=lambda x: click.style(x, bold=True)))
680
+ click.secho(
681
+ f"\nNeed more help?\n Join our Discord server: {DISCORD_LINK}",
682
+ fg="red",
683
+ )
684
+ display_failures(ctx)
685
+ if self.warnings.missing_auth:
686
+ self.display_warnings()
687
+ if GLOBAL_EXPERIMENTS.enabled:
688
+ self.display_experiments()
689
+ display_section_name("SUMMARY")
690
+ click.echo()
691
+
692
+ if self.operations_count:
693
+ self.display_api_operations(ctx)
694
+
695
+ self.display_phases()
696
+
697
+ if ctx.statistic.failures:
698
+ self.display_failures_summary(ctx)
699
+
700
+ if self.errors:
701
+ self.display_errors_summary()
702
+
703
+ if self.warnings.missing_auth:
704
+ affected = sum(len(operations) for operations in self.warnings.missing_auth.values())
705
+ click.secho("Warnings:", bold=True)
706
+ click.secho(f" ⚠️ Missing authentication: {bold(str(affected))}", fg="yellow")
707
+ click.echo()
708
+
709
+ if ctx.summary_lines:
710
+ _print_lines(ctx.summary_lines)
711
+ click.echo()
712
+
713
+ self.display_test_cases(ctx)
714
+ self.display_reports()
715
+ self.display_final_line(ctx, event)
716
+
717
+ def display_percentage(self) -> None:
718
+ """Add the current progress in % to the right side of the current line."""
719
+ assert self.operations_count is not None
720
+ selected = self.operations_count.selected
721
+ current_percentage = get_percentage(self.operations_processed, selected)
722
+ styled = click.style(current_percentage, fg="cyan")
723
+ # Total length of the message, so it will fill to the right border of the terminal.
724
+ # Padding is already taken into account in `ctx.current_line_length`
725
+ length = max(get_terminal_width() - self.current_line_length + len(styled) - len(current_percentage), 1)
726
+ template = f"{{:>{length}}}"
727
+ click.echo(template.format(styled))
728
+
729
+
730
+ TOO_MANY_RESPONSES_WARNING_TEMPLATE = (
731
+ "Most of the responses from {} have a {} status code. Did you specify proper API credentials?"
732
+ )
733
+ TOO_MANY_RESPONSES_THRESHOLD = 0.9
734
+
735
+
736
+ def has_too_many_responses_with_status(interactions: Iterable[Interaction], status_code: int) -> bool:
737
+ matched = 0
738
+ total = 0
739
+ for interaction in interactions:
740
+ if interaction.response is not None:
741
+ if interaction.response.status_code == status_code:
742
+ matched += 1
743
+ total += 1
744
+ if not total:
745
+ return False
746
+ return matched / total >= TOO_MANY_RESPONSES_THRESHOLD