lattifai 0.4.0__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.
- lattifai/bin/agent.py +4 -3
- lattifai/io/reader.py +4 -1
- lattifai/io/text_parser.py +75 -0
- lattifai/tokenizer/tokenizer.py +10 -1
- lattifai/workflows/__init__.py +34 -0
- lattifai/workflows/agents.py +10 -0
- lattifai/workflows/base.py +192 -0
- lattifai/workflows/file_manager.py +812 -0
- lattifai/workflows/gemini.py +159 -0
- lattifai/workflows/youtube.py +931 -0
- {lattifai-0.4.0.dist-info → lattifai-0.4.2.dist-info}/METADATA +6 -7
- {lattifai-0.4.0.dist-info → lattifai-0.4.2.dist-info}/RECORD +16 -9
- {lattifai-0.4.0.dist-info → lattifai-0.4.2.dist-info}/top_level.txt +0 -1
- {lattifai-0.4.0.dist-info → lattifai-0.4.2.dist-info}/WHEEL +0 -0
- {lattifai-0.4.0.dist-info → lattifai-0.4.2.dist-info}/entry_points.txt +0 -0
- {lattifai-0.4.0.dist-info → lattifai-0.4.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|