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.
- fastled/__init__.py +3 -0
- fastled/app.py +376 -0
- fastled/assets/example.txt +1 -0
- fastled/build_mode.py +25 -0
- fastled/check_cpp_syntax.py +34 -0
- fastled/cli.py +16 -0
- fastled/compile_server.py +251 -0
- fastled/docker_manager.py +259 -0
- fastled/filewatcher.py +146 -0
- fastled/open_browser.py +48 -0
- fastled/paths.py +4 -0
- fastled/sketch.py +55 -0
- fastled/web_compile.py +227 -0
- fastled-1.0.8.dist-info/LICENSE +21 -0
- fastled-1.0.8.dist-info/METADATA +121 -0
- fastled-1.0.8.dist-info/RECORD +19 -0
- fastled-1.0.8.dist-info/WHEEL +6 -0
- fastled-1.0.8.dist-info/entry_points.txt +4 -0
- fastled-1.0.8.dist-info/top_level.txt +2 -0
@@ -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
|
fastled/open_browser.py
ADDED
@@ -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)
|