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