schemathesis 4.0.0a11__py3-none-any.whl → 4.0.0b1__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 +35 -27
- schemathesis/auths.py +85 -54
- schemathesis/checks.py +65 -36
- schemathesis/cli/commands/run/__init__.py +32 -27
- schemathesis/cli/commands/run/context.py +6 -1
- schemathesis/cli/commands/run/events.py +7 -1
- schemathesis/cli/commands/run/executor.py +12 -7
- schemathesis/cli/commands/run/handlers/output.py +188 -80
- schemathesis/cli/commands/run/validation.py +21 -6
- schemathesis/cli/constants.py +1 -1
- schemathesis/config/__init__.py +2 -1
- schemathesis/config/_generation.py +12 -13
- schemathesis/config/_operations.py +14 -0
- schemathesis/config/_phases.py +41 -5
- schemathesis/config/_projects.py +33 -1
- schemathesis/config/_report.py +6 -2
- schemathesis/config/_warnings.py +25 -0
- schemathesis/config/schema.json +49 -1
- schemathesis/core/errors.py +15 -19
- schemathesis/core/transport.py +117 -2
- schemathesis/engine/context.py +1 -0
- schemathesis/engine/errors.py +61 -2
- schemathesis/engine/events.py +10 -2
- schemathesis/engine/phases/probes.py +3 -0
- schemathesis/engine/phases/stateful/__init__.py +2 -1
- schemathesis/engine/phases/stateful/_executor.py +38 -5
- schemathesis/engine/phases/stateful/context.py +2 -2
- schemathesis/engine/phases/unit/_executor.py +36 -7
- schemathesis/generation/__init__.py +0 -3
- schemathesis/generation/case.py +153 -28
- schemathesis/generation/coverage.py +1 -1
- schemathesis/generation/hypothesis/builder.py +43 -19
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +0 -8
- schemathesis/generation/overrides.py +11 -27
- schemathesis/generation/stateful/__init__.py +17 -0
- schemathesis/generation/stateful/state_machine.py +32 -108
- schemathesis/graphql/loaders.py +152 -8
- schemathesis/hooks.py +63 -39
- schemathesis/openapi/checks.py +82 -20
- schemathesis/openapi/generation/filters.py +9 -2
- schemathesis/openapi/loaders.py +134 -8
- schemathesis/pytest/lazy.py +4 -31
- schemathesis/pytest/loaders.py +24 -0
- schemathesis/pytest/plugin.py +38 -6
- schemathesis/schemas.py +161 -94
- schemathesis/specs/graphql/scalars.py +37 -3
- schemathesis/specs/graphql/schemas.py +18 -9
- schemathesis/specs/openapi/_hypothesis.py +53 -34
- schemathesis/specs/openapi/checks.py +111 -47
- schemathesis/specs/openapi/expressions/nodes.py +1 -1
- schemathesis/specs/openapi/formats.py +30 -3
- schemathesis/specs/openapi/media_types.py +44 -1
- schemathesis/specs/openapi/negative/__init__.py +5 -3
- schemathesis/specs/openapi/negative/mutations.py +2 -2
- schemathesis/specs/openapi/parameters.py +0 -3
- schemathesis/specs/openapi/schemas.py +14 -93
- schemathesis/specs/openapi/stateful/__init__.py +2 -1
- schemathesis/specs/openapi/stateful/links.py +1 -63
- schemathesis/transport/__init__.py +54 -16
- schemathesis/transport/prepare.py +31 -7
- schemathesis/transport/requests.py +21 -9
- schemathesis/transport/serialization.py +0 -4
- schemathesis/transport/wsgi.py +15 -8
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/METADATA +45 -87
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/RECORD +69 -71
- schemathesis/contrib/__init__.py +0 -9
- schemathesis/contrib/openapi/__init__.py +0 -9
- schemathesis/contrib/openapi/fill_missing_examples.py +0 -20
- schemathesis/generation/targets.py +0 -69
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/licenses/LICENSE +0 -0
schemathesis/generation/case.py
CHANGED
@@ -1,8 +1,9 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
from dataclasses import dataclass
|
3
|
+
from dataclasses import dataclass
|
4
4
|
from typing import TYPE_CHECKING, Any, Mapping
|
5
5
|
|
6
|
+
from schemathesis import transport
|
6
7
|
from schemathesis.checks import CHECKS, CheckContext, CheckFunction, run_checks
|
7
8
|
from schemathesis.core import NOT_SET, SCHEMATHESIS_TEST_CASE_HEADER, NotSet, curl
|
8
9
|
from schemathesis.core.failures import FailureGroup, failure_report_title, format_failures
|
@@ -11,42 +12,121 @@ from schemathesis.generation import generate_random_case_id
|
|
11
12
|
from schemathesis.generation.meta import CaseMetadata
|
12
13
|
from schemathesis.generation.overrides import Override, store_components
|
13
14
|
from schemathesis.hooks import HookContext, dispatch
|
14
|
-
from schemathesis.transport.prepare import prepare_request
|
15
|
+
from schemathesis.transport.prepare import prepare_path, prepare_request
|
15
16
|
|
16
17
|
if TYPE_CHECKING:
|
18
|
+
import httpx
|
19
|
+
import requests
|
17
20
|
import requests.auth
|
18
21
|
from requests.structures import CaseInsensitiveDict
|
22
|
+
from werkzeug.test import TestResponse
|
19
23
|
|
20
24
|
from schemathesis.schemas import APIOperation
|
21
25
|
|
22
26
|
|
27
|
+
def _default_headers() -> CaseInsensitiveDict:
|
28
|
+
from requests.structures import CaseInsensitiveDict
|
29
|
+
|
30
|
+
return CaseInsensitiveDict()
|
31
|
+
|
32
|
+
|
23
33
|
@dataclass
|
24
34
|
class Case:
|
25
|
-
"""
|
35
|
+
"""Generated test case data for a single API operation."""
|
26
36
|
|
27
37
|
operation: APIOperation
|
28
38
|
method: str
|
39
|
+
"""HTTP verb (`GET`, `POST`, etc.)"""
|
29
40
|
path: str
|
30
|
-
|
31
|
-
id: str
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
41
|
+
"""Path template from schema (e.g., `/users/{user_id}`)"""
|
42
|
+
id: str
|
43
|
+
"""Random ID sent in headers for log correlation"""
|
44
|
+
path_parameters: dict[str, Any]
|
45
|
+
"""Generated path variables (e.g., `{"user_id": "123"}`)"""
|
46
|
+
headers: CaseInsensitiveDict
|
47
|
+
"""Generated HTTP headers"""
|
48
|
+
cookies: dict[str, Any]
|
49
|
+
"""Generated cookies"""
|
50
|
+
query: dict[str, Any]
|
51
|
+
"""Generated query parameters"""
|
36
52
|
# By default, there is no body, but we can't use `None` as the default value because it clashes with `null`
|
37
53
|
# which is a valid payload.
|
38
|
-
body: list | dict[str, Any] | str | int | float | bool | bytes | NotSet
|
39
|
-
|
40
|
-
media_type: str | None
|
54
|
+
body: list | dict[str, Any] | str | int | float | bool | bytes | NotSet
|
55
|
+
"""Generated request body"""
|
56
|
+
media_type: str | None
|
57
|
+
"""Media type from OpenAPI schema (e.g., "multipart/form-data")"""
|
41
58
|
|
42
|
-
meta: CaseMetadata | None
|
59
|
+
meta: CaseMetadata | None
|
43
60
|
|
44
|
-
_auth: requests.auth.AuthBase | None
|
45
|
-
_has_explicit_auth: bool
|
61
|
+
_auth: requests.auth.AuthBase | None
|
62
|
+
_has_explicit_auth: bool
|
46
63
|
|
47
|
-
|
64
|
+
__slots__ = (
|
65
|
+
"operation",
|
66
|
+
"method",
|
67
|
+
"path",
|
68
|
+
"id",
|
69
|
+
"path_parameters",
|
70
|
+
"headers",
|
71
|
+
"cookies",
|
72
|
+
"query",
|
73
|
+
"body",
|
74
|
+
"media_type",
|
75
|
+
"meta",
|
76
|
+
"_auth",
|
77
|
+
"_has_explicit_auth",
|
78
|
+
"_components",
|
79
|
+
)
|
80
|
+
|
81
|
+
def __init__(
|
82
|
+
self,
|
83
|
+
operation: APIOperation,
|
84
|
+
method: str,
|
85
|
+
path: str,
|
86
|
+
*,
|
87
|
+
id: str | None = None,
|
88
|
+
path_parameters: dict[str, Any] | None = None,
|
89
|
+
headers: CaseInsensitiveDict | None = None,
|
90
|
+
cookies: dict[str, Any] | None = None,
|
91
|
+
query: dict[str, Any] | None = None,
|
92
|
+
body: list | dict[str, Any] | str | int | float | bool | bytes | "NotSet" = NOT_SET,
|
93
|
+
media_type: str | None = None,
|
94
|
+
meta: CaseMetadata | None = None,
|
95
|
+
_auth: requests.auth.AuthBase | None = None,
|
96
|
+
_has_explicit_auth: bool = False,
|
97
|
+
) -> None:
|
98
|
+
self.operation = operation
|
99
|
+
self.method = method
|
100
|
+
self.path = path
|
101
|
+
|
102
|
+
self.id = id if id is not None else generate_random_case_id()
|
103
|
+
self.path_parameters = path_parameters if path_parameters is not None else {}
|
104
|
+
self.headers = headers if headers is not None else _default_headers()
|
105
|
+
self.cookies = cookies if cookies is not None else {}
|
106
|
+
self.query = query if query is not None else {}
|
107
|
+
self.body = body
|
108
|
+
self.media_type = media_type
|
109
|
+
self.meta = meta
|
110
|
+
self._auth = _auth
|
111
|
+
self._has_explicit_auth = _has_explicit_auth
|
48
112
|
self._components = store_components(self)
|
49
113
|
|
114
|
+
def __eq__(self, other: object) -> bool:
|
115
|
+
if not isinstance(other, Case):
|
116
|
+
return NotImplemented
|
117
|
+
|
118
|
+
return (
|
119
|
+
self.operation == other.operation
|
120
|
+
and self.method == other.method
|
121
|
+
and self.path == other.path
|
122
|
+
and self.path_parameters == other.path_parameters
|
123
|
+
and self.headers == other.headers
|
124
|
+
and self.cookies == other.cookies
|
125
|
+
and self.query == other.query
|
126
|
+
and self.body == other.body
|
127
|
+
and self.media_type == other.media_type
|
128
|
+
)
|
129
|
+
|
50
130
|
@property
|
51
131
|
def _override(self) -> Override:
|
52
132
|
return Override.from_components(self._components, self)
|
@@ -56,6 +136,8 @@ class Case:
|
|
56
136
|
first = True
|
57
137
|
for name in ("path_parameters", "headers", "cookies", "query", "body"):
|
58
138
|
value = getattr(self, name)
|
139
|
+
if name != "body" and not value:
|
140
|
+
continue
|
59
141
|
if value is not None and not isinstance(value, NotSet):
|
60
142
|
if first:
|
61
143
|
first = False
|
@@ -69,8 +151,19 @@ class Case:
|
|
69
151
|
|
70
152
|
def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
|
71
153
|
|
154
|
+
@property
|
155
|
+
def formatted_path(self) -> str:
|
156
|
+
"""Path template with variables substituted (e.g., /users/{user_id} → /users/123)."""
|
157
|
+
return prepare_path(self.path, self.path_parameters)
|
158
|
+
|
72
159
|
def as_curl_command(self, headers: Mapping[str, Any] | None = None, verify: bool = True) -> str:
|
73
|
-
"""
|
160
|
+
"""Generate a curl command that reproduces this test case.
|
161
|
+
|
162
|
+
Args:
|
163
|
+
headers: Additional headers to include in the command.
|
164
|
+
verify: When False, adds `--insecure` flag to curl command.
|
165
|
+
|
166
|
+
"""
|
74
167
|
request_data = prepare_request(self, headers, config=self.operation.schema.config.output.sanitization)
|
75
168
|
return curl.generate(
|
76
169
|
method=str(request_data.method),
|
@@ -82,7 +175,6 @@ class Case:
|
|
82
175
|
)
|
83
176
|
|
84
177
|
def as_transport_kwargs(self, base_url: str | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]:
|
85
|
-
"""Convert the test case into a dictionary acceptable by the underlying transport call."""
|
86
178
|
return self.operation.schema.transport.serialize_case(self, base_url=base_url, headers=headers)
|
87
179
|
|
88
180
|
def call(
|
@@ -94,11 +186,28 @@ class Case:
|
|
94
186
|
cookies: dict[str, Any] | None = None,
|
95
187
|
**kwargs: Any,
|
96
188
|
) -> Response:
|
189
|
+
"""Make an HTTP request using this test case's data without validation.
|
190
|
+
|
191
|
+
Use when you need to validate response separately
|
192
|
+
|
193
|
+
Args:
|
194
|
+
base_url: Override the schema's base URL.
|
195
|
+
session: Reuse an existing requests session.
|
196
|
+
headers: Additional headers.
|
197
|
+
params: Additional query parameters.
|
198
|
+
cookies: Additional cookies.
|
199
|
+
**kwargs: Additional transport-level arguments.
|
200
|
+
|
201
|
+
"""
|
97
202
|
hook_context = HookContext(operation=self.operation)
|
98
203
|
dispatch("before_call", hook_context, self, **kwargs)
|
99
204
|
if self.operation.app is not None:
|
100
|
-
kwargs
|
101
|
-
|
205
|
+
kwargs.setdefault("app", self.operation.app)
|
206
|
+
if "app" in kwargs:
|
207
|
+
transport_ = transport.get(kwargs["app"])
|
208
|
+
else:
|
209
|
+
transport_ = self.operation.schema.transport
|
210
|
+
response = transport_.send(
|
102
211
|
self,
|
103
212
|
session=session,
|
104
213
|
base_url=base_url,
|
@@ -112,26 +221,29 @@ class Case:
|
|
112
221
|
|
113
222
|
def validate_response(
|
114
223
|
self,
|
115
|
-
response: Response,
|
224
|
+
response: Response | httpx.Response | requests.Response | TestResponse,
|
116
225
|
checks: list[CheckFunction] | None = None,
|
117
226
|
additional_checks: list[CheckFunction] | None = None,
|
118
227
|
excluded_checks: list[CheckFunction] | None = None,
|
119
228
|
headers: dict[str, Any] | None = None,
|
120
229
|
transport_kwargs: dict[str, Any] | None = None,
|
121
230
|
) -> None:
|
122
|
-
"""Validate
|
231
|
+
"""Validate a response against the API schema and built-in checks.
|
123
232
|
|
124
|
-
|
233
|
+
Args:
|
234
|
+
response: Response to validate.
|
235
|
+
checks: Explicit set of checks to run.
|
236
|
+
additional_checks: Additional custom checks to run.
|
237
|
+
excluded_checks: Built-in checks to skip.
|
238
|
+
headers: Headers used in the original request.
|
239
|
+
transport_kwargs: Transport arguments used in the original request.
|
125
240
|
|
126
|
-
:param response: Application response.
|
127
|
-
:param checks: A tuple of check functions that accept ``response`` and ``case``.
|
128
|
-
:param additional_checks: A tuple of additional checks that will be executed after ones from the ``checks``
|
129
|
-
argument.
|
130
|
-
:param excluded_checks: Checks excluded from the default ones.
|
131
241
|
"""
|
132
242
|
__tracebackhide__ = True
|
133
243
|
from requests.structures import CaseInsensitiveDict
|
134
244
|
|
245
|
+
response = Response.from_any(response)
|
246
|
+
|
135
247
|
checks = [
|
136
248
|
check
|
137
249
|
for check in list(checks or CHECKS.get_all()) + list(additional_checks or [])
|
@@ -167,6 +279,7 @@ class Case:
|
|
167
279
|
curl=curl,
|
168
280
|
config=self.operation.schema.config.output,
|
169
281
|
)
|
282
|
+
message += "\n\n"
|
170
283
|
raise FailureGroup(_failures, message) from None
|
171
284
|
|
172
285
|
def call_and_validate(
|
@@ -179,6 +292,18 @@ class Case:
|
|
179
292
|
excluded_checks: list[CheckFunction] | None = None,
|
180
293
|
**kwargs: Any,
|
181
294
|
) -> Response:
|
295
|
+
"""Make an HTTP request and validates the response automatically.
|
296
|
+
|
297
|
+
Args:
|
298
|
+
base_url: Override the schema's base URL.
|
299
|
+
session: Reuse an existing requests session.
|
300
|
+
headers: Additional headers to send.
|
301
|
+
checks: Explicit set of checks to run.
|
302
|
+
additional_checks: Additional custom checks to run.
|
303
|
+
excluded_checks: Built-in checks to skip.
|
304
|
+
**kwargs: Additional transport-level arguments.
|
305
|
+
|
306
|
+
"""
|
182
307
|
__tracebackhide__ = True
|
183
308
|
response = self.call(base_url, session, headers, **kwargs)
|
184
309
|
self.validate_response(
|
@@ -121,7 +121,7 @@ class CoverageContext:
|
|
121
121
|
path: list[str | int] | None = None,
|
122
122
|
) -> None:
|
123
123
|
self.location = location
|
124
|
-
self.generation_modes = generation_modes if generation_modes is not None else GenerationMode
|
124
|
+
self.generation_modes = generation_modes if generation_modes is not None else list(GenerationMode)
|
125
125
|
self.path = path or []
|
126
126
|
|
127
127
|
@contextmanager
|
@@ -9,7 +9,7 @@ from time import perf_counter
|
|
9
9
|
from typing import Any, Callable, Generator, Mapping
|
10
10
|
|
11
11
|
import hypothesis
|
12
|
-
from hypothesis import Phase
|
12
|
+
from hypothesis import Phase, Verbosity
|
13
13
|
from hypothesis import strategies as st
|
14
14
|
from hypothesis._settings import all_settings
|
15
15
|
from hypothesis.errors import Unsatisfiable
|
@@ -27,6 +27,7 @@ from schemathesis.core.validation import has_invalid_characters, is_latin_1_enco
|
|
27
27
|
from schemathesis.generation import GenerationMode, coverage
|
28
28
|
from schemathesis.generation.case import Case
|
29
29
|
from schemathesis.generation.hypothesis import DEFAULT_DEADLINE, examples, setup, strategies
|
30
|
+
from schemathesis.generation.hypothesis.examples import add_single_example
|
30
31
|
from schemathesis.generation.hypothesis.given import GivenInput
|
31
32
|
from schemathesis.generation.meta import (
|
32
33
|
CaseMetadata,
|
@@ -96,14 +97,17 @@ def create_test(
|
|
96
97
|
if settings.deadline == default.deadline:
|
97
98
|
settings = hypothesis.settings(settings, deadline=DEFAULT_DEADLINE)
|
98
99
|
|
100
|
+
if settings.verbosity == default.verbosity:
|
101
|
+
settings = hypothesis.settings(settings, verbosity=Verbosity.quiet)
|
102
|
+
|
99
103
|
if config.settings is not None:
|
100
104
|
# Merge the user-provided settings with the current ones
|
101
105
|
settings = hypothesis.settings(
|
102
|
-
settings,
|
106
|
+
config.settings,
|
103
107
|
**{
|
104
|
-
item: getattr(
|
108
|
+
item: getattr(settings, item)
|
105
109
|
for item in all_settings
|
106
|
-
if getattr(
|
110
|
+
if getattr(settings, item) != getattr(default, item)
|
107
111
|
},
|
108
112
|
)
|
109
113
|
|
@@ -126,7 +130,14 @@ def create_test(
|
|
126
130
|
and Phase.explicit in settings.phases
|
127
131
|
and specification.supports_feature(SpecificationFeature.EXAMPLES)
|
128
132
|
):
|
129
|
-
|
133
|
+
phases_config = config.project.phases_for(operation=operation)
|
134
|
+
hypothesis_test = add_examples(
|
135
|
+
hypothesis_test,
|
136
|
+
operation,
|
137
|
+
fill_missing=phases_config.examples.fill_missing,
|
138
|
+
hook_dispatcher=hook_dispatcher,
|
139
|
+
**strategy_kwargs,
|
140
|
+
)
|
130
141
|
|
131
142
|
if (
|
132
143
|
HypothesisTestMode.COVERAGE in config.modes
|
@@ -142,7 +153,8 @@ def create_test(
|
|
142
153
|
generation.modes,
|
143
154
|
auth_storage,
|
144
155
|
config.as_strategy_kwargs,
|
145
|
-
phases_config.coverage.
|
156
|
+
generate_duplicate_query_parameters=phases_config.coverage.generate_duplicate_query_parameters,
|
157
|
+
unexpected_methods=phases_config.coverage.unexpected_methods,
|
146
158
|
)
|
147
159
|
|
148
160
|
setattr(hypothesis_test, SETTINGS_ATTRIBUTE_NAME, settings)
|
@@ -188,7 +200,11 @@ def make_async_test(test: Callable) -> Callable:
|
|
188
200
|
|
189
201
|
|
190
202
|
def add_examples(
|
191
|
-
test: Callable,
|
203
|
+
test: Callable,
|
204
|
+
operation: APIOperation,
|
205
|
+
fill_missing: bool,
|
206
|
+
hook_dispatcher: HookDispatcher | None = None,
|
207
|
+
**kwargs: Any,
|
192
208
|
) -> Callable:
|
193
209
|
"""Add examples to the Hypothesis test, if they are specified in the schema."""
|
194
210
|
from hypothesis_jsonschema._canonicalise import HypothesisRefResolutionError
|
@@ -212,7 +228,11 @@ def add_examples(
|
|
212
228
|
if isinstance(exc, SchemaError):
|
213
229
|
InvalidRegexMark.set(test, exc)
|
214
230
|
|
215
|
-
|
231
|
+
if fill_missing and not result:
|
232
|
+
strategy = operation.as_strategy()
|
233
|
+
add_single_example(strategy, result)
|
234
|
+
|
235
|
+
context = HookContext(operation=operation) # context should be passed here instead
|
216
236
|
GLOBAL_HOOK_DISPATCHER.dispatch("before_add_examples", context, result)
|
217
237
|
operation.schema.hooks.dispatch("before_add_examples", context, result)
|
218
238
|
if hook_dispatcher:
|
@@ -246,6 +266,7 @@ def add_coverage(
|
|
246
266
|
generation_modes: list[GenerationMode],
|
247
267
|
auth_storage: AuthStorage | None,
|
248
268
|
as_strategy_kwargs: dict[str, Any],
|
269
|
+
generate_duplicate_query_parameters: bool,
|
249
270
|
unexpected_methods: set[str] | None = None,
|
250
271
|
) -> Callable:
|
251
272
|
from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
|
@@ -259,7 +280,9 @@ def add_coverage(
|
|
259
280
|
for container in LOCATION_TO_CONTAINER.values()
|
260
281
|
if container in as_strategy_kwargs
|
261
282
|
}
|
262
|
-
for case in _iter_coverage_cases(
|
283
|
+
for case in _iter_coverage_cases(
|
284
|
+
operation, generation_modes, generate_duplicate_query_parameters, unexpected_methods
|
285
|
+
):
|
263
286
|
if case.media_type and operation.schema.transport.get_first_matching_media_type(case.media_type) is None:
|
264
287
|
continue
|
265
288
|
adjust_urlencoded_payload(case)
|
@@ -396,6 +419,7 @@ def _stringify_value(val: Any, container_name: str) -> Any:
|
|
396
419
|
def _iter_coverage_cases(
|
397
420
|
operation: APIOperation,
|
398
421
|
generation_modes: list[GenerationMode],
|
422
|
+
generate_duplicate_query_parameters: bool,
|
399
423
|
unexpected_methods: set[str] | None = None,
|
400
424
|
) -> Generator[Case, None, None]:
|
401
425
|
from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
|
@@ -451,7 +475,7 @@ def _iter_coverage_cases(
|
|
451
475
|
data = template.with_body(value=value, media_type=body.media_type)
|
452
476
|
yield operation.Case(
|
453
477
|
**data.kwargs,
|
454
|
-
|
478
|
+
_meta=CaseMetadata(
|
455
479
|
generation=GenerationInfo(
|
456
480
|
time=elapsed,
|
457
481
|
mode=value.generation_mode,
|
@@ -473,7 +497,7 @@ def _iter_coverage_cases(
|
|
473
497
|
data = template.with_body(value=next_value, media_type=body.media_type)
|
474
498
|
yield operation.Case(
|
475
499
|
**data.kwargs,
|
476
|
-
|
500
|
+
_meta=CaseMetadata(
|
477
501
|
generation=GenerationInfo(
|
478
502
|
time=instant.elapsed,
|
479
503
|
mode=next_value.generation_mode,
|
@@ -494,7 +518,7 @@ def _iter_coverage_cases(
|
|
494
518
|
seen_positive.insert(data.kwargs)
|
495
519
|
yield operation.Case(
|
496
520
|
**data.kwargs,
|
497
|
-
|
521
|
+
_meta=CaseMetadata(
|
498
522
|
generation=GenerationInfo(
|
499
523
|
time=template_time,
|
500
524
|
mode=GenerationMode.POSITIVE,
|
@@ -522,7 +546,7 @@ def _iter_coverage_cases(
|
|
522
546
|
|
523
547
|
yield operation.Case(
|
524
548
|
**data.kwargs,
|
525
|
-
|
549
|
+
_meta=CaseMetadata(
|
526
550
|
generation=GenerationInfo(time=instant.elapsed, mode=value.generation_mode),
|
527
551
|
components=data.components,
|
528
552
|
phase=PhaseInfo.coverage(
|
@@ -542,14 +566,14 @@ def _iter_coverage_cases(
|
|
542
566
|
yield operation.Case(
|
543
567
|
**data.kwargs,
|
544
568
|
method=method.upper(),
|
545
|
-
|
569
|
+
_meta=CaseMetadata(
|
546
570
|
generation=GenerationInfo(time=instant.elapsed, mode=GenerationMode.NEGATIVE),
|
547
571
|
components=data.components,
|
548
572
|
phase=PhaseInfo.coverage(description=f"Unspecified HTTP method: {method.upper()}"),
|
549
573
|
),
|
550
574
|
)
|
551
575
|
# Generate duplicate query parameters
|
552
|
-
if operation.query:
|
576
|
+
if generate_duplicate_query_parameters and operation.query:
|
553
577
|
container = template["query"]
|
554
578
|
for parameter in operation.query:
|
555
579
|
instant = Instant()
|
@@ -564,7 +588,7 @@ def _iter_coverage_cases(
|
|
564
588
|
)
|
565
589
|
yield operation.Case(
|
566
590
|
**data.kwargs,
|
567
|
-
|
591
|
+
_meta=CaseMetadata(
|
568
592
|
generation=GenerationInfo(time=instant.elapsed, mode=GenerationMode.NEGATIVE),
|
569
593
|
components=data.components,
|
570
594
|
phase=PhaseInfo.coverage(
|
@@ -589,7 +613,7 @@ def _iter_coverage_cases(
|
|
589
613
|
)
|
590
614
|
yield operation.Case(
|
591
615
|
**data.kwargs,
|
592
|
-
|
616
|
+
_meta=CaseMetadata(
|
593
617
|
generation=GenerationInfo(time=instant.elapsed, mode=GenerationMode.NEGATIVE),
|
594
618
|
components=data.components,
|
595
619
|
phase=PhaseInfo.coverage(
|
@@ -631,7 +655,7 @@ def _iter_coverage_cases(
|
|
631
655
|
)
|
632
656
|
return operation.Case(
|
633
657
|
**data.kwargs,
|
634
|
-
|
658
|
+
_meta=CaseMetadata(
|
635
659
|
generation=GenerationInfo(
|
636
660
|
time=_instant.elapsed,
|
637
661
|
mode=_generation_mode,
|
@@ -757,7 +781,7 @@ def _case_to_kwargs(case: Case) -> dict:
|
|
757
781
|
kwargs = {}
|
758
782
|
for container_name in LOCATION_TO_CONTAINER.values():
|
759
783
|
value = getattr(case, container_name)
|
760
|
-
if isinstance(value, CaseInsensitiveDict):
|
784
|
+
if isinstance(value, CaseInsensitiveDict) and value:
|
761
785
|
kwargs[container_name] = dict(value)
|
762
786
|
elif value and value is not NOT_SET:
|
763
787
|
kwargs[container_name] = value
|
@@ -0,0 +1,93 @@
|
|
1
|
+
"""Support for Targeted Property-Based Testing."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
from dataclasses import dataclass
|
6
|
+
from typing import Callable, Sequence
|
7
|
+
|
8
|
+
from schemathesis.core.registries import Registry
|
9
|
+
from schemathesis.core.transport import Response
|
10
|
+
from schemathesis.generation.case import Case
|
11
|
+
|
12
|
+
|
13
|
+
@dataclass
|
14
|
+
class MetricContext:
|
15
|
+
"""Context for evaluating a metric on a single test execution.
|
16
|
+
|
17
|
+
This object bundles together the test `case` that was sent and
|
18
|
+
the corresponding HTTP `response`. Metric functions receive an
|
19
|
+
instance of `MetricContext` to compute a numeric score.
|
20
|
+
"""
|
21
|
+
|
22
|
+
case: Case
|
23
|
+
"""Generated test case."""
|
24
|
+
response: Response
|
25
|
+
"""The HTTP response returned by the server for this test case."""
|
26
|
+
|
27
|
+
__slots__ = ("case", "response")
|
28
|
+
|
29
|
+
|
30
|
+
MetricFunction = Callable[[MetricContext], float]
|
31
|
+
|
32
|
+
METRICS = Registry[MetricFunction]()
|
33
|
+
|
34
|
+
|
35
|
+
def metric(func: MetricFunction) -> MetricFunction:
|
36
|
+
"""Decorator to register a custom metric for targeted property-based testing.
|
37
|
+
|
38
|
+
Example:
|
39
|
+
```python
|
40
|
+
import schemathesis
|
41
|
+
|
42
|
+
@schemathesis.metric
|
43
|
+
def response_size(ctx: schemathesis.MetricContext) -> float:
|
44
|
+
return float(len(ctx.response.content))
|
45
|
+
```
|
46
|
+
|
47
|
+
"""
|
48
|
+
return METRICS.register(func)
|
49
|
+
|
50
|
+
|
51
|
+
@metric
|
52
|
+
def response_time(ctx: MetricContext) -> float:
|
53
|
+
"""Response time as a metric to maximize."""
|
54
|
+
return ctx.response.elapsed
|
55
|
+
|
56
|
+
|
57
|
+
class MetricCollector:
|
58
|
+
"""Collect multiple observations for metrics."""
|
59
|
+
|
60
|
+
__slots__ = ("metrics", "observations")
|
61
|
+
|
62
|
+
def __init__(self, metrics: list[MetricFunction] | None = None) -> None:
|
63
|
+
self.metrics = metrics or []
|
64
|
+
self.observations: dict[str, list[float]] = {metric.__name__: [] for metric in self.metrics}
|
65
|
+
|
66
|
+
def reset(self) -> None:
|
67
|
+
"""Reset all collected observations."""
|
68
|
+
for metric in self.metrics:
|
69
|
+
self.observations[metric.__name__].clear()
|
70
|
+
|
71
|
+
def store(self, case: Case, response: Response) -> None:
|
72
|
+
"""Calculate metrics & store them."""
|
73
|
+
ctx = MetricContext(case=case, response=response)
|
74
|
+
for metric in self.metrics:
|
75
|
+
self.observations[metric.__name__].append(metric(ctx))
|
76
|
+
|
77
|
+
def maximize(self) -> None:
|
78
|
+
"""Give feedback to the Hypothesis engine, so it maximizes the aggregated metrics."""
|
79
|
+
import hypothesis
|
80
|
+
|
81
|
+
for metric in self.metrics:
|
82
|
+
# Currently aggregation is just a sum
|
83
|
+
value = sum(self.observations[metric.__name__])
|
84
|
+
hypothesis.target(value, label=metric.__name__)
|
85
|
+
|
86
|
+
|
87
|
+
def maximize(metrics: Sequence[MetricFunction], case: Case, response: Response) -> None:
|
88
|
+
import hypothesis
|
89
|
+
|
90
|
+
ctx = MetricContext(case=case, response=response)
|
91
|
+
for metric in metrics:
|
92
|
+
value = metric(ctx)
|
93
|
+
hypothesis.target(value, label=metric.__name__)
|
schemathesis/generation/modes.py
CHANGED
@@ -11,14 +11,6 @@ class GenerationMode(str, Enum):
|
|
11
11
|
# Doesn't fit the API schema
|
12
12
|
NEGATIVE = "negative"
|
13
13
|
|
14
|
-
@classmethod
|
15
|
-
def default(cls) -> GenerationMode:
|
16
|
-
return cls.POSITIVE
|
17
|
-
|
18
|
-
@classmethod
|
19
|
-
def all(cls) -> list[GenerationMode]:
|
20
|
-
return list(GenerationMode)
|
21
|
-
|
22
14
|
@property
|
23
15
|
def is_positive(self) -> bool:
|
24
16
|
return self == GenerationMode.POSITIVE
|
@@ -2,17 +2,15 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
from collections.abc import Mapping
|
4
4
|
from dataclasses import dataclass
|
5
|
-
from typing import TYPE_CHECKING, Any,
|
5
|
+
from typing import TYPE_CHECKING, Any, Iterator
|
6
6
|
|
7
7
|
from schemathesis.config import ProjectConfig
|
8
|
-
from schemathesis.core.errors import IncorrectUsage
|
9
|
-
from schemathesis.core.marks import Mark
|
10
8
|
from schemathesis.core.transforms import diff
|
11
9
|
from schemathesis.generation.meta import ComponentKind
|
12
10
|
|
13
11
|
if TYPE_CHECKING:
|
14
12
|
from schemathesis.generation.case import Case
|
15
|
-
from schemathesis.schemas import APIOperation, Parameter
|
13
|
+
from schemathesis.schemas import APIOperation, Parameter
|
16
14
|
|
17
15
|
|
18
16
|
@dataclass
|
@@ -24,13 +22,15 @@ class Override:
|
|
24
22
|
cookies: dict[str, str]
|
25
23
|
path_parameters: dict[str, str]
|
26
24
|
|
27
|
-
def
|
28
|
-
|
29
|
-
"query"
|
30
|
-
"headers"
|
31
|
-
"cookies"
|
32
|
-
"path_parameters"
|
33
|
-
|
25
|
+
def items(self) -> Iterator[tuple[str, dict[str, str]]]:
|
26
|
+
for key, value in (
|
27
|
+
("query", self.query),
|
28
|
+
("headers", self.headers),
|
29
|
+
("cookies", self.cookies),
|
30
|
+
("path_parameters", self.path_parameters),
|
31
|
+
):
|
32
|
+
if value:
|
33
|
+
yield key, value
|
34
34
|
|
35
35
|
@classmethod
|
36
36
|
def from_components(cls, components: dict[ComponentKind, StoredValue], case: Case) -> Override:
|
@@ -77,14 +77,6 @@ def _get_override_value(param: Parameter, parameters: dict[str, Any]) -> Any:
|
|
77
77
|
return None
|
78
78
|
|
79
79
|
|
80
|
-
def _for_parameters(overridden: dict[str, str], defined: ParameterSet) -> dict[str, str]:
|
81
|
-
output = {}
|
82
|
-
for param in defined:
|
83
|
-
if param.name in overridden:
|
84
|
-
output[param.name] = overridden[param.name]
|
85
|
-
return output
|
86
|
-
|
87
|
-
|
88
80
|
@dataclass
|
89
81
|
class StoredValue:
|
90
82
|
value: dict[str, Any] | None
|
@@ -122,11 +114,3 @@ def store_components(case: Case) -> dict[ComponentKind, StoredValue]:
|
|
122
114
|
ComponentKind.PATH_PARAMETERS,
|
123
115
|
]
|
124
116
|
}
|
125
|
-
|
126
|
-
|
127
|
-
OverrideMark = Mark[Override](attr_name="override")
|
128
|
-
|
129
|
-
|
130
|
-
def check_no_override_mark(test: Callable) -> None:
|
131
|
-
if OverrideMark.is_set(test):
|
132
|
-
raise IncorrectUsage(f"`{test.__name__}` has already been decorated with `override`.")
|