fastled 1.2.5__tar.gz → 1.2.7__tar.gz

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 (81) hide show
  1. {fastled-1.2.5 → fastled-1.2.7}/PKG-INFO +11 -1
  2. {fastled-1.2.5 → fastled-1.2.7}/README.md +10 -0
  3. fastled-1.2.7/src/fastled/__init__.py +325 -0
  4. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/app.py +26 -0
  5. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/docker_manager.py +81 -6
  6. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/keyboard.py +8 -12
  7. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/live_client.py +17 -0
  8. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/open_browser.py +2 -1
  9. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/parse_args.py +6 -0
  10. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/site/build.py +46 -14
  11. {fastled-1.2.5 → fastled-1.2.7}/src/fastled.egg-info/PKG-INFO +11 -1
  12. {fastled-1.2.5 → fastled-1.2.7}/src/fastled.egg-info/SOURCES.txt +1 -0
  13. fastled-1.2.7/tests/test_build.py +57 -0
  14. {fastled-1.2.5 → fastled-1.2.7}/tests/test_ino/bad/bad.ino +9 -9
  15. {fastled-1.2.5 → fastled-1.2.7}/tests/test_ino/embedded/wasm.ino +9 -9
  16. {fastled-1.2.5 → fastled-1.2.7}/tests/test_ino/wasm/wasm.ino +9 -9
  17. fastled-1.2.5/src/fastled/__init__.py +0 -180
  18. {fastled-1.2.5 → fastled-1.2.7}/.aiderignore +0 -0
  19. {fastled-1.2.5 → fastled-1.2.7}/.github/workflows/build_multi_docker_image.yml +0 -0
  20. {fastled-1.2.5 → fastled-1.2.7}/.github/workflows/build_webpage.yml +0 -0
  21. {fastled-1.2.5 → fastled-1.2.7}/.github/workflows/lint.yml +0 -0
  22. {fastled-1.2.5 → fastled-1.2.7}/.github/workflows/publish_release.yml +0 -0
  23. {fastled-1.2.5 → fastled-1.2.7}/.github/workflows/test_build_exe.yml +0 -0
  24. {fastled-1.2.5 → fastled-1.2.7}/.github/workflows/test_macos.yml +0 -0
  25. {fastled-1.2.5 → fastled-1.2.7}/.github/workflows/test_ubuntu.yml +0 -0
  26. {fastled-1.2.5 → fastled-1.2.7}/.github/workflows/test_win.yml +0 -0
  27. {fastled-1.2.5 → fastled-1.2.7}/.gitignore +0 -0
  28. {fastled-1.2.5 → fastled-1.2.7}/.pylintrc +0 -0
  29. {fastled-1.2.5 → fastled-1.2.7}/.vscode/launch.json +0 -0
  30. {fastled-1.2.5 → fastled-1.2.7}/.vscode/settings.json +0 -0
  31. {fastled-1.2.5 → fastled-1.2.7}/.vscode/tasks.json +0 -0
  32. {fastled-1.2.5 → fastled-1.2.7}/LICENSE +0 -0
  33. {fastled-1.2.5 → fastled-1.2.7}/MANIFEST.in +0 -0
  34. {fastled-1.2.5 → fastled-1.2.7}/RELEASE.md +0 -0
  35. {fastled-1.2.5 → fastled-1.2.7}/TODO.md +0 -0
  36. {fastled-1.2.5 → fastled-1.2.7}/build_exe.py +0 -0
  37. {fastled-1.2.5 → fastled-1.2.7}/build_site.py +0 -0
  38. {fastled-1.2.5 → fastled-1.2.7}/clean +0 -0
  39. {fastled-1.2.5 → fastled-1.2.7}/install +0 -0
  40. {fastled-1.2.5 → fastled-1.2.7}/lint +0 -0
  41. {fastled-1.2.5 → fastled-1.2.7}/pyproject.toml +0 -0
  42. {fastled-1.2.5 → fastled-1.2.7}/requirements.testing.txt +0 -0
  43. {fastled-1.2.5 → fastled-1.2.7}/setup.cfg +0 -0
  44. {fastled-1.2.5 → fastled-1.2.7}/setup.py +0 -0
  45. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/assets/example.txt +0 -0
  46. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/cli.py +0 -0
  47. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/client_server.py +0 -0
  48. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/compile_server.py +0 -0
  49. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/compile_server_impl.py +0 -0
  50. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/filewatcher.py +0 -0
  51. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/paths.py +0 -0
  52. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/project_init.py +0 -0
  53. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/select_sketch_directory.py +0 -0
  54. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/settings.py +0 -0
  55. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/sketch.py +0 -0
  56. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/spinner.py +0 -0
  57. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/string_diff.py +0 -0
  58. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/test/can_run_local_docker_tests.py +0 -0
  59. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/test/examples.py +0 -0
  60. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/types.py +0 -0
  61. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/util.py +0 -0
  62. {fastled-1.2.5 → fastled-1.2.7}/src/fastled/web_compile.py +0 -0
  63. {fastled-1.2.5 → fastled-1.2.7}/src/fastled.egg-info/dependency_links.txt +0 -0
  64. {fastled-1.2.5 → fastled-1.2.7}/src/fastled.egg-info/entry_points.txt +0 -0
  65. {fastled-1.2.5 → fastled-1.2.7}/src/fastled.egg-info/requires.txt +0 -0
  66. {fastled-1.2.5 → fastled-1.2.7}/src/fastled.egg-info/top_level.txt +0 -0
  67. {fastled-1.2.5 → fastled-1.2.7}/test +0 -0
  68. {fastled-1.2.5 → fastled-1.2.7}/tests/test_api.py +0 -0
  69. {fastled-1.2.5 → fastled-1.2.7}/tests/test_bad_ino.py +0 -0
  70. {fastled-1.2.5 → fastled-1.2.7}/tests/test_build_examples.py +0 -0
  71. {fastled-1.2.5 → fastled-1.2.7}/tests/test_cli.py +0 -0
  72. {fastled-1.2.5 → fastled-1.2.7}/tests/test_compile_server.py +0 -0
  73. {fastled-1.2.5 → fastled-1.2.7}/tests/test_docker_linux_on_windows.py +0 -0
  74. {fastled-1.2.5 → fastled-1.2.7}/tests/test_embedded_data.py +0 -0
  75. {fastled-1.2.5 → fastled-1.2.7}/tests/test_examples.py +0 -0
  76. {fastled-1.2.5 → fastled-1.2.7}/tests/test_filechanger.py +0 -0
  77. {fastled-1.2.5 → fastled-1.2.7}/tests/test_ino/embedded/data/bigdata.dat +0 -0
  78. {fastled-1.2.5 → fastled-1.2.7}/tests/test_project_init.py +0 -0
  79. {fastled-1.2.5 → fastled-1.2.7}/tests/test_server_and_client_seperatly.py +0 -0
  80. {fastled-1.2.5 → fastled-1.2.7}/tests/test_webcompile.py +0 -0
  81. {fastled-1.2.5 → fastled-1.2.7}/upload_package.sh +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastled
3
- Version: 1.2.5
3
+ Version: 1.2.7
4
4
  Summary: FastLED Wasm Compiler
5
5
  Home-page: https://github.com/zackees/fastled-wasm
6
6
  Maintainer: Zachary Vorhies
@@ -186,6 +186,14 @@ with Api.server() as server:
186
186
  client.stop()
187
187
  ```
188
188
 
189
+ **Build Docker Image from a local copy of the FastLED repo**
190
+ ```python
191
+ from fastapi import Docker, Api
192
+ container_name: str = Docker.build_from_fastled_repo()
193
+ with Api.server(container_name=container_name) as server:
194
+ ...
195
+ ```
196
+
189
197
  # Features
190
198
 
191
199
  ## Hot reload by default
@@ -266,6 +274,8 @@ A: A big chunk of space is being used by unnecessary javascript `emscripten` bun
266
274
 
267
275
  # Revisions
268
276
 
277
+ * 1.2.7 - A bunch of fixes for MacOS and probably linux.
278
+ * 1.2.6 - Now builds image from the project root of FastLED.
269
279
  * 1.1.25 - Fix up paths for `--init`
270
280
  * 1.1.24 - Mac/Linux now properly responds to ctrl-c when waiting for a key event.
271
281
  * 1.1.23 - Fixes missing `live-server` on platforms that don't have it already.
@@ -163,6 +163,14 @@ with Api.server() as server:
163
163
  client.stop()
164
164
  ```
165
165
 
166
+ **Build Docker Image from a local copy of the FastLED repo**
167
+ ```python
168
+ from fastapi import Docker, Api
169
+ container_name: str = Docker.build_from_fastled_repo()
170
+ with Api.server(container_name=container_name) as server:
171
+ ...
172
+ ```
173
+
166
174
  # Features
167
175
 
168
176
  ## Hot reload by default
@@ -243,6 +251,8 @@ A: A big chunk of space is being used by unnecessary javascript `emscripten` bun
243
251
 
244
252
  # Revisions
245
253
 
254
+ * 1.2.7 - A bunch of fixes for MacOS and probably linux.
255
+ * 1.2.6 - Now builds image from the project root of FastLED.
246
256
  * 1.1.25 - Fix up paths for `--init`
247
257
  * 1.1.24 - Mac/Linux now properly responds to ctrl-c when waiting for a key event.
248
258
  * 1.1.23 - Fixes missing `live-server` on platforms that don't have it already.
@@ -0,0 +1,325 @@
1
+ """FastLED Wasm Compiler package."""
2
+
3
+ # context
4
+ import subprocess
5
+ from contextlib import contextmanager
6
+ from pathlib import Path
7
+ from typing import Generator
8
+
9
+ from .compile_server import CompileServer
10
+ from .live_client import LiveClient
11
+ from .site.build import build
12
+ from .types import BuildMode, CompileResult, CompileServerError
13
+
14
+ # IMPORTANT! There's a bug in github which will REJECT any version update
15
+ # that has any other change in the repo. Please bump the version as the
16
+ # ONLY change in a commit, or else the pypi update and the release will fail.
17
+ __version__ = "1.2.7"
18
+
19
+
20
+ class Api:
21
+ @staticmethod
22
+ def get_examples(host: str | CompileServer | None = None) -> list[str]:
23
+ from fastled.project_init import get_examples
24
+
25
+ if isinstance(host, CompileServer):
26
+ host = host.url()
27
+
28
+ return get_examples(host=host)
29
+
30
+ @staticmethod
31
+ def project_init(
32
+ example=None, outputdir=None, host: str | CompileServer | None = None
33
+ ) -> Path:
34
+ from fastled.project_init import project_init
35
+
36
+ if isinstance(host, CompileServer):
37
+ host = host.url()
38
+ return project_init(example, outputdir, host)
39
+
40
+ @staticmethod
41
+ def web_compile(
42
+ directory: Path | str,
43
+ host: str | CompileServer | None = None,
44
+ build_mode: BuildMode = BuildMode.QUICK,
45
+ profile: bool = False, # When true then profile information will be enabled and included in the zip.
46
+ ) -> CompileResult:
47
+ from fastled.web_compile import web_compile
48
+
49
+ if isinstance(host, CompileServer):
50
+ host = host.url()
51
+ if isinstance(directory, str):
52
+ directory = Path(directory)
53
+ out: CompileResult = web_compile(
54
+ directory, host, build_mode=build_mode, profile=profile
55
+ )
56
+ return out
57
+
58
+ @staticmethod
59
+ def live_client(
60
+ sketch_directory: Path,
61
+ host: str | CompileServer | None = None,
62
+ auto_start=True,
63
+ open_web_browser=True,
64
+ keep_running=True,
65
+ build_mode=BuildMode.QUICK,
66
+ profile=False,
67
+ ) -> LiveClient:
68
+ return LiveClient(
69
+ sketch_directory=sketch_directory,
70
+ host=host,
71
+ auto_start=auto_start,
72
+ open_web_browser=open_web_browser,
73
+ keep_running=keep_running,
74
+ build_mode=build_mode,
75
+ profile=profile,
76
+ )
77
+
78
+ @staticmethod
79
+ def spawn_server(
80
+ interactive=False,
81
+ auto_updates=None,
82
+ auto_start=True,
83
+ container_name: str | None = None,
84
+ ) -> CompileServer:
85
+ from fastled.compile_server import CompileServer
86
+
87
+ out = CompileServer(
88
+ container_name=container_name,
89
+ interactive=interactive,
90
+ auto_updates=auto_updates,
91
+ mapped_dir=None,
92
+ auto_start=auto_start,
93
+ )
94
+ return out
95
+
96
+ @staticmethod
97
+ @contextmanager
98
+ def server(
99
+ interactive=False,
100
+ auto_updates=None,
101
+ auto_start=True,
102
+ container_name: str | None = None,
103
+ ) -> Generator[CompileServer, None, None]:
104
+ server = Api.spawn_server(
105
+ interactive=interactive,
106
+ auto_updates=auto_updates,
107
+ auto_start=auto_start,
108
+ container_name=container_name,
109
+ )
110
+ try:
111
+ yield server
112
+ finally:
113
+ server.stop()
114
+
115
+
116
+ class Docker:
117
+ @staticmethod
118
+ def is_installed() -> bool:
119
+ from fastled.docker_manager import DockerManager
120
+
121
+ return DockerManager.is_docker_installed()
122
+
123
+ @staticmethod
124
+ def is_running() -> bool:
125
+ from fastled.docker_manager import DockerManager
126
+
127
+ return DockerManager.is_running()
128
+
129
+ @staticmethod
130
+ def is_container_running(container_name: str | None = None) -> bool:
131
+ # from fastled.docker import is_container_running
132
+ from fastled.docker_manager import DockerManager
133
+ from fastled.settings import CONTAINER_NAME
134
+
135
+ docker_mgr = DockerManager()
136
+ container_name = container_name or CONTAINER_NAME
137
+ return docker_mgr.is_container_running(container_name)
138
+
139
+ @staticmethod
140
+ def purge() -> None:
141
+ from fastled.docker_manager import DockerManager
142
+ from fastled.settings import IMAGE_NAME
143
+
144
+ docker_mgr = DockerManager()
145
+ docker_mgr.purge(image_name=IMAGE_NAME)
146
+
147
+ @staticmethod
148
+ def build_from_github(
149
+ url: str = "https://github.com/fastled/fastled",
150
+ output_dir: Path | str = Path(".cache/fastled"),
151
+ ) -> str:
152
+ """Build the FastLED WASM compiler Docker image from a GitHub repository.
153
+
154
+ Args:
155
+ url: GitHub repository URL (default: https://github.com/fastled/fastled)
156
+ output_dir: Directory to clone the repo into (default: .cache/fastled)
157
+
158
+ Returns:
159
+ Container name.
160
+ """
161
+
162
+ from fastled.docker_manager import DockerManager
163
+ from fastled.settings import CONTAINER_NAME, IMAGE_NAME
164
+
165
+ if isinstance(output_dir, str):
166
+ output_dir = Path(output_dir)
167
+
168
+ # Create output directory if it doesn't exist
169
+ output_dir.mkdir(parents=True, exist_ok=True)
170
+
171
+ # Clone or update the repository
172
+ if (output_dir / ".git").exists():
173
+ print(f"Updating existing repository in {output_dir}")
174
+ # Reset local changes and move HEAD back to handle force pushes
175
+ subprocess.run(
176
+ ["git", "reset", "--hard", "HEAD~10"],
177
+ cwd=output_dir,
178
+ check=True,
179
+ capture_output=True, # Suppress output of reset
180
+ )
181
+ subprocess.run(
182
+ ["git", "pull", "origin", "master"], cwd=output_dir, check=True
183
+ )
184
+ else:
185
+ print(f"Cloning {url} into {output_dir}")
186
+ subprocess.run(["git", "clone", url, str(output_dir)], check=True)
187
+
188
+ dockerfile_path = (
189
+ output_dir / "src" / "platforms" / "wasm" / "compiler" / "Dockerfile"
190
+ )
191
+
192
+ if not dockerfile_path.exists():
193
+ raise FileNotFoundError(
194
+ f"Dockerfile not found at {dockerfile_path}. "
195
+ "This may not be a valid FastLED repository."
196
+ )
197
+
198
+ docker_mgr = DockerManager()
199
+
200
+ platform_tag = ""
201
+ # if "arm" in docker_mgr.architecture():
202
+ if (
203
+ "arm"
204
+ in subprocess.run(["uname", "-m"], capture_output=True).stdout.decode()
205
+ ):
206
+ platform_tag = "-arm64"
207
+
208
+ # Build the image
209
+ docker_mgr.build_image(
210
+ image_name=IMAGE_NAME,
211
+ tag="main",
212
+ dockerfile_path=dockerfile_path,
213
+ build_context=output_dir,
214
+ build_args={"NO_PREWARM": "1"},
215
+ platform_tag=platform_tag,
216
+ )
217
+
218
+ # Run the container and return it
219
+ container = docker_mgr.run_container_detached(
220
+ image_name=IMAGE_NAME,
221
+ tag="main",
222
+ container_name=CONTAINER_NAME,
223
+ command=None, # Use default command from Dockerfile
224
+ volumes=None, # No volumes needed for build
225
+ ports=None, # No ports needed for build
226
+ remove_previous=True, # Remove any existing container
227
+ )
228
+
229
+ return container.name
230
+
231
+ @staticmethod
232
+ def build_from_fastled_repo(
233
+ project_root: Path | str = Path("."), platform_tag: str = ""
234
+ ) -> str:
235
+ """Build the FastLED WASM compiler Docker image, which will be tagged as "main".
236
+
237
+ Args:
238
+ project_root: Path to the FastLED project root directory
239
+ platform_tag: Optional platform tag (e.g. "-arm64" for ARM builds)
240
+
241
+ Returns:
242
+ The string name of the docker container.
243
+ """
244
+ from fastled.docker_manager import DockerManager
245
+ from fastled.settings import CONTAINER_NAME, IMAGE_NAME
246
+
247
+ if isinstance(project_root, str):
248
+ project_root = Path(project_root)
249
+
250
+ dockerfile_path = (
251
+ project_root / "src" / "platforms" / "wasm" / "compiler" / "Dockerfile"
252
+ )
253
+
254
+ docker_mgr = DockerManager()
255
+
256
+ platform_tag = ""
257
+ # if "arm" in docker_mgr.architecture():
258
+ if (
259
+ "arm"
260
+ in subprocess.run(["uname", "-m"], capture_output=True).stdout.decode()
261
+ ):
262
+ platform_tag = "-arm64"
263
+
264
+ # if image exists, remove it
265
+ docker_mgr.purge(image_name=IMAGE_NAME)
266
+
267
+ # Build the image
268
+ docker_mgr.build_image(
269
+ image_name=IMAGE_NAME,
270
+ tag="main",
271
+ dockerfile_path=dockerfile_path,
272
+ build_context=project_root,
273
+ build_args={"NO_PREWARM": "1"},
274
+ platform_tag=platform_tag,
275
+ )
276
+
277
+ # Run the container and return it
278
+ container = docker_mgr.run_container_detached(
279
+ image_name=IMAGE_NAME,
280
+ tag="main",
281
+ container_name=CONTAINER_NAME,
282
+ command=None, # Use default command from Dockerfile
283
+ volumes=None, # No volumes needed for build
284
+ ports=None, # No ports needed for build
285
+ remove_previous=True, # Remove any existing container
286
+ )
287
+ container_name = f"{container.name}"
288
+ return container_name
289
+
290
+
291
+ class Test:
292
+ __test__ = False # This prevents unittest from recognizing it as a test class.
293
+
294
+ @staticmethod
295
+ def can_run_local_docker_tests() -> bool:
296
+ from fastled.test.can_run_local_docker_tests import can_run_local_docker_tests
297
+
298
+ return can_run_local_docker_tests()
299
+
300
+ @staticmethod
301
+ def test_examples(
302
+ examples: list[str] | None = None, host: str | CompileServer | None = None
303
+ ) -> dict[str, Exception]:
304
+ from fastled.test.examples import test_examples
305
+
306
+ if isinstance(host, CompileServer):
307
+ host = host.url()
308
+
309
+ return test_examples(examples=examples, host=host)
310
+
311
+ @staticmethod
312
+ def build_site(outputdir: Path, fast: bool | None = None, check: bool = True):
313
+ """Builds the FastLED compiler site."""
314
+ build(outputdir=outputdir, fast=fast, check=check)
315
+
316
+
317
+ __all__ = [
318
+ "Api",
319
+ "Test",
320
+ "Build",
321
+ "CompileServer",
322
+ "CompileResult",
323
+ "CompileServerError",
324
+ "BuildMode",
325
+ ]
@@ -51,6 +51,32 @@ def main() -> int:
51
51
  print("Finished updating.")
52
52
  return 0
53
53
 
54
+ if args.build:
55
+ try:
56
+ project_root = Path(".").absolute()
57
+ print(f"Building Docker image at {project_root}")
58
+ from fastled import Api, Docker
59
+
60
+ docker_image_name = Docker.build_from_fastled_repo(
61
+ project_root=project_root
62
+ )
63
+ print(f"Built Docker image: {docker_image_name}")
64
+ print("Running server")
65
+ with Api.server(
66
+ auto_updates=False, container_name=docker_image_name
67
+ ) as server:
68
+ print(f"Server started at {server.url()}")
69
+ sketch_dir = Path("examples/wasm")
70
+ with Api.live_client(
71
+ sketch_directory=sketch_dir, host=server
72
+ ) as client:
73
+ print(f"Client started at {client.url()}")
74
+ while True:
75
+ time.sleep(0.1)
76
+ except KeyboardInterrupt:
77
+ print("\nExiting from client...")
78
+ return 1
79
+
54
80
  if args.server:
55
81
  print("Running in server only mode.")
56
82
  return run_server(args)
@@ -4,6 +4,7 @@ New abstraction for Docker management with improved Ctrl+C handling.
4
4
 
5
5
  import _thread
6
6
  import os
7
+ import platform
7
8
  import subprocess
8
9
  import sys
9
10
  import threading
@@ -97,10 +98,12 @@ class RunningContainer:
97
98
 
98
99
  class DockerManager:
99
100
  def __init__(self) -> None:
101
+ from docker.errors import DockerException
102
+
100
103
  try:
101
104
  self._client: DockerClient | None = None
102
105
  self.first_run = False
103
- except docker.errors.DockerException as e:
106
+ except DockerException as e:
104
107
  stack = traceback.format_exc()
105
108
  warnings.warn(f"Error initializing Docker client: {e}\n{stack}")
106
109
  raise
@@ -475,7 +478,7 @@ class DockerManager:
475
478
  print(
476
479
  f"Container {container_name} did not restart within {timeout} seconds."
477
480
  )
478
- container.stop()
481
+ container.stop(timeout=0)
479
482
  print(f"Container {container_name} has been stopped.")
480
483
  container.start()
481
484
  elif container.status == "paused":
@@ -501,6 +504,7 @@ class DockerManager:
501
504
  tty=True,
502
505
  volumes=volumes,
503
506
  ports=ports,
507
+ remove=True,
504
508
  )
505
509
  return container
506
510
 
@@ -552,6 +556,8 @@ class DockerManager:
552
556
  if isinstance(container, str):
553
557
  container = self.get_container(container)
554
558
 
559
+ assert container is not None, "Container not found."
560
+
555
561
  print(f"Attaching to container {container.name}...")
556
562
 
557
563
  first_run = self.first_run
@@ -570,7 +576,11 @@ class DockerManager:
570
576
  print(f"Could not put container {container_name} to sleep.")
571
577
  return
572
578
  try:
573
- container.pause()
579
+ if platform.system() == "Windows":
580
+ container.pause()
581
+ else:
582
+ container.stop()
583
+ container.remove()
574
584
  print(f"Container {container.name} has been suspended.")
575
585
  except KeyboardInterrupt:
576
586
  print(f"Container {container.name} interrupted by keyboard interrupt.")
@@ -583,21 +593,23 @@ class DockerManager:
583
593
  """
584
594
  if isinstance(container, str):
585
595
  container = self.get_container(container)
596
+ if not container:
597
+ print(f"Could not resume container {container}.")
598
+ return
586
599
  try:
587
600
  container.unpause()
588
601
  print(f"Container {container.name} has been resumed.")
589
602
  except Exception as e:
590
603
  print(f"Failed to resume container {container.name}: {e}")
591
604
 
592
- def get_container(self, container_name: str) -> Container:
605
+ def get_container(self, container_name: str) -> Container | None:
593
606
  """
594
607
  Get a container by name.
595
608
  """
596
609
  try:
597
610
  return self.client.containers.get(container_name)
598
611
  except docker.errors.NotFound:
599
- print(f"Container {container_name} not found.")
600
- raise
612
+ return None
601
613
 
602
614
  def is_container_running(self, container_name: str) -> bool:
603
615
  """
@@ -610,6 +622,67 @@ class DockerManager:
610
622
  print(f"Container {container_name} not found.")
611
623
  return False
612
624
 
625
+ def build_image(
626
+ self,
627
+ image_name: str,
628
+ tag: str,
629
+ dockerfile_path: Path,
630
+ build_context: Path,
631
+ build_args: dict[str, str] | None = None,
632
+ platform_tag: str = "",
633
+ ) -> None:
634
+ """
635
+ Build a Docker image from a Dockerfile.
636
+
637
+ Args:
638
+ image_name: Name for the image
639
+ tag: Tag for the image
640
+ dockerfile_path: Path to the Dockerfile
641
+ build_context: Path to the build context directory
642
+ build_args: Optional dictionary of build arguments
643
+ platform_tag: Optional platform tag (e.g. "-arm64")
644
+ """
645
+ if not dockerfile_path.exists():
646
+ raise FileNotFoundError(f"Dockerfile not found at {dockerfile_path}")
647
+
648
+ if not build_context.exists():
649
+ raise FileNotFoundError(
650
+ f"Build context directory not found at {build_context}"
651
+ )
652
+
653
+ print(f"Building Docker image {image_name}:{tag} from {dockerfile_path}")
654
+
655
+ # Prepare build arguments
656
+ buildargs = build_args or {}
657
+ if platform_tag:
658
+ buildargs["PLATFORM_TAG"] = platform_tag
659
+
660
+ try:
661
+ cmd_list = [
662
+ "docker",
663
+ "build",
664
+ "-t",
665
+ f"{image_name}:{tag}",
666
+ ]
667
+
668
+ # Add build args
669
+ for arg_name, arg_value in buildargs.items():
670
+ cmd_list.extend(["--build-arg", f"{arg_name}={arg_value}"])
671
+
672
+ # Add dockerfile and context paths
673
+ cmd_list.extend(["-f", str(dockerfile_path), str(build_context)])
674
+
675
+ cmd_str = subprocess.list2cmdline(cmd_list)
676
+ print(f"Running command: {cmd_str}")
677
+
678
+ # Run the build command
679
+ subprocess.run(cmd_list, check=True)
680
+ print(f"Successfully built image {image_name}:{tag}")
681
+
682
+ except subprocess.CalledProcessError as e:
683
+ print(f"Error building Docker image: {e}")
684
+ raise
685
+
613
686
  def purge(self, image_name: str) -> None:
614
687
  """
615
688
  Remove all containers and images associated with the given image name.
@@ -626,11 +699,13 @@ class DockerManager:
626
699
  if any(image_name in tag for tag in container.image.tags):
627
700
  print(f"Removing container {container.name}")
628
701
  container.remove(force=True)
702
+
629
703
  except Exception as e:
630
704
  print(f"Error removing containers: {e}")
631
705
 
632
706
  # Remove all images with this name
633
707
  try:
708
+ self.client.images.prune(filters={"dangling": False})
634
709
  images = self.client.images.list()
635
710
  for image in images:
636
711
  if any(image_name in tag for tag in image.tags):
@@ -6,12 +6,10 @@ import time
6
6
  from queue import Empty, Queue
7
7
  from threading import Thread
8
8
 
9
- _WHITE_SPACE = [" ", "\r", "\n"]
9
+ _WHITE_SPACE = {" ", "\n", "\r"} # Including Enter key as whitespace
10
10
 
11
11
 
12
- # Original space bar, but now also enter key.
13
12
  class SpaceBarWatcher:
14
-
15
13
  @classmethod
16
14
  def watch_space_bar_pressed(cls, timeout: float = 0) -> bool:
17
15
  watcher = cls()
@@ -32,9 +30,7 @@ class SpaceBarWatcher:
32
30
  self.process.start()
33
31
 
34
32
  def _watch_for_space(self) -> None:
35
- # Set stdin to non-blocking mode
36
33
  fd = sys.stdin.fileno()
37
-
38
34
  if os.name == "nt": # Windows
39
35
  import msvcrt
40
36
 
@@ -51,14 +47,13 @@ class SpaceBarWatcher:
51
47
  char = msvcrt.getch().decode() # type: ignore
52
48
  if char in _WHITE_SPACE:
53
49
  self.queue.put(ord(" "))
54
-
55
50
  else: # Unix-like systems
56
51
  import termios
57
52
  import tty
58
53
 
59
54
  old_settings = termios.tcgetattr(fd) # type: ignore
60
55
  try:
61
- tty.setraw(fd) # type: ignore
56
+ tty.setcbreak(fd) # Use cbreak mode to avoid console issues
62
57
  while True:
63
58
  # Check for cancel signal
64
59
  try:
@@ -70,21 +65,22 @@ class SpaceBarWatcher:
70
65
  # Check if there's input ready
71
66
  if select.select([sys.stdin], [], [], 0.1)[0]:
72
67
  char = sys.stdin.read(1)
73
- if ord(char) == 3: # ctrl+c on mac, maybe also linux?
68
+ if ord(char) == 3: # Ctrl+C
74
69
  _thread.interrupt_main()
75
70
  break
76
-
77
71
  if char in _WHITE_SPACE:
78
72
  self.queue.put(ord(" "))
79
73
  finally:
80
- termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) # type: ignore
74
+ termios.tcsetattr(
75
+ fd, termios.TCSADRAIN, old_settings
76
+ ) # Restore terminal settings
81
77
 
82
78
  def space_bar_pressed(self) -> bool:
83
79
  found = False
84
80
  while not self.queue.empty():
85
81
  try:
86
- key = self.queue.get(block=False, timeout=0.1)
87
- if key == ord(" "):
82
+ key = self.queue.get(block=False)
83
+ if key == ord(" "): # Spacebar
88
84
  found = True
89
85
  self.queue.task_done()
90
86
  except Empty:
@@ -45,6 +45,17 @@ class LiveClient:
45
45
  )
46
46
  return rtn
47
47
 
48
+ def url(self) -> str:
49
+ """Get the URL of the server."""
50
+ if isinstance(self.host, CompileServer):
51
+ return self.host.url()
52
+ if self.host is None:
53
+ import warnings
54
+
55
+ warnings.warn("TODO: use the actual host.")
56
+ return "http://localhost:9021"
57
+ return self.host
58
+
48
59
  @property
49
60
  def running(self) -> bool:
50
61
  return self.thread is not None and self.thread.is_alive()
@@ -67,3 +78,9 @@ class LiveClient:
67
78
  """Finalize the client."""
68
79
  self.stop()
69
80
  self.thread = None
81
+
82
+ def __enter__(self) -> "LiveClient":
83
+ return self
84
+
85
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
86
+ self.finalize()
@@ -17,7 +17,8 @@ def _open_browser_python(fastled_js: Path) -> None:
17
17
  subprocess.run(
18
18
  [sys.executable, "-m", "nodejs.npm", "install", "-g", "live-server"]
19
19
  )
20
- os.system(f"cd {fastled_js} && live-server")
20
+ proc = subprocess.Popen(["cd", fastled_js, "&&", "live-server"])
21
+ proc.wait()
21
22
 
22
23
 
23
24
  def _find_open_port(start_port: int) -> int: