unitysvc-services 0.1.24__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 (37) hide show
  1. unitysvc_services/__init__.py +4 -0
  2. unitysvc_services/api.py +421 -0
  3. unitysvc_services/cli.py +23 -0
  4. unitysvc_services/format_data.py +140 -0
  5. unitysvc_services/interactive_prompt.py +1132 -0
  6. unitysvc_services/list.py +216 -0
  7. unitysvc_services/models/__init__.py +71 -0
  8. unitysvc_services/models/base.py +1375 -0
  9. unitysvc_services/models/listing_data.py +118 -0
  10. unitysvc_services/models/listing_v1.py +56 -0
  11. unitysvc_services/models/provider_data.py +79 -0
  12. unitysvc_services/models/provider_v1.py +54 -0
  13. unitysvc_services/models/seller_data.py +120 -0
  14. unitysvc_services/models/seller_v1.py +42 -0
  15. unitysvc_services/models/service_data.py +114 -0
  16. unitysvc_services/models/service_v1.py +81 -0
  17. unitysvc_services/populate.py +207 -0
  18. unitysvc_services/publisher.py +1628 -0
  19. unitysvc_services/py.typed +0 -0
  20. unitysvc_services/query.py +688 -0
  21. unitysvc_services/scaffold.py +1103 -0
  22. unitysvc_services/schema/base.json +777 -0
  23. unitysvc_services/schema/listing_v1.json +1286 -0
  24. unitysvc_services/schema/provider_v1.json +952 -0
  25. unitysvc_services/schema/seller_v1.json +379 -0
  26. unitysvc_services/schema/service_v1.json +1306 -0
  27. unitysvc_services/test.py +965 -0
  28. unitysvc_services/unpublisher.py +505 -0
  29. unitysvc_services/update.py +287 -0
  30. unitysvc_services/utils.py +533 -0
  31. unitysvc_services/validator.py +731 -0
  32. unitysvc_services-0.1.24.dist-info/METADATA +184 -0
  33. unitysvc_services-0.1.24.dist-info/RECORD +37 -0
  34. unitysvc_services-0.1.24.dist-info/WHEEL +5 -0
  35. unitysvc_services-0.1.24.dist-info/entry_points.txt +3 -0
  36. unitysvc_services-0.1.24.dist-info/licenses/LICENSE +21 -0
  37. unitysvc_services-0.1.24.dist-info/top_level.txt +1 -0
@@ -0,0 +1,965 @@
1
+ """Test command group - test code examples with upstream credentials."""
2
+
3
+ import fnmatch
4
+ import os
5
+ import subprocess
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from .models.base import DocumentCategoryEnum, UpstreamStatusEnum
15
+ from .utils import determine_interpreter, find_files_by_schema, render_template_file
16
+
17
+ app = typer.Typer(help="Test code examples with upstream credentials")
18
+ console = Console()
19
+
20
+
21
+ def extract_service_directory_name(listing_file: Path) -> str | None:
22
+ """Extract service directory name from listing file path.
23
+
24
+ The service directory is the directory immediately after "services" directory.
25
+ For example: .../services/llama-3-1-405b-instruct/listing-svcreseller.json
26
+ Returns: "llama-3-1-405b-instruct"
27
+
28
+ Args:
29
+ listing_file: Path to the listing file
30
+
31
+ Returns:
32
+ Service directory name or None if not found
33
+ """
34
+ parts = listing_file.parts
35
+ try:
36
+ services_idx = parts.index("services")
37
+ # Service directory is immediately after "services"
38
+ if services_idx + 1 < len(parts):
39
+ return parts[services_idx + 1]
40
+ except (ValueError, IndexError):
41
+ pass
42
+ return None
43
+
44
+
45
+ def extract_code_examples_from_listing(listing_data: dict[str, Any], listing_file: Path) -> list[dict[str, Any]]:
46
+ """Extract code example documents from a listing file.
47
+
48
+ Args:
49
+ listing_data: Parsed listing data
50
+ listing_file: Path to the listing file for resolving relative paths
51
+
52
+ Returns:
53
+ List of code example documents with resolved file paths
54
+ """
55
+ code_examples = []
56
+
57
+ # Get service name for display - use directory name as fallback
58
+ service_name = listing_data.get("service_name")
59
+ if not service_name:
60
+ # Use service directory name as fallback
61
+ service_name = extract_service_directory_name(listing_file) or "unknown"
62
+
63
+ # Check user_access_interfaces
64
+ interfaces = listing_data.get("user_access_interfaces", [])
65
+
66
+ for interface in interfaces:
67
+ documents = interface.get("documents", [])
68
+
69
+ for doc in documents:
70
+ # Check if this is a code example document
71
+ category = doc.get("category", "")
72
+ if category == DocumentCategoryEnum.code_example:
73
+ # Resolve file path relative to listing file
74
+ file_path = doc.get("file_path")
75
+ if file_path:
76
+ # Resolve relative path
77
+ absolute_path = (listing_file.parent / file_path).resolve()
78
+
79
+ # Extract meta fields for code examples (expect, requirements, etc.)
80
+ meta = doc.get("meta", {}) or {}
81
+
82
+ code_example = {
83
+ "service_name": service_name,
84
+ "title": doc.get("title", "Untitled"),
85
+ "mime_type": doc.get("mime_type", "python"),
86
+ "file_path": str(absolute_path),
87
+ "listing_data": listing_data, # Full listing data for templates
88
+ "listing_file": listing_file, # Path to listing file for loading related data
89
+ "interface": interface, # Interface data for templates (base_url, routing_key, etc.)
90
+ "expect": meta.get("expect"), # Expected output substring for validation (from meta)
91
+ "requirements": meta.get("requirements"), # Required packages (from meta)
92
+ }
93
+ code_examples.append(code_example)
94
+
95
+ return code_examples
96
+
97
+
98
+ def load_related_data(listing_file: Path) -> dict[str, Any]:
99
+ """Load offering, provider, and seller data related to a listing file.
100
+
101
+ Args:
102
+ listing_file: Path to the listing file
103
+
104
+ Returns:
105
+ Dictionary with offering, provider, and seller data (may be empty dicts if not found)
106
+ """
107
+ result: dict[str, Any] = {
108
+ "offering": {},
109
+ "provider": {},
110
+ "seller": {},
111
+ }
112
+
113
+ try:
114
+ # Find offering file (service.json in same directory as listing) using find_files_by_schema
115
+ offering_results = find_files_by_schema(listing_file.parent, "service_v1")
116
+ if offering_results:
117
+ # Unpack tuple: (file_path, format, data)
118
+ # Data is already loaded by find_files_by_schema
119
+ _file_path, _format, offering_data = offering_results[0]
120
+ result["offering"] = offering_data
121
+ else:
122
+ console.print(f"[yellow]Warning: No service_v1 file found in {listing_file.parent}[/yellow]")
123
+
124
+ # Find provider file using find_files_by_schema
125
+ # Structure: data/{provider}/services/{service}/listing.json
126
+ # Go up to provider directory (2 levels up from listing)
127
+ provider_dir = listing_file.parent.parent.parent
128
+ provider_results = find_files_by_schema(provider_dir, "provider_v1")
129
+ if provider_results:
130
+ # Unpack tuple: (file_path, format, data)
131
+ # Data is already loaded by find_files_by_schema
132
+ _file_path, _format, provider_data = provider_results[0]
133
+ result["provider"] = provider_data
134
+ else:
135
+ console.print(f"[yellow]Warning: No provider_v1 file found in {provider_dir}[/yellow]")
136
+
137
+ # Find seller file using find_files_by_schema
138
+ # Go up to data directory (3 levels up from listing)
139
+ data_dir = listing_file.parent.parent.parent.parent
140
+ seller_results = find_files_by_schema(data_dir, "seller_v1")
141
+ if seller_results:
142
+ # Unpack tuple: (file_path, format, data)
143
+ # Data is already loaded by find_files_by_schema
144
+ _file_path, _format, seller_data = seller_results[0]
145
+ result["seller"] = seller_data
146
+ else:
147
+ console.print(f"[yellow]Warning: No seller_v1 file found in {data_dir}[/yellow]")
148
+
149
+ except Exception as e:
150
+ console.print(f"[yellow]Warning: Failed to load related data: {e}[/yellow]")
151
+
152
+ return result
153
+
154
+
155
+ def load_provider_credentials(listing_file: Path) -> dict[str, str] | None:
156
+ """Load API key and endpoint from service offering file.
157
+
158
+ Args:
159
+ listing_file: Path to the listing file (used to locate the service offering)
160
+
161
+ Returns:
162
+ Dictionary with api_key and base_url, or None if not found
163
+ """
164
+ try:
165
+ # Load related data including the offering
166
+ related_data = load_related_data(listing_file)
167
+ offering = related_data.get("offering", {})
168
+
169
+ if not offering:
170
+ return None
171
+
172
+ # Extract credentials from upstream_access_interface
173
+ upstream_access = offering.get("upstream_access_interface", {})
174
+ api_key = upstream_access.get("api_key")
175
+ base_url = upstream_access.get("base_url")
176
+
177
+ if api_key and base_url:
178
+ return {
179
+ "api_key": str(api_key),
180
+ "base_url": str(base_url),
181
+ }
182
+ except Exception as e:
183
+ console.print(f"[yellow]Warning: Failed to load service credentials: {e}[/yellow]")
184
+
185
+ return None
186
+
187
+
188
+ def execute_code_example(code_example: dict[str, Any], credentials: dict[str, str]) -> dict[str, Any]:
189
+ """Execute a code example script with upstream credentials.
190
+
191
+ Args:
192
+ code_example: Code example metadata with file_path and listing_data
193
+ credentials: Dictionary with api_key and base_url
194
+
195
+ Returns:
196
+ Result dictionary with success, exit_code, stdout, stderr, rendered_content, file_suffix
197
+ """
198
+ result: dict[str, Any] = {
199
+ "success": False,
200
+ "exit_code": None,
201
+ "error": None,
202
+ "stdout": None,
203
+ "stderr": None,
204
+ "rendered_content": None,
205
+ "file_suffix": None,
206
+ "listing_file": None,
207
+ "actual_filename": None,
208
+ }
209
+
210
+ file_path = code_example.get("file_path")
211
+ if not file_path or not Path(file_path).exists():
212
+ result["error"] = f"File not found: {file_path}"
213
+ return result
214
+
215
+ try:
216
+ # Get original file extension
217
+ original_path = Path(file_path)
218
+
219
+ # Load related data for template rendering (if needed)
220
+ listing_data = code_example.get("listing_data", {})
221
+ listing_file = code_example.get("listing_file")
222
+ related_data = {}
223
+ if listing_file:
224
+ related_data = load_related_data(Path(listing_file))
225
+
226
+ # Render template if applicable (handles both .j2 and non-.j2 files)
227
+ try:
228
+ file_content, actual_filename = render_template_file(
229
+ original_path,
230
+ listing=listing_data,
231
+ offering=related_data.get("offering", {}),
232
+ provider=related_data.get("provider", {}),
233
+ seller=related_data.get("seller", {}),
234
+ interface=code_example.get("interface", {}),
235
+ )
236
+ except Exception as e:
237
+ result["error"] = f"Template rendering failed: {str(e)}"
238
+ return result
239
+
240
+ # Get file suffix from the actual filename (after .j2 stripping if applicable)
241
+ file_suffix = Path(actual_filename).suffix or ".txt"
242
+
243
+ # Store rendered content and file suffix for later use (e.g., writing failed tests)
244
+ result["rendered_content"] = file_content
245
+ result["file_suffix"] = file_suffix
246
+ result["listing_file"] = listing_file
247
+ result["actual_filename"] = actual_filename
248
+
249
+ # Determine interpreter to use (using shared utility function)
250
+ interpreter_cmd, error = determine_interpreter(file_content, file_suffix)
251
+ if error:
252
+ result["error"] = error
253
+ return result
254
+
255
+ # At this point, interpreter_cmd is guaranteed to be a string (error check above)
256
+ assert interpreter_cmd is not None, "interpreter_cmd should not be None after error check"
257
+
258
+ # Prepare environment variables
259
+ env = os.environ.copy()
260
+ env["API_KEY"] = credentials["api_key"]
261
+ env["BASE_URL"] = credentials["base_url"]
262
+
263
+ # Write script to temporary file with original extension
264
+ with tempfile.NamedTemporaryFile(mode="w", suffix=file_suffix, delete=False) as temp_file:
265
+ temp_file.write(file_content)
266
+ temp_file_path = temp_file.name
267
+
268
+ try:
269
+ # Execute the script (interpreter availability already verified)
270
+ process = subprocess.run(
271
+ [interpreter_cmd, temp_file_path],
272
+ env=env,
273
+ capture_output=True,
274
+ text=True,
275
+ timeout=30,
276
+ )
277
+
278
+ result["exit_code"] = process.returncode
279
+ result["stdout"] = process.stdout
280
+ result["stderr"] = process.stderr
281
+
282
+ # Determine if test passed
283
+ # Test passes if: exit_code == 0 AND (expect is None OR expect in stdout)
284
+ expected_output = code_example.get("expect")
285
+
286
+ if process.returncode != 0:
287
+ # Failed: non-zero exit code
288
+ result["success"] = False
289
+ result["error"] = f"Script exited with code {process.returncode}. stderr: {process.stderr[:200]}"
290
+ elif expected_output and expected_output not in process.stdout:
291
+ # Failed: exit code is 0 but expected string not found in output
292
+ result["success"] = False
293
+ result["error"] = (
294
+ f"Output validation failed: expected substring '{expected_output}' "
295
+ f"not found in stdout. stdout: {process.stdout[:200]}"
296
+ )
297
+ else:
298
+ # Passed: exit code is 0 AND (no expect field OR expected string found)
299
+ result["success"] = True
300
+
301
+ finally:
302
+ try:
303
+ os.unlink(temp_file_path)
304
+ except Exception:
305
+ pass
306
+
307
+ except subprocess.TimeoutExpired:
308
+ result["error"] = "Script execution timeout (30 seconds)"
309
+ except Exception as e:
310
+ result["error"] = f"Error executing script: {str(e)}"
311
+
312
+ return result
313
+
314
+
315
+ def update_offering_override_status(listing_file: Path, status: UpstreamStatusEnum | None) -> None:
316
+ """Update or remove the status field in the offering override file.
317
+
318
+ Args:
319
+ listing_file: Path to the listing file (offering is in same directory)
320
+ status: Status to set (e.g., UpstreamStatusEnum.deprecated), or None to remove status field
321
+ """
322
+ import json
323
+
324
+ try:
325
+ # Find the offering file (service.json) in the same directory
326
+ offering_results = find_files_by_schema(listing_file.parent, "service_v1")
327
+ if not offering_results:
328
+ console.print(f"[yellow]⚠ No service offering file found in {listing_file.parent}[/yellow]")
329
+ return
330
+
331
+ # Get the base offering file path
332
+ offering_file_path, offering_format, _offering_data = offering_results[0]
333
+
334
+ # Construct override file path
335
+ override_path = offering_file_path.with_stem(f"{offering_file_path.stem}.override")
336
+
337
+ # Load existing override file if it exists
338
+ if override_path.exists():
339
+ try:
340
+ with open(override_path, encoding="utf-8") as f:
341
+ if offering_format == "json":
342
+ override_data = json.load(f)
343
+ else: # toml
344
+ import tomli
345
+
346
+ override_data = tomli.loads(f.read())
347
+ except Exception as e:
348
+ console.print(f"[yellow]⚠ Failed to read override file {override_path}: {e}[/yellow]")
349
+ override_data = {}
350
+ else:
351
+ override_data = {}
352
+
353
+ # Update or remove upstream_status field
354
+ if status is None:
355
+ # Remove upstream_status field if it exists and equals deprecated
356
+ if override_data.get("upstream_status") == UpstreamStatusEnum.deprecated:
357
+ del override_data["upstream_status"]
358
+ console.print(" [dim]→ Removed deprecated upstream_status from override file[/dim]")
359
+ else:
360
+ # Set upstream_status field
361
+ override_data["upstream_status"] = status.value
362
+ console.print(f" [dim]→ Set upstream_status to {status.value} in override file[/dim]")
363
+
364
+ # Write override file (or delete if empty)
365
+ if override_data:
366
+ # Write the override file
367
+ with open(override_path, "w", encoding="utf-8") as f:
368
+ if offering_format == "json":
369
+ json.dump(override_data, f, indent=2)
370
+ f.write("\n") # Add trailing newline
371
+ else: # toml
372
+ import tomli_w
373
+
374
+ f.write(tomli_w.dumps(override_data))
375
+ console.print(f" [dim]→ Updated override file: {override_path}[/dim]")
376
+ else:
377
+ # Delete override file if it's now empty
378
+ if override_path.exists():
379
+ override_path.unlink()
380
+ console.print(f" [dim]→ Removed empty override file: {override_path}[/dim]")
381
+
382
+ except Exception as e:
383
+ console.print(f"[yellow]⚠ Failed to update override file: {e}[/yellow]")
384
+
385
+
386
+ @app.command("list")
387
+ def list_code_examples(
388
+ data_dir: Path | None = typer.Argument(
389
+ None,
390
+ help="Directory containing provider data files (default: current directory)",
391
+ ),
392
+ provider_name: str | None = typer.Option(
393
+ None,
394
+ "--provider",
395
+ "-p",
396
+ help="Only list code examples for a specific provider",
397
+ ),
398
+ services: str | None = typer.Option(
399
+ None,
400
+ "--services",
401
+ "-s",
402
+ help="Comma-separated list of service patterns (supports wildcards, e.g., 'llama*,gpt-4*')",
403
+ ),
404
+ ):
405
+ """List available code examples without running them.
406
+
407
+ This command scans for code examples in listing files and displays them in a table
408
+ with file paths shown relative to the data directory.
409
+
410
+ Useful for exploring available examples before running tests.
411
+
412
+ Examples:
413
+ # List all code examples
414
+ usvc test list
415
+
416
+ # List for specific provider
417
+ usvc test list --provider fireworks
418
+
419
+ # List for specific services
420
+ usvc test list --services "llama*,gpt-4*"
421
+ """
422
+ # Set data directory
423
+ if data_dir is None:
424
+ data_dir = Path.cwd()
425
+
426
+ if not data_dir.is_absolute():
427
+ data_dir = Path.cwd() / data_dir
428
+
429
+ if not data_dir.exists():
430
+ console.print(
431
+ f"[red]✗[/red] Data directory not found: {data_dir}",
432
+ style="bold red",
433
+ )
434
+ raise typer.Exit(code=1)
435
+
436
+ # Parse service patterns if provided
437
+ service_patterns: list[str] = []
438
+ if services:
439
+ service_patterns = [s.strip() for s in services.split(",") if s.strip()]
440
+
441
+ console.print(f"[blue]Scanning for code examples in:[/blue] {data_dir}\n")
442
+
443
+ # Find all provider files
444
+ provider_results = find_files_by_schema(data_dir, "provider_v1")
445
+ provider_names: set[str] = set()
446
+
447
+ for _provider_file, _format, provider_data in provider_results:
448
+ prov_name = provider_data.get("name", "unknown")
449
+ if not provider_name or prov_name == provider_name:
450
+ provider_names.add(prov_name)
451
+
452
+ if not provider_names:
453
+ console.print("[yellow]No providers found.[/yellow]")
454
+ raise typer.Exit(code=0)
455
+
456
+ # Find all listing files
457
+ listing_results = find_files_by_schema(data_dir, "listing_v1")
458
+
459
+ if not listing_results:
460
+ console.print("[yellow]No listing files found.[/yellow]")
461
+ raise typer.Exit(code=0)
462
+
463
+ # Extract code examples from all listings
464
+ all_code_examples: list[tuple[dict[str, Any], str, str]] = []
465
+
466
+ for listing_file, _format, listing_data in listing_results:
467
+ # Determine provider for this listing
468
+ parts = listing_file.parts
469
+ prov_name = "unknown"
470
+
471
+ try:
472
+ services_idx = parts.index("services")
473
+ if services_idx > 0:
474
+ prov_name = parts[services_idx - 1]
475
+ except (ValueError, IndexError):
476
+ pass
477
+
478
+ # Skip if provider filter is set and doesn't match
479
+ if provider_name and prov_name != provider_name:
480
+ continue
481
+
482
+ # Skip if provider not in our list
483
+ if prov_name not in provider_names:
484
+ continue
485
+
486
+ # Filter by service directory name if patterns are provided
487
+ if service_patterns:
488
+ service_dir = extract_service_directory_name(listing_file)
489
+ if not service_dir:
490
+ continue
491
+
492
+ # Check if service matches any of the patterns
493
+ matches = any(fnmatch.fnmatch(service_dir, pattern) for pattern in service_patterns)
494
+ if not matches:
495
+ continue
496
+
497
+ code_examples = extract_code_examples_from_listing(listing_data, listing_file)
498
+
499
+ for example in code_examples:
500
+ # Get file extension (strip .j2 if present to show actual type)
501
+ file_path = example.get("file_path", "")
502
+ path = Path(file_path)
503
+ # If it's a .j2 template, get the extension before .j2
504
+ if path.suffix == ".j2":
505
+ file_ext = Path(path.stem).suffix or "unknown"
506
+ else:
507
+ file_ext = path.suffix or "unknown"
508
+ all_code_examples.append((example, prov_name, file_ext))
509
+
510
+ if not all_code_examples:
511
+ console.print("[yellow]No code examples found.[/yellow]")
512
+ raise typer.Exit(code=0)
513
+
514
+ # Display results in table
515
+ table = Table(title="Available Code Examples")
516
+ table.add_column("Service", style="cyan")
517
+ table.add_column("Provider", style="blue")
518
+ table.add_column("Title", style="white")
519
+ table.add_column("Type", style="magenta")
520
+ table.add_column("File Path", style="dim")
521
+
522
+ for example, prov_name, file_ext in all_code_examples:
523
+ file_path = example.get("file_path", "N/A")
524
+
525
+ # Show path relative to data directory
526
+ if file_path != "N/A":
527
+ try:
528
+ abs_path = Path(file_path).resolve()
529
+ rel_path = abs_path.relative_to(data_dir.resolve())
530
+ file_path = str(rel_path)
531
+ except ValueError:
532
+ # If relative_to fails, just show the path as-is
533
+ file_path = str(file_path)
534
+
535
+ row = [
536
+ example["service_name"],
537
+ prov_name,
538
+ example["title"],
539
+ file_ext,
540
+ file_path,
541
+ ]
542
+
543
+ table.add_row(*row)
544
+
545
+ console.print(table)
546
+ console.print(f"\n[green]Total:[/green] {len(all_code_examples)} code example(s)")
547
+
548
+
549
+ @app.command()
550
+ def run(
551
+ data_dir: Path | None = typer.Argument(
552
+ None,
553
+ help="Directory containing provider data files (default: current directory)",
554
+ ),
555
+ provider_name: str | None = typer.Option(
556
+ None,
557
+ "--provider",
558
+ "-p",
559
+ help="Only test code examples for a specific provider",
560
+ ),
561
+ services: str | None = typer.Option(
562
+ None,
563
+ "--services",
564
+ "-s",
565
+ help="Comma-separated list of service patterns (supports wildcards, e.g., 'llama*,gpt-4*')",
566
+ ),
567
+ test_file: str | None = typer.Option(
568
+ None,
569
+ "--test-file",
570
+ "-t",
571
+ help="Only run a specific test file by filename (e.g., 'code-example.py.j2')",
572
+ ),
573
+ verbose: bool = typer.Option(
574
+ False,
575
+ "--verbose",
576
+ "-v",
577
+ help="Show detailed output including stdout/stderr from scripts",
578
+ ),
579
+ force: bool = typer.Option(
580
+ False,
581
+ "--force",
582
+ "-f",
583
+ help="Force rerun all tests, ignoring existing .out and .err files",
584
+ ),
585
+ fail_fast: bool = typer.Option(
586
+ False,
587
+ "--fail-fast",
588
+ "-x",
589
+ help="Stop testing on first failure",
590
+ ),
591
+ ):
592
+ """Test code examples with upstream API credentials.
593
+
594
+ This command:
595
+ 1. Scans for all listing files (schema: listing_v1)
596
+ 2. Extracts code example documents
597
+ 3. Loads provider credentials from provider.toml
598
+ 4. Skips tests that have existing .out and .err files (unless --force is used)
599
+ 5. Executes each code example with API_KEY and BASE_URL set to upstream values
600
+ 6. Displays test results
601
+
602
+ Examples:
603
+ # Test all code examples
604
+ unitysvc_services test run
605
+
606
+ # Test specific provider
607
+ unitysvc_services test run --provider fireworks
608
+
609
+ # Test specific services (with wildcards)
610
+ unitysvc_services test run --services "llama*,gpt-4*"
611
+
612
+ # Test single service
613
+ unitysvc_services test run --services "llama-3-1-405b-instruct"
614
+
615
+ # Test specific file
616
+ unitysvc_services test run --test-file "code-example.py.j2"
617
+
618
+ # Combine filters
619
+ unitysvc_services test run --provider fireworks --services "llama*"
620
+
621
+ # Show detailed output
622
+ unitysvc_services test run --verbose
623
+
624
+ # Force rerun all tests (ignore existing results)
625
+ unitysvc_services test run --force
626
+
627
+ # Stop on first failure
628
+ unitysvc_services test run --fail-fast
629
+ """
630
+ # Set data directory
631
+ if data_dir is None:
632
+ data_dir = Path.cwd()
633
+
634
+ if not data_dir.is_absolute():
635
+ data_dir = Path.cwd() / data_dir
636
+
637
+ if not data_dir.exists():
638
+ console.print(
639
+ f"[red]✗[/red] Data directory not found: {data_dir}",
640
+ style="bold red",
641
+ )
642
+ raise typer.Exit(code=1)
643
+
644
+ # Parse service patterns if provided
645
+ service_patterns: list[str] = []
646
+ if services:
647
+ service_patterns = [s.strip() for s in services.split(",") if s.strip()]
648
+ console.print(f"[blue]Service filter patterns:[/blue] {', '.join(service_patterns)}\n")
649
+
650
+ # Display test file filter if provided
651
+ if test_file:
652
+ console.print(f"[blue]Test file filter:[/blue] {test_file}\n")
653
+
654
+ console.print(f"[blue]Scanning for listing files in:[/blue] {data_dir}\n")
655
+
656
+ # Find all listing files
657
+ listing_results = find_files_by_schema(data_dir, "listing_v1")
658
+
659
+ if not listing_results:
660
+ console.print("[yellow]No listing files found.[/yellow]")
661
+ raise typer.Exit(code=0)
662
+
663
+ console.print(f"[cyan]Found {len(listing_results)} listing file(s)[/cyan]\n")
664
+
665
+ # Extract code examples from all listings
666
+ all_code_examples: list[tuple[dict[str, Any], str, dict[str, str]]] = []
667
+
668
+ for listing_file, _format, listing_data in listing_results:
669
+ # Determine provider for this listing
670
+ # Provider is the directory name before "services"
671
+ parts = listing_file.parts
672
+ prov_name = "unknown"
673
+
674
+ try:
675
+ services_idx = parts.index("services")
676
+ if services_idx > 0:
677
+ prov_name = parts[services_idx - 1]
678
+ except (ValueError, IndexError):
679
+ pass
680
+
681
+ # Skip if provider filter is set and doesn't match
682
+ if provider_name and prov_name != provider_name:
683
+ continue
684
+
685
+ # Load credentials from service offering for this listing
686
+ credentials = load_provider_credentials(listing_file)
687
+ if not credentials:
688
+ console.print(f"[yellow]⚠ No credentials found for listing: {listing_file}[/yellow]")
689
+ continue
690
+
691
+ # Filter by service directory name if patterns are provided
692
+ if service_patterns:
693
+ service_dir = extract_service_directory_name(listing_file)
694
+ if not service_dir:
695
+ continue
696
+
697
+ # Check if service matches any of the patterns
698
+ matches = any(fnmatch.fnmatch(service_dir, pattern) for pattern in service_patterns)
699
+ if not matches:
700
+ continue
701
+
702
+ code_examples = extract_code_examples_from_listing(listing_data, listing_file)
703
+
704
+ for example in code_examples:
705
+ # Filter by test file name if provided
706
+ if test_file:
707
+ file_path = example.get("file_path", "")
708
+ # Check if the file path ends with the test file name
709
+ if not file_path.endswith(test_file):
710
+ continue
711
+
712
+ all_code_examples.append((example, prov_name, credentials))
713
+
714
+ if not all_code_examples:
715
+ console.print("[yellow]No code examples found in listings.[/yellow]")
716
+ raise typer.Exit(code=0)
717
+
718
+ console.print(f"[cyan]Found {len(all_code_examples)} code example(s)[/cyan]\n")
719
+
720
+ # Execute each code example
721
+ results = []
722
+ skipped_count = 0
723
+
724
+ # Track test results per service offering (for status updates)
725
+ # Key: offering directory path, Value: {"passed": int, "failed": int, "listing_file": Path}
726
+ offering_test_results: dict[str, dict[str, Any]] = {}
727
+
728
+ for example, prov_name, credentials in all_code_examples:
729
+ service_name = example["service_name"]
730
+ title = example["title"]
731
+ example_listing_file = example.get("listing_file")
732
+
733
+ # Determine actual filename (strip .j2 if it's a template)
734
+ file_path = example.get("file_path")
735
+ if file_path:
736
+ original_path = Path(file_path)
737
+ # If it's a .j2 template, the actual filename is without .j2
738
+ if original_path.suffix == ".j2":
739
+ actual_filename = original_path.stem
740
+ else:
741
+ actual_filename = original_path.name
742
+ else:
743
+ actual_filename = None
744
+
745
+ # Prepare output file paths if we have the necessary information
746
+ out_path = None
747
+ err_path = None
748
+ if example_listing_file and actual_filename:
749
+ listing_path = Path(example_listing_file)
750
+ listing_stem = listing_path.stem
751
+
752
+ # Create filename pattern: {service_name}_{listing_stem}_{actual_filename}.out/.err
753
+ # e.g., "llama-3-1-405b-instruct_svclisting_test.py.out"
754
+ base_filename = f"{service_name}_{listing_stem}_{actual_filename}"
755
+ out_filename = f"{base_filename}.out"
756
+ err_filename = f"{base_filename}.err"
757
+
758
+ # Output paths in the listing directory
759
+ out_path = listing_path.parent / out_filename
760
+ err_path = listing_path.parent / err_filename
761
+
762
+ # Check if test results already exist (skip if not forcing)
763
+ if not force and out_path and err_path and out_path.exists() and err_path.exists():
764
+ console.print(f"[bold]Testing:[/bold] {service_name} - {title}")
765
+ console.print(" [yellow]⊘ Skipped[/yellow] (results already exist)")
766
+ console.print()
767
+ skipped_count += 1
768
+ # Add a skipped result for the summary
769
+ results.append(
770
+ {
771
+ "service_name": service_name,
772
+ "provider": prov_name,
773
+ "title": title,
774
+ "result": {
775
+ "success": True,
776
+ "exit_code": None,
777
+ "skipped": True,
778
+ },
779
+ }
780
+ )
781
+ continue
782
+
783
+ console.print(f"[bold]Testing:[/bold] {service_name} - {title}")
784
+
785
+ result = execute_code_example(example, credentials)
786
+ result["skipped"] = False
787
+
788
+ results.append(
789
+ {
790
+ "service_name": service_name,
791
+ "provider": prov_name,
792
+ "title": title,
793
+ "result": result,
794
+ }
795
+ )
796
+
797
+ if result["success"]:
798
+ console.print(f" [green]✓ Success[/green] (exit code: {result['exit_code']})")
799
+ if verbose and result["stdout"]:
800
+ console.print(f" [dim]stdout:[/dim] {result['stdout'][:200]}")
801
+
802
+ # Save successful test output to .out and .err files (if paths were determined)
803
+ if out_path and err_path:
804
+ # Write stdout to .out file
805
+ try:
806
+ with open(out_path, "w", encoding="utf-8") as f:
807
+ f.write(result["stdout"] or "")
808
+ console.print(f" [dim]→ Output saved to:[/dim] {out_path}")
809
+ except Exception as e:
810
+ console.print(f" [yellow]⚠ Failed to save output: {e}[/yellow]")
811
+
812
+ # Write stderr to .err file
813
+ try:
814
+ with open(err_path, "w", encoding="utf-8") as f:
815
+ f.write(result["stderr"] or "")
816
+ if result["stderr"]:
817
+ console.print(f" [dim]→ Error output saved to:[/dim] {err_path}")
818
+ except Exception as e:
819
+ console.print(f" [yellow]⚠ Failed to save error output: {e}[/yellow]")
820
+
821
+ # Track test result for this offering (don't update status yet)
822
+ if example_listing_file and not test_file:
823
+ offering_dir = str(example_listing_file.parent)
824
+ if offering_dir not in offering_test_results:
825
+ offering_test_results[offering_dir] = {
826
+ "passed": 0,
827
+ "failed": 0,
828
+ "listing_file": example_listing_file,
829
+ }
830
+ offering_test_results[offering_dir]["passed"] += 1
831
+ else:
832
+ console.print(f" [red]✗ Failed[/red] - {result['error']}")
833
+ if verbose:
834
+ if result["stdout"]:
835
+ console.print(f" [dim]stdout:[/dim] {result['stdout'][:200]}")
836
+ if result["stderr"]:
837
+ console.print(f" [dim]stderr:[/dim] {result['stderr'][:200]}")
838
+
839
+ # Write failed test outputs and script to current directory
840
+ # Use actual_filename from result in case template rendering modified it
841
+ if result.get("listing_file") and result.get("actual_filename"):
842
+ result_listing_file = Path(result["listing_file"])
843
+ result_actual_filename = result["actual_filename"]
844
+ result_listing_stem = result_listing_file.stem
845
+
846
+ # Create filename: failed_{service_name}_{listing_stem}_{actual_filename}
847
+ # This will be the base name for .out, .err, and the script file
848
+ failed_filename = f"failed_{service_name}_{result_listing_stem}_{result_actual_filename}"
849
+
850
+ # Write stdout to .out file in current directory
851
+ out_filename = f"{failed_filename}.out"
852
+ try:
853
+ with open(out_filename, "w", encoding="utf-8") as f:
854
+ f.write(result["stdout"] or "")
855
+ console.print(f" [yellow]→ Output saved to:[/yellow] {out_filename}")
856
+ except Exception as e:
857
+ console.print(f" [yellow]⚠ Failed to save output: {e}[/yellow]")
858
+
859
+ # Write stderr to .err file in current directory
860
+ err_filename = f"{failed_filename}.err"
861
+ try:
862
+ with open(err_filename, "w", encoding="utf-8") as f:
863
+ f.write(result["stderr"] or "")
864
+ console.print(f" [yellow]→ Error output saved to:[/yellow] {err_filename}")
865
+ except Exception as e:
866
+ console.print(f" [yellow]⚠ Failed to save error output: {e}[/yellow]")
867
+
868
+ # Write failed test script content to current directory (for debugging)
869
+ # rendered_content is always set if we got here (set during template rendering)
870
+ try:
871
+ with open(failed_filename, "w", encoding="utf-8") as f:
872
+ f.write(result["rendered_content"])
873
+ console.print(f" [yellow]→ Test script saved to:[/yellow] {failed_filename}")
874
+ except Exception as e:
875
+ console.print(f" [yellow]⚠ Failed to save test script: {e}[/yellow]")
876
+
877
+ # Write environment variables to .env file
878
+ env_filename = f"{failed_filename}.env"
879
+ try:
880
+ with open(env_filename, "w", encoding="utf-8") as f:
881
+ f.write(f"API_KEY={credentials['api_key']}\n")
882
+ f.write(f"BASE_URL={credentials['base_url']}\n")
883
+ console.print(f" [yellow]→ Environment variables saved to:[/yellow] {env_filename}")
884
+ console.print(f" [dim] (source this file to reproduce: source {env_filename})[/dim]")
885
+ except Exception as e:
886
+ console.print(f" [yellow]⚠ Failed to save environment file: {e}[/yellow]")
887
+
888
+ # Track test result for this offering (don't update status yet)
889
+ if example_listing_file and not test_file:
890
+ offering_dir = str(example_listing_file.parent)
891
+ if offering_dir not in offering_test_results:
892
+ offering_test_results[offering_dir] = {
893
+ "passed": 0,
894
+ "failed": 0,
895
+ "listing_file": example_listing_file,
896
+ }
897
+ offering_test_results[offering_dir]["failed"] += 1
898
+
899
+ # Stop testing if fail-fast is enabled
900
+ if fail_fast:
901
+ console.print()
902
+ console.print("[yellow]⚠ Stopping tests due to --fail-fast[/yellow]")
903
+ break
904
+
905
+ console.print()
906
+
907
+ # Update offering status based on test results (only if not using --test-file)
908
+ if not test_file and offering_test_results:
909
+ console.print("\n[cyan]Updating service offering status...[/cyan]")
910
+ for _offering_dir, test_stats in offering_test_results.items():
911
+ listing_file = test_stats["listing_file"]
912
+ passed = test_stats["passed"]
913
+ failed = test_stats["failed"]
914
+
915
+ # If any test failed, set to deprecated
916
+ if failed > 0:
917
+ update_offering_override_status(listing_file, UpstreamStatusEnum.deprecated)
918
+ # If all tests passed, remove deprecated status
919
+ elif passed > 0 and failed == 0:
920
+ update_offering_override_status(listing_file, None)
921
+
922
+ # Print summary table
923
+ console.print("\n" + "=" * 70)
924
+ console.print("[bold]Test Results Summary:[/bold]\n")
925
+
926
+ table = Table(title="Code Example Tests")
927
+ table.add_column("Service", style="cyan")
928
+ table.add_column("Provider", style="blue")
929
+ table.add_column("Example", style="white")
930
+ table.add_column("Status", style="green")
931
+ table.add_column("Exit Code", style="white")
932
+
933
+ total_tests = len(results)
934
+ skipped = sum(1 for r in results if r["result"].get("skipped", False))
935
+ passed = sum(1 for r in results if r["result"]["success"] and not r["result"].get("skipped", False))
936
+ failed = total_tests - passed - skipped
937
+
938
+ for test in results:
939
+ result = test["result"]
940
+ if result.get("skipped", False):
941
+ status = "[yellow]⊘ Skipped[/yellow]"
942
+ elif result["success"]:
943
+ status = "[green]✓ Pass[/green]"
944
+ else:
945
+ status = "[red]✗ Fail[/red]"
946
+
947
+ # Use 'is not None' to properly handle exit_code of 0 (success)
948
+ exit_code = str(result["exit_code"]) if result["exit_code"] is not None else "N/A"
949
+
950
+ table.add_row(
951
+ test["service_name"],
952
+ test["provider"],
953
+ test["title"],
954
+ status,
955
+ exit_code,
956
+ )
957
+
958
+ console.print(table)
959
+ console.print(f"\n[green]✓ Passed: {passed}/{total_tests}[/green]")
960
+ if skipped > 0:
961
+ console.print(f"[yellow]⊘ Skipped: {skipped}/{total_tests}[/yellow]")
962
+ console.print(f"[red]✗ Failed: {failed}/{total_tests}[/red]")
963
+
964
+ if failed > 0:
965
+ raise typer.Exit(code=1)