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,546 @@
1
+ """
2
+ Firmware Ledger - Track deployed firmware on devices.
3
+
4
+ This module provides a ledger to track what firmware is currently deployed on each
5
+ device/port, allowing clients to skip re-upload if the same firmware is already running.
6
+ The cache is stored in ~/.fbuild/firmware_ledger.json (or dev path if FBUILD_DEV_MODE)
7
+ and uses file locking for thread-safe access.
8
+
9
+ Features:
10
+ - Port to firmware hash mapping with timestamps
11
+ - Source file hash tracking for change detection
12
+ - Build flags hash for build configuration tracking
13
+ - Automatic stale entry expiration (configurable, default 24 hours)
14
+ - Thread-safe file access with file locking
15
+ - Skip re-upload when firmware matches what's deployed
16
+
17
+ Example:
18
+ >>> from fbuild.daemon.firmware_ledger import FirmwareLedger, compute_firmware_hash
19
+ >>>
20
+ >>> # Record a deployment
21
+ >>> ledger = FirmwareLedger()
22
+ >>> fw_hash = compute_firmware_hash(Path("firmware.bin"))
23
+ >>> ledger.record_deployment("COM3", fw_hash, "abc123", "/path/to/project", "esp32dev")
24
+ >>>
25
+ >>> # Check if firmware is current
26
+ >>> if ledger.is_current("COM3", fw_hash, "abc123"):
27
+ >>> print("Firmware already deployed, skipping upload")
28
+ """
29
+
30
+ import hashlib
31
+ import json
32
+ import os
33
+ import sys
34
+ import threading
35
+ import time
36
+ from dataclasses import dataclass
37
+ from pathlib import Path
38
+ from typing import Any
39
+
40
+ # Stale entry threshold: 24 hours (in seconds)
41
+ DEFAULT_STALE_THRESHOLD_SECONDS = 24 * 60 * 60
42
+
43
+
44
+ def _get_ledger_path() -> Path:
45
+ """Get the path to the firmware ledger file.
46
+
47
+ Returns:
48
+ Path to firmware_ledger.json, respecting FBUILD_DEV_MODE
49
+ """
50
+ if os.environ.get("FBUILD_DEV_MODE") == "1":
51
+ # Use project-local directory for development
52
+ return Path.cwd() / ".fbuild" / "daemon_dev" / "firmware_ledger.json"
53
+ else:
54
+ # Use home directory for production
55
+ return Path.home() / ".fbuild" / "firmware_ledger.json"
56
+
57
+
58
+ class FirmwareLedgerError(Exception):
59
+ """Raised when firmware ledger operations fail."""
60
+
61
+ pass
62
+
63
+
64
+ @dataclass
65
+ class FirmwareEntry:
66
+ """A single entry in the firmware ledger.
67
+
68
+ Attributes:
69
+ port: Serial port name (e.g., "COM3", "/dev/ttyUSB0")
70
+ firmware_hash: SHA256 hash of the firmware file (.bin/.hex)
71
+ source_hash: Combined hash of all source files
72
+ project_dir: Absolute path to the project directory
73
+ environment: Build environment name (e.g., "esp32dev", "uno")
74
+ upload_timestamp: Unix timestamp when firmware was uploaded
75
+ build_flags_hash: Optional hash of build flags (for detecting config changes)
76
+ """
77
+
78
+ port: str
79
+ firmware_hash: str
80
+ source_hash: str
81
+ project_dir: str
82
+ environment: str
83
+ upload_timestamp: float
84
+ build_flags_hash: str | None = None
85
+
86
+ def is_stale(self, threshold: float = DEFAULT_STALE_THRESHOLD_SECONDS) -> bool:
87
+ """Check if this entry is stale (older than threshold).
88
+
89
+ Args:
90
+ threshold: Maximum age in seconds before entry is considered stale
91
+
92
+ Returns:
93
+ True if entry is older than threshold
94
+ """
95
+ return (time.time() - self.upload_timestamp) > threshold
96
+
97
+ def to_dict(self) -> dict[str, Any]:
98
+ """Convert to dictionary for JSON serialization."""
99
+ return {
100
+ "port": self.port,
101
+ "firmware_hash": self.firmware_hash,
102
+ "source_hash": self.source_hash,
103
+ "project_dir": self.project_dir,
104
+ "environment": self.environment,
105
+ "upload_timestamp": self.upload_timestamp,
106
+ "build_flags_hash": self.build_flags_hash,
107
+ }
108
+
109
+ @classmethod
110
+ def from_dict(cls, data: dict[str, Any]) -> "FirmwareEntry":
111
+ """Create entry from dictionary.
112
+
113
+ Args:
114
+ data: Dictionary with entry fields
115
+
116
+ Returns:
117
+ FirmwareEntry instance
118
+ """
119
+ return cls(
120
+ port=data["port"],
121
+ firmware_hash=data["firmware_hash"],
122
+ source_hash=data["source_hash"],
123
+ project_dir=data["project_dir"],
124
+ environment=data["environment"],
125
+ upload_timestamp=data["upload_timestamp"],
126
+ build_flags_hash=data.get("build_flags_hash"),
127
+ )
128
+
129
+
130
+ class FirmwareLedger:
131
+ """Manages port to firmware mapping with persistent storage.
132
+
133
+ The ledger stores mappings in ~/.fbuild/firmware_ledger.json (or dev path)
134
+ and provides thread-safe access through file locking.
135
+
136
+ Example:
137
+ >>> ledger = FirmwareLedger()
138
+ >>> ledger.record_deployment("COM3", "abc123", "def456", "/path/project", "esp32dev")
139
+ >>> entry = ledger.get_deployment("COM3")
140
+ >>> print(entry.firmware_hash if entry else "Not found")
141
+ >>> ledger.clear("COM3")
142
+ """
143
+
144
+ def __init__(self, ledger_path: Path | None = None):
145
+ """Initialize the firmware ledger.
146
+
147
+ Args:
148
+ ledger_path: Optional custom path for ledger file.
149
+ Defaults to ~/.fbuild/firmware_ledger.json (or dev path)
150
+ """
151
+ if ledger_path is None:
152
+ self._ledger_path = _get_ledger_path()
153
+ else:
154
+ self._ledger_path = ledger_path
155
+
156
+ # Thread lock for in-process synchronization
157
+ self._lock = threading.Lock()
158
+
159
+ @property
160
+ def ledger_path(self) -> Path:
161
+ """Get the path to the ledger file."""
162
+ return self._ledger_path
163
+
164
+ def _ensure_directory(self) -> None:
165
+ """Ensure the parent directory exists."""
166
+ self._ledger_path.parent.mkdir(parents=True, exist_ok=True)
167
+
168
+ def _read_ledger(self) -> dict[str, dict[str, Any]]:
169
+ """Read the ledger file.
170
+
171
+ Returns:
172
+ Dictionary mapping port names to entry dictionaries
173
+ """
174
+ if not self._ledger_path.exists():
175
+ return {}
176
+
177
+ try:
178
+ with open(self._ledger_path, encoding="utf-8") as f:
179
+ data = json.load(f)
180
+ if not isinstance(data, dict):
181
+ return {}
182
+ return data
183
+ except (json.JSONDecodeError, OSError):
184
+ return {}
185
+
186
+ def _write_ledger(self, data: dict[str, dict[str, Any]]) -> None:
187
+ """Write the ledger file.
188
+
189
+ Args:
190
+ data: Dictionary mapping port names to entry dictionaries
191
+ """
192
+ self._ensure_directory()
193
+ try:
194
+ with open(self._ledger_path, "w", encoding="utf-8") as f:
195
+ json.dump(data, f, indent=2)
196
+ except OSError as e:
197
+ raise FirmwareLedgerError(f"Failed to write ledger: {e}") from e
198
+
199
+ def _acquire_file_lock(self) -> Any:
200
+ """Acquire a file lock for cross-process synchronization.
201
+
202
+ Returns:
203
+ Lock file handle (or None on platforms without locking support)
204
+ """
205
+ self._ensure_directory()
206
+ lock_path = self._ledger_path.with_suffix(".lock")
207
+
208
+ try:
209
+ # Open lock file
210
+ lock_file = open(lock_path, "w", encoding="utf-8")
211
+
212
+ # Platform-specific locking
213
+ if sys.platform == "win32":
214
+ import msvcrt
215
+
216
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
217
+ else: # pragma: no cover - Unix only
218
+ import fcntl # type: ignore[import-not-found]
219
+
220
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
221
+
222
+ return lock_file
223
+ except (ImportError, OSError):
224
+ # Locking not available or failed - continue without lock
225
+ return None
226
+
227
+ def _release_file_lock(self, lock_file: Any) -> None:
228
+ """Release a file lock.
229
+
230
+ Args:
231
+ lock_file: Lock file handle from _acquire_file_lock
232
+ """
233
+ if lock_file is None:
234
+ return
235
+
236
+ try:
237
+ if sys.platform == "win32":
238
+ import msvcrt
239
+
240
+ try:
241
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
242
+ except OSError:
243
+ pass
244
+ else: # pragma: no cover - Unix only
245
+ import fcntl # type: ignore[import-not-found]
246
+
247
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
248
+
249
+ lock_file.close()
250
+ except (ImportError, OSError):
251
+ pass
252
+
253
+ def record_deployment(
254
+ self,
255
+ port: str,
256
+ firmware_hash: str,
257
+ source_hash: str,
258
+ project_dir: str,
259
+ environment: str,
260
+ build_flags_hash: str | None = None,
261
+ ) -> None:
262
+ """Record that firmware was deployed to a port.
263
+
264
+ Args:
265
+ port: Serial port name (e.g., "COM3", "/dev/ttyUSB0")
266
+ firmware_hash: SHA256 hash of the firmware file
267
+ source_hash: Combined hash of all source files
268
+ project_dir: Absolute path to the project directory
269
+ environment: Build environment name (e.g., "esp32dev")
270
+ build_flags_hash: Optional hash of build flags
271
+ """
272
+ entry = FirmwareEntry(
273
+ port=port,
274
+ firmware_hash=firmware_hash,
275
+ source_hash=source_hash,
276
+ project_dir=str(project_dir),
277
+ environment=environment,
278
+ upload_timestamp=time.time(),
279
+ build_flags_hash=build_flags_hash,
280
+ )
281
+
282
+ with self._lock:
283
+ lock_file = self._acquire_file_lock()
284
+ try:
285
+ data = self._read_ledger()
286
+ data[port] = entry.to_dict()
287
+ self._write_ledger(data)
288
+ finally:
289
+ self._release_file_lock(lock_file)
290
+
291
+ def get_deployment(self, port: str) -> FirmwareEntry | None:
292
+ """Get the deployment entry for a port.
293
+
294
+ Args:
295
+ port: Serial port name (e.g., "COM3", "/dev/ttyUSB0")
296
+
297
+ Returns:
298
+ FirmwareEntry or None if not found or stale
299
+ """
300
+ with self._lock:
301
+ lock_file = self._acquire_file_lock()
302
+ try:
303
+ data = self._read_ledger()
304
+ entry_data = data.get(port)
305
+ if entry_data is None:
306
+ return None
307
+
308
+ entry = FirmwareEntry.from_dict(entry_data)
309
+ if entry.is_stale():
310
+ return None
311
+
312
+ return entry
313
+ finally:
314
+ self._release_file_lock(lock_file)
315
+
316
+ def is_current(
317
+ self,
318
+ port: str,
319
+ firmware_hash: str,
320
+ source_hash: str,
321
+ ) -> bool:
322
+ """Check if firmware matches what's currently deployed.
323
+
324
+ This is used to determine if we can skip re-uploading firmware.
325
+
326
+ Args:
327
+ port: Serial port name
328
+ firmware_hash: SHA256 hash of the firmware file
329
+ source_hash: Combined hash of source files
330
+
331
+ Returns:
332
+ True if the firmware and source hashes match the deployed version
333
+ """
334
+ entry = self.get_deployment(port)
335
+ if entry is None:
336
+ return False
337
+
338
+ return entry.firmware_hash == firmware_hash and entry.source_hash == source_hash
339
+
340
+ def needs_redeploy(
341
+ self,
342
+ port: str,
343
+ source_hash: str,
344
+ build_flags_hash: str | None = None,
345
+ ) -> bool:
346
+ """Check if source has changed and needs redeployment.
347
+
348
+ This checks if the source files or build configuration have changed
349
+ since the last deployment.
350
+
351
+ Args:
352
+ port: Serial port name
353
+ source_hash: Current combined hash of source files
354
+ build_flags_hash: Current hash of build flags (optional)
355
+
356
+ Returns:
357
+ True if source or build flags have changed (needs redeploy),
358
+ False if same source and flags (can skip build/deploy)
359
+ """
360
+ entry = self.get_deployment(port)
361
+ if entry is None:
362
+ # No previous deployment, needs deploy
363
+ return True
364
+
365
+ # Check source hash
366
+ if entry.source_hash != source_hash:
367
+ return True
368
+
369
+ # Check build flags if provided
370
+ if build_flags_hash is not None and entry.build_flags_hash != build_flags_hash:
371
+ return True
372
+
373
+ return False
374
+
375
+ def clear(self, port: str) -> bool:
376
+ """Clear the entry for a port.
377
+
378
+ Use this when a device is reset or when you want to force a re-upload.
379
+
380
+ Args:
381
+ port: Serial port name to clear
382
+
383
+ Returns:
384
+ True if entry was cleared, False if not found
385
+ """
386
+ with self._lock:
387
+ lock_file = self._acquire_file_lock()
388
+ try:
389
+ data = self._read_ledger()
390
+ if port in data:
391
+ del data[port]
392
+ self._write_ledger(data)
393
+ return True
394
+ return False
395
+ finally:
396
+ self._release_file_lock(lock_file)
397
+
398
+ def clear_all(self) -> int:
399
+ """Clear all entries from the ledger.
400
+
401
+ Returns:
402
+ Number of entries cleared
403
+ """
404
+ with self._lock:
405
+ lock_file = self._acquire_file_lock()
406
+ try:
407
+ data = self._read_ledger()
408
+ count = len(data)
409
+ self._write_ledger({})
410
+ return count
411
+ finally:
412
+ self._release_file_lock(lock_file)
413
+
414
+ def clear_stale(
415
+ self,
416
+ threshold_seconds: float = DEFAULT_STALE_THRESHOLD_SECONDS,
417
+ ) -> int:
418
+ """Remove all stale entries from the ledger.
419
+
420
+ Args:
421
+ threshold_seconds: Maximum age in seconds before entry is considered stale
422
+
423
+ Returns:
424
+ Number of entries removed
425
+ """
426
+ with self._lock:
427
+ lock_file = self._acquire_file_lock()
428
+ try:
429
+ data = self._read_ledger()
430
+ original_count = len(data)
431
+
432
+ # Filter out stale entries
433
+ fresh_data = {}
434
+ for port, entry_data in data.items():
435
+ entry = FirmwareEntry.from_dict(entry_data)
436
+ if not entry.is_stale(threshold_seconds):
437
+ fresh_data[port] = entry_data
438
+
439
+ self._write_ledger(fresh_data)
440
+ return original_count - len(fresh_data)
441
+ finally:
442
+ self._release_file_lock(lock_file)
443
+
444
+ def get_all(self) -> dict[str, FirmwareEntry]:
445
+ """Get all non-stale entries in the ledger.
446
+
447
+ Returns:
448
+ Dictionary mapping port names to FirmwareEntry objects
449
+ """
450
+ with self._lock:
451
+ lock_file = self._acquire_file_lock()
452
+ try:
453
+ data = self._read_ledger()
454
+ result = {}
455
+ for port, entry_data in data.items():
456
+ entry = FirmwareEntry.from_dict(entry_data)
457
+ if not entry.is_stale():
458
+ result[port] = entry
459
+ return result
460
+ finally:
461
+ self._release_file_lock(lock_file)
462
+
463
+
464
+ def compute_firmware_hash(firmware_path: Path) -> str:
465
+ """Compute SHA256 hash of a firmware file.
466
+
467
+ Args:
468
+ firmware_path: Path to firmware file (.bin, .hex, etc.)
469
+
470
+ Returns:
471
+ Hexadecimal SHA256 hash string
472
+
473
+ Raises:
474
+ FirmwareLedgerError: If file cannot be read
475
+ """
476
+ try:
477
+ hasher = hashlib.sha256()
478
+ with open(firmware_path, "rb") as f:
479
+ # Read in chunks for large files
480
+ for chunk in iter(lambda: f.read(65536), b""):
481
+ hasher.update(chunk)
482
+ return hasher.hexdigest()
483
+ except OSError as e:
484
+ raise FirmwareLedgerError(f"Failed to hash firmware file: {e}") from e
485
+
486
+
487
+ def compute_source_hash(source_files: list[Path]) -> str:
488
+ """Compute combined hash of multiple source files.
489
+
490
+ The hash is computed by hashing each file's content in sorted order
491
+ (by path) to ensure deterministic results.
492
+
493
+ Args:
494
+ source_files: List of source file paths
495
+
496
+ Returns:
497
+ Hexadecimal SHA256 hash string representing all source files
498
+
499
+ Raises:
500
+ FirmwareLedgerError: If any file cannot be read
501
+ """
502
+ hasher = hashlib.sha256()
503
+
504
+ # Sort files by path for deterministic ordering
505
+ sorted_files = sorted(source_files, key=lambda p: str(p))
506
+
507
+ for file_path in sorted_files:
508
+ try:
509
+ # Include the relative path in the hash for detecting file renames/moves
510
+ hasher.update(str(file_path).encode("utf-8"))
511
+ hasher.update(b"\x00") # Null separator
512
+
513
+ with open(file_path, "rb") as f:
514
+ for chunk in iter(lambda: f.read(65536), b""):
515
+ hasher.update(chunk)
516
+ hasher.update(b"\x00") # Separator between files
517
+ except OSError as e:
518
+ raise FirmwareLedgerError(f"Failed to hash source file {file_path}: {e}") from e
519
+
520
+ return hasher.hexdigest()
521
+
522
+
523
+ def compute_build_flags_hash(build_flags: list[str] | str | None) -> str:
524
+ """Compute hash of build flags.
525
+
526
+ Args:
527
+ build_flags: Build flags as a list of strings or a single string
528
+
529
+ Returns:
530
+ Hexadecimal SHA256 hash string
531
+ """
532
+ hasher = hashlib.sha256()
533
+
534
+ if build_flags is None:
535
+ return hasher.hexdigest()
536
+
537
+ if isinstance(build_flags, str):
538
+ build_flags = [build_flags]
539
+
540
+ # Sort flags for deterministic ordering
541
+ sorted_flags = sorted(build_flags)
542
+ for flag in sorted_flags:
543
+ hasher.update(flag.encode("utf-8"))
544
+ hasher.update(b"\x00")
545
+
546
+ return hasher.hexdigest()