schemathesis 3.18.5__py3-none-any.whl → 3.19.1__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 +1 -3
- schemathesis/auths.py +218 -43
- schemathesis/cli/__init__.py +37 -20
- schemathesis/cli/callbacks.py +13 -1
- schemathesis/cli/cassettes.py +18 -18
- schemathesis/cli/context.py +25 -24
- schemathesis/cli/debug.py +3 -3
- schemathesis/cli/junitxml.py +4 -4
- schemathesis/cli/options.py +1 -1
- schemathesis/cli/output/default.py +2 -0
- schemathesis/constants.py +3 -3
- schemathesis/exceptions.py +9 -9
- schemathesis/extra/pytest_plugin.py +1 -1
- schemathesis/failures.py +65 -66
- schemathesis/filters.py +269 -0
- schemathesis/hooks.py +11 -11
- schemathesis/lazy.py +21 -16
- schemathesis/models.py +149 -107
- schemathesis/parameters.py +12 -7
- schemathesis/runner/events.py +55 -55
- schemathesis/runner/impl/core.py +26 -26
- schemathesis/runner/impl/solo.py +6 -7
- schemathesis/runner/impl/threadpool.py +5 -5
- schemathesis/runner/serialization.py +50 -50
- schemathesis/schemas.py +38 -23
- schemathesis/serializers.py +3 -3
- schemathesis/service/ci.py +25 -25
- schemathesis/service/client.py +2 -2
- schemathesis/service/events.py +12 -13
- schemathesis/service/hosts.py +4 -4
- schemathesis/service/metadata.py +14 -15
- schemathesis/service/models.py +12 -13
- schemathesis/service/report.py +30 -31
- schemathesis/service/serialization.py +2 -4
- schemathesis/specs/graphql/loaders.py +21 -2
- schemathesis/specs/graphql/schemas.py +8 -8
- schemathesis/specs/openapi/expressions/context.py +4 -4
- schemathesis/specs/openapi/expressions/lexer.py +11 -12
- schemathesis/specs/openapi/expressions/nodes.py +16 -16
- schemathesis/specs/openapi/expressions/parser.py +1 -1
- schemathesis/specs/openapi/links.py +15 -17
- schemathesis/specs/openapi/loaders.py +29 -2
- schemathesis/specs/openapi/negative/__init__.py +5 -5
- schemathesis/specs/openapi/negative/mutations.py +6 -6
- schemathesis/specs/openapi/parameters.py +12 -13
- schemathesis/specs/openapi/references.py +2 -2
- schemathesis/specs/openapi/schemas.py +11 -15
- schemathesis/specs/openapi/security.py +12 -7
- schemathesis/specs/openapi/stateful/links.py +4 -4
- schemathesis/stateful.py +19 -19
- schemathesis/targets.py +5 -6
- schemathesis/throttling.py +34 -0
- schemathesis/types.py +11 -13
- schemathesis/utils.py +2 -2
- {schemathesis-3.18.5.dist-info → schemathesis-3.19.1.dist-info}/METADATA +4 -3
- schemathesis-3.19.1.dist-info/RECORD +107 -0
- schemathesis-3.18.5.dist-info/RECORD +0 -105
- {schemathesis-3.18.5.dist-info → schemathesis-3.19.1.dist-info}/WHEEL +0 -0
- {schemathesis-3.18.5.dist-info → schemathesis-3.19.1.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.18.5.dist-info → schemathesis-3.19.1.dist-info}/licenses/LICENSE +0 -0
schemathesis/hooks.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import inspect
|
|
2
2
|
from collections import defaultdict
|
|
3
3
|
from copy import deepcopy
|
|
4
|
+
from dataclasses import dataclass, field
|
|
4
5
|
from enum import Enum, unique
|
|
5
|
-
from typing import TYPE_CHECKING, Any, Callable, DefaultDict, Dict, List, Optional, Union, cast
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Callable, ClassVar, DefaultDict, Dict, List, Optional, Union, cast
|
|
6
7
|
|
|
7
|
-
import attr
|
|
8
8
|
from hypothesis import strategies as st
|
|
9
9
|
|
|
10
10
|
from .types import GenericTest
|
|
@@ -22,13 +22,13 @@ class HookScope(Enum):
|
|
|
22
22
|
TEST = 3
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
@
|
|
25
|
+
@dataclass
|
|
26
26
|
class RegisteredHook:
|
|
27
|
-
signature: inspect.Signature
|
|
28
|
-
scopes: List[HookScope]
|
|
27
|
+
signature: inspect.Signature
|
|
28
|
+
scopes: List[HookScope]
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
@
|
|
31
|
+
@dataclass
|
|
32
32
|
class HookContext:
|
|
33
33
|
"""A context that is passed to some hook functions.
|
|
34
34
|
|
|
@@ -36,23 +36,23 @@ class HookContext:
|
|
|
36
36
|
Might be absent in some cases.
|
|
37
37
|
"""
|
|
38
38
|
|
|
39
|
-
operation: Optional["APIOperation"] =
|
|
39
|
+
operation: Optional["APIOperation"] = None
|
|
40
40
|
|
|
41
41
|
@deprecated_property(removed_in="4.0", replacement="operation")
|
|
42
42
|
def endpoint(self) -> Optional["APIOperation"]:
|
|
43
43
|
return self.operation
|
|
44
44
|
|
|
45
45
|
|
|
46
|
-
@
|
|
46
|
+
@dataclass
|
|
47
47
|
class HookDispatcher:
|
|
48
48
|
"""Generic hook dispatcher.
|
|
49
49
|
|
|
50
50
|
Provides a mechanism to extend Schemathesis in registered hook points.
|
|
51
51
|
"""
|
|
52
52
|
|
|
53
|
-
scope: HookScope
|
|
54
|
-
_hooks: DefaultDict[str, List[Callable]] =
|
|
55
|
-
_specs: Dict[str, RegisteredHook] = {}
|
|
53
|
+
scope: HookScope
|
|
54
|
+
_hooks: DefaultDict[str, List[Callable]] = field(default_factory=lambda: defaultdict(list))
|
|
55
|
+
_specs: ClassVar[Dict[str, RegisteredHook]] = {}
|
|
56
56
|
|
|
57
57
|
def register(self, hook: Union[str, Callable]) -> Callable:
|
|
58
58
|
"""Register a new hook.
|
schemathesis/lazy.py
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
1
2
|
from inspect import signature
|
|
2
3
|
from typing import Any, Callable, Dict, Generator, Optional, Type, Union
|
|
3
4
|
|
|
4
|
-
import attr
|
|
5
5
|
import pytest
|
|
6
6
|
from _pytest.fixtures import FixtureRequest
|
|
7
7
|
from hypothesis.core import HypothesisHandle
|
|
8
8
|
from hypothesis.errors import Flaky
|
|
9
9
|
from hypothesis.internal.escalation import format_exception, get_interesting_origin, get_trimmed_traceback
|
|
10
10
|
from hypothesis.internal.reflection import impersonate
|
|
11
|
+
from pyrate_limiter import Limiter
|
|
11
12
|
from pytest_subtests import SubTests, nullcontext
|
|
12
13
|
|
|
13
14
|
from ._compat import MultipleFailures
|
|
@@ -32,21 +33,22 @@ from .utils import (
|
|
|
32
33
|
)
|
|
33
34
|
|
|
34
35
|
|
|
35
|
-
@
|
|
36
|
+
@dataclass
|
|
36
37
|
class LazySchema:
|
|
37
|
-
fixture_name: str
|
|
38
|
-
base_url: Union[Optional[str], NotSet] =
|
|
39
|
-
method: Optional[Filter] =
|
|
40
|
-
endpoint: Optional[Filter] =
|
|
41
|
-
tag: Optional[Filter] =
|
|
42
|
-
operation_id: Optional[Filter] =
|
|
43
|
-
app: Any =
|
|
44
|
-
hooks: HookDispatcher =
|
|
45
|
-
auth: AuthStorage =
|
|
46
|
-
validate_schema: bool =
|
|
47
|
-
skip_deprecated_operations: bool =
|
|
48
|
-
data_generation_methods: Union[DataGenerationMethodInput, NotSet] =
|
|
49
|
-
code_sample_style: CodeSampleStyle =
|
|
38
|
+
fixture_name: str
|
|
39
|
+
base_url: Union[Optional[str], NotSet] = NOT_SET
|
|
40
|
+
method: Optional[Filter] = NOT_SET
|
|
41
|
+
endpoint: Optional[Filter] = NOT_SET
|
|
42
|
+
tag: Optional[Filter] = NOT_SET
|
|
43
|
+
operation_id: Optional[Filter] = NOT_SET
|
|
44
|
+
app: Any = NOT_SET
|
|
45
|
+
hooks: HookDispatcher = field(default_factory=lambda: HookDispatcher(scope=HookScope.SCHEMA))
|
|
46
|
+
auth: AuthStorage = field(default_factory=AuthStorage)
|
|
47
|
+
validate_schema: bool = True
|
|
48
|
+
skip_deprecated_operations: bool = False
|
|
49
|
+
data_generation_methods: Union[DataGenerationMethodInput, NotSet] = NOT_SET
|
|
50
|
+
code_sample_style: CodeSampleStyle = CodeSampleStyle.default()
|
|
51
|
+
rate_limiter: Optional[Limiter] = None
|
|
50
52
|
|
|
51
53
|
def hook(self, hook: Union[str, Callable]) -> Callable:
|
|
52
54
|
return self.hooks.register(hook)
|
|
@@ -105,13 +107,14 @@ class LazySchema:
|
|
|
105
107
|
tag=tag,
|
|
106
108
|
operation_id=operation_id,
|
|
107
109
|
hooks=self.hooks,
|
|
108
|
-
auth=self.auth if self.auth.
|
|
110
|
+
auth=self.auth if self.auth.providers is not None else NOT_SET,
|
|
109
111
|
test_function=test,
|
|
110
112
|
validate_schema=validate_schema,
|
|
111
113
|
skip_deprecated_operations=skip_deprecated_operations,
|
|
112
114
|
data_generation_methods=data_generation_methods,
|
|
113
115
|
code_sample_style=_code_sample_style,
|
|
114
116
|
app=self.app,
|
|
117
|
+
rate_limiter=self.rate_limiter,
|
|
115
118
|
)
|
|
116
119
|
fixtures = get_fixtures(test, request, given_kwargs)
|
|
117
120
|
# Changing the node id is required for better reporting - the method and path will appear there
|
|
@@ -270,6 +273,7 @@ def get_schema(
|
|
|
270
273
|
skip_deprecated_operations: Union[bool, NotSet] = NOT_SET,
|
|
271
274
|
data_generation_methods: Union[DataGenerationMethodInput, NotSet] = NOT_SET,
|
|
272
275
|
code_sample_style: CodeSampleStyle,
|
|
276
|
+
rate_limiter: Optional[Limiter],
|
|
273
277
|
) -> BaseSchema:
|
|
274
278
|
"""Loads a schema from the fixture."""
|
|
275
279
|
schema = request.getfixturevalue(name)
|
|
@@ -289,6 +293,7 @@ def get_schema(
|
|
|
289
293
|
skip_deprecated_operations=skip_deprecated_operations,
|
|
290
294
|
data_generation_methods=data_generation_methods,
|
|
291
295
|
code_sample_style=code_sample_style,
|
|
296
|
+
rate_limiter=rate_limiter,
|
|
292
297
|
)
|
|
293
298
|
|
|
294
299
|
|
schemathesis/models.py
CHANGED
|
@@ -4,6 +4,7 @@ import http
|
|
|
4
4
|
import json
|
|
5
5
|
from collections import Counter
|
|
6
6
|
from contextlib import contextmanager
|
|
7
|
+
from dataclasses import dataclass, field
|
|
7
8
|
from enum import Enum
|
|
8
9
|
from itertools import chain
|
|
9
10
|
from logging import LogRecord
|
|
@@ -28,9 +29,8 @@ from typing import (
|
|
|
28
29
|
from urllib.parse import quote, unquote, urljoin, urlparse, urlsplit, urlunsplit
|
|
29
30
|
from uuid import uuid4
|
|
30
31
|
|
|
31
|
-
import attr
|
|
32
32
|
import curlify
|
|
33
|
-
import requests
|
|
33
|
+
import requests.auth
|
|
34
34
|
import werkzeug
|
|
35
35
|
from hypothesis import event, note, reject
|
|
36
36
|
from hypothesis import strategies as st
|
|
@@ -77,13 +77,13 @@ if TYPE_CHECKING:
|
|
|
77
77
|
from .stateful import Stateful, StatefulTest
|
|
78
78
|
|
|
79
79
|
|
|
80
|
-
@
|
|
80
|
+
@dataclass
|
|
81
81
|
class CaseSource:
|
|
82
82
|
"""Data sources, used to generate a test case."""
|
|
83
83
|
|
|
84
|
-
case: "Case"
|
|
85
|
-
response: GenericResponse
|
|
86
|
-
elapsed: float
|
|
84
|
+
case: "Case"
|
|
85
|
+
response: GenericResponse
|
|
86
|
+
elapsed: float
|
|
87
87
|
|
|
88
88
|
def partial_deepcopy(self) -> "CaseSource":
|
|
89
89
|
return self.__class__(
|
|
@@ -110,26 +110,27 @@ def serialize(value: Any) -> str:
|
|
|
110
110
|
return json.dumps(value, sort_keys=True, default=_serialize_unknown)
|
|
111
111
|
|
|
112
112
|
|
|
113
|
-
@
|
|
113
|
+
@dataclass(repr=False)
|
|
114
114
|
class Case:
|
|
115
115
|
"""A single test case parameters."""
|
|
116
116
|
|
|
117
|
-
operation: "APIOperation"
|
|
118
|
-
path_parameters: Optional[PathParameters] =
|
|
119
|
-
headers: Optional[CaseInsensitiveDict] =
|
|
120
|
-
cookies: Optional[Cookies] =
|
|
121
|
-
query: Optional[Query] =
|
|
117
|
+
operation: "APIOperation"
|
|
118
|
+
path_parameters: Optional[PathParameters] = None
|
|
119
|
+
headers: Optional[CaseInsensitiveDict] = None
|
|
120
|
+
cookies: Optional[Cookies] = None
|
|
121
|
+
query: Optional[Query] = None
|
|
122
122
|
# By default, there is no body, but we can't use `None` as the default value because it clashes with `null`
|
|
123
123
|
# which is a valid payload.
|
|
124
|
-
body: Union[Body, NotSet] =
|
|
124
|
+
body: Union[Body, NotSet] = NOT_SET
|
|
125
125
|
|
|
126
|
-
source: Optional[CaseSource] =
|
|
126
|
+
source: Optional[CaseSource] = None
|
|
127
127
|
# The media type for cases with a payload. For example, "application/json"
|
|
128
|
-
media_type: Optional[str] =
|
|
128
|
+
media_type: Optional[str] = None
|
|
129
129
|
# The way the case was generated (None for manually crafted ones)
|
|
130
|
-
data_generation_method: Optional[DataGenerationMethod] =
|
|
130
|
+
data_generation_method: Optional[DataGenerationMethod] = None
|
|
131
131
|
# Unique test case identifier
|
|
132
|
-
id: str =
|
|
132
|
+
id: str = field(default_factory=lambda: uuid4().hex, compare=False)
|
|
133
|
+
_auth: Optional[requests.auth.AuthBase] = None
|
|
133
134
|
|
|
134
135
|
def __repr__(self) -> str:
|
|
135
136
|
parts = [f"{self.__class__.__name__}("]
|
|
@@ -307,7 +308,7 @@ class Case:
|
|
|
307
308
|
if "content-type" not in {header.lower() for header in final_headers}:
|
|
308
309
|
final_headers["Content-Type"] = self.media_type
|
|
309
310
|
base_url = self._get_base_url(base_url)
|
|
310
|
-
formatted_path = self.formatted_path.lstrip("/")
|
|
311
|
+
formatted_path = self.formatted_path.lstrip("/")
|
|
311
312
|
if not base_url.endswith("/"):
|
|
312
313
|
base_url += "/"
|
|
313
314
|
url = unquote(urljoin(base_url, quote(formatted_path)))
|
|
@@ -318,6 +319,8 @@ class Case:
|
|
|
318
319
|
extra = serializer.as_requests(context, self.body)
|
|
319
320
|
else:
|
|
320
321
|
extra = {}
|
|
322
|
+
if self._auth is not None:
|
|
323
|
+
extra["auth"] = self._auth
|
|
321
324
|
additional_headers = extra.pop("headers", None)
|
|
322
325
|
if additional_headers:
|
|
323
326
|
# Additional headers, needed for the serializer
|
|
@@ -337,6 +340,8 @@ class Case:
|
|
|
337
340
|
base_url: Optional[str] = None,
|
|
338
341
|
session: Optional[requests.Session] = None,
|
|
339
342
|
headers: Optional[Dict[str, Any]] = None,
|
|
343
|
+
params: Optional[Dict[str, Any]] = None,
|
|
344
|
+
cookies: Optional[Dict[str, Any]] = None,
|
|
340
345
|
**kwargs: Any,
|
|
341
346
|
) -> requests.Response:
|
|
342
347
|
"""Make a network call with `requests`."""
|
|
@@ -344,6 +349,10 @@ class Case:
|
|
|
344
349
|
dispatch("before_call", hook_context, self)
|
|
345
350
|
data = self.as_requests_kwargs(base_url, headers)
|
|
346
351
|
data.update(kwargs)
|
|
352
|
+
if params is not None:
|
|
353
|
+
_merge_dict_to(data, "params", params)
|
|
354
|
+
if cookies is not None:
|
|
355
|
+
_merge_dict_to(data, "cookies", cookies)
|
|
347
356
|
data.setdefault("timeout", DEFAULT_RESPONSE_TIMEOUT / 1000)
|
|
348
357
|
if session is None:
|
|
349
358
|
validate_vanilla_requests_kwargs(data)
|
|
@@ -352,7 +361,8 @@ class Case:
|
|
|
352
361
|
else:
|
|
353
362
|
close_session = False
|
|
354
363
|
try:
|
|
355
|
-
|
|
364
|
+
with self.operation.schema.ratelimit():
|
|
365
|
+
response = session.request(**data) # type: ignore
|
|
356
366
|
except requests.Timeout as exc:
|
|
357
367
|
timeout = 1000 * data["timeout"] # It is defined and not empty, since the exception happened
|
|
358
368
|
code_message = self._get_code_message(self.operation.schema.code_sample_style, exc.request)
|
|
@@ -387,7 +397,13 @@ class Case:
|
|
|
387
397
|
**extra,
|
|
388
398
|
}
|
|
389
399
|
|
|
390
|
-
def call_wsgi(
|
|
400
|
+
def call_wsgi(
|
|
401
|
+
self,
|
|
402
|
+
app: Any = None,
|
|
403
|
+
headers: Optional[Dict[str, str]] = None,
|
|
404
|
+
query_string: Optional[Dict[str, str]] = None,
|
|
405
|
+
**kwargs: Any,
|
|
406
|
+
) -> WSGIResponse:
|
|
391
407
|
application = app or self.app
|
|
392
408
|
if application is None:
|
|
393
409
|
raise RuntimeError(
|
|
@@ -397,8 +413,10 @@ class Case:
|
|
|
397
413
|
hook_context = HookContext(operation=self.operation)
|
|
398
414
|
dispatch("before_call", hook_context, self)
|
|
399
415
|
data = self.as_werkzeug_kwargs(headers)
|
|
416
|
+
if query_string is not None:
|
|
417
|
+
_merge_dict_to(data, "query_string", query_string)
|
|
400
418
|
client = werkzeug.Client(application, WSGIResponse)
|
|
401
|
-
with cookie_handler(client, self.cookies):
|
|
419
|
+
with cookie_handler(client, self.cookies), self.operation.schema.ratelimit():
|
|
402
420
|
response = client.open(**data, **kwargs)
|
|
403
421
|
requests_kwargs = self.as_requests_kwargs(base_url=self.get_full_base_url(), headers=headers)
|
|
404
422
|
response.request = requests.Request(**requests_kwargs).prepare()
|
|
@@ -519,6 +537,13 @@ class Case:
|
|
|
519
537
|
)
|
|
520
538
|
|
|
521
539
|
|
|
540
|
+
def _merge_dict_to(data: Dict[str, Any], data_key: str, new: Dict[str, Any]) -> None:
|
|
541
|
+
original = data[data_key] or {}
|
|
542
|
+
for key, value in new.items():
|
|
543
|
+
original[key] = value
|
|
544
|
+
data[data_key] = original
|
|
545
|
+
|
|
546
|
+
|
|
522
547
|
def validate_vanilla_requests_kwargs(data: Dict[str, Any]) -> None:
|
|
523
548
|
"""Check arguments for `requests.Session.request`.
|
|
524
549
|
|
|
@@ -569,10 +594,10 @@ def cookie_handler(client: werkzeug.Client, cookies: Optional[Cookies]) -> Gener
|
|
|
569
594
|
|
|
570
595
|
|
|
571
596
|
P = TypeVar("P", bound=Parameter)
|
|
572
|
-
D = TypeVar("D")
|
|
597
|
+
D = TypeVar("D", bound=dict)
|
|
573
598
|
|
|
574
599
|
|
|
575
|
-
@
|
|
600
|
+
@dataclass
|
|
576
601
|
class OperationDefinition(Generic[P, D]):
|
|
577
602
|
"""A wrapper to store not resolved API operation definitions.
|
|
578
603
|
|
|
@@ -581,16 +606,25 @@ class OperationDefinition(Generic[P, D]):
|
|
|
581
606
|
scope change to have a proper reference resolving later.
|
|
582
607
|
"""
|
|
583
608
|
|
|
584
|
-
raw: D
|
|
585
|
-
resolved: D
|
|
586
|
-
scope: str
|
|
587
|
-
parameters: Sequence[P]
|
|
609
|
+
raw: D
|
|
610
|
+
resolved: D
|
|
611
|
+
scope: str
|
|
612
|
+
parameters: Sequence[P]
|
|
613
|
+
|
|
614
|
+
def __contains__(self, item: Union[str, int]) -> bool:
|
|
615
|
+
return item in self.resolved
|
|
616
|
+
|
|
617
|
+
def __getitem__(self, item: Union[str, int]) -> Union[None, bool, float, str, list, Dict[str, Any]]:
|
|
618
|
+
return self.resolved[item]
|
|
619
|
+
|
|
620
|
+
def get(self, item: Union[str, int], default: Any = None) -> Union[None, bool, float, str, list, Dict[str, Any]]:
|
|
621
|
+
return self.resolved.get(item, default)
|
|
588
622
|
|
|
589
623
|
|
|
590
624
|
C = TypeVar("C", bound=Case)
|
|
591
625
|
|
|
592
626
|
|
|
593
|
-
@
|
|
627
|
+
@dataclass(eq=False)
|
|
594
628
|
class APIOperation(Generic[P, C]):
|
|
595
629
|
"""A single operation defined in an API.
|
|
596
630
|
|
|
@@ -606,23 +640,23 @@ class APIOperation(Generic[P, C]):
|
|
|
606
640
|
# `path` does not contain `basePath`
|
|
607
641
|
# Example <scheme>://<host>/<basePath>/users - "/users" is path
|
|
608
642
|
# https://swagger.io/docs/specification/2-0/api-host-and-base-path/
|
|
609
|
-
path: str
|
|
610
|
-
method: str
|
|
611
|
-
definition: OperationDefinition =
|
|
612
|
-
schema: "BaseSchema"
|
|
613
|
-
verbose_name: str =
|
|
614
|
-
app: Any =
|
|
615
|
-
base_url: Optional[str] =
|
|
616
|
-
path_parameters: ParameterSet[P] =
|
|
617
|
-
headers: ParameterSet[P] =
|
|
618
|
-
cookies: ParameterSet[P] =
|
|
619
|
-
query: ParameterSet[P] =
|
|
620
|
-
body: PayloadAlternatives[P] =
|
|
621
|
-
case_cls: Type[C] =
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
643
|
+
path: str
|
|
644
|
+
method: str
|
|
645
|
+
definition: OperationDefinition = field(repr=False)
|
|
646
|
+
schema: "BaseSchema"
|
|
647
|
+
verbose_name: str = None # type: ignore
|
|
648
|
+
app: Any = None
|
|
649
|
+
base_url: Optional[str] = None
|
|
650
|
+
path_parameters: ParameterSet[P] = field(default_factory=ParameterSet)
|
|
651
|
+
headers: ParameterSet[P] = field(default_factory=ParameterSet)
|
|
652
|
+
cookies: ParameterSet[P] = field(default_factory=ParameterSet)
|
|
653
|
+
query: ParameterSet[P] = field(default_factory=ParameterSet)
|
|
654
|
+
body: PayloadAlternatives[P] = field(default_factory=PayloadAlternatives)
|
|
655
|
+
case_cls: Type[C] = Case # type: ignore
|
|
656
|
+
|
|
657
|
+
def __post_init__(self) -> None:
|
|
658
|
+
if self.verbose_name is None:
|
|
659
|
+
self.verbose_name = f"{self.method.upper()} {self.full_path}" # type: ignore
|
|
626
660
|
|
|
627
661
|
@property
|
|
628
662
|
def full_path(self) -> str:
|
|
@@ -636,27 +670,35 @@ class APIOperation(Generic[P, C]):
|
|
|
636
670
|
"""Iterate over all operation's parameters."""
|
|
637
671
|
return chain(self.path_parameters, self.headers, self.cookies, self.query)
|
|
638
672
|
|
|
673
|
+
def _lookup_container(self, location: str) -> Union[ParameterSet[P], PayloadAlternatives[P], None]:
|
|
674
|
+
return {
|
|
675
|
+
"path": self.path_parameters,
|
|
676
|
+
"header": self.headers,
|
|
677
|
+
"cookie": self.cookies,
|
|
678
|
+
"query": self.query,
|
|
679
|
+
"body": self.body,
|
|
680
|
+
}.get(location)
|
|
681
|
+
|
|
639
682
|
def add_parameter(self, parameter: P) -> None:
|
|
640
683
|
"""Add a new processed parameter to an API operation.
|
|
641
684
|
|
|
642
685
|
:param parameter: A parameter that will be used with this operation.
|
|
643
686
|
:rtype: None
|
|
644
687
|
"""
|
|
645
|
-
lookup_table = {
|
|
646
|
-
"path": self.path_parameters,
|
|
647
|
-
"header": self.headers,
|
|
648
|
-
"cookie": self.cookies,
|
|
649
|
-
"query": self.query,
|
|
650
|
-
"body": self.body,
|
|
651
|
-
}
|
|
652
688
|
# If the parameter has a typo, then by default, there will be an error from `jsonschema` earlier.
|
|
653
689
|
# But if the user wants to skip schema validation, we choose to ignore a malformed parameter.
|
|
654
690
|
# In this case, we still might generate some tests for an API operation, but without this parameter,
|
|
655
691
|
# which is better than skip the whole operation from testing.
|
|
656
|
-
|
|
657
|
-
|
|
692
|
+
container = self._lookup_container(parameter.location)
|
|
693
|
+
if container is not None:
|
|
658
694
|
container.add(parameter)
|
|
659
695
|
|
|
696
|
+
def get_parameter(self, name: str, location: str) -> Optional[P]:
|
|
697
|
+
container = self._lookup_container(location)
|
|
698
|
+
if container is not None:
|
|
699
|
+
return container.get(name)
|
|
700
|
+
return None
|
|
701
|
+
|
|
660
702
|
def as_strategy(
|
|
661
703
|
self,
|
|
662
704
|
hooks: Optional["HookDispatcher"] = None,
|
|
@@ -788,35 +830,35 @@ Endpoint = APIOperation
|
|
|
788
830
|
class Status(str, Enum):
|
|
789
831
|
"""Status of an action or multiple actions."""
|
|
790
832
|
|
|
791
|
-
success = "success"
|
|
792
|
-
failure = "failure"
|
|
793
|
-
error = "error"
|
|
794
|
-
skip = "skip"
|
|
833
|
+
success = "success"
|
|
834
|
+
failure = "failure"
|
|
835
|
+
error = "error"
|
|
836
|
+
skip = "skip"
|
|
795
837
|
|
|
796
838
|
|
|
797
|
-
@
|
|
839
|
+
@dataclass(repr=False)
|
|
798
840
|
class Check:
|
|
799
841
|
"""Single check run result."""
|
|
800
842
|
|
|
801
|
-
name: str
|
|
802
|
-
value: Status
|
|
803
|
-
response: Optional[GenericResponse]
|
|
804
|
-
elapsed: float
|
|
805
|
-
example: Case
|
|
806
|
-
message: Optional[str] =
|
|
843
|
+
name: str
|
|
844
|
+
value: Status
|
|
845
|
+
response: Optional[GenericResponse]
|
|
846
|
+
elapsed: float
|
|
847
|
+
example: Case
|
|
848
|
+
message: Optional[str] = None
|
|
807
849
|
# Failure-specific context
|
|
808
|
-
context: Optional[FailureContext] =
|
|
809
|
-
request: Optional[requests.PreparedRequest] =
|
|
850
|
+
context: Optional[FailureContext] = None
|
|
851
|
+
request: Optional[requests.PreparedRequest] = None
|
|
810
852
|
|
|
811
853
|
|
|
812
|
-
@
|
|
854
|
+
@dataclass(repr=False)
|
|
813
855
|
class Request:
|
|
814
856
|
"""Request data extracted from `Case`."""
|
|
815
857
|
|
|
816
|
-
method: str
|
|
817
|
-
uri: str
|
|
818
|
-
body: Optional[str]
|
|
819
|
-
headers: Headers
|
|
858
|
+
method: str
|
|
859
|
+
uri: str
|
|
860
|
+
body: Optional[str]
|
|
861
|
+
headers: Headers
|
|
820
862
|
|
|
821
863
|
@classmethod
|
|
822
864
|
def from_case(cls, case: Case, session: requests.Session) -> "Request":
|
|
@@ -851,17 +893,17 @@ def serialize_payload(payload: bytes) -> str:
|
|
|
851
893
|
return base64.b64encode(payload).decode()
|
|
852
894
|
|
|
853
895
|
|
|
854
|
-
@
|
|
896
|
+
@dataclass(repr=False)
|
|
855
897
|
class Response:
|
|
856
898
|
"""Unified response data."""
|
|
857
899
|
|
|
858
|
-
status_code: int
|
|
859
|
-
message: str
|
|
860
|
-
headers: Dict[str, List[str]]
|
|
861
|
-
body: Optional[str]
|
|
862
|
-
encoding: Optional[str]
|
|
863
|
-
http_version: str
|
|
864
|
-
elapsed: float
|
|
900
|
+
status_code: int
|
|
901
|
+
message: str
|
|
902
|
+
headers: Dict[str, List[str]]
|
|
903
|
+
body: Optional[str]
|
|
904
|
+
encoding: Optional[str]
|
|
905
|
+
http_version: str
|
|
906
|
+
elapsed: float
|
|
865
907
|
|
|
866
908
|
@classmethod
|
|
867
909
|
def from_requests(cls, response: requests.Response) -> "Response":
|
|
@@ -911,16 +953,16 @@ class Response:
|
|
|
911
953
|
)
|
|
912
954
|
|
|
913
955
|
|
|
914
|
-
@
|
|
956
|
+
@dataclass
|
|
915
957
|
class Interaction:
|
|
916
958
|
"""A single interaction with the target app."""
|
|
917
959
|
|
|
918
|
-
request: Request
|
|
919
|
-
response: Response
|
|
920
|
-
checks: List[Check]
|
|
921
|
-
status: Status
|
|
922
|
-
data_generation_method: DataGenerationMethod
|
|
923
|
-
recorded_at: str =
|
|
960
|
+
request: Request
|
|
961
|
+
response: Response
|
|
962
|
+
checks: List[Check]
|
|
963
|
+
status: Status
|
|
964
|
+
data_generation_method: DataGenerationMethod
|
|
965
|
+
recorded_at: str = field(default_factory=lambda: datetime.datetime.now().isoformat())
|
|
924
966
|
|
|
925
967
|
@classmethod
|
|
926
968
|
def from_requests(
|
|
@@ -955,28 +997,28 @@ class Interaction:
|
|
|
955
997
|
)
|
|
956
998
|
|
|
957
999
|
|
|
958
|
-
@
|
|
1000
|
+
@dataclass(repr=False)
|
|
959
1001
|
class TestResult:
|
|
960
1002
|
"""Result of a single test."""
|
|
961
1003
|
|
|
962
1004
|
__test__ = False
|
|
963
1005
|
|
|
964
|
-
method: str
|
|
965
|
-
path: str
|
|
966
|
-
verbose_name: str
|
|
967
|
-
data_generation_method: List[DataGenerationMethod]
|
|
968
|
-
checks: List[Check] =
|
|
969
|
-
errors: List[Tuple[Exception, Optional[Case]]] =
|
|
970
|
-
interactions: List[Interaction] =
|
|
971
|
-
logs: List[LogRecord] =
|
|
972
|
-
is_errored: bool =
|
|
973
|
-
is_flaky: bool =
|
|
974
|
-
is_skipped: bool =
|
|
975
|
-
is_executed: bool =
|
|
976
|
-
seed: Optional[int] =
|
|
1006
|
+
method: str
|
|
1007
|
+
path: str
|
|
1008
|
+
verbose_name: str
|
|
1009
|
+
data_generation_method: List[DataGenerationMethod]
|
|
1010
|
+
checks: List[Check] = field(default_factory=list)
|
|
1011
|
+
errors: List[Tuple[Exception, Optional[Case]]] = field(default_factory=list)
|
|
1012
|
+
interactions: List[Interaction] = field(default_factory=list)
|
|
1013
|
+
logs: List[LogRecord] = field(default_factory=list)
|
|
1014
|
+
is_errored: bool = False
|
|
1015
|
+
is_flaky: bool = False
|
|
1016
|
+
is_skipped: bool = False
|
|
1017
|
+
is_executed: bool = False
|
|
1018
|
+
seed: Optional[int] = None
|
|
977
1019
|
# To show a proper reproduction code if an error happens and there is no way to get actual headers that were
|
|
978
1020
|
# sent over the network. Or there could be no actual requests at all
|
|
979
|
-
overridden_headers: Optional[Dict[str, Any]] =
|
|
1021
|
+
overridden_headers: Optional[Dict[str, Any]] = None
|
|
980
1022
|
|
|
981
1023
|
def mark_errored(self) -> None:
|
|
982
1024
|
self.is_errored = True
|
|
@@ -1052,15 +1094,15 @@ class TestResult:
|
|
|
1052
1094
|
self.interactions.append(Interaction.from_wsgi(case, response, headers, elapsed, status, checks))
|
|
1053
1095
|
|
|
1054
1096
|
|
|
1055
|
-
@
|
|
1097
|
+
@dataclass(repr=False)
|
|
1056
1098
|
class TestResultSet:
|
|
1057
1099
|
"""Set of multiple test results."""
|
|
1058
1100
|
|
|
1059
1101
|
__test__ = False
|
|
1060
1102
|
|
|
1061
|
-
results: List[TestResult] =
|
|
1062
|
-
generic_errors: List[InvalidSchema] =
|
|
1063
|
-
warnings: List[str] =
|
|
1103
|
+
results: List[TestResult] = field(default_factory=list)
|
|
1104
|
+
generic_errors: List[InvalidSchema] = field(default_factory=list)
|
|
1105
|
+
warnings: List[str] = field(default_factory=list)
|
|
1064
1106
|
|
|
1065
1107
|
def __iter__(self) -> Iterator[TestResult]:
|
|
1066
1108
|
return iter(self.results)
|
|
@@ -1127,4 +1169,4 @@ class TestResultSet:
|
|
|
1127
1169
|
self.warnings.append(warning)
|
|
1128
1170
|
|
|
1129
1171
|
|
|
1130
|
-
CheckFunction = Callable[[GenericResponse, Case], Optional[bool]]
|
|
1172
|
+
CheckFunction = Callable[[GenericResponse, Case], Optional[bool]]
|
schemathesis/parameters.py
CHANGED
|
@@ -2,15 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
These are basic entities that describe what data could be sent to the API.
|
|
4
4
|
"""
|
|
5
|
-
from
|
|
6
|
-
|
|
7
|
-
import attr
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Dict, Generator, Generic, List, Optional, TypeVar
|
|
8
7
|
|
|
9
8
|
if TYPE_CHECKING:
|
|
10
9
|
from .models import APIOperation
|
|
11
10
|
|
|
12
11
|
|
|
13
|
-
@
|
|
12
|
+
@dataclass(eq=False)
|
|
14
13
|
class Parameter:
|
|
15
14
|
"""A logically separate parameter bound to a location (e.g., to "query string").
|
|
16
15
|
|
|
@@ -19,7 +18,7 @@ class Parameter:
|
|
|
19
18
|
"""
|
|
20
19
|
|
|
21
20
|
# The parameter definition in the language acceptable by the API
|
|
22
|
-
definition: Any
|
|
21
|
+
definition: Any
|
|
23
22
|
|
|
24
23
|
@property
|
|
25
24
|
def location(self) -> str:
|
|
@@ -52,16 +51,22 @@ class Parameter:
|
|
|
52
51
|
P = TypeVar("P", bound=Parameter)
|
|
53
52
|
|
|
54
53
|
|
|
55
|
-
@
|
|
54
|
+
@dataclass
|
|
56
55
|
class ParameterSet(Generic[P]):
|
|
57
56
|
"""A set of parameters for the same location."""
|
|
58
57
|
|
|
59
|
-
items: List[P] =
|
|
58
|
+
items: List[P] = field(default_factory=list)
|
|
60
59
|
|
|
61
60
|
def add(self, parameter: P) -> None:
|
|
62
61
|
"""Add a new parameter."""
|
|
63
62
|
self.items.append(parameter)
|
|
64
63
|
|
|
64
|
+
def get(self, name: str) -> Optional[P]:
|
|
65
|
+
for parameter in self:
|
|
66
|
+
if parameter.name == name:
|
|
67
|
+
return parameter
|
|
68
|
+
return None
|
|
69
|
+
|
|
65
70
|
@property
|
|
66
71
|
def example(self) -> Dict[str, Any]:
|
|
67
72
|
"""Composite example gathered from individual parameters."""
|