schemathesis 3.13.0__py3-none-any.whl → 4.4.2__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 (245) hide show
  1. schemathesis/__init__.py +53 -25
  2. schemathesis/auths.py +507 -0
  3. schemathesis/checks.py +190 -25
  4. schemathesis/cli/__init__.py +27 -1016
  5. schemathesis/cli/__main__.py +4 -0
  6. schemathesis/cli/commands/__init__.py +133 -0
  7. schemathesis/cli/commands/data.py +10 -0
  8. schemathesis/cli/commands/run/__init__.py +602 -0
  9. schemathesis/cli/commands/run/context.py +228 -0
  10. schemathesis/cli/commands/run/events.py +60 -0
  11. schemathesis/cli/commands/run/executor.py +157 -0
  12. schemathesis/cli/commands/run/filters.py +53 -0
  13. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  14. schemathesis/cli/commands/run/handlers/base.py +45 -0
  15. schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
  16. schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
  17. schemathesis/cli/commands/run/handlers/output.py +1750 -0
  18. schemathesis/cli/commands/run/loaders.py +118 -0
  19. schemathesis/cli/commands/run/validation.py +256 -0
  20. schemathesis/cli/constants.py +5 -0
  21. schemathesis/cli/core.py +19 -0
  22. schemathesis/cli/ext/fs.py +16 -0
  23. schemathesis/cli/ext/groups.py +203 -0
  24. schemathesis/cli/ext/options.py +81 -0
  25. schemathesis/config/__init__.py +202 -0
  26. schemathesis/config/_auth.py +51 -0
  27. schemathesis/config/_checks.py +268 -0
  28. schemathesis/config/_diff_base.py +101 -0
  29. schemathesis/config/_env.py +21 -0
  30. schemathesis/config/_error.py +163 -0
  31. schemathesis/config/_generation.py +157 -0
  32. schemathesis/config/_health_check.py +24 -0
  33. schemathesis/config/_operations.py +335 -0
  34. schemathesis/config/_output.py +171 -0
  35. schemathesis/config/_parameters.py +19 -0
  36. schemathesis/config/_phases.py +253 -0
  37. schemathesis/config/_projects.py +543 -0
  38. schemathesis/config/_rate_limit.py +17 -0
  39. schemathesis/config/_report.py +120 -0
  40. schemathesis/config/_validator.py +9 -0
  41. schemathesis/config/_warnings.py +89 -0
  42. schemathesis/config/schema.json +975 -0
  43. schemathesis/core/__init__.py +72 -0
  44. schemathesis/core/adapter.py +34 -0
  45. schemathesis/core/compat.py +32 -0
  46. schemathesis/core/control.py +2 -0
  47. schemathesis/core/curl.py +100 -0
  48. schemathesis/core/deserialization.py +210 -0
  49. schemathesis/core/errors.py +588 -0
  50. schemathesis/core/failures.py +316 -0
  51. schemathesis/core/fs.py +19 -0
  52. schemathesis/core/hooks.py +20 -0
  53. schemathesis/core/jsonschema/__init__.py +13 -0
  54. schemathesis/core/jsonschema/bundler.py +183 -0
  55. schemathesis/core/jsonschema/keywords.py +40 -0
  56. schemathesis/core/jsonschema/references.py +222 -0
  57. schemathesis/core/jsonschema/types.py +41 -0
  58. schemathesis/core/lazy_import.py +15 -0
  59. schemathesis/core/loaders.py +107 -0
  60. schemathesis/core/marks.py +66 -0
  61. schemathesis/core/media_types.py +79 -0
  62. schemathesis/core/output/__init__.py +46 -0
  63. schemathesis/core/output/sanitization.py +54 -0
  64. schemathesis/core/parameters.py +45 -0
  65. schemathesis/core/rate_limit.py +60 -0
  66. schemathesis/core/registries.py +34 -0
  67. schemathesis/core/result.py +27 -0
  68. schemathesis/core/schema_analysis.py +17 -0
  69. schemathesis/core/shell.py +203 -0
  70. schemathesis/core/transforms.py +144 -0
  71. schemathesis/core/transport.py +223 -0
  72. schemathesis/core/validation.py +73 -0
  73. schemathesis/core/version.py +7 -0
  74. schemathesis/engine/__init__.py +28 -0
  75. schemathesis/engine/context.py +152 -0
  76. schemathesis/engine/control.py +44 -0
  77. schemathesis/engine/core.py +201 -0
  78. schemathesis/engine/errors.py +446 -0
  79. schemathesis/engine/events.py +284 -0
  80. schemathesis/engine/observations.py +42 -0
  81. schemathesis/engine/phases/__init__.py +108 -0
  82. schemathesis/engine/phases/analysis.py +28 -0
  83. schemathesis/engine/phases/probes.py +172 -0
  84. schemathesis/engine/phases/stateful/__init__.py +68 -0
  85. schemathesis/engine/phases/stateful/_executor.py +364 -0
  86. schemathesis/engine/phases/stateful/context.py +85 -0
  87. schemathesis/engine/phases/unit/__init__.py +220 -0
  88. schemathesis/engine/phases/unit/_executor.py +459 -0
  89. schemathesis/engine/phases/unit/_pool.py +82 -0
  90. schemathesis/engine/recorder.py +254 -0
  91. schemathesis/errors.py +47 -0
  92. schemathesis/filters.py +395 -0
  93. schemathesis/generation/__init__.py +25 -0
  94. schemathesis/generation/case.py +478 -0
  95. schemathesis/generation/coverage.py +1528 -0
  96. schemathesis/generation/hypothesis/__init__.py +121 -0
  97. schemathesis/generation/hypothesis/builder.py +992 -0
  98. schemathesis/generation/hypothesis/examples.py +56 -0
  99. schemathesis/generation/hypothesis/given.py +66 -0
  100. schemathesis/generation/hypothesis/reporting.py +285 -0
  101. schemathesis/generation/meta.py +227 -0
  102. schemathesis/generation/metrics.py +93 -0
  103. schemathesis/generation/modes.py +20 -0
  104. schemathesis/generation/overrides.py +127 -0
  105. schemathesis/generation/stateful/__init__.py +37 -0
  106. schemathesis/generation/stateful/state_machine.py +294 -0
  107. schemathesis/graphql/__init__.py +15 -0
  108. schemathesis/graphql/checks.py +109 -0
  109. schemathesis/graphql/loaders.py +285 -0
  110. schemathesis/hooks.py +270 -91
  111. schemathesis/openapi/__init__.py +13 -0
  112. schemathesis/openapi/checks.py +467 -0
  113. schemathesis/openapi/generation/__init__.py +0 -0
  114. schemathesis/openapi/generation/filters.py +72 -0
  115. schemathesis/openapi/loaders.py +315 -0
  116. schemathesis/pytest/__init__.py +5 -0
  117. schemathesis/pytest/control_flow.py +7 -0
  118. schemathesis/pytest/lazy.py +341 -0
  119. schemathesis/pytest/loaders.py +36 -0
  120. schemathesis/pytest/plugin.py +357 -0
  121. schemathesis/python/__init__.py +0 -0
  122. schemathesis/python/asgi.py +12 -0
  123. schemathesis/python/wsgi.py +12 -0
  124. schemathesis/schemas.py +683 -247
  125. schemathesis/specs/graphql/__init__.py +0 -1
  126. schemathesis/specs/graphql/nodes.py +27 -0
  127. schemathesis/specs/graphql/scalars.py +86 -0
  128. schemathesis/specs/graphql/schemas.py +395 -123
  129. schemathesis/specs/graphql/validation.py +33 -0
  130. schemathesis/specs/openapi/__init__.py +9 -1
  131. schemathesis/specs/openapi/_hypothesis.py +578 -317
  132. schemathesis/specs/openapi/adapter/__init__.py +10 -0
  133. schemathesis/specs/openapi/adapter/parameters.py +729 -0
  134. schemathesis/specs/openapi/adapter/protocol.py +59 -0
  135. schemathesis/specs/openapi/adapter/references.py +19 -0
  136. schemathesis/specs/openapi/adapter/responses.py +368 -0
  137. schemathesis/specs/openapi/adapter/security.py +144 -0
  138. schemathesis/specs/openapi/adapter/v2.py +30 -0
  139. schemathesis/specs/openapi/adapter/v3_0.py +30 -0
  140. schemathesis/specs/openapi/adapter/v3_1.py +30 -0
  141. schemathesis/specs/openapi/analysis.py +96 -0
  142. schemathesis/specs/openapi/checks.py +753 -74
  143. schemathesis/specs/openapi/converter.py +176 -37
  144. schemathesis/specs/openapi/definitions.py +599 -4
  145. schemathesis/specs/openapi/examples.py +581 -165
  146. schemathesis/specs/openapi/expressions/__init__.py +52 -5
  147. schemathesis/specs/openapi/expressions/extractors.py +25 -0
  148. schemathesis/specs/openapi/expressions/lexer.py +34 -31
  149. schemathesis/specs/openapi/expressions/nodes.py +97 -46
  150. schemathesis/specs/openapi/expressions/parser.py +35 -13
  151. schemathesis/specs/openapi/formats.py +122 -0
  152. schemathesis/specs/openapi/media_types.py +75 -0
  153. schemathesis/specs/openapi/negative/__init__.py +117 -68
  154. schemathesis/specs/openapi/negative/mutations.py +294 -104
  155. schemathesis/specs/openapi/negative/utils.py +3 -6
  156. schemathesis/specs/openapi/patterns.py +458 -0
  157. schemathesis/specs/openapi/references.py +60 -81
  158. schemathesis/specs/openapi/schemas.py +648 -650
  159. schemathesis/specs/openapi/serialization.py +53 -30
  160. schemathesis/specs/openapi/stateful/__init__.py +404 -69
  161. schemathesis/specs/openapi/stateful/control.py +87 -0
  162. schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
  163. schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
  164. schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
  165. schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
  166. schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
  167. schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
  168. schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
  169. schemathesis/specs/openapi/stateful/inference.py +254 -0
  170. schemathesis/specs/openapi/stateful/links.py +219 -78
  171. schemathesis/specs/openapi/types/__init__.py +3 -0
  172. schemathesis/specs/openapi/types/common.py +23 -0
  173. schemathesis/specs/openapi/types/v2.py +129 -0
  174. schemathesis/specs/openapi/types/v3.py +134 -0
  175. schemathesis/specs/openapi/utils.py +7 -6
  176. schemathesis/specs/openapi/warnings.py +75 -0
  177. schemathesis/transport/__init__.py +224 -0
  178. schemathesis/transport/asgi.py +26 -0
  179. schemathesis/transport/prepare.py +126 -0
  180. schemathesis/transport/requests.py +278 -0
  181. schemathesis/transport/serialization.py +329 -0
  182. schemathesis/transport/wsgi.py +175 -0
  183. schemathesis-4.4.2.dist-info/METADATA +213 -0
  184. schemathesis-4.4.2.dist-info/RECORD +192 -0
  185. {schemathesis-3.13.0.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
  186. schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
  187. {schemathesis-3.13.0.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
  188. schemathesis/_compat.py +0 -41
  189. schemathesis/_hypothesis.py +0 -115
  190. schemathesis/cli/callbacks.py +0 -188
  191. schemathesis/cli/cassettes.py +0 -253
  192. schemathesis/cli/context.py +0 -36
  193. schemathesis/cli/debug.py +0 -21
  194. schemathesis/cli/handlers.py +0 -11
  195. schemathesis/cli/junitxml.py +0 -41
  196. schemathesis/cli/options.py +0 -51
  197. schemathesis/cli/output/__init__.py +0 -1
  198. schemathesis/cli/output/default.py +0 -508
  199. schemathesis/cli/output/short.py +0 -40
  200. schemathesis/constants.py +0 -79
  201. schemathesis/exceptions.py +0 -207
  202. schemathesis/extra/_aiohttp.py +0 -27
  203. schemathesis/extra/_flask.py +0 -10
  204. schemathesis/extra/_server.py +0 -16
  205. schemathesis/extra/pytest_plugin.py +0 -216
  206. schemathesis/failures.py +0 -131
  207. schemathesis/fixups/__init__.py +0 -29
  208. schemathesis/fixups/fast_api.py +0 -30
  209. schemathesis/lazy.py +0 -227
  210. schemathesis/models.py +0 -1041
  211. schemathesis/parameters.py +0 -88
  212. schemathesis/runner/__init__.py +0 -460
  213. schemathesis/runner/events.py +0 -240
  214. schemathesis/runner/impl/__init__.py +0 -3
  215. schemathesis/runner/impl/core.py +0 -755
  216. schemathesis/runner/impl/solo.py +0 -85
  217. schemathesis/runner/impl/threadpool.py +0 -367
  218. schemathesis/runner/serialization.py +0 -189
  219. schemathesis/serializers.py +0 -233
  220. schemathesis/service/__init__.py +0 -3
  221. schemathesis/service/client.py +0 -46
  222. schemathesis/service/constants.py +0 -12
  223. schemathesis/service/events.py +0 -39
  224. schemathesis/service/handler.py +0 -39
  225. schemathesis/service/models.py +0 -7
  226. schemathesis/service/serialization.py +0 -153
  227. schemathesis/service/worker.py +0 -40
  228. schemathesis/specs/graphql/loaders.py +0 -215
  229. schemathesis/specs/openapi/constants.py +0 -7
  230. schemathesis/specs/openapi/expressions/context.py +0 -12
  231. schemathesis/specs/openapi/expressions/pointers.py +0 -29
  232. schemathesis/specs/openapi/filters.py +0 -44
  233. schemathesis/specs/openapi/links.py +0 -302
  234. schemathesis/specs/openapi/loaders.py +0 -453
  235. schemathesis/specs/openapi/parameters.py +0 -413
  236. schemathesis/specs/openapi/security.py +0 -129
  237. schemathesis/specs/openapi/validation.py +0 -24
  238. schemathesis/stateful.py +0 -349
  239. schemathesis/targets.py +0 -32
  240. schemathesis/types.py +0 -38
  241. schemathesis/utils.py +0 -436
  242. schemathesis-3.13.0.dist-info/METADATA +0 -202
  243. schemathesis-3.13.0.dist-info/RECORD +0 -91
  244. schemathesis-3.13.0.dist-info/entry_points.txt +0 -6
  245. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
@@ -0,0 +1,254 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import time
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Iterator, cast
7
+
8
+ from schemathesis.core.failures import Failure
9
+ from schemathesis.core.transport import Response
10
+ from schemathesis.engine import Status
11
+ from schemathesis.generation.case import Case
12
+
13
+ if TYPE_CHECKING:
14
+ import requests
15
+
16
+ from schemathesis.generation.stateful.state_machine import Transition
17
+
18
+
19
+ @dataclass
20
+ class ScenarioRecorder:
21
+ """Tracks and organizes all data related to a logical block of testing.
22
+
23
+ Records test cases, their hierarchy, API interactions, and results of checks performed during execution.
24
+ """
25
+
26
+ # Human-readable label
27
+ label: str
28
+
29
+ # Recorded test cases
30
+ cases: dict[str, CaseNode]
31
+ # Results of checks categorized by test case ID
32
+ checks: dict[str, list[CheckNode]]
33
+ # Network interactions by test case ID
34
+ interactions: dict[str, Interaction]
35
+ __slots__ = ("label", "status", "roots", "cases", "checks", "interactions")
36
+
37
+ def __init__(self, *, label: str) -> None:
38
+ self.label = label
39
+ self.cases = {}
40
+ self.checks = {}
41
+ self.interactions = {}
42
+
43
+ def record_case(
44
+ self, *, parent_id: str | None, case: Case, transition: Transition | None, is_transition_applied: bool
45
+ ) -> None:
46
+ """Record a test case and its relationship to a parent, if applicable."""
47
+ self.cases[case.id] = CaseNode(
48
+ value=case,
49
+ parent_id=parent_id,
50
+ transition=transition,
51
+ is_transition_applied=is_transition_applied,
52
+ )
53
+
54
+ def record_response(self, *, case_id: str, response: Response) -> None:
55
+ """Record the API response for a given test case."""
56
+ request = Request.from_prepared_request(response.request)
57
+ self.interactions[case_id] = Interaction(request=request, response=response)
58
+
59
+ def record_request(self, *, case_id: str, request: requests.PreparedRequest) -> None:
60
+ """Record a network-level error for a given test case."""
61
+ self.interactions[case_id] = Interaction(request=Request.from_prepared_request(request), response=None)
62
+
63
+ def record_check_failure(self, *, name: str, case_id: str, code_sample: str, failure: Failure) -> None:
64
+ """Record a failure of a check for a given test case."""
65
+ self.checks.setdefault(case_id, []).append(
66
+ CheckNode(
67
+ name=name,
68
+ status=Status.FAILURE,
69
+ failure_info=CheckFailureInfo(code_sample=code_sample, failure=failure),
70
+ )
71
+ )
72
+
73
+ def record_check_success(self, *, name: str, case_id: str) -> None:
74
+ """Record a successful pass of a check for a given test case."""
75
+ self.checks.setdefault(case_id, []).append(CheckNode(name=name, status=Status.SUCCESS, failure_info=None))
76
+
77
+ def find_failure_data(self, *, parent_id: str, failure: Failure) -> FailureData:
78
+ """Retrieve the relevant test case & interaction data for a failure.
79
+
80
+ It may happen that a failure comes from a different test case if a check generated some additional
81
+ test cases & interactions.
82
+ """
83
+ case_id = failure.case_id or parent_id
84
+ case = self.cases[case_id].value
85
+ request = self.interactions[case_id].request
86
+ response = self.interactions[case_id].response
87
+ assert isinstance(response, Response)
88
+ headers = {key: value[0] for key, value in request.headers.items()}
89
+ return FailureData(case=case, headers=headers, verify=response.verify)
90
+
91
+ def find_parent(self, *, case_id: str) -> Case | None:
92
+ """Find the parent case of a given test case, if it exists."""
93
+ case = self.cases.get(case_id)
94
+ if case is not None and case.parent_id is not None:
95
+ parent = self.cases.get(case.parent_id)
96
+ # The recorder state should always be consistent
97
+ assert parent is not None, "Parent does not exist"
98
+ return parent.value
99
+ return None
100
+
101
+ def find_related(self, *, case_id: str) -> Iterator[Case]:
102
+ """Iterate over all cases in the tree, starting from the root."""
103
+ seen = {case_id}
104
+
105
+ # First, find the root by going up
106
+ current_id = case_id
107
+ while True:
108
+ current_node = self.cases.get(current_id)
109
+ if current_node is None or current_node.parent_id is None:
110
+ root_id = current_id
111
+ break
112
+ current_id = current_node.parent_id
113
+
114
+ # Then traverse the whole tree from root
115
+ def traverse(node_id: str) -> Iterator[Case]:
116
+ # Get all children
117
+ for case_id, node in self.cases.items():
118
+ if node.parent_id == node_id and case_id not in seen:
119
+ seen.add(case_id)
120
+ yield node.value
121
+ # Recurse into children
122
+ yield from traverse(case_id)
123
+
124
+ # Start traversal from root
125
+ root_node = self.cases.get(root_id)
126
+ if root_node and root_id not in seen:
127
+ seen.add(root_id)
128
+ yield root_node.value
129
+ yield from traverse(root_id)
130
+
131
+ def find_response(self, *, case_id: str) -> Response | None:
132
+ """Retrieve the API response for a given test case, if available."""
133
+ interaction = self.interactions.get(case_id)
134
+ if interaction is None or interaction.response is None:
135
+ return None
136
+ return interaction.response
137
+
138
+
139
+ @dataclass
140
+ class CaseNode:
141
+ """Represents a test case and its parent-child relationship."""
142
+
143
+ value: Case
144
+ parent_id: str | None
145
+ # Transition may be absent if `parent_id` is present for cases when a case is derived inside a check
146
+ # and outside of the implemented transition logic (e.g. Open API links)
147
+ transition: Transition | None
148
+ is_transition_applied: bool
149
+
150
+ __slots__ = ("value", "parent_id", "transition", "is_transition_applied")
151
+
152
+
153
+ @dataclass
154
+ class CheckNode:
155
+ name: str
156
+ status: Status
157
+ failure_info: CheckFailureInfo | None
158
+
159
+ __slots__ = ("name", "status", "failure_info")
160
+
161
+
162
+ @dataclass
163
+ class CheckFailureInfo:
164
+ code_sample: str
165
+ failure: Failure
166
+
167
+ __slots__ = ("code_sample", "failure")
168
+
169
+
170
+ def serialize_payload(payload: bytes) -> str:
171
+ return base64.b64encode(payload).decode()
172
+
173
+
174
+ @dataclass(repr=False)
175
+ class Request:
176
+ """Request data extracted from `Case`."""
177
+
178
+ method: str
179
+ uri: str
180
+ body: bytes | None
181
+ body_size: int | None
182
+ headers: dict[str, list[str]]
183
+
184
+ __slots__ = ("method", "uri", "body", "body_size", "headers", "_encoded_body_cache")
185
+
186
+ def __init__(
187
+ self,
188
+ method: str,
189
+ uri: str,
190
+ body: bytes | None,
191
+ body_size: int | None,
192
+ headers: dict[str, list[str]],
193
+ ):
194
+ self.method = method
195
+ self.uri = uri
196
+ self.body = body
197
+ self.body_size = body_size
198
+ self.headers = headers
199
+ self._encoded_body_cache: str | None = None
200
+
201
+ @classmethod
202
+ def from_prepared_request(cls, prepared: requests.PreparedRequest) -> Request:
203
+ """A prepared request version is already stored in `requests.Response`."""
204
+ body = prepared.body
205
+
206
+ if isinstance(body, str):
207
+ # can be a string for `application/x-www-form-urlencoded`
208
+ body = body.encode("utf-8")
209
+
210
+ # these values have `str` type at this point
211
+ uri = cast(str, prepared.url)
212
+ method = cast(str, prepared.method)
213
+ return cls(
214
+ uri=uri,
215
+ method=method,
216
+ headers={key: [value] for (key, value) in prepared.headers.items()},
217
+ body=body,
218
+ body_size=len(body) if body is not None else None,
219
+ )
220
+
221
+ @property
222
+ def encoded_body(self) -> str | None:
223
+ if self.body is not None:
224
+ if self._encoded_body_cache is None:
225
+ self._encoded_body_cache = serialize_payload(self.body)
226
+ return self._encoded_body_cache
227
+ return None
228
+
229
+
230
+ @dataclass
231
+ class Interaction:
232
+ """Represents a single interaction with the tested application."""
233
+
234
+ request: Request
235
+ response: Response | None
236
+ timestamp: float
237
+
238
+ __slots__ = ("request", "response", "timestamp")
239
+
240
+ def __init__(self, request: Request, response: Response | None) -> None:
241
+ self.request = request
242
+ self.response = response
243
+ self.timestamp = time.time()
244
+
245
+
246
+ @dataclass
247
+ class FailureData:
248
+ """Details about a test failure, including the case and its context."""
249
+
250
+ case: Case
251
+ headers: dict[str, str]
252
+ verify: bool
253
+
254
+ __slots__ = ("case", "headers", "verify")
schemathesis/errors.py ADDED
@@ -0,0 +1,47 @@
1
+ """Public Schemathesis errors."""
2
+
3
+ from schemathesis.core.errors import (
4
+ HookError,
5
+ IncorrectUsage,
6
+ InfiniteRecursiveReference,
7
+ InternalError,
8
+ InvalidHeadersExample,
9
+ InvalidRateLimit,
10
+ InvalidRegexPattern,
11
+ InvalidRegexType,
12
+ InvalidSchema,
13
+ InvalidStateMachine,
14
+ InvalidTransition,
15
+ LoaderError,
16
+ NoLinksFound,
17
+ OperationNotFound,
18
+ SchemathesisError,
19
+ SerializationError,
20
+ SerializationNotPossible,
21
+ TransitionValidationError,
22
+ UnboundPrefix,
23
+ UnresolvableReference,
24
+ )
25
+
26
+ __all__ = [
27
+ "HookError",
28
+ "IncorrectUsage",
29
+ "InfiniteRecursiveReference",
30
+ "InternalError",
31
+ "InvalidHeadersExample",
32
+ "InvalidRateLimit",
33
+ "InvalidRegexPattern",
34
+ "InvalidRegexType",
35
+ "InvalidSchema",
36
+ "InvalidStateMachine",
37
+ "InvalidTransition",
38
+ "LoaderError",
39
+ "OperationNotFound",
40
+ "NoLinksFound",
41
+ "SchemathesisError",
42
+ "SerializationError",
43
+ "SerializationNotPossible",
44
+ "TransitionValidationError",
45
+ "UnboundPrefix",
46
+ "UnresolvableReference",
47
+ ]
@@ -0,0 +1,395 @@
1
+ """Filtering system that allows users to filter API operations based on certain criteria."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from functools import partial
9
+ from types import SimpleNamespace
10
+ from typing import TYPE_CHECKING, Any, Callable, List, Protocol, Union
11
+
12
+ from schemathesis.core.errors import IncorrectUsage
13
+ from schemathesis.core.transforms import resolve_pointer
14
+
15
+ if TYPE_CHECKING:
16
+ from schemathesis.schemas import APIOperation
17
+
18
+
19
+ class HasAPIOperation(Protocol):
20
+ operation: APIOperation
21
+
22
+
23
+ MatcherFunc = Callable[[HasAPIOperation], bool]
24
+ FilterValue = Union[str, List[str]]
25
+ RegexValue = Union[str, re.Pattern]
26
+ ERROR_EXPECTED_AND_REGEX = "Passing expected value and regex simultaneously is not allowed"
27
+ ERROR_EMPTY_FILTER = "Filter can not be empty"
28
+ ERROR_FILTER_EXISTS = "Filter already exists"
29
+
30
+
31
+ @dataclass(repr=False, frozen=True)
32
+ class Matcher:
33
+ """Encapsulates matching logic by various criteria."""
34
+
35
+ func: Callable[..., bool] = field(hash=False, compare=False)
36
+ # A short description of a matcher. Primarily exists for debugging purposes
37
+ label: str = field(hash=False, compare=False)
38
+ # Compare & hash matchers by a pre-computed hash value
39
+ _hash: int
40
+
41
+ def __repr__(self) -> str:
42
+ return f"<{self.__class__.__name__}: {self.label}>"
43
+
44
+ @classmethod
45
+ def for_function(cls, func: MatcherFunc) -> Matcher:
46
+ """Matcher that uses the given function for matching operations."""
47
+ return cls(func, label=func.__name__, _hash=hash(func))
48
+
49
+ @classmethod
50
+ def for_value(cls, attribute: str, expected: FilterValue) -> Matcher:
51
+ """Matcher that checks whether the specified attribute has the expected value."""
52
+ if isinstance(expected, list):
53
+ func = partial(by_value_list, attribute=attribute, expected=expected)
54
+ else:
55
+ func = partial(by_value, attribute=attribute, expected=expected)
56
+ label = f"{attribute}={expected!r}"
57
+ return cls(func, label=label, _hash=hash(label))
58
+
59
+ @classmethod
60
+ def for_regex(cls, attribute: str, regex: RegexValue) -> Matcher:
61
+ """Matcher that checks whether the specified attribute has the provided regex."""
62
+ if isinstance(regex, str):
63
+ flags: re.RegexFlag | int
64
+ if attribute == "method":
65
+ flags = re.IGNORECASE
66
+ else:
67
+ flags = 0
68
+ regex = re.compile(regex, flags=flags)
69
+ func = partial(by_regex, attribute=attribute, regex=regex)
70
+ label = f"{attribute}_regex={regex!r}"
71
+ return cls(func, label=label, _hash=hash(label))
72
+
73
+ def match(self, ctx: HasAPIOperation) -> bool:
74
+ """Whether matcher matches the given operation."""
75
+ return self.func(ctx)
76
+
77
+
78
+ def get_operation_attribute(operation: APIOperation, attribute: str) -> str | list[str] | None:
79
+ if attribute == "tag":
80
+ return operation.tags
81
+ if attribute == "operation_id":
82
+ return operation.definition.raw.get("operationId")
83
+ # Just uppercase `method`
84
+ value = getattr(operation, attribute)
85
+ if attribute == "method":
86
+ value = value.upper()
87
+ return value
88
+
89
+
90
+ def by_value(ctx: HasAPIOperation, attribute: str, expected: str) -> bool:
91
+ value = get_operation_attribute(ctx.operation, attribute)
92
+ if value is None:
93
+ return False
94
+ if isinstance(value, list):
95
+ return any(entry == expected for entry in value)
96
+ return value == expected
97
+
98
+
99
+ def by_value_list(ctx: HasAPIOperation, attribute: str, expected: list[str]) -> bool:
100
+ value = get_operation_attribute(ctx.operation, attribute)
101
+ if value is None:
102
+ return False
103
+ if isinstance(value, list):
104
+ return any(entry in expected for entry in value)
105
+ return value in expected
106
+
107
+
108
+ def by_regex(ctx: HasAPIOperation, attribute: str, regex: re.Pattern) -> bool:
109
+ value = get_operation_attribute(ctx.operation, attribute)
110
+ if value is None:
111
+ return False
112
+ if isinstance(value, list):
113
+ return any(bool(regex.search(entry)) for entry in value)
114
+ return bool(regex.search(value))
115
+
116
+
117
+ @dataclass(repr=False, frozen=True)
118
+ class Filter:
119
+ """Match API operations against a list of matchers."""
120
+
121
+ matchers: tuple[Matcher, ...]
122
+
123
+ __slots__ = ("matchers",)
124
+
125
+ def __repr__(self) -> str:
126
+ inner = " && ".join(matcher.label for matcher in self.matchers)
127
+ return f"<{self.__class__.__name__}: [{inner}]>"
128
+
129
+ def match(self, ctx: HasAPIOperation) -> bool:
130
+ """Whether the operation matches the filter.
131
+
132
+ Returns `True` only if all matchers matched.
133
+ """
134
+ return all(matcher.match(ctx) for matcher in self.matchers)
135
+
136
+
137
+ @dataclass
138
+ class FilterSet:
139
+ """Combines multiple filters to apply inclusion and exclusion rules on API operations."""
140
+
141
+ _includes: set[Filter]
142
+ _excludes: set[Filter]
143
+
144
+ __slots__ = ("_includes", "_excludes")
145
+
146
+ def __init__(self, _includes: set[Filter] | None = None, _excludes: set[Filter] | None = None) -> None:
147
+ self._includes = _includes or set()
148
+ self._excludes = _excludes or set()
149
+
150
+ def clone(self) -> FilterSet:
151
+ return FilterSet(_includes=self._includes.copy(), _excludes=self._excludes.copy())
152
+
153
+ def applies_to(self, operation: APIOperation) -> bool:
154
+ return self.match(SimpleNamespace(operation=operation))
155
+
156
+ def match(self, ctx: HasAPIOperation) -> bool:
157
+ """Determines whether the given operation should be included based on the defined filters.
158
+
159
+ Returns True if the operation:
160
+ - matches at least one INCLUDE filter OR no INCLUDE filters defined;
161
+ - does not match any EXCLUDE filter;
162
+ False otherwise.
163
+ """
164
+ # Exclude early if the operation is excluded by at least one EXCLUDE filter
165
+ for filter_ in self._excludes:
166
+ if filter_.match(ctx):
167
+ return False
168
+ if not self._includes:
169
+ # No includes - nothing to filter out, include the operation
170
+ return True
171
+ # Otherwise check if the operation is included by at least one INCLUDE filter
172
+ return any(filter_.match(ctx) for filter_ in self._includes)
173
+
174
+ def is_empty(self) -> bool:
175
+ """Whether the filter set does not contain any filters."""
176
+ return not self._includes and not self._excludes
177
+
178
+ def clear(self) -> None:
179
+ self._includes.clear()
180
+ self._excludes.clear()
181
+
182
+ def include(
183
+ self,
184
+ func: MatcherFunc | None = None,
185
+ *,
186
+ name: FilterValue | None = None,
187
+ name_regex: RegexValue | None = None,
188
+ method: FilterValue | None = None,
189
+ method_regex: RegexValue | None = None,
190
+ path: FilterValue | None = None,
191
+ path_regex: RegexValue | None = None,
192
+ tag: FilterValue | None = None,
193
+ tag_regex: RegexValue | None = None,
194
+ operation_id: FilterValue | None = None,
195
+ operation_id_regex: RegexValue | None = None,
196
+ ) -> None:
197
+ """Add a new INCLUDE filter."""
198
+ self._add_filter(
199
+ True,
200
+ func=func,
201
+ name=name,
202
+ name_regex=name_regex,
203
+ method=method,
204
+ method_regex=method_regex,
205
+ path=path,
206
+ path_regex=path_regex,
207
+ tag=tag,
208
+ tag_regex=tag_regex,
209
+ operation_id=operation_id,
210
+ operation_id_regex=operation_id_regex,
211
+ )
212
+
213
+ def exclude(
214
+ self,
215
+ func: MatcherFunc | None = None,
216
+ *,
217
+ name: FilterValue | None = None,
218
+ name_regex: RegexValue | None = None,
219
+ method: FilterValue | None = None,
220
+ method_regex: RegexValue | None = None,
221
+ path: FilterValue | None = None,
222
+ path_regex: RegexValue | None = None,
223
+ tag: FilterValue | None = None,
224
+ tag_regex: RegexValue | None = None,
225
+ operation_id: FilterValue | None = None,
226
+ operation_id_regex: RegexValue | None = None,
227
+ ) -> None:
228
+ """Add a new EXCLUDE filter."""
229
+ self._add_filter(
230
+ False,
231
+ func=func,
232
+ name=name,
233
+ name_regex=name_regex,
234
+ method=method,
235
+ method_regex=method_regex,
236
+ path=path,
237
+ path_regex=path_regex,
238
+ tag=tag,
239
+ tag_regex=tag_regex,
240
+ operation_id=operation_id,
241
+ operation_id_regex=operation_id_regex,
242
+ )
243
+
244
+ def _add_filter(
245
+ self,
246
+ include: bool,
247
+ *,
248
+ func: MatcherFunc | None = None,
249
+ name: FilterValue | None = None,
250
+ name_regex: RegexValue | None = None,
251
+ method: FilterValue | None = None,
252
+ method_regex: RegexValue | None = None,
253
+ path: FilterValue | None = None,
254
+ path_regex: RegexValue | None = None,
255
+ tag: FilterValue | None = None,
256
+ tag_regex: RegexValue | None = None,
257
+ operation_id: FilterValue | None = None,
258
+ operation_id_regex: RegexValue | None = None,
259
+ ) -> None:
260
+ matchers = []
261
+ if func is not None:
262
+ matchers.append(Matcher.for_function(func))
263
+ for attribute, expected, regex in (
264
+ ("label", name, name_regex),
265
+ ("method", method, method_regex),
266
+ ("path", path, path_regex),
267
+ ("tag", tag, tag_regex),
268
+ ("operation_id", operation_id, operation_id_regex),
269
+ ):
270
+ if expected is not None and regex is not None:
271
+ # To match anything the regex should match the expected value, hence passing them together is useless
272
+ raise IncorrectUsage(ERROR_EXPECTED_AND_REGEX)
273
+ if expected is not None:
274
+ if attribute == "method":
275
+ expected = _normalize_method(expected)
276
+ matchers.append(Matcher.for_value(attribute, expected))
277
+ if regex is not None:
278
+ matchers.append(Matcher.for_regex(attribute, regex))
279
+
280
+ if not matchers:
281
+ raise IncorrectUsage(ERROR_EMPTY_FILTER)
282
+ filter_ = Filter(matchers=tuple(matchers))
283
+ if filter_ in self._includes or filter_ in self._excludes:
284
+ raise IncorrectUsage(ERROR_FILTER_EXISTS)
285
+ if include:
286
+ self._includes.add(filter_)
287
+ else:
288
+ self._excludes.add(filter_)
289
+
290
+
291
+ def _normalize_method(value: FilterValue) -> FilterValue:
292
+ if isinstance(value, list):
293
+ return [item.upper() for item in value]
294
+ return value.upper()
295
+
296
+
297
+ def attach_filter_chain(
298
+ target: Callable,
299
+ attribute: str,
300
+ filter_func: Callable[..., None],
301
+ ) -> None:
302
+ """Attach a filtering function to an object, which allows chaining of filter criteria.
303
+
304
+ For example:
305
+
306
+ >>> def auth(): ...
307
+ >>> filter_set = FilterSet()
308
+ >>> attach_filter_chain(auth, "apply_to", filter_set.include)
309
+ >>> auth.apply_to(method="GET", path="/users/")
310
+
311
+ This will add a new `apply_to` method to `auth` that matches only the `GET /users/` operation.
312
+ """
313
+
314
+ def proxy(
315
+ func: MatcherFunc | None = None,
316
+ *,
317
+ name: FilterValue | None = None,
318
+ name_regex: str | None = None,
319
+ method: FilterValue | None = None,
320
+ method_regex: str | None = None,
321
+ tag: FilterValue | None = None,
322
+ tag_regex: RegexValue | None = None,
323
+ path: FilterValue | None = None,
324
+ path_regex: str | None = None,
325
+ operation_id: FilterValue | None = None,
326
+ operation_id_regex: RegexValue | None = None,
327
+ ) -> Callable:
328
+ __tracebackhide__ = True
329
+ filter_func(
330
+ func=func,
331
+ name=name,
332
+ name_regex=name_regex,
333
+ method=method,
334
+ method_regex=method_regex,
335
+ tag=tag,
336
+ tag_regex=tag_regex,
337
+ path=path,
338
+ path_regex=path_regex,
339
+ operation_id=operation_id,
340
+ operation_id_regex=operation_id_regex,
341
+ )
342
+ return target
343
+
344
+ proxy.__qualname__ = attribute
345
+ proxy.__name__ = attribute
346
+
347
+ setattr(target, attribute, proxy)
348
+
349
+
350
+ def is_deprecated(ctx: HasAPIOperation) -> bool:
351
+ return ctx.operation.definition.raw.get("deprecated") is True
352
+
353
+
354
+ def parse_expression(expression: str) -> tuple[str, str, Any]:
355
+ expression = expression.strip()
356
+
357
+ # Find the operator
358
+ for op in ("==", "!="):
359
+ try:
360
+ pointer, value = expression.split(op, 1)
361
+ break
362
+ except ValueError:
363
+ continue
364
+ else:
365
+ raise ValueError(f"Invalid expression: {expression}")
366
+
367
+ pointer = pointer.strip()
368
+ value = value.strip()
369
+ if not pointer or not value:
370
+ raise ValueError(f"Invalid expression: {expression}")
371
+ # Parse the JSON value
372
+ try:
373
+ return pointer, op, json.loads(value)
374
+ except json.JSONDecodeError:
375
+ # If it's not valid JSON, treat it as a string
376
+ return pointer, op, value
377
+
378
+
379
+ def expression_to_filter_function(expression: str) -> Callable[[HasAPIOperation], bool]:
380
+ pointer, op, value = parse_expression(expression)
381
+
382
+ if op == "==":
383
+
384
+ def filter_function(ctx: HasAPIOperation) -> bool:
385
+ definition = ctx.operation.definition.raw
386
+ resolved = resolve_pointer(definition, pointer)
387
+ return resolved == value
388
+ else:
389
+
390
+ def filter_function(ctx: HasAPIOperation) -> bool:
391
+ definition = ctx.operation.definition.raw
392
+ resolved = resolve_pointer(definition, pointer)
393
+ return resolved != value
394
+
395
+ return filter_function