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.
- schemathesis/checks.py +6 -4
- schemathesis/cli/commands/run/__init__.py +4 -4
- schemathesis/cli/commands/run/events.py +4 -9
- schemathesis/cli/commands/run/executor.py +6 -3
- schemathesis/cli/commands/run/filters.py +27 -19
- schemathesis/cli/commands/run/handlers/base.py +1 -1
- schemathesis/cli/commands/run/handlers/cassettes.py +1 -3
- schemathesis/cli/commands/run/handlers/output.py +765 -143
- schemathesis/cli/commands/run/validation.py +1 -1
- schemathesis/cli/ext/options.py +4 -1
- schemathesis/core/failures.py +54 -24
- schemathesis/engine/core.py +1 -1
- schemathesis/engine/events.py +3 -97
- schemathesis/engine/phases/stateful/__init__.py +1 -0
- schemathesis/engine/phases/stateful/_executor.py +19 -44
- schemathesis/engine/phases/unit/__init__.py +1 -0
- schemathesis/engine/phases/unit/_executor.py +2 -1
- schemathesis/engine/phases/unit/_pool.py +1 -1
- schemathesis/engine/recorder.py +8 -3
- schemathesis/generation/stateful/state_machine.py +53 -36
- schemathesis/graphql/checks.py +3 -9
- schemathesis/openapi/checks.py +8 -33
- schemathesis/schemas.py +34 -14
- schemathesis/specs/graphql/schemas.py +16 -15
- schemathesis/specs/openapi/expressions/__init__.py +11 -15
- schemathesis/specs/openapi/expressions/nodes.py +20 -20
- schemathesis/specs/openapi/links.py +126 -119
- schemathesis/specs/openapi/schemas.py +18 -22
- schemathesis/specs/openapi/stateful/__init__.py +77 -55
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/METADATA +1 -1
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/RECORD +34 -35
- schemathesis/specs/openapi/expressions/context.py +0 -14
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/entry_points.txt +0 -0
- {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
|
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
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
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.
|
223
|
-
self.
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
self.
|
229
|
-
|
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
|
-
|
241
|
-
|
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.
|
264
|
-
self.
|
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
|
-
|
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.
|
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
|
-
|
875
|
+
self._start_stateful_tests()
|
305
876
|
|
306
877
|
def _start_probing(self) -> None:
|
307
|
-
|
308
|
-
|
878
|
+
self.probing_manager = ProbingProgressManager(console=self.console)
|
879
|
+
self.probing_manager.start()
|
309
880
|
|
310
|
-
|
311
|
-
self.
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
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.
|
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.
|
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="
|
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
|
-
|
388
|
-
|
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
|
-
|
392
|
-
|
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
|
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
|
-
|
400
|
-
|
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
|
418
|
-
and event.status is not None
|
1028
|
+
and event.status not in (Status.INTERRUPTED, Status.SKIP, None)
|
419
1029
|
):
|
420
|
-
self.
|
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
|
428
|
-
|
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
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
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
|
-
|
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.
|
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.
|
522
|
-
f"{click.style(str(self.
|
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.
|
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.
|
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?"
|