schemathesis 3.25.6__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 +26 -18
- schemathesis/cli/cassettes.py +4 -4
- schemathesis/cli/context.py +4 -1
- schemathesis/cli/output/default.py +154 -39
- schemathesis/cli/output/short.py +4 -0
- schemathesis/experimental/__init__.py +7 -0
- schemathesis/filters.py +1 -0
- schemathesis/parameters.py +1 -0
- schemathesis/runner/__init__.py +12 -0
- schemathesis/runner/events.py +12 -0
- schemathesis/runner/impl/core.py +29 -2
- schemathesis/runner/probes.py +1 -0
- schemathesis/runner/serialization.py +4 -2
- schemathesis/schemas.py +1 -0
- schemathesis/serializers.py +1 -1
- 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 +29 -1
- schemathesis/specs/openapi/__init__.py +1 -0
- schemathesis/specs/openapi/_hypothesis.py +8 -0
- 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/security.py +5 -1
- {schemathesis-3.25.6.dist-info → schemathesis-3.26.0.dist-info}/METADATA +8 -5
- {schemathesis-3.25.6.dist-info → schemathesis-3.26.0.dist-info}/RECORD +39 -37
- {schemathesis-3.25.6.dist-info → schemathesis-3.26.0.dist-info}/WHEEL +0 -0
- {schemathesis-3.25.6.dist-info → schemathesis-3.26.0.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.25.6.dist-info → schemathesis-3.26.0.dist-info}/licenses/LICENSE +0 -0
schemathesis/runner/impl/core.py
CHANGED
|
@@ -53,10 +53,12 @@ from ...exceptions import (
|
|
|
53
53
|
from ...generation import DataGenerationMethod, GenerationConfig
|
|
54
54
|
from ...hooks import HookContext, get_all_by_name
|
|
55
55
|
from ...internal.datetime import current_datetime
|
|
56
|
-
from ...internal.result import Ok
|
|
56
|
+
from ...internal.result import Err, Ok, Result
|
|
57
57
|
from ...models import APIOperation, Case, Check, CheckFunction, Status, TestResult, TestResultSet
|
|
58
58
|
from ...runner import events
|
|
59
59
|
from ...schemas import BaseSchema
|
|
60
|
+
from ...service import extensions
|
|
61
|
+
from ...service.models import AnalysisResult, AnalysisSuccess
|
|
60
62
|
from ...specs.openapi import formats
|
|
61
63
|
from ...stateful import Feedback, Stateful
|
|
62
64
|
from ...targets import Target, TargetContext
|
|
@@ -66,6 +68,7 @@ from .. import probes
|
|
|
66
68
|
from ..serialization import SerializedTestResult
|
|
67
69
|
|
|
68
70
|
if TYPE_CHECKING:
|
|
71
|
+
from ...service.client import ServiceClient
|
|
69
72
|
from ...transports.responses import GenericResponse, WSGIResponse
|
|
70
73
|
|
|
71
74
|
|
|
@@ -97,6 +100,7 @@ class BaseRunner:
|
|
|
97
100
|
stateful_recursion_limit: int = DEFAULT_STATEFUL_RECURSION_LIMIT
|
|
98
101
|
count_operations: bool = True
|
|
99
102
|
count_links: bool = True
|
|
103
|
+
service_client: ServiceClient | None = None
|
|
100
104
|
_failures_counter: int = 0
|
|
101
105
|
|
|
102
106
|
def execute(self) -> EventStream:
|
|
@@ -109,9 +113,10 @@ class BaseRunner:
|
|
|
109
113
|
if self.auth is not None:
|
|
110
114
|
unregister_auth()
|
|
111
115
|
results = TestResultSet(seed=self.seed)
|
|
116
|
+
start_time = time.monotonic()
|
|
112
117
|
initialized = None
|
|
113
118
|
__probes = None
|
|
114
|
-
|
|
119
|
+
__analysis: Result[AnalysisResult, Exception] | None = None
|
|
115
120
|
|
|
116
121
|
def _initialize() -> events.Initialized:
|
|
117
122
|
nonlocal initialized
|
|
@@ -142,6 +147,25 @@ class BaseRunner:
|
|
|
142
147
|
_probes = cast(List[probes.ProbeRun], __probes)
|
|
143
148
|
return events.AfterProbing(probes=_probes)
|
|
144
149
|
|
|
150
|
+
def _before_analysis() -> events.BeforeAnalysis:
|
|
151
|
+
return events.BeforeAnalysis()
|
|
152
|
+
|
|
153
|
+
def _run_analysis() -> None:
|
|
154
|
+
nonlocal __analysis, __probes
|
|
155
|
+
|
|
156
|
+
if self.service_client is not None:
|
|
157
|
+
try:
|
|
158
|
+
_probes = cast(List[probes.ProbeRun], __probes)
|
|
159
|
+
result = self.service_client.analyze_schema(_probes, self.schema.raw_schema)
|
|
160
|
+
if isinstance(result, AnalysisSuccess):
|
|
161
|
+
extensions.apply(result.extensions, self.schema)
|
|
162
|
+
__analysis = Ok(result)
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
__analysis = Err(exc)
|
|
165
|
+
|
|
166
|
+
def _after_analysis() -> events.AfterAnalysis:
|
|
167
|
+
return events.AfterAnalysis(analysis=__analysis)
|
|
168
|
+
|
|
145
169
|
if stop_event.is_set():
|
|
146
170
|
yield _finish()
|
|
147
171
|
return
|
|
@@ -151,6 +175,9 @@ class BaseRunner:
|
|
|
151
175
|
_before_probes,
|
|
152
176
|
_run_probes,
|
|
153
177
|
_after_probes,
|
|
178
|
+
_before_analysis,
|
|
179
|
+
_run_analysis,
|
|
180
|
+
_after_analysis,
|
|
154
181
|
):
|
|
155
182
|
event = event_factory()
|
|
156
183
|
if event is not None:
|
schemathesis/runner/probes.py
CHANGED
|
@@ -5,6 +5,7 @@ the application supports certain inputs. This is done to avoid false positives i
|
|
|
5
5
|
For example, certail web servers do not support NULL bytes in headers, in such cases, the generated test case
|
|
6
6
|
will not reach the tested application at all.
|
|
7
7
|
"""
|
|
8
|
+
|
|
8
9
|
from __future__ import annotations
|
|
9
10
|
|
|
10
11
|
import enum
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
They all consist of primitive types and don't have references to schemas, app, etc.
|
|
4
4
|
"""
|
|
5
|
+
|
|
5
6
|
from __future__ import annotations
|
|
6
7
|
import logging
|
|
7
8
|
import re
|
|
@@ -226,8 +227,9 @@ class SerializedError:
|
|
|
226
227
|
message = f"Scalar type '{scalar_name}' is not recognized"
|
|
227
228
|
extras = []
|
|
228
229
|
title = "Unknown GraphQL Scalar"
|
|
229
|
-
elif isinstance(exception, hypothesis.errors.InvalidArgument) and
|
|
230
|
-
"larger than Hypothesis is designed to handle"
|
|
230
|
+
elif isinstance(exception, hypothesis.errors.InvalidArgument) and (
|
|
231
|
+
str(exception).endswith("larger than Hypothesis is designed to handle")
|
|
232
|
+
or "can neber generate an example, because min_size is larger than Hypothesis suports."
|
|
231
233
|
):
|
|
232
234
|
type_ = RuntimeErrorType.HYPOTHESIS_HEALTH_CHECK_LARGE_BASE_EXAMPLE
|
|
233
235
|
message = HEALTH_CHECK_MESSAGE_LARGE_BASE_EXAMPLE
|
schemathesis/schemas.py
CHANGED
schemathesis/serializers.py
CHANGED
|
@@ -173,7 +173,7 @@ def _to_yaml(value: Any) -> dict[str, Any]:
|
|
|
173
173
|
return {"data": yaml.dump(value, Dumper=SafeDumper)}
|
|
174
174
|
|
|
175
175
|
|
|
176
|
-
@register("text/yaml", aliases=("text/x-yaml", "
|
|
176
|
+
@register("text/yaml", aliases=("text/x-yaml", "text/vnd.yaml", "text/yml", "application/yaml", "application/x-yaml"))
|
|
177
177
|
class YAMLSerializer:
|
|
178
178
|
def as_requests(self, context: SerializerContext, value: Any) -> dict[str, Any]:
|
|
179
179
|
return _to_yaml(value)
|
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]
|