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.
- fbuild/__init__.py +0 -0
- fbuild/assets/example.txt +1 -0
- fbuild/build/__init__.py +117 -0
- fbuild/build/archive_creator.py +186 -0
- fbuild/build/binary_generator.py +444 -0
- fbuild/build/build_component_factory.py +131 -0
- fbuild/build/build_state.py +325 -0
- fbuild/build/build_utils.py +98 -0
- fbuild/build/compilation_executor.py +422 -0
- fbuild/build/compiler.py +165 -0
- fbuild/build/compiler_avr.py +574 -0
- fbuild/build/configurable_compiler.py +612 -0
- fbuild/build/configurable_linker.py +637 -0
- fbuild/build/flag_builder.py +186 -0
- fbuild/build/library_dependency_processor.py +185 -0
- fbuild/build/linker.py +708 -0
- fbuild/build/orchestrator.py +67 -0
- fbuild/build/orchestrator_avr.py +656 -0
- fbuild/build/orchestrator_esp32.py +797 -0
- fbuild/build/orchestrator_teensy.py +543 -0
- fbuild/build/source_compilation_orchestrator.py +220 -0
- fbuild/build/source_scanner.py +516 -0
- fbuild/cli.py +566 -0
- fbuild/cli_utils.py +312 -0
- fbuild/config/__init__.py +16 -0
- fbuild/config/board_config.py +457 -0
- fbuild/config/board_loader.py +92 -0
- fbuild/config/ini_parser.py +209 -0
- fbuild/config/mcu_specs.py +88 -0
- fbuild/daemon/__init__.py +34 -0
- fbuild/daemon/client.py +929 -0
- fbuild/daemon/compilation_queue.py +293 -0
- fbuild/daemon/daemon.py +474 -0
- fbuild/daemon/daemon_context.py +196 -0
- fbuild/daemon/error_collector.py +263 -0
- fbuild/daemon/file_cache.py +332 -0
- fbuild/daemon/lock_manager.py +270 -0
- fbuild/daemon/logging_utils.py +149 -0
- fbuild/daemon/messages.py +301 -0
- fbuild/daemon/operation_registry.py +288 -0
- fbuild/daemon/process_tracker.py +366 -0
- fbuild/daemon/processors/__init__.py +12 -0
- fbuild/daemon/processors/build_processor.py +157 -0
- fbuild/daemon/processors/deploy_processor.py +327 -0
- fbuild/daemon/processors/monitor_processor.py +146 -0
- fbuild/daemon/request_processor.py +401 -0
- fbuild/daemon/status_manager.py +216 -0
- fbuild/daemon/subprocess_manager.py +316 -0
- fbuild/deploy/__init__.py +17 -0
- fbuild/deploy/deployer.py +67 -0
- fbuild/deploy/deployer_esp32.py +314 -0
- fbuild/deploy/monitor.py +495 -0
- fbuild/interrupt_utils.py +34 -0
- fbuild/packages/__init__.py +53 -0
- fbuild/packages/archive_utils.py +1098 -0
- fbuild/packages/arduino_core.py +412 -0
- fbuild/packages/cache.py +249 -0
- fbuild/packages/downloader.py +366 -0
- fbuild/packages/framework_esp32.py +538 -0
- fbuild/packages/framework_teensy.py +346 -0
- fbuild/packages/github_utils.py +96 -0
- fbuild/packages/header_trampoline_cache.py +394 -0
- fbuild/packages/library_compiler.py +203 -0
- fbuild/packages/library_manager.py +549 -0
- fbuild/packages/library_manager_esp32.py +413 -0
- fbuild/packages/package.py +163 -0
- fbuild/packages/platform_esp32.py +383 -0
- fbuild/packages/platform_teensy.py +312 -0
- fbuild/packages/platform_utils.py +131 -0
- fbuild/packages/platformio_registry.py +325 -0
- fbuild/packages/sdk_utils.py +231 -0
- fbuild/packages/toolchain.py +436 -0
- fbuild/packages/toolchain_binaries.py +196 -0
- fbuild/packages/toolchain_esp32.py +484 -0
- fbuild/packages/toolchain_metadata.py +185 -0
- fbuild/packages/toolchain_teensy.py +404 -0
- fbuild/platform_configs/esp32.json +150 -0
- fbuild/platform_configs/esp32c2.json +144 -0
- fbuild/platform_configs/esp32c3.json +143 -0
- fbuild/platform_configs/esp32c5.json +151 -0
- fbuild/platform_configs/esp32c6.json +151 -0
- fbuild/platform_configs/esp32p4.json +149 -0
- fbuild/platform_configs/esp32s3.json +151 -0
- fbuild/platform_configs/imxrt1062.json +56 -0
- fbuild-1.1.0.dist-info/METADATA +447 -0
- fbuild-1.1.0.dist-info/RECORD +93 -0
- fbuild-1.1.0.dist-info/WHEEL +5 -0
- fbuild-1.1.0.dist-info/entry_points.txt +5 -0
- fbuild-1.1.0.dist-info/licenses/LICENSE +21 -0
- fbuild-1.1.0.dist-info/top_level.txt +2 -0
- fbuild_lint/__init__.py +0 -0
- fbuild_lint/ruff_plugins/__init__.py +0 -0
- 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]
|