schemathesis 4.0.0a1__py3-none-any.whl → 4.0.0a2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. schemathesis/checks.py +6 -4
  2. schemathesis/cli/commands/run/__init__.py +4 -4
  3. schemathesis/cli/commands/run/events.py +4 -9
  4. schemathesis/cli/commands/run/executor.py +6 -3
  5. schemathesis/cli/commands/run/filters.py +27 -19
  6. schemathesis/cli/commands/run/handlers/base.py +1 -1
  7. schemathesis/cli/commands/run/handlers/cassettes.py +1 -3
  8. schemathesis/cli/commands/run/handlers/output.py +765 -143
  9. schemathesis/cli/commands/run/validation.py +1 -1
  10. schemathesis/cli/ext/options.py +4 -1
  11. schemathesis/core/failures.py +54 -24
  12. schemathesis/engine/core.py +1 -1
  13. schemathesis/engine/events.py +3 -97
  14. schemathesis/engine/phases/stateful/__init__.py +1 -0
  15. schemathesis/engine/phases/stateful/_executor.py +19 -44
  16. schemathesis/engine/phases/unit/__init__.py +1 -0
  17. schemathesis/engine/phases/unit/_executor.py +2 -1
  18. schemathesis/engine/phases/unit/_pool.py +1 -1
  19. schemathesis/engine/recorder.py +8 -3
  20. schemathesis/generation/stateful/state_machine.py +53 -36
  21. schemathesis/graphql/checks.py +3 -9
  22. schemathesis/openapi/checks.py +8 -33
  23. schemathesis/schemas.py +34 -14
  24. schemathesis/specs/graphql/schemas.py +16 -15
  25. schemathesis/specs/openapi/expressions/__init__.py +11 -15
  26. schemathesis/specs/openapi/expressions/nodes.py +20 -20
  27. schemathesis/specs/openapi/links.py +126 -119
  28. schemathesis/specs/openapi/schemas.py +18 -22
  29. schemathesis/specs/openapi/stateful/__init__.py +77 -55
  30. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/METADATA +1 -1
  31. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/RECORD +34 -35
  32. schemathesis/specs/openapi/expressions/context.py +0 -14
  33. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/WHEEL +0 -0
  34. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/entry_points.txt +0 -0
  35. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.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"
@@ -40,12 +43,6 @@ def display_section_name(title: str, separator: str = "=", **kwargs: Any) -> Non
40
43
  click.secho(message, **kwargs)
41
44
 
42
45
 
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}]"
47
-
48
-
49
46
  def bold(option: str) -> str:
50
47
  return click.style(option, bold=True)
51
48
 
@@ -100,7 +97,7 @@ def display_failures_for_single_test(ctx: ExecutionContext, label: str, checks:
100
97
  click.echo()
101
98
 
102
99
 
103
- VERIFY_URL_SUGGESTION = "Verify that the URL points directly to the Open API schema"
100
+ VERIFY_URL_SUGGESTION = "Verify that the URL points directly to the Open API schema or GraphQL endpoint"
104
101
  DISABLE_SSL_SUGGESTION = f"Bypass SSL verification with {bold('`--request-tls-verify=false`')}."
105
102
  LOADER_ERROR_SUGGESTIONS = {
106
103
  # SSL-specific connection issue
@@ -128,12 +125,6 @@ def _display_extras(extras: list[str]) -> None:
128
125
  click.secho(f" {extra}")
129
126
 
130
127
 
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}")
135
-
136
-
137
128
  def display_header(version: str) -> None:
138
129
  prefix = "v" if version != "dev" else ""
139
130
  header = f"Schemathesis {prefix}{version}"
@@ -168,22 +159,627 @@ def _default_console() -> Console:
168
159
  BLOCK_PADDING = (0, 1, 0, 1)
169
160
 
170
161
 
162
+ @dataclass
163
+ class LoadingProgressManager:
164
+ console: Console
165
+ location: str
166
+ start_time: float
167
+ progress: Progress
168
+ progress_task_id: TaskID | None
169
+ is_interrupted: bool
170
+
171
+ __slots__ = ("console", "location", "start_time", "progress", "progress_task_id", "is_interrupted")
172
+
173
+ def __init__(self, console: Console, location: str) -> None:
174
+ from rich.progress import Progress, RenderableColumn, SpinnerColumn, TextColumn
175
+ from rich.style import Style
176
+ from rich.text import Text
177
+
178
+ self.console = console
179
+ self.location = location
180
+ self.start_time = time.monotonic()
181
+ progress_message = Text.assemble(
182
+ ("Loading specification from ", Style(color="white")),
183
+ (location, Style(color="cyan")),
184
+ )
185
+ self.progress = Progress(
186
+ TextColumn(""),
187
+ SpinnerColumn("clock"),
188
+ RenderableColumn(progress_message),
189
+ console=console,
190
+ transient=True,
191
+ )
192
+ self.progress_task_id = None
193
+ self.is_interrupted = False
194
+
195
+ def start(self) -> None:
196
+ """Start loading progress display."""
197
+ self.progress_task_id = self.progress.add_task("Loading", total=None)
198
+ self.progress.start()
199
+
200
+ def stop(self) -> None:
201
+ """Stop loading progress display."""
202
+ assert self.progress_task_id is not None
203
+ self.progress.stop_task(self.progress_task_id)
204
+ self.progress.stop()
205
+
206
+ def interrupt(self) -> None:
207
+ """Handle interruption during loading."""
208
+ self.is_interrupted = True
209
+ self.stop()
210
+
211
+ def get_completion_message(self) -> Text:
212
+ """Generate completion message including duration."""
213
+ from rich.style import Style
214
+ from rich.text import Text
215
+
216
+ duration = format_duration(int((time.monotonic() - self.start_time) * 1000))
217
+ if self.is_interrupted:
218
+ return Text.assemble(
219
+ ("⚡ ", Style(color="yellow")),
220
+ (f"Loading interrupted after {duration} while loading from ", Style(color="white")),
221
+ (self.location, Style(color="cyan")),
222
+ )
223
+ return Text.assemble(
224
+ ("✅ ", Style(color="green")),
225
+ ("Loaded specification from ", Style(color="bright_white")),
226
+ (self.location, Style(color="cyan")),
227
+ (f" (in {duration})", Style(color="bright_white")),
228
+ )
229
+
230
+ def get_error_message(self, error: LoaderError) -> Group:
231
+ from rich.console import Group
232
+ from rich.style import Style
233
+ from rich.text import Text
234
+
235
+ duration = format_duration(int((time.monotonic() - self.start_time) * 1000))
236
+
237
+ # Show what was attempted
238
+ attempted = Text.assemble(
239
+ ("❌ ", Style(color="red")),
240
+ ("Failed to load specification from ", Style(color="white")),
241
+ (self.location, Style(color="cyan")),
242
+ (f" after {duration}", Style(color="white")),
243
+ )
244
+
245
+ # Show error details
246
+ error_title = Text("Schema Loading Error", style=Style(color="red", bold=True))
247
+ error_message = Text(error.message)
248
+
249
+ return Group(
250
+ attempted,
251
+ Text(),
252
+ error_title,
253
+ Text(),
254
+ error_message,
255
+ )
256
+
257
+
258
+ @dataclass
259
+ class ProbingProgressManager:
260
+ console: Console
261
+ start_time: float
262
+ progress: Progress
263
+ progress_task_id: TaskID | None
264
+ is_interrupted: bool
265
+
266
+ __slots__ = ("console", "start_time", "progress", "progress_task_id", "is_interrupted")
267
+
268
+ def __init__(self, console: Console) -> None:
269
+ from rich.progress import Progress, RenderableColumn, SpinnerColumn, TextColumn
270
+ from rich.text import Text
271
+
272
+ self.console = console
273
+ self.start_time = time.monotonic()
274
+ self.progress = Progress(
275
+ TextColumn(""),
276
+ SpinnerColumn("clock"),
277
+ RenderableColumn(Text("Probing API capabilities", style="bright_white")),
278
+ transient=True,
279
+ console=console,
280
+ )
281
+ self.progress_task_id = None
282
+ self.is_interrupted = False
283
+
284
+ def start(self) -> None:
285
+ """Start probing progress display."""
286
+ self.progress_task_id = self.progress.add_task("Probing", total=None)
287
+ self.progress.start()
288
+
289
+ def stop(self) -> None:
290
+ """Stop probing progress display."""
291
+ assert self.progress_task_id is not None
292
+ self.progress.stop_task(self.progress_task_id)
293
+ self.progress.stop()
294
+
295
+ def interrupt(self) -> None:
296
+ """Handle interruption during probing."""
297
+ self.is_interrupted = True
298
+ self.stop()
299
+
300
+ def get_completion_message(self) -> Text:
301
+ """Generate completion message including duration."""
302
+ from rich.style import Style
303
+ from rich.text import Text
304
+
305
+ duration = format_duration(int((time.monotonic() - self.start_time) * 1000))
306
+ if self.is_interrupted:
307
+ return Text.assemble(
308
+ ("⚡ ", Style(color="yellow")),
309
+ (f"API probing interrupted after {duration}", Style(color="white")),
310
+ )
311
+ return Text.assemble(
312
+ ("✅ ", Style(color="green")),
313
+ ("API capabilities:", Style(color="white")),
314
+ )
315
+
316
+
171
317
  @dataclass
172
318
  class WarningData:
173
319
  missing_auth: dict[int, list[str]] = field(default_factory=dict)
174
320
 
175
321
 
322
+ @dataclass
323
+ class OperationProgress:
324
+ """Tracks individual operation progress."""
325
+
326
+ label: str
327
+ start_time: float
328
+ task_id: TaskID
329
+
330
+ __slots__ = ("label", "start_time", "task_id")
331
+
332
+
333
+ @dataclass
334
+ class UnitTestProgressManager:
335
+ """Manages progress display for unit tests."""
336
+
337
+ console: Console
338
+ title: str
339
+ current: int
340
+ total: int
341
+ start_time: float
342
+
343
+ # Progress components
344
+ title_progress: Progress
345
+ progress_bar: Progress
346
+ operations_progress: Progress
347
+ current_operations: dict[str, OperationProgress]
348
+ stats: dict[Status, int]
349
+ stats_progress: Progress
350
+ live: Live | None
351
+
352
+ # Task IDs
353
+ title_task_id: TaskID | None
354
+ progress_task_id: TaskID | None
355
+ stats_task_id: TaskID
356
+
357
+ is_interrupted: bool
358
+
359
+ __slots__ = (
360
+ "console",
361
+ "title",
362
+ "current",
363
+ "total",
364
+ "start_time",
365
+ "title_progress",
366
+ "progress_bar",
367
+ "operations_progress",
368
+ "current_operations",
369
+ "stats",
370
+ "stats_progress",
371
+ "live",
372
+ "title_task_id",
373
+ "progress_task_id",
374
+ "stats_task_id",
375
+ "is_interrupted",
376
+ )
377
+
378
+ def __init__(
379
+ self,
380
+ console: Console,
381
+ title: str,
382
+ total: int,
383
+ ) -> None:
384
+ from rich.progress import (
385
+ BarColumn,
386
+ Progress,
387
+ SpinnerColumn,
388
+ TextColumn,
389
+ TimeElapsedColumn,
390
+ )
391
+ from rich.style import Style
392
+
393
+ self.console = console
394
+ self.title = title
395
+ self.current = 0
396
+ self.total = total
397
+ self.start_time = time.monotonic()
398
+
399
+ # Initialize progress displays
400
+ self.title_progress = Progress(
401
+ TextColumn(""),
402
+ SpinnerColumn("clock"),
403
+ TextColumn("{task.description}", style=Style(color="white")),
404
+ console=self.console,
405
+ )
406
+ self.title_task_id = None
407
+
408
+ self.progress_bar = Progress(
409
+ TextColumn(" "),
410
+ TimeElapsedColumn(),
411
+ BarColumn(bar_width=None),
412
+ TextColumn("{task.percentage:.0f}% ({task.completed}/{task.total})"),
413
+ console=self.console,
414
+ )
415
+ self.progress_task_id = None
416
+
417
+ self.operations_progress = Progress(
418
+ TextColumn(" "),
419
+ SpinnerColumn("dots"),
420
+ TimeElapsedColumn(),
421
+ TextColumn(" {task.fields[label]}"),
422
+ console=self.console,
423
+ )
424
+
425
+ self.current_operations = {}
426
+
427
+ self.stats_progress = Progress(
428
+ TextColumn(" "),
429
+ TextColumn("{task.description}"),
430
+ console=self.console,
431
+ )
432
+ self.stats_task_id = self.stats_progress.add_task("")
433
+ self.stats = {
434
+ Status.SUCCESS: 0,
435
+ Status.FAILURE: 0,
436
+ Status.SKIP: 0,
437
+ Status.ERROR: 0,
438
+ }
439
+ self._update_stats_display()
440
+
441
+ self.live = None
442
+ self.is_interrupted = False
443
+
444
+ def _get_stats_message(self) -> str:
445
+ width = len(str(self.total))
446
+
447
+ parts = []
448
+ if self.stats[Status.SUCCESS]:
449
+ parts.append(f"✅ {self.stats[Status.SUCCESS]:{width}d} passed")
450
+ if self.stats[Status.FAILURE]:
451
+ parts.append(f"❌ {self.stats[Status.FAILURE]:{width}d} failed")
452
+ if self.stats[Status.ERROR]:
453
+ parts.append(f"🚫 {self.stats[Status.ERROR]:{width}d} errors")
454
+ if self.stats[Status.SKIP]:
455
+ parts.append(f"⏭️ {self.stats[Status.SKIP]:{width}d} skipped")
456
+ return " ".join(parts)
457
+
458
+ def _update_stats_display(self) -> None:
459
+ """Update the statistics display."""
460
+ self.stats_progress.update(self.stats_task_id, description=self._get_stats_message())
461
+
462
+ def start(self) -> None:
463
+ """Start progress display."""
464
+ from rich.console import Group
465
+ from rich.live import Live
466
+ from rich.text import Text
467
+
468
+ group = Group(
469
+ self.title_progress,
470
+ Text(),
471
+ self.progress_bar,
472
+ Text(),
473
+ self.operations_progress,
474
+ Text(),
475
+ self.stats_progress,
476
+ )
477
+
478
+ self.live = Live(group, refresh_per_second=10, console=self.console, transient=True)
479
+ self.live.start()
480
+
481
+ # Initialize both progress displays
482
+ self.title_task_id = self.title_progress.add_task(self.title, total=self.total)
483
+ self.progress_task_id = self.progress_bar.add_task(
484
+ "", # Empty description as it's shown in title
485
+ total=self.total,
486
+ )
487
+
488
+ def update_progress(self) -> None:
489
+ """Update progress in both displays."""
490
+ assert self.title_task_id is not None
491
+ assert self.progress_task_id is not None
492
+
493
+ self.current += 1
494
+ self.title_progress.update(self.title_task_id, completed=self.current)
495
+ self.progress_bar.update(self.progress_task_id, completed=self.current)
496
+
497
+ def start_operation(self, label: str) -> None:
498
+ """Start tracking new operation."""
499
+ task_id = self.operations_progress.add_task("", label=label, start_time=time.monotonic())
500
+ self.current_operations[label] = OperationProgress(label=label, start_time=time.monotonic(), task_id=task_id)
501
+
502
+ def finish_operation(self, label: str) -> None:
503
+ """Finish tracking operation."""
504
+ if operation := self.current_operations.pop(label, None):
505
+ if not self.current_operations:
506
+ assert self.title_task_id is not None
507
+ self.title_progress.update(self.title_task_id)
508
+ self.operations_progress.update(operation.task_id, visible=False)
509
+
510
+ def update_stats(self, status: Status) -> None:
511
+ """Update statistics for a finished scenario."""
512
+ self.stats[status] += 1
513
+ self._update_stats_display()
514
+
515
+ def interrupt(self) -> None:
516
+ self.is_interrupted = True
517
+ self.stats[Status.SKIP] += self.total - self.current
518
+ if self.live:
519
+ self.stop()
520
+
521
+ def stop(self) -> None:
522
+ """Stop all progress displays."""
523
+ if self.live:
524
+ self.live.stop()
525
+
526
+ def _get_status_icon(self, default_icon: str = "🕛") -> str:
527
+ if self.is_interrupted:
528
+ icon = "⚡"
529
+ elif self.stats[Status.ERROR] > 0:
530
+ icon = "🚫"
531
+ elif self.stats[Status.FAILURE] > 0:
532
+ icon = "❌"
533
+ elif self.stats[Status.SUCCESS] > 0:
534
+ icon = "✅"
535
+ elif self.stats[Status.SKIP] > 0:
536
+ icon = "⏭️"
537
+ else:
538
+ icon = default_icon
539
+ return icon
540
+
541
+ def get_completion_message(self, default_icon: str = "🕛") -> str:
542
+ """Complete the phase and return status message."""
543
+ duration = format_duration(int((time.monotonic() - self.start_time) * 1000))
544
+ icon = self._get_status_icon(default_icon)
545
+
546
+ message = self._get_stats_message() or "No tests were run"
547
+ if self.is_interrupted:
548
+ duration_message = f"interrupted after {duration}"
549
+ else:
550
+ duration_message = f"in {duration}"
551
+
552
+ return f"{icon} {self.title} ({duration_message})\n\n {message}"
553
+
554
+
555
+ @dataclass
556
+ class StatefulProgressManager:
557
+ """Manages progress display for stateful testing."""
558
+
559
+ console: Console
560
+ title: str
561
+ links_total: int
562
+ start_time: float
563
+
564
+ # Progress components
565
+ title_progress: Progress
566
+ progress_bar: Progress
567
+ stats_progress: Progress
568
+ live: Live | None
569
+
570
+ # Task IDs
571
+ title_task_id: TaskID | None
572
+ progress_task_id: TaskID | None
573
+ stats_task_id: TaskID
574
+
575
+ # State
576
+ scenarios: int
577
+ links_seen: set[str]
578
+ stats: dict[Status, int]
579
+ is_interrupted: bool
580
+
581
+ __slots__ = (
582
+ "console",
583
+ "title",
584
+ "links_total",
585
+ "start_time",
586
+ "title_progress",
587
+ "progress_bar",
588
+ "stats_progress",
589
+ "live",
590
+ "title_task_id",
591
+ "progress_task_id",
592
+ "stats_task_id",
593
+ "scenarios",
594
+ "links_seen",
595
+ "stats",
596
+ "is_interrupted",
597
+ )
598
+
599
+ def __init__(self, console: Console, title: str, links_total: int) -> None:
600
+ from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
601
+ from rich.style import Style
602
+
603
+ self.console = console
604
+ self.title = title
605
+ self.links_total = links_total
606
+ self.start_time = time.monotonic()
607
+
608
+ self.title_progress = Progress(
609
+ TextColumn(""),
610
+ SpinnerColumn("clock"),
611
+ TextColumn("{task.description}", style=Style(color="bright_white")),
612
+ console=self.console,
613
+ )
614
+ self.title_task_id = None
615
+
616
+ self.progress_bar = Progress(
617
+ TextColumn(" "),
618
+ TimeElapsedColumn(),
619
+ TextColumn("{task.fields[scenarios]:3d} scenarios • {task.fields[links]}"),
620
+ console=self.console,
621
+ )
622
+ self.progress_task_id = None
623
+
624
+ # Initialize stats progress
625
+ self.stats_progress = Progress(
626
+ TextColumn(" "),
627
+ TextColumn("{task.description}"),
628
+ console=self.console,
629
+ )
630
+ self.stats_task_id = self.stats_progress.add_task("")
631
+
632
+ self.live = None
633
+
634
+ # Initialize state
635
+ self.scenarios = 0
636
+ self.links_seen = set()
637
+ self.stats = {
638
+ Status.SUCCESS: 0,
639
+ Status.FAILURE: 0,
640
+ Status.ERROR: 0,
641
+ Status.SKIP: 0,
642
+ }
643
+ self.is_interrupted = False
644
+
645
+ def start(self) -> None:
646
+ """Start progress display."""
647
+ from rich.console import Group
648
+ from rich.live import Live
649
+ from rich.text import Text
650
+
651
+ # Initialize progress displays
652
+ self.title_task_id = self.title_progress.add_task("Stateful tests")
653
+ self.progress_task_id = self.progress_bar.add_task("", scenarios=0, links=f"0/{self.links_total} links")
654
+
655
+ # Create live display
656
+ group = Group(
657
+ self.title_progress,
658
+ Text(),
659
+ self.progress_bar,
660
+ Text(),
661
+ self.stats_progress,
662
+ )
663
+ self.live = Live(group, refresh_per_second=10, console=self.console, transient=True)
664
+ self.live.start()
665
+
666
+ def stop(self) -> None:
667
+ """Stop progress display."""
668
+ if self.live:
669
+ self.live.stop()
670
+
671
+ def update(self, links_seen: set[str], status: Status | None = None) -> None:
672
+ """Update progress and stats."""
673
+ self.scenarios += 1
674
+ self.links_seen.update(links_seen)
675
+
676
+ if status is not None:
677
+ self.stats[status] += 1
678
+
679
+ self._update_progress_display()
680
+ self._update_stats_display()
681
+
682
+ def _update_progress_display(self) -> None:
683
+ """Update the progress display."""
684
+ assert self.progress_task_id is not None
685
+ self.progress_bar.update(
686
+ self.progress_task_id,
687
+ scenarios=self.scenarios,
688
+ links=f"{len(self.links_seen)}/{self.links_total} links",
689
+ )
690
+
691
+ def _get_stats_message(self) -> str:
692
+ """Get formatted stats message."""
693
+ parts = []
694
+ if self.stats[Status.SUCCESS]:
695
+ parts.append(f"✅ {self.stats[Status.SUCCESS]} passed")
696
+ if self.stats[Status.FAILURE]:
697
+ parts.append(f"❌ {self.stats[Status.FAILURE]} failed")
698
+ if self.stats[Status.ERROR]:
699
+ parts.append(f"🚫 {self.stats[Status.ERROR]} errors")
700
+ if self.stats[Status.SKIP]:
701
+ parts.append(f"⏭️ {self.stats[Status.SKIP]} skipped")
702
+ return " ".join(parts)
703
+
704
+ def _update_stats_display(self) -> None:
705
+ """Update the statistics display."""
706
+ self.stats_progress.update(self.stats_task_id, description=self._get_stats_message())
707
+
708
+ def _get_status_icon(self, default_icon: str = "🕛") -> str:
709
+ if self.is_interrupted:
710
+ icon = "⚡"
711
+ elif self.stats[Status.ERROR] > 0:
712
+ icon = "🚫"
713
+ elif self.stats[Status.FAILURE] > 0:
714
+ icon = "❌"
715
+ elif self.stats[Status.SUCCESS] > 0:
716
+ icon = "✅"
717
+ elif self.stats[Status.SKIP] > 0:
718
+ icon = "⏭️"
719
+ else:
720
+ icon = default_icon
721
+ return icon
722
+
723
+ def interrupt(self) -> None:
724
+ """Handle interruption."""
725
+ self.is_interrupted = True
726
+ if self.live:
727
+ self.stop()
728
+
729
+ def get_completion_message(self, icon: str | None = None) -> tuple[str, str]:
730
+ """Complete the phase and return status message."""
731
+ duration = format_duration(int((time.monotonic() - self.start_time) * 1000))
732
+ icon = icon or self._get_status_icon()
733
+
734
+ message = self._get_stats_message() or "No tests were run"
735
+ if self.is_interrupted:
736
+ duration_message = f"interrupted after {duration}"
737
+ else:
738
+ duration_message = f"in {duration}"
739
+
740
+ return f"{icon} {self.title} ({duration_message})", message
741
+
742
+
743
+ def format_duration(duration_ms: int) -> str:
744
+ """Format duration in milliseconds to human readable string."""
745
+ parts = []
746
+
747
+ # Convert to components
748
+ ms = duration_ms % 1000
749
+ seconds = (duration_ms // 1000) % 60
750
+ minutes = (duration_ms // (1000 * 60)) % 60
751
+ hours = duration_ms // (1000 * 60 * 60)
752
+
753
+ # Add non-empty components
754
+ if hours > 0:
755
+ parts.append(f"{hours} h")
756
+ if minutes > 0:
757
+ parts.append(f"{minutes} m")
758
+ if seconds > 0:
759
+ parts.append(f"{seconds} s")
760
+ if ms > 0:
761
+ parts.append(f"{ms} ms")
762
+
763
+ # Handle zero duration
764
+ if not parts:
765
+ return "0 ms"
766
+
767
+ return " ".join(parts)
768
+
769
+
176
770
  @dataclass
177
771
  class OutputHandler(EventHandler):
178
772
  workers_num: int
179
773
  rate_limit: str | None
180
774
  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
775
+
776
+ loading_manager: LoadingProgressManager | None = None
777
+ probing_manager: ProbingProgressManager | None = None
778
+ unit_tests_manager: UnitTestProgressManager | None = None
779
+ stateful_tests_manager: StatefulProgressManager | None = None
780
+
781
+ statistic: ApiStatistic | None = None
185
782
  skip_reasons: list[str] = field(default_factory=list)
186
- current_line_length: int = 0
187
783
  cassette_config: CassetteConfig | None = None
188
784
  junit_xml_file: str | None = None
189
785
  warnings: WarningData = field(default_factory=WarningData)
@@ -205,9 +801,9 @@ class OutputHandler(EventHandler):
205
801
  if isinstance(event, events.EngineFinished):
206
802
  self._on_engine_finished(ctx, event)
207
803
  elif isinstance(event, events.Interrupted):
208
- self._on_interrupted()
804
+ self._on_interrupted(event)
209
805
  elif isinstance(event, events.FatalError):
210
- self._on_fatal_error(event)
806
+ self._on_fatal_error(ctx, event)
211
807
  elif isinstance(event, events.NonFatalError):
212
808
  self.errors.append(event)
213
809
  elif isinstance(event, LoadingStarted):
@@ -218,63 +814,36 @@ class OutputHandler(EventHandler):
218
814
  def start(self, ctx: ExecutionContext) -> None:
219
815
  display_header(SCHEMATHESIS_VERSION)
220
816
 
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
817
+ def shutdown(self, ctx: ExecutionContext) -> None:
818
+ if self.unit_tests_manager is not None:
819
+ self.unit_tests_manager.stop()
820
+ if self.stateful_tests_manager is not None:
821
+ self.stateful_tests_manager.stop()
822
+ if self.loading_manager is not None:
823
+ self.loading_manager.stop()
824
+ if self.probing_manager is not None:
825
+ self.probing_manager.stop()
238
826
 
239
827
  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")
828
+ self.loading_manager = LoadingProgressManager(console=self.console, location=event.location)
829
+ self.loading_manager.start()
256
830
 
257
831
  def _on_loading_finished(self, ctx: ExecutionContext, event: LoadingFinished) -> None:
258
832
  from rich.padding import Padding
259
833
  from rich.style import Style
260
834
  from rich.table import Table
261
- from rich.text import Text
262
835
 
263
- self._stop_progress()
264
- self.operations_count = event.operations_count
836
+ assert self.loading_manager is not None
837
+ self.loading_manager.stop()
265
838
 
266
- duration_ms = int(event.duration * 1000)
267
839
  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
- ),
840
+ self.loading_manager.get_completion_message(),
274
841
  BLOCK_PADDING,
275
842
  )
276
843
  self.console.print(message)
277
844
  self.console.print()
845
+ self.loading_manager = None
846
+ self.statistic = event.statistic
278
847
 
279
848
  table = Table(
280
849
  show_header=False,
@@ -287,7 +856,7 @@ class OutputHandler(EventHandler):
287
856
 
288
857
  table.add_row("Base URL:", event.base_url)
289
858
  table.add_row("Specification:", event.specification.name)
290
- table.add_row("Operations:", str(event.operations_count.total))
859
+ table.add_row("Operations:", str(event.statistic.operations.total))
291
860
 
292
861
  message = Padding(table, BLOCK_PADDING)
293
862
  self.console.print(message)
@@ -300,22 +869,32 @@ class OutputHandler(EventHandler):
300
869
  phase = event.phase
301
870
  if phase.name == PhaseName.PROBING and phase.is_enabled:
302
871
  self._start_probing()
872
+ elif phase.name == PhaseName.UNIT_TESTING and phase.is_enabled:
873
+ self._start_unit_tests()
303
874
  elif phase.name == PhaseName.STATEFUL_TESTING and phase.is_enabled and phase.skip_reason is None:
304
- click.secho("Stateful tests\n", bold=True)
875
+ self._start_stateful_tests()
305
876
 
306
877
  def _start_probing(self) -> None:
307
- from rich.progress import Progress, RenderableColumn, SpinnerColumn, TextColumn
308
- from rich.text import Text
878
+ self.probing_manager = ProbingProgressManager(console=self.console)
879
+ self.probing_manager.start()
309
880
 
310
- progress_message = Text("Probing API capabilities")
311
- self.progress = Progress(
312
- TextColumn(""),
313
- SpinnerColumn("clock"),
314
- RenderableColumn(progress_message),
315
- transient=True,
881
+ def _start_unit_tests(self) -> None:
882
+ assert self.statistic is not None
883
+ self.unit_tests_manager = UnitTestProgressManager(
884
+ console=self.console,
885
+ title="Unit tests",
886
+ total=self.statistic.operations.total,
887
+ )
888
+ self.unit_tests_manager.start()
889
+
890
+ def _start_stateful_tests(self) -> None:
891
+ assert self.statistic is not None
892
+ self.stateful_tests_manager = StatefulProgressManager(
316
893
  console=self.console,
894
+ title="Stateful tests",
895
+ links_total=self.statistic.links.total,
317
896
  )
318
- self._start_progress("Probing")
897
+ self.stateful_tests_manager.start()
319
898
 
320
899
  def _on_phase_finished(self, event: events.PhaseFinished) -> None:
321
900
  from rich.padding import Padding
@@ -327,7 +906,9 @@ class OutputHandler(EventHandler):
327
906
  self.phases[phase.name] = (event.status, phase.skip_reason)
328
907
 
329
908
  if phase.name == PhaseName.PROBING:
330
- self._stop_progress()
909
+ assert self.probing_manager is not None
910
+ self.probing_manager.stop()
911
+ self.probing_manager = None
331
912
 
332
913
  if event.status == Status.SUCCESS:
333
914
  assert isinstance(event.payload, Ok)
@@ -336,7 +917,7 @@ class OutputHandler(EventHandler):
336
917
  Padding(
337
918
  Text.assemble(
338
919
  ("✅ ", Style(color="green")),
339
- ("API capabilities:", Style(color="white", bold=True)),
920
+ ("API capabilities:", Style(color="bright_white")),
340
921
  ),
341
922
  BLOCK_PADDING,
342
923
  )
@@ -384,77 +965,130 @@ class OutputHandler(EventHandler):
384
965
  self.console.print(message)
385
966
  self.console.print()
386
967
  elif phase.name == PhaseName.STATEFUL_TESTING and phase.is_enabled:
387
- if event.status != Status.INTERRUPTED:
388
- click.echo("\n")
968
+ assert self.stateful_tests_manager is not None
969
+ self.stateful_tests_manager.stop()
970
+ if event.status == Status.ERROR:
971
+ title, summary = self.stateful_tests_manager.get_completion_message("🚫")
972
+ else:
973
+ title, summary = self.stateful_tests_manager.get_completion_message()
974
+
975
+ self.console.print(Padding(Text(title, style="bright_white"), BLOCK_PADDING))
976
+
977
+ table = Table(
978
+ show_header=False,
979
+ box=None,
980
+ padding=(0, 4),
981
+ collapse_padding=True,
982
+ )
983
+ table.add_column("Field", style=Style(color="bright_white", bold=True))
984
+ table.add_column("Value", style="cyan")
985
+ table.add_row("Scenarios:", f"{self.stateful_tests_manager.scenarios}")
986
+ table.add_row(
987
+ "API Links:", f"{len(self.stateful_tests_manager.links_seen)}/{self.stateful_tests_manager.links_total}"
988
+ )
989
+
990
+ self.console.print()
991
+ self.console.print(Padding(table, BLOCK_PADDING))
992
+ self.console.print()
993
+ self.console.print(Padding(Text(summary, style="bright_white"), (0, 0, 0, 5)))
994
+ self.console.print()
995
+ self.stateful_tests_manager = None
389
996
  elif phase.name == PhaseName.UNIT_TESTING and phase.is_enabled:
997
+ assert self.unit_tests_manager is not None
998
+ self.unit_tests_manager.stop()
999
+ if event.status == Status.ERROR:
1000
+ message = self.unit_tests_manager.get_completion_message("🚫")
1001
+ else:
1002
+ message = self.unit_tests_manager.get_completion_message()
1003
+ self.console.print(Padding(Text(message, style="white"), BLOCK_PADDING))
390
1004
  if event.status != Status.INTERRUPTED:
391
- click.echo()
392
- if self.workers_num > 1:
393
- click.echo()
1005
+ self.console.print()
1006
+ self.unit_tests_manager = None
394
1007
 
395
1008
  def _on_scenario_started(self, event: events.ScenarioStarted) -> None:
396
- if event.phase == PhaseName.UNIT_TESTING and self.workers_num == 1:
1009
+ if event.phase == PhaseName.UNIT_TESTING:
397
1010
  # We should display execution result + percentage in the end. For example:
398
1011
  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)
1012
+ assert self.unit_tests_manager is not None
1013
+ self.unit_tests_manager.start_operation(event.label)
404
1014
 
405
1015
  def _on_scenario_finished(self, event: events.ScenarioFinished) -> None:
406
- self.operations_processed += 1
407
1016
  if event.phase == PhaseName.UNIT_TESTING:
1017
+ assert self.unit_tests_manager is not None
1018
+ if event.label:
1019
+ self.unit_tests_manager.finish_operation(event.label)
1020
+ self.unit_tests_manager.update_progress()
1021
+ self.unit_tests_manager.update_stats(event.status)
408
1022
  if event.status == Status.SKIP and event.skip_reason is not None:
409
1023
  self.skip_reasons.append(event.skip_reason)
410
- self._display_execution_result(event.status)
411
1024
  self._check_warnings(event)
412
- if self.workers_num == 1:
413
- self.display_percentage()
414
1025
  elif (
415
1026
  event.phase == PhaseName.STATEFUL_TESTING
416
1027
  and not event.is_final
417
- and event.status != Status.INTERRUPTED
418
- and event.status is not None
1028
+ and event.status not in (Status.INTERRUPTED, Status.SKIP, None)
419
1029
  ):
420
- self._display_execution_result(event.status)
1030
+ assert self.stateful_tests_manager is not None
1031
+ links_seen = {case.transition.id for case in event.recorder.cases.values() if case.transition is not None}
1032
+ self.stateful_tests_manager.update(links_seen, event.status)
421
1033
 
422
1034
  def _check_warnings(self, event: events.ScenarioFinished) -> None:
423
1035
  for status_code in (401, 403):
424
1036
  if has_too_many_responses_with_status(event.recorder.interactions.values(), status_code):
425
1037
  self.warnings.missing_auth.setdefault(status_code, []).append(event.recorder.label)
426
1038
 
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()
1039
+ def _on_interrupted(self, event: events.Interrupted) -> None:
1040
+ from rich.padding import Padding
443
1041
 
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}."
1042
+ if self.unit_tests_manager is not None:
1043
+ self.unit_tests_manager.interrupt()
1044
+ elif self.stateful_tests_manager is not None:
1045
+ self.stateful_tests_manager.interrupt()
1046
+ elif self.loading_manager is not None:
1047
+ self.loading_manager.interrupt()
1048
+ message = Padding(
1049
+ self.loading_manager.get_completion_message(),
1050
+ BLOCK_PADDING,
1051
+ )
1052
+ self.console.print(message)
1053
+ self.console.print()
1054
+ elif self.probing_manager is not None:
1055
+ self.probing_manager.interrupt()
1056
+ message = Padding(
1057
+ self.probing_manager.get_completion_message(),
1058
+ BLOCK_PADDING,
457
1059
  )
1060
+ self.console.print(message)
1061
+ self.console.print()
1062
+
1063
+ def _on_fatal_error(self, ctx: ExecutionContext, event: events.FatalError) -> None:
1064
+ from rich.padding import Padding
1065
+ from rich.text import Text
1066
+
1067
+ self.shutdown(ctx)
1068
+
1069
+ if isinstance(event.exception, LoaderError):
1070
+ assert self.loading_manager is not None
1071
+ message = Padding(self.loading_manager.get_error_message(event.exception), BLOCK_PADDING)
1072
+ self.console.print(message)
1073
+ self.console.print()
1074
+ self.loading_manager = None
1075
+
1076
+ if event.exception.extras:
1077
+ for extra in event.exception.extras:
1078
+ self.console.print(Padding(Text(extra), (0, 0, 0, 5)))
1079
+ self.console.print()
1080
+
1081
+ if not (event.exception.kind == LoaderErrorKind.CONNECTION_OTHER and self.wait_for_schema is not None):
1082
+ suggestion = LOADER_ERROR_SUGGESTIONS.get(event.exception.kind)
1083
+ if suggestion is not None:
1084
+ click.secho(f"{click.style('Tip:', bold=True, fg='green')} {suggestion}")
1085
+
1086
+ raise click.Abort
1087
+ title = "Test Execution Error"
1088
+ message = DEFAULT_INTERNAL_ERROR_MESSAGE
1089
+ traceback = format_exception(event.exception, with_traceback=True)
1090
+ extras = split_traceback(traceback)
1091
+ suggestion = f"Please consider reporting the traceback above to our issue tracker:\n\n {ISSUE_TRACKER_URL}."
458
1092
  click.secho(title, fg="red", bold=True)
459
1093
  click.echo()
460
1094
  click.secho(message)
@@ -464,7 +1098,7 @@ class OutputHandler(EventHandler):
464
1098
  and event.exception.kind == LoaderErrorKind.CONNECTION_OTHER
465
1099
  and self.wait_for_schema is not None
466
1100
  ):
467
- _maybe_display_tip(suggestion)
1101
+ click.secho(f"\n{click.style('Tip:', bold=True, fg='green')} {suggestion}")
468
1102
 
469
1103
  raise click.Abort
470
1104
 
@@ -515,11 +1149,11 @@ class OutputHandler(EventHandler):
515
1149
  click.echo()
516
1150
 
517
1151
  def display_api_operations(self, ctx: ExecutionContext) -> None:
518
- assert self.operations_count is not None
1152
+ assert self.statistic is not None
519
1153
  click.secho("API Operations:", bold=True)
520
1154
  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)}"
1155
+ f" Selected: {click.style(str(self.statistic.operations.selected), bold=True)}/"
1156
+ f"{click.style(str(self.statistic.operations.total), bold=True)}"
523
1157
  )
524
1158
  click.secho(f" Tested: {click.style(str(len(ctx.statistic.tested_operations)), bold=True)}")
525
1159
  errors = len(
@@ -536,7 +1170,7 @@ class OutputHandler(EventHandler):
536
1170
  click.secho(f" Errored: {click.style(str(errors), bold=True)}")
537
1171
 
538
1172
  # 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
1173
+ total_skips = self.statistic.operations.selected - len(ctx.statistic.tested_operations) - errors
540
1174
  if total_skips:
541
1175
  click.secho(f" Skipped: {click.style(str(total_skips), bold=True)}")
542
1176
  for reason in sorted(set(self.skip_reasons)):
@@ -689,7 +1323,7 @@ class OutputHandler(EventHandler):
689
1323
  display_section_name("SUMMARY")
690
1324
  click.echo()
691
1325
 
692
- if self.operations_count:
1326
+ if self.statistic:
693
1327
  self.display_api_operations(ctx)
694
1328
 
695
1329
  self.display_phases()
@@ -714,18 +1348,6 @@ class OutputHandler(EventHandler):
714
1348
  self.display_reports()
715
1349
  self.display_final_line(ctx, event)
716
1350
 
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
1351
 
730
1352
  TOO_MANY_RESPONSES_WARNING_TEMPLATE = (
731
1353
  "Most of the responses from {} have a {} status code. Did you specify proper API credentials?"