schemathesis 4.0.0a1__py3-none-any.whl → 4.0.0a3__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 (44) hide show
  1. schemathesis/checks.py +6 -4
  2. schemathesis/cli/__init__.py +12 -1
  3. schemathesis/cli/commands/run/__init__.py +4 -4
  4. schemathesis/cli/commands/run/events.py +19 -4
  5. schemathesis/cli/commands/run/executor.py +9 -3
  6. schemathesis/cli/commands/run/filters.py +27 -19
  7. schemathesis/cli/commands/run/handlers/base.py +1 -1
  8. schemathesis/cli/commands/run/handlers/cassettes.py +1 -3
  9. schemathesis/cli/commands/run/handlers/output.py +860 -201
  10. schemathesis/cli/commands/run/validation.py +1 -1
  11. schemathesis/cli/ext/options.py +4 -1
  12. schemathesis/core/errors.py +8 -0
  13. schemathesis/core/failures.py +54 -24
  14. schemathesis/engine/core.py +1 -1
  15. schemathesis/engine/errors.py +11 -5
  16. schemathesis/engine/events.py +3 -97
  17. schemathesis/engine/phases/stateful/__init__.py +2 -0
  18. schemathesis/engine/phases/stateful/_executor.py +22 -50
  19. schemathesis/engine/phases/unit/__init__.py +1 -0
  20. schemathesis/engine/phases/unit/_executor.py +2 -1
  21. schemathesis/engine/phases/unit/_pool.py +1 -1
  22. schemathesis/engine/recorder.py +29 -23
  23. schemathesis/errors.py +19 -13
  24. schemathesis/generation/coverage.py +4 -4
  25. schemathesis/generation/hypothesis/builder.py +15 -12
  26. schemathesis/generation/stateful/state_machine.py +61 -45
  27. schemathesis/graphql/checks.py +3 -9
  28. schemathesis/openapi/checks.py +8 -33
  29. schemathesis/schemas.py +34 -14
  30. schemathesis/specs/graphql/schemas.py +16 -15
  31. schemathesis/specs/openapi/checks.py +50 -27
  32. schemathesis/specs/openapi/expressions/__init__.py +11 -15
  33. schemathesis/specs/openapi/expressions/nodes.py +20 -20
  34. schemathesis/specs/openapi/links.py +139 -118
  35. schemathesis/specs/openapi/patterns.py +170 -2
  36. schemathesis/specs/openapi/schemas.py +60 -36
  37. schemathesis/specs/openapi/stateful/__init__.py +185 -113
  38. schemathesis/specs/openapi/stateful/control.py +87 -0
  39. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/METADATA +1 -1
  40. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/RECORD +43 -43
  41. schemathesis/specs/openapi/expressions/context.py +0 -14
  42. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/WHEEL +0 -0
  43. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/entry_points.txt +0 -0
  44. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/licenses/LICENSE +0 -0
@@ -1,131 +1,159 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections import defaultdict
3
+ from dataclasses import dataclass
4
4
  from functools import lru_cache
5
5
  from typing import TYPE_CHECKING, Any, Callable, Iterator
6
6
 
7
7
  from hypothesis import strategies as st
8
8
  from hypothesis.stateful import Bundle, Rule, precondition, rule
9
9
 
10
- from schemathesis.core import NOT_SET, NotSet
11
10
  from schemathesis.core.result import Ok
11
+ from schemathesis.engine.recorder import ScenarioRecorder
12
+ from schemathesis.generation import GenerationMode
12
13
  from schemathesis.generation.case import Case
13
14
  from schemathesis.generation.hypothesis import strategies
14
- from schemathesis.generation.stateful.state_machine import APIStateMachine, Direction, StepResult, _normalize_name
15
-
16
- from ....generation import GenerationMode
17
- from .. import expressions
18
- from ..links import get_all_links
19
- from ..utils import expand_status_code
15
+ from schemathesis.generation.stateful.state_machine import APIStateMachine, StepInput, StepOutput, _normalize_name
16
+ from schemathesis.schemas import APIOperation
17
+ from schemathesis.specs.openapi.links import OpenApiLink, get_all_links
18
+ from schemathesis.specs.openapi.stateful.control import TransitionController
19
+ from schemathesis.specs.openapi.utils import expand_status_code
20
20
 
21
21
  if TYPE_CHECKING:
22
- from schemathesis.generation.stateful.state_machine import StepResult
23
-
24
- from ..schemas import BaseOpenAPISchema
22
+ from schemathesis.generation.stateful.state_machine import StepOutput
23
+ from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
25
24
 
26
- FilterFunction = Callable[["StepResult"], bool]
25
+ FilterFunction = Callable[["StepOutput"], bool]
27
26
 
28
27
 
29
28
  class OpenAPIStateMachine(APIStateMachine):
30
- _response_matchers: dict[str, Callable[[StepResult], str | None]]
29
+ _response_matchers: dict[str, Callable[[StepOutput], str | None]]
30
+ _transitions: ApiTransitions
31
+
32
+ def __init__(self) -> None:
33
+ self.recorder = ScenarioRecorder(label="Stateful tests")
34
+ self.control = TransitionController(self._transitions)
35
+ super().__init__()
31
36
 
32
- def _get_target_for_result(self, result: StepResult) -> str | None:
37
+ def _get_target_for_result(self, result: StepOutput) -> str | None:
33
38
  matcher = self._response_matchers.get(result.case.operation.label)
34
39
  if matcher is None:
35
40
  return None
36
41
  return matcher(result)
37
42
 
38
- def transform(self, result: StepResult, direction: Direction, case: Case) -> Case:
39
- context = expressions.ExpressionContext(case=result.case, response=result.response)
40
- direction.set_data(case, context=context)
41
- return case
42
-
43
43
 
44
44
  # The proportion of negative tests generated for "root" transitions
45
- NEGATIVE_TEST_CASES_THRESHOLD = 20
45
+ NEGATIVE_TEST_CASES_THRESHOLD = 10
46
46
 
47
47
 
48
- def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
49
- """Create a state machine class.
48
+ @dataclass
49
+ class OperationTransitions:
50
+ """Transitions for a single operation."""
50
51
 
51
- It aims to avoid making calls that are not likely to lead to a stateful call later. For example:
52
- 1. POST /users/
53
- 2. GET /users/{id}/
52
+ __slots__ = ("incoming", "outgoing")
54
53
 
55
- This state machine won't make calls to (2) without having a proper response from (1) first.
56
- """
54
+ def __init__(self) -> None:
55
+ self.incoming: list[OpenApiLink] = []
56
+ self.outgoing: list[OpenApiLink] = []
57
+
58
+
59
+ @dataclass
60
+ class ApiTransitions:
61
+ """Stores all transitions grouped by operation."""
62
+
63
+ __slots__ = ("operations",)
64
+
65
+ def __init__(self) -> None:
66
+ # operation label -> its transitions
67
+ self.operations: dict[str, OperationTransitions] = {}
68
+
69
+ def add_outgoing(self, source: str, link: OpenApiLink) -> None:
70
+ """Record an outgoing transition from source operation."""
71
+ self.operations.setdefault(source, OperationTransitions()).outgoing.append(link)
72
+ self.operations.setdefault(link.target.label, OperationTransitions()).incoming.append(link)
73
+
74
+
75
+ def collect_transitions(operations: list[APIOperation]) -> ApiTransitions:
76
+ """Collect all transitions between operations."""
77
+ transitions = ApiTransitions()
78
+
79
+ selected_labels = {operation.label for operation in operations}
80
+ for operation in operations:
81
+ for _, link in get_all_links(operation):
82
+ if link.target.label in selected_labels:
83
+ transitions.add_outgoing(operation.label, link)
84
+
85
+ return transitions
86
+
87
+
88
+ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
57
89
  operations = [result.ok() for result in schema.get_all_operations() if isinstance(result, Ok)]
58
90
  bundles = {}
59
- incoming_transitions = defaultdict(list)
60
- _response_matchers: dict[str, Callable[[StepResult], str | None]] = {}
61
- # Statistic structure follows the links and count for each response status code
91
+ transitions = collect_transitions(operations)
92
+ _response_matchers: dict[str, Callable[[StepOutput], str | None]] = {}
93
+
94
+ # Create bundles and matchers
62
95
  for operation in operations:
63
96
  all_status_codes = tuple(operation.definition.raw["responses"])
64
97
  bundle_matchers = []
65
- for _, link in get_all_links(operation):
66
- bundle_name = f"{operation.label} -> {link.status_code}"
67
- bundles[bundle_name] = Bundle(bundle_name)
68
- target_operation = link.get_target_operation()
69
- incoming_transitions[target_operation.label].append(link)
70
- bundle_matchers.append((bundle_name, make_response_filter(link.status_code, all_status_codes)))
98
+
99
+ if operation.label in transitions.operations:
100
+ # Use outgoing transitions
101
+ for link in transitions.operations[operation.label].outgoing:
102
+ bundle_name = f"{operation.label} -> {link.status_code}"
103
+ bundles[bundle_name] = Bundle(bundle_name)
104
+ bundle_matchers.append((bundle_name, make_response_filter(link.status_code, all_status_codes)))
105
+
71
106
  if bundle_matchers:
72
107
  _response_matchers[operation.label] = make_response_matcher(bundle_matchers)
108
+
73
109
  rules = {}
74
110
  catch_all = Bundle("catch_all")
75
111
 
76
112
  for target in operations:
77
- incoming = incoming_transitions.get(target.label)
78
- if incoming is not None:
79
- for link in incoming:
80
- source = link.operation
81
- bundle_name = f"{source.label} -> {link.status_code}"
82
- name = _normalize_name(f"{target.label} -> {link.status_code}")
83
- case_strategy = strategies.combine(
84
- [target.as_strategy(generation_mode=mode) for mode in schema.generation_config.modes]
85
- )
86
- bundle = bundles[bundle_name]
87
- rules[name] = transition(
88
- name=name,
89
- target=catch_all,
90
- previous=bundle,
91
- case=case_strategy,
92
- link=st.just(link),
113
+ if target.label in transitions.operations:
114
+ incoming = transitions.operations[target.label].incoming
115
+ if incoming:
116
+ for link in incoming:
117
+ bundle_name = f"{link.source.label} -> {link.status_code}"
118
+ name = _normalize_name(f"{link.status_code} -> {target.label}")
119
+ name = _normalize_name(f"{link.source.label} -> {link.status_code} -> {target.label}")
120
+ assert name not in rules
121
+ rules[name] = precondition(is_transition_allowed(bundle_name, link.source.label, target.label))(
122
+ transition(
123
+ name=name,
124
+ target=catch_all,
125
+ input=bundles[bundle_name].flatmap(
126
+ into_step_input(target=target, link=link, modes=schema.generation_config.modes)
127
+ ),
128
+ )
129
+ )
130
+ if transitions.operations[target.label].outgoing and target.method == "post":
131
+ # Allow POST methods for operations with outgoing transitions.
132
+ # This approach also includes cases when there is an incoming transition back to POST
133
+ # For example, POST /users/ -> GET /users/{id}/
134
+ # The source operation has no prerequisite, but we need to allow this rule to be executed
135
+ # in order to reach other transitions
136
+ name = _normalize_name(f"{target.label} -> X")
137
+ if len(schema.generation_config.modes) == 1:
138
+ case_strategy = target.as_strategy(generation_mode=schema.generation_config.modes[0])
139
+ else:
140
+ _strategies = {
141
+ method: target.as_strategy(generation_mode=method) for method in schema.generation_config.modes
142
+ }
143
+
144
+ @st.composite # type: ignore[misc]
145
+ def case_strategy_factory(
146
+ draw: st.DrawFn, strategies: dict[GenerationMode, st.SearchStrategy] = _strategies
147
+ ) -> Case:
148
+ if draw(st.integers(min_value=0, max_value=99)) < NEGATIVE_TEST_CASES_THRESHOLD:
149
+ return draw(strategies[GenerationMode.NEGATIVE])
150
+ return draw(strategies[GenerationMode.POSITIVE])
151
+
152
+ case_strategy = case_strategy_factory()
153
+
154
+ rules[name] = precondition(is_root_allowed(target.label))(
155
+ transition(name=name, target=catch_all, input=case_strategy.map(StepInput.initial))
93
156
  )
94
- elif any(
95
- incoming.operation.label == target.label
96
- for transitions in incoming_transitions.values()
97
- for incoming in transitions
98
- ):
99
- # No incoming transitions, but has at least one outgoing transition
100
- # For example, POST /users/ -> GET /users/{id}/
101
- # The source operation has no prerequisite, but we need to allow this rule to be executed
102
- # in order to reach other transitions
103
- name = _normalize_name(f"{target.label} -> X")
104
- if len(schema.generation_config.modes) == 1:
105
- case_strategy = target.as_strategy(generation_mode=schema.generation_config.modes[0])
106
- else:
107
- _strategies = {
108
- method: target.as_strategy(generation_mode=method) for method in schema.generation_config.modes
109
- }
110
-
111
- @st.composite # type: ignore[misc]
112
- def case_strategy_factory(
113
- draw: st.DrawFn, strategies: dict[GenerationMode, st.SearchStrategy] = _strategies
114
- ) -> Case:
115
- if draw(st.integers(min_value=0, max_value=99)) < NEGATIVE_TEST_CASES_THRESHOLD:
116
- return draw(strategies[GenerationMode.NEGATIVE])
117
- return draw(strategies[GenerationMode.POSITIVE])
118
-
119
- case_strategy = case_strategy_factory()
120
-
121
- rules[name] = precondition(ensure_links_followed)(
122
- transition(
123
- name=name,
124
- target=catch_all,
125
- previous=st.none(),
126
- case=case_strategy,
127
- )
128
- )
129
157
 
130
158
  return type(
131
159
  "APIWorkflow",
@@ -134,41 +162,85 @@ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
134
162
  "schema": schema,
135
163
  "bundles": bundles,
136
164
  "_response_matchers": _response_matchers,
165
+ "_transitions": transitions,
137
166
  **rules,
138
167
  },
139
168
  )
140
169
 
141
170
 
142
- def ensure_links_followed(machine: APIStateMachine) -> bool:
143
- # If there are responses that have links to follow, reject any rule without incoming transitions
144
- for bundle in machine.bundles.values():
145
- if bundle:
146
- return False
147
- return True
171
+ def into_step_input(
172
+ target: APIOperation, link: OpenApiLink, modes: list[GenerationMode]
173
+ ) -> Callable[[StepOutput], st.SearchStrategy[StepInput]]:
174
+ def builder(_output: StepOutput) -> st.SearchStrategy[StepInput]:
175
+ @st.composite # type: ignore[misc]
176
+ def inner(draw: st.DrawFn, output: StepOutput) -> StepInput:
177
+ transition_data = link.extract(output)
148
178
 
149
-
150
- def transition(
151
- *,
152
- name: str,
153
- target: Bundle,
154
- previous: Bundle | st.SearchStrategy,
155
- case: st.SearchStrategy,
156
- link: st.SearchStrategy | NotSet = NOT_SET,
157
- ) -> Callable[[Callable], Rule]:
158
- def step_function(*args_: Any, **kwargs_: Any) -> StepResult | None:
159
- return APIStateMachine._step(*args_, **kwargs_)
179
+ kwargs: dict[str, Any] = {
180
+ container: {
181
+ name: extracted.value.ok()
182
+ for name, extracted in data.items()
183
+ if isinstance(extracted.value, Ok) and extracted.value.ok() is not None
184
+ }
185
+ for container, data in transition_data.parameters.items()
186
+ }
187
+ if (
188
+ transition_data.request_body is not None
189
+ and isinstance(transition_data.request_body.value, Ok)
190
+ and not link.merge_body
191
+ ):
192
+ kwargs["body"] = transition_data.request_body.value.ok()
193
+ cases = strategies.combine([target.as_strategy(generation_mode=mode, **kwargs) for mode in modes])
194
+ case = draw(cases)
195
+ if (
196
+ transition_data.request_body is not None
197
+ and isinstance(transition_data.request_body.value, Ok)
198
+ and link.merge_body
199
+ ):
200
+ new = transition_data.request_body.value.ok()
201
+ if isinstance(case.body, dict) and isinstance(new, dict):
202
+ case.body = {**case.body, **new}
203
+ else:
204
+ case.body = new
205
+ return StepInput(case=case, transition=transition_data)
206
+
207
+ return inner(output=_output)
208
+
209
+ return builder
210
+
211
+
212
+ def is_transition_allowed(bundle_name: str, source: str, target: str) -> Callable[[OpenAPIStateMachine], bool]:
213
+ def inner(machine: OpenAPIStateMachine) -> bool:
214
+ return bool(machine.bundles.get(bundle_name)) and machine.control.allow_transition(source, target)
215
+
216
+ return inner
217
+
218
+
219
+ def is_root_allowed(label: str) -> Callable[[OpenAPIStateMachine], bool]:
220
+ def inner(machine: OpenAPIStateMachine) -> bool:
221
+ return machine.control.allow_root_transition(label, machine.bundles)
222
+
223
+ return inner
224
+
225
+
226
+ def transition(*, name: str, target: Bundle, input: st.SearchStrategy[StepInput]) -> Callable[[Callable], Rule]:
227
+ def step_function(self: OpenAPIStateMachine, input: StepInput) -> StepOutput | None:
228
+ if input.transition is not None:
229
+ self.recorder.record_case(
230
+ parent_id=input.transition.parent_id, transition=input.transition, case=input.case
231
+ )
232
+ else:
233
+ self.recorder.record_case(parent_id=None, transition=None, case=input.case)
234
+ self.control.record_step(input, self.recorder)
235
+ return APIStateMachine._step(self, input=input)
160
236
 
161
237
  step_function.__name__ = name
162
238
 
163
- kwargs = {"target": target, "previous": previous, "case": case}
164
- if not isinstance(link, NotSet):
165
- kwargs["link"] = link
166
-
167
- return rule(**kwargs)(step_function)
239
+ return rule(target=target, input=input)(step_function)
168
240
 
169
241
 
170
- def make_response_matcher(matchers: list[tuple[str, FilterFunction]]) -> Callable[[StepResult], str | None]:
171
- def compare(result: StepResult) -> str | None:
242
+ def make_response_matcher(matchers: list[tuple[str, FilterFunction]]) -> Callable[[StepOutput], str | None]:
243
+ def compare(result: StepOutput) -> str | None:
172
244
  for bundle_name, response_filter in matchers:
173
245
  if response_filter(result):
174
246
  return bundle_name
@@ -196,7 +268,7 @@ def match_status_code(status_code: str) -> FilterFunction:
196
268
  """
197
269
  status_codes = set(expand_status_code(status_code))
198
270
 
199
- def compare(result: StepResult) -> bool:
271
+ def compare(result: StepOutput) -> bool:
200
272
  return result.response.status_code in status_codes
201
273
 
202
274
  compare.__name__ = f"match_{status_code}_response"
@@ -214,7 +286,7 @@ def default_status_code(status_codes: Iterator[str]) -> FilterFunction:
214
286
  status_code for value in status_codes if value != "default" for status_code in expand_status_code(value)
215
287
  }
216
288
 
217
- def match_default_response(result: StepResult) -> bool:
289
+ def match_default_response(result: StepOutput) -> bool:
218
290
  return result.response.status_code not in expanded_status_codes
219
291
 
220
292
  return match_default_response
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING
6
+
7
+ from schemathesis.engine.recorder import ScenarioRecorder
8
+ from schemathesis.generation.stateful.state_machine import DEFAULT_STATEFUL_STEP_COUNT
9
+
10
+ if TYPE_CHECKING:
11
+ from requests.structures import CaseInsensitiveDict
12
+
13
+ from schemathesis.generation.stateful.state_machine import StepInput
14
+ from schemathesis.specs.openapi.stateful import ApiTransitions
15
+
16
+
17
+ # It is enough to be able to catch double-click type of issues
18
+ MAX_OPERATIONS_PER_SOURCE_CAP = 2
19
+ # Maximum number of concurrent root sources (e.g., active users in the system)
20
+ MAX_ROOT_SOURCES = 2
21
+
22
+
23
+ def _get_max_operations_per_source(transitions: ApiTransitions) -> int:
24
+ """Calculate global limit based on number of sources to maximize diversity of used API calls."""
25
+ sources = len(transitions.operations)
26
+
27
+ if sources == 0:
28
+ return MAX_OPERATIONS_PER_SOURCE_CAP
29
+
30
+ # Total steps divided by number of sources, but never below the cap
31
+ return max(MAX_OPERATIONS_PER_SOURCE_CAP, DEFAULT_STATEFUL_STEP_COUNT // sources)
32
+
33
+
34
+ @dataclass
35
+ class TransitionController:
36
+ """Controls which transitions can be executed in a state machine."""
37
+
38
+ __slots__ = ("transitions", "max_operations_per_source", "statistic")
39
+
40
+ def __init__(self, transitions: ApiTransitions) -> None:
41
+ # Incoming & outgoing transitions available in the state machine
42
+ self.transitions = transitions
43
+ self.max_operations_per_source = _get_max_operations_per_source(transitions)
44
+ # source -> derived API calls
45
+ self.statistic: dict[str, dict[str, Counter[str]]] = {}
46
+
47
+ def record_step(self, input: StepInput, recorder: ScenarioRecorder) -> None:
48
+ """Record API call input."""
49
+ case = input.case
50
+
51
+ if (
52
+ case.operation.label in self.transitions.operations
53
+ and self.transitions.operations[case.operation.label].outgoing
54
+ ):
55
+ # This API operation has outgoing transitions, hence record it as a source
56
+ entry = self.statistic.setdefault(input.case.operation.label, {})
57
+ entry[input.case.id] = Counter()
58
+
59
+ if input.transition is not None:
60
+ # Find immediate parent and record as derived operation
61
+ parent = recorder.cases[input.transition.parent_id]
62
+ source = parent.value.operation.label
63
+ case_id = parent.value.id
64
+
65
+ if source in self.statistic and case_id in self.statistic[source]:
66
+ self.statistic[source][case_id][case.operation.label] += 1
67
+
68
+ def allow_root_transition(self, source: str, bundles: dict[str, CaseInsensitiveDict]) -> bool:
69
+ """Decide if this root transition should be allowed now."""
70
+ if len(self.statistic.get(source, {})) < MAX_ROOT_SOURCES:
71
+ return True
72
+
73
+ # If all non-root operations are blocked, then allow root ones to make progress
74
+ history = {name.split("->")[0].strip() for name, values in bundles.items() if values}
75
+ return all(
76
+ incoming.source.label not in history
77
+ or not self.allow_transition(incoming.source.label, incoming.target.label)
78
+ for transitions in self.transitions.operations.values()
79
+ for incoming in transitions.incoming
80
+ if transitions.incoming
81
+ )
82
+
83
+ def allow_transition(self, source: str, target: str) -> bool:
84
+ """Decide if this transition should be allowed now."""
85
+ existing = self.statistic.get(source, {})
86
+ total = sum(metric.get(target, 0) for metric in existing.values())
87
+ return total < self.max_operations_per_source
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: schemathesis
3
- Version: 4.0.0a1
3
+ Version: 4.0.0a3
4
4
  Summary: Property-based testing framework for Open API and GraphQL based apps
5
5
  Project-URL: Documentation, https://schemathesis.readthedocs.io/en/stable/
6
6
  Project-URL: Changelog, https://schemathesis.readthedocs.io/en/stable/changelog.html