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
@@ -0,0 +1,508 @@
1
+ """
2
+ Client-side daemon connection object.
3
+
4
+ This module provides the DaemonConnection class which represents a client's
5
+ connection to the fbuild daemon. Each call to connect_daemon() creates a NEW
6
+ connection with a unique ID - connections are NOT singletons.
7
+
8
+ Usage:
9
+ from fbuild.daemon.connection import connect_daemon
10
+
11
+ # Using context manager (recommended)
12
+ with connect_daemon(Path("./project"), "esp32dev") as conn:
13
+ conn.install_dependencies()
14
+ conn.build(clean=True)
15
+ conn.deploy(port="/dev/ttyUSB0")
16
+ conn.monitor()
17
+
18
+ # Manual lifecycle management
19
+ conn = connect_daemon(Path("./project"), "esp32dev")
20
+ try:
21
+ conn.build()
22
+ finally:
23
+ conn.close()
24
+ """
25
+
26
+ import _thread
27
+ import json
28
+ import os
29
+ import socket
30
+ import time
31
+ import uuid
32
+ from pathlib import Path
33
+ from threading import Thread
34
+ from typing import Any
35
+
36
+ from fbuild.daemon.messages import (
37
+ ClientConnectRequest,
38
+ ClientDisconnectRequest,
39
+ ClientHeartbeatRequest,
40
+ )
41
+
42
+
43
+ def _get_daemon_dir(dev_mode: bool) -> Path:
44
+ """Get daemon directory based on dev mode setting.
45
+
46
+ Args:
47
+ dev_mode: Whether to use development mode directory.
48
+
49
+ Returns:
50
+ Path to daemon directory.
51
+ """
52
+ if dev_mode:
53
+ return Path.cwd() / ".fbuild" / "daemon_dev"
54
+ else:
55
+ return Path.home() / ".fbuild" / "daemon"
56
+
57
+
58
+ class DaemonConnection:
59
+ """Client-side connection to the fbuild daemon.
60
+
61
+ Represents a single client connection with a unique ID. Each connection
62
+ maintains its own heartbeat thread and can perform operations independently.
63
+
64
+ IMPORTANT: This is NOT a singleton. Each call to connect_daemon() creates
65
+ a new DaemonConnection instance with a unique connection_id.
66
+
67
+ Attributes:
68
+ connection_id: Unique UUID for this connection.
69
+ project_dir: Path to the project directory.
70
+ environment: Build environment name.
71
+ dev_mode: Whether using development mode daemon.
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ project_dir: Path,
77
+ environment: str,
78
+ dev_mode: bool | None = None,
79
+ ) -> None:
80
+ """Initialize a new daemon connection.
81
+
82
+ Args:
83
+ project_dir: Path to project directory.
84
+ environment: Build environment name.
85
+ dev_mode: Use dev mode daemon. Auto-detects from FBUILD_DEV_MODE if None.
86
+ """
87
+ self.connection_id: str = str(uuid.uuid4())
88
+ self.project_dir: Path = Path(project_dir).resolve()
89
+ self.environment: str = environment
90
+
91
+ # Auto-detect dev mode from environment variable if not specified
92
+ if dev_mode is None:
93
+ self.dev_mode: bool = os.environ.get("FBUILD_DEV_MODE") == "1"
94
+ else:
95
+ self.dev_mode = dev_mode
96
+
97
+ self._closed: bool = False
98
+ self._heartbeat_thread: Thread | None = None
99
+ self._heartbeat_interval: float = 10.0 # seconds between heartbeats
100
+
101
+ # Get daemon directory based on dev mode
102
+ self._daemon_dir = _get_daemon_dir(self.dev_mode)
103
+
104
+ # Send connect message to daemon
105
+ self._send_connect()
106
+
107
+ # Start heartbeat thread
108
+ self._start_heartbeat()
109
+
110
+ def __enter__(self) -> "DaemonConnection":
111
+ """Context manager entry.
112
+
113
+ Returns:
114
+ This connection instance.
115
+ """
116
+ return self
117
+
118
+ def __exit__(self, _exc_type: type[BaseException] | None, _exc_val: BaseException | None, _exc_tb: Any) -> None:
119
+ """Context manager exit - closes the connection."""
120
+ self.close()
121
+
122
+ def _start_heartbeat(self) -> None:
123
+ """Start the background heartbeat thread.
124
+
125
+ The heartbeat thread sends periodic heartbeats to the daemon to indicate
126
+ this connection is still alive. If heartbeats stop, the daemon will
127
+ eventually clean up this connection's resources.
128
+ """
129
+
130
+ def heartbeat_loop() -> None:
131
+ while not self._closed:
132
+ try:
133
+ self._send_heartbeat()
134
+ except KeyboardInterrupt:
135
+ _thread.interrupt_main()
136
+ break # Exit heartbeat loop on interrupt
137
+ except Exception:
138
+ # Silently ignore heartbeat failures - daemon may not be ready
139
+ pass
140
+
141
+ # Sleep in small increments to allow faster exit when closed
142
+ sleep_time = 0.0
143
+ while sleep_time < self._heartbeat_interval and not self._closed:
144
+ time.sleep(0.5)
145
+ sleep_time += 0.5
146
+
147
+ self._heartbeat_thread = Thread(target=heartbeat_loop, daemon=True)
148
+ self._heartbeat_thread.start()
149
+
150
+ def _send_heartbeat(self) -> None:
151
+ """Send a heartbeat message to the daemon.
152
+
153
+ Writes a heartbeat file to the daemon directory that the daemon
154
+ will pick up during its polling cycle.
155
+ """
156
+ if self._closed:
157
+ return
158
+
159
+ request = ClientHeartbeatRequest(
160
+ client_id=self.connection_id,
161
+ timestamp=time.time(),
162
+ )
163
+
164
+ heartbeat_file = self._daemon_dir / f"heartbeat_{self.connection_id}.json"
165
+ self._daemon_dir.mkdir(parents=True, exist_ok=True)
166
+
167
+ # Atomic write using temp file
168
+ temp_file = heartbeat_file.with_suffix(".tmp")
169
+ try:
170
+ with open(temp_file, "w") as f:
171
+ json.dump(request.to_dict(), f)
172
+ temp_file.replace(heartbeat_file)
173
+ except KeyboardInterrupt:
174
+ _thread.interrupt_main()
175
+ raise
176
+ except Exception:
177
+ # Best effort - don't fail if we can't write heartbeat
178
+ if temp_file.exists():
179
+ temp_file.unlink(missing_ok=True)
180
+
181
+ def _send_connect(self) -> None:
182
+ """Send a connect message to register with the daemon.
183
+
184
+ Called during __init__ to register this connection with the daemon.
185
+ """
186
+ request = ClientConnectRequest(
187
+ client_id=self.connection_id,
188
+ pid=os.getpid(),
189
+ hostname=socket.gethostname(),
190
+ version=self._get_version(),
191
+ timestamp=time.time(),
192
+ )
193
+
194
+ connect_file = self._daemon_dir / f"connect_{self.connection_id}.json"
195
+ self._daemon_dir.mkdir(parents=True, exist_ok=True)
196
+
197
+ # Atomic write
198
+ temp_file = connect_file.with_suffix(".tmp")
199
+ try:
200
+ with open(temp_file, "w") as f:
201
+ json.dump(request.to_dict(), f)
202
+ temp_file.replace(connect_file)
203
+ except KeyboardInterrupt:
204
+ _thread.interrupt_main()
205
+ raise
206
+ except Exception:
207
+ # Best effort - daemon may not be running yet
208
+ if temp_file.exists():
209
+ temp_file.unlink(missing_ok=True)
210
+
211
+ def _send_disconnect(self) -> None:
212
+ """Send a disconnect message to notify daemon of graceful close.
213
+
214
+ Called during close() to notify the daemon to clean up resources
215
+ associated with this connection.
216
+ """
217
+ request = ClientDisconnectRequest(
218
+ client_id=self.connection_id,
219
+ reason="graceful_close",
220
+ timestamp=time.time(),
221
+ )
222
+
223
+ disconnect_file = self._daemon_dir / f"disconnect_{self.connection_id}.json"
224
+ self._daemon_dir.mkdir(parents=True, exist_ok=True)
225
+
226
+ # Atomic write
227
+ temp_file = disconnect_file.with_suffix(".tmp")
228
+ try:
229
+ with open(temp_file, "w") as f:
230
+ json.dump(request.to_dict(), f)
231
+ temp_file.replace(disconnect_file)
232
+ except KeyboardInterrupt:
233
+ _thread.interrupt_main()
234
+ raise
235
+ except Exception:
236
+ # Best effort
237
+ if temp_file.exists():
238
+ temp_file.unlink(missing_ok=True)
239
+
240
+ # Clean up heartbeat file if it exists
241
+ heartbeat_file = self._daemon_dir / f"heartbeat_{self.connection_id}.json"
242
+ heartbeat_file.unlink(missing_ok=True)
243
+
244
+ # Clean up connect file if it exists
245
+ connect_file = self._daemon_dir / f"connect_{self.connection_id}.json"
246
+ connect_file.unlink(missing_ok=True)
247
+
248
+ def _get_version(self) -> str:
249
+ """Get the fbuild version string.
250
+
251
+ Returns:
252
+ Version string from fbuild package.
253
+ """
254
+ try:
255
+ from fbuild import __version__
256
+
257
+ return __version__
258
+ except ImportError:
259
+ return "unknown"
260
+
261
+ def _check_closed(self) -> None:
262
+ """Raise RuntimeError if connection is closed.
263
+
264
+ Raises:
265
+ RuntimeError: If the connection has been closed.
266
+ """
267
+ if self._closed:
268
+ raise RuntimeError(f"DaemonConnection {self.connection_id} is closed. Cannot perform operations on a closed connection.")
269
+
270
+ def close(self) -> None:
271
+ """Gracefully close the connection.
272
+
273
+ Stops the heartbeat thread, sends a disconnect message to the daemon,
274
+ and marks the connection as closed. Safe to call multiple times.
275
+ """
276
+ if self._closed:
277
+ return
278
+
279
+ self._closed = True
280
+
281
+ # Send disconnect message to daemon
282
+ self._send_disconnect()
283
+
284
+ # Wait for heartbeat thread to stop (with timeout)
285
+ if self._heartbeat_thread is not None:
286
+ self._heartbeat_thread.join(timeout=1.0)
287
+ self._heartbeat_thread = None
288
+
289
+ # =========================================================================
290
+ # Operation Methods
291
+ # =========================================================================
292
+
293
+ def install_dependencies(
294
+ self,
295
+ verbose: bool = False,
296
+ timeout: float = 1800,
297
+ ) -> bool:
298
+ """Install project dependencies (toolchain, framework, libraries).
299
+
300
+ This pre-downloads and caches all dependencies required for a build
301
+ without actually compiling.
302
+
303
+ Args:
304
+ verbose: Enable verbose output.
305
+ timeout: Maximum wait time in seconds (default: 30 minutes).
306
+
307
+ Returns:
308
+ True if dependencies installed successfully, False otherwise.
309
+
310
+ Raises:
311
+ RuntimeError: If the connection is closed.
312
+ """
313
+ self._check_closed()
314
+
315
+ from fbuild.daemon.client import InstallDependenciesRequestHandler
316
+
317
+ handler = InstallDependenciesRequestHandler(
318
+ project_dir=self.project_dir,
319
+ environment=self.environment,
320
+ verbose=verbose,
321
+ timeout=timeout,
322
+ )
323
+ return handler.execute()
324
+
325
+ def build(
326
+ self,
327
+ clean: bool = False,
328
+ verbose: bool = False,
329
+ timeout: float = 1800,
330
+ ) -> bool:
331
+ """Build the project.
332
+
333
+ Args:
334
+ clean: Whether to perform a clean build.
335
+ verbose: Enable verbose build output.
336
+ timeout: Maximum wait time in seconds (default: 30 minutes).
337
+
338
+ Returns:
339
+ True if build successful, False otherwise.
340
+
341
+ Raises:
342
+ RuntimeError: If the connection is closed.
343
+ """
344
+ self._check_closed()
345
+
346
+ from fbuild.daemon.client import BuildRequestHandler
347
+
348
+ handler = BuildRequestHandler(
349
+ project_dir=self.project_dir,
350
+ environment=self.environment,
351
+ clean_build=clean,
352
+ verbose=verbose,
353
+ timeout=timeout,
354
+ )
355
+ return handler.execute()
356
+
357
+ def deploy(
358
+ self,
359
+ port: str | None = None,
360
+ clean: bool = False,
361
+ monitor_after: bool = False,
362
+ monitor_timeout: float | None = None,
363
+ monitor_halt_on_error: str | None = None,
364
+ monitor_halt_on_success: str | None = None,
365
+ monitor_expect: str | None = None,
366
+ monitor_show_timestamp: bool = False,
367
+ timeout: float = 1800,
368
+ ) -> bool:
369
+ """Deploy (build and upload) the project to a device.
370
+
371
+ Args:
372
+ port: Serial port for upload (auto-detect if None).
373
+ clean: Whether to perform a clean build.
374
+ monitor_after: Whether to start serial monitor after deploy.
375
+ monitor_timeout: Timeout for monitor in seconds.
376
+ monitor_halt_on_error: Pattern to halt on error.
377
+ monitor_halt_on_success: Pattern to halt on success.
378
+ monitor_expect: Expected pattern to check.
379
+ monitor_show_timestamp: Prefix output with elapsed time.
380
+ timeout: Maximum wait time in seconds (default: 30 minutes).
381
+
382
+ Returns:
383
+ True if deploy successful, False otherwise.
384
+
385
+ Raises:
386
+ RuntimeError: If the connection is closed.
387
+ """
388
+ self._check_closed()
389
+
390
+ from fbuild.daemon.client import DeployRequestHandler
391
+
392
+ handler = DeployRequestHandler(
393
+ project_dir=self.project_dir,
394
+ environment=self.environment,
395
+ port=port,
396
+ clean_build=clean,
397
+ monitor_after=monitor_after,
398
+ monitor_timeout=monitor_timeout,
399
+ monitor_halt_on_error=monitor_halt_on_error,
400
+ monitor_halt_on_success=monitor_halt_on_success,
401
+ monitor_expect=monitor_expect,
402
+ monitor_show_timestamp=monitor_show_timestamp,
403
+ timeout=timeout,
404
+ )
405
+ return handler.execute()
406
+
407
+ def monitor(
408
+ self,
409
+ port: str | None = None,
410
+ baud_rate: int | None = None,
411
+ halt_on_error: str | None = None,
412
+ halt_on_success: str | None = None,
413
+ expect: str | None = None,
414
+ timeout: float | None = None,
415
+ show_timestamp: bool = False,
416
+ ) -> bool:
417
+ """Start serial monitoring.
418
+
419
+ Args:
420
+ port: Serial port (auto-detect if None).
421
+ baud_rate: Serial baud rate (use config default if None).
422
+ halt_on_error: Pattern to halt on error.
423
+ halt_on_success: Pattern to halt on success.
424
+ expect: Expected pattern to check at timeout/success.
425
+ timeout: Maximum monitoring time in seconds.
426
+ show_timestamp: Prefix output lines with elapsed time.
427
+
428
+ Returns:
429
+ True if monitoring completed successfully, False otherwise.
430
+
431
+ Raises:
432
+ RuntimeError: If the connection is closed.
433
+ """
434
+ self._check_closed()
435
+
436
+ from fbuild.daemon.client import MonitorRequestHandler
437
+
438
+ handler = MonitorRequestHandler(
439
+ project_dir=self.project_dir,
440
+ environment=self.environment,
441
+ port=port,
442
+ baud_rate=baud_rate,
443
+ halt_on_error=halt_on_error,
444
+ halt_on_success=halt_on_success,
445
+ expect=expect,
446
+ timeout=timeout,
447
+ show_timestamp=show_timestamp,
448
+ )
449
+ return handler.execute()
450
+
451
+ def get_status(self) -> dict[str, Any]:
452
+ """Get current daemon status.
453
+
454
+ Returns:
455
+ Dictionary with daemon status information including:
456
+ - running: Whether daemon is running
457
+ - state: Current daemon state
458
+ - message: Status message
459
+ - pid: Daemon process ID
460
+ - locks: Lock information
461
+
462
+ Raises:
463
+ RuntimeError: If the connection is closed.
464
+ """
465
+ self._check_closed()
466
+
467
+ from fbuild.daemon.client import get_daemon_status
468
+
469
+ return get_daemon_status()
470
+
471
+
472
+ def connect_daemon(
473
+ project_dir: Path | str,
474
+ environment: str,
475
+ dev_mode: bool | None = None,
476
+ ) -> DaemonConnection:
477
+ """Create a new daemon connection.
478
+
479
+ Each call creates a NEW connection with a unique ID. This is NOT a
480
+ singleton - multiple connections can exist simultaneously for different
481
+ projects or environments.
482
+
483
+ Usage:
484
+ # Using context manager (recommended)
485
+ with connect_daemon(Path("./project"), "esp32dev") as conn:
486
+ conn.build()
487
+ conn.deploy()
488
+
489
+ # Manual lifecycle
490
+ conn = connect_daemon(Path("./project"), "esp32dev")
491
+ try:
492
+ conn.build()
493
+ finally:
494
+ conn.close()
495
+
496
+ Args:
497
+ project_dir: Path to project directory.
498
+ environment: Build environment name.
499
+ dev_mode: Use dev mode daemon. Auto-detects from FBUILD_DEV_MODE if None.
500
+
501
+ Returns:
502
+ New DaemonConnection instance.
503
+ """
504
+ return DaemonConnection(
505
+ project_dir=Path(project_dir),
506
+ environment=environment,
507
+ dev_mode=dev_mode,
508
+ )