schemathesis 3.25.6__py3-none-any.whl → 4.0.0a1__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 (221) hide show
  1. schemathesis/__init__.py +27 -65
  2. schemathesis/auths.py +102 -82
  3. schemathesis/checks.py +126 -46
  4. schemathesis/cli/__init__.py +11 -1760
  5. schemathesis/cli/__main__.py +4 -0
  6. schemathesis/cli/commands/__init__.py +37 -0
  7. schemathesis/cli/commands/run/__init__.py +662 -0
  8. schemathesis/cli/commands/run/checks.py +80 -0
  9. schemathesis/cli/commands/run/context.py +117 -0
  10. schemathesis/cli/commands/run/events.py +35 -0
  11. schemathesis/cli/commands/run/executor.py +138 -0
  12. schemathesis/cli/commands/run/filters.py +194 -0
  13. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  14. schemathesis/cli/commands/run/handlers/base.py +18 -0
  15. schemathesis/cli/commands/run/handlers/cassettes.py +494 -0
  16. schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
  17. schemathesis/cli/commands/run/handlers/output.py +746 -0
  18. schemathesis/cli/commands/run/hypothesis.py +105 -0
  19. schemathesis/cli/commands/run/loaders.py +129 -0
  20. schemathesis/cli/{callbacks.py → commands/run/validation.py} +103 -174
  21. schemathesis/cli/constants.py +5 -52
  22. schemathesis/cli/core.py +17 -0
  23. schemathesis/cli/ext/fs.py +14 -0
  24. schemathesis/cli/ext/groups.py +55 -0
  25. schemathesis/cli/{options.py → ext/options.py} +39 -10
  26. schemathesis/cli/hooks.py +36 -0
  27. schemathesis/contrib/__init__.py +1 -3
  28. schemathesis/contrib/openapi/__init__.py +1 -3
  29. schemathesis/contrib/openapi/fill_missing_examples.py +3 -5
  30. schemathesis/core/__init__.py +58 -0
  31. schemathesis/core/compat.py +25 -0
  32. schemathesis/core/control.py +2 -0
  33. schemathesis/core/curl.py +58 -0
  34. schemathesis/core/deserialization.py +65 -0
  35. schemathesis/core/errors.py +370 -0
  36. schemathesis/core/failures.py +285 -0
  37. schemathesis/core/fs.py +19 -0
  38. schemathesis/{_lazy_import.py → core/lazy_import.py} +1 -0
  39. schemathesis/core/loaders.py +104 -0
  40. schemathesis/core/marks.py +66 -0
  41. schemathesis/{transports/content_types.py → core/media_types.py} +17 -13
  42. schemathesis/core/output/__init__.py +69 -0
  43. schemathesis/core/output/sanitization.py +197 -0
  44. schemathesis/core/rate_limit.py +60 -0
  45. schemathesis/core/registries.py +31 -0
  46. schemathesis/{internal → core}/result.py +1 -1
  47. schemathesis/core/transforms.py +113 -0
  48. schemathesis/core/transport.py +108 -0
  49. schemathesis/core/validation.py +38 -0
  50. schemathesis/core/version.py +7 -0
  51. schemathesis/engine/__init__.py +30 -0
  52. schemathesis/engine/config.py +59 -0
  53. schemathesis/engine/context.py +119 -0
  54. schemathesis/engine/control.py +36 -0
  55. schemathesis/engine/core.py +157 -0
  56. schemathesis/engine/errors.py +394 -0
  57. schemathesis/engine/events.py +337 -0
  58. schemathesis/engine/phases/__init__.py +66 -0
  59. schemathesis/{runner → engine/phases}/probes.py +50 -67
  60. schemathesis/engine/phases/stateful/__init__.py +65 -0
  61. schemathesis/engine/phases/stateful/_executor.py +326 -0
  62. schemathesis/engine/phases/stateful/context.py +85 -0
  63. schemathesis/engine/phases/unit/__init__.py +174 -0
  64. schemathesis/engine/phases/unit/_executor.py +321 -0
  65. schemathesis/engine/phases/unit/_pool.py +74 -0
  66. schemathesis/engine/recorder.py +241 -0
  67. schemathesis/errors.py +31 -0
  68. schemathesis/experimental/__init__.py +18 -14
  69. schemathesis/filters.py +103 -14
  70. schemathesis/generation/__init__.py +21 -37
  71. schemathesis/generation/case.py +190 -0
  72. schemathesis/generation/coverage.py +931 -0
  73. schemathesis/generation/hypothesis/__init__.py +30 -0
  74. schemathesis/generation/hypothesis/builder.py +585 -0
  75. schemathesis/generation/hypothesis/examples.py +50 -0
  76. schemathesis/generation/hypothesis/given.py +66 -0
  77. schemathesis/generation/hypothesis/reporting.py +14 -0
  78. schemathesis/generation/hypothesis/strategies.py +16 -0
  79. schemathesis/generation/meta.py +115 -0
  80. schemathesis/generation/modes.py +28 -0
  81. schemathesis/generation/overrides.py +96 -0
  82. schemathesis/generation/stateful/__init__.py +20 -0
  83. schemathesis/{stateful → generation/stateful}/state_machine.py +68 -81
  84. schemathesis/generation/targets.py +69 -0
  85. schemathesis/graphql/__init__.py +15 -0
  86. schemathesis/graphql/checks.py +115 -0
  87. schemathesis/graphql/loaders.py +131 -0
  88. schemathesis/hooks.py +99 -67
  89. schemathesis/openapi/__init__.py +13 -0
  90. schemathesis/openapi/checks.py +412 -0
  91. schemathesis/openapi/generation/__init__.py +0 -0
  92. schemathesis/openapi/generation/filters.py +63 -0
  93. schemathesis/openapi/loaders.py +178 -0
  94. schemathesis/pytest/__init__.py +5 -0
  95. schemathesis/pytest/control_flow.py +7 -0
  96. schemathesis/pytest/lazy.py +273 -0
  97. schemathesis/pytest/loaders.py +12 -0
  98. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +106 -127
  99. schemathesis/python/__init__.py +0 -0
  100. schemathesis/python/asgi.py +12 -0
  101. schemathesis/python/wsgi.py +12 -0
  102. schemathesis/schemas.py +537 -261
  103. schemathesis/specs/graphql/__init__.py +0 -1
  104. schemathesis/specs/graphql/_cache.py +25 -0
  105. schemathesis/specs/graphql/nodes.py +1 -0
  106. schemathesis/specs/graphql/scalars.py +7 -5
  107. schemathesis/specs/graphql/schemas.py +215 -187
  108. schemathesis/specs/graphql/validation.py +11 -18
  109. schemathesis/specs/openapi/__init__.py +7 -1
  110. schemathesis/specs/openapi/_cache.py +122 -0
  111. schemathesis/specs/openapi/_hypothesis.py +146 -165
  112. schemathesis/specs/openapi/checks.py +565 -67
  113. schemathesis/specs/openapi/converter.py +33 -6
  114. schemathesis/specs/openapi/definitions.py +11 -18
  115. schemathesis/specs/openapi/examples.py +139 -23
  116. schemathesis/specs/openapi/expressions/__init__.py +37 -2
  117. schemathesis/specs/openapi/expressions/context.py +4 -6
  118. schemathesis/specs/openapi/expressions/extractors.py +23 -0
  119. schemathesis/specs/openapi/expressions/lexer.py +20 -18
  120. schemathesis/specs/openapi/expressions/nodes.py +38 -14
  121. schemathesis/specs/openapi/expressions/parser.py +26 -5
  122. schemathesis/specs/openapi/formats.py +45 -0
  123. schemathesis/specs/openapi/links.py +65 -165
  124. schemathesis/specs/openapi/media_types.py +32 -0
  125. schemathesis/specs/openapi/negative/__init__.py +7 -3
  126. schemathesis/specs/openapi/negative/mutations.py +24 -8
  127. schemathesis/specs/openapi/parameters.py +46 -30
  128. schemathesis/specs/openapi/patterns.py +137 -0
  129. schemathesis/specs/openapi/references.py +47 -57
  130. schemathesis/specs/openapi/schemas.py +478 -369
  131. schemathesis/specs/openapi/security.py +25 -7
  132. schemathesis/specs/openapi/serialization.py +11 -6
  133. schemathesis/specs/openapi/stateful/__init__.py +185 -73
  134. schemathesis/specs/openapi/utils.py +6 -1
  135. schemathesis/transport/__init__.py +104 -0
  136. schemathesis/transport/asgi.py +26 -0
  137. schemathesis/transport/prepare.py +99 -0
  138. schemathesis/transport/requests.py +221 -0
  139. schemathesis/{_xml.py → transport/serialization.py} +143 -28
  140. schemathesis/transport/wsgi.py +165 -0
  141. schemathesis-4.0.0a1.dist-info/METADATA +297 -0
  142. schemathesis-4.0.0a1.dist-info/RECORD +152 -0
  143. {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
  144. {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/entry_points.txt +1 -1
  145. schemathesis/_compat.py +0 -74
  146. schemathesis/_dependency_versions.py +0 -17
  147. schemathesis/_hypothesis.py +0 -246
  148. schemathesis/_override.py +0 -49
  149. schemathesis/cli/cassettes.py +0 -375
  150. schemathesis/cli/context.py +0 -58
  151. schemathesis/cli/debug.py +0 -26
  152. schemathesis/cli/handlers.py +0 -16
  153. schemathesis/cli/junitxml.py +0 -43
  154. schemathesis/cli/output/__init__.py +0 -1
  155. schemathesis/cli/output/default.py +0 -790
  156. schemathesis/cli/output/short.py +0 -44
  157. schemathesis/cli/sanitization.py +0 -20
  158. schemathesis/code_samples.py +0 -149
  159. schemathesis/constants.py +0 -55
  160. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  161. schemathesis/contrib/openapi/formats/uuid.py +0 -15
  162. schemathesis/contrib/unique_data.py +0 -41
  163. schemathesis/exceptions.py +0 -560
  164. schemathesis/extra/_aiohttp.py +0 -27
  165. schemathesis/extra/_flask.py +0 -10
  166. schemathesis/extra/_server.py +0 -17
  167. schemathesis/failures.py +0 -209
  168. schemathesis/fixups/__init__.py +0 -36
  169. schemathesis/fixups/fast_api.py +0 -41
  170. schemathesis/fixups/utf8_bom.py +0 -29
  171. schemathesis/graphql.py +0 -4
  172. schemathesis/internal/__init__.py +0 -7
  173. schemathesis/internal/copy.py +0 -13
  174. schemathesis/internal/datetime.py +0 -5
  175. schemathesis/internal/deprecation.py +0 -34
  176. schemathesis/internal/jsonschema.py +0 -35
  177. schemathesis/internal/transformation.py +0 -15
  178. schemathesis/internal/validation.py +0 -34
  179. schemathesis/lazy.py +0 -361
  180. schemathesis/loaders.py +0 -120
  181. schemathesis/models.py +0 -1234
  182. schemathesis/parameters.py +0 -86
  183. schemathesis/runner/__init__.py +0 -570
  184. schemathesis/runner/events.py +0 -329
  185. schemathesis/runner/impl/__init__.py +0 -3
  186. schemathesis/runner/impl/core.py +0 -1035
  187. schemathesis/runner/impl/solo.py +0 -90
  188. schemathesis/runner/impl/threadpool.py +0 -400
  189. schemathesis/runner/serialization.py +0 -411
  190. schemathesis/sanitization.py +0 -248
  191. schemathesis/serializers.py +0 -323
  192. schemathesis/service/__init__.py +0 -18
  193. schemathesis/service/auth.py +0 -11
  194. schemathesis/service/ci.py +0 -201
  195. schemathesis/service/client.py +0 -100
  196. schemathesis/service/constants.py +0 -38
  197. schemathesis/service/events.py +0 -57
  198. schemathesis/service/hosts.py +0 -107
  199. schemathesis/service/metadata.py +0 -46
  200. schemathesis/service/models.py +0 -49
  201. schemathesis/service/report.py +0 -255
  202. schemathesis/service/serialization.py +0 -199
  203. schemathesis/service/usage.py +0 -65
  204. schemathesis/specs/graphql/loaders.py +0 -344
  205. schemathesis/specs/openapi/filters.py +0 -49
  206. schemathesis/specs/openapi/loaders.py +0 -667
  207. schemathesis/specs/openapi/stateful/links.py +0 -92
  208. schemathesis/specs/openapi/validation.py +0 -25
  209. schemathesis/stateful/__init__.py +0 -133
  210. schemathesis/targets.py +0 -45
  211. schemathesis/throttling.py +0 -41
  212. schemathesis/transports/__init__.py +0 -5
  213. schemathesis/transports/auth.py +0 -15
  214. schemathesis/transports/headers.py +0 -35
  215. schemathesis/transports/responses.py +0 -52
  216. schemathesis/types.py +0 -35
  217. schemathesis/utils.py +0 -169
  218. schemathesis-3.25.6.dist-info/METADATA +0 -356
  219. schemathesis-3.25.6.dist-info/RECORD +0 -134
  220. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  221. {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/licenses/LICENSE +0 -0
@@ -1,246 +0,0 @@
1
- """High-level API for creating Hypothesis tests."""
2
- from __future__ import annotations
3
-
4
- import asyncio
5
- import warnings
6
- from typing import Any, Callable, Generator, Mapping, Optional, Tuple
7
-
8
- import hypothesis
9
- from hypothesis import Phase
10
- from hypothesis import strategies as st
11
- from hypothesis.errors import HypothesisWarning, Unsatisfiable
12
- from hypothesis.internal.reflection import proxies
13
- from jsonschema.exceptions import SchemaError
14
-
15
- from .auths import get_auth_storage_from_test
16
- from .constants import DEFAULT_DEADLINE
17
- from .exceptions import OperationSchemaError, SerializationNotPossible
18
- from .generation import DataGenerationMethod, GenerationConfig
19
- from .hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher
20
- from .models import APIOperation, Case
21
- from .transports.content_types import parse_content_type
22
- from .transports.headers import has_invalid_characters, is_latin_1_encodable
23
- from .utils import GivenInput, combine_strategies
24
-
25
-
26
- def create_test(
27
- *,
28
- operation: APIOperation,
29
- test: Callable,
30
- settings: hypothesis.settings | None = None,
31
- seed: int | None = None,
32
- data_generation_methods: list[DataGenerationMethod],
33
- generation_config: GenerationConfig | None = None,
34
- as_strategy_kwargs: dict[str, Any] | None = None,
35
- keep_async_fn: bool = False,
36
- _given_args: tuple[GivenInput, ...] = (),
37
- _given_kwargs: dict[str, GivenInput] | None = None,
38
- ) -> Callable:
39
- """Create a Hypothesis test."""
40
- hook_dispatcher = getattr(test, "_schemathesis_hooks", None)
41
- auth_storage = get_auth_storage_from_test(test)
42
- strategies = []
43
- skip_on_not_negated = len(data_generation_methods) == 1 and DataGenerationMethod.negative in data_generation_methods
44
- for data_generation_method in data_generation_methods:
45
- strategies.append(
46
- operation.as_strategy(
47
- hooks=hook_dispatcher,
48
- auth_storage=auth_storage,
49
- data_generation_method=data_generation_method,
50
- generation_config=generation_config,
51
- skip_on_not_negated=skip_on_not_negated,
52
- **(as_strategy_kwargs or {}),
53
- )
54
- )
55
- strategy = combine_strategies(strategies)
56
- _given_kwargs = (_given_kwargs or {}).copy()
57
- _given_kwargs.setdefault("case", strategy)
58
-
59
- # Each generated test should be a unique function. It is especially important for the case when Schemathesis runs
60
- # tests in multiple threads because Hypothesis stores some internal attributes on function objects and re-writing
61
- # them from different threads may lead to unpredictable side-effects.
62
-
63
- @proxies(test) # type: ignore
64
- def test_function(*args: Any, **kwargs: Any) -> Any:
65
- __tracebackhide__ = True
66
- return test(*args, **kwargs)
67
-
68
- wrapped_test = hypothesis.given(*_given_args, **_given_kwargs)(test_function)
69
- if seed is not None:
70
- wrapped_test = hypothesis.seed(seed)(wrapped_test)
71
- if asyncio.iscoroutinefunction(test):
72
- # `pytest-trio` expects a coroutine function
73
- if keep_async_fn:
74
- wrapped_test.hypothesis.inner_test = test # type: ignore
75
- else:
76
- wrapped_test.hypothesis.inner_test = make_async_test(test) # type: ignore
77
- setup_default_deadline(wrapped_test)
78
- if settings is not None:
79
- wrapped_test = settings(wrapped_test)
80
- existing_settings = _get_hypothesis_settings(wrapped_test)
81
- if existing_settings is not None:
82
- existing_settings = remove_explain_phase(existing_settings)
83
- wrapped_test._hypothesis_internal_use_settings = existing_settings # type: ignore
84
- if Phase.explicit in existing_settings.phases:
85
- wrapped_test = add_examples(wrapped_test, operation, hook_dispatcher=hook_dispatcher)
86
- return wrapped_test
87
-
88
-
89
- def setup_default_deadline(wrapped_test: Callable) -> None:
90
- # Quite hacky, but it is the simplest way to set up the default deadline value without affecting non-Schemathesis
91
- # tests globally
92
- existing_settings = _get_hypothesis_settings(wrapped_test)
93
- if existing_settings is not None and existing_settings.deadline == hypothesis.settings.default.deadline:
94
- with warnings.catch_warnings():
95
- warnings.simplefilter("ignore", HypothesisWarning)
96
- new_settings = hypothesis.settings(existing_settings, deadline=DEFAULT_DEADLINE)
97
- wrapped_test._hypothesis_internal_use_settings = new_settings # type: ignore
98
-
99
-
100
- def remove_explain_phase(settings: hypothesis.settings) -> hypothesis.settings:
101
- # The "explain" phase is not supported
102
- if Phase.explain in settings.phases:
103
- phases = tuple(phase for phase in settings.phases if phase != Phase.explain)
104
- return hypothesis.settings(settings, phases=phases)
105
- return settings
106
-
107
-
108
- def _get_hypothesis_settings(test: Callable) -> hypothesis.settings | None:
109
- return getattr(test, "_hypothesis_internal_use_settings", None)
110
-
111
-
112
- def make_async_test(test: Callable) -> Callable:
113
- def async_run(*args: Any, **kwargs: Any) -> None:
114
- try:
115
- loop = asyncio.get_event_loop()
116
- except RuntimeError:
117
- loop = asyncio.new_event_loop()
118
- coro = test(*args, **kwargs)
119
- future = asyncio.ensure_future(coro, loop=loop)
120
- loop.run_until_complete(future)
121
-
122
- return async_run
123
-
124
-
125
- def add_examples(test: Callable, operation: APIOperation, hook_dispatcher: HookDispatcher | None = None) -> Callable:
126
- """Add examples to the Hypothesis test, if they are specified in the schema."""
127
- from hypothesis_jsonschema._canonicalise import HypothesisRefResolutionError
128
-
129
- try:
130
- examples: list[Case] = [get_single_example(strategy) for strategy in operation.get_strategies_from_examples()]
131
- except (
132
- OperationSchemaError,
133
- HypothesisRefResolutionError,
134
- Unsatisfiable,
135
- SerializationNotPossible,
136
- SchemaError,
137
- ) as exc:
138
- # Invalid schema:
139
- # In this case, the user didn't pass `--validate-schema=false` and see an error in the output anyway,
140
- # and no tests will be executed. For this reason, examples can be skipped
141
- # Recursive references: This test will be skipped anyway
142
- # Unsatisfiable:
143
- # The underlying schema is not satisfiable and test will raise an error for the same reason.
144
- # Skipping this exception here allows us to continue the testing process for other operations.
145
- # Still, we allow running user-defined hooks
146
- examples = []
147
- if isinstance(exc, Unsatisfiable):
148
- add_unsatisfied_example_mark(test, exc)
149
- if isinstance(exc, SerializationNotPossible):
150
- add_non_serializable_mark(test, exc)
151
- if isinstance(exc, SchemaError):
152
- add_invalid_regex_mark(test, exc)
153
- context = HookContext(operation) # context should be passed here instead
154
- GLOBAL_HOOK_DISPATCHER.dispatch("before_add_examples", context, examples)
155
- operation.schema.hooks.dispatch("before_add_examples", context, examples)
156
- if hook_dispatcher:
157
- hook_dispatcher.dispatch("before_add_examples", context, examples)
158
- original_test = test
159
- for example in examples:
160
- if example.headers is not None:
161
- invalid_headers = dict(find_invalid_headers(example.headers))
162
- if invalid_headers:
163
- add_invalid_example_header_mark(original_test, invalid_headers)
164
- continue
165
- if example.media_type is not None:
166
- try:
167
- media_type = parse_content_type(example.media_type)
168
- if media_type == ("application", "x-www-form-urlencoded"):
169
- example.body = prepare_urlencoded(example.body)
170
- except ValueError:
171
- pass
172
- test = hypothesis.example(case=example)(test)
173
- return test
174
-
175
-
176
- def find_invalid_headers(headers: Mapping) -> Generator[Tuple[str, str], None, None]:
177
- for name, value in headers.items():
178
- if not is_latin_1_encodable(value) or has_invalid_characters(name, value):
179
- yield name, value
180
-
181
-
182
- def prepare_urlencoded(data: Any) -> Any:
183
- if isinstance(data, list):
184
- output = []
185
- for item in data:
186
- if isinstance(item, dict):
187
- for key, value in item.items():
188
- output.append((key, value))
189
- else:
190
- output.append(item)
191
- return output
192
- return data
193
-
194
-
195
- def add_unsatisfied_example_mark(test: Callable, exc: Unsatisfiable) -> None:
196
- test._schemathesis_unsatisfied_example = exc # type: ignore
197
-
198
-
199
- def has_unsatisfied_example_mark(test: Callable) -> bool:
200
- return hasattr(test, "_schemathesis_unsatisfied_example")
201
-
202
-
203
- def add_non_serializable_mark(test: Callable, exc: SerializationNotPossible) -> None:
204
- test._schemathesis_non_serializable = exc # type: ignore
205
-
206
-
207
- def get_non_serializable_mark(test: Callable) -> Optional[SerializationNotPossible]:
208
- return getattr(test, "_schemathesis_non_serializable", None)
209
-
210
-
211
- def get_invalid_regex_mark(test: Callable) -> Optional[SchemaError]:
212
- return getattr(test, "_schemathesis_invalid_regex", None)
213
-
214
-
215
- def add_invalid_regex_mark(test: Callable, exc: SchemaError) -> None:
216
- test._schemathesis_invalid_regex = exc # type: ignore
217
-
218
-
219
- def get_invalid_example_headers_mark(test: Callable) -> Optional[dict[str, str]]:
220
- return getattr(test, "_schemathesis_invalid_example_headers", None)
221
-
222
-
223
- def add_invalid_example_header_mark(test: Callable, headers: dict[str, str]) -> None:
224
- test._schemathesis_invalid_example_headers = headers # type: ignore
225
-
226
-
227
- def get_single_example(strategy: st.SearchStrategy[Case]) -> Case:
228
- examples: list[Case] = []
229
- add_single_example(strategy, examples)
230
- return examples[0]
231
-
232
-
233
- def add_single_example(strategy: st.SearchStrategy[Case], examples: list[Case]) -> None:
234
- @hypothesis.given(strategy) # type: ignore
235
- @hypothesis.settings( # type: ignore
236
- database=None,
237
- max_examples=1,
238
- deadline=None,
239
- verbosity=hypothesis.Verbosity.quiet,
240
- phases=(hypothesis.Phase.generate,),
241
- suppress_health_check=list(hypothesis.HealthCheck),
242
- )
243
- def example_generating_inner_function(ex: Case) -> None:
244
- examples.append(ex)
245
-
246
- example_generating_inner_function()
schemathesis/_override.py DELETED
@@ -1,49 +0,0 @@
1
- from __future__ import annotations
2
- from dataclasses import dataclass
3
- from typing import TYPE_CHECKING, Optional
4
-
5
- from .exceptions import UsageError
6
- from .parameters import ParameterSet
7
- from .types import GenericTest
8
-
9
- if TYPE_CHECKING:
10
- from .models import APIOperation
11
-
12
-
13
- @dataclass
14
- class CaseOverride:
15
- """Overrides for various parts of a test case."""
16
-
17
- query: dict[str, str]
18
- headers: dict[str, str]
19
- cookies: dict[str, str]
20
- path_parameters: dict[str, str]
21
-
22
- def for_operation(self, operation: APIOperation) -> dict[str, dict[str, str]]:
23
- return {
24
- "query": (_for_parameters(self.query, operation.query)),
25
- "headers": (_for_parameters(self.headers, operation.headers)),
26
- "cookies": (_for_parameters(self.cookies, operation.cookies)),
27
- "path_parameters": (_for_parameters(self.path_parameters, operation.path_parameters)),
28
- }
29
-
30
-
31
- def _for_parameters(overridden: dict[str, str], defined: ParameterSet) -> dict[str, str]:
32
- output = {}
33
- for param in defined:
34
- if param.name in overridden:
35
- output[param.name] = overridden[param.name]
36
- return output
37
-
38
-
39
- def get_override_from_mark(test: GenericTest) -> Optional[CaseOverride]:
40
- return getattr(test, "_schemathesis_override", None)
41
-
42
-
43
- def set_override_mark(test: GenericTest, override: CaseOverride) -> None:
44
- test._schemathesis_override = override # type: ignore[attr-defined]
45
-
46
-
47
- def check_no_override_mark(test: GenericTest) -> None:
48
- if hasattr(test, "_schemathesis_override"):
49
- raise UsageError(f"`{test.__name__}` has already been decorated with `override`.")
@@ -1,375 +0,0 @@
1
- from __future__ import annotations
2
- import base64
3
- import json
4
- import re
5
- import sys
6
- import threading
7
- from dataclasses import dataclass, field
8
- from queue import Queue
9
- from typing import IO, Any, Generator, Iterator, cast, TYPE_CHECKING
10
-
11
- from ..constants import SCHEMATHESIS_VERSION
12
- from ..runner import events
13
- from ..types import RequestCert
14
- from .handlers import EventHandler
15
-
16
- if TYPE_CHECKING:
17
- import click
18
- import requests
19
- from ..models import Request, Response
20
- from ..runner.serialization import SerializedCheck, SerializedInteraction
21
- from .context import ExecutionContext
22
- from ..generation import DataGenerationMethod
23
-
24
- # Wait until the worker terminates
25
- WRITER_WORKER_JOIN_TIMEOUT = 1
26
-
27
-
28
- @dataclass
29
- class CassetteWriter(EventHandler):
30
- """Write interactions in a YAML cassette.
31
-
32
- A low-level interface is used to write data to YAML file during the test run and reduce the delay at
33
- the end of the test run.
34
- """
35
-
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
-
41
- def __post_init__(self) -> None:
42
- self.worker = threading.Thread(
43
- target=worker,
44
- kwargs={
45
- "file_handle": self.file_handle,
46
- "preserve_exact_body_bytes": self.preserve_exact_body_bytes,
47
- "queue": self.queue,
48
- },
49
- )
50
- self.worker.start()
51
-
52
- def handle_event(self, context: ExecutionContext, event: events.ExecutionEvent) -> None:
53
- if isinstance(event, events.Initialized):
54
- # In the beginning we write metadata and start `http_interactions` list
55
- self.queue.put(Initialize())
56
- if isinstance(event, events.AfterExecution):
57
- # Seed is always present at this point, the original Optional[int] type is there because `TestResult`
58
- # instance is created before `seed` is generated on the hypothesis side
59
- seed = cast(int, event.result.seed)
60
- self.queue.put(
61
- Process(
62
- seed=seed,
63
- correlation_id=event.correlation_id,
64
- thread_id=event.thread_id,
65
- # NOTE: For backward compatibility reasons AfterExecution stores a list of data generation methods
66
- # The list always contains one element - the method that was actually used for generation
67
- # This will change in the future
68
- data_generation_method=event.data_generation_method[0],
69
- interactions=event.result.interactions,
70
- )
71
- )
72
- if isinstance(event, events.Finished):
73
- self.shutdown()
74
-
75
- def shutdown(self) -> None:
76
- self.queue.put(Finalize())
77
- self._stop_worker()
78
-
79
- def _stop_worker(self) -> None:
80
- self.worker.join(WRITER_WORKER_JOIN_TIMEOUT)
81
-
82
-
83
- @dataclass
84
- class Initialize:
85
- """Start up, the first message to make preparations before proceeding the input data."""
86
-
87
-
88
- @dataclass
89
- class Process:
90
- """A new chunk of data should be processed."""
91
-
92
- seed: int
93
- correlation_id: str
94
- thread_id: int
95
- data_generation_method: DataGenerationMethod
96
- interactions: list[SerializedInteraction]
97
-
98
-
99
- @dataclass
100
- class Finalize:
101
- """The work is done and there will be no more messages to process."""
102
-
103
-
104
- def get_command_representation() -> str:
105
- """Get how Schemathesis was run."""
106
- # It is supposed to be executed from Schemathesis CLI, not via Click's `command.invoke`
107
- if not sys.argv[0].endswith(("schemathesis", "st")):
108
- return "<unknown entrypoint>"
109
- args = " ".join(sys.argv[1:])
110
- return f"st {args}"
111
-
112
-
113
- def worker(file_handle: click.utils.LazyFile, preserve_exact_body_bytes: bool, queue: Queue) -> None:
114
- """Write YAML to a file in an incremental manner.
115
-
116
- This implementation doesn't use `pyyaml` package and composes YAML manually as string due to the following reasons:
117
- - It is much faster. The string-based approach gives only ~2.5% time overhead when `yaml.CDumper` has ~11.2%;
118
- - Implementation complexity. We have a quite simple format where almost all values are strings, and it is much
119
- simpler to implement it with string composition rather than with adjusting `yaml.Serializer` to emit explicit
120
- types. Another point is that with `pyyaml` we need to emit events and handle some low-level details like
121
- providing tags, anchors to have incremental writing, with primitive types it is much simpler.
122
- """
123
- current_id = 1
124
- stream = file_handle.open()
125
-
126
- def format_header_values(values: list[str]) -> str:
127
- return "\n".join(f" - {json.dumps(v)}" for v in values)
128
-
129
- def format_headers(headers: dict[str, list[str]]) -> str:
130
- return "\n".join(f' "{name}":\n{format_header_values(values)}' for name, values in headers.items())
131
-
132
- def format_check_message(message: str | None) -> str:
133
- return "~" if message is None else f"{repr(message)}"
134
-
135
- def format_checks(checks: list[SerializedCheck]) -> str:
136
- return "\n".join(
137
- f" - name: '{check.name}'\n status: '{check.value.name.upper()}'\n message: {format_check_message(check.message)}"
138
- for check in checks
139
- )
140
-
141
- if preserve_exact_body_bytes:
142
-
143
- def format_request_body(output: IO, request: Request) -> None:
144
- if request.body is not None:
145
- output.write(
146
- f"""
147
- body:
148
- encoding: 'utf-8'
149
- base64_string: '{request.body}'"""
150
- )
151
-
152
- def format_response_body(output: IO, response: Response) -> None:
153
- if response.body is not None:
154
- output.write(
155
- f""" body:
156
- encoding: '{response.encoding}'
157
- base64_string: '{response.body}'"""
158
- )
159
-
160
- else:
161
-
162
- def format_request_body(output: IO, request: Request) -> None:
163
- if request.body is not None:
164
- string = _safe_decode(request.body, "utf8")
165
- output.write(
166
- """
167
- body:
168
- encoding: 'utf-8'
169
- string: """
170
- )
171
- write_double_quoted(output, string)
172
-
173
- def format_response_body(output: IO, response: Response) -> None:
174
- if response.body is not None:
175
- encoding = response.encoding or "utf8"
176
- string = _safe_decode(response.body, encoding)
177
- output.write(
178
- f""" body:
179
- encoding: '{encoding}'
180
- string: """
181
- )
182
- write_double_quoted(output, string)
183
-
184
- while True:
185
- item = queue.get()
186
- if isinstance(item, Initialize):
187
- stream.write(
188
- f"""command: '{get_command_representation()}'
189
- recorded_with: 'Schemathesis {SCHEMATHESIS_VERSION}'
190
- http_interactions:"""
191
- )
192
- elif isinstance(item, Process):
193
- for interaction in item.interactions:
194
- status = interaction.status.name.upper()
195
- # Body payloads are handled via separate `stream.write` calls to avoid some allocations
196
- stream.write(
197
- f"""\n- id: '{current_id}'
198
- status: '{status}'
199
- seed: '{item.seed}'
200
- thread_id: {item.thread_id}
201
- correlation_id: '{item.correlation_id}'
202
- data_generation_method: '{item.data_generation_method.value}'
203
- elapsed: '{interaction.response.elapsed}'
204
- recorded_at: '{interaction.recorded_at}'
205
- checks:
206
- {format_checks(interaction.checks)}
207
- request:
208
- uri: '{interaction.request.uri}'
209
- method: '{interaction.request.method}'
210
- headers:
211
- {format_headers(interaction.request.headers)}"""
212
- )
213
- format_request_body(stream, interaction.request)
214
- stream.write(
215
- f"""
216
- response:
217
- status:
218
- code: '{interaction.response.status_code}'
219
- message: {json.dumps(interaction.response.message)}
220
- headers:
221
- {format_headers(interaction.response.headers)}
222
- """
223
- )
224
- format_response_body(stream, interaction.response)
225
- stream.write(
226
- f"""
227
- http_version: '{interaction.response.http_version}'"""
228
- )
229
- current_id += 1
230
- else:
231
- break
232
- file_handle.close()
233
-
234
-
235
- def _safe_decode(value: str, encoding: str) -> str:
236
- """Decode base64-encoded body bytes as a string."""
237
- return base64.b64decode(value).decode(encoding, "replace")
238
-
239
-
240
- def write_double_quoted(stream: IO, text: str) -> None:
241
- """Writes a valid YAML string enclosed in double quotes."""
242
- from yaml.emitter import Emitter
243
-
244
- # Adapted from `yaml.Emitter.write_double_quoted`:
245
- # - Doesn't split the string, therefore doesn't track the current column
246
- # - Doesn't encode the input
247
- # - Allows Unicode unconditionally
248
- stream.write('"')
249
- start = end = 0
250
- length = len(text)
251
- while end <= length:
252
- ch = None
253
- if end < length:
254
- ch = text[end]
255
- if (
256
- ch is None
257
- or ch in '"\\\x85\u2028\u2029\uFEFF'
258
- or not ("\x20" <= ch <= "\x7E" or ("\xA0" <= ch <= "\uD7FF" or "\uE000" <= ch <= "\uFFFD"))
259
- ):
260
- if start < end:
261
- stream.write(text[start:end])
262
- start = end
263
- if ch is not None:
264
- # Escape character
265
- if ch in Emitter.ESCAPE_REPLACEMENTS:
266
- data = "\\" + Emitter.ESCAPE_REPLACEMENTS[ch]
267
- elif ch <= "\xFF":
268
- data = "\\x%02X" % ord(ch)
269
- elif ch <= "\uFFFF":
270
- data = "\\u%04X" % ord(ch)
271
- else:
272
- data = "\\U%08X" % ord(ch)
273
- stream.write(data)
274
- start = end + 1
275
- end += 1
276
- stream.write('"')
277
-
278
-
279
- @dataclass
280
- class Replayed:
281
- interaction: dict[str, Any]
282
- response: requests.Response
283
-
284
-
285
- def replay(
286
- cassette: dict[str, Any],
287
- id_: str | None = None,
288
- status: str | None = None,
289
- uri: str | None = None,
290
- method: str | None = None,
291
- request_tls_verify: bool = True,
292
- request_cert: RequestCert | None = None,
293
- request_proxy: str | None = None,
294
- ) -> Generator[Replayed, None, None]:
295
- """Replay saved interactions."""
296
- import requests
297
-
298
- session = requests.Session()
299
- session.verify = request_tls_verify
300
- session.cert = request_cert
301
- kwargs = {}
302
- if request_proxy is not None:
303
- kwargs["proxies"] = {"all": request_proxy}
304
- for interaction in filter_cassette(cassette["http_interactions"], id_, status, uri, method):
305
- request = get_prepared_request(interaction["request"])
306
- response = session.send(request, **kwargs) # type: ignore
307
- yield Replayed(interaction, response)
308
-
309
-
310
- def filter_cassette(
311
- interactions: list[dict[str, Any]],
312
- id_: str | None = None,
313
- status: str | None = None,
314
- uri: str | None = None,
315
- method: str | None = None,
316
- ) -> Iterator[dict[str, Any]]:
317
- filters = []
318
-
319
- def id_filter(item: dict[str, Any]) -> bool:
320
- return item["id"] == id_
321
-
322
- def status_filter(item: dict[str, Any]) -> bool:
323
- status_ = cast(str, status)
324
- return item["status"].upper() == status_.upper()
325
-
326
- def uri_filter(item: dict[str, Any]) -> bool:
327
- uri_ = cast(str, uri)
328
- return bool(re.search(uri_, item["request"]["uri"]))
329
-
330
- def method_filter(item: dict[str, Any]) -> bool:
331
- method_ = cast(str, method)
332
- return bool(re.search(method_, item["request"]["method"]))
333
-
334
- if id_ is not None:
335
- filters.append(id_filter)
336
-
337
- if status is not None:
338
- filters.append(status_filter)
339
-
340
- if uri is not None:
341
- filters.append(uri_filter)
342
-
343
- if method is not None:
344
- filters.append(method_filter)
345
-
346
- def is_match(interaction: dict[str, Any]) -> bool:
347
- return all(filter_(interaction) for filter_ in filters)
348
-
349
- return filter(is_match, interactions)
350
-
351
-
352
- def get_prepared_request(data: dict[str, Any]) -> requests.PreparedRequest:
353
- """Create a `requests.PreparedRequest` from a serialized one."""
354
- from requests.structures import CaseInsensitiveDict
355
- from requests.cookies import RequestsCookieJar
356
- import requests
357
-
358
- prepared = requests.PreparedRequest()
359
- prepared.method = data["method"]
360
- prepared.url = data["uri"]
361
- prepared._cookies = RequestsCookieJar() # type: ignore
362
- if "body" in data:
363
- body = data["body"]
364
- if "base64_string" in body:
365
- content = body["base64_string"]
366
- if content:
367
- prepared.body = base64.b64decode(content)
368
- else:
369
- content = body["string"]
370
- if content:
371
- prepared.body = content.encode("utf8")
372
- # There is always 1 value in a request
373
- headers = [(key, value[0]) for key, value in data["headers"].items()]
374
- prepared.headers = CaseInsensitiveDict(headers)
375
- return prepared