fastled 1.2.23__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/filewatcher.py ADDED
@@ -0,0 +1,202 @@
1
+ """File system watcher implementation using watchdog
2
+ """
3
+
4
+ import hashlib
5
+ import os
6
+ import queue
7
+ import threading
8
+ import time
9
+ from contextlib import redirect_stdout
10
+ from multiprocessing import Process, Queue
11
+ from pathlib import Path
12
+ from queue import Empty
13
+ from typing import Dict, Set
14
+
15
+ from watchdog.events import FileSystemEvent, FileSystemEventHandler
16
+ from watchdog.observers import Observer
17
+ from watchdog.observers.api import BaseObserver
18
+
19
+
20
+ class MyEventHandler(FileSystemEventHandler):
21
+ def __init__(
22
+ self,
23
+ change_queue: queue.Queue,
24
+ excluded_patterns: Set[str],
25
+ file_hashes: Dict[str, str],
26
+ ) -> None:
27
+ super().__init__()
28
+ self.change_queue = change_queue
29
+ self.excluded_patterns = excluded_patterns
30
+ self.file_hashes = file_hashes
31
+
32
+ def _get_file_hash(self, filepath: str) -> str:
33
+ try:
34
+ with open(filepath, "rb") as f:
35
+ return hashlib.md5(f.read()).hexdigest()
36
+ except Exception: # pylint: disable=broad-except
37
+ return ""
38
+
39
+ def on_modified(self, event: FileSystemEvent) -> None:
40
+ if not event.is_directory:
41
+ # Convert src_path to str if it's bytes
42
+ src_path = (
43
+ event.src_path.decode()
44
+ if isinstance(event.src_path, bytes)
45
+ else event.src_path
46
+ )
47
+ path = Path(src_path)
48
+ # Check if any part of the path matches excluded patterns
49
+ if not any(part in self.excluded_patterns for part in path.parts):
50
+ new_hash = self._get_file_hash(src_path)
51
+ if new_hash and new_hash != self.file_hashes.get(src_path):
52
+ self.file_hashes[src_path] = new_hash
53
+ self.change_queue.put(src_path)
54
+
55
+
56
+ class FileChangedNotifier(threading.Thread):
57
+ """Watches a directory for file changes and queues notifications"""
58
+
59
+ def __init__(
60
+ self,
61
+ path: str,
62
+ debounce_seconds: float = 1.0,
63
+ excluded_patterns: list[str] | None = None,
64
+ ) -> None:
65
+ """Initialize the notifier with a path to watch
66
+
67
+ Args:
68
+ path: Directory path to watch for changes
69
+ debounce_seconds: Minimum time between notifications for the same file
70
+ excluded_patterns: List of directory/file patterns to exclude from watching
71
+ """
72
+ super().__init__(daemon=True)
73
+ self.path = path
74
+ self.observer: BaseObserver | None = None
75
+ self.event_handler: MyEventHandler | None = None
76
+
77
+ # Combine default and user-provided patterns
78
+ self.excluded_patterns = (
79
+ set(excluded_patterns) if excluded_patterns is not None else set()
80
+ )
81
+ self.stopped = False
82
+ self.change_queue: queue.Queue = queue.Queue()
83
+ self.last_notification: Dict[str, float] = {}
84
+ self.file_hashes: Dict[str, str] = {}
85
+ self.debounce_seconds = debounce_seconds
86
+
87
+ def stop(self) -> None:
88
+ """Stop watching for changes"""
89
+ print("watcher stop")
90
+ self.stopped = True
91
+ if self.observer:
92
+ self.observer.stop()
93
+ self.observer.join()
94
+ self.observer = None
95
+ self.event_handler = None
96
+
97
+ def run(self) -> None:
98
+ """Thread main loop - starts watching for changes"""
99
+ self.event_handler = MyEventHandler(
100
+ self.change_queue, self.excluded_patterns, self.file_hashes
101
+ )
102
+ self.observer = Observer()
103
+ self.observer.schedule(self.event_handler, self.path, recursive=True)
104
+ self.observer.start()
105
+
106
+ try:
107
+ while not self.stopped:
108
+ time.sleep(0.1)
109
+ except KeyboardInterrupt:
110
+ print("File watcher stopped by user.")
111
+ finally:
112
+ self.stop()
113
+
114
+ def get_next_change(self, timeout: float = 0.001) -> str | None:
115
+ """Get the next file change event from the queue
116
+
117
+ Args:
118
+ timeout: How long to wait for next change in seconds
119
+
120
+ Returns:
121
+ Changed filepath or None if no change within timeout
122
+ """
123
+ try:
124
+ filepath = self.change_queue.get(timeout=timeout)
125
+ current_time = time.time()
126
+
127
+ # Check if we've seen this file recently
128
+ last_time = self.last_notification.get(filepath, 0)
129
+ if current_time - last_time < self.debounce_seconds:
130
+ return None
131
+
132
+ self.last_notification[filepath] = current_time
133
+ return filepath
134
+ except KeyboardInterrupt:
135
+ raise
136
+ except queue.Empty:
137
+ return None
138
+
139
+ def get_all_changes(self, timeout: float = 0.001) -> list[str]:
140
+ """Get all file change events from the queue
141
+
142
+ Args:
143
+ timeout: How long to wait for next change in seconds
144
+
145
+ Returns:
146
+ List of changed filepaths
147
+ """
148
+ changed_files = []
149
+ while True:
150
+ changed_file = self.get_next_change(timeout=timeout)
151
+ if changed_file is None:
152
+ break
153
+ changed_files.append(changed_file)
154
+ # clear all the changes from the queue
155
+ self.change_queue.queue.clear()
156
+ return changed_files
157
+
158
+
159
+ def _process_wrapper(root: Path, excluded_patterns: list[str], queue: Queue):
160
+ with open(os.devnull, "w") as fnull: # Redirect to /dev/null
161
+ with redirect_stdout(fnull):
162
+ watcher = FileChangedNotifier(
163
+ str(root), excluded_patterns=excluded_patterns
164
+ )
165
+ watcher.start()
166
+ while True:
167
+ try:
168
+ changed_files = watcher.get_all_changes()
169
+ for file in changed_files:
170
+ queue.put(file)
171
+ except KeyboardInterrupt:
172
+ break
173
+ watcher.stop()
174
+
175
+
176
+ class FileWatcherProcess:
177
+ def __init__(self, root: Path, excluded_patterns: list[str]) -> None:
178
+ self.queue: Queue = Queue()
179
+ self.process = Process(
180
+ target=_process_wrapper,
181
+ args=(root, excluded_patterns, self.queue),
182
+ daemon=True,
183
+ )
184
+ self.process.start()
185
+
186
+ def stop(self):
187
+ self.process.terminate()
188
+ self.process.join()
189
+ self.queue.close()
190
+ self.queue.join_thread()
191
+
192
+ def get_all_changes(self, timeout: float | None = None) -> list[str]:
193
+ changed_files = []
194
+ block = timeout is not None
195
+
196
+ while True:
197
+ try:
198
+ changed_file = self.queue.get(block=block, timeout=timeout)
199
+ changed_files.append(changed_file)
200
+ except Empty:
201
+ break
202
+ return changed_files
fastled/keyboard.py ADDED
@@ -0,0 +1,116 @@
1
+ import _thread
2
+ import os
3
+ import select
4
+ import sys
5
+ import time
6
+ from queue import Empty, Queue
7
+ from threading import Thread
8
+
9
+ _WHITE_SPACE = {" ", "\n", "\r"} # Including Enter key as whitespace
10
+
11
+
12
+ class SpaceBarWatcher:
13
+ @classmethod
14
+ def watch_space_bar_pressed(cls, timeout: float = 0) -> bool:
15
+ watcher = cls()
16
+ try:
17
+ start_time = time.time()
18
+ while True:
19
+ if watcher.space_bar_pressed():
20
+ return True
21
+ if time.time() - start_time > timeout:
22
+ return False
23
+ finally:
24
+ watcher.stop()
25
+
26
+ def __init__(self) -> None:
27
+ self.queue: Queue = Queue()
28
+ self.queue_cancel: Queue = Queue()
29
+ self.process = Thread(target=self._watch_for_space, daemon=True)
30
+ self.process.start()
31
+
32
+ def _watch_for_space(self) -> None:
33
+ fd = sys.stdin.fileno()
34
+ if os.name == "nt": # Windows
35
+ import msvcrt
36
+
37
+ while True:
38
+ # Check for cancel signal
39
+ try:
40
+ self.queue_cancel.get(timeout=0.1)
41
+ break
42
+ except Empty:
43
+ pass
44
+
45
+ # Check if there's input ready
46
+ if msvcrt.kbhit(): # type: ignore
47
+ char = msvcrt.getch().decode() # type: ignore
48
+ if char in _WHITE_SPACE:
49
+ self.queue.put(ord(" "))
50
+ else: # Unix-like systems
51
+ import termios
52
+ import tty
53
+
54
+ old_settings = termios.tcgetattr(fd) # type: ignore
55
+ try:
56
+ tty.setcbreak( # type: ignore
57
+ fd
58
+ ) # Use cbreak mode to avoid console issues # type: ignore
59
+ while True:
60
+ # Check for cancel signal
61
+ try:
62
+ self.queue_cancel.get(timeout=0.1)
63
+ break
64
+ except Empty:
65
+ pass
66
+
67
+ # Check if there's input ready
68
+ if select.select([sys.stdin], [], [], 0.1)[0]:
69
+ char = sys.stdin.read(1)
70
+ if ord(char) == 3: # Ctrl+C
71
+ _thread.interrupt_main()
72
+ break
73
+ if char in _WHITE_SPACE:
74
+ self.queue.put(ord(" "))
75
+ finally:
76
+ termios.tcsetattr( # type: ignore
77
+ fd, termios.TCSADRAIN, old_settings # type: ignore
78
+ ) # Restore terminal settings
79
+
80
+ def space_bar_pressed(self) -> bool:
81
+ found = False
82
+ while not self.queue.empty():
83
+ try:
84
+ key = self.queue.get(block=False)
85
+ if key == ord(" "): # Spacebar
86
+ found = True
87
+ self.queue.task_done()
88
+ except Empty:
89
+ break
90
+ return found
91
+
92
+ def stop(self) -> None:
93
+ self.queue_cancel.put(True)
94
+ self.process.join()
95
+
96
+
97
+ def main() -> None:
98
+ watcher = SpaceBarWatcher()
99
+ try:
100
+ while True:
101
+ if watcher.space_bar_pressed():
102
+ print("Space bar hit!")
103
+ break
104
+ time.sleep(1)
105
+ finally:
106
+ print("Stopping watcher.")
107
+ watcher.stop()
108
+ print("Watcher stopped.")
109
+ return
110
+
111
+
112
+ if __name__ == "__main__":
113
+ try:
114
+ main()
115
+ except KeyboardInterrupt:
116
+ print("Keyboard interrupt detected.")
fastled/live_client.py ADDED
@@ -0,0 +1,86 @@
1
+ import threading
2
+ from pathlib import Path
3
+
4
+ from fastled.compile_server import CompileServer
5
+ from fastled.types import BuildMode
6
+
7
+
8
+ class LiveClient:
9
+ """LiveClient class watches for changes and auto-triggeres rebuild."""
10
+
11
+ def __init__(
12
+ self,
13
+ sketch_directory: Path,
14
+ host: str | CompileServer | None = None,
15
+ auto_start: bool = True,
16
+ open_web_browser: bool = True,
17
+ keep_running: bool = True,
18
+ build_mode: BuildMode = BuildMode.QUICK,
19
+ profile: bool = False,
20
+ ) -> None:
21
+ self.sketch_directory = sketch_directory
22
+ self.host = host
23
+ self.open_web_browser = open_web_browser
24
+ self.keep_running = keep_running
25
+ self.build_mode = build_mode
26
+ self.profile = profile
27
+ self.auto_start = auto_start
28
+ self.shutdown = threading.Event()
29
+ self.thread: threading.Thread | None = None
30
+ if auto_start:
31
+ self.start()
32
+
33
+ def run(self) -> int:
34
+ """Run the client."""
35
+ from fastled.client_server import run_client # avoid circular import
36
+
37
+ rtn = run_client(
38
+ directory=self.sketch_directory,
39
+ host=self.host,
40
+ open_web_browser=self.open_web_browser,
41
+ keep_running=self.keep_running,
42
+ build_mode=self.build_mode,
43
+ profile=self.profile,
44
+ shutdown=self.shutdown,
45
+ )
46
+ return rtn
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
+
59
+ @property
60
+ def running(self) -> bool:
61
+ return self.thread is not None and self.thread.is_alive()
62
+
63
+ def start(self) -> None:
64
+ """Start the client."""
65
+ assert not self.running, "LiveClient is already running"
66
+ self.shutdown.clear()
67
+ self.thread = threading.Thread(target=self.run, daemon=True)
68
+ self.thread.start()
69
+
70
+ def stop(self) -> None:
71
+ """Stop the client."""
72
+ self.shutdown.set()
73
+ if self.thread:
74
+ self.thread.join()
75
+ self.thread = None
76
+
77
+ def finalize(self) -> None:
78
+ """Finalize the client."""
79
+ self.stop()
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()
@@ -0,0 +1,161 @@
1
+ import subprocess
2
+ import sys
3
+ import time
4
+ import webbrowser
5
+ from multiprocessing import Process
6
+ from pathlib import Path
7
+
8
+ DEFAULT_PORT = 8089 # different than live version.
9
+
10
+ PYTHON_EXE = sys.executable
11
+
12
+
13
+ def open_http_server_subprocess(
14
+ fastled_js: Path, port: int, open_browser: bool
15
+ ) -> None:
16
+ """Start livereload server in the fastled_js directory and return the process"""
17
+ import shutil
18
+
19
+ try:
20
+ if shutil.which("live-server") is not None:
21
+ cmd = [
22
+ "live-server",
23
+ f"--port={port}",
24
+ "--host=localhost",
25
+ ".",
26
+ ]
27
+ if not open_browser:
28
+ cmd.append("--no-browser")
29
+ subprocess.run(cmd, shell=True, cwd=fastled_js)
30
+ return
31
+
32
+ cmd = [
33
+ PYTHON_EXE,
34
+ "-m",
35
+ "fastled.open_browser2",
36
+ str(fastled_js),
37
+ "--port",
38
+ str(port),
39
+ ]
40
+ # return subprocess.Popen(cmd) # type ignore
41
+ # pipe stderr and stdout to null
42
+ subprocess.run(
43
+ cmd,
44
+ stdout=subprocess.DEVNULL,
45
+ stderr=subprocess.DEVNULL,
46
+ ) # type ignore
47
+ except KeyboardInterrupt:
48
+ import _thread
49
+
50
+ _thread.interrupt_main()
51
+
52
+
53
+ def is_port_free(port: int) -> bool:
54
+ """Check if a port is free"""
55
+ import httpx
56
+
57
+ try:
58
+ response = httpx.get(f"http://localhost:{port}", timeout=1)
59
+ response.raise_for_status()
60
+ return False
61
+ except (httpx.HTTPError, httpx.ConnectError):
62
+ return True
63
+
64
+
65
+ def find_free_port(start_port: int) -> int:
66
+ """Find a free port starting at start_port"""
67
+ for port in range(start_port, start_port + 100):
68
+ if is_port_free(port):
69
+ print(f"Found free port: {port}")
70
+ return port
71
+ else:
72
+ print(f"Port {port} is in use, finding next")
73
+ raise ValueError("Could not find a free port")
74
+
75
+
76
+ def wait_for_server(port: int, timeout: int = 10) -> None:
77
+ """Wait for the server to start."""
78
+ from httpx import get
79
+
80
+ future_time = time.time() + timeout
81
+ while future_time > time.time():
82
+ try:
83
+ response = get(f"http://localhost:{port}", timeout=1)
84
+ if response.status_code == 200:
85
+ return
86
+ except Exception:
87
+ continue
88
+ raise TimeoutError("Could not connect to server")
89
+
90
+
91
+ def _background_npm_install_live_server() -> None:
92
+ import shutil
93
+ import time
94
+
95
+ if shutil.which("npm") is None:
96
+ return
97
+
98
+ if shutil.which("live-server") is not None:
99
+ return
100
+
101
+ time.sleep(3)
102
+ subprocess.run(
103
+ ["npm", "install", "-g", "live-server"],
104
+ stdout=subprocess.DEVNULL,
105
+ stderr=subprocess.DEVNULL,
106
+ )
107
+
108
+
109
+ def open_browser_process(
110
+ fastled_js: Path, port: int | None = None, open_browser: bool = True
111
+ ) -> Process:
112
+ import shutil
113
+
114
+ """Start livereload server in the fastled_js directory and return the process"""
115
+ if port is not None:
116
+ if not is_port_free(port):
117
+ raise ValueError(f"Port {port} was specified but in use")
118
+ else:
119
+ port = find_free_port(DEFAULT_PORT)
120
+ out: Process = Process(
121
+ target=open_http_server_subprocess,
122
+ args=(fastled_js, port, False),
123
+ daemon=True,
124
+ )
125
+ out.start()
126
+ wait_for_server(port)
127
+ if open_browser:
128
+ print(f"Opening browser to http://localhost:{port}")
129
+ webbrowser.open(url=f"http://localhost:{port}", new=1, autoraise=True)
130
+
131
+ # start a deamon thread to install live-server
132
+ if shutil.which("live-server") is None:
133
+ import threading
134
+
135
+ t = threading.Thread(target=_background_npm_install_live_server)
136
+ t.daemon = True
137
+ t.start()
138
+ return out
139
+
140
+
141
+ if __name__ == "__main__":
142
+ import argparse
143
+
144
+ parser = argparse.ArgumentParser(
145
+ description="Open a browser to the fastled_js directory"
146
+ )
147
+ parser.add_argument(
148
+ "fastled_js",
149
+ type=Path,
150
+ help="Path to the fastled_js directory",
151
+ )
152
+ parser.add_argument(
153
+ "--port",
154
+ type=int,
155
+ default=DEFAULT_PORT,
156
+ help=f"Port to run the server on (default: {DEFAULT_PORT})",
157
+ )
158
+ args = parser.parse_args()
159
+
160
+ proc = open_browser_process(args.fastled_js, args.port, open_browser=True)
161
+ proc.join()
@@ -0,0 +1,111 @@
1
+ import argparse
2
+ from pathlib import Path
3
+
4
+ from livereload import Server
5
+
6
+
7
+ def _run_flask_server(fastled_js: Path, port: int) -> None:
8
+ """Run Flask server with live reload in a subprocess"""
9
+ try:
10
+ from flask import Flask, send_from_directory
11
+
12
+ app = Flask(__name__)
13
+
14
+ # Must be a full path or flask will fail to find the file.
15
+ fastled_js = fastled_js.resolve()
16
+
17
+ @app.route("/")
18
+ def serve_index():
19
+ return send_from_directory(fastled_js, "index.html")
20
+
21
+ @app.route("/<path:path>")
22
+ def serve_files(path):
23
+ response = send_from_directory(fastled_js, path)
24
+ # Some servers don't set the Content-Type header for a bunch of files.
25
+ if path.endswith(".js"):
26
+ response.headers["Content-Type"] = "application/javascript"
27
+ if path.endswith(".css"):
28
+ response.headers["Content-Type"] = "text/css"
29
+ if path.endswith(".wasm"):
30
+ response.headers["Content-Type"] = "application/wasm"
31
+ if path.endswith(".json"):
32
+ response.headers["Content-Type"] = "application/json"
33
+ if path.endswith(".png"):
34
+ response.headers["Content-Type"] = "image/png"
35
+ if path.endswith(".jpg"):
36
+ response.headers["Content-Type"] = "image/jpeg"
37
+ if path.endswith(".jpeg"):
38
+ response.headers["Content-Type"] = "image/jpeg"
39
+ if path.endswith(".gif"):
40
+ response.headers["Content-Type"] = "image/gif"
41
+ if path.endswith(".svg"):
42
+ response.headers["Content-Type"] = "image/svg+xml"
43
+ if path.endswith(".ico"):
44
+ response.headers["Content-Type"] = "image/x-icon"
45
+ if path.endswith(".html"):
46
+ response.headers["Content-Type"] = "text/html"
47
+
48
+ # now also add headers to force no caching
49
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
50
+ response.headers["Pragma"] = "no-cache"
51
+ response.headers["Expires"] = "0"
52
+ return response
53
+
54
+ server = Server(app.wsgi_app)
55
+ # Watch index.html for changes
56
+ server.watch(str(fastled_js / "index.html"))
57
+ # server.watch(str(fastled_js / "index.js"))
58
+ # server.watch(str(fastled_js / "index.css"))
59
+ # Start the server
60
+ server.serve(port=port, debug=True)
61
+ except KeyboardInterrupt:
62
+ import _thread
63
+
64
+ _thread.interrupt_main()
65
+ except Exception as e:
66
+ print(f"Failed to run Flask server: {e}")
67
+ import _thread
68
+
69
+ _thread.interrupt_main()
70
+
71
+
72
+ def run(path: Path, port: int) -> None:
73
+ """Run the Flask server."""
74
+ try:
75
+ _run_flask_server(path, port)
76
+ import warnings
77
+
78
+ warnings.warn("Flask server has stopped")
79
+ except KeyboardInterrupt:
80
+ import _thread
81
+
82
+ _thread.interrupt_main()
83
+ pass
84
+
85
+
86
+ def parse_args() -> argparse.Namespace:
87
+ """Parse the command line arguments."""
88
+ parser = argparse.ArgumentParser(
89
+ description="Open a browser to the fastled_js directory"
90
+ )
91
+ parser.add_argument(
92
+ "fastled_js", type=Path, help="Path to the fastled_js directory"
93
+ )
94
+ parser.add_argument(
95
+ "--port",
96
+ "-p",
97
+ type=int,
98
+ required=True,
99
+ help="Port to run the server on (default: %(default)s)",
100
+ )
101
+ return parser.parse_args()
102
+
103
+
104
+ def main() -> None:
105
+ """Main function."""
106
+ args = parse_args()
107
+ run(args.fastled_js, args.port)
108
+
109
+
110
+ if __name__ == "__main__":
111
+ main()