schemathesis 4.0.0a1__py3-none-any.whl → 4.0.0a3__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 (44) hide show
  1. schemathesis/checks.py +6 -4
  2. schemathesis/cli/__init__.py +12 -1
  3. schemathesis/cli/commands/run/__init__.py +4 -4
  4. schemathesis/cli/commands/run/events.py +19 -4
  5. schemathesis/cli/commands/run/executor.py +9 -3
  6. schemathesis/cli/commands/run/filters.py +27 -19
  7. schemathesis/cli/commands/run/handlers/base.py +1 -1
  8. schemathesis/cli/commands/run/handlers/cassettes.py +1 -3
  9. schemathesis/cli/commands/run/handlers/output.py +860 -201
  10. schemathesis/cli/commands/run/validation.py +1 -1
  11. schemathesis/cli/ext/options.py +4 -1
  12. schemathesis/core/errors.py +8 -0
  13. schemathesis/core/failures.py +54 -24
  14. schemathesis/engine/core.py +1 -1
  15. schemathesis/engine/errors.py +11 -5
  16. schemathesis/engine/events.py +3 -97
  17. schemathesis/engine/phases/stateful/__init__.py +2 -0
  18. schemathesis/engine/phases/stateful/_executor.py +22 -50
  19. schemathesis/engine/phases/unit/__init__.py +1 -0
  20. schemathesis/engine/phases/unit/_executor.py +2 -1
  21. schemathesis/engine/phases/unit/_pool.py +1 -1
  22. schemathesis/engine/recorder.py +29 -23
  23. schemathesis/errors.py +19 -13
  24. schemathesis/generation/coverage.py +4 -4
  25. schemathesis/generation/hypothesis/builder.py +15 -12
  26. schemathesis/generation/stateful/state_machine.py +61 -45
  27. schemathesis/graphql/checks.py +3 -9
  28. schemathesis/openapi/checks.py +8 -33
  29. schemathesis/schemas.py +34 -14
  30. schemathesis/specs/graphql/schemas.py +16 -15
  31. schemathesis/specs/openapi/checks.py +50 -27
  32. schemathesis/specs/openapi/expressions/__init__.py +11 -15
  33. schemathesis/specs/openapi/expressions/nodes.py +20 -20
  34. schemathesis/specs/openapi/links.py +139 -118
  35. schemathesis/specs/openapi/patterns.py +170 -2
  36. schemathesis/specs/openapi/schemas.py +60 -36
  37. schemathesis/specs/openapi/stateful/__init__.py +185 -113
  38. schemathesis/specs/openapi/stateful/control.py +87 -0
  39. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/METADATA +1 -1
  40. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/RECORD +43 -43
  41. schemathesis/specs/openapi/expressions/context.py +0 -14
  42. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/WHEEL +0 -0
  43. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/entry_points.txt +0 -0
  44. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ import time
4
5
  from dataclasses import dataclass, field
5
6
  from types import GeneratorType
6
7
  from typing import TYPE_CHECKING, Any, Generator, Iterable
@@ -23,11 +24,13 @@ from schemathesis.engine.phases import PhaseName, PhaseSkipReason
23
24
  from schemathesis.engine.phases.probes import ProbeOutcome
24
25
  from schemathesis.engine.recorder import Interaction
25
26
  from schemathesis.experimental import GLOBAL_EXPERIMENTS
26
- from schemathesis.schemas import ApiOperationsCount
27
+ from schemathesis.schemas import ApiStatistic
27
28
 
28
29
  if TYPE_CHECKING:
29
- from rich.console import Console
30
+ from rich.console import Console, Group
31
+ from rich.live import Live
30
32
  from rich.progress import Progress, TaskID
33
+ from rich.text import Text
31
34
 
32
35
  IO_ENCODING = os.getenv("PYTHONIOENCODING", "utf-8")
33
36
  DISCORD_LINK = "https://discord.gg/R9ASRAmHnA"
@@ -37,13 +40,7 @@ def display_section_name(title: str, separator: str = "=", **kwargs: Any) -> Non
37
40
  """Print section name with separators in terminal with the given title nicely centered."""
38
41
  message = f" {title} ".center(get_terminal_width(), separator)
39
42
  kwargs.setdefault("bold", True)
40
- click.secho(message, **kwargs)
41
-
42
-
43
- def get_percentage(position: int, length: int) -> str:
44
- """Format completion percentage in square brackets."""
45
- percentage_message = f"{position * 100 // length}%".rjust(4)
46
- return f"[{percentage_message}]"
43
+ click.echo(_style(message, **kwargs))
47
44
 
48
45
 
49
46
  def bold(option: str) -> str:
@@ -61,12 +58,14 @@ def display_failures(ctx: ExecutionContext) -> None:
61
58
 
62
59
 
63
60
  if IO_ENCODING != "utf-8":
61
+ HEADER_SEPARATOR = "-"
64
62
 
65
63
  def _style(text: str, **kwargs: Any) -> str:
66
64
  text = text.encode(IO_ENCODING, errors="replace").decode("utf-8")
67
65
  return click.style(text, **kwargs)
68
66
 
69
67
  else:
68
+ HEADER_SEPARATOR = "━"
70
69
 
71
70
  def _style(text: str, **kwargs: Any) -> str:
72
71
  return click.style(text, **kwargs)
@@ -100,7 +99,7 @@ def display_failures_for_single_test(ctx: ExecutionContext, label: str, checks:
100
99
  click.echo()
101
100
 
102
101
 
103
- VERIFY_URL_SUGGESTION = "Verify that the URL points directly to the Open API schema"
102
+ VERIFY_URL_SUGGESTION = "Verify that the URL points directly to the Open API schema or GraphQL endpoint"
104
103
  DISABLE_SSL_SUGGESTION = f"Bypass SSL verification with {bold('`--request-tls-verify=false`')}."
105
104
  LOADER_ERROR_SUGGESTIONS = {
106
105
  # SSL-specific connection issue
@@ -125,20 +124,14 @@ def _display_extras(extras: list[str]) -> None:
125
124
  if extras:
126
125
  click.echo()
127
126
  for extra in extras:
128
- click.secho(f" {extra}")
129
-
130
-
131
- def _maybe_display_tip(suggestion: str | None) -> None:
132
- # Display suggestion if any
133
- if suggestion is not None:
134
- click.secho(f"\n{click.style('Tip:', bold=True, fg='green')} {suggestion}")
127
+ click.echo(_style(f" {extra}"))
135
128
 
136
129
 
137
130
  def display_header(version: str) -> None:
138
131
  prefix = "v" if version != "dev" else ""
139
132
  header = f"Schemathesis {prefix}{version}"
140
- click.secho(header, bold=True)
141
- click.secho("━" * len(header), bold=True)
133
+ click.echo(_style(header, bold=True))
134
+ click.echo(_style(HEADER_SEPARATOR * len(header), bold=True))
142
135
  click.echo()
143
136
 
144
137
 
@@ -168,22 +161,634 @@ def _default_console() -> Console:
168
161
  BLOCK_PADDING = (0, 1, 0, 1)
169
162
 
170
163
 
164
+ @dataclass
165
+ class LoadingProgressManager:
166
+ console: Console
167
+ location: str
168
+ start_time: float
169
+ progress: Progress
170
+ progress_task_id: TaskID | None
171
+ is_interrupted: bool
172
+
173
+ __slots__ = ("console", "location", "start_time", "progress", "progress_task_id", "is_interrupted")
174
+
175
+ def __init__(self, console: Console, location: str) -> None:
176
+ from rich.progress import Progress, RenderableColumn, SpinnerColumn, TextColumn
177
+ from rich.style import Style
178
+ from rich.text import Text
179
+
180
+ self.console = console
181
+ self.location = location
182
+ self.start_time = time.monotonic()
183
+ progress_message = Text.assemble(
184
+ ("Loading specification from ", Style(color="white")),
185
+ (location, Style(color="cyan")),
186
+ )
187
+ self.progress = Progress(
188
+ TextColumn(""),
189
+ SpinnerColumn("clock"),
190
+ RenderableColumn(progress_message),
191
+ console=console,
192
+ transient=True,
193
+ )
194
+ self.progress_task_id = None
195
+ self.is_interrupted = False
196
+
197
+ def start(self) -> None:
198
+ """Start loading progress display."""
199
+ self.progress_task_id = self.progress.add_task("Loading", total=None)
200
+ self.progress.start()
201
+
202
+ def stop(self) -> None:
203
+ """Stop loading progress display."""
204
+ assert self.progress_task_id is not None
205
+ self.progress.stop_task(self.progress_task_id)
206
+ self.progress.stop()
207
+
208
+ def interrupt(self) -> None:
209
+ """Handle interruption during loading."""
210
+ self.is_interrupted = True
211
+ self.stop()
212
+
213
+ def get_completion_message(self) -> Text:
214
+ """Generate completion message including duration."""
215
+ from rich.style import Style
216
+ from rich.text import Text
217
+
218
+ duration = format_duration(int((time.monotonic() - self.start_time) * 1000))
219
+ if self.is_interrupted:
220
+ return Text.assemble(
221
+ ("⚡ ", Style(color="yellow")),
222
+ (f"Loading interrupted after {duration} while loading from ", Style(color="white")),
223
+ (self.location, Style(color="cyan")),
224
+ )
225
+ return Text.assemble(
226
+ ("✅ ", Style(color="green")),
227
+ ("Loaded specification from ", Style(color="bright_white")),
228
+ (self.location, Style(color="cyan")),
229
+ (f" (in {duration})", Style(color="bright_white")),
230
+ )
231
+
232
+ def get_error_message(self, error: LoaderError) -> Group:
233
+ from rich.console import Group
234
+ from rich.style import Style
235
+ from rich.text import Text
236
+
237
+ duration = format_duration(int((time.monotonic() - self.start_time) * 1000))
238
+
239
+ # Show what was attempted
240
+ attempted = Text.assemble(
241
+ ("❌ ", Style(color="red")),
242
+ ("Failed to load specification from ", Style(color="white")),
243
+ (self.location, Style(color="cyan")),
244
+ (f" after {duration}", Style(color="white")),
245
+ )
246
+
247
+ # Show error details
248
+ error_title = Text("Schema Loading Error", style=Style(color="red", bold=True))
249
+ error_message = Text(error.message)
250
+
251
+ return Group(
252
+ attempted,
253
+ Text(),
254
+ error_title,
255
+ Text(),
256
+ error_message,
257
+ )
258
+
259
+
260
+ @dataclass
261
+ class ProbingProgressManager:
262
+ console: Console
263
+ start_time: float
264
+ progress: Progress
265
+ progress_task_id: TaskID | None
266
+ is_interrupted: bool
267
+
268
+ __slots__ = ("console", "start_time", "progress", "progress_task_id", "is_interrupted")
269
+
270
+ def __init__(self, console: Console) -> None:
271
+ from rich.progress import Progress, RenderableColumn, SpinnerColumn, TextColumn
272
+ from rich.text import Text
273
+
274
+ self.console = console
275
+ self.start_time = time.monotonic()
276
+ self.progress = Progress(
277
+ TextColumn(""),
278
+ SpinnerColumn("clock"),
279
+ RenderableColumn(Text("Probing API capabilities", style="bright_white")),
280
+ transient=True,
281
+ console=console,
282
+ )
283
+ self.progress_task_id = None
284
+ self.is_interrupted = False
285
+
286
+ def start(self) -> None:
287
+ """Start probing progress display."""
288
+ self.progress_task_id = self.progress.add_task("Probing", total=None)
289
+ self.progress.start()
290
+
291
+ def stop(self) -> None:
292
+ """Stop probing progress display."""
293
+ assert self.progress_task_id is not None
294
+ self.progress.stop_task(self.progress_task_id)
295
+ self.progress.stop()
296
+
297
+ def interrupt(self) -> None:
298
+ """Handle interruption during probing."""
299
+ self.is_interrupted = True
300
+ self.stop()
301
+
302
+ def get_completion_message(self) -> Text:
303
+ """Generate completion message including duration."""
304
+ from rich.style import Style
305
+ from rich.text import Text
306
+
307
+ duration = format_duration(int((time.monotonic() - self.start_time) * 1000))
308
+ if self.is_interrupted:
309
+ return Text.assemble(
310
+ ("⚡ ", Style(color="yellow")),
311
+ (f"API probing interrupted after {duration}", Style(color="white")),
312
+ )
313
+ return Text.assemble(
314
+ ("✅ ", Style(color="green")),
315
+ ("API capabilities:", Style(color="white")),
316
+ )
317
+
318
+
171
319
  @dataclass
172
320
  class WarningData:
173
321
  missing_auth: dict[int, list[str]] = field(default_factory=dict)
174
322
 
175
323
 
324
+ @dataclass
325
+ class OperationProgress:
326
+ """Tracks individual operation progress."""
327
+
328
+ label: str
329
+ start_time: float
330
+ task_id: TaskID
331
+
332
+ __slots__ = ("label", "start_time", "task_id")
333
+
334
+
335
+ @dataclass
336
+ class UnitTestProgressManager:
337
+ """Manages progress display for unit tests."""
338
+
339
+ console: Console
340
+ title: str
341
+ current: int
342
+ total: int
343
+ start_time: float
344
+
345
+ # Progress components
346
+ title_progress: Progress
347
+ progress_bar: Progress
348
+ operations_progress: Progress
349
+ current_operations: dict[str, OperationProgress]
350
+ stats: dict[Status, int]
351
+ stats_progress: Progress
352
+ live: Live | None
353
+
354
+ # Task IDs
355
+ title_task_id: TaskID | None
356
+ progress_task_id: TaskID | None
357
+ stats_task_id: TaskID
358
+
359
+ is_interrupted: bool
360
+
361
+ __slots__ = (
362
+ "console",
363
+ "title",
364
+ "current",
365
+ "total",
366
+ "start_time",
367
+ "title_progress",
368
+ "progress_bar",
369
+ "operations_progress",
370
+ "current_operations",
371
+ "stats",
372
+ "stats_progress",
373
+ "live",
374
+ "title_task_id",
375
+ "progress_task_id",
376
+ "stats_task_id",
377
+ "is_interrupted",
378
+ )
379
+
380
+ def __init__(
381
+ self,
382
+ console: Console,
383
+ title: str,
384
+ total: int,
385
+ ) -> None:
386
+ from rich.progress import (
387
+ BarColumn,
388
+ Progress,
389
+ SpinnerColumn,
390
+ TextColumn,
391
+ TimeElapsedColumn,
392
+ )
393
+ from rich.style import Style
394
+
395
+ self.console = console
396
+ self.title = title
397
+ self.current = 0
398
+ self.total = total
399
+ self.start_time = time.monotonic()
400
+
401
+ # Initialize progress displays
402
+ self.title_progress = Progress(
403
+ TextColumn(""),
404
+ SpinnerColumn("clock"),
405
+ TextColumn("{task.description}", style=Style(color="white")),
406
+ console=self.console,
407
+ )
408
+ self.title_task_id = None
409
+
410
+ self.progress_bar = Progress(
411
+ TextColumn(" "),
412
+ TimeElapsedColumn(),
413
+ BarColumn(bar_width=None),
414
+ TextColumn("{task.percentage:.0f}% ({task.completed}/{task.total})"),
415
+ console=self.console,
416
+ )
417
+ self.progress_task_id = None
418
+
419
+ self.operations_progress = Progress(
420
+ TextColumn(" "),
421
+ SpinnerColumn("dots"),
422
+ TimeElapsedColumn(),
423
+ TextColumn(" {task.fields[label]}"),
424
+ console=self.console,
425
+ )
426
+
427
+ self.current_operations = {}
428
+
429
+ self.stats_progress = Progress(
430
+ TextColumn(" "),
431
+ TextColumn("{task.description}"),
432
+ console=self.console,
433
+ )
434
+ self.stats_task_id = self.stats_progress.add_task("")
435
+ self.stats = {
436
+ Status.SUCCESS: 0,
437
+ Status.FAILURE: 0,
438
+ Status.SKIP: 0,
439
+ Status.ERROR: 0,
440
+ }
441
+ self._update_stats_display()
442
+
443
+ self.live = None
444
+ self.is_interrupted = False
445
+
446
+ def _get_stats_message(self) -> str:
447
+ width = len(str(self.total))
448
+
449
+ parts = []
450
+ if self.stats[Status.SUCCESS]:
451
+ parts.append(f"✅ {self.stats[Status.SUCCESS]:{width}d} passed")
452
+ if self.stats[Status.FAILURE]:
453
+ parts.append(f"❌ {self.stats[Status.FAILURE]:{width}d} failed")
454
+ if self.stats[Status.ERROR]:
455
+ parts.append(f"🚫 {self.stats[Status.ERROR]:{width}d} errors")
456
+ if self.stats[Status.SKIP]:
457
+ parts.append(f"⏭️ {self.stats[Status.SKIP]:{width}d} skipped")
458
+ return " ".join(parts)
459
+
460
+ def _update_stats_display(self) -> None:
461
+ """Update the statistics display."""
462
+ self.stats_progress.update(self.stats_task_id, description=self._get_stats_message())
463
+
464
+ def start(self) -> None:
465
+ """Start progress display."""
466
+ from rich.console import Group
467
+ from rich.live import Live
468
+ from rich.text import Text
469
+
470
+ group = Group(
471
+ self.title_progress,
472
+ Text(),
473
+ self.progress_bar,
474
+ Text(),
475
+ self.operations_progress,
476
+ Text(),
477
+ self.stats_progress,
478
+ )
479
+
480
+ self.live = Live(group, refresh_per_second=10, console=self.console, transient=True)
481
+ self.live.start()
482
+
483
+ # Initialize both progress displays
484
+ self.title_task_id = self.title_progress.add_task(self.title, total=self.total)
485
+ self.progress_task_id = self.progress_bar.add_task(
486
+ "", # Empty description as it's shown in title
487
+ total=self.total,
488
+ )
489
+
490
+ def update_progress(self) -> None:
491
+ """Update progress in both displays."""
492
+ assert self.title_task_id is not None
493
+ assert self.progress_task_id is not None
494
+
495
+ self.current += 1
496
+ self.title_progress.update(self.title_task_id, completed=self.current)
497
+ self.progress_bar.update(self.progress_task_id, completed=self.current)
498
+
499
+ def start_operation(self, label: str) -> None:
500
+ """Start tracking new operation."""
501
+ task_id = self.operations_progress.add_task("", label=label, start_time=time.monotonic())
502
+ self.current_operations[label] = OperationProgress(label=label, start_time=time.monotonic(), task_id=task_id)
503
+
504
+ def finish_operation(self, label: str) -> None:
505
+ """Finish tracking operation."""
506
+ if operation := self.current_operations.pop(label, None):
507
+ if not self.current_operations:
508
+ assert self.title_task_id is not None
509
+ self.title_progress.update(self.title_task_id)
510
+ self.operations_progress.update(operation.task_id, visible=False)
511
+
512
+ def update_stats(self, status: Status) -> None:
513
+ """Update statistics for a finished scenario."""
514
+ self.stats[status] += 1
515
+ self._update_stats_display()
516
+
517
+ def interrupt(self) -> None:
518
+ self.is_interrupted = True
519
+ self.stats[Status.SKIP] += self.total - self.current
520
+ if self.live:
521
+ self.stop()
522
+
523
+ def stop(self) -> None:
524
+ """Stop all progress displays."""
525
+ if self.live:
526
+ self.live.stop()
527
+
528
+ def _get_status_icon(self, default_icon: str = "🕛") -> str:
529
+ if self.is_interrupted:
530
+ icon = "⚡"
531
+ elif self.stats[Status.ERROR] > 0:
532
+ icon = "🚫"
533
+ elif self.stats[Status.FAILURE] > 0:
534
+ icon = "❌"
535
+ elif self.stats[Status.SUCCESS] > 0:
536
+ icon = "✅"
537
+ elif self.stats[Status.SKIP] > 0:
538
+ icon = "⏭️"
539
+ else:
540
+ icon = default_icon
541
+ return icon
542
+
543
+ def get_completion_message(self, default_icon: str = "🕛") -> str:
544
+ """Complete the phase and return status message."""
545
+ duration = format_duration(int((time.monotonic() - self.start_time) * 1000))
546
+ icon = self._get_status_icon(default_icon)
547
+
548
+ message = self._get_stats_message() or "No tests were run"
549
+ if self.is_interrupted:
550
+ duration_message = f"interrupted after {duration}"
551
+ else:
552
+ duration_message = f"in {duration}"
553
+
554
+ return f"{icon} {self.title} ({duration_message})\n\n {message}"
555
+
556
+
557
+ @dataclass
558
+ class StatefulProgressManager:
559
+ """Manages progress display for stateful testing."""
560
+
561
+ console: Console
562
+ title: str
563
+ links_selected: int
564
+ links_total: int
565
+ start_time: float
566
+
567
+ # Progress components
568
+ title_progress: Progress
569
+ progress_bar: Progress
570
+ stats_progress: Progress
571
+ live: Live | None
572
+
573
+ # Task IDs
574
+ title_task_id: TaskID | None
575
+ progress_task_id: TaskID | None
576
+ stats_task_id: TaskID
577
+
578
+ # State
579
+ scenarios: int
580
+ links_covered: set[str]
581
+ stats: dict[Status, int]
582
+ is_interrupted: bool
583
+
584
+ __slots__ = (
585
+ "console",
586
+ "title",
587
+ "links_selected",
588
+ "links_total",
589
+ "start_time",
590
+ "title_progress",
591
+ "progress_bar",
592
+ "stats_progress",
593
+ "live",
594
+ "title_task_id",
595
+ "progress_task_id",
596
+ "stats_task_id",
597
+ "scenarios",
598
+ "links_covered",
599
+ "stats",
600
+ "is_interrupted",
601
+ )
602
+
603
+ def __init__(self, *, console: Console, title: str, links_selected: int, links_total: int) -> None:
604
+ from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
605
+ from rich.style import Style
606
+
607
+ self.console = console
608
+ self.title = title
609
+ self.links_selected = links_selected
610
+ self.links_total = links_total
611
+ self.start_time = time.monotonic()
612
+
613
+ self.title_progress = Progress(
614
+ TextColumn(""),
615
+ SpinnerColumn("clock"),
616
+ TextColumn("{task.description}", style=Style(color="bright_white")),
617
+ console=self.console,
618
+ )
619
+ self.title_task_id = None
620
+
621
+ self.progress_bar = Progress(
622
+ TextColumn(" "),
623
+ TimeElapsedColumn(),
624
+ TextColumn("{task.fields[scenarios]:3d} scenarios • {task.fields[links]}"),
625
+ console=self.console,
626
+ )
627
+ self.progress_task_id = None
628
+
629
+ # Initialize stats progress
630
+ self.stats_progress = Progress(
631
+ TextColumn(" "),
632
+ TextColumn("{task.description}"),
633
+ console=self.console,
634
+ )
635
+ self.stats_task_id = self.stats_progress.add_task("")
636
+
637
+ self.live = None
638
+
639
+ # Initialize state
640
+ self.scenarios = 0
641
+ self.links_covered = set()
642
+ self.stats = {
643
+ Status.SUCCESS: 0,
644
+ Status.FAILURE: 0,
645
+ Status.ERROR: 0,
646
+ Status.SKIP: 0,
647
+ }
648
+ self.is_interrupted = False
649
+
650
+ def start(self) -> None:
651
+ """Start progress display."""
652
+ from rich.console import Group
653
+ from rich.live import Live
654
+ from rich.text import Text
655
+
656
+ # Initialize progress displays
657
+ self.title_task_id = self.title_progress.add_task("Stateful tests")
658
+ self.progress_task_id = self.progress_bar.add_task(
659
+ "", scenarios=0, links=f"0 covered / {self.links_selected} selected / {self.links_total} total links"
660
+ )
661
+
662
+ # Create live display
663
+ group = Group(
664
+ self.title_progress,
665
+ Text(),
666
+ self.progress_bar,
667
+ Text(),
668
+ self.stats_progress,
669
+ )
670
+ self.live = Live(group, refresh_per_second=10, console=self.console, transient=True)
671
+ self.live.start()
672
+
673
+ def stop(self) -> None:
674
+ """Stop progress display."""
675
+ if self.live:
676
+ self.live.stop()
677
+
678
+ def update(self, links_covered: set[str], status: Status | None = None) -> None:
679
+ """Update progress and stats."""
680
+ self.scenarios += 1
681
+ self.links_covered.update(links_covered)
682
+
683
+ if status is not None:
684
+ self.stats[status] += 1
685
+
686
+ self._update_progress_display()
687
+ self._update_stats_display()
688
+
689
+ def _update_progress_display(self) -> None:
690
+ """Update the progress display."""
691
+ assert self.progress_task_id is not None
692
+ self.progress_bar.update(
693
+ self.progress_task_id,
694
+ scenarios=self.scenarios,
695
+ links=f"{len(self.links_covered)} covered / {self.links_selected} selected / {self.links_total} total links",
696
+ )
697
+
698
+ def _get_stats_message(self) -> str:
699
+ """Get formatted stats message."""
700
+ parts = []
701
+ if self.stats[Status.SUCCESS]:
702
+ parts.append(f"✅ {self.stats[Status.SUCCESS]} passed")
703
+ if self.stats[Status.FAILURE]:
704
+ parts.append(f"❌ {self.stats[Status.FAILURE]} failed")
705
+ if self.stats[Status.ERROR]:
706
+ parts.append(f"🚫 {self.stats[Status.ERROR]} errors")
707
+ if self.stats[Status.SKIP]:
708
+ parts.append(f"⏭️ {self.stats[Status.SKIP]} skipped")
709
+ return " ".join(parts)
710
+
711
+ def _update_stats_display(self) -> None:
712
+ """Update the statistics display."""
713
+ self.stats_progress.update(self.stats_task_id, description=self._get_stats_message())
714
+
715
+ def _get_status_icon(self, default_icon: str = "🕛") -> str:
716
+ if self.is_interrupted:
717
+ icon = "⚡"
718
+ elif self.stats[Status.ERROR] > 0:
719
+ icon = "🚫"
720
+ elif self.stats[Status.FAILURE] > 0:
721
+ icon = "❌"
722
+ elif self.stats[Status.SUCCESS] > 0:
723
+ icon = "✅"
724
+ elif self.stats[Status.SKIP] > 0:
725
+ icon = "⏭️"
726
+ else:
727
+ icon = default_icon
728
+ return icon
729
+
730
+ def interrupt(self) -> None:
731
+ """Handle interruption."""
732
+ self.is_interrupted = True
733
+ if self.live:
734
+ self.stop()
735
+
736
+ def get_completion_message(self, icon: str | None = None) -> tuple[str, str]:
737
+ """Complete the phase and return status message."""
738
+ duration = format_duration(int((time.monotonic() - self.start_time) * 1000))
739
+ icon = icon or self._get_status_icon()
740
+
741
+ message = self._get_stats_message() or "No tests were run"
742
+ if self.is_interrupted:
743
+ duration_message = f"interrupted after {duration}"
744
+ else:
745
+ duration_message = f"in {duration}"
746
+
747
+ return f"{icon} {self.title} ({duration_message})", message
748
+
749
+
750
+ def format_duration(duration_ms: int) -> str:
751
+ """Format duration in milliseconds to human readable string."""
752
+ parts = []
753
+
754
+ # Convert to components
755
+ ms = duration_ms % 1000
756
+ seconds = (duration_ms // 1000) % 60
757
+ minutes = (duration_ms // (1000 * 60)) % 60
758
+ hours = duration_ms // (1000 * 60 * 60)
759
+
760
+ # Add non-empty components
761
+ if hours > 0:
762
+ parts.append(f"{hours} h")
763
+ if minutes > 0:
764
+ parts.append(f"{minutes} m")
765
+ if seconds > 0:
766
+ parts.append(f"{seconds} s")
767
+ if ms > 0:
768
+ parts.append(f"{ms} ms")
769
+
770
+ # Handle zero duration
771
+ if not parts:
772
+ return "0 ms"
773
+
774
+ return " ".join(parts)
775
+
776
+
176
777
  @dataclass
177
778
  class OutputHandler(EventHandler):
178
779
  workers_num: int
780
+ # Seed can't be absent in the deterministic mode
781
+ seed: int | None
179
782
  rate_limit: str | None
180
783
  wait_for_schema: float | None
181
- progress: Progress | None = None
182
- progress_task_id: TaskID | None = None
183
- operations_processed: int = 0
184
- operations_count: ApiOperationsCount | None = None
784
+
785
+ loading_manager: LoadingProgressManager | None = None
786
+ probing_manager: ProbingProgressManager | None = None
787
+ unit_tests_manager: UnitTestProgressManager | None = None
788
+ stateful_tests_manager: StatefulProgressManager | None = None
789
+
790
+ statistic: ApiStatistic | None = None
185
791
  skip_reasons: list[str] = field(default_factory=list)
186
- current_line_length: int = 0
187
792
  cassette_config: CassetteConfig | None = None
188
793
  junit_xml_file: str | None = None
189
794
  warnings: WarningData = field(default_factory=WarningData)
@@ -205,9 +810,9 @@ class OutputHandler(EventHandler):
205
810
  if isinstance(event, events.EngineFinished):
206
811
  self._on_engine_finished(ctx, event)
207
812
  elif isinstance(event, events.Interrupted):
208
- self._on_interrupted()
813
+ self._on_interrupted(event)
209
814
  elif isinstance(event, events.FatalError):
210
- self._on_fatal_error(event)
815
+ self._on_fatal_error(ctx, event)
211
816
  elif isinstance(event, events.NonFatalError):
212
817
  self.errors.append(event)
213
818
  elif isinstance(event, LoadingStarted):
@@ -218,63 +823,36 @@ class OutputHandler(EventHandler):
218
823
  def start(self, ctx: ExecutionContext) -> None:
219
824
  display_header(SCHEMATHESIS_VERSION)
220
825
 
221
- def shutdown(self) -> None:
222
- if self.progress is not None and self.progress_task_id is not None:
223
- self.progress.stop_task(self.progress_task_id)
224
- self.progress.stop()
225
-
226
- def _start_progress(self, name: str) -> None:
227
- assert self.progress is not None
228
- self.progress_task_id = self.progress.add_task(name, total=None)
229
- self.progress.start()
230
-
231
- def _stop_progress(self) -> None:
232
- assert self.progress is not None
233
- assert self.progress_task_id is not None
234
- self.progress.stop_task(self.progress_task_id)
235
- self.progress.stop()
236
- self.progress = None
237
- self.progress_task_id = None
826
+ def shutdown(self, ctx: ExecutionContext) -> None:
827
+ if self.unit_tests_manager is not None:
828
+ self.unit_tests_manager.stop()
829
+ if self.stateful_tests_manager is not None:
830
+ self.stateful_tests_manager.stop()
831
+ if self.loading_manager is not None:
832
+ self.loading_manager.stop()
833
+ if self.probing_manager is not None:
834
+ self.probing_manager.stop()
238
835
 
239
836
  def _on_loading_started(self, event: LoadingStarted) -> None:
240
- from rich.progress import Progress, RenderableColumn, SpinnerColumn, TextColumn
241
- from rich.style import Style
242
- from rich.text import Text
243
-
244
- progress_message = Text.assemble(
245
- ("Loading specification from ", Style(color="white")),
246
- (event.location, Style(color="cyan")),
247
- )
248
- self.progress = Progress(
249
- TextColumn(""),
250
- SpinnerColumn("clock"),
251
- RenderableColumn(progress_message),
252
- console=self.console,
253
- transient=True,
254
- )
255
- self._start_progress("Loading")
837
+ self.loading_manager = LoadingProgressManager(console=self.console, location=event.location)
838
+ self.loading_manager.start()
256
839
 
257
840
  def _on_loading_finished(self, ctx: ExecutionContext, event: LoadingFinished) -> None:
258
841
  from rich.padding import Padding
259
842
  from rich.style import Style
260
843
  from rich.table import Table
261
- from rich.text import Text
262
844
 
263
- self._stop_progress()
264
- self.operations_count = event.operations_count
845
+ assert self.loading_manager is not None
846
+ self.loading_manager.stop()
265
847
 
266
- duration_ms = int(event.duration * 1000)
267
848
  message = Padding(
268
- Text.assemble(
269
- ("✅ ", Style(color="green")),
270
- ("Loaded specification from ", Style(color="white")),
271
- (event.location, Style(color="cyan")),
272
- (f" (in {duration_ms} ms)", Style(color="white")),
273
- ),
849
+ self.loading_manager.get_completion_message(),
274
850
  BLOCK_PADDING,
275
851
  )
276
852
  self.console.print(message)
277
853
  self.console.print()
854
+ self.loading_manager = None
855
+ self.statistic = event.statistic
278
856
 
279
857
  table = Table(
280
858
  show_header=False,
@@ -287,7 +865,7 @@ class OutputHandler(EventHandler):
287
865
 
288
866
  table.add_row("Base URL:", event.base_url)
289
867
  table.add_row("Specification:", event.specification.name)
290
- table.add_row("Operations:", str(event.operations_count.total))
868
+ table.add_row("Operations:", str(event.statistic.operations.total))
291
869
 
292
870
  message = Padding(table, BLOCK_PADDING)
293
871
  self.console.print(message)
@@ -300,22 +878,33 @@ class OutputHandler(EventHandler):
300
878
  phase = event.phase
301
879
  if phase.name == PhaseName.PROBING and phase.is_enabled:
302
880
  self._start_probing()
881
+ elif phase.name == PhaseName.UNIT_TESTING and phase.is_enabled:
882
+ self._start_unit_tests()
303
883
  elif phase.name == PhaseName.STATEFUL_TESTING and phase.is_enabled and phase.skip_reason is None:
304
- click.secho("Stateful tests\n", bold=True)
884
+ self._start_stateful_tests()
305
885
 
306
886
  def _start_probing(self) -> None:
307
- from rich.progress import Progress, RenderableColumn, SpinnerColumn, TextColumn
308
- from rich.text import Text
887
+ self.probing_manager = ProbingProgressManager(console=self.console)
888
+ self.probing_manager.start()
309
889
 
310
- progress_message = Text("Probing API capabilities")
311
- self.progress = Progress(
312
- TextColumn(""),
313
- SpinnerColumn("clock"),
314
- RenderableColumn(progress_message),
315
- transient=True,
890
+ def _start_unit_tests(self) -> None:
891
+ assert self.statistic is not None
892
+ self.unit_tests_manager = UnitTestProgressManager(
893
+ console=self.console,
894
+ title="Unit tests",
895
+ total=self.statistic.operations.total,
896
+ )
897
+ self.unit_tests_manager.start()
898
+
899
+ def _start_stateful_tests(self) -> None:
900
+ assert self.statistic is not None
901
+ self.stateful_tests_manager = StatefulProgressManager(
316
902
  console=self.console,
903
+ title="Stateful tests",
904
+ links_selected=self.statistic.links.selected,
905
+ links_total=self.statistic.links.total,
317
906
  )
318
- self._start_progress("Probing")
907
+ self.stateful_tests_manager.start()
319
908
 
320
909
  def _on_phase_finished(self, event: events.PhaseFinished) -> None:
321
910
  from rich.padding import Padding
@@ -327,7 +916,9 @@ class OutputHandler(EventHandler):
327
916
  self.phases[phase.name] = (event.status, phase.skip_reason)
328
917
 
329
918
  if phase.name == PhaseName.PROBING:
330
- self._stop_progress()
919
+ assert self.probing_manager is not None
920
+ self.probing_manager.stop()
921
+ self.probing_manager = None
331
922
 
332
923
  if event.status == Status.SUCCESS:
333
924
  assert isinstance(event.payload, Ok)
@@ -336,7 +927,7 @@ class OutputHandler(EventHandler):
336
927
  Padding(
337
928
  Text.assemble(
338
929
  ("✅ ", Style(color="green")),
339
- ("API capabilities:", Style(color="white", bold=True)),
930
+ ("API capabilities:", Style(color="bright_white")),
340
931
  ),
341
932
  BLOCK_PADDING,
342
933
  )
@@ -384,87 +975,141 @@ class OutputHandler(EventHandler):
384
975
  self.console.print(message)
385
976
  self.console.print()
386
977
  elif phase.name == PhaseName.STATEFUL_TESTING and phase.is_enabled:
387
- if event.status != Status.INTERRUPTED:
388
- click.echo("\n")
978
+ assert self.stateful_tests_manager is not None
979
+ self.stateful_tests_manager.stop()
980
+ if event.status == Status.ERROR:
981
+ title, summary = self.stateful_tests_manager.get_completion_message("🚫")
982
+ else:
983
+ title, summary = self.stateful_tests_manager.get_completion_message()
984
+
985
+ self.console.print(Padding(Text(title, style="bright_white"), BLOCK_PADDING))
986
+
987
+ table = Table(
988
+ show_header=False,
989
+ box=None,
990
+ padding=(0, 4),
991
+ collapse_padding=True,
992
+ )
993
+ table.add_column("Field", style=Style(color="bright_white", bold=True))
994
+ table.add_column("Value", style="cyan")
995
+ table.add_row("Scenarios:", f"{self.stateful_tests_manager.scenarios}")
996
+ table.add_row(
997
+ "API Links:",
998
+ f"{len(self.stateful_tests_manager.links_covered)} covered / {self.stateful_tests_manager.links_selected} selected / {self.stateful_tests_manager.links_total} total",
999
+ )
1000
+
1001
+ self.console.print()
1002
+ self.console.print(Padding(table, BLOCK_PADDING))
1003
+ self.console.print()
1004
+ self.console.print(Padding(Text(summary, style="bright_white"), (0, 0, 0, 5)))
1005
+ self.console.print()
1006
+ self.stateful_tests_manager = None
389
1007
  elif phase.name == PhaseName.UNIT_TESTING and phase.is_enabled:
1008
+ assert self.unit_tests_manager is not None
1009
+ self.unit_tests_manager.stop()
1010
+ if event.status == Status.ERROR:
1011
+ message = self.unit_tests_manager.get_completion_message("🚫")
1012
+ else:
1013
+ message = self.unit_tests_manager.get_completion_message()
1014
+ self.console.print(Padding(Text(message, style="white"), BLOCK_PADDING))
390
1015
  if event.status != Status.INTERRUPTED:
391
- click.echo()
392
- if self.workers_num > 1:
393
- click.echo()
1016
+ self.console.print()
1017
+ self.unit_tests_manager = None
394
1018
 
395
1019
  def _on_scenario_started(self, event: events.ScenarioStarted) -> None:
396
- if event.phase == PhaseName.UNIT_TESTING and self.workers_num == 1:
1020
+ if event.phase == PhaseName.UNIT_TESTING:
397
1021
  # We should display execution result + percentage in the end. For example:
398
1022
  assert event.label is not None
399
- max_length = get_terminal_width() - len(" . [XXX%]") - len(TRUNCATION_PLACEHOLDER)
400
- message = event.label
401
- message = message[:max_length] + (message[max_length:] and "[...]") + " "
402
- self.current_line_length = len(message)
403
- click.echo(message, nl=False)
1023
+ assert self.unit_tests_manager is not None
1024
+ self.unit_tests_manager.start_operation(event.label)
404
1025
 
405
1026
  def _on_scenario_finished(self, event: events.ScenarioFinished) -> None:
406
- self.operations_processed += 1
407
1027
  if event.phase == PhaseName.UNIT_TESTING:
1028
+ assert self.unit_tests_manager is not None
1029
+ if event.label:
1030
+ self.unit_tests_manager.finish_operation(event.label)
1031
+ self.unit_tests_manager.update_progress()
1032
+ self.unit_tests_manager.update_stats(event.status)
408
1033
  if event.status == Status.SKIP and event.skip_reason is not None:
409
1034
  self.skip_reasons.append(event.skip_reason)
410
- self._display_execution_result(event.status)
411
1035
  self._check_warnings(event)
412
- if self.workers_num == 1:
413
- self.display_percentage()
414
1036
  elif (
415
1037
  event.phase == PhaseName.STATEFUL_TESTING
416
1038
  and not event.is_final
417
- and event.status != Status.INTERRUPTED
418
- and event.status is not None
1039
+ and event.status not in (Status.INTERRUPTED, Status.SKIP, None)
419
1040
  ):
420
- self._display_execution_result(event.status)
1041
+ assert self.stateful_tests_manager is not None
1042
+ links_seen = {case.transition.id for case in event.recorder.cases.values() if case.transition is not None}
1043
+ self.stateful_tests_manager.update(links_seen, event.status)
421
1044
 
422
1045
  def _check_warnings(self, event: events.ScenarioFinished) -> None:
423
1046
  for status_code in (401, 403):
424
1047
  if has_too_many_responses_with_status(event.recorder.interactions.values(), status_code):
425
1048
  self.warnings.missing_auth.setdefault(status_code, []).append(event.recorder.label)
426
1049
 
427
- def _display_execution_result(self, status: Status) -> None:
428
- """Display an appropriate symbol for the given event's execution result."""
429
- symbol, color = {
430
- Status.SUCCESS: (".", "green"),
431
- Status.FAILURE: ("F", "red"),
432
- Status.ERROR: ("E", "red"),
433
- Status.SKIP: ("S", "yellow"),
434
- Status.INTERRUPTED: ("S", "yellow"),
435
- }[status]
436
- self.current_line_length += len(symbol)
437
- click.secho(symbol, nl=False, fg=color)
438
-
439
- def _on_interrupted(self) -> None:
440
- click.echo()
441
- display_section_name("KeyboardInterrupt", "!", bold=False)
442
- click.echo()
1050
+ def _on_interrupted(self, event: events.Interrupted) -> None:
1051
+ from rich.padding import Padding
443
1052
 
444
- def _on_fatal_error(self, event: events.FatalError) -> None:
445
- if isinstance(event.exception, LoaderError):
446
- title = "Schema Loading Error"
447
- message = event.exception.message
448
- extras = event.exception.extras
449
- suggestion = LOADER_ERROR_SUGGESTIONS.get(event.exception.kind)
450
- else:
451
- title = "Test Execution Error"
452
- message = DEFAULT_INTERNAL_ERROR_MESSAGE
453
- traceback = format_exception(event.exception, with_traceback=True)
454
- extras = split_traceback(traceback)
455
- suggestion = (
456
- f"Please consider reporting the traceback above to our issue tracker:\n\n {ISSUE_TRACKER_URL}."
1053
+ if self.unit_tests_manager is not None:
1054
+ self.unit_tests_manager.interrupt()
1055
+ elif self.stateful_tests_manager is not None:
1056
+ self.stateful_tests_manager.interrupt()
1057
+ elif self.loading_manager is not None:
1058
+ self.loading_manager.interrupt()
1059
+ message = Padding(
1060
+ self.loading_manager.get_completion_message(),
1061
+ BLOCK_PADDING,
457
1062
  )
458
- click.secho(title, fg="red", bold=True)
1063
+ self.console.print(message)
1064
+ self.console.print()
1065
+ elif self.probing_manager is not None:
1066
+ self.probing_manager.interrupt()
1067
+ message = Padding(
1068
+ self.probing_manager.get_completion_message(),
1069
+ BLOCK_PADDING,
1070
+ )
1071
+ self.console.print(message)
1072
+ self.console.print()
1073
+
1074
+ def _on_fatal_error(self, ctx: ExecutionContext, event: events.FatalError) -> None:
1075
+ from rich.padding import Padding
1076
+ from rich.text import Text
1077
+
1078
+ self.shutdown(ctx)
1079
+
1080
+ if isinstance(event.exception, LoaderError):
1081
+ assert self.loading_manager is not None
1082
+ message = Padding(self.loading_manager.get_error_message(event.exception), BLOCK_PADDING)
1083
+ self.console.print(message)
1084
+ self.console.print()
1085
+ self.loading_manager = None
1086
+
1087
+ if event.exception.extras:
1088
+ for extra in event.exception.extras:
1089
+ self.console.print(Padding(Text(extra), (0, 0, 0, 5)))
1090
+ self.console.print()
1091
+
1092
+ if not (event.exception.kind == LoaderErrorKind.CONNECTION_OTHER and self.wait_for_schema is not None):
1093
+ suggestion = LOADER_ERROR_SUGGESTIONS.get(event.exception.kind)
1094
+ if suggestion is not None:
1095
+ click.echo(_style(f"{click.style('Tip:', bold=True, fg='green')} {suggestion}"))
1096
+
1097
+ raise click.Abort
1098
+ title = "Test Execution Error"
1099
+ message = DEFAULT_INTERNAL_ERROR_MESSAGE
1100
+ traceback = format_exception(event.exception, with_traceback=True)
1101
+ extras = split_traceback(traceback)
1102
+ suggestion = f"Please consider reporting the traceback above to our issue tracker:\n\n {ISSUE_TRACKER_URL}."
1103
+ click.echo(_style(title, fg="red", bold=True))
459
1104
  click.echo()
460
- click.secho(message)
1105
+ click.echo(message)
461
1106
  _display_extras(extras)
462
1107
  if not (
463
1108
  isinstance(event.exception, LoaderError)
464
1109
  and event.exception.kind == LoaderErrorKind.CONNECTION_OTHER
465
1110
  and self.wait_for_schema is not None
466
1111
  ):
467
- _maybe_display_tip(suggestion)
1112
+ click.echo(_style(f"\n{click.style('Tip:', bold=True, fg='green')} {suggestion}"))
468
1113
 
469
1114
  raise click.Abort
470
1115
 
@@ -472,29 +1117,33 @@ class OutputHandler(EventHandler):
472
1117
  display_section_name("WARNINGS")
473
1118
  total = sum(len(endpoints) for endpoints in self.warnings.missing_auth.values())
474
1119
  suffix = "" if total == 1 else "s"
475
- click.secho(
476
- f"\nMissing or invalid API credentials: {total} API operation{suffix} returned authentication errors\n",
477
- fg="yellow",
1120
+ click.echo(
1121
+ _style(
1122
+ f"\nMissing or invalid API credentials: {total} API operation{suffix} returned authentication errors\n",
1123
+ fg="yellow",
1124
+ )
478
1125
  )
479
1126
 
480
1127
  for status_code, operations in self.warnings.missing_auth.items():
481
1128
  status_text = "Unauthorized" if status_code == 401 else "Forbidden"
482
1129
  count = len(operations)
483
1130
  suffix = "" if count == 1 else "s"
484
- click.secho(
485
- f"{status_code} {status_text} ({count} operation{suffix}):",
486
- fg="yellow",
1131
+ click.echo(
1132
+ _style(
1133
+ f"{status_code} {status_text} ({count} operation{suffix}):",
1134
+ fg="yellow",
1135
+ )
487
1136
  )
488
1137
  # Show first few API operations
489
1138
  for endpoint in operations[:3]:
490
- click.secho(f" {endpoint}", fg="yellow")
1139
+ click.echo(_style(f" - {endpoint}", fg="yellow"))
491
1140
  if len(operations) > 3:
492
- click.secho(f" + {len(operations) - 3} more", fg="yellow")
1141
+ click.echo(_style(f" + {len(operations) - 3} more", fg="yellow"))
493
1142
  click.echo()
494
- click.secho("Tip: ", bold=True, fg="yellow", nl=False)
495
- click.secho(f"Use {bold('--auth')} ", fg="yellow", nl=False)
496
- click.secho(f"or {bold('-H')} ", fg="yellow", nl=False)
497
- click.secho("to provide authentication credentials", fg="yellow")
1143
+ click.echo(_style("Tip: ", bold=True, fg="yellow"), nl=False)
1144
+ click.echo(_style(f"Use {bold('--auth')} ", fg="yellow"), nl=False)
1145
+ click.echo(_style(f"or {bold('-H')} ", fg="yellow"), nl=False)
1146
+ click.echo(_style("to provide authentication credentials", fg="yellow"))
498
1147
  click.echo()
499
1148
 
500
1149
  def display_experiments(self) -> None:
@@ -502,26 +1151,30 @@ class OutputHandler(EventHandler):
502
1151
 
503
1152
  click.echo()
504
1153
  for experiment in sorted(GLOBAL_EXPERIMENTS.enabled, key=lambda e: e.name):
505
- click.secho(f"🧪 {experiment.name}: ", bold=True, nl=False)
506
- click.secho(experiment.description)
507
- click.secho(f" Feedback: {experiment.discussion_url}")
1154
+ click.echo(_style(f"🧪 {experiment.name}: ", bold=True), nl=False)
1155
+ click.echo(_style(experiment.description))
1156
+ click.echo(_style(f" Feedback: {experiment.discussion_url}"))
508
1157
  click.echo()
509
1158
 
510
- click.secho(
511
- "Your feedback is crucial for experimental features. "
512
- "Please visit the provided URL(s) to share your thoughts.",
513
- dim=True,
1159
+ click.echo(
1160
+ _style(
1161
+ "Your feedback is crucial for experimental features. "
1162
+ "Please visit the provided URL(s) to share your thoughts.",
1163
+ dim=True,
1164
+ )
514
1165
  )
515
1166
  click.echo()
516
1167
 
517
1168
  def display_api_operations(self, ctx: ExecutionContext) -> None:
518
- assert self.operations_count is not None
519
- click.secho("API Operations:", bold=True)
520
- click.secho(
521
- f" Selected: {click.style(str(self.operations_count.selected), bold=True)}/"
522
- f"{click.style(str(self.operations_count.total), bold=True)}"
1169
+ assert self.statistic is not None
1170
+ click.echo(_style("API Operations:", bold=True))
1171
+ click.echo(
1172
+ _style(
1173
+ f" Selected: {click.style(str(self.statistic.operations.selected), bold=True)}/"
1174
+ f"{click.style(str(self.statistic.operations.total), bold=True)}"
1175
+ )
523
1176
  )
524
- click.secho(f" Tested: {click.style(str(len(ctx.statistic.tested_operations)), bold=True)}")
1177
+ click.echo(_style(f" Tested: {click.style(str(len(ctx.statistic.tested_operations)), bold=True)}"))
525
1178
  errors = len(
526
1179
  {
527
1180
  err.label
@@ -533,48 +1186,48 @@ class OutputHandler(EventHandler):
533
1186
  }
534
1187
  )
535
1188
  if errors:
536
- click.secho(f" Errored: {click.style(str(errors), bold=True)}")
1189
+ click.echo(_style(f" Errored: {click.style(str(errors), bold=True)}"))
537
1190
 
538
1191
  # API operations that are skipped due to fail-fast are counted here as well
539
- total_skips = self.operations_count.selected - len(ctx.statistic.tested_operations) - errors
1192
+ total_skips = self.statistic.operations.selected - len(ctx.statistic.tested_operations) - errors
540
1193
  if total_skips:
541
- click.secho(f" Skipped: {click.style(str(total_skips), bold=True)}")
1194
+ click.echo(_style(f" Skipped: {click.style(str(total_skips), bold=True)}"))
542
1195
  for reason in sorted(set(self.skip_reasons)):
543
- click.secho(f" - {reason.rstrip('.')}")
1196
+ click.echo(_style(f" - {reason.rstrip('.')}"))
544
1197
  click.echo()
545
1198
 
546
1199
  def display_phases(self) -> None:
547
- click.secho("Test Phases:", bold=True)
1200
+ click.echo(_style("Test Phases:", bold=True))
548
1201
 
549
1202
  for phase in PhaseName:
550
1203
  status, skip_reason = self.phases[phase]
551
1204
 
552
1205
  if status == Status.SKIP:
553
- click.secho(f" ⏭️ {phase.value}", fg="yellow", nl=False)
1206
+ click.echo(_style(f" ⏭️ {phase.value}", fg="yellow"), nl=False)
554
1207
  if skip_reason:
555
- click.secho(f" ({skip_reason.value})", fg="yellow")
1208
+ click.echo(_style(f" ({skip_reason.value})", fg="yellow"))
556
1209
  else:
557
1210
  click.echo()
558
1211
  elif status == Status.SUCCESS:
559
- click.secho(f" ✅ {phase.value}", fg="green")
1212
+ click.echo(_style(f" ✅ {phase.value}", fg="green"))
560
1213
  elif status == Status.FAILURE:
561
- click.secho(f" ❌ {phase.value}", fg="red")
1214
+ click.echo(_style(f" ❌ {phase.value}", fg="red"))
562
1215
  elif status == Status.ERROR:
563
- click.secho(f" 🚫 {phase.value}", fg="red")
1216
+ click.echo(_style(f" 🚫 {phase.value}", fg="red"))
564
1217
  elif status == Status.INTERRUPTED:
565
- click.secho(f" ⚡ {phase.value}", fg="yellow")
1218
+ click.echo(_style(f" ⚡ {phase.value}", fg="yellow"))
566
1219
  click.echo()
567
1220
 
568
1221
  def display_test_cases(self, ctx: ExecutionContext) -> None:
569
1222
  if ctx.statistic.total_cases == 0:
570
- click.secho("Test cases:", bold=True)
571
- click.secho(" No test cases were generated\n")
1223
+ click.echo(_style("Test cases:", bold=True))
1224
+ click.echo(" No test cases were generated\n")
572
1225
  return
573
1226
 
574
1227
  unique_failures = sum(
575
1228
  len(group.failures) for grouped in ctx.statistic.failures.values() for group in grouped.values()
576
1229
  )
577
- click.secho("Test cases:", bold=True)
1230
+ click.echo(_style("Test cases:", bold=True))
578
1231
 
579
1232
  parts = [f" {click.style(str(ctx.statistic.total_cases), bold=True)} generated"]
580
1233
 
@@ -593,7 +1246,7 @@ class OutputHandler(EventHandler):
593
1246
  if ctx.statistic.cases_without_checks > 0:
594
1247
  parts.append(f"{click.style(str(ctx.statistic.cases_without_checks), bold=True)} skipped")
595
1248
 
596
- click.secho(", ".join(parts) + "\n")
1249
+ click.echo(_style(", ".join(parts) + "\n"))
597
1250
 
598
1251
  def display_failures_summary(self, ctx: ExecutionContext) -> None:
599
1252
  # Collect all unique failures and their counts by title
@@ -604,14 +1257,14 @@ class OutputHandler(EventHandler):
604
1257
  data = failure_counts.get(failure.title, (failure.severity, 0))
605
1258
  failure_counts[failure.title] = (failure.severity, data[1] + 1)
606
1259
 
607
- click.secho("Failures:", bold=True)
1260
+ click.echo(_style("Failures:", bold=True))
608
1261
 
609
1262
  # Sort by severity first, then by title
610
1263
  sorted_failures = sorted(failure_counts.items(), key=lambda x: (x[1][0], x[0]))
611
1264
 
612
1265
  for title, (_, count) in sorted_failures:
613
- click.secho(f" ❌ {title}: ", nl=False)
614
- click.secho(str(count), bold=True)
1266
+ click.echo(_style(f" ❌ {title}: "), nl=False)
1267
+ click.echo(_style(str(count), bold=True))
615
1268
  click.echo()
616
1269
 
617
1270
  def display_errors_summary(self) -> None:
@@ -621,11 +1274,11 @@ class OutputHandler(EventHandler):
621
1274
  title = error.info.title
622
1275
  error_counts[title] = error_counts.get(title, 0) + 1
623
1276
 
624
- click.secho("Errors:", bold=True)
1277
+ click.echo(_style("Errors:", bold=True))
625
1278
 
626
1279
  for title in sorted(error_counts):
627
- click.secho(f" 🚫 {title}: ", nl=False)
628
- click.secho(str(error_counts[title]), bold=True)
1280
+ click.echo(_style(f" 🚫 {title}: "), nl=False)
1281
+ click.echo(_style(str(error_counts[title]), bold=True))
629
1282
  click.echo()
630
1283
 
631
1284
  def display_final_line(self, ctx: ExecutionContext, event: events.EngineFinished) -> None:
@@ -635,14 +1288,17 @@ class OutputHandler(EventHandler):
635
1288
  len(group.failures) for grouped in ctx.statistic.failures.values() for group in grouped.values()
636
1289
  )
637
1290
  if unique_failures:
638
- parts.append(f"{unique_failures} failures")
1291
+ suffix = "s" if unique_failures > 1 else ""
1292
+ parts.append(f"{unique_failures} failure{suffix}")
639
1293
 
640
1294
  if self.errors:
641
- parts.append(f"{len(self.errors)} errors")
1295
+ suffix = "s" if len(self.errors) > 1 else ""
1296
+ parts.append(f"{len(self.errors)} error{suffix}")
642
1297
 
643
1298
  total_warnings = sum(len(endpoints) for endpoints in self.warnings.missing_auth.values())
644
1299
  if total_warnings:
645
- parts.append(f"{total_warnings} warnings")
1300
+ suffix = "s" if total_warnings > 1 else ""
1301
+ parts.append(f"{total_warnings} warning{suffix}")
646
1302
 
647
1303
  if parts:
648
1304
  message = f"{', '.join(parts)} in {event.running_time:.2f}s"
@@ -665,21 +1321,35 @@ class OutputHandler(EventHandler):
665
1321
  reports.append(("JUnit XML", self.junit_xml_file))
666
1322
 
667
1323
  if reports:
668
- click.secho("Reports:", bold=True)
1324
+ click.echo(_style("Reports:", bold=True))
669
1325
  for report_type, path in reports:
670
- click.secho(f" {report_type}: {path}")
1326
+ click.echo(_style(f" - {report_type}: {path}"))
671
1327
  click.echo()
672
1328
 
1329
+ def display_seed(self) -> None:
1330
+ click.echo(_style("Seed: ", bold=True), nl=False)
1331
+ if self.seed is None:
1332
+ click.echo("not used in the deterministic mode")
1333
+ else:
1334
+ click.echo(str(self.seed))
1335
+ click.echo()
1336
+
673
1337
  def _on_engine_finished(self, ctx: ExecutionContext, event: events.EngineFinished) -> None:
1338
+ assert self.loading_manager is None
1339
+ assert self.probing_manager is None
1340
+ assert self.unit_tests_manager is None
1341
+ assert self.stateful_tests_manager is None
674
1342
  if self.errors:
675
1343
  display_section_name("ERRORS")
676
1344
  errors = sorted(self.errors, key=lambda r: (r.phase.value, r.label))
677
1345
  for error in errors:
678
1346
  display_section_name(error.label, "_", fg="red")
679
1347
  click.echo(error.info.format(bold=lambda x: click.style(x, bold=True)))
680
- click.secho(
681
- f"\nNeed more help?\n Join our Discord server: {DISCORD_LINK}",
682
- fg="red",
1348
+ click.echo(
1349
+ _style(
1350
+ f"\nNeed more help?\n Join our Discord server: {DISCORD_LINK}",
1351
+ fg="red",
1352
+ )
683
1353
  )
684
1354
  display_failures(ctx)
685
1355
  if self.warnings.missing_auth:
@@ -689,7 +1359,7 @@ class OutputHandler(EventHandler):
689
1359
  display_section_name("SUMMARY")
690
1360
  click.echo()
691
1361
 
692
- if self.operations_count:
1362
+ if self.statistic:
693
1363
  self.display_api_operations(ctx)
694
1364
 
695
1365
  self.display_phases()
@@ -702,8 +1372,8 @@ class OutputHandler(EventHandler):
702
1372
 
703
1373
  if self.warnings.missing_auth:
704
1374
  affected = sum(len(operations) for operations in self.warnings.missing_auth.values())
705
- click.secho("Warnings:", bold=True)
706
- click.secho(f" ⚠️ Missing authentication: {bold(str(affected))}", fg="yellow")
1375
+ click.echo(_style("Warnings:", bold=True))
1376
+ click.echo(_style(f" ⚠️ Missing authentication: {bold(str(affected))}", fg="yellow"))
707
1377
  click.echo()
708
1378
 
709
1379
  if ctx.summary_lines:
@@ -712,20 +1382,9 @@ class OutputHandler(EventHandler):
712
1382
 
713
1383
  self.display_test_cases(ctx)
714
1384
  self.display_reports()
1385
+ self.display_seed()
715
1386
  self.display_final_line(ctx, event)
716
1387
 
717
- def display_percentage(self) -> None:
718
- """Add the current progress in % to the right side of the current line."""
719
- assert self.operations_count is not None
720
- selected = self.operations_count.selected
721
- current_percentage = get_percentage(self.operations_processed, selected)
722
- styled = click.style(current_percentage, fg="cyan")
723
- # Total length of the message, so it will fill to the right border of the terminal.
724
- # Padding is already taken into account in `ctx.current_line_length`
725
- length = max(get_terminal_width() - self.current_line_length + len(styled) - len(current_percentage), 1)
726
- template = f"{{:>{length}}}"
727
- click.echo(template.format(styled))
728
-
729
1388
 
730
1389
  TOO_MANY_RESPONSES_WARNING_TEMPLATE = (
731
1390
  "Most of the responses from {} have a {} status code. Did you specify proper API credentials?"