gpu-dev 0.3.5__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,702 @@
1
+ """Interactive CLI components for GPU Developer CLI"""
2
+
3
+ import sys
4
+ from typing import Dict, List, Optional, Any
5
+
6
+ try:
7
+ import questionary
8
+ from questionary import Style
9
+
10
+ INTERACTIVE_AVAILABLE = True
11
+ except ImportError:
12
+ INTERACTIVE_AVAILABLE = False
13
+
14
+ from rich.console import Console
15
+ from rich.table import Table
16
+ from rich.panel import Panel
17
+
18
+ console = Console()
19
+
20
+ # Custom style for questionary - softer colors
21
+ custom_style = Style(
22
+ [
23
+ ("question", "fg:#5f87af bold"), # Soft blue
24
+ ("answer", "fg:#5f87af bold"), # Soft blue
25
+ ("pointer", "fg:#5f87af bold"), # Soft blue
26
+ ("highlighted", "fg:#5f87af"), # Soft blue, no bold
27
+ ("selected", "fg:#87af87"), # Soft green
28
+ ("separator", "fg:#808080"), # Neutral gray
29
+ ("instruction", ""),
30
+ ("text", ""),
31
+ ("disabled", "fg:#858585 italic"),
32
+ ]
33
+ )
34
+
35
+
36
+ def check_interactive_support() -> bool:
37
+ """Check if interactive mode is available"""
38
+ if not INTERACTIVE_AVAILABLE:
39
+ console.print(
40
+ "[red]❌ Interactive mode requires 'questionary'. Install with: pip install questionary[/red]"
41
+ )
42
+ return False
43
+
44
+ if not sys.stdin.isatty():
45
+ console.print(
46
+ "[yellow]⚠️ Non-interactive terminal detected. Use command-line flags instead.[/yellow]"
47
+ )
48
+ return False
49
+
50
+ return True
51
+
52
+
53
+ def select_gpu_type_interactive(
54
+ availability_info: Dict[str, Dict[str, Any]],
55
+ ) -> Optional[str]:
56
+ """Interactive GPU type selection with availability table"""
57
+ if not check_interactive_support():
58
+ return None
59
+
60
+ # Display availability table first
61
+ console.print("\n[cyan]🖥️ GPU Availability:[/cyan]")
62
+ table = Table()
63
+ table.add_column("GPU Type", style="cyan")
64
+ table.add_column("Available", style="green")
65
+ table.add_column("Total", style="blue")
66
+ table.add_column("Queue Length", style="yellow")
67
+ table.add_column("Est. Wait Time", style="magenta")
68
+
69
+ choices = []
70
+ for gpu_type, info in availability_info.items():
71
+ available = info.get("available", 0)
72
+ total = info.get("total", 0)
73
+ queue_length = info.get("queue_length", 0)
74
+ est_wait = info.get("estimated_wait_minutes", 0)
75
+
76
+ # Format wait time
77
+ if available > 0:
78
+ wait_display = "Available now"
79
+ status_indicator = "✅"
80
+ elif est_wait == 0:
81
+ wait_display = "Unknown"
82
+ status_indicator = "⚠️"
83
+ elif est_wait < 60:
84
+ wait_display = f"{int(est_wait)}min"
85
+ status_indicator = "⏳"
86
+ else:
87
+ hours = int(est_wait // 60)
88
+ minutes = int(est_wait % 60)
89
+ if minutes == 0:
90
+ wait_display = f"{hours}h"
91
+ else:
92
+ wait_display = f"{hours}h {minutes}min"
93
+ status_indicator = "⏳"
94
+
95
+ # Color code availability
96
+ if available > 0:
97
+ available_display = f"[green]{available}[/green]"
98
+ else:
99
+ available_display = f"[red]{available}[/red]"
100
+
101
+ table.add_row(
102
+ gpu_type.upper(),
103
+ available_display,
104
+ str(total),
105
+ str(queue_length),
106
+ wait_display,
107
+ )
108
+
109
+ # Create choice label with status
110
+ choice_label = (
111
+ f"{status_indicator} {gpu_type.upper()} ({available}/{total} available)"
112
+ )
113
+ if queue_length > 0:
114
+ choice_label += f" - {queue_length} in queue"
115
+
116
+ choices.append(questionary.Choice(title=choice_label, value=gpu_type))
117
+
118
+ console.print(table)
119
+ console.print()
120
+
121
+ # Interactive selection
122
+ try:
123
+ answer = questionary.select(
124
+ "Select GPU type:", choices=choices, style=custom_style
125
+ ).ask()
126
+
127
+ return answer
128
+ except (KeyboardInterrupt, EOFError):
129
+ console.print("\n[yellow]Selection cancelled.[/yellow]")
130
+ return None
131
+
132
+
133
+ def select_gpu_count_interactive(gpu_type: str, max_gpus: int) -> Optional[int]:
134
+ """Interactive GPU count selection"""
135
+ if not check_interactive_support():
136
+ return None
137
+
138
+ # Generate valid choices based on GPU type limits
139
+ if gpu_type.startswith("cpu-"):
140
+ # CPU instances don't have GPUs, but we still need a "count" for nodes
141
+ valid_counts = [0] # 0 GPUs for CPU-only instances
142
+ multinode_counts = [] # No multinode for CPU instances
143
+ elif gpu_type in ["t4", "l4", "a10g"]:
144
+ valid_counts = [1, 2, 4]
145
+ # Add multinode options
146
+ multinode_counts = [8, 12, 16, 20, 24] # multiples of 4
147
+ elif gpu_type == "g5g":
148
+ valid_counts = [1, 2]
149
+ multinode_counts = [4, 8] # multiples of 4
150
+ elif gpu_type == "t4-small":
151
+ valid_counts = [1]
152
+ multinode_counts = [2, 3, 4, 5, 6] # multiples of 1
153
+ else: # a100, h100, h200, b200
154
+ valid_counts = [1, 2, 4, 8]
155
+ # Add multinode options
156
+ multinode_counts = [16, 24, 32, 40, 48] # multiples of 8
157
+
158
+ # Filter single-node by actual max for this GPU type
159
+ valid_counts = [count for count in valid_counts if count <= max_gpus]
160
+
161
+ # Add multinode options (multiples of max_gpus)
162
+ multinode_counts = [
163
+ count for count in multinode_counts if count % max_gpus == 0]
164
+
165
+ choices = []
166
+
167
+ # Add single-node options
168
+ for count in valid_counts:
169
+ if count == 1:
170
+ label = f"1 GPU (single node)"
171
+ else:
172
+ label = f"{count} GPUs (single node)"
173
+ choices.append(questionary.Choice(title=label, value=count))
174
+
175
+ # Add separator and multinode options
176
+ if multinode_counts:
177
+ choices.append(questionary.Separator(
178
+ "--- Multinode (Distributed) ---"))
179
+ for count in multinode_counts:
180
+ nodes = count // max_gpus
181
+ label = f"{count} GPUs ({nodes} nodes × {max_gpus} GPUs)"
182
+ choices.append(questionary.Choice(title=label, value=count))
183
+
184
+ try:
185
+ if gpu_type.startswith("cpu-"):
186
+ question = f"Reserve {gpu_type.upper()} CPU instance?"
187
+ else:
188
+ question = f"How many {gpu_type.upper()} GPUs?"
189
+
190
+ answer = questionary.select(
191
+ question, choices=choices, style=custom_style
192
+ ).ask()
193
+
194
+ return answer
195
+ except (KeyboardInterrupt, EOFError):
196
+ console.print("\n[yellow]Selection cancelled.[/yellow]")
197
+ return None
198
+
199
+
200
+ def select_duration_interactive() -> Optional[float]:
201
+ """Interactive duration selection"""
202
+ if not check_interactive_support():
203
+ return None
204
+
205
+ # Common duration choices - cleaner labels
206
+ choices = [
207
+ questionary.Choice("15 minutes", 0.25),
208
+ questionary.Choice("30 minutes", 0.5),
209
+ questionary.Choice("1 hour", 1.0),
210
+ questionary.Choice("2 hours", 2.0),
211
+ questionary.Choice("4 hours", 4.0),
212
+ questionary.Choice("8 hours (default)", 8.0),
213
+ questionary.Choice("12 hours", 12.0),
214
+ questionary.Choice("24 hours (max)", 24.0),
215
+ questionary.Choice("Custom duration", "custom"),
216
+ ]
217
+
218
+ try:
219
+ answer = questionary.select(
220
+ "How long do you need the reservation?", choices=choices, style=custom_style
221
+ ).ask()
222
+
223
+ if answer == "custom":
224
+ # Ask for custom duration
225
+ custom_duration = questionary.text(
226
+ "Enter duration in hours (decimal allowed, max 24):",
227
+ validate=lambda x: _validate_duration(x),
228
+ style=custom_style,
229
+ ).ask()
230
+
231
+ if custom_duration:
232
+ return float(custom_duration)
233
+ else:
234
+ return None
235
+
236
+ return answer
237
+ except (KeyboardInterrupt, EOFError):
238
+ console.print("\n[yellow]Selection cancelled.[/yellow]")
239
+ return None
240
+
241
+
242
+ def select_jupyter_interactive() -> Optional[bool]:
243
+ """Interactive Jupyter Lab selection"""
244
+ if not check_interactive_support():
245
+ return None
246
+
247
+ try:
248
+ answer = questionary.confirm(
249
+ "Enable Jupyter Lab? (can be enabled later)",
250
+ default=False,
251
+ style=custom_style,
252
+ ).ask()
253
+
254
+ return answer
255
+ except (KeyboardInterrupt, EOFError):
256
+ console.print("\n[yellow]Selection cancelled.[/yellow]")
257
+ return None
258
+
259
+
260
+ def select_reservation_interactive(
261
+ reservations: List[Dict[str, Any]], action: str
262
+ ) -> Optional[str]:
263
+ """Interactive reservation selection for cancel/edit operations"""
264
+ if not check_interactive_support():
265
+ return None
266
+
267
+ if not reservations:
268
+ console.print(
269
+ f"[yellow]No reservations available to {action}.[/yellow]")
270
+ return None
271
+
272
+ # Display reservations table
273
+ console.print(
274
+ f"\n[cyan]📋 Your reservations (available to {action}):[/cyan]")
275
+
276
+ table = Table()
277
+ table.add_column("ID", style="cyan", no_wrap=True)
278
+ table.add_column("GPUs", style="magenta")
279
+ table.add_column("Status", style="yellow")
280
+ table.add_column("Created", style="blue")
281
+ table.add_column("Expires/ETA", style="red")
282
+
283
+ choices = []
284
+
285
+ for reservation in reservations:
286
+ try:
287
+ reservation_id = reservation.get("reservation_id", "unknown")
288
+ gpu_count = reservation.get("gpu_count", 1)
289
+ gpu_type = reservation.get("gpu_type", "unknown")
290
+ status = reservation.get("status", "unknown")
291
+ created_at = reservation.get("created_at", "N/A")
292
+
293
+ # Format GPU information
294
+ if gpu_type and gpu_type not in ["unknown", "Unknown"]:
295
+ gpu_display = f"{gpu_count}x {gpu_type.upper()}"
296
+ else:
297
+ gpu_display = str(gpu_count)
298
+
299
+ # Format expiration time or ETA
300
+ expires_at = reservation.get("expires_at", "N/A")
301
+ if status == "active" and expires_at != "N/A":
302
+ from datetime import datetime
303
+
304
+ try:
305
+ if isinstance(expires_at, str):
306
+ if expires_at.endswith("Z"):
307
+ expires_dt_utc = datetime.fromisoformat(
308
+ expires_at.replace("Z", "+00:00")
309
+ )
310
+ elif "+" in expires_at or expires_at.endswith("00:00"):
311
+ expires_dt_utc = datetime.fromisoformat(expires_at)
312
+ else:
313
+ from datetime import timezone
314
+
315
+ naive_dt = datetime.fromisoformat(expires_at)
316
+ expires_dt_utc = naive_dt.replace(
317
+ tzinfo=timezone.utc)
318
+
319
+ expires_dt = expires_dt_utc.astimezone()
320
+ expires_formatted = expires_dt.strftime("%m-%d %H:%M")
321
+ else:
322
+ expires_dt = datetime.fromtimestamp(expires_at)
323
+ expires_formatted = expires_dt.strftime("%m-%d %H:%M")
324
+ except (ValueError, TypeError):
325
+ expires_formatted = "Invalid"
326
+ elif status in ["queued", "pending"]:
327
+ estimated_wait = reservation.get("estimated_wait_minutes", "?")
328
+ if estimated_wait != "?" and estimated_wait is not None:
329
+ expires_formatted = f"~{estimated_wait}min"
330
+ else:
331
+ expires_formatted = "Calculating..."
332
+ else:
333
+ expires_formatted = "N/A"
334
+
335
+ # Format created_at datetime
336
+ created_formatted = "N/A"
337
+ if created_at and created_at != "N/A":
338
+ try:
339
+ from datetime import datetime
340
+
341
+ if isinstance(created_at, str):
342
+ if created_at.endswith("Z"):
343
+ created_dt_utc = datetime.fromisoformat(
344
+ created_at.replace("Z", "+00:00")
345
+ )
346
+ elif "+" in created_at or created_at.endswith("00:00"):
347
+ created_dt_utc = datetime.fromisoformat(created_at)
348
+ else:
349
+ from datetime import timezone
350
+
351
+ naive_dt = datetime.fromisoformat(created_at)
352
+ created_dt_utc = naive_dt.replace(
353
+ tzinfo=timezone.utc)
354
+
355
+ created_dt = created_dt_utc.astimezone()
356
+ created_formatted = created_dt.strftime("%m-%d %H:%M")
357
+ else:
358
+ created_dt = datetime.fromtimestamp(created_at)
359
+ created_formatted = created_dt.strftime("%m-%d %H:%M")
360
+ except (ValueError, TypeError):
361
+ if len(str(created_at)) > 10:
362
+ created_formatted = str(created_at)[:10]
363
+ else:
364
+ created_formatted = str(created_at)
365
+
366
+ table.add_row(
367
+ str(reservation_id)[:8],
368
+ gpu_display,
369
+ str(status),
370
+ created_formatted,
371
+ expires_formatted,
372
+ )
373
+
374
+ # Create choice for interactive selection
375
+ choice_label = f"{reservation_id[:8]} - {gpu_display} ({status})"
376
+ choices.append(questionary.Choice(
377
+ title=choice_label, value=reservation_id))
378
+
379
+ except Exception as row_error:
380
+ console.print(
381
+ f"[yellow]⚠️ Skipping malformed reservation: {str(row_error)}[/yellow]"
382
+ )
383
+ continue
384
+
385
+ console.print(table)
386
+ console.print()
387
+
388
+ if not choices:
389
+ console.print(
390
+ f"[yellow]No valid reservations found to {action}.[/yellow]")
391
+ return None
392
+
393
+ # Add "all" option for cancel action when there are multiple reservations
394
+ if action == "cancel" and len(choices) > 1:
395
+ choices.append(
396
+ questionary.Choice(
397
+ title="🗑️ Cancel ALL reservations above", value="__ALL__"
398
+ )
399
+ )
400
+
401
+ # Add quit option at the end for all actions
402
+ action_verb = "cancel" if action == "cancel" else "edit"
403
+ choices.append(
404
+ questionary.Choice(
405
+ title=f"❌ Quit (don't {action_verb} anything)", value="__QUIT__"
406
+ )
407
+ )
408
+
409
+ try:
410
+ answer = questionary.select(
411
+ f"Select reservation to {action}:", choices=choices, style=custom_style
412
+ ).ask()
413
+
414
+ return answer
415
+ except (KeyboardInterrupt, EOFError):
416
+ console.print("\n[yellow]Selection cancelled.[/yellow]")
417
+ return None
418
+
419
+
420
+ def _validate_duration(duration_str: str) -> bool:
421
+ """Validate duration input"""
422
+ try:
423
+ duration = float(duration_str)
424
+ if duration < 0.0833: # Less than 5 minutes
425
+ return "Minimum duration is 5 minutes (0.0833 hours)"
426
+ if duration > 24:
427
+ return "Maximum duration is 24 hours"
428
+ return True
429
+ except ValueError:
430
+ return "Please enter a valid number"
431
+
432
+
433
+ def ask_name_interactive() -> Optional[str]:
434
+ """Ask for optional reservation name"""
435
+ if not check_interactive_support():
436
+ return None
437
+
438
+ try:
439
+ answer = questionary.text(
440
+ "Reservation name (optional, press Enter to skip):", style=custom_style
441
+ ).ask()
442
+
443
+ # Return None if empty string
444
+ return answer.strip() if answer and answer.strip() else None
445
+ except (KeyboardInterrupt, EOFError):
446
+ console.print("\n[yellow]Selection cancelled.[/yellow]")
447
+ return None
448
+
449
+
450
+ def select_edit_action_interactive() -> Optional[str]:
451
+ """Interactive edit action selection"""
452
+ if not check_interactive_support():
453
+ return None
454
+
455
+ choices = [
456
+ questionary.Choice("Enable Jupyter Lab", "enable_jupyter"),
457
+ questionary.Choice("Disable Jupyter Lab", "disable_jupyter"),
458
+ questionary.Choice("Add secondary user", "add_user"),
459
+ questionary.Choice("Extend reservation duration", "extend"),
460
+ ]
461
+
462
+ try:
463
+ answer = questionary.select(
464
+ "What would you like to edit?", choices=choices, style=custom_style
465
+ ).ask()
466
+
467
+ return answer
468
+ except (KeyboardInterrupt, EOFError):
469
+ console.print("\n[yellow]Selection cancelled.[/yellow]")
470
+ return None
471
+
472
+
473
+ def ask_github_username_interactive() -> Optional[str]:
474
+ """Ask for GitHub username to add"""
475
+ if not check_interactive_support():
476
+ return None
477
+
478
+ try:
479
+ answer = questionary.text(
480
+ "Enter GitHub username to add:",
481
+ validate=lambda x: _validate_github_username(x),
482
+ style=custom_style,
483
+ ).ask()
484
+
485
+ return answer.strip() if answer else None
486
+ except (KeyboardInterrupt, EOFError):
487
+ console.print("\n[yellow]Selection cancelled.[/yellow]")
488
+ return None
489
+
490
+
491
+ def ask_extension_hours_interactive() -> Optional[float]:
492
+ """Ask for extension hours"""
493
+ if not check_interactive_support():
494
+ return None
495
+
496
+ try:
497
+ # Offer common extension choices
498
+ choices = [
499
+ questionary.Choice("1 hour", 1.0),
500
+ questionary.Choice("2 hours", 2.0),
501
+ questionary.Choice("4 hours", 4.0),
502
+ questionary.Choice("8 hours", 8.0),
503
+ questionary.Choice("12 hours", 12.0),
504
+ questionary.Choice("24 hours (max)", 24.0),
505
+ questionary.Choice("Custom extension", "custom"),
506
+ ]
507
+
508
+ answer = questionary.select(
509
+ "How many hours to extend?", choices=choices, style=custom_style
510
+ ).ask()
511
+
512
+ if answer == "custom":
513
+ # Ask for custom extension
514
+ custom_extension = questionary.text(
515
+ "Enter extension hours (decimal allowed, max 24):",
516
+ validate=lambda x: _validate_extension(x),
517
+ style=custom_style,
518
+ ).ask()
519
+
520
+ if custom_extension:
521
+ return float(custom_extension)
522
+ else:
523
+ return None
524
+
525
+ return answer
526
+ except (KeyboardInterrupt, EOFError):
527
+ console.print("\n[yellow]Selection cancelled.[/yellow]")
528
+ return None
529
+
530
+
531
+ def _validate_github_username(username: str) -> bool:
532
+ """Validate GitHub username format"""
533
+ if not username or not username.strip():
534
+ return "GitHub username cannot be empty"
535
+
536
+ username = username.strip()
537
+ if not username.replace("-", "").replace("_", "").replace(".", "").isalnum():
538
+ return "Invalid GitHub username format"
539
+
540
+ if len(username) > 39: # GitHub's max username length
541
+ return "GitHub username too long (max 39 characters)"
542
+
543
+ return True
544
+
545
+
546
+ def _validate_extension(hours_str: str) -> bool:
547
+ """Validate extension hours input"""
548
+ try:
549
+ hours = float(hours_str)
550
+ if hours <= 0:
551
+ return "Extension hours must be positive"
552
+ if hours > 24:
553
+ return "Maximum extension is 24 hours"
554
+ return True
555
+ except ValueError:
556
+ return "Please enter a valid number"
557
+
558
+
559
+ def select_disk_interactive(user_id: str, config: Any) -> Optional[str]:
560
+ """
561
+ Interactive disk selection for reserve command.
562
+ Returns:
563
+ - disk_name: User selected an existing disk
564
+ - "__no_disk__": User explicitly chose no disk
565
+ - "__create_new__": User wants to create a new disk (handled internally)
566
+ - "__cancelled__": User cancelled (Ctrl+C or EOF)
567
+ """
568
+ if not check_interactive_support():
569
+ return "__cancelled__"
570
+
571
+ from .disks import list_disks
572
+
573
+ while True: # Loop to support "Refresh list"
574
+ try:
575
+ # Get user's disks
576
+ disks = list_disks(user_id, config)
577
+
578
+ # Build choices
579
+ choices = []
580
+
581
+ if disks:
582
+ # Add header
583
+ choices.append(questionary.Separator("=== Your Disks ==="))
584
+
585
+ for disk in disks:
586
+ disk_name = disk['name']
587
+ size_gb = disk['size_gb']
588
+ disk_size = disk.get('disk_size', '') # Actual used size like "23G"
589
+ snapshot_count = disk['snapshot_count']
590
+
591
+ # Format display name - show used/total like "23G / 1024GB"
592
+ if disk_size:
593
+ size_display = f"{disk_size} / {size_gb}GB"
594
+ else:
595
+ size_display = f"{size_gb}GB"
596
+ display_parts = [f"{disk_name} ({size_display}, {snapshot_count} snapshots)"]
597
+
598
+ # Check if disk is deleted or in use
599
+ if disk.get('is_deleted', False):
600
+ display_parts.append("[DELETED]")
601
+ delete_date = disk.get('delete_date', 'unknown')
602
+ choices.append(questionary.Choice(
603
+ title=" ".join(display_parts),
604
+ value=None,
605
+ disabled=f"Soft-deleted, expires {delete_date}"
606
+ ))
607
+ elif disk['in_use']:
608
+ display_parts.append("[IN USE]")
609
+ # Disable this choice
610
+ choices.append(questionary.Choice(
611
+ title=" ".join(display_parts),
612
+ value=None,
613
+ disabled="Currently in use by another reservation"
614
+ ))
615
+ else:
616
+ choices.append(questionary.Choice(
617
+ title=" ".join(display_parts),
618
+ value=disk_name
619
+ ))
620
+
621
+ # Add options for creating new disk or no disk
622
+ choices.append(questionary.Separator("=== Options ==="))
623
+ choices.append(questionary.Choice(
624
+ title="Create new disk",
625
+ value="__create_new__"
626
+ ))
627
+ choices.append(questionary.Choice(
628
+ title="No disk (temporary storage only)",
629
+ value="__no_disk__"
630
+ ))
631
+ choices.append(questionary.Choice(
632
+ title="↻ Refresh list",
633
+ value="__refresh__"
634
+ ))
635
+
636
+ # Show selection
637
+ answer = questionary.select(
638
+ "Select a persistent disk:",
639
+ choices=choices,
640
+ style=custom_style,
641
+ ).ask()
642
+
643
+ if answer is None:
644
+ # User cancelled (Ctrl+C)
645
+ return "__cancelled__"
646
+
647
+ if answer == "__refresh__":
648
+ console.print("[cyan]Refreshing disk list...[/cyan]")
649
+ continue # Loop back to refresh
650
+
651
+ if answer == "__no_disk__":
652
+ # Return special marker to indicate explicit "no disk" choice
653
+ return "__no_disk__"
654
+
655
+ if answer == "__create_new__":
656
+ # Ask for disk name
657
+ disk_name = questionary.text(
658
+ "Enter name for new disk (alphanumeric, hyphens, underscores):",
659
+ validate=lambda x: _validate_disk_name(x),
660
+ style=custom_style,
661
+ ).ask()
662
+
663
+ if not disk_name:
664
+ return "__cancelled__"
665
+
666
+ # Validate the disk name (actual disk created by Lambda on first use)
667
+ from .disks import create_disk
668
+ success = create_disk(disk_name, user_id, config)
669
+ if success:
670
+ console.print(f"[cyan]✓ Will create disk '{disk_name}' with this reservation[/cyan]")
671
+ return disk_name
672
+ else:
673
+ console.print("[red]Invalid disk name. Continuing without persistent disk.[/red]")
674
+ return "__cancelled__"
675
+
676
+ # Return selected disk name
677
+ return answer
678
+
679
+ except EOFError:
680
+ # Handle EOF (e.g., piped input) gracefully
681
+ return "__cancelled__"
682
+ except KeyboardInterrupt:
683
+ # Handle Ctrl+C explicitly
684
+ return "__cancelled__"
685
+
686
+
687
+ def _validate_disk_name(disk_name: str) -> bool:
688
+ """Validate disk name format"""
689
+ if not disk_name or not disk_name.strip():
690
+ return "Disk name cannot be empty"
691
+
692
+ disk_name = disk_name.strip()
693
+
694
+ # Check alphanumeric + hyphens + underscores
695
+ import re
696
+ if not re.match(r'^[a-zA-Z0-9_-]+$', disk_name):
697
+ return "Disk name must contain only letters, numbers, hyphens, and underscores"
698
+
699
+ if len(disk_name) > 50:
700
+ return "Disk name too long (max 50 characters)"
701
+
702
+ return True