schemathesis 3.25.5__py3-none-any.whl → 3.26.0__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/_dependency_versions.py +1 -0
- schemathesis/_hypothesis.py +1 -0
- schemathesis/_xml.py +1 -0
- schemathesis/auths.py +1 -0
- schemathesis/cli/__init__.py +39 -37
- schemathesis/cli/cassettes.py +4 -4
- schemathesis/cli/context.py +6 -0
- schemathesis/cli/output/default.py +185 -45
- schemathesis/cli/output/short.py +8 -0
- schemathesis/experimental/__init__.py +7 -0
- schemathesis/filters.py +1 -0
- schemathesis/models.py +5 -2
- schemathesis/parameters.py +1 -0
- schemathesis/runner/__init__.py +36 -9
- schemathesis/runner/events.py +33 -1
- schemathesis/runner/impl/core.py +99 -23
- schemathesis/{cli → runner}/probes.py +32 -21
- schemathesis/runner/serialization.py +4 -2
- schemathesis/schemas.py +1 -0
- schemathesis/serializers.py +11 -3
- schemathesis/service/client.py +35 -2
- schemathesis/service/extensions.py +224 -0
- schemathesis/service/hosts.py +1 -0
- schemathesis/service/metadata.py +24 -0
- schemathesis/service/models.py +210 -2
- schemathesis/service/serialization.py +44 -1
- schemathesis/specs/openapi/__init__.py +1 -0
- schemathesis/specs/openapi/_hypothesis.py +9 -1
- schemathesis/specs/openapi/examples.py +22 -24
- schemathesis/specs/openapi/expressions/__init__.py +1 -0
- schemathesis/specs/openapi/expressions/lexer.py +1 -0
- schemathesis/specs/openapi/expressions/nodes.py +1 -0
- schemathesis/specs/openapi/links.py +1 -0
- schemathesis/specs/openapi/media_types.py +34 -0
- schemathesis/specs/openapi/negative/mutations.py +1 -0
- schemathesis/specs/openapi/schemas.py +10 -3
- schemathesis/specs/openapi/security.py +5 -1
- {schemathesis-3.25.5.dist-info → schemathesis-3.26.0.dist-info}/METADATA +8 -5
- {schemathesis-3.25.5.dist-info → schemathesis-3.26.0.dist-info}/RECORD +42 -40
- {schemathesis-3.25.5.dist-info → schemathesis-3.26.0.dist-info}/WHEEL +0 -0
- {schemathesis-3.25.5.dist-info → schemathesis-3.26.0.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.25.5.dist-info → schemathesis-3.26.0.dist-info}/licenses/LICENSE +0 -0
schemathesis/service/client.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
import json
|
|
2
3
|
import hashlib
|
|
3
4
|
import http
|
|
4
5
|
from dataclasses import asdict
|
|
5
|
-
from typing import Any
|
|
6
|
+
from typing import Any, TYPE_CHECKING
|
|
6
7
|
from urllib.parse import urljoin
|
|
7
8
|
|
|
8
9
|
import requests
|
|
@@ -11,8 +12,11 @@ from requests.adapters import HTTPAdapter, Retry
|
|
|
11
12
|
from ..constants import USER_AGENT
|
|
12
13
|
from .ci import CIProvider
|
|
13
14
|
from .constants import CI_PROVIDER_HEADER, REPORT_CORRELATION_ID_HEADER, REQUEST_TIMEOUT, UPLOAD_SOURCE_HEADER
|
|
14
|
-
from .metadata import Metadata
|
|
15
|
+
from .metadata import Metadata, collect_dependency_versions
|
|
15
16
|
from .models import (
|
|
17
|
+
AnalysisSuccess,
|
|
18
|
+
AnalysisError,
|
|
19
|
+
AnalysisResult,
|
|
16
20
|
ProjectDetails,
|
|
17
21
|
AuthResponse,
|
|
18
22
|
FailedUploadResponse,
|
|
@@ -23,6 +27,10 @@ from .models import (
|
|
|
23
27
|
)
|
|
24
28
|
|
|
25
29
|
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from ..runner import probes
|
|
32
|
+
|
|
33
|
+
|
|
26
34
|
def response_hook(response: requests.Response, **_kwargs: Any) -> None:
|
|
27
35
|
if response.status_code != http.HTTPStatus.REQUEST_ENTITY_TOO_LARGE:
|
|
28
36
|
response.raise_for_status()
|
|
@@ -98,3 +106,28 @@ class ServiceClient(requests.Session):
|
|
|
98
106
|
if response.status_code == http.HTTPStatus.REQUEST_ENTITY_TOO_LARGE:
|
|
99
107
|
return FailedUploadResponse(detail=data["detail"])
|
|
100
108
|
return UploadResponse(message=data["message"], next_url=data["next"], correlation_id=data["correlation_id"])
|
|
109
|
+
|
|
110
|
+
def analyze_schema(self, probes: list[probes.ProbeRun] | None, schema: dict[str, Any]) -> AnalysisResult:
|
|
111
|
+
"""Analyze the API schema."""
|
|
112
|
+
# Manual serialization reduces the size of the payload a bit
|
|
113
|
+
dependencies = collect_dependency_versions()
|
|
114
|
+
if probes is not None:
|
|
115
|
+
_probes = [probe.serialize() for probe in probes]
|
|
116
|
+
else:
|
|
117
|
+
_probes = []
|
|
118
|
+
content = json.dumps(
|
|
119
|
+
{
|
|
120
|
+
"probes": _probes,
|
|
121
|
+
"schema": schema,
|
|
122
|
+
"dependencies": list(map(asdict, dependencies)),
|
|
123
|
+
},
|
|
124
|
+
separators=(",", ":"),
|
|
125
|
+
)
|
|
126
|
+
response = self.post("/cli/analysis/", data=content, headers={"Content-Type": "application/json"}, timeout=None)
|
|
127
|
+
if response.status_code == http.HTTPStatus.REQUEST_ENTITY_TOO_LARGE:
|
|
128
|
+
try:
|
|
129
|
+
message = response.json()["detail"]
|
|
130
|
+
except json.JSONDecodeError:
|
|
131
|
+
message = response.text
|
|
132
|
+
return AnalysisError(message=message)
|
|
133
|
+
return AnalysisSuccess.from_dict(response.json())
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import re
|
|
5
|
+
from ipaddress import IPv4Network, IPv6Network
|
|
6
|
+
from typing import TYPE_CHECKING, Callable, Optional, Any
|
|
7
|
+
|
|
8
|
+
from ..graphql import nodes
|
|
9
|
+
from ..internal.result import Result, Ok, Err
|
|
10
|
+
from .models import (
|
|
11
|
+
Extension,
|
|
12
|
+
SchemaPatchesExtension,
|
|
13
|
+
StrategyDefinition,
|
|
14
|
+
OpenApiStringFormatsExtension,
|
|
15
|
+
GraphQLScalarsExtension,
|
|
16
|
+
MediaTypesExtension,
|
|
17
|
+
TransformFunctionDefinition,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from datetime import date, datetime
|
|
22
|
+
|
|
23
|
+
from hypothesis import strategies as st
|
|
24
|
+
|
|
25
|
+
from ..schemas import BaseSchema
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def apply(extensions: list[Extension], schema: BaseSchema) -> None:
|
|
29
|
+
"""Apply the given extensions."""
|
|
30
|
+
for extension in extensions:
|
|
31
|
+
if isinstance(extension, OpenApiStringFormatsExtension):
|
|
32
|
+
_apply_string_formats_extension(extension)
|
|
33
|
+
elif isinstance(extension, GraphQLScalarsExtension):
|
|
34
|
+
_apply_scalars_extension(extension)
|
|
35
|
+
elif isinstance(extension, MediaTypesExtension):
|
|
36
|
+
_apply_media_types_extension(extension)
|
|
37
|
+
elif isinstance(extension, SchemaPatchesExtension):
|
|
38
|
+
_apply_schema_patches_extension(extension, schema)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _apply_simple_extension(
|
|
42
|
+
extension: OpenApiStringFormatsExtension | GraphQLScalarsExtension | MediaTypesExtension,
|
|
43
|
+
collection: dict[str, Any],
|
|
44
|
+
register_strategy: Callable[[str, st.SearchStrategy], None],
|
|
45
|
+
) -> None:
|
|
46
|
+
errors = []
|
|
47
|
+
for name, value in collection.items():
|
|
48
|
+
strategy = strategy_from_definitions(value)
|
|
49
|
+
if isinstance(strategy, Err):
|
|
50
|
+
errors.append(str(strategy.err()))
|
|
51
|
+
else:
|
|
52
|
+
register_strategy(name, strategy.ok())
|
|
53
|
+
|
|
54
|
+
if errors:
|
|
55
|
+
extension.set_error(errors=errors)
|
|
56
|
+
else:
|
|
57
|
+
extension.set_success()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _apply_string_formats_extension(extension: OpenApiStringFormatsExtension) -> None:
|
|
61
|
+
from ..specs.openapi import formats
|
|
62
|
+
|
|
63
|
+
_apply_simple_extension(extension, extension.formats, formats.register)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _apply_scalars_extension(extension: GraphQLScalarsExtension) -> None:
|
|
67
|
+
from ..specs.graphql import scalars
|
|
68
|
+
|
|
69
|
+
_apply_simple_extension(extension, extension.scalars, scalars.scalar)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _apply_media_types_extension(extension: MediaTypesExtension) -> None:
|
|
73
|
+
from ..specs.openapi import media_types
|
|
74
|
+
|
|
75
|
+
_apply_simple_extension(extension, extension.media_types, media_types.register_media_type)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _find_built_in_strategy(name: str) -> Optional[st.SearchStrategy]:
|
|
79
|
+
"""Find a built-in Hypothesis strategy by its name."""
|
|
80
|
+
from hypothesis import provisional as pr
|
|
81
|
+
from hypothesis import strategies as st
|
|
82
|
+
|
|
83
|
+
for module in (st, pr):
|
|
84
|
+
if hasattr(module, name):
|
|
85
|
+
return getattr(module, name)
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _apply_schema_patches_extension(extension: SchemaPatchesExtension, schema: BaseSchema) -> None:
|
|
90
|
+
"""Apply a set of patches to the schema."""
|
|
91
|
+
for patch in extension.patches:
|
|
92
|
+
current: dict[str, Any] | list = schema.raw_schema
|
|
93
|
+
operation = patch["operation"]
|
|
94
|
+
path = patch["path"]
|
|
95
|
+
for part in path[:-1]:
|
|
96
|
+
if isinstance(current, dict):
|
|
97
|
+
if not isinstance(part, str):
|
|
98
|
+
extension.set_error([f"Invalid path: {path}"])
|
|
99
|
+
return
|
|
100
|
+
current = current.setdefault(part, {})
|
|
101
|
+
elif isinstance(current, list):
|
|
102
|
+
if not isinstance(part, int):
|
|
103
|
+
extension.set_error([f"Invalid path: {path}"])
|
|
104
|
+
return
|
|
105
|
+
try:
|
|
106
|
+
current = current[part]
|
|
107
|
+
except IndexError:
|
|
108
|
+
extension.set_error([f"Invalid path: {path}"])
|
|
109
|
+
return
|
|
110
|
+
if operation == "add":
|
|
111
|
+
# Add or replace the value at the target location.
|
|
112
|
+
current[path[-1]] = patch["value"] # type: ignore
|
|
113
|
+
elif operation == "remove":
|
|
114
|
+
# Remove the item at the target location if it exists.
|
|
115
|
+
if path:
|
|
116
|
+
last = path[-1]
|
|
117
|
+
if isinstance(current, dict) and isinstance(last, str) and last in current:
|
|
118
|
+
del current[last]
|
|
119
|
+
elif isinstance(current, list) and isinstance(last, int) and len(current) > last:
|
|
120
|
+
del current[last]
|
|
121
|
+
else:
|
|
122
|
+
extension.set_error([f"Invalid path: {path}"])
|
|
123
|
+
return
|
|
124
|
+
else:
|
|
125
|
+
current.clear()
|
|
126
|
+
|
|
127
|
+
extension.set_success()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def strategy_from_definitions(definitions: list[StrategyDefinition]) -> Result[st.SearchStrategy, Exception]:
|
|
131
|
+
from ..utils import combine_strategies
|
|
132
|
+
|
|
133
|
+
strategies = []
|
|
134
|
+
for definition in definitions:
|
|
135
|
+
strategy = _strategy_from_definition(definition)
|
|
136
|
+
if isinstance(strategy, Ok):
|
|
137
|
+
strategies.append(strategy.ok())
|
|
138
|
+
else:
|
|
139
|
+
return strategy
|
|
140
|
+
return Ok(combine_strategies(strategies))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
KNOWN_ARGUMENTS = {
|
|
144
|
+
"IPv4Network": IPv4Network,
|
|
145
|
+
"IPv6Network": IPv6Network,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def check_regex(regex: str) -> Result[None, Exception]:
|
|
150
|
+
try:
|
|
151
|
+
re.compile(regex)
|
|
152
|
+
except (re.error, OverflowError, RuntimeError):
|
|
153
|
+
return Err(ValueError(f"Invalid regex: `{regex}`"))
|
|
154
|
+
return Ok(None)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def check_sampled_from(elements: list) -> Result[None, Exception]:
|
|
158
|
+
if not elements:
|
|
159
|
+
return Err(ValueError("Invalid input for `sampled_from`: Cannot sample from a length-zero sequence"))
|
|
160
|
+
return Ok(None)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
STRATEGY_ARGUMENT_CHECKS = {
|
|
164
|
+
"from_regex": check_regex,
|
|
165
|
+
"sampled_from": check_sampled_from,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _strategy_from_definition(definition: StrategyDefinition) -> Result[st.SearchStrategy, Exception]:
|
|
170
|
+
base = _find_built_in_strategy(definition.name)
|
|
171
|
+
if base is None:
|
|
172
|
+
return Err(ValueError(f"Unknown built-in strategy: `{definition.name}`"))
|
|
173
|
+
arguments = definition.arguments or {}
|
|
174
|
+
arguments = arguments.copy()
|
|
175
|
+
for key, value in arguments.items():
|
|
176
|
+
if isinstance(value, str):
|
|
177
|
+
known = KNOWN_ARGUMENTS.get(value)
|
|
178
|
+
if known is not None:
|
|
179
|
+
arguments[key] = known
|
|
180
|
+
check = STRATEGY_ARGUMENT_CHECKS.get(definition.name)
|
|
181
|
+
if check is not None:
|
|
182
|
+
check_result = check(**arguments) # type: ignore
|
|
183
|
+
if isinstance(check_result, Err):
|
|
184
|
+
return check_result
|
|
185
|
+
strategy = base(**arguments)
|
|
186
|
+
for transform in definition.transforms or []:
|
|
187
|
+
if transform["kind"] == "map":
|
|
188
|
+
function = _get_map_function(transform)
|
|
189
|
+
if isinstance(function, Ok):
|
|
190
|
+
strategy = strategy.map(function.ok())
|
|
191
|
+
else:
|
|
192
|
+
return function
|
|
193
|
+
else:
|
|
194
|
+
return Err(ValueError(f"Unknown transform kind: {transform['kind']}"))
|
|
195
|
+
|
|
196
|
+
return Ok(strategy)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def make_strftime(format: str) -> Callable:
|
|
200
|
+
def strftime(value: date | datetime) -> str:
|
|
201
|
+
return value.strftime(format)
|
|
202
|
+
|
|
203
|
+
return strftime
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _get_map_function(definition: TransformFunctionDefinition) -> Result[Callable | None, Exception]:
|
|
207
|
+
from ..specs.openapi._hypothesis import Binary
|
|
208
|
+
|
|
209
|
+
TRANSFORM_FACTORIES: dict[str, Callable] = {
|
|
210
|
+
"str": lambda: str,
|
|
211
|
+
"base64_encode": lambda: lambda x: Binary(base64.b64encode(x)),
|
|
212
|
+
"base64_decode": lambda: lambda x: Binary(base64.b64decode(x)),
|
|
213
|
+
"urlsafe_base64_encode": lambda: lambda x: Binary(base64.urlsafe_b64encode(x)),
|
|
214
|
+
"strftime": make_strftime,
|
|
215
|
+
"GraphQLBoolean": lambda: nodes.Boolean,
|
|
216
|
+
"GraphQLFloat": lambda: nodes.Float,
|
|
217
|
+
"GraphQLInt": lambda: nodes.Int,
|
|
218
|
+
"GraphQLString": lambda: nodes.String,
|
|
219
|
+
}
|
|
220
|
+
factory = TRANSFORM_FACTORIES.get(definition["name"])
|
|
221
|
+
if factory is None:
|
|
222
|
+
return Err(ValueError(f"Unknown transform: {definition['name']}"))
|
|
223
|
+
arguments = definition.get("arguments", {})
|
|
224
|
+
return Ok(factory(**arguments))
|
schemathesis/service/hosts.py
CHANGED
schemathesis/service/metadata.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Useful info to collect from CLI usage."""
|
|
2
|
+
|
|
2
3
|
from __future__ import annotations
|
|
3
4
|
import os
|
|
4
5
|
import platform
|
|
6
|
+
from importlib import metadata
|
|
5
7
|
from dataclasses import dataclass, field
|
|
6
8
|
|
|
7
9
|
from ..constants import SCHEMATHESIS_VERSION
|
|
@@ -32,6 +34,27 @@ class CliMetadata:
|
|
|
32
34
|
version: str = SCHEMATHESIS_VERSION
|
|
33
35
|
|
|
34
36
|
|
|
37
|
+
DEPDENDENCY_NAMES = ["hypothesis", "hypothesis-jsonschema", "hypothesis-graphql"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Dependency:
|
|
42
|
+
"""A single dependency."""
|
|
43
|
+
|
|
44
|
+
# Name of the package.
|
|
45
|
+
name: str
|
|
46
|
+
# Version of the package.
|
|
47
|
+
version: str
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_name(cls, name: str) -> Dependency:
|
|
51
|
+
return cls(name=name, version=metadata.version(name))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def collect_dependency_versions() -> list[Dependency]:
|
|
55
|
+
return [Dependency.from_name(name) for name in DEPDENDENCY_NAMES]
|
|
56
|
+
|
|
57
|
+
|
|
35
58
|
@dataclass
|
|
36
59
|
class Metadata:
|
|
37
60
|
"""CLI environment metadata."""
|
|
@@ -44,3 +67,4 @@ class Metadata:
|
|
|
44
67
|
cli: CliMetadata = field(default_factory=CliMetadata)
|
|
45
68
|
# Used Docker image if any
|
|
46
69
|
docker_image: str | None = field(default_factory=lambda: os.getenv(DOCKER_IMAGE_ENV_VAR))
|
|
70
|
+
depdenencies: list[Dependency] = field(default_factory=collect_dependency_versions)
|
schemathesis/service/models.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
from dataclasses import dataclass
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
3
|
from enum import Enum
|
|
4
|
-
from typing import Any
|
|
4
|
+
from typing import Any, Iterable, TypedDict, Union, Literal
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class UploadSource(str, Enum):
|
|
@@ -47,3 +47,211 @@ class UploadResponse:
|
|
|
47
47
|
@dataclass
|
|
48
48
|
class FailedUploadResponse:
|
|
49
49
|
detail: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class NotAppliedState:
|
|
54
|
+
"""The extension was not applied."""
|
|
55
|
+
|
|
56
|
+
def __str__(self) -> str:
|
|
57
|
+
return "Not Applied"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class SuccessState:
|
|
62
|
+
"""The extension was applied successfully."""
|
|
63
|
+
|
|
64
|
+
def __str__(self) -> str:
|
|
65
|
+
return "Success"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ErrorState:
|
|
70
|
+
"""An error occurred during the extension application."""
|
|
71
|
+
|
|
72
|
+
errors: list[str] = field(default_factory=list)
|
|
73
|
+
exceptions: list[Exception] = field(default_factory=list)
|
|
74
|
+
|
|
75
|
+
def __str__(self) -> str:
|
|
76
|
+
return "Error"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
ExtensionState = Union[NotAppliedState, SuccessState, ErrorState]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class BaseExtension:
|
|
84
|
+
def set_state(self, state: ExtensionState) -> None:
|
|
85
|
+
self.state = state
|
|
86
|
+
|
|
87
|
+
def set_success(self) -> None:
|
|
88
|
+
self.set_state(SuccessState())
|
|
89
|
+
|
|
90
|
+
def set_error(self, errors: list[str] | None = None, exceptions: list[Exception] | None = None) -> None:
|
|
91
|
+
self.set_state(ErrorState(errors=errors or [], exceptions=exceptions or []))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class UnknownExtension(BaseExtension):
|
|
96
|
+
"""An unknown extension.
|
|
97
|
+
|
|
98
|
+
Likely the CLI should be updated.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
type: str
|
|
102
|
+
state: ExtensionState = field(default_factory=NotAppliedState)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def summary(self) -> str:
|
|
106
|
+
return f"`{self.type}`"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class AddPatch(TypedDict):
|
|
110
|
+
operation: Literal["add"]
|
|
111
|
+
path: list[str | int]
|
|
112
|
+
value: Any
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class RemovePatch(TypedDict):
|
|
116
|
+
operation: Literal["remove"]
|
|
117
|
+
path: list[str | int]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
Patch = Union[AddPatch, RemovePatch]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class SchemaPatchesExtension(BaseExtension):
|
|
125
|
+
"""Update the schema with its optimized version."""
|
|
126
|
+
|
|
127
|
+
patches: list[Patch]
|
|
128
|
+
state: ExtensionState = field(default_factory=NotAppliedState)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def summary(self) -> str:
|
|
132
|
+
count = len(self.patches)
|
|
133
|
+
plural = "es" if count > 1 else ""
|
|
134
|
+
return f"{count} schema patch{plural}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class TransformFunctionDefinition(TypedDict):
|
|
138
|
+
kind: Literal["map", "filter"]
|
|
139
|
+
name: str
|
|
140
|
+
arguments: dict[str, Any]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class StrategyDefinition:
|
|
145
|
+
name: str
|
|
146
|
+
transforms: list[TransformFunctionDefinition] | None = None
|
|
147
|
+
arguments: dict[str, Any] | None = None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _strategies_from_definition(items: dict[str, list[dict[str, Any]]]) -> dict[str, list[StrategyDefinition]]:
|
|
151
|
+
return {name: [StrategyDefinition(**item) for item in value] for name, value in items.items()}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _format_items(items: Iterable[str]) -> str:
|
|
155
|
+
return ", ".join([f"`{item}`" for item in items])
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class OpenApiStringFormatsExtension(BaseExtension):
|
|
160
|
+
"""Custom string formats."""
|
|
161
|
+
|
|
162
|
+
formats: dict[str, list[StrategyDefinition]]
|
|
163
|
+
state: ExtensionState = field(default_factory=NotAppliedState)
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def from_dict(cls, formats: dict[str, list[dict[str, Any]]]) -> OpenApiStringFormatsExtension:
|
|
167
|
+
return cls(formats=_strategies_from_definition(formats))
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def summary(self) -> str:
|
|
171
|
+
count = len(self.formats)
|
|
172
|
+
plural = "s" if count > 1 else ""
|
|
173
|
+
formats = _format_items(self.formats)
|
|
174
|
+
return f"Data generator{plural} for {formats} Open API format{plural}"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class GraphQLScalarsExtension(BaseExtension):
|
|
179
|
+
"""Custom scalars."""
|
|
180
|
+
|
|
181
|
+
scalars: dict[str, list[StrategyDefinition]]
|
|
182
|
+
state: ExtensionState = field(default_factory=NotAppliedState)
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def from_dict(cls, scalars: dict[str, list[dict[str, Any]]]) -> GraphQLScalarsExtension:
|
|
186
|
+
return cls(scalars=_strategies_from_definition(scalars))
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def summary(self) -> str:
|
|
190
|
+
count = len(self.scalars)
|
|
191
|
+
plural = "s" if count > 1 else ""
|
|
192
|
+
scalars = _format_items(self.scalars)
|
|
193
|
+
return f"Data generator{plural} for {scalars} GraphQL scalar{plural}"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass
|
|
197
|
+
class MediaTypesExtension(BaseExtension):
|
|
198
|
+
media_types: dict[str, list[StrategyDefinition]]
|
|
199
|
+
state: ExtensionState = field(default_factory=NotAppliedState)
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def from_dict(cls, media_types: dict[str, list[dict[str, Any]]]) -> MediaTypesExtension:
|
|
203
|
+
return cls(media_types=_strategies_from_definition(media_types))
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def summary(self) -> str:
|
|
207
|
+
count = len(self.media_types)
|
|
208
|
+
plural = "s" if count > 1 else ""
|
|
209
|
+
media_types = _format_items(self.media_types)
|
|
210
|
+
return f"Data generator{plural} for {media_types} media type{plural}"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# A CLI extension that can be used to adjust the behavior of Schemathesis.
|
|
214
|
+
Extension = Union[
|
|
215
|
+
SchemaPatchesExtension,
|
|
216
|
+
OpenApiStringFormatsExtension,
|
|
217
|
+
GraphQLScalarsExtension,
|
|
218
|
+
MediaTypesExtension,
|
|
219
|
+
UnknownExtension,
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def extension_from_dict(data: dict[str, Any]) -> Extension:
|
|
224
|
+
if data["type"] == "schema_patches":
|
|
225
|
+
return SchemaPatchesExtension(patches=data["patches"])
|
|
226
|
+
elif data["type"] == "string_formats":
|
|
227
|
+
return OpenApiStringFormatsExtension.from_dict(formats=data["items"])
|
|
228
|
+
elif data["type"] == "scalars":
|
|
229
|
+
return GraphQLScalarsExtension.from_dict(scalars=data["items"])
|
|
230
|
+
elif data["type"] == "media_types":
|
|
231
|
+
return MediaTypesExtension.from_dict(media_types=data["items"])
|
|
232
|
+
return UnknownExtension(type=data["type"])
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass
|
|
236
|
+
class AnalysisSuccess:
|
|
237
|
+
id: str
|
|
238
|
+
elapsed: float
|
|
239
|
+
message: str
|
|
240
|
+
extensions: list[Extension]
|
|
241
|
+
|
|
242
|
+
@classmethod
|
|
243
|
+
def from_dict(cls, data: dict[str, Any]) -> AnalysisSuccess:
|
|
244
|
+
return cls(
|
|
245
|
+
id=data["id"],
|
|
246
|
+
elapsed=data["elapsed"],
|
|
247
|
+
message=data["message"],
|
|
248
|
+
extensions=[extension_from_dict(ext) for ext in data["extensions"]],
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass
|
|
253
|
+
class AnalysisError:
|
|
254
|
+
message: str
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
AnalysisResult = Union[AnalysisSuccess, AnalysisError]
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
from dataclasses import asdict
|
|
3
4
|
from typing import Any, Callable, Dict, Optional, TypeVar, cast
|
|
4
5
|
|
|
6
|
+
from ..exceptions import format_exception
|
|
7
|
+
from ..internal.result import Err, Ok
|
|
8
|
+
from ..internal.transformation import merge_recursively
|
|
5
9
|
from ..models import Response
|
|
10
|
+
from .models import AnalysisSuccess
|
|
6
11
|
from ..runner import events
|
|
7
12
|
from ..runner.serialization import SerializedCase
|
|
8
|
-
from ..internal.transformation import merge_recursively
|
|
9
13
|
|
|
10
14
|
S = TypeVar("S", bound=events.ExecutionEvent)
|
|
11
15
|
SerializeFunc = Callable[[S], Optional[Dict[str, Any]]]
|
|
@@ -19,6 +23,33 @@ def serialize_initialized(event: events.Initialized) -> dict[str, Any] | None:
|
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
|
|
26
|
+
def serialize_before_probing(_: events.BeforeProbing) -> None:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def serialize_after_probing(event: events.AfterProbing) -> dict[str, Any] | None:
|
|
31
|
+
probes = event.probes or []
|
|
32
|
+
return {"probes": [probe.serialize() for probe in probes]}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def serialize_before_analysis(_: events.BeforeAnalysis) -> None:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def serialize_after_analysis(event: events.AfterAnalysis) -> dict[str, Any] | None:
|
|
40
|
+
data = {}
|
|
41
|
+
analysis = event.analysis
|
|
42
|
+
if isinstance(analysis, Ok):
|
|
43
|
+
result = analysis.ok()
|
|
44
|
+
if isinstance(result, AnalysisSuccess):
|
|
45
|
+
data["analysis_id"] = result.id
|
|
46
|
+
else:
|
|
47
|
+
data["error"] = result.message
|
|
48
|
+
elif isinstance(analysis, Err):
|
|
49
|
+
data["error"] = format_exception(analysis.err())
|
|
50
|
+
return data
|
|
51
|
+
|
|
52
|
+
|
|
22
53
|
def serialize_before_execution(event: events.BeforeExecution) -> dict[str, Any] | None:
|
|
23
54
|
return {
|
|
24
55
|
"correlation_id": event.correlation_id,
|
|
@@ -116,6 +147,10 @@ def serialize_finished(event: events.Finished) -> dict[str, Any] | None:
|
|
|
116
147
|
|
|
117
148
|
SERIALIZER_MAP = {
|
|
118
149
|
events.Initialized: serialize_initialized,
|
|
150
|
+
events.BeforeProbing: serialize_before_probing,
|
|
151
|
+
events.AfterProbing: serialize_after_probing,
|
|
152
|
+
events.BeforeAnalysis: serialize_before_analysis,
|
|
153
|
+
events.AfterAnalysis: serialize_after_analysis,
|
|
119
154
|
events.BeforeExecution: serialize_before_execution,
|
|
120
155
|
events.AfterExecution: serialize_after_execution,
|
|
121
156
|
events.Interrupted: serialize_interrupted,
|
|
@@ -128,6 +163,10 @@ def serialize_event(
|
|
|
128
163
|
event: events.ExecutionEvent,
|
|
129
164
|
*,
|
|
130
165
|
on_initialized: SerializeFunc | None = None,
|
|
166
|
+
on_before_probing: SerializeFunc | None = None,
|
|
167
|
+
on_after_probing: SerializeFunc | None = None,
|
|
168
|
+
on_before_analysis: SerializeFunc | None = None,
|
|
169
|
+
on_after_analysis: SerializeFunc | None = None,
|
|
131
170
|
on_before_execution: SerializeFunc | None = None,
|
|
132
171
|
on_after_execution: SerializeFunc | None = None,
|
|
133
172
|
on_interrupted: SerializeFunc | None = None,
|
|
@@ -139,6 +178,10 @@ def serialize_event(
|
|
|
139
178
|
# Use the explicitly provided serializer for this event and fallback to default one if it is not provided
|
|
140
179
|
serializer = {
|
|
141
180
|
events.Initialized: on_initialized,
|
|
181
|
+
events.BeforeProbing: on_before_probing,
|
|
182
|
+
events.AfterProbing: on_after_probing,
|
|
183
|
+
events.BeforeAnalysis: on_before_analysis,
|
|
184
|
+
events.AfterAnalysis: on_after_analysis,
|
|
142
185
|
events.BeforeExecution: on_before_execution,
|
|
143
186
|
events.AfterExecution: on_after_execution,
|
|
144
187
|
events.Interrupted: on_interrupted,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
from .formats import register_string_format as format
|
|
2
2
|
from .formats import unregister_string_format
|
|
3
3
|
from .loaders import from_aiohttp, from_asgi, from_dict, from_file, from_path, from_pytest_fixture, from_uri, from_wsgi
|
|
4
|
+
from .media_types import register_media_type as media_type
|
|
@@ -30,6 +30,7 @@ from ...types import NotSet
|
|
|
30
30
|
from ...serializers import Binary
|
|
31
31
|
from ...utils import compose, skip
|
|
32
32
|
from .constants import LOCATION_TO_CONTAINER
|
|
33
|
+
from .media_types import MEDIA_TYPES
|
|
33
34
|
from .negative import negative_schema
|
|
34
35
|
from .negative.utils import can_negate
|
|
35
36
|
from .parameters import OpenAPIBody, parameters_to_json_schema
|
|
@@ -134,7 +135,7 @@ def get_case_strategy(
|
|
|
134
135
|
|
|
135
136
|
context = HookContext(operation)
|
|
136
137
|
|
|
137
|
-
generation_config = generation_config or
|
|
138
|
+
generation_config = generation_config or operation.schema.generation_config
|
|
138
139
|
|
|
139
140
|
path_parameters_ = generate_parameter(
|
|
140
141
|
"path", path_parameters, operation, draw, context, hooks, generator, generation_config
|
|
@@ -175,6 +176,11 @@ def get_case_strategy(
|
|
|
175
176
|
else:
|
|
176
177
|
body_ = ValueContainer(value=body, location="body", generator=None)
|
|
177
178
|
else:
|
|
179
|
+
# This explicit body payload comes for a media type that has a custom strategy registered
|
|
180
|
+
# Such strategies only support binary payloads, otherwise they can't be serialized
|
|
181
|
+
if not isinstance(body, bytes) and media_type in MEDIA_TYPES:
|
|
182
|
+
all_media_types = operation.get_request_payload_content_types()
|
|
183
|
+
raise SerializationNotPossible.from_media_types(*all_media_types)
|
|
178
184
|
body_ = ValueContainer(value=body, location="body", generator=None)
|
|
179
185
|
|
|
180
186
|
if operation.schema.validate_schema and operation.method.upper() == "GET" and operation.body:
|
|
@@ -212,6 +218,8 @@ def _get_body_strategy(
|
|
|
212
218
|
operation: APIOperation,
|
|
213
219
|
generation_config: GenerationConfig,
|
|
214
220
|
) -> st.SearchStrategy:
|
|
221
|
+
if parameter.media_type in MEDIA_TYPES:
|
|
222
|
+
return MEDIA_TYPES[parameter.media_type]
|
|
215
223
|
# The cache key relies on object ids, which means that the parameter should not be mutated
|
|
216
224
|
# Note, the parent schema is not included as each parameter belong only to one schema
|
|
217
225
|
if parameter in _BODY_STRATEGIES_CACHE and strategy_factory in _BODY_STRATEGIES_CACHE[parameter]:
|