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