schemathesis 3.15.4__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 (251) 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 -1219
  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 +682 -257
  125. schemathesis/specs/graphql/__init__.py +0 -1
  126. schemathesis/specs/graphql/nodes.py +26 -2
  127. schemathesis/specs/graphql/scalars.py +77 -12
  128. schemathesis/specs/graphql/schemas.py +367 -148
  129. schemathesis/specs/graphql/validation.py +33 -0
  130. schemathesis/specs/openapi/__init__.py +9 -1
  131. schemathesis/specs/openapi/_hypothesis.py +555 -318
  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 +748 -82
  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 +93 -73
  154. schemathesis/specs/openapi/negative/mutations.py +294 -103
  155. schemathesis/specs/openapi/negative/utils.py +0 -9
  156. schemathesis/specs/openapi/patterns.py +458 -0
  157. schemathesis/specs/openapi/references.py +60 -81
  158. schemathesis/specs/openapi/schemas.py +647 -666
  159. schemathesis/specs/openapi/serialization.py +53 -30
  160. schemathesis/specs/openapi/stateful/__init__.py +403 -68
  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.15.4.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.15.4.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
  188. schemathesis/_compat.py +0 -57
  189. schemathesis/_hypothesis.py +0 -123
  190. schemathesis/auth.py +0 -214
  191. schemathesis/cli/callbacks.py +0 -240
  192. schemathesis/cli/cassettes.py +0 -351
  193. schemathesis/cli/context.py +0 -38
  194. schemathesis/cli/debug.py +0 -21
  195. schemathesis/cli/handlers.py +0 -11
  196. schemathesis/cli/junitxml.py +0 -41
  197. schemathesis/cli/options.py +0 -70
  198. schemathesis/cli/output/__init__.py +0 -1
  199. schemathesis/cli/output/default.py +0 -521
  200. schemathesis/cli/output/short.py +0 -40
  201. schemathesis/constants.py +0 -88
  202. schemathesis/exceptions.py +0 -257
  203. schemathesis/extra/_aiohttp.py +0 -27
  204. schemathesis/extra/_flask.py +0 -10
  205. schemathesis/extra/_server.py +0 -16
  206. schemathesis/extra/pytest_plugin.py +0 -251
  207. schemathesis/failures.py +0 -145
  208. schemathesis/fixups/__init__.py +0 -29
  209. schemathesis/fixups/fast_api.py +0 -30
  210. schemathesis/graphql.py +0 -5
  211. schemathesis/internal.py +0 -6
  212. schemathesis/lazy.py +0 -301
  213. schemathesis/models.py +0 -1113
  214. schemathesis/parameters.py +0 -91
  215. schemathesis/runner/__init__.py +0 -470
  216. schemathesis/runner/events.py +0 -242
  217. schemathesis/runner/impl/__init__.py +0 -3
  218. schemathesis/runner/impl/core.py +0 -791
  219. schemathesis/runner/impl/solo.py +0 -85
  220. schemathesis/runner/impl/threadpool.py +0 -367
  221. schemathesis/runner/serialization.py +0 -206
  222. schemathesis/serializers.py +0 -253
  223. schemathesis/service/__init__.py +0 -18
  224. schemathesis/service/auth.py +0 -10
  225. schemathesis/service/client.py +0 -62
  226. schemathesis/service/constants.py +0 -25
  227. schemathesis/service/events.py +0 -39
  228. schemathesis/service/handler.py +0 -46
  229. schemathesis/service/hosts.py +0 -74
  230. schemathesis/service/metadata.py +0 -42
  231. schemathesis/service/models.py +0 -21
  232. schemathesis/service/serialization.py +0 -184
  233. schemathesis/service/worker.py +0 -39
  234. schemathesis/specs/graphql/loaders.py +0 -215
  235. schemathesis/specs/openapi/constants.py +0 -7
  236. schemathesis/specs/openapi/expressions/context.py +0 -12
  237. schemathesis/specs/openapi/expressions/pointers.py +0 -29
  238. schemathesis/specs/openapi/filters.py +0 -44
  239. schemathesis/specs/openapi/links.py +0 -303
  240. schemathesis/specs/openapi/loaders.py +0 -453
  241. schemathesis/specs/openapi/parameters.py +0 -430
  242. schemathesis/specs/openapi/security.py +0 -129
  243. schemathesis/specs/openapi/validation.py +0 -24
  244. schemathesis/stateful.py +0 -358
  245. schemathesis/targets.py +0 -32
  246. schemathesis/types.py +0 -38
  247. schemathesis/utils.py +0 -475
  248. schemathesis-3.15.4.dist-info/METADATA +0 -202
  249. schemathesis-3.15.4.dist-info/RECORD +0 -99
  250. schemathesis-3.15.4.dist-info/entry_points.txt +0 -7
  251. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
@@ -0,0 +1,464 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import json
5
+ import sys
6
+ import threading
7
+ from dataclasses import dataclass
8
+ from http.cookies import SimpleCookie
9
+ from queue import Queue
10
+ from typing import IO, Callable, Iterator
11
+ from urllib.parse import parse_qsl, urlparse
12
+
13
+ import harfile
14
+
15
+ from schemathesis.cli.commands.run.context import ExecutionContext
16
+ from schemathesis.cli.commands.run.handlers.base import EventHandler, TextOutput, open_text_output
17
+ from schemathesis.config import ProjectConfig, ReportFormat, SchemathesisConfig
18
+ from schemathesis.core.output.sanitization import sanitize_url, sanitize_value
19
+ from schemathesis.core.transforms import deepclone
20
+ from schemathesis.core.transport import Response
21
+ from schemathesis.core.version import SCHEMATHESIS_VERSION
22
+ from schemathesis.engine import Status, events
23
+ from schemathesis.engine.recorder import CheckNode, Request, ScenarioRecorder
24
+ from schemathesis.generation.meta import CoveragePhaseData
25
+
26
+ # Wait until the worker terminates
27
+ WRITER_WORKER_JOIN_TIMEOUT = 1
28
+
29
+
30
+ @dataclass
31
+ class CassetteWriter(EventHandler):
32
+ """Write network interactions to a cassette."""
33
+
34
+ format: ReportFormat
35
+ output: TextOutput
36
+ config: ProjectConfig
37
+ queue: Queue
38
+ worker: threading.Thread
39
+
40
+ __slots__ = ("format", "output", "config", "queue", "worker")
41
+
42
+ def __init__(
43
+ self,
44
+ format: ReportFormat,
45
+ output: TextOutput,
46
+ config: ProjectConfig,
47
+ queue: Queue | None = None,
48
+ ) -> None:
49
+ self.format = format
50
+ self.output = output
51
+ self.config = config
52
+ self.queue = queue or Queue()
53
+
54
+ kwargs = {
55
+ "output": self.output,
56
+ "config": self.config,
57
+ "queue": self.queue,
58
+ }
59
+ writer: Callable
60
+ if self.format == ReportFormat.HAR:
61
+ writer = har_writer
62
+ else:
63
+ writer = vcr_writer
64
+
65
+ self.worker = threading.Thread(
66
+ name="SchemathesisCassetteWriter",
67
+ target=writer,
68
+ kwargs=kwargs,
69
+ )
70
+ self.worker.start()
71
+
72
+ def start(self, ctx: ExecutionContext) -> None:
73
+ self.queue.put(Initialize(seed=ctx.config.seed))
74
+
75
+ def handle_event(self, ctx: ExecutionContext, event: events.EngineEvent) -> None:
76
+ if isinstance(event, events.ScenarioFinished):
77
+ self.queue.put(Process(recorder=event.recorder))
78
+
79
+ def shutdown(self, ctx: ExecutionContext) -> None:
80
+ self.queue.put(Finalize())
81
+ self._stop_worker()
82
+
83
+ def _stop_worker(self) -> None:
84
+ self.worker.join(WRITER_WORKER_JOIN_TIMEOUT)
85
+
86
+
87
+ @dataclass
88
+ class Initialize:
89
+ """Start up, the first message to make preparations before proceeding the input data."""
90
+
91
+ seed: int | None
92
+
93
+ __slots__ = ("seed",)
94
+
95
+
96
+ @dataclass
97
+ class Process:
98
+ """A new chunk of data should be processed."""
99
+
100
+ recorder: ScenarioRecorder
101
+
102
+ __slots__ = ("recorder",)
103
+
104
+
105
+ @dataclass
106
+ class Finalize:
107
+ """The work is done and there will be no more messages to process."""
108
+
109
+ __slots__ = ()
110
+
111
+
112
+ def get_command_representation() -> str:
113
+ """Get how Schemathesis was run."""
114
+ # It is supposed to be executed from Schemathesis CLI, not via Click's `command.invoke`
115
+ if not sys.argv[0].endswith(("schemathesis", "st")):
116
+ return "<unknown entrypoint>"
117
+ args = " ".join(sys.argv[1:])
118
+ return f"st {args}"
119
+
120
+
121
+ def vcr_writer(output: TextOutput, config: ProjectConfig, queue: Queue) -> None:
122
+ """Write YAML to a file in an incremental manner."""
123
+ current_id = 1
124
+
125
+ def write_header_values(stream: IO, values: list[str]) -> None:
126
+ stream.writelines(f" - {json.dumps(v)}\n" for v in values)
127
+
128
+ if config.output.sanitization.enabled:
129
+ sanitization_keys = config.output.sanitization.keys_to_sanitize
130
+ sensitive_markers = config.output.sanitization.sensitive_markers
131
+ replacement = config.output.sanitization.replacement
132
+
133
+ def write_headers(stream: IO, headers: dict[str, list[str]]) -> None:
134
+ for name, values in headers.items():
135
+ lower_name = name.lower()
136
+ stream.write(f' "{name}":\n')
137
+
138
+ # Sanitize inline if needed
139
+ if lower_name in sanitization_keys or any(marker in lower_name for marker in sensitive_markers):
140
+ stream.write(f" - {json.dumps(replacement)}\n")
141
+ else:
142
+ write_header_values(stream, values)
143
+ else:
144
+
145
+ def write_headers(stream: IO, headers: dict[str, list[str]]) -> None:
146
+ for name, values in headers.items():
147
+ stream.write(f' "{name}":\n')
148
+ write_header_values(stream, values)
149
+
150
+ def write_checks(stream: IO, checks: list[CheckNode]) -> None:
151
+ if not checks:
152
+ stream.write("\n checks: []")
153
+ return
154
+
155
+ stream.write("\n checks:\n")
156
+ for check in checks:
157
+ message = check.failure_info.failure.title if check.failure_info else None
158
+ message_str = "~" if message is None else repr(message)
159
+ stream.write(
160
+ f" - name: '{check.name}'\n"
161
+ f" status: '{check.status.name.upper()}'\n"
162
+ f" message: {message_str}\n"
163
+ )
164
+
165
+ if config.reports.preserve_bytes:
166
+
167
+ def write_request_body(stream: IO, request: Request) -> None:
168
+ if request.encoded_body is not None:
169
+ stream.write(f"\n body:\n encoding: 'utf-8'\n base64_string: '{request.encoded_body}'")
170
+
171
+ def write_response_body(stream: IO, response: Response) -> None:
172
+ if response.encoded_body is not None:
173
+ stream.write(
174
+ f" body:\n encoding: '{response.encoding}'\n base64_string: '{response.encoded_body}'"
175
+ )
176
+ else:
177
+
178
+ def write_request_body(stream: IO, request: Request) -> None:
179
+ if request.body is not None:
180
+ string = request.body.decode("utf8", "replace")
181
+ stream.write("\n body:\n encoding: 'utf-8'\n string: ")
182
+ write_double_quoted(stream, string)
183
+
184
+ def write_response_body(stream: IO, response: Response) -> None:
185
+ if response.content is not None:
186
+ encoding = response.encoding or "utf8"
187
+ string = response.content.decode(encoding, "replace")
188
+ stream.write(f" body:\n encoding: '{encoding}'\n string: ")
189
+ write_double_quoted(stream, string)
190
+
191
+ with open_text_output(output) as stream:
192
+ while True:
193
+ item = queue.get()
194
+ if isinstance(item, Process):
195
+ for case_id, interaction in item.recorder.interactions.items():
196
+ case = item.recorder.cases[case_id]
197
+
198
+ # Determine status and checks
199
+ if interaction.response is not None:
200
+ if case_id in item.recorder.checks:
201
+ checks = item.recorder.checks[case_id]
202
+ status = Status.SUCCESS
203
+ for check in checks:
204
+ if check.status == Status.FAILURE:
205
+ status = check.status
206
+ break
207
+ else:
208
+ checks = []
209
+ status = Status.SKIP
210
+ else:
211
+ checks = []
212
+ status = Status.ERROR
213
+
214
+ # Write interaction header
215
+ stream.write(f"\n- id: '{case_id}'\n status: '{status.name}'")
216
+
217
+ # Write metadata if present
218
+ meta = case.value.meta
219
+ if meta is not None:
220
+ stream.write(
221
+ f"\n generation:\n"
222
+ f" time: {meta.generation.time}\n"
223
+ f" mode: {meta.generation.mode.value}\n"
224
+ f" components:"
225
+ )
226
+
227
+ for kind, info in meta.components.items():
228
+ stream.write(f"\n {kind.value}:\n mode: '{info.mode.value}'")
229
+
230
+ stream.write(f"\n phase:\n name: '{meta.phase.name.value}'\n data: ")
231
+
232
+ if isinstance(meta.phase.data, CoveragePhaseData):
233
+ stream.write("\n description: ")
234
+ write_double_quoted(stream, meta.phase.data.description)
235
+ stream.write("\n location: ")
236
+ write_double_quoted(stream, meta.phase.data.location)
237
+ stream.write("\n parameter: ")
238
+ if meta.phase.data.parameter is not None:
239
+ write_double_quoted(stream, meta.phase.data.parameter)
240
+ else:
241
+ stream.write("null")
242
+ stream.write("\n parameter_location: ")
243
+ if meta.phase.data.parameter_location is not None:
244
+ write_double_quoted(stream, meta.phase.data.parameter_location)
245
+ else:
246
+ stream.write("null")
247
+ else:
248
+ stream.write("{}")
249
+ else:
250
+ stream.write("\n metadata: null")
251
+
252
+ # Sanitize URL if needed
253
+ if config.output.sanitization.enabled:
254
+ uri = sanitize_url(interaction.request.uri, config=config.output.sanitization)
255
+ else:
256
+ uri = interaction.request.uri
257
+
258
+ recorded_at = datetime.datetime.fromtimestamp(
259
+ interaction.timestamp, datetime.timezone.utc
260
+ ).isoformat()
261
+
262
+ stream.write(f"\n recorded_at: '{recorded_at}'")
263
+ write_checks(stream, checks)
264
+ stream.write(
265
+ f"\n request:\n uri: '{uri}'\n method: '{interaction.request.method}'\n headers:\n"
266
+ )
267
+ write_headers(stream, interaction.request.headers)
268
+ write_request_body(stream, interaction.request)
269
+
270
+ # Write response
271
+ if interaction.response is not None:
272
+ stream.write(
273
+ f"\n response:\n"
274
+ f" status:\n"
275
+ f" code: '{interaction.response.status_code}'\n"
276
+ f" message: {json.dumps(interaction.response.message)}\n"
277
+ f" elapsed: '{interaction.response.elapsed}'\n"
278
+ f" headers:\n"
279
+ )
280
+ write_headers(stream, interaction.response.headers)
281
+ stream.write("\n")
282
+ write_response_body(stream, interaction.response)
283
+ stream.write(f"\n http_version: '{interaction.response.http_version}'")
284
+ else:
285
+ stream.write("\n response: null")
286
+
287
+ current_id += 1
288
+ elif isinstance(item, Initialize):
289
+ stream.write(
290
+ f"command: '{get_command_representation()}'\n"
291
+ f"recorded_with: 'Schemathesis {SCHEMATHESIS_VERSION}'\n"
292
+ f"seed: {item.seed}\n"
293
+ f"http_interactions:"
294
+ )
295
+ else:
296
+ break
297
+
298
+
299
+ def write_double_quoted(stream: IO, text: str | None) -> None:
300
+ """Writes a valid YAML string enclosed in double quotes."""
301
+ from yaml.emitter import Emitter
302
+
303
+ if text is None:
304
+ stream.write("null")
305
+ return
306
+
307
+ # Adapted from `yaml.Emitter.write_double_quoted`:
308
+ # - Doesn't split the string, therefore doesn't track the current column
309
+ # - Doesn't encode the input
310
+ # - Allows Unicode unconditionally
311
+ stream.write('"')
312
+ start = end = 0
313
+ length = len(text)
314
+ while end <= length:
315
+ ch = None
316
+ if end < length:
317
+ ch = text[end]
318
+ if (
319
+ ch is None
320
+ or ch in '"\\\x85\u2028\u2029\ufeff'
321
+ or not ("\x20" <= ch <= "\x7e" or ("\xa0" <= ch <= "\ud7ff" or "\ue000" <= ch <= "\ufffd"))
322
+ ):
323
+ if start < end:
324
+ stream.write(text[start:end])
325
+ start = end
326
+ if ch is not None:
327
+ # Escape character
328
+ if ch in Emitter.ESCAPE_REPLACEMENTS:
329
+ data = "\\" + Emitter.ESCAPE_REPLACEMENTS[ch]
330
+ elif ch <= "\xff":
331
+ data = f"\\x{ord(ch):02X}"
332
+ elif ch <= "\uffff":
333
+ data = f"\\u{ord(ch):04X}"
334
+ else:
335
+ data = f"\\U{ord(ch):08X}"
336
+ stream.write(data)
337
+ start = end + 1
338
+ end += 1
339
+ stream.write('"')
340
+
341
+
342
+ def har_writer(output: TextOutput, config: SchemathesisConfig, queue: Queue) -> None:
343
+ with harfile.open(output) as har:
344
+ while True:
345
+ item = queue.get()
346
+ if isinstance(item, Process):
347
+ for interaction in item.recorder.interactions.values():
348
+ if config.output.sanitization.enabled:
349
+ uri = sanitize_url(interaction.request.uri, config=config.output.sanitization)
350
+ else:
351
+ uri = interaction.request.uri
352
+ query_params = urlparse(uri).query
353
+ if interaction.request.body is not None:
354
+ post_data = harfile.PostData(
355
+ mimeType=interaction.request.headers.get("Content-Type", [""])[0],
356
+ text=interaction.request.encoded_body
357
+ if config.reports.preserve_bytes
358
+ else interaction.request.body.decode("utf-8", "replace"),
359
+ )
360
+ else:
361
+ post_data = None
362
+ if interaction.response is not None:
363
+ content_type = interaction.response.headers.get("Content-Type", [""])[0]
364
+ content = harfile.Content(
365
+ size=interaction.response.body_size or 0,
366
+ mimeType=content_type,
367
+ text=interaction.response.encoded_body
368
+ if config.reports.preserve_bytes
369
+ else interaction.response.content.decode("utf-8", "replace")
370
+ if interaction.response.content is not None
371
+ else None,
372
+ encoding="base64"
373
+ if interaction.response.content is not None and config.reports.preserve_bytes
374
+ else None,
375
+ )
376
+ http_version = f"HTTP/{interaction.response.http_version}"
377
+ if config.output.sanitization.enabled:
378
+ headers = deepclone(interaction.response.headers)
379
+ sanitize_value(headers, config=config.output.sanitization)
380
+ else:
381
+ headers = interaction.response.headers
382
+ response = harfile.Response(
383
+ status=interaction.response.status_code,
384
+ httpVersion=http_version,
385
+ statusText=interaction.response.message,
386
+ headers=[harfile.Record(name=name, value=values[0]) for name, values in headers.items()],
387
+ cookies=_extract_cookies(headers.get("Set-Cookie", [])),
388
+ content=content,
389
+ headersSize=_headers_size(headers),
390
+ bodySize=interaction.response.body_size or 0,
391
+ redirectURL=headers.get("Location", [""])[0],
392
+ )
393
+ time = round(interaction.response.elapsed * 1000, 2)
394
+ else:
395
+ response = HARFILE_NO_RESPONSE
396
+ time = 0
397
+ http_version = ""
398
+
399
+ if config.output.sanitization.enabled:
400
+ headers = deepclone(interaction.request.headers)
401
+ sanitize_value(headers, config=config.output.sanitization)
402
+ else:
403
+ headers = interaction.request.headers
404
+ started_datetime = datetime.datetime.fromtimestamp(
405
+ interaction.timestamp, datetime.timezone.utc
406
+ ).isoformat()
407
+ har.add_entry(
408
+ startedDateTime=started_datetime,
409
+ time=time,
410
+ request=harfile.Request(
411
+ method=interaction.request.method.upper(),
412
+ url=uri,
413
+ httpVersion=http_version,
414
+ headers=[harfile.Record(name=name, value=values[0]) for name, values in headers.items()],
415
+ queryString=[
416
+ harfile.Record(name=name, value=value)
417
+ for name, value in parse_qsl(query_params, keep_blank_values=True)
418
+ ],
419
+ cookies=_extract_cookies(headers.get("Cookie", [])),
420
+ headersSize=_headers_size(headers),
421
+ bodySize=interaction.request.body_size or 0,
422
+ postData=post_data,
423
+ ),
424
+ response=response,
425
+ timings=harfile.Timings(send=0, wait=0, receive=time, blocked=0, dns=0, connect=0, ssl=0),
426
+ )
427
+ elif isinstance(item, Finalize):
428
+ break
429
+
430
+
431
+ HARFILE_NO_RESPONSE = harfile.Response(
432
+ status=0,
433
+ httpVersion="",
434
+ statusText="",
435
+ headers=[],
436
+ cookies=[],
437
+ content=harfile.Content(),
438
+ )
439
+
440
+
441
+ def _headers_size(headers: dict[str, list[str]]) -> int:
442
+ size = 0
443
+ for name, values in headers.items():
444
+ # 4 is for ": " and "\r\n"
445
+ size += len(name) + 4 + len(values[0])
446
+ return size
447
+
448
+
449
+ def _extract_cookies(headers: list[str]) -> list[harfile.Cookie]:
450
+ return [cookie for items in headers for item in items for cookie in _cookie_to_har(item)]
451
+
452
+
453
+ def _cookie_to_har(cookie: str) -> Iterator[harfile.Cookie]:
454
+ parsed = SimpleCookie(cookie)
455
+ for name, data in parsed.items():
456
+ yield harfile.Cookie(
457
+ name=name,
458
+ value=data.value,
459
+ path=data["path"] or None,
460
+ domain=data["domain"] or None,
461
+ expires=data["expires"] or None,
462
+ httpOnly=data["httponly"] or None,
463
+ secure=data["secure"] or None,
464
+ )
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import platform
4
+ from dataclasses import dataclass
5
+ from typing import Iterable
6
+
7
+ from junit_xml import TestCase, TestSuite, to_xml_report_file
8
+
9
+ from schemathesis.cli.commands.run.context import ExecutionContext, GroupedFailures
10
+ from schemathesis.cli.commands.run.handlers.base import EventHandler, TextOutput, open_text_output
11
+ from schemathesis.core.failures import format_failures
12
+ from schemathesis.engine import Status, events
13
+
14
+
15
+ @dataclass
16
+ class JunitXMLHandler(EventHandler):
17
+ output: TextOutput
18
+ test_cases: dict
19
+
20
+ __slots__ = ("path", "test_cases")
21
+
22
+ def __init__(self, output: TextOutput, test_cases: dict | None = None) -> None:
23
+ self.output = output
24
+ self.test_cases = test_cases or {}
25
+
26
+ def handle_event(self, ctx: ExecutionContext, event: events.EngineEvent) -> None:
27
+ if isinstance(event, events.ScenarioFinished):
28
+ label = event.recorder.label
29
+ test_case = self.get_or_create_test_case(label)
30
+ test_case.elapsed_sec += event.elapsed_time
31
+ if event.status == Status.FAILURE and label in ctx.statistic.failures:
32
+ add_failure(test_case, ctx.statistic.failures[label].values(), ctx)
33
+ elif event.status == Status.SKIP and event.skip_reason is not None:
34
+ test_case.add_skipped_info(output=event.skip_reason)
35
+ elif isinstance(event, events.NonFatalError):
36
+ test_case = self.get_or_create_test_case(event.label)
37
+ test_case.add_error_info(output=event.info.format())
38
+ elif isinstance(event, events.EngineFinished):
39
+ test_suites = [
40
+ TestSuite("schemathesis", test_cases=list(self.test_cases.values()), hostname=platform.node())
41
+ ]
42
+ with open_text_output(self.output) as fd:
43
+ to_xml_report_file(file_descriptor=fd, test_suites=test_suites, prettyprint=True, encoding="utf-8")
44
+
45
+ def get_or_create_test_case(self, label: str) -> TestCase:
46
+ return self.test_cases.setdefault(label, TestCase(label, elapsed_sec=0.0, allow_multiple_subelements=True))
47
+
48
+
49
+ def add_failure(test_case: TestCase, checks: Iterable[GroupedFailures], context: ExecutionContext) -> None:
50
+ messages = [
51
+ format_failures(
52
+ case_id=f"{idx}. Test Case ID: {group.case_id}",
53
+ response=group.response,
54
+ failures=group.failures,
55
+ curl=group.code_sample,
56
+ config=context.config.output,
57
+ )
58
+ for idx, group in enumerate(checks, 1)
59
+ ]
60
+ test_case.add_failure_info(message="\n\n".join(messages))