schemathesis 3.39.7__py3-none-any.whl → 4.0.0a2__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 +27 -65
- schemathesis/auths.py +26 -68
- schemathesis/checks.py +130 -60
- schemathesis/cli/__init__.py +5 -2105
- schemathesis/cli/commands/__init__.py +37 -0
- schemathesis/cli/commands/run/__init__.py +662 -0
- schemathesis/cli/commands/run/checks.py +80 -0
- schemathesis/cli/commands/run/context.py +117 -0
- schemathesis/cli/commands/run/events.py +30 -0
- schemathesis/cli/commands/run/executor.py +141 -0
- schemathesis/cli/commands/run/filters.py +202 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +18 -0
- schemathesis/cli/{cassettes.py → commands/run/handlers/cassettes.py} +178 -247
- schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
- schemathesis/cli/commands/run/handlers/output.py +1368 -0
- schemathesis/cli/commands/run/hypothesis.py +105 -0
- schemathesis/cli/commands/run/loaders.py +129 -0
- schemathesis/cli/{callbacks.py → commands/run/validation.py} +59 -175
- schemathesis/cli/constants.py +5 -58
- schemathesis/cli/core.py +17 -0
- schemathesis/cli/ext/fs.py +14 -0
- schemathesis/cli/ext/groups.py +55 -0
- schemathesis/cli/{options.py → ext/options.py} +37 -16
- schemathesis/cli/hooks.py +36 -0
- schemathesis/contrib/__init__.py +1 -3
- schemathesis/contrib/openapi/__init__.py +1 -3
- schemathesis/contrib/openapi/fill_missing_examples.py +3 -7
- schemathesis/core/__init__.py +58 -0
- schemathesis/core/compat.py +25 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +58 -0
- schemathesis/core/deserialization.py +65 -0
- schemathesis/core/errors.py +370 -0
- schemathesis/core/failures.py +315 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/loaders.py +104 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
- schemathesis/{internal/output.py → core/output/__init__.py} +1 -0
- schemathesis/core/output/sanitization.py +197 -0
- schemathesis/{throttling.py → core/rate_limit.py} +16 -17
- schemathesis/core/registries.py +31 -0
- schemathesis/core/transforms.py +113 -0
- schemathesis/core/transport.py +108 -0
- schemathesis/core/validation.py +38 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +30 -0
- schemathesis/engine/config.py +59 -0
- schemathesis/engine/context.py +119 -0
- schemathesis/engine/control.py +36 -0
- schemathesis/engine/core.py +157 -0
- schemathesis/engine/errors.py +394 -0
- schemathesis/engine/events.py +243 -0
- schemathesis/engine/phases/__init__.py +66 -0
- schemathesis/{runner → engine/phases}/probes.py +49 -68
- schemathesis/engine/phases/stateful/__init__.py +66 -0
- schemathesis/engine/phases/stateful/_executor.py +301 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +175 -0
- schemathesis/engine/phases/unit/_executor.py +322 -0
- schemathesis/engine/phases/unit/_pool.py +74 -0
- schemathesis/engine/recorder.py +246 -0
- schemathesis/errors.py +31 -0
- schemathesis/experimental/__init__.py +9 -40
- schemathesis/filters.py +7 -95
- schemathesis/generation/__init__.py +3 -3
- schemathesis/generation/case.py +190 -0
- schemathesis/generation/coverage.py +22 -22
- schemathesis/{_patches.py → generation/hypothesis/__init__.py} +15 -6
- schemathesis/generation/hypothesis/builder.py +585 -0
- schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +14 -0
- schemathesis/generation/hypothesis/strategies.py +16 -0
- schemathesis/generation/meta.py +115 -0
- schemathesis/generation/modes.py +28 -0
- schemathesis/generation/overrides.py +96 -0
- schemathesis/generation/stateful/__init__.py +20 -0
- schemathesis/{stateful → generation/stateful}/state_machine.py +84 -109
- schemathesis/generation/targets.py +69 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +131 -0
- schemathesis/hooks.py +17 -62
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +387 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +63 -0
- schemathesis/openapi/loaders.py +178 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +273 -0
- schemathesis/pytest/loaders.py +12 -0
- schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +94 -107
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +456 -228
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +1 -2
- schemathesis/specs/graphql/scalars.py +5 -3
- schemathesis/specs/graphql/schemas.py +122 -123
- schemathesis/specs/graphql/validation.py +11 -17
- schemathesis/specs/openapi/__init__.py +6 -1
- schemathesis/specs/openapi/_cache.py +1 -2
- schemathesis/specs/openapi/_hypothesis.py +97 -134
- schemathesis/specs/openapi/checks.py +238 -219
- schemathesis/specs/openapi/converter.py +4 -4
- schemathesis/specs/openapi/definitions.py +1 -1
- schemathesis/specs/openapi/examples.py +22 -20
- schemathesis/specs/openapi/expressions/__init__.py +11 -15
- schemathesis/specs/openapi/expressions/extractors.py +1 -4
- schemathesis/specs/openapi/expressions/nodes.py +33 -32
- schemathesis/specs/openapi/formats.py +3 -2
- schemathesis/specs/openapi/links.py +123 -299
- schemathesis/specs/openapi/media_types.py +10 -12
- schemathesis/specs/openapi/negative/__init__.py +2 -1
- schemathesis/specs/openapi/negative/mutations.py +3 -2
- schemathesis/specs/openapi/parameters.py +8 -6
- schemathesis/specs/openapi/patterns.py +1 -1
- schemathesis/specs/openapi/references.py +11 -51
- schemathesis/specs/openapi/schemas.py +177 -191
- schemathesis/specs/openapi/security.py +1 -1
- schemathesis/specs/openapi/serialization.py +10 -6
- schemathesis/specs/openapi/stateful/__init__.py +97 -91
- schemathesis/transport/__init__.py +104 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +99 -0
- schemathesis/transport/requests.py +221 -0
- schemathesis/{_xml.py → transport/serialization.py} +69 -7
- schemathesis/transport/wsgi.py +165 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/METADATA +18 -14
- schemathesis-4.0.0a2.dist-info/RECORD +151 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/entry_points.txt +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -19
- schemathesis/_hypothesis.py +0 -559
- schemathesis/_override.py +0 -50
- schemathesis/_rate_limiter.py +0 -7
- schemathesis/cli/context.py +0 -75
- schemathesis/cli/debug.py +0 -27
- schemathesis/cli/handlers.py +0 -19
- schemathesis/cli/junitxml.py +0 -124
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -936
- schemathesis/cli/output/short.py +0 -59
- schemathesis/cli/reporting.py +0 -79
- schemathesis/cli/sanitization.py +0 -26
- schemathesis/code_samples.py +0 -151
- schemathesis/constants.py +0 -56
- schemathesis/contrib/openapi/formats/__init__.py +0 -9
- schemathesis/contrib/openapi/formats/uuid.py +0 -16
- schemathesis/contrib/unique_data.py +0 -41
- schemathesis/exceptions.py +0 -571
- schemathesis/extra/_aiohttp.py +0 -28
- schemathesis/extra/_flask.py +0 -13
- schemathesis/extra/_server.py +0 -18
- schemathesis/failures.py +0 -277
- schemathesis/fixups/__init__.py +0 -37
- schemathesis/fixups/fast_api.py +0 -41
- schemathesis/fixups/utf8_bom.py +0 -28
- schemathesis/generation/_methods.py +0 -44
- schemathesis/graphql.py +0 -3
- schemathesis/internal/__init__.py +0 -7
- schemathesis/internal/checks.py +0 -84
- schemathesis/internal/copy.py +0 -32
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -38
- schemathesis/internal/diff.py +0 -15
- schemathesis/internal/extensions.py +0 -27
- schemathesis/internal/jsonschema.py +0 -36
- schemathesis/internal/transformation.py +0 -26
- schemathesis/internal/validation.py +0 -34
- schemathesis/lazy.py +0 -474
- schemathesis/loaders.py +0 -122
- schemathesis/models.py +0 -1341
- schemathesis/parameters.py +0 -90
- schemathesis/runner/__init__.py +0 -605
- schemathesis/runner/events.py +0 -389
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/context.py +0 -104
- schemathesis/runner/impl/core.py +0 -1246
- schemathesis/runner/impl/solo.py +0 -80
- schemathesis/runner/impl/threadpool.py +0 -391
- schemathesis/runner/serialization.py +0 -544
- schemathesis/sanitization.py +0 -252
- schemathesis/serializers.py +0 -328
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -11
- schemathesis/service/ci.py +0 -202
- schemathesis/service/client.py +0 -133
- schemathesis/service/constants.py +0 -38
- schemathesis/service/events.py +0 -61
- schemathesis/service/extensions.py +0 -224
- schemathesis/service/hosts.py +0 -111
- schemathesis/service/metadata.py +0 -71
- schemathesis/service/models.py +0 -258
- schemathesis/service/report.py +0 -255
- schemathesis/service/serialization.py +0 -173
- schemathesis/service/usage.py +0 -66
- schemathesis/specs/graphql/loaders.py +0 -364
- schemathesis/specs/openapi/expressions/context.py +0 -16
- schemathesis/specs/openapi/loaders.py +0 -708
- schemathesis/specs/openapi/stateful/statistic.py +0 -198
- schemathesis/specs/openapi/stateful/types.py +0 -14
- schemathesis/specs/openapi/validation.py +0 -26
- schemathesis/stateful/__init__.py +0 -147
- schemathesis/stateful/config.py +0 -97
- schemathesis/stateful/context.py +0 -135
- schemathesis/stateful/events.py +0 -274
- schemathesis/stateful/runner.py +0 -309
- schemathesis/stateful/sink.py +0 -68
- schemathesis/stateful/statistic.py +0 -22
- schemathesis/stateful/validation.py +0 -100
- schemathesis/targets.py +0 -77
- schemathesis/transports/__init__.py +0 -359
- schemathesis/transports/asgi.py +0 -7
- schemathesis/transports/auth.py +0 -38
- schemathesis/transports/headers.py +0 -36
- schemathesis/transports/responses.py +0 -57
- schemathesis/types.py +0 -44
- schemathesis/utils.py +0 -164
- schemathesis-3.39.7.dist-info/RECORD +0 -160
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
- /schemathesis/{internal → core}/result.py +0 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/WHEEL +0 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/licenses/LICENSE +0 -0
@@ -3,8 +3,6 @@ from __future__ import annotations
|
|
3
3
|
import json
|
4
4
|
from typing import Any, Callable, Dict, Generator, List
|
5
5
|
|
6
|
-
from ...utils import compose
|
7
|
-
|
8
6
|
Generated = Dict[str, Any]
|
9
7
|
Definition = Dict[str, Any]
|
10
8
|
DefinitionList = List[Definition]
|
@@ -17,10 +15,16 @@ def make_serializer(
|
|
17
15
|
"""A maker function to avoid code duplication."""
|
18
16
|
|
19
17
|
def _wrapper(definitions: DefinitionList) -> Callable | None:
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
18
|
+
functions = list(func(definitions))
|
19
|
+
|
20
|
+
def composed(x: Any) -> Any:
|
21
|
+
result = x
|
22
|
+
for func in reversed(functions):
|
23
|
+
if func is not None:
|
24
|
+
result = func(result)
|
25
|
+
return result
|
26
|
+
|
27
|
+
return composed
|
24
28
|
|
25
29
|
return _wrapper
|
26
30
|
|
@@ -2,46 +2,38 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
from collections import defaultdict
|
4
4
|
from functools import lru_cache
|
5
|
-
from typing import TYPE_CHECKING, Any, Callable,
|
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
|
11
|
-
from
|
12
|
-
from
|
13
|
-
from
|
14
|
-
from
|
15
|
-
|
16
|
-
from
|
10
|
+
from schemathesis.core.result import Ok
|
11
|
+
from schemathesis.generation.case import Case
|
12
|
+
from schemathesis.generation.hypothesis import strategies
|
13
|
+
from schemathesis.generation.stateful.state_machine import APIStateMachine, StepInput, StepOutput, _normalize_name
|
14
|
+
from schemathesis.schemas import APIOperation
|
15
|
+
|
16
|
+
from ....generation import GenerationMode
|
17
|
+
from ..links import OpenApiLink, get_all_links
|
17
18
|
from ..utils import expand_status_code
|
18
|
-
from .statistic import OpenAPILinkStats
|
19
19
|
|
20
20
|
if TYPE_CHECKING:
|
21
|
-
from
|
21
|
+
from schemathesis.generation.stateful.state_machine import StepOutput
|
22
|
+
|
22
23
|
from ..schemas import BaseOpenAPISchema
|
23
|
-
|
24
|
+
|
25
|
+
FilterFunction = Callable[["StepOutput"], bool]
|
24
26
|
|
25
27
|
|
26
28
|
class OpenAPIStateMachine(APIStateMachine):
|
27
|
-
|
28
|
-
_response_matchers: dict[str, Callable[[StepResult], str | None]]
|
29
|
+
_response_matchers: dict[str, Callable[[StepOutput], str | None]]
|
29
30
|
|
30
|
-
def _get_target_for_result(self, result:
|
31
|
-
matcher = self._response_matchers.get(result.case.operation.
|
31
|
+
def _get_target_for_result(self, result: StepOutput) -> str | None:
|
32
|
+
matcher = self._response_matchers.get(result.case.operation.label)
|
32
33
|
if matcher is None:
|
33
34
|
return None
|
34
35
|
return matcher(result)
|
35
36
|
|
36
|
-
def transform(self, result: StepResult, direction: Direction, case: Case) -> Case:
|
37
|
-
context = expressions.ExpressionContext(case=result.case, response=result.response)
|
38
|
-
direction.set_data(case, elapsed=result.elapsed, context=context)
|
39
|
-
return case
|
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
37
|
|
46
38
|
# The proportion of negative tests generated for "root" transitions
|
47
39
|
NEGATIVE_TEST_CASES_THRESHOLD = 20
|
@@ -56,57 +48,41 @@ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
|
|
56
48
|
|
57
49
|
This state machine won't make calls to (2) without having a proper response from (1) first.
|
58
50
|
"""
|
59
|
-
from ....stateful.state_machine import _normalize_name
|
60
|
-
|
61
51
|
operations = [result.ok() for result in schema.get_all_operations() if isinstance(result, Ok)]
|
62
52
|
bundles = {}
|
63
53
|
incoming_transitions = defaultdict(list)
|
64
|
-
_response_matchers: dict[str, Callable[[
|
54
|
+
_response_matchers: dict[str, Callable[[StepOutput], str | None]] = {}
|
65
55
|
# Statistic structure follows the links and count for each response status code
|
66
|
-
transitions = {}
|
67
56
|
for operation in operations:
|
68
|
-
operation_links: dict[StatusCode, dict[TargetName, dict[LinkName, dict[int | None, int]]]] = {}
|
69
57
|
all_status_codes = tuple(operation.definition.raw["responses"])
|
70
58
|
bundle_matchers = []
|
71
59
|
for _, link in get_all_links(operation):
|
72
|
-
bundle_name = f"{operation.
|
60
|
+
bundle_name = f"{operation.label} -> {link.status_code}"
|
73
61
|
bundles[bundle_name] = Bundle(bundle_name)
|
74
|
-
|
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] = {}
|
62
|
+
incoming_transitions[link.target.label].append(link)
|
79
63
|
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
64
|
if bundle_matchers:
|
83
|
-
_response_matchers[operation.
|
65
|
+
_response_matchers[operation.label] = make_response_matcher(bundle_matchers)
|
84
66
|
rules = {}
|
85
67
|
catch_all = Bundle("catch_all")
|
86
68
|
|
87
69
|
for target in operations:
|
88
|
-
incoming = incoming_transitions.get(target.
|
70
|
+
incoming = incoming_transitions.get(target.label)
|
89
71
|
if incoming is not None:
|
90
72
|
for link in incoming:
|
91
|
-
|
92
|
-
|
93
|
-
name =
|
94
|
-
|
95
|
-
|
96
|
-
target
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
rules[name] = transition(
|
102
|
-
name=name,
|
103
|
-
target=catch_all,
|
104
|
-
previous=bundle,
|
105
|
-
case=case_strategy,
|
106
|
-
link=st.just(link),
|
73
|
+
bundle_name = f"{link.source.label} -> {link.status_code}"
|
74
|
+
name = _normalize_name(f"{link.status_code} -> {target.label}")
|
75
|
+
rules[name] = precondition(ensure_non_empty_bundle(bundle_name))(
|
76
|
+
transition(
|
77
|
+
name=name,
|
78
|
+
target=catch_all,
|
79
|
+
input=bundles[bundle_name].flatmap(
|
80
|
+
into_step_input(target=target, link=link, modes=schema.generation_config.modes)
|
81
|
+
),
|
82
|
+
)
|
107
83
|
)
|
108
84
|
elif any(
|
109
|
-
incoming.
|
85
|
+
incoming.source.label == target.label
|
110
86
|
for transitions in incoming_transitions.values()
|
111
87
|
for incoming in transitions
|
112
88
|
):
|
@@ -114,32 +90,26 @@ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
|
|
114
90
|
# For example, POST /users/ -> GET /users/{id}/
|
115
91
|
# The source operation has no prerequisite, but we need to allow this rule to be executed
|
116
92
|
# in order to reach other transitions
|
117
|
-
name = _normalize_name(f"{target.
|
118
|
-
if len(schema.
|
119
|
-
case_strategy = target.as_strategy(
|
93
|
+
name = _normalize_name(f"{target.label} -> X")
|
94
|
+
if len(schema.generation_config.modes) == 1:
|
95
|
+
case_strategy = target.as_strategy(generation_mode=schema.generation_config.modes[0])
|
120
96
|
else:
|
121
|
-
|
122
|
-
method: target.as_strategy(
|
123
|
-
for method in schema.data_generation_methods
|
97
|
+
_strategies = {
|
98
|
+
method: target.as_strategy(generation_mode=method) for method in schema.generation_config.modes
|
124
99
|
}
|
125
100
|
|
126
101
|
@st.composite # type: ignore[misc]
|
127
102
|
def case_strategy_factory(
|
128
|
-
draw: st.DrawFn, strategies: dict[
|
103
|
+
draw: st.DrawFn, strategies: dict[GenerationMode, st.SearchStrategy] = _strategies
|
129
104
|
) -> Case:
|
130
105
|
if draw(st.integers(min_value=0, max_value=99)) < NEGATIVE_TEST_CASES_THRESHOLD:
|
131
|
-
return draw(strategies[
|
132
|
-
return draw(strategies[
|
106
|
+
return draw(strategies[GenerationMode.NEGATIVE])
|
107
|
+
return draw(strategies[GenerationMode.POSITIVE])
|
133
108
|
|
134
109
|
case_strategy = case_strategy_factory()
|
135
110
|
|
136
111
|
rules[name] = precondition(ensure_links_followed)(
|
137
|
-
transition(
|
138
|
-
name=name,
|
139
|
-
target=catch_all,
|
140
|
-
previous=st.none(),
|
141
|
-
case=case_strategy,
|
142
|
-
)
|
112
|
+
transition(name=name, target=catch_all, input=case_strategy.map(StepInput.initial))
|
143
113
|
)
|
144
114
|
|
145
115
|
return type(
|
@@ -148,13 +118,60 @@ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
|
|
148
118
|
{
|
149
119
|
"schema": schema,
|
150
120
|
"bundles": bundles,
|
151
|
-
"_transition_stats_template": OpenAPILinkStats(transitions=transitions),
|
152
121
|
"_response_matchers": _response_matchers,
|
153
122
|
**rules,
|
154
123
|
},
|
155
124
|
)
|
156
125
|
|
157
126
|
|
127
|
+
def into_step_input(
|
128
|
+
target: APIOperation, link: OpenApiLink, modes: list[GenerationMode]
|
129
|
+
) -> Callable[[StepOutput], st.SearchStrategy[StepInput]]:
|
130
|
+
def builder(_output: StepOutput) -> st.SearchStrategy[StepInput]:
|
131
|
+
@st.composite # type: ignore[misc]
|
132
|
+
def inner(draw: st.DrawFn, output: StepOutput) -> StepInput:
|
133
|
+
transition_data = link.extract(output)
|
134
|
+
|
135
|
+
kwargs: dict[str, Any] = {
|
136
|
+
container: {
|
137
|
+
name: extracted.value.ok()
|
138
|
+
for name, extracted in data.items()
|
139
|
+
if isinstance(extracted.value, Ok) and extracted.value.ok() is not None
|
140
|
+
}
|
141
|
+
for container, data in transition_data.parameters.items()
|
142
|
+
}
|
143
|
+
if (
|
144
|
+
transition_data.request_body is not None
|
145
|
+
and isinstance(transition_data.request_body.value, Ok)
|
146
|
+
and not link.merge_body
|
147
|
+
):
|
148
|
+
kwargs["body"] = transition_data.request_body.value.ok()
|
149
|
+
cases = strategies.combine([target.as_strategy(generation_mode=mode, **kwargs) for mode in modes])
|
150
|
+
case = draw(cases)
|
151
|
+
if (
|
152
|
+
transition_data.request_body is not None
|
153
|
+
and isinstance(transition_data.request_body.value, Ok)
|
154
|
+
and link.merge_body
|
155
|
+
):
|
156
|
+
new = transition_data.request_body.value.ok()
|
157
|
+
if isinstance(case.body, dict) and isinstance(new, dict):
|
158
|
+
case.body = {**case.body, **new}
|
159
|
+
else:
|
160
|
+
case.body = new
|
161
|
+
return StepInput(case=case, transition=transition_data)
|
162
|
+
|
163
|
+
return inner(output=_output)
|
164
|
+
|
165
|
+
return builder
|
166
|
+
|
167
|
+
|
168
|
+
def ensure_non_empty_bundle(bundle_name: str) -> Callable[[APIStateMachine], bool]:
|
169
|
+
def inner(machine: APIStateMachine) -> bool:
|
170
|
+
return bool(machine.bundles.get(bundle_name))
|
171
|
+
|
172
|
+
return inner
|
173
|
+
|
174
|
+
|
158
175
|
def ensure_links_followed(machine: APIStateMachine) -> bool:
|
159
176
|
# If there are responses that have links to follow, reject any rule without incoming transitions
|
160
177
|
for bundle in machine.bundles.values():
|
@@ -163,28 +180,17 @@ def ensure_links_followed(machine: APIStateMachine) -> bool:
|
|
163
180
|
return True
|
164
181
|
|
165
182
|
|
166
|
-
def transition(
|
167
|
-
|
168
|
-
|
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_)
|
183
|
+
def transition(*, name: str, target: Bundle, input: st.SearchStrategy[StepInput]) -> Callable[[Callable], Rule]:
|
184
|
+
def step_function(self: APIStateMachine, input: StepInput) -> StepOutput | None:
|
185
|
+
return APIStateMachine._step(self, input=input)
|
176
186
|
|
177
187
|
step_function.__name__ = name
|
178
188
|
|
179
|
-
|
180
|
-
if not isinstance(link, NotSet):
|
181
|
-
kwargs["link"] = link
|
182
|
-
|
183
|
-
return rule(**kwargs)(step_function)
|
189
|
+
return rule(target=target, input=input)(step_function)
|
184
190
|
|
185
191
|
|
186
|
-
def make_response_matcher(matchers: list[tuple[str, FilterFunction]]) -> Callable[[
|
187
|
-
def compare(result:
|
192
|
+
def make_response_matcher(matchers: list[tuple[str, FilterFunction]]) -> Callable[[StepOutput], str | None]:
|
193
|
+
def compare(result: StepOutput) -> str | None:
|
188
194
|
for bundle_name, response_filter in matchers:
|
189
195
|
if response_filter(result):
|
190
196
|
return bundle_name
|
@@ -212,7 +218,7 @@ def match_status_code(status_code: str) -> FilterFunction:
|
|
212
218
|
"""
|
213
219
|
status_codes = set(expand_status_code(status_code))
|
214
220
|
|
215
|
-
def compare(result:
|
221
|
+
def compare(result: StepOutput) -> bool:
|
216
222
|
return result.response.status_code in status_codes
|
217
223
|
|
218
224
|
compare.__name__ = f"match_{status_code}_response"
|
@@ -230,7 +236,7 @@ def default_status_code(status_codes: Iterator[str]) -> FilterFunction:
|
|
230
236
|
status_code for value in status_codes if value != "default" for status_code in expand_status_code(value)
|
231
237
|
}
|
232
238
|
|
233
|
-
def match_default_response(result:
|
239
|
+
def match_default_response(result: StepOutput) -> bool:
|
234
240
|
return result.response.status_code not in expanded_status_codes
|
235
241
|
|
236
242
|
return match_default_response
|
@@ -0,0 +1,104 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from inspect import iscoroutinefunction
|
5
|
+
from typing import Any, Callable, Generic, Iterator, TypeVar
|
6
|
+
|
7
|
+
from schemathesis.core import media_types
|
8
|
+
from schemathesis.core.errors import SerializationNotPossible
|
9
|
+
|
10
|
+
|
11
|
+
def get(app: Any) -> BaseTransport:
|
12
|
+
"""Get transport to send the data to the application."""
|
13
|
+
from schemathesis.transport.asgi import ASGI_TRANSPORT
|
14
|
+
from schemathesis.transport.requests import REQUESTS_TRANSPORT
|
15
|
+
from schemathesis.transport.wsgi import WSGI_TRANSPORT
|
16
|
+
|
17
|
+
if app is None:
|
18
|
+
return REQUESTS_TRANSPORT
|
19
|
+
if iscoroutinefunction(app) or (
|
20
|
+
hasattr(app, "__call__") and iscoroutinefunction(app.__call__) # noqa: B004
|
21
|
+
):
|
22
|
+
return ASGI_TRANSPORT
|
23
|
+
return WSGI_TRANSPORT
|
24
|
+
|
25
|
+
|
26
|
+
C = TypeVar("C", contravariant=True)
|
27
|
+
R = TypeVar("R", covariant=True)
|
28
|
+
S = TypeVar("S", contravariant=True)
|
29
|
+
|
30
|
+
|
31
|
+
@dataclass
|
32
|
+
class SerializationContext(Generic[C]):
|
33
|
+
"""Generic context for serialization process."""
|
34
|
+
|
35
|
+
case: C
|
36
|
+
|
37
|
+
__slots__ = ("case",)
|
38
|
+
|
39
|
+
|
40
|
+
Serializer = Callable[[SerializationContext[C], Any], Any]
|
41
|
+
|
42
|
+
|
43
|
+
class BaseTransport(Generic[C, R, S]):
|
44
|
+
"""Base implementation with serializer registration."""
|
45
|
+
|
46
|
+
def __init__(self) -> None:
|
47
|
+
self._serializers: dict[str, Serializer[C]] = {}
|
48
|
+
|
49
|
+
def serialize_case(self, case: C, **kwargs: Any) -> dict[str, Any]:
|
50
|
+
"""Prepare the case for sending."""
|
51
|
+
raise NotImplementedError
|
52
|
+
|
53
|
+
def send(self, case: C, *, session: S | None = None, **kwargs: Any) -> R:
|
54
|
+
"""Send the case using this transport."""
|
55
|
+
raise NotImplementedError
|
56
|
+
|
57
|
+
def serializer(self, *media_types: str) -> Callable[[Serializer[C]], Serializer[C]]:
|
58
|
+
"""Register a serializer for given media types."""
|
59
|
+
|
60
|
+
def decorator(func: Serializer[C]) -> Serializer[C]:
|
61
|
+
for media_type in media_types:
|
62
|
+
self._serializers[media_type] = func
|
63
|
+
return func
|
64
|
+
|
65
|
+
return decorator
|
66
|
+
|
67
|
+
def unregister_serializer(self, *media_types: str) -> None:
|
68
|
+
for media_type in media_types:
|
69
|
+
self._serializers.pop(media_type, None)
|
70
|
+
|
71
|
+
def _copy_serializers_from(self, transport: BaseTransport) -> None:
|
72
|
+
self._serializers.update(transport._serializers)
|
73
|
+
|
74
|
+
def get_first_matching_media_type(self, media_type: str) -> tuple[str, Serializer[C]] | None:
|
75
|
+
return next(self.get_matching_media_types(media_type), None)
|
76
|
+
|
77
|
+
def get_matching_media_types(self, media_type: str) -> Iterator[tuple[str, Serializer[C]]]:
|
78
|
+
"""Get all registered media types matching the given media type."""
|
79
|
+
if media_type == "*/*":
|
80
|
+
# Shortcut to avoid comparing all values
|
81
|
+
yield from iter(self._serializers.items())
|
82
|
+
else:
|
83
|
+
main, sub = media_types.parse(media_type)
|
84
|
+
checks = [
|
85
|
+
media_types.is_json,
|
86
|
+
media_types.is_xml,
|
87
|
+
media_types.is_plain_text,
|
88
|
+
media_types.is_yaml,
|
89
|
+
]
|
90
|
+
for registered_media_type, serializer in self._serializers.items():
|
91
|
+
# Try known variations for popular media types and fallback to comparison
|
92
|
+
if any(check(media_type) and check(registered_media_type) for check in checks):
|
93
|
+
yield media_type, serializer
|
94
|
+
else:
|
95
|
+
target_main, target_sub = media_types.parse(registered_media_type)
|
96
|
+
if main in ("*", target_main) and sub in ("*", target_sub):
|
97
|
+
yield registered_media_type, serializer
|
98
|
+
|
99
|
+
def _get_serializer(self, input_media_type: str) -> Serializer[C]:
|
100
|
+
pair = self.get_first_matching_media_type(input_media_type)
|
101
|
+
if pair is None:
|
102
|
+
# This media type is set manually. Otherwise, it should have been rejected during the data generation
|
103
|
+
raise SerializationNotPossible.for_media_type(input_media_type)
|
104
|
+
return pair[1]
|
@@ -0,0 +1,26 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import TYPE_CHECKING, Any
|
4
|
+
|
5
|
+
from schemathesis.core.transport import Response
|
6
|
+
from schemathesis.generation.case import Case
|
7
|
+
from schemathesis.python import asgi
|
8
|
+
from schemathesis.transport.prepare import normalize_base_url
|
9
|
+
from schemathesis.transport.requests import REQUESTS_TRANSPORT, RequestsTransport
|
10
|
+
|
11
|
+
if TYPE_CHECKING:
|
12
|
+
import requests
|
13
|
+
|
14
|
+
|
15
|
+
class ASGITransport(RequestsTransport):
|
16
|
+
def send(self, case: Case, *, session: requests.Session | None = None, **kwargs: Any) -> Response:
|
17
|
+
if kwargs.get("base_url") is None:
|
18
|
+
kwargs["base_url"] = normalize_base_url(case.operation.base_url)
|
19
|
+
application = kwargs.pop("app", case.operation.app)
|
20
|
+
|
21
|
+
with asgi.get_client(application) as client:
|
22
|
+
return super().send(case, session=client, **kwargs)
|
23
|
+
|
24
|
+
|
25
|
+
ASGI_TRANSPORT = ASGITransport()
|
26
|
+
ASGI_TRANSPORT._copy_serializers_from(REQUESTS_TRANSPORT)
|
@@ -0,0 +1,99 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import TYPE_CHECKING, Any, Mapping, cast
|
4
|
+
from urllib.parse import quote, unquote, urljoin, urlsplit, urlunsplit
|
5
|
+
|
6
|
+
from schemathesis.core import SCHEMATHESIS_TEST_CASE_HEADER, NotSet
|
7
|
+
from schemathesis.core.errors import InvalidSchema
|
8
|
+
from schemathesis.core.output.sanitization import sanitize_url, sanitize_value
|
9
|
+
from schemathesis.core.transport import USER_AGENT
|
10
|
+
|
11
|
+
if TYPE_CHECKING:
|
12
|
+
from requests import PreparedRequest
|
13
|
+
from requests.structures import CaseInsensitiveDict
|
14
|
+
|
15
|
+
from schemathesis.generation.case import Case
|
16
|
+
|
17
|
+
|
18
|
+
def prepare_headers(case: Case, headers: dict[str, str] | None = None) -> CaseInsensitiveDict:
|
19
|
+
from requests.structures import CaseInsensitiveDict
|
20
|
+
|
21
|
+
final_headers = case.headers.copy() if case.headers is not None else CaseInsensitiveDict()
|
22
|
+
if headers:
|
23
|
+
final_headers.update(headers)
|
24
|
+
final_headers.setdefault("User-Agent", USER_AGENT)
|
25
|
+
final_headers.setdefault(SCHEMATHESIS_TEST_CASE_HEADER, case.id)
|
26
|
+
return final_headers
|
27
|
+
|
28
|
+
|
29
|
+
def prepare_url(case: Case, base_url: str | None) -> str:
|
30
|
+
"""Prepare URL based on case type."""
|
31
|
+
from schemathesis.specs.graphql.schemas import GraphQLSchema
|
32
|
+
|
33
|
+
base_url = base_url or case.operation.base_url
|
34
|
+
assert base_url is not None
|
35
|
+
path = prepare_path(case.path, case.path_parameters)
|
36
|
+
|
37
|
+
if isinstance(case.operation.schema, GraphQLSchema):
|
38
|
+
parts = list(urlsplit(base_url))
|
39
|
+
parts[2] = path
|
40
|
+
return urlunsplit(parts)
|
41
|
+
else:
|
42
|
+
path = path.lstrip("/")
|
43
|
+
if not base_url.endswith("/"):
|
44
|
+
base_url += "/"
|
45
|
+
return unquote(urljoin(base_url, quote(path)))
|
46
|
+
|
47
|
+
|
48
|
+
def prepare_body(case: Case) -> list | dict[str, Any] | str | int | float | bool | bytes | NotSet:
|
49
|
+
"""Prepare body based on case type."""
|
50
|
+
from schemathesis.specs.graphql.schemas import GraphQLSchema
|
51
|
+
|
52
|
+
if isinstance(case.operation.schema, GraphQLSchema):
|
53
|
+
return case.body if isinstance(case.body, (NotSet, bytes)) else {"query": case.body}
|
54
|
+
return case.body
|
55
|
+
|
56
|
+
|
57
|
+
def normalize_base_url(base_url: str | None) -> str | None:
|
58
|
+
"""Normalize base URL by ensuring proper hostname for local URLs.
|
59
|
+
|
60
|
+
If URL has no hostname (typical for WSGI apps), adds "localhost" as default hostname.
|
61
|
+
"""
|
62
|
+
if base_url is None:
|
63
|
+
return None
|
64
|
+
parts = urlsplit(base_url)
|
65
|
+
if not parts.hostname:
|
66
|
+
path = cast(str, parts.path or "")
|
67
|
+
return urlunsplit(("http", "localhost", path or "", "", ""))
|
68
|
+
return base_url
|
69
|
+
|
70
|
+
|
71
|
+
def prepare_path(path: str, parameters: dict[str, Any] | None) -> str:
|
72
|
+
try:
|
73
|
+
return path.format(**parameters or {})
|
74
|
+
except KeyError as exc:
|
75
|
+
# This may happen when a path template has a placeholder for variable "X", but parameter "X" is not defined
|
76
|
+
# in the parameters list.
|
77
|
+
# When `exc` is formatted, it is the missing key name in quotes. E.g. 'id'
|
78
|
+
raise InvalidSchema(f"Path parameter {exc} is not defined") from exc
|
79
|
+
except (IndexError, ValueError) as exc:
|
80
|
+
# A single unmatched `}` inside the path template may cause this
|
81
|
+
raise InvalidSchema(f"Malformed path template: `{path}`\n\n {exc}") from exc
|
82
|
+
|
83
|
+
|
84
|
+
def prepare_request(case: Case, headers: Mapping[str, Any] | None, sanitize: bool) -> PreparedRequest:
|
85
|
+
import requests
|
86
|
+
|
87
|
+
from schemathesis.transport.requests import REQUESTS_TRANSPORT
|
88
|
+
|
89
|
+
base_url = normalize_base_url(case.operation.base_url)
|
90
|
+
kwargs = REQUESTS_TRANSPORT.serialize_case(case, base_url=base_url, headers=headers)
|
91
|
+
if sanitize:
|
92
|
+
kwargs["url"] = sanitize_url(kwargs["url"])
|
93
|
+
sanitize_value(kwargs["headers"])
|
94
|
+
if kwargs["cookies"]:
|
95
|
+
sanitize_value(kwargs["cookies"])
|
96
|
+
if kwargs["params"]:
|
97
|
+
sanitize_value(kwargs["params"])
|
98
|
+
|
99
|
+
return requests.Request(**kwargs).prepare()
|