schemathesis 4.0.0a10__py3-none-any.whl → 4.0.0a12__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 (111) hide show
  1. schemathesis/__init__.py +29 -30
  2. schemathesis/auths.py +65 -24
  3. schemathesis/checks.py +73 -39
  4. schemathesis/cli/commands/__init__.py +51 -3
  5. schemathesis/cli/commands/data.py +10 -0
  6. schemathesis/cli/commands/run/__init__.py +163 -274
  7. schemathesis/cli/commands/run/context.py +8 -4
  8. schemathesis/cli/commands/run/events.py +11 -1
  9. schemathesis/cli/commands/run/executor.py +70 -78
  10. schemathesis/cli/commands/run/filters.py +15 -165
  11. schemathesis/cli/commands/run/handlers/cassettes.py +105 -104
  12. schemathesis/cli/commands/run/handlers/junitxml.py +5 -4
  13. schemathesis/cli/commands/run/handlers/output.py +195 -121
  14. schemathesis/cli/commands/run/loaders.py +35 -50
  15. schemathesis/cli/commands/run/validation.py +52 -162
  16. schemathesis/cli/core.py +5 -3
  17. schemathesis/cli/ext/fs.py +7 -5
  18. schemathesis/cli/ext/options.py +0 -21
  19. schemathesis/config/__init__.py +189 -0
  20. schemathesis/config/_auth.py +51 -0
  21. schemathesis/config/_checks.py +268 -0
  22. schemathesis/config/_diff_base.py +99 -0
  23. schemathesis/config/_env.py +21 -0
  24. schemathesis/config/_error.py +156 -0
  25. schemathesis/config/_generation.py +149 -0
  26. schemathesis/config/_health_check.py +24 -0
  27. schemathesis/config/_operations.py +327 -0
  28. schemathesis/config/_output.py +171 -0
  29. schemathesis/config/_parameters.py +19 -0
  30. schemathesis/config/_phases.py +187 -0
  31. schemathesis/config/_projects.py +523 -0
  32. schemathesis/config/_rate_limit.py +17 -0
  33. schemathesis/config/_report.py +120 -0
  34. schemathesis/config/_validator.py +9 -0
  35. schemathesis/config/_warnings.py +25 -0
  36. schemathesis/config/schema.json +885 -0
  37. schemathesis/core/__init__.py +2 -0
  38. schemathesis/core/compat.py +16 -9
  39. schemathesis/core/errors.py +24 -4
  40. schemathesis/core/failures.py +6 -7
  41. schemathesis/core/hooks.py +20 -0
  42. schemathesis/core/output/__init__.py +14 -37
  43. schemathesis/core/output/sanitization.py +3 -146
  44. schemathesis/core/transport.py +36 -1
  45. schemathesis/core/validation.py +16 -0
  46. schemathesis/engine/__init__.py +2 -4
  47. schemathesis/engine/context.py +42 -43
  48. schemathesis/engine/core.py +7 -5
  49. schemathesis/engine/errors.py +60 -1
  50. schemathesis/engine/events.py +10 -2
  51. schemathesis/engine/phases/__init__.py +10 -0
  52. schemathesis/engine/phases/probes.py +11 -8
  53. schemathesis/engine/phases/stateful/__init__.py +2 -1
  54. schemathesis/engine/phases/stateful/_executor.py +104 -46
  55. schemathesis/engine/phases/stateful/context.py +2 -2
  56. schemathesis/engine/phases/unit/__init__.py +23 -15
  57. schemathesis/engine/phases/unit/_executor.py +110 -21
  58. schemathesis/engine/phases/unit/_pool.py +1 -1
  59. schemathesis/errors.py +2 -0
  60. schemathesis/filters.py +2 -3
  61. schemathesis/generation/__init__.py +5 -33
  62. schemathesis/generation/case.py +6 -3
  63. schemathesis/generation/coverage.py +154 -124
  64. schemathesis/generation/hypothesis/builder.py +70 -20
  65. schemathesis/generation/meta.py +3 -3
  66. schemathesis/generation/metrics.py +93 -0
  67. schemathesis/generation/modes.py +0 -8
  68. schemathesis/generation/overrides.py +37 -1
  69. schemathesis/generation/stateful/__init__.py +4 -0
  70. schemathesis/generation/stateful/state_machine.py +9 -1
  71. schemathesis/graphql/loaders.py +159 -16
  72. schemathesis/hooks.py +62 -35
  73. schemathesis/openapi/checks.py +12 -8
  74. schemathesis/openapi/generation/filters.py +10 -8
  75. schemathesis/openapi/loaders.py +142 -17
  76. schemathesis/pytest/lazy.py +2 -5
  77. schemathesis/pytest/loaders.py +24 -0
  78. schemathesis/pytest/plugin.py +33 -2
  79. schemathesis/schemas.py +21 -66
  80. schemathesis/specs/graphql/scalars.py +37 -3
  81. schemathesis/specs/graphql/schemas.py +23 -18
  82. schemathesis/specs/openapi/_hypothesis.py +26 -28
  83. schemathesis/specs/openapi/checks.py +37 -36
  84. schemathesis/specs/openapi/examples.py +4 -3
  85. schemathesis/specs/openapi/formats.py +32 -5
  86. schemathesis/specs/openapi/media_types.py +44 -1
  87. schemathesis/specs/openapi/negative/__init__.py +2 -2
  88. schemathesis/specs/openapi/patterns.py +46 -16
  89. schemathesis/specs/openapi/references.py +2 -3
  90. schemathesis/specs/openapi/schemas.py +19 -22
  91. schemathesis/specs/openapi/stateful/__init__.py +12 -6
  92. schemathesis/transport/__init__.py +54 -16
  93. schemathesis/transport/prepare.py +38 -13
  94. schemathesis/transport/requests.py +12 -9
  95. schemathesis/transport/wsgi.py +11 -12
  96. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/METADATA +50 -97
  97. schemathesis-4.0.0a12.dist-info/RECORD +164 -0
  98. schemathesis/cli/commands/run/checks.py +0 -79
  99. schemathesis/cli/commands/run/hypothesis.py +0 -78
  100. schemathesis/cli/commands/run/reports.py +0 -72
  101. schemathesis/cli/hooks.py +0 -36
  102. schemathesis/contrib/__init__.py +0 -9
  103. schemathesis/contrib/openapi/__init__.py +0 -9
  104. schemathesis/contrib/openapi/fill_missing_examples.py +0 -20
  105. schemathesis/engine/config.py +0 -59
  106. schemathesis/experimental/__init__.py +0 -72
  107. schemathesis/generation/targets.py +0 -69
  108. schemathesis-4.0.0a10.dist-info/RECORD +0 -153
  109. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/WHEEL +0 -0
  110. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/entry_points.txt +0 -0
  111. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/licenses/LICENSE +0 -0
@@ -6,16 +6,16 @@ import sys
6
6
  import threading
7
7
  from dataclasses import dataclass, field
8
8
  from http.cookies import SimpleCookie
9
+ from pathlib import Path
9
10
  from queue import Queue
10
11
  from typing import IO, Callable, Iterator
11
12
  from urllib.parse import parse_qsl, urlparse
12
13
 
13
14
  import harfile
14
- from click.utils import LazyFile
15
15
 
16
16
  from schemathesis.cli.commands.run.context import ExecutionContext
17
17
  from schemathesis.cli.commands.run.handlers.base import EventHandler
18
- from schemathesis.cli.commands.run.reports import ReportFormat
18
+ from schemathesis.config import ProjectConfig, ReportFormat, SchemathesisConfig
19
19
  from schemathesis.core.output.sanitization import sanitize_url, sanitize_value
20
20
  from schemathesis.core.transforms import deepclone
21
21
  from schemathesis.core.transport import Response
@@ -33,17 +33,15 @@ class CassetteWriter(EventHandler):
33
33
  """Write network interactions to a cassette."""
34
34
 
35
35
  format: ReportFormat
36
- path: LazyFile
37
- sanitize_output: bool = True
38
- preserve_bytes: bool = False
36
+ path: Path
37
+ config: ProjectConfig
39
38
  queue: Queue = field(default_factory=Queue)
40
39
  worker: threading.Thread = field(init=False)
41
40
 
42
41
  def __post_init__(self) -> None:
43
42
  kwargs = {
44
43
  "path": self.path,
45
- "sanitize_output": self.sanitize_output,
46
- "preserve_bytes": self.preserve_bytes,
44
+ "config": self.config,
47
45
  "queue": self.queue,
48
46
  }
49
47
  writer: Callable
@@ -55,7 +53,7 @@ class CassetteWriter(EventHandler):
55
53
  self.worker.start()
56
54
 
57
55
  def start(self, ctx: ExecutionContext) -> None:
58
- self.queue.put(Initialize(seed=ctx.seed))
56
+ self.queue.put(Initialize(seed=ctx.config.seed))
59
57
 
60
58
  def handle_event(self, ctx: ExecutionContext, event: events.EngineEvent) -> None:
61
59
  if isinstance(event, events.ScenarioFinished):
@@ -103,7 +101,7 @@ def get_command_representation() -> str:
103
101
  return f"st {args}"
104
102
 
105
103
 
106
- def vcr_writer(path: LazyFile, sanitize_output: bool, preserve_bytes: bool, queue: Queue) -> None:
104
+ def vcr_writer(path: Path, config: ProjectConfig, queue: Queue) -> None:
107
105
  """Write YAML to a file in an incremental manner.
108
106
 
109
107
  This implementation doesn't use `pyyaml` package and composes YAML manually as string due to the following reasons:
@@ -114,16 +112,15 @@ def vcr_writer(path: LazyFile, sanitize_output: bool, preserve_bytes: bool, queu
114
112
  providing tags, anchors to have incremental writing, with primitive types it is much simpler.
115
113
  """
116
114
  current_id = 1
117
- stream = path.open()
118
115
 
119
116
  def format_header_values(values: list[str]) -> str:
120
117
  return "\n".join(f" - {json.dumps(v)}" for v in values)
121
118
 
122
- if sanitize_output:
119
+ if config.output.sanitization.enabled:
123
120
 
124
121
  def format_headers(headers: dict[str, list[str]]) -> str:
125
122
  headers = deepclone(headers)
126
- sanitize_value(headers)
123
+ sanitize_value(headers, config=config.output.sanitization)
127
124
  return "\n".join(f' "{name}":\n{format_header_values(values)}' for name, values in headers.items())
128
125
 
129
126
  else:
@@ -145,7 +142,7 @@ def vcr_writer(path: LazyFile, sanitize_output: bool, preserve_bytes: bool, queu
145
142
  checks:
146
143
  {items}"""
147
144
 
148
- if preserve_bytes:
145
+ if config.reports.preserve_bytes:
149
146
 
150
147
  def format_request_body(output: IO, request: Request) -> None:
151
148
  if request.encoded_body is not None:
@@ -188,102 +185,105 @@ def vcr_writer(path: LazyFile, sanitize_output: bool, preserve_bytes: bool, queu
188
185
  )
189
186
  write_double_quoted(output, string)
190
187
 
191
- while True:
192
- item = queue.get()
193
- if isinstance(item, Initialize):
194
- stream.write(
195
- f"""command: '{get_command_representation()}'
188
+ with open(path, "w", encoding="utf-8") as stream:
189
+ while True:
190
+ item = queue.get()
191
+ if isinstance(item, Initialize):
192
+ stream.write(
193
+ f"""command: '{get_command_representation()}'
196
194
  recorded_with: 'Schemathesis {SCHEMATHESIS_VERSION}'
197
195
  seed: {item.seed}
198
196
  http_interactions:"""
199
- )
200
- elif isinstance(item, Process):
201
- for case_id, interaction in item.recorder.interactions.items():
202
- case = item.recorder.cases[case_id]
203
- if interaction.response is not None:
204
- if case_id in item.recorder.checks:
205
- checks = item.recorder.checks[case_id]
206
- status = Status.SUCCESS
207
- for check in checks:
208
- if check.status == Status.FAILURE:
209
- status = check.status
210
- break
197
+ )
198
+ elif isinstance(item, Process):
199
+ for case_id, interaction in item.recorder.interactions.items():
200
+ case = item.recorder.cases[case_id]
201
+ if interaction.response is not None:
202
+ if case_id in item.recorder.checks:
203
+ checks = item.recorder.checks[case_id]
204
+ status = Status.SUCCESS
205
+ for check in checks:
206
+ if check.status == Status.FAILURE:
207
+ status = check.status
208
+ break
209
+ else:
210
+ # NOTE: Checks recording could be skipped if Schemathesis start skipping just
211
+ # discovered failures in order to get past them and potentially discover more failures
212
+ checks = []
213
+ status = Status.SKIP
211
214
  else:
212
- # NOTE: Checks recording could be skipped if Schemathesis start skipping just
213
- # discovered failures in order to get past them and potentially discover more failures
214
215
  checks = []
215
- status = Status.SKIP
216
- else:
217
- checks = []
218
- status = Status.ERROR
219
- # Body payloads are handled via separate `stream.write` calls to avoid some allocations
220
- stream.write(
221
- f"""\n- id: '{case_id}'
216
+ status = Status.ERROR
217
+ # Body payloads are handled via separate `stream.write` calls to avoid some allocations
218
+ stream.write(
219
+ f"""\n- id: '{case_id}'
222
220
  status: '{status.name}'"""
223
- )
224
- meta = case.value.meta
225
- if meta is not None:
226
- # Start metadata block
227
- stream.write(f"""
221
+ )
222
+ meta = case.value.meta
223
+ if meta is not None:
224
+ # Start metadata block
225
+ stream.write(f"""
228
226
  generation:
229
227
  time: {meta.generation.time}
230
228
  mode: {meta.generation.mode.value}
231
229
  components:""")
232
230
 
233
- # Write components
234
- for kind, info in meta.components.items():
235
- stream.write(f"""
231
+ # Write components
232
+ for kind, info in meta.components.items():
233
+ stream.write(f"""
236
234
  {kind.value}:
237
235
  mode: '{info.mode.value}'""")
238
- # Write phase info
239
- stream.write("\n phase:")
240
- stream.write(f"\n name: '{meta.phase.name.value}'")
241
- stream.write("\n data: ")
242
-
243
- # Write phase-specific data
244
- if isinstance(meta.phase.data, CoveragePhaseData):
245
- stream.write("""
236
+ # Write phase info
237
+ stream.write("\n phase:")
238
+ stream.write(f"\n name: '{meta.phase.name.value}'")
239
+ stream.write("\n data: ")
240
+
241
+ # Write phase-specific data
242
+ if isinstance(meta.phase.data, CoveragePhaseData):
243
+ stream.write("""
246
244
  description: """)
247
- write_double_quoted(stream, meta.phase.data.description)
248
- stream.write("""
245
+ write_double_quoted(stream, meta.phase.data.description)
246
+ stream.write("""
249
247
  location: """)
250
- write_double_quoted(stream, meta.phase.data.location)
251
- stream.write("""
248
+ write_double_quoted(stream, meta.phase.data.location)
249
+ stream.write("""
252
250
  parameter: """)
253
- if meta.phase.data.parameter is not None:
254
- write_double_quoted(stream, meta.phase.data.parameter)
255
- else:
256
- stream.write("null")
257
- stream.write("""
251
+ if meta.phase.data.parameter is not None:
252
+ write_double_quoted(stream, meta.phase.data.parameter)
253
+ else:
254
+ stream.write("null")
255
+ stream.write("""
258
256
  parameter_location: """)
259
- if meta.phase.data.parameter_location is not None:
260
- write_double_quoted(stream, meta.phase.data.parameter_location)
257
+ if meta.phase.data.parameter_location is not None:
258
+ write_double_quoted(stream, meta.phase.data.parameter_location)
259
+ else:
260
+ stream.write("null")
261
261
  else:
262
- stream.write("null")
262
+ # Empty objects for these phases
263
+ stream.write("{}")
263
264
  else:
264
- # Empty objects for these phases
265
- stream.write("{}")
266
- else:
267
- stream.write("null")
265
+ stream.write("null")
268
266
 
269
- if sanitize_output:
270
- uri = sanitize_url(interaction.request.uri)
271
- else:
272
- uri = interaction.request.uri
273
- recorded_at = datetime.datetime.fromtimestamp(interaction.timestamp, datetime.timezone.utc).isoformat()
274
- stream.write(
275
- f"""
267
+ if config.output.sanitization.enabled:
268
+ uri = sanitize_url(interaction.request.uri, config=config.output.sanitization)
269
+ else:
270
+ uri = interaction.request.uri
271
+ recorded_at = datetime.datetime.fromtimestamp(
272
+ interaction.timestamp, datetime.timezone.utc
273
+ ).isoformat()
274
+ stream.write(
275
+ f"""
276
276
  recorded_at: '{recorded_at}'{format_checks(checks)}
277
277
  request:
278
278
  uri: '{uri}'
279
279
  method: '{interaction.request.method}'
280
280
  headers:
281
281
  {format_headers(interaction.request.headers)}"""
282
- )
283
- format_request_body(stream, interaction.request)
284
- if interaction.response is not None:
285
- stream.write(
286
- f"""
282
+ )
283
+ format_request_body(stream, interaction.request)
284
+ if interaction.response is not None:
285
+ stream.write(
286
+ f"""
287
287
  response:
288
288
  status:
289
289
  code: '{interaction.response.status_code}'
@@ -292,19 +292,18 @@ http_interactions:"""
292
292
  headers:
293
293
  {format_headers(interaction.response.headers)}
294
294
  """
295
- )
296
- format_response_body(stream, interaction.response)
297
- stream.write(
298
- f"""
295
+ )
296
+ format_response_body(stream, interaction.response)
297
+ stream.write(
298
+ f"""
299
299
  http_version: '{interaction.response.http_version}'"""
300
- )
301
- else:
302
- stream.write("""
300
+ )
301
+ else:
302
+ stream.write("""
303
303
  response: null""")
304
- current_id += 1
305
- else:
306
- break
307
- path.close()
304
+ current_id += 1
305
+ else:
306
+ break
308
307
 
309
308
 
310
309
  def write_double_quoted(stream: IO, text: str | None) -> None:
@@ -350,14 +349,14 @@ def write_double_quoted(stream: IO, text: str | None) -> None:
350
349
  stream.write('"')
351
350
 
352
351
 
353
- def har_writer(path: LazyFile, sanitize_output: bool, preserve_bytes: bool, queue: Queue) -> None:
352
+ def har_writer(path: Path, config: SchemathesisConfig, queue: Queue) -> None:
354
353
  with harfile.open(path) as har:
355
354
  while True:
356
355
  item = queue.get()
357
356
  if isinstance(item, Process):
358
357
  for interaction in item.recorder.interactions.values():
359
- if sanitize_output:
360
- uri = sanitize_url(interaction.request.uri)
358
+ if config.output.sanitization.enabled:
359
+ uri = sanitize_url(interaction.request.uri, config=config.output.sanitization)
361
360
  else:
362
361
  uri = interaction.request.uri
363
362
  query_params = urlparse(uri).query
@@ -365,7 +364,7 @@ def har_writer(path: LazyFile, sanitize_output: bool, preserve_bytes: bool, queu
365
364
  post_data = harfile.PostData(
366
365
  mimeType=interaction.request.headers.get("Content-Type", [""])[0],
367
366
  text=interaction.request.encoded_body
368
- if preserve_bytes
367
+ if config.reports.preserve_bytes
369
368
  else interaction.request.body.decode("utf-8", "replace"),
370
369
  )
371
370
  else:
@@ -376,16 +375,18 @@ def har_writer(path: LazyFile, sanitize_output: bool, preserve_bytes: bool, queu
376
375
  size=interaction.response.body_size or 0,
377
376
  mimeType=content_type,
378
377
  text=interaction.response.encoded_body
379
- if preserve_bytes
378
+ if config.reports.preserve_bytes
380
379
  else interaction.response.content.decode("utf-8", "replace")
381
380
  if interaction.response.content is not None
382
381
  else None,
383
- encoding="base64" if interaction.response.content is not None and preserve_bytes else None,
382
+ encoding="base64"
383
+ if interaction.response.content is not None and config.reports.preserve_bytes
384
+ else None,
384
385
  )
385
386
  http_version = f"HTTP/{interaction.response.http_version}"
386
- if sanitize_output:
387
+ if config.output.sanitization.enabled:
387
388
  headers = deepclone(interaction.response.headers)
388
- sanitize_value(headers)
389
+ sanitize_value(headers, config=config.output.sanitization)
389
390
  else:
390
391
  headers = interaction.response.headers
391
392
  response = harfile.Response(
@@ -405,9 +406,9 @@ def har_writer(path: LazyFile, sanitize_output: bool, preserve_bytes: bool, queu
405
406
  time = 0
406
407
  http_version = ""
407
408
 
408
- if sanitize_output:
409
+ if config.output.sanitization.enabled:
409
410
  headers = deepclone(interaction.request.headers)
410
- sanitize_value(headers)
411
+ sanitize_value(headers, config=config.output.sanitization)
411
412
  else:
412
413
  headers = interaction.request.headers
413
414
  started_datetime = datetime.datetime.fromtimestamp(
@@ -2,9 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  import platform
4
4
  from dataclasses import dataclass, field
5
+ from pathlib import Path
5
6
  from typing import Iterable
6
7
 
7
- from click.utils import LazyFile
8
8
  from junit_xml import TestCase, TestSuite, to_xml_report_file
9
9
 
10
10
  from schemathesis.cli.commands.run.context import ExecutionContext, GroupedFailures
@@ -15,7 +15,7 @@ from schemathesis.engine import Status, events
15
15
 
16
16
  @dataclass
17
17
  class JunitXMLHandler(EventHandler):
18
- file_handle: LazyFile
18
+ path: Path
19
19
  test_cases: dict = field(default_factory=dict)
20
20
 
21
21
  def handle_event(self, ctx: ExecutionContext, event: events.EngineEvent) -> None:
@@ -34,7 +34,8 @@ class JunitXMLHandler(EventHandler):
34
34
  test_suites = [
35
35
  TestSuite("schemathesis", test_cases=list(self.test_cases.values()), hostname=platform.node())
36
36
  ]
37
- to_xml_report_file(file_descriptor=self.file_handle, test_suites=test_suites, prettyprint=True)
37
+ with open(self.path, "w") as fd:
38
+ to_xml_report_file(file_descriptor=fd, test_suites=test_suites, prettyprint=True)
38
39
 
39
40
  def get_or_create_test_case(self, label: str) -> TestCase:
40
41
  return self.test_cases.setdefault(label, TestCase(label, elapsed_sec=0.0, allow_multiple_subelements=True))
@@ -47,7 +48,7 @@ def add_failure(test_case: TestCase, checks: Iterable[GroupedFailures], context:
47
48
  response=group.response,
48
49
  failures=group.failures,
49
50
  curl=group.code_sample,
50
- config=context.output_config,
51
+ config=context.config.output,
51
52
  )
52
53
  for idx, group in enumerate(checks, 1)
53
54
  ]