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,310 @@
1
+ """
2
+ Firmware deployment module for uploading to embedded devices.
3
+
4
+ This module handles flashing firmware to ESP32 devices using esptool.
5
+ """
6
+
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from fbuild.config import PlatformIOConfig
14
+ from fbuild.packages import Cache
15
+
16
+ from .deployer import DeploymentError, DeploymentResult, IDeployer
17
+
18
+
19
+ class ESP32Deployer(IDeployer):
20
+ """Handles firmware deployment to embedded devices."""
21
+
22
+ def __init__(self, verbose: bool = False):
23
+ """Initialize deployer.
24
+
25
+ Args:
26
+ verbose: Whether to show verbose output
27
+ """
28
+ self.verbose = verbose
29
+
30
+ def deploy(
31
+ self,
32
+ project_dir: Path,
33
+ env_name: str,
34
+ port: Optional[str] = None,
35
+ ) -> DeploymentResult:
36
+ """Deploy firmware to a device.
37
+
38
+ Args:
39
+ project_dir: Path to project directory
40
+ env_name: Environment name to deploy
41
+ port: Serial port to use (auto-detect if None)
42
+
43
+ Returns:
44
+ DeploymentResult with success status and message
45
+ """
46
+ try:
47
+ # Load platformio.ini
48
+ ini_path = project_dir / "platformio.ini"
49
+ if not ini_path.exists():
50
+ raise DeploymentError(f"platformio.ini not found in {project_dir}")
51
+
52
+ config = PlatformIOConfig(ini_path)
53
+ env_config = config.get_env_config(env_name)
54
+
55
+ # Get board and platform
56
+ board_id = env_config.get("board")
57
+ platform_url = env_config.get("platform")
58
+
59
+ if not board_id or not platform_url:
60
+ raise DeploymentError("Board or platform not specified in platformio.ini")
61
+
62
+ # Determine platform type
63
+ if "espressif32" in platform_url or board_id.startswith("esp32"):
64
+ return self._deploy_esp32(project_dir, env_name, board_id, port, platform_url)
65
+ else:
66
+ raise DeploymentError(f"Deployment not supported for board: {board_id}")
67
+
68
+ except DeploymentError as e:
69
+ return DeploymentResult(success=False, message=str(e))
70
+ except KeyboardInterrupt as ke:
71
+ from fbuild.interrupt_utils import handle_keyboard_interrupt_properly
72
+
73
+ handle_keyboard_interrupt_properly(ke)
74
+ raise # Never reached, but satisfies type checker
75
+ except Exception as e:
76
+ return DeploymentResult(success=False, message=f"Unexpected deployment error: {e}")
77
+
78
+ def _deploy_esp32(
79
+ self,
80
+ project_dir: Path,
81
+ env_name: str,
82
+ board_id: str,
83
+ port: Optional[str],
84
+ platform_url: str,
85
+ ) -> DeploymentResult:
86
+ """Deploy firmware to ESP32 device.
87
+
88
+ Args:
89
+ project_dir: Path to project directory
90
+ env_name: Environment name
91
+ board_id: Board identifier
92
+ port: Serial port (auto-detect if None)
93
+ platform_url: Platform package URL
94
+
95
+ Returns:
96
+ DeploymentResult with success status
97
+ """
98
+ # Get build directory
99
+ build_dir = project_dir / ".fbuild" / "build" / env_name
100
+ firmware_bin = (build_dir / "firmware.bin").absolute()
101
+ bootloader_bin = (build_dir / "bootloader.bin").absolute()
102
+ partitions_bin = (build_dir / "partitions.bin").absolute()
103
+
104
+ if not firmware_bin.exists():
105
+ raise DeploymentError(f"Firmware not found at {firmware_bin}. Run 'fbuild build' first.")
106
+
107
+ # Get cache and ensure platform/toolchain packages
108
+ cache = Cache(project_dir)
109
+
110
+ # Import ESP32 packages
111
+ from fbuild.packages.framework_esp32 import FrameworkESP32
112
+ from fbuild.packages.platform_esp32 import PlatformESP32
113
+
114
+ # Ensure platform is downloaded first (needed to get board JSON)
115
+ platform = PlatformESP32(cache, platform_url, show_progress=self.verbose)
116
+ platform.ensure_platform()
117
+
118
+ # Get board JSON to determine MCU and required packages
119
+ board_json = platform.get_board_json(board_id)
120
+ mcu = board_json.get("build", {}).get("mcu", "esp32")
121
+ packages = platform.get_required_packages(mcu)
122
+
123
+ # Initialize framework
124
+ framework_url = packages.get("framework-arduinoespressif32")
125
+ libs_url = packages.get("framework-arduinoespressif32-libs")
126
+ if not framework_url or not libs_url:
127
+ raise DeploymentError("Framework URLs not found in platform package")
128
+
129
+ framework = FrameworkESP32(cache, framework_url, libs_url, show_progress=self.verbose)
130
+ framework.ensure_framework()
131
+
132
+ # Auto-detect port if not specified
133
+ if not port:
134
+ port = self._detect_serial_port()
135
+ if not port:
136
+ raise DeploymentError("No serial port specified and auto-detection failed. " + "Use --port to specify a port.")
137
+
138
+ if self.verbose:
139
+ print(f"Using port: {port}")
140
+
141
+ # Determine chip type and flash parameters from board JSON
142
+ chip = self._get_chip_type(mcu)
143
+ flash_mode = board_json.get("build", {}).get("flash_mode", "dio")
144
+
145
+ # Get flash frequency and convert to esptool format
146
+ f_flash = board_json.get("build", {}).get("f_flash", "80000000L")
147
+ if isinstance(f_flash, str) and f_flash.endswith("L"):
148
+ freq_value = int(f_flash.rstrip("L"))
149
+ flash_freq = f"{freq_value // 1000000}m"
150
+ elif isinstance(f_flash, (int, float)):
151
+ flash_freq = f"{int(f_flash // 1000000)}m"
152
+ else:
153
+ flash_freq = "80m"
154
+
155
+ flash_size = "detect"
156
+
157
+ # CRITICAL FIX: ESP32-C6/C3/C2/H2 ROM bootloader can only load the second-stage
158
+ # bootloader in DIO mode. Must use DIO for flashing even if app uses QIO.
159
+ # See: https://github.com/espressif/arduino-esp32/discussions/10418
160
+ if mcu in ["esp32c6", "esp32c3", "esp32c2", "esp32h2"]:
161
+ flash_mode = "dio"
162
+
163
+ # Determine bootloader offset based on MCU
164
+ # ESP32/ESP32-S2: 0x1000, ESP32-P4: 0x2000, others: 0x0
165
+ if mcu in ["esp32", "esp32s2"]:
166
+ bootloader_offset = "0x1000"
167
+ elif mcu == "esp32p4":
168
+ bootloader_offset = "0x2000"
169
+ else:
170
+ bootloader_offset = "0x0"
171
+
172
+ # Find boot_app0.bin in framework tools
173
+ boot_app0_bin = framework.framework_path / "tools" / "partitions" / "boot_app0.bin"
174
+
175
+ # Build esptool command to flash multiple binaries at different offsets
176
+ # Flash layout: bootloader @ offset, partition table @ 0x8000, boot_app0 @ 0xe000, app @ 0x10000
177
+ cmd = [
178
+ sys.executable,
179
+ "-m",
180
+ "esptool",
181
+ "--chip",
182
+ chip,
183
+ "--port",
184
+ port,
185
+ "--baud",
186
+ "460800",
187
+ "write_flash",
188
+ "-z", # Compress
189
+ "--flash-mode",
190
+ flash_mode,
191
+ "--flash-freq",
192
+ flash_freq,
193
+ "--flash-size",
194
+ flash_size,
195
+ ]
196
+
197
+ # Add bootloader if it exists
198
+ if bootloader_bin.exists():
199
+ cmd.extend([bootloader_offset, str(bootloader_bin)])
200
+ else:
201
+ if self.verbose:
202
+ print("Warning: bootloader.bin not found, skipping")
203
+
204
+ # Add partition table if it exists
205
+ if partitions_bin.exists():
206
+ cmd.extend(["0x8000", str(partitions_bin)])
207
+ else:
208
+ if self.verbose:
209
+ print("Warning: partitions.bin not found, skipping")
210
+
211
+ # Add boot_app0.bin if it exists
212
+ if boot_app0_bin.exists():
213
+ cmd.extend(["0xe000", str(boot_app0_bin)])
214
+ else:
215
+ if self.verbose:
216
+ print("Warning: boot_app0.bin not found, skipping")
217
+
218
+ # Add application firmware at 0x10000
219
+ cmd.extend(["0x10000", str(firmware_bin)])
220
+
221
+ if self.verbose:
222
+ print("Flashing firmware to device...")
223
+ print(f" Bootloader: {bootloader_offset}")
224
+ print(" Partition table: 0x8000")
225
+ print(" Boot app: 0xe000")
226
+ print(" Application: 0x10000")
227
+ print(f"Running: {' '.join(cmd)}")
228
+
229
+ # Execute esptool - must use cmd.exe for ESP32 on Windows
230
+ if sys.platform == "win32":
231
+ # Run via cmd.exe to avoid msys issues
232
+ env = os.environ.copy()
233
+ # Strip MSYS paths that cause issues
234
+ if "PATH" in env:
235
+ paths = env["PATH"].split(os.pathsep)
236
+ filtered_paths = [p for p in paths if "msys" not in p.lower()]
237
+ env["PATH"] = os.pathsep.join(filtered_paths)
238
+
239
+ result = subprocess.run(
240
+ cmd,
241
+ cwd=project_dir,
242
+ capture_output=not self.verbose,
243
+ text=False, # Don't decode as text - esptool may output binary data
244
+ env=env,
245
+ shell=False,
246
+ )
247
+ else:
248
+ result = subprocess.run(
249
+ cmd,
250
+ cwd=project_dir,
251
+ capture_output=not self.verbose,
252
+ text=False, # Don't decode as text - esptool may output binary data
253
+ )
254
+
255
+ if result.returncode != 0:
256
+ error_msg = "Upload failed"
257
+ if result.stderr:
258
+ error_msg = result.stderr.decode("utf-8", errors="replace")
259
+ return DeploymentResult(success=False, message=f"Deployment failed: {error_msg}", port=port)
260
+
261
+ return DeploymentResult(success=True, message="Firmware uploaded successfully", port=port)
262
+
263
+ def _get_chip_type(self, mcu: str) -> str:
264
+ """Get chip type string for esptool from MCU name.
265
+
266
+ Args:
267
+ mcu: MCU type (e.g., "esp32c6", "esp32s3")
268
+
269
+ Returns:
270
+ Chip type for esptool (e.g., "esp32c6", "esp32s3")
271
+ """
272
+ # Map MCU names to esptool chip types
273
+ return mcu # Usually they match directly
274
+
275
+ def _detect_serial_port(self) -> Optional[str]:
276
+ """Auto-detect serial port for device.
277
+
278
+ Returns:
279
+ Serial port name or None if not found
280
+ """
281
+ try:
282
+ import serial.tools.list_ports
283
+
284
+ ports = list(serial.tools.list_ports.comports())
285
+
286
+ # Look for ESP32 or USB-SERIAL devices
287
+ for port in ports:
288
+ description = (port.description or "").lower()
289
+ manufacturer = (port.manufacturer or "").lower()
290
+
291
+ if any(x in description or x in manufacturer for x in ["cp210", "ch340", "usb-serial", "uart", "esp32"]):
292
+ return port.device
293
+
294
+ # If no specific match, return first port
295
+ if ports:
296
+ return ports[0].device
297
+
298
+ except ImportError:
299
+ if self.verbose:
300
+ print("pyserial not installed. Cannot auto-detect port.")
301
+ except KeyboardInterrupt as ke:
302
+ from fbuild.interrupt_utils import handle_keyboard_interrupt_properly
303
+
304
+ handle_keyboard_interrupt_properly(ke)
305
+ raise # Never reached, but satisfies type checker
306
+ except Exception as e:
307
+ if self.verbose:
308
+ print(f"Port detection failed: {e}")
309
+
310
+ return None
@@ -0,0 +1,315 @@
1
+ """
2
+ Docker utilities for QEMU deployment.
3
+
4
+ This module provides utilities for managing Docker containers for ESP32 QEMU emulation,
5
+ including automatic Docker daemon startup detection and image management.
6
+ """
7
+
8
+ import os
9
+ import platform
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from typing import Optional
14
+
15
+ from fbuild.interrupt_utils import handle_keyboard_interrupt_properly
16
+
17
+
18
+ def get_docker_env() -> dict[str, str]:
19
+ """Get environment for Docker commands, handling Git Bash/MSYS2 path conversion."""
20
+ env = os.environ.copy()
21
+ # Set UTF-8 encoding environment variables for Windows
22
+ env["PYTHONIOENCODING"] = "utf-8"
23
+ env["PYTHONUTF8"] = "1"
24
+ # Only set MSYS_NO_PATHCONV if we're in a Git Bash/MSYS2 environment
25
+ if "MSYSTEM" in os.environ or os.environ.get("TERM") == "xterm" or "bash.exe" in os.environ.get("SHELL", ""):
26
+ env["MSYS_NO_PATHCONV"] = "1"
27
+ return env
28
+
29
+
30
+ def check_docker_daemon_running() -> bool:
31
+ """Check if Docker daemon is running.
32
+
33
+ Returns:
34
+ True if Docker daemon is running, False otherwise
35
+ """
36
+ try:
37
+ result = subprocess.run(
38
+ ["docker", "info"],
39
+ capture_output=True,
40
+ timeout=10,
41
+ env=get_docker_env(),
42
+ )
43
+ return result.returncode == 0
44
+ except (subprocess.SubprocessError, FileNotFoundError, subprocess.TimeoutExpired):
45
+ return False
46
+
47
+
48
+ def check_docker_installed() -> bool:
49
+ """Check if Docker is installed.
50
+
51
+ Returns:
52
+ True if Docker is installed, False otherwise
53
+ """
54
+ try:
55
+ result = subprocess.run(
56
+ ["docker", "--version"],
57
+ capture_output=True,
58
+ timeout=5,
59
+ env=get_docker_env(),
60
+ )
61
+ return result.returncode == 0
62
+ except (subprocess.SubprocessError, FileNotFoundError, subprocess.TimeoutExpired):
63
+ return False
64
+
65
+
66
+ def get_docker_desktop_path() -> Optional[str]:
67
+ """Get the path to Docker Desktop executable.
68
+
69
+ Returns:
70
+ Path to Docker Desktop or None if not found
71
+ """
72
+ system = platform.system()
73
+
74
+ if system == "Windows":
75
+ # Common locations for Docker Desktop on Windows
76
+ paths = [
77
+ r"C:\Program Files\Docker\Docker\Docker Desktop.exe",
78
+ r"C:\Program Files (x86)\Docker\Docker\Docker Desktop.exe",
79
+ os.path.expandvars(r"%ProgramFiles%\Docker\Docker\Docker Desktop.exe"),
80
+ os.path.expandvars(r"%LocalAppData%\Programs\Docker\Docker\Docker Desktop.exe"),
81
+ ]
82
+ for path in paths:
83
+ if os.path.exists(path):
84
+ return path
85
+
86
+ elif system == "Darwin": # macOS
87
+ paths = [
88
+ "/Applications/Docker.app/Contents/MacOS/Docker Desktop",
89
+ "/Applications/Docker.app",
90
+ ]
91
+ for path in paths:
92
+ if os.path.exists(path):
93
+ return path
94
+
95
+ elif system == "Linux":
96
+ # On Linux, Docker usually runs as a service, not a desktop app
97
+ return None
98
+
99
+ return None
100
+
101
+
102
+ def start_docker_daemon() -> bool:
103
+ """Attempt to start the Docker daemon.
104
+
105
+ This function tries to start Docker Desktop on Windows/macOS
106
+ or the Docker service on Linux.
107
+
108
+ Returns:
109
+ True if Docker daemon started successfully, False otherwise
110
+ """
111
+ system = platform.system()
112
+
113
+ if check_docker_daemon_running():
114
+ return True
115
+
116
+ print("Docker daemon is not running. Attempting to start...")
117
+
118
+ if system == "Windows":
119
+ docker_path = get_docker_desktop_path()
120
+ if docker_path:
121
+ try:
122
+ # Start Docker Desktop without waiting
123
+ subprocess.Popen(
124
+ [docker_path],
125
+ stdout=subprocess.DEVNULL,
126
+ stderr=subprocess.DEVNULL,
127
+ creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW,
128
+ )
129
+ return _wait_for_docker_daemon()
130
+ except KeyboardInterrupt as ke:
131
+ handle_keyboard_interrupt_properly(ke)
132
+ except Exception as e:
133
+ print(f"Failed to start Docker Desktop: {e}")
134
+ return False
135
+ else:
136
+ print("Docker Desktop not found. Please start Docker Desktop manually.")
137
+ return False
138
+
139
+ elif system == "Darwin":
140
+ docker_path = get_docker_desktop_path()
141
+ if docker_path:
142
+ try:
143
+ subprocess.Popen(
144
+ ["open", "-a", "Docker"],
145
+ stdout=subprocess.DEVNULL,
146
+ stderr=subprocess.DEVNULL,
147
+ )
148
+ return _wait_for_docker_daemon()
149
+ except KeyboardInterrupt as ke:
150
+ handle_keyboard_interrupt_properly(ke)
151
+ except Exception as e:
152
+ print(f"Failed to start Docker Desktop: {e}")
153
+ return False
154
+ else:
155
+ print("Docker Desktop not found. Please install Docker Desktop.")
156
+ return False
157
+
158
+ elif system == "Linux":
159
+ try:
160
+ # Try to start the Docker service using systemctl
161
+ result = subprocess.run(
162
+ ["sudo", "systemctl", "start", "docker"],
163
+ capture_output=True,
164
+ timeout=30,
165
+ )
166
+ if result.returncode == 0:
167
+ return _wait_for_docker_daemon()
168
+ else:
169
+ # Try without sudo (if user has permissions)
170
+ result = subprocess.run(
171
+ ["systemctl", "start", "docker"],
172
+ capture_output=True,
173
+ timeout=30,
174
+ )
175
+ if result.returncode == 0:
176
+ return _wait_for_docker_daemon()
177
+ except KeyboardInterrupt as ke:
178
+ handle_keyboard_interrupt_properly(ke)
179
+ except Exception as e:
180
+ print(f"Failed to start Docker service: {e}")
181
+
182
+ print("Failed to start Docker service. Try running: sudo systemctl start docker")
183
+ return False
184
+
185
+ return False
186
+
187
+
188
+ def _wait_for_docker_daemon(timeout: int = 60) -> bool:
189
+ """Wait for Docker daemon to become available.
190
+
191
+ Args:
192
+ timeout: Maximum time to wait in seconds
193
+
194
+ Returns:
195
+ True if Docker daemon is now running, False if timeout
196
+ """
197
+ print(f"Waiting for Docker daemon to start (timeout: {timeout}s)...")
198
+ start_time = time.time()
199
+
200
+ while (time.time() - start_time) < timeout:
201
+ if check_docker_daemon_running():
202
+ print("Docker daemon is now running!")
203
+ return True
204
+ time.sleep(2)
205
+ sys.stdout.write(".")
206
+ sys.stdout.flush()
207
+
208
+ print("\nTimeout waiting for Docker daemon to start.")
209
+ return False
210
+
211
+
212
+ def ensure_docker_available() -> bool:
213
+ """Ensure Docker is available, starting daemon if necessary.
214
+
215
+ Returns:
216
+ True if Docker is available, False otherwise
217
+ """
218
+ if not check_docker_installed():
219
+ print("Docker is not installed.")
220
+ print()
221
+ print("Install Docker:")
222
+ print(" - Windows/Mac: https://www.docker.com/products/docker-desktop")
223
+ print(" - Linux: https://docs.docker.com/engine/install/")
224
+ return False
225
+
226
+ if check_docker_daemon_running():
227
+ return True
228
+
229
+ return start_docker_daemon()
230
+
231
+
232
+ def check_docker_image_exists(image_name: str) -> bool:
233
+ """Check if a Docker image exists locally.
234
+
235
+ Args:
236
+ image_name: Name of the Docker image to check
237
+
238
+ Returns:
239
+ True if image exists, False otherwise
240
+ """
241
+ try:
242
+ result = subprocess.run(
243
+ ["docker", "images", "-q", image_name],
244
+ capture_output=True,
245
+ text=True,
246
+ timeout=10,
247
+ env=get_docker_env(),
248
+ )
249
+ return bool(result.stdout.strip())
250
+ except (subprocess.SubprocessError, FileNotFoundError, subprocess.TimeoutExpired):
251
+ return False
252
+
253
+
254
+ def pull_docker_image(image_name: str, timeout: int = 600) -> bool:
255
+ """Pull a Docker image.
256
+
257
+ Args:
258
+ image_name: Name of the Docker image to pull
259
+ timeout: Timeout in seconds (default 10 minutes)
260
+
261
+ Returns:
262
+ True if image was pulled successfully, False otherwise
263
+ """
264
+ print(f"Pulling Docker image: {image_name}")
265
+ print("This may take a few minutes on first run...")
266
+
267
+ try:
268
+ result = subprocess.run(
269
+ ["docker", "pull", image_name],
270
+ timeout=timeout,
271
+ env=get_docker_env(),
272
+ )
273
+ if result.returncode == 0:
274
+ print(f"Successfully pulled {image_name}")
275
+ return True
276
+ else:
277
+ print(f"Failed to pull {image_name}")
278
+ return False
279
+ except KeyboardInterrupt as ke:
280
+ handle_keyboard_interrupt_properly(ke)
281
+ except subprocess.TimeoutExpired:
282
+ print(f"Timeout pulling {image_name}")
283
+ return False
284
+ except Exception as e:
285
+ print(f"Error pulling {image_name}: {e}")
286
+ return False
287
+
288
+
289
+ def ensure_docker_image(image_name: str, fallback_images: Optional[list[str]] = None) -> bool:
290
+ """Ensure a Docker image is available, pulling if necessary.
291
+
292
+ Args:
293
+ image_name: Name of the Docker image to ensure
294
+ fallback_images: Optional list of fallback images to try if primary fails
295
+
296
+ Returns:
297
+ True if image is available, False otherwise
298
+ """
299
+ if check_docker_image_exists(image_name):
300
+ print(f"Image {image_name} already available locally")
301
+ return True
302
+
303
+ if pull_docker_image(image_name):
304
+ return True
305
+
306
+ # Try fallback images
307
+ if fallback_images:
308
+ for fallback in fallback_images:
309
+ print(f"Trying fallback image: {fallback}")
310
+ if check_docker_image_exists(fallback):
311
+ return True
312
+ if pull_docker_image(fallback):
313
+ return True
314
+
315
+ return False