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