fbuild 1.1.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.

Potentially problematic release.


This version of fbuild might be problematic. Click here for more details.

Files changed (93) hide show
  1. fbuild/__init__.py +0 -0
  2. fbuild/assets/example.txt +1 -0
  3. fbuild/build/__init__.py +117 -0
  4. fbuild/build/archive_creator.py +186 -0
  5. fbuild/build/binary_generator.py +444 -0
  6. fbuild/build/build_component_factory.py +131 -0
  7. fbuild/build/build_state.py +325 -0
  8. fbuild/build/build_utils.py +98 -0
  9. fbuild/build/compilation_executor.py +422 -0
  10. fbuild/build/compiler.py +165 -0
  11. fbuild/build/compiler_avr.py +574 -0
  12. fbuild/build/configurable_compiler.py +612 -0
  13. fbuild/build/configurable_linker.py +637 -0
  14. fbuild/build/flag_builder.py +186 -0
  15. fbuild/build/library_dependency_processor.py +185 -0
  16. fbuild/build/linker.py +708 -0
  17. fbuild/build/orchestrator.py +67 -0
  18. fbuild/build/orchestrator_avr.py +656 -0
  19. fbuild/build/orchestrator_esp32.py +797 -0
  20. fbuild/build/orchestrator_teensy.py +543 -0
  21. fbuild/build/source_compilation_orchestrator.py +220 -0
  22. fbuild/build/source_scanner.py +516 -0
  23. fbuild/cli.py +566 -0
  24. fbuild/cli_utils.py +312 -0
  25. fbuild/config/__init__.py +16 -0
  26. fbuild/config/board_config.py +457 -0
  27. fbuild/config/board_loader.py +92 -0
  28. fbuild/config/ini_parser.py +209 -0
  29. fbuild/config/mcu_specs.py +88 -0
  30. fbuild/daemon/__init__.py +34 -0
  31. fbuild/daemon/client.py +929 -0
  32. fbuild/daemon/compilation_queue.py +293 -0
  33. fbuild/daemon/daemon.py +474 -0
  34. fbuild/daemon/daemon_context.py +196 -0
  35. fbuild/daemon/error_collector.py +263 -0
  36. fbuild/daemon/file_cache.py +332 -0
  37. fbuild/daemon/lock_manager.py +270 -0
  38. fbuild/daemon/logging_utils.py +149 -0
  39. fbuild/daemon/messages.py +301 -0
  40. fbuild/daemon/operation_registry.py +288 -0
  41. fbuild/daemon/process_tracker.py +366 -0
  42. fbuild/daemon/processors/__init__.py +12 -0
  43. fbuild/daemon/processors/build_processor.py +157 -0
  44. fbuild/daemon/processors/deploy_processor.py +327 -0
  45. fbuild/daemon/processors/monitor_processor.py +146 -0
  46. fbuild/daemon/request_processor.py +401 -0
  47. fbuild/daemon/status_manager.py +216 -0
  48. fbuild/daemon/subprocess_manager.py +316 -0
  49. fbuild/deploy/__init__.py +17 -0
  50. fbuild/deploy/deployer.py +67 -0
  51. fbuild/deploy/deployer_esp32.py +314 -0
  52. fbuild/deploy/monitor.py +495 -0
  53. fbuild/interrupt_utils.py +34 -0
  54. fbuild/packages/__init__.py +53 -0
  55. fbuild/packages/archive_utils.py +1098 -0
  56. fbuild/packages/arduino_core.py +412 -0
  57. fbuild/packages/cache.py +249 -0
  58. fbuild/packages/downloader.py +366 -0
  59. fbuild/packages/framework_esp32.py +538 -0
  60. fbuild/packages/framework_teensy.py +346 -0
  61. fbuild/packages/github_utils.py +96 -0
  62. fbuild/packages/header_trampoline_cache.py +394 -0
  63. fbuild/packages/library_compiler.py +203 -0
  64. fbuild/packages/library_manager.py +549 -0
  65. fbuild/packages/library_manager_esp32.py +413 -0
  66. fbuild/packages/package.py +163 -0
  67. fbuild/packages/platform_esp32.py +383 -0
  68. fbuild/packages/platform_teensy.py +312 -0
  69. fbuild/packages/platform_utils.py +131 -0
  70. fbuild/packages/platformio_registry.py +325 -0
  71. fbuild/packages/sdk_utils.py +231 -0
  72. fbuild/packages/toolchain.py +436 -0
  73. fbuild/packages/toolchain_binaries.py +196 -0
  74. fbuild/packages/toolchain_esp32.py +484 -0
  75. fbuild/packages/toolchain_metadata.py +185 -0
  76. fbuild/packages/toolchain_teensy.py +404 -0
  77. fbuild/platform_configs/esp32.json +150 -0
  78. fbuild/platform_configs/esp32c2.json +144 -0
  79. fbuild/platform_configs/esp32c3.json +143 -0
  80. fbuild/platform_configs/esp32c5.json +151 -0
  81. fbuild/platform_configs/esp32c6.json +151 -0
  82. fbuild/platform_configs/esp32p4.json +149 -0
  83. fbuild/platform_configs/esp32s3.json +151 -0
  84. fbuild/platform_configs/imxrt1062.json +56 -0
  85. fbuild-1.1.0.dist-info/METADATA +447 -0
  86. fbuild-1.1.0.dist-info/RECORD +93 -0
  87. fbuild-1.1.0.dist-info/WHEEL +5 -0
  88. fbuild-1.1.0.dist-info/entry_points.txt +5 -0
  89. fbuild-1.1.0.dist-info/licenses/LICENSE +21 -0
  90. fbuild-1.1.0.dist-info/top_level.txt +2 -0
  91. fbuild_lint/__init__.py +0 -0
  92. fbuild_lint/ruff_plugins/__init__.py +0 -0
  93. fbuild_lint/ruff_plugins/keyboard_interrupt_checker.py +158 -0
@@ -0,0 +1,1098 @@
1
+ """Archive Extraction Utilities.
2
+
3
+ This module provides utilities for downloading and extracting compressed archives,
4
+ particularly for .tar.xz files used in embedded development toolchains and frameworks.
5
+ """
6
+
7
+ import gc
8
+ import platform
9
+ import shutil
10
+ import tarfile
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any, Callable, Optional
14
+
15
+ from .downloader import DownloadError, ExtractionError, PackageDownloader
16
+
17
+
18
+ class ArchiveExtractionError(Exception):
19
+ """Raised when archive extraction operations fail."""
20
+
21
+ pass
22
+
23
+
24
+ class ArchiveExtractor:
25
+ """Handles downloading and extracting compressed archives.
26
+
27
+ Supports .tar.xz archives with automatic cleanup and proper error handling.
28
+ """
29
+
30
+ def __init__(self, show_progress: bool = True):
31
+ """Initialize archive extractor.
32
+
33
+ Args:
34
+ show_progress: Whether to show download/extraction progress
35
+ """
36
+ self.show_progress = show_progress
37
+ self.downloader = PackageDownloader()
38
+ self._is_windows = platform.system() == "Windows"
39
+
40
+ def _retry_file_operation(self, operation: Callable[..., Any], *args: Any, max_retries: int = 5, **kwargs: Any) -> Any:
41
+ """Retry a file operation with exponential backoff on Windows.
42
+
43
+ On Windows, file operations can fail with PermissionError, OSError,
44
+ or FileNotFoundError due to file handle delays. This function retries
45
+ the operation with exponential backoff.
46
+
47
+ Args:
48
+ operation: Function to call (e.g., Path.unlink, shutil.rmtree)
49
+ *args: Positional arguments for the operation
50
+ max_retries: Maximum number of retry attempts
51
+ **kwargs: Keyword arguments for the operation
52
+
53
+ Raises:
54
+ The last exception if all retries fail
55
+ """
56
+ if not self._is_windows:
57
+ # No retry overhead on non-Windows platforms
58
+ return operation(*args, **kwargs)
59
+
60
+ delay = 0.05 # Start with 50ms
61
+ last_error = None
62
+
63
+ for attempt in range(max_retries):
64
+ try:
65
+ if attempt > 0:
66
+ gc.collect() # Force garbage collection
67
+ time.sleep(delay)
68
+ if self.show_progress:
69
+ print(f" [Windows] Retrying file operation (attempt {attempt + 1}/{max_retries})...")
70
+
71
+ return operation(*args, **kwargs)
72
+
73
+ except (PermissionError, OSError, FileNotFoundError) as e:
74
+ last_error = e
75
+ if attempt < max_retries - 1:
76
+ delay = min(delay * 2, 2.0) # Exponential backoff, max 2s
77
+ continue
78
+ else:
79
+ # Last attempt failed
80
+ raise
81
+
82
+ # Should not reach here, but just in case
83
+ if last_error:
84
+ raise last_error
85
+
86
+ def _copytree_with_retry(self, src: Path, dst: Path) -> None:
87
+ """Recursively copy directory tree with retry logic for each operation.
88
+
89
+ Unlike shutil.copytree, this retries each individual file/directory operation,
90
+ which is more robust on Windows where file handles may not be immediately available.
91
+
92
+ Args:
93
+ src: Source directory path
94
+ dst: Destination directory path
95
+ """
96
+ # Create destination directory with retry
97
+ self._retry_file_operation(dst.mkdir, parents=True, exist_ok=True)
98
+
99
+ # Iterate over source items - wrap iterdir() in retry logic for Windows
100
+ # On Windows, directory handles may not be immediately available after extraction
101
+ def get_items():
102
+ return list(src.iterdir())
103
+
104
+ items = self._retry_file_operation(get_items)
105
+ assert items is not None
106
+
107
+ for item in items:
108
+ src_item = item
109
+ dst_item = dst / item.name
110
+
111
+ # Check if item is a directory - wrap in retry logic
112
+ def is_directory():
113
+ return src_item.is_dir()
114
+
115
+ is_dir = self._retry_file_operation(is_directory)
116
+
117
+ if is_dir:
118
+ # Recursively copy subdirectory
119
+ self._copytree_with_retry(src_item, dst_item)
120
+ else:
121
+ # Copy file with retry
122
+ self._retry_file_operation(shutil.copy2, src_item, dst_item)
123
+
124
+ def download_and_extract(
125
+ self,
126
+ url: str,
127
+ target_dir: Path,
128
+ description: str,
129
+ cache_dir: Optional[Path] = None,
130
+ ) -> None:
131
+ """Download and extract a .tar.xz archive.
132
+
133
+ Args:
134
+ url: URL to the .tar.xz archive
135
+ target_dir: Directory to extract contents into
136
+ description: Human-readable description for progress messages
137
+ cache_dir: Optional directory to cache the downloaded archive
138
+ (defaults to parent of target_dir)
139
+
140
+ Raises:
141
+ DownloadError: If download fails
142
+ ExtractionError: If extraction fails
143
+ ArchiveExtractionError: If any other extraction operation fails
144
+ """
145
+ try:
146
+ archive_name = Path(url).name
147
+ cache_dir = cache_dir or target_dir.parent
148
+ archive_path = cache_dir / archive_name
149
+
150
+ # Download if not cached
151
+ if not archive_path.exists():
152
+ if self.show_progress:
153
+ print(f"Downloading {description}...")
154
+ archive_path.parent.mkdir(parents=True, exist_ok=True)
155
+ self.downloader.download(url, archive_path, show_progress=self.show_progress)
156
+ else:
157
+ if self.show_progress:
158
+ print(f"Using cached {description} archive")
159
+
160
+ # Extract to target directory
161
+ if self.show_progress:
162
+ print(f"Extracting {description}...")
163
+
164
+ # Detect archive type and use appropriate extraction method
165
+ if archive_path.suffix == ".zip":
166
+ self._extract_zip(archive_path, target_dir)
167
+ elif archive_path.name.endswith(".tar.xz") or archive_path.name.endswith(".txz"):
168
+ self._extract_tar_xz(archive_path, target_dir)
169
+ elif archive_path.name.endswith((".tar.gz", ".tgz")):
170
+ self._extract_tar_gz(archive_path, target_dir)
171
+ else:
172
+ # Default to tar.xz for backwards compatibility
173
+ self._extract_tar_xz(archive_path, target_dir)
174
+
175
+ except (DownloadError, ExtractionError):
176
+ raise
177
+ except KeyboardInterrupt as ke:
178
+ from fbuild.interrupt_utils import handle_keyboard_interrupt_properly
179
+
180
+ handle_keyboard_interrupt_properly(ke)
181
+ except Exception as e:
182
+ raise ArchiveExtractionError(f"Failed to extract {description}: {e}")
183
+
184
+ def _extract_tar_xz(self, archive_path: Path, target_dir: Path) -> None:
185
+ """Extract a .tar.xz archive to target directory.
186
+
187
+ Handles archives that extract to a single subdirectory or directly to multiple files.
188
+
189
+ Args:
190
+ archive_path: Path to the .tar.xz archive file
191
+ target_dir: Directory to extract contents into
192
+
193
+ Raises:
194
+ ExtractionError: If extraction fails
195
+ """
196
+ # Create temp extraction directory
197
+ temp_extract = target_dir.parent / f"temp_extract_{archive_path.name}"
198
+ temp_extract.mkdir(parents=True, exist_ok=True)
199
+
200
+ # Verify temp directory was created
201
+ if not temp_extract.exists():
202
+ raise ExtractionError(f"Failed to create temp extraction directory: {temp_extract}")
203
+
204
+ try:
205
+ # Extract .tar.xz archive with progress tracking
206
+ with tarfile.open(archive_path, "r:xz") as tar:
207
+ members = tar.getmembers()
208
+ total_members = len(members)
209
+
210
+ if self.show_progress:
211
+ from tqdm import tqdm
212
+
213
+ with tqdm(total=total_members, unit="file", desc=f"Extracting {archive_path.name}") as pbar:
214
+ for member in members:
215
+ # On Windows, individual file extractions can hit Permission Errors
216
+ # due to file handle delays or antivirus/Windows Defender scanning
217
+ # Wrap each extract in retry logic
218
+ if self._is_windows:
219
+
220
+ def extract_member():
221
+ tar.extract(member, temp_extract)
222
+
223
+ self._retry_file_operation(extract_member, max_retries=5)
224
+ else:
225
+ tar.extract(member, temp_extract)
226
+ pbar.update(1)
227
+ else:
228
+ tar.extractall(temp_extract)
229
+
230
+ # On Windows, force garbage collection and add LONG delay to let file handles close
231
+ # Large archives (3000+ files) need extensive time for Windows to stabilize
232
+ if self._is_windows:
233
+ gc.collect()
234
+ time.sleep(5.0) # Increased to 5s - filesystem stabilization for large archives
235
+
236
+ # NOTE: Do NOT verify temp_extract.exists() on Windows!
237
+ # Path.exists() is unreliable on Windows immediately after extraction
238
+ # due to file handle delays - it can return False even when directory was created
239
+ # If tar.extractall() didn't raise an exception, the extraction succeeded
240
+
241
+ # Find the extracted directory
242
+ # Usually it's a subdirectory like "esp32/" or directly extracted
243
+ # Wrap in retry logic - Windows may not have the directory handle ready yet
244
+ def get_extracted_items():
245
+ # On Windows, the directory might not be accessible immediately after creation
246
+ # Even after 5s delay, iterdir() can fail with WinError 3
247
+ # Retry with increasing delays to give Windows time to stabilize
248
+ return list(temp_extract.iterdir())
249
+
250
+ extracted_items = self._retry_file_operation(get_extracted_items, max_retries=10) if self._is_windows else list(temp_extract.iterdir())
251
+ assert extracted_items is not None
252
+
253
+ # Check if single item is a directory - wrap in retry logic for Windows
254
+ single_subdir = None
255
+ if len(extracted_items) == 1:
256
+ if self._is_windows:
257
+
258
+ def check_is_dir():
259
+ return extracted_items[0].is_dir()
260
+
261
+ is_single_dir = self._retry_file_operation(check_is_dir)
262
+ else:
263
+ is_single_dir = extracted_items[0].is_dir()
264
+
265
+ if is_single_dir:
266
+ # Single directory extracted - we'll move it atomically
267
+ single_subdir = extracted_items[0]
268
+
269
+ # On Windows, add another delay before move operation
270
+ if self._is_windows:
271
+ time.sleep(1.0) # Additional delay for directory handles
272
+
273
+ # Move directory using shutil.move() for entire tree (atomic operation)
274
+ # This is MUCH more reliable on Windows than iterating through individual files
275
+ # Single atomic operation instead of 3000+ individual file operations
276
+ if self.show_progress:
277
+ print(f"Moving extracted files to {target_dir.name}...")
278
+
279
+ # Track whether we need to remove target before move
280
+ target_removal_failed = False
281
+
282
+ # Remove existing target directory if it exists
283
+ if target_dir.exists():
284
+ if self.show_progress:
285
+ print(f" Removing existing {target_dir.name}...")
286
+
287
+ # On Windows, prepare for directory removal
288
+ if self._is_windows:
289
+ gc.collect() # Force garbage collection to release handles
290
+ time.sleep(1.0) # Give Windows time to release handles
291
+
292
+ try:
293
+ # Remove ignore_errors - let retry logic handle errors
294
+ # Retry with more attempts since directory removal is difficult on Windows
295
+ self._retry_file_operation(shutil.rmtree, target_dir, max_retries=10)
296
+
297
+ # Extra delay after successful removal on Windows
298
+ if self._is_windows:
299
+ time.sleep(0.5)
300
+ except KeyboardInterrupt as ke:
301
+ from fbuild.interrupt_utils import (
302
+ handle_keyboard_interrupt_properly,
303
+ )
304
+
305
+ handle_keyboard_interrupt_properly(ke)
306
+ except Exception as e:
307
+ # If removal fails, we CANNOT use shutil.move() because it will nest directories
308
+ # We must use the fallback individual file operations instead
309
+ if self.show_progress:
310
+ print(f" [Warning] Could not remove existing directory after 10 attempts: {e}")
311
+ print(" [Warning] Using individual file operations to overwrite...")
312
+ target_removal_failed = True
313
+ # DON'T re-raise - will use fallback path below
314
+
315
+ # Use shutil.move() for entire directory tree - single atomic operation
316
+ # But ONLY if target_removal_failed is False
317
+ # (If target couldn't be removed, shutil.move() will nest directories incorrectly)
318
+ if not target_removal_failed:
319
+ try:
320
+ if single_subdir:
321
+ # Move single extracted subdirectory to target location
322
+ # shutil.move(src, dst) moves src TO dst (renames it)
323
+ if self.show_progress:
324
+ print(f" [DEBUG] Moving {single_subdir.name} to {target_dir}")
325
+ print(f" [DEBUG] Source: {single_subdir}")
326
+ print(f" [DEBUG] Target: {target_dir}")
327
+ print(f" [DEBUG] Source exists: {single_subdir.exists()}")
328
+ print(f" [DEBUG] Target exists before: {target_dir.exists()}")
329
+
330
+ if self._is_windows:
331
+ result = self._retry_file_operation(shutil.move, str(single_subdir), str(target_dir))
332
+ if self.show_progress:
333
+ print(f" [DEBUG] shutil.move returned: {result}")
334
+ else:
335
+ shutil.move(str(single_subdir), str(target_dir))
336
+
337
+ if self.show_progress:
338
+ print(f" [DEBUG] Target exists after: {target_dir.exists()}")
339
+ if target_dir.exists() and target_dir.is_dir():
340
+ try:
341
+ items = list(target_dir.iterdir())
342
+ print(f" [DEBUG] Target has {len(items)} items")
343
+ if items:
344
+ print(f" [DEBUG] First 5 items: {[i.name for i in items[:5]]}")
345
+ except KeyboardInterrupt as ke:
346
+ from fbuild.interrupt_utils import (
347
+ handle_keyboard_interrupt_properly,
348
+ )
349
+
350
+ handle_keyboard_interrupt_properly(ke)
351
+ except Exception as e:
352
+ print(f" [DEBUG] Could not list target: {e}")
353
+ else:
354
+ # Multiple items - need to move temp_extract contents
355
+ # For this case, we need to move items individually (rare case)
356
+ raise Exception("Multiple items - need individual move")
357
+
358
+ if self.show_progress:
359
+ print(f" Successfully moved to {target_dir.name}")
360
+
361
+ except KeyboardInterrupt as ke:
362
+ from fbuild.interrupt_utils import (
363
+ handle_keyboard_interrupt_properly,
364
+ )
365
+
366
+ handle_keyboard_interrupt_properly(ke)
367
+ except Exception as move_error:
368
+ # If shutil.move() fails, fall back to individual file operations
369
+ if self.show_progress:
370
+ print(f" [Warning] Atomic move failed: {move_error}")
371
+ print(" Falling back to individual file operations...")
372
+
373
+ # Determine source directory for fallback
374
+ source_for_fallback = single_subdir if single_subdir else temp_extract
375
+
376
+ # Ensure target exists
377
+ target_dir.mkdir(parents=True, exist_ok=True)
378
+
379
+ # Get items with retry on Windows
380
+ def get_source_items():
381
+ return list(source_for_fallback.iterdir())
382
+
383
+ source_items = self._retry_file_operation(get_source_items) if self._is_windows else list(source_for_fallback.iterdir())
384
+
385
+ # Move items individually with retry
386
+ for item in source_items:
387
+ dest = target_dir / item.name
388
+ if dest.exists():
389
+ if dest.is_dir():
390
+ self._retry_file_operation(shutil.rmtree, dest, ignore_errors=True)
391
+ else:
392
+ self._retry_file_operation(dest.unlink)
393
+
394
+ # Try rename first, fall back to copy
395
+ try:
396
+ self._retry_file_operation(item.rename, dest)
397
+ except OSError:
398
+ if item.is_dir():
399
+ self._copytree_with_retry(item, dest)
400
+ else:
401
+ self._retry_file_operation(shutil.copy2, item, dest)
402
+
403
+ else:
404
+ # target_removal_failed is True - use individual file operations directly
405
+ # Cannot use shutil.move() because target still exists and it would nest
406
+ if self.show_progress:
407
+ print(" Using individual file copy to overwrite existing files...")
408
+
409
+ # Determine source directory
410
+ source_for_overwrite = single_subdir if single_subdir else temp_extract
411
+
412
+ # Ensure target exists
413
+ target_dir.mkdir(parents=True, exist_ok=True)
414
+
415
+ # Get items with retry on Windows
416
+ def get_source_items():
417
+ return list(source_for_overwrite.iterdir())
418
+
419
+ source_items = self._retry_file_operation(get_source_items) if self._is_windows else list(source_for_overwrite.iterdir())
420
+ assert source_items is not None
421
+
422
+ # Copy/overwrite items individually with retry
423
+ for item in source_items:
424
+ dest = target_dir / item.name
425
+ if dest.exists():
426
+ if dest.is_dir():
427
+ # Try to remove existing directory
428
+ try:
429
+ self._retry_file_operation(shutil.rmtree, dest, max_retries=10)
430
+ except KeyboardInterrupt as ke:
431
+ from fbuild.interrupt_utils import (
432
+ handle_keyboard_interrupt_properly,
433
+ )
434
+
435
+ handle_keyboard_interrupt_properly(ke)
436
+ except Exception:
437
+ # If can't remove, skip this item (maybe locked)
438
+ if self.show_progress:
439
+ print(f" [Warning] Could not overwrite {dest.name}, skipping...")
440
+ continue
441
+ else:
442
+ try:
443
+ self._retry_file_operation(dest.unlink, max_retries=5)
444
+ except KeyboardInterrupt as ke:
445
+ from fbuild.interrupt_utils import (
446
+ handle_keyboard_interrupt_properly,
447
+ )
448
+
449
+ handle_keyboard_interrupt_properly(ke)
450
+ except Exception:
451
+ # If can't remove file, skip
452
+ if self.show_progress:
453
+ print(f" [Warning] Could not overwrite {dest.name}, skipping...")
454
+ continue
455
+
456
+ # Try rename first, fall back to copy
457
+ try:
458
+ self._retry_file_operation(item.rename, dest)
459
+ except OSError:
460
+ if item.is_dir():
461
+ self._copytree_with_retry(item, dest)
462
+ else:
463
+ self._retry_file_operation(shutil.copy2, item, dest)
464
+
465
+ if self.show_progress:
466
+ print(f" Successfully extracted to {target_dir.name}")
467
+
468
+ except KeyboardInterrupt as ke:
469
+ from fbuild.interrupt_utils import handle_keyboard_interrupt_properly
470
+
471
+ handle_keyboard_interrupt_properly(ke)
472
+ except Exception as e:
473
+ raise ExtractionError(f"Failed to extract archive: {e}")
474
+ finally:
475
+ # Clean up temp directory
476
+ if temp_extract.exists():
477
+ shutil.rmtree(temp_extract, ignore_errors=True)
478
+
479
+ def _extract_zip(self, archive_path: Path, target_dir: Path) -> None:
480
+ """Extract a .zip archive to target directory.
481
+
482
+ Handles archives that extract to a single subdirectory or directly to multiple files.
483
+
484
+ Args:
485
+ archive_path: Path to the .zip archive file
486
+ target_dir: Directory to extract contents into
487
+
488
+ Raises:
489
+ ExtractionError: If extraction fails
490
+ """
491
+ import zipfile
492
+
493
+ # Create temp extraction directory
494
+ temp_extract = target_dir.parent / f"temp_extract_{archive_path.name}"
495
+ temp_extract.mkdir(parents=True, exist_ok=True)
496
+
497
+ try:
498
+ # Extract .zip archive with progress tracking
499
+ with zipfile.ZipFile(archive_path, "r") as zf:
500
+ members = zf.namelist()
501
+ total_members = len(members)
502
+
503
+ if self.show_progress:
504
+ from tqdm import tqdm
505
+
506
+ with tqdm(total=total_members, unit="file", desc=f"Extracting {archive_path.name}") as pbar:
507
+ for member in members:
508
+ # On Windows, individual file extractions can hit Permission Errors
509
+ # due to file handle delays or antivirus/Windows Defender scanning
510
+ # Wrap each extract in retry logic
511
+ if self._is_windows:
512
+
513
+ def extract_member():
514
+ zf.extract(member, temp_extract)
515
+
516
+ self._retry_file_operation(extract_member, max_retries=5)
517
+ else:
518
+ zf.extract(member, temp_extract)
519
+ pbar.update(1)
520
+ else:
521
+ zf.extractall(temp_extract)
522
+
523
+ # On Windows, force garbage collection and add LONG delay to let file handles close
524
+ # Large archives (3000+ files) need extensive time for Windows to stabilize
525
+ if self._is_windows:
526
+ gc.collect()
527
+ time.sleep(5.0) # Increased to 5s - filesystem stabilization for large archives
528
+
529
+ # Find the extracted directory
530
+ # Wrap in retry logic - Windows may not have the directory handle ready yet
531
+ def get_extracted_items():
532
+ return list(temp_extract.iterdir())
533
+
534
+ extracted_items = self._retry_file_operation(get_extracted_items) if self._is_windows else list(temp_extract.iterdir())
535
+ assert extracted_items is not None
536
+
537
+ # Check if single item is a directory - wrap in retry logic for Windows
538
+ single_subdir = None
539
+ if len(extracted_items) == 1:
540
+ if self._is_windows:
541
+
542
+ def check_is_dir():
543
+ return extracted_items[0].is_dir()
544
+
545
+ is_single_dir = self._retry_file_operation(check_is_dir)
546
+ else:
547
+ is_single_dir = extracted_items[0].is_dir()
548
+
549
+ if is_single_dir:
550
+ # Single directory extracted - we'll move it atomically
551
+ single_subdir = extracted_items[0]
552
+
553
+ # On Windows, add another delay before move operation
554
+ if self._is_windows:
555
+ time.sleep(1.0) # Additional delay for directory handles
556
+
557
+ # Move directory using shutil.move() for entire tree (atomic operation)
558
+ # This is MUCH more reliable on Windows than iterating through individual files
559
+ # Single atomic operation instead of 3000+ individual file operations
560
+ if self.show_progress:
561
+ print(f"Moving extracted files to {target_dir.name}...")
562
+
563
+ # Track whether we need to remove target before move
564
+ target_removal_failed = False
565
+
566
+ # Remove existing target directory if it exists
567
+ if target_dir.exists():
568
+ if self.show_progress:
569
+ print(f" Removing existing {target_dir.name}...")
570
+
571
+ # On Windows, prepare for directory removal
572
+ if self._is_windows:
573
+ gc.collect() # Force garbage collection to release handles
574
+ time.sleep(1.0) # Give Windows time to release handles
575
+
576
+ try:
577
+ # Remove ignore_errors - let retry logic handle errors
578
+ # Retry with more attempts since directory removal is difficult on Windows
579
+ self._retry_file_operation(shutil.rmtree, target_dir, max_retries=10)
580
+
581
+ # Extra delay after successful removal on Windows
582
+ if self._is_windows:
583
+ time.sleep(0.5)
584
+ except KeyboardInterrupt as ke:
585
+ from fbuild.interrupt_utils import (
586
+ handle_keyboard_interrupt_properly,
587
+ )
588
+
589
+ handle_keyboard_interrupt_properly(ke)
590
+ except Exception as e:
591
+ # If removal fails, we CANNOT use shutil.move() because it will nest directories
592
+ # We must use the fallback individual file operations instead
593
+ if self.show_progress:
594
+ print(f" [Warning] Could not remove existing directory after 10 attempts: {e}")
595
+ print(" [Warning] Using individual file operations to overwrite...")
596
+ target_removal_failed = True
597
+ # DON'T re-raise - will use fallback path below
598
+
599
+ # Use shutil.move() for entire directory tree - single atomic operation
600
+ # But ONLY if target_removal_failed is False
601
+ # (If target couldn't be removed, shutil.move() will nest directories incorrectly)
602
+ if not target_removal_failed:
603
+ try:
604
+ if single_subdir:
605
+ # Move single extracted subdirectory to target location
606
+ # shutil.move(src, dst) moves src TO dst (renames it)
607
+ if self.show_progress:
608
+ print(f" [DEBUG] Moving {single_subdir.name} to {target_dir}")
609
+ print(f" [DEBUG] Source: {single_subdir}")
610
+ print(f" [DEBUG] Target: {target_dir}")
611
+ print(f" [DEBUG] Source exists: {single_subdir.exists()}")
612
+ print(f" [DEBUG] Target exists before: {target_dir.exists()}")
613
+
614
+ if self._is_windows:
615
+ result = self._retry_file_operation(shutil.move, str(single_subdir), str(target_dir))
616
+ if self.show_progress:
617
+ print(f" [DEBUG] shutil.move returned: {result}")
618
+ else:
619
+ shutil.move(str(single_subdir), str(target_dir))
620
+
621
+ if self.show_progress:
622
+ print(f" [DEBUG] Target exists after: {target_dir.exists()}")
623
+ if target_dir.exists() and target_dir.is_dir():
624
+ try:
625
+ items = list(target_dir.iterdir())
626
+ print(f" [DEBUG] Target has {len(items)} items")
627
+ if items:
628
+ print(f" [DEBUG] First 5 items: {[i.name for i in items[:5]]}")
629
+ except KeyboardInterrupt as ke:
630
+ from fbuild.interrupt_utils import (
631
+ handle_keyboard_interrupt_properly,
632
+ )
633
+
634
+ handle_keyboard_interrupt_properly(ke)
635
+ except Exception as e:
636
+ print(f" [DEBUG] Could not list target: {e}")
637
+ else:
638
+ # Multiple items - need to move temp_extract contents
639
+ # For this case, we need to move items individually (rare case)
640
+ raise Exception("Multiple items - need individual move")
641
+
642
+ if self.show_progress:
643
+ print(f" Successfully moved to {target_dir.name}")
644
+
645
+ except KeyboardInterrupt as ke:
646
+ from fbuild.interrupt_utils import (
647
+ handle_keyboard_interrupt_properly,
648
+ )
649
+
650
+ handle_keyboard_interrupt_properly(ke)
651
+ except Exception as move_error:
652
+ # If shutil.move() fails, fall back to individual file operations
653
+ if self.show_progress:
654
+ print(f" [Warning] Atomic move failed: {move_error}")
655
+ print(" Falling back to individual file operations...")
656
+
657
+ # Determine source directory for fallback
658
+ source_for_fallback = single_subdir if single_subdir else temp_extract
659
+
660
+ # Ensure target exists
661
+ target_dir.mkdir(parents=True, exist_ok=True)
662
+
663
+ # Get items with retry on Windows
664
+ def get_source_items():
665
+ return list(source_for_fallback.iterdir())
666
+
667
+ source_items = self._retry_file_operation(get_source_items) if self._is_windows else list(source_for_fallback.iterdir())
668
+
669
+ # Move items individually with retry
670
+ for item in source_items:
671
+ dest = target_dir / item.name
672
+ if dest.exists():
673
+ if dest.is_dir():
674
+ self._retry_file_operation(shutil.rmtree, dest, ignore_errors=True)
675
+ else:
676
+ self._retry_file_operation(dest.unlink)
677
+
678
+ # Try rename first, fall back to copy
679
+ try:
680
+ self._retry_file_operation(item.rename, dest)
681
+ except OSError:
682
+ if item.is_dir():
683
+ self._copytree_with_retry(item, dest)
684
+ else:
685
+ self._retry_file_operation(shutil.copy2, item, dest)
686
+
687
+ else:
688
+ # target_removal_failed is True - use individual file operations directly
689
+ # Cannot use shutil.move() because target still exists and it would nest
690
+ if self.show_progress:
691
+ print(" Using individual file copy to overwrite existing files...")
692
+
693
+ # Determine source directory
694
+ source_for_overwrite = single_subdir if single_subdir else temp_extract
695
+
696
+ # Ensure target exists
697
+ target_dir.mkdir(parents=True, exist_ok=True)
698
+
699
+ # Get items with retry on Windows
700
+ def get_source_items():
701
+ return list(source_for_overwrite.iterdir())
702
+
703
+ source_items = self._retry_file_operation(get_source_items) if self._is_windows else list(source_for_overwrite.iterdir())
704
+ assert source_items is not None
705
+
706
+ # Copy/overwrite items individually with retry
707
+ for item in source_items:
708
+ dest = target_dir / item.name
709
+ if dest.exists():
710
+ if dest.is_dir():
711
+ # Try to remove existing directory
712
+ try:
713
+ self._retry_file_operation(shutil.rmtree, dest, max_retries=10)
714
+ except KeyboardInterrupt as ke:
715
+ from fbuild.interrupt_utils import (
716
+ handle_keyboard_interrupt_properly,
717
+ )
718
+
719
+ handle_keyboard_interrupt_properly(ke)
720
+ except Exception:
721
+ # If can't remove, skip this item (maybe locked)
722
+ if self.show_progress:
723
+ print(f" [Warning] Could not overwrite {dest.name}, skipping...")
724
+ continue
725
+ else:
726
+ try:
727
+ self._retry_file_operation(dest.unlink, max_retries=5)
728
+ except KeyboardInterrupt as ke:
729
+ from fbuild.interrupt_utils import (
730
+ handle_keyboard_interrupt_properly,
731
+ )
732
+
733
+ handle_keyboard_interrupt_properly(ke)
734
+ except Exception:
735
+ # If can't remove file, skip
736
+ if self.show_progress:
737
+ print(f" [Warning] Could not overwrite {dest.name}, skipping...")
738
+ continue
739
+
740
+ # Try rename first, fall back to copy
741
+ try:
742
+ self._retry_file_operation(item.rename, dest)
743
+ except OSError:
744
+ if item.is_dir():
745
+ self._copytree_with_retry(item, dest)
746
+ else:
747
+ self._retry_file_operation(shutil.copy2, item, dest)
748
+
749
+ if self.show_progress:
750
+ print(f" Successfully extracted to {target_dir.name}")
751
+
752
+ except KeyboardInterrupt as ke:
753
+ from fbuild.interrupt_utils import handle_keyboard_interrupt_properly
754
+
755
+ handle_keyboard_interrupt_properly(ke)
756
+ except Exception as e:
757
+ raise ExtractionError(f"Failed to extract archive: {e}")
758
+ finally:
759
+ # Clean up temp directory
760
+ if temp_extract.exists():
761
+ shutil.rmtree(temp_extract, ignore_errors=True)
762
+
763
+ def _extract_tar_gz(self, archive_path: Path, target_dir: Path) -> None:
764
+ """Extract a .tar.gz archive to target directory.
765
+
766
+ Handles archives that extract to a single subdirectory or directly to multiple files.
767
+
768
+ Args:
769
+ archive_path: Path to the .tar.gz archive file
770
+ target_dir: Directory to extract contents into
771
+
772
+ Raises:
773
+ ExtractionError: If extraction fails
774
+ """
775
+ # Create temp extraction directory
776
+ temp_extract = target_dir.parent / f"temp_extract_{archive_path.name}"
777
+ temp_extract.mkdir(parents=True, exist_ok=True)
778
+
779
+ try:
780
+ # Extract .tar.gz archive with progress tracking
781
+ with tarfile.open(archive_path, "r:gz") as tar:
782
+ members = tar.getmembers()
783
+ total_members = len(members)
784
+
785
+ if self.show_progress:
786
+ from tqdm import tqdm
787
+
788
+ with tqdm(total=total_members, unit="file", desc=f"Extracting {archive_path.name}") as pbar:
789
+ for member in members:
790
+ # On Windows, individual file extractions can hit Permission Errors
791
+ # due to file handle delays or antivirus/Windows Defender scanning
792
+ # Wrap each extract in retry logic
793
+ if self._is_windows:
794
+
795
+ def extract_member():
796
+ tar.extract(member, temp_extract)
797
+
798
+ self._retry_file_operation(extract_member, max_retries=5)
799
+ else:
800
+ tar.extract(member, temp_extract)
801
+ pbar.update(1)
802
+ else:
803
+ tar.extractall(temp_extract)
804
+
805
+ # On Windows, force garbage collection and add LONG delay to let file handles close
806
+ # Large archives (3000+ files) need extensive time for Windows to stabilize
807
+ if self._is_windows:
808
+ gc.collect()
809
+ time.sleep(3.0) # Increased to 3s - filesystem stabilization for large archives
810
+
811
+ # Find the extracted directory
812
+ # Wrap in retry logic - Windows may not have the directory handle ready yet
813
+ def get_extracted_items():
814
+ return list(temp_extract.iterdir())
815
+
816
+ extracted_items = self._retry_file_operation(get_extracted_items) if self._is_windows else list(temp_extract.iterdir())
817
+ assert extracted_items is not None
818
+
819
+ # Check if single item is a directory - wrap in retry logic for Windows
820
+ single_subdir = None
821
+ if len(extracted_items) == 1:
822
+ if self._is_windows:
823
+
824
+ def check_is_dir():
825
+ return extracted_items[0].is_dir()
826
+
827
+ is_single_dir = self._retry_file_operation(check_is_dir)
828
+ else:
829
+ is_single_dir = extracted_items[0].is_dir()
830
+
831
+ if is_single_dir:
832
+ # Single directory extracted - we'll move it atomically
833
+ single_subdir = extracted_items[0]
834
+
835
+ # On Windows, add another delay before move operation
836
+ if self._is_windows:
837
+ time.sleep(1.0) # Additional delay for directory handles
838
+
839
+ # Move directory using shutil.move() for entire tree (atomic operation)
840
+ # This is MUCH more reliable on Windows than iterating through individual files
841
+ # Single atomic operation instead of 3000+ individual file operations
842
+ if self.show_progress:
843
+ print(f"Moving extracted files to {target_dir.name}...")
844
+
845
+ # Track whether we need to remove target before move
846
+ target_removal_failed = False
847
+
848
+ # Remove existing target directory if it exists
849
+ if target_dir.exists():
850
+ if self.show_progress:
851
+ print(f" Removing existing {target_dir.name}...")
852
+
853
+ # On Windows, prepare for directory removal
854
+ if self._is_windows:
855
+ gc.collect() # Force garbage collection to release handles
856
+ time.sleep(1.0) # Give Windows time to release handles
857
+
858
+ try:
859
+ # Remove ignore_errors - let retry logic handle errors
860
+ # Retry with more attempts since directory removal is difficult on Windows
861
+ self._retry_file_operation(shutil.rmtree, target_dir, max_retries=10)
862
+
863
+ # Extra delay after successful removal on Windows
864
+ if self._is_windows:
865
+ time.sleep(0.5)
866
+ except KeyboardInterrupt as ke:
867
+ from fbuild.interrupt_utils import (
868
+ handle_keyboard_interrupt_properly,
869
+ )
870
+
871
+ handle_keyboard_interrupt_properly(ke)
872
+ except Exception as e:
873
+ # If removal fails, we CANNOT use shutil.move() because it will nest directories
874
+ # We must use the fallback individual file operations instead
875
+ if self.show_progress:
876
+ print(f" [Warning] Could not remove existing directory after 10 attempts: {e}")
877
+ print(" [Warning] Using individual file operations to overwrite...")
878
+ target_removal_failed = True
879
+ # DON'T re-raise - will use fallback path below
880
+
881
+ # Use shutil.move() for entire directory tree - single atomic operation
882
+ # But ONLY if target_removal_failed is False
883
+ # (If target couldn't be removed, shutil.move() will nest directories incorrectly)
884
+ if not target_removal_failed:
885
+ try:
886
+ if single_subdir:
887
+ # Move single extracted subdirectory to target location
888
+ # shutil.move(src, dst) moves src TO dst (renames it)
889
+ if self.show_progress:
890
+ print(f" [DEBUG] Moving {single_subdir.name} to {target_dir}")
891
+ print(f" [DEBUG] Source: {single_subdir}")
892
+ print(f" [DEBUG] Target: {target_dir}")
893
+ print(f" [DEBUG] Source exists: {single_subdir.exists()}")
894
+ print(f" [DEBUG] Target exists before: {target_dir.exists()}")
895
+
896
+ if self._is_windows:
897
+ result = self._retry_file_operation(shutil.move, str(single_subdir), str(target_dir))
898
+ if self.show_progress:
899
+ print(f" [DEBUG] shutil.move returned: {result}")
900
+ else:
901
+ shutil.move(str(single_subdir), str(target_dir))
902
+
903
+ if self.show_progress:
904
+ print(f" [DEBUG] Target exists after: {target_dir.exists()}")
905
+ if target_dir.exists() and target_dir.is_dir():
906
+ try:
907
+ items = list(target_dir.iterdir())
908
+ print(f" [DEBUG] Target has {len(items)} items")
909
+ if items:
910
+ print(f" [DEBUG] First 5 items: {[i.name for i in items[:5]]}")
911
+ except KeyboardInterrupt as ke:
912
+ from fbuild.interrupt_utils import (
913
+ handle_keyboard_interrupt_properly,
914
+ )
915
+
916
+ handle_keyboard_interrupt_properly(ke)
917
+ except Exception as e:
918
+ print(f" [DEBUG] Could not list target: {e}")
919
+ else:
920
+ # Multiple items - need to move temp_extract contents
921
+ # For this case, we need to move items individually (rare case)
922
+ raise Exception("Multiple items - need individual move")
923
+
924
+ if self.show_progress:
925
+ print(f" Successfully moved to {target_dir.name}")
926
+
927
+ except KeyboardInterrupt as ke:
928
+ from fbuild.interrupt_utils import (
929
+ handle_keyboard_interrupt_properly,
930
+ )
931
+
932
+ handle_keyboard_interrupt_properly(ke)
933
+ except Exception as move_error:
934
+ # If shutil.move() fails, fall back to individual file operations
935
+ if self.show_progress:
936
+ print(f" [Warning] Atomic move failed: {move_error}")
937
+ print(" Falling back to individual file operations...")
938
+
939
+ # Determine source directory for fallback
940
+ source_for_fallback = single_subdir if single_subdir else temp_extract
941
+
942
+ # Ensure target exists
943
+ target_dir.mkdir(parents=True, exist_ok=True)
944
+
945
+ # Get items with retry on Windows
946
+ def get_source_items():
947
+ return list(source_for_fallback.iterdir())
948
+
949
+ source_items = self._retry_file_operation(get_source_items) if self._is_windows else list(source_for_fallback.iterdir())
950
+
951
+ # Move items individually with retry
952
+ for item in source_items:
953
+ dest = target_dir / item.name
954
+ if dest.exists():
955
+ if dest.is_dir():
956
+ self._retry_file_operation(shutil.rmtree, dest, ignore_errors=True)
957
+ else:
958
+ self._retry_file_operation(dest.unlink)
959
+
960
+ # Try rename first, fall back to copy
961
+ try:
962
+ self._retry_file_operation(item.rename, dest)
963
+ except OSError:
964
+ if item.is_dir():
965
+ self._copytree_with_retry(item, dest)
966
+ else:
967
+ self._retry_file_operation(shutil.copy2, item, dest)
968
+
969
+ else:
970
+ # target_removal_failed is True - use individual file operations directly
971
+ # Cannot use shutil.move() because target still exists and it would nest
972
+ if self.show_progress:
973
+ print(" Using individual file copy to overwrite existing files...")
974
+
975
+ # Determine source directory
976
+ source_for_overwrite = single_subdir if single_subdir else temp_extract
977
+
978
+ # Ensure target exists
979
+ target_dir.mkdir(parents=True, exist_ok=True)
980
+
981
+ # Get items with retry on Windows
982
+ def get_source_items():
983
+ return list(source_for_overwrite.iterdir())
984
+
985
+ source_items = self._retry_file_operation(get_source_items) if self._is_windows else list(source_for_overwrite.iterdir())
986
+ assert source_items is not None
987
+
988
+ # Copy/overwrite items individually with retry
989
+ for item in source_items:
990
+ dest = target_dir / item.name
991
+ if dest.exists():
992
+ if dest.is_dir():
993
+ # Try to remove existing directory
994
+ try:
995
+ self._retry_file_operation(shutil.rmtree, dest, max_retries=10)
996
+ except KeyboardInterrupt as ke:
997
+ from fbuild.interrupt_utils import (
998
+ handle_keyboard_interrupt_properly,
999
+ )
1000
+
1001
+ handle_keyboard_interrupt_properly(ke)
1002
+ except Exception:
1003
+ # If can't remove, skip this item (maybe locked)
1004
+ if self.show_progress:
1005
+ print(f" [Warning] Could not overwrite {dest.name}, skipping...")
1006
+ continue
1007
+ else:
1008
+ try:
1009
+ self._retry_file_operation(dest.unlink, max_retries=5)
1010
+ except KeyboardInterrupt as ke:
1011
+ from fbuild.interrupt_utils import (
1012
+ handle_keyboard_interrupt_properly,
1013
+ )
1014
+
1015
+ handle_keyboard_interrupt_properly(ke)
1016
+ except Exception:
1017
+ # If can't remove file, skip
1018
+ if self.show_progress:
1019
+ print(f" [Warning] Could not overwrite {dest.name}, skipping...")
1020
+ continue
1021
+
1022
+ # Try rename first, fall back to copy
1023
+ try:
1024
+ self._retry_file_operation(item.rename, dest)
1025
+ except OSError:
1026
+ if item.is_dir():
1027
+ self._copytree_with_retry(item, dest)
1028
+ else:
1029
+ self._retry_file_operation(shutil.copy2, item, dest)
1030
+
1031
+ if self.show_progress:
1032
+ print(f" Successfully extracted to {target_dir.name}")
1033
+
1034
+ except KeyboardInterrupt as ke:
1035
+ from fbuild.interrupt_utils import handle_keyboard_interrupt_properly
1036
+
1037
+ handle_keyboard_interrupt_properly(ke)
1038
+ except Exception as e:
1039
+ raise ExtractionError(f"Failed to extract archive: {e}")
1040
+ finally:
1041
+ # Clean up temp directory
1042
+ if temp_extract.exists():
1043
+ shutil.rmtree(temp_extract, ignore_errors=True)
1044
+
1045
+
1046
+ class URLVersionExtractor:
1047
+ """Utilities for extracting version information from URLs."""
1048
+
1049
+ @staticmethod
1050
+ def extract_version_from_url(url: str, prefix: str = "") -> str:
1051
+ """Extract version string from a package URL.
1052
+
1053
+ Handles common URL patterns used in GitHub releases and package repositories.
1054
+
1055
+ Args:
1056
+ url: Package URL (e.g., https://github.com/.../download/3.3.4/esp32-3.3.4.tar.xz)
1057
+ prefix: Optional filename prefix to look for (e.g., "esp32-")
1058
+
1059
+ Returns:
1060
+ Version string (e.g., "3.3.4")
1061
+
1062
+ Examples:
1063
+ >>> URLVersionExtractor.extract_version_from_url(
1064
+ ... "https://github.com/.../releases/download/3.3.4/esp32-3.3.4.tar.xz",
1065
+ ... prefix="esp32-"
1066
+ ... )
1067
+ '3.3.4'
1068
+ """
1069
+ # URL format: .../releases/download/{version}/package-{version}.tar.xz
1070
+ parts = url.split("/")
1071
+ for i, part in enumerate(parts):
1072
+ if part == "download" and i + 1 < len(parts):
1073
+ version = parts[i + 1]
1074
+ # Clean up version (remove any suffixes)
1075
+ return version.split("-")[0] if "-" in version else version
1076
+
1077
+ # Fallback: extract from filename
1078
+ filename = url.split("/")[-1]
1079
+ if prefix and prefix in filename:
1080
+ version_part = filename.replace(prefix, "").replace(".tar.xz", "")
1081
+ version_part = version_part.replace(".tar.gz", "")
1082
+ return version_part.split("-")[0] if "-" in version_part else version_part
1083
+
1084
+ # Remove common archive extensions
1085
+ filename_no_ext = filename.replace(".tar.xz", "").replace(".tar.gz", "")
1086
+ filename_no_ext = filename_no_ext.replace(".zip", "")
1087
+
1088
+ # Try to find version pattern (e.g., "1.2.3", "v1.2.3")
1089
+ import re
1090
+
1091
+ version_match = re.search(r"v?(\d+\.\d+\.\d+)", filename_no_ext)
1092
+ if version_match:
1093
+ return version_match.group(1)
1094
+
1095
+ # Last resort: use URL hash
1096
+ from .cache import Cache
1097
+
1098
+ return Cache.hash_url(url)[:8]