fastled 1.2.67__py3-none-any.whl → 1.2.69__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.
fastled/docker_manager.py CHANGED
@@ -1,828 +1,925 @@
1
- """
2
- New abstraction for Docker management with improved Ctrl+C handling.
3
- """
4
-
5
- import _thread
6
- import os
7
- import platform
8
- import subprocess
9
- import sys
10
- import threading
11
- import time
12
- import traceback
13
- import warnings
14
- from datetime import datetime, timezone
15
- from pathlib import Path
16
-
17
- import docker
18
- from appdirs import user_data_dir
19
- from disklru import DiskLRUCache
20
- from docker.client import DockerClient
21
- from docker.errors import DockerException, ImageNotFound, NotFound
22
- from docker.models.containers import Container
23
- from docker.models.images import Image
24
- from filelock import FileLock
25
-
26
- from fastled.spinner import Spinner
27
-
28
- CONFIG_DIR = Path(user_data_dir("fastled", "fastled"))
29
- CONFIG_DIR.mkdir(parents=True, exist_ok=True)
30
- DB_FILE = CONFIG_DIR / "db.db"
31
- DISK_CACHE = DiskLRUCache(str(DB_FILE), 10)
32
- _IS_GITHUB = "GITHUB_ACTIONS" in os.environ
33
-
34
-
35
- # Docker uses datetimes in UTC but without the timezone info. If we pass in a tz
36
- # then it will throw an exception.
37
- def _utc_now_no_tz() -> datetime:
38
- now = datetime.now(timezone.utc)
39
- return now.replace(tzinfo=None)
40
-
41
-
42
- def _win32_docker_location() -> str | None:
43
- home_dir = Path.home()
44
- out = [
45
- "C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe",
46
- f"{home_dir}\\AppData\\Local\\Docker\\Docker Desktop.exe",
47
- ]
48
- for loc in out:
49
- if Path(loc).exists():
50
- return loc
51
- return None
52
-
53
-
54
- def get_lock(image_name: str) -> FileLock:
55
- """Get the file lock for this DockerManager instance."""
56
- lock_file = CONFIG_DIR / f"{image_name}.lock"
57
- CONFIG_DIR.mkdir(parents=True, exist_ok=True)
58
- print(CONFIG_DIR)
59
- if not lock_file.parent.exists():
60
- lock_file.parent.mkdir(parents=True, exist_ok=True)
61
- out: FileLock
62
- out = FileLock(str(lock_file)) # type: ignore
63
- return out
64
-
65
-
66
- class RunningContainer:
67
- def __init__(self, container, first_run=False):
68
- self.container = container
69
- self.first_run = first_run
70
- self.running = True
71
- self.thread = threading.Thread(target=self._log_monitor)
72
- self.thread.daemon = True
73
- self.thread.start()
74
-
75
- def _log_monitor(self):
76
- from_date = _utc_now_no_tz() if not self.first_run else None
77
- to_date = _utc_now_no_tz()
78
-
79
- while self.running:
80
- try:
81
- for log in self.container.logs(
82
- follow=False, since=from_date, until=to_date, stream=True
83
- ):
84
- print(log.decode("utf-8"), end="")
85
- time.sleep(0.1)
86
- from_date = to_date
87
- to_date = _utc_now_no_tz()
88
- except KeyboardInterrupt:
89
- print("Monitoring logs interrupted by user.")
90
- _thread.interrupt_main()
91
- break
92
- except Exception as e:
93
- print(f"Error monitoring logs: {e}")
94
- break
95
-
96
- def detach(self) -> None:
97
- """Stop monitoring the container logs"""
98
- self.running = False
99
- self.thread.join()
100
-
101
- def stop(self) -> None:
102
- """Stop the container"""
103
- self.container.stop()
104
- self.detach()
105
-
106
-
107
- class DockerManager:
108
- def __init__(self) -> None:
109
- from docker.errors import DockerException
110
-
111
- try:
112
- self._client: DockerClient | None = None
113
- self.first_run = False
114
- except DockerException as e:
115
- stack = traceback.format_exc()
116
- warnings.warn(f"Error initializing Docker client: {e}\n{stack}")
117
- raise
118
-
119
- @property
120
- def client(self) -> DockerClient:
121
- if self._client is None:
122
- self._client = docker.from_env()
123
- return self._client
124
-
125
- @staticmethod
126
- def is_docker_installed() -> bool:
127
- """Check if Docker is installed on the system."""
128
- try:
129
- subprocess.run(["docker", "--version"], capture_output=True, check=True)
130
- print("Docker is installed.")
131
- return True
132
- except subprocess.CalledProcessError as e:
133
- print(f"Docker command failed: {str(e)}")
134
- return False
135
- except FileNotFoundError:
136
- print("Docker is not installed.")
137
- return False
138
-
139
- @staticmethod
140
- def ensure_linux_containers_for_windows() -> bool:
141
- """Ensure Docker is using Linux containers on Windows."""
142
- if sys.platform != "win32":
143
- return True # Only needed on Windows
144
-
145
- try:
146
- # Check if we're already in Linux container mode
147
- result = subprocess.run(
148
- ["docker", "info"], capture_output=True, text=True, check=True
149
- )
150
-
151
- if "linux" in result.stdout.lower():
152
- print("Already using Linux containers")
153
- return True
154
-
155
- if not _IS_GITHUB:
156
- answer = (
157
- input(
158
- "\nDocker on Windows must be in linux mode, this is a global change, switch? [y/n]"
159
- )
160
- .strip()
161
- .lower()[:1]
162
- )
163
- if answer != "y":
164
- return False
165
-
166
- print("Switching to Linux containers...")
167
- warnings.warn("Switching Docker to use Linux container context...")
168
-
169
- # Explicitly specify the Linux container context
170
- linux_context = "desktop-linux"
171
- subprocess.run(
172
- ["cmd", "/c", f"docker context use {linux_context}"],
173
- check=True,
174
- capture_output=True,
175
- )
176
-
177
- # Verify the switch worked
178
- verify = subprocess.run(
179
- ["docker", "info"], capture_output=True, text=True, check=True
180
- )
181
- if "linux" in verify.stdout.lower():
182
- print(
183
- f"Successfully switched to Linux containers using '{linux_context}' context"
184
- )
185
- return True
186
-
187
- warnings.warn(
188
- f"Failed to switch to Linux containers with context '{linux_context}': {verify.stdout}"
189
- )
190
- return False
191
-
192
- except subprocess.CalledProcessError as e:
193
- print(f"Failed to switch to Linux containers: {e}")
194
- if e.stdout:
195
- print(f"stdout: {e.stdout}")
196
- if e.stderr:
197
- print(f"stderr: {e.stderr}")
198
- return False
199
- except Exception as e:
200
- print(f"Unexpected error switching to Linux containers: {e}")
201
- return False
202
-
203
- @staticmethod
204
- def is_running() -> bool:
205
- """Check if Docker is running by pinging the Docker daemon."""
206
-
207
- if not DockerManager.is_docker_installed():
208
- print("Docker is not installed.")
209
- return False
210
- try:
211
- # self.client.ping()
212
- client = docker.from_env()
213
- client.ping()
214
- print("Docker is running.")
215
- return True
216
- except DockerException as e:
217
- print(f"Docker is not running: {str(e)}")
218
- return False
219
- except Exception as e:
220
- print(f"Error pinging Docker daemon: {str(e)}")
221
- return False
222
-
223
- def start(self) -> bool:
224
- """Attempt to start Docker Desktop (or the Docker daemon) automatically."""
225
- print("Attempting to start Docker...")
226
-
227
- try:
228
- if sys.platform == "win32":
229
- docker_path = _win32_docker_location()
230
- if not docker_path:
231
- print("Docker Desktop not found.")
232
- return False
233
- subprocess.run(["start", "", docker_path], shell=True)
234
- elif sys.platform == "darwin":
235
- subprocess.run(["open", "-a", "Docker"])
236
- elif sys.platform.startswith("linux"):
237
- subprocess.run(["sudo", "systemctl", "start", "docker"])
238
- else:
239
- print("Unknown platform. Cannot auto-launch Docker.")
240
- return False
241
-
242
- # Wait for Docker to start up with increasing delays
243
- print("Waiting for Docker Desktop to start...")
244
- attempts = 0
245
- max_attempts = 20 # Increased max wait time
246
- while attempts < max_attempts:
247
- attempts += 1
248
- if self.is_running():
249
- print("Docker started successfully.")
250
- return True
251
-
252
- # Gradually increase wait time between checks
253
- wait_time = min(5, 1 + attempts * 0.5)
254
- print(
255
- f"Docker not ready yet, waiting {wait_time:.1f}s... (attempt {attempts}/{max_attempts})"
256
- )
257
- time.sleep(wait_time)
258
-
259
- print("Failed to start Docker within the expected time.")
260
- print(
261
- "Please try starting Docker Desktop manually and run this command again."
262
- )
263
- except KeyboardInterrupt:
264
- print("Aborted by user.")
265
- raise
266
- except Exception as e:
267
- print(f"Error starting Docker: {str(e)}")
268
- return False
269
-
270
- def validate_or_download_image(
271
- self, image_name: str, tag: str = "latest", upgrade: bool = False
272
- ) -> bool:
273
- """
274
- Validate if the image exists, and if not, download it.
275
- If upgrade is True, will pull the latest version even if image exists locally.
276
- """
277
- ok = DockerManager.ensure_linux_containers_for_windows()
278
- if not ok:
279
- warnings.warn(
280
- "Failed to ensure Linux containers on Windows. This build may fail."
281
- )
282
- print(f"Validating image {image_name}:{tag}...")
283
- remote_image_hash_from_local_image: str | None = None
284
- remote_image_hash: str | None = None
285
-
286
- with get_lock(f"{image_name}-{tag}"):
287
- try:
288
- local_image = self.client.images.get(f"{image_name}:{tag}")
289
- print(f"Image {image_name}:{tag} is already available.")
290
-
291
- if upgrade:
292
- remote_image = self.client.images.get_registry_data(
293
- f"{image_name}:{tag}"
294
- )
295
- remote_image_hash = remote_image.id
296
-
297
- try:
298
- local_image_id = local_image.id
299
- assert local_image_id is not None
300
- remote_image_hash_from_local_image = DISK_CACHE.get(
301
- local_image_id
302
- )
303
- except KeyboardInterrupt:
304
- raise
305
- except Exception:
306
- remote_image_hash_from_local_image = None
307
- stack = traceback.format_exc()
308
- warnings.warn(
309
- f"Error getting remote image hash from local image: {stack}"
310
- )
311
- if remote_image_hash_from_local_image == remote_image_hash:
312
- print(f"Local image {image_name}:{tag} is up to date.")
313
- return False
314
-
315
- # Quick check for latest version
316
- with Spinner(f"Pulling newer version of {image_name}:{tag}..."):
317
- # This needs to be swapped out using the the command line interface AI!
318
- # _ = self.client.images.pull(image_name, tag=tag)
319
- cmd_list = ["docker", "pull", f"{image_name}:{tag}"]
320
- cmd_str = subprocess.list2cmdline(cmd_list)
321
- print(f"Running command: {cmd_str}")
322
- subprocess.run(cmd_list, check=True)
323
- print(f"Updated to newer version of {image_name}:{tag}")
324
- local_image_hash = self.client.images.get(f"{image_name}:{tag}").id
325
- assert local_image_hash is not None
326
- if remote_image_hash is not None:
327
- DISK_CACHE.put(local_image_hash, remote_image_hash)
328
- return True
329
-
330
- except ImageNotFound:
331
- print(f"Image {image_name}:{tag} not found.")
332
- with Spinner("Loading "):
333
- # We use docker cli here because it shows the download.
334
- cmd_list = ["docker", "pull", f"{image_name}:{tag}"]
335
- cmd_str = subprocess.list2cmdline(cmd_list)
336
- print(f"Running command: {cmd_str}")
337
- subprocess.run(cmd_list, check=True)
338
- try:
339
- local_image = self.client.images.get(f"{image_name}:{tag}")
340
- local_image_hash = local_image.id
341
- print(f"Image {image_name}:{tag} downloaded successfully.")
342
- except ImageNotFound:
343
- warnings.warn(f"Image {image_name}:{tag} not found after download.")
344
- return True
345
-
346
- def tag_image(self, image_name: str, old_tag: str, new_tag: str) -> None:
347
- """
348
- Tag an image with a new tag.
349
- """
350
- image: Image = self.client.images.get(f"{image_name}:{old_tag}")
351
- image.tag(image_name, new_tag)
352
- print(f"Image {image_name}:{old_tag} tagged as {new_tag}.")
353
-
354
- def _container_configs_match(
355
- self,
356
- container: Container,
357
- command: str | None,
358
- volumes: dict | None,
359
- ports: dict | None,
360
- ) -> bool:
361
- """Compare if existing container has matching configuration"""
362
- try:
363
- # Check if container is using the same image
364
- image = container.image
365
- assert image is not None
366
- container_image_id = image.id
367
- container_image_tags = image.tags
368
- assert container_image_id is not None
369
-
370
- # Simplified image comparison - just compare the IDs directly
371
- if not container_image_tags:
372
- print(f"Container using untagged image with ID: {container_image_id}")
373
- else:
374
- current_image = self.client.images.get(container_image_tags[0])
375
- if container_image_id != current_image.id:
376
- print(
377
- f"Container using different image version. Container: {container_image_id}, Current: {current_image.id}"
378
- )
379
- return False
380
-
381
- # Check command if specified
382
- if command and container.attrs["Config"]["Cmd"] != command.split():
383
- print(
384
- f"Command mismatch: {container.attrs['Config']['Cmd']} != {command}"
385
- )
386
- return False
387
-
388
- # Check volumes if specified
389
- if volumes:
390
- container_mounts = (
391
- {
392
- m["Source"]: {"bind": m["Destination"], "mode": m["Mode"]}
393
- for m in container.attrs["Mounts"]
394
- }
395
- if container.attrs.get("Mounts")
396
- else {}
397
- )
398
-
399
- for host_dir, mount in volumes.items():
400
- if host_dir not in container_mounts:
401
- print(f"Volume {host_dir} not found in container mounts.")
402
- return False
403
- if container_mounts[host_dir] != mount:
404
- print(
405
- f"Volume {host_dir} has different mount options: {container_mounts[host_dir]} != {mount}"
406
- )
407
- return False
408
-
409
- # Check ports if specified
410
- if ports:
411
- container_ports = (
412
- container.attrs["Config"]["ExposedPorts"]
413
- if container.attrs["Config"].get("ExposedPorts")
414
- else {}
415
- )
416
- container_port_bindings = (
417
- container.attrs["HostConfig"]["PortBindings"]
418
- if container.attrs["HostConfig"].get("PortBindings")
419
- else {}
420
- )
421
-
422
- for container_port, host_port in ports.items():
423
- port_key = f"{container_port}/tcp"
424
- if port_key not in container_ports:
425
- print(f"Container port {port_key} not found.")
426
- return False
427
- if not container_port_bindings.get(port_key, [{"HostPort": None}])[
428
- 0
429
- ]["HostPort"] == str(host_port):
430
- print(f"Port {host_port} is not bound to {port_key}.")
431
- return False
432
- except KeyboardInterrupt:
433
- raise
434
- except NotFound:
435
- print("Container not found.")
436
- return False
437
- except Exception as e:
438
- stack = traceback.format_exc()
439
- warnings.warn(f"Error checking container config: {e}\n{stack}")
440
- return False
441
- return True
442
-
443
- def run_container_detached(
444
- self,
445
- image_name: str,
446
- tag: str,
447
- container_name: str,
448
- command: str | None = None,
449
- volumes: dict[str, dict[str, str]] | None = None,
450
- ports: dict[int, int] | None = None,
451
- remove_previous: bool = False,
452
- ) -> Container:
453
- """
454
- Run a container from an image. If it already exists with matching config, start it.
455
- If it exists with different config, remove and recreate it.
456
-
457
- Args:
458
- volumes: Dict mapping host paths to dicts with 'bind' and 'mode' keys
459
- Example: {'/host/path': {'bind': '/container/path', 'mode': 'rw'}}
460
- ports: Dict mapping host ports to container ports
461
- Example: {8080: 80} maps host port 8080 to container port 80
462
- """
463
- image_name = f"{image_name}:{tag}"
464
- try:
465
- container: Container = self.client.containers.get(container_name)
466
-
467
- if remove_previous:
468
- print(f"Removing existing container {container_name}...")
469
- container.remove(force=True)
470
- raise NotFound("Container removed due to remove_previous")
471
- # Check if configuration matches
472
- elif not self._container_configs_match(container, command, volumes, ports):
473
- print(
474
- f"Container {container_name} exists but with different configuration. Removing and recreating..."
475
- )
476
- container.remove(force=True)
477
- raise NotFound("Container removed due to config mismatch")
478
- print(f"Container {container_name} found with matching configuration.")
479
-
480
- # Existing container with matching config - handle various states
481
- if container.status == "running":
482
- print(f"Container {container_name} is already running.")
483
- elif container.status == "exited":
484
- print(f"Starting existing container {container_name}.")
485
- container.start()
486
- elif container.status == "restarting":
487
- print(f"Waiting for container {container_name} to restart...")
488
- timeout = 10
489
- container.wait(timeout=10)
490
- if container.status == "running":
491
- print(f"Container {container_name} has restarted.")
492
- else:
493
- print(
494
- f"Container {container_name} did not restart within {timeout} seconds."
495
- )
496
- container.stop(timeout=0)
497
- print(f"Container {container_name} has been stopped.")
498
- container.start()
499
- elif container.status == "paused":
500
- print(f"Resuming existing container {container_name}.")
501
- container.unpause()
502
- else:
503
- print(f"Unknown container status: {container.status}")
504
- print(f"Starting existing container {container_name}.")
505
- self.first_run = True
506
- container.start()
507
- except NotFound:
508
- print(f"Creating and starting {container_name}")
509
- out_msg = f"# Running in container: {command}"
510
- msg_len = len(out_msg)
511
- print("\n" + "#" * msg_len)
512
- print(out_msg)
513
- print("#" * msg_len + "\n")
514
- container = self.client.containers.run(
515
- image=image_name,
516
- command=command,
517
- name=container_name,
518
- detach=True,
519
- tty=True,
520
- volumes=volumes,
521
- ports=ports, # type: ignore
522
- remove=True,
523
- )
524
- return container
525
-
526
- def run_container_interactive(
527
- self,
528
- image_name: str,
529
- tag: str,
530
- container_name: str,
531
- command: str | None = None,
532
- volumes: dict[str, dict[str, str]] | None = None,
533
- ports: dict[int, int] | None = None,
534
- ) -> None:
535
- # Remove existing container
536
- try:
537
- container: Container = self.client.containers.get(container_name)
538
- container.remove(force=True)
539
- except NotFound:
540
- pass
541
- start_time = time.time()
542
- try:
543
- docker_command: list[str] = [
544
- "docker",
545
- "run",
546
- "-it",
547
- "--rm",
548
- "--name",
549
- container_name,
550
- ]
551
- if volumes:
552
- for host_dir, mount in volumes.items():
553
- docker_command.extend(["-v", f"{host_dir}:{mount['bind']}"])
554
- if ports:
555
- for host_port, container_port in ports.items():
556
- docker_command.extend(["-p", f"{host_port}:{container_port}"])
557
- docker_command.append(f"{image_name}:{tag}")
558
- if command:
559
- docker_command.append(command)
560
- cmd_str: str = subprocess.list2cmdline(docker_command)
561
- print(f"Running command: {cmd_str}")
562
- subprocess.run(docker_command, check=False)
563
- except subprocess.CalledProcessError as e:
564
- print(f"Error running Docker command: {e}")
565
- diff = time.time() - start_time
566
- if diff < 5:
567
- raise
568
- sys.exit(1) # Probably a user exit.
569
-
570
- def attach_and_run(self, container: Container | str) -> RunningContainer:
571
- """
572
- Attach to a running container and monitor its logs in a background thread.
573
- Returns a RunningContainer object that can be used to stop monitoring.
574
- """
575
- if isinstance(container, str):
576
- container_name = container
577
- tmp = self.get_container(container)
578
- assert tmp is not None, f"Container {container_name} not found."
579
- container = tmp
580
-
581
- assert container is not None, "Container not found."
582
-
583
- print(f"Attaching to container {container.name}...")
584
-
585
- first_run = self.first_run
586
- self.first_run = False
587
-
588
- return RunningContainer(container, first_run)
589
-
590
- def suspend_container(self, container: Container | str) -> None:
591
- """
592
- Suspend (pause) the container.
593
- """
594
- if isinstance(container, str):
595
- container_name = container
596
- # container = self.get_container(container)
597
- tmp = self.get_container(container_name)
598
- if not tmp:
599
- print(f"Could not put container {container_name} to sleep.")
600
- return
601
- container = tmp
602
- assert isinstance(container, Container)
603
- try:
604
- if platform.system() == "Windows":
605
- container.pause()
606
- else:
607
- container.stop()
608
- container.remove()
609
- print(f"Container {container.name} has been suspended.")
610
- except KeyboardInterrupt:
611
- print(f"Container {container.name} interrupted by keyboard interrupt.")
612
- except Exception as e:
613
- print(f"Failed to suspend container {container.name}: {e}")
614
-
615
- def resume_container(self, container: Container | str) -> None:
616
- """
617
- Resume (unpause) the container.
618
- """
619
- container_name = "UNKNOWN"
620
- if isinstance(container, str):
621
- container_name = container
622
- container_or_none = self.get_container(container)
623
- if container_or_none is None:
624
- print(f"Could not resume container {container}.")
625
- return
626
- container = container_or_none
627
- container_name = container.name
628
- elif isinstance(container, Container):
629
- container_name = container.name
630
- assert isinstance(container, Container)
631
- if not container:
632
- print(f"Could not resume container {container}.")
633
- return
634
- try:
635
- assert isinstance(container, Container)
636
- container.unpause()
637
- print(f"Container {container.name} has been resumed.")
638
- except Exception as e:
639
- print(f"Failed to resume container {container_name}: {e}")
640
-
641
- def get_container(self, container_name: str) -> Container | None:
642
- """
643
- Get a container by name.
644
- """
645
- try:
646
- return self.client.containers.get(container_name)
647
- except NotFound:
648
- return None
649
-
650
- def is_container_running(self, container_name: str) -> bool:
651
- """
652
- Check if a container is running.
653
- """
654
- try:
655
- container = self.client.containers.get(container_name)
656
- return container.status == "running"
657
- except NotFound:
658
- print(f"Container {container_name} not found.")
659
- return False
660
-
661
- def build_image(
662
- self,
663
- image_name: str,
664
- tag: str,
665
- dockerfile_path: Path,
666
- build_context: Path,
667
- build_args: dict[str, str] | None = None,
668
- platform_tag: str = "",
669
- ) -> None:
670
- """
671
- Build a Docker image from a Dockerfile.
672
-
673
- Args:
674
- image_name: Name for the image
675
- tag: Tag for the image
676
- dockerfile_path: Path to the Dockerfile
677
- build_context: Path to the build context directory
678
- build_args: Optional dictionary of build arguments
679
- platform_tag: Optional platform tag (e.g. "-arm64")
680
- """
681
- if not dockerfile_path.exists():
682
- raise FileNotFoundError(f"Dockerfile not found at {dockerfile_path}")
683
-
684
- if not build_context.exists():
685
- raise FileNotFoundError(
686
- f"Build context directory not found at {build_context}"
687
- )
688
-
689
- print(f"Building Docker image {image_name}:{tag} from {dockerfile_path}")
690
-
691
- # Prepare build arguments
692
- buildargs = build_args or {}
693
- if platform_tag:
694
- buildargs["PLATFORM_TAG"] = platform_tag
695
-
696
- try:
697
- cmd_list = [
698
- "docker",
699
- "build",
700
- "-t",
701
- f"{image_name}:{tag}",
702
- ]
703
-
704
- # Add build args
705
- for arg_name, arg_value in buildargs.items():
706
- cmd_list.extend(["--build-arg", f"{arg_name}={arg_value}"])
707
-
708
- # Add dockerfile and context paths
709
- cmd_list.extend(["-f", str(dockerfile_path), str(build_context)])
710
-
711
- cmd_str = subprocess.list2cmdline(cmd_list)
712
- print(f"Running command: {cmd_str}")
713
-
714
- # Run the build command
715
- # cp = subprocess.run(cmd_list, check=True, capture_output=True)
716
- proc: subprocess.Popen = subprocess.Popen(
717
- cmd_list, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
718
- )
719
- stdout = proc.stdout
720
- assert stdout is not None, "stdout is None"
721
- for line in iter(stdout.readline, b""):
722
- try:
723
- line_str = line.decode("utf-8")
724
- print(line_str, end="")
725
- except UnicodeDecodeError:
726
- print("Error decoding line")
727
- rtn = proc.wait()
728
- if rtn != 0:
729
- warnings.warn(
730
- f"Error building Docker image, is docker running? {rtn}, stdout: {stdout}, stderr: {proc.stderr}"
731
- )
732
- raise subprocess.CalledProcessError(rtn, cmd_str)
733
- print(f"Successfully built image {image_name}:{tag}")
734
-
735
- except subprocess.CalledProcessError as e:
736
- print(f"Error building Docker image: {e}")
737
- raise
738
-
739
- def purge(self, image_name: str) -> None:
740
- """
741
- Remove all containers and images associated with the given image name.
742
-
743
- Args:
744
- image_name: The name of the image to purge (without tag)
745
- """
746
- print(f"Purging all containers and images for {image_name}...")
747
-
748
- # Remove all containers using this image
749
- try:
750
- containers = self.client.containers.list(all=True)
751
- for container in containers:
752
- if any(image_name in tag for tag in container.image.tags):
753
- print(f"Removing container {container.name}")
754
- container.remove(force=True)
755
-
756
- except Exception as e:
757
- print(f"Error removing containers: {e}")
758
-
759
- # Remove all images with this name
760
- try:
761
- self.client.images.prune(filters={"dangling": False})
762
- images = self.client.images.list()
763
- for image in images:
764
- if any(image_name in tag for tag in image.tags):
765
- print(f"Removing image {image.tags}")
766
- self.client.images.remove(image.id, force=True)
767
- except Exception as e:
768
- print(f"Error removing images: {e}")
769
-
770
-
771
- def main() -> None:
772
- # Register SIGINT handler
773
- # signal.signal(signal.SIGINT, handle_sigint)
774
-
775
- docker_manager = DockerManager()
776
-
777
- # Parameters
778
- image_name = "python"
779
- tag = "3.10-slim"
780
- # new_tag = "my-python"
781
- container_name = "my-python-container"
782
- command = "python -m http.server"
783
- running_container: RunningContainer | None = None
784
-
785
- try:
786
- # Step 1: Validate or download the image
787
- docker_manager.validate_or_download_image(image_name, tag, upgrade=True)
788
-
789
- # Step 2: Tag the image
790
- # docker_manager.tag_image(image_name, tag, new_tag)
791
-
792
- # Step 3: Run the container
793
- container = docker_manager.run_container_detached(
794
- image_name, tag, container_name, command
795
- )
796
-
797
- # Step 4: Attach and monitor the container logs
798
- running_container = docker_manager.attach_and_run(container)
799
-
800
- # Wait for keyboard interrupt
801
- while True:
802
- time.sleep(0.1)
803
-
804
- except KeyboardInterrupt:
805
- print("\nStopping container...")
806
- if isinstance(running_container, RunningContainer):
807
- running_container.stop()
808
- container_or_none = docker_manager.get_container(container_name)
809
- if container_or_none is not None:
810
- docker_manager.suspend_container(container_or_none)
811
- else:
812
- warnings.warn(f"Container {container_name} not found.")
813
-
814
- try:
815
- # Suspend and resume the container
816
- container = docker_manager.get_container(container_name)
817
- assert container is not None, "Container not found."
818
- docker_manager.suspend_container(container)
819
-
820
- input("Press Enter to resume the container...")
821
-
822
- docker_manager.resume_container(container)
823
- except Exception as e:
824
- print(f"An error occurred: {e}")
825
-
826
-
827
- if __name__ == "__main__":
828
- main()
1
+ """
2
+ New abstraction for Docker management with improved Ctrl+C handling.
3
+ """
4
+
5
+ import _thread
6
+ import json
7
+ import os
8
+ import platform
9
+ import subprocess
10
+ import sys
11
+ import threading
12
+ import time
13
+ import traceback
14
+ import warnings
15
+ from dataclasses import dataclass
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+ import docker
20
+ from appdirs import user_data_dir
21
+ from disklru import DiskLRUCache
22
+ from docker.client import DockerClient
23
+ from docker.errors import DockerException, ImageNotFound, NotFound
24
+ from docker.models.containers import Container
25
+ from docker.models.images import Image
26
+ from filelock import FileLock
27
+
28
+ from fastled.spinner import Spinner
29
+
30
+ CONFIG_DIR = Path(user_data_dir("fastled", "fastled"))
31
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
32
+ DB_FILE = CONFIG_DIR / "db.db"
33
+ DISK_CACHE = DiskLRUCache(str(DB_FILE), 10)
34
+ _IS_GITHUB = "GITHUB_ACTIONS" in os.environ
35
+
36
+
37
+ # Docker uses datetimes in UTC but without the timezone info. If we pass in a tz
38
+ # then it will throw an exception.
39
+ def _utc_now_no_tz() -> datetime:
40
+ now = datetime.now(timezone.utc)
41
+ return now.replace(tzinfo=None)
42
+
43
+
44
+ def _win32_docker_location() -> str | None:
45
+ home_dir = Path.home()
46
+ out = [
47
+ "C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe",
48
+ f"{home_dir}\\AppData\\Local\\Docker\\Docker Desktop.exe",
49
+ ]
50
+ for loc in out:
51
+ if Path(loc).exists():
52
+ return loc
53
+ return None
54
+
55
+
56
+ def get_lock(image_name: str) -> FileLock:
57
+ """Get the file lock for this DockerManager instance."""
58
+ lock_file = CONFIG_DIR / f"{image_name}.lock"
59
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
60
+ print(CONFIG_DIR)
61
+ if not lock_file.parent.exists():
62
+ lock_file.parent.mkdir(parents=True, exist_ok=True)
63
+ out: FileLock
64
+ out = FileLock(str(lock_file)) # type: ignore
65
+ return out
66
+
67
+
68
+ @dataclass
69
+ class Volume:
70
+ """
71
+ Represents a Docker volume mapping between host and container.
72
+
73
+ Attributes:
74
+ host_path: Path on the host system (e.g., "C:\\Users\\username\\project")
75
+ container_path: Path inside the container (e.g., "/app/data")
76
+ mode: Access mode, "rw" for read-write or "ro" for read-only
77
+ """
78
+
79
+ host_path: str
80
+ container_path: str
81
+ mode: str = "rw"
82
+
83
+ def to_dict(self) -> dict[str, dict[str, str]]:
84
+ """Convert the Volume object to the format expected by Docker API."""
85
+ return {self.host_path: {"bind": self.container_path, "mode": self.mode}}
86
+
87
+ @classmethod
88
+ def from_dict(cls, volume_dict: dict[str, dict[str, str]]) -> list["Volume"]:
89
+ """Create Volume objects from a Docker volume dictionary."""
90
+ volumes = []
91
+ for host_path, config in volume_dict.items():
92
+ volumes.append(
93
+ cls(
94
+ host_path=host_path,
95
+ container_path=config["bind"],
96
+ mode=config.get("mode", "rw"),
97
+ )
98
+ )
99
+ return volumes
100
+
101
+
102
+ class RunningContainer:
103
+ def __init__(self, container, first_run=False):
104
+ self.container = container
105
+ self.first_run = first_run
106
+ self.running = True
107
+ self.thread = threading.Thread(target=self._log_monitor)
108
+ self.thread.daemon = True
109
+ self.thread.start()
110
+
111
+ def _log_monitor(self):
112
+ from_date = _utc_now_no_tz() if not self.first_run else None
113
+ to_date = _utc_now_no_tz()
114
+
115
+ while self.running:
116
+ try:
117
+ for log in self.container.logs(
118
+ follow=False, since=from_date, until=to_date, stream=True
119
+ ):
120
+ print(log.decode("utf-8"), end="")
121
+ time.sleep(0.1)
122
+ from_date = to_date
123
+ to_date = _utc_now_no_tz()
124
+ except KeyboardInterrupt:
125
+ print("Monitoring logs interrupted by user.")
126
+ _thread.interrupt_main()
127
+ break
128
+ except Exception as e:
129
+ print(f"Error monitoring logs: {e}")
130
+ break
131
+
132
+ def detach(self) -> None:
133
+ """Stop monitoring the container logs"""
134
+ self.running = False
135
+ self.thread.join()
136
+
137
+ def stop(self) -> None:
138
+ """Stop the container"""
139
+ self.container.stop()
140
+ self.detach()
141
+
142
+
143
+ def _hack_to_fix_mac(volumes: list[Volume] | None) -> list[Volume] | None:
144
+ """Fixes the volume mounts on MacOS by removing the mode."""
145
+ if volumes is None:
146
+ return None
147
+ if sys.platform != "darwin":
148
+ # Only macos needs hacking.
149
+ return volumes
150
+
151
+ volumes = volumes.copy()
152
+ # Work around a Docker bug on MacOS where the expected network socket to the
153
+ # the host is not mounted correctly. This was actually fixed in recent versions
154
+ # of docker client but there is a large chunk of Docker clients out there with
155
+ # this bug in it.
156
+ #
157
+ # This hack is done by mounting the socket directly to the container.
158
+ # This socket talks to the docker daemon on the host.
159
+ #
160
+ # Found here.
161
+ # https://github.com/docker/docker-py/issues/3069#issuecomment-1316778735
162
+ # if it exists already then return the input
163
+ for volume in volumes:
164
+ if volume.host_path == "/var/run/docker.sock":
165
+ return volumes
166
+ # ok it doesn't exist, so add it
167
+ volumes.append(
168
+ Volume(
169
+ host_path="/var/run/docker.sock",
170
+ container_path="/var/run/docker.sock",
171
+ mode="rw",
172
+ )
173
+ )
174
+ return volumes
175
+
176
+
177
+ class DockerManager:
178
+ def __init__(self) -> None:
179
+ from docker.errors import DockerException
180
+
181
+ self.is_suspended: bool = False
182
+
183
+ try:
184
+ self._client: DockerClient | None = None
185
+ self.first_run = False
186
+ except DockerException as e:
187
+ stack = traceback.format_exc()
188
+ warnings.warn(f"Error initializing Docker client: {e}\n{stack}")
189
+ raise
190
+
191
+ @property
192
+ def client(self) -> DockerClient:
193
+ if self._client is None:
194
+ self._client = docker.from_env()
195
+ return self._client
196
+
197
+ @staticmethod
198
+ def is_docker_installed() -> bool:
199
+ """Check if Docker is installed on the system."""
200
+ try:
201
+ subprocess.run(["docker", "--version"], capture_output=True, check=True)
202
+ print("Docker is installed.")
203
+ return True
204
+ except subprocess.CalledProcessError as e:
205
+ print(f"Docker command failed: {str(e)}")
206
+ return False
207
+ except FileNotFoundError:
208
+ print("Docker is not installed.")
209
+ return False
210
+
211
+ @staticmethod
212
+ def ensure_linux_containers_for_windows() -> bool:
213
+ """Ensure Docker is using Linux containers on Windows."""
214
+ if sys.platform != "win32":
215
+ return True # Only needed on Windows
216
+
217
+ try:
218
+ # Check if we're already in Linux container mode
219
+ result = subprocess.run(
220
+ ["docker", "info"], capture_output=True, text=True, check=True
221
+ )
222
+
223
+ if "linux" in result.stdout.lower():
224
+ print("Already using Linux containers")
225
+ return True
226
+
227
+ if not _IS_GITHUB:
228
+ answer = (
229
+ input(
230
+ "\nDocker on Windows must be in linux mode, this is a global change, switch? [y/n]"
231
+ )
232
+ .strip()
233
+ .lower()[:1]
234
+ )
235
+ if answer != "y":
236
+ return False
237
+
238
+ print("Switching to Linux containers...")
239
+ warnings.warn("Switching Docker to use Linux container context...")
240
+
241
+ # Explicitly specify the Linux container context
242
+ linux_context = "desktop-linux"
243
+ subprocess.run(
244
+ ["cmd", "/c", f"docker context use {linux_context}"],
245
+ check=True,
246
+ capture_output=True,
247
+ )
248
+
249
+ # Verify the switch worked
250
+ verify = subprocess.run(
251
+ ["docker", "info"], capture_output=True, text=True, check=True
252
+ )
253
+ if "linux" in verify.stdout.lower():
254
+ print(
255
+ f"Successfully switched to Linux containers using '{linux_context}' context"
256
+ )
257
+ return True
258
+
259
+ warnings.warn(
260
+ f"Failed to switch to Linux containers with context '{linux_context}': {verify.stdout}"
261
+ )
262
+ return False
263
+
264
+ except subprocess.CalledProcessError as e:
265
+ print(f"Failed to switch to Linux containers: {e}")
266
+ if e.stdout:
267
+ print(f"stdout: {e.stdout}")
268
+ if e.stderr:
269
+ print(f"stderr: {e.stderr}")
270
+ return False
271
+ except Exception as e:
272
+ print(f"Unexpected error switching to Linux containers: {e}")
273
+ return False
274
+
275
+ @staticmethod
276
+ def is_running() -> tuple[bool, Exception | None]:
277
+ """Check if Docker is running by pinging the Docker daemon."""
278
+
279
+ if not DockerManager.is_docker_installed():
280
+ print("Docker is not installed.")
281
+ return False, Exception("Docker is not installed.")
282
+ try:
283
+ # self.client.ping()
284
+ client = docker.from_env()
285
+ client.ping()
286
+ print("Docker is running.")
287
+ return True, None
288
+ except DockerException as e:
289
+ print(f"Docker is not running: {str(e)}")
290
+ return False, e
291
+ except Exception as e:
292
+ print(f"Error pinging Docker daemon: {str(e)}")
293
+ return False, e
294
+
295
+ def start(self) -> bool:
296
+ """Attempt to start Docker Desktop (or the Docker daemon) automatically."""
297
+ print("Attempting to start Docker...")
298
+
299
+ try:
300
+ if sys.platform == "win32":
301
+ docker_path = _win32_docker_location()
302
+ if not docker_path:
303
+ print("Docker Desktop not found.")
304
+ return False
305
+ subprocess.run(["start", "", docker_path], shell=True)
306
+ elif sys.platform == "darwin":
307
+ subprocess.run(["open", "-a", "Docker"])
308
+ elif sys.platform.startswith("linux"):
309
+ subprocess.run(["sudo", "systemctl", "start", "docker"])
310
+ else:
311
+ print("Unknown platform. Cannot auto-launch Docker.")
312
+ return False
313
+
314
+ # Wait for Docker to start up with increasing delays
315
+ print("Waiting for Docker Desktop to start...")
316
+ attempts = 0
317
+ max_attempts = 20 # Increased max wait time
318
+ while attempts < max_attempts:
319
+ attempts += 1
320
+ if self.is_running():
321
+ print("Docker started successfully.")
322
+ return True
323
+
324
+ # Gradually increase wait time between checks
325
+ wait_time = min(5, 1 + attempts * 0.5)
326
+ print(
327
+ f"Docker not ready yet, waiting {wait_time:.1f}s... (attempt {attempts}/{max_attempts})"
328
+ )
329
+ time.sleep(wait_time)
330
+
331
+ print("Failed to start Docker within the expected time.")
332
+ print(
333
+ "Please try starting Docker Desktop manually and run this command again."
334
+ )
335
+ except KeyboardInterrupt:
336
+ print("Aborted by user.")
337
+ raise
338
+ except Exception as e:
339
+ print(f"Error starting Docker: {str(e)}")
340
+ return False
341
+
342
+ def validate_or_download_image(
343
+ self, image_name: str, tag: str = "latest", upgrade: bool = False
344
+ ) -> bool:
345
+ """
346
+ Validate if the image exists, and if not, download it.
347
+ If upgrade is True, will pull the latest version even if image exists locally.
348
+ """
349
+ ok = DockerManager.ensure_linux_containers_for_windows()
350
+ if not ok:
351
+ warnings.warn(
352
+ "Failed to ensure Linux containers on Windows. This build may fail."
353
+ )
354
+ print(f"Validating image {image_name}:{tag}...")
355
+ remote_image_hash_from_local_image: str | None = None
356
+ remote_image_hash: str | None = None
357
+
358
+ with get_lock(f"{image_name}-{tag}"):
359
+ try:
360
+ local_image = self.client.images.get(f"{image_name}:{tag}")
361
+ print(f"Image {image_name}:{tag} is already available.")
362
+
363
+ if upgrade:
364
+ remote_image = self.client.images.get_registry_data(
365
+ f"{image_name}:{tag}"
366
+ )
367
+ remote_image_hash = remote_image.id
368
+
369
+ try:
370
+ local_image_id = local_image.id
371
+ assert local_image_id is not None
372
+ remote_image_hash_from_local_image = DISK_CACHE.get(
373
+ local_image_id
374
+ )
375
+ except KeyboardInterrupt:
376
+ raise
377
+ except Exception:
378
+ remote_image_hash_from_local_image = None
379
+ stack = traceback.format_exc()
380
+ warnings.warn(
381
+ f"Error getting remote image hash from local image: {stack}"
382
+ )
383
+ if remote_image_hash_from_local_image == remote_image_hash:
384
+ print(f"Local image {image_name}:{tag} is up to date.")
385
+ return False
386
+
387
+ # Quick check for latest version
388
+ with Spinner(f"Pulling newer version of {image_name}:{tag}..."):
389
+ cmd_list = ["docker", "pull", f"{image_name}:{tag}"]
390
+ cmd_str = subprocess.list2cmdline(cmd_list)
391
+ print(f"Running command: {cmd_str}")
392
+ subprocess.run(cmd_list, check=True)
393
+ print(f"Updated to newer version of {image_name}:{tag}")
394
+ local_image_hash = self.client.images.get(f"{image_name}:{tag}").id
395
+ assert local_image_hash is not None
396
+ if remote_image_hash is not None:
397
+ DISK_CACHE.put(local_image_hash, remote_image_hash)
398
+ return True
399
+
400
+ except ImageNotFound:
401
+ print(f"Image {image_name}:{tag} not found.")
402
+ with Spinner("Loading "):
403
+ # We use docker cli here because it shows the download.
404
+ cmd_list = ["docker", "pull", f"{image_name}:{tag}"]
405
+ cmd_str = subprocess.list2cmdline(cmd_list)
406
+ print(f"Running command: {cmd_str}")
407
+ subprocess.run(cmd_list, check=True)
408
+ try:
409
+ local_image = self.client.images.get(f"{image_name}:{tag}")
410
+ local_image_hash = local_image.id
411
+ print(f"Image {image_name}:{tag} downloaded successfully.")
412
+ except ImageNotFound:
413
+ warnings.warn(f"Image {image_name}:{tag} not found after download.")
414
+ return True
415
+
416
+ def tag_image(self, image_name: str, old_tag: str, new_tag: str) -> None:
417
+ """
418
+ Tag an image with a new tag.
419
+ """
420
+ image: Image = self.client.images.get(f"{image_name}:{old_tag}")
421
+ image.tag(image_name, new_tag)
422
+ print(f"Image {image_name}:{old_tag} tagged as {new_tag}.")
423
+
424
+ def _container_configs_match(
425
+ self,
426
+ container: Container,
427
+ command: str | None,
428
+ volumes_dict: dict[str, dict[str, str]] | None,
429
+ ports: dict[int, int] | None,
430
+ ) -> bool:
431
+ """Compare if existing container has matching configuration"""
432
+ try:
433
+ # Check if container is using the same image
434
+ image = container.image
435
+ assert image is not None
436
+ container_image_id = image.id
437
+ container_image_tags = image.tags
438
+ assert container_image_id is not None
439
+
440
+ # Simplified image comparison - just compare the IDs directly
441
+ if not container_image_tags:
442
+ print(f"Container using untagged image with ID: {container_image_id}")
443
+ else:
444
+ current_image = self.client.images.get(container_image_tags[0])
445
+ if container_image_id != current_image.id:
446
+ print(
447
+ f"Container using different image version. Container: {container_image_id}, Current: {current_image.id}"
448
+ )
449
+ return False
450
+
451
+ # Check command if specified
452
+ if command and container.attrs["Config"]["Cmd"] != command.split():
453
+ print(
454
+ f"Command mismatch: {container.attrs['Config']['Cmd']} != {command}"
455
+ )
456
+ return False
457
+
458
+ # Check volumes if specified
459
+ if volumes_dict:
460
+ container_mounts = (
461
+ {
462
+ m["Source"]: {"bind": m["Destination"], "mode": m["Mode"]}
463
+ for m in container.attrs["Mounts"]
464
+ }
465
+ if container.attrs.get("Mounts")
466
+ else {}
467
+ )
468
+
469
+ for host_dir, mount in volumes_dict.items():
470
+ if host_dir not in container_mounts:
471
+ print(f"Volume {host_dir} not found in container mounts.")
472
+ return False
473
+ if container_mounts[host_dir] != mount:
474
+ print(
475
+ f"Volume {host_dir} has different mount options: {container_mounts[host_dir]} != {mount}"
476
+ )
477
+ return False
478
+
479
+ # Check ports if specified
480
+ if ports:
481
+ container_ports = (
482
+ container.attrs["Config"]["ExposedPorts"]
483
+ if container.attrs["Config"].get("ExposedPorts")
484
+ else {}
485
+ )
486
+ container_port_bindings = (
487
+ container.attrs["HostConfig"]["PortBindings"]
488
+ if container.attrs["HostConfig"].get("PortBindings")
489
+ else {}
490
+ )
491
+
492
+ for container_port, host_port in ports.items():
493
+ port_key = f"{container_port}/tcp"
494
+ if port_key not in container_ports:
495
+ print(f"Container port {port_key} not found.")
496
+ return False
497
+ if not container_port_bindings.get(port_key, [{"HostPort": None}])[
498
+ 0
499
+ ]["HostPort"] == str(host_port):
500
+ print(f"Port {host_port} is not bound to {port_key}.")
501
+ return False
502
+ except KeyboardInterrupt:
503
+ raise
504
+ except NotFound:
505
+ print("Container not found.")
506
+ return False
507
+ except Exception as e:
508
+ stack = traceback.format_exc()
509
+ warnings.warn(f"Error checking container config: {e}\n{stack}")
510
+ return False
511
+ return True
512
+
513
+ def run_container_detached(
514
+ self,
515
+ image_name: str,
516
+ tag: str,
517
+ container_name: str,
518
+ command: str | None = None,
519
+ volumes: list[Volume] | None = None,
520
+ ports: dict[int, int] | None = None,
521
+ remove_previous: bool = False,
522
+ ) -> Container:
523
+ """
524
+ Run a container from an image. If it already exists with matching config, start it.
525
+ If it exists with different config, remove and recreate it.
526
+
527
+ Args:
528
+ volumes: List of Volume objects for container volume mappings
529
+ ports: Dict mapping host ports to container ports
530
+ Example: {8080: 80} maps host port 8080 to container port 80
531
+ """
532
+ volumes = _hack_to_fix_mac(volumes)
533
+ # Convert volumes to the format expected by Docker API
534
+ volumes_dict = None
535
+ if volumes is not None:
536
+ volumes_dict = {}
537
+ for volume in volumes:
538
+ volumes_dict.update(volume.to_dict())
539
+
540
+ # Serialize the volumes to a json string
541
+ if volumes_dict:
542
+ volumes_str = json.dumps(volumes_dict)
543
+ print(f"Volumes: {volumes_str}")
544
+ print("Done")
545
+ image_name = f"{image_name}:{tag}"
546
+ try:
547
+ container: Container = self.client.containers.get(container_name)
548
+
549
+ if remove_previous:
550
+ print(f"Removing existing container {container_name}...")
551
+ container.remove(force=True)
552
+ raise NotFound("Container removed due to remove_previous")
553
+ # Check if configuration matches
554
+ elif not self._container_configs_match(
555
+ container, command, volumes_dict, ports
556
+ ):
557
+ print(
558
+ f"Container {container_name} exists but with different configuration. Removing and recreating..."
559
+ )
560
+ container.remove(force=True)
561
+ raise NotFound("Container removed due to config mismatch")
562
+ print(f"Container {container_name} found with matching configuration.")
563
+
564
+ # Existing container with matching config - handle various states
565
+ if container.status == "running":
566
+ print(f"Container {container_name} is already running.")
567
+ elif container.status == "exited":
568
+ print(f"Starting existing container {container_name}.")
569
+ container.start()
570
+ elif container.status == "restarting":
571
+ print(f"Waiting for container {container_name} to restart...")
572
+ timeout = 10
573
+ container.wait(timeout=10)
574
+ if container.status == "running":
575
+ print(f"Container {container_name} has restarted.")
576
+ else:
577
+ print(
578
+ f"Container {container_name} did not restart within {timeout} seconds."
579
+ )
580
+ container.stop(timeout=0)
581
+ print(f"Container {container_name} has been stopped.")
582
+ container.start()
583
+ elif container.status == "paused":
584
+ print(f"Resuming existing container {container_name}.")
585
+ container.unpause()
586
+ else:
587
+ print(f"Unknown container status: {container.status}")
588
+ print(f"Starting existing container {container_name}.")
589
+ self.first_run = True
590
+ container.start()
591
+ except NotFound:
592
+ print(f"Creating and starting {container_name}")
593
+ out_msg = f"# Running in container: {command}"
594
+ msg_len = len(out_msg)
595
+ print("\n" + "#" * msg_len)
596
+ print(out_msg)
597
+ print("#" * msg_len + "\n")
598
+ container = self.client.containers.run(
599
+ image=image_name,
600
+ command=command,
601
+ name=container_name,
602
+ detach=True,
603
+ tty=True,
604
+ volumes=volumes_dict,
605
+ ports=ports, # type: ignore
606
+ remove=True,
607
+ )
608
+ return container
609
+
610
+ def run_container_interactive(
611
+ self,
612
+ image_name: str,
613
+ tag: str,
614
+ container_name: str,
615
+ command: str | None = None,
616
+ volumes: list[Volume] | None = None,
617
+ ports: dict[int, int] | None = None,
618
+ ) -> None:
619
+ # Convert volumes to the format expected by Docker API
620
+ volumes = _hack_to_fix_mac(volumes)
621
+ volumes_dict = None
622
+ if volumes is not None:
623
+ volumes_dict = {}
624
+ for volume in volumes:
625
+ volumes_dict.update(volume.to_dict())
626
+ # Remove existing container
627
+ try:
628
+ container: Container = self.client.containers.get(container_name)
629
+ container.remove(force=True)
630
+ except NotFound:
631
+ pass
632
+ start_time = time.time()
633
+ try:
634
+ docker_command: list[str] = [
635
+ "docker",
636
+ "run",
637
+ "-it",
638
+ "--rm",
639
+ "--name",
640
+ container_name,
641
+ ]
642
+ if volumes_dict:
643
+ for host_dir, mount in volumes_dict.items():
644
+ docker_volume_arg = [
645
+ "-v",
646
+ f"{host_dir}:{mount['bind']}:{mount['mode']}",
647
+ ]
648
+ docker_command.extend(docker_volume_arg)
649
+ if ports:
650
+ for host_port, container_port in ports.items():
651
+ docker_command.extend(["-p", f"{host_port}:{container_port}"])
652
+ docker_command.append(f"{image_name}:{tag}")
653
+ if command:
654
+ docker_command.append(command)
655
+ cmd_str: str = subprocess.list2cmdline(docker_command)
656
+ print(f"Running command: {cmd_str}")
657
+ subprocess.run(docker_command, check=False)
658
+ except subprocess.CalledProcessError as e:
659
+ print(f"Error running Docker command: {e}")
660
+ diff = time.time() - start_time
661
+ if diff < 5:
662
+ raise
663
+ sys.exit(1) # Probably a user exit.
664
+
665
+ def attach_and_run(self, container: Container | str) -> RunningContainer:
666
+ """
667
+ Attach to a running container and monitor its logs in a background thread.
668
+ Returns a RunningContainer object that can be used to stop monitoring.
669
+ """
670
+ if isinstance(container, str):
671
+ container_name = container
672
+ tmp = self.get_container(container)
673
+ assert tmp is not None, f"Container {container_name} not found."
674
+ container = tmp
675
+
676
+ assert container is not None, "Container not found."
677
+
678
+ print(f"Attaching to container {container.name}...")
679
+
680
+ first_run = self.first_run
681
+ self.first_run = False
682
+
683
+ return RunningContainer(container, first_run)
684
+
685
+ def suspend_container(self, container: Container | str) -> None:
686
+ """
687
+ Suspend (pause) the container.
688
+ """
689
+ if self.is_suspended:
690
+ return
691
+ if isinstance(container, str):
692
+ container_name = container
693
+ # container = self.get_container(container)
694
+ tmp = self.get_container(container_name)
695
+ if not tmp:
696
+ print(f"Could not put container {container_name} to sleep.")
697
+ return
698
+ container = tmp
699
+ assert isinstance(container, Container)
700
+ try:
701
+ if platform.system() == "Windows":
702
+ container.pause()
703
+ else:
704
+ container.stop()
705
+ container.remove()
706
+ print(f"Container {container.name} has been suspended.")
707
+ except KeyboardInterrupt:
708
+ print(f"Container {container.name} interrupted by keyboard interrupt.")
709
+ except Exception as e:
710
+ print(f"Failed to suspend container {container.name}: {e}")
711
+
712
+ def resume_container(self, container: Container | str) -> None:
713
+ """
714
+ Resume (unpause) the container.
715
+ """
716
+ container_name = "UNKNOWN"
717
+ if isinstance(container, str):
718
+ container_name = container
719
+ container_or_none = self.get_container(container)
720
+ if container_or_none is None:
721
+ print(f"Could not resume container {container}.")
722
+ return
723
+ container = container_or_none
724
+ container_name = container.name
725
+ elif isinstance(container, Container):
726
+ container_name = container.name
727
+ assert isinstance(container, Container)
728
+ if not container:
729
+ print(f"Could not resume container {container}.")
730
+ return
731
+ try:
732
+ assert isinstance(container, Container)
733
+ container.unpause()
734
+ print(f"Container {container.name} has been resumed.")
735
+ except Exception as e:
736
+ print(f"Failed to resume container {container_name}: {e}")
737
+
738
+ def get_container(self, container_name: str) -> Container | None:
739
+ """
740
+ Get a container by name.
741
+ """
742
+ try:
743
+ return self.client.containers.get(container_name)
744
+ except NotFound:
745
+ return None
746
+
747
+ def is_container_running(self, container_name: str) -> bool:
748
+ """
749
+ Check if a container is running.
750
+ """
751
+ try:
752
+ container = self.client.containers.get(container_name)
753
+ return container.status == "running"
754
+ except NotFound:
755
+ print(f"Container {container_name} not found.")
756
+ return False
757
+
758
+ def build_image(
759
+ self,
760
+ image_name: str,
761
+ tag: str,
762
+ dockerfile_path: Path,
763
+ build_context: Path,
764
+ build_args: dict[str, str] | None = None,
765
+ platform_tag: str = "",
766
+ ) -> None:
767
+ """
768
+ Build a Docker image from a Dockerfile.
769
+
770
+ Args:
771
+ image_name: Name for the image
772
+ tag: Tag for the image
773
+ dockerfile_path: Path to the Dockerfile
774
+ build_context: Path to the build context directory
775
+ build_args: Optional dictionary of build arguments
776
+ platform_tag: Optional platform tag (e.g. "-arm64")
777
+ """
778
+ if not dockerfile_path.exists():
779
+ raise FileNotFoundError(f"Dockerfile not found at {dockerfile_path}")
780
+
781
+ if not build_context.exists():
782
+ raise FileNotFoundError(
783
+ f"Build context directory not found at {build_context}"
784
+ )
785
+
786
+ print(f"Building Docker image {image_name}:{tag} from {dockerfile_path}")
787
+
788
+ # Prepare build arguments
789
+ buildargs = build_args or {}
790
+ if platform_tag:
791
+ buildargs["PLATFORM_TAG"] = platform_tag
792
+
793
+ try:
794
+ cmd_list = [
795
+ "docker",
796
+ "build",
797
+ "-t",
798
+ f"{image_name}:{tag}",
799
+ ]
800
+
801
+ # Add build args
802
+ for arg_name, arg_value in buildargs.items():
803
+ cmd_list.extend(["--build-arg", f"{arg_name}={arg_value}"])
804
+
805
+ # Add dockerfile and context paths
806
+ cmd_list.extend(["-f", str(dockerfile_path), str(build_context)])
807
+
808
+ cmd_str = subprocess.list2cmdline(cmd_list)
809
+ print(f"Running command: {cmd_str}")
810
+
811
+ # Run the build command
812
+ # cp = subprocess.run(cmd_list, check=True, capture_output=True)
813
+ proc: subprocess.Popen = subprocess.Popen(
814
+ cmd_list, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
815
+ )
816
+ stdout = proc.stdout
817
+ assert stdout is not None, "stdout is None"
818
+ for line in iter(stdout.readline, b""):
819
+ try:
820
+ line_str = line.decode("utf-8")
821
+ print(line_str, end="")
822
+ except UnicodeDecodeError:
823
+ print("Error decoding line")
824
+ rtn = proc.wait()
825
+ if rtn != 0:
826
+ warnings.warn(
827
+ f"Error building Docker image, is docker running? {rtn}, stdout: {stdout}, stderr: {proc.stderr}"
828
+ )
829
+ raise subprocess.CalledProcessError(rtn, cmd_str)
830
+ print(f"Successfully built image {image_name}:{tag}")
831
+
832
+ except subprocess.CalledProcessError as e:
833
+ print(f"Error building Docker image: {e}")
834
+ raise
835
+
836
+ def purge(self, image_name: str) -> None:
837
+ """
838
+ Remove all containers and images associated with the given image name.
839
+
840
+ Args:
841
+ image_name: The name of the image to purge (without tag)
842
+ """
843
+ print(f"Purging all containers and images for {image_name}...")
844
+
845
+ # Remove all containers using this image
846
+ try:
847
+ containers = self.client.containers.list(all=True)
848
+ for container in containers:
849
+ if any(image_name in tag for tag in container.image.tags):
850
+ print(f"Removing container {container.name}")
851
+ container.remove(force=True)
852
+
853
+ except Exception as e:
854
+ print(f"Error removing containers: {e}")
855
+
856
+ # Remove all images with this name
857
+ try:
858
+ self.client.images.prune(filters={"dangling": False})
859
+ images = self.client.images.list()
860
+ for image in images:
861
+ if any(image_name in tag for tag in image.tags):
862
+ print(f"Removing image {image.tags}")
863
+ self.client.images.remove(image.id, force=True)
864
+ except Exception as e:
865
+ print(f"Error removing images: {e}")
866
+
867
+
868
+ def main() -> None:
869
+ # Register SIGINT handler
870
+ # signal.signal(signal.SIGINT, handle_sigint)
871
+
872
+ docker_manager = DockerManager()
873
+
874
+ # Parameters
875
+ image_name = "python"
876
+ tag = "3.10-slim"
877
+ # new_tag = "my-python"
878
+ container_name = "my-python-container"
879
+ command = "python -m http.server"
880
+ running_container: RunningContainer | None = None
881
+
882
+ try:
883
+ # Step 1: Validate or download the image
884
+ docker_manager.validate_or_download_image(image_name, tag, upgrade=True)
885
+
886
+ # Step 2: Tag the image
887
+ # docker_manager.tag_image(image_name, tag, new_tag)
888
+
889
+ # Step 3: Run the container
890
+ container = docker_manager.run_container_detached(
891
+ image_name, tag, container_name, command
892
+ )
893
+
894
+ # Step 4: Attach and monitor the container logs
895
+ running_container = docker_manager.attach_and_run(container)
896
+
897
+ # Wait for keyboard interrupt
898
+ while True:
899
+ time.sleep(0.1)
900
+
901
+ except KeyboardInterrupt:
902
+ print("\nStopping container...")
903
+ if isinstance(running_container, RunningContainer):
904
+ running_container.stop()
905
+ container_or_none = docker_manager.get_container(container_name)
906
+ if container_or_none is not None:
907
+ docker_manager.suspend_container(container_or_none)
908
+ else:
909
+ warnings.warn(f"Container {container_name} not found.")
910
+
911
+ try:
912
+ # Suspend and resume the container
913
+ container = docker_manager.get_container(container_name)
914
+ assert container is not None, "Container not found."
915
+ docker_manager.suspend_container(container)
916
+
917
+ input("Press Enter to resume the container...")
918
+
919
+ docker_manager.resume_container(container)
920
+ except Exception as e:
921
+ print(f"An error occurred: {e}")
922
+
923
+
924
+ if __name__ == "__main__":
925
+ main()