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.
Files changed (121) hide show
  1. fbuild/__init__.py +390 -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_info_generator.py +624 -0
  8. fbuild/build/build_state.py +325 -0
  9. fbuild/build/build_utils.py +93 -0
  10. fbuild/build/compilation_executor.py +422 -0
  11. fbuild/build/compiler.py +165 -0
  12. fbuild/build/compiler_avr.py +574 -0
  13. fbuild/build/configurable_compiler.py +664 -0
  14. fbuild/build/configurable_linker.py +637 -0
  15. fbuild/build/flag_builder.py +214 -0
  16. fbuild/build/library_dependency_processor.py +185 -0
  17. fbuild/build/linker.py +708 -0
  18. fbuild/build/orchestrator.py +67 -0
  19. fbuild/build/orchestrator_avr.py +651 -0
  20. fbuild/build/orchestrator_esp32.py +878 -0
  21. fbuild/build/orchestrator_rp2040.py +719 -0
  22. fbuild/build/orchestrator_stm32.py +696 -0
  23. fbuild/build/orchestrator_teensy.py +580 -0
  24. fbuild/build/source_compilation_orchestrator.py +218 -0
  25. fbuild/build/source_scanner.py +516 -0
  26. fbuild/cli.py +717 -0
  27. fbuild/cli_utils.py +314 -0
  28. fbuild/config/__init__.py +16 -0
  29. fbuild/config/board_config.py +542 -0
  30. fbuild/config/board_loader.py +92 -0
  31. fbuild/config/ini_parser.py +369 -0
  32. fbuild/config/mcu_specs.py +88 -0
  33. fbuild/daemon/__init__.py +42 -0
  34. fbuild/daemon/async_client.py +531 -0
  35. fbuild/daemon/client.py +1505 -0
  36. fbuild/daemon/compilation_queue.py +293 -0
  37. fbuild/daemon/configuration_lock.py +865 -0
  38. fbuild/daemon/daemon.py +585 -0
  39. fbuild/daemon/daemon_context.py +293 -0
  40. fbuild/daemon/error_collector.py +263 -0
  41. fbuild/daemon/file_cache.py +332 -0
  42. fbuild/daemon/firmware_ledger.py +546 -0
  43. fbuild/daemon/lock_manager.py +508 -0
  44. fbuild/daemon/logging_utils.py +149 -0
  45. fbuild/daemon/messages.py +957 -0
  46. fbuild/daemon/operation_registry.py +288 -0
  47. fbuild/daemon/port_state_manager.py +249 -0
  48. fbuild/daemon/process_tracker.py +366 -0
  49. fbuild/daemon/processors/__init__.py +18 -0
  50. fbuild/daemon/processors/build_processor.py +248 -0
  51. fbuild/daemon/processors/deploy_processor.py +664 -0
  52. fbuild/daemon/processors/install_deps_processor.py +431 -0
  53. fbuild/daemon/processors/locking_processor.py +777 -0
  54. fbuild/daemon/processors/monitor_processor.py +285 -0
  55. fbuild/daemon/request_processor.py +457 -0
  56. fbuild/daemon/shared_serial.py +819 -0
  57. fbuild/daemon/status_manager.py +238 -0
  58. fbuild/daemon/subprocess_manager.py +316 -0
  59. fbuild/deploy/__init__.py +21 -0
  60. fbuild/deploy/deployer.py +67 -0
  61. fbuild/deploy/deployer_esp32.py +310 -0
  62. fbuild/deploy/docker_utils.py +315 -0
  63. fbuild/deploy/monitor.py +519 -0
  64. fbuild/deploy/qemu_runner.py +603 -0
  65. fbuild/interrupt_utils.py +34 -0
  66. fbuild/ledger/__init__.py +52 -0
  67. fbuild/ledger/board_ledger.py +560 -0
  68. fbuild/output.py +352 -0
  69. fbuild/packages/__init__.py +66 -0
  70. fbuild/packages/archive_utils.py +1098 -0
  71. fbuild/packages/arduino_core.py +412 -0
  72. fbuild/packages/cache.py +256 -0
  73. fbuild/packages/concurrent_manager.py +510 -0
  74. fbuild/packages/downloader.py +518 -0
  75. fbuild/packages/fingerprint.py +423 -0
  76. fbuild/packages/framework_esp32.py +538 -0
  77. fbuild/packages/framework_rp2040.py +349 -0
  78. fbuild/packages/framework_stm32.py +459 -0
  79. fbuild/packages/framework_teensy.py +346 -0
  80. fbuild/packages/github_utils.py +96 -0
  81. fbuild/packages/header_trampoline_cache.py +394 -0
  82. fbuild/packages/library_compiler.py +203 -0
  83. fbuild/packages/library_manager.py +549 -0
  84. fbuild/packages/library_manager_esp32.py +725 -0
  85. fbuild/packages/package.py +163 -0
  86. fbuild/packages/platform_esp32.py +383 -0
  87. fbuild/packages/platform_rp2040.py +400 -0
  88. fbuild/packages/platform_stm32.py +581 -0
  89. fbuild/packages/platform_teensy.py +312 -0
  90. fbuild/packages/platform_utils.py +131 -0
  91. fbuild/packages/platformio_registry.py +369 -0
  92. fbuild/packages/sdk_utils.py +231 -0
  93. fbuild/packages/toolchain.py +436 -0
  94. fbuild/packages/toolchain_binaries.py +196 -0
  95. fbuild/packages/toolchain_esp32.py +489 -0
  96. fbuild/packages/toolchain_metadata.py +185 -0
  97. fbuild/packages/toolchain_rp2040.py +436 -0
  98. fbuild/packages/toolchain_stm32.py +417 -0
  99. fbuild/packages/toolchain_teensy.py +404 -0
  100. fbuild/platform_configs/esp32.json +150 -0
  101. fbuild/platform_configs/esp32c2.json +144 -0
  102. fbuild/platform_configs/esp32c3.json +143 -0
  103. fbuild/platform_configs/esp32c5.json +151 -0
  104. fbuild/platform_configs/esp32c6.json +151 -0
  105. fbuild/platform_configs/esp32p4.json +149 -0
  106. fbuild/platform_configs/esp32s3.json +151 -0
  107. fbuild/platform_configs/imxrt1062.json +56 -0
  108. fbuild/platform_configs/rp2040.json +70 -0
  109. fbuild/platform_configs/rp2350.json +76 -0
  110. fbuild/platform_configs/stm32f1.json +59 -0
  111. fbuild/platform_configs/stm32f4.json +63 -0
  112. fbuild/py.typed +0 -0
  113. fbuild-1.2.8.dist-info/METADATA +468 -0
  114. fbuild-1.2.8.dist-info/RECORD +121 -0
  115. fbuild-1.2.8.dist-info/WHEEL +5 -0
  116. fbuild-1.2.8.dist-info/entry_points.txt +5 -0
  117. fbuild-1.2.8.dist-info/licenses/LICENSE +21 -0
  118. fbuild-1.2.8.dist-info/top_level.txt +2 -0
  119. fbuild_lint/__init__.py +0 -0
  120. fbuild_lint/ruff_plugins/__init__.py +0 -0
  121. 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