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.
- schemathesis/checks.py +6 -4
- schemathesis/cli/__init__.py +12 -1
- schemathesis/cli/commands/run/__init__.py +4 -4
- schemathesis/cli/commands/run/events.py +19 -4
- schemathesis/cli/commands/run/executor.py +9 -3
- schemathesis/cli/commands/run/filters.py +27 -19
- schemathesis/cli/commands/run/handlers/base.py +1 -1
- schemathesis/cli/commands/run/handlers/cassettes.py +1 -3
- schemathesis/cli/commands/run/handlers/output.py +860 -201
- schemathesis/cli/commands/run/validation.py +1 -1
- schemathesis/cli/ext/options.py +4 -1
- schemathesis/core/errors.py +8 -0
- schemathesis/core/failures.py +54 -24
- schemathesis/engine/core.py +1 -1
- schemathesis/engine/errors.py +11 -5
- schemathesis/engine/events.py +3 -97
- schemathesis/engine/phases/stateful/__init__.py +2 -0
- schemathesis/engine/phases/stateful/_executor.py +22 -50
- schemathesis/engine/phases/unit/__init__.py +1 -0
- schemathesis/engine/phases/unit/_executor.py +2 -1
- schemathesis/engine/phases/unit/_pool.py +1 -1
- schemathesis/engine/recorder.py +29 -23
- schemathesis/errors.py +19 -13
- schemathesis/generation/coverage.py +4 -4
- schemathesis/generation/hypothesis/builder.py +15 -12
- schemathesis/generation/stateful/state_machine.py +61 -45
- schemathesis/graphql/checks.py +3 -9
- schemathesis/openapi/checks.py +8 -33
- schemathesis/schemas.py +34 -14
- schemathesis/specs/graphql/schemas.py +16 -15
- schemathesis/specs/openapi/checks.py +50 -27
- schemathesis/specs/openapi/expressions/__init__.py +11 -15
- schemathesis/specs/openapi/expressions/nodes.py +20 -20
- schemathesis/specs/openapi/links.py +139 -118
- schemathesis/specs/openapi/patterns.py +170 -2
- schemathesis/specs/openapi/schemas.py +60 -36
- schemathesis/specs/openapi/stateful/__init__.py +185 -113
- schemathesis/specs/openapi/stateful/control.py +87 -0
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/METADATA +1 -1
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/RECORD +43 -43
- schemathesis/specs/openapi/expressions/context.py +0 -14
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/entry_points.txt +0 -0
- {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
|
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,
|
15
|
-
|
16
|
-
from
|
17
|
-
from
|
18
|
-
from
|
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
|
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[["
|
25
|
+
FilterFunction = Callable[["StepOutput"], bool]
|
27
26
|
|
28
27
|
|
29
28
|
class OpenAPIStateMachine(APIStateMachine):
|
30
|
-
_response_matchers: dict[str, Callable[[
|
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:
|
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 =
|
45
|
+
NEGATIVE_TEST_CASES_THRESHOLD = 10
|
46
46
|
|
47
47
|
|
48
|
-
|
49
|
-
|
48
|
+
@dataclass
|
49
|
+
class OperationTransitions:
|
50
|
+
"""Transitions for a single operation."""
|
50
51
|
|
51
|
-
|
52
|
-
1. POST /users/
|
53
|
-
2. GET /users/{id}/
|
52
|
+
__slots__ = ("incoming", "outgoing")
|
54
53
|
|
55
|
-
|
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
|
-
|
60
|
-
_response_matchers: dict[str, Callable[[
|
61
|
-
|
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
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
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
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
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
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
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
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
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
|
-
|
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[[
|
171
|
-
def compare(result:
|
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:
|
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:
|
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.
|
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
|