fastled 1.3.30__py3-none-any.whl → 1.4.50__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.
Files changed (47) hide show
  1. fastled/__init__.py +30 -2
  2. fastled/__main__.py +14 -0
  3. fastled/__version__.py +1 -1
  4. fastled/app.py +51 -2
  5. fastled/args.py +33 -0
  6. fastled/client_server.py +188 -40
  7. fastled/compile_server.py +10 -0
  8. fastled/compile_server_impl.py +34 -1
  9. fastled/docker_manager.py +56 -14
  10. fastled/emoji_util.py +27 -0
  11. fastled/filewatcher.py +6 -3
  12. fastled/find_good_connection.py +105 -0
  13. fastled/header_dump.py +63 -0
  14. fastled/install/__init__.py +1 -0
  15. fastled/install/examples_manager.py +62 -0
  16. fastled/install/extension_manager.py +113 -0
  17. fastled/install/main.py +156 -0
  18. fastled/install/project_detection.py +167 -0
  19. fastled/install/test_install.py +373 -0
  20. fastled/install/vscode_config.py +344 -0
  21. fastled/interruptible_http.py +148 -0
  22. fastled/live_client.py +21 -1
  23. fastled/open_browser.py +84 -16
  24. fastled/parse_args.py +110 -9
  25. fastled/playwright/chrome_extension_downloader.py +207 -0
  26. fastled/playwright/playwright_browser.py +773 -0
  27. fastled/playwright/resize_tracking.py +127 -0
  28. fastled/print_filter.py +52 -52
  29. fastled/project_init.py +20 -13
  30. fastled/select_sketch_directory.py +142 -19
  31. fastled/server_flask.py +37 -1
  32. fastled/settings.py +47 -3
  33. fastled/sketch.py +121 -4
  34. fastled/string_diff.py +162 -26
  35. fastled/test/examples.py +7 -5
  36. fastled/types.py +4 -0
  37. fastled/util.py +34 -0
  38. fastled/version.py +41 -41
  39. fastled/web_compile.py +379 -236
  40. fastled/zip_files.py +76 -0
  41. {fastled-1.3.30.dist-info → fastled-1.4.50.dist-info}/METADATA +533 -508
  42. fastled-1.4.50.dist-info/RECORD +60 -0
  43. fastled-1.3.30.dist-info/RECORD +0 -44
  44. {fastled-1.3.30.dist-info → fastled-1.4.50.dist-info}/WHEEL +0 -0
  45. {fastled-1.3.30.dist-info → fastled-1.4.50.dist-info}/entry_points.txt +0 -0
  46. {fastled-1.3.30.dist-info → fastled-1.4.50.dist-info}/licenses/LICENSE +0 -0
  47. {fastled-1.3.30.dist-info → fastled-1.4.50.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,127 @@
1
+ """
2
+ Resize tracking for Playwright browser integration.
3
+
4
+ This module provides a class to track browser window resize events and adjust
5
+ the viewport accordingly without using internal polling loops.
6
+ """
7
+
8
+ from typing import Any
9
+
10
+
11
+ class ResizeTracker:
12
+ """Tracks browser window resize events and adjusts viewport accordingly."""
13
+
14
+ def __init__(self, page: Any):
15
+ """Initialize the resize tracker.
16
+
17
+ Args:
18
+ page: The Playwright page object to track
19
+ """
20
+ self.page = page
21
+ self.last_outer_size: tuple[int, int] | None = None
22
+
23
+ async def _get_window_info(self) -> dict[str, int] | None:
24
+ """Get browser window dimensions information.
25
+
26
+ Returns:
27
+ Dictionary containing window dimensions or None if unable to retrieve
28
+ """
29
+ if self.page is None:
30
+ return None
31
+
32
+ try:
33
+ return await self.page.evaluate(
34
+ """
35
+ () => {
36
+ return {
37
+ outerWidth: window.outerWidth,
38
+ outerHeight: window.outerHeight,
39
+ innerWidth: window.innerWidth,
40
+ innerHeight: window.innerHeight,
41
+ contentWidth: document.documentElement.clientWidth,
42
+ contentHeight: document.documentElement.clientHeight
43
+ };
44
+ }
45
+ """
46
+ )
47
+ except Exception:
48
+ return None
49
+
50
+ async def set_viewport_size(self, width: int, height: int) -> None:
51
+ """Set the viewport size.
52
+
53
+ Args:
54
+ width: The viewport width
55
+ height: The viewport height
56
+ """
57
+ if self.page is None:
58
+ raise Exception("Page is None")
59
+
60
+ await self.page.set_viewport_size({"width": width, "height": height})
61
+
62
+ async def update(self) -> Exception | None:
63
+ """Update the resize tracking and adjust viewport if needed.
64
+
65
+ Returns:
66
+ None if successful, Exception if an error occurred
67
+ """
68
+ try:
69
+ # Check if page is still alive
70
+ if self.page is None or self.page.is_closed():
71
+ return Exception("Page closed")
72
+
73
+ window_info = await self._get_window_info()
74
+
75
+ if window_info:
76
+ current_outer = (
77
+ window_info["outerWidth"],
78
+ window_info["outerHeight"],
79
+ )
80
+
81
+ # Check if window size changed
82
+ if (
83
+ self.last_outer_size is None
84
+ or current_outer != self.last_outer_size
85
+ ):
86
+
87
+ if self.last_outer_size is not None:
88
+ print("[PYTHON] *** BROWSER WINDOW RESIZED ***")
89
+ print(
90
+ f"[PYTHON] Outer window changed from {self.last_outer_size[0]}x{self.last_outer_size[1]} to {current_outer[0]}x{current_outer[1]}"
91
+ )
92
+
93
+ self.last_outer_size = current_outer
94
+
95
+ # Set viewport to match the outer window size
96
+ outer_width = int(window_info["outerWidth"])
97
+ outer_height = int(window_info["outerHeight"])
98
+
99
+ print(
100
+ f"[PYTHON] Setting viewport to match outer window size: {outer_width}x{outer_height}"
101
+ )
102
+
103
+ await self.set_viewport_size(outer_width, outer_height)
104
+ print("[PYTHON] Viewport set successfully")
105
+
106
+ # Query the actual window dimensions after the viewport change
107
+ updated_window_info = await self._get_window_info()
108
+
109
+ if updated_window_info:
110
+ print(f"[PYTHON] Updated window info: {updated_window_info}")
111
+
112
+ # Update our tracking with the actual final outer size
113
+ self.last_outer_size = (
114
+ updated_window_info["outerWidth"],
115
+ updated_window_info["outerHeight"],
116
+ )
117
+ print(
118
+ f"[PYTHON] Updated last_outer_size to actual final size: {self.last_outer_size}"
119
+ )
120
+ else:
121
+ print("[PYTHON] Could not get updated window info")
122
+
123
+ except Exception as e:
124
+ return e
125
+
126
+ # Success case
127
+ return None
fastled/print_filter.py CHANGED
@@ -1,52 +1,52 @@
1
- import re
2
- from abc import ABC, abstractmethod
3
- from enum import Enum
4
-
5
-
6
- class PrintFilter(ABC):
7
- """Abstract base class for filtering text output."""
8
-
9
- def __init__(self, echo: bool = True) -> None:
10
- self.echo = echo
11
-
12
- @abstractmethod
13
- def filter(self, text: str) -> str:
14
- """Filter the text according to implementation-specific rules."""
15
- pass
16
-
17
- def print(self, text: str | bytes) -> str:
18
- """Prints the text to the console after filtering."""
19
- if isinstance(text, bytes):
20
- text = text.decode("utf-8")
21
- text = self.filter(text)
22
- if self.echo:
23
- print(text, end="")
24
- return text
25
-
26
-
27
- def _handle_ino_cpp(line: str) -> str:
28
- if ".ino.cpp" in line[0:30]:
29
- # Extract the filename without path and extension
30
- match = re.search(r"src/([^/]+)\.ino\.cpp", line)
31
- if match:
32
- filename = match.group(1)
33
- # Replace with examples/Filename/Filename.ino format
34
- line = line.replace(
35
- f"src/{filename}.ino.cpp", f"examples/{filename}/{filename}.ino"
36
- )
37
- else:
38
- # Fall back to simple extension replacement if regex doesn't match
39
- line = line.replace(".ino.cpp", ".ino")
40
- return line
41
-
42
-
43
- class PrintFilterDefault(PrintFilter):
44
- """Provides default filtering for FastLED output."""
45
-
46
- def filter(self, text: str) -> str:
47
- return text
48
-
49
-
50
- class CompileOrLink(Enum):
51
- COMPILE = "compile"
52
- LINK = "link"
1
+ import re
2
+ from abc import ABC, abstractmethod
3
+ from enum import Enum
4
+
5
+
6
+ class PrintFilter(ABC):
7
+ """Abstract base class for filtering text output."""
8
+
9
+ def __init__(self, echo: bool = True) -> None:
10
+ self.echo = echo
11
+
12
+ @abstractmethod
13
+ def filter(self, text: str) -> str:
14
+ """Filter the text according to implementation-specific rules."""
15
+ pass
16
+
17
+ def print(self, text: str | bytes) -> str:
18
+ """Prints the text to the console after filtering."""
19
+ if isinstance(text, bytes):
20
+ text = text.decode("utf-8")
21
+ text = self.filter(text)
22
+ if self.echo:
23
+ print(text, end="")
24
+ return text
25
+
26
+
27
+ def _handle_ino_cpp(line: str) -> str:
28
+ if ".ino.cpp" in line[0:30]:
29
+ # Extract the filename without path and extension
30
+ match = re.search(r"src/([^/]+)\.ino\.cpp", line)
31
+ if match:
32
+ filename = match.group(1)
33
+ # Replace with examples/Filename/Filename.ino format
34
+ line = line.replace(
35
+ f"src/{filename}.ino.cpp", f"examples/{filename}/{filename}.ino"
36
+ )
37
+ else:
38
+ # Fall back to simple extension replacement if regex doesn't match
39
+ line = line.replace(".ino.cpp", ".ino")
40
+ return line
41
+
42
+
43
+ class PrintFilterDefault(PrintFilter):
44
+ """Provides default filtering for FastLED output."""
45
+
46
+ def filter(self, text: str) -> str:
47
+ return text
48
+
49
+
50
+ class CompileOrLink(Enum):
51
+ COMPILE = "compile"
52
+ LINK = "link"
fastled/project_init.py CHANGED
@@ -22,20 +22,27 @@ def get_examples(host: str | None = None) -> list[str]:
22
22
 
23
23
 
24
24
  def _prompt_for_example() -> str:
25
+ from fastled.select_sketch_directory import _disambiguate_user_choice
26
+
25
27
  examples = get_examples()
26
- while True:
27
- print("Available examples:")
28
- for i, example in enumerate(examples):
29
- print(f" [{i+1}]: {example}")
30
- answer = input("Enter the example number or name: ").strip()
31
- if answer.isdigit():
32
- example_num = int(answer) - 1
33
- if example_num < 0 or example_num >= len(examples):
34
- print("Invalid example number")
35
- continue
36
- return examples[example_num]
37
- elif answer in examples:
38
- return answer
28
+
29
+ # Find default example index (prefer DEFAULT_EXAMPLE if it exists)
30
+ default_index = 0
31
+ if DEFAULT_EXAMPLE in examples:
32
+ default_index = examples.index(DEFAULT_EXAMPLE)
33
+
34
+ result = _disambiguate_user_choice(
35
+ examples,
36
+ option_to_str=lambda x: x,
37
+ prompt="Available examples:",
38
+ default_index=default_index,
39
+ )
40
+
41
+ if result is None:
42
+ # Fallback to DEFAULT_EXAMPLE if user cancelled
43
+ return DEFAULT_EXAMPLE
44
+
45
+ return result
39
46
 
40
47
 
41
48
  class DownloadThread(threading.Thread):
@@ -1,10 +1,134 @@
1
1
  from pathlib import Path
2
+ from typing import Callable, TypeVar, Union
2
3
 
3
- from fastled.string_diff import string_diff_paths
4
+ from fastled.string_diff import string_diff
5
+
6
+ T = TypeVar("T")
7
+
8
+
9
+ def _disambiguate_user_choice(
10
+ options: list[T],
11
+ option_to_str: Callable[[T], str] = str,
12
+ prompt: str = "Multiple matches found. Please choose:",
13
+ default_index: int = 0,
14
+ ) -> Union[T, None]:
15
+ """
16
+ Present multiple options to the user with a default selection.
17
+
18
+ Args:
19
+ options: List of options to choose from
20
+ option_to_str: Function to convert option to display string
21
+ prompt: Prompt message to show user
22
+ default_index: Index of the default option (0-based)
23
+
24
+ Returns:
25
+ Selected option or None if cancelled
26
+ """
27
+ if not options:
28
+ return None
29
+
30
+ if len(options) == 1:
31
+ return options[0]
32
+
33
+ # Ensure default_index is valid
34
+ if default_index < 0 or default_index >= len(options):
35
+ default_index = 0
36
+
37
+ print(f"\n{prompt}")
38
+ for i, option in enumerate(options):
39
+ option_str = option_to_str(option)
40
+ if i == default_index:
41
+ print(f" [{i+1}]: [{option_str}]") # Default option shown in brackets
42
+ else:
43
+ print(f" [{i+1}]: {option_str}")
44
+
45
+ default_option_str = option_to_str(options[default_index])
46
+ user_input = input(
47
+ f"\nEnter number or name (default: [{default_option_str}]): "
48
+ ).strip()
49
+
50
+ # Handle empty input - select default
51
+ if not user_input:
52
+ return options[default_index]
53
+
54
+ # Try to parse as number
55
+ try:
56
+ index = int(user_input) - 1
57
+ if 0 <= index < len(options):
58
+ return options[index]
59
+ except ValueError:
60
+ pass
61
+
62
+ # Try to match by name (case insensitive)
63
+ user_input_lower = user_input.lower()
64
+ for option in options:
65
+ option_str = option_to_str(option).lower()
66
+ if option_str == user_input_lower:
67
+ return option
68
+
69
+ # Try partial match
70
+ matches = []
71
+ for option in options:
72
+ option_str = option_to_str(option)
73
+ if user_input_lower in option_str.lower():
74
+ matches.append(option)
75
+
76
+ if len(matches) == 1:
77
+ return matches[0]
78
+ elif len(matches) > 1:
79
+ # Recursive disambiguation with the filtered matches
80
+ return _disambiguate_user_choice(
81
+ matches,
82
+ option_to_str,
83
+ f"Multiple partial matches for '{user_input}':",
84
+ 0, # Reset default to first match
85
+ )
86
+
87
+ # Try fuzzy matching as fallback
88
+ # For better fuzzy matching on paths, extract just the last component (basename)
89
+ # to avoid the "examples/" prefix interfering with matching
90
+ from pathlib import Path as PathLib
91
+
92
+ option_basenames = []
93
+ for option in options:
94
+ option_str = option_to_str(option)
95
+ # Extract basename for fuzzy matching
96
+ basename = (
97
+ PathLib(option_str).name
98
+ if "/" in option_str or "\\" in option_str
99
+ else option_str
100
+ )
101
+ option_basenames.append(basename)
102
+
103
+ fuzzy_results = string_diff(user_input, option_basenames)
104
+
105
+ if fuzzy_results:
106
+ # Map fuzzy results back to original options
107
+ fuzzy_matches = []
108
+ for _, matched_basename in fuzzy_results:
109
+ for i, basename in enumerate(option_basenames):
110
+ if basename == matched_basename:
111
+ fuzzy_matches.append(options[i])
112
+ break
113
+
114
+ if len(fuzzy_matches) == 1:
115
+ return fuzzy_matches[0]
116
+ elif len(fuzzy_matches) > 1:
117
+ # Recursive disambiguation with fuzzy matches
118
+ return _disambiguate_user_choice(
119
+ fuzzy_matches,
120
+ option_to_str,
121
+ f"Multiple fuzzy matches for '{user_input}':",
122
+ 0,
123
+ )
124
+
125
+ # No match found
126
+ print(f"No match found for '{user_input}'. Please try again.")
127
+ return _disambiguate_user_choice(options, option_to_str, prompt, default_index)
4
128
 
5
129
 
6
130
  def select_sketch_directory(
7
- sketch_directories: list[Path], cwd_is_fastled: bool
131
+ sketch_directories: list[Path], cwd_is_fastled: bool, is_followup: bool = False
8
132
  ) -> str | None:
9
133
  if cwd_is_fastled:
10
134
  exclude = ["src", "dev", "tests"]
@@ -17,21 +141,20 @@ def select_sketch_directory(
17
141
  print(f"\nUsing sketch directory: {sketch_directories[0]}")
18
142
  return str(sketch_directories[0])
19
143
  elif len(sketch_directories) > 1:
20
- print("\nMultiple Directories found, choose one:")
21
- for i, sketch_dir in enumerate(sketch_directories):
22
- print(f" [{i+1}]: {sketch_dir}")
23
- which = input(
24
- "\nPlease specify a sketch directory\nYou can enter a number or type a fuzzy search: "
25
- ).strip()
26
- try:
27
- index = int(which) - 1
28
- return str(sketch_directories[index])
29
- except (ValueError, IndexError):
30
- inputs = [p for p in sketch_directories]
31
- top_hits: list[tuple[float, Path]] = string_diff_paths(which, inputs)
32
- if len(top_hits) == 1:
33
- example = top_hits[0][1]
34
- return str(example)
35
- else:
36
- return select_sketch_directory([p for _, p in top_hits], cwd_is_fastled)
144
+ # First scan with >4 directories: return None (too many to auto-select)
145
+ if not is_followup and len(sketch_directories) > 4:
146
+ return None
147
+
148
+ # Prompt user to disambiguate
149
+ result = _disambiguate_user_choice(
150
+ sketch_directories,
151
+ option_to_str=lambda x: str(x),
152
+ prompt="Multiple Directories found, choose one:",
153
+ default_index=0,
154
+ )
155
+
156
+ if result is None:
157
+ return None
158
+
159
+ return str(result)
37
160
  return None
fastled/server_flask.py CHANGED
@@ -85,6 +85,14 @@ def _run_flask_server(
85
85
 
86
86
  # logger.error(f"Server error: {e}")
87
87
 
88
+ @app.after_request
89
+ def add_security_headers(response):
90
+ """Add security headers required for cross-origin isolation and audio worklets"""
91
+ # Required for SharedArrayBuffer and audio worklets
92
+ response.headers["Cross-Origin-Embedder-Policy"] = "credentialless"
93
+ response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
94
+ return response
95
+
88
96
  @app.before_request
89
97
  def log_request_info():
90
98
  """Log details of each request before processing"""
@@ -333,7 +341,35 @@ def _run_flask_server(
333
341
  # server.watch(str(fastled_js / "index.css"))
334
342
  # Start the server
335
343
  logger.info(f"Starting server on port {port}")
336
- server.serve(port=port, debug=True)
344
+
345
+ # Configure SSL if certificates are provided
346
+ if certfile and keyfile:
347
+ logger.info(
348
+ f"Configuring SSL with certfile: {certfile}, keyfile: {keyfile}"
349
+ )
350
+ # Monkey-patch the server to use SSL
351
+ # The livereload Server doesn't expose ssl_context directly,
352
+ # so we need to use the underlying tornado server
353
+ import ssl
354
+
355
+ from tornado import httpserver
356
+ from tornado.ioloop import IOLoop
357
+ from tornado.wsgi import WSGIContainer
358
+
359
+ # Create SSL context
360
+ ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
361
+ ssl_ctx.load_cert_chain(str(certfile), str(keyfile))
362
+
363
+ # Wrap the WSGI app in Tornado's WSGIContainer
364
+ wsgi_container = WSGIContainer(server.app) # type: ignore[arg-type]
365
+
366
+ # Create HTTP server with SSL
367
+ http_server = httpserver.HTTPServer(wsgi_container, ssl_options=ssl_ctx)
368
+ http_server.listen(port)
369
+ logger.info(f"HTTPS server started on port {port}")
370
+ IOLoop.current().start()
371
+ else:
372
+ server.serve(port=port, debug=True)
337
373
  except KeyboardInterrupt:
338
374
  logger.info("Server stopped by keyboard interrupt")
339
375
  import _thread
fastled/settings.py CHANGED
@@ -1,16 +1,60 @@
1
1
  import os
2
2
  import platform
3
+ import sys
3
4
 
4
5
  FILE_CHANGED_DEBOUNCE_SECONDS = 2.0
5
6
  MACHINE = platform.machine().lower()
6
7
  IS_ARM: bool = "arm" in MACHINE or "aarch64" in MACHINE
7
8
  PLATFORM_TAG: str = "-arm64" if IS_ARM else ""
8
- CONTAINER_NAME = f"fastled-wasm-container{PLATFORM_TAG}"
9
+
10
+
11
+ def _is_running_under_github_actions() -> bool:
12
+ """Detect if we're running under github actions."""
13
+ return "GITHUB_ACTIONS" in os.environ
14
+
15
+
16
+ def _is_running_under_pytest() -> bool:
17
+ """Detect if we're running under pytest."""
18
+ # Check if pytest is in the loaded modules
19
+ if "pytest" in sys.modules:
20
+ return True
21
+
22
+ # Check for pytest environment variables
23
+ if "PYTEST_CURRENT_TEST" in os.environ:
24
+ return True
25
+
26
+ return False
27
+
28
+
29
+ def _get_container_name() -> str:
30
+ """Get the appropriate container name based on runtime context."""
31
+ base_name = "fastled-wasm-container"
32
+
33
+ if not _is_running_under_github_actions() and _is_running_under_pytest():
34
+ # Use test container name when running under pytest
35
+ return f"{base_name}-test{PLATFORM_TAG}"
36
+ else:
37
+ # Use regular container name
38
+ return f"{base_name}{PLATFORM_TAG}"
39
+
40
+
41
+ def _get_server_port() -> int:
42
+ """Get the appropriate server port based on runtime context."""
43
+ if not _is_running_under_github_actions() and _is_running_under_pytest():
44
+ # Use test port when running under pytest to avoid conflicts
45
+ return 9022
46
+ else:
47
+ # Use regular port
48
+ return 9021
49
+
50
+
51
+ CONTAINER_NAME = _get_container_name()
9
52
  DEFAULT_URL = str(os.environ.get("FASTLED_URL", "https://fastled.onrender.com"))
10
- SERVER_PORT = 9021
53
+ SERVER_PORT = _get_server_port()
54
+ AUTH_TOKEN = "oBOT5jbsO4ztgrpNsQwlmFLIKB"
11
55
 
12
56
  IMAGE_NAME = "niteris/fastled-wasm"
13
- DEFAULT_CONTAINER_NAME = "fastled-wasm-container"
57
+ DEFAULT_CONTAINER_NAME = _get_container_name()
14
58
  # IMAGE_TAG = "latest"
15
59
 
16
60
  DOCKER_FILE = (