schemathesis 3.39.7__py3-none-any.whl → 4.0.0a2__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 (229) hide show
  1. schemathesis/__init__.py +27 -65
  2. schemathesis/auths.py +26 -68
  3. schemathesis/checks.py +130 -60
  4. schemathesis/cli/__init__.py +5 -2105
  5. schemathesis/cli/commands/__init__.py +37 -0
  6. schemathesis/cli/commands/run/__init__.py +662 -0
  7. schemathesis/cli/commands/run/checks.py +80 -0
  8. schemathesis/cli/commands/run/context.py +117 -0
  9. schemathesis/cli/commands/run/events.py +30 -0
  10. schemathesis/cli/commands/run/executor.py +141 -0
  11. schemathesis/cli/commands/run/filters.py +202 -0
  12. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  13. schemathesis/cli/commands/run/handlers/base.py +18 -0
  14. schemathesis/cli/{cassettes.py → commands/run/handlers/cassettes.py} +178 -247
  15. schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
  16. schemathesis/cli/commands/run/handlers/output.py +1368 -0
  17. schemathesis/cli/commands/run/hypothesis.py +105 -0
  18. schemathesis/cli/commands/run/loaders.py +129 -0
  19. schemathesis/cli/{callbacks.py → commands/run/validation.py} +59 -175
  20. schemathesis/cli/constants.py +5 -58
  21. schemathesis/cli/core.py +17 -0
  22. schemathesis/cli/ext/fs.py +14 -0
  23. schemathesis/cli/ext/groups.py +55 -0
  24. schemathesis/cli/{options.py → ext/options.py} +37 -16
  25. schemathesis/cli/hooks.py +36 -0
  26. schemathesis/contrib/__init__.py +1 -3
  27. schemathesis/contrib/openapi/__init__.py +1 -3
  28. schemathesis/contrib/openapi/fill_missing_examples.py +3 -7
  29. schemathesis/core/__init__.py +58 -0
  30. schemathesis/core/compat.py +25 -0
  31. schemathesis/core/control.py +2 -0
  32. schemathesis/core/curl.py +58 -0
  33. schemathesis/core/deserialization.py +65 -0
  34. schemathesis/core/errors.py +370 -0
  35. schemathesis/core/failures.py +315 -0
  36. schemathesis/core/fs.py +19 -0
  37. schemathesis/core/loaders.py +104 -0
  38. schemathesis/core/marks.py +66 -0
  39. schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
  40. schemathesis/{internal/output.py → core/output/__init__.py} +1 -0
  41. schemathesis/core/output/sanitization.py +197 -0
  42. schemathesis/{throttling.py → core/rate_limit.py} +16 -17
  43. schemathesis/core/registries.py +31 -0
  44. schemathesis/core/transforms.py +113 -0
  45. schemathesis/core/transport.py +108 -0
  46. schemathesis/core/validation.py +38 -0
  47. schemathesis/core/version.py +7 -0
  48. schemathesis/engine/__init__.py +30 -0
  49. schemathesis/engine/config.py +59 -0
  50. schemathesis/engine/context.py +119 -0
  51. schemathesis/engine/control.py +36 -0
  52. schemathesis/engine/core.py +157 -0
  53. schemathesis/engine/errors.py +394 -0
  54. schemathesis/engine/events.py +243 -0
  55. schemathesis/engine/phases/__init__.py +66 -0
  56. schemathesis/{runner → engine/phases}/probes.py +49 -68
  57. schemathesis/engine/phases/stateful/__init__.py +66 -0
  58. schemathesis/engine/phases/stateful/_executor.py +301 -0
  59. schemathesis/engine/phases/stateful/context.py +85 -0
  60. schemathesis/engine/phases/unit/__init__.py +175 -0
  61. schemathesis/engine/phases/unit/_executor.py +322 -0
  62. schemathesis/engine/phases/unit/_pool.py +74 -0
  63. schemathesis/engine/recorder.py +246 -0
  64. schemathesis/errors.py +31 -0
  65. schemathesis/experimental/__init__.py +9 -40
  66. schemathesis/filters.py +7 -95
  67. schemathesis/generation/__init__.py +3 -3
  68. schemathesis/generation/case.py +190 -0
  69. schemathesis/generation/coverage.py +22 -22
  70. schemathesis/{_patches.py → generation/hypothesis/__init__.py} +15 -6
  71. schemathesis/generation/hypothesis/builder.py +585 -0
  72. schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
  73. schemathesis/generation/hypothesis/given.py +66 -0
  74. schemathesis/generation/hypothesis/reporting.py +14 -0
  75. schemathesis/generation/hypothesis/strategies.py +16 -0
  76. schemathesis/generation/meta.py +115 -0
  77. schemathesis/generation/modes.py +28 -0
  78. schemathesis/generation/overrides.py +96 -0
  79. schemathesis/generation/stateful/__init__.py +20 -0
  80. schemathesis/{stateful → generation/stateful}/state_machine.py +84 -109
  81. schemathesis/generation/targets.py +69 -0
  82. schemathesis/graphql/__init__.py +15 -0
  83. schemathesis/graphql/checks.py +109 -0
  84. schemathesis/graphql/loaders.py +131 -0
  85. schemathesis/hooks.py +17 -62
  86. schemathesis/openapi/__init__.py +13 -0
  87. schemathesis/openapi/checks.py +387 -0
  88. schemathesis/openapi/generation/__init__.py +0 -0
  89. schemathesis/openapi/generation/filters.py +63 -0
  90. schemathesis/openapi/loaders.py +178 -0
  91. schemathesis/pytest/__init__.py +5 -0
  92. schemathesis/pytest/control_flow.py +7 -0
  93. schemathesis/pytest/lazy.py +273 -0
  94. schemathesis/pytest/loaders.py +12 -0
  95. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +94 -107
  96. schemathesis/python/__init__.py +0 -0
  97. schemathesis/python/asgi.py +12 -0
  98. schemathesis/python/wsgi.py +12 -0
  99. schemathesis/schemas.py +456 -228
  100. schemathesis/specs/graphql/__init__.py +0 -1
  101. schemathesis/specs/graphql/_cache.py +1 -2
  102. schemathesis/specs/graphql/scalars.py +5 -3
  103. schemathesis/specs/graphql/schemas.py +122 -123
  104. schemathesis/specs/graphql/validation.py +11 -17
  105. schemathesis/specs/openapi/__init__.py +6 -1
  106. schemathesis/specs/openapi/_cache.py +1 -2
  107. schemathesis/specs/openapi/_hypothesis.py +97 -134
  108. schemathesis/specs/openapi/checks.py +238 -219
  109. schemathesis/specs/openapi/converter.py +4 -4
  110. schemathesis/specs/openapi/definitions.py +1 -1
  111. schemathesis/specs/openapi/examples.py +22 -20
  112. schemathesis/specs/openapi/expressions/__init__.py +11 -15
  113. schemathesis/specs/openapi/expressions/extractors.py +1 -4
  114. schemathesis/specs/openapi/expressions/nodes.py +33 -32
  115. schemathesis/specs/openapi/formats.py +3 -2
  116. schemathesis/specs/openapi/links.py +123 -299
  117. schemathesis/specs/openapi/media_types.py +10 -12
  118. schemathesis/specs/openapi/negative/__init__.py +2 -1
  119. schemathesis/specs/openapi/negative/mutations.py +3 -2
  120. schemathesis/specs/openapi/parameters.py +8 -6
  121. schemathesis/specs/openapi/patterns.py +1 -1
  122. schemathesis/specs/openapi/references.py +11 -51
  123. schemathesis/specs/openapi/schemas.py +177 -191
  124. schemathesis/specs/openapi/security.py +1 -1
  125. schemathesis/specs/openapi/serialization.py +10 -6
  126. schemathesis/specs/openapi/stateful/__init__.py +97 -91
  127. schemathesis/transport/__init__.py +104 -0
  128. schemathesis/transport/asgi.py +26 -0
  129. schemathesis/transport/prepare.py +99 -0
  130. schemathesis/transport/requests.py +221 -0
  131. schemathesis/{_xml.py → transport/serialization.py} +69 -7
  132. schemathesis/transport/wsgi.py +165 -0
  133. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/METADATA +18 -14
  134. schemathesis-4.0.0a2.dist-info/RECORD +151 -0
  135. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/entry_points.txt +1 -1
  136. schemathesis/_compat.py +0 -74
  137. schemathesis/_dependency_versions.py +0 -19
  138. schemathesis/_hypothesis.py +0 -559
  139. schemathesis/_override.py +0 -50
  140. schemathesis/_rate_limiter.py +0 -7
  141. schemathesis/cli/context.py +0 -75
  142. schemathesis/cli/debug.py +0 -27
  143. schemathesis/cli/handlers.py +0 -19
  144. schemathesis/cli/junitxml.py +0 -124
  145. schemathesis/cli/output/__init__.py +0 -1
  146. schemathesis/cli/output/default.py +0 -936
  147. schemathesis/cli/output/short.py +0 -59
  148. schemathesis/cli/reporting.py +0 -79
  149. schemathesis/cli/sanitization.py +0 -26
  150. schemathesis/code_samples.py +0 -151
  151. schemathesis/constants.py +0 -56
  152. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  153. schemathesis/contrib/openapi/formats/uuid.py +0 -16
  154. schemathesis/contrib/unique_data.py +0 -41
  155. schemathesis/exceptions.py +0 -571
  156. schemathesis/extra/_aiohttp.py +0 -28
  157. schemathesis/extra/_flask.py +0 -13
  158. schemathesis/extra/_server.py +0 -18
  159. schemathesis/failures.py +0 -277
  160. schemathesis/fixups/__init__.py +0 -37
  161. schemathesis/fixups/fast_api.py +0 -41
  162. schemathesis/fixups/utf8_bom.py +0 -28
  163. schemathesis/generation/_methods.py +0 -44
  164. schemathesis/graphql.py +0 -3
  165. schemathesis/internal/__init__.py +0 -7
  166. schemathesis/internal/checks.py +0 -84
  167. schemathesis/internal/copy.py +0 -32
  168. schemathesis/internal/datetime.py +0 -5
  169. schemathesis/internal/deprecation.py +0 -38
  170. schemathesis/internal/diff.py +0 -15
  171. schemathesis/internal/extensions.py +0 -27
  172. schemathesis/internal/jsonschema.py +0 -36
  173. schemathesis/internal/transformation.py +0 -26
  174. schemathesis/internal/validation.py +0 -34
  175. schemathesis/lazy.py +0 -474
  176. schemathesis/loaders.py +0 -122
  177. schemathesis/models.py +0 -1341
  178. schemathesis/parameters.py +0 -90
  179. schemathesis/runner/__init__.py +0 -605
  180. schemathesis/runner/events.py +0 -389
  181. schemathesis/runner/impl/__init__.py +0 -3
  182. schemathesis/runner/impl/context.py +0 -104
  183. schemathesis/runner/impl/core.py +0 -1246
  184. schemathesis/runner/impl/solo.py +0 -80
  185. schemathesis/runner/impl/threadpool.py +0 -391
  186. schemathesis/runner/serialization.py +0 -544
  187. schemathesis/sanitization.py +0 -252
  188. schemathesis/serializers.py +0 -328
  189. schemathesis/service/__init__.py +0 -18
  190. schemathesis/service/auth.py +0 -11
  191. schemathesis/service/ci.py +0 -202
  192. schemathesis/service/client.py +0 -133
  193. schemathesis/service/constants.py +0 -38
  194. schemathesis/service/events.py +0 -61
  195. schemathesis/service/extensions.py +0 -224
  196. schemathesis/service/hosts.py +0 -111
  197. schemathesis/service/metadata.py +0 -71
  198. schemathesis/service/models.py +0 -258
  199. schemathesis/service/report.py +0 -255
  200. schemathesis/service/serialization.py +0 -173
  201. schemathesis/service/usage.py +0 -66
  202. schemathesis/specs/graphql/loaders.py +0 -364
  203. schemathesis/specs/openapi/expressions/context.py +0 -16
  204. schemathesis/specs/openapi/loaders.py +0 -708
  205. schemathesis/specs/openapi/stateful/statistic.py +0 -198
  206. schemathesis/specs/openapi/stateful/types.py +0 -14
  207. schemathesis/specs/openapi/validation.py +0 -26
  208. schemathesis/stateful/__init__.py +0 -147
  209. schemathesis/stateful/config.py +0 -97
  210. schemathesis/stateful/context.py +0 -135
  211. schemathesis/stateful/events.py +0 -274
  212. schemathesis/stateful/runner.py +0 -309
  213. schemathesis/stateful/sink.py +0 -68
  214. schemathesis/stateful/statistic.py +0 -22
  215. schemathesis/stateful/validation.py +0 -100
  216. schemathesis/targets.py +0 -77
  217. schemathesis/transports/__init__.py +0 -359
  218. schemathesis/transports/asgi.py +0 -7
  219. schemathesis/transports/auth.py +0 -38
  220. schemathesis/transports/headers.py +0 -36
  221. schemathesis/transports/responses.py +0 -57
  222. schemathesis/types.py +0 -44
  223. schemathesis/utils.py +0 -164
  224. schemathesis-3.39.7.dist-info/RECORD +0 -160
  225. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  226. /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
  227. /schemathesis/{internal → core}/result.py +0 -0
  228. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/WHEEL +0 -0
  229. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/licenses/LICENSE +0 -0
@@ -1,31 +1,28 @@
1
1
  from __future__ import annotations
2
2
 
3
- import base64
3
+ import datetime
4
4
  import enum
5
5
  import json
6
- import re
7
6
  import sys
8
7
  import threading
9
8
  from dataclasses import dataclass, field
10
9
  from http.cookies import SimpleCookie
11
10
  from queue import Queue
12
- from typing import IO, TYPE_CHECKING, Any, Callable, Generator, Iterator, cast
11
+ from typing import IO, Callable, Iterator
13
12
  from urllib.parse import parse_qsl, urlparse
14
13
 
14
+ import click
15
15
  import harfile
16
16
 
17
- from ..constants import SCHEMATHESIS_VERSION
18
- from ..runner import events
19
- from .handlers import EventHandler
20
-
21
- if TYPE_CHECKING:
22
- import click
23
- import requests
24
-
25
- from ..models import Request, Response
26
- from ..runner.serialization import SerializedCheck, SerializedInteraction
27
- from ..types import RequestCert
28
- from .context import ExecutionContext
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
29
26
 
30
27
  # Wait until the worker terminates
31
28
  WRITER_WORKER_JOIN_TIMEOUT = 1
@@ -49,58 +46,39 @@ class CassetteFormat(str, enum.Enum):
49
46
 
50
47
 
51
48
  @dataclass
52
- class CassetteWriter(EventHandler):
53
- """Write interactions in a YAML cassette.
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
54
 
55
- A low-level interface is used to write data to YAML file during the test run and reduce the delay at
56
- the end of the test run.
57
- """
58
55
 
59
- file_handle: click.utils.LazyFile
60
- format: CassetteFormat
61
- preserve_exact_body_bytes: bool
56
+ @dataclass
57
+ class CassetteWriter(EventHandler):
58
+ """Write network interactions to a cassette."""
59
+
60
+ config: CassetteConfig
62
61
  queue: Queue = field(default_factory=Queue)
63
62
  worker: threading.Thread = field(init=False)
64
63
 
65
64
  def __post_init__(self) -> None:
66
- kwargs = {
67
- "file_handle": self.file_handle,
68
- "queue": self.queue,
69
- "preserve_exact_body_bytes": self.preserve_exact_body_bytes,
70
- }
65
+ kwargs = {"config": self.config, "queue": self.queue}
71
66
  writer: Callable
72
- if self.format == CassetteFormat.HAR:
67
+ if self.config.format == CassetteFormat.HAR:
73
68
  writer = har_writer
74
69
  else:
75
70
  writer = vcr_writer
76
71
  self.worker = threading.Thread(name="SchemathesisCassetteWriter", target=writer, kwargs=kwargs)
77
72
  self.worker.start()
78
73
 
79
- def handle_event(self, context: ExecutionContext, event: events.ExecutionEvent) -> None:
80
- if isinstance(event, events.Initialized):
81
- # In the beginning we write metadata and start `http_interactions` list
82
- self.queue.put(Initialize(seed=event.seed))
83
- elif isinstance(event, events.AfterExecution):
84
- self.queue.put(
85
- Process(
86
- correlation_id=event.correlation_id,
87
- thread_id=event.thread_id,
88
- interactions=event.result.interactions,
89
- )
90
- )
91
- elif isinstance(event, events.AfterStatefulExecution):
92
- self.queue.put(
93
- Process(
94
- # Correlation ID is not used in stateful testing
95
- correlation_id="",
96
- thread_id=event.thread_id,
97
- interactions=event.result.interactions,
98
- )
99
- )
100
- elif isinstance(event, events.Finished):
101
- self.shutdown()
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))
102
80
 
103
- def shutdown(self) -> None:
81
+ def shutdown(self, ctx: ExecutionContext) -> None:
104
82
  self.queue.put(Finalize())
105
83
  self._stop_worker()
106
84
 
@@ -114,20 +92,24 @@ class Initialize:
114
92
 
115
93
  seed: int | None
116
94
 
95
+ __slots__ = ("seed",)
96
+
117
97
 
118
98
  @dataclass
119
99
  class Process:
120
100
  """A new chunk of data should be processed."""
121
101
 
122
- correlation_id: str
123
- thread_id: int
124
- interactions: list[SerializedInteraction]
102
+ recorder: ScenarioRecorder
103
+
104
+ __slots__ = ("recorder",)
125
105
 
126
106
 
127
107
  @dataclass
128
108
  class Finalize:
129
109
  """The work is done and there will be no more messages to process."""
130
110
 
111
+ __slots__ = ()
112
+
131
113
 
132
114
  def get_command_representation() -> str:
133
115
  """Get how Schemathesis was run."""
@@ -138,7 +120,7 @@ def get_command_representation() -> str:
138
120
  return f"st {args}"
139
121
 
140
122
 
141
- def vcr_writer(file_handle: click.utils.LazyFile, preserve_exact_body_bytes: bool, queue: Queue) -> None:
123
+ def vcr_writer(config: CassetteConfig, queue: Queue) -> None:
142
124
  """Write YAML to a file in an incremental manner.
143
125
 
144
126
  This implementation doesn't use `pyyaml` package and composes YAML manually as string due to the following reasons:
@@ -149,52 +131,61 @@ def vcr_writer(file_handle: click.utils.LazyFile, preserve_exact_body_bytes: boo
149
131
  providing tags, anchors to have incremental writing, with primitive types it is much simpler.
150
132
  """
151
133
  current_id = 1
152
- stream = file_handle.open()
134
+ stream = config.path.open()
153
135
 
154
136
  def format_header_values(values: list[str]) -> str:
155
137
  return "\n".join(f" - {json.dumps(v)}" for v in values)
156
138
 
157
- def format_headers(headers: dict[str, list[str]]) -> str:
158
- return "\n".join(f' "{name}":\n{format_header_values(values)}' for name, values in headers.items())
139
+ if config.sanitize_output:
140
+
141
+ def format_headers(headers: dict[str, list[str]]) -> str:
142
+ headers = deepclone(headers)
143
+ sanitize_value(headers)
144
+ return "\n".join(f' "{name}":\n{format_header_values(values)}' for name, values in headers.items())
145
+
146
+ else:
147
+
148
+ def format_headers(headers: dict[str, list[str]]) -> str:
149
+ return "\n".join(f' "{name}":\n{format_header_values(values)}' for name, values in headers.items())
159
150
 
160
151
  def format_check_message(message: str | None) -> str:
161
152
  return "~" if message is None else f"{message!r}"
162
153
 
163
- def format_checks(checks: list[SerializedCheck]) -> str:
154
+ def format_checks(checks: list[CheckNode]) -> str:
164
155
  if not checks:
165
- return " checks: []"
156
+ return "\n checks: []"
166
157
  items = "\n".join(
167
- f" - name: '{check.name}'\n status: '{check.value.name.upper()}'\n message: {format_check_message(check.message)}"
158
+ 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)}"
168
159
  for check in checks
169
160
  )
170
161
  return f"""
171
162
  checks:
172
163
  {items}"""
173
164
 
174
- if preserve_exact_body_bytes:
165
+ if config.preserve_exact_body_bytes:
175
166
 
176
167
  def format_request_body(output: IO, request: Request) -> None:
177
- if request.body is not None:
168
+ if request.encoded_body is not None:
178
169
  output.write(
179
170
  f"""
180
171
  body:
181
172
  encoding: 'utf-8'
182
- base64_string: '{request.body}'"""
173
+ base64_string: '{request.encoded_body}'"""
183
174
  )
184
175
 
185
176
  def format_response_body(output: IO, response: Response) -> None:
186
- if response.body is not None:
177
+ if response.encoded_body is not None:
187
178
  output.write(
188
179
  f""" body:
189
180
  encoding: '{response.encoding}'
190
- base64_string: '{response.body}'"""
181
+ base64_string: '{response.encoded_body}'"""
191
182
  )
192
183
 
193
184
  else:
194
185
 
195
186
  def format_request_body(output: IO, request: Request) -> None:
196
187
  if request.body is not None:
197
- string = _safe_decode(request.body, "utf8")
188
+ string = request.body.decode("utf8", "replace")
198
189
  output.write(
199
190
  """
200
191
  body:
@@ -204,9 +195,9 @@ def vcr_writer(file_handle: click.utils.LazyFile, preserve_exact_body_bytes: boo
204
195
  write_double_quoted(output, string)
205
196
 
206
197
  def format_response_body(output: IO, response: Response) -> None:
207
- if response.body is not None:
198
+ if response.content is not None:
208
199
  encoding = response.encoding or "utf8"
209
- string = _safe_decode(response.body, encoding)
200
+ string = response.content.decode(encoding, "replace")
210
201
  output.write(
211
202
  f""" body:
212
203
  encoding: '{encoding}'
@@ -214,62 +205,94 @@ def vcr_writer(file_handle: click.utils.LazyFile, preserve_exact_body_bytes: boo
214
205
  )
215
206
  write_double_quoted(output, string)
216
207
 
217
- seed = "null"
218
208
  while True:
219
209
  item = queue.get()
220
210
  if isinstance(item, Initialize):
221
- seed = f"'{item.seed}'"
222
211
  stream.write(
223
212
  f"""command: '{get_command_representation()}'
224
213
  recorded_with: 'Schemathesis {SCHEMATHESIS_VERSION}'
214
+ seed: {item.seed}
225
215
  http_interactions:"""
226
216
  )
227
217
  elif isinstance(item, Process):
228
- for interaction in item.interactions:
229
- status = interaction.status.name.upper()
218
+ for case_id, interaction in item.recorder.interactions.items():
219
+ case = item.recorder.cases[case_id]
220
+ if interaction.response is not None:
221
+ if case_id in item.recorder.checks:
222
+ checks = item.recorder.checks[case_id]
223
+ status = Status.SUCCESS
224
+ for check in checks:
225
+ if check.status == Status.FAILURE:
226
+ status = check.status
227
+ break
228
+ else:
229
+ # NOTE: Checks recording could be skipped if Schemathesis start skipping just
230
+ # discovered failures in order to get past them and potentially discover more failures
231
+ checks = []
232
+ status = Status.SKIP
233
+ else:
234
+ checks = []
235
+ status = Status.ERROR
230
236
  # Body payloads are handled via separate `stream.write` calls to avoid some allocations
231
- phase = f"'{interaction.phase.value}'" if interaction.phase is not None else "null"
232
237
  stream.write(
233
- f"""\n- id: '{current_id}'
234
- status: '{status}'
235
- seed: {seed}
236
- thread_id: {item.thread_id}
237
- correlation_id: '{item.correlation_id}'
238
- data_generation_method: '{interaction.data_generation_method.value}'
239
- meta:
240
- description: """
238
+ f"""\n- id: '{case_id}'
239
+ status: '{status.name}'"""
241
240
  )
242
-
243
- if interaction.description is not None:
244
- write_double_quoted(stream, interaction.description)
245
- else:
246
- stream.write("null")
247
-
248
- stream.write("\n location: ")
249
- if interaction.location is not None:
250
- write_double_quoted(stream, interaction.location)
251
- else:
252
- stream.write("null")
253
-
254
- stream.write("\n parameter: ")
255
- if interaction.parameter is not None:
256
- write_double_quoted(stream, interaction.parameter)
241
+ meta = case.value.meta
242
+ if meta is not None:
243
+ # Start metadata block
244
+ stream.write(f"""
245
+ generation:
246
+ time: {meta.generation.time}
247
+ mode: {meta.generation.mode.value}
248
+ components:""")
249
+
250
+ # Write components
251
+ for kind, info in meta.components.items():
252
+ stream.write(f"""
253
+ {kind.value}:
254
+ mode: '{info.mode.value}'""")
255
+ # Write phase info
256
+ stream.write("\n phase:")
257
+ stream.write(f"\n name: '{meta.phase.name.value}'")
258
+ stream.write("\n data: ")
259
+
260
+ # Write phase-specific data
261
+ if isinstance(meta.phase.data, CoveragePhaseData):
262
+ stream.write("""
263
+ description: """)
264
+ write_double_quoted(stream, meta.phase.data.description)
265
+ stream.write("""
266
+ location: """)
267
+ write_double_quoted(stream, meta.phase.data.location)
268
+ stream.write("""
269
+ parameter: """)
270
+ if meta.phase.data.parameter is not None:
271
+ write_double_quoted(stream, meta.phase.data.parameter)
272
+ else:
273
+ stream.write("null")
274
+ stream.write("""
275
+ parameter_location: """)
276
+ if meta.phase.data.parameter_location is not None:
277
+ write_double_quoted(stream, meta.phase.data.parameter_location)
278
+ else:
279
+ stream.write("null")
280
+ else:
281
+ # Empty objects for these phases
282
+ stream.write("{}")
257
283
  else:
258
284
  stream.write("null")
259
285
 
260
- stream.write("\n parameter_location: ")
261
- if interaction.parameter_location is not None:
262
- write_double_quoted(stream, interaction.parameter_location)
286
+ if config.sanitize_output:
287
+ uri = sanitize_url(interaction.request.uri)
263
288
  else:
264
- stream.write("null")
289
+ uri = interaction.request.uri
290
+ recorded_at = datetime.datetime.fromtimestamp(interaction.timestamp, datetime.timezone.utc).isoformat()
265
291
  stream.write(
266
292
  f"""
267
- phase: {phase}
268
- elapsed: '{interaction.response.elapsed if interaction.response else 0}'
269
- recorded_at: '{interaction.recorded_at}'
270
- {format_checks(interaction.checks)}
293
+ recorded_at: '{recorded_at}'{format_checks(checks)}
271
294
  request:
272
- uri: '{interaction.request.uri}'
295
+ uri: '{uri}'
273
296
  method: '{interaction.request.method}'
274
297
  headers:
275
298
  {format_headers(interaction.request.headers)}"""
@@ -282,6 +305,7 @@ http_interactions:"""
282
305
  status:
283
306
  code: '{interaction.response.status_code}'
284
307
  message: {json.dumps(interaction.response.message)}
308
+ elapsed: '{interaction.response.elapsed}'
285
309
  headers:
286
310
  {format_headers(interaction.response.headers)}
287
311
  """
@@ -293,23 +317,21 @@ http_interactions:"""
293
317
  )
294
318
  else:
295
319
  stream.write("""
296
- response: null
297
- """)
320
+ response: null""")
298
321
  current_id += 1
299
322
  else:
300
323
  break
301
- file_handle.close()
302
-
324
+ config.path.close()
303
325
 
304
- def _safe_decode(value: str, encoding: str) -> str:
305
- """Decode base64-encoded body bytes as a string."""
306
- return base64.b64decode(value).decode(encoding, "replace")
307
326
 
308
-
309
- def write_double_quoted(stream: IO, text: str) -> None:
327
+ def write_double_quoted(stream: IO, text: str | None) -> None:
310
328
  """Writes a valid YAML string enclosed in double quotes."""
311
329
  from yaml.emitter import Emitter
312
330
 
331
+ if text is None:
332
+ stream.write("null")
333
+ return
334
+
313
335
  # Adapted from `yaml.Emitter.write_double_quoted`:
314
336
  # - Doesn't split the string, therefore doesn't track the current column
315
337
  # - Doesn't encode the input
@@ -345,26 +367,23 @@ def write_double_quoted(stream: IO, text: str) -> None:
345
367
  stream.write('"')
346
368
 
347
369
 
348
- def har_writer(file_handle: click.utils.LazyFile, preserve_exact_body_bytes: bool, queue: Queue) -> None:
349
- if preserve_exact_body_bytes:
350
-
351
- def get_body(body: str) -> str:
352
- return body
353
- else:
354
-
355
- def get_body(body: str) -> str:
356
- return base64.b64decode(body).decode("utf-8", errors="replace")
357
-
358
- with harfile.open(file_handle) as har:
370
+ def har_writer(config: CassetteConfig, queue: Queue) -> None:
371
+ with harfile.open(config.path) as har:
359
372
  while True:
360
373
  item = queue.get()
361
374
  if isinstance(item, Process):
362
- for interaction in item.interactions:
363
- query_params = urlparse(interaction.request.uri).query
375
+ for interaction in item.recorder.interactions.values():
376
+ if config.sanitize_output:
377
+ uri = sanitize_url(interaction.request.uri)
378
+ else:
379
+ uri = interaction.request.uri
380
+ query_params = urlparse(uri).query
364
381
  if interaction.request.body is not None:
365
382
  post_data = harfile.PostData(
366
383
  mimeType=interaction.request.headers.get("Content-Type", [""])[0],
367
- text=get_body(interaction.request.body),
384
+ text=interaction.request.encoded_body
385
+ if config.preserve_exact_body_bytes
386
+ else interaction.request.body.decode("utf-8", "replace"),
368
387
  )
369
388
  else:
370
389
  post_data = None
@@ -373,25 +392,31 @@ def har_writer(file_handle: click.utils.LazyFile, preserve_exact_body_bytes: boo
373
392
  content = harfile.Content(
374
393
  size=interaction.response.body_size or 0,
375
394
  mimeType=content_type,
376
- text=get_body(interaction.response.body) if interaction.response.body is not None else None,
395
+ text=interaction.response.encoded_body
396
+ if config.preserve_exact_body_bytes
397
+ else interaction.response.content.decode("utf-8", "replace")
398
+ if interaction.response.content is not None
399
+ else None,
377
400
  encoding="base64"
378
- if interaction.response.body is not None and preserve_exact_body_bytes
401
+ if interaction.response.content is not None and config.preserve_exact_body_bytes
379
402
  else None,
380
403
  )
381
404
  http_version = f"HTTP/{interaction.response.http_version}"
405
+ if config.sanitize_output:
406
+ headers = deepclone(interaction.response.headers)
407
+ sanitize_value(headers)
408
+ else:
409
+ headers = interaction.response.headers
382
410
  response = harfile.Response(
383
411
  status=interaction.response.status_code,
384
412
  httpVersion=http_version,
385
413
  statusText=interaction.response.message,
386
- headers=[
387
- harfile.Record(name=name, value=values[0])
388
- for name, values in interaction.response.headers.items()
389
- ],
390
- cookies=_extract_cookies(interaction.response.headers.get("Set-Cookie", [])),
414
+ headers=[harfile.Record(name=name, value=values[0]) for name, values in headers.items()],
415
+ cookies=_extract_cookies(headers.get("Set-Cookie", [])),
391
416
  content=content,
392
- headersSize=_headers_size(interaction.response.headers),
417
+ headersSize=_headers_size(headers),
393
418
  bodySize=interaction.response.body_size or 0,
394
- redirectURL=interaction.response.headers.get("Location", [""])[0],
419
+ redirectURL=headers.get("Location", [""])[0],
395
420
  )
396
421
  time = round(interaction.response.elapsed * 1000, 2)
397
422
  else:
@@ -399,23 +424,28 @@ def har_writer(file_handle: click.utils.LazyFile, preserve_exact_body_bytes: boo
399
424
  time = 0
400
425
  http_version = ""
401
426
 
427
+ if config.sanitize_output:
428
+ headers = deepclone(interaction.request.headers)
429
+ sanitize_value(headers)
430
+ else:
431
+ headers = interaction.request.headers
432
+ started_datetime = datetime.datetime.fromtimestamp(
433
+ interaction.timestamp, datetime.timezone.utc
434
+ ).isoformat()
402
435
  har.add_entry(
403
- startedDateTime=interaction.recorded_at,
436
+ startedDateTime=started_datetime,
404
437
  time=time,
405
438
  request=harfile.Request(
406
439
  method=interaction.request.method.upper(),
407
- url=interaction.request.uri,
440
+ url=uri,
408
441
  httpVersion=http_version,
409
- headers=[
410
- harfile.Record(name=name, value=values[0])
411
- for name, values in interaction.request.headers.items()
412
- ],
442
+ headers=[harfile.Record(name=name, value=values[0]) for name, values in headers.items()],
413
443
  queryString=[
414
444
  harfile.Record(name=name, value=value)
415
445
  for name, value in parse_qsl(query_params, keep_blank_values=True)
416
446
  ],
417
- cookies=_extract_cookies(interaction.request.headers.get("Cookie", [])),
418
- headersSize=_headers_size(interaction.request.headers),
447
+ cookies=_extract_cookies(headers.get("Cookie", [])),
448
+ headersSize=_headers_size(headers),
419
449
  bodySize=interaction.request.body_size or 0,
420
450
  postData=post_data,
421
451
  ),
@@ -460,102 +490,3 @@ def _cookie_to_har(cookie: str) -> Iterator[harfile.Cookie]:
460
490
  httpOnly=data["httponly"] or None,
461
491
  secure=data["secure"] or None,
462
492
  )
463
-
464
-
465
- @dataclass
466
- class Replayed:
467
- interaction: dict[str, Any]
468
- response: requests.Response
469
-
470
-
471
- def replay(
472
- cassette: dict[str, Any],
473
- id_: str | None = None,
474
- status: str | None = None,
475
- uri: str | None = None,
476
- method: str | None = None,
477
- request_tls_verify: bool = True,
478
- request_cert: RequestCert | None = None,
479
- request_proxy: str | None = None,
480
- ) -> Generator[Replayed, None, None]:
481
- """Replay saved interactions."""
482
- import requests
483
-
484
- session = requests.Session()
485
- session.verify = request_tls_verify
486
- session.cert = request_cert
487
- kwargs = {}
488
- if request_proxy is not None:
489
- kwargs["proxies"] = {"all": request_proxy}
490
- for interaction in filter_cassette(cassette["http_interactions"], id_, status, uri, method):
491
- request = get_prepared_request(interaction["request"])
492
- response = session.send(request, **kwargs) # type: ignore
493
- yield Replayed(interaction, response)
494
-
495
-
496
- def filter_cassette(
497
- interactions: list[dict[str, Any]],
498
- id_: str | None = None,
499
- status: str | None = None,
500
- uri: str | None = None,
501
- method: str | None = None,
502
- ) -> Iterator[dict[str, Any]]:
503
- filters = []
504
-
505
- def id_filter(item: dict[str, Any]) -> bool:
506
- return item["id"] == id_
507
-
508
- def status_filter(item: dict[str, Any]) -> bool:
509
- status_ = cast(str, status)
510
- return item["status"].upper() == status_.upper()
511
-
512
- def uri_filter(item: dict[str, Any]) -> bool:
513
- uri_ = cast(str, uri)
514
- return bool(re.search(uri_, item["request"]["uri"]))
515
-
516
- def method_filter(item: dict[str, Any]) -> bool:
517
- method_ = cast(str, method)
518
- return bool(re.search(method_, item["request"]["method"]))
519
-
520
- if id_ is not None:
521
- filters.append(id_filter)
522
-
523
- if status is not None:
524
- filters.append(status_filter)
525
-
526
- if uri is not None:
527
- filters.append(uri_filter)
528
-
529
- if method is not None:
530
- filters.append(method_filter)
531
-
532
- def is_match(interaction: dict[str, Any]) -> bool:
533
- return all(filter_(interaction) for filter_ in filters)
534
-
535
- return filter(is_match, interactions)
536
-
537
-
538
- def get_prepared_request(data: dict[str, Any]) -> requests.PreparedRequest:
539
- """Create a `requests.PreparedRequest` from a serialized one."""
540
- import requests
541
- from requests.cookies import RequestsCookieJar
542
- from requests.structures import CaseInsensitiveDict
543
-
544
- prepared = requests.PreparedRequest()
545
- prepared.method = data["method"]
546
- prepared.url = data["uri"]
547
- prepared._cookies = RequestsCookieJar() # type: ignore
548
- if "body" in data:
549
- body = data["body"]
550
- if "base64_string" in body:
551
- content = body["base64_string"]
552
- if content:
553
- prepared.body = base64.b64decode(content)
554
- else:
555
- content = body["string"]
556
- if content:
557
- prepared.body = content.encode("utf8")
558
- # There is always 1 value in a request
559
- headers = [(key, value[0]) for key, value in data["headers"].items()]
560
- prepared.headers = CaseInsensitiveDict(headers)
561
- return prepared