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
@@ -0,0 +1,494 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import enum
5
+ import json
6
+ import sys
7
+ import threading
8
+ from dataclasses import dataclass, field
9
+ from http.cookies import SimpleCookie
10
+ from queue import Queue
11
+ from typing import IO, Callable, Iterator
12
+ from urllib.parse import parse_qsl, urlparse
13
+
14
+ import click
15
+ import harfile
16
+
17
+ from schemathesis.cli.commands.run.context import ExecutionContext
18
+ from schemathesis.cli.commands.run.handlers.base import EventHandler
19
+ from schemathesis.core.output.sanitization import sanitize_url, sanitize_value
20
+ from schemathesis.core.transforms import deepclone
21
+ from schemathesis.core.transport import Response
22
+ from schemathesis.core.version import SCHEMATHESIS_VERSION
23
+ from schemathesis.engine import Status, events
24
+ from schemathesis.engine.recorder import CheckNode, Request, ScenarioRecorder
25
+ from schemathesis.generation.meta import CoveragePhaseData
26
+
27
+ # Wait until the worker terminates
28
+ WRITER_WORKER_JOIN_TIMEOUT = 1
29
+
30
+
31
+ class CassetteFormat(str, enum.Enum):
32
+ """Type of the cassette."""
33
+
34
+ VCR = "vcr"
35
+ HAR = "har"
36
+
37
+ @classmethod
38
+ def from_str(cls, value: str) -> CassetteFormat:
39
+ try:
40
+ return cls[value.upper()]
41
+ except KeyError:
42
+ available_formats = ", ".join(cls)
43
+ raise ValueError(
44
+ f"Invalid value for cassette format: {value}. Available formats: {available_formats}"
45
+ ) from None
46
+
47
+
48
+ @dataclass
49
+ class CassetteConfig:
50
+ path: click.utils.LazyFile
51
+ format: CassetteFormat = CassetteFormat.VCR
52
+ preserve_exact_body_bytes: bool = False
53
+ sanitize_output: bool = True
54
+
55
+
56
+ @dataclass
57
+ class CassetteWriter(EventHandler):
58
+ """Write network interactions to a cassette."""
59
+
60
+ config: CassetteConfig
61
+ queue: Queue = field(default_factory=Queue)
62
+ worker: threading.Thread = field(init=False)
63
+
64
+ def __post_init__(self) -> None:
65
+ kwargs = {"config": self.config, "queue": self.queue}
66
+ writer: Callable
67
+ if self.config.format == CassetteFormat.HAR:
68
+ writer = har_writer
69
+ else:
70
+ writer = vcr_writer
71
+ self.worker = threading.Thread(name="SchemathesisCassetteWriter", target=writer, kwargs=kwargs)
72
+ self.worker.start()
73
+
74
+ def start(self, ctx: ExecutionContext) -> None:
75
+ self.queue.put(Initialize(seed=ctx.seed))
76
+
77
+ def handle_event(self, ctx: ExecutionContext, event: events.EngineEvent) -> None:
78
+ if isinstance(event, events.ScenarioFinished):
79
+ self.queue.put(Process(recorder=event.recorder))
80
+ elif isinstance(event, events.EngineFinished):
81
+ self.shutdown()
82
+
83
+ def shutdown(self) -> None:
84
+ self.queue.put(Finalize())
85
+ self._stop_worker()
86
+
87
+ def _stop_worker(self) -> None:
88
+ self.worker.join(WRITER_WORKER_JOIN_TIMEOUT)
89
+
90
+
91
+ @dataclass
92
+ class Initialize:
93
+ """Start up, the first message to make preparations before proceeding the input data."""
94
+
95
+ seed: int | None
96
+
97
+ __slots__ = ("seed",)
98
+
99
+
100
+ @dataclass
101
+ class Process:
102
+ """A new chunk of data should be processed."""
103
+
104
+ recorder: ScenarioRecorder
105
+
106
+ __slots__ = ("recorder",)
107
+
108
+
109
+ @dataclass
110
+ class Finalize:
111
+ """The work is done and there will be no more messages to process."""
112
+
113
+ __slots__ = ()
114
+
115
+
116
+ def get_command_representation() -> str:
117
+ """Get how Schemathesis was run."""
118
+ # It is supposed to be executed from Schemathesis CLI, not via Click's `command.invoke`
119
+ if not sys.argv[0].endswith(("schemathesis", "st")):
120
+ return "<unknown entrypoint>"
121
+ args = " ".join(sys.argv[1:])
122
+ return f"st {args}"
123
+
124
+
125
+ def vcr_writer(config: CassetteConfig, queue: Queue) -> None:
126
+ """Write YAML to a file in an incremental manner.
127
+
128
+ This implementation doesn't use `pyyaml` package and composes YAML manually as string due to the following reasons:
129
+ - It is much faster. The string-based approach gives only ~2.5% time overhead when `yaml.CDumper` has ~11.2%;
130
+ - Implementation complexity. We have a quite simple format where almost all values are strings, and it is much
131
+ simpler to implement it with string composition rather than with adjusting `yaml.Serializer` to emit explicit
132
+ types. Another point is that with `pyyaml` we need to emit events and handle some low-level details like
133
+ providing tags, anchors to have incremental writing, with primitive types it is much simpler.
134
+ """
135
+ current_id = 1
136
+ stream = config.path.open()
137
+
138
+ def format_header_values(values: list[str]) -> str:
139
+ return "\n".join(f" - {json.dumps(v)}" for v in values)
140
+
141
+ if config.sanitize_output:
142
+
143
+ def format_headers(headers: dict[str, list[str]]) -> str:
144
+ headers = deepclone(headers)
145
+ sanitize_value(headers)
146
+ return "\n".join(f' "{name}":\n{format_header_values(values)}' for name, values in headers.items())
147
+
148
+ else:
149
+
150
+ def format_headers(headers: dict[str, list[str]]) -> str:
151
+ return "\n".join(f' "{name}":\n{format_header_values(values)}' for name, values in headers.items())
152
+
153
+ def format_check_message(message: str | None) -> str:
154
+ return "~" if message is None else f"{message!r}"
155
+
156
+ def format_checks(checks: list[CheckNode]) -> str:
157
+ if not checks:
158
+ return "\n checks: []"
159
+ items = "\n".join(
160
+ f" - name: '{check.name}'\n status: '{check.status.name.upper()}'\n message: {format_check_message(check.failure_info.failure.title if check.failure_info else None)}"
161
+ for check in checks
162
+ )
163
+ return f"""
164
+ checks:
165
+ {items}"""
166
+
167
+ if config.preserve_exact_body_bytes:
168
+
169
+ def format_request_body(output: IO, request: Request) -> None:
170
+ if request.encoded_body is not None:
171
+ output.write(
172
+ f"""
173
+ body:
174
+ encoding: 'utf-8'
175
+ base64_string: '{request.encoded_body}'"""
176
+ )
177
+
178
+ def format_response_body(output: IO, response: Response) -> None:
179
+ if response.encoded_body is not None:
180
+ output.write(
181
+ f""" body:
182
+ encoding: '{response.encoding}'
183
+ base64_string: '{response.encoded_body}'"""
184
+ )
185
+
186
+ else:
187
+
188
+ def format_request_body(output: IO, request: Request) -> None:
189
+ if request.body is not None:
190
+ string = request.body.decode("utf8", "replace")
191
+ output.write(
192
+ """
193
+ body:
194
+ encoding: 'utf-8'
195
+ string: """
196
+ )
197
+ write_double_quoted(output, string)
198
+
199
+ def format_response_body(output: IO, response: Response) -> None:
200
+ if response.content is not None:
201
+ encoding = response.encoding or "utf8"
202
+ string = response.content.decode(encoding, "replace")
203
+ output.write(
204
+ f""" body:
205
+ encoding: '{encoding}'
206
+ string: """
207
+ )
208
+ write_double_quoted(output, string)
209
+
210
+ while True:
211
+ item = queue.get()
212
+ if isinstance(item, Initialize):
213
+ stream.write(
214
+ f"""command: '{get_command_representation()}'
215
+ recorded_with: 'Schemathesis {SCHEMATHESIS_VERSION}'
216
+ seed: {item.seed}
217
+ http_interactions:"""
218
+ )
219
+ elif isinstance(item, Process):
220
+ for case_id, interaction in item.recorder.interactions.items():
221
+ case = item.recorder.cases[case_id]
222
+ if interaction.response is not None:
223
+ if case_id in item.recorder.checks:
224
+ checks = item.recorder.checks[case_id]
225
+ status = Status.SUCCESS
226
+ for check in checks:
227
+ if check.status == Status.FAILURE:
228
+ status = check.status
229
+ break
230
+ else:
231
+ # NOTE: Checks recording could be skipped if Schemathesis start skipping just
232
+ # discovered failures in order to get past them and potentially discover more failures
233
+ checks = []
234
+ status = Status.SKIP
235
+ else:
236
+ checks = []
237
+ status = Status.ERROR
238
+ # Body payloads are handled via separate `stream.write` calls to avoid some allocations
239
+ stream.write(
240
+ f"""\n- id: '{case_id}'
241
+ status: '{status.name}'"""
242
+ )
243
+ meta = case.value.meta
244
+ if meta is not None:
245
+ # Start metadata block
246
+ stream.write(f"""
247
+ generation:
248
+ time: {meta.generation.time}
249
+ mode: {meta.generation.mode.value}
250
+ components:""")
251
+
252
+ # Write components
253
+ for kind, info in meta.components.items():
254
+ stream.write(f"""
255
+ {kind.value}:
256
+ mode: '{info.mode.value}'""")
257
+ # Write phase info
258
+ stream.write("\n phase:")
259
+ stream.write(f"\n name: '{meta.phase.name.value}'")
260
+ stream.write("\n data: ")
261
+
262
+ # Write phase-specific data
263
+ if isinstance(meta.phase.data, CoveragePhaseData):
264
+ stream.write("""
265
+ description: """)
266
+ write_double_quoted(stream, meta.phase.data.description)
267
+ stream.write("""
268
+ location: """)
269
+ write_double_quoted(stream, meta.phase.data.location)
270
+ stream.write("""
271
+ parameter: """)
272
+ if meta.phase.data.parameter is not None:
273
+ write_double_quoted(stream, meta.phase.data.parameter)
274
+ else:
275
+ stream.write("null")
276
+ stream.write("""
277
+ parameter_location: """)
278
+ if meta.phase.data.parameter_location is not None:
279
+ write_double_quoted(stream, meta.phase.data.parameter_location)
280
+ else:
281
+ stream.write("null")
282
+ else:
283
+ # Empty objects for these phases
284
+ stream.write("{}")
285
+ else:
286
+ stream.write("null")
287
+
288
+ if config.sanitize_output:
289
+ uri = sanitize_url(interaction.request.uri)
290
+ else:
291
+ uri = interaction.request.uri
292
+ recorded_at = datetime.datetime.fromtimestamp(interaction.timestamp, datetime.timezone.utc).isoformat()
293
+ stream.write(
294
+ f"""
295
+ recorded_at: '{recorded_at}'{format_checks(checks)}
296
+ request:
297
+ uri: '{uri}'
298
+ method: '{interaction.request.method}'
299
+ headers:
300
+ {format_headers(interaction.request.headers)}"""
301
+ )
302
+ format_request_body(stream, interaction.request)
303
+ if interaction.response is not None:
304
+ stream.write(
305
+ f"""
306
+ response:
307
+ status:
308
+ code: '{interaction.response.status_code}'
309
+ message: {json.dumps(interaction.response.message)}
310
+ elapsed: '{interaction.response.elapsed}'
311
+ headers:
312
+ {format_headers(interaction.response.headers)}
313
+ """
314
+ )
315
+ format_response_body(stream, interaction.response)
316
+ stream.write(
317
+ f"""
318
+ http_version: '{interaction.response.http_version}'"""
319
+ )
320
+ else:
321
+ stream.write("""
322
+ response: null""")
323
+ current_id += 1
324
+ else:
325
+ break
326
+ config.path.close()
327
+
328
+
329
+ def write_double_quoted(stream: IO, text: str | None) -> None:
330
+ """Writes a valid YAML string enclosed in double quotes."""
331
+ from yaml.emitter import Emitter
332
+
333
+ if text is None:
334
+ stream.write("null")
335
+ return
336
+
337
+ # Adapted from `yaml.Emitter.write_double_quoted`:
338
+ # - Doesn't split the string, therefore doesn't track the current column
339
+ # - Doesn't encode the input
340
+ # - Allows Unicode unconditionally
341
+ stream.write('"')
342
+ start = end = 0
343
+ length = len(text)
344
+ while end <= length:
345
+ ch = None
346
+ if end < length:
347
+ ch = text[end]
348
+ if (
349
+ ch is None
350
+ or ch in '"\\\x85\u2028\u2029\ufeff'
351
+ or not ("\x20" <= ch <= "\x7e" or ("\xa0" <= ch <= "\ud7ff" or "\ue000" <= ch <= "\ufffd"))
352
+ ):
353
+ if start < end:
354
+ stream.write(text[start:end])
355
+ start = end
356
+ if ch is not None:
357
+ # Escape character
358
+ if ch in Emitter.ESCAPE_REPLACEMENTS:
359
+ data = "\\" + Emitter.ESCAPE_REPLACEMENTS[ch]
360
+ elif ch <= "\xff":
361
+ data = f"\\x{ord(ch):02X}"
362
+ elif ch <= "\uffff":
363
+ data = f"\\u{ord(ch):04X}"
364
+ else:
365
+ data = f"\\U{ord(ch):08X}"
366
+ stream.write(data)
367
+ start = end + 1
368
+ end += 1
369
+ stream.write('"')
370
+
371
+
372
+ def har_writer(config: CassetteConfig, queue: Queue) -> None:
373
+ with harfile.open(config.path) as har:
374
+ while True:
375
+ item = queue.get()
376
+ if isinstance(item, Process):
377
+ for interaction in item.recorder.interactions.values():
378
+ if config.sanitize_output:
379
+ uri = sanitize_url(interaction.request.uri)
380
+ else:
381
+ uri = interaction.request.uri
382
+ query_params = urlparse(uri).query
383
+ if interaction.request.body is not None:
384
+ post_data = harfile.PostData(
385
+ mimeType=interaction.request.headers.get("Content-Type", [""])[0],
386
+ text=interaction.request.encoded_body
387
+ if config.preserve_exact_body_bytes
388
+ else interaction.request.body.decode("utf-8", "replace"),
389
+ )
390
+ else:
391
+ post_data = None
392
+ if interaction.response is not None:
393
+ content_type = interaction.response.headers.get("Content-Type", [""])[0]
394
+ content = harfile.Content(
395
+ size=interaction.response.body_size or 0,
396
+ mimeType=content_type,
397
+ text=interaction.response.encoded_body
398
+ if config.preserve_exact_body_bytes
399
+ else interaction.response.content.decode("utf-8", "replace")
400
+ if interaction.response.content is not None
401
+ else None,
402
+ encoding="base64"
403
+ if interaction.response.content is not None and config.preserve_exact_body_bytes
404
+ else None,
405
+ )
406
+ http_version = f"HTTP/{interaction.response.http_version}"
407
+ if config.sanitize_output:
408
+ headers = deepclone(interaction.response.headers)
409
+ sanitize_value(headers)
410
+ else:
411
+ headers = interaction.response.headers
412
+ response = harfile.Response(
413
+ status=interaction.response.status_code,
414
+ httpVersion=http_version,
415
+ statusText=interaction.response.message,
416
+ headers=[harfile.Record(name=name, value=values[0]) for name, values in headers.items()],
417
+ cookies=_extract_cookies(headers.get("Set-Cookie", [])),
418
+ content=content,
419
+ headersSize=_headers_size(headers),
420
+ bodySize=interaction.response.body_size or 0,
421
+ redirectURL=headers.get("Location", [""])[0],
422
+ )
423
+ time = round(interaction.response.elapsed * 1000, 2)
424
+ else:
425
+ response = HARFILE_NO_RESPONSE
426
+ time = 0
427
+ http_version = ""
428
+
429
+ if config.sanitize_output:
430
+ headers = deepclone(interaction.request.headers)
431
+ sanitize_value(headers)
432
+ else:
433
+ headers = interaction.request.headers
434
+ started_datetime = datetime.datetime.fromtimestamp(
435
+ interaction.timestamp, datetime.timezone.utc
436
+ ).isoformat()
437
+ har.add_entry(
438
+ startedDateTime=started_datetime,
439
+ time=time,
440
+ request=harfile.Request(
441
+ method=interaction.request.method.upper(),
442
+ url=uri,
443
+ httpVersion=http_version,
444
+ headers=[harfile.Record(name=name, value=values[0]) for name, values in headers.items()],
445
+ queryString=[
446
+ harfile.Record(name=name, value=value)
447
+ for name, value in parse_qsl(query_params, keep_blank_values=True)
448
+ ],
449
+ cookies=_extract_cookies(headers.get("Cookie", [])),
450
+ headersSize=_headers_size(headers),
451
+ bodySize=interaction.request.body_size or 0,
452
+ postData=post_data,
453
+ ),
454
+ response=response,
455
+ timings=harfile.Timings(send=0, wait=0, receive=time, blocked=0, dns=0, connect=0, ssl=0),
456
+ )
457
+ elif isinstance(item, Finalize):
458
+ break
459
+
460
+
461
+ HARFILE_NO_RESPONSE = harfile.Response(
462
+ status=0,
463
+ httpVersion="",
464
+ statusText="",
465
+ headers=[],
466
+ cookies=[],
467
+ content=harfile.Content(),
468
+ )
469
+
470
+
471
+ def _headers_size(headers: dict[str, list[str]]) -> int:
472
+ size = 0
473
+ for name, values in headers.items():
474
+ # 4 is for ": " and "\r\n"
475
+ size += len(name) + 4 + len(values[0])
476
+ return size
477
+
478
+
479
+ def _extract_cookies(headers: list[str]) -> list[harfile.Cookie]:
480
+ return [cookie for items in headers for item in items for cookie in _cookie_to_har(item)]
481
+
482
+
483
+ def _cookie_to_har(cookie: str) -> Iterator[harfile.Cookie]:
484
+ parsed = SimpleCookie(cookie)
485
+ for name, data in parsed.items():
486
+ yield harfile.Cookie(
487
+ name=name,
488
+ value=data.value,
489
+ path=data["path"] or None,
490
+ domain=data["domain"] or None,
491
+ expires=data["expires"] or None,
492
+ httpOnly=data["httponly"] or None,
493
+ secure=data["secure"] or None,
494
+ )
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import platform
4
+ from dataclasses import dataclass, field
5
+ from typing import Iterable
6
+
7
+ from click.utils import LazyFile
8
+ from junit_xml import TestCase, TestSuite, to_xml_report_file
9
+
10
+ from schemathesis.cli.commands.run.context import ExecutionContext, GroupedFailures
11
+ from schemathesis.cli.commands.run.handlers.base import EventHandler
12
+ from schemathesis.core.failures import format_failures
13
+ from schemathesis.engine import Status, events
14
+
15
+
16
+ @dataclass
17
+ class JunitXMLHandler(EventHandler):
18
+ file_handle: LazyFile
19
+ test_cases: dict = field(default_factory=dict)
20
+
21
+ def handle_event(self, ctx: ExecutionContext, event: events.EngineEvent) -> None:
22
+ if isinstance(event, events.ScenarioFinished):
23
+ label = event.recorder.label
24
+ test_case = self.get_or_create_test_case(label)
25
+ test_case.elapsed_sec += event.elapsed_time
26
+ if event.status == Status.FAILURE:
27
+ add_failure(test_case, ctx.statistic.failures[label].values(), ctx)
28
+ elif event.status == Status.SKIP:
29
+ test_case.add_skipped_info(output=event.skip_reason)
30
+ elif isinstance(event, events.NonFatalError):
31
+ test_case = self.get_or_create_test_case(event.label)
32
+ test_case.add_error_info(output=event.info.format())
33
+ elif isinstance(event, events.EngineFinished):
34
+ test_suites = [
35
+ TestSuite("schemathesis", test_cases=list(self.test_cases.values()), hostname=platform.node())
36
+ ]
37
+ to_xml_report_file(file_descriptor=self.file_handle, test_suites=test_suites, prettyprint=True)
38
+
39
+ def get_or_create_test_case(self, label: str) -> TestCase:
40
+ return self.test_cases.setdefault(label, TestCase(label, elapsed_sec=0.0, allow_multiple_subelements=True))
41
+
42
+
43
+ def add_failure(test_case: TestCase, checks: Iterable[GroupedFailures], context: ExecutionContext) -> None:
44
+ messages = [
45
+ format_failures(
46
+ case_id=f"{idx}. Test Case ID: {group.case_id}",
47
+ response=group.response,
48
+ failures=group.failures,
49
+ curl=group.code_sample,
50
+ config=context.output_config,
51
+ )
52
+ for idx, group in enumerate(checks, 1)
53
+ ]
54
+ test_case.add_failure_info(message="\n\n".join(messages))