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,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
+ }