schemathesis 3.19.7__py3-none-any.whl → 3.20.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/_compat.py +3 -2
- schemathesis/_hypothesis.py +21 -6
- schemathesis/_xml.py +177 -0
- schemathesis/auths.py +48 -10
- schemathesis/cli/__init__.py +77 -19
- schemathesis/cli/callbacks.py +42 -18
- schemathesis/cli/context.py +2 -1
- schemathesis/cli/output/default.py +102 -34
- schemathesis/cli/sanitization.py +15 -0
- schemathesis/code_samples.py +141 -0
- schemathesis/constants.py +1 -24
- schemathesis/exceptions.py +127 -26
- schemathesis/experimental/__init__.py +85 -0
- schemathesis/extra/pytest_plugin.py +10 -4
- schemathesis/fixups/__init__.py +8 -2
- schemathesis/fixups/fast_api.py +11 -1
- schemathesis/fixups/utf8_bom.py +7 -1
- schemathesis/hooks.py +63 -0
- schemathesis/lazy.py +10 -4
- schemathesis/loaders.py +57 -0
- schemathesis/models.py +120 -96
- schemathesis/parameters.py +3 -0
- schemathesis/runner/__init__.py +3 -0
- schemathesis/runner/events.py +55 -20
- schemathesis/runner/impl/core.py +54 -54
- schemathesis/runner/serialization.py +75 -34
- schemathesis/sanitization.py +248 -0
- schemathesis/schemas.py +21 -6
- schemathesis/serializers.py +32 -3
- schemathesis/service/serialization.py +5 -1
- schemathesis/specs/graphql/loaders.py +44 -13
- schemathesis/specs/graphql/schemas.py +56 -25
- schemathesis/specs/openapi/_hypothesis.py +11 -23
- schemathesis/specs/openapi/definitions.py +572 -0
- schemathesis/specs/openapi/loaders.py +100 -49
- schemathesis/specs/openapi/parameters.py +2 -2
- schemathesis/specs/openapi/schemas.py +87 -13
- schemathesis/specs/openapi/security.py +1 -0
- schemathesis/stateful.py +2 -2
- schemathesis/utils.py +30 -9
- schemathesis-3.20.1.dist-info/METADATA +342 -0
- {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/RECORD +45 -39
- schemathesis-3.19.7.dist-info/METADATA +0 -291
- {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/WHEEL +0 -0
- {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/licenses/LICENSE +0 -0
schemathesis/_compat.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import hypothesis
|
|
2
2
|
import hypothesis_jsonschema._from_schema
|
|
3
3
|
import jsonschema
|
|
4
|
-
import werkzeug
|
|
5
4
|
from hypothesis import strategies as st
|
|
6
5
|
from hypothesis.errors import InvalidArgument
|
|
7
6
|
from packaging import version
|
|
@@ -11,7 +10,9 @@ try:
|
|
|
11
10
|
except ImportError:
|
|
12
11
|
import importlib_metadata as metadata # type: ignore
|
|
13
12
|
|
|
14
|
-
|
|
13
|
+
WERKZEUG_VERSION = version.parse(metadata.version("werkzeug"))
|
|
14
|
+
IS_WERKZEUG_ABOVE_3 = WERKZEUG_VERSION >= version.parse("3.0")
|
|
15
|
+
if WERKZEUG_VERSION < version.parse("2.1.0"):
|
|
15
16
|
from werkzeug.wrappers.json import JSONMixin
|
|
16
17
|
else:
|
|
17
18
|
|
schemathesis/_hypothesis.py
CHANGED
|
@@ -12,7 +12,7 @@ from hypothesis_jsonschema._canonicalise import HypothesisRefResolutionError
|
|
|
12
12
|
|
|
13
13
|
from .auths import get_auth_storage_from_test
|
|
14
14
|
from .constants import DEFAULT_DEADLINE, DataGenerationMethod
|
|
15
|
-
from .exceptions import
|
|
15
|
+
from .exceptions import OperationSchemaError
|
|
16
16
|
from .hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher
|
|
17
17
|
from .models import APIOperation, Case
|
|
18
18
|
from .utils import GivenInput, combine_strategies
|
|
@@ -63,16 +63,19 @@ def create_test(
|
|
|
63
63
|
setup_default_deadline(wrapped_test)
|
|
64
64
|
if settings is not None:
|
|
65
65
|
wrapped_test = settings(wrapped_test)
|
|
66
|
-
existing_settings =
|
|
67
|
-
if existing_settings
|
|
68
|
-
|
|
66
|
+
existing_settings = _get_hypothesis_settings(wrapped_test)
|
|
67
|
+
if existing_settings is not None:
|
|
68
|
+
existing_settings = remove_explain_phase(existing_settings)
|
|
69
|
+
wrapped_test._hypothesis_internal_use_settings = existing_settings # type: ignore
|
|
70
|
+
if Phase.explicit in existing_settings.phases:
|
|
71
|
+
wrapped_test = add_examples(wrapped_test, operation, hook_dispatcher=hook_dispatcher)
|
|
69
72
|
return wrapped_test
|
|
70
73
|
|
|
71
74
|
|
|
72
75
|
def setup_default_deadline(wrapped_test: Callable) -> None:
|
|
73
76
|
# Quite hacky, but it is the simplest way to set up the default deadline value without affecting non-Schemathesis
|
|
74
77
|
# tests globally
|
|
75
|
-
existing_settings =
|
|
78
|
+
existing_settings = _get_hypothesis_settings(wrapped_test)
|
|
76
79
|
if existing_settings is not None and existing_settings.deadline == hypothesis.settings.default.deadline:
|
|
77
80
|
with warnings.catch_warnings():
|
|
78
81
|
warnings.simplefilter("ignore", HypothesisWarning)
|
|
@@ -80,6 +83,18 @@ def setup_default_deadline(wrapped_test: Callable) -> None:
|
|
|
80
83
|
wrapped_test._hypothesis_internal_use_settings = new_settings # type: ignore
|
|
81
84
|
|
|
82
85
|
|
|
86
|
+
def remove_explain_phase(settings: hypothesis.settings) -> hypothesis.settings:
|
|
87
|
+
# The "explain" phase is not supported
|
|
88
|
+
if Phase.explain in settings.phases:
|
|
89
|
+
phases = tuple(phase for phase in settings.phases if phase != Phase.explain)
|
|
90
|
+
return hypothesis.settings(settings, phases=phases)
|
|
91
|
+
return settings
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _get_hypothesis_settings(test: Callable) -> Optional[hypothesis.settings]:
|
|
95
|
+
return getattr(test, "_hypothesis_internal_use_settings", None)
|
|
96
|
+
|
|
97
|
+
|
|
83
98
|
def make_async_test(test: Callable) -> Callable:
|
|
84
99
|
def async_run(*args: Any, **kwargs: Any) -> None:
|
|
85
100
|
loop = asyncio.get_event_loop()
|
|
@@ -94,7 +109,7 @@ def add_examples(test: Callable, operation: APIOperation, hook_dispatcher: Optio
|
|
|
94
109
|
"""Add examples to the Hypothesis test, if they are specified in the schema."""
|
|
95
110
|
try:
|
|
96
111
|
examples: List[Case] = [get_single_example(strategy) for strategy in operation.get_strategies_from_examples()]
|
|
97
|
-
except (
|
|
112
|
+
except (OperationSchemaError, HypothesisRefResolutionError, Unsatisfiable):
|
|
98
113
|
# Invalid schema:
|
|
99
114
|
# In this case, the user didn't pass `--validate-schema=false` and see an error in the output anyway,
|
|
100
115
|
# and no tests will be executed. For this reason, examples can be skipped
|
schemathesis/_xml.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""XML serialization."""
|
|
2
|
+
from io import StringIO
|
|
3
|
+
from typing import Any, Dict, List, Union
|
|
4
|
+
from xml.etree import ElementTree
|
|
5
|
+
|
|
6
|
+
from hypothesis import reject
|
|
7
|
+
|
|
8
|
+
from .exceptions import UnboundPrefixError
|
|
9
|
+
from .utils import fast_deepcopy
|
|
10
|
+
|
|
11
|
+
Primitive = Union[str, int, float, bool, None]
|
|
12
|
+
JSON = Union[Primitive, List, Dict[str, Any]]
|
|
13
|
+
DEFAULT_TAG_NAME = "data"
|
|
14
|
+
NAMESPACE_URL = "http://example.com/schema"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _to_xml(value: Any, raw_schema: Dict[str, Any], resolved_schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
18
|
+
"""Serialize a generated Python object as an XML string.
|
|
19
|
+
|
|
20
|
+
Schemas may contain additional information for fine-tuned XML serialization.
|
|
21
|
+
|
|
22
|
+
:param value: Generated value
|
|
23
|
+
:param raw_schema: The payload definition with not resolved references.
|
|
24
|
+
:param resolved_schema: The payload definition with all references resolved.
|
|
25
|
+
"""
|
|
26
|
+
if isinstance(value, (bytes, str)):
|
|
27
|
+
return {"data": value}
|
|
28
|
+
tag = _get_xml_tag(raw_schema, resolved_schema)
|
|
29
|
+
buffer = StringIO()
|
|
30
|
+
# Collect all namespaces to ensure that all child nodes with prefixes have proper namespaces in their parent nodes
|
|
31
|
+
namespace_stack: List[str] = []
|
|
32
|
+
_write_xml(buffer, value, tag, resolved_schema, namespace_stack)
|
|
33
|
+
data = buffer.getvalue()
|
|
34
|
+
if not is_valid_xml(data):
|
|
35
|
+
reject()
|
|
36
|
+
return {"data": data.encode("utf8")}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_from_string = ElementTree.fromstring
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_valid_xml(data: str) -> bool:
|
|
43
|
+
try:
|
|
44
|
+
_from_string(f"<root xmlns:smp='{NAMESPACE_URL}'>{data}</root>")
|
|
45
|
+
return True
|
|
46
|
+
except ElementTree.ParseError:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_xml_tag(raw_schema: Dict[str, Any], resolved_schema: Dict[str, Any]) -> str:
|
|
51
|
+
# On the top level we need to detect the proper XML tag, in other cases it is known from object properties
|
|
52
|
+
if resolved_schema.get("xml", {}).get("name"):
|
|
53
|
+
return resolved_schema["xml"]["name"]
|
|
54
|
+
|
|
55
|
+
# Check if the name can be derived from a reference in the raw schema
|
|
56
|
+
if "$ref" in raw_schema:
|
|
57
|
+
return _get_tag_name_from_reference(raw_schema["$ref"])
|
|
58
|
+
|
|
59
|
+
# Here we don't have any name for the payload schema - no reference or the `xml` property
|
|
60
|
+
return DEFAULT_TAG_NAME
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _write_xml(buffer: StringIO, value: JSON, tag: str, schema: Dict[str, Any], namespace_stack: List[str]) -> None:
|
|
64
|
+
if isinstance(value, dict):
|
|
65
|
+
_write_object(buffer, value, tag, schema, namespace_stack)
|
|
66
|
+
elif isinstance(value, list):
|
|
67
|
+
_write_array(buffer, value, tag, schema, namespace_stack)
|
|
68
|
+
else:
|
|
69
|
+
_write_primitive(buffer, value, tag, schema, namespace_stack)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _validate_prefix(options: Dict[str, Any], namespace_stack: List[str]) -> None:
|
|
73
|
+
try:
|
|
74
|
+
prefix = options["prefix"]
|
|
75
|
+
if prefix not in namespace_stack:
|
|
76
|
+
raise UnboundPrefixError(prefix)
|
|
77
|
+
except KeyError:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def push_namespace_if_any(namespace_stack: List[str], options: Dict[str, Any]) -> None:
|
|
82
|
+
if "namespace" in options and "prefix" in options:
|
|
83
|
+
namespace_stack.append(options["prefix"])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def pop_namespace_if_any(namespace_stack: List[str], options: Dict[str, Any]) -> None:
|
|
87
|
+
if "namespace" in options and "prefix" in options:
|
|
88
|
+
namespace_stack.pop()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _write_object(buffer: StringIO, obj: Dict[str, JSON], tag: str, schema: Dict[str, Any], stack: List[str]) -> None:
|
|
92
|
+
options = schema.get("xml", {})
|
|
93
|
+
push_namespace_if_any(stack, options)
|
|
94
|
+
if "prefix" in options:
|
|
95
|
+
tag = f"{options['prefix']}:{tag}"
|
|
96
|
+
buffer.write(f"<{tag}")
|
|
97
|
+
if "namespace" in options:
|
|
98
|
+
_write_namespace(buffer, options)
|
|
99
|
+
attributes = []
|
|
100
|
+
children_buffer = StringIO()
|
|
101
|
+
properties = schema.get("properties", {})
|
|
102
|
+
for child_name, value in obj.items():
|
|
103
|
+
property_schema = properties.get(child_name, {})
|
|
104
|
+
child_options = property_schema.get("xml", {})
|
|
105
|
+
push_namespace_if_any(stack, child_options)
|
|
106
|
+
child_tag = child_options.get("name", child_name)
|
|
107
|
+
if child_options.get("prefix"):
|
|
108
|
+
_validate_prefix(child_options, stack)
|
|
109
|
+
prefix = child_options["prefix"]
|
|
110
|
+
child_tag = f"{prefix}:{child_tag}"
|
|
111
|
+
if child_options.get("attribute", False):
|
|
112
|
+
attributes.append(f'{child_tag}="{value}"')
|
|
113
|
+
continue
|
|
114
|
+
_write_xml(children_buffer, value, child_tag, property_schema, stack)
|
|
115
|
+
pop_namespace_if_any(stack, child_options)
|
|
116
|
+
|
|
117
|
+
if attributes:
|
|
118
|
+
buffer.write(f" {' '.join(attributes)}")
|
|
119
|
+
buffer.write(">")
|
|
120
|
+
buffer.write(children_buffer.getvalue())
|
|
121
|
+
buffer.write(f"</{tag}>")
|
|
122
|
+
pop_namespace_if_any(stack, options)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _write_array(buffer: StringIO, obj: List[JSON], tag: str, schema: Dict[str, Any], stack: List[str]) -> None:
|
|
126
|
+
options = schema.get("xml", {})
|
|
127
|
+
push_namespace_if_any(stack, options)
|
|
128
|
+
if options.get("prefix"):
|
|
129
|
+
tag = f"{options['prefix']}:{tag}"
|
|
130
|
+
wrapped = options.get("wrapped", False)
|
|
131
|
+
is_namespace_specified = False
|
|
132
|
+
if wrapped:
|
|
133
|
+
buffer.write(f"<{tag}")
|
|
134
|
+
if "namespace" in options:
|
|
135
|
+
is_namespace_specified = True
|
|
136
|
+
_write_namespace(buffer, options)
|
|
137
|
+
buffer.write(">")
|
|
138
|
+
# In Open API `items` value should be an object and not an array
|
|
139
|
+
items = fast_deepcopy(schema.get("items", {}))
|
|
140
|
+
child_options = items.get("xml", {})
|
|
141
|
+
child_tag = child_options.get("name", tag)
|
|
142
|
+
if not is_namespace_specified and "namespace" in options:
|
|
143
|
+
child_options.setdefault("namespace", options["namespace"])
|
|
144
|
+
if "prefix" in options:
|
|
145
|
+
child_options.setdefault("prefix", options["prefix"])
|
|
146
|
+
items["xml"] = child_options
|
|
147
|
+
_validate_prefix(child_options, stack)
|
|
148
|
+
for item in obj:
|
|
149
|
+
_write_xml(buffer, item, child_tag, items, stack)
|
|
150
|
+
if wrapped:
|
|
151
|
+
buffer.write(f"</{tag}>")
|
|
152
|
+
pop_namespace_if_any(stack, options)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _write_primitive(
|
|
156
|
+
buffer: StringIO, obj: Primitive, tag: str, schema: Dict[str, Any], namespace_stack: List[str]
|
|
157
|
+
) -> None:
|
|
158
|
+
xml_options = schema.get("xml", {})
|
|
159
|
+
# There is no need for modifying the namespace stack, as we know that this function is terminal - it do not recurse
|
|
160
|
+
# and this element don't have any children. Therefore, checking the prefix is enough
|
|
161
|
+
_validate_prefix(xml_options, namespace_stack)
|
|
162
|
+
buffer.write(f"<{tag}")
|
|
163
|
+
if "namespace" in xml_options:
|
|
164
|
+
_write_namespace(buffer, xml_options)
|
|
165
|
+
buffer.write(f">{obj}</{tag}>")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _write_namespace(buffer: StringIO, options: Dict[str, Any]) -> None:
|
|
169
|
+
buffer.write(" xmlns")
|
|
170
|
+
if "prefix" in options:
|
|
171
|
+
buffer.write(f":{options['prefix']}")
|
|
172
|
+
buffer.write(f'="{options["namespace"]}"')
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _get_tag_name_from_reference(reference: str) -> str:
|
|
176
|
+
"""Extract object name from a reference."""
|
|
177
|
+
return reference.rsplit("/", maxsplit=1)[1]
|
schemathesis/auths.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""Support for custom API authentication mechanisms."""
|
|
2
|
+
import inspect
|
|
2
3
|
import threading
|
|
3
4
|
import time
|
|
5
|
+
import warnings
|
|
4
6
|
from dataclasses import dataclass, field
|
|
5
|
-
from typing import TYPE_CHECKING, Any, Callable, Generic, List, Optional, Type, TypeVar, Union
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Callable, Generic, List, Optional, Type, TypeVar, Union, overload
|
|
6
8
|
|
|
7
9
|
import requests.auth
|
|
8
10
|
from typing_extensions import Protocol, runtime_checkable
|
|
@@ -32,12 +34,13 @@ class AuthContext:
|
|
|
32
34
|
|
|
33
35
|
|
|
34
36
|
@runtime_checkable
|
|
35
|
-
class AuthProvider(Protocol):
|
|
37
|
+
class AuthProvider(Generic[Auth], Protocol):
|
|
36
38
|
"""Get authentication data for an API and set it on the generated test cases."""
|
|
37
39
|
|
|
38
|
-
def get(self, context: AuthContext) -> Optional[Auth]:
|
|
40
|
+
def get(self, case: "Case", context: AuthContext) -> Optional[Auth]:
|
|
39
41
|
"""Get the authentication data.
|
|
40
42
|
|
|
43
|
+
:param Case case: Generated test case.
|
|
41
44
|
:param AuthContext context: Holds state relevant for the authentication process.
|
|
42
45
|
:return: Any authentication data you find useful for your use case. For example, it could be an access token.
|
|
43
46
|
"""
|
|
@@ -65,7 +68,7 @@ class RequestsAuth(Generic[Auth]):
|
|
|
65
68
|
|
|
66
69
|
auth: requests.auth.AuthBase
|
|
67
70
|
|
|
68
|
-
def get(self, _: AuthContext) -> Optional[Auth]:
|
|
71
|
+
def get(self, _: "Case", __: AuthContext) -> Optional[Auth]:
|
|
69
72
|
return self.auth # type: ignore[return-value]
|
|
70
73
|
|
|
71
74
|
def set(self, case: "Case", _: Auth, __: AuthContext) -> None:
|
|
@@ -83,7 +86,7 @@ class CachingAuthProvider(Generic[Auth]):
|
|
|
83
86
|
timer: Callable[[], float] = time.monotonic
|
|
84
87
|
_refresh_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
85
88
|
|
|
86
|
-
def get(self, context: AuthContext) -> Optional[Auth]:
|
|
89
|
+
def get(self, case: "Case", context: AuthContext) -> Optional[Auth]:
|
|
87
90
|
"""Get cached auth value."""
|
|
88
91
|
if self.cache_entry is None or self.timer() >= self.cache_entry.expires:
|
|
89
92
|
with self._refresh_lock:
|
|
@@ -91,7 +94,7 @@ class CachingAuthProvider(Generic[Auth]):
|
|
|
91
94
|
# Another thread updated the cache
|
|
92
95
|
return self.cache_entry.data
|
|
93
96
|
# We know that optional auth is possible only inside a higher-level wrapper
|
|
94
|
-
data: Auth = self.provider
|
|
97
|
+
data: Auth = _provider_get(self.provider, case, context) # type: ignore[assignment]
|
|
95
98
|
self.cache_entry = CacheEntry(data=data, expires=self.timer() + self.refresh_interval)
|
|
96
99
|
return data
|
|
97
100
|
return self.cache_entry.data
|
|
@@ -207,9 +210,9 @@ class SelectiveAuthProvider(Generic[Auth]):
|
|
|
207
210
|
provider: AuthProvider
|
|
208
211
|
filter_set: FilterSet
|
|
209
212
|
|
|
210
|
-
def get(self, context: AuthContext) -> Optional[Auth]:
|
|
213
|
+
def get(self, case: "Case", context: AuthContext) -> Optional[Auth]:
|
|
211
214
|
if self.filter_set.match(context):
|
|
212
|
-
return self.provider
|
|
215
|
+
return _provider_get(self.provider, case, context)
|
|
213
216
|
return None
|
|
214
217
|
|
|
215
218
|
def set(self, case: "Case", data: Auth, context: AuthContext) -> None:
|
|
@@ -227,6 +230,23 @@ class AuthStorage(Generic[Auth]):
|
|
|
227
230
|
"""Whether there is an auth provider set."""
|
|
228
231
|
return bool(self.providers)
|
|
229
232
|
|
|
233
|
+
@overload
|
|
234
|
+
def __call__(
|
|
235
|
+
self,
|
|
236
|
+
*,
|
|
237
|
+
refresh_interval: Optional[int] = DEFAULT_REFRESH_INTERVAL,
|
|
238
|
+
) -> FilterableRegisterAuth:
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
@overload
|
|
242
|
+
def __call__(
|
|
243
|
+
self,
|
|
244
|
+
provider_class: Type[AuthProvider],
|
|
245
|
+
*,
|
|
246
|
+
refresh_interval: Optional[int] = DEFAULT_REFRESH_INTERVAL,
|
|
247
|
+
) -> FilterableApplyAuth:
|
|
248
|
+
pass
|
|
249
|
+
|
|
230
250
|
def __call__(
|
|
231
251
|
self,
|
|
232
252
|
provider_class: Optional[Type[AuthProvider]] = None,
|
|
@@ -281,7 +301,6 @@ class AuthStorage(Generic[Auth]):
|
|
|
281
301
|
@schemathesis.auth()
|
|
282
302
|
class TokenAuth:
|
|
283
303
|
def get(self, context):
|
|
284
|
-
# This is a real endpoint, try it out!
|
|
285
304
|
response = requests.post(
|
|
286
305
|
"https://example.schemathesis.io/api/token/",
|
|
287
306
|
json={"username": "demo", "password": "test"},
|
|
@@ -359,12 +378,31 @@ class AuthStorage(Generic[Auth]):
|
|
|
359
378
|
if not self.is_defined:
|
|
360
379
|
raise UsageError("No auth provider is defined.")
|
|
361
380
|
for provider in self.providers:
|
|
362
|
-
data: Optional[Auth] = provider
|
|
381
|
+
data: Optional[Auth] = _provider_get(provider, case, context)
|
|
363
382
|
if data is not None:
|
|
364
383
|
provider.set(case, data, context)
|
|
365
384
|
break
|
|
366
385
|
|
|
367
386
|
|
|
387
|
+
def _provider_get(auth_provider: AuthProvider, case: "Case", context: AuthContext) -> Optional[Auth]:
|
|
388
|
+
# A shim to provide a compatibility layer between previously used convention for `AuthProvider.get`
|
|
389
|
+
# where it used to accept a single `context` argument
|
|
390
|
+
method = auth_provider.get
|
|
391
|
+
parameters = inspect.signature(method).parameters
|
|
392
|
+
if len(parameters) == 1:
|
|
393
|
+
# Old calling convention
|
|
394
|
+
warnings.warn(
|
|
395
|
+
"The method 'get' of your AuthProvider is using the old calling convention, "
|
|
396
|
+
"which is deprecated and will be removed in Schemathesis 4.0. "
|
|
397
|
+
"Please update it to accept both 'case' and 'context' as arguments.",
|
|
398
|
+
DeprecationWarning,
|
|
399
|
+
stacklevel=1,
|
|
400
|
+
)
|
|
401
|
+
return method(context) # type: ignore
|
|
402
|
+
# New calling convention
|
|
403
|
+
return method(case, context)
|
|
404
|
+
|
|
405
|
+
|
|
368
406
|
def set_on_case(case: "Case", context: AuthContext, auth_storage: Optional[AuthStorage]) -> None:
|
|
369
407
|
"""Set authentication data on this case.
|
|
370
408
|
|
schemathesis/cli/__init__.py
CHANGED
|
@@ -3,7 +3,9 @@ import enum
|
|
|
3
3
|
import os
|
|
4
4
|
import sys
|
|
5
5
|
import traceback
|
|
6
|
+
import warnings
|
|
6
7
|
from collections import defaultdict
|
|
8
|
+
from contextlib import suppress
|
|
7
9
|
from dataclasses import dataclass
|
|
8
10
|
from enum import Enum
|
|
9
11
|
from queue import Queue
|
|
@@ -14,12 +16,14 @@ import click
|
|
|
14
16
|
import hypothesis
|
|
15
17
|
import requests
|
|
16
18
|
import yaml
|
|
19
|
+
from urllib3.exceptions import InsecureRequestWarning
|
|
17
20
|
|
|
18
21
|
from .. import checks as checks_module
|
|
19
|
-
from .. import contrib
|
|
22
|
+
from .. import contrib, experimental
|
|
20
23
|
from .. import fixups as _fixups
|
|
21
24
|
from .. import runner, service
|
|
22
25
|
from .. import targets as targets_module
|
|
26
|
+
from ..code_samples import CodeSampleStyle
|
|
23
27
|
from ..constants import (
|
|
24
28
|
API_NAME_ENV_VAR,
|
|
25
29
|
BASE_URL_ENV_VAR,
|
|
@@ -29,10 +33,9 @@ from ..constants import (
|
|
|
29
33
|
HOOKS_MODULE_ENV_VAR,
|
|
30
34
|
HYPOTHESIS_IN_MEMORY_DATABASE_IDENTIFIER,
|
|
31
35
|
WAIT_FOR_SCHEMA_ENV_VAR,
|
|
32
|
-
CodeSampleStyle,
|
|
33
36
|
DataGenerationMethod,
|
|
34
37
|
)
|
|
35
|
-
from ..exceptions import
|
|
38
|
+
from ..exceptions import SchemaError
|
|
36
39
|
from ..fixups import ALL_FIXUPS
|
|
37
40
|
from ..hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher, HookScope
|
|
38
41
|
from ..models import Case, CheckFunction
|
|
@@ -52,6 +55,7 @@ from .debug import DebugOutputHandler
|
|
|
52
55
|
from .handlers import EventHandler
|
|
53
56
|
from .junitxml import JunitXMLHandler
|
|
54
57
|
from .options import CsvChoice, CsvEnumChoice, CustomHelpMessageChoice, NotSet, OptionalInt
|
|
58
|
+
from .sanitization import SanitizationHandler
|
|
55
59
|
|
|
56
60
|
try:
|
|
57
61
|
from yaml import CSafeLoader as SafeLoader
|
|
@@ -85,6 +89,7 @@ DEPRECATED_PRE_RUN_OPTION_WARNING = (
|
|
|
85
89
|
f"Use the `{HOOKS_MODULE_ENV_VAR}` environment variable instead"
|
|
86
90
|
)
|
|
87
91
|
CASSETTES_PATH_INVALID_USAGE_MESSAGE = "Can't use `--store-network-log` and `--cassette-path` simultaneously"
|
|
92
|
+
COLOR_OPTIONS_INVALID_USAGE_MESSAGE = "Can't use `--no-color` and `--force-color` simultaneously"
|
|
88
93
|
|
|
89
94
|
|
|
90
95
|
def reset_checks() -> None:
|
|
@@ -497,6 +502,13 @@ The report data, consisting of a tar gz file with multiple JSON files, is subjec
|
|
|
497
502
|
help="Force Schemathesis to parse the input schema with the specified spec version.",
|
|
498
503
|
type=click.Choice(["20", "30"]),
|
|
499
504
|
)
|
|
505
|
+
@click.option(
|
|
506
|
+
"--sanitize-output",
|
|
507
|
+
type=bool,
|
|
508
|
+
default=True,
|
|
509
|
+
show_default=True,
|
|
510
|
+
help="Enable or disable automatic output sanitization to obscure sensitive data.",
|
|
511
|
+
)
|
|
500
512
|
@click.option(
|
|
501
513
|
"--contrib-unique-data",
|
|
502
514
|
"contrib_unique_data",
|
|
@@ -586,6 +598,14 @@ The report data, consisting of a tar gz file with multiple JSON files, is subjec
|
|
|
586
598
|
group=ParameterGroup.hypothesis,
|
|
587
599
|
)
|
|
588
600
|
@click.option("--no-color", help="Disable ANSI color escape codes.", type=bool, is_flag=True)
|
|
601
|
+
@click.option("--force-color", help="Explicitly tells to enable ANSI color escape codes.", type=bool, is_flag=True)
|
|
602
|
+
@click.option(
|
|
603
|
+
"--experimental",
|
|
604
|
+
help="Enable experimental support for specific features.",
|
|
605
|
+
type=click.Choice([experimental.OPEN_API_3_1.name]),
|
|
606
|
+
callback=callbacks.convert_experimental,
|
|
607
|
+
multiple=True,
|
|
608
|
+
)
|
|
589
609
|
@click.option(
|
|
590
610
|
"--schemathesis-io-token",
|
|
591
611
|
help="Schemathesis.io authentication token.",
|
|
@@ -618,6 +638,7 @@ def run(
|
|
|
618
638
|
auth: Optional[Tuple[str, str]],
|
|
619
639
|
auth_type: str,
|
|
620
640
|
headers: Dict[str, str],
|
|
641
|
+
experimental: list,
|
|
621
642
|
checks: Iterable[str] = DEFAULT_CHECKS_NAMES,
|
|
622
643
|
exclude_checks: Iterable[str] = [],
|
|
623
644
|
data_generation_methods: Tuple[DataGenerationMethod, ...] = DEFAULT_DATA_GENERATION_METHODS,
|
|
@@ -652,6 +673,7 @@ def run(
|
|
|
652
673
|
stateful: Optional[Stateful] = None,
|
|
653
674
|
stateful_recursion_limit: int = DEFAULT_STATEFUL_RECURSION_LIMIT,
|
|
654
675
|
force_schema_version: Optional[str] = None,
|
|
676
|
+
sanitize_output: bool = True,
|
|
655
677
|
contrib_unique_data: bool = False,
|
|
656
678
|
contrib_openapi_formats_uuid: bool = False,
|
|
657
679
|
hypothesis_database: Optional[str] = None,
|
|
@@ -670,6 +692,7 @@ def run(
|
|
|
670
692
|
schemathesis_io_url: str = service.DEFAULT_URL,
|
|
671
693
|
schemathesis_io_telemetry: bool = True,
|
|
672
694
|
hosts_file: PathLike = service.DEFAULT_HOSTS_PATH,
|
|
695
|
+
force_color: bool = False,
|
|
673
696
|
) -> None:
|
|
674
697
|
"""Perform schemathesis test against an API specified by SCHEMA.
|
|
675
698
|
|
|
@@ -677,6 +700,10 @@ def run(
|
|
|
677
700
|
|
|
678
701
|
API_NAME is an API identifier to upload data to Schemathesis.io.
|
|
679
702
|
"""
|
|
703
|
+
# Enable selected experiments
|
|
704
|
+
for experiment in experimental:
|
|
705
|
+
experiment.enable()
|
|
706
|
+
|
|
680
707
|
report: Optional[Union[ReportToService, click.utils.LazyFile]]
|
|
681
708
|
if report_value is None:
|
|
682
709
|
report = None
|
|
@@ -685,7 +712,12 @@ def run(
|
|
|
685
712
|
else:
|
|
686
713
|
report = REPORT_TO_SERVICE
|
|
687
714
|
started_at = current_datetime()
|
|
688
|
-
|
|
715
|
+
|
|
716
|
+
if no_color and force_color:
|
|
717
|
+
error_message(COLOR_OPTIONS_INVALID_USAGE_MESSAGE)
|
|
718
|
+
sys.exit(1)
|
|
719
|
+
decide_color_output(ctx, no_color, force_color)
|
|
720
|
+
|
|
689
721
|
check_auth(auth, headers)
|
|
690
722
|
selected_targets = tuple(target for target in targets_module.ALL_TARGETS if target.__name__ in targets)
|
|
691
723
|
|
|
@@ -806,6 +838,7 @@ def run(
|
|
|
806
838
|
workers_num=workers_num,
|
|
807
839
|
rate_limit=rate_limit,
|
|
808
840
|
show_errors_tracebacks=show_errors_tracebacks,
|
|
841
|
+
wait_for_schema=wait_for_schema,
|
|
809
842
|
validate_schema=validate_schema,
|
|
810
843
|
cassette_path=cassette_path,
|
|
811
844
|
cassette_preserve_exact_body_bytes=cassette_preserve_exact_body_bytes,
|
|
@@ -814,6 +847,7 @@ def run(
|
|
|
814
847
|
code_sample_style=code_sample_style,
|
|
815
848
|
data_generation_methods=data_generation_methods,
|
|
816
849
|
debug_output_file=debug_output_file,
|
|
850
|
+
sanitize_output=sanitize_output,
|
|
817
851
|
host_data=host_data,
|
|
818
852
|
client=client,
|
|
819
853
|
report=report,
|
|
@@ -944,8 +978,10 @@ def into_event_stream(
|
|
|
944
978
|
stateful_recursion_limit=stateful_recursion_limit,
|
|
945
979
|
hypothesis_settings=hypothesis_settings,
|
|
946
980
|
).execute()
|
|
981
|
+
except SchemaError as error:
|
|
982
|
+
yield events.InternalError.from_schema_error(error)
|
|
947
983
|
except Exception as exc:
|
|
948
|
-
yield events.InternalError.from_exc(exc
|
|
984
|
+
yield events.InternalError.from_exc(exc)
|
|
949
985
|
|
|
950
986
|
|
|
951
987
|
def load_schema(config: LoaderConfig) -> BaseSchema:
|
|
@@ -961,17 +997,24 @@ def load_schema(config: LoaderConfig) -> BaseSchema:
|
|
|
961
997
|
return _try_load_schema(config, first, second)
|
|
962
998
|
|
|
963
999
|
|
|
1000
|
+
def should_try_more(exc: SchemaError) -> bool:
|
|
1001
|
+
# We should not try other loaders for cases when we can't even establish connection
|
|
1002
|
+
return not isinstance(exc.__cause__, requests.exceptions.ConnectionError)
|
|
1003
|
+
|
|
1004
|
+
|
|
964
1005
|
def _try_load_schema(
|
|
965
1006
|
config: LoaderConfig, first: Callable[[LoaderConfig], BaseSchema], second: Callable[[LoaderConfig], BaseSchema]
|
|
966
1007
|
) -> BaseSchema:
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
except (HTTPError, SchemaLoadingError) as exc:
|
|
1008
|
+
with warnings.catch_warnings():
|
|
1009
|
+
warnings.simplefilter("ignore", InsecureRequestWarning)
|
|
970
1010
|
try:
|
|
971
|
-
return
|
|
972
|
-
except
|
|
973
|
-
|
|
974
|
-
|
|
1011
|
+
return first(config)
|
|
1012
|
+
except SchemaError as exc:
|
|
1013
|
+
if should_try_more(exc):
|
|
1014
|
+
with suppress(Exception):
|
|
1015
|
+
return second(config)
|
|
1016
|
+
# Re-raise the original error
|
|
1017
|
+
raise exc
|
|
975
1018
|
|
|
976
1019
|
|
|
977
1020
|
def _load_graphql_schema(config: LoaderConfig) -> GraphQLSchema:
|
|
@@ -1095,6 +1138,7 @@ def execute(
|
|
|
1095
1138
|
workers_num: int,
|
|
1096
1139
|
rate_limit: Optional[str],
|
|
1097
1140
|
show_errors_tracebacks: bool,
|
|
1141
|
+
wait_for_schema: Optional[float],
|
|
1098
1142
|
validate_schema: bool,
|
|
1099
1143
|
cassette_path: Optional[click.utils.LazyFile],
|
|
1100
1144
|
cassette_preserve_exact_body_bytes: bool,
|
|
@@ -1103,6 +1147,7 @@ def execute(
|
|
|
1103
1147
|
code_sample_style: CodeSampleStyle,
|
|
1104
1148
|
data_generation_methods: Tuple[DataGenerationMethod, ...],
|
|
1105
1149
|
debug_output_file: Optional[click.utils.LazyFile],
|
|
1150
|
+
sanitize_output: bool,
|
|
1106
1151
|
host_data: service.hosts.HostData,
|
|
1107
1152
|
client: Optional[service.ServiceClient],
|
|
1108
1153
|
report: Optional[Union[ReportToService, click.utils.LazyFile]],
|
|
@@ -1156,11 +1201,14 @@ def execute(
|
|
|
1156
1201
|
cassettes.CassetteWriter(cassette_path, preserve_exact_body_bytes=cassette_preserve_exact_body_bytes)
|
|
1157
1202
|
)
|
|
1158
1203
|
handlers.append(get_output_handler(workers_num))
|
|
1204
|
+
if sanitize_output:
|
|
1205
|
+
handlers.insert(0, SanitizationHandler())
|
|
1159
1206
|
execution_context = ExecutionContext(
|
|
1160
1207
|
hypothesis_settings=hypothesis_settings,
|
|
1161
1208
|
workers_num=workers_num,
|
|
1162
1209
|
rate_limit=rate_limit,
|
|
1163
1210
|
show_errors_tracebacks=show_errors_tracebacks,
|
|
1211
|
+
wait_for_schema=wait_for_schema,
|
|
1164
1212
|
validate_schema=validate_schema,
|
|
1165
1213
|
cassette_path=cassette_path.name if cassette_path is not None else None,
|
|
1166
1214
|
junit_xml_file=junit_xml.name if junit_xml is not None else None,
|
|
@@ -1195,9 +1243,10 @@ def execute(
|
|
|
1195
1243
|
|
|
1196
1244
|
|
|
1197
1245
|
def handle_service_error(exc: requests.HTTPError, api_name: str) -> NoReturn:
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1246
|
+
response = cast(requests.Response, exc.response)
|
|
1247
|
+
if response.status_code == 403:
|
|
1248
|
+
error_message(response.json()["detail"])
|
|
1249
|
+
elif response.status_code == 404:
|
|
1201
1250
|
error_message(f"API with name `{api_name}` not found!")
|
|
1202
1251
|
else:
|
|
1203
1252
|
output.default.display_service_error(service.Error(exc))
|
|
@@ -1221,6 +1270,7 @@ def get_exit_code(event: events.ExecutionEvent) -> int:
|
|
|
1221
1270
|
@click.option("--uri", help="A regexp that filters interactions by their request URI.", type=str)
|
|
1222
1271
|
@click.option("--method", help="A regexp that filters interactions by their request method.", type=str)
|
|
1223
1272
|
@click.option("--no-color", help="Disable ANSI color escape codes.", type=bool, is_flag=True)
|
|
1273
|
+
@click.option("--force-color", help="Explicitly tells to enable ANSI color escape codes.", type=bool, is_flag=True)
|
|
1224
1274
|
@click.option("--verbosity", "-v", help="Increase verbosity of the output.", count=True)
|
|
1225
1275
|
@with_request_tls_verify
|
|
1226
1276
|
@with_request_cert
|
|
@@ -1238,13 +1288,18 @@ def replay(
|
|
|
1238
1288
|
request_tls_verify: bool = True,
|
|
1239
1289
|
request_cert: Optional[str] = None,
|
|
1240
1290
|
request_cert_key: Optional[str] = None,
|
|
1291
|
+
force_color: bool = False,
|
|
1241
1292
|
) -> None:
|
|
1242
1293
|
"""Replay a cassette.
|
|
1243
1294
|
|
|
1244
1295
|
Cassettes in VCR-compatible format can be replayed.
|
|
1245
1296
|
For example, ones that are recorded with ``store-network-log`` option of `st run` command.
|
|
1246
1297
|
"""
|
|
1247
|
-
|
|
1298
|
+
if no_color and force_color:
|
|
1299
|
+
error_message(COLOR_OPTIONS_INVALID_USAGE_MESSAGE)
|
|
1300
|
+
sys.exit(1)
|
|
1301
|
+
decide_color_output(ctx, no_color, force_color)
|
|
1302
|
+
|
|
1248
1303
|
click.secho(f"{bold('Replaying cassette')}: {cassette_path}")
|
|
1249
1304
|
with open(cassette_path, "rb") as fd:
|
|
1250
1305
|
cassette = yaml.load(fd, Loader=SafeLoader)
|
|
@@ -1313,7 +1368,8 @@ def login(token: str, hostname: str, hosts_file: str, protocol: str, request_tls
|
|
|
1313
1368
|
service.hosts.store(token, hostname, hosts_file)
|
|
1314
1369
|
success_message(f"Logged in into {hostname} as " + bold(username))
|
|
1315
1370
|
except requests.HTTPError as exc:
|
|
1316
|
-
|
|
1371
|
+
response = cast(requests.Response, exc.response)
|
|
1372
|
+
detail = response.json()["detail"]
|
|
1317
1373
|
error_message(f"Failed to login into {hostname}: " + bold(detail))
|
|
1318
1374
|
sys.exit(1)
|
|
1319
1375
|
|
|
@@ -1358,8 +1414,10 @@ def bold(message: str) -> str:
|
|
|
1358
1414
|
return click.style(message, bold=True)
|
|
1359
1415
|
|
|
1360
1416
|
|
|
1361
|
-
def
|
|
1362
|
-
if
|
|
1417
|
+
def decide_color_output(ctx: click.Context, no_color: bool, force_color: bool) -> None:
|
|
1418
|
+
if force_color:
|
|
1419
|
+
ctx.color = True
|
|
1420
|
+
elif no_color or "NO_COLOR" in os.environ:
|
|
1363
1421
|
ctx.color = False
|
|
1364
1422
|
|
|
1365
1423
|
|