fbuild 1.2.8__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.
- fbuild/__init__.py +390 -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_info_generator.py +624 -0
- fbuild/build/build_state.py +325 -0
- fbuild/build/build_utils.py +93 -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 +664 -0
- fbuild/build/configurable_linker.py +637 -0
- fbuild/build/flag_builder.py +214 -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 +651 -0
- fbuild/build/orchestrator_esp32.py +878 -0
- fbuild/build/orchestrator_rp2040.py +719 -0
- fbuild/build/orchestrator_stm32.py +696 -0
- fbuild/build/orchestrator_teensy.py +580 -0
- fbuild/build/source_compilation_orchestrator.py +218 -0
- fbuild/build/source_scanner.py +516 -0
- fbuild/cli.py +717 -0
- fbuild/cli_utils.py +314 -0
- fbuild/config/__init__.py +16 -0
- fbuild/config/board_config.py +542 -0
- fbuild/config/board_loader.py +92 -0
- fbuild/config/ini_parser.py +369 -0
- fbuild/config/mcu_specs.py +88 -0
- fbuild/daemon/__init__.py +42 -0
- fbuild/daemon/async_client.py +531 -0
- fbuild/daemon/client.py +1505 -0
- fbuild/daemon/compilation_queue.py +293 -0
- fbuild/daemon/configuration_lock.py +865 -0
- fbuild/daemon/daemon.py +585 -0
- fbuild/daemon/daemon_context.py +293 -0
- fbuild/daemon/error_collector.py +263 -0
- fbuild/daemon/file_cache.py +332 -0
- fbuild/daemon/firmware_ledger.py +546 -0
- fbuild/daemon/lock_manager.py +508 -0
- fbuild/daemon/logging_utils.py +149 -0
- fbuild/daemon/messages.py +957 -0
- fbuild/daemon/operation_registry.py +288 -0
- fbuild/daemon/port_state_manager.py +249 -0
- fbuild/daemon/process_tracker.py +366 -0
- fbuild/daemon/processors/__init__.py +18 -0
- fbuild/daemon/processors/build_processor.py +248 -0
- fbuild/daemon/processors/deploy_processor.py +664 -0
- fbuild/daemon/processors/install_deps_processor.py +431 -0
- fbuild/daemon/processors/locking_processor.py +777 -0
- fbuild/daemon/processors/monitor_processor.py +285 -0
- fbuild/daemon/request_processor.py +457 -0
- fbuild/daemon/shared_serial.py +819 -0
- fbuild/daemon/status_manager.py +238 -0
- fbuild/daemon/subprocess_manager.py +316 -0
- fbuild/deploy/__init__.py +21 -0
- fbuild/deploy/deployer.py +67 -0
- fbuild/deploy/deployer_esp32.py +310 -0
- fbuild/deploy/docker_utils.py +315 -0
- fbuild/deploy/monitor.py +519 -0
- fbuild/deploy/qemu_runner.py +603 -0
- fbuild/interrupt_utils.py +34 -0
- fbuild/ledger/__init__.py +52 -0
- fbuild/ledger/board_ledger.py +560 -0
- fbuild/output.py +352 -0
- fbuild/packages/__init__.py +66 -0
- fbuild/packages/archive_utils.py +1098 -0
- fbuild/packages/arduino_core.py +412 -0
- fbuild/packages/cache.py +256 -0
- fbuild/packages/concurrent_manager.py +510 -0
- fbuild/packages/downloader.py +518 -0
- fbuild/packages/fingerprint.py +423 -0
- fbuild/packages/framework_esp32.py +538 -0
- fbuild/packages/framework_rp2040.py +349 -0
- fbuild/packages/framework_stm32.py +459 -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 +725 -0
- fbuild/packages/package.py +163 -0
- fbuild/packages/platform_esp32.py +383 -0
- fbuild/packages/platform_rp2040.py +400 -0
- fbuild/packages/platform_stm32.py +581 -0
- fbuild/packages/platform_teensy.py +312 -0
- fbuild/packages/platform_utils.py +131 -0
- fbuild/packages/platformio_registry.py +369 -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 +489 -0
- fbuild/packages/toolchain_metadata.py +185 -0
- fbuild/packages/toolchain_rp2040.py +436 -0
- fbuild/packages/toolchain_stm32.py +417 -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/platform_configs/rp2040.json +70 -0
- fbuild/platform_configs/rp2350.json +76 -0
- fbuild/platform_configs/stm32f1.json +59 -0
- fbuild/platform_configs/stm32f4.json +63 -0
- fbuild/py.typed +0 -0
- fbuild-1.2.8.dist-info/METADATA +468 -0
- fbuild-1.2.8.dist-info/RECORD +121 -0
- fbuild-1.2.8.dist-info/WHEEL +5 -0
- fbuild-1.2.8.dist-info/entry_points.txt +5 -0
- fbuild-1.2.8.dist-info/licenses/LICENSE +21 -0
- fbuild-1.2.8.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,518 @@
|
|
|
1
|
+
"""Package downloader with progress tracking and checksum verification.
|
|
2
|
+
|
|
3
|
+
This module handles downloading packages from URLs, extracting archives,
|
|
4
|
+
and verifying integrity with checksums.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import gc
|
|
8
|
+
import hashlib
|
|
9
|
+
import platform
|
|
10
|
+
import tarfile
|
|
11
|
+
import time
|
|
12
|
+
import zipfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar
|
|
15
|
+
from urllib.parse import urlparse
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
import requests
|
|
19
|
+
from tqdm import tqdm
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import requests
|
|
23
|
+
from tqdm import tqdm
|
|
24
|
+
|
|
25
|
+
REQUESTS_AVAILABLE = True
|
|
26
|
+
except ImportError:
|
|
27
|
+
REQUESTS_AVAILABLE = False
|
|
28
|
+
requests: Any = None
|
|
29
|
+
tqdm: Any = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DownloadError(Exception):
|
|
33
|
+
"""Raised when download fails."""
|
|
34
|
+
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ChecksumError(Exception):
|
|
39
|
+
"""Raised when checksum verification fails."""
|
|
40
|
+
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ExtractionError(Exception):
|
|
45
|
+
"""Raised when archive extraction fails."""
|
|
46
|
+
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
T = TypeVar("T")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _retry_windows_file_operation(
|
|
54
|
+
operation: Callable[[], T],
|
|
55
|
+
max_retries: int = 10,
|
|
56
|
+
initial_delay: float = 0.05,
|
|
57
|
+
) -> T:
|
|
58
|
+
"""Retry a file operation on Windows to handle transient locking issues.
|
|
59
|
+
|
|
60
|
+
Windows file handles can be delayed in release due to antivirus scanning,
|
|
61
|
+
delayed garbage collection, or OS-level file caching. This function retries
|
|
62
|
+
file operations with exponential backoff to handle these transient issues.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
operation: Callable that performs the file operation
|
|
66
|
+
max_retries: Maximum number of retry attempts
|
|
67
|
+
initial_delay: Initial delay in seconds (doubles each retry)
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Result of the operation
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
The last exception encountered if all retries fail
|
|
74
|
+
"""
|
|
75
|
+
is_windows = platform.system() == "Windows"
|
|
76
|
+
|
|
77
|
+
if not is_windows:
|
|
78
|
+
# On non-Windows systems, just call the function directly
|
|
79
|
+
return operation()
|
|
80
|
+
|
|
81
|
+
# Windows: use retry logic for file operations
|
|
82
|
+
delay = initial_delay
|
|
83
|
+
last_exception = None
|
|
84
|
+
|
|
85
|
+
for attempt in range(max_retries):
|
|
86
|
+
try:
|
|
87
|
+
# Force garbage collection to release file handles
|
|
88
|
+
if attempt > 0:
|
|
89
|
+
gc.collect()
|
|
90
|
+
time.sleep(delay)
|
|
91
|
+
|
|
92
|
+
return operation()
|
|
93
|
+
|
|
94
|
+
except (PermissionError, OSError, FileNotFoundError) as e:
|
|
95
|
+
# WinError 32: File is being used by another process
|
|
96
|
+
# WinError 2: File not found (temp file disappeared due to handle delays)
|
|
97
|
+
# Also catch OSError with errno 13 (access denied) or 32 (in use)
|
|
98
|
+
last_exception = e
|
|
99
|
+
|
|
100
|
+
# Check if this is a retriable error
|
|
101
|
+
is_retriable = False
|
|
102
|
+
if isinstance(e, (PermissionError, FileNotFoundError)):
|
|
103
|
+
is_retriable = True
|
|
104
|
+
elif hasattr(e, "errno") and e.errno in (2, 13, 32):
|
|
105
|
+
is_retriable = True
|
|
106
|
+
|
|
107
|
+
if is_retriable and attempt < max_retries - 1:
|
|
108
|
+
delay = min(delay * 2, 2.0) # Exponential backoff, max 2s
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
# Not retriable or exhausted retries
|
|
112
|
+
raise
|
|
113
|
+
|
|
114
|
+
# If we get here, all retries failed
|
|
115
|
+
if last_exception:
|
|
116
|
+
raise last_exception
|
|
117
|
+
raise PermissionError(f"Failed to perform file operation after {max_retries} attempts")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class PackageDownloader:
|
|
121
|
+
"""Downloads and extracts packages with progress tracking."""
|
|
122
|
+
|
|
123
|
+
def __init__(self, chunk_size: int = 8192):
|
|
124
|
+
"""Initialize downloader.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
chunk_size: Size of chunks for downloading and hashing
|
|
128
|
+
"""
|
|
129
|
+
self.chunk_size = chunk_size
|
|
130
|
+
|
|
131
|
+
if not REQUESTS_AVAILABLE:
|
|
132
|
+
raise ImportError("requests and tqdm are required for downloading. " + "Install with: pip install requests tqdm")
|
|
133
|
+
|
|
134
|
+
def download(
|
|
135
|
+
self,
|
|
136
|
+
url: str,
|
|
137
|
+
dest_path: Path,
|
|
138
|
+
checksum: Optional[str] = None,
|
|
139
|
+
show_progress: bool = True,
|
|
140
|
+
) -> Path:
|
|
141
|
+
"""Download a file from a URL.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
url: URL to download from
|
|
145
|
+
dest_path: Destination file path
|
|
146
|
+
checksum: Optional SHA256 checksum for verification
|
|
147
|
+
show_progress: Whether to show progress bar
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Path to the downloaded file
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
DownloadError: If download fails
|
|
154
|
+
ChecksumError: If checksum verification fails
|
|
155
|
+
"""
|
|
156
|
+
dest_path = Path(dest_path)
|
|
157
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
|
|
159
|
+
# Use temporary file during download
|
|
160
|
+
# Use .download extension instead of .tmp to avoid antivirus interference
|
|
161
|
+
temp_file = Path(str(dest_path) + ".download")
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
# Start download with streaming
|
|
165
|
+
response = requests.get(url, stream=True, timeout=30)
|
|
166
|
+
response.raise_for_status()
|
|
167
|
+
|
|
168
|
+
# Get file size for progress bar
|
|
169
|
+
total_size = int(response.headers.get("content-length", 0))
|
|
170
|
+
|
|
171
|
+
# Setup progress bar
|
|
172
|
+
progress_bar = None
|
|
173
|
+
if show_progress and total_size > 0:
|
|
174
|
+
filename = Path(urlparse(url).path).name
|
|
175
|
+
progress_bar = tqdm(
|
|
176
|
+
total=total_size,
|
|
177
|
+
unit="B",
|
|
178
|
+
unit_scale=True,
|
|
179
|
+
unit_divisor=1024,
|
|
180
|
+
desc=f"Downloading {filename}",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Download file
|
|
184
|
+
sha256 = hashlib.sha256() if checksum else None
|
|
185
|
+
|
|
186
|
+
with open(temp_file, "wb") as f:
|
|
187
|
+
for chunk in response.iter_content(chunk_size=self.chunk_size):
|
|
188
|
+
if chunk:
|
|
189
|
+
f.write(chunk)
|
|
190
|
+
if progress_bar:
|
|
191
|
+
progress_bar.update(len(chunk))
|
|
192
|
+
if sha256:
|
|
193
|
+
sha256.update(chunk)
|
|
194
|
+
|
|
195
|
+
if progress_bar:
|
|
196
|
+
progress_bar.close()
|
|
197
|
+
|
|
198
|
+
# Force garbage collection to help release file handles (Windows)
|
|
199
|
+
gc.collect()
|
|
200
|
+
|
|
201
|
+
# On Windows, add delay to let file handles stabilize after write
|
|
202
|
+
if platform.system() == "Windows":
|
|
203
|
+
time.sleep(0.2)
|
|
204
|
+
|
|
205
|
+
# Check if temp file still exists (antivirus might quarantine immediately)
|
|
206
|
+
if not temp_file.exists():
|
|
207
|
+
# File was quarantined immediately after download
|
|
208
|
+
# Check if antivirus moved it to dest_path already
|
|
209
|
+
if dest_path.exists():
|
|
210
|
+
# Antivirus renamed it for us
|
|
211
|
+
return dest_path
|
|
212
|
+
# Otherwise, file was deleted/quarantined - this is unrecoverable
|
|
213
|
+
raise DownloadError(
|
|
214
|
+
f"Downloaded file was immediately quarantined by antivirus: {temp_file}. " + f"Try adding an exclusion for {dest_path.parent} or disabling antivirus temporarily."
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Verify checksum if provided
|
|
218
|
+
if checksum and sha256:
|
|
219
|
+
actual_checksum = sha256.hexdigest()
|
|
220
|
+
if actual_checksum.lower() != checksum.lower():
|
|
221
|
+
# Delete temp file before raising (not inside retry wrapper)
|
|
222
|
+
try:
|
|
223
|
+
_retry_windows_file_operation(lambda: temp_file.unlink())
|
|
224
|
+
except KeyboardInterrupt as ke:
|
|
225
|
+
from fbuild.interrupt_utils import (
|
|
226
|
+
handle_keyboard_interrupt_properly,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
handle_keyboard_interrupt_properly(ke)
|
|
230
|
+
except Exception:
|
|
231
|
+
pass # Ignore unlink errors, we're about to raise checksum error anyway
|
|
232
|
+
# Raise checksum error (NOT inside retry wrapper)
|
|
233
|
+
raise ChecksumError(f"Checksum mismatch for {url}\n" + f"Expected: {checksum}\n" + f"Got: {actual_checksum}")
|
|
234
|
+
|
|
235
|
+
# Move temp file to final destination
|
|
236
|
+
# For large files, antivirus scanning can take longer, so use more aggressive retry
|
|
237
|
+
if dest_path.exists():
|
|
238
|
+
# Try to delete existing file, but don't fail if antivirus is holding it
|
|
239
|
+
# The rename logic below will handle this case with extended retry
|
|
240
|
+
try:
|
|
241
|
+
_retry_windows_file_operation(lambda: dest_path.unlink())
|
|
242
|
+
except (PermissionError, OSError):
|
|
243
|
+
# File is locked (likely by antivirus), skip deletion
|
|
244
|
+
# The rename will handle this with copy fallback
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
# Use longer max delay for rename to handle antivirus scanning of large files
|
|
248
|
+
def rename_with_extended_retry() -> Path:
|
|
249
|
+
"""Rename with extended retry specifically for large file antivirus delays."""
|
|
250
|
+
import shutil
|
|
251
|
+
|
|
252
|
+
delay = 0.2
|
|
253
|
+
max_delay = 15.0 # Allow up to 15s for antivirus scanning
|
|
254
|
+
|
|
255
|
+
# Initial check - fail fast if file is already gone
|
|
256
|
+
if not temp_file.exists():
|
|
257
|
+
if dest_path.exists():
|
|
258
|
+
# Antivirus already moved it
|
|
259
|
+
return dest_path
|
|
260
|
+
raise FileNotFoundError(f"Temp file was quarantined immediately after download: {temp_file}. " + f"Add antivirus exclusion for {dest_path.parent} and retry.")
|
|
261
|
+
|
|
262
|
+
for attempt in range(30): # More attempts for rename
|
|
263
|
+
try:
|
|
264
|
+
if attempt > 0:
|
|
265
|
+
gc.collect()
|
|
266
|
+
time.sleep(delay)
|
|
267
|
+
|
|
268
|
+
# Check if temp file still exists (antivirus might have moved/quarantined it)
|
|
269
|
+
if not temp_file.exists():
|
|
270
|
+
# Wait and check if it reappears or if dest already exists
|
|
271
|
+
time.sleep(min(delay * 2, max_delay))
|
|
272
|
+
gc.collect() # Try to release any file handles
|
|
273
|
+
|
|
274
|
+
if dest_path.exists():
|
|
275
|
+
# File was already moved (possibly by antivirus restoration)
|
|
276
|
+
return dest_path
|
|
277
|
+
|
|
278
|
+
# Check again after longer wait
|
|
279
|
+
if not temp_file.exists():
|
|
280
|
+
# File disappeared - could be antivirus quarantine
|
|
281
|
+
if attempt < 25: # Give more attempts for file to reappear
|
|
282
|
+
delay = min(delay * 1.3, max_delay)
|
|
283
|
+
continue
|
|
284
|
+
|
|
285
|
+
# Last resort: check if dest_path appeared
|
|
286
|
+
if dest_path.exists():
|
|
287
|
+
return dest_path
|
|
288
|
+
|
|
289
|
+
raise FileNotFoundError(
|
|
290
|
+
f"Temp file disappeared (possibly quarantined by antivirus): {temp_file}. " + f"Try disabling antivirus or adding an exclusion for {dest_path.parent}"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Try to rename/move the file
|
|
294
|
+
try:
|
|
295
|
+
# On Windows, if dest exists, try to delete it first
|
|
296
|
+
if dest_path.exists():
|
|
297
|
+
try:
|
|
298
|
+
dest_path.unlink()
|
|
299
|
+
except (PermissionError, OSError):
|
|
300
|
+
# Dest file is locked, use copy fallback immediately
|
|
301
|
+
if attempt >= 3: # Give a few attempts, then use copy
|
|
302
|
+
shutil.copy2(temp_file, dest_path)
|
|
303
|
+
try:
|
|
304
|
+
temp_file.unlink()
|
|
305
|
+
except KeyboardInterrupt as ke:
|
|
306
|
+
from fbuild.interrupt_utils import (
|
|
307
|
+
handle_keyboard_interrupt_properly,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
handle_keyboard_interrupt_properly(ke)
|
|
311
|
+
raise # Never reached, but satisfies type checker
|
|
312
|
+
except Exception:
|
|
313
|
+
pass # Ignore unlink errors after successful copy
|
|
314
|
+
return dest_path
|
|
315
|
+
# Otherwise retry
|
|
316
|
+
raise
|
|
317
|
+
|
|
318
|
+
return temp_file.rename(dest_path)
|
|
319
|
+
except (PermissionError, OSError) as rename_err:
|
|
320
|
+
# If rename fails, try copy + delete as fallback
|
|
321
|
+
if attempt >= 15: # Use copy method after multiple rename failures
|
|
322
|
+
try:
|
|
323
|
+
shutil.copy2(temp_file, dest_path)
|
|
324
|
+
try:
|
|
325
|
+
temp_file.unlink()
|
|
326
|
+
except KeyboardInterrupt as ke:
|
|
327
|
+
from fbuild.interrupt_utils import (
|
|
328
|
+
handle_keyboard_interrupt_properly,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
handle_keyboard_interrupt_properly(ke)
|
|
332
|
+
raise # Never reached, but satisfies type checker
|
|
333
|
+
except Exception:
|
|
334
|
+
pass # Ignore unlink errors after successful copy
|
|
335
|
+
return dest_path
|
|
336
|
+
except KeyboardInterrupt as ke:
|
|
337
|
+
from fbuild.interrupt_utils import (
|
|
338
|
+
handle_keyboard_interrupt_properly,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
handle_keyboard_interrupt_properly(ke)
|
|
342
|
+
raise # Never reached, but satisfies type checker
|
|
343
|
+
except Exception:
|
|
344
|
+
pass # Let the outer exception handling deal with it
|
|
345
|
+
raise rename_err
|
|
346
|
+
|
|
347
|
+
except (PermissionError, OSError, FileNotFoundError) as e:
|
|
348
|
+
if attempt < 29 and (isinstance(e, (PermissionError, FileNotFoundError)) or (hasattr(e, "errno") and e.errno in (2, 13, 32))):
|
|
349
|
+
delay = min(delay * 1.3, max_delay)
|
|
350
|
+
continue
|
|
351
|
+
raise
|
|
352
|
+
raise PermissionError(f"Failed to move file after extended retry: {temp_file} -> {dest_path}")
|
|
353
|
+
|
|
354
|
+
if platform.system() == "Windows":
|
|
355
|
+
dest_path = rename_with_extended_retry()
|
|
356
|
+
else:
|
|
357
|
+
dest_path = _retry_windows_file_operation(lambda: temp_file.rename(dest_path))
|
|
358
|
+
|
|
359
|
+
return dest_path
|
|
360
|
+
|
|
361
|
+
except requests.RequestException as e:
|
|
362
|
+
if temp_file.exists():
|
|
363
|
+
try:
|
|
364
|
+
_retry_windows_file_operation(lambda: temp_file.unlink())
|
|
365
|
+
except KeyboardInterrupt as ke:
|
|
366
|
+
from fbuild.interrupt_utils import (
|
|
367
|
+
handle_keyboard_interrupt_properly,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
handle_keyboard_interrupt_properly(ke)
|
|
371
|
+
raise # Never reached, but satisfies type checker
|
|
372
|
+
except Exception:
|
|
373
|
+
pass # Ignore cleanup errors when reporting download error
|
|
374
|
+
raise DownloadError(f"Failed to download {url}: {e}")
|
|
375
|
+
|
|
376
|
+
except KeyboardInterrupt as ke:
|
|
377
|
+
from fbuild.interrupt_utils import handle_keyboard_interrupt_properly
|
|
378
|
+
|
|
379
|
+
handle_keyboard_interrupt_properly(ke)
|
|
380
|
+
raise # Never reached, but satisfies type checker
|
|
381
|
+
except Exception:
|
|
382
|
+
if temp_file.exists():
|
|
383
|
+
try:
|
|
384
|
+
_retry_windows_file_operation(lambda: temp_file.unlink())
|
|
385
|
+
except KeyboardInterrupt as ke:
|
|
386
|
+
from fbuild.interrupt_utils import (
|
|
387
|
+
handle_keyboard_interrupt_properly,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
handle_keyboard_interrupt_properly(ke)
|
|
391
|
+
raise # Never reached, but satisfies type checker
|
|
392
|
+
except Exception:
|
|
393
|
+
pass # Ignore cleanup errors when reporting original error
|
|
394
|
+
raise
|
|
395
|
+
|
|
396
|
+
def extract_archive(self, archive_path: Path, dest_dir: Path, show_progress: bool = True) -> Path:
|
|
397
|
+
"""Extract an archive file.
|
|
398
|
+
|
|
399
|
+
Supports .tar.gz, .tar.bz2, .tar.xz, and .zip formats.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
archive_path: Path to the archive file
|
|
403
|
+
dest_dir: Destination directory for extraction
|
|
404
|
+
show_progress: Whether to show progress information
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
Path to the extracted directory
|
|
408
|
+
|
|
409
|
+
Raises:
|
|
410
|
+
ExtractionError: If extraction fails
|
|
411
|
+
"""
|
|
412
|
+
archive_path = Path(archive_path)
|
|
413
|
+
dest_dir = Path(dest_dir)
|
|
414
|
+
|
|
415
|
+
if not archive_path.exists():
|
|
416
|
+
raise ExtractionError(f"Archive not found: {archive_path}")
|
|
417
|
+
|
|
418
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
419
|
+
|
|
420
|
+
try:
|
|
421
|
+
if show_progress:
|
|
422
|
+
print(f"Extracting {archive_path.name}...")
|
|
423
|
+
|
|
424
|
+
# Determine archive type and extract
|
|
425
|
+
if archive_path.suffix == ".zip":
|
|
426
|
+
self._extract_zip(archive_path, dest_dir)
|
|
427
|
+
elif archive_path.name.endswith((".tar.gz", ".tar.bz2", ".tar.xz")):
|
|
428
|
+
self._extract_tar(archive_path, dest_dir)
|
|
429
|
+
else:
|
|
430
|
+
raise ExtractionError(f"Unsupported archive format: {archive_path.suffix}")
|
|
431
|
+
|
|
432
|
+
return dest_dir
|
|
433
|
+
|
|
434
|
+
except KeyboardInterrupt as ke:
|
|
435
|
+
from fbuild.interrupt_utils import handle_keyboard_interrupt_properly
|
|
436
|
+
|
|
437
|
+
handle_keyboard_interrupt_properly(ke)
|
|
438
|
+
raise # Never reached, but satisfies type checker
|
|
439
|
+
except Exception as e:
|
|
440
|
+
raise ExtractionError(f"Failed to extract {archive_path}: {e}")
|
|
441
|
+
|
|
442
|
+
def _extract_tar(self, archive_path: Path, dest_dir: Path) -> None:
|
|
443
|
+
"""Extract a tar archive.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
archive_path: Path to tar archive
|
|
447
|
+
dest_dir: Destination directory
|
|
448
|
+
"""
|
|
449
|
+
with tarfile.open(archive_path, "r:*") as tar:
|
|
450
|
+
tar.extractall(dest_dir)
|
|
451
|
+
|
|
452
|
+
def _extract_zip(self, archive_path: Path, dest_dir: Path) -> None:
|
|
453
|
+
"""Extract a zip archive.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
archive_path: Path to zip archive
|
|
457
|
+
dest_dir: Destination directory
|
|
458
|
+
"""
|
|
459
|
+
with zipfile.ZipFile(archive_path, "r") as zip_file:
|
|
460
|
+
zip_file.extractall(dest_dir)
|
|
461
|
+
|
|
462
|
+
def download_and_extract(
|
|
463
|
+
self,
|
|
464
|
+
url: str,
|
|
465
|
+
cache_dir: Path,
|
|
466
|
+
extract_dir: Path,
|
|
467
|
+
checksum: Optional[str] = None,
|
|
468
|
+
show_progress: bool = True,
|
|
469
|
+
) -> Path:
|
|
470
|
+
"""Download and extract a package in one operation.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
url: URL to download from
|
|
474
|
+
cache_dir: Directory to cache the downloaded archive
|
|
475
|
+
extract_dir: Directory to extract to
|
|
476
|
+
checksum: Optional SHA256 checksum
|
|
477
|
+
show_progress: Whether to show progress
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
Path to the extracted directory
|
|
481
|
+
"""
|
|
482
|
+
# Determine archive filename from URL
|
|
483
|
+
filename = Path(urlparse(url).path).name
|
|
484
|
+
archive_path = cache_dir / filename
|
|
485
|
+
|
|
486
|
+
# Download if not cached
|
|
487
|
+
if not archive_path.exists():
|
|
488
|
+
self.download(url, archive_path, checksum, show_progress)
|
|
489
|
+
elif show_progress:
|
|
490
|
+
print(f"Using cached {filename}")
|
|
491
|
+
|
|
492
|
+
# Extract
|
|
493
|
+
return self.extract_archive(archive_path, extract_dir, show_progress)
|
|
494
|
+
|
|
495
|
+
def verify_checksum(self, file_path: Path, expected: str) -> bool:
|
|
496
|
+
"""Verify SHA256 checksum of a file.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
file_path: Path to file to verify
|
|
500
|
+
expected: Expected SHA256 checksum (hex string)
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
True if checksum matches
|
|
504
|
+
|
|
505
|
+
Raises:
|
|
506
|
+
ChecksumError: If checksum doesn't match
|
|
507
|
+
"""
|
|
508
|
+
sha256 = hashlib.sha256()
|
|
509
|
+
|
|
510
|
+
with open(file_path, "rb") as f:
|
|
511
|
+
for chunk in iter(lambda: f.read(self.chunk_size), b""):
|
|
512
|
+
sha256.update(chunk)
|
|
513
|
+
|
|
514
|
+
actual = sha256.hexdigest()
|
|
515
|
+
if actual.lower() != expected.lower():
|
|
516
|
+
raise ChecksumError(f"Checksum mismatch for {file_path}\n" + f"Expected: {expected}\n" + f"Got: {actual}")
|
|
517
|
+
|
|
518
|
+
return True
|