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/__init__.py
CHANGED
|
@@ -27,7 +27,7 @@ from_uri = openapi.from_uri
|
|
|
27
27
|
from_wsgi = openapi.from_wsgi
|
|
28
28
|
|
|
29
29
|
# Public API
|
|
30
|
-
auth = auths.
|
|
30
|
+
auth = auths.GLOBAL_AUTH_STORAGE
|
|
31
31
|
check = checks.register
|
|
32
32
|
hook = hooks.register
|
|
33
33
|
serializer = serializers.register
|
|
@@ -37,5 +37,3 @@ target = targets.register
|
|
|
37
37
|
register_check = checks.register
|
|
38
38
|
register_target = targets.register
|
|
39
39
|
register_string_format = openapi.format
|
|
40
|
-
|
|
41
|
-
auth.__dict__["register"] = auths.register
|
schemathesis/auths.py
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
"""Support for custom API authentication mechanisms."""
|
|
2
2
|
import threading
|
|
3
3
|
import time
|
|
4
|
-
from
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable, Generic, List, Optional, Type, TypeVar, Union
|
|
5
6
|
|
|
6
|
-
import
|
|
7
|
+
import requests.auth
|
|
7
8
|
from typing_extensions import Protocol, runtime_checkable
|
|
8
9
|
|
|
9
10
|
from .exceptions import UsageError
|
|
11
|
+
from .filters import FilterSet, FilterValue, MatcherFunc, attach_filter_chain
|
|
10
12
|
from .types import GenericTest
|
|
11
13
|
|
|
12
14
|
if TYPE_CHECKING:
|
|
@@ -17,7 +19,7 @@ AUTH_STORAGE_ATTRIBUTE_NAME = "_schemathesis_auth"
|
|
|
17
19
|
Auth = TypeVar("Auth")
|
|
18
20
|
|
|
19
21
|
|
|
20
|
-
@
|
|
22
|
+
@dataclass
|
|
21
23
|
class AuthContext:
|
|
22
24
|
"""Holds state relevant for the authentication process.
|
|
23
25
|
|
|
@@ -25,15 +27,15 @@ class AuthContext:
|
|
|
25
27
|
:ivar app: Optional Python application if the WSGI / ASGI integration is used.
|
|
26
28
|
"""
|
|
27
29
|
|
|
28
|
-
operation: "APIOperation"
|
|
29
|
-
app: Optional[Any]
|
|
30
|
+
operation: "APIOperation"
|
|
31
|
+
app: Optional[Any]
|
|
30
32
|
|
|
31
33
|
|
|
32
34
|
@runtime_checkable
|
|
33
35
|
class AuthProvider(Protocol):
|
|
34
36
|
"""Get authentication data for an API and set it on the generated test cases."""
|
|
35
37
|
|
|
36
|
-
def get(self, context: AuthContext) -> Auth:
|
|
38
|
+
def get(self, context: AuthContext) -> Optional[Auth]:
|
|
37
39
|
"""Get the authentication data.
|
|
38
40
|
|
|
39
41
|
:param AuthContext context: Holds state relevant for the authentication process.
|
|
@@ -49,33 +51,47 @@ class AuthProvider(Protocol):
|
|
|
49
51
|
"""
|
|
50
52
|
|
|
51
53
|
|
|
52
|
-
@
|
|
54
|
+
@dataclass
|
|
53
55
|
class CacheEntry(Generic[Auth]):
|
|
54
56
|
"""Cached auth data."""
|
|
55
57
|
|
|
56
|
-
data: Auth
|
|
57
|
-
expires: float
|
|
58
|
+
data: Auth
|
|
59
|
+
expires: float
|
|
58
60
|
|
|
59
61
|
|
|
60
|
-
@
|
|
62
|
+
@dataclass
|
|
63
|
+
class RequestsAuth(Generic[Auth]):
|
|
64
|
+
"""Provider that sets auth data via `requests` auth instance."""
|
|
65
|
+
|
|
66
|
+
auth: requests.auth.AuthBase
|
|
67
|
+
|
|
68
|
+
def get(self, _: AuthContext) -> Optional[Auth]:
|
|
69
|
+
return self.auth # type: ignore[return-value]
|
|
70
|
+
|
|
71
|
+
def set(self, case: "Case", _: Auth, __: AuthContext) -> None:
|
|
72
|
+
case._auth = self.auth
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
61
76
|
class CachingAuthProvider(Generic[Auth]):
|
|
62
77
|
"""Caches the underlying auth provider."""
|
|
63
78
|
|
|
64
|
-
provider: AuthProvider
|
|
65
|
-
refresh_interval: int =
|
|
66
|
-
cache_entry: Optional[CacheEntry[Auth]] =
|
|
79
|
+
provider: AuthProvider
|
|
80
|
+
refresh_interval: int = DEFAULT_REFRESH_INTERVAL
|
|
81
|
+
cache_entry: Optional[CacheEntry[Auth]] = None
|
|
67
82
|
# The timer exists here to simplify testing
|
|
68
|
-
timer: Callable[[], float] =
|
|
69
|
-
_refresh_lock: threading.Lock =
|
|
83
|
+
timer: Callable[[], float] = time.monotonic
|
|
84
|
+
_refresh_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
70
85
|
|
|
71
|
-
def get(self, context: AuthContext) -> Auth:
|
|
86
|
+
def get(self, context: AuthContext) -> Optional[Auth]:
|
|
72
87
|
"""Get cached auth value."""
|
|
73
88
|
if self.cache_entry is None or self.timer() >= self.cache_entry.expires:
|
|
74
89
|
with self._refresh_lock:
|
|
75
90
|
if not (self.cache_entry is None or self.timer() >= self.cache_entry.expires):
|
|
76
91
|
# Another thread updated the cache
|
|
77
92
|
return self.cache_entry.data
|
|
78
|
-
|
|
93
|
+
# We know that optional auth is possible only inside a higher-level wrapper
|
|
94
|
+
data: Auth = self.provider.get(context) # type: ignore[assignment]
|
|
79
95
|
self.cache_entry = CacheEntry(data=data, expires=self.timer() + self.refresh_interval)
|
|
80
96
|
return data
|
|
81
97
|
return self.cache_entry.data
|
|
@@ -88,20 +104,176 @@ class CachingAuthProvider(Generic[Auth]):
|
|
|
88
104
|
self.provider.set(case, data, context)
|
|
89
105
|
|
|
90
106
|
|
|
91
|
-
|
|
107
|
+
class FilterableRegisterAuth(Protocol):
|
|
108
|
+
"""Protocol that adds filters to the return value of `register`."""
|
|
109
|
+
|
|
110
|
+
def __call__(self, provider_class: Type[AuthProvider]) -> Type[AuthProvider]:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
def apply_to(
|
|
114
|
+
self,
|
|
115
|
+
func: Optional[MatcherFunc] = None,
|
|
116
|
+
*,
|
|
117
|
+
name: Optional[FilterValue] = None,
|
|
118
|
+
name_regex: Optional[str] = None,
|
|
119
|
+
method: Optional[FilterValue] = None,
|
|
120
|
+
method_regex: Optional[str] = None,
|
|
121
|
+
path: Optional[FilterValue] = None,
|
|
122
|
+
path_regex: Optional[str] = None,
|
|
123
|
+
) -> "FilterableRegisterAuth":
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
def skip_for(
|
|
127
|
+
self,
|
|
128
|
+
func: Optional[MatcherFunc] = None,
|
|
129
|
+
*,
|
|
130
|
+
name: Optional[FilterValue] = None,
|
|
131
|
+
name_regex: Optional[str] = None,
|
|
132
|
+
method: Optional[FilterValue] = None,
|
|
133
|
+
method_regex: Optional[str] = None,
|
|
134
|
+
path: Optional[FilterValue] = None,
|
|
135
|
+
path_regex: Optional[str] = None,
|
|
136
|
+
) -> "FilterableRegisterAuth":
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class FilterableApplyAuth(Protocol):
|
|
141
|
+
"""Protocol that adds filters to the return value of `apply`."""
|
|
142
|
+
|
|
143
|
+
def __call__(self, test: GenericTest) -> GenericTest:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
def apply_to(
|
|
147
|
+
self,
|
|
148
|
+
func: Optional[MatcherFunc] = None,
|
|
149
|
+
*,
|
|
150
|
+
name: Optional[FilterValue] = None,
|
|
151
|
+
name_regex: Optional[str] = None,
|
|
152
|
+
method: Optional[FilterValue] = None,
|
|
153
|
+
method_regex: Optional[str] = None,
|
|
154
|
+
path: Optional[FilterValue] = None,
|
|
155
|
+
path_regex: Optional[str] = None,
|
|
156
|
+
) -> "FilterableApplyAuth":
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
def skip_for(
|
|
160
|
+
self,
|
|
161
|
+
func: Optional[MatcherFunc] = None,
|
|
162
|
+
*,
|
|
163
|
+
name: Optional[FilterValue] = None,
|
|
164
|
+
name_regex: Optional[str] = None,
|
|
165
|
+
method: Optional[FilterValue] = None,
|
|
166
|
+
method_regex: Optional[str] = None,
|
|
167
|
+
path: Optional[FilterValue] = None,
|
|
168
|
+
path_regex: Optional[str] = None,
|
|
169
|
+
) -> "FilterableApplyAuth":
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class FilterableRequestsAuth(Protocol):
|
|
174
|
+
"""Protocol that adds filters to the return value of `set_from_requests`."""
|
|
175
|
+
|
|
176
|
+
def apply_to(
|
|
177
|
+
self,
|
|
178
|
+
func: Optional[MatcherFunc] = None,
|
|
179
|
+
*,
|
|
180
|
+
name: Optional[FilterValue] = None,
|
|
181
|
+
name_regex: Optional[str] = None,
|
|
182
|
+
method: Optional[FilterValue] = None,
|
|
183
|
+
method_regex: Optional[str] = None,
|
|
184
|
+
path: Optional[FilterValue] = None,
|
|
185
|
+
path_regex: Optional[str] = None,
|
|
186
|
+
) -> "FilterableRequestsAuth":
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
def skip_for(
|
|
190
|
+
self,
|
|
191
|
+
func: Optional[MatcherFunc] = None,
|
|
192
|
+
*,
|
|
193
|
+
name: Optional[FilterValue] = None,
|
|
194
|
+
name_regex: Optional[str] = None,
|
|
195
|
+
method: Optional[FilterValue] = None,
|
|
196
|
+
method_regex: Optional[str] = None,
|
|
197
|
+
path: Optional[FilterValue] = None,
|
|
198
|
+
path_regex: Optional[str] = None,
|
|
199
|
+
) -> "FilterableRequestsAuth":
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass
|
|
204
|
+
class SelectiveAuthProvider(Generic[Auth]):
|
|
205
|
+
"""Applies auth depending on the configured filters."""
|
|
206
|
+
|
|
207
|
+
provider: AuthProvider
|
|
208
|
+
filter_set: FilterSet
|
|
209
|
+
|
|
210
|
+
def get(self, context: AuthContext) -> Optional[Auth]:
|
|
211
|
+
if self.filter_set.match(context):
|
|
212
|
+
return self.provider.get(context)
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
def set(self, case: "Case", data: Auth, context: AuthContext) -> None:
|
|
216
|
+
self.provider.set(case, data, context)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass
|
|
92
220
|
class AuthStorage(Generic[Auth]):
|
|
93
221
|
"""Store and manage API authentication."""
|
|
94
222
|
|
|
95
|
-
|
|
223
|
+
providers: List[AuthProvider] = field(default_factory=list)
|
|
96
224
|
|
|
97
225
|
@property
|
|
98
226
|
def is_defined(self) -> bool:
|
|
99
227
|
"""Whether there is an auth provider set."""
|
|
100
|
-
return self.
|
|
228
|
+
return bool(self.providers)
|
|
229
|
+
|
|
230
|
+
def __call__(
|
|
231
|
+
self,
|
|
232
|
+
provider_class: Optional[Type[AuthProvider]] = None,
|
|
233
|
+
*,
|
|
234
|
+
refresh_interval: Optional[int] = DEFAULT_REFRESH_INTERVAL,
|
|
235
|
+
) -> Union[FilterableRegisterAuth, FilterableApplyAuth]:
|
|
236
|
+
if provider_class is not None:
|
|
237
|
+
return self.apply(provider_class, refresh_interval=refresh_interval)
|
|
238
|
+
return self.register(refresh_interval=refresh_interval)
|
|
239
|
+
|
|
240
|
+
def set_from_requests(self, auth: requests.auth.AuthBase) -> FilterableRequestsAuth:
|
|
241
|
+
"""Use `requests` auth instance as an auth provider."""
|
|
242
|
+
filter_set = FilterSet()
|
|
243
|
+
self.providers.append(SelectiveAuthProvider(provider=RequestsAuth(auth), filter_set=filter_set))
|
|
244
|
+
|
|
245
|
+
class _FilterableRequestsAuth:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
attach_filter_chain(_FilterableRequestsAuth, "apply_to", filter_set.include)
|
|
249
|
+
attach_filter_chain(_FilterableRequestsAuth, "skip_for", filter_set.exclude)
|
|
250
|
+
|
|
251
|
+
return _FilterableRequestsAuth # type: ignore[return-value]
|
|
252
|
+
|
|
253
|
+
def _set_provider(
|
|
254
|
+
self,
|
|
255
|
+
*,
|
|
256
|
+
provider_class: Type[AuthProvider],
|
|
257
|
+
refresh_interval: Optional[int] = DEFAULT_REFRESH_INTERVAL,
|
|
258
|
+
filter_set: FilterSet,
|
|
259
|
+
) -> None:
|
|
260
|
+
if not issubclass(provider_class, AuthProvider):
|
|
261
|
+
raise TypeError(
|
|
262
|
+
f"`{provider_class.__name__}` is not a valid auth provider. "
|
|
263
|
+
f"Check `schemathesis.auths.AuthProvider` documentation for examples."
|
|
264
|
+
)
|
|
265
|
+
provider: AuthProvider
|
|
266
|
+
# Apply caching if desired
|
|
267
|
+
if refresh_interval is not None:
|
|
268
|
+
provider = CachingAuthProvider(provider_class(), refresh_interval=refresh_interval)
|
|
269
|
+
else:
|
|
270
|
+
provider = provider_class()
|
|
271
|
+
# Store filters if any
|
|
272
|
+
if not filter_set.is_empty():
|
|
273
|
+
provider = SelectiveAuthProvider(provider, filter_set)
|
|
274
|
+
self.providers.append(provider)
|
|
101
275
|
|
|
102
|
-
def register(
|
|
103
|
-
self, refresh_interval: Optional[int] = DEFAULT_REFRESH_INTERVAL
|
|
104
|
-
) -> Callable[[Type[AuthProvider]], Type[AuthProvider]]:
|
|
276
|
+
def register(self, *, refresh_interval: Optional[int] = DEFAULT_REFRESH_INTERVAL) -> FilterableRegisterAuth:
|
|
105
277
|
"""Register a new auth provider.
|
|
106
278
|
|
|
107
279
|
.. code-block:: python
|
|
@@ -121,32 +293,27 @@ class AuthStorage(Generic[Auth]):
|
|
|
121
293
|
# Modify `case` the way you need
|
|
122
294
|
case.headers = {"Authorization": f"Bearer {data}"}
|
|
123
295
|
"""
|
|
296
|
+
filter_set = FilterSet()
|
|
124
297
|
|
|
125
298
|
def wrapper(provider_class: Type[AuthProvider]) -> Type[AuthProvider]:
|
|
126
|
-
|
|
127
|
-
raise TypeError(
|
|
128
|
-
f"`{provider_class.__name__}` is not a valid auth provider. "
|
|
129
|
-
f"Check `schemathesis.auths.AuthProvider` documentation for examples."
|
|
130
|
-
)
|
|
131
|
-
# Apply caching if desired
|
|
132
|
-
if refresh_interval is not None:
|
|
133
|
-
self.provider = CachingAuthProvider(provider_class(), refresh_interval=refresh_interval)
|
|
134
|
-
else:
|
|
135
|
-
self.provider = provider_class()
|
|
299
|
+
self._set_provider(provider_class=provider_class, refresh_interval=refresh_interval, filter_set=filter_set)
|
|
136
300
|
return provider_class
|
|
137
301
|
|
|
138
|
-
|
|
302
|
+
attach_filter_chain(wrapper, "apply_to", filter_set.include)
|
|
303
|
+
attach_filter_chain(wrapper, "skip_for", filter_set.exclude)
|
|
304
|
+
|
|
305
|
+
return wrapper # type: ignore[return-value]
|
|
139
306
|
|
|
140
307
|
def unregister(self) -> None:
|
|
141
308
|
"""Unregister the currently registered auth provider.
|
|
142
309
|
|
|
143
310
|
No-op if there is no auth provider registered.
|
|
144
311
|
"""
|
|
145
|
-
self.
|
|
312
|
+
self.providers = []
|
|
146
313
|
|
|
147
314
|
def apply(
|
|
148
315
|
self, provider_class: Type[AuthProvider], *, refresh_interval: Optional[int] = DEFAULT_REFRESH_INTERVAL
|
|
149
|
-
) ->
|
|
316
|
+
) -> FilterableApplyAuth:
|
|
150
317
|
"""Register auth provider only on one test function.
|
|
151
318
|
|
|
152
319
|
:param Type[AuthProvider] provider_class: Authentication provider class.
|
|
@@ -158,19 +325,25 @@ class AuthStorage(Generic[Auth]):
|
|
|
158
325
|
...
|
|
159
326
|
|
|
160
327
|
|
|
161
|
-
@schema.auth
|
|
328
|
+
@schema.auth(Auth)
|
|
162
329
|
@schema.parametrize()
|
|
163
330
|
def test_api(case):
|
|
164
331
|
...
|
|
165
332
|
|
|
166
333
|
"""
|
|
334
|
+
filter_set = FilterSet()
|
|
167
335
|
|
|
168
336
|
def wrapper(test: GenericTest) -> GenericTest:
|
|
169
337
|
auth_storage = self.add_auth_storage(test)
|
|
170
|
-
auth_storage.
|
|
338
|
+
auth_storage._set_provider(
|
|
339
|
+
provider_class=provider_class, refresh_interval=refresh_interval, filter_set=filter_set
|
|
340
|
+
)
|
|
171
341
|
return test
|
|
172
342
|
|
|
173
|
-
|
|
343
|
+
attach_filter_chain(wrapper, "apply_to", filter_set.include)
|
|
344
|
+
attach_filter_chain(wrapper, "skip_for", filter_set.exclude)
|
|
345
|
+
|
|
346
|
+
return wrapper # type: ignore[return-value]
|
|
174
347
|
|
|
175
348
|
@classmethod
|
|
176
349
|
def add_auth_storage(cls, test: GenericTest) -> "AuthStorage":
|
|
@@ -183,11 +356,13 @@ class AuthStorage(Generic[Auth]):
|
|
|
183
356
|
|
|
184
357
|
def set(self, case: "Case", context: AuthContext) -> None:
|
|
185
358
|
"""Set authentication data on a generated test case."""
|
|
186
|
-
if self.
|
|
187
|
-
data: Auth = self.provider.get(context)
|
|
188
|
-
self.provider.set(case, data, context)
|
|
189
|
-
else:
|
|
359
|
+
if not self.is_defined:
|
|
190
360
|
raise UsageError("No auth provider is defined.")
|
|
361
|
+
for provider in self.providers:
|
|
362
|
+
data: Optional[Auth] = provider.get(context)
|
|
363
|
+
if data is not None:
|
|
364
|
+
provider.set(case, data, context)
|
|
365
|
+
break
|
|
191
366
|
|
|
192
367
|
|
|
193
368
|
def set_on_case(case: "Case", context: AuthContext, auth_storage: Optional[AuthStorage]) -> None:
|
schemathesis/cli/__init__.py
CHANGED
|
@@ -4,12 +4,12 @@ import os
|
|
|
4
4
|
import sys
|
|
5
5
|
import traceback
|
|
6
6
|
from collections import defaultdict
|
|
7
|
+
from dataclasses import dataclass
|
|
7
8
|
from enum import Enum
|
|
8
9
|
from queue import Queue
|
|
9
10
|
from typing import Any, Callable, Dict, Generator, Iterable, List, NoReturn, Optional, Tuple, Union, cast
|
|
10
11
|
from urllib.parse import urlparse
|
|
11
12
|
|
|
12
|
-
import attr
|
|
13
13
|
import click
|
|
14
14
|
import hypothesis
|
|
15
15
|
import requests
|
|
@@ -448,6 +448,13 @@ REPORT_TO_SERVICE = object()
|
|
|
448
448
|
multiple=True,
|
|
449
449
|
type=click.Choice(list(ALL_FIXUPS) + ["all"]),
|
|
450
450
|
)
|
|
451
|
+
@click.option(
|
|
452
|
+
"--rate-limit",
|
|
453
|
+
help="The maximum rate of requests to send to the tested API in the format of `<limit>/<duration>`. "
|
|
454
|
+
"Example - `100/m` for 100 requests per minute.",
|
|
455
|
+
type=str,
|
|
456
|
+
callback=callbacks.validate_rate_limit,
|
|
457
|
+
)
|
|
451
458
|
@click.option(
|
|
452
459
|
"--stateful",
|
|
453
460
|
help="Utilize stateful testing capabilities.",
|
|
@@ -480,7 +487,7 @@ REPORT_TO_SERVICE = object()
|
|
|
480
487
|
@click.option(
|
|
481
488
|
"--contrib-openapi-formats-uuid",
|
|
482
489
|
"contrib_openapi_formats_uuid",
|
|
483
|
-
help="
|
|
490
|
+
help="Enable support for the `uuid` string format.",
|
|
484
491
|
is_flag=True,
|
|
485
492
|
default=False,
|
|
486
493
|
show_default=True,
|
|
@@ -619,6 +626,7 @@ def run(
|
|
|
619
626
|
store_network_log: Optional[click.utils.LazyFile] = None,
|
|
620
627
|
wait_for_schema: Optional[float] = None,
|
|
621
628
|
fixups: Tuple[str] = (), # type: ignore
|
|
629
|
+
rate_limit: Optional[str] = None,
|
|
622
630
|
stateful: Optional[Stateful] = None,
|
|
623
631
|
stateful_recursion_limit: int = DEFAULT_STATEFUL_RECURSION_LIMIT,
|
|
624
632
|
force_schema_version: Optional[str] = None,
|
|
@@ -755,6 +763,7 @@ def run(
|
|
|
755
763
|
max_response_time=max_response_time,
|
|
756
764
|
targets=selected_targets,
|
|
757
765
|
workers_num=workers_num,
|
|
766
|
+
rate_limit=rate_limit,
|
|
758
767
|
stateful=stateful,
|
|
759
768
|
stateful_recursion_limit=stateful_recursion_limit,
|
|
760
769
|
hypothesis_settings=hypothesis_settings,
|
|
@@ -763,6 +772,7 @@ def run(
|
|
|
763
772
|
event_stream,
|
|
764
773
|
hypothesis_settings=hypothesis_settings,
|
|
765
774
|
workers_num=workers_num,
|
|
775
|
+
rate_limit=rate_limit,
|
|
766
776
|
show_errors_tracebacks=show_errors_tracebacks,
|
|
767
777
|
validate_schema=validate_schema,
|
|
768
778
|
cassette_path=cassette_path,
|
|
@@ -789,32 +799,33 @@ def prepare_request_cert(cert: Optional[str], key: Optional[str]) -> Optional[Re
|
|
|
789
799
|
return cert
|
|
790
800
|
|
|
791
801
|
|
|
792
|
-
@
|
|
802
|
+
@dataclass
|
|
793
803
|
class LoaderConfig:
|
|
794
804
|
"""Container for API loader parameters.
|
|
795
805
|
|
|
796
806
|
The main goal is to avoid too many parameters in function signatures.
|
|
797
807
|
"""
|
|
798
808
|
|
|
799
|
-
schema_location: str
|
|
800
|
-
app: Any
|
|
801
|
-
base_url: Optional[str]
|
|
802
|
-
validate_schema: bool
|
|
803
|
-
skip_deprecated_operations: bool
|
|
804
|
-
data_generation_methods: Tuple[DataGenerationMethod, ...]
|
|
805
|
-
force_schema_version: Optional[str]
|
|
806
|
-
request_tls_verify: Union[bool, str]
|
|
807
|
-
request_cert: Optional[RequestCert]
|
|
808
|
-
wait_for_schema: Optional[float]
|
|
809
|
+
schema_location: str
|
|
810
|
+
app: Any
|
|
811
|
+
base_url: Optional[str]
|
|
812
|
+
validate_schema: bool
|
|
813
|
+
skip_deprecated_operations: bool
|
|
814
|
+
data_generation_methods: Tuple[DataGenerationMethod, ...]
|
|
815
|
+
force_schema_version: Optional[str]
|
|
816
|
+
request_tls_verify: Union[bool, str]
|
|
817
|
+
request_cert: Optional[RequestCert]
|
|
818
|
+
wait_for_schema: Optional[float]
|
|
819
|
+
rate_limit: Optional[str]
|
|
809
820
|
# Network request parameters
|
|
810
|
-
auth: Optional[Tuple[str, str]]
|
|
811
|
-
auth_type: Optional[str]
|
|
812
|
-
headers: Optional[Dict[str, str]]
|
|
821
|
+
auth: Optional[Tuple[str, str]]
|
|
822
|
+
auth_type: Optional[str]
|
|
823
|
+
headers: Optional[Dict[str, str]]
|
|
813
824
|
# Schema filters
|
|
814
|
-
endpoint: Optional[Filter]
|
|
815
|
-
method: Optional[Filter]
|
|
816
|
-
tag: Optional[Filter]
|
|
817
|
-
operation_id: Optional[Filter]
|
|
825
|
+
endpoint: Optional[Filter]
|
|
826
|
+
method: Optional[Filter]
|
|
827
|
+
tag: Optional[Filter]
|
|
828
|
+
operation_id: Optional[Filter]
|
|
818
829
|
|
|
819
830
|
|
|
820
831
|
def into_event_stream(
|
|
@@ -849,6 +860,7 @@ def into_event_stream(
|
|
|
849
860
|
seed: Optional[int],
|
|
850
861
|
exit_first: bool,
|
|
851
862
|
max_failures: Optional[int],
|
|
863
|
+
rate_limit: Optional[str],
|
|
852
864
|
dry_run: bool,
|
|
853
865
|
store_interactions: bool,
|
|
854
866
|
stateful: Optional[Stateful],
|
|
@@ -868,6 +880,7 @@ def into_event_stream(
|
|
|
868
880
|
request_tls_verify=request_tls_verify,
|
|
869
881
|
request_cert=request_cert,
|
|
870
882
|
wait_for_schema=wait_for_schema,
|
|
883
|
+
rate_limit=rate_limit,
|
|
871
884
|
auth=auth,
|
|
872
885
|
auth_type=auth_type,
|
|
873
886
|
headers=headers,
|
|
@@ -968,6 +981,7 @@ def get_loader_kwargs(loader: Callable, config: LoaderConfig) -> Dict[str, Any]:
|
|
|
968
981
|
"validate_schema": config.validate_schema,
|
|
969
982
|
"force_schema_version": config.force_schema_version,
|
|
970
983
|
"data_generation_methods": config.data_generation_methods,
|
|
984
|
+
"rate_limit": config.rate_limit,
|
|
971
985
|
}
|
|
972
986
|
if loader is not oas_loaders.from_path:
|
|
973
987
|
kwargs["headers"] = config.headers
|
|
@@ -986,6 +1000,7 @@ def get_graphql_loader_kwargs(
|
|
|
986
1000
|
"app": config.app,
|
|
987
1001
|
"base_url": config.base_url,
|
|
988
1002
|
"data_generation_methods": config.data_generation_methods,
|
|
1003
|
+
"rate_limit": config.rate_limit,
|
|
989
1004
|
}
|
|
990
1005
|
if loader is not gql_loaders.from_path:
|
|
991
1006
|
kwargs["headers"] = config.headers
|
|
@@ -1046,6 +1061,7 @@ def execute(
|
|
|
1046
1061
|
*,
|
|
1047
1062
|
hypothesis_settings: hypothesis.settings,
|
|
1048
1063
|
workers_num: int,
|
|
1064
|
+
rate_limit: Optional[str],
|
|
1049
1065
|
show_errors_tracebacks: bool,
|
|
1050
1066
|
validate_schema: bool,
|
|
1051
1067
|
cassette_path: Optional[click.utils.LazyFile],
|
|
@@ -1111,6 +1127,7 @@ def execute(
|
|
|
1111
1127
|
execution_context = ExecutionContext(
|
|
1112
1128
|
hypothesis_settings=hypothesis_settings,
|
|
1113
1129
|
workers_num=workers_num,
|
|
1130
|
+
rate_limit=rate_limit,
|
|
1114
1131
|
show_errors_tracebacks=show_errors_tracebacks,
|
|
1115
1132
|
validate_schema=validate_schema,
|
|
1116
1133
|
cassette_path=cassette_path.name if cassette_path is not None else None,
|
schemathesis/cli/callbacks.py
CHANGED
|
@@ -10,7 +10,7 @@ import hypothesis
|
|
|
10
10
|
from click.types import LazyFile # type: ignore
|
|
11
11
|
from requests import PreparedRequest, RequestException
|
|
12
12
|
|
|
13
|
-
from .. import utils
|
|
13
|
+
from .. import exceptions, throttling, utils
|
|
14
14
|
from ..constants import CodeSampleStyle, DataGenerationMethod
|
|
15
15
|
from ..service.hosts import get_temporary_hosts_file
|
|
16
16
|
from ..stateful import Stateful
|
|
@@ -92,6 +92,18 @@ def validate_base_url(ctx: click.core.Context, param: click.core.Parameter, raw_
|
|
|
92
92
|
return raw_value
|
|
93
93
|
|
|
94
94
|
|
|
95
|
+
def validate_rate_limit(
|
|
96
|
+
ctx: click.core.Context, param: click.core.Parameter, raw_value: Optional[str]
|
|
97
|
+
) -> Optional[str]:
|
|
98
|
+
if raw_value is None:
|
|
99
|
+
return raw_value
|
|
100
|
+
try:
|
|
101
|
+
throttling.parse_units(raw_value)
|
|
102
|
+
return raw_value
|
|
103
|
+
except exceptions.UsageError as exc:
|
|
104
|
+
raise click.UsageError(exc.args[0]) from exc
|
|
105
|
+
|
|
106
|
+
|
|
95
107
|
APPLICATION_FORMAT_MESSAGE = (
|
|
96
108
|
"Can not import application from the given module!\n"
|
|
97
109
|
"The `--app` option value should be in format:\n\n path:variable\n\n"
|
schemathesis/cli/cassettes.py
CHANGED
|
@@ -3,10 +3,10 @@ import json
|
|
|
3
3
|
import re
|
|
4
4
|
import sys
|
|
5
5
|
import threading
|
|
6
|
+
from dataclasses import dataclass, field
|
|
6
7
|
from queue import Queue
|
|
7
8
|
from typing import IO, Any, Dict, Generator, Iterator, List, Optional, cast
|
|
8
9
|
|
|
9
|
-
import attr
|
|
10
10
|
import click
|
|
11
11
|
import requests
|
|
12
12
|
from requests.cookies import RequestsCookieJar
|
|
@@ -25,7 +25,7 @@ from .handlers import EventHandler
|
|
|
25
25
|
WRITER_WORKER_JOIN_TIMEOUT = 1
|
|
26
26
|
|
|
27
27
|
|
|
28
|
-
@
|
|
28
|
+
@dataclass
|
|
29
29
|
class CassetteWriter(EventHandler):
|
|
30
30
|
"""Write interactions in a YAML cassette.
|
|
31
31
|
|
|
@@ -33,12 +33,12 @@ class CassetteWriter(EventHandler):
|
|
|
33
33
|
the end of the test run.
|
|
34
34
|
"""
|
|
35
35
|
|
|
36
|
-
file_handle: click.utils.LazyFile
|
|
37
|
-
preserve_exact_body_bytes: bool
|
|
38
|
-
queue: Queue =
|
|
39
|
-
worker: threading.Thread =
|
|
36
|
+
file_handle: click.utils.LazyFile
|
|
37
|
+
preserve_exact_body_bytes: bool
|
|
38
|
+
queue: Queue = field(default_factory=Queue)
|
|
39
|
+
worker: threading.Thread = field(init=False)
|
|
40
40
|
|
|
41
|
-
def
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
42
|
self.worker = threading.Thread(
|
|
43
43
|
target=worker,
|
|
44
44
|
kwargs={
|
|
@@ -80,23 +80,23 @@ class CassetteWriter(EventHandler):
|
|
|
80
80
|
self.worker.join(WRITER_WORKER_JOIN_TIMEOUT)
|
|
81
81
|
|
|
82
82
|
|
|
83
|
-
@
|
|
83
|
+
@dataclass
|
|
84
84
|
class Initialize:
|
|
85
85
|
"""Start up, the first message to make preparations before proceeding the input data."""
|
|
86
86
|
|
|
87
87
|
|
|
88
|
-
@
|
|
88
|
+
@dataclass
|
|
89
89
|
class Process:
|
|
90
90
|
"""A new chunk of data should be processed."""
|
|
91
91
|
|
|
92
|
-
seed: int
|
|
93
|
-
correlation_id: str
|
|
94
|
-
thread_id: int
|
|
95
|
-
data_generation_method: constants.DataGenerationMethod
|
|
96
|
-
interactions: List[SerializedInteraction]
|
|
92
|
+
seed: int
|
|
93
|
+
correlation_id: str
|
|
94
|
+
thread_id: int
|
|
95
|
+
data_generation_method: constants.DataGenerationMethod
|
|
96
|
+
interactions: List[SerializedInteraction]
|
|
97
97
|
|
|
98
98
|
|
|
99
|
-
@
|
|
99
|
+
@dataclass
|
|
100
100
|
class Finalize:
|
|
101
101
|
"""The work is done and there will be no more messages to process."""
|
|
102
102
|
|
|
@@ -274,10 +274,10 @@ def write_double_quoted(stream: IO, text: str) -> None:
|
|
|
274
274
|
stream.write('"')
|
|
275
275
|
|
|
276
276
|
|
|
277
|
-
@
|
|
277
|
+
@dataclass
|
|
278
278
|
class Replayed:
|
|
279
|
-
interaction: Dict[str, Any]
|
|
280
|
-
response: requests.Response
|
|
279
|
+
interaction: Dict[str, Any]
|
|
280
|
+
response: requests.Response
|
|
281
281
|
|
|
282
282
|
|
|
283
283
|
def replay(
|