fastled 1.2.33__py3-none-any.whl → 1.4.50__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/__init__.py +51 -192
- fastled/__main__.py +14 -0
- fastled/__version__.py +6 -0
- fastled/app.py +124 -27
- fastled/args.py +124 -0
- fastled/assets/localhost-key.pem +28 -0
- fastled/assets/localhost.pem +27 -0
- fastled/cli.py +10 -2
- fastled/cli_test.py +21 -0
- fastled/cli_test_interactive.py +21 -0
- fastled/client_server.py +334 -55
- fastled/compile_server.py +12 -1
- fastled/compile_server_impl.py +115 -42
- fastled/docker_manager.py +392 -69
- fastled/emoji_util.py +27 -0
- fastled/filewatcher.py +100 -8
- fastled/find_good_connection.py +105 -0
- fastled/header_dump.py +63 -0
- fastled/install/__init__.py +1 -0
- fastled/install/examples_manager.py +62 -0
- fastled/install/extension_manager.py +113 -0
- fastled/install/main.py +156 -0
- fastled/install/project_detection.py +167 -0
- fastled/install/test_install.py +373 -0
- fastled/install/vscode_config.py +344 -0
- fastled/interruptible_http.py +148 -0
- fastled/keyboard.py +1 -0
- fastled/keyz.py +84 -0
- fastled/live_client.py +26 -1
- fastled/open_browser.py +133 -89
- fastled/parse_args.py +219 -15
- fastled/playwright/chrome_extension_downloader.py +207 -0
- fastled/playwright/playwright_browser.py +773 -0
- fastled/playwright/resize_tracking.py +127 -0
- fastled/print_filter.py +52 -0
- fastled/project_init.py +20 -13
- fastled/select_sketch_directory.py +142 -17
- fastled/server_flask.py +487 -0
- fastled/server_start.py +21 -0
- fastled/settings.py +53 -4
- fastled/site/build.py +2 -10
- fastled/site/examples.py +10 -0
- fastled/sketch.py +129 -7
- fastled/string_diff.py +218 -9
- fastled/test/examples.py +7 -5
- fastled/types.py +22 -2
- fastled/util.py +78 -0
- fastled/version.py +41 -0
- fastled/web_compile.py +401 -218
- fastled/zip_files.py +76 -0
- {fastled-1.2.33.dist-info → fastled-1.4.50.dist-info}/METADATA +533 -382
- fastled-1.4.50.dist-info/RECORD +60 -0
- {fastled-1.2.33.dist-info → fastled-1.4.50.dist-info}/WHEEL +1 -1
- fastled/open_browser2.py +0 -111
- fastled-1.2.33.dist-info/RECORD +0 -33
- {fastled-1.2.33.dist-info → fastled-1.4.50.dist-info}/entry_points.txt +0 -0
- {fastled-1.2.33.dist-info → fastled-1.4.50.dist-info/licenses}/LICENSE +0 -0
- {fastled-1.2.33.dist-info → fastled-1.4.50.dist-info}/top_level.txt +0 -0
fastled/docker_manager.py
CHANGED
|
@@ -3,6 +3,7 @@ New abstraction for Docker management with improved Ctrl+C handling.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import _thread
|
|
6
|
+
import json
|
|
6
7
|
import os
|
|
7
8
|
import platform
|
|
8
9
|
import subprocess
|
|
@@ -11,6 +12,7 @@ import threading
|
|
|
11
12
|
import time
|
|
12
13
|
import traceback
|
|
13
14
|
import warnings
|
|
15
|
+
from dataclasses import dataclass
|
|
14
16
|
from datetime import datetime, timezone
|
|
15
17
|
from pathlib import Path
|
|
16
18
|
|
|
@@ -18,17 +20,22 @@ import docker
|
|
|
18
20
|
from appdirs import user_data_dir
|
|
19
21
|
from disklru import DiskLRUCache
|
|
20
22
|
from docker.client import DockerClient
|
|
23
|
+
from docker.errors import DockerException, ImageNotFound, NotFound
|
|
21
24
|
from docker.models.containers import Container
|
|
22
25
|
from docker.models.images import Image
|
|
23
26
|
from filelock import FileLock
|
|
24
27
|
|
|
25
|
-
from fastled.
|
|
28
|
+
from fastled.print_filter import PrintFilter, PrintFilterDefault
|
|
26
29
|
|
|
27
30
|
CONFIG_DIR = Path(user_data_dir("fastled", "fastled"))
|
|
28
31
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
29
32
|
DB_FILE = CONFIG_DIR / "db.db"
|
|
30
33
|
DISK_CACHE = DiskLRUCache(str(DB_FILE), 10)
|
|
31
34
|
_IS_GITHUB = "GITHUB_ACTIONS" in os.environ
|
|
35
|
+
_DEFAULT_BUILD_DIR = "/js/.pio/build"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
FORCE_CLEAR: bool = bool(os.environ.get("FASTLED_FORCE_CLEAR", "0") == "1")
|
|
32
39
|
|
|
33
40
|
|
|
34
41
|
# Docker uses datetimes in UTC but without the timezone info. If we pass in a tz
|
|
@@ -38,6 +45,37 @@ def _utc_now_no_tz() -> datetime:
|
|
|
38
45
|
return now.replace(tzinfo=None)
|
|
39
46
|
|
|
40
47
|
|
|
48
|
+
def set_ramdisk_size(size: str) -> None:
|
|
49
|
+
"""Set the tmpfs size for the container."""
|
|
50
|
+
# This is a hack to set the tmpfs size from the environment variable.
|
|
51
|
+
# It should be set in the docker-compose.yml file.
|
|
52
|
+
# If not set, return 25MB.
|
|
53
|
+
try:
|
|
54
|
+
os.environ["TMPFS_SIZE"] = str(size)
|
|
55
|
+
except ValueError:
|
|
56
|
+
os.environ["TMPFS_SIZE"] = "0" # Defaults to off
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_ramdisk_size() -> str | None:
|
|
60
|
+
"""Get the tmpfs size for the container."""
|
|
61
|
+
# This is a hack to get the tmpfs size from the environment variable.
|
|
62
|
+
# It should be set in the docker-compose.yml file.
|
|
63
|
+
# If not set, return 25MB.
|
|
64
|
+
try:
|
|
65
|
+
return os.environ.get("TMPFS_SIZE", None)
|
|
66
|
+
except ValueError:
|
|
67
|
+
return None # Defaults to off
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_force_remove_image_previous() -> bool:
|
|
71
|
+
"""Get the force remove image previous value."""
|
|
72
|
+
return os.environ.get("FASTLED_FORCE_CLEAR", "0") == "1"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def set_clear() -> None:
|
|
76
|
+
os.environ["FASTLED_FORCE_CLEAR"] = "1"
|
|
77
|
+
|
|
78
|
+
|
|
41
79
|
def _win32_docker_location() -> str | None:
|
|
42
80
|
home_dir = Path.home()
|
|
43
81
|
out = [
|
|
@@ -57,11 +95,59 @@ def get_lock(image_name: str) -> FileLock:
|
|
|
57
95
|
print(CONFIG_DIR)
|
|
58
96
|
if not lock_file.parent.exists():
|
|
59
97
|
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
-
|
|
98
|
+
out: FileLock
|
|
99
|
+
out = FileLock(str(lock_file)) # type: ignore
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class Volume:
|
|
105
|
+
"""
|
|
106
|
+
Represents a Docker volume mapping between host and container.
|
|
107
|
+
|
|
108
|
+
Attributes:
|
|
109
|
+
host_path: Path on the host system (e.g., "C:\\Users\\username\\project")
|
|
110
|
+
container_path: Path inside the container (e.g., "/app/data")
|
|
111
|
+
mode: Access mode, "rw" for read-write or "ro" for read-only
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
host_path: str
|
|
115
|
+
container_path: str
|
|
116
|
+
mode: str = "rw"
|
|
117
|
+
|
|
118
|
+
def to_dict(self) -> dict[str, dict[str, str]]:
|
|
119
|
+
"""Convert the Volume object to the format expected by Docker API."""
|
|
120
|
+
return {self.host_path: {"bind": self.container_path, "mode": self.mode}}
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def from_dict(cls, volume_dict: dict[str, dict[str, str]]) -> list["Volume"]:
|
|
124
|
+
"""Create Volume objects from a Docker volume dictionary."""
|
|
125
|
+
volumes = []
|
|
126
|
+
for host_path, config in volume_dict.items():
|
|
127
|
+
volumes.append(
|
|
128
|
+
cls(
|
|
129
|
+
host_path=host_path,
|
|
130
|
+
container_path=config["bind"],
|
|
131
|
+
mode=config.get("mode", "rw"),
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
return volumes
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# Override the default PrintFilter to use a custom one.
|
|
138
|
+
def make_default_print_filter() -> PrintFilter:
|
|
139
|
+
"""Create a default PrintFilter instance."""
|
|
140
|
+
return PrintFilterDefault()
|
|
61
141
|
|
|
62
142
|
|
|
63
143
|
class RunningContainer:
|
|
64
|
-
def __init__(
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
container: Container,
|
|
147
|
+
first_run: bool = False,
|
|
148
|
+
filter: PrintFilter | None = None,
|
|
149
|
+
) -> None:
|
|
150
|
+
self.filter = filter or make_default_print_filter()
|
|
65
151
|
self.container = container
|
|
66
152
|
self.first_run = first_run
|
|
67
153
|
self.running = True
|
|
@@ -78,7 +164,8 @@ class RunningContainer:
|
|
|
78
164
|
for log in self.container.logs(
|
|
79
165
|
follow=False, since=from_date, until=to_date, stream=True
|
|
80
166
|
):
|
|
81
|
-
print(log.decode("utf-8"), end="")
|
|
167
|
+
# print(log.decode("utf-8"), end="")
|
|
168
|
+
self.filter.print(log)
|
|
82
169
|
time.sleep(0.1)
|
|
83
170
|
from_date = to_date
|
|
84
171
|
to_date = _utc_now_no_tz()
|
|
@@ -95,11 +182,64 @@ class RunningContainer:
|
|
|
95
182
|
self.running = False
|
|
96
183
|
self.thread.join()
|
|
97
184
|
|
|
185
|
+
def stop(self) -> None:
|
|
186
|
+
"""Stop the container"""
|
|
187
|
+
self.container.stop()
|
|
188
|
+
self.detach()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _hack_to_fix_mac(volumes: list[Volume] | None) -> list[Volume] | None:
|
|
192
|
+
"""Fixes the volume mounts on MacOS by removing the mode."""
|
|
193
|
+
if volumes is None:
|
|
194
|
+
return None
|
|
195
|
+
if sys.platform != "darwin":
|
|
196
|
+
# Only macos needs hacking.
|
|
197
|
+
return volumes
|
|
198
|
+
|
|
199
|
+
volumes = volumes.copy()
|
|
200
|
+
# Work around a Docker bug on MacOS where the expected network socket to the
|
|
201
|
+
# the host is not mounted correctly. This was actually fixed in recent versions
|
|
202
|
+
# of docker client but there is a large chunk of Docker clients out there with
|
|
203
|
+
# this bug in it.
|
|
204
|
+
#
|
|
205
|
+
# This hack is done by mounting the socket directly to the container.
|
|
206
|
+
# This socket talks to the docker daemon on the host.
|
|
207
|
+
#
|
|
208
|
+
# Found here.
|
|
209
|
+
# https://github.com/docker/docker-py/issues/3069#issuecomment-1316778735
|
|
210
|
+
# if it exists already then return the input
|
|
211
|
+
for volume in volumes:
|
|
212
|
+
if volume.host_path == "/var/run/docker.sock":
|
|
213
|
+
return volumes
|
|
214
|
+
# ok it doesn't exist, so add it
|
|
215
|
+
volumes.append(
|
|
216
|
+
Volume(
|
|
217
|
+
host_path="/var/run/docker.sock",
|
|
218
|
+
container_path="/var/run/docker.sock",
|
|
219
|
+
mode="rw",
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
return volumes
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def set_force_remove_image_previous(new_value: str | None = None) -> None:
|
|
226
|
+
if new_value is not None:
|
|
227
|
+
os.environ["FASTLED_FORCE_CLEAR"] = new_value
|
|
228
|
+
else:
|
|
229
|
+
os.environ["FASTLED_FORCE_CLEAR"] = "1"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def force_image_removal() -> bool:
|
|
233
|
+
"""Get the force remove image previous value."""
|
|
234
|
+
return os.environ.get("FASTLED_FORCE_CLEAR", "0") == "1"
|
|
235
|
+
|
|
98
236
|
|
|
99
237
|
class DockerManager:
|
|
100
238
|
def __init__(self) -> None:
|
|
101
239
|
from docker.errors import DockerException
|
|
102
240
|
|
|
241
|
+
self.is_suspended: bool = False
|
|
242
|
+
|
|
103
243
|
try:
|
|
104
244
|
self._client: DockerClient | None = None
|
|
105
245
|
self.first_run = False
|
|
@@ -111,7 +251,34 @@ class DockerManager:
|
|
|
111
251
|
@property
|
|
112
252
|
def client(self) -> DockerClient:
|
|
113
253
|
if self._client is None:
|
|
114
|
-
|
|
254
|
+
# Retry logic for WSL startup on Windows
|
|
255
|
+
max_retries = 10
|
|
256
|
+
retry_delay = 2 # seconds
|
|
257
|
+
last_error = None
|
|
258
|
+
|
|
259
|
+
for attempt in range(max_retries):
|
|
260
|
+
try:
|
|
261
|
+
self._client = docker.from_env()
|
|
262
|
+
if attempt > 0:
|
|
263
|
+
print(
|
|
264
|
+
f"Successfully connected to Docker after {attempt + 1} attempts"
|
|
265
|
+
)
|
|
266
|
+
return self._client
|
|
267
|
+
except DockerException as e:
|
|
268
|
+
last_error = e
|
|
269
|
+
if attempt < max_retries - 1:
|
|
270
|
+
if attempt == 0:
|
|
271
|
+
print("Waiting for Docker/WSL to be ready...")
|
|
272
|
+
print(
|
|
273
|
+
f"Attempt {attempt + 1}/{max_retries} failed, retrying in {retry_delay}s..."
|
|
274
|
+
)
|
|
275
|
+
time.sleep(retry_delay)
|
|
276
|
+
else:
|
|
277
|
+
print(
|
|
278
|
+
f"Failed to connect to Docker after {max_retries} attempts"
|
|
279
|
+
)
|
|
280
|
+
raise last_error
|
|
281
|
+
assert self._client is not None
|
|
115
282
|
return self._client
|
|
116
283
|
|
|
117
284
|
@staticmethod
|
|
@@ -181,10 +348,6 @@ class DockerManager:
|
|
|
181
348
|
)
|
|
182
349
|
return False
|
|
183
350
|
|
|
184
|
-
except subprocess.CalledProcessError as e:
|
|
185
|
-
print(f"Error occurred: {e}")
|
|
186
|
-
return False
|
|
187
|
-
|
|
188
351
|
except subprocess.CalledProcessError as e:
|
|
189
352
|
print(f"Failed to switch to Linux containers: {e}")
|
|
190
353
|
if e.stdout:
|
|
@@ -197,22 +360,24 @@ class DockerManager:
|
|
|
197
360
|
return False
|
|
198
361
|
|
|
199
362
|
@staticmethod
|
|
200
|
-
def is_running() -> bool:
|
|
363
|
+
def is_running() -> tuple[bool, Exception | None]:
|
|
201
364
|
"""Check if Docker is running by pinging the Docker daemon."""
|
|
365
|
+
|
|
202
366
|
if not DockerManager.is_docker_installed():
|
|
203
|
-
|
|
367
|
+
print("Docker is not installed.")
|
|
368
|
+
return False, Exception("Docker is not installed.")
|
|
204
369
|
try:
|
|
205
370
|
# self.client.ping()
|
|
206
371
|
client = docker.from_env()
|
|
207
372
|
client.ping()
|
|
208
373
|
print("Docker is running.")
|
|
209
|
-
return True
|
|
210
|
-
except
|
|
374
|
+
return True, None
|
|
375
|
+
except DockerException as e:
|
|
211
376
|
print(f"Docker is not running: {str(e)}")
|
|
212
|
-
return False
|
|
377
|
+
return False, e
|
|
213
378
|
except Exception as e:
|
|
214
379
|
print(f"Error pinging Docker daemon: {str(e)}")
|
|
215
|
-
return False
|
|
380
|
+
return False, e
|
|
216
381
|
|
|
217
382
|
def start(self) -> bool:
|
|
218
383
|
"""Attempt to start Docker Desktop (or the Docker daemon) automatically."""
|
|
@@ -226,7 +391,8 @@ class DockerManager:
|
|
|
226
391
|
return False
|
|
227
392
|
subprocess.run(["start", "", docker_path], shell=True)
|
|
228
393
|
elif sys.platform == "darwin":
|
|
229
|
-
subprocess.run(["open", "
|
|
394
|
+
subprocess.run(["open", "-a", "Docker"])
|
|
395
|
+
time.sleep(2) # Give Docker time to start
|
|
230
396
|
elif sys.platform.startswith("linux"):
|
|
231
397
|
subprocess.run(["sudo", "systemctl", "start", "docker"])
|
|
232
398
|
else:
|
|
@@ -261,9 +427,69 @@ class DockerManager:
|
|
|
261
427
|
print(f"Error starting Docker: {str(e)}")
|
|
262
428
|
return False
|
|
263
429
|
|
|
430
|
+
def has_newer_version(
|
|
431
|
+
self, image_name: str, tag: str = "latest"
|
|
432
|
+
) -> tuple[bool, str]:
|
|
433
|
+
"""
|
|
434
|
+
Check if a newer version of the image is available in the registry.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
image_name: The name of the image to check
|
|
438
|
+
tag: The tag of the image to check
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
A tuple of (has_newer_version, message)
|
|
442
|
+
has_newer_version: True if a newer version is available, False otherwise
|
|
443
|
+
message: A message describing the result, including the date of the newer version if available
|
|
444
|
+
"""
|
|
445
|
+
try:
|
|
446
|
+
# Get the local image
|
|
447
|
+
local_image = self.client.images.get(f"{image_name}:{tag}")
|
|
448
|
+
local_image_id = local_image.id
|
|
449
|
+
assert local_image_id is not None
|
|
450
|
+
|
|
451
|
+
# Get the remote image data
|
|
452
|
+
remote_image = self.client.images.get_registry_data(f"{image_name}:{tag}")
|
|
453
|
+
remote_image_hash = remote_image.id
|
|
454
|
+
|
|
455
|
+
# Check if we have a cached remote hash for this local image
|
|
456
|
+
try:
|
|
457
|
+
remote_image_hash_from_local_image = DISK_CACHE.get(local_image_id)
|
|
458
|
+
except Exception:
|
|
459
|
+
remote_image_hash_from_local_image = None
|
|
460
|
+
|
|
461
|
+
# Compare the hashes
|
|
462
|
+
if remote_image_hash_from_local_image == remote_image_hash:
|
|
463
|
+
return False, f"Local image {image_name}:{tag} is up to date."
|
|
464
|
+
else:
|
|
465
|
+
# Get the creation date of the remote image if possible
|
|
466
|
+
try:
|
|
467
|
+
# Try to get detailed image info including creation date
|
|
468
|
+
remote_image_details = self.client.api.inspect_image(
|
|
469
|
+
f"{image_name}:{tag}"
|
|
470
|
+
)
|
|
471
|
+
if "Created" in remote_image_details:
|
|
472
|
+
created_date = remote_image_details["Created"].split("T")[
|
|
473
|
+
0
|
|
474
|
+
] # Extract just the date part
|
|
475
|
+
return (
|
|
476
|
+
True,
|
|
477
|
+
f"Newer version of {image_name}:{tag} is available (published on {created_date}).",
|
|
478
|
+
)
|
|
479
|
+
except Exception:
|
|
480
|
+
pass
|
|
481
|
+
|
|
482
|
+
# Fallback if we couldn't get the date
|
|
483
|
+
return True, f"Newer version of {image_name}:{tag} is available."
|
|
484
|
+
|
|
485
|
+
except ImageNotFound:
|
|
486
|
+
return True, f"Image {image_name}:{tag} not found locally."
|
|
487
|
+
except DockerException as e:
|
|
488
|
+
return False, f"Error checking for newer version: {e}"
|
|
489
|
+
|
|
264
490
|
def validate_or_download_image(
|
|
265
491
|
self, image_name: str, tag: str = "latest", upgrade: bool = False
|
|
266
|
-
) ->
|
|
492
|
+
) -> bool:
|
|
267
493
|
"""
|
|
268
494
|
Validate if the image exists, and if not, download it.
|
|
269
495
|
If upgrade is True, will pull the latest version even if image exists locally.
|
|
@@ -289,8 +515,10 @@ class DockerManager:
|
|
|
289
515
|
remote_image_hash = remote_image.id
|
|
290
516
|
|
|
291
517
|
try:
|
|
518
|
+
local_image_id = local_image.id
|
|
519
|
+
assert local_image_id is not None
|
|
292
520
|
remote_image_hash_from_local_image = DISK_CACHE.get(
|
|
293
|
-
|
|
521
|
+
local_image_id
|
|
294
522
|
)
|
|
295
523
|
except KeyboardInterrupt:
|
|
296
524
|
raise
|
|
@@ -302,34 +530,32 @@ class DockerManager:
|
|
|
302
530
|
)
|
|
303
531
|
if remote_image_hash_from_local_image == remote_image_hash:
|
|
304
532
|
print(f"Local image {image_name}:{tag} is up to date.")
|
|
305
|
-
return
|
|
533
|
+
return False
|
|
306
534
|
|
|
307
535
|
# Quick check for latest version
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
cmd_list = ["docker", "pull", f"{image_name}:{tag}"]
|
|
312
|
-
cmd_str = subprocess.list2cmdline(cmd_list)
|
|
313
|
-
print(f"Running command: {cmd_str}")
|
|
314
|
-
subprocess.run(cmd_list, check=True)
|
|
536
|
+
print(f"Pulling newer version of {image_name}:{tag}...")
|
|
537
|
+
cmd_list = ["docker", "pull", f"{image_name}:{tag}"]
|
|
538
|
+
subprocess.run(cmd_list, check=True)
|
|
315
539
|
print(f"Updated to newer version of {image_name}:{tag}")
|
|
316
540
|
local_image_hash = self.client.images.get(f"{image_name}:{tag}").id
|
|
541
|
+
assert local_image_hash is not None
|
|
317
542
|
if remote_image_hash is not None:
|
|
318
543
|
DISK_CACHE.put(local_image_hash, remote_image_hash)
|
|
544
|
+
return True
|
|
319
545
|
|
|
320
|
-
except
|
|
546
|
+
except ImageNotFound:
|
|
321
547
|
print(f"Image {image_name}:{tag} not found.")
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
subprocess.run(cmd_list, check=True)
|
|
548
|
+
print("Loading...")
|
|
549
|
+
# We use docker cli here because it shows the download.
|
|
550
|
+
cmd_list = ["docker", "pull", f"{image_name}:{tag}"]
|
|
551
|
+
subprocess.run(cmd_list, check=True)
|
|
327
552
|
try:
|
|
328
553
|
local_image = self.client.images.get(f"{image_name}:{tag}")
|
|
329
554
|
local_image_hash = local_image.id
|
|
330
555
|
print(f"Image {image_name}:{tag} downloaded successfully.")
|
|
331
|
-
except
|
|
556
|
+
except ImageNotFound:
|
|
332
557
|
warnings.warn(f"Image {image_name}:{tag} not found after download.")
|
|
558
|
+
return True
|
|
333
559
|
|
|
334
560
|
def tag_image(self, image_name: str, old_tag: str, new_tag: str) -> None:
|
|
335
561
|
"""
|
|
@@ -343,14 +569,17 @@ class DockerManager:
|
|
|
343
569
|
self,
|
|
344
570
|
container: Container,
|
|
345
571
|
command: str | None,
|
|
346
|
-
|
|
347
|
-
ports: dict | None,
|
|
572
|
+
volumes_dict: dict[str, dict[str, str]] | None,
|
|
573
|
+
ports: dict[int, int] | None,
|
|
348
574
|
) -> bool:
|
|
349
575
|
"""Compare if existing container has matching configuration"""
|
|
350
576
|
try:
|
|
351
577
|
# Check if container is using the same image
|
|
352
|
-
|
|
353
|
-
|
|
578
|
+
image = container.image
|
|
579
|
+
assert image is not None
|
|
580
|
+
container_image_id = image.id
|
|
581
|
+
container_image_tags = image.tags
|
|
582
|
+
assert container_image_id is not None
|
|
354
583
|
|
|
355
584
|
# Simplified image comparison - just compare the IDs directly
|
|
356
585
|
if not container_image_tags:
|
|
@@ -371,7 +600,7 @@ class DockerManager:
|
|
|
371
600
|
return False
|
|
372
601
|
|
|
373
602
|
# Check volumes if specified
|
|
374
|
-
if
|
|
603
|
+
if volumes_dict:
|
|
375
604
|
container_mounts = (
|
|
376
605
|
{
|
|
377
606
|
m["Source"]: {"bind": m["Destination"], "mode": m["Mode"]}
|
|
@@ -381,7 +610,7 @@ class DockerManager:
|
|
|
381
610
|
else {}
|
|
382
611
|
)
|
|
383
612
|
|
|
384
|
-
for host_dir, mount in
|
|
613
|
+
for host_dir, mount in volumes_dict.items():
|
|
385
614
|
if host_dir not in container_mounts:
|
|
386
615
|
print(f"Volume {host_dir} not found in container mounts.")
|
|
387
616
|
return False
|
|
@@ -416,7 +645,7 @@ class DockerManager:
|
|
|
416
645
|
return False
|
|
417
646
|
except KeyboardInterrupt:
|
|
418
647
|
raise
|
|
419
|
-
except
|
|
648
|
+
except NotFound:
|
|
420
649
|
print("Container not found.")
|
|
421
650
|
return False
|
|
422
651
|
except Exception as e:
|
|
@@ -431,20 +660,43 @@ class DockerManager:
|
|
|
431
660
|
tag: str,
|
|
432
661
|
container_name: str,
|
|
433
662
|
command: str | None = None,
|
|
434
|
-
volumes:
|
|
663
|
+
volumes: list[Volume] | None = None,
|
|
435
664
|
ports: dict[int, int] | None = None,
|
|
436
665
|
remove_previous: bool = False,
|
|
666
|
+
environment: dict[str, str] | None = None,
|
|
667
|
+
tmpfs_size: str | None = None, # suffixed like 25mb.
|
|
437
668
|
) -> Container:
|
|
438
669
|
"""
|
|
439
670
|
Run a container from an image. If it already exists with matching config, start it.
|
|
440
671
|
If it exists with different config, remove and recreate it.
|
|
441
672
|
|
|
442
673
|
Args:
|
|
443
|
-
volumes:
|
|
444
|
-
Example: {'/host/path': {'bind': '/container/path', 'mode': 'rw'}}
|
|
674
|
+
volumes: List of Volume objects for container volume mappings
|
|
445
675
|
ports: Dict mapping host ports to container ports
|
|
446
676
|
Example: {8080: 80} maps host port 8080 to container port 80
|
|
447
677
|
"""
|
|
678
|
+
remove_previous = remove_previous or get_force_remove_image_previous()
|
|
679
|
+
if get_force_remove_image_previous():
|
|
680
|
+
# make a banner print
|
|
681
|
+
print(
|
|
682
|
+
"Force removing previous image due to FASTLED_FORCE_CLEAR environment variable."
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
tmpfs_size = tmpfs_size or get_ramdisk_size()
|
|
686
|
+
sys_admin = tmpfs_size is not None and tmpfs_size != "0"
|
|
687
|
+
volumes = _hack_to_fix_mac(volumes)
|
|
688
|
+
# Convert volumes to the format expected by Docker API
|
|
689
|
+
volumes_dict = None
|
|
690
|
+
if volumes is not None:
|
|
691
|
+
volumes_dict = {}
|
|
692
|
+
for volume in volumes:
|
|
693
|
+
volumes_dict.update(volume.to_dict())
|
|
694
|
+
|
|
695
|
+
# Serialize the volumes to a json string
|
|
696
|
+
if volumes_dict:
|
|
697
|
+
volumes_str = json.dumps(volumes_dict)
|
|
698
|
+
print(f"Volumes: {volumes_str}")
|
|
699
|
+
print("Done")
|
|
448
700
|
image_name = f"{image_name}:{tag}"
|
|
449
701
|
try:
|
|
450
702
|
container: Container = self.client.containers.get(container_name)
|
|
@@ -452,14 +704,16 @@ class DockerManager:
|
|
|
452
704
|
if remove_previous:
|
|
453
705
|
print(f"Removing existing container {container_name}...")
|
|
454
706
|
container.remove(force=True)
|
|
455
|
-
raise
|
|
707
|
+
raise NotFound("Container removed due to remove_previous")
|
|
456
708
|
# Check if configuration matches
|
|
457
|
-
elif not self._container_configs_match(
|
|
709
|
+
elif not self._container_configs_match(
|
|
710
|
+
container, command, volumes_dict, ports
|
|
711
|
+
):
|
|
458
712
|
print(
|
|
459
713
|
f"Container {container_name} exists but with different configuration. Removing and recreating..."
|
|
460
714
|
)
|
|
461
715
|
container.remove(force=True)
|
|
462
|
-
raise
|
|
716
|
+
raise NotFound("Container removed due to config mismatch")
|
|
463
717
|
print(f"Container {container_name} found with matching configuration.")
|
|
464
718
|
|
|
465
719
|
# Existing container with matching config - handle various states
|
|
@@ -489,21 +743,28 @@ class DockerManager:
|
|
|
489
743
|
print(f"Starting existing container {container_name}.")
|
|
490
744
|
self.first_run = True
|
|
491
745
|
container.start()
|
|
492
|
-
except
|
|
746
|
+
except NotFound:
|
|
493
747
|
print(f"Creating and starting {container_name}")
|
|
494
748
|
out_msg = f"# Running in container: {command}"
|
|
495
749
|
msg_len = len(out_msg)
|
|
496
750
|
print("\n" + "#" * msg_len)
|
|
497
751
|
print(out_msg)
|
|
498
752
|
print("#" * msg_len + "\n")
|
|
753
|
+
|
|
754
|
+
tmpfs: dict[str, str] | None = None
|
|
755
|
+
if tmpfs_size:
|
|
756
|
+
tmpfs = {_DEFAULT_BUILD_DIR: f"size={tmpfs_size}"}
|
|
499
757
|
container = self.client.containers.run(
|
|
500
|
-
image_name,
|
|
501
|
-
command,
|
|
758
|
+
image=image_name,
|
|
759
|
+
command=command,
|
|
502
760
|
name=container_name,
|
|
761
|
+
tmpfs=tmpfs,
|
|
762
|
+
cap_add=["SYS_ADMIN"] if sys_admin else None,
|
|
503
763
|
detach=True,
|
|
504
764
|
tty=True,
|
|
505
|
-
volumes=
|
|
506
|
-
ports=ports,
|
|
765
|
+
volumes=volumes_dict,
|
|
766
|
+
ports=ports, # type: ignore
|
|
767
|
+
environment=environment,
|
|
507
768
|
remove=True,
|
|
508
769
|
)
|
|
509
770
|
return container
|
|
@@ -514,14 +775,22 @@ class DockerManager:
|
|
|
514
775
|
tag: str,
|
|
515
776
|
container_name: str,
|
|
516
777
|
command: str | None = None,
|
|
517
|
-
volumes:
|
|
778
|
+
volumes: list[Volume] | None = None,
|
|
518
779
|
ports: dict[int, int] | None = None,
|
|
780
|
+
environment: dict[str, str] | None = None,
|
|
519
781
|
) -> None:
|
|
782
|
+
# Convert volumes to the format expected by Docker API
|
|
783
|
+
volumes = _hack_to_fix_mac(volumes)
|
|
784
|
+
volumes_dict = None
|
|
785
|
+
if volumes is not None:
|
|
786
|
+
volumes_dict = {}
|
|
787
|
+
for volume in volumes:
|
|
788
|
+
volumes_dict.update(volume.to_dict())
|
|
520
789
|
# Remove existing container
|
|
521
790
|
try:
|
|
522
791
|
container: Container = self.client.containers.get(container_name)
|
|
523
792
|
container.remove(force=True)
|
|
524
|
-
except
|
|
793
|
+
except NotFound:
|
|
525
794
|
pass
|
|
526
795
|
start_time = time.time()
|
|
527
796
|
try:
|
|
@@ -533,12 +802,19 @@ class DockerManager:
|
|
|
533
802
|
"--name",
|
|
534
803
|
container_name,
|
|
535
804
|
]
|
|
536
|
-
if
|
|
537
|
-
for host_dir, mount in
|
|
538
|
-
|
|
805
|
+
if volumes_dict:
|
|
806
|
+
for host_dir, mount in volumes_dict.items():
|
|
807
|
+
docker_volume_arg = [
|
|
808
|
+
"-v",
|
|
809
|
+
f"{host_dir}:{mount['bind']}:{mount['mode']}",
|
|
810
|
+
]
|
|
811
|
+
docker_command.extend(docker_volume_arg)
|
|
539
812
|
if ports:
|
|
540
813
|
for host_port, container_port in ports.items():
|
|
541
814
|
docker_command.extend(["-p", f"{host_port}:{container_port}"])
|
|
815
|
+
if environment:
|
|
816
|
+
for env_name, env_value in environment.items():
|
|
817
|
+
docker_command.extend(["-e", f"{env_name}={env_value}"])
|
|
542
818
|
docker_command.append(f"{image_name}:{tag}")
|
|
543
819
|
if command:
|
|
544
820
|
docker_command.append(command)
|
|
@@ -558,7 +834,10 @@ class DockerManager:
|
|
|
558
834
|
Returns a RunningContainer object that can be used to stop monitoring.
|
|
559
835
|
"""
|
|
560
836
|
if isinstance(container, str):
|
|
561
|
-
|
|
837
|
+
container_name = container
|
|
838
|
+
tmp = self.get_container(container)
|
|
839
|
+
assert tmp is not None, f"Container {container_name} not found."
|
|
840
|
+
container = tmp
|
|
562
841
|
|
|
563
842
|
assert container is not None, "Container not found."
|
|
564
843
|
|
|
@@ -573,12 +852,17 @@ class DockerManager:
|
|
|
573
852
|
"""
|
|
574
853
|
Suspend (pause) the container.
|
|
575
854
|
"""
|
|
855
|
+
if self.is_suspended:
|
|
856
|
+
return
|
|
576
857
|
if isinstance(container, str):
|
|
577
858
|
container_name = container
|
|
578
|
-
container = self.get_container(container)
|
|
579
|
-
|
|
859
|
+
# container = self.get_container(container)
|
|
860
|
+
tmp = self.get_container(container_name)
|
|
861
|
+
if not tmp:
|
|
580
862
|
print(f"Could not put container {container_name} to sleep.")
|
|
581
863
|
return
|
|
864
|
+
container = tmp
|
|
865
|
+
assert isinstance(container, Container)
|
|
582
866
|
try:
|
|
583
867
|
if platform.system() == "Windows":
|
|
584
868
|
container.pause()
|
|
@@ -595,16 +879,27 @@ class DockerManager:
|
|
|
595
879
|
"""
|
|
596
880
|
Resume (unpause) the container.
|
|
597
881
|
"""
|
|
882
|
+
container_name = "UNKNOWN"
|
|
598
883
|
if isinstance(container, str):
|
|
599
|
-
|
|
884
|
+
container_name = container
|
|
885
|
+
container_or_none = self.get_container(container)
|
|
886
|
+
if container_or_none is None:
|
|
887
|
+
print(f"Could not resume container {container}.")
|
|
888
|
+
return
|
|
889
|
+
container = container_or_none
|
|
890
|
+
container_name = container.name
|
|
891
|
+
elif isinstance(container, Container):
|
|
892
|
+
container_name = container.name
|
|
893
|
+
assert isinstance(container, Container)
|
|
600
894
|
if not container:
|
|
601
895
|
print(f"Could not resume container {container}.")
|
|
602
896
|
return
|
|
603
897
|
try:
|
|
898
|
+
assert isinstance(container, Container)
|
|
604
899
|
container.unpause()
|
|
605
900
|
print(f"Container {container.name} has been resumed.")
|
|
606
901
|
except Exception as e:
|
|
607
|
-
print(f"Failed to resume container {
|
|
902
|
+
print(f"Failed to resume container {container_name}: {e}")
|
|
608
903
|
|
|
609
904
|
def get_container(self, container_name: str) -> Container | None:
|
|
610
905
|
"""
|
|
@@ -612,7 +907,7 @@ class DockerManager:
|
|
|
612
907
|
"""
|
|
613
908
|
try:
|
|
614
909
|
return self.client.containers.get(container_name)
|
|
615
|
-
except
|
|
910
|
+
except NotFound:
|
|
616
911
|
return None
|
|
617
912
|
|
|
618
913
|
def is_container_running(self, container_name: str) -> bool:
|
|
@@ -622,7 +917,7 @@ class DockerManager:
|
|
|
622
917
|
try:
|
|
623
918
|
container = self.client.containers.get(container_name)
|
|
624
919
|
return container.status == "running"
|
|
625
|
-
except
|
|
920
|
+
except NotFound:
|
|
626
921
|
print(f"Container {container_name} not found.")
|
|
627
922
|
return False
|
|
628
923
|
|
|
@@ -694,7 +989,9 @@ class DockerManager:
|
|
|
694
989
|
print("Error decoding line")
|
|
695
990
|
rtn = proc.wait()
|
|
696
991
|
if rtn != 0:
|
|
697
|
-
|
|
992
|
+
warnings.warn(
|
|
993
|
+
f"Error building Docker image, is docker running? {rtn}, stdout: {stdout}, stderr: {proc.stderr}"
|
|
994
|
+
)
|
|
698
995
|
raise subprocess.CalledProcessError(rtn, cmd_str)
|
|
699
996
|
print(f"Successfully built image {image_name}:{tag}")
|
|
700
997
|
|
|
@@ -705,6 +1002,7 @@ class DockerManager:
|
|
|
705
1002
|
def purge(self, image_name: str) -> None:
|
|
706
1003
|
"""
|
|
707
1004
|
Remove all containers and images associated with the given image name.
|
|
1005
|
+
Also removes FastLED containers by name pattern (including test containers).
|
|
708
1006
|
|
|
709
1007
|
Args:
|
|
710
1008
|
image_name: The name of the image to purge (without tag)
|
|
@@ -715,8 +1013,27 @@ class DockerManager:
|
|
|
715
1013
|
try:
|
|
716
1014
|
containers = self.client.containers.list(all=True)
|
|
717
1015
|
for container in containers:
|
|
1016
|
+
should_remove = False
|
|
1017
|
+
|
|
1018
|
+
# Check if container uses the specified image
|
|
718
1019
|
if any(image_name in tag for tag in container.image.tags):
|
|
719
|
-
|
|
1020
|
+
should_remove = True
|
|
1021
|
+
print(
|
|
1022
|
+
f"Removing container {container.name} (uses image {image_name})"
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
# Also check for FastLED container name patterns (including test containers)
|
|
1026
|
+
elif any(
|
|
1027
|
+
pattern in container.name
|
|
1028
|
+
for pattern in [
|
|
1029
|
+
"fastled-wasm-container",
|
|
1030
|
+
"fastled-wasm-container-test",
|
|
1031
|
+
]
|
|
1032
|
+
):
|
|
1033
|
+
should_remove = True
|
|
1034
|
+
print(f"Removing FastLED container {container.name}")
|
|
1035
|
+
|
|
1036
|
+
if should_remove:
|
|
720
1037
|
container.remove(force=True)
|
|
721
1038
|
|
|
722
1039
|
except Exception as e:
|
|
@@ -734,7 +1051,7 @@ class DockerManager:
|
|
|
734
1051
|
print(f"Error removing images: {e}")
|
|
735
1052
|
|
|
736
1053
|
|
|
737
|
-
def main():
|
|
1054
|
+
def main() -> None:
|
|
738
1055
|
# Register SIGINT handler
|
|
739
1056
|
# signal.signal(signal.SIGINT, handle_sigint)
|
|
740
1057
|
|
|
@@ -746,6 +1063,7 @@ def main():
|
|
|
746
1063
|
# new_tag = "my-python"
|
|
747
1064
|
container_name = "my-python-container"
|
|
748
1065
|
command = "python -m http.server"
|
|
1066
|
+
running_container: RunningContainer | None = None
|
|
749
1067
|
|
|
750
1068
|
try:
|
|
751
1069
|
# Step 1: Validate or download the image
|
|
@@ -768,13 +1086,18 @@ def main():
|
|
|
768
1086
|
|
|
769
1087
|
except KeyboardInterrupt:
|
|
770
1088
|
print("\nStopping container...")
|
|
771
|
-
running_container
|
|
772
|
-
|
|
773
|
-
docker_manager.
|
|
1089
|
+
if isinstance(running_container, RunningContainer):
|
|
1090
|
+
running_container.stop()
|
|
1091
|
+
container_or_none = docker_manager.get_container(container_name)
|
|
1092
|
+
if container_or_none is not None:
|
|
1093
|
+
docker_manager.suspend_container(container_or_none)
|
|
1094
|
+
else:
|
|
1095
|
+
warnings.warn(f"Container {container_name} not found.")
|
|
774
1096
|
|
|
775
1097
|
try:
|
|
776
1098
|
# Suspend and resume the container
|
|
777
1099
|
container = docker_manager.get_container(container_name)
|
|
1100
|
+
assert container is not None, "Container not found."
|
|
778
1101
|
docker_manager.suspend_container(container)
|
|
779
1102
|
|
|
780
1103
|
input("Press Enter to resume the container...")
|