algorithm-nexus 0.2.1__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.
@@ -0,0 +1,683 @@
1
+ # Copyright IBM Corp. 2026
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Validate command for Algorithm Nexus CLI."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Annotated, Any, Literal
11
+
12
+ try:
13
+ import typer
14
+ from pydantic import ValidationError
15
+ from rich.console import Console
16
+ from rich.panel import Panel
17
+ except ImportError:
18
+ print(
19
+ "Error: CLI dependencies are not installed.\n"
20
+ "Please install them with: pip install algorithm-nexus[cli]",
21
+ file=sys.stderr,
22
+ )
23
+ sys.exit(1)
24
+
25
+ from algorithm_nexus.commands.utils import (
26
+ ValidationErrorCollector,
27
+ determine_output_format,
28
+ load_yaml_file,
29
+ print_results_table,
30
+ print_structured_results,
31
+ validate_output_format,
32
+ write_results_to_file,
33
+ )
34
+ from algorithm_nexus.models import (
35
+ AlgorithmNexusModelConfig,
36
+ AlgorithmNexusPackageConfig,
37
+ BenchmarkBinding,
38
+ CategoricalValueMapping,
39
+ FieldMapping,
40
+ LogicalBenchmarkConfig,
41
+ )
42
+
43
+ console = Console()
44
+
45
+
46
+ def format_pydantic_error(error: dict[str, Any], file_path: Path) -> str:
47
+ """Format a Pydantic validation error into a readable message."""
48
+ loc = ".".join(str(x) for x in error["loc"])
49
+ msg = error["msg"]
50
+
51
+ # Extract more context from the error if available
52
+ error_type = error.get("type", "")
53
+
54
+ # For value_error types, the message usually contains the full explanation
55
+ if error_type.startswith("value_error"):
56
+ # The message already contains the full context
57
+ return f"[bold]{file_path}[/bold]\n Field: [cyan]{loc}[/cyan]\n Error: {msg}"
58
+
59
+ # For missing field errors
60
+ if error_type == "missing":
61
+ return f"[bold]{file_path}[/bold]\n Field: [cyan]{loc}[/cyan]\n Error: This required field is missing"
62
+
63
+ # Default format for other errors
64
+ return f"[bold]{file_path}[/bold]\n Field: [cyan]{loc}[/cyan]\n Error: {msg}"
65
+
66
+
67
+ def validate_nexus_yaml(
68
+ package_dir: Path,
69
+ collector: ValidationErrorCollector,
70
+ ) -> AlgorithmNexusPackageConfig | None:
71
+ """Validate nexus.yaml.
72
+
73
+ Returns the validated package config if successful, None otherwise.
74
+ """
75
+ nexus_yaml_path = package_dir / "nexus.yaml"
76
+ data = load_yaml_file(nexus_yaml_path, collector)
77
+ if data is None:
78
+ return None
79
+
80
+ try:
81
+ return AlgorithmNexusPackageConfig.model_validate(data)
82
+ except ValidationError as exc:
83
+ for error in exc.errors():
84
+ collector.add(format_pydantic_error(error, nexus_yaml_path))
85
+ return None
86
+
87
+
88
+ def validate_model_yaml(
89
+ model_dir: Path, collector: ValidationErrorCollector
90
+ ) -> AlgorithmNexusModelConfig | None:
91
+ """Validate a model's model.yaml file.
92
+
93
+ Returns the validated model config if successful, None otherwise.
94
+ """
95
+ model_yaml_path = model_dir / "model.yaml"
96
+ data = load_yaml_file(model_yaml_path, collector)
97
+ if data is None:
98
+ return None
99
+
100
+ try:
101
+ return AlgorithmNexusModelConfig.model_validate(data)
102
+ except ValidationError as exc:
103
+ for error in exc.errors():
104
+ collector.add(format_pydantic_error(error, model_yaml_path))
105
+ return None
106
+
107
+
108
+ def validate_benchmark_instances(
109
+ model_dir: Path,
110
+ collector: ValidationErrorCollector,
111
+ registered_experiments: set[str],
112
+ ) -> None:
113
+ """Validate a model's benchmark_instances/ folder.
114
+
115
+ Validates that each benchmark instance has a space.yaml file.
116
+ Does not validate the contents of space.yaml as that is ADO's responsibility.
117
+ """
118
+ benchmark_instances_dir = model_dir / "benchmark_instances"
119
+
120
+ # benchmark_instances/ is optional
121
+ if not benchmark_instances_dir.exists():
122
+ return
123
+
124
+ if not benchmark_instances_dir.is_dir():
125
+ collector.add(
126
+ f"benchmark_instances must be a directory when present: {benchmark_instances_dir}"
127
+ )
128
+ return
129
+
130
+ # Validate each benchmark instance folder
131
+ for instance_dir in benchmark_instances_dir.iterdir():
132
+ if not instance_dir.is_dir():
133
+ continue
134
+
135
+ space_yaml_path = instance_dir / "space.yaml"
136
+
137
+ # Each benchmark instance must have a space.yaml
138
+ if not space_yaml_path.exists():
139
+ collector.add(
140
+ f"[bold]{instance_dir}[/bold]\n"
141
+ f" Error: Missing required space.yaml file for benchmark instance '{instance_dir.name}'"
142
+ )
143
+
144
+
145
+ def validate_optional_file(
146
+ path: Path, collector: ValidationErrorCollector, context: str
147
+ ) -> bool:
148
+ """Validate optional file and add info if missing. Returns True if valid or doesn't exist."""
149
+ if not path.exists():
150
+ collector.add_info(f"{context}")
151
+ return False
152
+ elif not path.is_file():
153
+ collector.add(f"{context} must be a file when present: {path}")
154
+ return False
155
+
156
+ return True
157
+
158
+
159
+ def validate_optional_dir(
160
+ path: Path, collector: ValidationErrorCollector, context: str
161
+ ) -> bool:
162
+ """Validate optional directory and add info if missing. Returns True if valid or doesn't exist."""
163
+ if not path.exists():
164
+ collector.add_info(f"{context}")
165
+ return False
166
+ elif not path.is_dir():
167
+ collector.add(f"{context} must be a directory when present: {path}")
168
+ return False
169
+
170
+ return True
171
+
172
+
173
+ def validate_model_directory(
174
+ model_dir: Path,
175
+ collector: ValidationErrorCollector,
176
+ registered_experiments: set[str],
177
+ ) -> AlgorithmNexusModelConfig | None:
178
+ """Validate a single model directory structure and contents.
179
+
180
+ Returns the validated model config if successful, None otherwise.
181
+ """
182
+ if not model_dir.is_dir():
183
+ collector.add(f"Model path is not a directory: {model_dir}")
184
+ return None
185
+
186
+ # Validate optional usage.md
187
+ usage_md = model_dir / "usage.md"
188
+ validate_optional_file(
189
+ usage_md,
190
+ collector,
191
+ f"Optional model file missing for '{model_dir.name}': usage.md",
192
+ )
193
+
194
+ # Validate model.yaml
195
+ model_config = validate_model_yaml(model_dir, collector)
196
+
197
+ # Validate optional benchmark_instances/
198
+ validate_benchmark_instances(model_dir, collector, registered_experiments)
199
+
200
+ return model_config
201
+
202
+
203
+ def validate_package_directory(
204
+ package_dir: Path, collector: ValidationErrorCollector
205
+ ) -> None:
206
+ """Validate the structure and contents of a Nexus package directory."""
207
+ if not package_dir.is_dir():
208
+ collector.add(f"Package path is not a directory: {package_dir}")
209
+ return
210
+
211
+ # Validate nexus.yaml and extract registered experiments
212
+ package_config = validate_nexus_yaml(package_dir, collector)
213
+ registered_experiments: set[str] = set()
214
+ if package_config and package_config.package.benchmark_packages:
215
+ # Collect all experiment identifiers from all benchmark packages
216
+ for pkg in package_config.package.benchmark_packages:
217
+ registered_experiments.update(pkg.experiments)
218
+
219
+ # Validate unique experiment identifiers across all packages
220
+ all_experiments = []
221
+ for pkg in package_config.package.benchmark_packages:
222
+ all_experiments.extend(pkg.experiments)
223
+ duplicates = {exp for exp in all_experiments if all_experiments.count(exp) > 1}
224
+ if duplicates:
225
+ collector.add(
226
+ f"Duplicate experiment identifiers across benchmark packages in nexus.yaml: {', '.join(sorted(duplicates))}"
227
+ )
228
+
229
+ # Validate optional skills directory
230
+ skills_dir = package_dir / "skills"
231
+ validate_optional_dir(
232
+ skills_dir, collector, "Optional package directory missing: skills"
233
+ )
234
+
235
+ # Validate optional benchmark_packages directory
236
+ benchmark_packages_dir = package_dir / "benchmark_packages"
237
+ if validate_optional_dir(
238
+ benchmark_packages_dir,
239
+ collector,
240
+ "Optional package directory missing: benchmark_packages",
241
+ ):
242
+ # Check that each subdirectory is a valid Python package
243
+ for pkg_dir in benchmark_packages_dir.iterdir():
244
+ if not pkg_dir.is_dir():
245
+ continue
246
+ pyproject_toml = pkg_dir / "pyproject.toml"
247
+ if not pyproject_toml.exists():
248
+ collector.add_info(
249
+ f"Optional file missing in benchmark_packages/{pkg_dir.name}/: pyproject.toml (required for local benchmark Python package)"
250
+ )
251
+
252
+ # Validate optional package-level benchmark_instances directory
253
+ # The validate_benchmark_instances function expects a directory that contains benchmark_instances/
254
+ # So we pass package_dir and it will look for package_dir/benchmark_instances/
255
+ validate_benchmark_instances(package_dir, collector, registered_experiments)
256
+
257
+ # Check if models directory exists
258
+ models_dir = package_dir / "models"
259
+ if not validate_optional_dir(
260
+ models_dir, collector, "Optional package directory missing: models"
261
+ ):
262
+ return
263
+
264
+ # Track HuggingFace model IDs to detect duplicates
265
+ hf_id_to_models: dict[str, list[str]] = {}
266
+
267
+ # Only validate model directories if models_dir exists
268
+ for model_dir in models_dir.iterdir():
269
+ model_config = validate_model_directory(
270
+ model_dir, collector, registered_experiments
271
+ )
272
+
273
+ # Extract HF ID from validated model config for duplicate checking
274
+ if model_config is not None:
275
+ hf_id = model_config.model.id
276
+ if hf_id not in hf_id_to_models:
277
+ hf_id_to_models[hf_id] = []
278
+ hf_id_to_models[hf_id].append(model_dir.name)
279
+
280
+ # Check for duplicate HuggingFace model IDs
281
+ for hf_id, model_names in hf_id_to_models.items():
282
+ if len(model_names) > 1:
283
+ models_list = ", ".join(f"'{name}'" for name in sorted(model_names))
284
+ collector.add(
285
+ f"Duplicate HuggingFace model ID '{hf_id}' found in models: {models_list}"
286
+ )
287
+
288
+
289
+ def validate_package(
290
+ package_path: Annotated[
291
+ Path,
292
+ typer.Argument(
293
+ help="Path to a Nexus package directory.",
294
+ dir_okay=True,
295
+ file_okay=False,
296
+ readable=True,
297
+ resolve_path=True,
298
+ ),
299
+ ],
300
+ ) -> None:
301
+ """Validate Nexus package structure and YAML configuration files."""
302
+ collector = ValidationErrorCollector()
303
+ validate_package_directory(package_path, collector)
304
+
305
+ if collector.has_errors:
306
+ console.print(
307
+ Panel(
308
+ collector.format_errors(),
309
+ title="[bold red]Validation Failed[/bold red]",
310
+ border_style="red",
311
+ )
312
+ )
313
+ raise typer.Exit(code=1)
314
+
315
+ # Build success message
316
+ success_message = "[green]✓[/green] All validation checks passed"
317
+
318
+ if collector.has_info:
319
+ success_message += (
320
+ "\n\n[bold]Optional files/directories:[/bold]\n" + collector.format_info()
321
+ )
322
+
323
+ console.print(
324
+ Panel(
325
+ success_message,
326
+ title="[bold green]Validation Successful[/bold green]",
327
+ border_style="green",
328
+ )
329
+ )
330
+
331
+
332
+ def _print_validation_table(all_results: list[dict[str, Any]]) -> None:
333
+ rows = []
334
+ for result in all_results:
335
+ issues = []
336
+ if result.get("errors"):
337
+ issues.extend([f"E: {e}" for e in result["errors"]])
338
+ if result.get("warnings"):
339
+ issues.extend([f"W: {w}" for w in result["warnings"]])
340
+ rows.append(
341
+ {
342
+ "instance_path": result["instance_path"],
343
+ "status": result["status"],
344
+ "details": "\n".join(issues) if issues else "-",
345
+ }
346
+ )
347
+ print_results_table(rows, title="Validation Results", details_column="Issues")
348
+
349
+
350
+ def validate_benchmarks(
351
+ pr_url: Annotated[
352
+ str | None,
353
+ typer.Option(
354
+ "--pr",
355
+ help="GitHub Pull Request URL (e.g., https://github.com/IBM/algorithm-nexus/pull/123). "
356
+ "If not provided, validates all benchmark instances.",
357
+ ),
358
+ ] = None,
359
+ packages_root: Annotated[
360
+ Path,
361
+ typer.Option(
362
+ "--packages-root",
363
+ help="Path to packages directory",
364
+ ),
365
+ ] = Path("./packages"),
366
+ package: Annotated[
367
+ str | None,
368
+ typer.Option(
369
+ "--package",
370
+ help="Validate only benchmark instances from a specific package",
371
+ ),
372
+ ] = None,
373
+ verbose: Annotated[
374
+ bool,
375
+ typer.Option(
376
+ "--verbose",
377
+ help="Show detailed validation output",
378
+ ),
379
+ ] = False,
380
+ fail_fast: Annotated[
381
+ bool,
382
+ typer.Option(
383
+ "--fail-fast",
384
+ help="Stop validation on first error",
385
+ ),
386
+ ] = False,
387
+ output_format: Annotated[
388
+ Literal["json", "yaml", "table"] | None,
389
+ typer.Option(
390
+ "-o",
391
+ "--output-format",
392
+ help="Output format: 'json', 'yaml', or 'table' (default: table)",
393
+ ),
394
+ ] = None,
395
+ ) -> None:
396
+ """Validate benchmark instances.
397
+
398
+ This command supports three modes:
399
+ 1. PR mode: Validate instances modified in a PR (provide pr_url)
400
+ 2. All mode: Validate all benchmark instances (no pr_url)
401
+ 3. Package mode: Validate instances from a specific package (use --package)
402
+
403
+ The command:
404
+ - Installs required benchmark packages in isolated virtual environments
405
+ - Validates each instance with ADO dry-run
406
+ - Reports validation results
407
+ """
408
+ from algorithm_nexus.commands.benchmark_manager import BenchmarkManager
409
+
410
+ # Validate output format if specified
411
+ if output_format:
412
+ validate_output_format(
413
+ output_format, allow_yaml=True, allow_csv=False, allow_table=True
414
+ )
415
+
416
+ # Warn if both package filter and PR URL are provided (package is ignored in PR mode)
417
+ if package and pr_url:
418
+ console.print(
419
+ "[yellow]Warning:[/yellow] --package is ignored when --pr is specified. "
420
+ "In PR mode, only instances changed in the PR are validated."
421
+ )
422
+
423
+ # Validate package exists if package filter is specified (non-PR mode only)
424
+ if package and not pr_url:
425
+ package_path = packages_root / package
426
+ if not package_path.is_dir():
427
+ console.print(
428
+ f"[red]Error:[/red] Package '{package}' not found in {packages_root.resolve()}"
429
+ )
430
+ console.print(
431
+ "\nTo see available packages, run: [cyan]nexus list packages[/cyan]"
432
+ )
433
+ raise typer.Exit(code=1)
434
+
435
+ try:
436
+ # Create BenchmarkManager based on mode
437
+ if pr_url:
438
+ # PR mode
439
+ manager = BenchmarkManager(pr_url=pr_url, execute=False)
440
+ results = manager.validate(
441
+ packages_root=None,
442
+ package_filter=None,
443
+ verbose=verbose,
444
+ fail_fast=fail_fast,
445
+ )
446
+ else:
447
+ # All or package mode
448
+ manager = BenchmarkManager(pr_url=None, execute=False)
449
+ results = manager.validate(
450
+ packages_root=packages_root,
451
+ package_filter=package,
452
+ verbose=verbose,
453
+ fail_fast=fail_fast,
454
+ )
455
+
456
+ # Extract results
457
+ all_results = results.instances
458
+ total_failed = results.failed
459
+
460
+ # Determine output format (default to table for human-readable)
461
+ fmt = output_format or "table"
462
+
463
+ # Format output based on requested format
464
+ if fmt in ("json", "yaml"):
465
+ # Structured output (JSON or YAML)
466
+ output_data = {
467
+ "pr_url": pr_url,
468
+ "instances": all_results,
469
+ }
470
+ print_structured_results(output_data, fmt)
471
+ else:
472
+ # Human-readable table output (default)
473
+ _print_validation_table(all_results)
474
+
475
+ # Exit with error if any validation failed
476
+ if total_failed > 0:
477
+ raise typer.Exit(code=1)
478
+
479
+ except KeyboardInterrupt:
480
+ console.print("\n[yellow]Interrupted by user[/yellow]")
481
+ raise typer.Exit(code=130)
482
+ except typer.Exit:
483
+ # Re-raise typer.Exit to preserve exit codes
484
+ raise
485
+ except Exception as e:
486
+ console.print(f"\n[red]Error:[/red] {e}")
487
+ if verbose:
488
+ import traceback
489
+
490
+ console.print(traceback.format_exc())
491
+ raise typer.Exit(code=1)
492
+
493
+
494
+ def _check_binding_integrity(
495
+ binding: BenchmarkBinding,
496
+ definition_id: str,
497
+ property_ids: set[str],
498
+ metric_ids: set[str] | None,
499
+ collector: ValidationErrorCollector,
500
+ file_path: Path,
501
+ binding_index: int,
502
+ ) -> None:
503
+ """Check referential integrity between a binding and its parent definition."""
504
+ prefix = f"[bold]{file_path}[/bold]\n Binding[{binding_index}]"
505
+
506
+ # 1. benchmarkIdentifier must match the definition
507
+ if binding.benchmarkIdentifier != definition_id:
508
+ collector.add(
509
+ f"{prefix}: benchmarkIdentifier '{binding.benchmarkIdentifier}' "
510
+ f"does not match definition '{definition_id}'"
511
+ )
512
+
513
+ # 2. Property identifiers in propertyMapping must exist in the definition
514
+ if binding.propertyMapping:
515
+ for entry in binding.propertyMapping:
516
+ if isinstance(entry, FieldMapping):
517
+ pid = entry.benchmark.identifier
518
+ if pid not in property_ids:
519
+ collector.add(
520
+ f"{prefix}: propertyMapping references unknown benchmark property '{pid}'"
521
+ )
522
+ elif isinstance(entry, CategoricalValueMapping):
523
+ pid = entry.categoricalValue.property.identifier
524
+ if pid not in property_ids:
525
+ collector.add(
526
+ f"{prefix}: propertyMapping references unknown benchmark property '{pid}'"
527
+ )
528
+
529
+ # 3. Metric identifiers in metricMapping must exist in the definition
530
+ if binding.metricMapping and metric_ids is not None:
531
+ for entry in binding.metricMapping:
532
+ mid = entry.benchmark.identifier
533
+ if mid not in metric_ids:
534
+ collector.add(
535
+ f"{prefix}: metricMapping references unknown benchmark metric '{mid}'"
536
+ )
537
+
538
+
539
+ def validate_logical_benchmark_file(
540
+ file: Path,
541
+ collector: ValidationErrorCollector,
542
+ ) -> LogicalBenchmarkConfig | None:
543
+ """Validate a logical benchmark YAML file against the schema and referential integrity rules.
544
+
545
+ Returns the parsed LogicalBenchmarkConfig if schema validation passes, None otherwise.
546
+ Integrity errors are collected but do not prevent returning the parsed object.
547
+ """
548
+ data = load_yaml_file(file, collector)
549
+ if data is None:
550
+ return None
551
+
552
+ try:
553
+ parsed = LogicalBenchmarkConfig.model_validate(data)
554
+ except ValidationError as exc:
555
+ for error in exc.errors():
556
+ collector.add(format_pydantic_error(error, file))
557
+ return None
558
+
559
+ # Referential integrity checks
560
+ defn = parsed.logicalBenchmark
561
+ definition_id = defn.benchmarkIdentifier
562
+ property_ids = {p.identifier for p in defn.properties}
563
+ metric_ids = set(defn.metrics) if defn.metrics is not None else None
564
+
565
+ if parsed.bindings:
566
+ for i, binding in enumerate(parsed.bindings):
567
+ _check_binding_integrity(
568
+ binding,
569
+ definition_id,
570
+ property_ids,
571
+ metric_ids,
572
+ collector,
573
+ file,
574
+ i,
575
+ )
576
+
577
+ return parsed
578
+
579
+
580
+ def validate_logical_benchmarks(
581
+ benchmarks_root: Annotated[
582
+ Path,
583
+ typer.Option(
584
+ "--benchmarks-root",
585
+ help="Path to the directory containing logical benchmark YAML files.",
586
+ exists=True,
587
+ file_okay=False,
588
+ dir_okay=True,
589
+ resolve_path=True,
590
+ ),
591
+ ] = Path("./benchmarks"),
592
+ file: Annotated[
593
+ Path | None,
594
+ typer.Option(
595
+ "--file",
596
+ help="Validate a single logical benchmark YAML file instead of the whole directory.",
597
+ exists=True,
598
+ file_okay=True,
599
+ dir_okay=False,
600
+ readable=True,
601
+ resolve_path=True,
602
+ ),
603
+ ] = None,
604
+ output_format: Annotated[
605
+ Literal["json", "yaml", "table"] | None,
606
+ typer.Option(
607
+ "-o",
608
+ "--output-format",
609
+ help="Output format: 'json', 'yaml', or 'table' (default: table)",
610
+ ),
611
+ ] = None,
612
+ output_file: Annotated[
613
+ Path | None,
614
+ typer.Option(
615
+ "--output-file",
616
+ help="Write results to a file. Format is inferred from the file extension (.json/.yaml) unless --output-format is also set.",
617
+ dir_okay=False,
618
+ writable=True,
619
+ ),
620
+ ] = None,
621
+ ) -> None:
622
+ """Validate logical benchmark YAML files.
623
+
624
+ This command supports two modes:
625
+ 1. Directory mode: validate all *.yaml files under --benchmarks-root (default: ./benchmarks)
626
+ 2. Single-file mode: validate one file via --file
627
+
628
+ Checks performed for each file:
629
+ - Schema correctness (required fields, valid structure)
630
+ - Referential integrity (binding identifiers match the definition, property/metric
631
+ references exist in the definition)
632
+
633
+ Exit code is 0 on success, 1 if any file fails validation.
634
+ """
635
+ # NOTE: no verbose mode — errors are always shown in the table's Issues column
636
+ if output_format:
637
+ validate_output_format(
638
+ output_format, allow_yaml=True, allow_csv=False, allow_table=True
639
+ )
640
+
641
+ # Collect files to validate
642
+ if file is not None:
643
+ files = [file]
644
+ else:
645
+ files = sorted(benchmarks_root.glob("*.yaml"))
646
+ if not files:
647
+ console.print(
648
+ f"[yellow]No YAML files found in {benchmarks_root.resolve()}[/yellow]"
649
+ )
650
+ raise typer.Exit(code=0)
651
+
652
+ all_results: list[dict[str, Any]] = []
653
+ total_failed = 0
654
+
655
+ for yaml_file in files:
656
+ collector = ValidationErrorCollector()
657
+ validate_logical_benchmark_file(yaml_file, collector)
658
+
659
+ success = not collector.has_errors
660
+ if not success:
661
+ total_failed += 1
662
+
663
+ result: dict[str, Any] = {
664
+ "instance_path": str(yaml_file),
665
+ "status": "success" if success else "failed",
666
+ "errors": collector.errors,
667
+ "warnings": [],
668
+ }
669
+ all_results.append(result)
670
+
671
+ output_data = {"files": all_results}
672
+
673
+ fmt = determine_output_format(output_format, output_file, default="table")
674
+
675
+ if output_file:
676
+ write_results_to_file(output_data, output_file, fmt)
677
+ elif fmt in ("json", "yaml"):
678
+ print_structured_results(output_data, fmt)
679
+ else:
680
+ _print_validation_table(all_results)
681
+
682
+ if total_failed > 0:
683
+ raise typer.Exit(code=1)