fbuild 1.2.8__py3-none-any.whl → 1.2.15__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 (47) hide show
  1. fbuild/__init__.py +5 -1
  2. fbuild/build/configurable_compiler.py +49 -6
  3. fbuild/build/configurable_linker.py +14 -9
  4. fbuild/build/orchestrator_esp32.py +6 -3
  5. fbuild/build/orchestrator_rp2040.py +6 -2
  6. fbuild/cli.py +300 -5
  7. fbuild/config/ini_parser.py +13 -1
  8. fbuild/daemon/__init__.py +11 -0
  9. fbuild/daemon/async_client.py +5 -4
  10. fbuild/daemon/async_client_lib.py +1543 -0
  11. fbuild/daemon/async_protocol.py +825 -0
  12. fbuild/daemon/async_server.py +2100 -0
  13. fbuild/daemon/client.py +425 -13
  14. fbuild/daemon/configuration_lock.py +13 -13
  15. fbuild/daemon/connection.py +508 -0
  16. fbuild/daemon/connection_registry.py +579 -0
  17. fbuild/daemon/daemon.py +517 -164
  18. fbuild/daemon/daemon_context.py +72 -1
  19. fbuild/daemon/device_discovery.py +477 -0
  20. fbuild/daemon/device_manager.py +821 -0
  21. fbuild/daemon/error_collector.py +263 -263
  22. fbuild/daemon/file_cache.py +332 -332
  23. fbuild/daemon/firmware_ledger.py +46 -123
  24. fbuild/daemon/lock_manager.py +508 -508
  25. fbuild/daemon/messages.py +431 -0
  26. fbuild/daemon/operation_registry.py +288 -288
  27. fbuild/daemon/processors/build_processor.py +34 -1
  28. fbuild/daemon/processors/deploy_processor.py +1 -3
  29. fbuild/daemon/processors/locking_processor.py +7 -7
  30. fbuild/daemon/request_processor.py +457 -457
  31. fbuild/daemon/shared_serial.py +7 -7
  32. fbuild/daemon/status_manager.py +238 -238
  33. fbuild/daemon/subprocess_manager.py +316 -316
  34. fbuild/deploy/docker_utils.py +182 -2
  35. fbuild/deploy/monitor.py +1 -1
  36. fbuild/deploy/qemu_runner.py +71 -13
  37. fbuild/ledger/board_ledger.py +46 -122
  38. fbuild/output.py +238 -2
  39. fbuild/packages/library_compiler.py +15 -5
  40. fbuild/packages/library_manager.py +12 -6
  41. fbuild-1.2.15.dist-info/METADATA +569 -0
  42. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/RECORD +46 -39
  43. fbuild-1.2.8.dist-info/METADATA +0 -468
  44. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/WHEEL +0 -0
  45. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/entry_points.txt +0 -0
  46. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/licenses/LICENSE +0 -0
  47. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/top_level.txt +0 -0
fbuild/output.py CHANGED
@@ -32,12 +32,13 @@ import sys
32
32
  import time
33
33
  from pathlib import Path
34
34
  from types import TracebackType
35
- from typing import Optional, TextIO
35
+ from typing import Optional, Protocol, TextIO, runtime_checkable
36
36
 
37
37
  # Global state for the timer
38
38
  _start_time: Optional[float] = None
39
39
  _output_stream: TextIO = sys.stdout
40
40
  _verbose: bool = True
41
+ _output_file: Optional[TextIO] = None
41
42
 
42
43
 
43
44
  def init_timer(output_stream: Optional[TextIO] = None) -> None:
@@ -77,6 +78,27 @@ def set_verbose(verbose: bool) -> None:
77
78
  _verbose = verbose
78
79
 
79
80
 
81
+ def set_output_file(output_file: Optional[TextIO]) -> None:
82
+ """
83
+ Set a file to receive all log output (in addition to stdout).
84
+
85
+ Args:
86
+ output_file: File object to receive output, or None to disable file output
87
+ """
88
+ global _output_file
89
+ _output_file = output_file
90
+
91
+
92
+ def get_output_file() -> Optional[TextIO]:
93
+ """
94
+ Get the current output file.
95
+
96
+ Returns:
97
+ The current output file, or None if not set
98
+ """
99
+ return _output_file
100
+
101
+
80
102
  def get_elapsed() -> float:
81
103
  """
82
104
  Get elapsed time since timer initialization.
@@ -112,9 +134,15 @@ def _print(message: str, end: str = "\n") -> None:
112
134
  end: End character (default newline)
113
135
  """
114
136
  timestamp = format_timestamp()
115
- _output_stream.write(f"{timestamp} {message}{end}")
137
+ line = f"{timestamp} {message}{end}"
138
+ _output_stream.write(line)
116
139
  _output_stream.flush()
117
140
 
141
+ # Also write to output file if set
142
+ if _output_file is not None:
143
+ _output_file.write(line)
144
+ _output_file.flush()
145
+
118
146
 
119
147
  def log(message: str, verbose_only: bool = False) -> None:
120
148
  """
@@ -350,3 +378,211 @@ class TimedLogger:
350
378
  def log(self, message: str) -> None:
351
379
  """Log a message within this operation."""
352
380
  log(message, self.verbose_only)
381
+
382
+
383
+ # =============================================================================
384
+ # Progress Callback Protocol and Helpers
385
+ # =============================================================================
386
+
387
+
388
+ def format_size(size_bytes: int) -> str:
389
+ """
390
+ Format a byte count as a human-readable size.
391
+
392
+ Args:
393
+ size_bytes: Size in bytes
394
+
395
+ Returns:
396
+ Human-readable size string (e.g., "1.2 MB", "340 KB", "12 B")
397
+ """
398
+ if size_bytes < 0:
399
+ return "0 B"
400
+ if size_bytes < 1024:
401
+ return f"{size_bytes} B"
402
+ elif size_bytes < 1024 * 1024:
403
+ return f"{size_bytes / 1024:.1f} KB"
404
+ elif size_bytes < 1024 * 1024 * 1024:
405
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
406
+ else:
407
+ return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
408
+
409
+
410
+ def format_progress_bar(current: int, total: int, width: int = 28) -> str:
411
+ """
412
+ Generate an ASCII progress bar.
413
+
414
+ Args:
415
+ current: Current progress value
416
+ total: Total progress value
417
+ width: Width of the progress bar in characters (default 28)
418
+
419
+ Returns:
420
+ ASCII progress bar string like "████████░░░░░░░░░░░░░░░░░░░░"
421
+
422
+ Examples:
423
+ >>> format_progress_bar(5, 10, 20)
424
+ '██████████░░░░░░░░░░'
425
+ >>> format_progress_bar(0, 10, 10)
426
+ '░░░░░░░░░░'
427
+ >>> format_progress_bar(10, 10, 10)
428
+ '██████████'
429
+ """
430
+ if total <= 0:
431
+ return "░" * width
432
+ ratio = min(1.0, max(0.0, current / total))
433
+ filled = int(width * ratio)
434
+ empty = width - filled
435
+ return "█" * filled + "░" * empty
436
+
437
+
438
+ @runtime_checkable
439
+ class ProgressCallback(Protocol):
440
+ """
441
+ Protocol for progress callback implementations.
442
+
443
+ This protocol defines the interface for tracking build progress events.
444
+ Implementations can log to terminal, update TUI elements, or report to daemons.
445
+ """
446
+
447
+ def on_file_start(self, file: str, index: int, total: int) -> None:
448
+ """
449
+ Called when compilation of a file starts.
450
+
451
+ Args:
452
+ file: Name or path of the file being compiled
453
+ index: Current file index (1-based)
454
+ total: Total number of files to compile
455
+ """
456
+ ...
457
+
458
+ def on_file_complete(self, file: str, index: int, total: int, cached: bool = False) -> None:
459
+ """
460
+ Called when compilation of a file completes.
461
+
462
+ Args:
463
+ file: Name or path of the file that was compiled
464
+ index: Current file index (1-based)
465
+ total: Total number of files to compile
466
+ cached: If True, the file was served from cache (not recompiled)
467
+ """
468
+ ...
469
+
470
+ def on_download_progress(self, url: str, downloaded: int, total: int) -> None:
471
+ """
472
+ Called during file download to report progress.
473
+
474
+ Args:
475
+ url: URL being downloaded
476
+ downloaded: Bytes downloaded so far
477
+ total: Total bytes to download (0 if unknown)
478
+ """
479
+ ...
480
+
481
+ def on_extract_progress(self, archive: str, extracted: int, total: int) -> None:
482
+ """
483
+ Called during archive extraction to report progress.
484
+
485
+ Args:
486
+ archive: Archive file being extracted
487
+ extracted: Number of files/items extracted so far
488
+ total: Total number of files/items to extract
489
+ """
490
+ ...
491
+
492
+ def on_phase_start(self, phase: int, total: int, message: str) -> None:
493
+ """
494
+ Called when a build phase starts.
495
+
496
+ Args:
497
+ phase: Current phase number (1-based)
498
+ total: Total number of phases
499
+ message: Phase description
500
+ """
501
+ ...
502
+
503
+ def on_phase_complete(self, phase: int, total: int, elapsed: float) -> None:
504
+ """
505
+ Called when a build phase completes.
506
+
507
+ Args:
508
+ phase: Completed phase number (1-based)
509
+ total: Total number of phases
510
+ elapsed: Time spent in this phase (seconds)
511
+ """
512
+ ...
513
+
514
+
515
+ class DefaultProgressCallback:
516
+ """
517
+ Default implementation of ProgressCallback that logs to the existing output system.
518
+
519
+ This implementation uses log_detail() and log_phase() to display progress
520
+ in a format consistent with the rest of fbuild's output.
521
+ """
522
+
523
+ def __init__(self, verbose_only: bool = False, show_progress_bar: bool = True):
524
+ """
525
+ Initialize the default progress callback.
526
+
527
+ Args:
528
+ verbose_only: If True, only log when verbose mode is enabled
529
+ show_progress_bar: If True, show ASCII progress bars for downloads/extractions
530
+ """
531
+ self.verbose_only = verbose_only
532
+ self.show_progress_bar = show_progress_bar
533
+ self._last_download_percent: int = -1
534
+ self._last_extract_percent: int = -1
535
+
536
+ def on_file_start(self, file: str, index: int, total: int) -> None:
537
+ """Log file compilation start."""
538
+ log_detail(f"[{index}/{total}] Compiling {file}...", verbose_only=self.verbose_only)
539
+
540
+ def on_file_complete(self, file: str, index: int, total: int, cached: bool = False) -> None:
541
+ """Log file compilation completion."""
542
+ suffix = " (cached)" if cached else ""
543
+ log_detail(f"[{index}/{total}] {file}{suffix}", verbose_only=True)
544
+
545
+ def on_download_progress(self, url: str, downloaded: int, total: int) -> None:
546
+ """Log download progress (rate-limited to avoid excessive output)."""
547
+ if total <= 0:
548
+ return
549
+
550
+ percent = int(100 * downloaded / total)
551
+ # Only log at 10% intervals to avoid spam
552
+ if percent // 10 == self._last_download_percent // 10 and percent != 100:
553
+ return
554
+ self._last_download_percent = percent
555
+
556
+ filename = url.split("/")[-1][:30]
557
+ if self.show_progress_bar:
558
+ bar = format_progress_bar(downloaded, total, 20)
559
+ log_detail(f"{bar} {percent:3d}% {filename} ({format_size(downloaded)}/{format_size(total)})", verbose_only=self.verbose_only)
560
+ else:
561
+ log_detail(f"Downloading {filename}: {percent}% ({format_size(downloaded)}/{format_size(total)})", verbose_only=self.verbose_only)
562
+
563
+ def on_extract_progress(self, archive: str, extracted: int, total: int) -> None:
564
+ """Log extraction progress (rate-limited to avoid excessive output)."""
565
+ if total <= 0:
566
+ return
567
+
568
+ percent = int(100 * extracted / total)
569
+ # Only log at 25% intervals to avoid spam
570
+ if percent // 25 == self._last_extract_percent // 25 and percent != 100:
571
+ return
572
+ self._last_extract_percent = percent
573
+
574
+ archive_name = Path(archive).name[:30]
575
+ if self.show_progress_bar:
576
+ bar = format_progress_bar(extracted, total, 20)
577
+ log_detail(f"{bar} {percent:3d}% Extracting {archive_name} ({extracted}/{total} files)", verbose_only=self.verbose_only)
578
+ else:
579
+ log_detail(f"Extracting {archive_name}: {percent}% ({extracted}/{total} files)", verbose_only=self.verbose_only)
580
+
581
+ def on_phase_start(self, phase: int, total: int, message: str) -> None:
582
+ """Log phase start using log_phase."""
583
+ log_phase(phase, total, f"{message}...", verbose_only=self.verbose_only)
584
+
585
+ def on_phase_complete(self, phase: int, total: int, elapsed: float) -> None:
586
+ """Log phase completion with elapsed time."""
587
+ del phase, total # Unused in default implementation
588
+ log_detail(f"Done ({elapsed:.2f}s)", verbose_only=self.verbose_only)
@@ -8,6 +8,8 @@ import subprocess
8
8
  from pathlib import Path
9
9
  from typing import TYPE_CHECKING, Callable, List, Optional, Tuple
10
10
 
11
+ from fbuild.output import ProgressCallback, log_detail
12
+
11
13
  if TYPE_CHECKING:
12
14
  from .library_manager import LibraryInfo
13
15
 
@@ -70,6 +72,7 @@ class LibraryCompiler:
70
72
  defines: List[str],
71
73
  extra_flags: List[str],
72
74
  show_progress: bool = True,
75
+ progress_callback: ProgressCallback | None = None,
73
76
  ) -> Tuple[Path, List[Path], List[str]]:
74
77
  """Compile a library into a static archive (.a file).
75
78
 
@@ -97,7 +100,7 @@ class LibraryCompiler:
97
100
  """
98
101
  try:
99
102
  if show_progress:
100
- print(f"Compiling library: {library_name}")
103
+ log_detail(f"Compiling library: {library_name}")
101
104
 
102
105
  if not source_files:
103
106
  raise LibraryCompilationError(f"No source files found in library '{library_name}'")
@@ -155,14 +158,21 @@ class LibraryCompiler:
155
158
  compile_commands.append(" ".join(cmd))
156
159
 
157
160
  # Compile
158
- if show_progress:
159
- print(f" Compiling {source.name}...")
161
+ file_index = source_files.index(source) + 1 # 1-based indexing
162
+ total_files = len(source_files)
163
+ if progress_callback:
164
+ progress_callback.on_file_start(source.name, file_index, total_files)
165
+ elif show_progress:
166
+ log_detail(f"Compiling {source.name}...")
160
167
 
161
168
  result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8")
162
169
 
163
170
  if result.returncode != 0:
164
171
  raise LibraryCompilationError(f"Failed to compile {source}:\n{result.stderr}")
165
172
 
173
+ if progress_callback:
174
+ progress_callback.on_file_complete(source.name, file_index, total_files)
175
+
166
176
  object_files.append(obj_file)
167
177
 
168
178
  # Create static archive using avr-ar
@@ -170,7 +180,7 @@ class LibraryCompiler:
170
180
  archive_file = lib_dir / f"lib{library_name}.a"
171
181
 
172
182
  if show_progress:
173
- print(f" Creating archive: {archive_file.name}")
183
+ log_detail(f"Creating archive: {archive_file.name}")
174
184
 
175
185
  # Remove old archive if exists
176
186
  if archive_file.exists():
@@ -188,7 +198,7 @@ class LibraryCompiler:
188
198
  # Object files are needed for proper LTO symbol resolution
189
199
 
190
200
  if show_progress:
191
- print(f"Library '{library_name}' compiled successfully")
201
+ log_detail(f"Library '{library_name}' compiled successfully")
192
202
 
193
203
  return archive_file, object_files, compile_commands
194
204
 
@@ -10,6 +10,7 @@ from pathlib import Path
10
10
  from typing import Dict, List, Optional, Tuple
11
11
  from urllib.parse import urlparse
12
12
 
13
+ from fbuild.output import log_detail
13
14
  from fbuild.packages.downloader import PackageDownloader
14
15
  from fbuild.packages.github_utils import GitHubURLOptimizer
15
16
  from fbuild.packages.library_compiler import LibraryCompilationError, LibraryCompiler
@@ -269,7 +270,7 @@ class LibraryManager:
269
270
  if GitHubURLOptimizer.is_github_url(url):
270
271
  url = GitHubURLOptimizer.optimize_url(url)
271
272
  if show_progress:
272
- print(f"Optimized GitHub URL: {url}")
273
+ log_detail(f"Optimized GitHub URL: {url}")
273
274
 
274
275
  # Extract library name
275
276
  lib_name = self._extract_library_name(original_url)
@@ -278,7 +279,7 @@ class LibraryManager:
278
279
  # Skip if already downloaded
279
280
  if library.exists:
280
281
  if show_progress:
281
- print(f"Library '{lib_name}' already downloaded")
282
+ log_detail(f"Library '{lib_name}' already downloaded")
282
283
  return library
283
284
 
284
285
  # Create library directory
@@ -289,13 +290,13 @@ class LibraryManager:
289
290
  temp_archive = library.lib_dir / filename
290
291
 
291
292
  if show_progress:
292
- print(f"Downloading library: {lib_name}")
293
+ log_detail(f"Downloading library: {lib_name}")
293
294
 
294
295
  self.downloader.download(url, temp_archive, show_progress=show_progress)
295
296
 
296
297
  # Extract to src directory
297
298
  if show_progress:
298
- print(f"Extracting library: {lib_name}")
299
+ log_detail(f"Extracting library: {lib_name}")
299
300
 
300
301
  temp_extract = library.lib_dir / "_extract"
301
302
  temp_extract.mkdir(exist_ok=True)
@@ -475,8 +476,13 @@ class LibraryManager:
475
476
  List of compiled Library instances
476
477
  """
477
478
  libraries = []
479
+ total = len(lib_deps)
480
+
481
+ for index, url in enumerate(lib_deps, 1):
482
+ # Extract library name for logging
483
+ library_name = self._extract_library_name(url)
484
+ log_detail(f"[{index}/{total}] {library_name}")
478
485
 
479
- for url in lib_deps:
480
486
  # Download library
481
487
  library = self.download_library(url, show_progress)
482
488
 
@@ -485,7 +491,7 @@ class LibraryManager:
485
491
 
486
492
  if needs_rebuild:
487
493
  if show_progress and reason:
488
- print(f"Rebuilding library '{library.name}': {reason}")
494
+ log_detail(f"Rebuilding library '{library.name}': {reason}")
489
495
 
490
496
  self.compile_library(
491
497
  library,