schemathesis 3.25.5__py3-none-any.whl → 3.39.7__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 (146) hide show
  1. schemathesis/__init__.py +6 -6
  2. schemathesis/_compat.py +2 -2
  3. schemathesis/_dependency_versions.py +4 -2
  4. schemathesis/_hypothesis.py +369 -56
  5. schemathesis/_lazy_import.py +1 -0
  6. schemathesis/_override.py +5 -4
  7. schemathesis/_patches.py +21 -0
  8. schemathesis/_rate_limiter.py +7 -0
  9. schemathesis/_xml.py +75 -22
  10. schemathesis/auths.py +78 -16
  11. schemathesis/checks.py +21 -9
  12. schemathesis/cli/__init__.py +793 -448
  13. schemathesis/cli/__main__.py +4 -0
  14. schemathesis/cli/callbacks.py +58 -13
  15. schemathesis/cli/cassettes.py +233 -47
  16. schemathesis/cli/constants.py +8 -2
  17. schemathesis/cli/context.py +24 -4
  18. schemathesis/cli/debug.py +2 -1
  19. schemathesis/cli/handlers.py +4 -1
  20. schemathesis/cli/junitxml.py +103 -22
  21. schemathesis/cli/options.py +15 -4
  22. schemathesis/cli/output/default.py +286 -115
  23. schemathesis/cli/output/short.py +25 -6
  24. schemathesis/cli/reporting.py +79 -0
  25. schemathesis/cli/sanitization.py +6 -0
  26. schemathesis/code_samples.py +5 -3
  27. schemathesis/constants.py +1 -0
  28. schemathesis/contrib/openapi/__init__.py +1 -1
  29. schemathesis/contrib/openapi/fill_missing_examples.py +3 -1
  30. schemathesis/contrib/openapi/formats/uuid.py +2 -1
  31. schemathesis/contrib/unique_data.py +3 -3
  32. schemathesis/exceptions.py +76 -65
  33. schemathesis/experimental/__init__.py +35 -0
  34. schemathesis/extra/_aiohttp.py +1 -0
  35. schemathesis/extra/_flask.py +4 -1
  36. schemathesis/extra/_server.py +1 -0
  37. schemathesis/extra/pytest_plugin.py +17 -25
  38. schemathesis/failures.py +77 -9
  39. schemathesis/filters.py +185 -8
  40. schemathesis/fixups/__init__.py +1 -0
  41. schemathesis/fixups/fast_api.py +2 -2
  42. schemathesis/fixups/utf8_bom.py +1 -2
  43. schemathesis/generation/__init__.py +20 -36
  44. schemathesis/generation/_hypothesis.py +59 -0
  45. schemathesis/generation/_methods.py +44 -0
  46. schemathesis/generation/coverage.py +931 -0
  47. schemathesis/graphql.py +0 -1
  48. schemathesis/hooks.py +89 -12
  49. schemathesis/internal/checks.py +84 -0
  50. schemathesis/internal/copy.py +22 -3
  51. schemathesis/internal/deprecation.py +6 -2
  52. schemathesis/internal/diff.py +15 -0
  53. schemathesis/internal/extensions.py +27 -0
  54. schemathesis/internal/jsonschema.py +2 -1
  55. schemathesis/internal/output.py +68 -0
  56. schemathesis/internal/result.py +1 -1
  57. schemathesis/internal/transformation.py +11 -0
  58. schemathesis/lazy.py +138 -25
  59. schemathesis/loaders.py +7 -5
  60. schemathesis/models.py +323 -213
  61. schemathesis/parameters.py +4 -0
  62. schemathesis/runner/__init__.py +72 -22
  63. schemathesis/runner/events.py +86 -6
  64. schemathesis/runner/impl/context.py +104 -0
  65. schemathesis/runner/impl/core.py +447 -187
  66. schemathesis/runner/impl/solo.py +19 -29
  67. schemathesis/runner/impl/threadpool.py +70 -79
  68. schemathesis/{cli → runner}/probes.py +37 -25
  69. schemathesis/runner/serialization.py +150 -17
  70. schemathesis/sanitization.py +5 -1
  71. schemathesis/schemas.py +170 -102
  72. schemathesis/serializers.py +17 -4
  73. schemathesis/service/ci.py +1 -0
  74. schemathesis/service/client.py +39 -6
  75. schemathesis/service/events.py +5 -1
  76. schemathesis/service/extensions.py +224 -0
  77. schemathesis/service/hosts.py +6 -2
  78. schemathesis/service/metadata.py +25 -0
  79. schemathesis/service/models.py +211 -2
  80. schemathesis/service/report.py +6 -6
  81. schemathesis/service/serialization.py +60 -71
  82. schemathesis/service/usage.py +1 -0
  83. schemathesis/specs/graphql/_cache.py +26 -0
  84. schemathesis/specs/graphql/loaders.py +25 -5
  85. schemathesis/specs/graphql/nodes.py +1 -0
  86. schemathesis/specs/graphql/scalars.py +2 -2
  87. schemathesis/specs/graphql/schemas.py +130 -100
  88. schemathesis/specs/graphql/validation.py +1 -2
  89. schemathesis/specs/openapi/__init__.py +1 -0
  90. schemathesis/specs/openapi/_cache.py +123 -0
  91. schemathesis/specs/openapi/_hypothesis.py +79 -61
  92. schemathesis/specs/openapi/checks.py +504 -25
  93. schemathesis/specs/openapi/converter.py +31 -4
  94. schemathesis/specs/openapi/definitions.py +10 -17
  95. schemathesis/specs/openapi/examples.py +143 -31
  96. schemathesis/specs/openapi/expressions/__init__.py +37 -2
  97. schemathesis/specs/openapi/expressions/context.py +1 -1
  98. schemathesis/specs/openapi/expressions/extractors.py +26 -0
  99. schemathesis/specs/openapi/expressions/lexer.py +20 -18
  100. schemathesis/specs/openapi/expressions/nodes.py +29 -6
  101. schemathesis/specs/openapi/expressions/parser.py +26 -5
  102. schemathesis/specs/openapi/formats.py +44 -0
  103. schemathesis/specs/openapi/links.py +125 -42
  104. schemathesis/specs/openapi/loaders.py +77 -36
  105. schemathesis/specs/openapi/media_types.py +34 -0
  106. schemathesis/specs/openapi/negative/__init__.py +6 -3
  107. schemathesis/specs/openapi/negative/mutations.py +21 -6
  108. schemathesis/specs/openapi/parameters.py +39 -25
  109. schemathesis/specs/openapi/patterns.py +137 -0
  110. schemathesis/specs/openapi/references.py +37 -7
  111. schemathesis/specs/openapi/schemas.py +368 -242
  112. schemathesis/specs/openapi/security.py +25 -7
  113. schemathesis/specs/openapi/serialization.py +1 -0
  114. schemathesis/specs/openapi/stateful/__init__.py +198 -70
  115. schemathesis/specs/openapi/stateful/statistic.py +198 -0
  116. schemathesis/specs/openapi/stateful/types.py +14 -0
  117. schemathesis/specs/openapi/utils.py +6 -1
  118. schemathesis/specs/openapi/validation.py +1 -0
  119. schemathesis/stateful/__init__.py +35 -21
  120. schemathesis/stateful/config.py +97 -0
  121. schemathesis/stateful/context.py +135 -0
  122. schemathesis/stateful/events.py +274 -0
  123. schemathesis/stateful/runner.py +309 -0
  124. schemathesis/stateful/sink.py +68 -0
  125. schemathesis/stateful/state_machine.py +67 -38
  126. schemathesis/stateful/statistic.py +22 -0
  127. schemathesis/stateful/validation.py +100 -0
  128. schemathesis/targets.py +33 -1
  129. schemathesis/throttling.py +25 -5
  130. schemathesis/transports/__init__.py +354 -0
  131. schemathesis/transports/asgi.py +7 -0
  132. schemathesis/transports/auth.py +25 -2
  133. schemathesis/transports/content_types.py +3 -1
  134. schemathesis/transports/headers.py +2 -1
  135. schemathesis/transports/responses.py +9 -4
  136. schemathesis/types.py +9 -0
  137. schemathesis/utils.py +11 -16
  138. schemathesis-3.39.7.dist-info/METADATA +293 -0
  139. schemathesis-3.39.7.dist-info/RECORD +160 -0
  140. {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/WHEEL +1 -1
  141. schemathesis/specs/openapi/filters.py +0 -49
  142. schemathesis/specs/openapi/stateful/links.py +0 -92
  143. schemathesis-3.25.5.dist-info/METADATA +0 -356
  144. schemathesis-3.25.5.dist-info/RECORD +0 -134
  145. {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/entry_points.txt +0 -0
  146. {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/licenses/LICENSE +0 -0
@@ -1,39 +1,54 @@
1
1
  from __future__ import annotations
2
- import base64
2
+
3
3
  import os
4
4
  import platform
5
5
  import shutil
6
6
  import textwrap
7
7
  import time
8
- from itertools import groupby
9
- from queue import Queue
10
- from typing import Any, Generator, cast
8
+ from importlib import metadata
9
+ from types import GeneratorType
10
+ from typing import TYPE_CHECKING, Any, Generator, Literal, cast
11
11
 
12
12
  import click
13
- from importlib import metadata
14
13
 
15
- from ... import service
16
- from ...code_samples import CodeSampleStyle
14
+ from ... import experimental, service
17
15
  from ...constants import (
18
16
  DISCORD_LINK,
17
+ FALSE_VALUES,
19
18
  FLAKY_FAILURE_MESSAGE,
19
+ GITHUB_APP_LINK,
20
+ ISSUE_TRACKER_URL,
20
21
  REPORT_SUGGESTION_ENV_VAR,
21
22
  SCHEMATHESIS_TEST_CASE_HEADER,
22
23
  SCHEMATHESIS_VERSION,
23
- FALSE_VALUES,
24
- ISSUE_TRACKER_URL,
25
- GITHUB_APP_LINK,
26
24
  )
27
- from ...exceptions import RuntimeErrorType, prepare_response_payload
25
+ from ...exceptions import (
26
+ RuntimeErrorType,
27
+ extract_requests_exception_details,
28
+ format_exception,
29
+ )
28
30
  from ...experimental import GLOBAL_EXPERIMENTS
31
+ from ...internal.output import prepare_response_payload
32
+ from ...internal.result import Ok
29
33
  from ...models import Status
30
34
  from ...runner import events
31
35
  from ...runner.events import InternalErrorType, SchemaErrorType
32
- from ...runner.serialization import SerializedError, SerializedTestResult, deduplicate_failures, SerializedCheck
36
+ from ...runner.probes import ProbeOutcome
37
+ from ...runner.serialization import SerializedError, SerializedTestResult
38
+ from ...service.models import AnalysisSuccess, ErrorState, UnknownExtension
39
+ from ...stateful import events as stateful_events
40
+ from ...stateful.sink import StateMachineSink
33
41
  from ..context import ExecutionContext, FileReportContext, ServiceReportContext
34
42
  from ..handlers import EventHandler
43
+ from ..reporting import TEST_CASE_ID_TITLE, get_runtime_error_suggestion, group_by_case, split_traceback
44
+
45
+ if TYPE_CHECKING:
46
+ from queue import Queue
47
+
48
+ import requests
35
49
 
36
50
  SPINNER_REPETITION_NUMBER = 10
51
+ IO_ENCODING = os.getenv("PYTHONIOENCODING", "utf-8")
37
52
 
38
53
 
39
54
  def get_terminal_width() -> int:
@@ -58,14 +73,14 @@ def get_percentage(position: int, length: int) -> str:
58
73
  return f"[{percentage_message}]"
59
74
 
60
75
 
61
- def display_execution_result(context: ExecutionContext, event: events.AfterExecution) -> None:
76
+ def display_execution_result(context: ExecutionContext, status: Literal["success", "failure", "error", "skip"]) -> None:
62
77
  """Display an appropriate symbol for the given event's execution result."""
63
78
  symbol, color = {
64
- Status.success: (".", "green"),
65
- Status.failure: ("F", "red"),
66
- Status.error: ("E", "red"),
67
- Status.skip: ("S", "yellow"),
68
- }[event.status]
79
+ "success": (".", "green"),
80
+ "failure": ("F", "red"),
81
+ "error": ("E", "red"),
82
+ "skip": ("S", "yellow"),
83
+ }[status]
69
84
  context.current_line_length += len(symbol)
70
85
  click.secho(symbol, nl=False, fg=color)
71
86
 
@@ -130,7 +145,9 @@ def display_hypothesis_output(hypothesis_output: list[str]) -> None:
130
145
 
131
146
  def display_errors(context: ExecutionContext, event: events.Finished) -> None:
132
147
  """Display all errors in the test run."""
133
- if not event.has_errors:
148
+ probes = context.probes or []
149
+ has_probe_errors = any(probe.outcome == ProbeOutcome.ERROR for probe in probes)
150
+ if not event.has_errors and not has_probe_errors:
134
151
  return
135
152
 
136
153
  display_section_name("ERRORS")
@@ -147,6 +164,12 @@ def display_errors(context: ExecutionContext, event: events.Finished) -> None:
147
164
  should_display_full_traceback_message |= display_single_error(context, result)
148
165
  if event.generic_errors:
149
166
  display_generic_errors(context, event.generic_errors)
167
+ if has_probe_errors:
168
+ display_section_name("API Probe errors", "_", fg="red")
169
+ for probe in probes:
170
+ if probe.error is not None:
171
+ error = SerializedError.from_exception(probe.error)
172
+ _display_error(context, error)
150
173
  if should_display_full_traceback_message and not context.show_trace:
151
174
  click.secho(
152
175
  "\nAdd this option to your command line parameters to see full tracebacks: --show-trace",
@@ -179,16 +202,20 @@ def display_generic_errors(context: ExecutionContext, errors: list[SerializedErr
179
202
 
180
203
  def display_full_traceback_message(error: SerializedError) -> bool:
181
204
  # Some errors should not trigger the message that suggests to show full tracebacks to the user
182
- return not error.exception.startswith(
183
- (
184
- "DeadlineExceeded",
185
- "OperationSchemaError",
186
- "requests.exceptions",
187
- "SerializationNotPossible",
188
- "hypothesis.errors.FailedHealthCheck",
189
- "hypothesis.errors.InvalidArgument: Scalar ",
190
- "hypothesis.errors.InvalidArgument: min_size=",
205
+ return (
206
+ not error.exception.startswith(
207
+ (
208
+ "DeadlineExceeded",
209
+ "OperationSchemaError",
210
+ "requests.exceptions",
211
+ "SerializationNotPossible",
212
+ "hypothesis.errors.FailedHealthCheck",
213
+ "hypothesis.errors.InvalidArgument: Scalar ",
214
+ "hypothesis.errors.InvalidArgument: min_size=",
215
+ "hypothesis.errors.Unsatisfiable",
216
+ )
191
217
  )
218
+ and "can never generate an example, because min_size is larger than Hypothesis supports." not in error.exception
192
219
  )
193
220
 
194
221
 
@@ -241,11 +268,11 @@ def _display_error(context: ExecutionContext, error: SerializedError) -> bool:
241
268
  if error.extras:
242
269
  extras = error.extras
243
270
  elif context.show_trace and error.type.has_useful_traceback:
244
- extras = _split_traceback(error.exception_with_traceback)
271
+ extras = split_traceback(error.exception_with_traceback)
245
272
  else:
246
273
  extras = []
247
274
  _display_extras(extras)
248
- suggestion = RUNTIME_ERROR_SUGGESTIONS.get(error.type)
275
+ suggestion = get_runtime_error_suggestion(error.type)
249
276
  _maybe_display_tip(suggestion)
250
277
  return display_full_traceback_message(error)
251
278
 
@@ -264,7 +291,16 @@ def display_failures(context: ExecutionContext, event: events.Finished) -> None:
264
291
  display_failures_for_single_test(context, result)
265
292
 
266
293
 
267
- TEST_CASE_ID_TITLE = "Test Case ID"
294
+ if IO_ENCODING != "utf-8":
295
+
296
+ def _secho(text: str, **kwargs: Any) -> None:
297
+ text = text.encode(IO_ENCODING, errors="replace").decode("utf-8")
298
+ click.secho(text, **kwargs)
299
+
300
+ else:
301
+
302
+ def _secho(text: str, **kwargs: Any) -> None:
303
+ click.secho(text, **kwargs)
268
304
 
269
305
 
270
306
  def display_failures_for_single_test(context: ExecutionContext, result: SerializedTestResult) -> None:
@@ -282,63 +318,31 @@ def display_failures_for_single_test(context: ExecutionContext, result: Serializ
282
318
  for check_idx, check in enumerate(checks):
283
319
  if check_idx == 0:
284
320
  click.secho(f"{idx}. {TEST_CASE_ID_TITLE}: {check.example.id}", bold=True)
285
- if check.context is not None:
286
- title = check.context.title
287
- if check.context.message:
288
- message = check.context.message
289
- else:
290
- message = None
291
- else:
292
- title = f"Custom check failed: `{check.name}`"
293
- message = check.message
294
- click.secho(f"\n- {title}", fg="red", bold=True)
321
+ click.secho(f"\n- {check.title}", fg="red", bold=True)
322
+ message = check.formatted_message
295
323
  if message:
296
- message = textwrap.indent(message, prefix=" ")
297
- click.secho(f"\n{message}", fg="red")
324
+ _secho(f"\n{message}", fg="red")
298
325
  if check_idx + 1 == len(checks):
299
326
  if check.response is not None:
300
327
  status_code = check.response.status_code
301
328
  reason = get_reason(status_code)
302
329
  response = bold(f"[{check.response.status_code}] {reason}")
303
330
  click.echo(f"\n{response}:")
304
-
305
- response_body = check.response.body
306
- if check.response is not None and response_body is not None:
307
- if not response_body:
331
+ if check.response.body is not None:
332
+ if not check.response.body:
308
333
  click.echo("\n <EMPTY>")
309
334
  else:
310
335
  encoding = check.response.encoding or "utf8"
311
336
  try:
312
- payload = base64.b64decode(response_body).decode(encoding)
313
- payload = prepare_response_payload(payload)
337
+ # Checked that is not None
338
+ body = cast(bytes, check.response.deserialize_body())
339
+ payload = body.decode(encoding)
340
+ payload = prepare_response_payload(payload, config=context.output_config)
314
341
  payload = textwrap.indent(f"\n`{payload}`", prefix=" ")
315
342
  click.echo(payload)
316
343
  except UnicodeDecodeError:
317
344
  click.echo("\n <BINARY>")
318
-
319
- click.echo(
320
- f"\n{bold('Reproduce with')}: \n\n {code_sample}\n",
321
- )
322
-
323
-
324
- def group_by_case(
325
- checks: list[SerializedCheck], code_sample_style: CodeSampleStyle
326
- ) -> Generator[tuple[str, Generator[SerializedCheck, None, None]], None, None]:
327
- checks = deduplicate_failures(checks)
328
- checks = sorted(checks, key=lambda c: _by_unique_code_sample(c, code_sample_style))
329
- yield from groupby(checks, lambda c: _by_unique_code_sample(c, code_sample_style))
330
-
331
-
332
- def _by_unique_code_sample(check: SerializedCheck, code_sample_style: CodeSampleStyle) -> str:
333
- request_body = base64.b64decode(check.example.body).decode() if check.example.body is not None else None
334
- return code_sample_style.generate(
335
- method=check.example.method,
336
- url=check.example.url,
337
- body=request_body,
338
- headers=check.example.headers,
339
- verify=check.example.verify,
340
- extra_headers=check.example.extra_headers,
341
- )
345
+ _secho(f"\n{bold('Reproduce with')}: \n\n {code_sample}\n")
342
346
 
343
347
 
344
348
  def display_application_logs(context: ExecutionContext, event: events.Finished) -> None:
@@ -357,11 +361,89 @@ def display_single_log(result: SerializedTestResult) -> None:
357
361
  click.echo("\n\n".join(result.logs))
358
362
 
359
363
 
364
+ def display_analysis(context: ExecutionContext) -> None:
365
+ """Display schema analysis details."""
366
+ import requests.exceptions
367
+
368
+ if context.analysis is None:
369
+ return
370
+ display_section_name("SCHEMA ANALYSIS")
371
+ if isinstance(context.analysis, Ok):
372
+ analysis = context.analysis.ok()
373
+ click.echo()
374
+ if isinstance(analysis, AnalysisSuccess):
375
+ click.secho(analysis.message, bold=True)
376
+ click.echo(f"\nAnalysis took: {analysis.elapsed:.2f}ms")
377
+ if analysis.extensions:
378
+ known = []
379
+ failed = []
380
+ unknown = []
381
+ for extension in analysis.extensions:
382
+ if isinstance(extension, UnknownExtension):
383
+ unknown.append(extension)
384
+ elif isinstance(extension.state, ErrorState):
385
+ failed.append(extension)
386
+ else:
387
+ known.append(extension)
388
+ if known:
389
+ click.echo("\nThe following extensions have been applied:\n")
390
+ for extension in known:
391
+ click.echo(f" - {extension.summary}")
392
+ if failed:
393
+ click.echo("\nThe following extensions errored:\n")
394
+ for extension in failed:
395
+ click.echo(f" - {extension.summary}")
396
+ suggestion = f"Please, consider reporting this to our issue tracker:\n\n {ISSUE_TRACKER_URL}"
397
+ click.secho(f"\n{click.style('Tip:', bold=True, fg='green')} {suggestion}")
398
+ if unknown:
399
+ noun = "extension" if len(unknown) == 1 else "extensions"
400
+ specific_noun = "this extension" if len(unknown) == 1 else "these extensions"
401
+ title = click.style("Compatibility Notice", bold=True)
402
+ click.secho(f"\n{title}: {len(unknown)} {noun} not recognized:\n")
403
+ for extension in unknown:
404
+ click.echo(f" - {extension.summary}")
405
+ suggestion = f"Consider updating the CLI to add support for {specific_noun}."
406
+ click.secho(f"\n{click.style('Tip:', bold=True, fg='green')} {suggestion}")
407
+ else:
408
+ click.echo("\nNo extensions have been applied.")
409
+ else:
410
+ click.echo("An error happened during schema analysis:\n")
411
+ click.secho(f" {analysis.message}", bold=True)
412
+ click.echo()
413
+ else:
414
+ exception = context.analysis.err()
415
+ suggestion = None
416
+ if isinstance(exception, requests.exceptions.HTTPError):
417
+ response = exception.response
418
+ click.secho("Error\n", fg="red", bold=True)
419
+ _display_service_network_error(response)
420
+ click.echo()
421
+ return
422
+ if isinstance(exception, requests.RequestException):
423
+ message, extras = extract_requests_exception_details(exception)
424
+ suggestion = "Please check your network connection and try again."
425
+ title = "Network Error"
426
+ else:
427
+ traceback = format_exception(exception, True)
428
+ extras = split_traceback(traceback)
429
+ title = "Internal Error"
430
+ message = f"We apologize for the inconvenience. This appears to be an internal issue.\nPlease, consider reporting the following details to our issue tracker:\n\n {ISSUE_TRACKER_URL}"
431
+ suggestion = "Please update your CLI to the latest version and try again."
432
+ click.secho(f"{title}\n", fg="red", bold=True)
433
+ click.echo(message)
434
+ _display_extras(extras)
435
+ _maybe_display_tip(suggestion)
436
+ click.echo()
437
+
438
+
360
439
  def display_statistic(context: ExecutionContext, event: events.Finished) -> None:
361
440
  """Format and print statistic collected by :obj:`models.TestResult`."""
362
441
  display_section_name("SUMMARY")
363
442
  click.echo()
364
443
  total = event.total
444
+ if context.state_machine_sink is not None:
445
+ click.echo(context.state_machine_sink.transitions.to_formatted_table(get_terminal_width()))
446
+ click.echo()
365
447
  if event.is_empty or not total:
366
448
  click.secho("No checks were performed.", bold=True)
367
449
 
@@ -469,7 +551,7 @@ def display_report_metadata(meta: service.Metadata) -> None:
469
551
  if value is not None:
470
552
  click.secho(f" -> {key}: {value}")
471
553
  click.echo()
472
- click.secho(f"Compressed report size: {meta.size / 1024.:,.0f} KB", bold=True)
554
+ click.secho(f"Compressed report size: {meta.size / 1024.0:,.0f} KB", bold=True)
473
555
 
474
556
 
475
557
  def display_service_unauthorized(hostname: str) -> None:
@@ -487,43 +569,49 @@ def display_service_unauthorized(hostname: str) -> None:
487
569
 
488
570
  def display_service_error(event: service.Error, message_prefix: str = "") -> None:
489
571
  """Show information about an error during communication with Schemathesis.io."""
490
- from requests import RequestException, HTTPError, Response
572
+ from requests import HTTPError, RequestException, Response
491
573
 
492
574
  if isinstance(event.exception, HTTPError):
493
575
  response = cast(Response, event.exception.response)
494
- status_code = response.status_code
495
- if 500 <= status_code <= 599:
496
- click.secho(f"Schemathesis.io responded with HTTP {status_code}", fg="red")
497
- # Server error, should be resolved soon
498
- click.secho(
499
- "\nIt is likely that we are already notified about the issue and working on a fix\n"
500
- "Please, try again in 30 minutes",
501
- fg="red",
502
- )
503
- elif status_code == 401:
504
- # Likely an invalid token
505
- click.echo("Your CLI is not authenticated.")
506
- display_service_unauthorized("schemathesis.io")
507
- else:
508
- try:
509
- data = response.json()
510
- detail = data["detail"]
511
- click.secho(f"{message_prefix}{detail}", fg="red")
512
- except Exception:
513
- # Other client-side errors are likely caused by a bug on the CLI side
514
- click.secho(
515
- "We apologize for the inconvenience. This appears to be an internal issue.\n"
516
- "Please, consider reporting the following details to our issue "
517
- f"tracker:\n\n {ISSUE_TRACKER_URL}\n\nResponse: {response.text!r}\n"
518
- f"Headers: {response.headers!r}",
519
- fg="red",
520
- )
576
+ _display_service_network_error(response, message_prefix)
521
577
  elif isinstance(event.exception, RequestException):
522
578
  ask_to_report(event, report_to_issues=False)
523
579
  else:
524
580
  ask_to_report(event)
525
581
 
526
582
 
583
+ def _display_service_network_error(response: requests.Response, message_prefix: str = "") -> None:
584
+ status_code = response.status_code
585
+ if 500 <= status_code <= 599:
586
+ click.secho(f"Schemathesis.io responded with HTTP {status_code}", fg="red")
587
+ # Server error, should be resolved soon
588
+ click.secho(
589
+ "\nIt is likely that we are already notified about the issue and working on a fix\n"
590
+ "Please, try again in 30 minutes",
591
+ fg="red",
592
+ )
593
+ elif status_code == 401:
594
+ # Likely an invalid token
595
+ click.echo("Your CLI is not authenticated.")
596
+ display_service_unauthorized("schemathesis.io")
597
+ else:
598
+ try:
599
+ data = response.json()
600
+ detail = data["detail"]
601
+ click.secho(f"{message_prefix}{detail}", fg="red")
602
+ except Exception:
603
+ # Other client-side errors are likely caused by a bug on the CLI side
604
+ click.secho(
605
+ "We apologize for the inconvenience. This appears to be an internal issue.\n"
606
+ "Please, consider reporting the following details to our issue "
607
+ f"tracker:\n\n {ISSUE_TRACKER_URL}\n\nResponse: {response.text!r}\n"
608
+ f"Status: {response.status_code}\n"
609
+ f"Headers: {response.headers!r}",
610
+ fg="red",
611
+ )
612
+ _maybe_display_tip("Please update your CLI to the latest version and try again.")
613
+
614
+
527
615
  SERVICE_ERROR_MESSAGE = "An error happened during uploading reports to Schemathesis.io"
528
616
 
529
617
 
@@ -562,7 +650,6 @@ def wait_for_report_handler(queue: Queue, title: str, timeout: float = service.W
562
650
 
563
651
  def create_spinner(repetitions: int) -> Generator[str, None, None]:
564
652
  """A simple spinner that yields its individual characters."""
565
- assert repetitions > 0, "The number of repetitions should be greater than zero"
566
653
  while True:
567
654
  for ch in "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏":
568
655
  # Skip branch coverage, as it is not possible because of the assertion above
@@ -623,10 +710,6 @@ def should_skip_suggestion(context: ExecutionContext, event: events.InternalErro
623
710
  return event.subtype == SchemaErrorType.CONNECTION_OTHER and context.wait_for_schema is not None
624
711
 
625
712
 
626
- def _split_traceback(traceback: str) -> list[str]:
627
- return [entry for entry in traceback.splitlines() if entry]
628
-
629
-
630
713
  def _display_extras(extras: list[str]) -> None:
631
714
  if extras:
632
715
  click.echo()
@@ -647,7 +730,7 @@ def display_internal_error(context: ExecutionContext, event: events.InternalErro
647
730
  if event.type == InternalErrorType.SCHEMA:
648
731
  extras = event.extras
649
732
  elif context.show_trace:
650
- extras = _split_traceback(event.exception_with_traceback)
733
+ extras = split_traceback(event.exception_with_traceback)
651
734
  else:
652
735
  extras = [event.exception]
653
736
  _display_extras(extras)
@@ -694,7 +777,44 @@ def handle_initialized(context: ExecutionContext, event: events.Initialized) ->
694
777
  click.secho(f"Collected API links: {links_count}", bold=True)
695
778
  if isinstance(context.report, ServiceReportContext):
696
779
  click.secho("Report to Schemathesis.io: ENABLED", bold=True)
697
- if context.operations_count >= 1:
780
+ if context.initialization_lines:
781
+ _print_lines(context.initialization_lines)
782
+
783
+
784
+ def handle_before_probing(context: ExecutionContext, event: events.BeforeProbing) -> None:
785
+ click.secho("API probing: ...\r", bold=True, nl=False)
786
+
787
+
788
+ def handle_after_probing(context: ExecutionContext, event: events.AfterProbing) -> None:
789
+ context.probes = event.probes
790
+ status = "SKIP"
791
+ if event.probes is not None:
792
+ for probe in event.probes:
793
+ if probe.outcome in (ProbeOutcome.SUCCESS, ProbeOutcome.FAILURE):
794
+ # The probe itself has been executed
795
+ status = "SUCCESS"
796
+ elif probe.outcome == ProbeOutcome.ERROR:
797
+ status = "ERROR"
798
+ click.secho(f"API probing: {status}", bold=True, nl=False)
799
+ click.echo()
800
+
801
+
802
+ def handle_before_analysis(context: ExecutionContext, event: events.BeforeAnalysis) -> None:
803
+ click.secho("Schema analysis: ...\r", bold=True, nl=False)
804
+
805
+
806
+ def handle_after_analysis(context: ExecutionContext, event: events.AfterAnalysis) -> None:
807
+ context.analysis = event.analysis
808
+ status = "SKIP"
809
+ if event.analysis is not None:
810
+ if isinstance(event.analysis, Ok) and isinstance(event.analysis.ok(), AnalysisSuccess):
811
+ status = "SUCCESS"
812
+ else:
813
+ status = "ERROR"
814
+ click.secho(f"Schema analysis: {status}", bold=True, nl=False)
815
+ click.echo()
816
+ operations_count = cast(int, context.operations_count) # INVARIANT: should not be `None`
817
+ if operations_count >= 1:
698
818
  click.echo()
699
819
 
700
820
 
@@ -720,7 +840,7 @@ def handle_after_execution(context: ExecutionContext, event: events.AfterExecuti
720
840
  """Display the execution result + current progress at the same line with the method / path names."""
721
841
  context.operations_processed += 1
722
842
  context.results.append(event.result)
723
- display_execution_result(context, event)
843
+ display_execution_result(context, event.status.value)
724
844
  display_percentage(context, event)
725
845
 
726
846
 
@@ -731,13 +851,30 @@ def handle_finished(context: ExecutionContext, event: events.Finished) -> None:
731
851
  display_errors(context, event)
732
852
  display_failures(context, event)
733
853
  display_application_logs(context, event)
854
+ display_analysis(context)
734
855
  display_statistic(context, event)
856
+ if context.summary_lines:
857
+ click.echo()
858
+ _print_lines(context.summary_lines)
735
859
  click.echo()
736
860
  display_summary(event)
737
861
 
738
862
 
863
+ def _print_lines(lines: list[str | Generator[str, None, None]]) -> None:
864
+ for entry in lines:
865
+ if isinstance(entry, str):
866
+ click.echo(entry)
867
+ elif isinstance(entry, GeneratorType):
868
+ for line in entry:
869
+ click.echo(line)
870
+
871
+
739
872
  def handle_interrupted(context: ExecutionContext, event: events.Interrupted) -> None:
740
873
  click.echo()
874
+ _handle_interrupted(context)
875
+
876
+
877
+ def _handle_interrupted(context: ExecutionContext) -> None:
741
878
  context.is_interrupted = True
742
879
  display_section_name("KeyboardInterrupt", "!", bold=False)
743
880
 
@@ -747,19 +884,53 @@ def handle_internal_error(context: ExecutionContext, event: events.InternalError
747
884
  raise click.Abort
748
885
 
749
886
 
887
+ def handle_stateful_event(context: ExecutionContext, event: events.StatefulEvent) -> None:
888
+ if isinstance(event.data, stateful_events.RunStarted):
889
+ context.state_machine_sink = event.data.state_machine.sink()
890
+ if not experimental.STATEFUL_ONLY.is_enabled:
891
+ click.echo()
892
+ click.secho("Stateful tests\n", bold=True)
893
+ elif isinstance(event.data, stateful_events.ScenarioFinished) and not event.data.is_final:
894
+ if event.data.status == stateful_events.ScenarioStatus.INTERRUPTED:
895
+ _handle_interrupted(context)
896
+ elif event.data.status != stateful_events.ScenarioStatus.REJECTED:
897
+ display_execution_result(context, event.data.status.value)
898
+ elif isinstance(event.data, stateful_events.RunFinished):
899
+ click.echo()
900
+ # It is initialized in `RunStarted`
901
+ sink = cast(StateMachineSink, context.state_machine_sink)
902
+ sink.consume(event.data)
903
+
904
+
905
+ def handle_after_stateful_execution(context: ExecutionContext, event: events.AfterStatefulExecution) -> None:
906
+ context.results.append(event.result)
907
+
908
+
750
909
  class DefaultOutputStyleHandler(EventHandler):
751
910
  def handle_event(self, context: ExecutionContext, event: events.ExecutionEvent) -> None:
752
911
  """Choose and execute a proper handler for the given event."""
753
912
  if isinstance(event, events.Initialized):
754
913
  handle_initialized(context, event)
755
- if isinstance(event, events.BeforeExecution):
914
+ elif isinstance(event, events.BeforeProbing):
915
+ handle_before_probing(context, event)
916
+ elif isinstance(event, events.AfterProbing):
917
+ handle_after_probing(context, event)
918
+ elif isinstance(event, events.BeforeAnalysis):
919
+ handle_before_analysis(context, event)
920
+ elif isinstance(event, events.AfterAnalysis):
921
+ handle_after_analysis(context, event)
922
+ elif isinstance(event, events.BeforeExecution):
756
923
  handle_before_execution(context, event)
757
- if isinstance(event, events.AfterExecution):
924
+ elif isinstance(event, events.AfterExecution):
758
925
  context.hypothesis_output.extend(event.hypothesis_output)
759
926
  handle_after_execution(context, event)
760
- if isinstance(event, events.Finished):
927
+ elif isinstance(event, events.Finished):
761
928
  handle_finished(context, event)
762
- if isinstance(event, events.Interrupted):
929
+ elif isinstance(event, events.Interrupted):
763
930
  handle_interrupted(context, event)
764
- if isinstance(event, events.InternalError):
931
+ elif isinstance(event, events.InternalError):
765
932
  handle_internal_error(context, event)
933
+ elif isinstance(event, events.StatefulEvent):
934
+ handle_stateful_event(context, event)
935
+ elif isinstance(event, events.AfterStatefulExecution):
936
+ handle_after_stateful_execution(context, event)
@@ -1,6 +1,7 @@
1
1
  import click
2
2
 
3
3
  from ...runner import events
4
+ from ...stateful import events as stateful_events
4
5
  from ..context import ExecutionContext
5
6
  from ..handlers import EventHandler
6
7
  from . import default
@@ -15,7 +16,13 @@ def handle_after_execution(context: ExecutionContext, event: events.AfterExecuti
15
16
  context.operations_processed += 1
16
17
  context.results.append(event.result)
17
18
  context.hypothesis_output.extend(event.hypothesis_output)
18
- default.display_execution_result(context, event)
19
+ default.display_execution_result(context, event.status.value)
20
+
21
+
22
+ def handle_stateful_event(context: ExecutionContext, event: events.StatefulEvent) -> None:
23
+ if isinstance(event.data, stateful_events.RunStarted):
24
+ click.echo()
25
+ default.handle_stateful_event(context, event)
19
26
 
20
27
 
21
28
  class ShortOutputStyleHandler(EventHandler):
@@ -26,15 +33,27 @@ class ShortOutputStyleHandler(EventHandler):
26
33
  """
27
34
  if isinstance(event, events.Initialized):
28
35
  default.handle_initialized(context, event)
29
- if isinstance(event, events.BeforeExecution):
36
+ elif isinstance(event, events.BeforeProbing):
37
+ default.handle_before_probing(context, event)
38
+ elif isinstance(event, events.AfterProbing):
39
+ default.handle_after_probing(context, event)
40
+ elif isinstance(event, events.BeforeAnalysis):
41
+ default.handle_before_analysis(context, event)
42
+ elif isinstance(event, events.AfterAnalysis):
43
+ default.handle_after_analysis(context, event)
44
+ elif isinstance(event, events.BeforeExecution):
30
45
  handle_before_execution(context, event)
31
- if isinstance(event, events.AfterExecution):
46
+ elif isinstance(event, events.AfterExecution):
32
47
  handle_after_execution(context, event)
33
- if isinstance(event, events.Finished):
48
+ elif isinstance(event, events.Finished):
34
49
  if context.operations_count == context.operations_processed:
35
50
  click.echo()
36
51
  default.handle_finished(context, event)
37
- if isinstance(event, events.Interrupted):
52
+ elif isinstance(event, events.Interrupted):
38
53
  default.handle_interrupted(context, event)
39
- if isinstance(event, events.InternalError):
54
+ elif isinstance(event, events.InternalError):
40
55
  default.handle_internal_error(context, event)
56
+ elif isinstance(event, events.StatefulEvent):
57
+ handle_stateful_event(context, event)
58
+ elif isinstance(event, events.AfterStatefulExecution):
59
+ default.handle_after_stateful_execution(context, event)