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