fastled 1.1.15__py2.py3-none-any.whl → 1.1.17__py2.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.
fastled/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """FastLED Wasm Compiler package."""
2
2
 
3
- __version__ = "1.1.15"
3
+ __version__ = "1.1.17"
fastled/app.py CHANGED
@@ -44,9 +44,6 @@ class CompiledResult:
44
44
  hash_value: str | None
45
45
 
46
46
 
47
- DOCKER = DockerManager(container_name=CONTAINER_NAME)
48
-
49
-
50
47
  def parse_args() -> argparse.Namespace:
51
48
  """Parse command-line arguments."""
52
49
  parser = argparse.ArgumentParser(description=f"FastLED WASM Compiler {__version__}")
@@ -107,16 +104,32 @@ def parse_args() -> argparse.Namespace:
107
104
  action="store_true",
108
105
  help="Run the server in the current directory, volume mapping fastled if we are in the repo",
109
106
  )
110
-
111
- build_mode.add_argument(
107
+ parser.add_argument(
112
108
  "--force-compile",
113
109
  action="store_true",
114
110
  help="Skips the test to see if the current directory is a valid FastLED sketch directory",
115
111
  )
112
+ parser.add_argument(
113
+ "--no-auto-updates",
114
+ action="store_true",
115
+ help="Disable automatic updates of the wasm compiler image when using docker.",
116
+ )
117
+ parser.add_argument(
118
+ "--update",
119
+ action="store_true",
120
+ help="Update the wasm compiler (if necessary) before running",
121
+ )
116
122
 
117
123
  cwd_is_fastled = looks_like_fastled_repo(Path(os.getcwd()))
118
124
 
119
125
  args = parser.parse_args()
126
+ if args.update:
127
+ args.auto_update = True
128
+ elif args.no_auto_updates:
129
+ args.auto_update = False
130
+ else:
131
+ args.auto_update = None
132
+
120
133
  if not cwd_is_fastled and not args.localhost and not args.web and not args.server:
121
134
  print(f"Using web compiler at {DEFAULT_URL}")
122
135
  args.web = DEFAULT_URL
@@ -202,8 +215,8 @@ def run_web_compiler(
202
215
 
203
216
 
204
217
  def _try_start_server_or_get_url(args: argparse.Namespace) -> str | CompileServer:
218
+ auto_update = args.auto_update
205
219
  is_local_host = "localhost" in args.web or "127.0.0.1" in args.web or args.localhost
206
-
207
220
  # test to see if there is already a local host server
208
221
  local_host_needs_server = False
209
222
  if is_local_host:
@@ -228,7 +241,7 @@ def _try_start_server_or_get_url(args: argparse.Namespace) -> str | CompileServe
228
241
  else:
229
242
  try:
230
243
  print("No local server found, starting one...")
231
- compile_server = CompileServer()
244
+ compile_server = CompileServer(auto_updates=auto_update)
232
245
  print("Waiting for the local compiler to start...")
233
246
  if not compile_server.wait_for_startup():
234
247
  print("Failed to start local compiler.")
@@ -252,7 +265,7 @@ def run_client(args: argparse.Namespace) -> int:
252
265
  return 1
253
266
 
254
267
  # If not explicitly using web compiler, check Docker installation
255
- if not args.web and not DOCKER.is_docker_installed():
268
+ if not args.web and not DockerManager.is_docker_installed():
256
269
  print(
257
270
  "\nDocker is not installed on this system - switching to web compiler instead."
258
271
  )
@@ -304,9 +317,7 @@ def run_client(args: argparse.Namespace) -> int:
304
317
  if open_web_browser:
305
318
  browser_proc = open_browser_thread(Path(args.directory) / "fastled_js")
306
319
  else:
307
- print(
308
- "\nCompilation successful. Run without --just-compile to open in browser and watch for changes."
309
- )
320
+ print("\nCompilation successful.")
310
321
  if compile_server:
311
322
  print("Shutting down compile server...")
312
323
  compile_server.stop()
@@ -402,7 +413,8 @@ def run_client(args: argparse.Namespace) -> int:
402
413
 
403
414
  def run_server(args: argparse.Namespace) -> int:
404
415
  interactive = args.interactive
405
- compile_server = CompileServer(interactive=interactive)
416
+ auto_update = args.auto_update
417
+ compile_server = CompileServer(interactive=interactive, auto_updates=auto_update)
406
418
  if not interactive:
407
419
  print(f"Server started at {compile_server.url()}")
408
420
  compile_server.wait_for_startup()
@@ -432,9 +444,8 @@ def main() -> int:
432
444
 
433
445
  if __name__ == "__main__":
434
446
  try:
435
- # os.chdir("../fastled")
436
- sys.argv.append("examples/SdCard")
437
- sys.argv.append("--localhost")
447
+ os.chdir("../fastled")
448
+ sys.argv.append("--server")
438
449
  sys.exit(main())
439
450
  except KeyboardInterrupt:
440
451
  print("\nExiting from main...")
fastled/compile_server.py CHANGED
@@ -1,13 +1,14 @@
1
- import socket
2
1
  import subprocess
3
2
  import time
3
+ from datetime import datetime, timezone
4
4
  from pathlib import Path
5
5
 
6
6
  import httpx
7
7
 
8
- from fastled.docker_manager import DockerManager
8
+ from fastled.docker_manager import DISK_CACHE, DockerManager, RunningContainer
9
9
  from fastled.sketch import looks_like_fastled_repo
10
10
 
11
+ _IMAGE_NAME = "niteris/fastled-wasm"
11
12
  _DEFAULT_CONTAINER_NAME = "fastled-wasm-compiler"
12
13
 
13
14
  SERVER_PORT = 9021
@@ -15,24 +16,12 @@ SERVER_PORT = 9021
15
16
  SERVER_OPTIONS = ["--allow-shutdown", "--no-auto-update"]
16
17
 
17
18
 
18
- def find_available_port(start_port: int = SERVER_PORT) -> int:
19
- """Find an available port starting from the given port."""
20
- port = start_port
21
- end_port = start_port + 1000
22
- while True:
23
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
24
- if s.connect_ex(("localhost", port)) != 0:
25
- return port
26
- port += 1
27
- if port >= end_port:
28
- raise RuntimeError("No available ports found")
29
-
30
-
31
19
  class CompileServer:
32
20
  def __init__(
33
21
  self,
34
22
  container_name=_DEFAULT_CONTAINER_NAME,
35
23
  interactive: bool = False,
24
+ auto_updates: bool | None = None,
36
25
  ) -> None:
37
26
 
38
27
  cwd = Path(".").resolve()
@@ -44,11 +33,11 @@ class CompileServer:
44
33
  fastled_src_dir = cwd / "src"
45
34
 
46
35
  self.container_name = container_name
47
- self.docker = DockerManager(container_name=container_name)
48
- self.running = False
49
- self.running_process: subprocess.Popen | None = None
36
+ self.docker = DockerManager()
50
37
  self.fastled_src_dir: Path | None = fastled_src_dir
51
38
  self.interactive = interactive
39
+ self.running_container: RunningContainer | None = None
40
+ self.auto_updates = auto_updates
52
41
  self._port = self._start()
53
42
  # fancy print
54
43
  if not interactive:
@@ -57,6 +46,16 @@ class CompileServer:
57
46
  print(msg)
58
47
  print("#" * len(msg) + "\n")
59
48
 
49
+ @property
50
+ def running(self) -> bool:
51
+ if not self._port:
52
+ return False
53
+ if not DockerManager.is_docker_installed():
54
+ return False
55
+ if not DockerManager.is_running():
56
+ return False
57
+ return self.docker.is_container_running(self.container_name)
58
+
60
59
  def using_fastled_src_dir_volume(self) -> bool:
61
60
  return self.fastled_src_dir is not None
62
61
 
@@ -86,56 +85,43 @@ class CompileServer:
86
85
  except Exception:
87
86
  pass
88
87
  time.sleep(0.1)
89
- if not self.running:
88
+ if not self.docker.is_container_running(self.container_name):
90
89
  return False
91
90
  return False
92
91
 
93
92
  def _start(self) -> int:
94
93
  print("Compiling server starting")
95
- self.running = True
96
- # Ensure Docker is running
97
- with self.docker.get_lock():
98
- if not self.docker.is_running():
99
- if not self.docker.start():
100
- print("Docker could not be started. Exiting.")
101
- raise RuntimeError("Docker could not be started. Exiting.")
102
94
 
103
- # Clean up any existing container with the same name
104
- try:
105
- container_exists = (
106
- subprocess.run(
107
- ["docker", "inspect", self.container_name],
108
- capture_output=True,
109
- text=True,
110
- ).returncode
111
- == 0
112
- )
113
- if container_exists:
114
- print("Cleaning up existing container")
115
- subprocess.run(
116
- ["docker", "rm", "-f", self.container_name],
117
- check=False,
118
- )
119
- except KeyboardInterrupt:
120
- raise
121
- except Exception as e:
122
- print(f"Warning: Failed to remove existing container: {e}")
123
-
124
- print("Ensuring Docker image exists at latest version")
125
- if not self.docker.ensure_image_exists():
126
- print("Failed to ensure Docker image exists.")
127
- raise RuntimeError("Failed to ensure Docker image exists")
95
+ # Ensure Docker is running
96
+ if not self.docker.is_running():
97
+ if not self.docker.start():
98
+ print("Docker could not be started. Exiting.")
99
+ raise RuntimeError("Docker could not be started. Exiting.")
100
+ now = datetime.now(timezone.utc)
101
+ now_str = now.strftime("%Y-%m-%d %H %Z")
102
+
103
+ upgrade = False
104
+ if self.auto_updates is None:
105
+ prev_date_str = DISK_CACHE.get("last-update")
106
+ if prev_date_str != now_str:
107
+ print("One hour has passed, checking docker for updates")
108
+ upgrade = True
109
+ else:
110
+ upgrade = self.auto_updates
111
+ self.docker.validate_or_download_image(
112
+ image_name=_IMAGE_NAME, tag="main", upgrade=upgrade
113
+ )
114
+ DISK_CACHE.put("last-update", now_str)
128
115
 
129
116
  print("Docker image now validated")
130
- port = find_available_port()
131
- print(f"Found an available port: {port}")
117
+ port = SERVER_PORT
132
118
  if self.interactive:
133
119
  server_command = ["/bin/bash"]
134
120
  else:
135
121
  server_command = ["python", "/js/run.py", "server"] + SERVER_OPTIONS
136
122
  server_cmd_str = subprocess.list2cmdline(server_command)
137
123
  print(f"Started Docker container with command: {server_cmd_str}")
138
- ports = {port: 80}
124
+ ports = {80: port}
139
125
  volumes = None
140
126
  if self.fastled_src_dir:
141
127
  print(
@@ -144,78 +130,29 @@ class CompileServer:
144
130
  volumes = {
145
131
  str(self.fastled_src_dir): {"bind": "/host/fastled/src", "mode": "ro"}
146
132
  }
147
- self.running_process = self.docker.run_container(
148
- server_command, ports=ports, volumes=volumes, tty=self.interactive
133
+
134
+ cmd_str = subprocess.list2cmdline(server_command)
135
+
136
+ self.docker.run_container(
137
+ image_name=_IMAGE_NAME,
138
+ tag="main",
139
+ container_name=self.container_name,
140
+ command=cmd_str,
141
+ ports=ports,
142
+ volumes=volumes,
149
143
  )
144
+ self.running_container = self.docker.attach_and_run(self.container_name)
145
+ assert self.running_container is not None, "Container should be running"
146
+
150
147
  print("Compile server starting")
151
- time.sleep(3)
152
- if self.running_process.poll() is not None:
153
- print("Server failed to start")
154
- self.running = False
155
- raise RuntimeError("Server failed to start")
156
148
  return port
157
149
 
158
150
  def proceess_running(self) -> bool:
159
- if self.running_process is None:
160
- return False
161
- return self.running_process.poll() is None
151
+ return self.docker.is_container_running(self.container_name)
162
152
 
163
153
  def stop(self) -> None:
164
- print(f"Stopping server on port {self._port}")
165
- # # attempt to send a shutdown signal to the server
166
- # try:
167
- # httpx.get(f"http://localhost:{self._port}/shutdown", timeout=2)
168
- # # except Exception:
169
- # except Exception as e:
170
- # print(f"Failed to send shutdown signal: {e}")
171
- # pass
172
- try:
173
- # Stop the Docker container
174
- cp: subprocess.CompletedProcess
175
- cp = subprocess.run(
176
- ["docker", "stop", self.container_name],
177
- capture_output=True,
178
- text=True,
179
- check=True,
180
- )
181
- if cp.returncode != 0:
182
- print(f"Failed to stop Docker container: {cp.stderr}")
183
-
184
- cp = subprocess.run(
185
- ["docker", "rm", self.container_name],
186
- capture_output=True,
187
- text=True,
188
- check=True,
189
- )
190
- if cp.returncode != 0:
191
- print(f"Failed to remove Docker container: {cp.stderr}")
192
-
193
- # Close the stdout pipe
194
- if self.running_process and self.running_process.stdout:
195
- self.running_process.stdout.close()
196
-
197
- # Wait for the process to fully terminate with a timeout
198
- self.running_process.wait(timeout=10)
199
- if self.running_process.returncode is None:
200
- # kill
201
- self.running_process.kill()
202
- if self.running_process.returncode is not None:
203
- print(
204
- f"Server stopped with return code {self.running_process.returncode}"
205
- )
206
-
207
- except subprocess.TimeoutExpired:
208
- # Force kill if it doesn't stop gracefully
209
- if self.running_process:
210
- self.running_process.kill()
211
- self.running_process.wait()
212
- except KeyboardInterrupt:
213
- if self.running_process:
214
- self.running_process.kill()
215
- self.running_process.wait()
216
- except Exception as e:
217
- print(f"Error stopping Docker container: {e}")
218
- finally:
219
- self.running_process = None
220
- self.running = False
154
+ # print(f"Stopping server on port {self._port}")
155
+ if self.running_container:
156
+ self.running_container.stop()
157
+ self.docker.suspend_container(self.container_name)
221
158
  print("Compile server stopped")
fastled/docker_manager.py CHANGED
@@ -1,17 +1,36 @@
1
- """Docker management functionality for FastLED WASM compiler."""
1
+ """
2
+ New abstraction for Docker management with improved Ctrl+C handling.
3
+ """
2
4
 
5
+ import _thread
3
6
  import subprocess
4
7
  import sys
8
+ import threading
5
9
  import time
10
+ import traceback
11
+ import warnings
12
+ from datetime import datetime, timezone
6
13
  from pathlib import Path
7
14
 
8
- import docker # type: ignore
15
+ import docker
16
+ from appdirs import user_data_dir
17
+ from disklru import DiskLRUCache
18
+ from docker.client import DockerClient
19
+ from docker.models.containers import Container
20
+ from docker.models.images import Image
9
21
  from filelock import FileLock
10
22
 
11
- TAG = "main"
23
+ CONFIG_DIR = Path(user_data_dir("fastled", "fastled"))
24
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
25
+ DB_FILE = CONFIG_DIR / "db.db"
26
+ DISK_CACHE = DiskLRUCache(str(DB_FILE), 10)
12
27
 
13
- _HERE = Path(__file__).parent
14
- _FILE_LOCK = FileLock(str(_HERE / "fled.lock"))
28
+
29
+ # Docker uses datetimes in UTC but without the timezone info. If we pass in a tz
30
+ # then it will throw an exception.
31
+ def _utc_now_no_tz() -> datetime:
32
+ now = datetime.now(timezone.utc)
33
+ return now.replace(tzinfo=None)
15
34
 
16
35
 
17
36
  def _win32_docker_location() -> str | None:
@@ -26,11 +45,52 @@ def _win32_docker_location() -> str | None:
26
45
  return None
27
46
 
28
47
 
29
- class DockerManager:
30
- """Manages Docker operations for FastLED WASM compiler."""
48
+ def get_lock(image_name: str) -> FileLock:
49
+ """Get the file lock for this DockerManager instance."""
50
+ lock_file = CONFIG_DIR / f"{image_name}.lock"
51
+ return FileLock(str(lock_file))
52
+
53
+
54
+ class RunningContainer:
55
+ def __init__(self, container, first_run=False):
56
+ self.container = container
57
+ self.first_run = first_run
58
+ self.running = True
59
+ self.thread = threading.Thread(target=self._log_monitor)
60
+ self.thread.daemon = True
61
+ self.thread.start()
62
+
63
+ def _log_monitor(self):
64
+ from_date = _utc_now_no_tz() if not self.first_run else None
65
+ to_date = _utc_now_no_tz()
66
+
67
+ while self.running:
68
+ try:
69
+ for log in self.container.logs(
70
+ follow=False, since=from_date, until=to_date, stream=True
71
+ ):
72
+ print(log.decode("utf-8"), end="")
73
+ time.sleep(0.1)
74
+ from_date = to_date
75
+ to_date = _utc_now_no_tz()
76
+ except KeyboardInterrupt:
77
+ print("Monitoring logs interrupted by user.")
78
+ _thread.interrupt_main()
79
+ break
80
+ except Exception as e:
81
+ print(f"Error monitoring logs: {e}")
82
+ break
83
+
84
+ def stop(self) -> None:
85
+ """Stop monitoring the container logs"""
86
+ self.running = False
87
+ self.thread.join()
88
+
31
89
 
32
- def __init__(self, container_name: str):
33
- self.container_name = container_name
90
+ class DockerManager:
91
+ def __init__(self) -> None:
92
+ self.client: DockerClient = docker.from_env()
93
+ self.first_run = False
34
94
 
35
95
  @staticmethod
36
96
  def is_docker_installed() -> bool:
@@ -46,9 +106,13 @@ class DockerManager:
46
106
  print("Docker is not installed.")
47
107
  return False
48
108
 
49
- def is_running(self) -> bool:
109
+ @staticmethod
110
+ def is_running() -> bool:
50
111
  """Check if Docker is running by pinging the Docker daemon."""
112
+ if not DockerManager.is_docker_installed():
113
+ return False
51
114
  try:
115
+ # self.client.ping()
52
116
  client = docker.from_env()
53
117
  client.ping()
54
118
  print("Docker is running.")
@@ -104,158 +168,348 @@ class DockerManager:
104
168
  print(f"Error starting Docker: {str(e)}")
105
169
  return False
106
170
 
107
- def ensure_linux_containers(self) -> bool:
108
- """Ensure Docker is using Linux containers on Windows."""
109
- if sys.platform == "win32":
110
- try:
111
- # Check if we're already in Linux container mode
112
- result = subprocess.run(
113
- ["docker", "info"], capture_output=True, text=True, check=True
114
- )
115
- if "linux" in result.stdout.lower():
116
- return True
171
+ def validate_or_download_image(
172
+ self, image_name: str, tag: str = "latest", upgrade: bool = False
173
+ ) -> None:
174
+ """
175
+ Validate if the image exists, and if not, download it.
176
+ If upgrade is True, will pull the latest version even if image exists locally.
177
+ """
178
+ print(f"Validating image {image_name}:{tag}...")
117
179
 
118
- print("Switching to Linux containers...")
119
- subprocess.run(
120
- ["cmd", "/c", "docker context ls"], check=True, capture_output=True
121
- )
122
- subprocess.run(
123
- ["cmd", "/c", "docker context use default"],
124
- check=True,
125
- capture_output=True,
126
- )
127
- return True
128
- except subprocess.CalledProcessError as e:
129
- print(f"Failed to switch to Linux containers: {e}")
130
- print(f"stdout: {e.stdout}")
131
- print(f"stderr: {e.stderr}")
132
- return False
133
- return True # Non-Windows platforms don't need this
180
+ with get_lock(f"{image_name}-{tag}"):
181
+ try:
182
+ local_image = self.client.images.get(f"{image_name}:{tag}")
183
+ print(f"Image {image_name}:{tag} is already available.")
184
+
185
+ if upgrade:
186
+ remote_image = self.client.images.get_registry_data(
187
+ f"{image_name}:{tag}"
188
+ )
189
+ remote_image_hash = remote_image.id
190
+ remote_image_hash_from_local_image: str | None = None
191
+ try:
192
+ remote_image_hash_from_local_image = DISK_CACHE.get(
193
+ local_image.id
194
+ )
195
+ except KeyboardInterrupt:
196
+ raise
197
+ except Exception:
198
+ remote_image_hash_from_local_image = None
199
+ import traceback
200
+ import warnings
201
+
202
+ stack = traceback.format_exc()
203
+ warnings.warn(
204
+ f"Error getting remote image hash from local image: {stack}"
205
+ )
206
+ if remote_image_hash_from_local_image == remote_image_hash:
207
+ print(f"Local image {image_name}:{tag} is up to date.")
208
+ return
209
+
210
+ # Quick check for latest version
211
+
212
+ print(f"Pulling newer version of {image_name}:{tag}...")
213
+ _ = self.client.images.pull(image_name, tag=tag)
214
+ print(f"Updated to newer version of {image_name}:{tag}")
215
+ local_image_hash = self.client.images.get(f"{image_name}:{tag}").id
216
+ DISK_CACHE.put(local_image_hash, remote_image_hash)
217
+
218
+ except docker.errors.ImageNotFound:
219
+ print(f"Image {image_name}:{tag} not found. Downloading...")
220
+ self.client.images.pull(image_name, tag=tag)
221
+ try:
222
+ local_image = self.client.images.get(f"{image_name}:{tag}")
223
+ local_image_hash = local_image.id
224
+ DISK_CACHE.put(local_image_hash, remote_image_hash)
225
+ print(f"Image {image_name}:{tag} downloaded successfully.")
226
+ except docker.errors.ImageNotFound:
227
+ import warnings
228
+
229
+ warnings.warn(f"Image {image_name}:{tag} not found after download.")
230
+
231
+ def tag_image(self, image_name: str, old_tag: str, new_tag: str) -> None:
232
+ """
233
+ Tag an image with a new tag.
234
+ """
235
+ image: Image = self.client.images.get(f"{image_name}:{old_tag}")
236
+ image.tag(image_name, new_tag)
237
+ print(f"Image {image_name}:{old_tag} tagged as {new_tag}.")
134
238
 
135
- def get_lock(self) -> FileLock:
136
- """Get the file lock for this DockerManager instance."""
137
- return _FILE_LOCK
239
+ def _container_configs_match(
240
+ self,
241
+ container: Container,
242
+ command: str | None,
243
+ volumes: dict | None,
244
+ ports: dict | None,
245
+ ) -> bool:
246
+ """Compare if existing container has matching configuration"""
247
+ try:
248
+ # Check if container is using the same image
249
+ container_image_id = container.image.id
250
+ container_image_tags = container.image.tags
138
251
 
139
- def ensure_image_exists(self, force_update: bool = False) -> bool:
140
- """Check if local image exists, pull from remote if not or if update requested."""
252
+ # Simplified image comparison - just compare the IDs directly
253
+ if not container_image_tags:
254
+ print(f"Container using untagged image with ID: {container_image_id}")
255
+ else:
256
+ current_image = self.client.images.get(container_image_tags[0])
257
+ if container_image_id != current_image.id:
258
+ print(
259
+ f"Container using different image version. Container: {container_image_id}, Current: {current_image.id}"
260
+ )
261
+ return False
141
262
 
142
- try:
143
- if not self.ensure_linux_containers():
263
+ # Check command if specified
264
+ if command and container.attrs["Config"]["Cmd"] != command.split():
265
+ print(
266
+ f"Command mismatch: {container.attrs['Config']['Cmd']} != {command}"
267
+ )
144
268
  return False
145
269
 
146
- image_name = f"{self.container_name}:{TAG}"
147
- remote_image = f"niteris/fastled-wasm:{TAG}"
148
- print("Pulling latest")
149
- cmd_check_newer_image = [
150
- "docker",
151
- "pull",
152
- remote_image,
153
- ]
154
- result = subprocess.run(
155
- cmd_check_newer_image,
156
- text=True,
157
- check=False,
158
- )
159
- if result.returncode != 0:
160
- print("Failed to check for newer image.")
161
- return False
162
- print("Tagging image")
163
-
164
- tag_result = subprocess.run(
165
- [
166
- "docker",
167
- "tag",
168
- remote_image,
169
- image_name,
170
- ],
171
- capture_output=True,
172
- text=True,
173
- check=False,
174
- )
175
- if tag_result.returncode != 0:
176
- print(f"Failed to tag image: {tag_result.stderr}")
177
- return False
178
- return True
179
- except subprocess.CalledProcessError as e:
180
- print(f"Failed to ensure image exists: {e}")
181
- return False
270
+ # Check volumes if specified
271
+ if volumes:
272
+ container_mounts = (
273
+ {
274
+ m["Source"]: {"bind": m["Destination"], "mode": m["Mode"]}
275
+ for m in container.attrs["Mounts"]
276
+ }
277
+ if container.attrs.get("Mounts")
278
+ else {}
279
+ )
182
280
 
183
- def container_exists(self) -> bool:
184
- """Check if a container with the given name exists."""
185
- try:
186
- result = subprocess.run(
187
- ["docker", "container", "inspect", self.container_name],
188
- capture_output=True,
189
- check=False,
190
- )
191
- return result.returncode == 0
192
- except subprocess.CalledProcessError:
193
- return False
281
+ for host_dir, mount in volumes.items():
282
+ if host_dir not in container_mounts:
283
+ print(f"Volume {host_dir} not found in container mounts.")
284
+ return False
285
+ if container_mounts[host_dir] != mount:
286
+ print(
287
+ f"Volume {host_dir} has different mount options: {container_mounts[host_dir]} != {mount}"
288
+ )
289
+ return False
290
+
291
+ # Check ports if specified
292
+ if ports:
293
+ container_ports = (
294
+ container.attrs["Config"]["ExposedPorts"]
295
+ if container.attrs["Config"].get("ExposedPorts")
296
+ else {}
297
+ )
298
+ container_port_bindings = (
299
+ container.attrs["HostConfig"]["PortBindings"]
300
+ if container.attrs["HostConfig"].get("PortBindings")
301
+ else {}
302
+ )
194
303
 
195
- def remove_container(self) -> bool:
196
- """Remove a container if it exists."""
197
- try:
198
- subprocess.run(
199
- ["docker", "rm", "-f", self.container_name],
200
- check=True,
201
- )
202
- return True
203
- except subprocess.CalledProcessError:
304
+ for container_port, host_port in ports.items():
305
+ port_key = f"{container_port}/tcp"
306
+ if port_key not in container_ports:
307
+ print(f"Container port {port_key} not found.")
308
+ return False
309
+ if not container_port_bindings.get(port_key, [{"HostPort": None}])[
310
+ 0
311
+ ]["HostPort"] == str(host_port):
312
+ print(f"Port {host_port} is not bound to {port_key}.")
313
+ return False
314
+ except KeyboardInterrupt:
315
+ raise
316
+ except docker.errors.NotFound:
317
+ print("Container not found.")
204
318
  return False
205
-
206
- def full_container_name(self) -> str:
207
- """Get the name of the container."""
208
- return f"{self.container_name}:{TAG}"
319
+ except Exception as e:
320
+ stack = traceback.format_exc()
321
+ warnings.warn(f"Error checking container config: {e}\n{stack}")
322
+ return False
323
+ return True
209
324
 
210
325
  def run_container(
211
326
  self,
212
- cmd: list[str],
327
+ image_name: str,
328
+ tag: str,
329
+ container_name: str,
330
+ command: str | None = None,
213
331
  volumes: dict[str, dict[str, str]] | None = None,
214
332
  ports: dict[int, int] | None = None,
215
- tty: bool = False,
216
- ) -> subprocess.Popen:
217
- """Run the Docker container with the specified volume.
333
+ ) -> Container:
334
+ """
335
+ Run a container from an image. If it already exists with matching config, start it.
336
+ If it exists with different config, remove and recreate it.
218
337
 
219
338
  Args:
220
- cmd: Command to run in the container
221
339
  volumes: Dict mapping host paths to dicts with 'bind' and 'mode' keys
340
+ Example: {'/host/path': {'bind': '/container/path', 'mode': 'rw'}}
222
341
  ports: Dict mapping host ports to container ports
342
+ Example: {8080: 80} maps host port 8080 to container port 80
223
343
  """
224
- volumes = volumes or {}
225
- ports = ports or {}
226
-
227
- print("Creating new container...")
228
- docker_command = ["docker", "run"]
229
-
230
- if tty:
231
- assert sys.stdout.isatty(), "TTY mode requires a TTY"
232
- docker_command.append("-it")
233
- # Attach volumes if specified
234
- docker_command += [
235
- "--name",
236
- self.container_name,
237
- ]
238
- if ports:
239
- for host_port, container_port in ports.items():
240
- docker_command.extend(["-p", f"{host_port}:{container_port}"])
241
- if volumes:
242
- for host_path, mount_spec in volumes.items():
243
- docker_command.extend(
244
- ["-v", f"{host_path}:{mount_spec['bind']}:{mount_spec['mode']}"]
344
+ image_name = f"{image_name}:{tag}"
345
+ try:
346
+ container: Container = self.client.containers.get(container_name)
347
+
348
+ # Check if configuration matches
349
+ if not self._container_configs_match(container, command, volumes, ports):
350
+ print(
351
+ f"Container {container_name} exists but with different configuration. Removing and recreating..."
245
352
  )
353
+ container.remove(force=True)
354
+ raise docker.errors.NotFound("Container removed due to config mismatch")
355
+ print(f"Container {container_name} found with matching configuration.")
356
+
357
+ # Existing container with matching config - handle various states
358
+ if container.status == "running":
359
+ print(f"Container {container_name} is already running.")
360
+ elif container.status == "exited":
361
+ print(f"Starting existing container {container_name}.")
362
+ container.start()
363
+ elif container.status == "restarting":
364
+ print(f"Waiting for container {container_name} to restart...")
365
+ timeout = 10
366
+ container.wait(timeout=10)
367
+ if container.status == "running":
368
+ print(f"Container {container_name} has restarted.")
369
+ else:
370
+ print(
371
+ f"Container {container_name} did not restart within {timeout} seconds."
372
+ )
373
+ container.stop()
374
+ print(f"Container {container_name} has been stopped.")
375
+ container.start()
376
+ elif container.status == "paused":
377
+ print(f"Resuming existing container {container_name}.")
378
+ container.unpause()
379
+ else:
380
+ print(f"Unknown container status: {container.status}")
381
+ print(f"Starting existing container {container_name}.")
382
+ self.first_run = True
383
+ container.start()
384
+ except docker.errors.NotFound:
385
+ print(f"Creating and starting {container_name}")
386
+ container = self.client.containers.run(
387
+ image_name,
388
+ command,
389
+ name=container_name,
390
+ detach=True,
391
+ tty=True,
392
+ volumes=volumes,
393
+ ports=ports,
394
+ )
395
+ return container
246
396
 
247
- docker_command.extend(
248
- [
249
- f"{self.container_name}:{TAG}",
250
- ]
251
- )
252
- docker_command.extend(cmd)
397
+ def attach_and_run(self, container: Container | str) -> RunningContainer:
398
+ """
399
+ Attach to a running container and monitor its logs in a background thread.
400
+ Returns a RunningContainer object that can be used to stop monitoring.
401
+ """
402
+ if isinstance(container, str):
403
+ container = self.get_container(container)
404
+
405
+ print(f"Attaching to container {container.name}...")
253
406
 
254
- print(f"Running command: {' '.join(docker_command)}")
407
+ first_run = self.first_run
408
+ self.first_run = False
409
+
410
+ return RunningContainer(container, first_run)
411
+
412
+ def suspend_container(self, container: Container | str) -> None:
413
+ """
414
+ Suspend (pause) the container.
415
+ """
416
+ if isinstance(container, str):
417
+ container_name = container
418
+ container = self.get_container(container)
419
+ if not container:
420
+ print(f"Could not put container {container_name} to sleep.")
421
+ return
422
+ try:
423
+ container.pause()
424
+ print(f"Container {container.name} has been suspended.")
425
+ except KeyboardInterrupt:
426
+ print(f"Container {container.name} interrupted by keyboard interrupt.")
427
+ except Exception as e:
428
+ print(f"Failed to suspend container {container.name}: {e}")
429
+
430
+ def resume_container(self, container: Container | str) -> None:
431
+ """
432
+ Resume (unpause) the container.
433
+ """
434
+ if isinstance(container, str):
435
+ container = self.get_container(container)
436
+ try:
437
+ container.unpause()
438
+ print(f"Container {container.name} has been resumed.")
439
+ except Exception as e:
440
+ print(f"Failed to resume container {container.name}: {e}")
255
441
 
256
- process = subprocess.Popen(
257
- docker_command,
258
- text=True,
442
+ def get_container(self, container_name: str) -> Container:
443
+ """
444
+ Get a container by name.
445
+ """
446
+ try:
447
+ return self.client.containers.get(container_name)
448
+ except docker.errors.NotFound:
449
+ print(f"Container {container_name} not found.")
450
+ raise
451
+
452
+ def is_container_running(self, container_name: str) -> bool:
453
+ """
454
+ Check if a container is running.
455
+ """
456
+ try:
457
+ container = self.client.containers.get(container_name)
458
+ return container.status == "running"
459
+ except docker.errors.NotFound:
460
+ print(f"Container {container_name} not found.")
461
+ return False
462
+
463
+
464
+ def main():
465
+ # Register SIGINT handler
466
+ # signal.signal(signal.SIGINT, handle_sigint)
467
+
468
+ docker_manager = DockerManager()
469
+
470
+ # Parameters
471
+ image_name = "python"
472
+ tag = "3.10-slim"
473
+ # new_tag = "my-python"
474
+ container_name = "my-python-container"
475
+ command = "python -m http.server"
476
+
477
+ try:
478
+ # Step 1: Validate or download the image
479
+ docker_manager.validate_or_download_image(image_name, tag, upgrade=True)
480
+
481
+ # Step 2: Tag the image
482
+ # docker_manager.tag_image(image_name, tag, new_tag)
483
+
484
+ # Step 3: Run the container
485
+ container = docker_manager.run_container(
486
+ image_name, tag, container_name, command
259
487
  )
260
488
 
261
- return process
489
+ # Step 4: Attach and monitor the container logs
490
+ running_container = docker_manager.attach_and_run(container)
491
+
492
+ # Wait for keyboard interrupt
493
+ while True:
494
+ time.sleep(0.1)
495
+
496
+ except KeyboardInterrupt:
497
+ print("\nStopping container...")
498
+ running_container.stop()
499
+ container = docker_manager.get_container(container_name)
500
+ docker_manager.suspend_container(container)
501
+
502
+ try:
503
+ # Suspend and resume the container
504
+ container = docker_manager.get_container(container_name)
505
+ docker_manager.suspend_container(container)
506
+
507
+ input("Press Enter to resume the container...")
508
+
509
+ docker_manager.resume_container(container)
510
+ except Exception as e:
511
+ print(f"An error occurred: {e}")
512
+
513
+
514
+ if __name__ == "__main__":
515
+ main()
fastled/web_compile.py CHANGED
@@ -5,7 +5,7 @@ import os
5
5
  import shutil
6
6
  import tempfile
7
7
  import zipfile
8
- from concurrent.futures import Future, ProcessPoolExecutor, as_completed
8
+ from concurrent.futures import Future, ThreadPoolExecutor, as_completed
9
9
  from dataclasses import dataclass
10
10
  from pathlib import Path
11
11
 
@@ -21,7 +21,7 @@ ENDPOINT_COMPILED_WASM = "compile/wasm"
21
21
  _TIMEOUT = 60 * 4 # 2 mins timeout
22
22
  _AUTH_TOKEN = "oBOT5jbsO4ztgrpNsQwlmFLIKB"
23
23
  ENABLE_EMBEDDED_DATA = True
24
- _EXECUTOR = ProcessPoolExecutor(max_workers=8)
24
+ _EXECUTOR = ThreadPoolExecutor(max_workers=8)
25
25
 
26
26
 
27
27
  @dataclass
@@ -66,16 +66,11 @@ def _test_connection(host: str, use_ipv4: bool) -> ConnectionResult:
66
66
  )
67
67
  result = ConnectionResult(host, test_response.status_code == 200, use_ipv4)
68
68
  except KeyboardInterrupt:
69
- # main thraed interrupt
70
-
71
69
  _thread.interrupt_main()
72
70
 
73
71
  except TimeoutError:
74
72
  result = ConnectionResult(host, False, use_ipv4)
75
- except Exception as e:
76
- import warnings
77
-
78
- warnings.warn(f"Connection failed: {e}")
73
+ except Exception:
79
74
  result = ConnectionResult(host, False, use_ipv4)
80
75
  return result
81
76
 
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastled
3
- Version: 1.1.15
3
+ Version: 1.1.17
4
4
  Summary: FastLED Wasm Compiler
5
5
  Home-page: https://github.com/zackees/fastled-wasm
6
6
  Maintainer: Zachary Vorhies
7
7
  License: BSD 3-Clause License
8
8
  Keywords: template-python-cmd
9
9
  Classifier: Programming Language :: Python :: 3
10
- Requires-Python: >=3.7
10
+ Requires-Python: >=3.9
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
13
  Requires-Dist: docker
@@ -16,6 +16,8 @@ Requires-Dist: watchdog
16
16
  Requires-Dist: livereload
17
17
  Requires-Dist: download
18
18
  Requires-Dist: filelock
19
+ Requires-Dist: disklru>=2.0.1
20
+ Requires-Dist: appdirs
19
21
 
20
22
  # FastLED Wasm compiler
21
23
 
@@ -159,6 +161,8 @@ A: A big chunk of space is being used by unnecessary javascript `emscripten` is
159
161
 
160
162
  # Revisions
161
163
 
164
+ * 1.1.17 - Added `--update` and `--no-auto-update` to control whether the compiler in docker mode will try to update.
165
+ * 1.1.16 - Rewrote docker logic to use container suspension and resumption. Much much faster.
162
166
  * 1.1.15 - Fixed logic for considering ipv6 addresses. Auto selection of ipv6 is now restored.
163
167
  * 1.1.14 - Fixes for regression in using --server and --localhost as two instances, this is now under test.
164
168
  * 1.1.13 - Disable the use of ipv6. It produces random timeouts on the onrender server we are using for the web compiler.
@@ -1,20 +1,20 @@
1
- fastled/__init__.py,sha256=2BNTyVWziwf2g_946IKK7hNoB3ufZnDa9odeUM0JvlA,64
2
- fastled/app.py,sha256=7qjfLbqSlLVmkLifHaEkRAXo0ZRiJFiBCDvAxeOJX8Y,15290
1
+ fastled/__init__.py,sha256=eYxLUpPhHXARLnRFwdJgoCDIx4kVkzNiZ_--PzqMApE,64
2
+ fastled/app.py,sha256=xgMl-9s3dy2TurnSusVdIAr3ZbSi5oyEAofHsa7hZho,15695
3
3
  fastled/build_mode.py,sha256=joMwsV4K1y_LijT4gEAcjx69RZBoe_KmFmHZdPYbL_4,631
4
4
  fastled/cli.py,sha256=CNR_pQR0sNVPNuv8e_nmm-0PI8sU-eUBUgnWgWkzW9c,237
5
- fastled/compile_server.py,sha256=baXSd8V3C2tA--oYl8-_TPEvJxtNYck788W-iT-xyi8,8022
6
- fastled/docker_manager.py,sha256=gaDZpFV7E-9JhYIn6ahkP--9dGT32-WR5wZaU-o--6g,9107
5
+ fastled/compile_server.py,sha256=aBdpILSRrDsCJ5e9g5uwIqt9bcqE_8FrSddCV2ygtrI,5401
6
+ fastled/docker_manager.py,sha256=dj6s1mT-ecURqYJH-JpZZWuFHr-dGkQIOuFm845Nz40,20042
7
7
  fastled/filewatcher.py,sha256=fJNMQRDCpihSL4nQeYPqbD4m1Jzjcz_-YRAo-wlPW6k,6518
8
8
  fastled/keyboard.py,sha256=rqndglWYzRy6oiqHgsmx1peLd0Yrpci01zGENlCzh_s,2576
9
9
  fastled/open_browser.py,sha256=RRHcsZ5Vzsw1AuZUEYuSfjKmf_9j3NGMDUR-FndHmqs,1483
10
10
  fastled/paths.py,sha256=VsPmgu0lNSCFOoEC0BsTYzDygXqy15AHUfN-tTuzDZA,99
11
11
  fastled/sketch.py,sha256=KhhPFqlFVlBk8YrzFy7-ioe7zEzecgrVLhyFbLpBp7k,1845
12
12
  fastled/util.py,sha256=t4M3NFMhnCzfYbLvIyJi0RdFssZqbTN_vVIaej1WV-U,265
13
- fastled/web_compile.py,sha256=QXPtSQfWle4JVd3IG-ZHz-LfRsHiYWQRl2yRXH34m4s,10492
13
+ fastled/web_compile.py,sha256=KuvKGdX6SSUUqC7YgX4T9SMSP5wdcPUhpg9-K9zPoTI,10378
14
14
  fastled/assets/example.txt,sha256=lTBovRjiz0_TgtAtbA1C5hNi2ffbqnNPqkKg6UiKCT8,54
15
- fastled-1.1.15.dist-info/LICENSE,sha256=b6pOoifSXiUaz_lDS84vWlG3fr4yUKwB8fzkrH9R8bQ,1064
16
- fastled-1.1.15.dist-info/METADATA,sha256=L7sRuUoYogtP4T95QHkLM23ds3SZTdp2V75R13vlzlE,13223
17
- fastled-1.1.15.dist-info/WHEEL,sha256=0VNUDWQJzfRahYI3neAhz2UVbRCtztpN5dPHAGvmGXc,109
18
- fastled-1.1.15.dist-info/entry_points.txt,sha256=RCwmzCSOS4-C2i9EziANq7Z2Zb4KFnEMR1FQC0bBwAw,101
19
- fastled-1.1.15.dist-info/top_level.txt,sha256=xfG6Z_ol9V5YmBROkZq2QTRwjbS2ouCUxaTJsOwfkOo,14
20
- fastled-1.1.15.dist-info/RECORD,,
15
+ fastled-1.1.17.dist-info/LICENSE,sha256=b6pOoifSXiUaz_lDS84vWlG3fr4yUKwB8fzkrH9R8bQ,1064
16
+ fastled-1.1.17.dist-info/METADATA,sha256=IX_gRsLWHvl9Fk7sbMWTmVoLqRGuDcAW_88tbfLAKuA,13496
17
+ fastled-1.1.17.dist-info/WHEEL,sha256=0VNUDWQJzfRahYI3neAhz2UVbRCtztpN5dPHAGvmGXc,109
18
+ fastled-1.1.17.dist-info/entry_points.txt,sha256=RCwmzCSOS4-C2i9EziANq7Z2Zb4KFnEMR1FQC0bBwAw,101
19
+ fastled-1.1.17.dist-info/top_level.txt,sha256=xfG6Z_ol9V5YmBROkZq2QTRwjbS2ouCUxaTJsOwfkOo,14
20
+ fastled-1.1.17.dist-info/RECORD,,