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,584 @@
1
+ # Copyright IBM Corp. 2026
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Utility functions for Algorithm Nexus CLI commands."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import csv
9
+ import io
10
+ import json
11
+ import re
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import yaml
17
+
18
+ try:
19
+ import typer
20
+ from rich.console import Console
21
+ from rich.table import Table
22
+ except ImportError:
23
+ print(
24
+ "Error: CLI dependencies are not installed.\n"
25
+ "Please install them with: pip install algorithm-nexus[cli]",
26
+ file=sys.stderr,
27
+ )
28
+ sys.exit(1)
29
+
30
+ from algorithm_nexus.models import AlgorithmNexusPackageConfig
31
+
32
+ console = Console()
33
+
34
+
35
+ def strip_ansi_codes(text: str) -> str:
36
+ """Remove ANSI escape sequences from text.
37
+
38
+ Args:
39
+ text: Text potentially containing ANSI codes
40
+
41
+ Returns:
42
+ Text with ANSI codes removed
43
+ """
44
+ ansi_escape = re.compile(r"\x1b\[[0-9;]*m")
45
+ return ansi_escape.sub("", text)
46
+
47
+
48
+ class ValidationErrorCollector:
49
+ """Collects validation errors and info messages during package validation."""
50
+
51
+ def __init__(self) -> None:
52
+ """Initialize the error collector."""
53
+ self.errors: list[str] = []
54
+ self.info: list[str] = []
55
+
56
+ def add(self, message: str) -> None:
57
+ """Add a single error message."""
58
+ self.errors.append(message)
59
+
60
+ def add_info(self, message: str) -> None:
61
+ """Add a single info message."""
62
+ self.info.append(message)
63
+
64
+ def extend(self, messages: list[str]) -> None:
65
+ """Add multiple error messages."""
66
+ self.errors.extend(messages)
67
+
68
+ @property
69
+ def has_errors(self) -> bool:
70
+ """Check if any errors have been collected."""
71
+ return bool(self.errors)
72
+
73
+ @property
74
+ def has_info(self) -> bool:
75
+ """Check if any info messages have been collected."""
76
+ return bool(self.info)
77
+
78
+ def format_errors(self) -> str:
79
+ """Format errors as a bulleted list."""
80
+ return "\n".join(f"[red]✗[/red] {error}" for error in self.errors)
81
+
82
+ def format_info(self) -> str:
83
+ """Format info messages as a bulleted list."""
84
+ return "\n".join(f"[yellow]i[/yellow] {msg}" for msg in self.info)
85
+
86
+ def __str__(self) -> str:
87
+ """Format errors as a bulleted list."""
88
+ return self.format_errors()
89
+
90
+
91
+ def load_yaml_file(
92
+ path: Path, collector: ValidationErrorCollector
93
+ ) -> dict[str, Any] | None:
94
+ """Load and parse a YAML file, collecting any errors.
95
+
96
+ Returns a dict if successful, None otherwise.
97
+ Validates that the YAML contains a mapping (dict) at the top level.
98
+ """
99
+ if not path.is_file():
100
+ collector.add(f"Missing YAML file: {path}")
101
+ return None
102
+
103
+ try:
104
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
105
+ except yaml.YAMLError as exc:
106
+ collector.add(f"Invalid YAML syntax in {path}: {exc}")
107
+ return None
108
+
109
+ if data is None:
110
+ collector.add(f"YAML file is empty: {path}")
111
+ return None
112
+
113
+ if not isinstance(data, dict):
114
+ collector.add(
115
+ f"{path} must contain a YAML mapping at the top level, got {type(data).__name__}"
116
+ )
117
+ return None
118
+
119
+ return data
120
+
121
+
122
+ def validate_output_format(
123
+ output_format: str | None,
124
+ allow_txt: bool = False,
125
+ allow_yaml: bool = False,
126
+ allow_csv: bool = True,
127
+ allow_table: bool = False,
128
+ ) -> None:
129
+ """Validate the output format parameter.
130
+
131
+ Args:
132
+ output_format: The output format to validate
133
+ allow_txt: Whether to allow 'txt' format (for requirements files)
134
+ allow_yaml: Whether to allow 'yaml' format
135
+ allow_csv: Whether to allow 'csv' format (default: True)
136
+ allow_table: Whether to allow 'table' format (for human-readable tables)
137
+
138
+ Raises:
139
+ typer.Exit: If the format is invalid
140
+ """
141
+ valid_formats = ["json"]
142
+ if allow_csv:
143
+ valid_formats.append("csv")
144
+ if allow_txt:
145
+ valid_formats.append("txt")
146
+ if allow_yaml:
147
+ valid_formats.append("yaml")
148
+ if allow_table:
149
+ valid_formats.append("table")
150
+
151
+ if output_format is not None and output_format not in valid_formats:
152
+ formats_str = "', '".join(valid_formats)
153
+ console.print(
154
+ f"[red]Error:[/red] Invalid output format '{output_format}'. "
155
+ f"Must be '{formats_str}'."
156
+ )
157
+ raise typer.Exit(code=1)
158
+
159
+
160
+ def validate_txt_only_format(output_format: str | None) -> None:
161
+ """Validate that output format is 'txt' if specified.
162
+
163
+ Args:
164
+ output_format: The output format to validate
165
+
166
+ Raises:
167
+ typer.Exit: If the format is not 'txt'
168
+ """
169
+ if output_format is not None and output_format != "txt":
170
+ console.print(
171
+ f"[red]Error:[/red] Invalid output format '{output_format}'. Must be 'txt'."
172
+ )
173
+ raise typer.Exit(code=1)
174
+
175
+
176
+ def output_data(
177
+ data: list[dict[str, Any]],
178
+ headers: list[str],
179
+ output_format: str | None,
180
+ output_file: Path | None,
181
+ table_title: str,
182
+ ) -> None:
183
+ """Output data in the specified format.
184
+
185
+ Args:
186
+ data: List of dictionaries containing the data rows
187
+ headers: List of column headers
188
+ output_format: Output format ('csv', 'json', 'yaml', or None for table)
189
+ output_file: Optional file path to write output to
190
+ table_title: Title for the table output
191
+ """
192
+ if output_format == "json":
193
+ json_output = json.dumps(data, indent=2)
194
+ if output_file:
195
+ output_file.write_text(json_output)
196
+ console.print(f"[green]Output written to {output_file}[/green]")
197
+ else:
198
+ console.print(json_output)
199
+ elif output_format == "yaml":
200
+ yaml_output = yaml.dump(data, default_flow_style=False, sort_keys=False)
201
+ if output_file:
202
+ output_file.write_text(yaml_output)
203
+ console.print(f"[green]Output written to {output_file}[/green]")
204
+ else:
205
+ console.print(yaml_output)
206
+ elif output_format == "csv":
207
+ # Write CSV to string first
208
+ csv_buffer = io.StringIO()
209
+ writer = csv.DictWriter(csv_buffer, fieldnames=headers)
210
+ writer.writeheader()
211
+ writer.writerows(data)
212
+ csv_output = csv_buffer.getvalue()
213
+
214
+ if output_file:
215
+ output_file.write_text(csv_output)
216
+ console.print(f"[green]Output written to {output_file}[/green]")
217
+ else:
218
+ console.print(csv_output)
219
+ else:
220
+ # Default table output
221
+ if output_file:
222
+ console.print(
223
+ "[yellow]Warning: --output-file is ignored for table output[/yellow]"
224
+ )
225
+
226
+ console.print(f"\n[bold]{table_title}[/bold]\n")
227
+ table = Table(show_header=True, header_style="bold cyan")
228
+
229
+ # Add columns with appropriate styling
230
+ for header in headers:
231
+ if "package" in header.lower() and "nexus" in header.lower():
232
+ table.add_column(header, style="cyan")
233
+ elif "package" in header.lower():
234
+ table.add_column(header, style="yellow")
235
+ elif "experiment" in header.lower():
236
+ table.add_column(header, style="white")
237
+ else:
238
+ table.add_column(header)
239
+
240
+ # Add rows
241
+ for row in data:
242
+ table.add_row(*[str(row[h]) for h in headers])
243
+
244
+ console.print(table)
245
+
246
+
247
+ def collect_benchmark_data(
248
+ packages_root: Path,
249
+ warn_on_error: bool = False,
250
+ ) -> dict[str, dict[str, list[str]]]:
251
+ """Collect benchmark package data from all nexus packages.
252
+
253
+ Args:
254
+ packages_root: Root directory containing package directories
255
+ warn_on_error: If True, print warnings to stderr for failed loads
256
+
257
+ Returns:
258
+ Dictionary mapping benchmark_package -> experiment_id -> [nexus_package_names]
259
+ """
260
+ # Structure: benchmark_package -> experiment_id -> list of nexus packages
261
+ benchmark_data: dict[str, dict[str, list[str]]] = {}
262
+
263
+ for package_dir in packages_root.iterdir():
264
+ if not package_dir.is_dir() or package_dir.name.startswith("."):
265
+ continue
266
+
267
+ nexus_yaml_path = package_dir / "nexus.yaml"
268
+ if not nexus_yaml_path.exists():
269
+ continue
270
+
271
+ collector = ValidationErrorCollector()
272
+ nexus_data = load_yaml_file(nexus_yaml_path, collector)
273
+ if nexus_data is None or collector.has_errors:
274
+ if warn_on_error:
275
+ error_console = Console(stderr=True)
276
+ error_console.print(
277
+ f"[yellow]Warning:[/yellow] Skipping {package_dir.name}: "
278
+ f"Failed to load nexus.yaml"
279
+ )
280
+ continue
281
+
282
+ try:
283
+ package_config = AlgorithmNexusPackageConfig.model_validate(nexus_data)
284
+ package_name = package_config.package.name
285
+
286
+ if package_config.package.benchmark_packages:
287
+ for bench_pkg in package_config.package.benchmark_packages:
288
+ req_spec = bench_pkg.requirement_specifier
289
+ if req_spec not in benchmark_data:
290
+ benchmark_data[req_spec] = {}
291
+
292
+ for exp_id in bench_pkg.experiments:
293
+ if exp_id not in benchmark_data[req_spec]:
294
+ benchmark_data[req_spec][exp_id] = []
295
+ benchmark_data[req_spec][exp_id].append(package_name)
296
+ except Exception as e: # noqa: S112
297
+ if warn_on_error:
298
+ error_console = Console(stderr=True)
299
+ error_console.print(
300
+ f"[yellow]Warning:[/yellow] Skipping {package_dir.name}: "
301
+ f"Invalid package configuration ({type(e).__name__})"
302
+ )
303
+ continue
304
+
305
+ return benchmark_data
306
+
307
+
308
+ def try_load_package_config(
309
+ package_dir: Path,
310
+ warn_on_error: bool = False,
311
+ ) -> AlgorithmNexusPackageConfig | None:
312
+ """Attempt to load and validate a package configuration.
313
+
314
+ Args:
315
+ package_dir: Directory containing the nexus.yaml file
316
+ warn_on_error: If True, print warnings to stderr for failed loads
317
+
318
+ Returns:
319
+ Validated AlgorithmNexusPackageConfig if successful, None otherwise
320
+ """
321
+ nexus_yaml_path = package_dir / "nexus.yaml"
322
+ if not nexus_yaml_path.exists():
323
+ return None
324
+
325
+ collector = ValidationErrorCollector()
326
+ nexus_data = load_yaml_file(nexus_yaml_path, collector)
327
+ if nexus_data is None or collector.has_errors:
328
+ if warn_on_error:
329
+ error_console = Console(stderr=True)
330
+ error_console.print(
331
+ f"[yellow]Warning:[/yellow] Skipping {package_dir.name}: "
332
+ f"Failed to load nexus.yaml"
333
+ )
334
+ return None
335
+
336
+ try:
337
+ return AlgorithmNexusPackageConfig.model_validate(nexus_data)
338
+ except Exception as e: # noqa: S112
339
+ if warn_on_error:
340
+ error_console = Console(stderr=True)
341
+ error_console.print(
342
+ f"[yellow]Warning:[/yellow] Skipping {package_dir.name}: "
343
+ f"Invalid package configuration ({type(e).__name__})"
344
+ )
345
+ return None
346
+
347
+
348
+ def find_package_config(
349
+ nexus_package: str,
350
+ packages_root: Path,
351
+ ) -> tuple[Path, AlgorithmNexusPackageConfig] | None:
352
+ """Find and load a package configuration by name.
353
+
354
+ Args:
355
+ nexus_package: Name of the Nexus package to find
356
+ packages_root: Root directory containing package directories
357
+
358
+ Returns:
359
+ Tuple of (package_dir, package_config) if found, None otherwise
360
+ """
361
+ for pkg_dir in packages_root.iterdir():
362
+ if not pkg_dir.is_dir() or pkg_dir.name.startswith("."):
363
+ continue
364
+
365
+ package_config = try_load_package_config(pkg_dir)
366
+ if package_config and package_config.package.name == nexus_package:
367
+ return (pkg_dir, package_config)
368
+
369
+ return None
370
+
371
+
372
+ def output_requirements_txt(
373
+ requirements: list[str],
374
+ output_file: Path | None,
375
+ ) -> None:
376
+ """Output requirements in txt format.
377
+
378
+ Args:
379
+ requirements: List of requirement specifiers
380
+ output_file: Optional file path to write output to
381
+ """
382
+ txt_output = "\n".join(requirements) + "\n"
383
+
384
+ if output_file:
385
+ output_file.write_text(txt_output)
386
+ console.print(f"[green]Requirements written to {output_file}[/green]")
387
+ else:
388
+ console.print(txt_output, end="")
389
+
390
+
391
+ def output_benchmark_requirements_table(
392
+ benchmark_packages: list[Any],
393
+ nexus_package: str,
394
+ output_format: str | None,
395
+ output_file: Path | None,
396
+ ) -> None:
397
+ """Output benchmark requirements in table format.
398
+
399
+ Args:
400
+ benchmark_packages: List of BenchmarkPackage objects
401
+ nexus_package: Name of the Nexus package
402
+ output_format: Output format (csv, json, or None for table)
403
+ output_file: Optional file path to write output to
404
+ """
405
+ headers = ["Requirement Specifier"]
406
+
407
+ data = [
408
+ {"Requirement Specifier": bench_pkg.requirement_specifier}
409
+ for bench_pkg in benchmark_packages
410
+ ]
411
+
412
+ output_data(
413
+ data=data,
414
+ headers=headers,
415
+ output_format=output_format,
416
+ output_file=output_file,
417
+ table_title=f"Benchmark Requirements for Nexus Package: {nexus_package}",
418
+ )
419
+
420
+
421
+ def format_results(results: dict[str, Any], fmt: str) -> str:
422
+ """Format benchmark results as JSON or YAML string.
423
+
424
+ Args:
425
+ results: Dictionary containing results to format
426
+ fmt: Output format ('json' or 'yaml')
427
+
428
+ Returns:
429
+ Formatted string representation of results
430
+ """
431
+ if fmt == "json":
432
+ return json.dumps(results, indent=2)
433
+ else: # yaml
434
+ return yaml.safe_dump(results, default_flow_style=False, sort_keys=False)
435
+
436
+
437
+ def get_status_color(status: str) -> str:
438
+ """Get the color name for a given status.
439
+
440
+ Args:
441
+ status: Status string (e.g., 'success', 'failed', 'started')
442
+
443
+ Returns:
444
+ Color name for Rich console formatting
445
+ """
446
+ status_colors = {
447
+ "success": "green",
448
+ "started": "cyan",
449
+ "failed": "red",
450
+ }
451
+ return status_colors.get(status, "yellow")
452
+
453
+
454
+ def get_status_display(status: str) -> str:
455
+ """Get color-coded status display string.
456
+
457
+ Args:
458
+ status: Status string (e.g., 'success', 'failed', 'started')
459
+
460
+ Returns:
461
+ Rich-formatted status string with color
462
+ """
463
+ color = get_status_color(status)
464
+ return f"[{color}]{status}[/{color}]"
465
+
466
+
467
+ def determine_output_format(
468
+ output_format: str | None,
469
+ output_file: Path | None,
470
+ default: str = "table",
471
+ ) -> str:
472
+ """Determine the effective output format from CLI options.
473
+
474
+ Precedence: explicit --output-format > file extension > default.
475
+
476
+ Args:
477
+ output_format: Explicitly requested format, or None.
478
+ output_file: Output file path (used to infer format from extension), or None.
479
+ default: Fallback format when neither of the above apply.
480
+
481
+ Returns:
482
+ Resolved format string (e.g. 'json', 'yaml', 'table').
483
+ """
484
+ if output_format:
485
+ return output_format
486
+ if output_file and output_file.suffix in (".yaml", ".yml"):
487
+ return "yaml"
488
+ if output_file:
489
+ return "json"
490
+ return default
491
+
492
+
493
+ def write_results_to_file(results: dict[str, Any], output_file: Path, fmt: str) -> None:
494
+ """Write benchmark results to a file in the specified format.
495
+
496
+ Args:
497
+ results: Dictionary containing results to write
498
+ output_file: Path to the output file
499
+ fmt: Output format ('json' or 'yaml')
500
+ """
501
+ output_file.write_text(format_results(results, fmt))
502
+ console.print(f"\nResults written to: {output_file}")
503
+
504
+
505
+ def print_structured_results(results: dict[str, Any], fmt: str) -> None:
506
+ """Print benchmark results in structured format (JSON or YAML).
507
+
508
+ Args:
509
+ results: Dictionary containing results to print
510
+ fmt: Output format ('json' or 'yaml')
511
+ """
512
+ console.print() # Blank line before output
513
+ console.print(format_results(results, fmt))
514
+
515
+
516
+ def print_results_table(
517
+ rows: list[dict[str, Any]],
518
+ title: str,
519
+ details_column: str,
520
+ ) -> None:
521
+ """Print a results table with Instance, Status, and a details column.
522
+
523
+ Each row dict may contain:
524
+ - ``instance_path`` (str): shown in the Instance column.
525
+ - ``status`` (str): coloured in the Status column.
526
+ - ``details`` (str): pre-formatted text for the details column.
527
+
528
+ Args:
529
+ rows: List of row dicts as described above.
530
+ title: Table title.
531
+ details_column: Header label for the third column.
532
+ """
533
+ table = Table(title=f"\n{title}", show_header=True)
534
+ table.add_column("Instance", style="cyan")
535
+ table.add_column("Status", style="bold")
536
+ table.add_column(details_column)
537
+
538
+ for row in rows:
539
+ status = row.get("status", "unknown")
540
+ color = get_status_color(status)
541
+ status_display = f"[{color}]{status}[/{color}]"
542
+ table.add_row(
543
+ row.get("instance_path", "Unknown"),
544
+ status_display,
545
+ row.get("details", "-"),
546
+ )
547
+
548
+ console.print(table)
549
+
550
+
551
+ def print_human_readable_results(results: dict[str, Any]) -> None:
552
+ """Print benchmark execution results as a table.
553
+
554
+ Args:
555
+ results: Dictionary containing results with 'instances' key
556
+ """
557
+ instances = results.get("instances")
558
+ if not instances:
559
+ console.print("[yellow]No benchmark instances found[/yellow]")
560
+ return
561
+
562
+ rows = []
563
+ for instance in instances:
564
+ details = []
565
+ if instance.get("message"):
566
+ details.append(instance["message"])
567
+ if instance.get("space_id"):
568
+ details.append(f"Space ID: {instance['space_id']}")
569
+ if instance.get("operation_id"):
570
+ details.append(f"Operation ID: {instance['operation_id']}")
571
+ if instance.get("ray_job_id"):
572
+ details.append(f"Ray Job ID: {instance['ray_job_id']}")
573
+ rows.append(
574
+ {
575
+ "instance_path": instance.get("instance_path", "Unknown"),
576
+ "status": instance.get("status", "unknown"),
577
+ "details": "\n".join(details) if details else "-",
578
+ }
579
+ )
580
+
581
+ print_results_table(rows, title="Execution Results", details_column="Details")
582
+
583
+
584
+ # Made with Bob