fastled 1.2.33__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 (58) hide show
  1. fastled/__init__.py +51 -192
  2. fastled/__main__.py +14 -0
  3. fastled/__version__.py +6 -0
  4. fastled/app.py +124 -27
  5. fastled/args.py +124 -0
  6. fastled/assets/localhost-key.pem +28 -0
  7. fastled/assets/localhost.pem +27 -0
  8. fastled/cli.py +10 -2
  9. fastled/cli_test.py +21 -0
  10. fastled/cli_test_interactive.py +21 -0
  11. fastled/client_server.py +334 -55
  12. fastled/compile_server.py +12 -1
  13. fastled/compile_server_impl.py +115 -42
  14. fastled/docker_manager.py +392 -69
  15. fastled/emoji_util.py +27 -0
  16. fastled/filewatcher.py +100 -8
  17. fastled/find_good_connection.py +105 -0
  18. fastled/header_dump.py +63 -0
  19. fastled/install/__init__.py +1 -0
  20. fastled/install/examples_manager.py +62 -0
  21. fastled/install/extension_manager.py +113 -0
  22. fastled/install/main.py +156 -0
  23. fastled/install/project_detection.py +167 -0
  24. fastled/install/test_install.py +373 -0
  25. fastled/install/vscode_config.py +344 -0
  26. fastled/interruptible_http.py +148 -0
  27. fastled/keyboard.py +1 -0
  28. fastled/keyz.py +84 -0
  29. fastled/live_client.py +26 -1
  30. fastled/open_browser.py +133 -89
  31. fastled/parse_args.py +219 -15
  32. fastled/playwright/chrome_extension_downloader.py +207 -0
  33. fastled/playwright/playwright_browser.py +773 -0
  34. fastled/playwright/resize_tracking.py +127 -0
  35. fastled/print_filter.py +52 -0
  36. fastled/project_init.py +20 -13
  37. fastled/select_sketch_directory.py +142 -17
  38. fastled/server_flask.py +487 -0
  39. fastled/server_start.py +21 -0
  40. fastled/settings.py +53 -4
  41. fastled/site/build.py +2 -10
  42. fastled/site/examples.py +10 -0
  43. fastled/sketch.py +129 -7
  44. fastled/string_diff.py +218 -9
  45. fastled/test/examples.py +7 -5
  46. fastled/types.py +22 -2
  47. fastled/util.py +78 -0
  48. fastled/version.py +41 -0
  49. fastled/web_compile.py +401 -218
  50. fastled/zip_files.py +76 -0
  51. {fastled-1.2.33.dist-info → fastled-1.4.50.dist-info}/METADATA +533 -382
  52. fastled-1.4.50.dist-info/RECORD +60 -0
  53. {fastled-1.2.33.dist-info → fastled-1.4.50.dist-info}/WHEEL +1 -1
  54. fastled/open_browser2.py +0 -111
  55. fastled-1.2.33.dist-info/RECORD +0 -33
  56. {fastled-1.2.33.dist-info → fastled-1.4.50.dist-info}/entry_points.txt +0 -0
  57. {fastled-1.2.33.dist-info → fastled-1.4.50.dist-info/licenses}/LICENSE +0 -0
  58. {fastled-1.2.33.dist-info → fastled-1.4.50.dist-info}/top_level.txt +0 -0
fastled/sketch.py CHANGED
@@ -4,7 +4,9 @@ from pathlib import Path
4
4
  _MAX_FILES_SEARCH_LIMIT = 10000
5
5
 
6
6
 
7
- def find_sketch_directories(directory: Path) -> list[Path]:
7
+ def find_sketch_directories(directory: Path | None = None) -> list[Path]:
8
+ if directory is None:
9
+ directory = Path(".")
8
10
  file_count = 0
9
11
  sketch_directories: list[Path] = []
10
12
  # search all the paths one level deep
@@ -23,10 +25,27 @@ def find_sketch_directories(directory: Path) -> list[Path]:
23
25
  if looks_like_sketch_directory(path, quick=True):
24
26
  sketch_directories.append(path)
25
27
  if dir_name.lower() == "examples":
26
- for example in path.iterdir():
27
- if example.is_dir():
28
- if looks_like_sketch_directory(example, quick=True):
29
- sketch_directories.append(example)
28
+ # Recursively search examples directory for sketch directories
29
+ def search_examples_recursive(
30
+ examples_path: Path, depth: int = 0, max_depth: int = 3
31
+ ):
32
+ nonlocal file_count
33
+ if depth >= max_depth:
34
+ return
35
+ for example in examples_path.iterdir():
36
+ if example.is_dir():
37
+ if str(example.name).startswith("."):
38
+ continue
39
+ file_count += 1
40
+ if file_count > _MAX_FILES_SEARCH_LIMIT:
41
+ return
42
+ if looks_like_sketch_directory(example, quick=True):
43
+ sketch_directories.append(example)
44
+ else:
45
+ # Keep searching deeper if this isn't a sketch directory
46
+ search_examples_recursive(example, depth + 1, max_depth)
47
+
48
+ search_examples_recursive(path)
30
49
  # make relative to cwd
31
50
  sketch_directories = [p.relative_to(directory) for p in sketch_directories]
32
51
  return sketch_directories
@@ -60,7 +79,7 @@ def get_sketch_files(directory: Path) -> list[Path]:
60
79
  return files
61
80
 
62
81
 
63
- def looks_like_fastled_repo(directory: Path) -> bool:
82
+ def looks_like_fastled_repo(directory: Path = Path(".")) -> bool:
64
83
  libprops = directory / "library.properties"
65
84
  if not libprops.exists():
66
85
  return False
@@ -72,7 +91,10 @@ def _lots_and_lots_of_files(directory: Path) -> bool:
72
91
  return len(get_sketch_files(directory)) > 100
73
92
 
74
93
 
75
- def looks_like_sketch_directory(directory: Path, quick=False) -> bool:
94
+ def looks_like_sketch_directory(directory: Path | str | None, quick=False) -> bool:
95
+ if directory is None:
96
+ return False
97
+ directory = Path(directory)
76
98
  if looks_like_fastled_repo(directory):
77
99
  print("Directory looks like the FastLED repo")
78
100
  return False
@@ -95,3 +117,103 @@ def looks_like_sketch_directory(directory: Path, quick=False) -> bool:
95
117
  if platformini_file:
96
118
  return True
97
119
  return False
120
+
121
+
122
+ def find_sketch_by_partial_name(
123
+ partial_name: str, search_dir: Path | None = None
124
+ ) -> Path | None:
125
+ """
126
+ Find a sketch directory by partial name match.
127
+
128
+ Args:
129
+ partial_name: Partial name to match against sketch directories
130
+ search_dir: Directory to search in (defaults to current directory)
131
+
132
+ Returns:
133
+ Path to the matching sketch directory, or None if no unique match found
134
+
135
+ Raises:
136
+ ValueError: If multiple matches are found with no clear best match, or no matches found
137
+ """
138
+ if search_dir is None:
139
+ search_dir = Path(".")
140
+
141
+ # First, find all sketch directories
142
+ sketch_directories = find_sketch_directories(search_dir)
143
+
144
+ # Normalize the partial name to use forward slashes for cross-platform matching
145
+ partial_name_normalized = partial_name.replace("\\", "/").lower()
146
+
147
+ # Get the set of characters in the partial name for similarity check
148
+ partial_chars = set(partial_name_normalized)
149
+
150
+ # Find matches where the partial name appears in the path
151
+ matches = []
152
+ for sketch_dir in sketch_directories:
153
+ # Normalize the sketch directory path to use forward slashes
154
+ sketch_str_normalized = str(sketch_dir).replace("\\", "/").lower()
155
+
156
+ # Character similarity check: at least 50% of partial name chars must be in target
157
+ target_chars = set(sketch_str_normalized)
158
+ matching_chars = partial_chars & target_chars
159
+ similarity = (
160
+ len(matching_chars) / len(partial_chars) if len(partial_chars) > 0 else 0
161
+ )
162
+
163
+ # Check if partial_name matches the directory name or any part of the path
164
+ # AND has sufficient character similarity
165
+ if partial_name_normalized in sketch_str_normalized and similarity >= 0.5:
166
+ matches.append(sketch_dir)
167
+
168
+ if len(matches) == 0:
169
+ # Check if this is a total mismatch (low character similarity with all sketches)
170
+ all_low_similarity = True
171
+ for sketch_dir in sketch_directories:
172
+ sketch_str_normalized = str(sketch_dir).replace("\\", "/").lower()
173
+ target_chars = set(sketch_str_normalized)
174
+ matching_chars = partial_chars & target_chars
175
+ similarity = (
176
+ len(matching_chars) / len(partial_chars)
177
+ if len(partial_chars) > 0
178
+ else 0
179
+ )
180
+ if similarity > 0.5:
181
+ all_low_similarity = False
182
+ break
183
+
184
+ if all_low_similarity and len(sketch_directories) > 0:
185
+ # List all available sketches
186
+ sketches_str = "\n ".join(str(s) for s in sketch_directories)
187
+ raise ValueError(
188
+ f"'{partial_name}' does not look like any of the available sketches.\n\n"
189
+ f"Available sketches:\n {sketches_str}"
190
+ )
191
+ else:
192
+ raise ValueError(f"No sketch directory found matching '{partial_name}'")
193
+ elif len(matches) == 1:
194
+ return matches[0]
195
+ else:
196
+ # Multiple matches - try to find the best match
197
+ # Best match criteria: exact match of the final directory name
198
+ exact_matches = []
199
+ for match in matches:
200
+ # Get the final directory name
201
+ final_dir_name = match.name.lower()
202
+ if final_dir_name == partial_name_normalized:
203
+ exact_matches.append(match)
204
+
205
+ if len(exact_matches) == 1:
206
+ # Found exactly one exact match - this is the best match
207
+ return exact_matches[0]
208
+ elif len(exact_matches) > 1:
209
+ # Multiple exact matches - still ambiguous
210
+ matches_str = "\n ".join(str(m) for m in exact_matches)
211
+ raise ValueError(
212
+ f"Multiple sketch directories found matching '{partial_name}':\n {matches_str}"
213
+ )
214
+ else:
215
+ # No exact match - ambiguous partial matches
216
+ matches_str = "\n ".join(str(m) for m in matches)
217
+ raise ValueError(
218
+ f"Multiple sketch directories found matching '{partial_name}':\n {matches_str}"
219
+ )
fastled/string_diff.py CHANGED
@@ -3,6 +3,49 @@ from pathlib import Path
3
3
  from rapidfuzz import fuzz
4
4
 
5
5
 
6
+ def _filter_out_obvious_bad_choices(
7
+ input_str: str, string_list: list[str]
8
+ ) -> list[str]:
9
+ """
10
+ Filter out strings that are too different from the input string.
11
+ This is a heuristic and may not be perfect.
12
+ """
13
+ if not input_str.strip(): # Handle empty input
14
+ return string_list
15
+
16
+ input_chars = set(input_str.lower())
17
+ filtered_list = []
18
+ for s in string_list:
19
+ # Check if at least half of the input characters are in the string
20
+ s_chars = set(s.lower())
21
+ common_chars = input_chars.intersection(s_chars)
22
+ if len(common_chars) >= len(input_chars) / 2:
23
+ filtered_list.append(s)
24
+ return filtered_list
25
+
26
+
27
+ def is_in_order_match(input_str: str, other: str) -> bool:
28
+ """
29
+ Check if the input string is an in-order match for any string in the list.
30
+ An in-order match means that the characters of the input string appear
31
+ in the same order in the string from the list, ignoring spaces in the input.
32
+ """
33
+
34
+ # Remove spaces from input string for matching
35
+ input_chars = [c.lower() for c in input_str if c != " "]
36
+ other_chars = [c.lower() for c in other]
37
+ input_index = 0
38
+ other_index = 0
39
+ while input_index < len(input_chars) and other_index < len(other_chars):
40
+ if input_chars[input_index] == other_chars[other_index]:
41
+ input_index += 1
42
+ other_index += 1
43
+ # If we reached the end of the input string, it means all characters were found in order
44
+ if input_index == len(input_chars):
45
+ return True
46
+ return False
47
+
48
+
6
49
  # Returns the min distance strings. If there is a tie, it returns
7
50
  # all the strings that have the same min distance.
8
51
  # Returns a tuple of index and string.
@@ -13,19 +56,179 @@ def string_diff(
13
56
  def normalize(s: str) -> str:
14
57
  return s.lower() if ignore_case else s
15
58
 
16
- # distances = [
17
- # #Levenshtein.distance(normalize(input_string), normalize(s)) for s in string_list
18
- # fuzz.partial_ratio(normalize(input_string), normalize(s)) for s in string_list
19
- # ]
59
+ # Handle empty input or empty list
60
+ if not input_string.strip():
61
+ # Return all strings with equal distance for empty input
62
+ return [(i, s) for i, s in enumerate(string_list)]
63
+
64
+ if not string_list:
65
+ return []
66
+
67
+ map_string: dict[str, str] = {}
68
+
69
+ if ignore_case:
70
+ map_string = {s.lower(): s for s in string_list}
71
+ else:
72
+ map_string = {s: s for s in string_list}
73
+
74
+ original_string_list = string_list.copy()
75
+ if ignore_case:
76
+ string_list = [s.lower() for s in string_list]
77
+ input_string = input_string.lower()
78
+
79
+ # Check for exact matches, but also check if there are other substring matches
80
+ exact_matches = [s for s in string_list if s == input_string]
81
+ substring_matches = [s for s in string_list if input_string in s]
82
+
83
+ # If there's exactly one exact match, and there are substring matches,
84
+ # check if we should prioritize the exact match or return all variants
85
+ if len(exact_matches) == 1 and len(substring_matches) > 1:
86
+ exact_match = exact_matches[0]
87
+ other_substring_matches = [s for s in substring_matches if s != exact_match]
88
+
89
+ # Prioritize exact match only if it appears at the start of other matches
90
+ # AND those matches have a camelCase boundary (indicating compound words)
91
+ # We need to use the original (non-lowercased) strings for camelCase detection
92
+ should_prioritize_exact = True
93
+ original_exact_match = map_string[exact_match] # Get the original casing
94
+
95
+ for other_match in other_substring_matches:
96
+ original_other_match = map_string[other_match] # Get the original casing
97
+
98
+ if not original_other_match.lower().startswith(
99
+ original_exact_match.lower()
100
+ ):
101
+ # If the exact match isn't at the start, don't prioritize
102
+ should_prioritize_exact = False
103
+ break
104
+
105
+ # Check for camelCase boundary after the exact match in the ORIGINAL string
106
+ remainder = original_other_match[len(original_exact_match) :]
107
+ if remainder and remainder[0].isupper():
108
+ # Only prioritize exact match if the exact match is very short (4 chars or less)
109
+ # AND the remainder suggests a different concept
110
+ if len(original_exact_match) <= 4 and len(remainder) >= 6:
111
+ # This looks like a camelCase compound word (e.g., "wasm" -> "WasmScreenCoords")
112
+ continue
113
+ else:
114
+ # This looks like a variant (e.g., "Noise" -> "NoisePlayground", "Fire2012" -> "Fire2012WithPalette")
115
+ should_prioritize_exact = False
116
+ break
117
+ else:
118
+ # This looks like a variant/extension (e.g., "Blur" -> "Blur2d")
119
+ should_prioritize_exact = False
120
+ break
121
+
122
+ if should_prioritize_exact:
123
+ out: list[tuple[float, str]] = []
124
+ for i, s in enumerate(exact_matches):
125
+ s_mapped = map_string.get(s, s)
126
+ out.append((i, s_mapped))
127
+ return out
128
+ else:
129
+ # Apply character count filtering only for very specific compound terms
130
+ # Main criteria: contains numbers AND ends with numbers/letters (like Wave2d, Fire2012)
131
+ original_exact_match = map_string[exact_match]
132
+ should_apply_char_filter = (
133
+ len(original_exact_match) >= 5 # Longer terms
134
+ and any(c.isdigit() for c in original_exact_match) # Contains numbers
135
+ and (
136
+ original_exact_match[-1].isdigit()
137
+ or original_exact_match[-1].islower()
138
+ ) # Ends specifically (compound pattern)
139
+ )
140
+
141
+ if should_apply_char_filter:
142
+ # Filter substring matches based on extra character count
143
+ # Use a more lenient threshold for shorter base terms
144
+ if len(original_exact_match) <= 6:
145
+ # For short terms, allow more extra chars (e.g., "Wave2d" + "FxWave2d")
146
+ MAX_EXTRA_CHARS = min(10, len(original_exact_match) * 2)
147
+ else:
148
+ # For longer terms, allow significant extensions (e.g., "Fire2012" + "Fire2012WithPalette")
149
+ MAX_EXTRA_CHARS = 12
150
+
151
+ filtered_matches = []
152
+
153
+ for s in substring_matches:
154
+ original_s = map_string[s]
155
+ if s == exact_match:
156
+ # Always include the exact match
157
+ filtered_matches.append(s)
158
+ else:
159
+ # Calculate extra characters
160
+ extra_chars = len(original_s) - len(original_exact_match)
161
+ if extra_chars <= MAX_EXTRA_CHARS:
162
+ filtered_matches.append(s)
163
+
164
+ # Return filtered matches
165
+ out: list[tuple[float, str]] = []
166
+ for i, s in enumerate(filtered_matches):
167
+ s_mapped = map_string.get(s, s) or s
168
+ out.append((i, s_mapped))
169
+ return out
170
+ else:
171
+ # Return all substring matches (original behavior for base terms)
172
+ out: list[tuple[float, str]] = []
173
+ for i, s in enumerate(substring_matches):
174
+ s_mapped = map_string.get(s, s) or s
175
+ out.append((i, s_mapped))
176
+ return out
177
+
178
+ # If there's only an exact match and no other substring matches, return just the exact match
179
+ if exact_matches and len(substring_matches) == 1:
180
+ out: list[tuple[float, str]] = []
181
+ for i, s in enumerate(exact_matches):
182
+ s_mapped = map_string.get(s, s)
183
+ out.append((i, s_mapped))
184
+ return out
185
+
186
+ # Apply set membership filtering for queries with 3+ characters
187
+ if len(input_string.strip()) >= 3:
188
+ filtered = _filter_out_obvious_bad_choices(input_string, string_list)
189
+ if filtered: # Only apply filter if it doesn't eliminate everything
190
+ string_list = filtered
191
+
192
+ # Second filter: exact substring filtering if applicable
193
+ if substring_matches:
194
+ string_list = substring_matches
195
+ # Return all substring matches
196
+ out: list[tuple[float, str]] = []
197
+ for i, s in enumerate(string_list):
198
+ s_mapped = map_string.get(s, s)
199
+ out.append((i, s_mapped))
200
+ return out
201
+
202
+ # Third filter: in order exact match filtering if applicable.
203
+ in_order_matches = [s for s in string_list if is_in_order_match(input_string, s)]
204
+ if in_order_matches:
205
+ string_list = in_order_matches
206
+
207
+ # Calculate distances
20
208
  distances: list[float] = []
21
209
  for s in string_list:
22
210
  dist = fuzz.token_sort_ratio(normalize(input_string), normalize(s))
23
211
  distances.append(1.0 / (dist + 1.0))
212
+
213
+ # Handle case where no strings remain after filtering
214
+ if not distances:
215
+ # Fall back to original list and calculate distances
216
+ string_list = original_string_list.copy()
217
+ if ignore_case:
218
+ string_list = [s.lower() for s in string_list]
219
+
220
+ distances = []
221
+ for s in string_list:
222
+ dist = fuzz.token_sort_ratio(normalize(input_string), normalize(s))
223
+ distances.append(1.0 / (dist + 1.0))
224
+
24
225
  min_distance = min(distances)
25
226
  out: list[tuple[float, str]] = []
26
227
  for i, d in enumerate(distances):
27
228
  if d == min_distance:
28
- out.append((i, string_list[i]))
229
+ s = string_list[i]
230
+ s_mapped = map_string.get(s, s)
231
+ out.append((i, s_mapped))
29
232
 
30
233
  return out
31
234
 
@@ -33,10 +236,16 @@ def string_diff(
33
236
  def string_diff_paths(
34
237
  input_string: str | Path, path_list: list[Path], ignore_case=True
35
238
  ) -> list[tuple[float, Path]]:
36
- string_list = [str(p) for p in path_list]
37
- tmp = string_diff(str(input_string), string_list, ignore_case)
239
+ # Normalize path separators to forward slashes for consistent comparison
240
+ string_list = [str(p).replace("\\", "/") for p in path_list]
241
+ input_str = str(input_string).replace("\\", "/")
242
+
243
+ tmp = string_diff(input_str, string_list, ignore_case)
38
244
  out: list[tuple[float, Path]] = []
39
245
  for i, j in tmp:
40
- p = Path(j)
41
- out.append((i, p))
246
+ # Find the original path that matches the normalized result
247
+ for idx, orig_path in enumerate(path_list):
248
+ if str(orig_path).replace("\\", "/") == j:
249
+ out.append((i, orig_path))
250
+ break
42
251
  return out
fastled/test/examples.py CHANGED
@@ -2,6 +2,8 @@ from tempfile import TemporaryDirectory
2
2
  from time import time
3
3
  from warnings import warn
4
4
 
5
+ from fastled.emoji_util import safe_print
6
+
5
7
  _FILTER = True
6
8
 
7
9
 
@@ -18,21 +20,21 @@ def test_examples(
18
20
  examples.remove("LuminescentGrand")
19
21
  with TemporaryDirectory() as tmpdir:
20
22
  for example in examples:
21
- print(f"Initializing example: {example}")
23
+ safe_print(f"Initializing example: {example}")
22
24
  try:
23
25
  sketch_dir = Api.project_init(example, outputdir=tmpdir, host=host)
24
26
  except Exception as e:
25
27
  warn(f"Failed to initialize example: {example}, error: {e}")
26
28
  out[example] = e
27
29
  continue
28
- print(f"Project initialized at: {sketch_dir}")
30
+ safe_print(f"Project initialized at: {sketch_dir}")
29
31
  start = time()
30
- print(f"Compiling example: {example}")
32
+ safe_print(f"Compiling example: {example}")
31
33
  diff = time() - start
32
- print(f"Compilation took: {diff:.2f} seconds")
34
+ safe_print(f"Compilation took: {diff:.2f} seconds")
33
35
  result = Api.web_compile(sketch_dir, host=host)
34
36
  if not result.success:
35
- print(f"Compilation failed for {example}: {result.stdout}")
37
+ safe_print(f"Compilation failed for {example}: {result.stdout}")
36
38
  out[example] = Exception(result.stdout)
37
39
  return out
38
40
 
fastled/types.py CHANGED
@@ -1,8 +1,10 @@
1
- import argparse
2
1
  from dataclasses import dataclass
3
2
  from enum import Enum
4
3
  from typing import Any
5
4
 
5
+ from fastled.args import Args
6
+ from fastled.print_filter import PrintFilterDefault
7
+
6
8
 
7
9
  @dataclass
8
10
  class CompileResult:
@@ -10,6 +12,10 @@ class CompileResult:
10
12
  stdout: str
11
13
  hash_value: str | None
12
14
  zip_bytes: bytes
15
+ zip_time: float
16
+ libfastled_time: float
17
+ sketch_time: float
18
+ response_processing_time: float
13
19
 
14
20
  def __bool__(self) -> bool:
15
21
  return self.success
@@ -17,6 +23,11 @@ class CompileResult:
17
23
  def to_dict(self) -> dict[str, Any]:
18
24
  return self.__dict__.copy()
19
25
 
26
+ def __post_init__(self):
27
+ # Filter the stdout.
28
+ pf = PrintFilterDefault(echo=False)
29
+ self.stdout = pf.print(self.stdout)
30
+
20
31
 
21
32
  class CompileServerError(Exception):
22
33
  """Error class for failing to instantiate CompileServer."""
@@ -38,7 +49,7 @@ class BuildMode(Enum):
38
49
  raise ValueError(f"BUILD_MODE must be one of {valid_modes}, got {mode_str}")
39
50
 
40
51
  @staticmethod
41
- def from_args(args: argparse.Namespace) -> "BuildMode":
52
+ def from_args(args: Args) -> "BuildMode":
42
53
  if args.debug:
43
54
  return BuildMode.DEBUG
44
55
  elif args.release:
@@ -59,3 +70,12 @@ class Platform(Enum):
59
70
  raise ValueError(
60
71
  f"Platform must be one of {valid_modes}, got {platform_str}"
61
72
  )
73
+
74
+
75
+ @dataclass
76
+ class FileResponse:
77
+ """File response from the server."""
78
+
79
+ filename: str
80
+ content: str
81
+ mimetype: str
fastled/util.py CHANGED
@@ -8,3 +8,81 @@ def hash_file(file_path: Path) -> str:
8
8
  for chunk in iter(lambda: f.read(4096), b""):
9
9
  hasher.update(chunk)
10
10
  return hasher.hexdigest()
11
+
12
+
13
+ def banner_string(msg: str) -> str:
14
+ lines = msg.splitlines()
15
+ max_length = max(len(line) for line in lines)
16
+ border = "#" * (max_length + 4)
17
+ out: list[str] = []
18
+ out.append(border)
19
+ for line in lines:
20
+ out.append(f"# {line} " + " " * (max_length - len(line)) + "#")
21
+ out.append(border)
22
+ outstr = "\n".join(out)
23
+ return f"\n{outstr}\n"
24
+
25
+
26
+ def print_banner(msg: str) -> None:
27
+ """Print a message in a banner format."""
28
+ print(banner_string(msg))
29
+
30
+
31
+ def port_is_free(port: int) -> bool:
32
+ """Check if a port is free."""
33
+ import socket
34
+
35
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
36
+ try:
37
+ _ = sock.bind(("localhost", port)) and sock.bind(("0.0.0.0", port))
38
+ return True
39
+ except OSError:
40
+ return False
41
+
42
+
43
+ def find_free_port(start_port: int, end_port: int) -> int | None:
44
+ """Find a free port on the system."""
45
+
46
+ for port in range(start_port, end_port):
47
+ if port_is_free(port):
48
+ return port
49
+ import warnings
50
+
51
+ warnings.warn(
52
+ f"No free port found in the range {start_port}-{end_port}. Using {start_port}."
53
+ )
54
+ return None
55
+
56
+
57
+ def download_emsdk_headers(base_url: str, filepath: Path) -> None:
58
+ """Download EMSDK headers from the specified URL and save to filepath.
59
+
60
+ Args:
61
+ base_url: Base URL of the server (e.g., 'http://localhost:8080')
62
+ filepath: Path where to save the headers ZIP file (must end with .zip)
63
+
64
+ Raises:
65
+ ValueError: If filepath doesn't end with .zip
66
+ RuntimeError: If download fails or server returns error
67
+ """
68
+ if not str(filepath).endswith(".zip"):
69
+ raise ValueError("Filepath must end with .zip")
70
+
71
+ import httpx
72
+
73
+ try:
74
+ timeout = httpx.Timeout(30.0, read=30.0)
75
+ with httpx.stream(
76
+ "GET", f"{base_url}/headers/emsdk", timeout=timeout
77
+ ) as response:
78
+ if response.status_code == 200:
79
+ filepath.parent.mkdir(parents=True, exist_ok=True)
80
+ with open(filepath, "wb") as f:
81
+ for chunk in response.iter_bytes(chunk_size=512000):
82
+ f.write(chunk)
83
+ else:
84
+ raise RuntimeError(
85
+ f"Failed to get EMSDK headers: HTTP {response.status_code}"
86
+ )
87
+ except Exception as e:
88
+ raise RuntimeError(f"Error downloading EMSDK headers: {e}") from e
fastled/version.py ADDED
@@ -0,0 +1,41 @@
1
+ from concurrent.futures import Future, ThreadPoolExecutor
2
+
3
+ import httpx
4
+
5
+ from fastled.__version__ import __version_url_latest__
6
+
7
+
8
+ def _fetch_version() -> str | Exception:
9
+ """
10
+ Helper function to fetch the latest version from the GitHub repository.
11
+ """
12
+ try:
13
+ response = httpx.get(__version_url_latest__)
14
+ response.raise_for_status()
15
+ # Extract the version string from the response text
16
+ version_line = response.text.split("__version__ = ")[1].split('"')[1]
17
+ return version_line
18
+ except Exception as e:
19
+ return e
20
+
21
+
22
+ def get_latest_version() -> Future[str | Exception]:
23
+ """
24
+ Fetch the latest version from the GitHub repository.
25
+ Returns a future that will resolve with the version string or an exception.
26
+ """
27
+ executor = ThreadPoolExecutor()
28
+ return executor.submit(_fetch_version)
29
+
30
+
31
+ def unit_test() -> None:
32
+ future = get_latest_version()
33
+ latest_version = future.result() # Wait for the future to complete
34
+ if isinstance(latest_version, Exception):
35
+ print(f"Error fetching latest version: {latest_version}")
36
+ else:
37
+ print(f"Latest version: {latest_version}")
38
+
39
+
40
+ if __name__ == "__main__":
41
+ unit_test()