schemathesis 3.29.2__py3-none-any.whl → 3.30.1__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 (125) hide show
  1. schemathesis/__init__.py +3 -3
  2. schemathesis/_compat.py +2 -2
  3. schemathesis/_dependency_versions.py +1 -3
  4. schemathesis/_hypothesis.py +6 -0
  5. schemathesis/_lazy_import.py +1 -0
  6. schemathesis/_override.py +1 -0
  7. schemathesis/_rate_limiter.py +2 -1
  8. schemathesis/_xml.py +1 -0
  9. schemathesis/auths.py +4 -2
  10. schemathesis/checks.py +8 -5
  11. schemathesis/cli/__init__.py +28 -1
  12. schemathesis/cli/callbacks.py +3 -4
  13. schemathesis/cli/cassettes.py +6 -4
  14. schemathesis/cli/constants.py +2 -0
  15. schemathesis/cli/context.py +5 -0
  16. schemathesis/cli/debug.py +2 -1
  17. schemathesis/cli/handlers.py +1 -1
  18. schemathesis/cli/junitxml.py +5 -4
  19. schemathesis/cli/options.py +1 -0
  20. schemathesis/cli/output/default.py +56 -24
  21. schemathesis/cli/output/short.py +21 -10
  22. schemathesis/cli/sanitization.py +1 -0
  23. schemathesis/code_samples.py +1 -0
  24. schemathesis/constants.py +1 -0
  25. schemathesis/contrib/openapi/__init__.py +1 -1
  26. schemathesis/contrib/openapi/fill_missing_examples.py +2 -0
  27. schemathesis/contrib/openapi/formats/uuid.py +2 -1
  28. schemathesis/contrib/unique_data.py +2 -1
  29. schemathesis/exceptions.py +42 -61
  30. schemathesis/experimental/__init__.py +14 -0
  31. schemathesis/extra/_aiohttp.py +1 -0
  32. schemathesis/extra/_server.py +1 -0
  33. schemathesis/extra/pytest_plugin.py +13 -24
  34. schemathesis/failures.py +42 -8
  35. schemathesis/filters.py +2 -1
  36. schemathesis/fixups/__init__.py +1 -0
  37. schemathesis/fixups/fast_api.py +2 -2
  38. schemathesis/fixups/utf8_bom.py +1 -2
  39. schemathesis/generation/__init__.py +2 -1
  40. schemathesis/hooks.py +3 -1
  41. schemathesis/internal/copy.py +19 -3
  42. schemathesis/internal/deprecation.py +1 -1
  43. schemathesis/internal/jsonschema.py +2 -1
  44. schemathesis/internal/output.py +68 -0
  45. schemathesis/internal/result.py +1 -1
  46. schemathesis/internal/transformation.py +1 -0
  47. schemathesis/lazy.py +11 -2
  48. schemathesis/loaders.py +4 -2
  49. schemathesis/models.py +22 -7
  50. schemathesis/parameters.py +1 -0
  51. schemathesis/runner/__init__.py +1 -1
  52. schemathesis/runner/events.py +22 -4
  53. schemathesis/runner/impl/core.py +69 -33
  54. schemathesis/runner/impl/solo.py +2 -1
  55. schemathesis/runner/impl/threadpool.py +4 -0
  56. schemathesis/runner/probes.py +1 -1
  57. schemathesis/runner/serialization.py +1 -1
  58. schemathesis/sanitization.py +2 -0
  59. schemathesis/schemas.py +7 -4
  60. schemathesis/service/ci.py +1 -0
  61. schemathesis/service/client.py +7 -7
  62. schemathesis/service/events.py +2 -1
  63. schemathesis/service/extensions.py +5 -5
  64. schemathesis/service/hosts.py +1 -0
  65. schemathesis/service/metadata.py +2 -1
  66. schemathesis/service/models.py +2 -1
  67. schemathesis/service/report.py +3 -3
  68. schemathesis/service/serialization.py +62 -23
  69. schemathesis/service/usage.py +1 -0
  70. schemathesis/specs/graphql/_cache.py +1 -1
  71. schemathesis/specs/graphql/loaders.py +17 -1
  72. schemathesis/specs/graphql/nodes.py +1 -0
  73. schemathesis/specs/graphql/scalars.py +2 -2
  74. schemathesis/specs/graphql/schemas.py +7 -7
  75. schemathesis/specs/graphql/validation.py +1 -2
  76. schemathesis/specs/openapi/_hypothesis.py +17 -11
  77. schemathesis/specs/openapi/checks.py +102 -9
  78. schemathesis/specs/openapi/converter.py +2 -1
  79. schemathesis/specs/openapi/definitions.py +2 -1
  80. schemathesis/specs/openapi/examples.py +7 -9
  81. schemathesis/specs/openapi/expressions/__init__.py +29 -2
  82. schemathesis/specs/openapi/expressions/context.py +1 -1
  83. schemathesis/specs/openapi/expressions/extractors.py +23 -0
  84. schemathesis/specs/openapi/expressions/lexer.py +19 -18
  85. schemathesis/specs/openapi/expressions/nodes.py +24 -4
  86. schemathesis/specs/openapi/expressions/parser.py +26 -5
  87. schemathesis/specs/openapi/filters.py +1 -0
  88. schemathesis/specs/openapi/links.py +35 -7
  89. schemathesis/specs/openapi/loaders.py +31 -11
  90. schemathesis/specs/openapi/negative/__init__.py +2 -1
  91. schemathesis/specs/openapi/negative/mutations.py +1 -0
  92. schemathesis/specs/openapi/parameters.py +1 -0
  93. schemathesis/specs/openapi/schemas.py +28 -39
  94. schemathesis/specs/openapi/security.py +1 -0
  95. schemathesis/specs/openapi/serialization.py +1 -0
  96. schemathesis/specs/openapi/stateful/__init__.py +159 -70
  97. schemathesis/specs/openapi/stateful/statistic.py +198 -0
  98. schemathesis/specs/openapi/stateful/types.py +13 -0
  99. schemathesis/specs/openapi/utils.py +1 -0
  100. schemathesis/specs/openapi/validation.py +1 -0
  101. schemathesis/stateful/__init__.py +4 -2
  102. schemathesis/stateful/config.py +66 -0
  103. schemathesis/stateful/context.py +103 -0
  104. schemathesis/stateful/events.py +215 -0
  105. schemathesis/stateful/runner.py +238 -0
  106. schemathesis/stateful/sink.py +68 -0
  107. schemathesis/stateful/state_machine.py +39 -22
  108. schemathesis/stateful/statistic.py +20 -0
  109. schemathesis/stateful/validation.py +66 -0
  110. schemathesis/targets.py +1 -0
  111. schemathesis/throttling.py +23 -3
  112. schemathesis/transports/__init__.py +28 -10
  113. schemathesis/transports/auth.py +1 -0
  114. schemathesis/transports/content_types.py +1 -1
  115. schemathesis/transports/headers.py +2 -1
  116. schemathesis/transports/responses.py +6 -4
  117. schemathesis/types.py +1 -0
  118. schemathesis/utils.py +1 -0
  119. {schemathesis-3.29.2.dist-info → schemathesis-3.30.1.dist-info}/METADATA +3 -3
  120. schemathesis-3.30.1.dist-info/RECORD +151 -0
  121. schemathesis/specs/openapi/stateful/links.py +0 -92
  122. schemathesis-3.29.2.dist-info/RECORD +0 -141
  123. {schemathesis-3.29.2.dist-info → schemathesis-3.30.1.dist-info}/WHEEL +0 -0
  124. {schemathesis-3.29.2.dist-info → schemathesis-3.30.1.dist-info}/entry_points.txt +0 -0
  125. {schemathesis-3.29.2.dist-info → schemathesis-3.30.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import TYPE_CHECKING, Iterator, List, Union
5
+
6
+ from ....internal.copy import fast_deepcopy
7
+ from ....stateful.statistic import TransitionStats
8
+ from .types import AggregatedResponseCounter, LinkName, ResponseCounter, SourceName, StatusCode, TargetName
9
+
10
+ if TYPE_CHECKING:
11
+ from ....stateful import events
12
+
13
+
14
+ @dataclass
15
+ class LinkSource:
16
+ name: str
17
+ responses: dict[StatusCode, dict[TargetName, dict[LinkName, ResponseCounter]]]
18
+ is_first: bool
19
+
20
+ __slots__ = ("name", "responses", "is_first")
21
+
22
+
23
+ @dataclass
24
+ class OperationResponse:
25
+ status_code: str
26
+ targets: dict[TargetName, dict[LinkName, ResponseCounter]]
27
+ is_last: bool
28
+
29
+ __slots__ = ("status_code", "targets", "is_last")
30
+
31
+
32
+ @dataclass
33
+ class Link:
34
+ name: str
35
+ target: str
36
+ responses: ResponseCounter
37
+ is_last: bool
38
+ is_single: bool
39
+
40
+ __slots__ = ("name", "target", "responses", "is_last", "is_single")
41
+
42
+
43
+ StatisticEntry = Union[LinkSource, OperationResponse, Link]
44
+
45
+
46
+ @dataclass
47
+ class FormattedStatisticEntry:
48
+ line: str
49
+ entry: StatisticEntry
50
+ __slots__ = ("line", "entry")
51
+
52
+
53
+ @dataclass
54
+ class OpenAPILinkStats(TransitionStats):
55
+ """Statistics about link transitions for a state machine run."""
56
+
57
+ transitions: dict[SourceName, dict[StatusCode, dict[TargetName, dict[LinkName, ResponseCounter]]]]
58
+
59
+ roots: dict[TargetName, ResponseCounter] = field(default_factory=dict)
60
+
61
+ __slots__ = ("transitions",)
62
+
63
+ def consume(self, event: events.StatefulEvent) -> None:
64
+ from ....stateful import events
65
+
66
+ if isinstance(event, events.StepFinished):
67
+ if event.transition_id is not None:
68
+ transition_id = event.transition_id
69
+ source = self.transitions[transition_id.source]
70
+ transition = source[transition_id.status_code][event.target][transition_id.name]
71
+ if event.response is not None:
72
+ key = event.response.status_code
73
+ else:
74
+ key = None
75
+ counter = transition.setdefault(key, 0)
76
+ transition[key] = counter + 1
77
+ else:
78
+ # A start of a sequence has an empty source and does not belong to any transition
79
+ target = self.roots.setdefault(event.target, {})
80
+ if event.response is not None:
81
+ key = event.response.status_code
82
+ else:
83
+ key = None
84
+ counter = target.setdefault(key, 0)
85
+ target[key] = counter + 1
86
+
87
+ def copy(self) -> OpenAPILinkStats:
88
+ return self.__class__(transitions=fast_deepcopy(self.transitions))
89
+
90
+ def iter(self) -> Iterator[StatisticEntry]:
91
+ for source_idx, (source, responses) in enumerate(self.transitions.items()):
92
+ yield LinkSource(name=source, responses=responses, is_first=source_idx == 0)
93
+ for response_idx, (status_code, targets) in enumerate(responses.items()):
94
+ yield OperationResponse(
95
+ status_code=status_code, targets=targets, is_last=response_idx == len(responses) - 1
96
+ )
97
+ for target_idx, (target, links) in enumerate(targets.items()):
98
+ for link_idx, (link_name, link_responses) in enumerate(links.items()):
99
+ yield Link(
100
+ name=link_name,
101
+ target=target,
102
+ responses=link_responses,
103
+ is_last=target_idx == len(targets) - 1 and link_idx == len(links) - 1,
104
+ is_single=len(links) == 1,
105
+ )
106
+
107
+ def iter_with_format(self) -> Iterator[FormattedStatisticEntry]:
108
+ current_response = None
109
+ for entry in self.iter():
110
+ if isinstance(entry, LinkSource):
111
+ if not entry.is_first:
112
+ yield FormattedStatisticEntry(line=f"\n{entry.name}", entry=entry)
113
+ else:
114
+ yield FormattedStatisticEntry(line=f"{entry.name}", entry=entry)
115
+ elif isinstance(entry, OperationResponse):
116
+ current_response = entry
117
+ if entry.is_last:
118
+ yield FormattedStatisticEntry(line=f"└── {entry.status_code}", entry=entry)
119
+ else:
120
+ yield FormattedStatisticEntry(line=f"├── {entry.status_code}", entry=entry)
121
+ else:
122
+ if current_response is not None and current_response.is_last:
123
+ line = " "
124
+ else:
125
+ line = "│ "
126
+ if entry.is_last:
127
+ line += "└"
128
+ else:
129
+ line += "├"
130
+ if entry.is_single or entry.name == entry.target:
131
+ line += f"── {entry.target}"
132
+ else:
133
+ line += f"── {entry.name} -> {entry.target}"
134
+ yield FormattedStatisticEntry(line=line, entry=entry)
135
+
136
+ def to_formatted_table(self, width: int) -> str:
137
+ """Format the statistic as a table."""
138
+ entries = list(self.iter_with_format())
139
+ lines: List[str | list[str]] = [HEADER, ""]
140
+ column_widths = [len(column) for column in HEADER]
141
+ for entry in entries:
142
+ if isinstance(entry.entry, Link):
143
+ aggregated = _aggregate_responses(entry.entry.responses)
144
+ values = [
145
+ entry.line,
146
+ str(aggregated["2xx"]),
147
+ str(aggregated["4xx"]),
148
+ str(aggregated["5xx"]),
149
+ str(aggregated["Total"]),
150
+ ]
151
+ column_widths = [max(column_widths[idx], len(column)) for idx, column in enumerate(values)]
152
+ lines.append(values)
153
+ else:
154
+ lines.append(entry.line)
155
+ used_width = sum(column_widths) + 4 * PADDING
156
+ max_space = width - used_width if used_width < width else 0
157
+ formatted_lines = []
158
+
159
+ for line in lines:
160
+ if isinstance(line, list):
161
+ formatted_line, *counters = line
162
+ formatted_line = formatted_line.ljust(column_widths[0] + max_space)
163
+
164
+ for column, max_width in zip(counters, column_widths[1:]):
165
+ formatted_line += f"{column:>{max_width + PADDING}}"
166
+
167
+ formatted_lines.append(formatted_line)
168
+ else:
169
+ formatted_lines.append(line)
170
+
171
+ return "\n".join(formatted_lines)
172
+
173
+
174
+ PADDING = 4
175
+ HEADER = ["Links", "2xx", "4xx", "5xx", "Total"]
176
+
177
+
178
+ def _aggregate_responses(responses: ResponseCounter) -> AggregatedResponseCounter:
179
+ """Aggregate responses by status code ranges."""
180
+ output: AggregatedResponseCounter = {
181
+ "2xx": 0,
182
+ # NOTE: 3xx responses are not counted
183
+ "4xx": 0,
184
+ "5xx": 0,
185
+ "Total": 0,
186
+ }
187
+ for status_code, count in responses.items():
188
+ if status_code is not None:
189
+ if 200 <= status_code < 300:
190
+ output["2xx"] += count
191
+ output["Total"] += count
192
+ elif 400 <= status_code < 500:
193
+ output["4xx"] += count
194
+ output["Total"] += count
195
+ elif 500 <= status_code < 600:
196
+ output["5xx"] += count
197
+ output["Total"] += count
198
+ return output
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable, Dict, TypedDict, Union
4
+
5
+ from ....stateful.state_machine import StepResult
6
+
7
+ StatusCode = str
8
+ LinkName = str
9
+ TargetName = str
10
+ SourceName = str
11
+ ResponseCounter = Dict[Union[int, None], int]
12
+ FilterFunction = Callable[["StepResult"], bool]
13
+ AggregatedResponseCounter = TypedDict("AggregatedResponseCounter", {"2xx": int, "4xx": int, "5xx": int, "Total": int})
@@ -1,4 +1,5 @@
1
1
  from __future__ import annotations
2
+
2
3
  import string
3
4
  from itertools import product
4
5
  from typing import Any, Generator
@@ -1,4 +1,5 @@
1
1
  from __future__ import annotations
2
+
2
3
  from typing import Any
3
4
 
4
5
  from ...constants import HTTP_METHODS
@@ -1,17 +1,19 @@
1
1
  from __future__ import annotations
2
+
2
3
  import enum
3
4
  import json
4
5
  from dataclasses import dataclass, field
5
6
  from typing import TYPE_CHECKING, Any, Callable, Generator
6
7
 
7
8
  from .. import GenerationConfig
8
- from ..exceptions import OperationSchemaError
9
- from ..models import APIOperation, Case
10
9
  from ..constants import NOT_SET
10
+ from ..exceptions import OperationSchemaError
11
11
  from ..internal.result import Ok, Result
12
+ from ..models import APIOperation, Case
12
13
 
13
14
  if TYPE_CHECKING:
14
15
  import hypothesis
16
+
15
17
  from ..transports.responses import GenericResponse
16
18
  from .state_machine import APIStateMachine
17
19
 
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ if TYPE_CHECKING:
7
+ import hypothesis
8
+
9
+ from ..models import CheckFunction
10
+
11
+
12
+ def _default_checks_factory() -> tuple[CheckFunction, ...]:
13
+ from ..checks import ALL_CHECKS
14
+ from ..specs.openapi.checks import use_after_free
15
+
16
+ return ALL_CHECKS + (use_after_free,)
17
+
18
+
19
+ def _get_default_hypothesis_settings_kwargs() -> dict[str, Any]:
20
+ import hypothesis
21
+
22
+ return {"phases": (hypothesis.Phase.generate,), "deadline": None}
23
+
24
+
25
+ def _default_hypothesis_settings_factory() -> hypothesis.settings:
26
+ # To avoid importing hypothesis at the module level
27
+ import hypothesis
28
+
29
+ return hypothesis.settings(**_get_default_hypothesis_settings_kwargs())
30
+
31
+
32
+ @dataclass
33
+ class StatefulTestRunnerConfig:
34
+ """Configuration for the stateful test runner."""
35
+
36
+ # Checks to run against each response
37
+ checks: tuple[CheckFunction, ...] = field(default_factory=_default_checks_factory)
38
+ # Hypothesis settings for state machine execution
39
+ hypothesis_settings: hypothesis.settings = field(default_factory=_default_hypothesis_settings_factory)
40
+ # Whether to stop the execution after the first failure
41
+ exit_first: bool = False
42
+ # Custom headers sent with each request
43
+ headers: dict[str, str] = field(default_factory=dict)
44
+ # Timeout for each request in milliseconds
45
+ request_timeout: int | None = None
46
+
47
+ def __post_init__(self) -> None:
48
+ import hypothesis
49
+
50
+ kwargs = _get_hypothesis_settings_kwargs_override(self.hypothesis_settings)
51
+ if kwargs:
52
+ self.hypothesis_settings = hypothesis.settings(self.hypothesis_settings, **kwargs)
53
+
54
+
55
+ def _get_hypothesis_settings_kwargs_override(settings: hypothesis.settings) -> dict[str, Any]:
56
+ """Get the settings that should be overridden to match the defaults for API state machines."""
57
+ import hypothesis
58
+
59
+ kwargs = {}
60
+ hypothesis_default = hypothesis.settings()
61
+ state_machine_default = _default_hypothesis_settings_factory()
62
+ if settings.phases == hypothesis_default.phases:
63
+ kwargs["phases"] = state_machine_default.phases
64
+ if settings.deadline == hypothesis_default.deadline:
65
+ kwargs["deadline"] = state_machine_default.deadline
66
+ return kwargs
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import traceback
4
+ from dataclasses import dataclass, field
5
+ from typing import TYPE_CHECKING, Tuple, Type, Union
6
+
7
+ from ..exceptions import CheckFailed
8
+ from . import events
9
+
10
+ if TYPE_CHECKING:
11
+ from ..models import Check
12
+ from ..transports.responses import GenericResponse
13
+
14
+ FailureKey = Union[Type[CheckFailed], Tuple[str, int]]
15
+
16
+
17
+ def _failure_cache_key(exc: CheckFailed | AssertionError) -> FailureKey:
18
+ """Create a ket to identify unique failures."""
19
+ from hypothesis.internal.escalation import get_trimmed_traceback
20
+
21
+ # For CheckFailed, we already have all distinctive information about the failure, which is contained
22
+ # in the exception type itself.
23
+ if isinstance(exc, CheckFailed):
24
+ return exc.__class__
25
+
26
+ # Assertion come from the user's code and we may try to group them by location
27
+ tb = get_trimmed_traceback(exc)
28
+ filename, lineno, *_ = traceback.extract_tb(tb)[-1]
29
+ return (filename, lineno)
30
+
31
+
32
+ @dataclass
33
+ class RunnerContext:
34
+ """Mutable context for state machine execution."""
35
+
36
+ # All seen failure keys, both grouped and individual ones
37
+ seen_in_run: set[FailureKey] = field(default_factory=set)
38
+ # Failures keys seen in the current suite
39
+ seen_in_suite: set[FailureKey] = field(default_factory=set)
40
+ # Unique failures collected in the current suite
41
+ failures_for_suite: list[Check] = field(default_factory=list)
42
+ # Status of the current step
43
+ current_step_status: events.StepStatus | None = None
44
+ current_response: GenericResponse | None = None
45
+
46
+ @property
47
+ def current_scenario_status(self) -> events.ScenarioStatus:
48
+ if self.current_step_status == events.StepStatus.SUCCESS:
49
+ return events.ScenarioStatus.SUCCESS
50
+ elif self.current_step_status == events.StepStatus.FAILURE:
51
+ return events.ScenarioStatus.FAILURE
52
+ elif self.current_step_status == events.StepStatus.ERROR:
53
+ return events.ScenarioStatus.ERROR
54
+ elif self.current_step_status == events.StepStatus.INTERRUPTED:
55
+ return events.ScenarioStatus.INTERRUPTED
56
+ return events.ScenarioStatus.REJECTED
57
+
58
+ def reset_step(self) -> None:
59
+ self.current_step_status = None
60
+ self.current_response = None
61
+
62
+ def step_succeeded(self) -> None:
63
+ self.current_step_status = events.StepStatus.SUCCESS
64
+
65
+ def step_failed(self) -> None:
66
+ self.current_step_status = events.StepStatus.FAILURE
67
+
68
+ def step_errored(self) -> None:
69
+ self.current_step_status = events.StepStatus.ERROR
70
+
71
+ def step_interrupted(self) -> None:
72
+ self.current_step_status = events.StepStatus.INTERRUPTED
73
+
74
+ def mark_as_seen_in_run(self, exc: CheckFailed) -> None:
75
+ key = _failure_cache_key(exc)
76
+ self.seen_in_run.add(key)
77
+ causes = exc.causes or ()
78
+ for cause in causes:
79
+ key = _failure_cache_key(cause)
80
+ self.seen_in_run.add(key)
81
+
82
+ def mark_as_seen_in_suite(self, exc: CheckFailed | AssertionError) -> None:
83
+ key = _failure_cache_key(exc)
84
+ self.seen_in_suite.add(key)
85
+
86
+ def mark_current_suite_as_seen_in_run(self) -> None:
87
+ self.seen_in_run.update(self.seen_in_suite)
88
+
89
+ def is_seen_in_run(self, exc: CheckFailed | AssertionError) -> bool:
90
+ key = _failure_cache_key(exc)
91
+ return key in self.seen_in_run
92
+
93
+ def is_seen_in_suite(self, exc: CheckFailed | AssertionError) -> bool:
94
+ key = _failure_cache_key(exc)
95
+ return key in self.seen_in_suite
96
+
97
+ def add_failed_check(self, check: Check) -> None:
98
+ self.failures_for_suite.append(check)
99
+
100
+ def reset(self) -> None:
101
+ self.failures_for_suite = []
102
+ self.seen_in_suite.clear()
103
+ self.reset_step()
@@ -0,0 +1,215 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from typing import TYPE_CHECKING, Type
7
+
8
+ if TYPE_CHECKING:
9
+ from ..models import Check
10
+ from .state_machine import APIStateMachine
11
+
12
+
13
+ class RunStatus(str, Enum):
14
+ """Status of the state machine run."""
15
+
16
+ SUCCESS = "success"
17
+ FAILURE = "failure"
18
+ ERROR = "error"
19
+ INTERRUPTED = "interrupted"
20
+
21
+
22
+ @dataclass
23
+ class StatefulEvent:
24
+ """Basic stateful test event."""
25
+
26
+ timestamp: float
27
+
28
+ __slots__ = ("timestamp",)
29
+
30
+
31
+ @dataclass
32
+ class RunStarted(StatefulEvent):
33
+ """Before executing all scenarios."""
34
+
35
+ started_at: float
36
+ state_machine: Type[APIStateMachine]
37
+
38
+ __slots__ = ("state_machine", "timestamp", "started_at")
39
+
40
+ def __init__(self, *, state_machine: Type[APIStateMachine]) -> None:
41
+ self.state_machine = state_machine
42
+ self.started_at = time.time()
43
+ self.timestamp = time.monotonic()
44
+
45
+
46
+ @dataclass
47
+ class RunFinished(StatefulEvent):
48
+ """After executing all scenarios."""
49
+
50
+ status: RunStatus
51
+
52
+ __slots__ = ("timestamp", "status")
53
+
54
+ def __init__(self, *, status: RunStatus) -> None:
55
+ self.status = status
56
+ self.timestamp = time.monotonic()
57
+
58
+
59
+ class SuiteStatus(str, Enum):
60
+ """Status of the suite execution."""
61
+
62
+ SUCCESS = "success"
63
+ FAILURE = "failure"
64
+ ERROR = "error"
65
+ INTERRUPTED = "interrupted"
66
+
67
+
68
+ @dataclass
69
+ class SuiteStarted(StatefulEvent):
70
+ """Before executing a set of scenarios."""
71
+
72
+ __slots__ = ("timestamp",)
73
+
74
+ def __init__(self) -> None:
75
+ self.timestamp = time.monotonic()
76
+
77
+
78
+ @dataclass
79
+ class SuiteFinished(StatefulEvent):
80
+ """After executing a set of scenarios."""
81
+
82
+ status: SuiteStatus
83
+ failures: list[Check]
84
+
85
+ __slots__ = ("timestamp", "status", "failures")
86
+
87
+ def __init__(self, *, status: SuiteStatus, failures: list[Check]) -> None:
88
+ self.status = status
89
+ self.failures = failures
90
+ self.timestamp = time.monotonic()
91
+
92
+
93
+ class ScenarioStatus(str, Enum):
94
+ """Status of a single scenario execution."""
95
+
96
+ SUCCESS = "success"
97
+ FAILURE = "failure"
98
+ ERROR = "error"
99
+ # Rejected by Hypothesis
100
+ REJECTED = "rejected"
101
+ INTERRUPTED = "interrupted"
102
+
103
+
104
+ @dataclass
105
+ class ScenarioStarted(StatefulEvent):
106
+ """Before a single state machine execution."""
107
+
108
+ # Whether this is a scenario that tries to reproduce a failure
109
+ is_final: bool
110
+
111
+ __slots__ = ("timestamp", "is_final")
112
+
113
+ def __init__(self, *, is_final: bool) -> None:
114
+ self.is_final = is_final
115
+ self.timestamp = time.monotonic()
116
+
117
+
118
+ @dataclass
119
+ class ScenarioFinished(StatefulEvent):
120
+ """After a single state machine execution."""
121
+
122
+ status: ScenarioStatus
123
+ # Whether this is a scenario that tries to reproduce a failure
124
+ is_final: bool
125
+
126
+ __slots__ = ("timestamp", "status", "is_final")
127
+
128
+ def __init__(self, *, status: ScenarioStatus, is_final: bool) -> None:
129
+ self.status = status
130
+ self.is_final = is_final
131
+ self.timestamp = time.monotonic()
132
+
133
+
134
+ class StepStatus(str, Enum):
135
+ """Status of a single state machine step."""
136
+
137
+ SUCCESS = "success"
138
+ FAILURE = "failure"
139
+ ERROR = "error"
140
+ INTERRUPTED = "interrupted"
141
+
142
+
143
+ @dataclass
144
+ class StepStarted(StatefulEvent):
145
+ """Before a single state machine step."""
146
+
147
+ __slots__ = ("timestamp",)
148
+
149
+ def __init__(self) -> None:
150
+ self.timestamp = time.monotonic()
151
+
152
+
153
+ @dataclass
154
+ class TransitionId:
155
+ """Id of the the that was hit."""
156
+
157
+ name: str
158
+ # Status code as defined in the transition, i.e. may be `default`
159
+ status_code: str
160
+ source: str
161
+
162
+ __slots__ = ("name", "status_code", "source")
163
+
164
+
165
+ @dataclass
166
+ class ResponseData:
167
+ """Common data for responses."""
168
+
169
+ status_code: int
170
+ elapsed: float
171
+ __slots__ = ("status_code", "elapsed")
172
+
173
+
174
+ @dataclass
175
+ class StepFinished(StatefulEvent):
176
+ """After a single state machine step."""
177
+
178
+ status: StepStatus
179
+ transition_id: TransitionId | None
180
+ target: str
181
+ response: ResponseData | None
182
+
183
+ __slots__ = ("timestamp", "status", "transition_id", "target", "response")
184
+
185
+ def __init__(
186
+ self, *, status: StepStatus, transition_id: TransitionId | None, target: str, response: ResponseData | None
187
+ ) -> None:
188
+ self.status = status
189
+ self.transition_id = transition_id
190
+ self.target = target
191
+ self.response = response
192
+ self.timestamp = time.monotonic()
193
+
194
+
195
+ @dataclass
196
+ class Interrupted(StatefulEvent):
197
+ """The state machine execution was interrupted."""
198
+
199
+ __slots__ = ("timestamp",)
200
+
201
+ def __init__(self) -> None:
202
+ self.timestamp = time.monotonic()
203
+
204
+
205
+ @dataclass
206
+ class Errored(StatefulEvent):
207
+ """An error occurred during the state machine execution."""
208
+
209
+ exception: Exception
210
+
211
+ __slots__ = ("timestamp", "exception")
212
+
213
+ def __init__(self, *, exception: Exception) -> None:
214
+ self.exception = exception
215
+ self.timestamp = time.monotonic()