fbuild 1.1.0__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.

Potentially problematic release.


This version of fbuild might be problematic. Click here for more details.

Files changed (93) hide show
  1. fbuild/__init__.py +0 -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_state.py +325 -0
  8. fbuild/build/build_utils.py +98 -0
  9. fbuild/build/compilation_executor.py +422 -0
  10. fbuild/build/compiler.py +165 -0
  11. fbuild/build/compiler_avr.py +574 -0
  12. fbuild/build/configurable_compiler.py +612 -0
  13. fbuild/build/configurable_linker.py +637 -0
  14. fbuild/build/flag_builder.py +186 -0
  15. fbuild/build/library_dependency_processor.py +185 -0
  16. fbuild/build/linker.py +708 -0
  17. fbuild/build/orchestrator.py +67 -0
  18. fbuild/build/orchestrator_avr.py +656 -0
  19. fbuild/build/orchestrator_esp32.py +797 -0
  20. fbuild/build/orchestrator_teensy.py +543 -0
  21. fbuild/build/source_compilation_orchestrator.py +220 -0
  22. fbuild/build/source_scanner.py +516 -0
  23. fbuild/cli.py +566 -0
  24. fbuild/cli_utils.py +312 -0
  25. fbuild/config/__init__.py +16 -0
  26. fbuild/config/board_config.py +457 -0
  27. fbuild/config/board_loader.py +92 -0
  28. fbuild/config/ini_parser.py +209 -0
  29. fbuild/config/mcu_specs.py +88 -0
  30. fbuild/daemon/__init__.py +34 -0
  31. fbuild/daemon/client.py +929 -0
  32. fbuild/daemon/compilation_queue.py +293 -0
  33. fbuild/daemon/daemon.py +474 -0
  34. fbuild/daemon/daemon_context.py +196 -0
  35. fbuild/daemon/error_collector.py +263 -0
  36. fbuild/daemon/file_cache.py +332 -0
  37. fbuild/daemon/lock_manager.py +270 -0
  38. fbuild/daemon/logging_utils.py +149 -0
  39. fbuild/daemon/messages.py +301 -0
  40. fbuild/daemon/operation_registry.py +288 -0
  41. fbuild/daemon/process_tracker.py +366 -0
  42. fbuild/daemon/processors/__init__.py +12 -0
  43. fbuild/daemon/processors/build_processor.py +157 -0
  44. fbuild/daemon/processors/deploy_processor.py +327 -0
  45. fbuild/daemon/processors/monitor_processor.py +146 -0
  46. fbuild/daemon/request_processor.py +401 -0
  47. fbuild/daemon/status_manager.py +216 -0
  48. fbuild/daemon/subprocess_manager.py +316 -0
  49. fbuild/deploy/__init__.py +17 -0
  50. fbuild/deploy/deployer.py +67 -0
  51. fbuild/deploy/deployer_esp32.py +314 -0
  52. fbuild/deploy/monitor.py +495 -0
  53. fbuild/interrupt_utils.py +34 -0
  54. fbuild/packages/__init__.py +53 -0
  55. fbuild/packages/archive_utils.py +1098 -0
  56. fbuild/packages/arduino_core.py +412 -0
  57. fbuild/packages/cache.py +249 -0
  58. fbuild/packages/downloader.py +366 -0
  59. fbuild/packages/framework_esp32.py +538 -0
  60. fbuild/packages/framework_teensy.py +346 -0
  61. fbuild/packages/github_utils.py +96 -0
  62. fbuild/packages/header_trampoline_cache.py +394 -0
  63. fbuild/packages/library_compiler.py +203 -0
  64. fbuild/packages/library_manager.py +549 -0
  65. fbuild/packages/library_manager_esp32.py +413 -0
  66. fbuild/packages/package.py +163 -0
  67. fbuild/packages/platform_esp32.py +383 -0
  68. fbuild/packages/platform_teensy.py +312 -0
  69. fbuild/packages/platform_utils.py +131 -0
  70. fbuild/packages/platformio_registry.py +325 -0
  71. fbuild/packages/sdk_utils.py +231 -0
  72. fbuild/packages/toolchain.py +436 -0
  73. fbuild/packages/toolchain_binaries.py +196 -0
  74. fbuild/packages/toolchain_esp32.py +484 -0
  75. fbuild/packages/toolchain_metadata.py +185 -0
  76. fbuild/packages/toolchain_teensy.py +404 -0
  77. fbuild/platform_configs/esp32.json +150 -0
  78. fbuild/platform_configs/esp32c2.json +144 -0
  79. fbuild/platform_configs/esp32c3.json +143 -0
  80. fbuild/platform_configs/esp32c5.json +151 -0
  81. fbuild/platform_configs/esp32c6.json +151 -0
  82. fbuild/platform_configs/esp32p4.json +149 -0
  83. fbuild/platform_configs/esp32s3.json +151 -0
  84. fbuild/platform_configs/imxrt1062.json +56 -0
  85. fbuild-1.1.0.dist-info/METADATA +447 -0
  86. fbuild-1.1.0.dist-info/RECORD +93 -0
  87. fbuild-1.1.0.dist-info/WHEEL +5 -0
  88. fbuild-1.1.0.dist-info/entry_points.txt +5 -0
  89. fbuild-1.1.0.dist-info/licenses/LICENSE +21 -0
  90. fbuild-1.1.0.dist-info/top_level.txt +2 -0
  91. fbuild_lint/__init__.py +0 -0
  92. fbuild_lint/ruff_plugins/__init__.py +0 -0
  93. fbuild_lint/ruff_plugins/keyboard_interrupt_checker.py +158 -0
@@ -0,0 +1,929 @@
1
+ """
2
+ fbuild Daemon Client
3
+
4
+ Client interface for requesting deploy and monitor operations from the daemon.
5
+ Handles daemon lifecycle, request submission, and progress monitoring.
6
+ """
7
+
8
+ import _thread
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ from abc import ABC, abstractmethod
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import psutil
19
+
20
+ from fbuild.daemon.messages import (
21
+ BuildRequest,
22
+ DaemonState,
23
+ DaemonStatus,
24
+ DeployRequest,
25
+ MonitorRequest,
26
+ )
27
+
28
+ # Daemon configuration (must match daemon settings)
29
+ DAEMON_NAME = "fbuild_daemon"
30
+ DAEMON_DIR = Path.home() / ".fbuild" / "daemon"
31
+ PID_FILE = DAEMON_DIR / f"{DAEMON_NAME}.pid"
32
+ STATUS_FILE = DAEMON_DIR / "daemon_status.json"
33
+ BUILD_REQUEST_FILE = DAEMON_DIR / "build_request.json"
34
+ DEPLOY_REQUEST_FILE = DAEMON_DIR / "deploy_request.json"
35
+ MONITOR_REQUEST_FILE = DAEMON_DIR / "monitor_request.json"
36
+
37
+
38
+ def is_daemon_running() -> bool:
39
+ """Check if daemon is running, clean up stale PID files.
40
+
41
+ Returns:
42
+ True if daemon is running, False otherwise
43
+ """
44
+ if not PID_FILE.exists():
45
+ return False
46
+
47
+ try:
48
+ with open(PID_FILE) as f:
49
+ pid = int(f.read().strip())
50
+
51
+ # Check if process exists
52
+ if psutil.pid_exists(pid):
53
+ return True
54
+ else:
55
+ # Stale PID file - remove it
56
+ print(f"Removing stale PID file: {PID_FILE}")
57
+ PID_FILE.unlink()
58
+ return False
59
+ except KeyboardInterrupt:
60
+ _thread.interrupt_main()
61
+ raise
62
+ except Exception:
63
+ # Corrupted PID file - remove it
64
+ try:
65
+ PID_FILE.unlink(missing_ok=True)
66
+ except KeyboardInterrupt:
67
+ _thread.interrupt_main()
68
+ raise
69
+ except Exception:
70
+ pass
71
+ return False
72
+
73
+
74
+ def start_daemon() -> None:
75
+ """Start the daemon process."""
76
+ daemon_script = Path(__file__).parent / "daemon.py"
77
+
78
+ if not daemon_script.exists():
79
+ raise RuntimeError(f"Daemon script not found: {daemon_script}")
80
+
81
+ # Start daemon in background
82
+ subprocess.Popen(
83
+ [sys.executable, str(daemon_script)],
84
+ stdout=subprocess.DEVNULL,
85
+ stderr=subprocess.DEVNULL,
86
+ stdin=subprocess.DEVNULL,
87
+ )
88
+
89
+
90
+ def read_status_file() -> DaemonStatus:
91
+ """Read current daemon status with corruption recovery.
92
+
93
+ Returns:
94
+ DaemonStatus object (or default status if file doesn't exist or corrupted)
95
+ """
96
+ if not STATUS_FILE.exists():
97
+ return DaemonStatus(
98
+ state=DaemonState.UNKNOWN,
99
+ message="Status file not found",
100
+ updated_at=time.time(),
101
+ )
102
+
103
+ try:
104
+ with open(STATUS_FILE) as f:
105
+ data = json.load(f)
106
+
107
+ # Parse into typed DaemonStatus
108
+ return DaemonStatus.from_dict(data)
109
+
110
+ except (json.JSONDecodeError, ValueError):
111
+ # Corrupted JSON - return default status
112
+ return DaemonStatus(
113
+ state=DaemonState.UNKNOWN,
114
+ message="Status file corrupted (invalid JSON)",
115
+ updated_at=time.time(),
116
+ )
117
+ except KeyboardInterrupt:
118
+ _thread.interrupt_main()
119
+ raise
120
+ except Exception:
121
+ return DaemonStatus(
122
+ state=DaemonState.UNKNOWN,
123
+ message="Failed to read status",
124
+ updated_at=time.time(),
125
+ )
126
+
127
+
128
+ def write_request_file(request_file: Path, request: Any) -> None:
129
+ """Atomically write request file.
130
+
131
+ Args:
132
+ request_file: Path to request file
133
+ request: Request object (DeployRequest or MonitorRequest)
134
+ """
135
+ DAEMON_DIR.mkdir(parents=True, exist_ok=True)
136
+
137
+ # Atomic write using temporary file
138
+ temp_file = request_file.with_suffix(".tmp")
139
+ with open(temp_file, "w") as f:
140
+ json.dump(request.to_dict(), f, indent=2)
141
+
142
+ # Atomic rename
143
+ temp_file.replace(request_file)
144
+
145
+
146
+ def display_status(status: DaemonStatus, prefix: str = " ") -> None:
147
+ """Display status update to user.
148
+
149
+ Args:
150
+ status: DaemonStatus object
151
+ prefix: Line prefix for indentation
152
+ """
153
+ # Show current operation if available, otherwise use message
154
+ display_text = status.current_operation or status.message
155
+
156
+ if status.state == DaemonState.DEPLOYING:
157
+ print(f"{prefix}📦 {display_text}", flush=True)
158
+ elif status.state == DaemonState.MONITORING:
159
+ print(f"{prefix}👁️ {display_text}", flush=True)
160
+ elif status.state == DaemonState.BUILDING:
161
+ print(f"{prefix}🔨 {display_text}", flush=True)
162
+ elif status.state == DaemonState.COMPLETED:
163
+ print(f"{prefix}✅ {display_text}", flush=True)
164
+ elif status.state == DaemonState.FAILED:
165
+ print(f"{prefix}❌ {display_text}", flush=True)
166
+ else:
167
+ print(f"{prefix}ℹ️ {display_text}", flush=True)
168
+
169
+
170
+ def ensure_daemon_running() -> bool:
171
+ """Ensure daemon is running, start if needed.
172
+
173
+ Returns:
174
+ True if daemon is running or started successfully, False otherwise
175
+ """
176
+ if is_daemon_running():
177
+ return True
178
+
179
+ # If we reach here, daemon is not running (stale PID was cleaned by is_daemon_running)
180
+ # Clear stale status file to prevent race condition where client reads old status
181
+ # from previous daemon run before new daemon writes fresh status
182
+ if STATUS_FILE.exists():
183
+ try:
184
+ STATUS_FILE.unlink()
185
+ except KeyboardInterrupt:
186
+ _thread.interrupt_main()
187
+ raise
188
+ except Exception:
189
+ pass # Best effort - continue even if delete fails
190
+
191
+ print("🔗 Starting fbuild daemon...")
192
+ start_daemon()
193
+
194
+ # Wait up to 10 seconds for daemon to start and write fresh status
195
+ for _ in range(10):
196
+ if is_daemon_running():
197
+ # Daemon is running - check if status file is fresh
198
+ status = read_status_file()
199
+ if status.state != DaemonState.UNKNOWN:
200
+ # Valid status received from new daemon
201
+ print("✅ Daemon started successfully")
202
+ return True
203
+ time.sleep(1)
204
+
205
+ print("❌ Failed to start daemon")
206
+ return False
207
+
208
+
209
+ def request_build(
210
+ project_dir: Path,
211
+ environment: str,
212
+ clean_build: bool = False,
213
+ verbose: bool = False,
214
+ timeout: float = 1800,
215
+ ) -> bool:
216
+ """Request a build operation from the daemon.
217
+
218
+ Args:
219
+ project_dir: Project directory
220
+ environment: Build environment
221
+ clean_build: Whether to perform clean build
222
+ verbose: Enable verbose build output
223
+ timeout: Maximum wait time in seconds (default: 30 minutes)
224
+
225
+ Returns:
226
+ True if build successful, False otherwise
227
+ """
228
+ handler = BuildRequestHandler(
229
+ project_dir=project_dir,
230
+ environment=environment,
231
+ clean_build=clean_build,
232
+ verbose=verbose,
233
+ timeout=timeout,
234
+ )
235
+ return handler.execute()
236
+
237
+
238
+ def _display_monitor_summary(project_dir: Path) -> None:
239
+ """Display monitor summary from JSON file.
240
+
241
+ Args:
242
+ project_dir: Project directory where summary file is located
243
+ """
244
+ summary_file = project_dir / ".fbuild" / "monitor_summary.json"
245
+ if not summary_file.exists():
246
+ return
247
+
248
+ try:
249
+ with open(summary_file, "r", encoding="utf-8") as f:
250
+ summary = json.load(f)
251
+
252
+ print("\n" + "=" * 50)
253
+ print("Monitor Summary")
254
+ print("=" * 50)
255
+
256
+ # Display expect pattern result
257
+ if summary.get("expect_pattern"):
258
+ pattern = summary["expect_pattern"]
259
+ found = summary.get("expect_found", False)
260
+ status = "FOUND ✓" if found else "NOT FOUND ✗"
261
+ print(f'Expected pattern: "{pattern}" - {status}')
262
+
263
+ # Display halt on error pattern result
264
+ if summary.get("halt_on_error_pattern"):
265
+ pattern = summary["halt_on_error_pattern"]
266
+ found = summary.get("halt_on_error_found", False)
267
+ status = "FOUND ✗" if found else "NOT FOUND ✓"
268
+ print(f'Error pattern: "{pattern}" - {status}')
269
+
270
+ # Display halt on success pattern result
271
+ if summary.get("halt_on_success_pattern"):
272
+ pattern = summary["halt_on_success_pattern"]
273
+ found = summary.get("halt_on_success_found", False)
274
+ status = "FOUND ✓" if found else "NOT FOUND ✗"
275
+ print(f'Success pattern: "{pattern}" - {status}')
276
+
277
+ # Display statistics
278
+ lines = summary.get("lines_processed", 0)
279
+ elapsed = summary.get("elapsed_time", 0.0)
280
+ exit_reason = summary.get("exit_reason", "unknown")
281
+
282
+ print(f"Lines processed: {lines}")
283
+ print(f"Time elapsed: {elapsed:.2f}s")
284
+
285
+ # Translate exit_reason to user-friendly text
286
+ reason_text = {
287
+ "timeout": "Timeout reached",
288
+ "expect_found": "Expected pattern found",
289
+ "halt_error": "Error pattern detected",
290
+ "halt_success": "Success pattern detected",
291
+ "interrupted": "Interrupted by user",
292
+ "error": "Serial port error",
293
+ }.get(exit_reason, exit_reason)
294
+
295
+ print(f"Exit reason: {reason_text}")
296
+ print("=" * 50)
297
+
298
+ except KeyboardInterrupt: # noqa: KBI002
299
+ raise
300
+ except Exception:
301
+ # Silently fail - don't disrupt the user experience
302
+ pass
303
+
304
+
305
+ # ============================================================================
306
+ # REQUEST HANDLER ARCHITECTURE
307
+ # ============================================================================
308
+
309
+
310
+ class BaseRequestHandler(ABC):
311
+ """Base class for handling daemon requests with common functionality.
312
+
313
+ Implements the template method pattern to eliminate duplication across
314
+ build, deploy, and monitor request handlers.
315
+ """
316
+
317
+ def __init__(self, project_dir: Path, environment: str, timeout: float = 1800):
318
+ """Initialize request handler.
319
+
320
+ Args:
321
+ project_dir: Project directory
322
+ environment: Build environment
323
+ timeout: Maximum wait time in seconds (default: 30 minutes)
324
+ """
325
+ self.project_dir = project_dir
326
+ self.environment = environment
327
+ self.timeout = timeout
328
+ self.start_time = 0.0
329
+ self.last_message: str | None = None
330
+ self.monitoring_started = False
331
+ self.output_file_position = 0
332
+
333
+ @abstractmethod
334
+ def create_request(self) -> BuildRequest | DeployRequest | MonitorRequest:
335
+ """Create the specific request object.
336
+
337
+ Returns:
338
+ Request object (BuildRequest, DeployRequest, or MonitorRequest)
339
+ """
340
+ pass
341
+
342
+ @abstractmethod
343
+ def get_request_file(self) -> Path:
344
+ """Get the request file path.
345
+
346
+ Returns:
347
+ Path to request file
348
+ """
349
+ pass
350
+
351
+ @abstractmethod
352
+ def get_operation_name(self) -> str:
353
+ """Get the operation name for display.
354
+
355
+ Returns:
356
+ Operation name (e.g., "Build", "Deploy", "Monitor")
357
+ """
358
+ pass
359
+
360
+ @abstractmethod
361
+ def get_operation_emoji(self) -> str:
362
+ """Get the operation emoji for display.
363
+
364
+ Returns:
365
+ Operation emoji (e.g., "🔨", "📦", "👁️")
366
+ """
367
+ pass
368
+
369
+ def should_tail_output(self) -> bool:
370
+ """Check if output file should be tailed.
371
+
372
+ Returns:
373
+ True if output should be tailed, False otherwise
374
+ """
375
+ return False
376
+
377
+ def on_monitoring_started(self) -> None:
378
+ """Hook called when monitoring phase starts."""
379
+ pass
380
+
381
+ def on_completion(self, elapsed: float) -> None:
382
+ """Hook called on successful completion.
383
+
384
+ Args:
385
+ elapsed: Elapsed time in seconds
386
+ """
387
+ pass
388
+
389
+ def on_failure(self, status: DaemonStatus, elapsed: float) -> None:
390
+ """Hook called on failure.
391
+
392
+ Args:
393
+ status: Current daemon status
394
+ elapsed: Elapsed time in seconds
395
+ """
396
+ pass
397
+
398
+ def print_submission_info(self) -> None:
399
+ """Print request submission information."""
400
+ print(f"\n📤 Submitting {self.get_operation_name().lower()} request...")
401
+ print(f" Project: {self.project_dir}")
402
+ print(f" Environment: {self.environment}")
403
+
404
+ def tail_output_file(self) -> None:
405
+ """Tail the output file and print new lines."""
406
+ output_file = self.project_dir / ".fbuild" / "monitor_output.txt"
407
+ if output_file.exists():
408
+ try:
409
+ with open(output_file, "r", encoding="utf-8", errors="replace") as f:
410
+ f.seek(self.output_file_position)
411
+ new_lines = f.read()
412
+ if new_lines:
413
+ print(new_lines, end="", flush=True)
414
+ self.output_file_position = f.tell()
415
+ except KeyboardInterrupt: # noqa: KBI002
416
+ raise
417
+ except Exception:
418
+ pass # Ignore read errors
419
+
420
+ def read_remaining_output(self) -> None:
421
+ """Read any remaining output from output file."""
422
+ if not self.monitoring_started:
423
+ return
424
+
425
+ output_file = self.project_dir / ".fbuild" / "monitor_output.txt"
426
+ if output_file.exists():
427
+ try:
428
+ with open(output_file, "r", encoding="utf-8", errors="replace") as f:
429
+ f.seek(self.output_file_position)
430
+ new_lines = f.read()
431
+ if new_lines:
432
+ print(new_lines, end="", flush=True)
433
+ except KeyboardInterrupt: # noqa: KBI002
434
+ raise
435
+ except Exception:
436
+ pass
437
+
438
+ def handle_keyboard_interrupt(self, request_id: str) -> bool:
439
+ """Handle keyboard interrupt with background option.
440
+
441
+ Args:
442
+ request_id: Request ID for cancellation
443
+
444
+ Returns:
445
+ False (operation not completed or cancelled)
446
+ """
447
+ print("\n\n⚠️ Interrupted by user (Ctrl-C)")
448
+ response = input("Keep operation running in background? (y/n): ").strip().lower()
449
+
450
+ if response in ("y", "yes"):
451
+ print("\n✅ Operation continues in background")
452
+ print(" Check status: fbuild daemon status")
453
+ print(" Stop daemon: fbuild daemon stop")
454
+ return False
455
+ else:
456
+ print("\n🛑 Requesting daemon to stop operation...")
457
+ cancel_file = DAEMON_DIR / f"cancel_{request_id}.signal"
458
+ cancel_file.touch()
459
+ print(" Operation cancellation requested")
460
+ return False
461
+
462
+ def execute(self) -> bool:
463
+ """Execute the request and monitor progress.
464
+
465
+ Returns:
466
+ True if operation successful, False otherwise
467
+ """
468
+ # Ensure daemon is running
469
+ if not ensure_daemon_running():
470
+ return False
471
+
472
+ # Print submission info
473
+ self.print_submission_info()
474
+
475
+ # Create and submit request
476
+ request = self.create_request()
477
+ write_request_file(self.get_request_file(), request)
478
+ print(f" Request ID: {request.request_id}")
479
+ print(" ✅ Submitted\n")
480
+
481
+ # Monitor progress
482
+ print(f"{self.get_operation_emoji()} {self.get_operation_name()} Progress:")
483
+ self.start_time = time.time()
484
+
485
+ while True:
486
+ try:
487
+ elapsed = time.time() - self.start_time
488
+
489
+ # Check timeout
490
+ if elapsed > self.timeout:
491
+ print(f"\n❌ {self.get_operation_name()} timeout ({self.timeout}s)")
492
+ return False
493
+
494
+ # Read status
495
+ status = read_status_file()
496
+
497
+ # Display progress when message changes
498
+ if status.message != self.last_message:
499
+ display_status(status)
500
+ self.last_message = status.message
501
+
502
+ # Handle monitoring phase
503
+ if self.should_tail_output() and status.state == DaemonState.MONITORING:
504
+ if not self.monitoring_started:
505
+ self.monitoring_started = True
506
+ print() # Blank line before serial output
507
+ self.on_monitoring_started()
508
+
509
+ if self.monitoring_started and self.should_tail_output():
510
+ self.tail_output_file()
511
+
512
+ # Check completion
513
+ if status.state == DaemonState.COMPLETED:
514
+ if status.request_id == request.request_id:
515
+ self.read_remaining_output()
516
+ self.on_completion(elapsed)
517
+ print(f"\n✅ {self.get_operation_name()} completed in {elapsed:.1f}s")
518
+ return True
519
+
520
+ elif status.state == DaemonState.FAILED:
521
+ if status.request_id == request.request_id:
522
+ self.read_remaining_output()
523
+ self.on_failure(status, elapsed)
524
+ print(f"\n❌ {self.get_operation_name()} failed: {status.message}")
525
+ return False
526
+
527
+ # Sleep before next poll
528
+ poll_interval = 0.1 if self.monitoring_started else 0.5
529
+ time.sleep(poll_interval)
530
+
531
+ except KeyboardInterrupt: # noqa: KBI002
532
+ return self.handle_keyboard_interrupt(request.request_id)
533
+
534
+
535
+ class BuildRequestHandler(BaseRequestHandler):
536
+ """Handler for build requests."""
537
+
538
+ def __init__(
539
+ self,
540
+ project_dir: Path,
541
+ environment: str,
542
+ clean_build: bool = False,
543
+ verbose: bool = False,
544
+ timeout: float = 1800,
545
+ ):
546
+ """Initialize build request handler.
547
+
548
+ Args:
549
+ project_dir: Project directory
550
+ environment: Build environment
551
+ clean_build: Whether to perform clean build
552
+ verbose: Enable verbose build output
553
+ timeout: Maximum wait time in seconds
554
+ """
555
+ super().__init__(project_dir, environment, timeout)
556
+ self.clean_build = clean_build
557
+ self.verbose = verbose
558
+
559
+ def create_request(self) -> BuildRequest:
560
+ """Create build request."""
561
+ return BuildRequest(
562
+ project_dir=str(self.project_dir.absolute()),
563
+ environment=self.environment,
564
+ clean_build=self.clean_build,
565
+ verbose=self.verbose,
566
+ caller_pid=os.getpid(),
567
+ caller_cwd=os.getcwd(),
568
+ )
569
+
570
+ def get_request_file(self) -> Path:
571
+ """Get build request file path."""
572
+ return BUILD_REQUEST_FILE
573
+
574
+ def get_operation_name(self) -> str:
575
+ """Get operation name."""
576
+ return "Build"
577
+
578
+ def get_operation_emoji(self) -> str:
579
+ """Get operation emoji."""
580
+ return "🔨"
581
+
582
+ def print_submission_info(self) -> None:
583
+ """Print build submission information."""
584
+ super().print_submission_info()
585
+ if self.clean_build:
586
+ print(" Clean build: Yes")
587
+
588
+
589
+ class DeployRequestHandler(BaseRequestHandler):
590
+ """Handler for deploy requests."""
591
+
592
+ def __init__(
593
+ self,
594
+ project_dir: Path,
595
+ environment: str,
596
+ port: str | None = None,
597
+ clean_build: bool = False,
598
+ monitor_after: bool = False,
599
+ monitor_timeout: float | None = None,
600
+ monitor_halt_on_error: str | None = None,
601
+ monitor_halt_on_success: str | None = None,
602
+ monitor_expect: str | None = None,
603
+ timeout: float = 1800,
604
+ ):
605
+ """Initialize deploy request handler.
606
+
607
+ Args:
608
+ project_dir: Project directory
609
+ environment: Build environment
610
+ port: Serial port (optional)
611
+ clean_build: Whether to perform clean build
612
+ monitor_after: Whether to start monitor after deploy
613
+ monitor_timeout: Timeout for monitor
614
+ monitor_halt_on_error: Pattern to halt on error
615
+ monitor_halt_on_success: Pattern to halt on success
616
+ monitor_expect: Expected pattern to check
617
+ timeout: Maximum wait time in seconds
618
+ """
619
+ super().__init__(project_dir, environment, timeout)
620
+ self.port = port
621
+ self.clean_build = clean_build
622
+ self.monitor_after = monitor_after
623
+ self.monitor_timeout = monitor_timeout
624
+ self.monitor_halt_on_error = monitor_halt_on_error
625
+ self.monitor_halt_on_success = monitor_halt_on_success
626
+ self.monitor_expect = monitor_expect
627
+
628
+ def create_request(self) -> DeployRequest:
629
+ """Create deploy request."""
630
+ return DeployRequest(
631
+ project_dir=str(self.project_dir.absolute()),
632
+ environment=self.environment,
633
+ port=self.port,
634
+ clean_build=self.clean_build,
635
+ monitor_after=self.monitor_after,
636
+ monitor_timeout=self.monitor_timeout,
637
+ monitor_halt_on_error=self.monitor_halt_on_error,
638
+ monitor_halt_on_success=self.monitor_halt_on_success,
639
+ monitor_expect=self.monitor_expect,
640
+ caller_pid=os.getpid(),
641
+ caller_cwd=os.getcwd(),
642
+ )
643
+
644
+ def get_request_file(self) -> Path:
645
+ """Get deploy request file path."""
646
+ return DEPLOY_REQUEST_FILE
647
+
648
+ def get_operation_name(self) -> str:
649
+ """Get operation name."""
650
+ return "Deploy"
651
+
652
+ def get_operation_emoji(self) -> str:
653
+ """Get operation emoji."""
654
+ return "📦"
655
+
656
+ def should_tail_output(self) -> bool:
657
+ """Check if output should be tailed."""
658
+ return self.monitor_after
659
+
660
+ def print_submission_info(self) -> None:
661
+ """Print deploy submission information."""
662
+ super().print_submission_info()
663
+ if self.port:
664
+ print(f" Port: {self.port}")
665
+
666
+ def on_completion(self, elapsed: float) -> None:
667
+ """Handle completion with monitor summary."""
668
+ if self.monitoring_started:
669
+ _display_monitor_summary(self.project_dir)
670
+
671
+ def on_failure(self, status: DaemonStatus, elapsed: float) -> None:
672
+ """Handle failure with monitor summary."""
673
+ if self.monitoring_started:
674
+ _display_monitor_summary(self.project_dir)
675
+
676
+
677
+ class MonitorRequestHandler(BaseRequestHandler):
678
+ """Handler for monitor requests."""
679
+
680
+ def __init__(
681
+ self,
682
+ project_dir: Path,
683
+ environment: str,
684
+ port: str | None = None,
685
+ baud_rate: int | None = None,
686
+ halt_on_error: str | None = None,
687
+ halt_on_success: str | None = None,
688
+ expect: str | None = None,
689
+ timeout: float | None = None,
690
+ ):
691
+ """Initialize monitor request handler.
692
+
693
+ Args:
694
+ project_dir: Project directory
695
+ environment: Build environment
696
+ port: Serial port (optional)
697
+ baud_rate: Serial baud rate (optional)
698
+ halt_on_error: Pattern to halt on error
699
+ halt_on_success: Pattern to halt on success
700
+ expect: Expected pattern to check
701
+ timeout: Maximum monitoring time in seconds
702
+ """
703
+ super().__init__(project_dir, environment, timeout or 3600)
704
+ self.port = port
705
+ self.baud_rate = baud_rate
706
+ self.halt_on_error = halt_on_error
707
+ self.halt_on_success = halt_on_success
708
+ self.expect = expect
709
+ self.monitor_timeout = timeout
710
+
711
+ def create_request(self) -> MonitorRequest:
712
+ """Create monitor request."""
713
+ return MonitorRequest(
714
+ project_dir=str(self.project_dir.absolute()),
715
+ environment=self.environment,
716
+ port=self.port,
717
+ baud_rate=self.baud_rate,
718
+ halt_on_error=self.halt_on_error,
719
+ halt_on_success=self.halt_on_success,
720
+ expect=self.expect,
721
+ timeout=self.monitor_timeout,
722
+ caller_pid=os.getpid(),
723
+ caller_cwd=os.getcwd(),
724
+ )
725
+
726
+ def get_request_file(self) -> Path:
727
+ """Get monitor request file path."""
728
+ return MONITOR_REQUEST_FILE
729
+
730
+ def get_operation_name(self) -> str:
731
+ """Get operation name."""
732
+ return "Monitor"
733
+
734
+ def get_operation_emoji(self) -> str:
735
+ """Get operation emoji."""
736
+ return "👁️"
737
+
738
+ def should_tail_output(self) -> bool:
739
+ """Check if output should be tailed."""
740
+ return True
741
+
742
+ def print_submission_info(self) -> None:
743
+ """Print monitor submission information."""
744
+ super().print_submission_info()
745
+ if self.port:
746
+ print(f" Port: {self.port}")
747
+ if self.baud_rate:
748
+ print(f" Baud rate: {self.baud_rate}")
749
+ if self.monitor_timeout:
750
+ print(f" Timeout: {self.monitor_timeout}s")
751
+
752
+ def on_completion(self, elapsed: float) -> None:
753
+ """Handle completion with monitor summary."""
754
+ if self.monitoring_started:
755
+ _display_monitor_summary(self.project_dir)
756
+
757
+ def on_failure(self, status: DaemonStatus, elapsed: float) -> None:
758
+ """Handle failure with monitor summary."""
759
+ if self.monitoring_started:
760
+ _display_monitor_summary(self.project_dir)
761
+
762
+
763
+ def request_deploy(
764
+ project_dir: Path,
765
+ environment: str,
766
+ port: str | None = None,
767
+ clean_build: bool = False,
768
+ monitor_after: bool = False,
769
+ monitor_timeout: float | None = None,
770
+ monitor_halt_on_error: str | None = None,
771
+ monitor_halt_on_success: str | None = None,
772
+ monitor_expect: str | None = None,
773
+ timeout: float = 1800,
774
+ ) -> bool:
775
+ """Request a deploy operation from the daemon.
776
+
777
+ Args:
778
+ project_dir: Project directory
779
+ environment: Build environment
780
+ port: Serial port (optional, auto-detect if None)
781
+ clean_build: Whether to perform clean build
782
+ monitor_after: Whether to start monitor after deploy
783
+ monitor_timeout: Timeout for monitor (if monitor_after=True)
784
+ monitor_halt_on_error: Pattern to halt on error (if monitor_after=True)
785
+ monitor_halt_on_success: Pattern to halt on success (if monitor_after=True)
786
+ monitor_expect: Expected pattern to check at timeout/success (if monitor_after=True)
787
+ timeout: Maximum wait time in seconds (default: 30 minutes)
788
+
789
+ Returns:
790
+ True if deploy successful, False otherwise
791
+ """
792
+ handler = DeployRequestHandler(
793
+ project_dir=project_dir,
794
+ environment=environment,
795
+ port=port,
796
+ clean_build=clean_build,
797
+ monitor_after=monitor_after,
798
+ monitor_timeout=monitor_timeout,
799
+ monitor_halt_on_error=monitor_halt_on_error,
800
+ monitor_halt_on_success=monitor_halt_on_success,
801
+ monitor_expect=monitor_expect,
802
+ timeout=timeout,
803
+ )
804
+ return handler.execute()
805
+
806
+
807
+ def request_monitor(
808
+ project_dir: Path,
809
+ environment: str,
810
+ port: str | None = None,
811
+ baud_rate: int | None = None,
812
+ halt_on_error: str | None = None,
813
+ halt_on_success: str | None = None,
814
+ expect: str | None = None,
815
+ timeout: float | None = None,
816
+ ) -> bool:
817
+ """Request a monitor operation from the daemon.
818
+
819
+ Args:
820
+ project_dir: Project directory
821
+ environment: Build environment
822
+ port: Serial port (optional, auto-detect if None)
823
+ baud_rate: Serial baud rate (optional)
824
+ halt_on_error: Pattern to halt on (error detection)
825
+ halt_on_success: Pattern to halt on (success detection)
826
+ expect: Expected pattern to check at timeout/success
827
+ timeout: Maximum monitoring time in seconds
828
+
829
+ Returns:
830
+ True if monitoring successful, False otherwise
831
+ """
832
+ handler = MonitorRequestHandler(
833
+ project_dir=project_dir,
834
+ environment=environment,
835
+ port=port,
836
+ baud_rate=baud_rate,
837
+ halt_on_error=halt_on_error,
838
+ halt_on_success=halt_on_success,
839
+ expect=expect,
840
+ timeout=timeout,
841
+ )
842
+ return handler.execute()
843
+
844
+
845
+ def stop_daemon() -> bool:
846
+ """Stop the daemon gracefully.
847
+
848
+ Returns:
849
+ True if daemon was stopped, False otherwise
850
+ """
851
+ if not is_daemon_running():
852
+ print("Daemon is not running")
853
+ return False
854
+
855
+ # Create shutdown signal file
856
+ shutdown_file = DAEMON_DIR / "shutdown.signal"
857
+ shutdown_file.touch()
858
+
859
+ # Wait for daemon to exit
860
+ print("Stopping daemon...")
861
+ for _ in range(10):
862
+ if not is_daemon_running():
863
+ print("✅ Daemon stopped")
864
+ return True
865
+ time.sleep(1)
866
+
867
+ print("⚠️ Daemon did not stop gracefully")
868
+ return False
869
+
870
+
871
+ def get_daemon_status() -> dict[str, Any]:
872
+ """Get current daemon status.
873
+
874
+ Returns:
875
+ Dictionary with daemon status information
876
+ """
877
+ status: dict[str, Any] = {
878
+ "running": is_daemon_running(),
879
+ "pid_file_exists": PID_FILE.exists(),
880
+ "status_file_exists": STATUS_FILE.exists(),
881
+ }
882
+
883
+ if PID_FILE.exists():
884
+ try:
885
+ with open(PID_FILE) as f:
886
+ status["pid"] = int(f.read().strip())
887
+ except KeyboardInterrupt:
888
+ _thread.interrupt_main()
889
+ raise
890
+ except Exception:
891
+ status["pid"] = None
892
+
893
+ if STATUS_FILE.exists():
894
+ daemon_status = read_status_file()
895
+ # Convert DaemonStatus to dict for JSON serialization
896
+ status["current_status"] = daemon_status.to_dict()
897
+
898
+ return status
899
+
900
+
901
+ def main() -> int:
902
+ """Command-line interface for client."""
903
+ import argparse
904
+
905
+ parser = argparse.ArgumentParser(description="fbuild Daemon Client")
906
+ parser.add_argument("--status", action="store_true", help="Show daemon status")
907
+ parser.add_argument("--stop", action="store_true", help="Stop the daemon")
908
+
909
+ args = parser.parse_args()
910
+
911
+ if args.status:
912
+ status = get_daemon_status()
913
+ print("Daemon Status:")
914
+ print(json.dumps(status, indent=2))
915
+ return 0
916
+
917
+ if args.stop:
918
+ return 0 if stop_daemon() else 1
919
+
920
+ parser.print_help()
921
+ return 1
922
+
923
+
924
+ if __name__ == "__main__":
925
+ try:
926
+ sys.exit(main())
927
+ except KeyboardInterrupt: # noqa: KBI002
928
+ print("\nInterrupted by user")
929
+ sys.exit(130)