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/docker_manager.py CHANGED
@@ -3,6 +3,7 @@ New abstraction for Docker management with improved Ctrl+C handling.
3
3
  """
4
4
 
5
5
  import _thread
6
+ import json
6
7
  import os
7
8
  import platform
8
9
  import subprocess
@@ -11,6 +12,7 @@ import threading
11
12
  import time
12
13
  import traceback
13
14
  import warnings
15
+ from dataclasses import dataclass
14
16
  from datetime import datetime, timezone
15
17
  from pathlib import Path
16
18
 
@@ -18,17 +20,22 @@ import docker
18
20
  from appdirs import user_data_dir
19
21
  from disklru import DiskLRUCache
20
22
  from docker.client import DockerClient
23
+ from docker.errors import DockerException, ImageNotFound, NotFound
21
24
  from docker.models.containers import Container
22
25
  from docker.models.images import Image
23
26
  from filelock import FileLock
24
27
 
25
- from fastled.spinner import Spinner
28
+ from fastled.print_filter import PrintFilter, PrintFilterDefault
26
29
 
27
30
  CONFIG_DIR = Path(user_data_dir("fastled", "fastled"))
28
31
  CONFIG_DIR.mkdir(parents=True, exist_ok=True)
29
32
  DB_FILE = CONFIG_DIR / "db.db"
30
33
  DISK_CACHE = DiskLRUCache(str(DB_FILE), 10)
31
34
  _IS_GITHUB = "GITHUB_ACTIONS" in os.environ
35
+ _DEFAULT_BUILD_DIR = "/js/.pio/build"
36
+
37
+
38
+ FORCE_CLEAR: bool = bool(os.environ.get("FASTLED_FORCE_CLEAR", "0") == "1")
32
39
 
33
40
 
34
41
  # Docker uses datetimes in UTC but without the timezone info. If we pass in a tz
@@ -38,6 +45,37 @@ def _utc_now_no_tz() -> datetime:
38
45
  return now.replace(tzinfo=None)
39
46
 
40
47
 
48
+ def set_ramdisk_size(size: str) -> None:
49
+ """Set the tmpfs size for the container."""
50
+ # This is a hack to set the tmpfs size from the environment variable.
51
+ # It should be set in the docker-compose.yml file.
52
+ # If not set, return 25MB.
53
+ try:
54
+ os.environ["TMPFS_SIZE"] = str(size)
55
+ except ValueError:
56
+ os.environ["TMPFS_SIZE"] = "0" # Defaults to off
57
+
58
+
59
+ def get_ramdisk_size() -> str | None:
60
+ """Get the tmpfs size for the container."""
61
+ # This is a hack to get the tmpfs size from the environment variable.
62
+ # It should be set in the docker-compose.yml file.
63
+ # If not set, return 25MB.
64
+ try:
65
+ return os.environ.get("TMPFS_SIZE", None)
66
+ except ValueError:
67
+ return None # Defaults to off
68
+
69
+
70
+ def get_force_remove_image_previous() -> bool:
71
+ """Get the force remove image previous value."""
72
+ return os.environ.get("FASTLED_FORCE_CLEAR", "0") == "1"
73
+
74
+
75
+ def set_clear() -> None:
76
+ os.environ["FASTLED_FORCE_CLEAR"] = "1"
77
+
78
+
41
79
  def _win32_docker_location() -> str | None:
42
80
  home_dir = Path.home()
43
81
  out = [
@@ -57,11 +95,59 @@ def get_lock(image_name: str) -> FileLock:
57
95
  print(CONFIG_DIR)
58
96
  if not lock_file.parent.exists():
59
97
  lock_file.parent.mkdir(parents=True, exist_ok=True)
60
- return FileLock(str(lock_file))
98
+ out: FileLock
99
+ out = FileLock(str(lock_file)) # type: ignore
100
+ return out
101
+
102
+
103
+ @dataclass
104
+ class Volume:
105
+ """
106
+ Represents a Docker volume mapping between host and container.
107
+
108
+ Attributes:
109
+ host_path: Path on the host system (e.g., "C:\\Users\\username\\project")
110
+ container_path: Path inside the container (e.g., "/app/data")
111
+ mode: Access mode, "rw" for read-write or "ro" for read-only
112
+ """
113
+
114
+ host_path: str
115
+ container_path: str
116
+ mode: str = "rw"
117
+
118
+ def to_dict(self) -> dict[str, dict[str, str]]:
119
+ """Convert the Volume object to the format expected by Docker API."""
120
+ return {self.host_path: {"bind": self.container_path, "mode": self.mode}}
121
+
122
+ @classmethod
123
+ def from_dict(cls, volume_dict: dict[str, dict[str, str]]) -> list["Volume"]:
124
+ """Create Volume objects from a Docker volume dictionary."""
125
+ volumes = []
126
+ for host_path, config in volume_dict.items():
127
+ volumes.append(
128
+ cls(
129
+ host_path=host_path,
130
+ container_path=config["bind"],
131
+ mode=config.get("mode", "rw"),
132
+ )
133
+ )
134
+ return volumes
135
+
136
+
137
+ # Override the default PrintFilter to use a custom one.
138
+ def make_default_print_filter() -> PrintFilter:
139
+ """Create a default PrintFilter instance."""
140
+ return PrintFilterDefault()
61
141
 
62
142
 
63
143
  class RunningContainer:
64
- def __init__(self, container, first_run=False):
144
+ def __init__(
145
+ self,
146
+ container: Container,
147
+ first_run: bool = False,
148
+ filter: PrintFilter | None = None,
149
+ ) -> None:
150
+ self.filter = filter or make_default_print_filter()
65
151
  self.container = container
66
152
  self.first_run = first_run
67
153
  self.running = True
@@ -78,7 +164,8 @@ class RunningContainer:
78
164
  for log in self.container.logs(
79
165
  follow=False, since=from_date, until=to_date, stream=True
80
166
  ):
81
- print(log.decode("utf-8"), end="")
167
+ # print(log.decode("utf-8"), end="")
168
+ self.filter.print(log)
82
169
  time.sleep(0.1)
83
170
  from_date = to_date
84
171
  to_date = _utc_now_no_tz()
@@ -95,11 +182,64 @@ class RunningContainer:
95
182
  self.running = False
96
183
  self.thread.join()
97
184
 
185
+ def stop(self) -> None:
186
+ """Stop the container"""
187
+ self.container.stop()
188
+ self.detach()
189
+
190
+
191
+ def _hack_to_fix_mac(volumes: list[Volume] | None) -> list[Volume] | None:
192
+ """Fixes the volume mounts on MacOS by removing the mode."""
193
+ if volumes is None:
194
+ return None
195
+ if sys.platform != "darwin":
196
+ # Only macos needs hacking.
197
+ return volumes
198
+
199
+ volumes = volumes.copy()
200
+ # Work around a Docker bug on MacOS where the expected network socket to the
201
+ # the host is not mounted correctly. This was actually fixed in recent versions
202
+ # of docker client but there is a large chunk of Docker clients out there with
203
+ # this bug in it.
204
+ #
205
+ # This hack is done by mounting the socket directly to the container.
206
+ # This socket talks to the docker daemon on the host.
207
+ #
208
+ # Found here.
209
+ # https://github.com/docker/docker-py/issues/3069#issuecomment-1316778735
210
+ # if it exists already then return the input
211
+ for volume in volumes:
212
+ if volume.host_path == "/var/run/docker.sock":
213
+ return volumes
214
+ # ok it doesn't exist, so add it
215
+ volumes.append(
216
+ Volume(
217
+ host_path="/var/run/docker.sock",
218
+ container_path="/var/run/docker.sock",
219
+ mode="rw",
220
+ )
221
+ )
222
+ return volumes
223
+
224
+
225
+ def set_force_remove_image_previous(new_value: str | None = None) -> None:
226
+ if new_value is not None:
227
+ os.environ["FASTLED_FORCE_CLEAR"] = new_value
228
+ else:
229
+ os.environ["FASTLED_FORCE_CLEAR"] = "1"
230
+
231
+
232
+ def force_image_removal() -> bool:
233
+ """Get the force remove image previous value."""
234
+ return os.environ.get("FASTLED_FORCE_CLEAR", "0") == "1"
235
+
98
236
 
99
237
  class DockerManager:
100
238
  def __init__(self) -> None:
101
239
  from docker.errors import DockerException
102
240
 
241
+ self.is_suspended: bool = False
242
+
103
243
  try:
104
244
  self._client: DockerClient | None = None
105
245
  self.first_run = False
@@ -111,7 +251,34 @@ class DockerManager:
111
251
  @property
112
252
  def client(self) -> DockerClient:
113
253
  if self._client is None:
114
- self._client = docker.from_env()
254
+ # Retry logic for WSL startup on Windows
255
+ max_retries = 10
256
+ retry_delay = 2 # seconds
257
+ last_error = None
258
+
259
+ for attempt in range(max_retries):
260
+ try:
261
+ self._client = docker.from_env()
262
+ if attempt > 0:
263
+ print(
264
+ f"Successfully connected to Docker after {attempt + 1} attempts"
265
+ )
266
+ return self._client
267
+ except DockerException as e:
268
+ last_error = e
269
+ if attempt < max_retries - 1:
270
+ if attempt == 0:
271
+ print("Waiting for Docker/WSL to be ready...")
272
+ print(
273
+ f"Attempt {attempt + 1}/{max_retries} failed, retrying in {retry_delay}s..."
274
+ )
275
+ time.sleep(retry_delay)
276
+ else:
277
+ print(
278
+ f"Failed to connect to Docker after {max_retries} attempts"
279
+ )
280
+ raise last_error
281
+ assert self._client is not None
115
282
  return self._client
116
283
 
117
284
  @staticmethod
@@ -181,10 +348,6 @@ class DockerManager:
181
348
  )
182
349
  return False
183
350
 
184
- except subprocess.CalledProcessError as e:
185
- print(f"Error occurred: {e}")
186
- return False
187
-
188
351
  except subprocess.CalledProcessError as e:
189
352
  print(f"Failed to switch to Linux containers: {e}")
190
353
  if e.stdout:
@@ -197,22 +360,24 @@ class DockerManager:
197
360
  return False
198
361
 
199
362
  @staticmethod
200
- def is_running() -> bool:
363
+ def is_running() -> tuple[bool, Exception | None]:
201
364
  """Check if Docker is running by pinging the Docker daemon."""
365
+
202
366
  if not DockerManager.is_docker_installed():
203
- return False
367
+ print("Docker is not installed.")
368
+ return False, Exception("Docker is not installed.")
204
369
  try:
205
370
  # self.client.ping()
206
371
  client = docker.from_env()
207
372
  client.ping()
208
373
  print("Docker is running.")
209
- return True
210
- except docker.errors.DockerException as e:
374
+ return True, None
375
+ except DockerException as e:
211
376
  print(f"Docker is not running: {str(e)}")
212
- return False
377
+ return False, e
213
378
  except Exception as e:
214
379
  print(f"Error pinging Docker daemon: {str(e)}")
215
- return False
380
+ return False, e
216
381
 
217
382
  def start(self) -> bool:
218
383
  """Attempt to start Docker Desktop (or the Docker daemon) automatically."""
@@ -226,7 +391,8 @@ class DockerManager:
226
391
  return False
227
392
  subprocess.run(["start", "", docker_path], shell=True)
228
393
  elif sys.platform == "darwin":
229
- subprocess.run(["open", "/Applications/Docker.app"])
394
+ subprocess.run(["open", "-a", "Docker"])
395
+ time.sleep(2) # Give Docker time to start
230
396
  elif sys.platform.startswith("linux"):
231
397
  subprocess.run(["sudo", "systemctl", "start", "docker"])
232
398
  else:
@@ -261,9 +427,69 @@ class DockerManager:
261
427
  print(f"Error starting Docker: {str(e)}")
262
428
  return False
263
429
 
430
+ def has_newer_version(
431
+ self, image_name: str, tag: str = "latest"
432
+ ) -> tuple[bool, str]:
433
+ """
434
+ Check if a newer version of the image is available in the registry.
435
+
436
+ Args:
437
+ image_name: The name of the image to check
438
+ tag: The tag of the image to check
439
+
440
+ Returns:
441
+ A tuple of (has_newer_version, message)
442
+ has_newer_version: True if a newer version is available, False otherwise
443
+ message: A message describing the result, including the date of the newer version if available
444
+ """
445
+ try:
446
+ # Get the local image
447
+ local_image = self.client.images.get(f"{image_name}:{tag}")
448
+ local_image_id = local_image.id
449
+ assert local_image_id is not None
450
+
451
+ # Get the remote image data
452
+ remote_image = self.client.images.get_registry_data(f"{image_name}:{tag}")
453
+ remote_image_hash = remote_image.id
454
+
455
+ # Check if we have a cached remote hash for this local image
456
+ try:
457
+ remote_image_hash_from_local_image = DISK_CACHE.get(local_image_id)
458
+ except Exception:
459
+ remote_image_hash_from_local_image = None
460
+
461
+ # Compare the hashes
462
+ if remote_image_hash_from_local_image == remote_image_hash:
463
+ return False, f"Local image {image_name}:{tag} is up to date."
464
+ else:
465
+ # Get the creation date of the remote image if possible
466
+ try:
467
+ # Try to get detailed image info including creation date
468
+ remote_image_details = self.client.api.inspect_image(
469
+ f"{image_name}:{tag}"
470
+ )
471
+ if "Created" in remote_image_details:
472
+ created_date = remote_image_details["Created"].split("T")[
473
+ 0
474
+ ] # Extract just the date part
475
+ return (
476
+ True,
477
+ f"Newer version of {image_name}:{tag} is available (published on {created_date}).",
478
+ )
479
+ except Exception:
480
+ pass
481
+
482
+ # Fallback if we couldn't get the date
483
+ return True, f"Newer version of {image_name}:{tag} is available."
484
+
485
+ except ImageNotFound:
486
+ return True, f"Image {image_name}:{tag} not found locally."
487
+ except DockerException as e:
488
+ return False, f"Error checking for newer version: {e}"
489
+
264
490
  def validate_or_download_image(
265
491
  self, image_name: str, tag: str = "latest", upgrade: bool = False
266
- ) -> None:
492
+ ) -> bool:
267
493
  """
268
494
  Validate if the image exists, and if not, download it.
269
495
  If upgrade is True, will pull the latest version even if image exists locally.
@@ -289,8 +515,10 @@ class DockerManager:
289
515
  remote_image_hash = remote_image.id
290
516
 
291
517
  try:
518
+ local_image_id = local_image.id
519
+ assert local_image_id is not None
292
520
  remote_image_hash_from_local_image = DISK_CACHE.get(
293
- local_image.id
521
+ local_image_id
294
522
  )
295
523
  except KeyboardInterrupt:
296
524
  raise
@@ -302,34 +530,32 @@ class DockerManager:
302
530
  )
303
531
  if remote_image_hash_from_local_image == remote_image_hash:
304
532
  print(f"Local image {image_name}:{tag} is up to date.")
305
- return
533
+ return False
306
534
 
307
535
  # Quick check for latest version
308
- with Spinner(f"Pulling newer version of {image_name}:{tag}..."):
309
- # This needs to be swapped out using the the command line interface AI!
310
- # _ = self.client.images.pull(image_name, tag=tag)
311
- cmd_list = ["docker", "pull", f"{image_name}:{tag}"]
312
- cmd_str = subprocess.list2cmdline(cmd_list)
313
- print(f"Running command: {cmd_str}")
314
- subprocess.run(cmd_list, check=True)
536
+ print(f"Pulling newer version of {image_name}:{tag}...")
537
+ cmd_list = ["docker", "pull", f"{image_name}:{tag}"]
538
+ subprocess.run(cmd_list, check=True)
315
539
  print(f"Updated to newer version of {image_name}:{tag}")
316
540
  local_image_hash = self.client.images.get(f"{image_name}:{tag}").id
541
+ assert local_image_hash is not None
317
542
  if remote_image_hash is not None:
318
543
  DISK_CACHE.put(local_image_hash, remote_image_hash)
544
+ return True
319
545
 
320
- except docker.errors.ImageNotFound:
546
+ except ImageNotFound:
321
547
  print(f"Image {image_name}:{tag} not found.")
322
- with Spinner("Loading "):
323
- cmd_list = ["docker", "pull", f"{image_name}:{tag}"]
324
- cmd_str = subprocess.list2cmdline(cmd_list)
325
- print(f"Running command: {cmd_str}")
326
- subprocess.run(cmd_list, check=True)
548
+ print("Loading...")
549
+ # We use docker cli here because it shows the download.
550
+ cmd_list = ["docker", "pull", f"{image_name}:{tag}"]
551
+ subprocess.run(cmd_list, check=True)
327
552
  try:
328
553
  local_image = self.client.images.get(f"{image_name}:{tag}")
329
554
  local_image_hash = local_image.id
330
555
  print(f"Image {image_name}:{tag} downloaded successfully.")
331
- except docker.errors.ImageNotFound:
556
+ except ImageNotFound:
332
557
  warnings.warn(f"Image {image_name}:{tag} not found after download.")
558
+ return True
333
559
 
334
560
  def tag_image(self, image_name: str, old_tag: str, new_tag: str) -> None:
335
561
  """
@@ -343,14 +569,17 @@ class DockerManager:
343
569
  self,
344
570
  container: Container,
345
571
  command: str | None,
346
- volumes: dict | None,
347
- ports: dict | None,
572
+ volumes_dict: dict[str, dict[str, str]] | None,
573
+ ports: dict[int, int] | None,
348
574
  ) -> bool:
349
575
  """Compare if existing container has matching configuration"""
350
576
  try:
351
577
  # Check if container is using the same image
352
- container_image_id = container.image.id
353
- container_image_tags = container.image.tags
578
+ image = container.image
579
+ assert image is not None
580
+ container_image_id = image.id
581
+ container_image_tags = image.tags
582
+ assert container_image_id is not None
354
583
 
355
584
  # Simplified image comparison - just compare the IDs directly
356
585
  if not container_image_tags:
@@ -371,7 +600,7 @@ class DockerManager:
371
600
  return False
372
601
 
373
602
  # Check volumes if specified
374
- if volumes:
603
+ if volumes_dict:
375
604
  container_mounts = (
376
605
  {
377
606
  m["Source"]: {"bind": m["Destination"], "mode": m["Mode"]}
@@ -381,7 +610,7 @@ class DockerManager:
381
610
  else {}
382
611
  )
383
612
 
384
- for host_dir, mount in volumes.items():
613
+ for host_dir, mount in volumes_dict.items():
385
614
  if host_dir not in container_mounts:
386
615
  print(f"Volume {host_dir} not found in container mounts.")
387
616
  return False
@@ -416,7 +645,7 @@ class DockerManager:
416
645
  return False
417
646
  except KeyboardInterrupt:
418
647
  raise
419
- except docker.errors.NotFound:
648
+ except NotFound:
420
649
  print("Container not found.")
421
650
  return False
422
651
  except Exception as e:
@@ -431,20 +660,43 @@ class DockerManager:
431
660
  tag: str,
432
661
  container_name: str,
433
662
  command: str | None = None,
434
- volumes: dict[str, dict[str, str]] | None = None,
663
+ volumes: list[Volume] | None = None,
435
664
  ports: dict[int, int] | None = None,
436
665
  remove_previous: bool = False,
666
+ environment: dict[str, str] | None = None,
667
+ tmpfs_size: str | None = None, # suffixed like 25mb.
437
668
  ) -> Container:
438
669
  """
439
670
  Run a container from an image. If it already exists with matching config, start it.
440
671
  If it exists with different config, remove and recreate it.
441
672
 
442
673
  Args:
443
- volumes: Dict mapping host paths to dicts with 'bind' and 'mode' keys
444
- Example: {'/host/path': {'bind': '/container/path', 'mode': 'rw'}}
674
+ volumes: List of Volume objects for container volume mappings
445
675
  ports: Dict mapping host ports to container ports
446
676
  Example: {8080: 80} maps host port 8080 to container port 80
447
677
  """
678
+ remove_previous = remove_previous or get_force_remove_image_previous()
679
+ if get_force_remove_image_previous():
680
+ # make a banner print
681
+ print(
682
+ "Force removing previous image due to FASTLED_FORCE_CLEAR environment variable."
683
+ )
684
+
685
+ tmpfs_size = tmpfs_size or get_ramdisk_size()
686
+ sys_admin = tmpfs_size is not None and tmpfs_size != "0"
687
+ volumes = _hack_to_fix_mac(volumes)
688
+ # Convert volumes to the format expected by Docker API
689
+ volumes_dict = None
690
+ if volumes is not None:
691
+ volumes_dict = {}
692
+ for volume in volumes:
693
+ volumes_dict.update(volume.to_dict())
694
+
695
+ # Serialize the volumes to a json string
696
+ if volumes_dict:
697
+ volumes_str = json.dumps(volumes_dict)
698
+ print(f"Volumes: {volumes_str}")
699
+ print("Done")
448
700
  image_name = f"{image_name}:{tag}"
449
701
  try:
450
702
  container: Container = self.client.containers.get(container_name)
@@ -452,14 +704,16 @@ class DockerManager:
452
704
  if remove_previous:
453
705
  print(f"Removing existing container {container_name}...")
454
706
  container.remove(force=True)
455
- raise docker.errors.NotFound("Container removed due to remove_previous")
707
+ raise NotFound("Container removed due to remove_previous")
456
708
  # Check if configuration matches
457
- elif not self._container_configs_match(container, command, volumes, ports):
709
+ elif not self._container_configs_match(
710
+ container, command, volumes_dict, ports
711
+ ):
458
712
  print(
459
713
  f"Container {container_name} exists but with different configuration. Removing and recreating..."
460
714
  )
461
715
  container.remove(force=True)
462
- raise docker.errors.NotFound("Container removed due to config mismatch")
716
+ raise NotFound("Container removed due to config mismatch")
463
717
  print(f"Container {container_name} found with matching configuration.")
464
718
 
465
719
  # Existing container with matching config - handle various states
@@ -489,21 +743,28 @@ class DockerManager:
489
743
  print(f"Starting existing container {container_name}.")
490
744
  self.first_run = True
491
745
  container.start()
492
- except docker.errors.NotFound:
746
+ except NotFound:
493
747
  print(f"Creating and starting {container_name}")
494
748
  out_msg = f"# Running in container: {command}"
495
749
  msg_len = len(out_msg)
496
750
  print("\n" + "#" * msg_len)
497
751
  print(out_msg)
498
752
  print("#" * msg_len + "\n")
753
+
754
+ tmpfs: dict[str, str] | None = None
755
+ if tmpfs_size:
756
+ tmpfs = {_DEFAULT_BUILD_DIR: f"size={tmpfs_size}"}
499
757
  container = self.client.containers.run(
500
- image_name,
501
- command,
758
+ image=image_name,
759
+ command=command,
502
760
  name=container_name,
761
+ tmpfs=tmpfs,
762
+ cap_add=["SYS_ADMIN"] if sys_admin else None,
503
763
  detach=True,
504
764
  tty=True,
505
- volumes=volumes,
506
- ports=ports,
765
+ volumes=volumes_dict,
766
+ ports=ports, # type: ignore
767
+ environment=environment,
507
768
  remove=True,
508
769
  )
509
770
  return container
@@ -514,14 +775,22 @@ class DockerManager:
514
775
  tag: str,
515
776
  container_name: str,
516
777
  command: str | None = None,
517
- volumes: dict[str, dict[str, str]] | None = None,
778
+ volumes: list[Volume] | None = None,
518
779
  ports: dict[int, int] | None = None,
780
+ environment: dict[str, str] | None = None,
519
781
  ) -> None:
782
+ # Convert volumes to the format expected by Docker API
783
+ volumes = _hack_to_fix_mac(volumes)
784
+ volumes_dict = None
785
+ if volumes is not None:
786
+ volumes_dict = {}
787
+ for volume in volumes:
788
+ volumes_dict.update(volume.to_dict())
520
789
  # Remove existing container
521
790
  try:
522
791
  container: Container = self.client.containers.get(container_name)
523
792
  container.remove(force=True)
524
- except docker.errors.NotFound:
793
+ except NotFound:
525
794
  pass
526
795
  start_time = time.time()
527
796
  try:
@@ -533,12 +802,19 @@ class DockerManager:
533
802
  "--name",
534
803
  container_name,
535
804
  ]
536
- if volumes:
537
- for host_dir, mount in volumes.items():
538
- docker_command.extend(["-v", f"{host_dir}:{mount['bind']}"])
805
+ if volumes_dict:
806
+ for host_dir, mount in volumes_dict.items():
807
+ docker_volume_arg = [
808
+ "-v",
809
+ f"{host_dir}:{mount['bind']}:{mount['mode']}",
810
+ ]
811
+ docker_command.extend(docker_volume_arg)
539
812
  if ports:
540
813
  for host_port, container_port in ports.items():
541
814
  docker_command.extend(["-p", f"{host_port}:{container_port}"])
815
+ if environment:
816
+ for env_name, env_value in environment.items():
817
+ docker_command.extend(["-e", f"{env_name}={env_value}"])
542
818
  docker_command.append(f"{image_name}:{tag}")
543
819
  if command:
544
820
  docker_command.append(command)
@@ -558,7 +834,10 @@ class DockerManager:
558
834
  Returns a RunningContainer object that can be used to stop monitoring.
559
835
  """
560
836
  if isinstance(container, str):
561
- container = self.get_container(container)
837
+ container_name = container
838
+ tmp = self.get_container(container)
839
+ assert tmp is not None, f"Container {container_name} not found."
840
+ container = tmp
562
841
 
563
842
  assert container is not None, "Container not found."
564
843
 
@@ -573,12 +852,17 @@ class DockerManager:
573
852
  """
574
853
  Suspend (pause) the container.
575
854
  """
855
+ if self.is_suspended:
856
+ return
576
857
  if isinstance(container, str):
577
858
  container_name = container
578
- container = self.get_container(container)
579
- if not container:
859
+ # container = self.get_container(container)
860
+ tmp = self.get_container(container_name)
861
+ if not tmp:
580
862
  print(f"Could not put container {container_name} to sleep.")
581
863
  return
864
+ container = tmp
865
+ assert isinstance(container, Container)
582
866
  try:
583
867
  if platform.system() == "Windows":
584
868
  container.pause()
@@ -595,16 +879,27 @@ class DockerManager:
595
879
  """
596
880
  Resume (unpause) the container.
597
881
  """
882
+ container_name = "UNKNOWN"
598
883
  if isinstance(container, str):
599
- container = self.get_container(container)
884
+ container_name = container
885
+ container_or_none = self.get_container(container)
886
+ if container_or_none is None:
887
+ print(f"Could not resume container {container}.")
888
+ return
889
+ container = container_or_none
890
+ container_name = container.name
891
+ elif isinstance(container, Container):
892
+ container_name = container.name
893
+ assert isinstance(container, Container)
600
894
  if not container:
601
895
  print(f"Could not resume container {container}.")
602
896
  return
603
897
  try:
898
+ assert isinstance(container, Container)
604
899
  container.unpause()
605
900
  print(f"Container {container.name} has been resumed.")
606
901
  except Exception as e:
607
- print(f"Failed to resume container {container.name}: {e}")
902
+ print(f"Failed to resume container {container_name}: {e}")
608
903
 
609
904
  def get_container(self, container_name: str) -> Container | None:
610
905
  """
@@ -612,7 +907,7 @@ class DockerManager:
612
907
  """
613
908
  try:
614
909
  return self.client.containers.get(container_name)
615
- except docker.errors.NotFound:
910
+ except NotFound:
616
911
  return None
617
912
 
618
913
  def is_container_running(self, container_name: str) -> bool:
@@ -622,7 +917,7 @@ class DockerManager:
622
917
  try:
623
918
  container = self.client.containers.get(container_name)
624
919
  return container.status == "running"
625
- except docker.errors.NotFound:
920
+ except NotFound:
626
921
  print(f"Container {container_name} not found.")
627
922
  return False
628
923
 
@@ -694,7 +989,9 @@ class DockerManager:
694
989
  print("Error decoding line")
695
990
  rtn = proc.wait()
696
991
  if rtn != 0:
697
- print(f"Error building Docker image: {rtn}")
992
+ warnings.warn(
993
+ f"Error building Docker image, is docker running? {rtn}, stdout: {stdout}, stderr: {proc.stderr}"
994
+ )
698
995
  raise subprocess.CalledProcessError(rtn, cmd_str)
699
996
  print(f"Successfully built image {image_name}:{tag}")
700
997
 
@@ -705,6 +1002,7 @@ class DockerManager:
705
1002
  def purge(self, image_name: str) -> None:
706
1003
  """
707
1004
  Remove all containers and images associated with the given image name.
1005
+ Also removes FastLED containers by name pattern (including test containers).
708
1006
 
709
1007
  Args:
710
1008
  image_name: The name of the image to purge (without tag)
@@ -715,8 +1013,27 @@ class DockerManager:
715
1013
  try:
716
1014
  containers = self.client.containers.list(all=True)
717
1015
  for container in containers:
1016
+ should_remove = False
1017
+
1018
+ # Check if container uses the specified image
718
1019
  if any(image_name in tag for tag in container.image.tags):
719
- print(f"Removing container {container.name}")
1020
+ should_remove = True
1021
+ print(
1022
+ f"Removing container {container.name} (uses image {image_name})"
1023
+ )
1024
+
1025
+ # Also check for FastLED container name patterns (including test containers)
1026
+ elif any(
1027
+ pattern in container.name
1028
+ for pattern in [
1029
+ "fastled-wasm-container",
1030
+ "fastled-wasm-container-test",
1031
+ ]
1032
+ ):
1033
+ should_remove = True
1034
+ print(f"Removing FastLED container {container.name}")
1035
+
1036
+ if should_remove:
720
1037
  container.remove(force=True)
721
1038
 
722
1039
  except Exception as e:
@@ -734,7 +1051,7 @@ class DockerManager:
734
1051
  print(f"Error removing images: {e}")
735
1052
 
736
1053
 
737
- def main():
1054
+ def main() -> None:
738
1055
  # Register SIGINT handler
739
1056
  # signal.signal(signal.SIGINT, handle_sigint)
740
1057
 
@@ -746,6 +1063,7 @@ def main():
746
1063
  # new_tag = "my-python"
747
1064
  container_name = "my-python-container"
748
1065
  command = "python -m http.server"
1066
+ running_container: RunningContainer | None = None
749
1067
 
750
1068
  try:
751
1069
  # Step 1: Validate or download the image
@@ -768,13 +1086,18 @@ def main():
768
1086
 
769
1087
  except KeyboardInterrupt:
770
1088
  print("\nStopping container...")
771
- running_container.stop()
772
- container = docker_manager.get_container(container_name)
773
- docker_manager.suspend_container(container)
1089
+ if isinstance(running_container, RunningContainer):
1090
+ running_container.stop()
1091
+ container_or_none = docker_manager.get_container(container_name)
1092
+ if container_or_none is not None:
1093
+ docker_manager.suspend_container(container_or_none)
1094
+ else:
1095
+ warnings.warn(f"Container {container_name} not found.")
774
1096
 
775
1097
  try:
776
1098
  # Suspend and resume the container
777
1099
  container = docker_manager.get_container(container_name)
1100
+ assert container is not None, "Container not found."
778
1101
  docker_manager.suspend_container(container)
779
1102
 
780
1103
  input("Press Enter to resume the container...")