schemathesis 3.25.5__py3-none-any.whl → 3.39.7__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/__init__.py +6 -6
- schemathesis/_compat.py +2 -2
- schemathesis/_dependency_versions.py +4 -2
- schemathesis/_hypothesis.py +369 -56
- schemathesis/_lazy_import.py +1 -0
- schemathesis/_override.py +5 -4
- schemathesis/_patches.py +21 -0
- schemathesis/_rate_limiter.py +7 -0
- schemathesis/_xml.py +75 -22
- schemathesis/auths.py +78 -16
- schemathesis/checks.py +21 -9
- schemathesis/cli/__init__.py +793 -448
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/callbacks.py +58 -13
- schemathesis/cli/cassettes.py +233 -47
- schemathesis/cli/constants.py +8 -2
- schemathesis/cli/context.py +24 -4
- schemathesis/cli/debug.py +2 -1
- schemathesis/cli/handlers.py +4 -1
- schemathesis/cli/junitxml.py +103 -22
- schemathesis/cli/options.py +15 -4
- schemathesis/cli/output/default.py +286 -115
- schemathesis/cli/output/short.py +25 -6
- schemathesis/cli/reporting.py +79 -0
- schemathesis/cli/sanitization.py +6 -0
- schemathesis/code_samples.py +5 -3
- schemathesis/constants.py +1 -0
- schemathesis/contrib/openapi/__init__.py +1 -1
- schemathesis/contrib/openapi/fill_missing_examples.py +3 -1
- schemathesis/contrib/openapi/formats/uuid.py +2 -1
- schemathesis/contrib/unique_data.py +3 -3
- schemathesis/exceptions.py +76 -65
- schemathesis/experimental/__init__.py +35 -0
- schemathesis/extra/_aiohttp.py +1 -0
- schemathesis/extra/_flask.py +4 -1
- schemathesis/extra/_server.py +1 -0
- schemathesis/extra/pytest_plugin.py +17 -25
- schemathesis/failures.py +77 -9
- schemathesis/filters.py +185 -8
- schemathesis/fixups/__init__.py +1 -0
- schemathesis/fixups/fast_api.py +2 -2
- schemathesis/fixups/utf8_bom.py +1 -2
- schemathesis/generation/__init__.py +20 -36
- schemathesis/generation/_hypothesis.py +59 -0
- schemathesis/generation/_methods.py +44 -0
- schemathesis/generation/coverage.py +931 -0
- schemathesis/graphql.py +0 -1
- schemathesis/hooks.py +89 -12
- schemathesis/internal/checks.py +84 -0
- schemathesis/internal/copy.py +22 -3
- schemathesis/internal/deprecation.py +6 -2
- schemathesis/internal/diff.py +15 -0
- schemathesis/internal/extensions.py +27 -0
- schemathesis/internal/jsonschema.py +2 -1
- schemathesis/internal/output.py +68 -0
- schemathesis/internal/result.py +1 -1
- schemathesis/internal/transformation.py +11 -0
- schemathesis/lazy.py +138 -25
- schemathesis/loaders.py +7 -5
- schemathesis/models.py +323 -213
- schemathesis/parameters.py +4 -0
- schemathesis/runner/__init__.py +72 -22
- schemathesis/runner/events.py +86 -6
- schemathesis/runner/impl/context.py +104 -0
- schemathesis/runner/impl/core.py +447 -187
- schemathesis/runner/impl/solo.py +19 -29
- schemathesis/runner/impl/threadpool.py +70 -79
- schemathesis/{cli → runner}/probes.py +37 -25
- schemathesis/runner/serialization.py +150 -17
- schemathesis/sanitization.py +5 -1
- schemathesis/schemas.py +170 -102
- schemathesis/serializers.py +17 -4
- schemathesis/service/ci.py +1 -0
- schemathesis/service/client.py +39 -6
- schemathesis/service/events.py +5 -1
- schemathesis/service/extensions.py +224 -0
- schemathesis/service/hosts.py +6 -2
- schemathesis/service/metadata.py +25 -0
- schemathesis/service/models.py +211 -2
- schemathesis/service/report.py +6 -6
- schemathesis/service/serialization.py +60 -71
- schemathesis/service/usage.py +1 -0
- schemathesis/specs/graphql/_cache.py +26 -0
- schemathesis/specs/graphql/loaders.py +25 -5
- schemathesis/specs/graphql/nodes.py +1 -0
- schemathesis/specs/graphql/scalars.py +2 -2
- schemathesis/specs/graphql/schemas.py +130 -100
- schemathesis/specs/graphql/validation.py +1 -2
- schemathesis/specs/openapi/__init__.py +1 -0
- schemathesis/specs/openapi/_cache.py +123 -0
- schemathesis/specs/openapi/_hypothesis.py +79 -61
- schemathesis/specs/openapi/checks.py +504 -25
- schemathesis/specs/openapi/converter.py +31 -4
- schemathesis/specs/openapi/definitions.py +10 -17
- schemathesis/specs/openapi/examples.py +143 -31
- schemathesis/specs/openapi/expressions/__init__.py +37 -2
- schemathesis/specs/openapi/expressions/context.py +1 -1
- schemathesis/specs/openapi/expressions/extractors.py +26 -0
- schemathesis/specs/openapi/expressions/lexer.py +20 -18
- schemathesis/specs/openapi/expressions/nodes.py +29 -6
- schemathesis/specs/openapi/expressions/parser.py +26 -5
- schemathesis/specs/openapi/formats.py +44 -0
- schemathesis/specs/openapi/links.py +125 -42
- schemathesis/specs/openapi/loaders.py +77 -36
- schemathesis/specs/openapi/media_types.py +34 -0
- schemathesis/specs/openapi/negative/__init__.py +6 -3
- schemathesis/specs/openapi/negative/mutations.py +21 -6
- schemathesis/specs/openapi/parameters.py +39 -25
- schemathesis/specs/openapi/patterns.py +137 -0
- schemathesis/specs/openapi/references.py +37 -7
- schemathesis/specs/openapi/schemas.py +368 -242
- schemathesis/specs/openapi/security.py +25 -7
- schemathesis/specs/openapi/serialization.py +1 -0
- schemathesis/specs/openapi/stateful/__init__.py +198 -70
- schemathesis/specs/openapi/stateful/statistic.py +198 -0
- schemathesis/specs/openapi/stateful/types.py +14 -0
- schemathesis/specs/openapi/utils.py +6 -1
- schemathesis/specs/openapi/validation.py +1 -0
- schemathesis/stateful/__init__.py +35 -21
- schemathesis/stateful/config.py +97 -0
- schemathesis/stateful/context.py +135 -0
- schemathesis/stateful/events.py +274 -0
- schemathesis/stateful/runner.py +309 -0
- schemathesis/stateful/sink.py +68 -0
- schemathesis/stateful/state_machine.py +67 -38
- schemathesis/stateful/statistic.py +22 -0
- schemathesis/stateful/validation.py +100 -0
- schemathesis/targets.py +33 -1
- schemathesis/throttling.py +25 -5
- schemathesis/transports/__init__.py +354 -0
- schemathesis/transports/asgi.py +7 -0
- schemathesis/transports/auth.py +25 -2
- schemathesis/transports/content_types.py +3 -1
- schemathesis/transports/headers.py +2 -1
- schemathesis/transports/responses.py +9 -4
- schemathesis/types.py +9 -0
- schemathesis/utils.py +11 -16
- schemathesis-3.39.7.dist-info/METADATA +293 -0
- schemathesis-3.39.7.dist-info/RECORD +160 -0
- {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/WHEEL +1 -1
- schemathesis/specs/openapi/filters.py +0 -49
- schemathesis/specs/openapi/stateful/links.py +0 -92
- schemathesis-3.25.5.dist-info/METADATA +0 -356
- schemathesis-3.25.5.dist-info/RECORD +0 -134
- {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
"""Processing of ``securityDefinitions`` or ``securitySchemes`` keywords."""
|
|
2
|
+
|
|
2
3
|
from __future__ import annotations
|
|
3
|
-
from dataclasses import dataclass
|
|
4
|
-
from typing import Any, ClassVar, Generator
|
|
5
4
|
|
|
6
|
-
from
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Generator
|
|
7
7
|
|
|
8
|
-
from ...models import APIOperation
|
|
9
8
|
from .parameters import OpenAPI20Parameter, OpenAPI30Parameter, OpenAPIParameter
|
|
10
9
|
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from jsonschema import RefResolver
|
|
12
|
+
|
|
13
|
+
from ...models import APIOperation
|
|
14
|
+
|
|
11
15
|
|
|
12
16
|
@dataclass
|
|
13
17
|
class BaseSecurityProcessor:
|
|
@@ -124,9 +128,23 @@ class OpenAPISecurityProcessor(BaseSecurityProcessor):
|
|
|
124
128
|
"""In Open API 3 security definitions are located in ``components`` and may have references inside."""
|
|
125
129
|
components = schema.get("components", {})
|
|
126
130
|
security_schemes = components.get("securitySchemes", {})
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
131
|
+
# At this point, the resolution scope could differ from the root scope, that's why we need to restore it
|
|
132
|
+
# as now we resolve root-level references
|
|
133
|
+
if len(resolver._scopes_stack) > 1:
|
|
134
|
+
scope = resolver.resolution_scope
|
|
135
|
+
resolver.pop_scope()
|
|
136
|
+
else:
|
|
137
|
+
scope = None
|
|
138
|
+
resolve = resolver.resolve
|
|
139
|
+
try:
|
|
140
|
+
if "$ref" in security_schemes:
|
|
141
|
+
return resolve(security_schemes["$ref"])[1]
|
|
142
|
+
return {
|
|
143
|
+
key: resolve(value["$ref"])[1] if "$ref" in value else value for key, value in security_schemes.items()
|
|
144
|
+
}
|
|
145
|
+
finally:
|
|
146
|
+
if scope is not None:
|
|
147
|
+
resolver._scopes_stack.append(scope)
|
|
130
148
|
|
|
131
149
|
def _make_http_auth_parameter(self, definition: dict[str, Any]) -> dict[str, Any]:
|
|
132
150
|
schema = make_auth_header_schema(definition)
|
|
@@ -1,29 +1,51 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
from collections import defaultdict
|
|
3
|
-
from
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Iterator
|
|
4
6
|
|
|
5
7
|
from hypothesis import strategies as st
|
|
6
8
|
from hypothesis.stateful import Bundle, Rule, precondition, rule
|
|
7
|
-
from requests.structures import CaseInsensitiveDict
|
|
8
9
|
|
|
10
|
+
from ....constants import NOT_SET
|
|
11
|
+
from ....generation import DataGenerationMethod, combine_strategies
|
|
9
12
|
from ....internal.result import Ok
|
|
10
13
|
from ....stateful.state_machine import APIStateMachine, Direction, StepResult
|
|
11
|
-
from ....
|
|
14
|
+
from ....types import NotSet
|
|
12
15
|
from .. import expressions
|
|
13
|
-
from
|
|
14
|
-
from
|
|
16
|
+
from ..links import get_all_links
|
|
17
|
+
from ..utils import expand_status_code
|
|
18
|
+
from .statistic import OpenAPILinkStats
|
|
15
19
|
|
|
16
20
|
if TYPE_CHECKING:
|
|
17
|
-
from ....models import
|
|
21
|
+
from ....models import Case
|
|
18
22
|
from ..schemas import BaseOpenAPISchema
|
|
23
|
+
from .types import FilterFunction, LinkName, StatusCode, TargetName
|
|
19
24
|
|
|
20
25
|
|
|
21
26
|
class OpenAPIStateMachine(APIStateMachine):
|
|
27
|
+
_transition_stats_template: ClassVar[OpenAPILinkStats]
|
|
28
|
+
_response_matchers: dict[str, Callable[[StepResult], str | None]]
|
|
29
|
+
|
|
30
|
+
def _get_target_for_result(self, result: StepResult) -> str | None:
|
|
31
|
+
matcher = self._response_matchers.get(result.case.operation.verbose_name)
|
|
32
|
+
if matcher is None:
|
|
33
|
+
return None
|
|
34
|
+
return matcher(result)
|
|
35
|
+
|
|
22
36
|
def transform(self, result: StepResult, direction: Direction, case: Case) -> Case:
|
|
23
37
|
context = expressions.ExpressionContext(case=result.case, response=result.response)
|
|
24
38
|
direction.set_data(case, elapsed=result.elapsed, context=context)
|
|
25
39
|
return case
|
|
26
40
|
|
|
41
|
+
@classmethod
|
|
42
|
+
def format_rules(cls) -> str:
|
|
43
|
+
return "\n".join(item.line for item in cls._transition_stats_template.iter_with_format())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# The proportion of negative tests generated for "root" transitions
|
|
47
|
+
NEGATIVE_TEST_CASES_THRESHOLD = 20
|
|
48
|
+
|
|
27
49
|
|
|
28
50
|
def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
|
|
29
51
|
"""Create a state machine class.
|
|
@@ -34,75 +56,181 @@ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
|
|
|
34
56
|
|
|
35
57
|
This state machine won't make calls to (2) without having a proper response from (1) first.
|
|
36
58
|
"""
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
connections: APIOperationConnections = defaultdict(list)
|
|
59
|
+
from ....stateful.state_machine import _normalize_name
|
|
60
|
+
|
|
40
61
|
operations = [result.ok() for result in schema.get_all_operations() if isinstance(result, Ok)]
|
|
62
|
+
bundles = {}
|
|
63
|
+
incoming_transitions = defaultdict(list)
|
|
64
|
+
_response_matchers: dict[str, Callable[[StepResult], str | None]] = {}
|
|
65
|
+
# Statistic structure follows the links and count for each response status code
|
|
66
|
+
transitions = {}
|
|
41
67
|
for operation in operations:
|
|
42
|
-
|
|
68
|
+
operation_links: dict[StatusCode, dict[TargetName, dict[LinkName, dict[int | None, int]]]] = {}
|
|
69
|
+
all_status_codes = tuple(operation.definition.raw["responses"])
|
|
70
|
+
bundle_matchers = []
|
|
71
|
+
for _, link in get_all_links(operation):
|
|
72
|
+
bundle_name = f"{operation.verbose_name} -> {link.status_code}"
|
|
73
|
+
bundles[bundle_name] = Bundle(bundle_name)
|
|
74
|
+
target_operation = link.get_target_operation()
|
|
75
|
+
incoming_transitions[target_operation.verbose_name].append(link)
|
|
76
|
+
response_targets = operation_links.setdefault(link.status_code, {})
|
|
77
|
+
target_links = response_targets.setdefault(target_operation.verbose_name, {})
|
|
78
|
+
target_links[link.name] = {}
|
|
79
|
+
bundle_matchers.append((bundle_name, make_response_filter(link.status_code, all_status_codes)))
|
|
80
|
+
if operation_links:
|
|
81
|
+
transitions[operation.verbose_name] = operation_links
|
|
82
|
+
if bundle_matchers:
|
|
83
|
+
_response_matchers[operation.verbose_name] = make_response_matcher(bundle_matchers)
|
|
84
|
+
rules = {}
|
|
85
|
+
catch_all = Bundle("catch_all")
|
|
86
|
+
|
|
87
|
+
for target in operations:
|
|
88
|
+
incoming = incoming_transitions.get(target.verbose_name)
|
|
89
|
+
if incoming is not None:
|
|
90
|
+
for link in incoming:
|
|
91
|
+
source = link.operation
|
|
92
|
+
bundle_name = f"{source.verbose_name} -> {link.status_code}"
|
|
93
|
+
name = _normalize_name(f"{target.verbose_name} -> {link.status_code}")
|
|
94
|
+
case_strategy = combine_strategies(
|
|
95
|
+
[
|
|
96
|
+
target.as_strategy(data_generation_method=data_generation_method)
|
|
97
|
+
for data_generation_method in schema.data_generation_methods
|
|
98
|
+
]
|
|
99
|
+
)
|
|
100
|
+
bundle = bundles[bundle_name]
|
|
101
|
+
rules[name] = transition(
|
|
102
|
+
name=name,
|
|
103
|
+
target=catch_all,
|
|
104
|
+
previous=bundle,
|
|
105
|
+
case=case_strategy,
|
|
106
|
+
link=st.just(link),
|
|
107
|
+
)
|
|
108
|
+
elif any(
|
|
109
|
+
incoming.operation.verbose_name == target.verbose_name
|
|
110
|
+
for transitions in incoming_transitions.values()
|
|
111
|
+
for incoming in transitions
|
|
112
|
+
):
|
|
113
|
+
# No incoming transitions, but has at least one outgoing transition
|
|
114
|
+
# For example, POST /users/ -> GET /users/{id}/
|
|
115
|
+
# The source operation has no prerequisite, but we need to allow this rule to be executed
|
|
116
|
+
# in order to reach other transitions
|
|
117
|
+
name = _normalize_name(f"{target.verbose_name} -> X")
|
|
118
|
+
if len(schema.data_generation_methods) == 1:
|
|
119
|
+
case_strategy = target.as_strategy(data_generation_method=schema.data_generation_methods[0])
|
|
120
|
+
else:
|
|
121
|
+
strategies = {
|
|
122
|
+
method: target.as_strategy(data_generation_method=method)
|
|
123
|
+
for method in schema.data_generation_methods
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@st.composite # type: ignore[misc]
|
|
127
|
+
def case_strategy_factory(
|
|
128
|
+
draw: st.DrawFn, strategies: dict[DataGenerationMethod, st.SearchStrategy] = strategies
|
|
129
|
+
) -> Case:
|
|
130
|
+
if draw(st.integers(min_value=0, max_value=99)) < NEGATIVE_TEST_CASES_THRESHOLD:
|
|
131
|
+
return draw(strategies[DataGenerationMethod.negative])
|
|
132
|
+
return draw(strategies[DataGenerationMethod.positive])
|
|
133
|
+
|
|
134
|
+
case_strategy = case_strategy_factory()
|
|
135
|
+
|
|
136
|
+
rules[name] = precondition(ensure_links_followed)(
|
|
137
|
+
transition(
|
|
138
|
+
name=name,
|
|
139
|
+
target=catch_all,
|
|
140
|
+
previous=st.none(),
|
|
141
|
+
case=case_strategy,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return type(
|
|
146
|
+
"APIWorkflow",
|
|
147
|
+
(OpenAPIStateMachine,),
|
|
148
|
+
{
|
|
149
|
+
"schema": schema,
|
|
150
|
+
"bundles": bundles,
|
|
151
|
+
"_transition_stats_template": OpenAPILinkStats(transitions=transitions),
|
|
152
|
+
"_response_matchers": _response_matchers,
|
|
153
|
+
**rules,
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def ensure_links_followed(machine: APIStateMachine) -> bool:
|
|
159
|
+
# If there are responses that have links to follow, reject any rule without incoming transitions
|
|
160
|
+
for bundle in machine.bundles.values():
|
|
161
|
+
if bundle:
|
|
162
|
+
return False
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def transition(
|
|
167
|
+
*,
|
|
168
|
+
name: str,
|
|
169
|
+
target: Bundle,
|
|
170
|
+
previous: Bundle | st.SearchStrategy,
|
|
171
|
+
case: st.SearchStrategy,
|
|
172
|
+
link: st.SearchStrategy | NotSet = NOT_SET,
|
|
173
|
+
) -> Callable[[Callable], Rule]:
|
|
174
|
+
def step_function(*args_: Any, **kwargs_: Any) -> StepResult | None:
|
|
175
|
+
return APIStateMachine._step(*args_, **kwargs_)
|
|
176
|
+
|
|
177
|
+
step_function.__name__ = name
|
|
178
|
+
|
|
179
|
+
kwargs = {"target": target, "previous": previous, "case": case}
|
|
180
|
+
if not isinstance(link, NotSet):
|
|
181
|
+
kwargs["link"] = link
|
|
182
|
+
|
|
183
|
+
return rule(**kwargs)(step_function)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def make_response_matcher(matchers: list[tuple[str, FilterFunction]]) -> Callable[[StepResult], str | None]:
|
|
187
|
+
def compare(result: StepResult) -> str | None:
|
|
188
|
+
for bundle_name, response_filter in matchers:
|
|
189
|
+
if response_filter(result):
|
|
190
|
+
return bundle_name
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
return compare
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@lru_cache
|
|
197
|
+
def make_response_filter(status_code: str, all_status_codes: Iterator[str]) -> FilterFunction:
|
|
198
|
+
"""Create a filter for stored responses.
|
|
199
|
+
|
|
200
|
+
This filter will decide whether some response is suitable to use as a source for requesting some API operation.
|
|
201
|
+
"""
|
|
202
|
+
if status_code == "default":
|
|
203
|
+
return default_status_code(all_status_codes)
|
|
204
|
+
return match_status_code(status_code)
|
|
43
205
|
|
|
44
|
-
rules = make_all_rules(operations, bundles, connections)
|
|
45
206
|
|
|
46
|
-
|
|
47
|
-
|
|
207
|
+
def match_status_code(status_code: str) -> FilterFunction:
|
|
208
|
+
"""Create a filter function that matches all responses with the given status code.
|
|
209
|
+
|
|
210
|
+
Note that the status code can contain "X", which means any digit.
|
|
211
|
+
For example, 50X will match all status codes from 500 to 509.
|
|
212
|
+
"""
|
|
213
|
+
status_codes = set(expand_status_code(status_code))
|
|
214
|
+
|
|
215
|
+
def compare(result: StepResult) -> bool:
|
|
216
|
+
return result.response.status_code in status_codes
|
|
217
|
+
|
|
218
|
+
compare.__name__ = f"match_{status_code}_response"
|
|
219
|
+
|
|
220
|
+
return compare
|
|
48
221
|
|
|
49
222
|
|
|
50
|
-
def
|
|
51
|
-
"""Create
|
|
223
|
+
def default_status_code(status_codes: Iterator[str]) -> FilterFunction:
|
|
224
|
+
"""Create a filter that matches all "default" responses.
|
|
52
225
|
|
|
53
|
-
|
|
54
|
-
|
|
226
|
+
In Open API, the "default" response is the one that is used if no other options were matched.
|
|
227
|
+
Therefore, we need to match only responses that were not matched by other listed status codes.
|
|
55
228
|
"""
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def make_all_rules(
|
|
66
|
-
operations: list[APIOperation],
|
|
67
|
-
bundles: dict[str, CaseInsensitiveDict],
|
|
68
|
-
connections: APIOperationConnections,
|
|
69
|
-
) -> dict[str, Rule]:
|
|
70
|
-
"""Create rules for all API operations, based on the provided connections."""
|
|
71
|
-
rules = {}
|
|
72
|
-
for operation in operations:
|
|
73
|
-
new_rule = make_rule(operation, bundles[operation.path][operation.method.upper()], connections)
|
|
74
|
-
if new_rule is not None:
|
|
75
|
-
rules[f"rule {operation.verbose_name}"] = new_rule
|
|
76
|
-
return rules
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def make_rule(
|
|
80
|
-
operation: APIOperation,
|
|
81
|
-
bundle: Bundle,
|
|
82
|
-
connections: APIOperationConnections,
|
|
83
|
-
) -> Rule | None:
|
|
84
|
-
"""Create a rule for an API operation."""
|
|
85
|
-
|
|
86
|
-
def _make_rule(previous: st.SearchStrategy) -> Rule:
|
|
87
|
-
decorator = rule(target=bundle, previous=previous, case=operation.as_strategy()) # type: ignore
|
|
88
|
-
return decorator(APIStateMachine._step)
|
|
89
|
-
|
|
90
|
-
incoming = connections.get(operation.verbose_name)
|
|
91
|
-
if incoming is not None:
|
|
92
|
-
incoming_connections = cast(List[Connection], incoming)
|
|
93
|
-
strategies = [connection.strategy for connection in incoming_connections]
|
|
94
|
-
_rule = _make_rule(combine_strategies(strategies))
|
|
95
|
-
|
|
96
|
-
def has_source_response(self: OpenAPIStateMachine) -> bool:
|
|
97
|
-
# To trigger this transition, there should be matching responses from the source operations
|
|
98
|
-
return any(connection.source in self.bundles for connection in incoming_connections)
|
|
99
|
-
|
|
100
|
-
return precondition(has_source_response)(_rule)
|
|
101
|
-
# No incoming transitions - make rules only for operations that have at least one outgoing transition
|
|
102
|
-
if any(
|
|
103
|
-
connection.source == operation.verbose_name
|
|
104
|
-
for operation_connections in connections.values()
|
|
105
|
-
for connection in operation_connections
|
|
106
|
-
):
|
|
107
|
-
return _make_rule(st.none())
|
|
108
|
-
return None
|
|
229
|
+
expanded_status_codes = {
|
|
230
|
+
status_code for value in status_codes if value != "default" for status_code in expand_status_code(value)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
def match_default_response(result: StepResult) -> bool:
|
|
234
|
+
return result.response.status_code not in expanded_status_codes
|
|
235
|
+
|
|
236
|
+
return match_default_response
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import TYPE_CHECKING, Iterator, Union
|
|
5
|
+
|
|
6
|
+
from ....internal.copy import fast_deepcopy
|
|
7
|
+
from ....stateful.statistic import TransitionStats
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ....stateful import events
|
|
11
|
+
from .types import AggregatedResponseCounter, LinkName, ResponseCounter, SourceName, StatusCode, TargetName
|
|
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,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Callable, Dict, TypedDict, Union
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from ....stateful.state_machine import StepResult
|
|
7
|
+
|
|
8
|
+
StatusCode = str
|
|
9
|
+
LinkName = str
|
|
10
|
+
TargetName = str
|
|
11
|
+
SourceName = str
|
|
12
|
+
ResponseCounter = Dict[Union[int, None], int]
|
|
13
|
+
FilterFunction = Callable[["StepResult"], bool]
|
|
14
|
+
AggregatedResponseCounter = TypedDict("AggregatedResponseCounter", {"2xx": int, "4xx": int, "5xx": int, "Total": int})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
import string
|
|
3
|
-
from itertools import product
|
|
4
|
+
from itertools import chain, product
|
|
4
5
|
from typing import Any, Generator
|
|
5
6
|
|
|
6
7
|
|
|
@@ -10,6 +11,10 @@ def expand_status_code(status_code: str | int) -> Generator[int, None, None]:
|
|
|
10
11
|
yield int("".join(expanded))
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
def expand_status_codes(status_codes: list[str]) -> set[int]:
|
|
15
|
+
return set(chain.from_iterable(expand_status_code(code) for code in status_codes))
|
|
16
|
+
|
|
17
|
+
|
|
13
18
|
def is_header_location(location: str) -> bool:
|
|
14
19
|
"""Whether this location affects HTTP headers."""
|
|
15
20
|
return location in ("header", "cookie")
|