fastled 1.1.45__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.
@@ -0,0 +1,665 @@
1
+ """
2
+ New abstraction for Docker management with improved Ctrl+C handling.
3
+ """
4
+
5
+ import _thread
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ import threading
10
+ import time
11
+ import traceback
12
+ import warnings
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+
16
+ import docker
17
+ from appdirs import user_data_dir
18
+ from disklru import DiskLRUCache
19
+ from docker.client import DockerClient
20
+ from docker.models.containers import Container
21
+ from docker.models.images import Image
22
+ from filelock import FileLock
23
+
24
+ from fastled.spinner import Spinner
25
+
26
+ CONFIG_DIR = Path(user_data_dir("fastled", "fastled"))
27
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
28
+ DB_FILE = CONFIG_DIR / "db.db"
29
+ DISK_CACHE = DiskLRUCache(str(DB_FILE), 10)
30
+ _IS_GITHUB = "GITHUB_ACTIONS" in os.environ
31
+
32
+
33
+ # Docker uses datetimes in UTC but without the timezone info. If we pass in a tz
34
+ # then it will throw an exception.
35
+ def _utc_now_no_tz() -> datetime:
36
+ now = datetime.now(timezone.utc)
37
+ return now.replace(tzinfo=None)
38
+
39
+
40
+ def _win32_docker_location() -> str | None:
41
+ home_dir = Path.home()
42
+ out = [
43
+ "C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe",
44
+ f"{home_dir}\\AppData\\Local\\Docker\\Docker Desktop.exe",
45
+ ]
46
+ for loc in out:
47
+ if Path(loc).exists():
48
+ return loc
49
+ return None
50
+
51
+
52
+ def get_lock(image_name: str) -> FileLock:
53
+ """Get the file lock for this DockerManager instance."""
54
+ lock_file = CONFIG_DIR / f"{image_name}.lock"
55
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
56
+ print(CONFIG_DIR)
57
+ if not lock_file.parent.exists():
58
+ lock_file.parent.mkdir(parents=True, exist_ok=True)
59
+ return FileLock(str(lock_file))
60
+
61
+
62
+ class RunningContainer:
63
+ def __init__(self, container, first_run=False):
64
+ self.container = container
65
+ self.first_run = first_run
66
+ self.running = True
67
+ self.thread = threading.Thread(target=self._log_monitor)
68
+ self.thread.daemon = True
69
+ self.thread.start()
70
+
71
+ def _log_monitor(self):
72
+ from_date = _utc_now_no_tz() if not self.first_run else None
73
+ to_date = _utc_now_no_tz()
74
+
75
+ while self.running:
76
+ try:
77
+ for log in self.container.logs(
78
+ follow=False, since=from_date, until=to_date, stream=True
79
+ ):
80
+ print(log.decode("utf-8"), end="")
81
+ time.sleep(0.1)
82
+ from_date = to_date
83
+ to_date = _utc_now_no_tz()
84
+ except KeyboardInterrupt:
85
+ print("Monitoring logs interrupted by user.")
86
+ _thread.interrupt_main()
87
+ break
88
+ except Exception as e:
89
+ print(f"Error monitoring logs: {e}")
90
+ break
91
+
92
+ def detach(self) -> None:
93
+ """Stop monitoring the container logs"""
94
+ self.running = False
95
+ self.thread.join()
96
+
97
+
98
+ class DockerManager:
99
+ def __init__(self) -> None:
100
+ try:
101
+ self._client: DockerClient | None = None
102
+ self.first_run = False
103
+ except docker.errors.DockerException as e:
104
+ stack = traceback.format_exc()
105
+ warnings.warn(f"Error initializing Docker client: {e}\n{stack}")
106
+ raise
107
+
108
+ @property
109
+ def client(self) -> DockerClient:
110
+ if self._client is None:
111
+ self._client = docker.from_env()
112
+ return self._client
113
+
114
+ @staticmethod
115
+ def is_docker_installed() -> bool:
116
+ """Check if Docker is installed on the system."""
117
+ try:
118
+ subprocess.run(["docker", "--version"], capture_output=True, check=True)
119
+ print("Docker is installed.")
120
+ return True
121
+ except subprocess.CalledProcessError as e:
122
+ print(f"Docker command failed: {str(e)}")
123
+ return False
124
+ except FileNotFoundError:
125
+ print("Docker is not installed.")
126
+ return False
127
+
128
+ @staticmethod
129
+ def ensure_linux_containers_for_windows() -> bool:
130
+ """Ensure Docker is using Linux containers on Windows."""
131
+ if sys.platform != "win32":
132
+ return True # Only needed on Windows
133
+
134
+ try:
135
+ # Check if we're already in Linux container mode
136
+ result = subprocess.run(
137
+ ["docker", "info"], capture_output=True, text=True, check=True
138
+ )
139
+
140
+ if "linux" in result.stdout.lower():
141
+ print("Already using Linux containers")
142
+ return True
143
+
144
+ if not _IS_GITHUB:
145
+ answer = (
146
+ input(
147
+ "\nDocker on Windows must be in linux mode, this is a global change, switch? [y/n]"
148
+ )
149
+ .strip()
150
+ .lower()[:1]
151
+ )
152
+ if answer != "y":
153
+ return False
154
+
155
+ print("Switching to Linux containers...")
156
+ warnings.warn("Switching Docker to use Linux container context...")
157
+
158
+ # Explicitly specify the Linux container context
159
+ linux_context = "desktop-linux"
160
+ subprocess.run(
161
+ ["cmd", "/c", f"docker context use {linux_context}"],
162
+ check=True,
163
+ capture_output=True,
164
+ )
165
+
166
+ # Verify the switch worked
167
+ verify = subprocess.run(
168
+ ["docker", "info"], capture_output=True, text=True, check=True
169
+ )
170
+ if "linux" in verify.stdout.lower():
171
+ print(
172
+ f"Successfully switched to Linux containers using '{linux_context}' context"
173
+ )
174
+ return True
175
+
176
+ warnings.warn(
177
+ f"Failed to switch to Linux containers with context '{linux_context}': {verify.stdout}"
178
+ )
179
+ return False
180
+
181
+ except subprocess.CalledProcessError as e:
182
+ print(f"Error occurred: {e}")
183
+ return False
184
+
185
+ except subprocess.CalledProcessError as e:
186
+ print(f"Failed to switch to Linux containers: {e}")
187
+ if e.stdout:
188
+ print(f"stdout: {e.stdout}")
189
+ if e.stderr:
190
+ print(f"stderr: {e.stderr}")
191
+ return False
192
+ except Exception as e:
193
+ print(f"Unexpected error switching to Linux containers: {e}")
194
+ return False
195
+
196
+ @staticmethod
197
+ def is_running() -> bool:
198
+ """Check if Docker is running by pinging the Docker daemon."""
199
+ if not DockerManager.is_docker_installed():
200
+ return False
201
+ try:
202
+ # self.client.ping()
203
+ client = docker.from_env()
204
+ client.ping()
205
+ print("Docker is running.")
206
+ return True
207
+ except docker.errors.DockerException as e:
208
+ print(f"Docker is not running: {str(e)}")
209
+ return False
210
+ except Exception as e:
211
+ print(f"Error pinging Docker daemon: {str(e)}")
212
+ return False
213
+
214
+ def start(self) -> bool:
215
+ """Attempt to start Docker Desktop (or the Docker daemon) automatically."""
216
+ print("Attempting to start Docker...")
217
+
218
+ try:
219
+ if sys.platform == "win32":
220
+ docker_path = _win32_docker_location()
221
+ if not docker_path:
222
+ print("Docker Desktop not found.")
223
+ return False
224
+ subprocess.run(["start", "", docker_path], shell=True)
225
+ elif sys.platform == "darwin":
226
+ subprocess.run(["open", "/Applications/Docker.app"])
227
+ elif sys.platform.startswith("linux"):
228
+ subprocess.run(["sudo", "systemctl", "start", "docker"])
229
+ else:
230
+ print("Unknown platform. Cannot auto-launch Docker.")
231
+ return False
232
+
233
+ # Wait for Docker to start up with increasing delays
234
+ print("Waiting for Docker Desktop to start...")
235
+ attempts = 0
236
+ max_attempts = 20 # Increased max wait time
237
+ while attempts < max_attempts:
238
+ attempts += 1
239
+ if self.is_running():
240
+ print("Docker started successfully.")
241
+ return True
242
+
243
+ # Gradually increase wait time between checks
244
+ wait_time = min(5, 1 + attempts * 0.5)
245
+ print(
246
+ f"Docker not ready yet, waiting {wait_time:.1f}s... (attempt {attempts}/{max_attempts})"
247
+ )
248
+ time.sleep(wait_time)
249
+
250
+ print("Failed to start Docker within the expected time.")
251
+ print(
252
+ "Please try starting Docker Desktop manually and run this command again."
253
+ )
254
+ except KeyboardInterrupt:
255
+ print("Aborted by user.")
256
+ raise
257
+ except Exception as e:
258
+ print(f"Error starting Docker: {str(e)}")
259
+ return False
260
+
261
+ def validate_or_download_image(
262
+ self, image_name: str, tag: str = "latest", upgrade: bool = False
263
+ ) -> None:
264
+ """
265
+ Validate if the image exists, and if not, download it.
266
+ If upgrade is True, will pull the latest version even if image exists locally.
267
+ """
268
+ ok = DockerManager.ensure_linux_containers_for_windows()
269
+ if not ok:
270
+ warnings.warn(
271
+ "Failed to ensure Linux containers on Windows. This build may fail."
272
+ )
273
+ print(f"Validating image {image_name}:{tag}...")
274
+ remote_image_hash_from_local_image: str | None = None
275
+ remote_image_hash: str | None = None
276
+
277
+ with get_lock(f"{image_name}-{tag}"):
278
+ try:
279
+ local_image = self.client.images.get(f"{image_name}:{tag}")
280
+ print(f"Image {image_name}:{tag} is already available.")
281
+
282
+ if upgrade:
283
+ remote_image = self.client.images.get_registry_data(
284
+ f"{image_name}:{tag}"
285
+ )
286
+ remote_image_hash = remote_image.id
287
+
288
+ try:
289
+ remote_image_hash_from_local_image = DISK_CACHE.get(
290
+ local_image.id
291
+ )
292
+ except KeyboardInterrupt:
293
+ raise
294
+ except Exception:
295
+ remote_image_hash_from_local_image = None
296
+ stack = traceback.format_exc()
297
+ warnings.warn(
298
+ f"Error getting remote image hash from local image: {stack}"
299
+ )
300
+ if remote_image_hash_from_local_image == remote_image_hash:
301
+ print(f"Local image {image_name}:{tag} is up to date.")
302
+ return
303
+
304
+ # Quick check for latest version
305
+ with Spinner(f"Pulling newer version of {image_name}:{tag}..."):
306
+ # This needs to be swapped out using the the command line interface AI!
307
+ # _ = self.client.images.pull(image_name, tag=tag)
308
+ cmd_list = ["docker", "pull", f"{image_name}:{tag}"]
309
+ cmd_str = subprocess.list2cmdline(cmd_list)
310
+ print(f"Running command: {cmd_str}")
311
+ subprocess.run(cmd_list, check=True)
312
+ print(f"Updated to newer version of {image_name}:{tag}")
313
+ local_image_hash = self.client.images.get(f"{image_name}:{tag}").id
314
+ if remote_image_hash is not None:
315
+ DISK_CACHE.put(local_image_hash, remote_image_hash)
316
+
317
+ except docker.errors.ImageNotFound:
318
+ print(f"Image {image_name}:{tag} not found.")
319
+ with Spinner("Loading "):
320
+ cmd_list = ["docker", "pull", f"{image_name}:{tag}"]
321
+ cmd_str = subprocess.list2cmdline(cmd_list)
322
+ print(f"Running command: {cmd_str}")
323
+ subprocess.run(cmd_list, check=True)
324
+ try:
325
+ local_image = self.client.images.get(f"{image_name}:{tag}")
326
+ local_image_hash = local_image.id
327
+ print(f"Image {image_name}:{tag} downloaded successfully.")
328
+ except docker.errors.ImageNotFound:
329
+ warnings.warn(f"Image {image_name}:{tag} not found after download.")
330
+
331
+ def tag_image(self, image_name: str, old_tag: str, new_tag: str) -> None:
332
+ """
333
+ Tag an image with a new tag.
334
+ """
335
+ image: Image = self.client.images.get(f"{image_name}:{old_tag}")
336
+ image.tag(image_name, new_tag)
337
+ print(f"Image {image_name}:{old_tag} tagged as {new_tag}.")
338
+
339
+ def _container_configs_match(
340
+ self,
341
+ container: Container,
342
+ command: str | None,
343
+ volumes: dict | None,
344
+ ports: dict | None,
345
+ ) -> bool:
346
+ """Compare if existing container has matching configuration"""
347
+ try:
348
+ # Check if container is using the same image
349
+ container_image_id = container.image.id
350
+ container_image_tags = container.image.tags
351
+
352
+ # Simplified image comparison - just compare the IDs directly
353
+ if not container_image_tags:
354
+ print(f"Container using untagged image with ID: {container_image_id}")
355
+ else:
356
+ current_image = self.client.images.get(container_image_tags[0])
357
+ if container_image_id != current_image.id:
358
+ print(
359
+ f"Container using different image version. Container: {container_image_id}, Current: {current_image.id}"
360
+ )
361
+ return False
362
+
363
+ # Check command if specified
364
+ if command and container.attrs["Config"]["Cmd"] != command.split():
365
+ print(
366
+ f"Command mismatch: {container.attrs['Config']['Cmd']} != {command}"
367
+ )
368
+ return False
369
+
370
+ # Check volumes if specified
371
+ if volumes:
372
+ container_mounts = (
373
+ {
374
+ m["Source"]: {"bind": m["Destination"], "mode": m["Mode"]}
375
+ for m in container.attrs["Mounts"]
376
+ }
377
+ if container.attrs.get("Mounts")
378
+ else {}
379
+ )
380
+
381
+ for host_dir, mount in volumes.items():
382
+ if host_dir not in container_mounts:
383
+ print(f"Volume {host_dir} not found in container mounts.")
384
+ return False
385
+ if container_mounts[host_dir] != mount:
386
+ print(
387
+ f"Volume {host_dir} has different mount options: {container_mounts[host_dir]} != {mount}"
388
+ )
389
+ return False
390
+
391
+ # Check ports if specified
392
+ if ports:
393
+ container_ports = (
394
+ container.attrs["Config"]["ExposedPorts"]
395
+ if container.attrs["Config"].get("ExposedPorts")
396
+ else {}
397
+ )
398
+ container_port_bindings = (
399
+ container.attrs["HostConfig"]["PortBindings"]
400
+ if container.attrs["HostConfig"].get("PortBindings")
401
+ else {}
402
+ )
403
+
404
+ for container_port, host_port in ports.items():
405
+ port_key = f"{container_port}/tcp"
406
+ if port_key not in container_ports:
407
+ print(f"Container port {port_key} not found.")
408
+ return False
409
+ if not container_port_bindings.get(port_key, [{"HostPort": None}])[
410
+ 0
411
+ ]["HostPort"] == str(host_port):
412
+ print(f"Port {host_port} is not bound to {port_key}.")
413
+ return False
414
+ except KeyboardInterrupt:
415
+ raise
416
+ except docker.errors.NotFound:
417
+ print("Container not found.")
418
+ return False
419
+ except Exception as e:
420
+ stack = traceback.format_exc()
421
+ warnings.warn(f"Error checking container config: {e}\n{stack}")
422
+ return False
423
+ return True
424
+
425
+ def run_container_detached(
426
+ self,
427
+ image_name: str,
428
+ tag: str,
429
+ container_name: str,
430
+ command: str | None = None,
431
+ volumes: dict[str, dict[str, str]] | None = None,
432
+ ports: dict[int, int] | None = None,
433
+ remove_previous: bool = False,
434
+ ) -> Container:
435
+ """
436
+ Run a container from an image. If it already exists with matching config, start it.
437
+ If it exists with different config, remove and recreate it.
438
+
439
+ Args:
440
+ volumes: Dict mapping host paths to dicts with 'bind' and 'mode' keys
441
+ Example: {'/host/path': {'bind': '/container/path', 'mode': 'rw'}}
442
+ ports: Dict mapping host ports to container ports
443
+ Example: {8080: 80} maps host port 8080 to container port 80
444
+ """
445
+ image_name = f"{image_name}:{tag}"
446
+ try:
447
+ container: Container = self.client.containers.get(container_name)
448
+
449
+ if remove_previous:
450
+ print(f"Removing existing container {container_name}...")
451
+ container.remove(force=True)
452
+ raise docker.errors.NotFound("Container removed due to remove_previous")
453
+ # Check if configuration matches
454
+ elif not self._container_configs_match(container, command, volumes, ports):
455
+ print(
456
+ f"Container {container_name} exists but with different configuration. Removing and recreating..."
457
+ )
458
+ container.remove(force=True)
459
+ raise docker.errors.NotFound("Container removed due to config mismatch")
460
+ print(f"Container {container_name} found with matching configuration.")
461
+
462
+ # Existing container with matching config - handle various states
463
+ if container.status == "running":
464
+ print(f"Container {container_name} is already running.")
465
+ elif container.status == "exited":
466
+ print(f"Starting existing container {container_name}.")
467
+ container.start()
468
+ elif container.status == "restarting":
469
+ print(f"Waiting for container {container_name} to restart...")
470
+ timeout = 10
471
+ container.wait(timeout=10)
472
+ if container.status == "running":
473
+ print(f"Container {container_name} has restarted.")
474
+ else:
475
+ print(
476
+ f"Container {container_name} did not restart within {timeout} seconds."
477
+ )
478
+ container.stop()
479
+ print(f"Container {container_name} has been stopped.")
480
+ container.start()
481
+ elif container.status == "paused":
482
+ print(f"Resuming existing container {container_name}.")
483
+ container.unpause()
484
+ else:
485
+ print(f"Unknown container status: {container.status}")
486
+ print(f"Starting existing container {container_name}.")
487
+ self.first_run = True
488
+ container.start()
489
+ except docker.errors.NotFound:
490
+ print(f"Creating and starting {container_name}")
491
+ out_msg = f"# Running in container: {command}"
492
+ msg_len = len(out_msg)
493
+ print("\n" + "#" * msg_len)
494
+ print(out_msg)
495
+ print("#" * msg_len + "\n")
496
+ container = self.client.containers.run(
497
+ image_name,
498
+ command,
499
+ name=container_name,
500
+ detach=True,
501
+ tty=True,
502
+ volumes=volumes,
503
+ ports=ports,
504
+ )
505
+ return container
506
+
507
+ def run_container_interactive(
508
+ self,
509
+ image_name: str,
510
+ tag: str,
511
+ container_name: str,
512
+ command: str | None = None,
513
+ volumes: dict[str, dict[str, str]] | None = None,
514
+ ports: dict[int, int] | None = None,
515
+ ) -> None:
516
+ # Remove existing container
517
+ try:
518
+ container: Container = self.client.containers.get(container_name)
519
+ container.remove(force=True)
520
+ except docker.errors.NotFound:
521
+ pass
522
+ try:
523
+ docker_command: list[str] = [
524
+ "docker",
525
+ "run",
526
+ "-it",
527
+ "--rm",
528
+ "--name",
529
+ container_name,
530
+ ]
531
+ if volumes:
532
+ for host_dir, mount in volumes.items():
533
+ docker_command.extend(["-v", f"{host_dir}:{mount['bind']}"])
534
+ if ports:
535
+ for host_port, container_port in ports.items():
536
+ docker_command.extend(["-p", f"{host_port}:{container_port}"])
537
+ docker_command.append(f"{image_name}:{tag}")
538
+ if command:
539
+ docker_command.append(command)
540
+ cmd_str: str = subprocess.list2cmdline(docker_command)
541
+ print(f"Running command: {cmd_str}")
542
+ subprocess.run(docker_command, check=True)
543
+ except subprocess.CalledProcessError as e:
544
+ print(f"Error running Docker command: {e}")
545
+ raise
546
+
547
+ def attach_and_run(self, container: Container | str) -> RunningContainer:
548
+ """
549
+ Attach to a running container and monitor its logs in a background thread.
550
+ Returns a RunningContainer object that can be used to stop monitoring.
551
+ """
552
+ if isinstance(container, str):
553
+ container = self.get_container(container)
554
+
555
+ print(f"Attaching to container {container.name}...")
556
+
557
+ first_run = self.first_run
558
+ self.first_run = False
559
+
560
+ return RunningContainer(container, first_run)
561
+
562
+ def suspend_container(self, container: Container | str) -> None:
563
+ """
564
+ Suspend (pause) the container.
565
+ """
566
+ if isinstance(container, str):
567
+ container_name = container
568
+ container = self.get_container(container)
569
+ if not container:
570
+ print(f"Could not put container {container_name} to sleep.")
571
+ return
572
+ try:
573
+ container.pause()
574
+ print(f"Container {container.name} has been suspended.")
575
+ except KeyboardInterrupt:
576
+ print(f"Container {container.name} interrupted by keyboard interrupt.")
577
+ except Exception as e:
578
+ print(f"Failed to suspend container {container.name}: {e}")
579
+
580
+ def resume_container(self, container: Container | str) -> None:
581
+ """
582
+ Resume (unpause) the container.
583
+ """
584
+ if isinstance(container, str):
585
+ container = self.get_container(container)
586
+ try:
587
+ container.unpause()
588
+ print(f"Container {container.name} has been resumed.")
589
+ except Exception as e:
590
+ print(f"Failed to resume container {container.name}: {e}")
591
+
592
+ def get_container(self, container_name: str) -> Container:
593
+ """
594
+ Get a container by name.
595
+ """
596
+ try:
597
+ return self.client.containers.get(container_name)
598
+ except docker.errors.NotFound:
599
+ print(f"Container {container_name} not found.")
600
+ raise
601
+
602
+ def is_container_running(self, container_name: str) -> bool:
603
+ """
604
+ Check if a container is running.
605
+ """
606
+ try:
607
+ container = self.client.containers.get(container_name)
608
+ return container.status == "running"
609
+ except docker.errors.NotFound:
610
+ print(f"Container {container_name} not found.")
611
+ return False
612
+
613
+
614
+ def main():
615
+ # Register SIGINT handler
616
+ # signal.signal(signal.SIGINT, handle_sigint)
617
+
618
+ docker_manager = DockerManager()
619
+
620
+ # Parameters
621
+ image_name = "python"
622
+ tag = "3.10-slim"
623
+ # new_tag = "my-python"
624
+ container_name = "my-python-container"
625
+ command = "python -m http.server"
626
+
627
+ try:
628
+ # Step 1: Validate or download the image
629
+ docker_manager.validate_or_download_image(image_name, tag, upgrade=True)
630
+
631
+ # Step 2: Tag the image
632
+ # docker_manager.tag_image(image_name, tag, new_tag)
633
+
634
+ # Step 3: Run the container
635
+ container = docker_manager.run_container_detached(
636
+ image_name, tag, container_name, command
637
+ )
638
+
639
+ # Step 4: Attach and monitor the container logs
640
+ running_container = docker_manager.attach_and_run(container)
641
+
642
+ # Wait for keyboard interrupt
643
+ while True:
644
+ time.sleep(0.1)
645
+
646
+ except KeyboardInterrupt:
647
+ print("\nStopping container...")
648
+ running_container.stop()
649
+ container = docker_manager.get_container(container_name)
650
+ docker_manager.suspend_container(container)
651
+
652
+ try:
653
+ # Suspend and resume the container
654
+ container = docker_manager.get_container(container_name)
655
+ docker_manager.suspend_container(container)
656
+
657
+ input("Press Enter to resume the container...")
658
+
659
+ docker_manager.resume_container(container)
660
+ except Exception as e:
661
+ print(f"An error occurred: {e}")
662
+
663
+
664
+ if __name__ == "__main__":
665
+ main()