lattifai 0.4.1__py3-none-any.whl → 0.4.2__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,812 @@
1
+ """
2
+ File existence management utilities for video processing workflows
3
+ """
4
+
5
+ import asyncio
6
+ import os
7
+ import sys
8
+ from contextlib import contextmanager
9
+ from pathlib import Path
10
+ from typing import Dict, List, Optional, Sequence, Tuple
11
+
12
+ import colorful
13
+
14
+ try:
15
+ import questionary
16
+ except ImportError: # pragma: no cover - optional dependency
17
+ questionary = None
18
+
19
+
20
+ class FileExistenceManager:
21
+ """Utility class for handling file existence checks and user confirmations"""
22
+
23
+ FILE_TYPE_INFO = {
24
+ 'media': ('🎬', 'Media'),
25
+ # 'audio': ('📱', 'Audio'),
26
+ # 'video': ('🎬', 'Video'),
27
+ 'subtitle': ('📝', 'Subtitle'),
28
+ }
29
+
30
+ @staticmethod
31
+ def check_existing_files(
32
+ video_id: str,
33
+ output_dir: str,
34
+ media_formats: List[str] = None,
35
+ subtitle_formats: List[str] = None,
36
+ ) -> Dict[str, List[str]]:
37
+ """
38
+ Enhanced version to check for existing media files with customizable formats
39
+
40
+ Args:
41
+ video_id: Video ID from any platform
42
+ output_dir: Output directory to check
43
+ media_formats: List of media formats to check (audio and video combined)
44
+ subtitle_formats: List of subtitle formats to check
45
+
46
+ Returns:
47
+ Dictionary with 'media', 'subtitle' keys containing lists of existing files
48
+ """
49
+ output_path = Path(output_dir).expanduser()
50
+ existing_files = {'media': [], 'subtitle': []}
51
+
52
+ if not output_path.exists():
53
+ return existing_files
54
+
55
+ # Default formats - combine audio and video formats
56
+ media_formats = media_formats or ['mp3', 'wav', 'm4a', 'aac', 'opus', 'mp4', 'webm', 'mkv', 'avi']
57
+ subtitle_formats = subtitle_formats or ['md', 'srt', 'vtt', 'ass', 'ssa', 'sub', 'sbv', 'txt']
58
+
59
+ # Check for media files (audio and video)
60
+ for ext in set(media_formats): # Remove duplicates
61
+ # Pattern 1: Simple pattern like {video_id}.mp3
62
+ media_file = output_path / f'{video_id}.{ext}'
63
+ if media_file.exists():
64
+ existing_files['media'].append(str(media_file))
65
+
66
+ # Pattern 2: With suffix like {video_id}_Edit.mp3 or {video_id}.something.mp3
67
+ for media_file in output_path.glob(f'{video_id}*.{ext}'):
68
+ file_path = str(media_file)
69
+ if file_path not in existing_files['media']:
70
+ existing_files['media'].append(file_path)
71
+
72
+ # Check for subtitle files
73
+ for ext in set(subtitle_formats): # Remove duplicates
74
+ # Check multiple naming patterns for subtitle files
75
+ # Pattern 1: Simple pattern like {video_id}.vtt
76
+ subtitle_file = output_path / f'{video_id}.{ext}'
77
+ if subtitle_file.exists():
78
+ existing_files['subtitle'].append(str(subtitle_file))
79
+
80
+ # Pattern 2: With language/track suffix like {video_id}.en-trackid.vtt
81
+ for sub_file in output_path.glob(f'{video_id}.*.{ext}'):
82
+ file_path = str(sub_file)
83
+ if file_path not in existing_files['subtitle']:
84
+ existing_files['subtitle'].append(file_path)
85
+
86
+ if 'md' in subtitle_formats:
87
+ # Gemini-specific pattern: {video_id}_Gemini.md
88
+ gemini_subtitle_file = output_path / f'{video_id}_Gemini.md'
89
+ if gemini_subtitle_file.exists():
90
+ existing_files['subtitle'].append(str(gemini_subtitle_file))
91
+
92
+ return existing_files
93
+
94
+ @staticmethod
95
+ def prompt_user_confirmation(existing_files: Dict[str, List[str]], operation: str = 'download') -> str:
96
+ """
97
+ Prompt user for confirmation when files already exist (legacy, confirms all files together)
98
+
99
+ Args:
100
+ existing_files: Dictionary of existing files
101
+ operation: Type of operation (e.g., "download", "generate")
102
+
103
+ Returns:
104
+ User choice: 'use' (use existing), 'overwrite' (regenerate), or 'cancel'
105
+ """
106
+ has_media = bool(existing_files.get('media', []))
107
+ has_subtitle = bool(existing_files.get('subtitle', []))
108
+
109
+ if not has_media and not has_subtitle:
110
+ return 'proceed' # No existing files, proceed normally
111
+
112
+ # Header with warning color
113
+ print(f'\n{colorful.bold_yellow("⚠️ Existing files found:")}')
114
+
115
+ # Collect file paths for options
116
+ file_paths = []
117
+ if has_media:
118
+ file_paths.extend(existing_files['media'])
119
+ if has_subtitle:
120
+ file_paths.extend(existing_files['subtitle'])
121
+
122
+ # Create display options with emojis
123
+ options = []
124
+ for file_path in file_paths:
125
+ # Determine emoji based on file type
126
+ if has_media and file_path in existing_files['media']:
127
+ display_text = f'{colorful.green("•")} 🎬 Media file: {file_path}'
128
+ else:
129
+ display_text = f'{colorful.green("•")} 📝 Subtitle file: {file_path}'
130
+ options.append((display_text, file_path))
131
+
132
+ # Add overwrite and cancel options
133
+ options.extend(
134
+ [
135
+ (' Overwrite existing files (re-generate or download)', 'overwrite'),
136
+ (' Cancel operation', 'cancel'),
137
+ ]
138
+ )
139
+
140
+ prompt_message = 'What would you like to do?'
141
+ default_value = file_paths[0] if file_paths else 'use'
142
+ choice = FileExistenceManager._prompt_user_choice(prompt_message, options, default=default_value)
143
+
144
+ if choice == 'overwrite':
145
+ print(f'{colorful.yellow("🔄 Overwriting existing files")}')
146
+ elif choice == 'cancel':
147
+ print(f'{colorful.red("❌ Operation cancelled")}')
148
+ elif choice in file_paths:
149
+ print(f'{colorful.green(f"✅ Using selected file: {choice}")}')
150
+ else:
151
+ print(f'{colorful.green("✅ Using existing files")}')
152
+
153
+ return choice
154
+
155
+ @staticmethod
156
+ def prompt_file_type_confirmation(file_type: str, files: List[str], operation: str = 'download') -> str:
157
+ """
158
+ Prompt user for confirmation for a specific file type
159
+
160
+ Args:
161
+ file_type: Type of file ('audio', 'video', 'subtitle', 'gemini')
162
+ files: List of existing files of this type
163
+ operation: Type of operation (e.g., "download", "generate")
164
+
165
+ Returns:
166
+ User choice: 'use' (use existing), 'overwrite' (regenerate), or 'cancel'
167
+ """
168
+ if not files:
169
+ return 'proceed'
170
+
171
+ emoji, label = FileExistenceManager.FILE_TYPE_INFO.get(file_type, ('📄', file_type.capitalize()))
172
+ del emoji # Unused variable
173
+
174
+ # Header with warning color
175
+ print(f'\n{colorful.bold_yellow(f"⚠️ Existing {label} files found:")}')
176
+
177
+ for file_path in files:
178
+ print(f' {colorful.green("•")} {file_path}')
179
+
180
+ prompt_message = f'What would you like to do with {label.lower()} files?'
181
+ options = [
182
+ (f'Use existing {label.lower()} files (skip {operation})', 'use'),
183
+ (f'Overwrite {label.lower()} files (re-{operation})', 'overwrite'),
184
+ ('Cancel operation', 'cancel'),
185
+ ]
186
+ choice = FileExistenceManager._prompt_user_choice(prompt_message, options, default='use')
187
+
188
+ if choice == 'use':
189
+ print(f'{colorful.green(f"✅ Using existing {label.lower()} files")}')
190
+ elif choice == 'overwrite':
191
+ print(f'{colorful.yellow(f"🔄 Overwriting {label.lower()} files")}')
192
+ elif choice == 'cancel':
193
+ print(f'{colorful.red("❌ Operation cancelled")}')
194
+
195
+ return choice
196
+
197
+ @staticmethod
198
+ def prompt_file_selection(
199
+ file_type: str, files: List[str], operation: str = 'use', enable_gemini: bool = False
200
+ ) -> str:
201
+ """
202
+ Prompt user to select a specific file from a list, or choose to overwrite/cancel
203
+
204
+ Args:
205
+ file_type: Type of file (e.g., 'gemini transcript', 'subtitle')
206
+ files: List of existing files to choose from
207
+ operation: Type of operation (e.g., "transcribe", "download")
208
+ enable_gemini: If True, adds "Transcribe with Gemini" option
209
+
210
+ Returns:
211
+ Selected file path, 'overwrite' to regenerate, 'gemini' to transcribe with Gemini, or 'cancel' to abort
212
+ """
213
+ if not files:
214
+ return 'proceed'
215
+
216
+ # If only one file, simplify the choice
217
+ if len(files) == 1:
218
+ return (
219
+ FileExistenceManager.prompt_file_type_confirmation(
220
+ file_type=file_type, files=files, operation=operation
221
+ )
222
+ if files
223
+ else 'proceed'
224
+ )
225
+
226
+ # Multiple files: let user choose which one
227
+ print(f'\n{colorful.bold_yellow(f"⚠️ Multiple {file_type} files found:")}')
228
+
229
+ # Create options with full file paths
230
+ options = []
231
+ for i, file_path in enumerate(files, 1):
232
+ # Display full path for clarity
233
+ options.append((f'{colorful.cyan(file_path)}', file_path))
234
+
235
+ # Add Gemini transcription option if enabled
236
+ if enable_gemini:
237
+ options.append((colorful.magenta('✨ Transcribe with Gemini 2.5 Pro'), 'gemini'))
238
+
239
+ # Add overwrite and cancel options
240
+ options.append((colorful.yellow(f'Overwrite (re-{operation} or download)'), 'overwrite'))
241
+ options.append((colorful.red('Cancel operation'), 'cancel'))
242
+
243
+ prompt_message = colorful.bold_black_on_cyan(f'Select which {file_type} to use:')
244
+ choice = FileExistenceManager._prompt_user_choice(prompt_message, options, default=files[0])
245
+
246
+ if choice == 'cancel':
247
+ print(f'{colorful.red("❌ Operation cancelled")}')
248
+ elif choice == 'overwrite':
249
+ print(f'{colorful.yellow(f"🔄 Overwriting all {file_type} files")}')
250
+ elif choice == 'gemini':
251
+ print(f'{colorful.magenta("✨ Will transcribe with Gemini 2.5 Pro")}')
252
+ else:
253
+ print(f'{colorful.green(f"✅ Using: {choice}")}')
254
+
255
+ return choice
256
+
257
+ @staticmethod
258
+ def prompt_per_file_type_confirmation(
259
+ existing_files: Dict[str, List[str]], operation: str = 'download'
260
+ ) -> Dict[str, str]:
261
+ """
262
+ Prompt user for confirmation for each file type, combining interactive selections when possible.
263
+
264
+ Args:
265
+ existing_files: Dictionary of existing files by type
266
+ operation: Type of operation (e.g., "download", "generate")
267
+
268
+ Returns:
269
+ Dictionary mapping file type to user choice ('use', 'overwrite', 'proceed', or 'cancel')
270
+ """
271
+ ordered_types = []
272
+ for preferred in ['media', 'audio', 'video', 'subtitle']:
273
+ if preferred not in ordered_types:
274
+ ordered_types.append(preferred)
275
+ for file_type in existing_files.keys():
276
+ if file_type not in ordered_types:
277
+ ordered_types.append(file_type)
278
+
279
+ file_types_with_files = [ft for ft in ordered_types if existing_files.get(ft)]
280
+ choices = {ft: 'proceed' for ft in ordered_types}
281
+
282
+ if not file_types_with_files:
283
+ return choices
284
+
285
+ combined_result = FileExistenceManager._combined_file_type_prompt(
286
+ existing_files, operation, file_types_with_files
287
+ )
288
+ if combined_result is not None:
289
+ choices.update(combined_result)
290
+ return choices
291
+
292
+ for file_type in file_types_with_files:
293
+ choice = FileExistenceManager.prompt_file_type_confirmation(file_type, existing_files[file_type], operation)
294
+ choices[file_type] = choice
295
+ if choice == 'cancel':
296
+ for remaining in file_types_with_files[file_types_with_files.index(file_type) + 1 :]:
297
+ choices[remaining] = 'cancel'
298
+ break
299
+
300
+ return choices
301
+
302
+ @staticmethod
303
+ def is_interactive_mode() -> bool:
304
+ """Check if we're running in interactive mode (TTY available)"""
305
+ return sys.stdin.isatty() and sys.stdout.isatty()
306
+
307
+ @staticmethod
308
+ def _prompt_user_choice(
309
+ prompt_message: str,
310
+ options: Sequence[Tuple[str, str]],
311
+ default: str = None,
312
+ ) -> str:
313
+ """
314
+ Prompt the user to select from the provided options using an interactive selector when available.
315
+
316
+ Args:
317
+ prompt_message: Message displayed above the options.
318
+ options: Sequence of (label, value) option tuples.
319
+ default: Value to use when the user submits without a selection.
320
+
321
+ Returns:
322
+ The selected option value.
323
+ """
324
+ interactive_mode = FileExistenceManager.is_interactive_mode()
325
+
326
+ if interactive_mode and FileExistenceManager._supports_native_selector():
327
+ try:
328
+ return FileExistenceManager._prompt_with_arrow_keys(prompt_message, options, default)
329
+ except Exception:
330
+ # Fall back to other mechanisms if native selector fails
331
+ pass
332
+
333
+ if interactive_mode and questionary is not None and not FileExistenceManager._is_asyncio_loop_running():
334
+ try:
335
+ questionary_choices = [questionary.Choice(title=str(label), value=value) for label, value in options]
336
+ selection = questionary.select(
337
+ message=str(prompt_message),
338
+ choices=questionary_choices,
339
+ default=default,
340
+ ).ask()
341
+ except (KeyboardInterrupt, EOFError):
342
+ return 'cancel'
343
+ except Exception:
344
+ selection = None
345
+
346
+ if selection:
347
+ return selection
348
+ if default:
349
+ return default
350
+ return 'cancel'
351
+
352
+ return FileExistenceManager._prompt_with_numeric_input(prompt_message, options, default)
353
+
354
+ @staticmethod
355
+ def _prompt_with_numeric_input(
356
+ prompt_message: str,
357
+ options: Sequence[Tuple[str, str]],
358
+ default: str = None,
359
+ ) -> str:
360
+ numbered_choices = {str(index + 1): value for index, (_, value) in enumerate(options)}
361
+ label_lines = [f'{index + 1}. {label}' for index, (label, _) in enumerate(options)]
362
+ prompt_header = f'{prompt_message}'
363
+ print(prompt_header)
364
+ for line in label_lines:
365
+ print(line)
366
+
367
+ while True:
368
+ try:
369
+ raw_choice = input(f'\nEnter your choice (1-{len(options)}): ').strip()
370
+ except (EOFError, KeyboardInterrupt):
371
+ return 'cancel'
372
+
373
+ if not raw_choice and default:
374
+ return default
375
+
376
+ if raw_choice in numbered_choices:
377
+ return numbered_choices[raw_choice]
378
+
379
+ print('Invalid choice. Please enter one of the displayed numbers.')
380
+
381
+ @staticmethod
382
+ def _combined_file_type_prompt(
383
+ existing_files: Dict[str, List[str]],
384
+ operation: str,
385
+ file_types: Sequence[str],
386
+ ) -> Optional[Dict[str, str]]:
387
+ interactive_mode = FileExistenceManager.is_interactive_mode()
388
+ if not interactive_mode:
389
+ return None
390
+
391
+ if FileExistenceManager._supports_native_selector():
392
+ result = FileExistenceManager._prompt_combined_file_actions_native(existing_files, file_types, operation)
393
+ if result is not None:
394
+ return result
395
+
396
+ if questionary is not None and not FileExistenceManager._is_asyncio_loop_running():
397
+ return FileExistenceManager._prompt_combined_file_actions_questionary(existing_files, file_types, operation)
398
+
399
+ return None
400
+
401
+ @staticmethod
402
+ def _prompt_combined_file_actions_native(
403
+ existing_files: Dict[str, List[str]],
404
+ file_types: Sequence[str],
405
+ operation: str,
406
+ ) -> Optional[Dict[str, str]]:
407
+ states = {file_type: 'use' for file_type in file_types}
408
+ selected_index = 0
409
+ total_items = len(file_types) + 2 # file types + confirm + cancel
410
+ total_lines = len(file_types) + 4 # prompt + items + confirm/cancel + instructions
411
+ print()
412
+ FileExistenceManager._render_combined_file_menu(existing_files, file_types, states, selected_index, operation)
413
+
414
+ num_mapping = {str(index + 1): index for index in range(len(file_types))}
415
+
416
+ try:
417
+ if os.name == 'nt':
418
+ read_key = FileExistenceManager._read_key_windows
419
+ raw_mode = _NullContext()
420
+ else:
421
+ read_key = FileExistenceManager._read_key_posix
422
+ raw_mode = FileExistenceManager._stdin_raw_mode()
423
+
424
+ with raw_mode:
425
+ while True:
426
+ key = read_key()
427
+ if key == 'up':
428
+ selected_index = (selected_index - 1) % total_items
429
+ FileExistenceManager._refresh_combined_file_menu(
430
+ total_lines, existing_files, file_types, states, selected_index, operation
431
+ )
432
+ elif key == 'down':
433
+ selected_index = (selected_index + 1) % total_items
434
+ FileExistenceManager._refresh_combined_file_menu(
435
+ total_lines, existing_files, file_types, states, selected_index, operation
436
+ )
437
+ elif key in ('enter', 'space'):
438
+ if selected_index < len(file_types):
439
+ current_type = file_types[selected_index]
440
+ states[current_type] = 'overwrite' if states[current_type] == 'use' else 'use'
441
+ FileExistenceManager._refresh_combined_file_menu(
442
+ total_lines, existing_files, file_types, states, selected_index, operation
443
+ )
444
+ elif selected_index == len(file_types):
445
+ return FileExistenceManager._finalize_combined_states(file_types, states)
446
+ else:
447
+ return FileExistenceManager._cancel_combined_states(file_types)
448
+ elif key == 'cancel':
449
+ return FileExistenceManager._cancel_combined_states(file_types)
450
+ elif key in num_mapping:
451
+ selected_index = num_mapping[key]
452
+ FileExistenceManager._refresh_combined_file_menu(
453
+ total_lines, existing_files, file_types, states, selected_index, operation
454
+ )
455
+ except KeyboardInterrupt:
456
+ return FileExistenceManager._cancel_combined_states(file_types)
457
+ except Exception:
458
+ return None
459
+ finally:
460
+ print()
461
+
462
+ return None
463
+
464
+ @staticmethod
465
+ def _render_combined_file_menu(
466
+ existing_files: Dict[str, List[str]],
467
+ file_types: Sequence[str],
468
+ states: Dict[str, str],
469
+ selected_index: int,
470
+ operation: str,
471
+ ) -> None:
472
+ prompt = colorful.bold_black_on_cyan('Select how to handle existing files')
473
+ print(prompt)
474
+
475
+ for idx, file_type in enumerate(file_types):
476
+ label = FileExistenceManager.FILE_TYPE_INFO.get(file_type, ('📄', file_type.capitalize()))[1]
477
+ count = len(existing_files.get(file_type, []))
478
+ count_suffix = f' ({count} file{"s" if count != 1 else ""})'
479
+ state_plain = 'Use existing' if states[file_type] == 'use' else f'Overwrite ({operation})'
480
+ if idx == selected_index:
481
+ prefix = colorful.bold_white('>')
482
+ line = colorful.bold_black_on_cyan(f'{label}: {state_plain}{count_suffix}')
483
+ else:
484
+ prefix = ' '
485
+ if states[file_type] == 'use':
486
+ state_text = colorful.green('Use existing')
487
+ else:
488
+ state_text = colorful.yellow(f'Overwrite ({operation})')
489
+ line = f'{label}: {state_text}{count_suffix}'
490
+ print(f'{prefix} {line}')
491
+
492
+ confirm_index = len(file_types)
493
+ cancel_index = len(file_types) + 1
494
+
495
+ if selected_index == confirm_index:
496
+ confirm_line = colorful.bold_black_on_cyan('Confirm selections')
497
+ confirm_prefix = colorful.bold_white('>')
498
+ else:
499
+ confirm_line = colorful.bold_green('Confirm selections')
500
+ confirm_prefix = ' '
501
+ print(f'{confirm_prefix} {confirm_line}')
502
+
503
+ if selected_index == cancel_index:
504
+ cancel_line = colorful.bold_black_on_cyan('Cancel operation')
505
+ cancel_prefix = colorful.bold_white('>')
506
+ else:
507
+ cancel_line = colorful.bold_red('Cancel operation')
508
+ cancel_prefix = ' '
509
+ print(f'{cancel_prefix} {cancel_line}')
510
+
511
+ print(
512
+ 'Use '
513
+ + colorful.bold_black_on_cyan('↑/↓')
514
+ + ' to navigate. Enter/Space toggles an item. Confirm to proceed or cancel to abort.'
515
+ )
516
+
517
+ @staticmethod
518
+ def _refresh_combined_file_menu(
519
+ total_lines: int,
520
+ existing_files: Dict[str, List[str]],
521
+ file_types: Sequence[str],
522
+ states: Dict[str, str],
523
+ selected_index: int,
524
+ operation: str,
525
+ ) -> None:
526
+ move_up = '\033[F' * total_lines
527
+ clear_line = '\033[K'
528
+ sys.stdout.write(move_up)
529
+ sys.stdout.write(clear_line)
530
+ sys.stdout.flush()
531
+
532
+ prompt = colorful.bold_black_on_cyan('Select how to handle existing files')
533
+ print(prompt)
534
+
535
+ for idx, file_type in enumerate(file_types):
536
+ sys.stdout.write(clear_line)
537
+ label = FileExistenceManager.FILE_TYPE_INFO.get(file_type, ('📄', file_type.capitalize()))[1]
538
+ count = len(existing_files.get(file_type, []))
539
+ count_suffix = f' ({count} file{"s" if count != 1 else ""})'
540
+ state_plain = 'Use existing' if states[file_type] == 'use' else f'Overwrite ({operation})'
541
+ if idx == selected_index:
542
+ prefix = colorful.bold_white('>')
543
+ line = colorful.bold_black_on_cyan(f'{label}: {state_plain}{count_suffix}')
544
+ else:
545
+ prefix = ' '
546
+ if states[file_type] == 'use':
547
+ state_text = colorful.green('Use existing')
548
+ else:
549
+ state_text = colorful.yellow(f'Overwrite ({operation})')
550
+ line = f'{label}: {state_text}{count_suffix}'
551
+ print(f'{prefix} {line}')
552
+
553
+ sys.stdout.write(clear_line)
554
+ confirm_index = len(file_types)
555
+ cancel_index = len(file_types) + 1
556
+ if selected_index == confirm_index:
557
+ confirm_line = colorful.bold_black_on_cyan('Confirm selections')
558
+ confirm_prefix = colorful.bold_white('>')
559
+ else:
560
+ confirm_line = colorful.bold_green('Confirm selections')
561
+ confirm_prefix = ' '
562
+ print(f'{confirm_prefix} {confirm_line}')
563
+
564
+ sys.stdout.write(clear_line)
565
+ if selected_index == cancel_index:
566
+ cancel_line = colorful.bold_black_on_cyan('Cancel operation')
567
+ cancel_prefix = colorful.bold_white('>')
568
+ else:
569
+ cancel_line = colorful.bold_red('Cancel operation')
570
+ cancel_prefix = ' '
571
+ print(f'{cancel_prefix} {cancel_line}')
572
+
573
+ sys.stdout.write(clear_line)
574
+ print(
575
+ 'Use '
576
+ + colorful.bold_black_on_cyan('↑/↓')
577
+ + ' to navigate. Enter/Space toggles an item. Confirm to proceed or cancel to abort.'
578
+ )
579
+ sys.stdout.flush()
580
+
581
+ @staticmethod
582
+ def _prompt_combined_file_actions_questionary(
583
+ existing_files: Dict[str, List[str]],
584
+ file_types: Sequence[str],
585
+ operation: str,
586
+ ) -> Dict[str, str]:
587
+ label_choices = []
588
+ for file_type in file_types:
589
+ label = FileExistenceManager.FILE_TYPE_INFO.get(file_type, ('📄', file_type.capitalize()))[1]
590
+ count = len(existing_files.get(file_type, []))
591
+ count_suffix = f' ({count} file{"s" if count != 1 else ""})'
592
+ label_choices.append(questionary.Choice(title=f'{label}{count_suffix}', value=file_type))
593
+
594
+ label_choices.append(questionary.Choice(title='Cancel operation', value='__cancel__'))
595
+
596
+ try:
597
+ selection = questionary.checkbox(
598
+ message='Select file types to overwrite (others will use existing files)',
599
+ choices=label_choices,
600
+ instruction='Press Space to toggle overwrite. Press Enter to confirm.',
601
+ ).ask()
602
+ except (KeyboardInterrupt, EOFError):
603
+ return FileExistenceManager._cancel_combined_states(file_types)
604
+
605
+ if selection is None or '__cancel__' in selection:
606
+ return FileExistenceManager._cancel_combined_states(file_types)
607
+
608
+ states = {file_type: ('overwrite' if file_type in selection else 'use') for file_type in file_types}
609
+ return FileExistenceManager._finalize_combined_states(file_types, states)
610
+
611
+ @staticmethod
612
+ def _finalize_combined_states(file_types: Sequence[str], states: Dict[str, str]) -> Dict[str, str]:
613
+ return {file_type: ('overwrite' if states.get(file_type) == 'overwrite' else 'use') for file_type in file_types}
614
+
615
+ @staticmethod
616
+ def _cancel_combined_states(file_types: Sequence[str]) -> Dict[str, str]:
617
+ return {file_type: 'cancel' for file_type in file_types}
618
+
619
+ @staticmethod
620
+ def _supports_native_selector() -> bool:
621
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
622
+ return False
623
+ if os.name == 'nt':
624
+ try:
625
+ import msvcrt # noqa: F401
626
+ except ImportError:
627
+ return False
628
+ return True
629
+ try:
630
+ import termios # noqa: F401
631
+ import tty # noqa: F401
632
+ except ImportError:
633
+ return False
634
+ return True
635
+
636
+ @staticmethod
637
+ def _prompt_with_arrow_keys(
638
+ prompt_message: str,
639
+ options: Sequence[Tuple[str, str]],
640
+ default: str = None,
641
+ ) -> str:
642
+ value_by_number = {str(index + 1): value for index, (_, value) in enumerate(options)}
643
+ selected_index = 0
644
+ if default is not None:
645
+ for idx, (_, value) in enumerate(options):
646
+ if value == default:
647
+ selected_index = idx
648
+ break
649
+
650
+ helper_lines = 2 # prompt line + hint line
651
+ total_lines = len(options) + helper_lines
652
+ print() # spacer before menu
653
+ FileExistenceManager._render_menu(prompt_message, options, selected_index)
654
+
655
+ try:
656
+ if os.name == 'nt':
657
+ read_key = FileExistenceManager._read_key_windows
658
+ raw_mode = _NullContext()
659
+ else:
660
+ read_key = FileExistenceManager._read_key_posix
661
+ raw_mode = FileExistenceManager._stdin_raw_mode()
662
+
663
+ with raw_mode:
664
+ while True:
665
+ key = read_key()
666
+ if key == 'up':
667
+ selected_index = (selected_index - 1) % len(options)
668
+ FileExistenceManager._refresh_menu(total_lines, prompt_message, options, selected_index)
669
+ elif key == 'down':
670
+ selected_index = (selected_index + 1) % len(options)
671
+ FileExistenceManager._refresh_menu(total_lines, prompt_message, options, selected_index)
672
+ elif key == 'enter':
673
+ return options[selected_index][1]
674
+ elif key in value_by_number:
675
+ return value_by_number[key]
676
+ elif key == 'cancel':
677
+ return 'cancel'
678
+ except KeyboardInterrupt:
679
+ return 'cancel'
680
+ except Exception:
681
+ return FileExistenceManager._prompt_with_numeric_input(prompt_message, options, default)
682
+ finally:
683
+ print()
684
+
685
+ @staticmethod
686
+ def _render_menu(prompt_message: str, options: Sequence[Tuple[str, str]], selected_index: int) -> None:
687
+ prompt = f'{prompt_message}'
688
+ print(prompt)
689
+ for idx, (label, _) in enumerate(options):
690
+ if idx == selected_index:
691
+ prefix = colorful.bold_white('>')
692
+ suffix = colorful.bold_black_on_cyan(str(label))
693
+ else:
694
+ prefix = ' '
695
+ suffix = label
696
+ print(f'{prefix} {suffix}')
697
+ print('Use ' + colorful.bold_black_on_cyan('↑/↓') + ' to move, Enter to confirm, or press a number to choose.')
698
+
699
+ @staticmethod
700
+ def _refresh_menu(
701
+ total_lines: int,
702
+ prompt_message: str,
703
+ options: Sequence[Tuple[str, str]],
704
+ selected_index: int,
705
+ ) -> None:
706
+ move_up = '\033[F' * total_lines
707
+ clear_line = '\033[K'
708
+ sys.stdout.write(move_up)
709
+ sys.stdout.write(clear_line)
710
+ sys.stdout.flush()
711
+ prompt = f'{prompt_message}'
712
+ print(prompt)
713
+ for idx, (label, _) in enumerate(options):
714
+ sys.stdout.write(clear_line)
715
+ if idx == selected_index:
716
+ prefix = colorful.bold_white('>')
717
+ suffix = colorful.bold_black_on_cyan(str(label))
718
+ else:
719
+ prefix = ' '
720
+ suffix = label
721
+ print(f'{prefix} {suffix}')
722
+ sys.stdout.write(clear_line)
723
+ print('Use ' + colorful.bold_black_on_cyan('↑/↓') + ' to move, Enter to confirm, or press a number to choose.')
724
+ sys.stdout.flush()
725
+
726
+ @staticmethod
727
+ def _read_key_windows() -> str:
728
+ import msvcrt
729
+
730
+ while True:
731
+ ch = msvcrt.getwch()
732
+ if ch in ('\x00', '\xe0'):
733
+ extended = msvcrt.getwch()
734
+ if extended == 'H':
735
+ return 'up'
736
+ if extended == 'P':
737
+ return 'down'
738
+ continue
739
+ if ch in ('\r', '\n'):
740
+ return 'enter'
741
+ if ch == ' ':
742
+ return 'space'
743
+ if ch.isdigit():
744
+ return ch
745
+ if ch.lower() in ('j', 'k'):
746
+ return 'down' if ch.lower() == 'j' else 'up'
747
+ if ch == '\x1b':
748
+ return 'cancel'
749
+ if ch == '\x03':
750
+ raise KeyboardInterrupt
751
+
752
+ @staticmethod
753
+ def _read_key_posix() -> str:
754
+ ch = sys.stdin.read(1)
755
+ if ch == '\x1b':
756
+ seq = sys.stdin.read(2)
757
+ if seq == '[A':
758
+ return 'up'
759
+ if seq == '[B':
760
+ return 'down'
761
+ return 'cancel'
762
+ if ch in ('\r', '\n'):
763
+ return 'enter'
764
+ if ch == ' ':
765
+ return 'space'
766
+ if ch.isdigit():
767
+ return ch
768
+ if ch.lower() == 'j':
769
+ return 'down'
770
+ if ch.lower() == 'k':
771
+ return 'up'
772
+ if ch == '\x03':
773
+ raise KeyboardInterrupt
774
+ return ''
775
+
776
+ @staticmethod
777
+ def _stdin_raw_mode():
778
+ if os.name == 'nt':
779
+ return _NullContext()
780
+
781
+ import termios
782
+ import tty
783
+
784
+ fd = sys.stdin.fileno()
785
+ old_settings = termios.tcgetattr(fd)
786
+
787
+ @contextmanager
788
+ def _raw():
789
+ try:
790
+ tty.setcbreak(fd)
791
+ yield
792
+ finally:
793
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
794
+
795
+ return _raw()
796
+
797
+ @staticmethod
798
+ def _is_asyncio_loop_running() -> bool:
799
+ """Detect whether an asyncio event loop is already running in this thread."""
800
+ try:
801
+ asyncio.get_running_loop()
802
+ except RuntimeError:
803
+ return False
804
+ return True
805
+
806
+
807
+ class _NullContext:
808
+ def __enter__(self):
809
+ return None
810
+
811
+ def __exit__(self, exc_type, exc_val, exc_tb):
812
+ return False