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.
- schemathesis/cli/__init__.py +3 -3
- schemathesis/cli/commands/run/__init__.py +159 -135
- schemathesis/cli/commands/run/checks.py +2 -3
- schemathesis/cli/commands/run/context.py +102 -19
- schemathesis/cli/commands/run/executor.py +33 -12
- schemathesis/cli/commands/run/filters.py +1 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +27 -46
- schemathesis/cli/commands/run/handlers/junitxml.py +1 -1
- schemathesis/cli/commands/run/handlers/output.py +238 -102
- schemathesis/cli/commands/run/hypothesis.py +14 -41
- schemathesis/cli/commands/run/reports.py +72 -0
- schemathesis/cli/commands/run/validation.py +18 -12
- schemathesis/cli/ext/groups.py +42 -13
- schemathesis/cli/ext/options.py +15 -8
- schemathesis/core/__init__.py +7 -1
- schemathesis/core/errors.py +79 -11
- schemathesis/core/failures.py +2 -1
- schemathesis/core/transforms.py +1 -1
- schemathesis/engine/config.py +2 -2
- schemathesis/engine/core.py +11 -1
- schemathesis/engine/errors.py +8 -3
- schemathesis/engine/events.py +7 -0
- schemathesis/engine/phases/__init__.py +16 -4
- schemathesis/engine/phases/stateful/_executor.py +1 -1
- schemathesis/engine/phases/unit/__init__.py +77 -53
- schemathesis/engine/phases/unit/_executor.py +28 -23
- schemathesis/engine/phases/unit/_pool.py +8 -0
- schemathesis/errors.py +6 -2
- schemathesis/experimental/__init__.py +0 -6
- schemathesis/filters.py +8 -0
- schemathesis/generation/coverage.py +6 -1
- schemathesis/generation/hypothesis/builder.py +222 -97
- schemathesis/generation/stateful/state_machine.py +49 -3
- schemathesis/openapi/checks.py +3 -1
- schemathesis/pytest/lazy.py +43 -5
- schemathesis/pytest/plugin.py +4 -4
- schemathesis/schemas.py +1 -1
- schemathesis/specs/openapi/checks.py +28 -11
- schemathesis/specs/openapi/examples.py +2 -5
- schemathesis/specs/openapi/expressions/__init__.py +22 -6
- schemathesis/specs/openapi/expressions/nodes.py +15 -21
- schemathesis/specs/openapi/expressions/parser.py +1 -1
- schemathesis/specs/openapi/parameters.py +0 -2
- schemathesis/specs/openapi/patterns.py +24 -7
- schemathesis/specs/openapi/schemas.py +13 -13
- schemathesis/specs/openapi/serialization.py +14 -0
- schemathesis/specs/openapi/stateful/__init__.py +96 -23
- schemathesis/specs/openapi/{links.py → stateful/links.py} +60 -16
- {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/METADATA +7 -26
- {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/RECORD +53 -52
- {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/entry_points.txt +0 -0
- {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.
|
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('`--
|
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,
|
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
|
-
|
456
|
-
|
457
|
-
|
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.
|
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
|
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
|
-
|
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"
|
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
|
752
|
-
|
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
|
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
|
-
|
793
|
-
junit_xml_file: str | None = None
|
787
|
+
report_config: ReportConfig | None = None
|
794
788
|
warnings: WarningData = field(default_factory=WarningData)
|
795
|
-
errors:
|
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.
|
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
|
-
|
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
|
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=
|
895
|
-
total=self.statistic.operations.
|
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
|
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
|
1008
|
-
|
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
|
-
|
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
|
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
|
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
|
1048
|
-
self.warnings.missing_auth.setdefault(status_code,
|
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
|
-
|
1119
|
-
|
1120
|
-
|
1121
|
-
|
1122
|
-
|
1123
|
-
|
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
|
-
|
1128
|
-
|
1129
|
-
|
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"
|
1183
|
+
f"Schemathesis configuration: {count} operation{suffix} returned only 4xx responses during unit tests\n",
|
1134
1184
|
fg="yellow",
|
1135
1185
|
)
|
1136
1186
|
)
|
1137
|
-
|
1138
|
-
for endpoint in
|
1187
|
+
|
1188
|
+
for endpoint in sorted(self.warnings.only_4xx_responses)[:3]:
|
1139
1189
|
click.echo(_style(f" - {endpoint}", fg="yellow"))
|
1140
|
-
if len(
|
1141
|
-
click.echo(_style(f" + {len(
|
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
|
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"
|
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
|
-
|
1317
|
-
|
1318
|
-
|
1319
|
-
|
1320
|
-
|
1321
|
-
|
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
|
-
|
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
|
-
|
1396
|
-
|
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
|
-
|
1401
|
-
|
1537
|
+
status = interaction.response.status_code
|
1538
|
+
counts[status] = counts.get(status, 0) + 1
|
1402
1539
|
total += 1
|
1403
|
-
|
1404
|
-
|
1405
|
-
return matched / total >= TOO_MANY_RESPONSES_THRESHOLD
|
1540
|
+
|
1541
|
+
return StatusCodeStatistic(counts=counts, total=total)
|