kstlib 0.0.1a0__py3-none-any.whl → 1.0.1__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.
Files changed (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.1.dist-info/METADATA +201 -0
  159. kstlib-1.0.1.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.1.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.1.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,577 @@
1
+ """Container session runner for production environments.
2
+
3
+ This module provides the ContainerRunner class for managing Podman/Docker
4
+ containers, enabling persistent processes with pseudo-terminal support.
5
+
6
+ Features:
7
+ - Create named containers with custom images and volumes
8
+ - Attach to running containers (replaces current process)
9
+ - Retrieve container logs with ANSI codes preserved
10
+ - Support for both Podman and Docker runtimes
11
+ - Automatic log volume mounting for post-mortem analysis
12
+
13
+ Example:
14
+ >>> from kstlib.ops import SessionConfig, BackendType
15
+ >>> from kstlib.ops.container import ContainerRunner
16
+ >>> runner = ContainerRunner(runtime="podman") # doctest: +SKIP
17
+ >>> config = SessionConfig( # doctest: +SKIP
18
+ ... name="bot",
19
+ ... backend=BackendType.CONTAINER,
20
+ ... image="bot:latest",
21
+ ... volumes=["./data:/app/data"],
22
+ ... )
23
+ >>> status = runner.start(config) # doctest: +SKIP
24
+ >>> runner.attach("bot") # Replaces process # doctest: +SKIP
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import logging
31
+ import shutil
32
+ import subprocess
33
+ from typing import TYPE_CHECKING, Any
34
+
35
+ from kstlib.ops.exceptions import (
36
+ ContainerRuntimeNotFoundError,
37
+ SessionAttachError,
38
+ SessionExistsError,
39
+ SessionNotFoundError,
40
+ SessionStartError,
41
+ SessionStopError,
42
+ )
43
+ from kstlib.ops.models import BackendType, SessionConfig, SessionState, SessionStatus
44
+
45
+ if TYPE_CHECKING:
46
+ from collections.abc import Sequence
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+
51
+ class ContainerRunner:
52
+ """Container runner for Podman/Docker containers.
53
+
54
+ Manages containers for running persistent processes with
55
+ pseudo-terminal support for TUI applications.
56
+
57
+ Args:
58
+ runtime: Container runtime to use ("podman" or "docker").
59
+
60
+ Attributes:
61
+ runtime: The container runtime name.
62
+ binary: The validated runtime binary path.
63
+
64
+ Examples:
65
+ >>> runner = ContainerRunner() # Uses podman by default # doctest: +SKIP
66
+ >>> runner = ContainerRunner(runtime="docker") # doctest: +SKIP
67
+ >>> config = SessionConfig(
68
+ ... name="app",
69
+ ... backend=BackendType.CONTAINER,
70
+ ... image="python:3.10-slim",
71
+ ... )
72
+ >>> status = runner.start(config) # doctest: +SKIP
73
+ """
74
+
75
+ def __init__(self, runtime: str | None = None) -> None:
76
+ """Initialize ContainerRunner.
77
+
78
+ Args:
79
+ runtime: Container runtime ("podman", "docker", or None for auto-detect).
80
+ Auto-detection tries podman first, then docker.
81
+ """
82
+ if runtime is None:
83
+ # Auto-detect: try podman first, then docker
84
+ if shutil.which("podman"):
85
+ self._runtime = "podman"
86
+ elif shutil.which("docker"):
87
+ self._runtime = "docker"
88
+ else:
89
+ self._runtime = "podman" # Will fail with clear error message
90
+ else:
91
+ self._runtime = runtime
92
+ self._binary_path: str | None = None
93
+
94
+ @property
95
+ def runtime(self) -> str:
96
+ """Return the configured runtime name."""
97
+ return self._runtime
98
+
99
+ @property
100
+ def binary(self) -> str:
101
+ """Return validated container runtime binary path.
102
+
103
+ Raises:
104
+ ContainerRuntimeNotFoundError: If runtime is not installed.
105
+ """
106
+ if self._binary_path is None:
107
+ path = shutil.which(self._runtime)
108
+ if path is None:
109
+ raise ContainerRuntimeNotFoundError(
110
+ f"Container runtime '{self._runtime}' not found in PATH. "
111
+ f"Install {self._runtime}: https://{'podman.io' if self._runtime == 'podman' else 'docker.com'}"
112
+ )
113
+ self._binary_path = path
114
+ return self._binary_path
115
+
116
+ def _run(
117
+ self,
118
+ args: Sequence[str],
119
+ *,
120
+ check: bool = False,
121
+ ) -> subprocess.CompletedProcess[str]:
122
+ """Run a container command.
123
+
124
+ Args:
125
+ args: Command arguments (without binary name).
126
+ check: Whether to raise on non-zero exit.
127
+
128
+ Returns:
129
+ CompletedProcess with stdout/stderr.
130
+
131
+ Raises:
132
+ ContainerRuntimeNotFoundError: If runtime is not installed.
133
+ """
134
+ cmd = [self.binary, *args]
135
+ logger.debug("Running: %s", " ".join(cmd))
136
+ return subprocess.run( # noqa: S603
137
+ cmd,
138
+ capture_output=True,
139
+ text=True,
140
+ check=check,
141
+ )
142
+
143
+ def _inspect(self, name: str) -> dict[str, Any] | None:
144
+ """Inspect a container and return its metadata.
145
+
146
+ Args:
147
+ name: Container name.
148
+
149
+ Returns:
150
+ Container metadata dict or None if not found.
151
+ """
152
+ result = self._run(["inspect", name, "--format", "json"])
153
+ if result.returncode != 0:
154
+ return None
155
+ try:
156
+ data = json.loads(result.stdout)
157
+ if isinstance(data, list) and len(data) > 0:
158
+ return data[0] # type: ignore[no-any-return]
159
+ return None
160
+ except json.JSONDecodeError:
161
+ return None
162
+
163
+ def _restart_stopped(self, name: str) -> SessionStatus:
164
+ """Restart a stopped container.
165
+
166
+ Args:
167
+ name: Container name to restart.
168
+
169
+ Returns:
170
+ SessionStatus after restart.
171
+
172
+ Raises:
173
+ SessionStartError: If restart failed.
174
+ """
175
+ logger.info("Restarting stopped container: %s", name)
176
+ result = self._run(["start", name])
177
+ if result.returncode != 0:
178
+ raise SessionStartError(
179
+ name,
180
+ "container",
181
+ f"Failed to restart: {result.stderr}",
182
+ )
183
+ return self.status(name)
184
+
185
+ def _build_run_args(self, config: SessionConfig) -> list[str]:
186
+ """Build command arguments for container run.
187
+
188
+ Args:
189
+ config: Session configuration.
190
+
191
+ Returns:
192
+ List of command arguments.
193
+ """
194
+ args = [
195
+ "run",
196
+ "-d", # Detached
197
+ "--name",
198
+ config.name,
199
+ "-it", # Interactive with pseudo-terminal (for TUI support)
200
+ ]
201
+
202
+ # Working directory
203
+ if config.working_dir:
204
+ args.extend(["-w", config.working_dir])
205
+
206
+ # Environment variables
207
+ for key, value in config.env.items():
208
+ args.extend(["-e", f"{key}={value}"])
209
+
210
+ # Volumes
211
+ for volume in config.volumes:
212
+ args.extend(["-v", volume])
213
+
214
+ # Log volume (auto-mount for post-mortem analysis)
215
+ if config.log_volume:
216
+ args.extend(["-v", config.log_volume])
217
+
218
+ # Port mappings
219
+ for port in config.ports:
220
+ args.extend(["-p", port])
221
+
222
+ # Image
223
+ args.append(config.image) # type: ignore[arg-type]
224
+
225
+ # Command (optional)
226
+ if config.command:
227
+ args.extend(config.command.split())
228
+
229
+ return args
230
+
231
+ def start(self, config: SessionConfig) -> SessionStatus:
232
+ """Create and start a new container.
233
+
234
+ Args:
235
+ config: Session configuration with image and options.
236
+
237
+ Returns:
238
+ SessionStatus with state information.
239
+
240
+ Raises:
241
+ SessionExistsError: If container already exists and is running.
242
+ SessionStartError: If container failed to start.
243
+ ContainerRuntimeNotFoundError: If runtime is not installed.
244
+ """
245
+ # Check if container already exists
246
+ if self.exists(config.name):
247
+ info = self._inspect(config.name)
248
+ if info and info.get("State", {}).get("Running", False):
249
+ raise SessionExistsError(config.name, "container")
250
+ # Container exists but stopped - restart it
251
+ return self._restart_stopped(config.name)
252
+
253
+ if not config.image:
254
+ raise SessionStartError(
255
+ config.name,
256
+ "container",
257
+ "Container image is required",
258
+ )
259
+
260
+ args = self._build_run_args(config)
261
+ result = self._run(args)
262
+
263
+ if result.returncode != 0:
264
+ raise SessionStartError(
265
+ config.name,
266
+ "container",
267
+ result.stderr.strip() or "Unknown error",
268
+ )
269
+
270
+ logger.info("Started container: %s", config.name)
271
+ return self.status(config.name)
272
+
273
+ def stop(
274
+ self,
275
+ name: str,
276
+ *,
277
+ graceful: bool = True,
278
+ timeout: int = 10,
279
+ ) -> bool:
280
+ """Stop a running container.
281
+
282
+ Args:
283
+ name: Container name to stop.
284
+ graceful: If True, use stop with timeout. If False, use kill.
285
+ timeout: Seconds to wait for graceful shutdown.
286
+
287
+ Returns:
288
+ True if stopped, False if not running.
289
+
290
+ Raises:
291
+ SessionNotFoundError: If container doesn't exist.
292
+ SessionStopError: If container couldn't be stopped.
293
+ """
294
+ if not self.exists(name):
295
+ raise SessionNotFoundError(name, "container")
296
+
297
+ cmd = ["stop", "-t", str(timeout), name] if graceful else ["kill", name]
298
+ result = self._run(cmd)
299
+
300
+ if result.returncode != 0:
301
+ # Check if container already stopped
302
+ info = self._inspect(name)
303
+ if info and not info.get("State", {}).get("Running", False):
304
+ logger.info("Container already stopped: %s", name)
305
+ return True
306
+ raise SessionStopError(
307
+ name,
308
+ "container",
309
+ result.stderr.strip() or "Unknown error",
310
+ )
311
+
312
+ # Remove the container after stopping
313
+ self._run(["rm", name])
314
+
315
+ logger.info("Stopped container: %s", name)
316
+ return True
317
+
318
+ def attach(self, name: str) -> None:
319
+ """Attach to a running container.
320
+
321
+ This method replaces the current process with container attach.
322
+ It does not return on success.
323
+
324
+ Args:
325
+ name: Container name to attach to.
326
+
327
+ Raises:
328
+ SessionNotFoundError: If container doesn't exist.
329
+ SessionAttachError: If attachment failed.
330
+
331
+ Note:
332
+ Use Ctrl+P Ctrl+Q to detach from the container.
333
+ """
334
+ if not self.exists(name):
335
+ raise SessionNotFoundError(name, "container")
336
+
337
+ # Check if running
338
+ info = self._inspect(name)
339
+ if info and not info.get("State", {}).get("Running", False):
340
+ raise SessionAttachError(
341
+ name,
342
+ "container",
343
+ "Container is not running",
344
+ )
345
+
346
+ binary = self.binary
347
+ logger.info("Attaching to container: %s", name)
348
+
349
+ # Attach to container (interactive)
350
+ # On Windows, os.execvp doesn't work well with paths containing spaces
351
+ # Use subprocess.run which handles this correctly
352
+ try:
353
+ result = subprocess.run( # noqa: S603
354
+ [binary, "attach", name],
355
+ check=False,
356
+ )
357
+ # Docker returns exit code 1 when detaching with Ctrl+P Ctrl+Q
358
+ # This is normal behavior, not an error
359
+ if result.returncode not in (0, 1):
360
+ raise SessionAttachError(
361
+ name,
362
+ "container",
363
+ f"Attach exited with code {result.returncode}",
364
+ )
365
+ except OSError as e:
366
+ raise SessionAttachError(name, "container", str(e)) from e
367
+
368
+ def status(self, name: str) -> SessionStatus:
369
+ """Get status of a container.
370
+
371
+ Args:
372
+ name: Container name to query.
373
+
374
+ Returns:
375
+ SessionStatus with current state.
376
+
377
+ Raises:
378
+ SessionNotFoundError: If container doesn't exist.
379
+ """
380
+ info = self._inspect(name)
381
+ if info is None:
382
+ raise SessionNotFoundError(name, "container")
383
+
384
+ state_info = info.get("State", {})
385
+ running = state_info.get("Running", False)
386
+ exited = state_info.get("Status") == "exited"
387
+
388
+ if running:
389
+ state = SessionState.RUNNING
390
+ elif exited:
391
+ state = SessionState.EXITED
392
+ else:
393
+ state = SessionState.STOPPED
394
+
395
+ # Get PID
396
+ pid = state_info.get("Pid")
397
+ if pid == 0:
398
+ pid = None
399
+
400
+ # Get image name
401
+ image = info.get("Config", {}).get("Image") or info.get("Image", "")
402
+
403
+ # Get created timestamp
404
+ created = info.get("Created", "")
405
+
406
+ # Get exit code if exited
407
+ exit_code = None
408
+ if exited:
409
+ exit_code = state_info.get("ExitCode")
410
+
411
+ return SessionStatus(
412
+ name=name,
413
+ state=state,
414
+ backend=BackendType.CONTAINER,
415
+ pid=pid,
416
+ created_at=created,
417
+ image=image,
418
+ exit_code=exit_code,
419
+ )
420
+
421
+ def logs(self, name: str, lines: int = 100) -> str:
422
+ """Retrieve recent logs from a container.
423
+
424
+ Args:
425
+ name: Container name to get logs from.
426
+ lines: Number of lines to retrieve.
427
+
428
+ Returns:
429
+ String with log output (ANSI codes preserved).
430
+
431
+ Raises:
432
+ SessionNotFoundError: If container doesn't exist.
433
+ """
434
+ if not self.exists(name):
435
+ raise SessionNotFoundError(name, "container")
436
+
437
+ result = self._run(["logs", "--tail", str(lines), name])
438
+
439
+ # Combine stdout and stderr (container logs may go to either)
440
+ return result.stdout + result.stderr
441
+
442
+ def exists(self, name: str) -> bool:
443
+ """Check if a container with the given name exists.
444
+
445
+ Args:
446
+ name: Container name to check.
447
+
448
+ Returns:
449
+ True if container exists, False otherwise.
450
+ """
451
+ return self._inspect(name) is not None
452
+
453
+ @staticmethod
454
+ def _parse_container_json(raw: str) -> list[dict[str, Any]]:
455
+ """Parse container JSON output into a list of dicts.
456
+
457
+ Podman may return a single JSON array or one JSON object per line
458
+ depending on version.
459
+
460
+ Args:
461
+ raw: Raw stdout from ``ps --format json``.
462
+
463
+ Returns:
464
+ List of parsed container dicts.
465
+ """
466
+ try:
467
+ parsed = json.loads(raw)
468
+ except json.JSONDecodeError:
469
+ parsed = None
470
+
471
+ if isinstance(parsed, list):
472
+ return [item for item in parsed if isinstance(item, dict)]
473
+
474
+ items: list[dict[str, Any]] = []
475
+ for line in raw.split("\n"):
476
+ if not line:
477
+ continue
478
+ try:
479
+ obj = json.loads(line)
480
+ if isinstance(obj, dict):
481
+ items.append(obj)
482
+ except json.JSONDecodeError:
483
+ continue
484
+ return items
485
+
486
+ @staticmethod
487
+ def _container_to_status(data: dict[str, Any]) -> SessionStatus | None:
488
+ """Convert a single container dict to a SessionStatus.
489
+
490
+ Args:
491
+ data: Parsed container JSON dict.
492
+
493
+ Returns:
494
+ SessionStatus or None if the data is unparseable.
495
+ """
496
+ try:
497
+ name = data.get("Names") or data.get("Name", "")
498
+ if isinstance(name, list):
499
+ name = name[0] if name else ""
500
+
501
+ state_str = data.get("State", "").lower()
502
+ if state_str == "running":
503
+ state = SessionState.RUNNING
504
+ elif state_str in ("exited", "stopped"):
505
+ state = SessionState.EXITED
506
+ else:
507
+ state = SessionState.UNKNOWN
508
+
509
+ return SessionStatus(
510
+ name=name,
511
+ state=state,
512
+ backend=BackendType.CONTAINER,
513
+ image=data.get("Image", ""),
514
+ created_at=data.get("CreatedAt", ""),
515
+ )
516
+ except (KeyError, TypeError, AttributeError):
517
+ return None
518
+
519
+ def list_sessions(self) -> list[SessionStatus]:
520
+ """List all containers.
521
+
522
+ Returns:
523
+ List of SessionStatus for all containers.
524
+ """
525
+ result = self._run(["ps", "-a", "--format", "json"])
526
+
527
+ if result.returncode != 0:
528
+ return []
529
+
530
+ raw = result.stdout.strip()
531
+ if not raw:
532
+ return []
533
+
534
+ items = self._parse_container_json(raw)
535
+ sessions: list[SessionStatus] = []
536
+ for data in items:
537
+ status = self._container_to_status(data)
538
+ if status is not None:
539
+ sessions.append(status)
540
+
541
+ return sessions
542
+
543
+ def exec(
544
+ self,
545
+ name: str,
546
+ command: str,
547
+ *,
548
+ interactive: bool = False,
549
+ ) -> subprocess.CompletedProcess[str]:
550
+ """Execute a command in a running container.
551
+
552
+ Args:
553
+ name: Container name.
554
+ command: Command to execute.
555
+ interactive: If True, use -it flags.
556
+
557
+ Returns:
558
+ CompletedProcess with stdout/stderr.
559
+
560
+ Raises:
561
+ SessionNotFoundError: If container doesn't exist.
562
+ """
563
+ if not self.exists(name):
564
+ raise SessionNotFoundError(name, "container")
565
+
566
+ args = ["exec"]
567
+ if interactive:
568
+ args.append("-it")
569
+ args.append(name)
570
+ args.extend(command.split())
571
+
572
+ return self._run(args)
573
+
574
+
575
+ __all__ = [
576
+ "ContainerRunner",
577
+ ]