schemathesis 4.0.0a3__py3-none-any.whl → 4.0.0a5__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 (53) hide show
  1. schemathesis/cli/__init__.py +3 -3
  2. schemathesis/cli/commands/run/__init__.py +159 -135
  3. schemathesis/cli/commands/run/checks.py +2 -3
  4. schemathesis/cli/commands/run/context.py +102 -19
  5. schemathesis/cli/commands/run/executor.py +33 -12
  6. schemathesis/cli/commands/run/filters.py +1 -0
  7. schemathesis/cli/commands/run/handlers/cassettes.py +27 -46
  8. schemathesis/cli/commands/run/handlers/junitxml.py +1 -1
  9. schemathesis/cli/commands/run/handlers/output.py +238 -102
  10. schemathesis/cli/commands/run/hypothesis.py +14 -41
  11. schemathesis/cli/commands/run/reports.py +72 -0
  12. schemathesis/cli/commands/run/validation.py +18 -12
  13. schemathesis/cli/ext/groups.py +42 -13
  14. schemathesis/cli/ext/options.py +15 -8
  15. schemathesis/core/__init__.py +7 -1
  16. schemathesis/core/errors.py +79 -11
  17. schemathesis/core/failures.py +2 -1
  18. schemathesis/core/transforms.py +1 -1
  19. schemathesis/engine/config.py +2 -2
  20. schemathesis/engine/core.py +11 -1
  21. schemathesis/engine/errors.py +8 -3
  22. schemathesis/engine/events.py +7 -0
  23. schemathesis/engine/phases/__init__.py +16 -4
  24. schemathesis/engine/phases/stateful/_executor.py +1 -1
  25. schemathesis/engine/phases/unit/__init__.py +77 -53
  26. schemathesis/engine/phases/unit/_executor.py +28 -23
  27. schemathesis/engine/phases/unit/_pool.py +8 -0
  28. schemathesis/errors.py +6 -2
  29. schemathesis/experimental/__init__.py +0 -6
  30. schemathesis/filters.py +8 -0
  31. schemathesis/generation/coverage.py +6 -1
  32. schemathesis/generation/hypothesis/builder.py +222 -97
  33. schemathesis/generation/stateful/state_machine.py +49 -3
  34. schemathesis/openapi/checks.py +3 -1
  35. schemathesis/pytest/lazy.py +43 -5
  36. schemathesis/pytest/plugin.py +4 -4
  37. schemathesis/schemas.py +1 -1
  38. schemathesis/specs/openapi/checks.py +28 -11
  39. schemathesis/specs/openapi/examples.py +2 -5
  40. schemathesis/specs/openapi/expressions/__init__.py +22 -6
  41. schemathesis/specs/openapi/expressions/nodes.py +15 -21
  42. schemathesis/specs/openapi/expressions/parser.py +1 -1
  43. schemathesis/specs/openapi/parameters.py +0 -2
  44. schemathesis/specs/openapi/patterns.py +24 -7
  45. schemathesis/specs/openapi/schemas.py +13 -13
  46. schemathesis/specs/openapi/serialization.py +14 -0
  47. schemathesis/specs/openapi/stateful/__init__.py +96 -23
  48. schemathesis/specs/openapi/{links.py → stateful/links.py} +60 -16
  49. {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/METADATA +7 -26
  50. {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/RECORD +53 -52
  51. {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/WHEEL +0 -0
  52. {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/entry_points.txt +0 -0
  53. {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/licenses/LICENSE +0 -0
@@ -1,8 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ import textwrap
4
5
  import time
5
6
  from dataclasses import dataclass, field
7
+ from json.decoder import JSONDecodeError
6
8
  from types import GeneratorType
7
9
  from typing import TYPE_CHECKING, Any, Generator, Iterable
8
10
 
@@ -11,19 +13,22 @@ import click
11
13
  from schemathesis.cli.commands.run.context import ExecutionContext, GroupedFailures
12
14
  from schemathesis.cli.commands.run.events import LoadingFinished, LoadingStarted
13
15
  from schemathesis.cli.commands.run.handlers.base import EventHandler
14
- from schemathesis.cli.commands.run.handlers.cassettes import CassetteConfig
16
+ from schemathesis.cli.commands.run.reports import ReportConfig, ReportFormat
15
17
  from schemathesis.cli.constants import ISSUE_TRACKER_URL
16
18
  from schemathesis.cli.core import get_terminal_width
17
19
  from schemathesis.core.errors import LoaderError, LoaderErrorKind, format_exception, split_traceback
18
20
  from schemathesis.core.failures import MessageBlock, Severity, format_failures
21
+ from schemathesis.core.output import prepare_response_payload
19
22
  from schemathesis.core.result import Err, Ok
20
23
  from schemathesis.core.version import SCHEMATHESIS_VERSION
21
24
  from schemathesis.engine import Status, events
25
+ from schemathesis.engine.config import EngineConfig
22
26
  from schemathesis.engine.errors import EngineErrorInfo
23
27
  from schemathesis.engine.phases import PhaseName, PhaseSkipReason
24
28
  from schemathesis.engine.phases.probes import ProbeOutcome
25
- from schemathesis.engine.recorder import Interaction
29
+ from schemathesis.engine.recorder import Interaction, ScenarioRecorder
26
30
  from schemathesis.experimental import GLOBAL_EXPERIMENTS
31
+ from schemathesis.generation.modes import GenerationMode
27
32
  from schemathesis.schemas import ApiStatistic
28
33
 
29
34
  if TYPE_CHECKING:
@@ -32,6 +37,8 @@ if TYPE_CHECKING:
32
37
  from rich.progress import Progress, TaskID
33
38
  from rich.text import Text
34
39
 
40
+ from schemathesis.generation.stateful.state_machine import ExtractionFailure
41
+
35
42
  IO_ENCODING = os.getenv("PYTHONIOENCODING", "utf-8")
36
43
  DISCORD_LINK = "https://discord.gg/R9ASRAmHnA"
37
44
 
@@ -100,7 +107,7 @@ def display_failures_for_single_test(ctx: ExecutionContext, label: str, checks:
100
107
 
101
108
 
102
109
  VERIFY_URL_SUGGESTION = "Verify that the URL points directly to the Open API schema or GraphQL endpoint"
103
- DISABLE_SSL_SUGGESTION = f"Bypass SSL verification with {bold('`--request-tls-verify=false`')}."
110
+ DISABLE_SSL_SUGGESTION = f"Bypass SSL verification with {bold('`--tls-verify=false`')}."
104
111
  LOADER_ERROR_SUGGESTIONS = {
105
112
  # SSL-specific connection issue
106
113
  LoaderErrorKind.CONNECTION_SSL: DISABLE_SSL_SUGGESTION,
@@ -318,7 +325,8 @@ class ProbingProgressManager:
318
325
 
319
326
  @dataclass
320
327
  class WarningData:
321
- missing_auth: dict[int, list[str]] = field(default_factory=dict)
328
+ missing_auth: dict[int, set[str]] = field(default_factory=dict)
329
+ only_4xx_responses: set[str] = field(default_factory=set) # operations that only returned 4xx
322
330
 
323
331
 
324
332
  @dataclass
@@ -379,6 +387,7 @@ class UnitTestProgressManager:
379
387
 
380
388
  def __init__(
381
389
  self,
390
+ *,
382
391
  console: Console,
383
392
  title: str,
384
393
  total: int,
@@ -437,6 +446,7 @@ class UnitTestProgressManager:
437
446
  Status.FAILURE: 0,
438
447
  Status.SKIP: 0,
439
448
  Status.ERROR: 0,
449
+ Status.INTERRUPTED: 0,
440
450
  }
441
451
  self._update_stats_display()
442
452
 
@@ -452,9 +462,10 @@ class UnitTestProgressManager:
452
462
  if self.stats[Status.FAILURE]:
453
463
  parts.append(f"❌ {self.stats[Status.FAILURE]:{width}d} failed")
454
464
  if self.stats[Status.ERROR]:
455
- parts.append(f"🚫 {self.stats[Status.ERROR]:{width}d} errors")
456
- if self.stats[Status.SKIP]:
457
- parts.append(f"⏭️ {self.stats[Status.SKIP]:{width}d} skipped")
465
+ suffix = "s" if self.stats[Status.ERROR] > 1 else ""
466
+ parts.append(f"🚫 {self.stats[Status.ERROR]:{width}d} error{suffix}")
467
+ if self.stats[Status.SKIP] or self.stats[Status.INTERRUPTED]:
468
+ parts.append(f"⏭ {self.stats[Status.SKIP] + self.stats[Status.INTERRUPTED]:{width}d} skipped")
458
469
  return " ".join(parts)
459
470
 
460
471
  def _update_stats_display(self) -> None:
@@ -506,7 +517,11 @@ class UnitTestProgressManager:
506
517
  if operation := self.current_operations.pop(label, None):
507
518
  if not self.current_operations:
508
519
  assert self.title_task_id is not None
509
- self.title_progress.update(self.title_task_id)
520
+ if self.current == self.total - 1:
521
+ description = f" {self.title}"
522
+ else:
523
+ description = self.title
524
+ self.title_progress.update(self.title_task_id, description=description)
510
525
  self.operations_progress.update(operation.task_id, visible=False)
511
526
 
512
527
  def update_stats(self, status: Status) -> None:
@@ -535,7 +550,7 @@ class UnitTestProgressManager:
535
550
  elif self.stats[Status.SUCCESS] > 0:
536
551
  icon = "✅"
537
552
  elif self.stats[Status.SKIP] > 0:
538
- icon = "⏭️"
553
+ icon = ""
539
554
  else:
540
555
  icon = default_icon
541
556
  return icon
@@ -654,7 +669,7 @@ class StatefulProgressManager:
654
669
  from rich.text import Text
655
670
 
656
671
  # Initialize progress displays
657
- self.title_task_id = self.title_progress.add_task("Stateful tests")
672
+ self.title_task_id = self.title_progress.add_task("Stateful")
658
673
  self.progress_task_id = self.progress_bar.add_task(
659
674
  "", scenarios=0, links=f"0 covered / {self.links_selected} selected / {self.links_total} total links"
660
675
  )
@@ -703,9 +718,10 @@ class StatefulProgressManager:
703
718
  if self.stats[Status.FAILURE]:
704
719
  parts.append(f"❌ {self.stats[Status.FAILURE]} failed")
705
720
  if self.stats[Status.ERROR]:
706
- parts.append(f"🚫 {self.stats[Status.ERROR]} errors")
721
+ suffix = "s" if self.stats[Status.ERROR] > 1 else ""
722
+ parts.append(f"🚫 {self.stats[Status.ERROR]} error{suffix}")
707
723
  if self.stats[Status.SKIP]:
708
- parts.append(f"⏭️ {self.stats[Status.SKIP]} skipped")
724
+ parts.append(f"{self.stats[Status.SKIP]} skipped")
709
725
  return " ".join(parts)
710
726
 
711
727
  def _update_stats_display(self) -> None:
@@ -722,7 +738,7 @@ class StatefulProgressManager:
722
738
  elif self.stats[Status.SUCCESS] > 0:
723
739
  icon = "✅"
724
740
  elif self.stats[Status.SKIP] > 0:
725
- icon = "⏭️"
741
+ icon = ""
726
742
  else:
727
743
  icon = default_icon
728
744
  return icon
@@ -748,39 +764,18 @@ class StatefulProgressManager:
748
764
 
749
765
 
750
766
  def format_duration(duration_ms: int) -> str:
751
- """Format duration in milliseconds to human readable string."""
752
- parts = []
753
-
754
- # Convert to components
755
- ms = duration_ms % 1000
756
- seconds = (duration_ms // 1000) % 60
757
- minutes = (duration_ms // (1000 * 60)) % 60
758
- hours = duration_ms // (1000 * 60 * 60)
759
-
760
- # Add non-empty components
761
- if hours > 0:
762
- parts.append(f"{hours} h")
763
- if minutes > 0:
764
- parts.append(f"{minutes} m")
765
- if seconds > 0:
766
- parts.append(f"{seconds} s")
767
- if ms > 0:
768
- parts.append(f"{ms} ms")
769
-
770
- # Handle zero duration
771
- if not parts:
772
- return "0 ms"
773
-
774
- return " ".join(parts)
767
+ """Format duration in milliseconds to seconds with 2 decimal places."""
768
+ return f"{duration_ms / 1000:.2f}s"
775
769
 
776
770
 
777
771
  @dataclass
778
772
  class OutputHandler(EventHandler):
779
773
  workers_num: int
780
- # Seed can't be absent in the deterministic mode
774
+ # Seed can be absent in the deterministic mode
781
775
  seed: int | None
782
776
  rate_limit: str | None
783
777
  wait_for_schema: float | None
778
+ engine_config: EngineConfig
784
779
 
785
780
  loading_manager: LoadingProgressManager | None = None
786
781
  probing_manager: ProbingProgressManager | None = None
@@ -789,10 +784,9 @@ class OutputHandler(EventHandler):
789
784
 
790
785
  statistic: ApiStatistic | None = None
791
786
  skip_reasons: list[str] = field(default_factory=list)
792
- cassette_config: CassetteConfig | None = None
793
- junit_xml_file: str | None = None
787
+ report_config: ReportConfig | None = None
794
788
  warnings: WarningData = field(default_factory=WarningData)
795
- errors: list[events.NonFatalError] = field(default_factory=list)
789
+ errors: set[events.NonFatalError] = field(default_factory=set)
796
790
  phases: dict[PhaseName, tuple[Status, PhaseSkipReason | None]] = field(
797
791
  default_factory=lambda: {phase: (Status.SKIP, None) for phase in PhaseName}
798
792
  )
@@ -814,7 +808,7 @@ class OutputHandler(EventHandler):
814
808
  elif isinstance(event, events.FatalError):
815
809
  self._on_fatal_error(ctx, event)
816
810
  elif isinstance(event, events.NonFatalError):
817
- self.errors.append(event)
811
+ self.errors.add(event)
818
812
  elif isinstance(event, LoadingStarted):
819
813
  self._on_loading_started(event)
820
814
  elif isinstance(event, LoadingFinished):
@@ -865,7 +859,8 @@ class OutputHandler(EventHandler):
865
859
 
866
860
  table.add_row("Base URL:", event.base_url)
867
861
  table.add_row("Specification:", event.specification.name)
868
- table.add_row("Operations:", str(event.statistic.operations.total))
862
+ statistic = event.statistic.operations
863
+ table.add_row("Operations:", f"{statistic.selected} selected / {statistic.total} total")
869
864
 
870
865
  message = Padding(table, BLOCK_PADDING)
871
866
  self.console.print(message)
@@ -878,8 +873,8 @@ class OutputHandler(EventHandler):
878
873
  phase = event.phase
879
874
  if phase.name == PhaseName.PROBING and phase.is_enabled:
880
875
  self._start_probing()
881
- elif phase.name == PhaseName.UNIT_TESTING and phase.is_enabled:
882
- self._start_unit_tests()
876
+ elif phase.name in [PhaseName.EXAMPLES, PhaseName.COVERAGE, PhaseName.FUZZING] and phase.is_enabled:
877
+ self._start_unit_tests(phase.name)
883
878
  elif phase.name == PhaseName.STATEFUL_TESTING and phase.is_enabled and phase.skip_reason is None:
884
879
  self._start_stateful_tests()
885
880
 
@@ -887,12 +882,13 @@ class OutputHandler(EventHandler):
887
882
  self.probing_manager = ProbingProgressManager(console=self.console)
888
883
  self.probing_manager.start()
889
884
 
890
- def _start_unit_tests(self) -> None:
885
+ def _start_unit_tests(self, phase: PhaseName) -> None:
891
886
  assert self.statistic is not None
887
+ assert self.unit_tests_manager is None
892
888
  self.unit_tests_manager = UnitTestProgressManager(
893
889
  console=self.console,
894
- title="Unit tests",
895
- total=self.statistic.operations.total,
890
+ title=phase.value,
891
+ total=self.statistic.operations.selected,
896
892
  )
897
893
  self.unit_tests_manager.start()
898
894
 
@@ -900,7 +896,7 @@ class OutputHandler(EventHandler):
900
896
  assert self.statistic is not None
901
897
  self.stateful_tests_manager = StatefulProgressManager(
902
898
  console=self.console,
903
- title="Stateful tests",
899
+ title="Stateful",
904
900
  links_selected=self.statistic.links.selected,
905
901
  links_total=self.statistic.links.total,
906
902
  )
@@ -956,7 +952,7 @@ class OutputHandler(EventHandler):
956
952
  elif event.status == Status.SKIP:
957
953
  message = Padding(
958
954
  Text.assemble(
959
- ("⏭️ ", ""),
955
+ ("", ""),
960
956
  ("API probing skipped", Style(color="yellow")),
961
957
  ),
962
958
  BLOCK_PADDING,
@@ -974,8 +970,7 @@ class OutputHandler(EventHandler):
974
970
  )
975
971
  self.console.print(message)
976
972
  self.console.print()
977
- elif phase.name == PhaseName.STATEFUL_TESTING and phase.is_enabled:
978
- assert self.stateful_tests_manager is not None
973
+ elif phase.name == PhaseName.STATEFUL_TESTING and phase.is_enabled and self.stateful_tests_manager is not None:
979
974
  self.stateful_tests_manager.stop()
980
975
  if event.status == Status.ERROR:
981
976
  title, summary = self.stateful_tests_manager.get_completion_message("🚫")
@@ -1004,27 +999,29 @@ class OutputHandler(EventHandler):
1004
999
  self.console.print(Padding(Text(summary, style="bright_white"), (0, 0, 0, 5)))
1005
1000
  self.console.print()
1006
1001
  self.stateful_tests_manager = None
1007
- elif phase.name == PhaseName.UNIT_TESTING and phase.is_enabled:
1008
- assert self.unit_tests_manager is not None
1002
+ elif (
1003
+ phase.name in [PhaseName.EXAMPLES, PhaseName.COVERAGE, PhaseName.FUZZING]
1004
+ and phase.is_enabled
1005
+ and self.unit_tests_manager is not None
1006
+ ):
1009
1007
  self.unit_tests_manager.stop()
1010
1008
  if event.status == Status.ERROR:
1011
1009
  message = self.unit_tests_manager.get_completion_message("🚫")
1012
1010
  else:
1013
1011
  message = self.unit_tests_manager.get_completion_message()
1014
1012
  self.console.print(Padding(Text(message, style="white"), BLOCK_PADDING))
1015
- if event.status != Status.INTERRUPTED:
1016
- self.console.print()
1013
+ self.console.print()
1017
1014
  self.unit_tests_manager = None
1018
1015
 
1019
1016
  def _on_scenario_started(self, event: events.ScenarioStarted) -> None:
1020
- if event.phase == PhaseName.UNIT_TESTING:
1017
+ if event.phase in [PhaseName.EXAMPLES, PhaseName.COVERAGE, PhaseName.FUZZING]:
1021
1018
  # We should display execution result + percentage in the end. For example:
1022
1019
  assert event.label is not None
1023
1020
  assert self.unit_tests_manager is not None
1024
1021
  self.unit_tests_manager.start_operation(event.label)
1025
1022
 
1026
1023
  def _on_scenario_finished(self, event: events.ScenarioFinished) -> None:
1027
- if event.phase == PhaseName.UNIT_TESTING:
1024
+ if event.phase in [PhaseName.EXAMPLES, PhaseName.COVERAGE, PhaseName.FUZZING]:
1028
1025
  assert self.unit_tests_manager is not None
1029
1026
  if event.label:
1030
1027
  self.unit_tests_manager.finish_operation(event.label)
@@ -1043,9 +1040,39 @@ class OutputHandler(EventHandler):
1043
1040
  self.stateful_tests_manager.update(links_seen, event.status)
1044
1041
 
1045
1042
  def _check_warnings(self, event: events.ScenarioFinished) -> None:
1043
+ statistic = aggregate_status_codes(event.recorder.interactions.values())
1044
+
1045
+ if statistic.total == 0:
1046
+ return
1047
+
1046
1048
  for status_code in (401, 403):
1047
- if has_too_many_responses_with_status(event.recorder.interactions.values(), status_code):
1048
- self.warnings.missing_auth.setdefault(status_code, []).append(event.recorder.label)
1049
+ if statistic.ratio_for(status_code) >= TOO_MANY_RESPONSES_THRESHOLD:
1050
+ self.warnings.missing_auth.setdefault(status_code, set()).add(event.recorder.label)
1051
+
1052
+ # Warn if all positive test cases got 4xx in return and no failure was found
1053
+ def all_positive_are_rejected(recorder: ScenarioRecorder) -> bool:
1054
+ seen_positive = False
1055
+ for case in recorder.cases.values():
1056
+ if not (case.value.meta is not None and case.value.meta.generation.mode == GenerationMode.POSITIVE):
1057
+ continue
1058
+ seen_positive = True
1059
+ interaction = recorder.interactions.get(case.value.id)
1060
+ if not (interaction is not None and interaction.response is not None):
1061
+ continue
1062
+ # At least one positive response for positive test case
1063
+ if 200 <= interaction.response.status_code < 300:
1064
+ return False
1065
+ # If there are positive test cases, and we ended up here, then there are no 2xx responses for them
1066
+ # Otherwise, there are no positive test cases at all and this check should pass
1067
+ return seen_positive
1068
+
1069
+ if (
1070
+ event.status == Status.SUCCESS
1071
+ and GenerationMode.POSITIVE in self.engine_config.execution.generation.modes
1072
+ and all_positive_are_rejected(event.recorder)
1073
+ and statistic.should_warn_about_only_4xx()
1074
+ ):
1075
+ self.warnings.only_4xx_responses.add(event.recorder.label)
1049
1076
 
1050
1077
  def _on_interrupted(self, event: events.Interrupted) -> None:
1051
1078
  from rich.padding import Padding
@@ -1115,36 +1142,58 @@ class OutputHandler(EventHandler):
1115
1142
 
1116
1143
  def display_warnings(self) -> None:
1117
1144
  display_section_name("WARNINGS")
1118
- total = sum(len(endpoints) for endpoints in self.warnings.missing_auth.values())
1119
- suffix = "" if total == 1 else "s"
1120
- click.echo(
1121
- _style(
1122
- f"\nMissing or invalid API credentials: {total} API operation{suffix} returned authentication errors\n",
1123
- fg="yellow",
1145
+ click.echo()
1146
+ if self.warnings.missing_auth:
1147
+ total = sum(len(endpoints) for endpoints in self.warnings.missing_auth.values())
1148
+ suffix = "" if total == 1 else "s"
1149
+ click.echo(
1150
+ _style(
1151
+ f"Missing or invalid API credentials: {total} API operation{suffix} returned authentication errors\n",
1152
+ fg="yellow",
1153
+ )
1124
1154
  )
1125
- )
1126
1155
 
1127
- for status_code, operations in self.warnings.missing_auth.items():
1128
- status_text = "Unauthorized" if status_code == 401 else "Forbidden"
1129
- count = len(operations)
1156
+ for status_code, operations in self.warnings.missing_auth.items():
1157
+ status_text = "Unauthorized" if status_code == 401 else "Forbidden"
1158
+ count = len(operations)
1159
+ suffix = "" if count == 1 else "s"
1160
+ click.echo(
1161
+ _style(
1162
+ f"{status_code} {status_text} ({count} operation{suffix}):",
1163
+ fg="yellow",
1164
+ )
1165
+ )
1166
+ # Show first few API operations
1167
+ for endpoint in sorted(operations)[:3]:
1168
+ click.echo(_style(f" - {endpoint}", fg="yellow"))
1169
+ if len(operations) > 3:
1170
+ click.echo(_style(f" + {len(operations) - 3} more", fg="yellow"))
1171
+ click.echo()
1172
+ click.echo(_style("Tip: ", bold=True, fg="yellow"), nl=False)
1173
+ click.echo(_style(f"Use {bold('--auth')} ", fg="yellow"), nl=False)
1174
+ click.echo(_style(f"or {bold('-H')} ", fg="yellow"), nl=False)
1175
+ click.echo(_style("to provide authentication credentials", fg="yellow"))
1176
+ click.echo()
1177
+
1178
+ if self.warnings.only_4xx_responses:
1179
+ count = len(self.warnings.only_4xx_responses)
1130
1180
  suffix = "" if count == 1 else "s"
1131
1181
  click.echo(
1132
1182
  _style(
1133
- f"{status_code} {status_text} ({count} operation{suffix}):",
1183
+ f"Schemathesis configuration: {count} operation{suffix} returned only 4xx responses during unit tests\n",
1134
1184
  fg="yellow",
1135
1185
  )
1136
1186
  )
1137
- # Show first few API operations
1138
- for endpoint in operations[:3]:
1187
+
1188
+ for endpoint in sorted(self.warnings.only_4xx_responses)[:3]:
1139
1189
  click.echo(_style(f" - {endpoint}", fg="yellow"))
1140
- if len(operations) > 3:
1141
- click.echo(_style(f" + {len(operations) - 3} more", fg="yellow"))
1190
+ if len(self.warnings.only_4xx_responses) > 3:
1191
+ click.echo(_style(f" + {len(self.warnings.only_4xx_responses) - 3} more", fg="yellow"))
1192
+ click.echo()
1193
+
1194
+ click.echo(_style("Tip: ", bold=True, fg="yellow"), nl=False)
1195
+ click.echo(_style("Check base URL or adjust data generation settings", fg="yellow"))
1142
1196
  click.echo()
1143
- click.echo(_style("Tip: ", bold=True, fg="yellow"), nl=False)
1144
- click.echo(_style(f"Use {bold('--auth')} ", fg="yellow"), nl=False)
1145
- click.echo(_style(f"or {bold('-H')} ", fg="yellow"), nl=False)
1146
- click.echo(_style("to provide authentication credentials", fg="yellow"))
1147
- click.echo()
1148
1197
 
1149
1198
  def display_experiments(self) -> None:
1150
1199
  display_section_name("EXPERIMENTS")
@@ -1165,6 +1214,54 @@ class OutputHandler(EventHandler):
1165
1214
  )
1166
1215
  click.echo()
1167
1216
 
1217
+ def display_stateful_failures(self, ctx: ExecutionContext) -> None:
1218
+ display_section_name("Stateful tests")
1219
+
1220
+ click.echo("\nFailed to extract data from response:")
1221
+
1222
+ grouped: dict[str, list[ExtractionFailure]] = {}
1223
+ for failure in ctx.statistic.extraction_failures:
1224
+ grouped.setdefault(failure.id, []).append(failure)
1225
+
1226
+ for idx, (transition_id, failures) in enumerate(grouped.items(), 1):
1227
+ for failure in failures:
1228
+ click.echo(f"\n {idx}. Test Case ID: {failure.case_id}\n")
1229
+ click.echo(f" {transition_id}")
1230
+
1231
+ indent = " "
1232
+ if failure.error:
1233
+ if isinstance(failure.error, JSONDecodeError):
1234
+ click.echo(f"\n{indent}Failed to parse JSON from response")
1235
+ else:
1236
+ click.echo(f"\n{indent}{failure.error.__class__.__name__}: {failure.error}")
1237
+ else:
1238
+ description = (
1239
+ f"\n{indent}Could not resolve parameter `{failure.parameter_name}` via `{failure.expression}`"
1240
+ )
1241
+ prefix = "$response.body"
1242
+ if failure.expression.startswith(prefix):
1243
+ description += f"\n{indent}Path `{failure.expression[len(prefix) :]}` not found in response"
1244
+ click.echo(description)
1245
+
1246
+ click.echo()
1247
+
1248
+ for case, response in reversed(failure.history):
1249
+ curl = case.as_curl_command(headers=dict(response.request.headers), verify=response.verify)
1250
+ click.echo(f"{indent}[{response.status_code}] {curl}")
1251
+
1252
+ response = failure.response
1253
+
1254
+ if response.content is None or not response.content:
1255
+ click.echo(f"\n{indent}<EMPTY>")
1256
+ else:
1257
+ try:
1258
+ payload = prepare_response_payload(response.text, config=ctx.output_config)
1259
+ click.echo(textwrap.indent(f"\n{payload}", prefix=indent))
1260
+ except UnicodeDecodeError:
1261
+ click.echo(f"\n{indent}<BINARY>")
1262
+
1263
+ click.echo()
1264
+
1168
1265
  def display_api_operations(self, ctx: ExecutionContext) -> None:
1169
1266
  assert self.statistic is not None
1170
1267
  click.echo(_style("API Operations:", bold=True))
@@ -1180,7 +1277,7 @@ class OutputHandler(EventHandler):
1180
1277
  err.label
1181
1278
  for err in self.errors
1182
1279
  # Some API operations may have some tests before they have an error
1183
- if err.phase == PhaseName.UNIT_TESTING
1280
+ if err.phase in [PhaseName.EXAMPLES, PhaseName.COVERAGE, PhaseName.FUZZING]
1184
1281
  and err.label not in ctx.statistic.tested_operations
1185
1282
  and err.related_to_operation
1186
1283
  }
@@ -1203,7 +1300,7 @@ class OutputHandler(EventHandler):
1203
1300
  status, skip_reason = self.phases[phase]
1204
1301
 
1205
1302
  if status == Status.SKIP:
1206
- click.echo(_style(f" ⏭️ {phase.value}", fg="yellow"), nl=False)
1303
+ click.echo(_style(f" {phase.value}", fg="yellow"), nl=False)
1207
1304
  if skip_reason:
1208
1305
  click.echo(_style(f" ({skip_reason.value})", fg="yellow"))
1209
1306
  else:
@@ -1313,14 +1410,13 @@ class OutputHandler(EventHandler):
1313
1410
  display_section_name(message, fg=color)
1314
1411
 
1315
1412
  def display_reports(self) -> None:
1316
- reports = []
1317
- if self.cassette_config is not None:
1318
- format_name = self.cassette_config.format.name.upper()
1319
- reports.append((format_name, self.cassette_config.path.name))
1320
- if self.junit_xml_file is not None:
1321
- reports.append(("JUnit XML", self.junit_xml_file))
1322
-
1323
- if reports:
1413
+ if self.report_config is not None:
1414
+ reports = [
1415
+ (format.value.upper(), self.report_config.get_path(format).name)
1416
+ for format in ReportFormat
1417
+ if format in self.report_config.formats
1418
+ ]
1419
+
1324
1420
  click.echo(_style("Reports:", bold=True))
1325
1421
  for report_type, path in reports:
1326
1422
  click.echo(_style(f" - {report_type}: {path}"))
@@ -1341,7 +1437,7 @@ class OutputHandler(EventHandler):
1341
1437
  assert self.stateful_tests_manager is None
1342
1438
  if self.errors:
1343
1439
  display_section_name("ERRORS")
1344
- errors = sorted(self.errors, key=lambda r: (r.phase.value, r.label))
1440
+ errors = sorted(self.errors, key=lambda r: (r.phase.value, r.label, r.info.title))
1345
1441
  for error in errors:
1346
1442
  display_section_name(error.label, "_", fg="red")
1347
1443
  click.echo(error.info.format(bold=lambda x: click.style(x, bold=True)))
@@ -1352,10 +1448,12 @@ class OutputHandler(EventHandler):
1352
1448
  )
1353
1449
  )
1354
1450
  display_failures(ctx)
1355
- if self.warnings.missing_auth:
1451
+ if self.warnings.missing_auth or self.warnings.only_4xx_responses:
1356
1452
  self.display_warnings()
1357
1453
  if GLOBAL_EXPERIMENTS.enabled:
1358
1454
  self.display_experiments()
1455
+ if ctx.statistic.extraction_failures:
1456
+ self.display_stateful_failures(ctx)
1359
1457
  display_section_name("SUMMARY")
1360
1458
  click.echo()
1361
1459
 
@@ -1370,10 +1468,21 @@ class OutputHandler(EventHandler):
1370
1468
  if self.errors:
1371
1469
  self.display_errors_summary()
1372
1470
 
1373
- if self.warnings.missing_auth:
1374
- affected = sum(len(operations) for operations in self.warnings.missing_auth.values())
1471
+ if self.warnings.missing_auth or self.warnings.only_4xx_responses:
1375
1472
  click.echo(_style("Warnings:", bold=True))
1376
- click.echo(_style(f" ⚠️ Missing authentication: {bold(str(affected))}", fg="yellow"))
1473
+
1474
+ if self.warnings.missing_auth:
1475
+ affected = sum(len(operations) for operations in self.warnings.missing_auth.values())
1476
+ click.echo(_style(f" ⚠️ Missing authentication: {bold(str(affected))}", fg="yellow"))
1477
+
1478
+ if self.warnings.only_4xx_responses:
1479
+ count = len(self.warnings.only_4xx_responses)
1480
+ suffix = "" if count == 1 else "s"
1481
+ click.echo(
1482
+ _style(f" ⚠️ Schemathesis configuration: {bold(str(count))}", fg="yellow"),
1483
+ nl=False,
1484
+ )
1485
+ click.echo(_style(f" operation{suffix} returned only 4xx responses during unit tests", fg="yellow"))
1377
1486
  click.echo()
1378
1487
 
1379
1488
  if ctx.summary_lines:
@@ -1392,14 +1501,41 @@ TOO_MANY_RESPONSES_WARNING_TEMPLATE = (
1392
1501
  TOO_MANY_RESPONSES_THRESHOLD = 0.9
1393
1502
 
1394
1503
 
1395
- def has_too_many_responses_with_status(interactions: Iterable[Interaction], status_code: int) -> bool:
1396
- matched = 0
1504
+ @dataclass
1505
+ class StatusCodeStatistic:
1506
+ """Statistics about HTTP status codes in a scenario."""
1507
+
1508
+ counts: dict[int, int]
1509
+ total: int
1510
+
1511
+ __slots__ = ("counts", "total")
1512
+
1513
+ def ratio_for(self, status_code: int) -> float:
1514
+ """Calculate the ratio of responses with the given status code."""
1515
+ if self.total == 0:
1516
+ return 0.0
1517
+ return self.counts.get(status_code, 0) / self.total
1518
+
1519
+ def should_warn_about_only_4xx(self) -> bool:
1520
+ """Check if an operation should be warned about (only 4xx responses, excluding auth)."""
1521
+ if self.total == 0:
1522
+ return False
1523
+ # Don't duplicate auth warnings
1524
+ if set(self.counts.keys()) <= {401, 403}:
1525
+ return False
1526
+ # At this point we know we only have 4xx responses
1527
+ return True
1528
+
1529
+
1530
+ def aggregate_status_codes(interactions: Iterable[Interaction]) -> StatusCodeStatistic:
1531
+ """Analyze status codes from interactions."""
1532
+ counts: dict[int, int] = {}
1397
1533
  total = 0
1534
+
1398
1535
  for interaction in interactions:
1399
1536
  if interaction.response is not None:
1400
- if interaction.response.status_code == status_code:
1401
- matched += 1
1537
+ status = interaction.response.status_code
1538
+ counts[status] = counts.get(status, 0) + 1
1402
1539
  total += 1
1403
- if not total:
1404
- return False
1405
- return matched / total >= TOO_MANY_RESPONSES_THRESHOLD
1540
+
1541
+ return StatusCodeStatistic(counts=counts, total=total)