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,510 @@
|
|
|
1
|
+
"""Concurrent package download and installation manager.
|
|
2
|
+
|
|
3
|
+
This module provides thread-safe package management with:
|
|
4
|
+
- Fine-grained per-package locking
|
|
5
|
+
- Parallel downloads via ThreadPoolExecutor
|
|
6
|
+
- Atomic installation with rollback
|
|
7
|
+
- Fingerprint-based validation
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import shutil
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Callable, Dict, Iterator, List, Optional
|
|
20
|
+
|
|
21
|
+
from .cache import Cache
|
|
22
|
+
from .downloader import DownloadError, ExtractionError, PackageDownloader
|
|
23
|
+
from .fingerprint import FingerprintRegistry, PackageFingerprint
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# Default lock timeout: 10 minutes (for large package downloads)
|
|
28
|
+
DEFAULT_PACKAGE_LOCK_TIMEOUT = 600.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class PackageSpec:
|
|
33
|
+
"""Specification for a package to download/install."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
url: str
|
|
37
|
+
version: str = "latest"
|
|
38
|
+
checksum: Optional[str] = None
|
|
39
|
+
key_files: Optional[List[str]] = None # Files to verify after extraction
|
|
40
|
+
post_install: Optional[Callable[[Path], None]] = None # Post-install hook
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class PackageResult:
|
|
45
|
+
"""Result of a package installation."""
|
|
46
|
+
|
|
47
|
+
spec: PackageSpec
|
|
48
|
+
success: bool
|
|
49
|
+
install_path: Optional[Path]
|
|
50
|
+
fingerprint: Optional[PackageFingerprint]
|
|
51
|
+
error: Optional[str]
|
|
52
|
+
elapsed_time: float
|
|
53
|
+
was_cached: bool
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PackageLockError(RuntimeError):
|
|
57
|
+
"""Error raised when a package lock cannot be acquired."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, package_id: str, message: str):
|
|
60
|
+
self.package_id = package_id
|
|
61
|
+
super().__init__(f"Package lock error for '{package_id}': {message}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class PackageLockInfo:
|
|
66
|
+
"""Information about a package lock."""
|
|
67
|
+
|
|
68
|
+
lock: threading.Lock
|
|
69
|
+
created_at: float
|
|
70
|
+
acquired_at: Optional[float] = None
|
|
71
|
+
holder_thread_id: Optional[int] = None
|
|
72
|
+
holder_description: Optional[str] = None
|
|
73
|
+
|
|
74
|
+
def is_held(self) -> bool:
|
|
75
|
+
"""Check if lock is currently held."""
|
|
76
|
+
return self.acquired_at is not None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ConcurrentPackageManager:
|
|
80
|
+
"""Thread-safe package download and extraction manager.
|
|
81
|
+
|
|
82
|
+
Provides concurrent package management with:
|
|
83
|
+
- Per-package fine-grained locking (no blocking unrelated packages)
|
|
84
|
+
- Parallel downloads using ThreadPoolExecutor
|
|
85
|
+
- Atomic extraction with rollback on failure
|
|
86
|
+
- Fingerprint-based cache validation
|
|
87
|
+
|
|
88
|
+
Example:
|
|
89
|
+
>>> manager = ConcurrentPackageManager(cache)
|
|
90
|
+
>>> specs = [
|
|
91
|
+
... PackageSpec(name="toolchain", url="https://..."),
|
|
92
|
+
... PackageSpec(name="framework", url="https://..."),
|
|
93
|
+
... ]
|
|
94
|
+
>>> results = manager.ensure_packages(specs)
|
|
95
|
+
>>> for result in results:
|
|
96
|
+
... if result.success:
|
|
97
|
+
... print(f"{result.spec.name}: {result.install_path}")
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
cache: Cache,
|
|
103
|
+
max_workers: int = 4,
|
|
104
|
+
show_progress: bool = True,
|
|
105
|
+
):
|
|
106
|
+
"""Initialize concurrent package manager.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
cache: Cache instance for storing packages
|
|
110
|
+
max_workers: Maximum number of concurrent downloads
|
|
111
|
+
show_progress: Whether to show download progress
|
|
112
|
+
"""
|
|
113
|
+
self.cache = cache
|
|
114
|
+
self.max_workers = max_workers
|
|
115
|
+
self.show_progress = show_progress
|
|
116
|
+
self.downloader = PackageDownloader()
|
|
117
|
+
self.registry = FingerprintRegistry(cache.cache_root)
|
|
118
|
+
|
|
119
|
+
# Locking infrastructure
|
|
120
|
+
self._master_lock = threading.Lock()
|
|
121
|
+
self._package_locks: Dict[str, PackageLockInfo] = {}
|
|
122
|
+
|
|
123
|
+
def _get_package_id(self, url: str, version: str) -> str:
|
|
124
|
+
"""Generate unique package identifier."""
|
|
125
|
+
url_hash = PackageFingerprint.hash_url(url)
|
|
126
|
+
return f"{url_hash}:{version}"
|
|
127
|
+
|
|
128
|
+
def _get_or_create_lock(self, package_id: str) -> PackageLockInfo:
|
|
129
|
+
"""Get or create a lock for the given package."""
|
|
130
|
+
with self._master_lock:
|
|
131
|
+
if package_id not in self._package_locks:
|
|
132
|
+
self._package_locks[package_id] = PackageLockInfo(
|
|
133
|
+
lock=threading.Lock(),
|
|
134
|
+
created_at=time.time(),
|
|
135
|
+
)
|
|
136
|
+
return self._package_locks[package_id]
|
|
137
|
+
|
|
138
|
+
@contextmanager
|
|
139
|
+
def acquire_package_lock(
|
|
140
|
+
self,
|
|
141
|
+
url: str,
|
|
142
|
+
version: str,
|
|
143
|
+
blocking: bool = True,
|
|
144
|
+
timeout: float = DEFAULT_PACKAGE_LOCK_TIMEOUT,
|
|
145
|
+
description: Optional[str] = None,
|
|
146
|
+
) -> Iterator[None]:
|
|
147
|
+
"""Acquire a lock for a specific package.
|
|
148
|
+
|
|
149
|
+
This ensures that only one thread can download/extract a package
|
|
150
|
+
at a time, while allowing other packages to be processed concurrently.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
url: Package URL
|
|
154
|
+
version: Package version
|
|
155
|
+
blocking: If True, wait for lock. If False, raise error if unavailable.
|
|
156
|
+
timeout: Maximum time to wait for lock
|
|
157
|
+
description: Human-readable description of operation
|
|
158
|
+
|
|
159
|
+
Yields:
|
|
160
|
+
None (lock is held for duration of context)
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
PackageLockError: If lock cannot be acquired
|
|
164
|
+
"""
|
|
165
|
+
package_id = self._get_package_id(url, version)
|
|
166
|
+
lock_info = self._get_or_create_lock(package_id)
|
|
167
|
+
|
|
168
|
+
logger.debug(f"Acquiring package lock for: {package_id}")
|
|
169
|
+
|
|
170
|
+
acquired = lock_info.lock.acquire(blocking=blocking, timeout=timeout if blocking else -1)
|
|
171
|
+
if not acquired:
|
|
172
|
+
raise PackageLockError(package_id, f"Lock unavailable (held by: {lock_info.holder_description or 'unknown'})")
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
with self._master_lock:
|
|
176
|
+
lock_info.acquired_at = time.time()
|
|
177
|
+
lock_info.holder_thread_id = threading.get_ident()
|
|
178
|
+
lock_info.holder_description = description or f"Package operation for {package_id}"
|
|
179
|
+
|
|
180
|
+
logger.debug(f"Package lock acquired for: {package_id}")
|
|
181
|
+
yield
|
|
182
|
+
finally:
|
|
183
|
+
with self._master_lock:
|
|
184
|
+
lock_info.acquired_at = None
|
|
185
|
+
lock_info.holder_thread_id = None
|
|
186
|
+
lock_info.holder_description = None
|
|
187
|
+
lock_info.lock.release()
|
|
188
|
+
logger.debug(f"Package lock released for: {package_id}")
|
|
189
|
+
|
|
190
|
+
def is_package_installed(self, url: str, version: str) -> bool:
|
|
191
|
+
"""Check if a package is already installed and valid.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
url: Package URL
|
|
195
|
+
version: Package version
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
True if package is installed and fingerprint valid
|
|
199
|
+
"""
|
|
200
|
+
return self.registry.is_installed(url, version)
|
|
201
|
+
|
|
202
|
+
def get_install_path(self, url: str, version: str) -> Optional[Path]:
|
|
203
|
+
"""Get installation path for a package.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
url: Package URL
|
|
207
|
+
version: Package version
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Installation path or None if not installed
|
|
211
|
+
"""
|
|
212
|
+
return self.registry.get_install_path(url, version)
|
|
213
|
+
|
|
214
|
+
def download_package(
|
|
215
|
+
self,
|
|
216
|
+
spec: PackageSpec,
|
|
217
|
+
force: bool = False,
|
|
218
|
+
) -> PackageResult:
|
|
219
|
+
"""Download and install a single package (thread-safe).
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
spec: Package specification
|
|
223
|
+
force: Force re-download even if cached
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
PackageResult with installation details
|
|
227
|
+
"""
|
|
228
|
+
start_time = time.time()
|
|
229
|
+
|
|
230
|
+
# Check if already installed
|
|
231
|
+
if not force and self.is_package_installed(spec.url, spec.version):
|
|
232
|
+
install_path = self.get_install_path(spec.url, spec.version)
|
|
233
|
+
fingerprint = self.registry.get_fingerprint(spec.url, spec.version)
|
|
234
|
+
return PackageResult(
|
|
235
|
+
spec=spec,
|
|
236
|
+
success=True,
|
|
237
|
+
install_path=install_path,
|
|
238
|
+
fingerprint=fingerprint,
|
|
239
|
+
error=None,
|
|
240
|
+
elapsed_time=time.time() - start_time,
|
|
241
|
+
was_cached=True,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# Acquire lock and download
|
|
245
|
+
try:
|
|
246
|
+
with self.acquire_package_lock(spec.url, spec.version, description=f"Download {spec.name}"):
|
|
247
|
+
# Double-check after acquiring lock (another thread may have installed it)
|
|
248
|
+
if not force and self.is_package_installed(spec.url, spec.version):
|
|
249
|
+
install_path = self.get_install_path(spec.url, spec.version)
|
|
250
|
+
fingerprint = self.registry.get_fingerprint(spec.url, spec.version)
|
|
251
|
+
return PackageResult(
|
|
252
|
+
spec=spec,
|
|
253
|
+
success=True,
|
|
254
|
+
install_path=install_path,
|
|
255
|
+
fingerprint=fingerprint,
|
|
256
|
+
error=None,
|
|
257
|
+
elapsed_time=time.time() - start_time,
|
|
258
|
+
was_cached=True,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# Perform download and installation
|
|
262
|
+
return self._do_download_and_install(spec, start_time, force)
|
|
263
|
+
|
|
264
|
+
except KeyboardInterrupt as ke:
|
|
265
|
+
from fbuild.interrupt_utils import handle_keyboard_interrupt_properly
|
|
266
|
+
|
|
267
|
+
handle_keyboard_interrupt_properly(ke)
|
|
268
|
+
except PackageLockError as e:
|
|
269
|
+
return PackageResult(
|
|
270
|
+
spec=spec,
|
|
271
|
+
success=False,
|
|
272
|
+
install_path=None,
|
|
273
|
+
fingerprint=None,
|
|
274
|
+
error=str(e),
|
|
275
|
+
elapsed_time=time.time() - start_time,
|
|
276
|
+
was_cached=False,
|
|
277
|
+
)
|
|
278
|
+
except Exception as e:
|
|
279
|
+
return PackageResult(
|
|
280
|
+
spec=spec,
|
|
281
|
+
success=False,
|
|
282
|
+
install_path=None,
|
|
283
|
+
fingerprint=None,
|
|
284
|
+
error=f"Unexpected error: {e}",
|
|
285
|
+
elapsed_time=time.time() - start_time,
|
|
286
|
+
was_cached=False,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def _do_download_and_install(
|
|
290
|
+
self,
|
|
291
|
+
spec: PackageSpec,
|
|
292
|
+
start_time: float,
|
|
293
|
+
force: bool,
|
|
294
|
+
) -> PackageResult:
|
|
295
|
+
"""Internal: perform actual download and installation.
|
|
296
|
+
|
|
297
|
+
Must be called while holding the package lock.
|
|
298
|
+
"""
|
|
299
|
+
url_hash = PackageFingerprint.hash_url(spec.url)
|
|
300
|
+
|
|
301
|
+
# Determine paths
|
|
302
|
+
cache_dir = self.cache.packages_dir / url_hash / spec.version
|
|
303
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
304
|
+
|
|
305
|
+
# Get archive filename from URL
|
|
306
|
+
archive_name = Path(spec.url.split("/")[-1].split("?")[0])
|
|
307
|
+
archive_path = cache_dir / archive_name
|
|
308
|
+
|
|
309
|
+
# Determine install directory
|
|
310
|
+
install_dir = self.cache.toolchains_dir / url_hash / spec.version
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
# Step 1: Download archive
|
|
314
|
+
if force or not archive_path.exists():
|
|
315
|
+
if self.show_progress:
|
|
316
|
+
print(f"Downloading {spec.name}...")
|
|
317
|
+
self.downloader.download(
|
|
318
|
+
spec.url,
|
|
319
|
+
archive_path,
|
|
320
|
+
checksum=spec.checksum,
|
|
321
|
+
show_progress=self.show_progress,
|
|
322
|
+
)
|
|
323
|
+
else:
|
|
324
|
+
if self.show_progress:
|
|
325
|
+
print(f"Using cached archive for {spec.name}")
|
|
326
|
+
|
|
327
|
+
# Step 2: Atomic extraction
|
|
328
|
+
self._atomic_extract(archive_path, install_dir, spec)
|
|
329
|
+
|
|
330
|
+
# Step 3: Create fingerprint
|
|
331
|
+
fingerprint = PackageFingerprint.from_archive(
|
|
332
|
+
url=spec.url,
|
|
333
|
+
version=spec.version,
|
|
334
|
+
archive_path=archive_path,
|
|
335
|
+
extracted_dir=install_dir,
|
|
336
|
+
key_files=spec.key_files,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Step 4: Save fingerprint and register
|
|
340
|
+
fingerprint.save(install_dir)
|
|
341
|
+
self.registry.register(fingerprint, install_dir)
|
|
342
|
+
|
|
343
|
+
# Step 5: Run post-install hook if provided
|
|
344
|
+
if spec.post_install:
|
|
345
|
+
spec.post_install(install_dir)
|
|
346
|
+
|
|
347
|
+
return PackageResult(
|
|
348
|
+
spec=spec,
|
|
349
|
+
success=True,
|
|
350
|
+
install_path=install_dir,
|
|
351
|
+
fingerprint=fingerprint,
|
|
352
|
+
error=None,
|
|
353
|
+
elapsed_time=time.time() - start_time,
|
|
354
|
+
was_cached=False,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
except KeyboardInterrupt as ke:
|
|
358
|
+
from fbuild.interrupt_utils import handle_keyboard_interrupt_properly
|
|
359
|
+
|
|
360
|
+
handle_keyboard_interrupt_properly(ke)
|
|
361
|
+
except (DownloadError, ExtractionError) as e:
|
|
362
|
+
return PackageResult(
|
|
363
|
+
spec=spec,
|
|
364
|
+
success=False,
|
|
365
|
+
install_path=None,
|
|
366
|
+
fingerprint=None,
|
|
367
|
+
error=str(e),
|
|
368
|
+
elapsed_time=time.time() - start_time,
|
|
369
|
+
was_cached=False,
|
|
370
|
+
)
|
|
371
|
+
except Exception as e:
|
|
372
|
+
return PackageResult(
|
|
373
|
+
spec=spec,
|
|
374
|
+
success=False,
|
|
375
|
+
install_path=None,
|
|
376
|
+
fingerprint=None,
|
|
377
|
+
error=f"Installation failed: {e}",
|
|
378
|
+
elapsed_time=time.time() - start_time,
|
|
379
|
+
was_cached=False,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
def _atomic_extract(
|
|
383
|
+
self,
|
|
384
|
+
archive_path: Path,
|
|
385
|
+
dest_dir: Path,
|
|
386
|
+
spec: PackageSpec,
|
|
387
|
+
) -> Path:
|
|
388
|
+
"""Extract archive atomically with rollback on failure.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
archive_path: Path to archive file
|
|
392
|
+
dest_dir: Final destination directory
|
|
393
|
+
spec: Package specification
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
Path to extracted directory
|
|
397
|
+
|
|
398
|
+
Raises:
|
|
399
|
+
ExtractionError: If extraction fails
|
|
400
|
+
"""
|
|
401
|
+
if self.show_progress:
|
|
402
|
+
print(f"Extracting {spec.name}...")
|
|
403
|
+
|
|
404
|
+
# Extract to temporary directory first
|
|
405
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
406
|
+
temp_path = Path(temp_dir)
|
|
407
|
+
self.downloader.extract_archive(archive_path, temp_path, show_progress=False)
|
|
408
|
+
|
|
409
|
+
# Find extracted content (may be nested in a single directory)
|
|
410
|
+
extracted_items = list(temp_path.iterdir())
|
|
411
|
+
if len(extracted_items) == 1 and extracted_items[0].is_dir():
|
|
412
|
+
source_dir = extracted_items[0]
|
|
413
|
+
else:
|
|
414
|
+
source_dir = temp_path
|
|
415
|
+
|
|
416
|
+
# Remove existing destination if present
|
|
417
|
+
if dest_dir.exists():
|
|
418
|
+
shutil.rmtree(dest_dir)
|
|
419
|
+
|
|
420
|
+
# Move to final location atomically
|
|
421
|
+
dest_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
422
|
+
shutil.move(str(source_dir), str(dest_dir))
|
|
423
|
+
|
|
424
|
+
return dest_dir
|
|
425
|
+
|
|
426
|
+
def ensure_packages(
|
|
427
|
+
self,
|
|
428
|
+
specs: List[PackageSpec],
|
|
429
|
+
force: bool = False,
|
|
430
|
+
) -> List[PackageResult]:
|
|
431
|
+
"""Download and install multiple packages in parallel.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
specs: List of package specifications
|
|
435
|
+
force: Force re-download even if cached
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
List of PackageResult for each spec (in same order)
|
|
439
|
+
"""
|
|
440
|
+
if not specs:
|
|
441
|
+
return []
|
|
442
|
+
|
|
443
|
+
results: Dict[str, PackageResult] = {}
|
|
444
|
+
|
|
445
|
+
# Use ThreadPoolExecutor for parallel downloads
|
|
446
|
+
with ThreadPoolExecutor(max_workers=min(self.max_workers, len(specs))) as executor:
|
|
447
|
+
# Submit all download tasks
|
|
448
|
+
future_to_spec = {executor.submit(self.download_package, spec, force): spec for spec in specs}
|
|
449
|
+
|
|
450
|
+
# Collect results as they complete
|
|
451
|
+
for future in as_completed(future_to_spec):
|
|
452
|
+
spec = future_to_spec[future]
|
|
453
|
+
try:
|
|
454
|
+
result = future.result()
|
|
455
|
+
results[spec.name] = result
|
|
456
|
+
|
|
457
|
+
if self.show_progress:
|
|
458
|
+
status = "✓" if result.success else "✗"
|
|
459
|
+
cached = " (cached)" if result.was_cached else ""
|
|
460
|
+
print(f" {status} {spec.name}{cached}")
|
|
461
|
+
|
|
462
|
+
except KeyboardInterrupt as ke:
|
|
463
|
+
from fbuild.interrupt_utils import (
|
|
464
|
+
handle_keyboard_interrupt_properly,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
handle_keyboard_interrupt_properly(ke)
|
|
468
|
+
except Exception as e:
|
|
469
|
+
results[spec.name] = PackageResult(
|
|
470
|
+
spec=spec,
|
|
471
|
+
success=False,
|
|
472
|
+
install_path=None,
|
|
473
|
+
fingerprint=None,
|
|
474
|
+
error=str(e),
|
|
475
|
+
elapsed_time=0.0,
|
|
476
|
+
was_cached=False,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
# Return results in original order
|
|
480
|
+
return [results[spec.name] for spec in specs]
|
|
481
|
+
|
|
482
|
+
def cleanup_locks(self) -> int:
|
|
483
|
+
"""Remove unused package locks.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
Number of locks removed
|
|
487
|
+
"""
|
|
488
|
+
with self._master_lock:
|
|
489
|
+
keys_to_remove = [key for key, info in self._package_locks.items() if not info.is_held()]
|
|
490
|
+
for key in keys_to_remove:
|
|
491
|
+
del self._package_locks[key]
|
|
492
|
+
return len(keys_to_remove)
|
|
493
|
+
|
|
494
|
+
def get_lock_status(self) -> Dict[str, Dict[str, Any]]:
|
|
495
|
+
"""Get current lock status for debugging.
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
Dictionary of package_id -> lock info
|
|
499
|
+
"""
|
|
500
|
+
with self._master_lock:
|
|
501
|
+
return {
|
|
502
|
+
pkg_id: {
|
|
503
|
+
"is_held": info.is_held(),
|
|
504
|
+
"holder_thread_id": info.holder_thread_id,
|
|
505
|
+
"holder_description": info.holder_description,
|
|
506
|
+
"created_at": info.created_at,
|
|
507
|
+
"acquired_at": info.acquired_at,
|
|
508
|
+
}
|
|
509
|
+
for pkg_id, info in self._package_locks.items()
|
|
510
|
+
}
|