fastled 1.0.8__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.
@@ -0,0 +1,251 @@
1
+ import socket
2
+ import subprocess
3
+ import threading
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import httpx
9
+
10
+ from fastled.docker_manager import DockerManager
11
+ from fastled.sketch import looks_like_fastled_repo
12
+
13
+ _DEFAULT_CONTAINER_NAME = "fastled-wasm-compiler"
14
+
15
+ SERVER_PORT = 9021
16
+
17
+
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
+ class CompileServer:
32
+ def __init__(
33
+ self,
34
+ container_name=_DEFAULT_CONTAINER_NAME,
35
+ interactive: bool = False,
36
+ ) -> None:
37
+
38
+ cwd = Path(".").resolve()
39
+ fastled_src_dir: Path | None = None
40
+ if looks_like_fastled_repo(cwd):
41
+ print(
42
+ "Looks like a FastLED repo, using it as the source directory and mapping it into the server."
43
+ )
44
+ fastled_src_dir = cwd / "src"
45
+
46
+ self.container_name = container_name
47
+ self.docker = DockerManager(container_name=container_name)
48
+ self.running = False
49
+ self.thread: Optional[threading.Thread] = None
50
+ self.running_process: subprocess.Popen | None = None
51
+ self.fastled_src_dir: Path | None = fastled_src_dir
52
+ self.interactive = interactive
53
+ self._port = self._start()
54
+
55
+ def port(self) -> int:
56
+ return self._port
57
+
58
+ def url(self) -> str:
59
+ return f"http://localhost:{self._port}"
60
+
61
+ def wait_for_startup(self, timeout: int = 100) -> bool:
62
+ """Wait for the server to start up."""
63
+ start_time = time.time()
64
+ while time.time() - start_time < timeout:
65
+ # ping the server to see if it's up
66
+ if not self._port:
67
+ return False
68
+ # use httpx to ping the server
69
+ # if successful, return True
70
+ try:
71
+ response = httpx.get(
72
+ f"http://localhost:{self._port}", follow_redirects=True
73
+ )
74
+ if response.status_code < 400:
75
+ return True
76
+ except KeyboardInterrupt:
77
+ raise
78
+ except Exception:
79
+ pass
80
+ time.sleep(0.1)
81
+ if not self.running:
82
+ return False
83
+ return False
84
+
85
+ def _start(self) -> int:
86
+ print("Compiling server starting")
87
+ self.running = True
88
+ # Ensure Docker is running
89
+ with self.docker.get_lock():
90
+ if not self.docker.is_running():
91
+ if not self.docker.start():
92
+ print("Docker could not be started. Exiting.")
93
+ raise RuntimeError("Docker could not be started. Exiting.")
94
+
95
+ # Clean up any existing container with the same name
96
+
97
+ try:
98
+ container_exists = (
99
+ subprocess.run(
100
+ ["docker", "inspect", self.container_name],
101
+ capture_output=True,
102
+ text=True,
103
+ ).returncode
104
+ == 0
105
+ )
106
+ if container_exists:
107
+ print("Cleaning up existing container")
108
+ subprocess.run(
109
+ ["docker", "rm", "-f", self.container_name],
110
+ check=False,
111
+ )
112
+ except KeyboardInterrupt:
113
+ raise
114
+ except Exception as e:
115
+ print(f"Warning: Failed to remove existing container: {e}")
116
+
117
+ print("Ensuring Docker image exists at latest version")
118
+ if not self.docker.ensure_image_exists():
119
+ print("Failed to ensure Docker image exists.")
120
+ raise RuntimeError("Failed to ensure Docker image exists")
121
+
122
+ print("Docker image now validated")
123
+
124
+ # Remove the image to force a fresh download
125
+ # subprocess.run(["docker", "rmi", "fastled-wasm"], capture_output=True)
126
+ # print("All clean")
127
+
128
+ port = find_available_port()
129
+ print(f"Found an available port: {port}")
130
+ # server_command = ["python", "/js/run.py", "server", "--allow-shutdown"]
131
+ if self.interactive:
132
+ server_command = ["/bin/bash"]
133
+ else:
134
+ server_command = ["python", "/js/run.py", "server"]
135
+ print(f"Started Docker container with command: {server_command}")
136
+ ports = {port: 80}
137
+ volumes = None
138
+ if self.fastled_src_dir:
139
+ print(
140
+ f"Mounting FastLED source directory {self.fastled_src_dir} into container /host/fastled/src"
141
+ )
142
+ volumes = {
143
+ str(self.fastled_src_dir): {"bind": "/host/fastled/src", "mode": "ro"}
144
+ }
145
+ if not self.interactive:
146
+ # no auto-update because the source directory is mapped in.
147
+ # This should be automatic now.
148
+ server_command.append("--no-auto-update") # stop git repo updates.
149
+ self.running_process = self.docker.run_container(
150
+ server_command, ports=ports, volumes=volumes
151
+ )
152
+ print("Compile server starting")
153
+ time.sleep(3)
154
+ if self.running_process.poll() is not None:
155
+ print("Server failed to start")
156
+ self.running = False
157
+ raise RuntimeError("Server failed to start")
158
+ self.thread = threading.Thread(target=self._server_loop, daemon=True)
159
+ self.thread.start()
160
+
161
+ return port
162
+
163
+ def proceess_running(self) -> bool:
164
+ if self.running_process is None:
165
+ return False
166
+ return self.running_process.poll() is None
167
+
168
+ def stop(self) -> None:
169
+ print(f"Stopping server on port {self._port}")
170
+ # attempt to send a shutdown signal to the server
171
+ try:
172
+ httpx.get(f"http://localhost:{self._port}/shutdown", timeout=2)
173
+ # except Exception:
174
+ except Exception as e:
175
+ print(f"Failed to send shutdown signal: {e}")
176
+ pass
177
+ try:
178
+ # Stop the Docker container
179
+ cp: subprocess.CompletedProcess
180
+ cp = subprocess.run(
181
+ ["docker", "stop", self.container_name],
182
+ capture_output=True,
183
+ text=True,
184
+ check=True,
185
+ )
186
+ if cp.returncode != 0:
187
+ print(f"Failed to stop Docker container: {cp.stderr}")
188
+
189
+ cp = subprocess.run(
190
+ ["docker", "rm", self.container_name],
191
+ capture_output=True,
192
+ text=True,
193
+ check=True,
194
+ )
195
+ if cp.returncode != 0:
196
+ print(f"Failed to remove Docker container: {cp.stderr}")
197
+
198
+ # Close the stdout pipe
199
+ if self.running_process and self.running_process.stdout:
200
+ self.running_process.stdout.close()
201
+
202
+ # Wait for the process to fully terminate with a timeout
203
+ self.running_process.wait(timeout=10)
204
+ if self.running_process.returncode is None:
205
+ # kill
206
+ self.running_process.kill()
207
+ if self.running_process.returncode is not None:
208
+ print(
209
+ f"Server stopped with return code {self.running_process.returncode}"
210
+ )
211
+
212
+ except subprocess.TimeoutExpired:
213
+ # Force kill if it doesn't stop gracefully
214
+ if self.running_process:
215
+ self.running_process.kill()
216
+ self.running_process.wait()
217
+ except KeyboardInterrupt:
218
+ if self.running_process:
219
+ self.running_process.kill()
220
+ self.running_process.wait()
221
+ except Exception as e:
222
+ print(f"Error stopping Docker container: {e}")
223
+ finally:
224
+ self.running_process = None
225
+ # Signal the server thread to stop
226
+ self.running = False
227
+ if self.thread:
228
+ self.thread.join(timeout=10) # Wait up to 10 seconds for thread to finish
229
+ if self.thread.is_alive():
230
+ print("Warning: Server thread did not terminate properly")
231
+
232
+ print("Compile server stopped")
233
+
234
+ def _server_loop(self) -> None:
235
+ try:
236
+ while self.running:
237
+ if self.running_process:
238
+ # Read Docker container output
239
+ # Check if Docker process is still running
240
+ if self.running_process.poll() is not None:
241
+ print("Docker server stopped unexpectedly")
242
+ self.running = False
243
+ break
244
+
245
+ time.sleep(0.1) # Prevent busy waiting
246
+ except KeyboardInterrupt:
247
+ print("Server thread stopped by user.")
248
+ except Exception as e:
249
+ print(f"Error in server thread: {e}")
250
+ finally:
251
+ self.running = False
@@ -0,0 +1,259 @@
1
+ """Docker management functionality for FastLED WASM compiler."""
2
+
3
+ import subprocess
4
+ import sys
5
+ import time
6
+ from pathlib import Path
7
+
8
+ import docker # type: ignore
9
+ from filelock import FileLock
10
+
11
+ TAG = "main"
12
+
13
+ _HERE = Path(__file__).parent
14
+ _FILE_LOCK = FileLock(str(_HERE / "fled.lock"))
15
+
16
+
17
+ def _win32_docker_location() -> str | None:
18
+ home_dir = Path.home()
19
+ out = [
20
+ "C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe",
21
+ f"{home_dir}\\AppData\\Local\\Docker\\Docker Desktop.exe",
22
+ ]
23
+ for loc in out:
24
+ if Path(loc).exists():
25
+ return loc
26
+ return None
27
+
28
+
29
+ class DockerManager:
30
+ """Manages Docker operations for FastLED WASM compiler."""
31
+
32
+ def __init__(self, container_name: str):
33
+ self.container_name = container_name
34
+
35
+ @staticmethod
36
+ def is_docker_installed() -> bool:
37
+ """Check if Docker is installed on the system."""
38
+ try:
39
+ subprocess.run(["docker", "--version"], capture_output=True, check=True)
40
+ print("Docker is installed.")
41
+ return True
42
+ except subprocess.CalledProcessError as e:
43
+ print(f"Docker command failed: {str(e)}")
44
+ return False
45
+ except FileNotFoundError:
46
+ print("Docker is not installed.")
47
+ return False
48
+
49
+ def is_running(self) -> bool:
50
+ """Check if Docker is running by pinging the Docker daemon."""
51
+ try:
52
+ client = docker.from_env()
53
+ client.ping()
54
+ print("Docker is running.")
55
+ return True
56
+ except docker.errors.DockerException as e:
57
+ print(f"Docker is not running: {str(e)}")
58
+ return False
59
+
60
+ def start(self) -> bool:
61
+ """Attempt to start Docker Desktop (or the Docker daemon) automatically."""
62
+ print("Attempting to start Docker...")
63
+
64
+ try:
65
+ if sys.platform == "win32":
66
+ docker_path = _win32_docker_location()
67
+ if not docker_path:
68
+ print("Docker Desktop not found.")
69
+ return False
70
+ subprocess.run(["start", "", docker_path], shell=True)
71
+ elif sys.platform == "darwin":
72
+ subprocess.run(["open", "/Applications/Docker.app"])
73
+ elif sys.platform.startswith("linux"):
74
+ subprocess.run(["sudo", "systemctl", "start", "docker"])
75
+ else:
76
+ print("Unknown platform. Cannot auto-launch Docker.")
77
+ return False
78
+
79
+ # Wait for Docker to start up with increasing delays
80
+ print("Waiting for Docker Desktop to start...")
81
+ attempts = 0
82
+ max_attempts = 20 # Increased max wait time
83
+ while attempts < max_attempts:
84
+ attempts += 1
85
+ if self.is_running():
86
+ print("Docker started successfully.")
87
+ return True
88
+
89
+ # Gradually increase wait time between checks
90
+ wait_time = min(5, 1 + attempts * 0.5)
91
+ print(
92
+ f"Docker not ready yet, waiting {wait_time:.1f}s... (attempt {attempts}/{max_attempts})"
93
+ )
94
+ time.sleep(wait_time)
95
+
96
+ print("Failed to start Docker within the expected time.")
97
+ print(
98
+ "Please try starting Docker Desktop manually and run this command again."
99
+ )
100
+ except KeyboardInterrupt:
101
+ print("Aborted by user.")
102
+ raise
103
+ except Exception as e:
104
+ print(f"Error starting Docker: {str(e)}")
105
+ return False
106
+
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
117
+
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
134
+
135
+ def get_lock(self) -> FileLock:
136
+ """Get the file lock for this DockerManager instance."""
137
+ return _FILE_LOCK
138
+
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."""
141
+
142
+ try:
143
+ if not self.ensure_linux_containers():
144
+ return False
145
+
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
182
+
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
194
+
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:
204
+ return False
205
+
206
+ def full_container_name(self) -> str:
207
+ """Get the name of the container."""
208
+ return f"{self.container_name}:{TAG}"
209
+
210
+ def run_container(
211
+ self,
212
+ cmd: list[str],
213
+ volumes: dict[str, dict[str, str]] | None = None,
214
+ ports: dict[int, int] | None = None,
215
+ ) -> subprocess.Popen:
216
+ """Run the Docker container with the specified volume.
217
+
218
+ Args:
219
+ cmd: Command to run in the container
220
+ volumes: Dict mapping host paths to dicts with 'bind' and 'mode' keys
221
+ ports: Dict mapping host ports to container ports
222
+ """
223
+ volumes = volumes or {}
224
+ ports = ports or {}
225
+
226
+ print("Creating new container...")
227
+ docker_command = ["docker", "run"]
228
+
229
+ if sys.stdout.isatty():
230
+ docker_command.append("-it")
231
+ # Attach volumes if specified
232
+ docker_command += [
233
+ "--name",
234
+ self.container_name,
235
+ ]
236
+ if ports:
237
+ for host_port, container_port in ports.items():
238
+ docker_command.extend(["-p", f"{host_port}:{container_port}"])
239
+ if volumes:
240
+ for host_path, mount_spec in volumes.items():
241
+ docker_command.extend(
242
+ ["-v", f"{host_path}:{mount_spec['bind']}:{mount_spec['mode']}"]
243
+ )
244
+
245
+ docker_command.extend(
246
+ [
247
+ f"{self.container_name}:{TAG}",
248
+ ]
249
+ )
250
+ docker_command.extend(cmd)
251
+
252
+ print(f"Running command: {' '.join(docker_command)}")
253
+
254
+ process = subprocess.Popen(
255
+ docker_command,
256
+ text=True,
257
+ )
258
+
259
+ return process
fastled/filewatcher.py ADDED
@@ -0,0 +1,146 @@
1
+ """File system watcher implementation using watchdog
2
+ """
3
+
4
+ import hashlib
5
+ import queue
6
+ import threading
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Dict, Set
10
+
11
+ from watchdog.events import FileSystemEvent, FileSystemEventHandler
12
+ from watchdog.observers import Observer
13
+ from watchdog.observers.api import BaseObserver
14
+
15
+
16
+ class MyEventHandler(FileSystemEventHandler):
17
+ def __init__(
18
+ self,
19
+ change_queue: queue.Queue,
20
+ excluded_patterns: Set[str],
21
+ file_hashes: Dict[str, str],
22
+ ) -> None:
23
+ super().__init__()
24
+ self.change_queue = change_queue
25
+ self.excluded_patterns = excluded_patterns
26
+ self.file_hashes = file_hashes
27
+
28
+ def _get_file_hash(self, filepath: str) -> str:
29
+ try:
30
+ with open(filepath, "rb") as f:
31
+ return hashlib.md5(f.read()).hexdigest()
32
+ except Exception: # pylint: disable=broad-except
33
+ return ""
34
+
35
+ def on_modified(self, event: FileSystemEvent) -> None:
36
+ if not event.is_directory:
37
+ path = Path(event.src_path)
38
+ # Check if any part of the path matches excluded patterns
39
+ if not any(part in self.excluded_patterns for part in path.parts):
40
+ new_hash = self._get_file_hash(event.src_path)
41
+ if new_hash and new_hash != self.file_hashes.get(event.src_path):
42
+ self.file_hashes[event.src_path] = new_hash
43
+ self.change_queue.put(event.src_path)
44
+
45
+
46
+ class FileChangedNotifier(threading.Thread):
47
+ """Watches a directory for file changes and queues notifications"""
48
+
49
+ def __init__(
50
+ self,
51
+ path: str,
52
+ debounce_seconds: float = 1.0,
53
+ excluded_patterns: list[str] | None = None,
54
+ ) -> None:
55
+ """Initialize the notifier with a path to watch
56
+
57
+ Args:
58
+ path: Directory path to watch for changes
59
+ debounce_seconds: Minimum time between notifications for the same file
60
+ excluded_patterns: List of directory/file patterns to exclude from watching
61
+ """
62
+ super().__init__(daemon=True)
63
+ self.path = path
64
+ self.observer: BaseObserver | None = None
65
+ self.event_handler: MyEventHandler | None = None
66
+
67
+ # Combine default and user-provided patterns
68
+ self.excluded_patterns = (
69
+ set(excluded_patterns) if excluded_patterns is not None else set()
70
+ )
71
+ self.stopped = False
72
+ self.change_queue: queue.Queue = queue.Queue()
73
+ self.last_notification: Dict[str, float] = {}
74
+ self.file_hashes: Dict[str, str] = {}
75
+ self.debounce_seconds = debounce_seconds
76
+
77
+ def stop(self) -> None:
78
+ """Stop watching for changes"""
79
+ print("watcher stop")
80
+ self.stopped = True
81
+ if self.observer:
82
+ self.observer.stop()
83
+ self.observer.join()
84
+ self.observer = None
85
+ self.event_handler = None
86
+
87
+ def run(self) -> None:
88
+ """Thread main loop - starts watching for changes"""
89
+ self.event_handler = MyEventHandler(
90
+ self.change_queue, self.excluded_patterns, self.file_hashes
91
+ )
92
+ self.observer = Observer()
93
+ self.observer.schedule(self.event_handler, self.path, recursive=True)
94
+ self.observer.start()
95
+
96
+ try:
97
+ while not self.stopped:
98
+ time.sleep(0.1)
99
+ except KeyboardInterrupt:
100
+ print("File watcher stopped by user.")
101
+ finally:
102
+ self.stop()
103
+
104
+ def get_next_change(self, timeout: float = 0.001) -> str | None:
105
+ """Get the next file change event from the queue
106
+
107
+ Args:
108
+ timeout: How long to wait for next change in seconds
109
+
110
+ Returns:
111
+ Changed filepath or None if no change within timeout
112
+ """
113
+ try:
114
+ filepath = self.change_queue.get(timeout=timeout)
115
+ current_time = time.time()
116
+
117
+ # Check if we've seen this file recently
118
+ last_time = self.last_notification.get(filepath, 0)
119
+ if current_time - last_time < self.debounce_seconds:
120
+ return None
121
+
122
+ self.last_notification[filepath] = current_time
123
+ return filepath
124
+ except KeyboardInterrupt:
125
+ raise
126
+ except queue.Empty:
127
+ return None
128
+
129
+ def get_all_changes(self, timeout: float = 0.001) -> list[str]:
130
+ """Get all file change events from the queue
131
+
132
+ Args:
133
+ timeout: How long to wait for next change in seconds
134
+
135
+ Returns:
136
+ List of changed filepaths
137
+ """
138
+ changed_files = []
139
+ while True:
140
+ changed_file = self.get_next_change(timeout=timeout)
141
+ if changed_file is None:
142
+ break
143
+ changed_files.append(changed_file)
144
+ # clear all the changes from the queue
145
+ self.change_queue.queue.clear()
146
+ return changed_files
@@ -0,0 +1,48 @@
1
+ import socket
2
+ import subprocess
3
+ from pathlib import Path
4
+
5
+ DEFAULT_PORT = 8081
6
+
7
+
8
+ def _open_browser_python(fastled_js: Path, port: int) -> subprocess.Popen:
9
+ """Start livereload server in the fastled_js directory using CLI"""
10
+ print(f"\nStarting livereload server in {fastled_js} on port {port}")
11
+
12
+ # Construct command for livereload CLI
13
+ cmd = [
14
+ "livereload",
15
+ str(fastled_js), # directory to serve
16
+ "--port",
17
+ str(port),
18
+ "-t",
19
+ str(fastled_js / "index.html"), # file to watch
20
+ "-w",
21
+ "0.1", # delay
22
+ "-o",
23
+ "0.5", # open browser delay
24
+ ]
25
+
26
+ # Start the process
27
+ process = subprocess.Popen(cmd)
28
+ return process
29
+
30
+
31
+ def _find_open_port(start_port: int) -> int:
32
+ """Find an open port starting from start_port."""
33
+ port = start_port
34
+ while True:
35
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
36
+ if sock.connect_ex(("localhost", port)) != 0:
37
+ return port
38
+ port += 1
39
+
40
+
41
+ def open_browser_thread(fastled_js: Path, port: int | None = None) -> subprocess.Popen:
42
+ """Start livereload server in the fastled_js directory and return the started thread"""
43
+ if port is None:
44
+ port = DEFAULT_PORT
45
+
46
+ port = _find_open_port(port)
47
+
48
+ return _open_browser_python(fastled_js, port)