schemathesis 4.0.0a2__py3-none-any.whl → 4.0.0a4__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 (47) hide show
  1. schemathesis/cli/__init__.py +15 -4
  2. schemathesis/cli/commands/run/__init__.py +148 -94
  3. schemathesis/cli/commands/run/context.py +72 -2
  4. schemathesis/cli/commands/run/events.py +22 -2
  5. schemathesis/cli/commands/run/executor.py +35 -12
  6. schemathesis/cli/commands/run/filters.py +1 -0
  7. schemathesis/cli/commands/run/handlers/cassettes.py +27 -46
  8. schemathesis/cli/commands/run/handlers/junitxml.py +1 -1
  9. schemathesis/cli/commands/run/handlers/output.py +180 -87
  10. schemathesis/cli/commands/run/hypothesis.py +30 -19
  11. schemathesis/cli/commands/run/reports.py +72 -0
  12. schemathesis/cli/commands/run/validation.py +18 -12
  13. schemathesis/cli/ext/groups.py +42 -13
  14. schemathesis/cli/ext/options.py +15 -8
  15. schemathesis/core/errors.py +85 -9
  16. schemathesis/core/failures.py +2 -1
  17. schemathesis/core/transforms.py +1 -1
  18. schemathesis/engine/core.py +1 -1
  19. schemathesis/engine/errors.py +17 -6
  20. schemathesis/engine/phases/stateful/__init__.py +1 -0
  21. schemathesis/engine/phases/stateful/_executor.py +9 -12
  22. schemathesis/engine/phases/unit/__init__.py +2 -3
  23. schemathesis/engine/phases/unit/_executor.py +16 -13
  24. schemathesis/engine/recorder.py +22 -21
  25. schemathesis/errors.py +23 -13
  26. schemathesis/filters.py +8 -0
  27. schemathesis/generation/coverage.py +10 -5
  28. schemathesis/generation/hypothesis/builder.py +15 -12
  29. schemathesis/generation/stateful/state_machine.py +57 -12
  30. schemathesis/pytest/lazy.py +2 -3
  31. schemathesis/pytest/plugin.py +2 -3
  32. schemathesis/schemas.py +1 -1
  33. schemathesis/specs/openapi/checks.py +77 -37
  34. schemathesis/specs/openapi/expressions/__init__.py +22 -6
  35. schemathesis/specs/openapi/expressions/nodes.py +15 -21
  36. schemathesis/specs/openapi/expressions/parser.py +1 -1
  37. schemathesis/specs/openapi/parameters.py +0 -2
  38. schemathesis/specs/openapi/patterns.py +170 -2
  39. schemathesis/specs/openapi/schemas.py +67 -39
  40. schemathesis/specs/openapi/stateful/__init__.py +207 -84
  41. schemathesis/specs/openapi/stateful/control.py +87 -0
  42. schemathesis/specs/openapi/{links.py → stateful/links.py} +72 -14
  43. {schemathesis-4.0.0a2.dist-info → schemathesis-4.0.0a4.dist-info}/METADATA +1 -1
  44. {schemathesis-4.0.0a2.dist-info → schemathesis-4.0.0a4.dist-info}/RECORD +47 -45
  45. {schemathesis-4.0.0a2.dist-info → schemathesis-4.0.0a4.dist-info}/WHEEL +0 -0
  46. {schemathesis-4.0.0a2.dist-info → schemathesis-4.0.0a4.dist-info}/entry_points.txt +0 -0
  47. {schemathesis-4.0.0a2.dist-info → schemathesis-4.0.0a4.dist-info}/licenses/LICENSE +0 -0
@@ -1,17 +1,28 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from schemathesis.cli.commands import Group, run, schemathesis
4
+ from schemathesis.cli.commands.run.context import ExecutionContext
5
+ from schemathesis.cli.commands.run.events import LoadingFinished, LoadingStarted
4
6
  from schemathesis.cli.commands.run.executor import handler
5
7
  from schemathesis.cli.commands.run.handlers import EventHandler
6
- from schemathesis.cli.ext.groups import GROUPS
8
+ from schemathesis.cli.ext.groups import GROUPS, OptionGroup
7
9
 
8
- __all__ = ["schemathesis", "run", "EventHandler", "add_group", "handler"]
10
+ __all__ = [
11
+ "schemathesis",
12
+ "run",
13
+ "EventHandler",
14
+ "ExecutionContext",
15
+ "LoadingStarted",
16
+ "LoadingFinished",
17
+ "add_group",
18
+ "handler",
19
+ ]
9
20
 
10
21
 
11
22
  def add_group(name: str, *, index: int | None = None) -> Group:
12
23
  """Add a custom options group to `st run`."""
13
24
  if index is not None:
14
- GROUPS.insert(index, name)
25
+ GROUPS[name] = OptionGroup(name=name, order=index)
15
26
  else:
16
- GROUPS.append(name)
27
+ GROUPS[name] = OptionGroup(name=name)
17
28
  return Group(name)
@@ -1,16 +1,17 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from pathlib import Path
3
4
  from random import Random
4
5
  from typing import Any, Sequence
5
6
 
6
7
  import click
8
+ from click.utils import LazyFile
7
9
 
8
10
  from schemathesis import contrib, experimental
9
11
  from schemathesis.checks import CHECKS
10
12
  from schemathesis.cli.commands.run import executor, validation
11
13
  from schemathesis.cli.commands.run.checks import CheckArguments
12
14
  from schemathesis.cli.commands.run.filters import FilterArguments, with_filters
13
- from schemathesis.cli.commands.run.handlers.cassettes import CassetteConfig, CassetteFormat
14
15
  from schemathesis.cli.commands.run.hypothesis import (
15
16
  HYPOTHESIS_IN_MEMORY_DATABASE_IDENTIFIER,
16
17
  HealthCheck,
@@ -19,6 +20,7 @@ from schemathesis.cli.commands.run.hypothesis import (
19
20
  prepare_phases,
20
21
  prepare_settings,
21
22
  )
23
+ from schemathesis.cli.commands.run.reports import DEFAULT_REPORT_DIRECTORY, ReportConfig, ReportFormat
22
24
  from schemathesis.cli.constants import DEFAULT_WORKERS, MAX_WORKERS, MIN_WORKERS
23
25
  from schemathesis.cli.core import ensure_color
24
26
  from schemathesis.cli.ext.groups import group, grouped_option
@@ -48,26 +50,15 @@ DEFAULT_PHASES = ("unit", "stateful")
48
50
  @click.argument("schema", type=str) # type: ignore[misc]
49
51
  @group("Options")
50
52
  @grouped_option(
51
- "--phases",
52
- help="A comma-separated list of test phases to run",
53
- type=CsvChoice(["unit", "stateful"]),
54
- default=",".join(DEFAULT_PHASES),
55
- metavar="",
56
- )
57
- @grouped_option(
58
- "--base-url",
59
- "-b",
60
- help="Base URL of the API, required when schema is provided as a file",
53
+ "--url",
54
+ "-u",
55
+ "base_url",
56
+ help="API base URL (required for file-based schemas)",
57
+ metavar="URL",
61
58
  type=str,
62
59
  callback=validation.validate_base_url,
63
60
  envvar="SCHEMATHESIS_BASE_URL",
64
61
  )
65
- @grouped_option(
66
- "--suppress-health-check",
67
- help="A comma-separated list of Schemathesis health checks to disable",
68
- type=CsvEnumChoice(HealthCheck),
69
- metavar="",
70
- )
71
62
  @grouped_option(
72
63
  "--workers",
73
64
  "-w",
@@ -82,6 +73,26 @@ DEFAULT_PHASES = ("unit", "stateful")
82
73
  callback=validation.convert_workers,
83
74
  metavar="",
84
75
  )
76
+ @grouped_option(
77
+ "--phases",
78
+ help="A comma-separated list of test phases to run",
79
+ type=CsvChoice(["unit", "stateful"]),
80
+ default=",".join(DEFAULT_PHASES),
81
+ metavar="",
82
+ )
83
+ @grouped_option(
84
+ "--suppress-health-check",
85
+ help="A comma-separated list of Schemathesis health checks to disable",
86
+ type=CsvEnumChoice(HealthCheck),
87
+ metavar="",
88
+ )
89
+ @grouped_option(
90
+ "--wait-for-schema",
91
+ help="Maximum duration, in seconds, to wait for the API schema to become available. Disabled by default",
92
+ type=click.FloatRange(1.0),
93
+ default=None,
94
+ envvar="SCHEMATHESIS_WAIT_FOR_SCHEMA",
95
+ )
85
96
  @group("API validation options")
86
97
  @grouped_option(
87
98
  "--checks",
@@ -106,11 +117,6 @@ DEFAULT_PHASES = ("unit", "stateful")
106
117
  show_default=True,
107
118
  metavar="",
108
119
  )
109
- @grouped_option(
110
- "--max-response-time",
111
- help="Time limit in seconds for API response times. The test will fail if a response time exceeds this limit",
112
- type=click.FloatRange(min=0.0, min_open=True),
113
- )
114
120
  @grouped_option(
115
121
  "-x",
116
122
  "--exitfirst",
@@ -127,42 +133,52 @@ DEFAULT_PHASES = ("unit", "stateful")
127
133
  help="Terminate the test suite after reaching a specified number of failures or errors",
128
134
  show_default=True,
129
135
  )
130
- @group("Filtering options")
136
+ @grouped_option(
137
+ "--max-response-time",
138
+ help="Maximum allowed API response time in seconds",
139
+ type=click.FloatRange(min=0.0, min_open=True),
140
+ metavar="SECONDS",
141
+ )
142
+ @group(
143
+ "Filtering options",
144
+ description=(
145
+ "Filter operations by path, method, name, tag, or operation-id using:\n\n"
146
+ "--include-TYPE VALUE Match operations with exact VALUE\n"
147
+ "--include-TYPE-regex PATTERN Match operations using regular expression\n"
148
+ "--exclude-TYPE VALUE Exclude operations with exact VALUE\n"
149
+ "--exclude-TYPE-regex PATTERN Exclude operations using regular expression"
150
+ ),
151
+ )
131
152
  @with_filters
132
153
  @grouped_option(
133
154
  "--include-by",
134
155
  "include_by",
135
156
  type=str,
136
- help="Include API operations by expression",
157
+ metavar="EXPR",
158
+ help="Include using custom expression",
137
159
  )
138
160
  @grouped_option(
139
161
  "--exclude-by",
140
162
  "exclude_by",
141
163
  type=str,
142
- help="Exclude API operations by expression",
164
+ metavar="EXPR",
165
+ help="Exclude using custom expression",
143
166
  )
144
167
  @grouped_option(
145
168
  "--exclude-deprecated",
146
- help="Exclude deprecated API operations from testing",
169
+ help="Skip deprecated operations",
147
170
  is_flag=True,
148
171
  is_eager=True,
149
172
  default=False,
150
173
  show_default=True,
151
174
  )
152
- @group("Loader options")
153
- @grouped_option(
154
- "--wait-for-schema",
155
- help="Maximum duration, in seconds, to wait for the API schema to become available. Disabled by default",
156
- type=click.FloatRange(1.0),
157
- default=None,
158
- envvar="SCHEMATHESIS_WAIT_FOR_SCHEMA",
159
- )
160
175
  @group("Network requests options")
161
176
  @grouped_option(
162
177
  "--header",
163
178
  "-H",
164
179
  "headers",
165
- help=r"Add a custom HTTP header to all API requests. Format: 'Header-Name: Value'",
180
+ help=r"Add a custom HTTP header to all API requests",
181
+ metavar="NAME:VALUE",
166
182
  multiple=True,
167
183
  type=str,
168
184
  callback=validation.validate_headers,
@@ -170,29 +186,40 @@ DEFAULT_PHASES = ("unit", "stateful")
170
186
  @grouped_option(
171
187
  "--auth",
172
188
  "-a",
173
- help="Provide the server authentication details in the 'USER:PASSWORD' format",
189
+ help="Authenticate all API requests with basic authentication",
190
+ metavar="USER:PASS",
174
191
  type=str,
175
192
  callback=validation.validate_auth,
176
193
  )
177
194
  @grouped_option(
178
- "--request-timeout",
179
- help="Timeout limit, in seconds, for each network request during tests",
180
- type=click.FloatRange(min=0.0, min_open=True),
181
- default=DEFAULT_RESPONSE_TIMEOUT,
182
- )
183
- @grouped_option(
184
- "--request-proxy",
195
+ "--proxy",
196
+ "request_proxy",
185
197
  help="Set the proxy for all network requests",
198
+ metavar="URL",
186
199
  type=str,
187
200
  )
188
201
  @grouped_option(
189
- "--request-tls-verify",
190
- help="Configures TLS certificate verification for server requests. Can specify path to CA_BUNDLE for custom certs",
202
+ "--tls-verify",
203
+ "request_tls_verify",
204
+ help="Path to CA bundle for TLS verification, or 'false' to disable",
191
205
  type=str,
192
206
  default="true",
193
207
  show_default=True,
194
208
  callback=validation.convert_boolean_string,
195
209
  )
210
+ @grouped_option(
211
+ "--rate-limit",
212
+ help="Specify a rate limit for test requests in '<limit>/<duration>' format. "
213
+ "Example - `100/m` for 100 requests per minute",
214
+ type=str,
215
+ callback=validation.validate_rate_limit,
216
+ )
217
+ @grouped_option(
218
+ "--request-timeout",
219
+ help="Timeout limit, in seconds, for each network request during tests",
220
+ type=click.FloatRange(min=0.0, min_open=True),
221
+ default=DEFAULT_RESPONSE_TIMEOUT,
222
+ )
196
223
  @grouped_option(
197
224
  "--request-cert",
198
225
  help="File path of unencrypted client certificate for authentication. "
@@ -210,45 +237,56 @@ DEFAULT_PHASES = ("unit", "stateful")
210
237
  show_default=False,
211
238
  callback=validation.validate_request_cert_key,
212
239
  )
240
+ @group("Output options")
213
241
  @grouped_option(
214
- "--rate-limit",
215
- help="Specify a rate limit for test requests in '<limit>/<duration>' format. "
216
- "Example - `100/m` for 100 requests per minute",
217
- type=str,
218
- callback=validation.validate_rate_limit,
242
+ "--report",
243
+ "report_formats",
244
+ help="Generate test reports in specified formats",
245
+ type=CsvEnumChoice(ReportFormat),
246
+ is_eager=True,
247
+ metavar="FORMAT",
219
248
  )
220
- @group("Output options")
221
249
  @grouped_option(
222
- "--junit-xml",
223
- help="Output a JUnit-XML style report at the specified file path",
250
+ "--report-dir",
251
+ help="Directory to store all report files",
252
+ type=click.Path(file_okay=False, dir_okay=True),
253
+ default=DEFAULT_REPORT_DIRECTORY,
254
+ show_default=True,
255
+ )
256
+ @grouped_option(
257
+ "--report-junit-path",
258
+ help="Custom path for JUnit XML report",
224
259
  type=click.File("w", encoding="utf-8"),
260
+ is_eager=True,
225
261
  )
226
262
  @grouped_option(
227
- "--cassette-path",
228
- help="Save the test outcomes in a VCR-compatible format",
263
+ "--report-vcr-path",
264
+ help="Custom path for VCR cassette",
229
265
  type=click.File("w", encoding="utf-8"),
230
266
  is_eager=True,
231
267
  )
232
268
  @grouped_option(
233
- "--cassette-format",
234
- help="Format of the saved cassettes",
235
- type=click.Choice([item.name.lower() for item in CassetteFormat]),
236
- default=CassetteFormat.VCR.name.lower(),
237
- callback=validation.convert_cassette_format,
238
- metavar="",
269
+ "--report-har-path",
270
+ help="Custom path for HAR file",
271
+ type=click.File("w", encoding="utf-8"),
272
+ is_eager=True,
239
273
  )
240
274
  @grouped_option(
241
- "--cassette-preserve-exact-body-bytes",
275
+ "--report-preserve-bytes",
242
276
  help="Retain exact byte sequence of payloads in cassettes, encoded as base64",
277
+ type=bool,
243
278
  is_flag=True,
244
- callback=validation.validate_preserve_exact_body_bytes,
279
+ default=False,
280
+ callback=validation.validate_preserve_bytes,
245
281
  )
246
282
  @grouped_option(
247
283
  "--output-sanitize",
248
- type=bool,
249
- default=True,
284
+ type=str,
285
+ default="true",
250
286
  show_default=True,
251
287
  help="Enable or disable automatic output sanitization to obscure sensitive data",
288
+ metavar="BOOLEAN",
289
+ callback=validation.convert_boolean_string,
252
290
  )
253
291
  @grouped_option(
254
292
  "--output-truncate",
@@ -256,6 +294,7 @@ DEFAULT_PHASES = ("unit", "stateful")
256
294
  type=str,
257
295
  default="true",
258
296
  show_default=True,
297
+ metavar="BOOLEAN",
259
298
  callback=validation.convert_boolean_string,
260
299
  )
261
300
  @group("Experimental options")
@@ -306,10 +345,10 @@ DEFAULT_PHASES = ("unit", "stateful")
306
345
  )
307
346
  @group("Data generation options")
308
347
  @grouped_option(
309
- "--generation-mode",
348
+ "--mode",
349
+ "-m",
310
350
  "generation_modes",
311
- help="Specify the approach Schemathesis uses to generate test data. "
312
- "Use 'positive' for valid data, 'negative' for invalid data, or 'all' for both",
351
+ help="Test data generation mode",
313
352
  type=click.Choice([item.value for item in GenerationMode] + ["all"]),
314
353
  default=GenerationMode.default().value,
315
354
  callback=validation.convert_generation_mode,
@@ -317,14 +356,23 @@ DEFAULT_PHASES = ("unit", "stateful")
317
356
  metavar="",
318
357
  )
319
358
  @grouped_option(
320
- "--generation-seed",
321
- help="Seed value for Schemathesis, ensuring reproducibility across test runs",
359
+ "--max-examples",
360
+ "-n",
361
+ "generation_max_examples",
362
+ help="Maximum number of test cases per API operation",
363
+ type=click.IntRange(1),
364
+ )
365
+ @grouped_option(
366
+ "--seed",
367
+ "generation_seed",
368
+ help="Random seed for reproducible test runs",
322
369
  type=int,
323
370
  )
324
371
  @grouped_option(
325
- "--generation-max-examples",
326
- help="The cap on the number of examples generated by Schemathesis for each API operation",
327
- type=click.IntRange(1),
372
+ "--no-shrink",
373
+ "generation_no_shrink",
374
+ help="Disable test case shrinking. Makes test failures harder to debug but improves performance",
375
+ is_flag=True,
328
376
  )
329
377
  @grouped_option(
330
378
  "--generation-deterministic",
@@ -336,10 +384,11 @@ DEFAULT_PHASES = ("unit", "stateful")
336
384
  )
337
385
  @grouped_option(
338
386
  "--generation-allow-x00",
339
- help="Whether to allow the generation of `\x00` bytes within strings",
387
+ help="Whether to allow the generation of 'NULL' bytes within strings",
340
388
  type=str,
341
389
  default="true",
342
390
  show_default=True,
391
+ metavar="BOOLEAN",
343
392
  callback=validation.convert_boolean_string,
344
393
  )
345
394
  @grouped_option(
@@ -350,8 +399,8 @@ DEFAULT_PHASES = ("unit", "stateful")
350
399
  callback=validation.validate_generation_codec,
351
400
  )
352
401
  @grouped_option(
353
- "--generation-optimize",
354
- "generation_optimize",
402
+ "--generation-maximize",
403
+ "generation_maximize",
355
404
  multiple=True,
356
405
  help="Guide input generation to values more likely to expose bugs via targeted property-based testing",
357
406
  type=RegistryChoice(TARGETS),
@@ -470,8 +519,8 @@ def run(
470
519
  negative_data_rejection_allowed_statuses: list[str],
471
520
  included_check_names: Sequence[str],
472
521
  excluded_check_names: Sequence[str],
473
- phases: Sequence[str] = DEFAULT_PHASES,
474
522
  max_response_time: float | None = None,
523
+ phases: Sequence[str] = DEFAULT_PHASES,
475
524
  exit_first: bool = False,
476
525
  max_failures: int | None = None,
477
526
  include_path: Sequence[str] = (),
@@ -499,18 +548,20 @@ def run(
499
548
  exclude_deprecated: bool = False,
500
549
  workers_num: int = DEFAULT_WORKERS,
501
550
  base_url: str | None = None,
551
+ wait_for_schema: float | None = None,
552
+ rate_limit: str | None = None,
502
553
  suppress_health_check: list[HealthCheck] | None = None,
503
554
  request_timeout: int | None = None,
504
555
  request_tls_verify: bool = True,
505
556
  request_cert: str | None = None,
506
557
  request_cert_key: str | None = None,
507
558
  request_proxy: str | None = None,
508
- junit_xml: click.utils.LazyFile | None = None,
509
- cassette_path: click.utils.LazyFile | None = None,
510
- cassette_format: CassetteFormat = CassetteFormat.VCR,
511
- cassette_preserve_exact_body_bytes: bool = False,
512
- wait_for_schema: float | None = None,
513
- rate_limit: str | None = None,
559
+ report_formats: list[ReportFormat] | None = None,
560
+ report_dir: Path = DEFAULT_REPORT_DIRECTORY,
561
+ report_junit_path: LazyFile | None = None,
562
+ report_vcr_path: LazyFile | None = None,
563
+ report_har_path: LazyFile | None = None,
564
+ report_preserve_bytes: bool = False,
514
565
  output_sanitize: bool = True,
515
566
  output_truncate: bool = True,
516
567
  contrib_openapi_fill_missing_examples: bool = False,
@@ -519,7 +570,7 @@ def run(
519
570
  generation_modes: tuple[GenerationMode, ...] = DEFAULT_GENERATOR_MODES,
520
571
  generation_seed: int | None = None,
521
572
  generation_max_examples: int | None = None,
522
- generation_optimize: Sequence[str] | None = None,
573
+ generation_maximize: Sequence[str] | None = None,
523
574
  generation_deterministic: bool | None = None,
524
575
  generation_database: str | None = None,
525
576
  generation_unique_inputs: bool = False,
@@ -527,6 +578,7 @@ def run(
527
578
  generation_graphql_allow_null: bool = True,
528
579
  generation_with_security_parameters: bool = True,
529
580
  generation_codec: str = "utf-8",
581
+ generation_no_shrink: bool = False,
530
582
  force_color: bool = False,
531
583
  no_color: bool = False,
532
584
  **__kwargs: Any,
@@ -541,7 +593,7 @@ def run(
541
593
 
542
594
  validation.validate_schema(schema, base_url)
543
595
 
544
- _hypothesis_phases = prepare_phases(hypothesis_phases, hypothesis_no_phases)
596
+ _hypothesis_phases = prepare_phases(hypothesis_phases, hypothesis_no_phases, generation_no_shrink)
545
597
  _hypothesis_suppress_health_check = prepare_health_checks(suppress_health_check)
546
598
 
547
599
  for experiment in experiments:
@@ -591,13 +643,16 @@ def run(
591
643
  if exit_first and max_failures is None:
592
644
  max_failures = 1
593
645
 
594
- cassette_config = None
595
- if cassette_path is not None:
596
- cassette_config = CassetteConfig(
597
- path=cassette_path,
598
- format=cassette_format,
646
+ report_config = None
647
+ if report_formats or report_junit_path or report_vcr_path or report_har_path:
648
+ report_config = ReportConfig(
649
+ formats=report_formats,
650
+ directory=Path(report_dir),
651
+ junit_path=report_junit_path if report_junit_path else None,
652
+ vcr_path=report_vcr_path if report_vcr_path else None,
653
+ har_path=report_har_path if report_har_path else None,
654
+ preserve_bytes=report_preserve_bytes,
599
655
  sanitize_output=output_sanitize,
600
- preserve_exact_body_bytes=cassette_preserve_exact_body_bytes,
601
656
  )
602
657
 
603
658
  # Use the same seed for all tests unless `derandomize=True` is used
@@ -616,7 +671,7 @@ def run(
616
671
  execution=ExecutionConfig(
617
672
  phases=phases_,
618
673
  checks=selected_checks,
619
- targets=TARGETS.get_by_names(generation_optimize or []),
674
+ targets=TARGETS.get_by_names(generation_maximize or []),
620
675
  hypothesis_settings=prepare_settings(
621
676
  database=generation_database,
622
677
  derandomize=generation_deterministic,
@@ -654,8 +709,7 @@ def run(
654
709
  wait_for_schema=wait_for_schema,
655
710
  rate_limit=rate_limit,
656
711
  output=OutputConfig(sanitize=output_sanitize, truncate=output_truncate),
657
- cassette=cassette_config,
658
- junit_xml=junit_xml,
712
+ report=report_config,
659
713
  args=ctx.args,
660
714
  params=ctx.params,
661
715
  )
@@ -1,13 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass, field
4
- from typing import Generator
4
+ from typing import TYPE_CHECKING, Generator
5
5
 
6
6
  from schemathesis.core.failures import Failure
7
7
  from schemathesis.core.output import OutputConfig
8
+ from schemathesis.core.result import Err, Ok
9
+ from schemathesis.core.transforms import UNRESOLVABLE
8
10
  from schemathesis.core.transport import Response
9
11
  from schemathesis.engine import Status, events
10
- from schemathesis.engine.recorder import ScenarioRecorder
12
+ from schemathesis.engine.recorder import CaseNode, ScenarioRecorder
13
+ from schemathesis.generation.case import Case
14
+
15
+ if TYPE_CHECKING:
16
+ from schemathesis.generation.stateful.state_machine import ExtractionFailure
11
17
 
12
18
 
13
19
  @dataclass
@@ -17,6 +23,8 @@ class Statistic:
17
23
  outcomes: dict[Status, int]
18
24
  failures: dict[str, dict[str, GroupedFailures]]
19
25
 
26
+ extraction_failures: set[ExtractionFailure]
27
+
20
28
  tested_operations: set[str]
21
29
 
22
30
  total_cases: int
@@ -26,6 +34,7 @@ class Statistic:
26
34
  __slots__ = (
27
35
  "outcomes",
28
36
  "failures",
37
+ "extraction_failures",
29
38
  "tested_operations",
30
39
  "total_cases",
31
40
  "cases_with_failures",
@@ -35,6 +44,7 @@ class Statistic:
35
44
  def __init__(self) -> None:
36
45
  self.outcomes = {}
37
46
  self.failures = {}
47
+ self.extraction_failures = set()
38
48
  self.tested_operations = set()
39
49
  self.total_cases = 0
40
50
  self.cases_with_failures = 0
@@ -42,10 +52,25 @@ class Statistic:
42
52
 
43
53
  def record_checks(self, recorder: ScenarioRecorder) -> None:
44
54
  """Update statistics and store failures from a new batch of checks."""
55
+ from schemathesis.generation.stateful.state_machine import ExtractionFailure
56
+
45
57
  failures = self.failures.get(recorder.label, {})
46
58
 
47
59
  self.total_cases += len(recorder.cases)
48
60
 
61
+ extraction_failures = set()
62
+
63
+ def collect_history(node: CaseNode, response: Response) -> list[tuple[Case, Response]]:
64
+ history = [(node.value, response)]
65
+ current = node
66
+ while current.parent_id is not None:
67
+ current_response = recorder.find_response(case_id=current.parent_id)
68
+ # We need a response to get there, so it should be present
69
+ assert current_response is not None
70
+ current = recorder.cases[current.parent_id]
71
+ history.append((current.value, current_response))
72
+ return history
73
+
49
74
  for case_id, case in recorder.cases.items():
50
75
  checks = recorder.checks.get(case_id, [])
51
76
 
@@ -71,11 +96,56 @@ class Statistic:
71
96
  failures[case_id].failures.append(check.failure_info.failure)
72
97
  if has_failures:
73
98
  self.cases_with_failures += 1
99
+
100
+ if case.transition is None:
101
+ continue
102
+ transition = case.transition
103
+ parent = recorder.cases[transition.parent_id]
104
+ response = recorder.find_response(case_id=parent.value.id)
105
+ # We need a response to get there, so it should be present
106
+ assert response is not None
107
+
108
+ for params in transition.parameters.values():
109
+ for parameter, extracted in params.items():
110
+ if isinstance(extracted.value, Ok) and extracted.value.ok() is UNRESOLVABLE:
111
+ history = collect_history(parent, response)
112
+ extraction_failures.add(
113
+ ExtractionFailure(
114
+ id=transition.id,
115
+ case_id=case_id,
116
+ source=parent.value.operation.label,
117
+ target=case.value.operation.label,
118
+ parameter_name=parameter,
119
+ expression=extracted.definition,
120
+ history=history,
121
+ response=response,
122
+ error=None,
123
+ )
124
+ )
125
+ elif isinstance(extracted.value, Err):
126
+ history = collect_history(parent, response)
127
+ extraction_failures.add(
128
+ ExtractionFailure(
129
+ id=transition.id,
130
+ case_id=case_id,
131
+ source=parent.value.operation.label,
132
+ target=case.value.operation.label,
133
+ parameter_name=parameter,
134
+ expression=extracted.definition,
135
+ history=history,
136
+ response=response,
137
+ error=extracted.value.err(),
138
+ )
139
+ )
140
+
74
141
  if failures:
75
142
  for group in failures.values():
76
143
  group.failures = sorted(set(group.failures))
77
144
  self.failures[recorder.label] = failures
78
145
 
146
+ if extraction_failures:
147
+ self.extraction_failures.update(extraction_failures)
148
+
79
149
 
80
150
  @dataclass
81
151
  class GroupedFailures:
@@ -16,10 +16,28 @@ class LoadingStarted(events.EngineEvent):
16
16
 
17
17
 
18
18
  class LoadingFinished(events.EngineEvent):
19
- __slots__ = ("id", "timestamp", "location", "duration", "base_url", "specification", "statistic")
19
+ __slots__ = (
20
+ "id",
21
+ "timestamp",
22
+ "location",
23
+ "duration",
24
+ "base_url",
25
+ "base_path",
26
+ "specification",
27
+ "statistic",
28
+ "schema",
29
+ )
20
30
 
21
31
  def __init__(
22
- self, location: str, start_time: float, base_url: str, specification: Specification, statistic: ApiStatistic
32
+ self,
33
+ *,
34
+ location: str,
35
+ start_time: float,
36
+ base_url: str,
37
+ base_path: str,
38
+ specification: Specification,
39
+ statistic: ApiStatistic,
40
+ schema: dict,
23
41
  ) -> None:
24
42
  self.id = uuid.uuid4()
25
43
  self.timestamp = time.time()
@@ -28,3 +46,5 @@ class LoadingFinished(events.EngineEvent):
28
46
  self.base_url = base_url
29
47
  self.specification = specification
30
48
  self.statistic = statistic
49
+ self.schema = schema
50
+ self.base_path = base_path