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
fastled/sketch.py CHANGED
@@ -25,10 +25,27 @@ def find_sketch_directories(directory: Path | None = None) -> list[Path]:
25
25
  if looks_like_sketch_directory(path, quick=True):
26
26
  sketch_directories.append(path)
27
27
  if dir_name.lower() == "examples":
28
- for example in path.iterdir():
29
- if example.is_dir():
30
- if looks_like_sketch_directory(example, quick=True):
31
- 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)
32
49
  # make relative to cwd
33
50
  sketch_directories = [p.relative_to(directory) for p in sketch_directories]
34
51
  return sketch_directories
@@ -100,3 +117,103 @@ def looks_like_sketch_directory(directory: Path | str | None, quick=False) -> bo
100
117
  if platformini_file:
101
118
  return True
102
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
@@ -10,11 +10,14 @@ def _filter_out_obvious_bad_choices(
10
10
  Filter out strings that are too different from the input string.
11
11
  This is a heuristic and may not be perfect.
12
12
  """
13
- input_chars = set(input_str)
13
+ if not input_str.strip(): # Handle empty input
14
+ return string_list
15
+
16
+ input_chars = set(input_str.lower())
14
17
  filtered_list = []
15
18
  for s in string_list:
16
19
  # Check if at least half of the input characters are in the string
17
- s_chars = set(s)
20
+ s_chars = set(s.lower())
18
21
  common_chars = input_chars.intersection(s_chars)
19
22
  if len(common_chars) >= len(input_chars) / 2:
20
23
  filtered_list.append(s)
@@ -29,8 +32,8 @@ def is_in_order_match(input_str: str, other: str) -> bool:
29
32
  """
30
33
 
31
34
  # Remove spaces from input string for matching
32
- input_chars = [c for c in input_str if c != " "]
33
- other_chars = list(other)
35
+ input_chars = [c.lower() for c in input_str if c != " "]
36
+ other_chars = [c.lower() for c in other]
34
37
  input_index = 0
35
38
  other_index = 0
36
39
  while input_index < len(input_chars) and other_index < len(other_chars):
@@ -53,6 +56,14 @@ def string_diff(
53
56
  def normalize(s: str) -> str:
54
57
  return s.lower() if ignore_case else s
55
58
 
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
+
56
67
  map_string: dict[str, str] = {}
57
68
 
58
69
  if ignore_case:
@@ -60,38 +71,157 @@ def string_diff(
60
71
  else:
61
72
  map_string = {s: s for s in string_list}
62
73
 
74
+ original_string_list = string_list.copy()
63
75
  if ignore_case:
64
76
  string_list = [s.lower() for s in string_list]
65
77
  input_string = input_string.lower()
66
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
+
67
186
  # Apply set membership filtering for queries with 3+ characters
68
- if len(input_string) >= 3:
69
- string_list = _filter_out_obvious_bad_choices(input_string, string_list)
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
70
191
 
71
192
  # Second filter: exact substring filtering if applicable
72
- is_substring = False
73
- for s in string_list:
74
- if input_string in s:
75
- is_substring = True
76
- break
77
-
78
- if is_substring:
79
- string_list = [s for s in string_list if input_string in s]
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
80
201
 
81
202
  # Third filter: in order exact match filtering if applicable.
82
- is_in_order = False
83
- for s in string_list:
84
- if is_in_order_match(input_string, s):
85
- is_in_order = True
86
- break
87
-
88
- if is_in_order:
89
- string_list = [s for s in string_list if is_in_order_match(input_string, s)]
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
90
206
 
207
+ # Calculate distances
91
208
  distances: list[float] = []
92
209
  for s in string_list:
93
210
  dist = fuzz.token_sort_ratio(normalize(input_string), normalize(s))
94
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
+
95
225
  min_distance = min(distances)
96
226
  out: list[tuple[float, str]] = []
97
227
  for i, d in enumerate(distances):
@@ -106,10 +236,16 @@ def string_diff(
106
236
  def string_diff_paths(
107
237
  input_string: str | Path, path_list: list[Path], ignore_case=True
108
238
  ) -> list[tuple[float, Path]]:
109
- string_list = [str(p) for p in path_list]
110
- 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)
111
244
  out: list[tuple[float, Path]] = []
112
245
  for i, j in tmp:
113
- p = Path(j)
114
- 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
115
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
@@ -12,6 +12,10 @@ class CompileResult:
12
12
  stdout: str
13
13
  hash_value: str | None
14
14
  zip_bytes: bytes
15
+ zip_time: float
16
+ libfastled_time: float
17
+ sketch_time: float
18
+ response_processing_time: float
15
19
 
16
20
  def __bool__(self) -> bool:
17
21
  return self.success
fastled/util.py CHANGED
@@ -52,3 +52,37 @@ def find_free_port(start_port: int, end_port: int) -> int | None:
52
52
  f"No free port found in the range {start_port}-{end_port}. Using {start_port}."
53
53
  )
54
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 CHANGED
@@ -1,41 +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()
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()