schemathesis 3.35.5__py3-none-any.whl → 3.36.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/checks.py +8 -5
- schemathesis/cli/__init__.py +4 -13
- schemathesis/contrib/unique_data.py +1 -2
- schemathesis/generation/coverage.py +48 -7
- schemathesis/internal/checks.py +53 -0
- schemathesis/models.py +20 -8
- schemathesis/runner/__init__.py +7 -1
- schemathesis/runner/impl/context.py +18 -4
- schemathesis/runner/impl/core.py +57 -12
- schemathesis/runner/impl/solo.py +1 -1
- schemathesis/runner/impl/threadpool.py +3 -4
- schemathesis/schemas.py +2 -2
- schemathesis/specs/graphql/loaders.py +2 -2
- schemathesis/specs/graphql/schemas.py +9 -5
- schemathesis/specs/openapi/checks.py +76 -27
- schemathesis/specs/openapi/loaders.py +2 -2
- schemathesis/specs/openapi/schemas.py +19 -3
- schemathesis/stateful/config.py +1 -0
- schemathesis/stateful/context.py +10 -0
- schemathesis/stateful/runner.py +19 -2
- schemathesis/stateful/state_machine.py +2 -1
- schemathesis/stateful/validation.py +9 -4
- {schemathesis-3.35.5.dist-info → schemathesis-3.36.0.dist-info}/METADATA +1 -1
- {schemathesis-3.35.5.dist-info → schemathesis-3.36.0.dist-info}/RECORD +27 -26
- {schemathesis-3.35.5.dist-info → schemathesis-3.36.0.dist-info}/WHEEL +0 -0
- {schemathesis-3.35.5.dist-info → schemathesis-3.36.0.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.35.5.dist-info → schemathesis-3.36.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -13,7 +13,6 @@ from hypothesis.errors import HypothesisWarning
|
|
|
13
13
|
|
|
14
14
|
from ..._hypothesis import create_test
|
|
15
15
|
from ...internal.result import Ok
|
|
16
|
-
from ...models import CheckFunction
|
|
17
16
|
from ...stateful import Feedback, Stateful
|
|
18
17
|
from ...transports.auth import get_requests_auth
|
|
19
18
|
from ...utils import capture_hypothesis_output
|
|
@@ -21,13 +20,13 @@ from .. import events
|
|
|
21
20
|
from .core import BaseRunner, asgi_test, get_session, handle_schema_error, network_test, run_test, wsgi_test
|
|
22
21
|
|
|
23
22
|
if TYPE_CHECKING:
|
|
24
|
-
from .context import RunnerContext
|
|
25
23
|
import hypothesis
|
|
26
24
|
|
|
27
25
|
from ...generation import DataGenerationMethod, GenerationConfig
|
|
28
|
-
from ...
|
|
26
|
+
from ...internal.checks import CheckFunction
|
|
29
27
|
from ...targets import Target
|
|
30
28
|
from ...types import RawAuth
|
|
29
|
+
from .context import RunnerContext
|
|
31
30
|
|
|
32
31
|
|
|
33
32
|
def _run_task(
|
|
@@ -235,7 +234,7 @@ class ThreadPoolRunner(BaseRunner):
|
|
|
235
234
|
# It would be better to have a separate producer thread and communicate via threading events.
|
|
236
235
|
# Though it is a bit more complex, so the current solution is suboptimal in terms of resources utilization,
|
|
237
236
|
# but good enough and easy enough to implement.
|
|
238
|
-
tasks_generator = iter(self.schema.get_all_operations())
|
|
237
|
+
tasks_generator = iter(self.schema.get_all_operations(generation_config=self.generation_config))
|
|
239
238
|
generator_done = threading.Event()
|
|
240
239
|
tasks_queue: Queue = Queue()
|
|
241
240
|
# Add at least `workers_num` tasks first, so all workers are busy
|
schemathesis/schemas.py
CHANGED
|
@@ -241,7 +241,7 @@ class BaseSchema(Mapping):
|
|
|
241
241
|
raise NotImplementedError
|
|
242
242
|
|
|
243
243
|
def get_all_operations(
|
|
244
|
-
self, hooks: HookDispatcher | None = None
|
|
244
|
+
self, hooks: HookDispatcher | None = None, generation_config: GenerationConfig | None = None
|
|
245
245
|
) -> Generator[Result[APIOperation, OperationSchemaError], None, None]:
|
|
246
246
|
raise NotImplementedError
|
|
247
247
|
|
|
@@ -276,7 +276,7 @@ class BaseSchema(Mapping):
|
|
|
276
276
|
_given_kwargs: dict[str, GivenInput] | None = None,
|
|
277
277
|
) -> Generator[Result[tuple[APIOperation, Callable], OperationSchemaError], None, None]:
|
|
278
278
|
"""Generate all operations and Hypothesis tests for them."""
|
|
279
|
-
for result in self.get_all_operations(hooks=hooks):
|
|
279
|
+
for result in self.get_all_operations(hooks=hooks, generation_config=generation_config):
|
|
280
280
|
if isinstance(result, Ok):
|
|
281
281
|
operation = result.ok()
|
|
282
282
|
_as_strategy_kwargs: dict[str, Any] | None
|
|
@@ -139,12 +139,12 @@ def from_url(
|
|
|
139
139
|
interval=WAIT_FOR_SCHEMA_INTERVAL,
|
|
140
140
|
)
|
|
141
141
|
def _load_schema(_uri: str, **_kwargs: Any) -> requests.Response:
|
|
142
|
-
|
|
143
|
-
return requests.post(_uri, **kwargs)
|
|
142
|
+
return requests.post(_uri, **_kwargs)
|
|
144
143
|
|
|
145
144
|
else:
|
|
146
145
|
_load_schema = requests.post
|
|
147
146
|
|
|
147
|
+
kwargs.setdefault("timeout", DEFAULT_RESPONSE_TIMEOUT / 1000)
|
|
148
148
|
response = load_schema_from_url(lambda: _load_schema(url, **kwargs))
|
|
149
149
|
raw_schema = extract_schema_from_response(response)
|
|
150
150
|
return from_dict(
|
|
@@ -27,7 +27,7 @@ from requests.structures import CaseInsensitiveDict
|
|
|
27
27
|
|
|
28
28
|
from ... import auths
|
|
29
29
|
from ...checks import not_a_server_error
|
|
30
|
-
from ...constants import NOT_SET
|
|
30
|
+
from ...constants import NOT_SET, SCHEMATHESIS_TEST_CASE_HEADER
|
|
31
31
|
from ...exceptions import OperationNotFound, OperationSchemaError
|
|
32
32
|
from ...generation import DataGenerationMethod, GenerationConfig
|
|
33
33
|
from ...hooks import (
|
|
@@ -38,7 +38,7 @@ from ...hooks import (
|
|
|
38
38
|
should_skip_operation,
|
|
39
39
|
)
|
|
40
40
|
from ...internal.result import Ok, Result
|
|
41
|
-
from ...models import APIOperation, Case,
|
|
41
|
+
from ...models import APIOperation, Case, OperationDefinition
|
|
42
42
|
from ...schemas import APIOperationMap, BaseSchema
|
|
43
43
|
from ...types import Body, Cookies, Headers, NotSet, PathParameters, Query
|
|
44
44
|
from ..openapi.constants import LOCATION_TO_CONTAINER
|
|
@@ -49,6 +49,7 @@ if TYPE_CHECKING:
|
|
|
49
49
|
from hypothesis.strategies import SearchStrategy
|
|
50
50
|
|
|
51
51
|
from ...auths import AuthStorage
|
|
52
|
+
from ...internal.checks import CheckFunction
|
|
52
53
|
from ...stateful import Stateful, StatefulTest
|
|
53
54
|
from ...transports.responses import GenericResponse
|
|
54
55
|
|
|
@@ -61,6 +62,9 @@ class RootType(enum.Enum):
|
|
|
61
62
|
|
|
62
63
|
@dataclass(repr=False)
|
|
63
64
|
class GraphQLCase(Case):
|
|
65
|
+
def __hash__(self) -> int:
|
|
66
|
+
return hash(self.as_curl_command({SCHEMATHESIS_TEST_CASE_HEADER: "0"}))
|
|
67
|
+
|
|
64
68
|
def _get_url(self, base_url: str | None) -> str:
|
|
65
69
|
base_url = self._get_base_url(base_url)
|
|
66
70
|
# Replace the path, in case if the user provided any path parameters via hooks
|
|
@@ -78,11 +82,12 @@ class GraphQLCase(Case):
|
|
|
78
82
|
additional_checks: tuple[CheckFunction, ...] = (),
|
|
79
83
|
excluded_checks: tuple[CheckFunction, ...] = (),
|
|
80
84
|
code_sample_style: str | None = None,
|
|
85
|
+
headers: dict[str, Any] | None = None,
|
|
81
86
|
) -> None:
|
|
82
87
|
checks = checks or (not_a_server_error,)
|
|
83
88
|
checks += additional_checks
|
|
84
89
|
checks = tuple(check for check in checks if check not in excluded_checks)
|
|
85
|
-
return super().validate_response(response, checks, code_sample_style=code_sample_style)
|
|
90
|
+
return super().validate_response(response, checks, code_sample_style=code_sample_style, headers=headers)
|
|
86
91
|
|
|
87
92
|
|
|
88
93
|
C = TypeVar("C", bound=Case)
|
|
@@ -186,8 +191,7 @@ class GraphQLSchema(BaseSchema):
|
|
|
186
191
|
return 0
|
|
187
192
|
|
|
188
193
|
def get_all_operations(
|
|
189
|
-
self,
|
|
190
|
-
hooks: HookDispatcher | None = None,
|
|
194
|
+
self, hooks: HookDispatcher | None = None, generation_config: GenerationConfig | None = None
|
|
191
195
|
) -> Generator[Result[APIOperation, OperationSchemaError], None, None]:
|
|
192
196
|
schema = self.client_schema
|
|
193
197
|
for root_type, operation_type in (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
+
import enum
|
|
4
5
|
from http.cookies import SimpleCookie
|
|
5
6
|
from typing import TYPE_CHECKING, Any, Dict, Generator, NoReturn, cast
|
|
6
7
|
from urllib.parse import parse_qs, urlparse
|
|
@@ -25,11 +26,12 @@ from .utils import expand_status_code
|
|
|
25
26
|
if TYPE_CHECKING:
|
|
26
27
|
from requests import PreparedRequest
|
|
27
28
|
|
|
29
|
+
from ...internal.checks import CheckContext
|
|
28
30
|
from ...models import APIOperation, Case
|
|
29
31
|
from ...transports.responses import GenericResponse
|
|
30
32
|
|
|
31
33
|
|
|
32
|
-
def status_code_conformance(response: GenericResponse, case: Case) -> bool | None:
|
|
34
|
+
def status_code_conformance(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
|
|
33
35
|
from .schemas import BaseOpenAPISchema
|
|
34
36
|
|
|
35
37
|
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
|
@@ -60,7 +62,7 @@ def _expand_responses(responses: dict[str | int, Any]) -> Generator[int, None, N
|
|
|
60
62
|
yield from expand_status_code(code)
|
|
61
63
|
|
|
62
64
|
|
|
63
|
-
def content_type_conformance(response: GenericResponse, case: Case) -> bool | None:
|
|
65
|
+
def content_type_conformance(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
|
|
64
66
|
from .schemas import BaseOpenAPISchema
|
|
65
67
|
|
|
66
68
|
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
|
@@ -115,7 +117,7 @@ def _reraise_malformed_media_type(case: Case, exc: ValueError, location: str, ac
|
|
|
115
117
|
) from exc
|
|
116
118
|
|
|
117
119
|
|
|
118
|
-
def response_headers_conformance(response: GenericResponse, case: Case) -> bool | None:
|
|
120
|
+
def response_headers_conformance(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
|
|
119
121
|
import jsonschema
|
|
120
122
|
|
|
121
123
|
from .parameters import OpenAPI20Parameter, OpenAPI30Parameter
|
|
@@ -171,11 +173,11 @@ def response_headers_conformance(response: GenericResponse, case: Case) -> bool
|
|
|
171
173
|
)
|
|
172
174
|
except jsonschema.ValidationError as exc:
|
|
173
175
|
exc_class = get_schema_validation_error(case.operation.verbose_name, exc)
|
|
174
|
-
|
|
176
|
+
error_ctx = failures.ValidationErrorContext.from_exception(
|
|
175
177
|
exc, output_config=case.operation.schema.output_config
|
|
176
178
|
)
|
|
177
179
|
try:
|
|
178
|
-
raise exc_class("Response header does not conform to the schema", context=
|
|
180
|
+
raise exc_class("Response header does not conform to the schema", context=error_ctx) from exc
|
|
179
181
|
except Exception as exc:
|
|
180
182
|
errors.append(exc)
|
|
181
183
|
return _maybe_raise_one_or_more(errors) # type: ignore[func-returns-value]
|
|
@@ -203,7 +205,7 @@ def _coerce_header_value(value: str, schema: dict[str, Any]) -> str | int | floa
|
|
|
203
205
|
return value
|
|
204
206
|
|
|
205
207
|
|
|
206
|
-
def response_schema_conformance(response: GenericResponse, case: Case) -> bool | None:
|
|
208
|
+
def response_schema_conformance(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
|
|
207
209
|
from .schemas import BaseOpenAPISchema
|
|
208
210
|
|
|
209
211
|
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
|
@@ -211,7 +213,7 @@ def response_schema_conformance(response: GenericResponse, case: Case) -> bool |
|
|
|
211
213
|
return case.operation.validate_response(response)
|
|
212
214
|
|
|
213
215
|
|
|
214
|
-
def negative_data_rejection(response: GenericResponse, case: Case) -> bool | None:
|
|
216
|
+
def negative_data_rejection(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
|
|
215
217
|
from .schemas import BaseOpenAPISchema
|
|
216
218
|
|
|
217
219
|
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
|
@@ -258,7 +260,7 @@ def has_only_additional_properties_in_non_body_parameters(case: Case) -> bool:
|
|
|
258
260
|
return True
|
|
259
261
|
|
|
260
262
|
|
|
261
|
-
def use_after_free(response: GenericResponse, original: Case) -> bool | None:
|
|
263
|
+
def use_after_free(ctx: CheckContext, response: GenericResponse, original: Case) -> bool | None:
|
|
262
264
|
from ...transports.responses import get_reason
|
|
263
265
|
from .schemas import BaseOpenAPISchema
|
|
264
266
|
|
|
@@ -298,7 +300,7 @@ def use_after_free(response: GenericResponse, original: Case) -> bool | None:
|
|
|
298
300
|
return None
|
|
299
301
|
|
|
300
302
|
|
|
301
|
-
def ensure_resource_availability(response: GenericResponse, original: Case) -> bool | None:
|
|
303
|
+
def ensure_resource_availability(ctx: CheckContext, response: GenericResponse, original: Case) -> bool | None:
|
|
302
304
|
from ...transports.responses import get_reason
|
|
303
305
|
from .schemas import BaseOpenAPISchema
|
|
304
306
|
|
|
@@ -332,7 +334,12 @@ def ensure_resource_availability(response: GenericResponse, original: Case) -> b
|
|
|
332
334
|
return None
|
|
333
335
|
|
|
334
336
|
|
|
335
|
-
|
|
337
|
+
class AuthKind(enum.Enum):
|
|
338
|
+
EXPLICIT = "explicit"
|
|
339
|
+
GENERATED = "generated"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def ignored_auth(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
|
|
336
343
|
"""Check if an operation declares authentication as a requirement but does not actually enforce it."""
|
|
337
344
|
from .schemas import BaseOpenAPISchema
|
|
338
345
|
|
|
@@ -340,32 +347,49 @@ def ignored_auth(response: GenericResponse, case: Case) -> bool | None:
|
|
|
340
347
|
return True
|
|
341
348
|
security_parameters = _get_security_parameters(case.operation)
|
|
342
349
|
# Authentication is required for this API operation and response is successful
|
|
343
|
-
# Will it still be successful if there is no auth?
|
|
344
350
|
if security_parameters and 200 <= response.status_code < 300:
|
|
345
|
-
|
|
346
|
-
|
|
351
|
+
auth = _contains_auth(ctx, response.request, security_parameters)
|
|
352
|
+
if auth == AuthKind.EXPLICIT:
|
|
353
|
+
# Auth is explicitly set, it is expected to be valid
|
|
354
|
+
# Check if invalid auth will give an error
|
|
347
355
|
_remove_auth_from_case(case, security_parameters)
|
|
348
356
|
new_response = case.operation.schema.transport.send(case)
|
|
349
357
|
if 200 <= new_response.status_code < 300:
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
358
|
+
_update_response(response, new_response)
|
|
359
|
+
_raise_no_auth_error(new_response, case.operation.verbose_name, "that requires authentication")
|
|
360
|
+
# Try to set invalid auth and check if it succeeds
|
|
361
|
+
for parameter in security_parameters:
|
|
362
|
+
_set_auth_for_case(case, parameter)
|
|
363
|
+
new_response = case.operation.schema.transport.send(case)
|
|
364
|
+
if 200 <= new_response.status_code < 300:
|
|
365
|
+
_update_response(response, new_response)
|
|
366
|
+
_raise_no_auth_error(new_response, case.operation.verbose_name, "with any auth")
|
|
367
|
+
_remove_auth_from_case(case, security_parameters)
|
|
368
|
+
elif auth == AuthKind.GENERATED:
|
|
369
|
+
# If this auth is generated which means it is likely invalid, then
|
|
370
|
+
# this request should have been an error
|
|
371
|
+
_raise_no_auth_error(response, case.operation.verbose_name, "with invalid auth")
|
|
357
372
|
else:
|
|
358
373
|
# Successful response when there is no auth
|
|
359
|
-
|
|
374
|
+
_raise_no_auth_error(response, case.operation.verbose_name, "that requires authentication")
|
|
360
375
|
return None
|
|
361
376
|
|
|
362
377
|
|
|
363
|
-
def
|
|
378
|
+
def _update_response(old: GenericResponse, new: GenericResponse) -> None:
|
|
379
|
+
# Mutate the response object in place on the best effort basis
|
|
380
|
+
if hasattr(old, "__attrs__"):
|
|
381
|
+
for attribute in new.__attrs__:
|
|
382
|
+
setattr(old, attribute, getattr(new, attribute))
|
|
383
|
+
else:
|
|
384
|
+
old.__dict__.update(new.__dict__)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _raise_no_auth_error(response: GenericResponse, operation: str, suffix: str) -> NoReturn:
|
|
364
388
|
from ...transports.responses import get_reason
|
|
365
389
|
|
|
366
390
|
exc_class = get_ignored_auth_error(operation)
|
|
367
391
|
reason = get_reason(response.status_code)
|
|
368
|
-
message = f"The API returned `{response.status_code} {reason}` for `{operation}`
|
|
392
|
+
message = f"The API returned `{response.status_code} {reason}` for `{operation}` {suffix}."
|
|
369
393
|
raise exc_class(
|
|
370
394
|
failures.IgnoredAuth.title,
|
|
371
395
|
context=failures.IgnoredAuth(message=message),
|
|
@@ -387,7 +411,9 @@ def _get_security_parameters(operation: APIOperation) -> list[SecurityParameter]
|
|
|
387
411
|
]
|
|
388
412
|
|
|
389
413
|
|
|
390
|
-
def _contains_auth(
|
|
414
|
+
def _contains_auth(
|
|
415
|
+
ctx: CheckContext, request: PreparedRequest, security_parameters: list[SecurityParameter]
|
|
416
|
+
) -> AuthKind | None:
|
|
391
417
|
"""Whether a request has authentication declared in the schema."""
|
|
392
418
|
from requests.cookies import RequestsCookieJar
|
|
393
419
|
|
|
@@ -410,10 +436,20 @@ def _contains_auth(request: PreparedRequest, security_parameters: list[SecurityP
|
|
|
410
436
|
return p["in"] == "cookie" and (p["name"] in cookies or p["name"] in header_cookies)
|
|
411
437
|
|
|
412
438
|
for parameter in security_parameters:
|
|
413
|
-
if has_header(parameter)
|
|
414
|
-
|
|
439
|
+
if has_header(parameter):
|
|
440
|
+
if ctx.headers is not None and parameter["name"] in ctx.headers:
|
|
441
|
+
return AuthKind.EXPLICIT
|
|
442
|
+
return AuthKind.GENERATED
|
|
443
|
+
if has_cookie(parameter):
|
|
444
|
+
if ctx.headers is not None and "Cookie" in ctx.headers:
|
|
445
|
+
cookies = cast(RequestsCookieJar, ctx.headers["Cookie"]) # type: ignore
|
|
446
|
+
if parameter["name"] in cookies:
|
|
447
|
+
return AuthKind.EXPLICIT
|
|
448
|
+
return AuthKind.GENERATED
|
|
449
|
+
if has_query(parameter):
|
|
450
|
+
return AuthKind.GENERATED
|
|
415
451
|
|
|
416
|
-
return
|
|
452
|
+
return None
|
|
417
453
|
|
|
418
454
|
|
|
419
455
|
def _remove_auth_from_case(case: Case, security_parameters: list[SecurityParameter]) -> None:
|
|
@@ -431,6 +467,19 @@ def _remove_auth_from_case(case: Case, security_parameters: list[SecurityParamet
|
|
|
431
467
|
case.cookies.pop(name, None)
|
|
432
468
|
|
|
433
469
|
|
|
470
|
+
def _set_auth_for_case(case: Case, parameter: SecurityParameter) -> None:
|
|
471
|
+
name = parameter["name"]
|
|
472
|
+
for location, attr_name in (
|
|
473
|
+
("header", "headers"),
|
|
474
|
+
("query", "query"),
|
|
475
|
+
("cookie", "cookies"),
|
|
476
|
+
):
|
|
477
|
+
if parameter["in"] == location:
|
|
478
|
+
container = getattr(case, attr_name, {})
|
|
479
|
+
container[name] = "SCHEMATHESIS-INVALID-VALUE"
|
|
480
|
+
setattr(case, attr_name, container)
|
|
481
|
+
|
|
482
|
+
|
|
434
483
|
@dataclass
|
|
435
484
|
class ResourcePath:
|
|
436
485
|
"""A path to a resource with variables."""
|
|
@@ -163,12 +163,12 @@ def from_uri(
|
|
|
163
163
|
interval=WAIT_FOR_SCHEMA_INTERVAL,
|
|
164
164
|
)
|
|
165
165
|
def _load_schema(_uri: str, **_kwargs: Any) -> requests.Response:
|
|
166
|
-
|
|
167
|
-
return requests.get(_uri, **kwargs)
|
|
166
|
+
return requests.get(_uri, **_kwargs)
|
|
168
167
|
|
|
169
168
|
else:
|
|
170
169
|
_load_schema = requests.get
|
|
171
170
|
|
|
171
|
+
kwargs.setdefault("timeout", DEFAULT_RESPONSE_TIMEOUT / 1000)
|
|
172
172
|
response = load_schema_from_url(lambda: _load_schema(uri, **kwargs))
|
|
173
173
|
return from_file(
|
|
174
174
|
response.text,
|
|
@@ -253,7 +253,7 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
253
253
|
return self.collect_parameters(itertools.chain(parameters, shared_parameters), operation)
|
|
254
254
|
|
|
255
255
|
def get_all_operations(
|
|
256
|
-
self, hooks: HookDispatcher | None = None
|
|
256
|
+
self, hooks: HookDispatcher | None = None, generation_config: GenerationConfig | None = None
|
|
257
257
|
) -> Generator[Result[APIOperation, OperationSchemaError], None, None]:
|
|
258
258
|
"""Iterate over all operations defined in the API.
|
|
259
259
|
|
|
@@ -308,7 +308,17 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
308
308
|
continue
|
|
309
309
|
parameters = resolved.get("parameters", ())
|
|
310
310
|
parameters = collect_parameters(itertools.chain(parameters, shared_parameters), resolved)
|
|
311
|
-
operation = make_operation(
|
|
311
|
+
operation = make_operation(
|
|
312
|
+
path,
|
|
313
|
+
method,
|
|
314
|
+
parameters,
|
|
315
|
+
entry,
|
|
316
|
+
resolved,
|
|
317
|
+
scope,
|
|
318
|
+
with_security_parameters=generation_config.with_security_parameters
|
|
319
|
+
if generation_config
|
|
320
|
+
else None,
|
|
321
|
+
)
|
|
312
322
|
context = HookContext(operation=operation)
|
|
313
323
|
if (
|
|
314
324
|
should_skip_operation(GLOBAL_HOOK_DISPATCHER, context)
|
|
@@ -383,6 +393,7 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
383
393
|
raw: dict[str, Any],
|
|
384
394
|
resolved: dict[str, Any],
|
|
385
395
|
scope: str,
|
|
396
|
+
with_security_parameters: bool | None = None,
|
|
386
397
|
) -> APIOperation:
|
|
387
398
|
"""Create JSON schemas for the query, body, etc from Swagger parameters definitions."""
|
|
388
399
|
__tracebackhide__ = True
|
|
@@ -397,7 +408,12 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
397
408
|
)
|
|
398
409
|
for parameter in parameters:
|
|
399
410
|
operation.add_parameter(parameter)
|
|
400
|
-
|
|
411
|
+
with_security_parameters = (
|
|
412
|
+
with_security_parameters
|
|
413
|
+
if with_security_parameters is not None
|
|
414
|
+
else self.generation_config.with_security_parameters
|
|
415
|
+
)
|
|
416
|
+
if with_security_parameters:
|
|
401
417
|
self.security.process_definitions(self.raw_schema, operation, self.resolver)
|
|
402
418
|
self.dispatch_hook("before_init_operation", HookContext(operation=operation), operation)
|
|
403
419
|
return operation
|
schemathesis/stateful/config.py
CHANGED
schemathesis/stateful/context.py
CHANGED
|
@@ -4,6 +4,7 @@ import traceback
|
|
|
4
4
|
from dataclasses import dataclass, field
|
|
5
5
|
from typing import TYPE_CHECKING, Tuple, Type, Union
|
|
6
6
|
|
|
7
|
+
from ..constants import NOT_SET
|
|
7
8
|
from ..exceptions import CheckFailed
|
|
8
9
|
from ..targets import TargetMetricCollector
|
|
9
10
|
from . import events
|
|
@@ -11,6 +12,7 @@ from . import events
|
|
|
11
12
|
if TYPE_CHECKING:
|
|
12
13
|
from ..models import Case, Check
|
|
13
14
|
from ..transports.responses import GenericResponse
|
|
15
|
+
from ..types import NotSet
|
|
14
16
|
|
|
15
17
|
FailureKey = Union[Type[CheckFailed], Tuple[str, int]]
|
|
16
18
|
|
|
@@ -52,6 +54,7 @@ class RunnerContext:
|
|
|
52
54
|
completed_scenarios: int = 0
|
|
53
55
|
# Metrics collector for targeted testing
|
|
54
56
|
metric_collector: TargetMetricCollector = field(default_factory=lambda: TargetMetricCollector(targets=[]))
|
|
57
|
+
step_outcomes: dict[int, BaseException | None] = field(default_factory=dict)
|
|
55
58
|
|
|
56
59
|
@property
|
|
57
60
|
def current_scenario_status(self) -> events.ScenarioStatus:
|
|
@@ -69,6 +72,7 @@ class RunnerContext:
|
|
|
69
72
|
self.completed_scenarios += 1
|
|
70
73
|
self.current_step_status = None
|
|
71
74
|
self.current_response = None
|
|
75
|
+
self.step_outcomes.clear()
|
|
72
76
|
|
|
73
77
|
def reset_step(self) -> None:
|
|
74
78
|
self.checks_for_step = []
|
|
@@ -123,3 +127,9 @@ class RunnerContext:
|
|
|
123
127
|
self.seen_in_suite.clear()
|
|
124
128
|
self.reset_scenario()
|
|
125
129
|
self.metric_collector.reset()
|
|
130
|
+
|
|
131
|
+
def store_step_outcome(self, case: Case, outcome: BaseException | None) -> None:
|
|
132
|
+
self.step_outcomes[hash(case)] = outcome
|
|
133
|
+
|
|
134
|
+
def get_step_outcome(self, case: Case) -> BaseException | None | NotSet:
|
|
135
|
+
return self.step_outcomes.get(hash(case), NOT_SET)
|
schemathesis/stateful/runner.py
CHANGED
|
@@ -163,17 +163,34 @@ def _execute_state_machine_loop(
|
|
|
163
163
|
try:
|
|
164
164
|
if config.dry_run:
|
|
165
165
|
return None
|
|
166
|
+
if config.unique_data:
|
|
167
|
+
cached = ctx.get_step_outcome(case)
|
|
168
|
+
if isinstance(cached, BaseException):
|
|
169
|
+
raise cached
|
|
170
|
+
elif cached is None:
|
|
171
|
+
return None
|
|
166
172
|
result = super().step(case, previous)
|
|
167
173
|
ctx.step_succeeded()
|
|
168
|
-
except CheckFailed:
|
|
174
|
+
except CheckFailed as exc:
|
|
175
|
+
if config.unique_data:
|
|
176
|
+
ctx.store_step_outcome(case, exc)
|
|
169
177
|
ctx.step_failed()
|
|
170
178
|
raise
|
|
171
|
-
except Exception:
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
if config.unique_data:
|
|
181
|
+
ctx.store_step_outcome(case, exc)
|
|
172
182
|
ctx.step_errored()
|
|
173
183
|
raise
|
|
174
184
|
except KeyboardInterrupt:
|
|
175
185
|
ctx.step_interrupted()
|
|
176
186
|
raise
|
|
187
|
+
except BaseException as exc:
|
|
188
|
+
if config.unique_data:
|
|
189
|
+
ctx.store_step_outcome(case, exc)
|
|
190
|
+
raise exc
|
|
191
|
+
else:
|
|
192
|
+
if config.unique_data:
|
|
193
|
+
ctx.store_step_outcome(case, None)
|
|
177
194
|
finally:
|
|
178
195
|
transition_id: events.TransitionId | None
|
|
179
196
|
if previous is not None:
|
|
@@ -12,7 +12,8 @@ from hypothesis.stateful import RuleBasedStateMachine
|
|
|
12
12
|
from .._dependency_versions import HYPOTHESIS_HAS_STATEFUL_NAMING_IMPROVEMENTS
|
|
13
13
|
from ..constants import NO_LINKS_ERROR_MESSAGE, NOT_SET
|
|
14
14
|
from ..exceptions import UsageError
|
|
15
|
-
from ..
|
|
15
|
+
from ..internal.checks import CheckFunction
|
|
16
|
+
from ..models import APIOperation, Case
|
|
16
17
|
from .config import _default_hypothesis_settings_factory
|
|
17
18
|
from .runner import StatefulTestRunner, StatefulTestRunnerConfig
|
|
18
19
|
from .sink import StateMachineSink
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import TYPE_CHECKING
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
4
|
|
|
5
5
|
from ..exceptions import CheckFailed, get_grouped_exception
|
|
6
|
+
from ..internal.checks import CheckContext
|
|
6
7
|
|
|
7
8
|
if TYPE_CHECKING:
|
|
8
9
|
from ..failures import FailureContext
|
|
9
|
-
from ..
|
|
10
|
+
from ..internal.checks import CheckFunction
|
|
11
|
+
from ..models import Case
|
|
10
12
|
from ..transports.responses import GenericResponse
|
|
11
13
|
from .context import RunnerContext
|
|
12
14
|
|
|
@@ -19,8 +21,11 @@ def validate_response(
|
|
|
19
21
|
checks: tuple[CheckFunction, ...],
|
|
20
22
|
additional_checks: tuple[CheckFunction, ...] = (),
|
|
21
23
|
max_response_time: int | None = None,
|
|
24
|
+
headers: dict[str, Any] | None = None,
|
|
22
25
|
) -> None:
|
|
23
26
|
"""Validate the response against the provided checks."""
|
|
27
|
+
from requests.structures import CaseInsensitiveDict
|
|
28
|
+
|
|
24
29
|
from .._compat import MultipleFailures
|
|
25
30
|
from ..checks import _make_max_response_time_failure_message
|
|
26
31
|
from ..failures import ResponseTimeExceeded
|
|
@@ -28,6 +33,7 @@ def validate_response(
|
|
|
28
33
|
|
|
29
34
|
exceptions: list[CheckFailed | AssertionError] = []
|
|
30
35
|
check_results = ctx.checks_for_step
|
|
36
|
+
check_ctx = CheckContext(headers=CaseInsensitiveDict(headers) if headers else None)
|
|
31
37
|
|
|
32
38
|
def _on_failure(exc: CheckFailed | AssertionError, message: str, context: FailureContext | None) -> None:
|
|
33
39
|
exceptions.append(exc)
|
|
@@ -62,8 +68,7 @@ def validate_response(
|
|
|
62
68
|
name = check.__name__
|
|
63
69
|
copied_case = case.partial_deepcopy()
|
|
64
70
|
try:
|
|
65
|
-
check(response, copied_case)
|
|
66
|
-
skip_check = check(response, copied_case)
|
|
71
|
+
skip_check = check(check_ctx, response, copied_case)
|
|
67
72
|
if not skip_check:
|
|
68
73
|
_on_passed(name, copied_case)
|
|
69
74
|
except CheckFailed as exc:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: schemathesis
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.36.0
|
|
4
4
|
Summary: Property-based testing framework for Open API and GraphQL based apps
|
|
5
5
|
Project-URL: Documentation, https://schemathesis.readthedocs.io/en/stable/
|
|
6
6
|
Project-URL: Changelog, https://schemathesis.readthedocs.io/en/stable/changelog.html
|