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.
Files changed (60) hide show
  1. schemathesis/__init__.py +1 -3
  2. schemathesis/auths.py +218 -43
  3. schemathesis/cli/__init__.py +37 -20
  4. schemathesis/cli/callbacks.py +13 -1
  5. schemathesis/cli/cassettes.py +18 -18
  6. schemathesis/cli/context.py +25 -24
  7. schemathesis/cli/debug.py +3 -3
  8. schemathesis/cli/junitxml.py +4 -4
  9. schemathesis/cli/options.py +1 -1
  10. schemathesis/cli/output/default.py +2 -0
  11. schemathesis/constants.py +3 -3
  12. schemathesis/exceptions.py +9 -9
  13. schemathesis/extra/pytest_plugin.py +1 -1
  14. schemathesis/failures.py +65 -66
  15. schemathesis/filters.py +269 -0
  16. schemathesis/hooks.py +11 -11
  17. schemathesis/lazy.py +21 -16
  18. schemathesis/models.py +149 -107
  19. schemathesis/parameters.py +12 -7
  20. schemathesis/runner/events.py +55 -55
  21. schemathesis/runner/impl/core.py +26 -26
  22. schemathesis/runner/impl/solo.py +6 -7
  23. schemathesis/runner/impl/threadpool.py +5 -5
  24. schemathesis/runner/serialization.py +50 -50
  25. schemathesis/schemas.py +38 -23
  26. schemathesis/serializers.py +3 -3
  27. schemathesis/service/ci.py +25 -25
  28. schemathesis/service/client.py +2 -2
  29. schemathesis/service/events.py +12 -13
  30. schemathesis/service/hosts.py +4 -4
  31. schemathesis/service/metadata.py +14 -15
  32. schemathesis/service/models.py +12 -13
  33. schemathesis/service/report.py +30 -31
  34. schemathesis/service/serialization.py +2 -4
  35. schemathesis/specs/graphql/loaders.py +21 -2
  36. schemathesis/specs/graphql/schemas.py +8 -8
  37. schemathesis/specs/openapi/expressions/context.py +4 -4
  38. schemathesis/specs/openapi/expressions/lexer.py +11 -12
  39. schemathesis/specs/openapi/expressions/nodes.py +16 -16
  40. schemathesis/specs/openapi/expressions/parser.py +1 -1
  41. schemathesis/specs/openapi/links.py +15 -17
  42. schemathesis/specs/openapi/loaders.py +29 -2
  43. schemathesis/specs/openapi/negative/__init__.py +5 -5
  44. schemathesis/specs/openapi/negative/mutations.py +6 -6
  45. schemathesis/specs/openapi/parameters.py +12 -13
  46. schemathesis/specs/openapi/references.py +2 -2
  47. schemathesis/specs/openapi/schemas.py +11 -15
  48. schemathesis/specs/openapi/security.py +12 -7
  49. schemathesis/specs/openapi/stateful/links.py +4 -4
  50. schemathesis/stateful.py +19 -19
  51. schemathesis/targets.py +5 -6
  52. schemathesis/throttling.py +34 -0
  53. schemathesis/types.py +11 -13
  54. schemathesis/utils.py +2 -2
  55. {schemathesis-3.18.5.dist-info → schemathesis-3.19.1.dist-info}/METADATA +4 -3
  56. schemathesis-3.19.1.dist-info/RECORD +107 -0
  57. schemathesis-3.18.5.dist-info/RECORD +0 -105
  58. {schemathesis-3.18.5.dist-info → schemathesis-3.19.1.dist-info}/WHEEL +0 -0
  59. {schemathesis-3.18.5.dist-info → schemathesis-3.19.1.dist-info}/entry_points.txt +0 -0
  60. {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.register
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 typing import TYPE_CHECKING, Any, Callable, Generic, Optional, Type, TypeVar
4
+ from dataclasses import dataclass, field
5
+ from typing import TYPE_CHECKING, Any, Callable, Generic, List, Optional, Type, TypeVar, Union
5
6
 
6
- import attr
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
- @attr.s(slots=True) # pragma: no mutate
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" = attr.ib() # pragma: no mutate
29
- app: Optional[Any] = attr.ib() # pragma: no mutate
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: # type: ignore
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
- @attr.s(slots=True)
54
+ @dataclass
53
55
  class CacheEntry(Generic[Auth]):
54
56
  """Cached auth data."""
55
57
 
56
- data: Auth = attr.ib()
57
- expires: float = attr.ib()
58
+ data: Auth
59
+ expires: float
58
60
 
59
61
 
60
- @attr.s(slots=True)
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 = attr.ib()
65
- refresh_interval: int = attr.ib(default=DEFAULT_REFRESH_INTERVAL)
66
- cache_entry: Optional[CacheEntry[Auth]] = attr.ib(default=None)
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] = attr.ib(default=time.monotonic)
69
- _refresh_lock: threading.Lock = attr.ib(factory=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
- data: Auth = self.provider.get(context)
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
- @attr.s(slots=True)
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
- provider: Optional[AuthProvider] = attr.ib(default=None)
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.provider is not None
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
- if not issubclass(provider_class, AuthProvider):
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
- return wrapper
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.provider = None
312
+ self.providers = []
146
313
 
147
314
  def apply(
148
315
  self, provider_class: Type[AuthProvider], *, refresh_interval: Optional[int] = DEFAULT_REFRESH_INTERVAL
149
- ) -> Callable[[GenericTest], GenericTest]:
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.apply(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.register(refresh_interval=refresh_interval)(provider_class)
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
- return wrapper
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.provider is not None:
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:
@@ -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="Forces Schemathesis to generate unique test cases.",
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
- @attr.s(slots=True)
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 = attr.ib() # pragma: no mutate
800
- app: Any = attr.ib() # pragma: no mutate
801
- base_url: Optional[str] = attr.ib() # pragma: no mutate
802
- validate_schema: bool = attr.ib() # pragma: no mutate
803
- skip_deprecated_operations: bool = attr.ib() # pragma: no mutate
804
- data_generation_methods: Tuple[DataGenerationMethod, ...] = attr.ib() # pragma: no mutate
805
- force_schema_version: Optional[str] = attr.ib() # pragma: no mutate
806
- request_tls_verify: Union[bool, str] = attr.ib() # pragma: no mutate
807
- request_cert: Optional[RequestCert] = attr.ib() # pragma: no mutate
808
- wait_for_schema: Optional[float] = attr.ib() # pragma: no mutate
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]] = attr.ib() # pragma: no mutate
811
- auth_type: Optional[str] = attr.ib() # pragma: no mutate
812
- headers: Optional[Dict[str, str]] = attr.ib() # pragma: no mutate
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] = attr.ib() # pragma: no mutate
815
- method: Optional[Filter] = attr.ib() # pragma: no mutate
816
- tag: Optional[Filter] = attr.ib() # pragma: no mutate
817
- operation_id: Optional[Filter] = attr.ib() # pragma: no mutate
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,
@@ -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"
@@ -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
- @attr.s(slots=True) # pragma: no mutate
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 = attr.ib() # pragma: no mutate
37
- preserve_exact_body_bytes: bool = attr.ib() # pragma: no mutate
38
- queue: Queue = attr.ib(factory=Queue) # pragma: no mutate
39
- worker: threading.Thread = attr.ib(init=False) # pragma: no mutate
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 __attrs_post_init__(self) -> None:
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
- @attr.s(slots=True) # pragma: no mutate
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
- @attr.s(slots=True) # pragma: no mutate
88
+ @dataclass
89
89
  class Process:
90
90
  """A new chunk of data should be processed."""
91
91
 
92
- seed: int = attr.ib() # pragma: no mutate
93
- correlation_id: str = attr.ib() # pragma: no mutate
94
- thread_id: int = attr.ib() # pragma: no mutate
95
- data_generation_method: constants.DataGenerationMethod = attr.ib() # pragma: no mutate
96
- interactions: List[SerializedInteraction] = attr.ib() # pragma: no mutate
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
- @attr.s(slots=True) # pragma: no mutate
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
- @attr.s(slots=True) # pragma: no mutate
277
+ @dataclass
278
278
  class Replayed:
279
- interaction: Dict[str, Any] = attr.ib() # pragma: no mutate
280
- response: requests.Response = attr.ib() # pragma: no mutate
279
+ interaction: Dict[str, Any]
280
+ response: requests.Response
281
281
 
282
282
 
283
283
  def replay(