schemathesis 3.39.7__py3-none-any.whl → 4.0.0a2__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 (229) hide show
  1. schemathesis/__init__.py +27 -65
  2. schemathesis/auths.py +26 -68
  3. schemathesis/checks.py +130 -60
  4. schemathesis/cli/__init__.py +5 -2105
  5. schemathesis/cli/commands/__init__.py +37 -0
  6. schemathesis/cli/commands/run/__init__.py +662 -0
  7. schemathesis/cli/commands/run/checks.py +80 -0
  8. schemathesis/cli/commands/run/context.py +117 -0
  9. schemathesis/cli/commands/run/events.py +30 -0
  10. schemathesis/cli/commands/run/executor.py +141 -0
  11. schemathesis/cli/commands/run/filters.py +202 -0
  12. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  13. schemathesis/cli/commands/run/handlers/base.py +18 -0
  14. schemathesis/cli/{cassettes.py → commands/run/handlers/cassettes.py} +178 -247
  15. schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
  16. schemathesis/cli/commands/run/handlers/output.py +1368 -0
  17. schemathesis/cli/commands/run/hypothesis.py +105 -0
  18. schemathesis/cli/commands/run/loaders.py +129 -0
  19. schemathesis/cli/{callbacks.py → commands/run/validation.py} +59 -175
  20. schemathesis/cli/constants.py +5 -58
  21. schemathesis/cli/core.py +17 -0
  22. schemathesis/cli/ext/fs.py +14 -0
  23. schemathesis/cli/ext/groups.py +55 -0
  24. schemathesis/cli/{options.py → ext/options.py} +37 -16
  25. schemathesis/cli/hooks.py +36 -0
  26. schemathesis/contrib/__init__.py +1 -3
  27. schemathesis/contrib/openapi/__init__.py +1 -3
  28. schemathesis/contrib/openapi/fill_missing_examples.py +3 -7
  29. schemathesis/core/__init__.py +58 -0
  30. schemathesis/core/compat.py +25 -0
  31. schemathesis/core/control.py +2 -0
  32. schemathesis/core/curl.py +58 -0
  33. schemathesis/core/deserialization.py +65 -0
  34. schemathesis/core/errors.py +370 -0
  35. schemathesis/core/failures.py +315 -0
  36. schemathesis/core/fs.py +19 -0
  37. schemathesis/core/loaders.py +104 -0
  38. schemathesis/core/marks.py +66 -0
  39. schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
  40. schemathesis/{internal/output.py → core/output/__init__.py} +1 -0
  41. schemathesis/core/output/sanitization.py +197 -0
  42. schemathesis/{throttling.py → core/rate_limit.py} +16 -17
  43. schemathesis/core/registries.py +31 -0
  44. schemathesis/core/transforms.py +113 -0
  45. schemathesis/core/transport.py +108 -0
  46. schemathesis/core/validation.py +38 -0
  47. schemathesis/core/version.py +7 -0
  48. schemathesis/engine/__init__.py +30 -0
  49. schemathesis/engine/config.py +59 -0
  50. schemathesis/engine/context.py +119 -0
  51. schemathesis/engine/control.py +36 -0
  52. schemathesis/engine/core.py +157 -0
  53. schemathesis/engine/errors.py +394 -0
  54. schemathesis/engine/events.py +243 -0
  55. schemathesis/engine/phases/__init__.py +66 -0
  56. schemathesis/{runner → engine/phases}/probes.py +49 -68
  57. schemathesis/engine/phases/stateful/__init__.py +66 -0
  58. schemathesis/engine/phases/stateful/_executor.py +301 -0
  59. schemathesis/engine/phases/stateful/context.py +85 -0
  60. schemathesis/engine/phases/unit/__init__.py +175 -0
  61. schemathesis/engine/phases/unit/_executor.py +322 -0
  62. schemathesis/engine/phases/unit/_pool.py +74 -0
  63. schemathesis/engine/recorder.py +246 -0
  64. schemathesis/errors.py +31 -0
  65. schemathesis/experimental/__init__.py +9 -40
  66. schemathesis/filters.py +7 -95
  67. schemathesis/generation/__init__.py +3 -3
  68. schemathesis/generation/case.py +190 -0
  69. schemathesis/generation/coverage.py +22 -22
  70. schemathesis/{_patches.py → generation/hypothesis/__init__.py} +15 -6
  71. schemathesis/generation/hypothesis/builder.py +585 -0
  72. schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
  73. schemathesis/generation/hypothesis/given.py +66 -0
  74. schemathesis/generation/hypothesis/reporting.py +14 -0
  75. schemathesis/generation/hypothesis/strategies.py +16 -0
  76. schemathesis/generation/meta.py +115 -0
  77. schemathesis/generation/modes.py +28 -0
  78. schemathesis/generation/overrides.py +96 -0
  79. schemathesis/generation/stateful/__init__.py +20 -0
  80. schemathesis/{stateful → generation/stateful}/state_machine.py +84 -109
  81. schemathesis/generation/targets.py +69 -0
  82. schemathesis/graphql/__init__.py +15 -0
  83. schemathesis/graphql/checks.py +109 -0
  84. schemathesis/graphql/loaders.py +131 -0
  85. schemathesis/hooks.py +17 -62
  86. schemathesis/openapi/__init__.py +13 -0
  87. schemathesis/openapi/checks.py +387 -0
  88. schemathesis/openapi/generation/__init__.py +0 -0
  89. schemathesis/openapi/generation/filters.py +63 -0
  90. schemathesis/openapi/loaders.py +178 -0
  91. schemathesis/pytest/__init__.py +5 -0
  92. schemathesis/pytest/control_flow.py +7 -0
  93. schemathesis/pytest/lazy.py +273 -0
  94. schemathesis/pytest/loaders.py +12 -0
  95. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +94 -107
  96. schemathesis/python/__init__.py +0 -0
  97. schemathesis/python/asgi.py +12 -0
  98. schemathesis/python/wsgi.py +12 -0
  99. schemathesis/schemas.py +456 -228
  100. schemathesis/specs/graphql/__init__.py +0 -1
  101. schemathesis/specs/graphql/_cache.py +1 -2
  102. schemathesis/specs/graphql/scalars.py +5 -3
  103. schemathesis/specs/graphql/schemas.py +122 -123
  104. schemathesis/specs/graphql/validation.py +11 -17
  105. schemathesis/specs/openapi/__init__.py +6 -1
  106. schemathesis/specs/openapi/_cache.py +1 -2
  107. schemathesis/specs/openapi/_hypothesis.py +97 -134
  108. schemathesis/specs/openapi/checks.py +238 -219
  109. schemathesis/specs/openapi/converter.py +4 -4
  110. schemathesis/specs/openapi/definitions.py +1 -1
  111. schemathesis/specs/openapi/examples.py +22 -20
  112. schemathesis/specs/openapi/expressions/__init__.py +11 -15
  113. schemathesis/specs/openapi/expressions/extractors.py +1 -4
  114. schemathesis/specs/openapi/expressions/nodes.py +33 -32
  115. schemathesis/specs/openapi/formats.py +3 -2
  116. schemathesis/specs/openapi/links.py +123 -299
  117. schemathesis/specs/openapi/media_types.py +10 -12
  118. schemathesis/specs/openapi/negative/__init__.py +2 -1
  119. schemathesis/specs/openapi/negative/mutations.py +3 -2
  120. schemathesis/specs/openapi/parameters.py +8 -6
  121. schemathesis/specs/openapi/patterns.py +1 -1
  122. schemathesis/specs/openapi/references.py +11 -51
  123. schemathesis/specs/openapi/schemas.py +177 -191
  124. schemathesis/specs/openapi/security.py +1 -1
  125. schemathesis/specs/openapi/serialization.py +10 -6
  126. schemathesis/specs/openapi/stateful/__init__.py +97 -91
  127. schemathesis/transport/__init__.py +104 -0
  128. schemathesis/transport/asgi.py +26 -0
  129. schemathesis/transport/prepare.py +99 -0
  130. schemathesis/transport/requests.py +221 -0
  131. schemathesis/{_xml.py → transport/serialization.py} +69 -7
  132. schemathesis/transport/wsgi.py +165 -0
  133. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/METADATA +18 -14
  134. schemathesis-4.0.0a2.dist-info/RECORD +151 -0
  135. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/entry_points.txt +1 -1
  136. schemathesis/_compat.py +0 -74
  137. schemathesis/_dependency_versions.py +0 -19
  138. schemathesis/_hypothesis.py +0 -559
  139. schemathesis/_override.py +0 -50
  140. schemathesis/_rate_limiter.py +0 -7
  141. schemathesis/cli/context.py +0 -75
  142. schemathesis/cli/debug.py +0 -27
  143. schemathesis/cli/handlers.py +0 -19
  144. schemathesis/cli/junitxml.py +0 -124
  145. schemathesis/cli/output/__init__.py +0 -1
  146. schemathesis/cli/output/default.py +0 -936
  147. schemathesis/cli/output/short.py +0 -59
  148. schemathesis/cli/reporting.py +0 -79
  149. schemathesis/cli/sanitization.py +0 -26
  150. schemathesis/code_samples.py +0 -151
  151. schemathesis/constants.py +0 -56
  152. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  153. schemathesis/contrib/openapi/formats/uuid.py +0 -16
  154. schemathesis/contrib/unique_data.py +0 -41
  155. schemathesis/exceptions.py +0 -571
  156. schemathesis/extra/_aiohttp.py +0 -28
  157. schemathesis/extra/_flask.py +0 -13
  158. schemathesis/extra/_server.py +0 -18
  159. schemathesis/failures.py +0 -277
  160. schemathesis/fixups/__init__.py +0 -37
  161. schemathesis/fixups/fast_api.py +0 -41
  162. schemathesis/fixups/utf8_bom.py +0 -28
  163. schemathesis/generation/_methods.py +0 -44
  164. schemathesis/graphql.py +0 -3
  165. schemathesis/internal/__init__.py +0 -7
  166. schemathesis/internal/checks.py +0 -84
  167. schemathesis/internal/copy.py +0 -32
  168. schemathesis/internal/datetime.py +0 -5
  169. schemathesis/internal/deprecation.py +0 -38
  170. schemathesis/internal/diff.py +0 -15
  171. schemathesis/internal/extensions.py +0 -27
  172. schemathesis/internal/jsonschema.py +0 -36
  173. schemathesis/internal/transformation.py +0 -26
  174. schemathesis/internal/validation.py +0 -34
  175. schemathesis/lazy.py +0 -474
  176. schemathesis/loaders.py +0 -122
  177. schemathesis/models.py +0 -1341
  178. schemathesis/parameters.py +0 -90
  179. schemathesis/runner/__init__.py +0 -605
  180. schemathesis/runner/events.py +0 -389
  181. schemathesis/runner/impl/__init__.py +0 -3
  182. schemathesis/runner/impl/context.py +0 -104
  183. schemathesis/runner/impl/core.py +0 -1246
  184. schemathesis/runner/impl/solo.py +0 -80
  185. schemathesis/runner/impl/threadpool.py +0 -391
  186. schemathesis/runner/serialization.py +0 -544
  187. schemathesis/sanitization.py +0 -252
  188. schemathesis/serializers.py +0 -328
  189. schemathesis/service/__init__.py +0 -18
  190. schemathesis/service/auth.py +0 -11
  191. schemathesis/service/ci.py +0 -202
  192. schemathesis/service/client.py +0 -133
  193. schemathesis/service/constants.py +0 -38
  194. schemathesis/service/events.py +0 -61
  195. schemathesis/service/extensions.py +0 -224
  196. schemathesis/service/hosts.py +0 -111
  197. schemathesis/service/metadata.py +0 -71
  198. schemathesis/service/models.py +0 -258
  199. schemathesis/service/report.py +0 -255
  200. schemathesis/service/serialization.py +0 -173
  201. schemathesis/service/usage.py +0 -66
  202. schemathesis/specs/graphql/loaders.py +0 -364
  203. schemathesis/specs/openapi/expressions/context.py +0 -16
  204. schemathesis/specs/openapi/loaders.py +0 -708
  205. schemathesis/specs/openapi/stateful/statistic.py +0 -198
  206. schemathesis/specs/openapi/stateful/types.py +0 -14
  207. schemathesis/specs/openapi/validation.py +0 -26
  208. schemathesis/stateful/__init__.py +0 -147
  209. schemathesis/stateful/config.py +0 -97
  210. schemathesis/stateful/context.py +0 -135
  211. schemathesis/stateful/events.py +0 -274
  212. schemathesis/stateful/runner.py +0 -309
  213. schemathesis/stateful/sink.py +0 -68
  214. schemathesis/stateful/statistic.py +0 -22
  215. schemathesis/stateful/validation.py +0 -100
  216. schemathesis/targets.py +0 -77
  217. schemathesis/transports/__init__.py +0 -359
  218. schemathesis/transports/asgi.py +0 -7
  219. schemathesis/transports/auth.py +0 -38
  220. schemathesis/transports/headers.py +0 -36
  221. schemathesis/transports/responses.py +0 -57
  222. schemathesis/types.py +0 -44
  223. schemathesis/utils.py +0 -164
  224. schemathesis-3.39.7.dist-info/RECORD +0 -160
  225. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  226. /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
  227. /schemathesis/{internal → core}/result.py +0 -0
  228. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/WHEEL +0 -0
  229. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1368 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+ from dataclasses import dataclass, field
6
+ from types import GeneratorType
7
+ from typing import TYPE_CHECKING, Any, Generator, Iterable
8
+
9
+ import click
10
+
11
+ from schemathesis.cli.commands.run.context import ExecutionContext, GroupedFailures
12
+ from schemathesis.cli.commands.run.events import LoadingFinished, LoadingStarted
13
+ from schemathesis.cli.commands.run.handlers.base import EventHandler
14
+ from schemathesis.cli.commands.run.handlers.cassettes import CassetteConfig
15
+ from schemathesis.cli.constants import ISSUE_TRACKER_URL
16
+ from schemathesis.cli.core import get_terminal_width
17
+ from schemathesis.core.errors import LoaderError, LoaderErrorKind, format_exception, split_traceback
18
+ from schemathesis.core.failures import MessageBlock, Severity, format_failures
19
+ from schemathesis.core.result import Err, Ok
20
+ from schemathesis.core.version import SCHEMATHESIS_VERSION
21
+ from schemathesis.engine import Status, events
22
+ from schemathesis.engine.errors import EngineErrorInfo
23
+ from schemathesis.engine.phases import PhaseName, PhaseSkipReason
24
+ from schemathesis.engine.phases.probes import ProbeOutcome
25
+ from schemathesis.engine.recorder import Interaction
26
+ from schemathesis.experimental import GLOBAL_EXPERIMENTS
27
+ from schemathesis.schemas import ApiStatistic
28
+
29
+ if TYPE_CHECKING:
30
+ from rich.console import Console, Group
31
+ from rich.live import Live
32
+ from rich.progress import Progress, TaskID
33
+ from rich.text import Text
34
+
35
+ IO_ENCODING = os.getenv("PYTHONIOENCODING", "utf-8")
36
+ DISCORD_LINK = "https://discord.gg/R9ASRAmHnA"
37
+
38
+
39
+ def display_section_name(title: str, separator: str = "=", **kwargs: Any) -> None:
40
+ """Print section name with separators in terminal with the given title nicely centered."""
41
+ message = f" {title} ".center(get_terminal_width(), separator)
42
+ kwargs.setdefault("bold", True)
43
+ click.secho(message, **kwargs)
44
+
45
+
46
+ def bold(option: str) -> str:
47
+ return click.style(option, bold=True)
48
+
49
+
50
+ def display_failures(ctx: ExecutionContext) -> None:
51
+ """Display all failures in the test run."""
52
+ if not ctx.statistic.failures:
53
+ return
54
+
55
+ display_section_name("FAILURES")
56
+ for label, failures in ctx.statistic.failures.items():
57
+ display_failures_for_single_test(ctx, label, failures.values())
58
+
59
+
60
+ if IO_ENCODING != "utf-8":
61
+
62
+ def _style(text: str, **kwargs: Any) -> str:
63
+ text = text.encode(IO_ENCODING, errors="replace").decode("utf-8")
64
+ return click.style(text, **kwargs)
65
+
66
+ else:
67
+
68
+ def _style(text: str, **kwargs: Any) -> str:
69
+ return click.style(text, **kwargs)
70
+
71
+
72
+ def failure_formatter(block: MessageBlock, content: str) -> str:
73
+ if block == MessageBlock.CASE_ID:
74
+ return _style(content, bold=True)
75
+ if block == MessageBlock.FAILURE:
76
+ return _style(content, fg="red", bold=True)
77
+ if block == MessageBlock.STATUS:
78
+ return _style(content, bold=True)
79
+ assert block == MessageBlock.CURL
80
+ return _style(content.replace("Reproduce with", bold("Reproduce with")))
81
+
82
+
83
+ def display_failures_for_single_test(ctx: ExecutionContext, label: str, checks: Iterable[GroupedFailures]) -> None:
84
+ """Display a failure for a single method / path."""
85
+ display_section_name(label, "_", fg="red")
86
+ for idx, group in enumerate(checks, 1):
87
+ click.echo(
88
+ format_failures(
89
+ case_id=f"{idx}. Test Case ID: {group.case_id}",
90
+ response=group.response,
91
+ failures=group.failures,
92
+ curl=group.code_sample,
93
+ formatter=failure_formatter,
94
+ config=ctx.output_config,
95
+ )
96
+ )
97
+ click.echo()
98
+
99
+
100
+ VERIFY_URL_SUGGESTION = "Verify that the URL points directly to the Open API schema or GraphQL endpoint"
101
+ DISABLE_SSL_SUGGESTION = f"Bypass SSL verification with {bold('`--request-tls-verify=false`')}."
102
+ LOADER_ERROR_SUGGESTIONS = {
103
+ # SSL-specific connection issue
104
+ LoaderErrorKind.CONNECTION_SSL: DISABLE_SSL_SUGGESTION,
105
+ # Other connection problems
106
+ LoaderErrorKind.CONNECTION_OTHER: f"Use {bold('`--wait-for-schema=NUM`')} to wait up to NUM seconds for schema availability.",
107
+ # Response issues
108
+ LoaderErrorKind.UNEXPECTED_CONTENT_TYPE: VERIFY_URL_SUGGESTION,
109
+ LoaderErrorKind.HTTP_FORBIDDEN: "Verify your API keys or authentication headers.",
110
+ LoaderErrorKind.HTTP_NOT_FOUND: VERIFY_URL_SUGGESTION,
111
+ # OpenAPI specification issues
112
+ LoaderErrorKind.OPEN_API_UNSPECIFIED_VERSION: "Include the version in the schema.",
113
+ # YAML specific issues
114
+ LoaderErrorKind.YAML_NUMERIC_STATUS_CODES: "Convert numeric status codes to strings.",
115
+ LoaderErrorKind.YAML_NON_STRING_KEYS: "Convert non-string keys to strings.",
116
+ # Unclassified
117
+ 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}",
118
+ }
119
+
120
+
121
+ def _display_extras(extras: list[str]) -> None:
122
+ if extras:
123
+ click.echo()
124
+ for extra in extras:
125
+ click.secho(f" {extra}")
126
+
127
+
128
+ def display_header(version: str) -> None:
129
+ prefix = "v" if version != "dev" else ""
130
+ header = f"Schemathesis {prefix}{version}"
131
+ click.secho(header, bold=True)
132
+ click.secho("━" * len(header), bold=True)
133
+ click.echo()
134
+
135
+
136
+ DEFAULT_INTERNAL_ERROR_MESSAGE = "An internal error occurred during the test run"
137
+ TRUNCATION_PLACEHOLDER = "[...]"
138
+
139
+
140
+ def _print_lines(lines: list[str | Generator[str, None, None]]) -> None:
141
+ for entry in lines:
142
+ if isinstance(entry, str):
143
+ click.echo(entry)
144
+ elif isinstance(entry, GeneratorType):
145
+ for line in entry:
146
+ click.echo(line)
147
+
148
+
149
+ def _default_console() -> Console:
150
+ from rich.console import Console
151
+
152
+ kwargs = {}
153
+ # For stdout recording in tests
154
+ if "PYTEST_VERSION" in os.environ:
155
+ kwargs["width"] = 240
156
+ return Console(**kwargs)
157
+
158
+
159
+ BLOCK_PADDING = (0, 1, 0, 1)
160
+
161
+
162
+ @dataclass
163
+ class LoadingProgressManager:
164
+ console: Console
165
+ location: str
166
+ start_time: float
167
+ progress: Progress
168
+ progress_task_id: TaskID | None
169
+ is_interrupted: bool
170
+
171
+ __slots__ = ("console", "location", "start_time", "progress", "progress_task_id", "is_interrupted")
172
+
173
+ def __init__(self, console: Console, location: str) -> None:
174
+ from rich.progress import Progress, RenderableColumn, SpinnerColumn, TextColumn
175
+ from rich.style import Style
176
+ from rich.text import Text
177
+
178
+ self.console = console
179
+ self.location = location
180
+ self.start_time = time.monotonic()
181
+ progress_message = Text.assemble(
182
+ ("Loading specification from ", Style(color="white")),
183
+ (location, Style(color="cyan")),
184
+ )
185
+ self.progress = Progress(
186
+ TextColumn(""),
187
+ SpinnerColumn("clock"),
188
+ RenderableColumn(progress_message),
189
+ console=console,
190
+ transient=True,
191
+ )
192
+ self.progress_task_id = None
193
+ self.is_interrupted = False
194
+
195
+ def start(self) -> None:
196
+ """Start loading progress display."""
197
+ self.progress_task_id = self.progress.add_task("Loading", total=None)
198
+ self.progress.start()
199
+
200
+ def stop(self) -> None:
201
+ """Stop loading progress display."""
202
+ assert self.progress_task_id is not None
203
+ self.progress.stop_task(self.progress_task_id)
204
+ self.progress.stop()
205
+
206
+ def interrupt(self) -> None:
207
+ """Handle interruption during loading."""
208
+ self.is_interrupted = True
209
+ self.stop()
210
+
211
+ def get_completion_message(self) -> Text:
212
+ """Generate completion message including duration."""
213
+ from rich.style import Style
214
+ from rich.text import Text
215
+
216
+ duration = format_duration(int((time.monotonic() - self.start_time) * 1000))
217
+ if self.is_interrupted:
218
+ return Text.assemble(
219
+ ("⚡ ", Style(color="yellow")),
220
+ (f"Loading interrupted after {duration} while loading from ", Style(color="white")),
221
+ (self.location, Style(color="cyan")),
222
+ )
223
+ return Text.assemble(
224
+ ("✅ ", Style(color="green")),
225
+ ("Loaded specification from ", Style(color="bright_white")),
226
+ (self.location, Style(color="cyan")),
227
+ (f" (in {duration})", Style(color="bright_white")),
228
+ )
229
+
230
+ def get_error_message(self, error: LoaderError) -> Group:
231
+ from rich.console import Group
232
+ from rich.style import Style
233
+ from rich.text import Text
234
+
235
+ duration = format_duration(int((time.monotonic() - self.start_time) * 1000))
236
+
237
+ # Show what was attempted
238
+ attempted = Text.assemble(
239
+ ("❌ ", Style(color="red")),
240
+ ("Failed to load specification from ", Style(color="white")),
241
+ (self.location, Style(color="cyan")),
242
+ (f" after {duration}", Style(color="white")),
243
+ )
244
+
245
+ # Show error details
246
+ error_title = Text("Schema Loading Error", style=Style(color="red", bold=True))
247
+ error_message = Text(error.message)
248
+
249
+ return Group(
250
+ attempted,
251
+ Text(),
252
+ error_title,
253
+ Text(),
254
+ error_message,
255
+ )
256
+
257
+
258
+ @dataclass
259
+ class ProbingProgressManager:
260
+ console: Console
261
+ start_time: float
262
+ progress: Progress
263
+ progress_task_id: TaskID | None
264
+ is_interrupted: bool
265
+
266
+ __slots__ = ("console", "start_time", "progress", "progress_task_id", "is_interrupted")
267
+
268
+ def __init__(self, console: Console) -> None:
269
+ from rich.progress import Progress, RenderableColumn, SpinnerColumn, TextColumn
270
+ from rich.text import Text
271
+
272
+ self.console = console
273
+ self.start_time = time.monotonic()
274
+ self.progress = Progress(
275
+ TextColumn(""),
276
+ SpinnerColumn("clock"),
277
+ RenderableColumn(Text("Probing API capabilities", style="bright_white")),
278
+ transient=True,
279
+ console=console,
280
+ )
281
+ self.progress_task_id = None
282
+ self.is_interrupted = False
283
+
284
+ def start(self) -> None:
285
+ """Start probing progress display."""
286
+ self.progress_task_id = self.progress.add_task("Probing", total=None)
287
+ self.progress.start()
288
+
289
+ def stop(self) -> None:
290
+ """Stop probing progress display."""
291
+ assert self.progress_task_id is not None
292
+ self.progress.stop_task(self.progress_task_id)
293
+ self.progress.stop()
294
+
295
+ def interrupt(self) -> None:
296
+ """Handle interruption during probing."""
297
+ self.is_interrupted = True
298
+ self.stop()
299
+
300
+ def get_completion_message(self) -> Text:
301
+ """Generate completion message including duration."""
302
+ from rich.style import Style
303
+ from rich.text import Text
304
+
305
+ duration = format_duration(int((time.monotonic() - self.start_time) * 1000))
306
+ if self.is_interrupted:
307
+ return Text.assemble(
308
+ ("⚡ ", Style(color="yellow")),
309
+ (f"API probing interrupted after {duration}", Style(color="white")),
310
+ )
311
+ return Text.assemble(
312
+ ("✅ ", Style(color="green")),
313
+ ("API capabilities:", Style(color="white")),
314
+ )
315
+
316
+
317
+ @dataclass
318
+ class WarningData:
319
+ missing_auth: dict[int, list[str]] = field(default_factory=dict)
320
+
321
+
322
+ @dataclass
323
+ class OperationProgress:
324
+ """Tracks individual operation progress."""
325
+
326
+ label: str
327
+ start_time: float
328
+ task_id: TaskID
329
+
330
+ __slots__ = ("label", "start_time", "task_id")
331
+
332
+
333
+ @dataclass
334
+ class UnitTestProgressManager:
335
+ """Manages progress display for unit tests."""
336
+
337
+ console: Console
338
+ title: str
339
+ current: int
340
+ total: int
341
+ start_time: float
342
+
343
+ # Progress components
344
+ title_progress: Progress
345
+ progress_bar: Progress
346
+ operations_progress: Progress
347
+ current_operations: dict[str, OperationProgress]
348
+ stats: dict[Status, int]
349
+ stats_progress: Progress
350
+ live: Live | None
351
+
352
+ # Task IDs
353
+ title_task_id: TaskID | None
354
+ progress_task_id: TaskID | None
355
+ stats_task_id: TaskID
356
+
357
+ is_interrupted: bool
358
+
359
+ __slots__ = (
360
+ "console",
361
+ "title",
362
+ "current",
363
+ "total",
364
+ "start_time",
365
+ "title_progress",
366
+ "progress_bar",
367
+ "operations_progress",
368
+ "current_operations",
369
+ "stats",
370
+ "stats_progress",
371
+ "live",
372
+ "title_task_id",
373
+ "progress_task_id",
374
+ "stats_task_id",
375
+ "is_interrupted",
376
+ )
377
+
378
+ def __init__(
379
+ self,
380
+ console: Console,
381
+ title: str,
382
+ total: int,
383
+ ) -> None:
384
+ from rich.progress import (
385
+ BarColumn,
386
+ Progress,
387
+ SpinnerColumn,
388
+ TextColumn,
389
+ TimeElapsedColumn,
390
+ )
391
+ from rich.style import Style
392
+
393
+ self.console = console
394
+ self.title = title
395
+ self.current = 0
396
+ self.total = total
397
+ self.start_time = time.monotonic()
398
+
399
+ # Initialize progress displays
400
+ self.title_progress = Progress(
401
+ TextColumn(""),
402
+ SpinnerColumn("clock"),
403
+ TextColumn("{task.description}", style=Style(color="white")),
404
+ console=self.console,
405
+ )
406
+ self.title_task_id = None
407
+
408
+ self.progress_bar = Progress(
409
+ TextColumn(" "),
410
+ TimeElapsedColumn(),
411
+ BarColumn(bar_width=None),
412
+ TextColumn("{task.percentage:.0f}% ({task.completed}/{task.total})"),
413
+ console=self.console,
414
+ )
415
+ self.progress_task_id = None
416
+
417
+ self.operations_progress = Progress(
418
+ TextColumn(" "),
419
+ SpinnerColumn("dots"),
420
+ TimeElapsedColumn(),
421
+ TextColumn(" {task.fields[label]}"),
422
+ console=self.console,
423
+ )
424
+
425
+ self.current_operations = {}
426
+
427
+ self.stats_progress = Progress(
428
+ TextColumn(" "),
429
+ TextColumn("{task.description}"),
430
+ console=self.console,
431
+ )
432
+ self.stats_task_id = self.stats_progress.add_task("")
433
+ self.stats = {
434
+ Status.SUCCESS: 0,
435
+ Status.FAILURE: 0,
436
+ Status.SKIP: 0,
437
+ Status.ERROR: 0,
438
+ }
439
+ self._update_stats_display()
440
+
441
+ self.live = None
442
+ self.is_interrupted = False
443
+
444
+ def _get_stats_message(self) -> str:
445
+ width = len(str(self.total))
446
+
447
+ parts = []
448
+ if self.stats[Status.SUCCESS]:
449
+ parts.append(f"✅ {self.stats[Status.SUCCESS]:{width}d} passed")
450
+ if self.stats[Status.FAILURE]:
451
+ parts.append(f"❌ {self.stats[Status.FAILURE]:{width}d} failed")
452
+ if self.stats[Status.ERROR]:
453
+ parts.append(f"🚫 {self.stats[Status.ERROR]:{width}d} errors")
454
+ if self.stats[Status.SKIP]:
455
+ parts.append(f"⏭️ {self.stats[Status.SKIP]:{width}d} skipped")
456
+ return " ".join(parts)
457
+
458
+ def _update_stats_display(self) -> None:
459
+ """Update the statistics display."""
460
+ self.stats_progress.update(self.stats_task_id, description=self._get_stats_message())
461
+
462
+ def start(self) -> None:
463
+ """Start progress display."""
464
+ from rich.console import Group
465
+ from rich.live import Live
466
+ from rich.text import Text
467
+
468
+ group = Group(
469
+ self.title_progress,
470
+ Text(),
471
+ self.progress_bar,
472
+ Text(),
473
+ self.operations_progress,
474
+ Text(),
475
+ self.stats_progress,
476
+ )
477
+
478
+ self.live = Live(group, refresh_per_second=10, console=self.console, transient=True)
479
+ self.live.start()
480
+
481
+ # Initialize both progress displays
482
+ self.title_task_id = self.title_progress.add_task(self.title, total=self.total)
483
+ self.progress_task_id = self.progress_bar.add_task(
484
+ "", # Empty description as it's shown in title
485
+ total=self.total,
486
+ )
487
+
488
+ def update_progress(self) -> None:
489
+ """Update progress in both displays."""
490
+ assert self.title_task_id is not None
491
+ assert self.progress_task_id is not None
492
+
493
+ self.current += 1
494
+ self.title_progress.update(self.title_task_id, completed=self.current)
495
+ self.progress_bar.update(self.progress_task_id, completed=self.current)
496
+
497
+ def start_operation(self, label: str) -> None:
498
+ """Start tracking new operation."""
499
+ task_id = self.operations_progress.add_task("", label=label, start_time=time.monotonic())
500
+ self.current_operations[label] = OperationProgress(label=label, start_time=time.monotonic(), task_id=task_id)
501
+
502
+ def finish_operation(self, label: str) -> None:
503
+ """Finish tracking operation."""
504
+ if operation := self.current_operations.pop(label, None):
505
+ if not self.current_operations:
506
+ assert self.title_task_id is not None
507
+ self.title_progress.update(self.title_task_id)
508
+ self.operations_progress.update(operation.task_id, visible=False)
509
+
510
+ def update_stats(self, status: Status) -> None:
511
+ """Update statistics for a finished scenario."""
512
+ self.stats[status] += 1
513
+ self._update_stats_display()
514
+
515
+ def interrupt(self) -> None:
516
+ self.is_interrupted = True
517
+ self.stats[Status.SKIP] += self.total - self.current
518
+ if self.live:
519
+ self.stop()
520
+
521
+ def stop(self) -> None:
522
+ """Stop all progress displays."""
523
+ if self.live:
524
+ self.live.stop()
525
+
526
+ def _get_status_icon(self, default_icon: str = "🕛") -> str:
527
+ if self.is_interrupted:
528
+ icon = "⚡"
529
+ elif self.stats[Status.ERROR] > 0:
530
+ icon = "🚫"
531
+ elif self.stats[Status.FAILURE] > 0:
532
+ icon = "❌"
533
+ elif self.stats[Status.SUCCESS] > 0:
534
+ icon = "✅"
535
+ elif self.stats[Status.SKIP] > 0:
536
+ icon = "⏭️"
537
+ else:
538
+ icon = default_icon
539
+ return icon
540
+
541
+ def get_completion_message(self, default_icon: str = "🕛") -> str:
542
+ """Complete the phase and return status message."""
543
+ duration = format_duration(int((time.monotonic() - self.start_time) * 1000))
544
+ icon = self._get_status_icon(default_icon)
545
+
546
+ message = self._get_stats_message() or "No tests were run"
547
+ if self.is_interrupted:
548
+ duration_message = f"interrupted after {duration}"
549
+ else:
550
+ duration_message = f"in {duration}"
551
+
552
+ return f"{icon} {self.title} ({duration_message})\n\n {message}"
553
+
554
+
555
+ @dataclass
556
+ class StatefulProgressManager:
557
+ """Manages progress display for stateful testing."""
558
+
559
+ console: Console
560
+ title: str
561
+ links_total: int
562
+ start_time: float
563
+
564
+ # Progress components
565
+ title_progress: Progress
566
+ progress_bar: Progress
567
+ stats_progress: Progress
568
+ live: Live | None
569
+
570
+ # Task IDs
571
+ title_task_id: TaskID | None
572
+ progress_task_id: TaskID | None
573
+ stats_task_id: TaskID
574
+
575
+ # State
576
+ scenarios: int
577
+ links_seen: set[str]
578
+ stats: dict[Status, int]
579
+ is_interrupted: bool
580
+
581
+ __slots__ = (
582
+ "console",
583
+ "title",
584
+ "links_total",
585
+ "start_time",
586
+ "title_progress",
587
+ "progress_bar",
588
+ "stats_progress",
589
+ "live",
590
+ "title_task_id",
591
+ "progress_task_id",
592
+ "stats_task_id",
593
+ "scenarios",
594
+ "links_seen",
595
+ "stats",
596
+ "is_interrupted",
597
+ )
598
+
599
+ def __init__(self, console: Console, title: str, links_total: int) -> None:
600
+ from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
601
+ from rich.style import Style
602
+
603
+ self.console = console
604
+ self.title = title
605
+ self.links_total = links_total
606
+ self.start_time = time.monotonic()
607
+
608
+ self.title_progress = Progress(
609
+ TextColumn(""),
610
+ SpinnerColumn("clock"),
611
+ TextColumn("{task.description}", style=Style(color="bright_white")),
612
+ console=self.console,
613
+ )
614
+ self.title_task_id = None
615
+
616
+ self.progress_bar = Progress(
617
+ TextColumn(" "),
618
+ TimeElapsedColumn(),
619
+ TextColumn("{task.fields[scenarios]:3d} scenarios • {task.fields[links]}"),
620
+ console=self.console,
621
+ )
622
+ self.progress_task_id = None
623
+
624
+ # Initialize stats progress
625
+ self.stats_progress = Progress(
626
+ TextColumn(" "),
627
+ TextColumn("{task.description}"),
628
+ console=self.console,
629
+ )
630
+ self.stats_task_id = self.stats_progress.add_task("")
631
+
632
+ self.live = None
633
+
634
+ # Initialize state
635
+ self.scenarios = 0
636
+ self.links_seen = set()
637
+ self.stats = {
638
+ Status.SUCCESS: 0,
639
+ Status.FAILURE: 0,
640
+ Status.ERROR: 0,
641
+ Status.SKIP: 0,
642
+ }
643
+ self.is_interrupted = False
644
+
645
+ def start(self) -> None:
646
+ """Start progress display."""
647
+ from rich.console import Group
648
+ from rich.live import Live
649
+ from rich.text import Text
650
+
651
+ # Initialize progress displays
652
+ self.title_task_id = self.title_progress.add_task("Stateful tests")
653
+ self.progress_task_id = self.progress_bar.add_task("", scenarios=0, links=f"0/{self.links_total} links")
654
+
655
+ # Create live display
656
+ group = Group(
657
+ self.title_progress,
658
+ Text(),
659
+ self.progress_bar,
660
+ Text(),
661
+ self.stats_progress,
662
+ )
663
+ self.live = Live(group, refresh_per_second=10, console=self.console, transient=True)
664
+ self.live.start()
665
+
666
+ def stop(self) -> None:
667
+ """Stop progress display."""
668
+ if self.live:
669
+ self.live.stop()
670
+
671
+ def update(self, links_seen: set[str], status: Status | None = None) -> None:
672
+ """Update progress and stats."""
673
+ self.scenarios += 1
674
+ self.links_seen.update(links_seen)
675
+
676
+ if status is not None:
677
+ self.stats[status] += 1
678
+
679
+ self._update_progress_display()
680
+ self._update_stats_display()
681
+
682
+ def _update_progress_display(self) -> None:
683
+ """Update the progress display."""
684
+ assert self.progress_task_id is not None
685
+ self.progress_bar.update(
686
+ self.progress_task_id,
687
+ scenarios=self.scenarios,
688
+ links=f"{len(self.links_seen)}/{self.links_total} links",
689
+ )
690
+
691
+ def _get_stats_message(self) -> str:
692
+ """Get formatted stats message."""
693
+ parts = []
694
+ if self.stats[Status.SUCCESS]:
695
+ parts.append(f"✅ {self.stats[Status.SUCCESS]} passed")
696
+ if self.stats[Status.FAILURE]:
697
+ parts.append(f"❌ {self.stats[Status.FAILURE]} failed")
698
+ if self.stats[Status.ERROR]:
699
+ parts.append(f"🚫 {self.stats[Status.ERROR]} errors")
700
+ if self.stats[Status.SKIP]:
701
+ parts.append(f"⏭️ {self.stats[Status.SKIP]} skipped")
702
+ return " ".join(parts)
703
+
704
+ def _update_stats_display(self) -> None:
705
+ """Update the statistics display."""
706
+ self.stats_progress.update(self.stats_task_id, description=self._get_stats_message())
707
+
708
+ def _get_status_icon(self, default_icon: str = "🕛") -> str:
709
+ if self.is_interrupted:
710
+ icon = "⚡"
711
+ elif self.stats[Status.ERROR] > 0:
712
+ icon = "🚫"
713
+ elif self.stats[Status.FAILURE] > 0:
714
+ icon = "❌"
715
+ elif self.stats[Status.SUCCESS] > 0:
716
+ icon = "✅"
717
+ elif self.stats[Status.SKIP] > 0:
718
+ icon = "⏭️"
719
+ else:
720
+ icon = default_icon
721
+ return icon
722
+
723
+ def interrupt(self) -> None:
724
+ """Handle interruption."""
725
+ self.is_interrupted = True
726
+ if self.live:
727
+ self.stop()
728
+
729
+ def get_completion_message(self, icon: str | None = None) -> tuple[str, str]:
730
+ """Complete the phase and return status message."""
731
+ duration = format_duration(int((time.monotonic() - self.start_time) * 1000))
732
+ icon = icon or self._get_status_icon()
733
+
734
+ message = self._get_stats_message() or "No tests were run"
735
+ if self.is_interrupted:
736
+ duration_message = f"interrupted after {duration}"
737
+ else:
738
+ duration_message = f"in {duration}"
739
+
740
+ return f"{icon} {self.title} ({duration_message})", message
741
+
742
+
743
+ def format_duration(duration_ms: int) -> str:
744
+ """Format duration in milliseconds to human readable string."""
745
+ parts = []
746
+
747
+ # Convert to components
748
+ ms = duration_ms % 1000
749
+ seconds = (duration_ms // 1000) % 60
750
+ minutes = (duration_ms // (1000 * 60)) % 60
751
+ hours = duration_ms // (1000 * 60 * 60)
752
+
753
+ # Add non-empty components
754
+ if hours > 0:
755
+ parts.append(f"{hours} h")
756
+ if minutes > 0:
757
+ parts.append(f"{minutes} m")
758
+ if seconds > 0:
759
+ parts.append(f"{seconds} s")
760
+ if ms > 0:
761
+ parts.append(f"{ms} ms")
762
+
763
+ # Handle zero duration
764
+ if not parts:
765
+ return "0 ms"
766
+
767
+ return " ".join(parts)
768
+
769
+
770
+ @dataclass
771
+ class OutputHandler(EventHandler):
772
+ workers_num: int
773
+ rate_limit: str | None
774
+ wait_for_schema: float | None
775
+
776
+ loading_manager: LoadingProgressManager | None = None
777
+ probing_manager: ProbingProgressManager | None = None
778
+ unit_tests_manager: UnitTestProgressManager | None = None
779
+ stateful_tests_manager: StatefulProgressManager | None = None
780
+
781
+ statistic: ApiStatistic | None = None
782
+ skip_reasons: list[str] = field(default_factory=list)
783
+ cassette_config: CassetteConfig | None = None
784
+ junit_xml_file: str | None = None
785
+ warnings: WarningData = field(default_factory=WarningData)
786
+ errors: list[events.NonFatalError] = field(default_factory=list)
787
+ phases: dict[PhaseName, tuple[Status, PhaseSkipReason | None]] = field(
788
+ default_factory=lambda: {phase: (Status.SKIP, None) for phase in PhaseName}
789
+ )
790
+ console: Console = field(default_factory=_default_console)
791
+
792
+ def handle_event(self, ctx: ExecutionContext, event: events.EngineEvent) -> None:
793
+ if isinstance(event, events.PhaseStarted):
794
+ self._on_phase_started(event)
795
+ elif isinstance(event, events.PhaseFinished):
796
+ self._on_phase_finished(event)
797
+ elif isinstance(event, events.ScenarioStarted):
798
+ self._on_scenario_started(event)
799
+ elif isinstance(event, events.ScenarioFinished):
800
+ self._on_scenario_finished(event)
801
+ if isinstance(event, events.EngineFinished):
802
+ self._on_engine_finished(ctx, event)
803
+ elif isinstance(event, events.Interrupted):
804
+ self._on_interrupted(event)
805
+ elif isinstance(event, events.FatalError):
806
+ self._on_fatal_error(ctx, event)
807
+ elif isinstance(event, events.NonFatalError):
808
+ self.errors.append(event)
809
+ elif isinstance(event, LoadingStarted):
810
+ self._on_loading_started(event)
811
+ elif isinstance(event, LoadingFinished):
812
+ self._on_loading_finished(ctx, event)
813
+
814
+ def start(self, ctx: ExecutionContext) -> None:
815
+ display_header(SCHEMATHESIS_VERSION)
816
+
817
+ def shutdown(self, ctx: ExecutionContext) -> None:
818
+ if self.unit_tests_manager is not None:
819
+ self.unit_tests_manager.stop()
820
+ if self.stateful_tests_manager is not None:
821
+ self.stateful_tests_manager.stop()
822
+ if self.loading_manager is not None:
823
+ self.loading_manager.stop()
824
+ if self.probing_manager is not None:
825
+ self.probing_manager.stop()
826
+
827
+ def _on_loading_started(self, event: LoadingStarted) -> None:
828
+ self.loading_manager = LoadingProgressManager(console=self.console, location=event.location)
829
+ self.loading_manager.start()
830
+
831
+ def _on_loading_finished(self, ctx: ExecutionContext, event: LoadingFinished) -> None:
832
+ from rich.padding import Padding
833
+ from rich.style import Style
834
+ from rich.table import Table
835
+
836
+ assert self.loading_manager is not None
837
+ self.loading_manager.stop()
838
+
839
+ message = Padding(
840
+ self.loading_manager.get_completion_message(),
841
+ BLOCK_PADDING,
842
+ )
843
+ self.console.print(message)
844
+ self.console.print()
845
+ self.loading_manager = None
846
+ self.statistic = event.statistic
847
+
848
+ table = Table(
849
+ show_header=False,
850
+ box=None,
851
+ padding=(0, 4),
852
+ collapse_padding=True,
853
+ )
854
+ table.add_column("Field", style=Style(color="bright_white", bold=True))
855
+ table.add_column("Value", style="cyan")
856
+
857
+ table.add_row("Base URL:", event.base_url)
858
+ table.add_row("Specification:", event.specification.name)
859
+ table.add_row("Operations:", str(event.statistic.operations.total))
860
+
861
+ message = Padding(table, BLOCK_PADDING)
862
+ self.console.print(message)
863
+ self.console.print()
864
+
865
+ if ctx.initialization_lines:
866
+ _print_lines(ctx.initialization_lines)
867
+
868
+ def _on_phase_started(self, event: events.PhaseStarted) -> None:
869
+ phase = event.phase
870
+ if phase.name == PhaseName.PROBING and phase.is_enabled:
871
+ self._start_probing()
872
+ elif phase.name == PhaseName.UNIT_TESTING and phase.is_enabled:
873
+ self._start_unit_tests()
874
+ elif phase.name == PhaseName.STATEFUL_TESTING and phase.is_enabled and phase.skip_reason is None:
875
+ self._start_stateful_tests()
876
+
877
+ def _start_probing(self) -> None:
878
+ self.probing_manager = ProbingProgressManager(console=self.console)
879
+ self.probing_manager.start()
880
+
881
+ def _start_unit_tests(self) -> None:
882
+ assert self.statistic is not None
883
+ self.unit_tests_manager = UnitTestProgressManager(
884
+ console=self.console,
885
+ title="Unit tests",
886
+ total=self.statistic.operations.total,
887
+ )
888
+ self.unit_tests_manager.start()
889
+
890
+ def _start_stateful_tests(self) -> None:
891
+ assert self.statistic is not None
892
+ self.stateful_tests_manager = StatefulProgressManager(
893
+ console=self.console,
894
+ title="Stateful tests",
895
+ links_total=self.statistic.links.total,
896
+ )
897
+ self.stateful_tests_manager.start()
898
+
899
+ def _on_phase_finished(self, event: events.PhaseFinished) -> None:
900
+ from rich.padding import Padding
901
+ from rich.style import Style
902
+ from rich.table import Table
903
+ from rich.text import Text
904
+
905
+ phase = event.phase
906
+ self.phases[phase.name] = (event.status, phase.skip_reason)
907
+
908
+ if phase.name == PhaseName.PROBING:
909
+ assert self.probing_manager is not None
910
+ self.probing_manager.stop()
911
+ self.probing_manager = None
912
+
913
+ if event.status == Status.SUCCESS:
914
+ assert isinstance(event.payload, Ok)
915
+ payload = event.payload.ok()
916
+ self.console.print(
917
+ Padding(
918
+ Text.assemble(
919
+ ("✅ ", Style(color="green")),
920
+ ("API capabilities:", Style(color="bright_white")),
921
+ ),
922
+ BLOCK_PADDING,
923
+ )
924
+ )
925
+ self.console.print()
926
+
927
+ table = Table(
928
+ show_header=False,
929
+ box=None,
930
+ padding=(0, 4),
931
+ collapse_padding=True,
932
+ )
933
+ table.add_column("Capability", style=Style(color="bright_white", bold=True))
934
+ table.add_column("Status", style="cyan")
935
+ for probe_run in payload.probes:
936
+ icon, style = {
937
+ ProbeOutcome.SUCCESS: ("✓", Style(color="green")),
938
+ ProbeOutcome.FAILURE: ("✘", Style(color="red")),
939
+ ProbeOutcome.SKIP: ("⊘", Style(color="yellow")),
940
+ ProbeOutcome.ERROR: ("⚠", Style(color="yellow")),
941
+ }[probe_run.outcome]
942
+
943
+ table.add_row(f"{probe_run.probe.name}:", Text(icon, style=style))
944
+
945
+ message = Padding(table, BLOCK_PADDING)
946
+ elif event.status == Status.SKIP:
947
+ message = Padding(
948
+ Text.assemble(
949
+ ("⏭️ ", ""),
950
+ ("API probing skipped", Style(color="yellow")),
951
+ ),
952
+ BLOCK_PADDING,
953
+ )
954
+ else:
955
+ assert event.status == Status.ERROR
956
+ assert isinstance(event.payload, Err)
957
+ error = EngineErrorInfo(event.payload.err())
958
+ message = Padding(
959
+ Text.assemble(
960
+ ("🚫 ", ""),
961
+ (f"API probing failed: {error.message}", Style(color="red")),
962
+ ),
963
+ BLOCK_PADDING,
964
+ )
965
+ self.console.print(message)
966
+ self.console.print()
967
+ elif phase.name == PhaseName.STATEFUL_TESTING and phase.is_enabled:
968
+ assert self.stateful_tests_manager is not None
969
+ self.stateful_tests_manager.stop()
970
+ if event.status == Status.ERROR:
971
+ title, summary = self.stateful_tests_manager.get_completion_message("🚫")
972
+ else:
973
+ title, summary = self.stateful_tests_manager.get_completion_message()
974
+
975
+ self.console.print(Padding(Text(title, style="bright_white"), BLOCK_PADDING))
976
+
977
+ table = Table(
978
+ show_header=False,
979
+ box=None,
980
+ padding=(0, 4),
981
+ collapse_padding=True,
982
+ )
983
+ table.add_column("Field", style=Style(color="bright_white", bold=True))
984
+ table.add_column("Value", style="cyan")
985
+ table.add_row("Scenarios:", f"{self.stateful_tests_manager.scenarios}")
986
+ table.add_row(
987
+ "API Links:", f"{len(self.stateful_tests_manager.links_seen)}/{self.stateful_tests_manager.links_total}"
988
+ )
989
+
990
+ self.console.print()
991
+ self.console.print(Padding(table, BLOCK_PADDING))
992
+ self.console.print()
993
+ self.console.print(Padding(Text(summary, style="bright_white"), (0, 0, 0, 5)))
994
+ self.console.print()
995
+ self.stateful_tests_manager = None
996
+ elif phase.name == PhaseName.UNIT_TESTING and phase.is_enabled:
997
+ assert self.unit_tests_manager is not None
998
+ self.unit_tests_manager.stop()
999
+ if event.status == Status.ERROR:
1000
+ message = self.unit_tests_manager.get_completion_message("🚫")
1001
+ else:
1002
+ message = self.unit_tests_manager.get_completion_message()
1003
+ self.console.print(Padding(Text(message, style="white"), BLOCK_PADDING))
1004
+ if event.status != Status.INTERRUPTED:
1005
+ self.console.print()
1006
+ self.unit_tests_manager = None
1007
+
1008
+ def _on_scenario_started(self, event: events.ScenarioStarted) -> None:
1009
+ if event.phase == PhaseName.UNIT_TESTING:
1010
+ # We should display execution result + percentage in the end. For example:
1011
+ assert event.label is not None
1012
+ assert self.unit_tests_manager is not None
1013
+ self.unit_tests_manager.start_operation(event.label)
1014
+
1015
+ def _on_scenario_finished(self, event: events.ScenarioFinished) -> None:
1016
+ if event.phase == PhaseName.UNIT_TESTING:
1017
+ assert self.unit_tests_manager is not None
1018
+ if event.label:
1019
+ self.unit_tests_manager.finish_operation(event.label)
1020
+ self.unit_tests_manager.update_progress()
1021
+ self.unit_tests_manager.update_stats(event.status)
1022
+ if event.status == Status.SKIP and event.skip_reason is not None:
1023
+ self.skip_reasons.append(event.skip_reason)
1024
+ self._check_warnings(event)
1025
+ elif (
1026
+ event.phase == PhaseName.STATEFUL_TESTING
1027
+ and not event.is_final
1028
+ and event.status not in (Status.INTERRUPTED, Status.SKIP, None)
1029
+ ):
1030
+ assert self.stateful_tests_manager is not None
1031
+ links_seen = {case.transition.id for case in event.recorder.cases.values() if case.transition is not None}
1032
+ self.stateful_tests_manager.update(links_seen, event.status)
1033
+
1034
+ def _check_warnings(self, event: events.ScenarioFinished) -> None:
1035
+ for status_code in (401, 403):
1036
+ if has_too_many_responses_with_status(event.recorder.interactions.values(), status_code):
1037
+ self.warnings.missing_auth.setdefault(status_code, []).append(event.recorder.label)
1038
+
1039
+ def _on_interrupted(self, event: events.Interrupted) -> None:
1040
+ from rich.padding import Padding
1041
+
1042
+ if self.unit_tests_manager is not None:
1043
+ self.unit_tests_manager.interrupt()
1044
+ elif self.stateful_tests_manager is not None:
1045
+ self.stateful_tests_manager.interrupt()
1046
+ elif self.loading_manager is not None:
1047
+ self.loading_manager.interrupt()
1048
+ message = Padding(
1049
+ self.loading_manager.get_completion_message(),
1050
+ BLOCK_PADDING,
1051
+ )
1052
+ self.console.print(message)
1053
+ self.console.print()
1054
+ elif self.probing_manager is not None:
1055
+ self.probing_manager.interrupt()
1056
+ message = Padding(
1057
+ self.probing_manager.get_completion_message(),
1058
+ BLOCK_PADDING,
1059
+ )
1060
+ self.console.print(message)
1061
+ self.console.print()
1062
+
1063
+ def _on_fatal_error(self, ctx: ExecutionContext, event: events.FatalError) -> None:
1064
+ from rich.padding import Padding
1065
+ from rich.text import Text
1066
+
1067
+ self.shutdown(ctx)
1068
+
1069
+ if isinstance(event.exception, LoaderError):
1070
+ assert self.loading_manager is not None
1071
+ message = Padding(self.loading_manager.get_error_message(event.exception), BLOCK_PADDING)
1072
+ self.console.print(message)
1073
+ self.console.print()
1074
+ self.loading_manager = None
1075
+
1076
+ if event.exception.extras:
1077
+ for extra in event.exception.extras:
1078
+ self.console.print(Padding(Text(extra), (0, 0, 0, 5)))
1079
+ self.console.print()
1080
+
1081
+ if not (event.exception.kind == LoaderErrorKind.CONNECTION_OTHER and self.wait_for_schema is not None):
1082
+ suggestion = LOADER_ERROR_SUGGESTIONS.get(event.exception.kind)
1083
+ if suggestion is not None:
1084
+ click.secho(f"{click.style('Tip:', bold=True, fg='green')} {suggestion}")
1085
+
1086
+ raise click.Abort
1087
+ title = "Test Execution Error"
1088
+ message = DEFAULT_INTERNAL_ERROR_MESSAGE
1089
+ traceback = format_exception(event.exception, with_traceback=True)
1090
+ extras = split_traceback(traceback)
1091
+ suggestion = f"Please consider reporting the traceback above to our issue tracker:\n\n {ISSUE_TRACKER_URL}."
1092
+ click.secho(title, fg="red", bold=True)
1093
+ click.echo()
1094
+ click.secho(message)
1095
+ _display_extras(extras)
1096
+ if not (
1097
+ isinstance(event.exception, LoaderError)
1098
+ and event.exception.kind == LoaderErrorKind.CONNECTION_OTHER
1099
+ and self.wait_for_schema is not None
1100
+ ):
1101
+ click.secho(f"\n{click.style('Tip:', bold=True, fg='green')} {suggestion}")
1102
+
1103
+ raise click.Abort
1104
+
1105
+ def display_warnings(self) -> None:
1106
+ display_section_name("WARNINGS")
1107
+ total = sum(len(endpoints) for endpoints in self.warnings.missing_auth.values())
1108
+ suffix = "" if total == 1 else "s"
1109
+ click.secho(
1110
+ f"\nMissing or invalid API credentials: {total} API operation{suffix} returned authentication errors\n",
1111
+ fg="yellow",
1112
+ )
1113
+
1114
+ for status_code, operations in self.warnings.missing_auth.items():
1115
+ status_text = "Unauthorized" if status_code == 401 else "Forbidden"
1116
+ count = len(operations)
1117
+ suffix = "" if count == 1 else "s"
1118
+ click.secho(
1119
+ f"{status_code} {status_text} ({count} operation{suffix}):",
1120
+ fg="yellow",
1121
+ )
1122
+ # Show first few API operations
1123
+ for endpoint in operations[:3]:
1124
+ click.secho(f" • {endpoint}", fg="yellow")
1125
+ if len(operations) > 3:
1126
+ click.secho(f" + {len(operations) - 3} more", fg="yellow")
1127
+ click.echo()
1128
+ click.secho("Tip: ", bold=True, fg="yellow", nl=False)
1129
+ click.secho(f"Use {bold('--auth')} ", fg="yellow", nl=False)
1130
+ click.secho(f"or {bold('-H')} ", fg="yellow", nl=False)
1131
+ click.secho("to provide authentication credentials", fg="yellow")
1132
+ click.echo()
1133
+
1134
+ def display_experiments(self) -> None:
1135
+ display_section_name("EXPERIMENTS")
1136
+
1137
+ click.echo()
1138
+ for experiment in sorted(GLOBAL_EXPERIMENTS.enabled, key=lambda e: e.name):
1139
+ click.secho(f"🧪 {experiment.name}: ", bold=True, nl=False)
1140
+ click.secho(experiment.description)
1141
+ click.secho(f" Feedback: {experiment.discussion_url}")
1142
+ click.echo()
1143
+
1144
+ click.secho(
1145
+ "Your feedback is crucial for experimental features. "
1146
+ "Please visit the provided URL(s) to share your thoughts.",
1147
+ dim=True,
1148
+ )
1149
+ click.echo()
1150
+
1151
+ def display_api_operations(self, ctx: ExecutionContext) -> None:
1152
+ assert self.statistic is not None
1153
+ click.secho("API Operations:", bold=True)
1154
+ click.secho(
1155
+ f" Selected: {click.style(str(self.statistic.operations.selected), bold=True)}/"
1156
+ f"{click.style(str(self.statistic.operations.total), bold=True)}"
1157
+ )
1158
+ click.secho(f" Tested: {click.style(str(len(ctx.statistic.tested_operations)), bold=True)}")
1159
+ errors = len(
1160
+ {
1161
+ err.label
1162
+ for err in self.errors
1163
+ # Some API operations may have some tests before they have an error
1164
+ if err.phase == PhaseName.UNIT_TESTING
1165
+ and err.label not in ctx.statistic.tested_operations
1166
+ and err.related_to_operation
1167
+ }
1168
+ )
1169
+ if errors:
1170
+ click.secho(f" Errored: {click.style(str(errors), bold=True)}")
1171
+
1172
+ # API operations that are skipped due to fail-fast are counted here as well
1173
+ total_skips = self.statistic.operations.selected - len(ctx.statistic.tested_operations) - errors
1174
+ if total_skips:
1175
+ click.secho(f" Skipped: {click.style(str(total_skips), bold=True)}")
1176
+ for reason in sorted(set(self.skip_reasons)):
1177
+ click.secho(f" - {reason.rstrip('.')}")
1178
+ click.echo()
1179
+
1180
+ def display_phases(self) -> None:
1181
+ click.secho("Test Phases:", bold=True)
1182
+
1183
+ for phase in PhaseName:
1184
+ status, skip_reason = self.phases[phase]
1185
+
1186
+ if status == Status.SKIP:
1187
+ click.secho(f" ⏭️ {phase.value}", fg="yellow", nl=False)
1188
+ if skip_reason:
1189
+ click.secho(f" ({skip_reason.value})", fg="yellow")
1190
+ else:
1191
+ click.echo()
1192
+ elif status == Status.SUCCESS:
1193
+ click.secho(f" ✅ {phase.value}", fg="green")
1194
+ elif status == Status.FAILURE:
1195
+ click.secho(f" ❌ {phase.value}", fg="red")
1196
+ elif status == Status.ERROR:
1197
+ click.secho(f" 🚫 {phase.value}", fg="red")
1198
+ elif status == Status.INTERRUPTED:
1199
+ click.secho(f" ⚡ {phase.value}", fg="yellow")
1200
+ click.echo()
1201
+
1202
+ def display_test_cases(self, ctx: ExecutionContext) -> None:
1203
+ if ctx.statistic.total_cases == 0:
1204
+ click.secho("Test cases:", bold=True)
1205
+ click.secho(" No test cases were generated\n")
1206
+ return
1207
+
1208
+ unique_failures = sum(
1209
+ len(group.failures) for grouped in ctx.statistic.failures.values() for group in grouped.values()
1210
+ )
1211
+ click.secho("Test cases:", bold=True)
1212
+
1213
+ parts = [f" {click.style(str(ctx.statistic.total_cases), bold=True)} generated"]
1214
+
1215
+ # Don't show pass/fail status if all cases were skipped
1216
+ if ctx.statistic.cases_without_checks == ctx.statistic.total_cases:
1217
+ parts.append(f"{click.style(str(ctx.statistic.cases_without_checks), bold=True)} skipped")
1218
+ else:
1219
+ if unique_failures > 0:
1220
+ parts.append(
1221
+ f"{click.style(str(ctx.statistic.cases_with_failures), bold=True)} found "
1222
+ f"{click.style(str(unique_failures), bold=True)} unique failures"
1223
+ )
1224
+ else:
1225
+ parts.append(f"{click.style(str(ctx.statistic.total_cases), bold=True)} passed")
1226
+
1227
+ if ctx.statistic.cases_without_checks > 0:
1228
+ parts.append(f"{click.style(str(ctx.statistic.cases_without_checks), bold=True)} skipped")
1229
+
1230
+ click.secho(", ".join(parts) + "\n")
1231
+
1232
+ def display_failures_summary(self, ctx: ExecutionContext) -> None:
1233
+ # Collect all unique failures and their counts by title
1234
+ failure_counts: dict[str, tuple[Severity, int]] = {}
1235
+ for grouped in ctx.statistic.failures.values():
1236
+ for group in grouped.values():
1237
+ for failure in group.failures:
1238
+ data = failure_counts.get(failure.title, (failure.severity, 0))
1239
+ failure_counts[failure.title] = (failure.severity, data[1] + 1)
1240
+
1241
+ click.secho("Failures:", bold=True)
1242
+
1243
+ # Sort by severity first, then by title
1244
+ sorted_failures = sorted(failure_counts.items(), key=lambda x: (x[1][0], x[0]))
1245
+
1246
+ for title, (_, count) in sorted_failures:
1247
+ click.secho(f" ❌ {title}: ", nl=False)
1248
+ click.secho(str(count), bold=True)
1249
+ click.echo()
1250
+
1251
+ def display_errors_summary(self) -> None:
1252
+ # Group errors by title and count occurrences
1253
+ error_counts: dict[str, int] = {}
1254
+ for error in self.errors:
1255
+ title = error.info.title
1256
+ error_counts[title] = error_counts.get(title, 0) + 1
1257
+
1258
+ click.secho("Errors:", bold=True)
1259
+
1260
+ for title in sorted(error_counts):
1261
+ click.secho(f" 🚫 {title}: ", nl=False)
1262
+ click.secho(str(error_counts[title]), bold=True)
1263
+ click.echo()
1264
+
1265
+ def display_final_line(self, ctx: ExecutionContext, event: events.EngineFinished) -> None:
1266
+ parts = []
1267
+
1268
+ unique_failures = sum(
1269
+ len(group.failures) for grouped in ctx.statistic.failures.values() for group in grouped.values()
1270
+ )
1271
+ if unique_failures:
1272
+ parts.append(f"{unique_failures} failures")
1273
+
1274
+ if self.errors:
1275
+ parts.append(f"{len(self.errors)} errors")
1276
+
1277
+ total_warnings = sum(len(endpoints) for endpoints in self.warnings.missing_auth.values())
1278
+ if total_warnings:
1279
+ parts.append(f"{total_warnings} warnings")
1280
+
1281
+ if parts:
1282
+ message = f"{', '.join(parts)} in {event.running_time:.2f}s"
1283
+ color = "red" if (unique_failures or self.errors) else "yellow"
1284
+ elif ctx.statistic.total_cases == 0:
1285
+ message = "Empty test suite"
1286
+ color = "yellow"
1287
+ else:
1288
+ message = f"No issues found in {event.running_time:.2f}s"
1289
+ color = "green"
1290
+
1291
+ display_section_name(message, fg=color)
1292
+
1293
+ def display_reports(self) -> None:
1294
+ reports = []
1295
+ if self.cassette_config is not None:
1296
+ format_name = self.cassette_config.format.name.upper()
1297
+ reports.append((format_name, self.cassette_config.path.name))
1298
+ if self.junit_xml_file is not None:
1299
+ reports.append(("JUnit XML", self.junit_xml_file))
1300
+
1301
+ if reports:
1302
+ click.secho("Reports:", bold=True)
1303
+ for report_type, path in reports:
1304
+ click.secho(f" • {report_type}: {path}")
1305
+ click.echo()
1306
+
1307
+ def _on_engine_finished(self, ctx: ExecutionContext, event: events.EngineFinished) -> None:
1308
+ if self.errors:
1309
+ display_section_name("ERRORS")
1310
+ errors = sorted(self.errors, key=lambda r: (r.phase.value, r.label))
1311
+ for error in errors:
1312
+ display_section_name(error.label, "_", fg="red")
1313
+ click.echo(error.info.format(bold=lambda x: click.style(x, bold=True)))
1314
+ click.secho(
1315
+ f"\nNeed more help?\n Join our Discord server: {DISCORD_LINK}",
1316
+ fg="red",
1317
+ )
1318
+ display_failures(ctx)
1319
+ if self.warnings.missing_auth:
1320
+ self.display_warnings()
1321
+ if GLOBAL_EXPERIMENTS.enabled:
1322
+ self.display_experiments()
1323
+ display_section_name("SUMMARY")
1324
+ click.echo()
1325
+
1326
+ if self.statistic:
1327
+ self.display_api_operations(ctx)
1328
+
1329
+ self.display_phases()
1330
+
1331
+ if ctx.statistic.failures:
1332
+ self.display_failures_summary(ctx)
1333
+
1334
+ if self.errors:
1335
+ self.display_errors_summary()
1336
+
1337
+ if self.warnings.missing_auth:
1338
+ affected = sum(len(operations) for operations in self.warnings.missing_auth.values())
1339
+ click.secho("Warnings:", bold=True)
1340
+ click.secho(f" ⚠️ Missing authentication: {bold(str(affected))}", fg="yellow")
1341
+ click.echo()
1342
+
1343
+ if ctx.summary_lines:
1344
+ _print_lines(ctx.summary_lines)
1345
+ click.echo()
1346
+
1347
+ self.display_test_cases(ctx)
1348
+ self.display_reports()
1349
+ self.display_final_line(ctx, event)
1350
+
1351
+
1352
+ TOO_MANY_RESPONSES_WARNING_TEMPLATE = (
1353
+ "Most of the responses from {} have a {} status code. Did you specify proper API credentials?"
1354
+ )
1355
+ TOO_MANY_RESPONSES_THRESHOLD = 0.9
1356
+
1357
+
1358
+ def has_too_many_responses_with_status(interactions: Iterable[Interaction], status_code: int) -> bool:
1359
+ matched = 0
1360
+ total = 0
1361
+ for interaction in interactions:
1362
+ if interaction.response is not None:
1363
+ if interaction.response.status_code == status_code:
1364
+ matched += 1
1365
+ total += 1
1366
+ if not total:
1367
+ return False
1368
+ return matched / total >= TOO_MANY_RESPONSES_THRESHOLD