schemathesis 3.25.5__py3-none-any.whl → 3.39.7__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 (146) hide show
  1. schemathesis/__init__.py +6 -6
  2. schemathesis/_compat.py +2 -2
  3. schemathesis/_dependency_versions.py +4 -2
  4. schemathesis/_hypothesis.py +369 -56
  5. schemathesis/_lazy_import.py +1 -0
  6. schemathesis/_override.py +5 -4
  7. schemathesis/_patches.py +21 -0
  8. schemathesis/_rate_limiter.py +7 -0
  9. schemathesis/_xml.py +75 -22
  10. schemathesis/auths.py +78 -16
  11. schemathesis/checks.py +21 -9
  12. schemathesis/cli/__init__.py +793 -448
  13. schemathesis/cli/__main__.py +4 -0
  14. schemathesis/cli/callbacks.py +58 -13
  15. schemathesis/cli/cassettes.py +233 -47
  16. schemathesis/cli/constants.py +8 -2
  17. schemathesis/cli/context.py +24 -4
  18. schemathesis/cli/debug.py +2 -1
  19. schemathesis/cli/handlers.py +4 -1
  20. schemathesis/cli/junitxml.py +103 -22
  21. schemathesis/cli/options.py +15 -4
  22. schemathesis/cli/output/default.py +286 -115
  23. schemathesis/cli/output/short.py +25 -6
  24. schemathesis/cli/reporting.py +79 -0
  25. schemathesis/cli/sanitization.py +6 -0
  26. schemathesis/code_samples.py +5 -3
  27. schemathesis/constants.py +1 -0
  28. schemathesis/contrib/openapi/__init__.py +1 -1
  29. schemathesis/contrib/openapi/fill_missing_examples.py +3 -1
  30. schemathesis/contrib/openapi/formats/uuid.py +2 -1
  31. schemathesis/contrib/unique_data.py +3 -3
  32. schemathesis/exceptions.py +76 -65
  33. schemathesis/experimental/__init__.py +35 -0
  34. schemathesis/extra/_aiohttp.py +1 -0
  35. schemathesis/extra/_flask.py +4 -1
  36. schemathesis/extra/_server.py +1 -0
  37. schemathesis/extra/pytest_plugin.py +17 -25
  38. schemathesis/failures.py +77 -9
  39. schemathesis/filters.py +185 -8
  40. schemathesis/fixups/__init__.py +1 -0
  41. schemathesis/fixups/fast_api.py +2 -2
  42. schemathesis/fixups/utf8_bom.py +1 -2
  43. schemathesis/generation/__init__.py +20 -36
  44. schemathesis/generation/_hypothesis.py +59 -0
  45. schemathesis/generation/_methods.py +44 -0
  46. schemathesis/generation/coverage.py +931 -0
  47. schemathesis/graphql.py +0 -1
  48. schemathesis/hooks.py +89 -12
  49. schemathesis/internal/checks.py +84 -0
  50. schemathesis/internal/copy.py +22 -3
  51. schemathesis/internal/deprecation.py +6 -2
  52. schemathesis/internal/diff.py +15 -0
  53. schemathesis/internal/extensions.py +27 -0
  54. schemathesis/internal/jsonschema.py +2 -1
  55. schemathesis/internal/output.py +68 -0
  56. schemathesis/internal/result.py +1 -1
  57. schemathesis/internal/transformation.py +11 -0
  58. schemathesis/lazy.py +138 -25
  59. schemathesis/loaders.py +7 -5
  60. schemathesis/models.py +323 -213
  61. schemathesis/parameters.py +4 -0
  62. schemathesis/runner/__init__.py +72 -22
  63. schemathesis/runner/events.py +86 -6
  64. schemathesis/runner/impl/context.py +104 -0
  65. schemathesis/runner/impl/core.py +447 -187
  66. schemathesis/runner/impl/solo.py +19 -29
  67. schemathesis/runner/impl/threadpool.py +70 -79
  68. schemathesis/{cli → runner}/probes.py +37 -25
  69. schemathesis/runner/serialization.py +150 -17
  70. schemathesis/sanitization.py +5 -1
  71. schemathesis/schemas.py +170 -102
  72. schemathesis/serializers.py +17 -4
  73. schemathesis/service/ci.py +1 -0
  74. schemathesis/service/client.py +39 -6
  75. schemathesis/service/events.py +5 -1
  76. schemathesis/service/extensions.py +224 -0
  77. schemathesis/service/hosts.py +6 -2
  78. schemathesis/service/metadata.py +25 -0
  79. schemathesis/service/models.py +211 -2
  80. schemathesis/service/report.py +6 -6
  81. schemathesis/service/serialization.py +60 -71
  82. schemathesis/service/usage.py +1 -0
  83. schemathesis/specs/graphql/_cache.py +26 -0
  84. schemathesis/specs/graphql/loaders.py +25 -5
  85. schemathesis/specs/graphql/nodes.py +1 -0
  86. schemathesis/specs/graphql/scalars.py +2 -2
  87. schemathesis/specs/graphql/schemas.py +130 -100
  88. schemathesis/specs/graphql/validation.py +1 -2
  89. schemathesis/specs/openapi/__init__.py +1 -0
  90. schemathesis/specs/openapi/_cache.py +123 -0
  91. schemathesis/specs/openapi/_hypothesis.py +79 -61
  92. schemathesis/specs/openapi/checks.py +504 -25
  93. schemathesis/specs/openapi/converter.py +31 -4
  94. schemathesis/specs/openapi/definitions.py +10 -17
  95. schemathesis/specs/openapi/examples.py +143 -31
  96. schemathesis/specs/openapi/expressions/__init__.py +37 -2
  97. schemathesis/specs/openapi/expressions/context.py +1 -1
  98. schemathesis/specs/openapi/expressions/extractors.py +26 -0
  99. schemathesis/specs/openapi/expressions/lexer.py +20 -18
  100. schemathesis/specs/openapi/expressions/nodes.py +29 -6
  101. schemathesis/specs/openapi/expressions/parser.py +26 -5
  102. schemathesis/specs/openapi/formats.py +44 -0
  103. schemathesis/specs/openapi/links.py +125 -42
  104. schemathesis/specs/openapi/loaders.py +77 -36
  105. schemathesis/specs/openapi/media_types.py +34 -0
  106. schemathesis/specs/openapi/negative/__init__.py +6 -3
  107. schemathesis/specs/openapi/negative/mutations.py +21 -6
  108. schemathesis/specs/openapi/parameters.py +39 -25
  109. schemathesis/specs/openapi/patterns.py +137 -0
  110. schemathesis/specs/openapi/references.py +37 -7
  111. schemathesis/specs/openapi/schemas.py +368 -242
  112. schemathesis/specs/openapi/security.py +25 -7
  113. schemathesis/specs/openapi/serialization.py +1 -0
  114. schemathesis/specs/openapi/stateful/__init__.py +198 -70
  115. schemathesis/specs/openapi/stateful/statistic.py +198 -0
  116. schemathesis/specs/openapi/stateful/types.py +14 -0
  117. schemathesis/specs/openapi/utils.py +6 -1
  118. schemathesis/specs/openapi/validation.py +1 -0
  119. schemathesis/stateful/__init__.py +35 -21
  120. schemathesis/stateful/config.py +97 -0
  121. schemathesis/stateful/context.py +135 -0
  122. schemathesis/stateful/events.py +274 -0
  123. schemathesis/stateful/runner.py +309 -0
  124. schemathesis/stateful/sink.py +68 -0
  125. schemathesis/stateful/state_machine.py +67 -38
  126. schemathesis/stateful/statistic.py +22 -0
  127. schemathesis/stateful/validation.py +100 -0
  128. schemathesis/targets.py +33 -1
  129. schemathesis/throttling.py +25 -5
  130. schemathesis/transports/__init__.py +354 -0
  131. schemathesis/transports/asgi.py +7 -0
  132. schemathesis/transports/auth.py +25 -2
  133. schemathesis/transports/content_types.py +3 -1
  134. schemathesis/transports/headers.py +2 -1
  135. schemathesis/transports/responses.py +9 -4
  136. schemathesis/types.py +9 -0
  137. schemathesis/utils.py +11 -16
  138. schemathesis-3.39.7.dist-info/METADATA +293 -0
  139. schemathesis-3.39.7.dist-info/RECORD +160 -0
  140. {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/WHEEL +1 -1
  141. schemathesis/specs/openapi/filters.py +0 -49
  142. schemathesis/specs/openapi/stateful/links.py +0 -92
  143. schemathesis-3.25.5.dist-info/METADATA +0 -356
  144. schemathesis-3.25.5.dist-info/RECORD +0 -134
  145. {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/entry_points.txt +0 -0
  146. {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,4 @@
1
+ if __name__ == "__main__":
2
+ import schemathesis.cli
3
+
4
+ schemathesis.cli.schemathesis()
@@ -2,33 +2,36 @@ from __future__ import annotations
2
2
 
3
3
  import codecs
4
4
  import enum
5
+ import operator
5
6
  import os
6
7
  import re
7
8
  import traceback
8
9
  from contextlib import contextmanager
9
- from functools import partial
10
- from typing import Generator, TYPE_CHECKING, Callable
10
+ from functools import partial, reduce
11
+ from typing import TYPE_CHECKING, Callable, Generator
11
12
  from urllib.parse import urlparse
12
13
 
13
14
  import click
14
15
 
15
- from click.types import LazyFile # type: ignore
16
-
17
16
  from .. import exceptions, experimental, throttling
18
17
  from ..code_samples import CodeSampleStyle
18
+ from ..constants import TRUE_VALUES
19
19
  from ..exceptions import extract_nth_traceback
20
20
  from ..generation import DataGenerationMethod
21
- from ..constants import TRUE_VALUES, FALSE_VALUES
21
+ from ..internal.transformation import convert_boolean_string as _convert_boolean_string
22
22
  from ..internal.validation import file_exists, is_filename, is_illegal_surrogate
23
23
  from ..loaders import load_app
24
24
  from ..service.hosts import get_temporary_hosts_file
25
+ from ..stateful import Stateful
25
26
  from ..transports.headers import has_invalid_characters, is_latin_1_encodable
26
- from ..types import PathLike
27
+ from .cassettes import CassetteFormat
27
28
  from .constants import DEFAULT_WORKERS
28
- from ..stateful import Stateful
29
29
 
30
30
  if TYPE_CHECKING:
31
31
  import hypothesis
32
+ from click.types import LazyFile # type: ignore[attr-defined]
33
+
34
+ from ..types import PathLike
32
35
 
33
36
  INVALID_DERANDOMIZE_MESSAGE = (
34
37
  "`--hypothesis-derandomize` implies no database, so passing `--hypothesis-database` too is invalid."
@@ -338,13 +341,59 @@ def convert_experimental(
338
341
 
339
342
 
340
343
  def convert_checks(ctx: click.core.Context, param: click.core.Parameter, value: tuple[list[str]]) -> list[str]:
341
- return sum(value, [])
344
+ return reduce(operator.iadd, value, [])
345
+
346
+
347
+ def convert_status_codes(
348
+ ctx: click.core.Context, param: click.core.Parameter, value: list[str] | None
349
+ ) -> list[str] | None:
350
+ if not value:
351
+ return value
352
+
353
+ invalid = []
354
+
355
+ for code in value:
356
+ if len(code) != 3:
357
+ invalid.append(code)
358
+ continue
359
+
360
+ if code[0] not in {"1", "2", "3", "4", "5"}:
361
+ invalid.append(code)
362
+ continue
363
+
364
+ upper_code = code.upper()
365
+
366
+ if "X" in upper_code:
367
+ if (
368
+ upper_code[1:] == "XX"
369
+ or (upper_code[1] == "X" and upper_code[2].isdigit())
370
+ or (upper_code[1].isdigit() and upper_code[2] == "X")
371
+ ):
372
+ continue
373
+ else:
374
+ invalid.append(code)
375
+ continue
376
+
377
+ if not code.isnumeric():
378
+ invalid.append(code)
379
+
380
+ if invalid:
381
+ raise click.UsageError(
382
+ f"Invalid status code(s): {', '.join(invalid)}. "
383
+ "Use valid 3-digit codes between 100 and 599, "
384
+ "or wildcards (e.g., 2XX, 2X0, 20X), where X is a wildcard digit."
385
+ )
386
+ return value
342
387
 
343
388
 
344
389
  def convert_code_sample_style(ctx: click.core.Context, param: click.core.Parameter, value: str) -> CodeSampleStyle:
345
390
  return CodeSampleStyle.from_str(value)
346
391
 
347
392
 
393
+ def convert_cassette_format(ctx: click.core.Context, param: click.core.Parameter, value: str) -> CassetteFormat:
394
+ return CassetteFormat.from_str(value)
395
+
396
+
348
397
  def convert_data_generation_method(
349
398
  ctx: click.core.Context, param: click.core.Parameter, value: str
350
399
  ) -> list[DataGenerationMethod]:
@@ -374,11 +423,7 @@ def convert_hosts_file(ctx: click.core.Context, param: click.core.Parameter, val
374
423
 
375
424
 
376
425
  def convert_boolean_string(ctx: click.core.Context, param: click.core.Parameter, value: str) -> str | bool:
377
- if value.lower() in TRUE_VALUES:
378
- return True
379
- if value.lower() in FALSE_VALUES:
380
- return False
381
- return value
426
+ return _convert_boolean_string(value)
382
427
 
383
428
 
384
429
  def convert_report(ctx: click.core.Context, param: click.core.Option, value: LazyFile) -> LazyFile:
@@ -1,30 +1,53 @@
1
1
  from __future__ import annotations
2
+
2
3
  import base64
4
+ import enum
3
5
  import json
4
6
  import re
5
7
  import sys
6
8
  import threading
7
9
  from dataclasses import dataclass, field
10
+ from http.cookies import SimpleCookie
8
11
  from queue import Queue
9
- from typing import IO, Any, Generator, Iterator, cast, TYPE_CHECKING
12
+ from typing import IO, TYPE_CHECKING, Any, Callable, Generator, Iterator, cast
13
+ from urllib.parse import parse_qsl, urlparse
14
+
15
+ import harfile
10
16
 
11
17
  from ..constants import SCHEMATHESIS_VERSION
12
18
  from ..runner import events
13
- from ..types import RequestCert
14
19
  from .handlers import EventHandler
15
20
 
16
21
  if TYPE_CHECKING:
17
22
  import click
18
23
  import requests
24
+
19
25
  from ..models import Request, Response
20
26
  from ..runner.serialization import SerializedCheck, SerializedInteraction
27
+ from ..types import RequestCert
21
28
  from .context import ExecutionContext
22
- from ..generation import DataGenerationMethod
23
29
 
24
30
  # Wait until the worker terminates
25
31
  WRITER_WORKER_JOIN_TIMEOUT = 1
26
32
 
27
33
 
34
+ class CassetteFormat(str, enum.Enum):
35
+ """Type of the cassette."""
36
+
37
+ VCR = "vcr"
38
+ HAR = "har"
39
+
40
+ @classmethod
41
+ def from_str(cls, value: str) -> CassetteFormat:
42
+ try:
43
+ return cls[value.upper()]
44
+ except KeyError:
45
+ available_formats = ", ".join(cls)
46
+ raise ValueError(
47
+ f"Invalid value for cassette format: {value}. Available formats: {available_formats}"
48
+ ) from None
49
+
50
+
28
51
  @dataclass
29
52
  class CassetteWriter(EventHandler):
30
53
  """Write interactions in a YAML cassette.
@@ -34,42 +57,47 @@ class CassetteWriter(EventHandler):
34
57
  """
35
58
 
36
59
  file_handle: click.utils.LazyFile
60
+ format: CassetteFormat
37
61
  preserve_exact_body_bytes: bool
38
62
  queue: Queue = field(default_factory=Queue)
39
63
  worker: threading.Thread = field(init=False)
40
64
 
41
65
  def __post_init__(self) -> None:
42
- self.worker = threading.Thread(
43
- target=worker,
44
- kwargs={
45
- "file_handle": self.file_handle,
46
- "preserve_exact_body_bytes": self.preserve_exact_body_bytes,
47
- "queue": self.queue,
48
- },
49
- )
66
+ kwargs = {
67
+ "file_handle": self.file_handle,
68
+ "queue": self.queue,
69
+ "preserve_exact_body_bytes": self.preserve_exact_body_bytes,
70
+ }
71
+ writer: Callable
72
+ if self.format == CassetteFormat.HAR:
73
+ writer = har_writer
74
+ else:
75
+ writer = vcr_writer
76
+ self.worker = threading.Thread(name="SchemathesisCassetteWriter", target=writer, kwargs=kwargs)
50
77
  self.worker.start()
51
78
 
52
79
  def handle_event(self, context: ExecutionContext, event: events.ExecutionEvent) -> None:
53
80
  if isinstance(event, events.Initialized):
54
81
  # In the beginning we write metadata and start `http_interactions` list
55
- self.queue.put(Initialize())
56
- if isinstance(event, events.AfterExecution):
57
- # Seed is always present at this point, the original Optional[int] type is there because `TestResult`
58
- # instance is created before `seed` is generated on the hypothesis side
59
- seed = cast(int, event.result.seed)
82
+ self.queue.put(Initialize(seed=event.seed))
83
+ elif isinstance(event, events.AfterExecution):
60
84
  self.queue.put(
61
85
  Process(
62
- seed=seed,
63
86
  correlation_id=event.correlation_id,
64
87
  thread_id=event.thread_id,
65
- # NOTE: For backward compatibility reasons AfterExecution stores a list of data generation methods
66
- # The list always contains one element - the method that was actually used for generation
67
- # This will change in the future
68
- data_generation_method=event.data_generation_method[0],
69
88
  interactions=event.result.interactions,
70
89
  )
71
90
  )
72
- if isinstance(event, events.Finished):
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):
73
101
  self.shutdown()
74
102
 
75
103
  def shutdown(self) -> None:
@@ -84,15 +112,15 @@ class CassetteWriter(EventHandler):
84
112
  class Initialize:
85
113
  """Start up, the first message to make preparations before proceeding the input data."""
86
114
 
115
+ seed: int | None
116
+
87
117
 
88
118
  @dataclass
89
119
  class Process:
90
120
  """A new chunk of data should be processed."""
91
121
 
92
- seed: int
93
122
  correlation_id: str
94
123
  thread_id: int
95
- data_generation_method: DataGenerationMethod
96
124
  interactions: list[SerializedInteraction]
97
125
 
98
126
 
@@ -110,7 +138,7 @@ def get_command_representation() -> str:
110
138
  return f"st {args}"
111
139
 
112
140
 
113
- def worker(file_handle: click.utils.LazyFile, preserve_exact_body_bytes: bool, queue: Queue) -> None:
141
+ def vcr_writer(file_handle: click.utils.LazyFile, preserve_exact_body_bytes: bool, queue: Queue) -> None:
114
142
  """Write YAML to a file in an incremental manner.
115
143
 
116
144
  This implementation doesn't use `pyyaml` package and composes YAML manually as string due to the following reasons:
@@ -130,13 +158,18 @@ def worker(file_handle: click.utils.LazyFile, preserve_exact_body_bytes: bool, q
130
158
  return "\n".join(f' "{name}":\n{format_header_values(values)}' for name, values in headers.items())
131
159
 
132
160
  def format_check_message(message: str | None) -> str:
133
- return "~" if message is None else f"{repr(message)}"
161
+ return "~" if message is None else f"{message!r}"
134
162
 
135
163
  def format_checks(checks: list[SerializedCheck]) -> str:
136
- return "\n".join(
164
+ if not checks:
165
+ return " checks: []"
166
+ items = "\n".join(
137
167
  f" - name: '{check.name}'\n status: '{check.value.name.upper()}'\n message: {format_check_message(check.message)}"
138
168
  for check in checks
139
169
  )
170
+ return f"""
171
+ checks:
172
+ {items}"""
140
173
 
141
174
  if preserve_exact_body_bytes:
142
175
 
@@ -181,9 +214,11 @@ def worker(file_handle: click.utils.LazyFile, preserve_exact_body_bytes: bool, q
181
214
  )
182
215
  write_double_quoted(output, string)
183
216
 
217
+ seed = "null"
184
218
  while True:
185
219
  item = queue.get()
186
220
  if isinstance(item, Initialize):
221
+ seed = f"'{item.seed}'"
187
222
  stream.write(
188
223
  f"""command: '{get_command_representation()}'
189
224
  recorded_with: 'Schemathesis {SCHEMATHESIS_VERSION}'
@@ -193,16 +228,45 @@ http_interactions:"""
193
228
  for interaction in item.interactions:
194
229
  status = interaction.status.name.upper()
195
230
  # 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"
196
232
  stream.write(
197
233
  f"""\n- id: '{current_id}'
198
234
  status: '{status}'
199
- seed: '{item.seed}'
235
+ seed: {seed}
200
236
  thread_id: {item.thread_id}
201
237
  correlation_id: '{item.correlation_id}'
202
- data_generation_method: '{item.data_generation_method.value}'
203
- elapsed: '{interaction.response.elapsed}'
238
+ data_generation_method: '{interaction.data_generation_method.value}'
239
+ meta:
240
+ description: """
241
+ )
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)
257
+ else:
258
+ stream.write("null")
259
+
260
+ stream.write("\n parameter_location: ")
261
+ if interaction.parameter_location is not None:
262
+ write_double_quoted(stream, interaction.parameter_location)
263
+ else:
264
+ stream.write("null")
265
+ stream.write(
266
+ f"""
267
+ phase: {phase}
268
+ elapsed: '{interaction.response.elapsed if interaction.response else 0}'
204
269
  recorded_at: '{interaction.recorded_at}'
205
- checks:
206
270
  {format_checks(interaction.checks)}
207
271
  request:
208
272
  uri: '{interaction.request.uri}'
@@ -211,8 +275,9 @@ http_interactions:"""
211
275
  {format_headers(interaction.request.headers)}"""
212
276
  )
213
277
  format_request_body(stream, interaction.request)
214
- stream.write(
215
- f"""
278
+ if interaction.response is not None:
279
+ stream.write(
280
+ f"""
216
281
  response:
217
282
  status:
218
283
  code: '{interaction.response.status_code}'
@@ -220,12 +285,16 @@ http_interactions:"""
220
285
  headers:
221
286
  {format_headers(interaction.response.headers)}
222
287
  """
223
- )
224
- format_response_body(stream, interaction.response)
225
- stream.write(
226
- f"""
288
+ )
289
+ format_response_body(stream, interaction.response)
290
+ stream.write(
291
+ f"""
227
292
  http_version: '{interaction.response.http_version}'"""
228
- )
293
+ )
294
+ else:
295
+ stream.write("""
296
+ response: null
297
+ """)
229
298
  current_id += 1
230
299
  else:
231
300
  break
@@ -254,8 +323,8 @@ def write_double_quoted(stream: IO, text: str) -> None:
254
323
  ch = text[end]
255
324
  if (
256
325
  ch is None
257
- or ch in '"\\\x85\u2028\u2029\uFEFF'
258
- or not ("\x20" <= ch <= "\x7E" or ("\xA0" <= ch <= "\uD7FF" or "\uE000" <= ch <= "\uFFFD"))
326
+ or ch in '"\\\x85\u2028\u2029\ufeff'
327
+ or not ("\x20" <= ch <= "\x7e" or ("\xa0" <= ch <= "\ud7ff" or "\ue000" <= ch <= "\ufffd"))
259
328
  ):
260
329
  if start < end:
261
330
  stream.write(text[start:end])
@@ -264,18 +333,135 @@ def write_double_quoted(stream: IO, text: str) -> None:
264
333
  # Escape character
265
334
  if ch in Emitter.ESCAPE_REPLACEMENTS:
266
335
  data = "\\" + Emitter.ESCAPE_REPLACEMENTS[ch]
267
- elif ch <= "\xFF":
268
- data = "\\x%02X" % ord(ch)
269
- elif ch <= "\uFFFF":
270
- data = "\\u%04X" % ord(ch)
336
+ elif ch <= "\xff":
337
+ data = f"\\x{ord(ch):02X}"
338
+ elif ch <= "\uffff":
339
+ data = f"\\u{ord(ch):04X}"
271
340
  else:
272
- data = "\\U%08X" % ord(ch)
341
+ data = f"\\U{ord(ch):08X}"
273
342
  stream.write(data)
274
343
  start = end + 1
275
344
  end += 1
276
345
  stream.write('"')
277
346
 
278
347
 
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:
359
+ while True:
360
+ item = queue.get()
361
+ if isinstance(item, Process):
362
+ for interaction in item.interactions:
363
+ query_params = urlparse(interaction.request.uri).query
364
+ if interaction.request.body is not None:
365
+ post_data = harfile.PostData(
366
+ mimeType=interaction.request.headers.get("Content-Type", [""])[0],
367
+ text=get_body(interaction.request.body),
368
+ )
369
+ else:
370
+ post_data = None
371
+ if interaction.response is not None:
372
+ content_type = interaction.response.headers.get("Content-Type", [""])[0]
373
+ content = harfile.Content(
374
+ size=interaction.response.body_size or 0,
375
+ mimeType=content_type,
376
+ text=get_body(interaction.response.body) if interaction.response.body is not None else None,
377
+ encoding="base64"
378
+ if interaction.response.body is not None and preserve_exact_body_bytes
379
+ else None,
380
+ )
381
+ http_version = f"HTTP/{interaction.response.http_version}"
382
+ response = harfile.Response(
383
+ status=interaction.response.status_code,
384
+ httpVersion=http_version,
385
+ 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", [])),
391
+ content=content,
392
+ headersSize=_headers_size(interaction.response.headers),
393
+ bodySize=interaction.response.body_size or 0,
394
+ redirectURL=interaction.response.headers.get("Location", [""])[0],
395
+ )
396
+ time = round(interaction.response.elapsed * 1000, 2)
397
+ else:
398
+ response = HARFILE_NO_RESPONSE
399
+ time = 0
400
+ http_version = ""
401
+
402
+ har.add_entry(
403
+ startedDateTime=interaction.recorded_at,
404
+ time=time,
405
+ request=harfile.Request(
406
+ method=interaction.request.method.upper(),
407
+ url=interaction.request.uri,
408
+ httpVersion=http_version,
409
+ headers=[
410
+ harfile.Record(name=name, value=values[0])
411
+ for name, values in interaction.request.headers.items()
412
+ ],
413
+ queryString=[
414
+ harfile.Record(name=name, value=value)
415
+ for name, value in parse_qsl(query_params, keep_blank_values=True)
416
+ ],
417
+ cookies=_extract_cookies(interaction.request.headers.get("Cookie", [])),
418
+ headersSize=_headers_size(interaction.request.headers),
419
+ bodySize=interaction.request.body_size or 0,
420
+ postData=post_data,
421
+ ),
422
+ response=response,
423
+ timings=harfile.Timings(send=0, wait=0, receive=time, blocked=0, dns=0, connect=0, ssl=0),
424
+ )
425
+ elif isinstance(item, Finalize):
426
+ break
427
+
428
+
429
+ HARFILE_NO_RESPONSE = harfile.Response(
430
+ status=0,
431
+ httpVersion="",
432
+ statusText="",
433
+ headers=[],
434
+ cookies=[],
435
+ content=harfile.Content(),
436
+ )
437
+
438
+
439
+ def _headers_size(headers: dict[str, list[str]]) -> int:
440
+ size = 0
441
+ for name, values in headers.items():
442
+ # 4 is for ": " and "\r\n"
443
+ size += len(name) + 4 + len(values[0])
444
+ return size
445
+
446
+
447
+ def _extract_cookies(headers: list[str]) -> list[harfile.Cookie]:
448
+ return [cookie for items in headers for item in items for cookie in _cookie_to_har(item)]
449
+
450
+
451
+ def _cookie_to_har(cookie: str) -> Iterator[harfile.Cookie]:
452
+ parsed = SimpleCookie(cookie)
453
+ for name, data in parsed.items():
454
+ yield harfile.Cookie(
455
+ name=name,
456
+ value=data.value,
457
+ path=data["path"] or None,
458
+ domain=data["domain"] or None,
459
+ expires=data["expires"] or None,
460
+ httpOnly=data["httponly"] or None,
461
+ secure=data["secure"] or None,
462
+ )
463
+
464
+
279
465
  @dataclass
280
466
  class Replayed:
281
467
  interaction: dict[str, Any]
@@ -351,9 +537,9 @@ def filter_cassette(
351
537
 
352
538
  def get_prepared_request(data: dict[str, Any]) -> requests.PreparedRequest:
353
539
  """Create a `requests.PreparedRequest` from a serialized one."""
354
- from requests.structures import CaseInsensitiveDict
355
- from requests.cookies import RequestsCookieJar
356
540
  import requests
541
+ from requests.cookies import RequestsCookieJar
542
+ from requests.structures import CaseInsensitiveDict
357
543
 
358
544
  prepared = requests.PreparedRequest()
359
545
  prepared.method = data["method"]
@@ -1,9 +1,11 @@
1
1
  from __future__ import annotations
2
+
2
3
  from enum import IntEnum, unique
3
4
  from typing import TYPE_CHECKING
4
5
 
5
6
  if TYPE_CHECKING:
6
7
  import hypothesis
8
+
7
9
  MIN_WORKERS = 1
8
10
  DEFAULT_WORKERS = MIN_WORKERS
9
11
  MAX_WORKERS = 64
@@ -40,11 +42,15 @@ class HealthCheck(IntEnum):
40
42
  filter_too_much = 2
41
43
  too_slow = 3
42
44
  large_base_example = 7
45
+ all = 8
43
46
 
44
- def as_hypothesis(self) -> hypothesis.HealthCheck:
47
+ def as_hypothesis(self) -> list[hypothesis.HealthCheck]:
45
48
  from hypothesis import HealthCheck
46
49
 
47
- return HealthCheck[self.name]
50
+ if self.name == "all":
51
+ return list(HealthCheck)
52
+
53
+ return [HealthCheck[self.name]]
48
54
 
49
55
 
50
56
  @unique
@@ -1,17 +1,25 @@
1
1
  from __future__ import annotations
2
- import os
2
+
3
3
  import shutil
4
4
  from dataclasses import dataclass, field
5
- from queue import Queue
6
- from typing import TYPE_CHECKING
5
+ from typing import TYPE_CHECKING, Generator
7
6
 
8
7
  from ..code_samples import CodeSampleStyle
9
8
  from ..internal.deprecation import deprecated_property
10
- from ..runner.serialization import SerializedTestResult
9
+ from ..internal.output import OutputConfig
11
10
 
12
11
  if TYPE_CHECKING:
12
+ import os
13
+ from queue import Queue
14
+
13
15
  import hypothesis
14
16
 
17
+ from ..internal.result import Result
18
+ from ..runner.probes import ProbeRun
19
+ from ..runner.serialization import SerializedTestResult
20
+ from ..service.models import AnalysisResult
21
+ from ..stateful.sink import StateMachineSink
22
+
15
23
 
16
24
  @dataclass
17
25
  class ServiceReportContext:
@@ -49,7 +57,19 @@ class ExecutionContext:
49
57
  verbosity: int = 0
50
58
  code_sample_style: CodeSampleStyle = CodeSampleStyle.default()
51
59
  report: ServiceReportContext | FileReportContext | None = None
60
+ probes: list[ProbeRun] | None = None
61
+ analysis: Result[AnalysisResult, Exception] | None = None
62
+ output_config: OutputConfig = field(default_factory=OutputConfig)
63
+ state_machine_sink: StateMachineSink | None = None
64
+ initialization_lines: list[str | Generator[str, None, None]] = field(default_factory=list)
65
+ summary_lines: list[str | Generator[str, None, None]] = field(default_factory=list)
52
66
 
53
67
  @deprecated_property(removed_in="4.0", replacement="show_trace")
54
68
  def show_errors_tracebacks(self) -> bool:
55
69
  return self.show_trace
70
+
71
+ def add_initialization_line(self, line: str | Generator[str, None, None]) -> None:
72
+ self.initialization_lines.append(line)
73
+
74
+ def add_summary_line(self, line: str | Generator[str, None, None]) -> None:
75
+ self.summary_lines.append(line)
schemathesis/cli/debug.py CHANGED
@@ -1,13 +1,14 @@
1
1
  from __future__ import annotations
2
+
2
3
  import json
3
4
  from dataclasses import dataclass
4
5
  from typing import TYPE_CHECKING
5
6
 
6
-
7
7
  from .handlers import EventHandler
8
8
 
9
9
  if TYPE_CHECKING:
10
10
  from click.utils import LazyFile
11
+
11
12
  from ..runner import events
12
13
  from .context import ExecutionContext
13
14
 
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
- from typing import TYPE_CHECKING
3
2
 
3
+ from typing import TYPE_CHECKING, Any
4
4
 
5
5
  if TYPE_CHECKING:
6
6
  from ..runner import events
@@ -8,6 +8,9 @@ if TYPE_CHECKING:
8
8
 
9
9
 
10
10
  class EventHandler:
11
+ def __init__(self, *args: Any, **params: Any) -> None:
12
+ pass
13
+
11
14
  def handle_event(self, context: ExecutionContext, event: events.ExecutionEvent) -> None:
12
15
  raise NotImplementedError
13
16