kstlib 0.0.1a0__py3-none-any.whl → 1.0.0__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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.0.dist-info/METADATA +201 -0
- kstlib-1.0.0.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
- kstlib-1.0.0.dist-info/entry_points.txt +2 -0
- kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/top_level.txt +0 -0
kstlib/ops/models.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Data models for the kstlib.ops module.
|
|
2
|
+
|
|
3
|
+
This module defines the core data structures used by the ops module:
|
|
4
|
+
|
|
5
|
+
- BackendType: Enum for backend selection (tmux, container)
|
|
6
|
+
- SessionState: Enum for session state (running, stopped, exited, unknown)
|
|
7
|
+
- SessionConfig: Configuration for creating a session
|
|
8
|
+
- SessionStatus: Current status of a session
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from enum import Enum
|
|
15
|
+
|
|
16
|
+
from kstlib.ops.validators import (
|
|
17
|
+
validate_command,
|
|
18
|
+
validate_env,
|
|
19
|
+
validate_image_name,
|
|
20
|
+
validate_ports,
|
|
21
|
+
validate_session_name,
|
|
22
|
+
validate_volumes,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BackendType(str, Enum):
|
|
27
|
+
"""Backend type for session management.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
TMUX: Use tmux for session management (dev/local).
|
|
31
|
+
CONTAINER: Use Podman/Docker for session management (prod).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
TMUX = "tmux"
|
|
35
|
+
CONTAINER = "container"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SessionState(str, Enum):
|
|
39
|
+
"""State of a session or container.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
RUNNING: Session is active and running.
|
|
43
|
+
STOPPED: Session was stopped gracefully.
|
|
44
|
+
EXITED: Container exited (with exit code).
|
|
45
|
+
DEFINED: Session exists in config but has not been started.
|
|
46
|
+
UNKNOWN: State cannot be determined.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
RUNNING = "running"
|
|
50
|
+
STOPPED = "stopped"
|
|
51
|
+
EXITED = "exited"
|
|
52
|
+
DEFINED = "defined"
|
|
53
|
+
UNKNOWN = "unknown"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class SessionConfig:
|
|
58
|
+
"""Configuration for creating a session.
|
|
59
|
+
|
|
60
|
+
This dataclass holds all configuration options for both tmux and container
|
|
61
|
+
backends. Options that are not applicable to the selected backend are ignored.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
name: Unique session name (required).
|
|
65
|
+
backend: Backend type (tmux or container).
|
|
66
|
+
command: Command to run in the session (tmux) or container.
|
|
67
|
+
working_dir: Working directory for the session.
|
|
68
|
+
env: Environment variables to set.
|
|
69
|
+
image: Container image to use (container backend only).
|
|
70
|
+
volumes: Volume mounts in "host:container" format.
|
|
71
|
+
ports: Port mappings in "host:container" format.
|
|
72
|
+
runtime: Container runtime to use ("podman" or "docker").
|
|
73
|
+
log_volume: Log volume mount for persistence (auto-mounted).
|
|
74
|
+
|
|
75
|
+
Examples:
|
|
76
|
+
>>> config = SessionConfig(
|
|
77
|
+
... name="astro",
|
|
78
|
+
... backend=BackendType.TMUX,
|
|
79
|
+
... command="python -m astro.bot",
|
|
80
|
+
... )
|
|
81
|
+
|
|
82
|
+
>>> config = SessionConfig(
|
|
83
|
+
... name="astro-prod",
|
|
84
|
+
... backend=BackendType.CONTAINER,
|
|
85
|
+
... image="astro-bot:latest",
|
|
86
|
+
... volumes=["./data:/app/data"],
|
|
87
|
+
... log_volume="./logs:/app/logs",
|
|
88
|
+
... )
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
name: str
|
|
92
|
+
backend: BackendType = BackendType.TMUX
|
|
93
|
+
# Common options
|
|
94
|
+
command: str | None = None
|
|
95
|
+
working_dir: str | None = None
|
|
96
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
97
|
+
# Container-specific options
|
|
98
|
+
image: str | None = None
|
|
99
|
+
volumes: list[str] = field(default_factory=list)
|
|
100
|
+
ports: list[str] = field(default_factory=list)
|
|
101
|
+
runtime: str | None = None
|
|
102
|
+
# Log persistence (post-mortem analysis)
|
|
103
|
+
log_volume: str | None = None
|
|
104
|
+
|
|
105
|
+
def __post_init__(self) -> None:
|
|
106
|
+
"""Validate configuration values.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
ValueError: If any configuration value is invalid.
|
|
110
|
+
"""
|
|
111
|
+
validate_session_name(self.name)
|
|
112
|
+
if self.command is not None:
|
|
113
|
+
validate_command(self.command)
|
|
114
|
+
if self.image is not None:
|
|
115
|
+
validate_image_name(self.image)
|
|
116
|
+
if self.volumes:
|
|
117
|
+
validate_volumes(self.volumes)
|
|
118
|
+
if self.ports:
|
|
119
|
+
validate_ports(self.ports)
|
|
120
|
+
if self.env:
|
|
121
|
+
validate_env(self.env)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(slots=True)
|
|
125
|
+
class SessionStatus:
|
|
126
|
+
"""Current status of a session or container.
|
|
127
|
+
|
|
128
|
+
This dataclass holds the runtime status information for a session,
|
|
129
|
+
including state, PID, creation time, and backend-specific details.
|
|
130
|
+
|
|
131
|
+
Attributes:
|
|
132
|
+
name: Session name.
|
|
133
|
+
state: Current state (running, stopped, exited, unknown).
|
|
134
|
+
backend: Backend type used for this session.
|
|
135
|
+
pid: Process ID (tmux server PID or container main PID).
|
|
136
|
+
created_at: ISO timestamp when the session was created.
|
|
137
|
+
window_count: Number of tmux windows (tmux backend only).
|
|
138
|
+
image: Container image name (container backend only).
|
|
139
|
+
exit_code: Container exit code if exited (container backend only).
|
|
140
|
+
|
|
141
|
+
Examples:
|
|
142
|
+
>>> status = SessionStatus(
|
|
143
|
+
... name="astro",
|
|
144
|
+
... state=SessionState.RUNNING,
|
|
145
|
+
... backend=BackendType.TMUX,
|
|
146
|
+
... pid=12345,
|
|
147
|
+
... window_count=1,
|
|
148
|
+
... )
|
|
149
|
+
|
|
150
|
+
>>> status = SessionStatus(
|
|
151
|
+
... name="astro-prod",
|
|
152
|
+
... state=SessionState.RUNNING,
|
|
153
|
+
... backend=BackendType.CONTAINER,
|
|
154
|
+
... pid=67890,
|
|
155
|
+
... image="astro-bot:latest",
|
|
156
|
+
... )
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
name: str
|
|
160
|
+
state: SessionState
|
|
161
|
+
backend: BackendType
|
|
162
|
+
pid: int | None = None
|
|
163
|
+
created_at: str | None = None
|
|
164
|
+
# tmux-specific
|
|
165
|
+
window_count: int = 0
|
|
166
|
+
# container-specific
|
|
167
|
+
image: str | None = None
|
|
168
|
+
exit_code: int | None = None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
__all__ = [
|
|
172
|
+
"BackendType",
|
|
173
|
+
"SessionConfig",
|
|
174
|
+
"SessionState",
|
|
175
|
+
"SessionStatus",
|
|
176
|
+
]
|
kstlib/ops/tmux.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""tmux session runner for local development.
|
|
2
|
+
|
|
3
|
+
This module provides the TmuxRunner class for managing tmux sessions,
|
|
4
|
+
enabling detach/attach workflows for local development and backtesting.
|
|
5
|
+
|
|
6
|
+
Features:
|
|
7
|
+
- Create named sessions with custom commands and working directories
|
|
8
|
+
- Attach to running sessions (replaces current process)
|
|
9
|
+
- Capture session logs with ANSI codes preserved
|
|
10
|
+
- List all sessions with status information
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
>>> from kstlib.ops import SessionConfig, BackendType
|
|
14
|
+
>>> from kstlib.ops.tmux import TmuxRunner
|
|
15
|
+
>>> runner = TmuxRunner() # doctest: +SKIP
|
|
16
|
+
>>> config = SessionConfig( # doctest: +SKIP
|
|
17
|
+
... name="dev",
|
|
18
|
+
... backend=BackendType.TMUX,
|
|
19
|
+
... command="python app.py",
|
|
20
|
+
... )
|
|
21
|
+
>>> status = runner.start(config) # doctest: +SKIP
|
|
22
|
+
>>> runner.attach("dev") # Replaces process # doctest: +SKIP
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import shutil
|
|
30
|
+
import subprocess
|
|
31
|
+
from typing import TYPE_CHECKING
|
|
32
|
+
|
|
33
|
+
from kstlib.ops.exceptions import (
|
|
34
|
+
SessionAttachError,
|
|
35
|
+
SessionExistsError,
|
|
36
|
+
SessionNotFoundError,
|
|
37
|
+
SessionStartError,
|
|
38
|
+
SessionStopError,
|
|
39
|
+
TmuxNotFoundError,
|
|
40
|
+
)
|
|
41
|
+
from kstlib.ops.models import BackendType, SessionConfig, SessionState, SessionStatus
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from collections.abc import Sequence
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TmuxRunner:
|
|
50
|
+
"""tmux session runner for local development.
|
|
51
|
+
|
|
52
|
+
Manages tmux sessions for running persistent processes with
|
|
53
|
+
detach/attach capability.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
binary: Path or name of the tmux binary.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
binary: The tmux binary path (validated on first use).
|
|
60
|
+
|
|
61
|
+
Examples:
|
|
62
|
+
>>> runner = TmuxRunner() # doctest: +SKIP
|
|
63
|
+
>>> config = SessionConfig(name="bot", command="python bot.py")
|
|
64
|
+
>>> status = runner.start(config) # doctest: +SKIP
|
|
65
|
+
>>> runner.attach("bot") # doctest: +SKIP
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, binary: str = "tmux") -> None:
|
|
69
|
+
"""Initialize TmuxRunner.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
binary: Path or name of the tmux binary.
|
|
73
|
+
"""
|
|
74
|
+
self._binary_name = binary
|
|
75
|
+
self._binary_path: str | None = None
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def binary(self) -> str:
|
|
79
|
+
"""Return validated tmux binary path.
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
TmuxNotFoundError: If tmux is not installed.
|
|
83
|
+
"""
|
|
84
|
+
if self._binary_path is None:
|
|
85
|
+
path = shutil.which(self._binary_name)
|
|
86
|
+
if path is None:
|
|
87
|
+
raise TmuxNotFoundError(
|
|
88
|
+
f"tmux binary '{self._binary_name}' not found in PATH. "
|
|
89
|
+
"Install tmux: brew install tmux (macOS), apt install tmux (Linux)"
|
|
90
|
+
)
|
|
91
|
+
self._binary_path = path
|
|
92
|
+
return self._binary_path
|
|
93
|
+
|
|
94
|
+
def _run(
|
|
95
|
+
self,
|
|
96
|
+
args: Sequence[str],
|
|
97
|
+
*,
|
|
98
|
+
check: bool = False,
|
|
99
|
+
) -> subprocess.CompletedProcess[str]:
|
|
100
|
+
"""Run a tmux command.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
args: Command arguments (without binary name).
|
|
104
|
+
check: Whether to raise on non-zero exit.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
CompletedProcess with stdout/stderr.
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
TmuxNotFoundError: If tmux is not installed.
|
|
111
|
+
"""
|
|
112
|
+
cmd = [self.binary, *args]
|
|
113
|
+
logger.debug("Running: %s", " ".join(cmd))
|
|
114
|
+
return subprocess.run( # noqa: S603
|
|
115
|
+
cmd,
|
|
116
|
+
capture_output=True,
|
|
117
|
+
text=True,
|
|
118
|
+
check=check,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def start(self, config: SessionConfig) -> SessionStatus:
|
|
122
|
+
"""Create and start a new tmux session.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
config: Session configuration.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
SessionStatus with state information.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
SessionExistsError: If session already exists.
|
|
132
|
+
SessionStartError: If session failed to start.
|
|
133
|
+
TmuxNotFoundError: If tmux is not installed.
|
|
134
|
+
"""
|
|
135
|
+
if self.exists(config.name):
|
|
136
|
+
raise SessionExistsError(config.name, "tmux")
|
|
137
|
+
|
|
138
|
+
# Build command: tmux new-session -d -s {name} [-c {dir}] [command]
|
|
139
|
+
args = ["new-session", "-d", "-s", config.name]
|
|
140
|
+
|
|
141
|
+
if config.working_dir:
|
|
142
|
+
args.extend(["-c", config.working_dir])
|
|
143
|
+
|
|
144
|
+
# Environment variables
|
|
145
|
+
for key, value in config.env.items():
|
|
146
|
+
args.extend(["-e", f"{key}={value}"])
|
|
147
|
+
|
|
148
|
+
if config.command:
|
|
149
|
+
args.append(config.command)
|
|
150
|
+
|
|
151
|
+
result = self._run(args)
|
|
152
|
+
|
|
153
|
+
if result.returncode != 0:
|
|
154
|
+
raise SessionStartError(
|
|
155
|
+
config.name,
|
|
156
|
+
"tmux",
|
|
157
|
+
result.stderr.strip() or "Unknown error",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
logger.info("Started tmux session: %s", config.name)
|
|
161
|
+
return self.status(config.name)
|
|
162
|
+
|
|
163
|
+
def stop(
|
|
164
|
+
self,
|
|
165
|
+
name: str,
|
|
166
|
+
*,
|
|
167
|
+
graceful: bool = True,
|
|
168
|
+
timeout: int = 10, # noqa: ARG002
|
|
169
|
+
) -> bool:
|
|
170
|
+
"""Stop a tmux session.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
name: Session name to stop.
|
|
174
|
+
graceful: If True, send C-c first, then kill if needed.
|
|
175
|
+
timeout: Unused for tmux (interface compliance with AbstractRunner).
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
True if stopped, False if not running.
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
SessionNotFoundError: If session doesn't exist.
|
|
182
|
+
SessionStopError: If session couldn't be stopped.
|
|
183
|
+
"""
|
|
184
|
+
if not self.exists(name):
|
|
185
|
+
raise SessionNotFoundError(name, "tmux")
|
|
186
|
+
|
|
187
|
+
if graceful:
|
|
188
|
+
# Send interrupt signal first
|
|
189
|
+
self._run(["send-keys", "-t", name, "C-c"])
|
|
190
|
+
# Small delay is handled by tmux itself
|
|
191
|
+
|
|
192
|
+
# Kill the session
|
|
193
|
+
result = self._run(["kill-session", "-t", name])
|
|
194
|
+
|
|
195
|
+
if result.returncode != 0:
|
|
196
|
+
# Session may have already exited
|
|
197
|
+
if not self.exists(name):
|
|
198
|
+
logger.info("tmux session already stopped: %s", name)
|
|
199
|
+
return True
|
|
200
|
+
raise SessionStopError(
|
|
201
|
+
name,
|
|
202
|
+
"tmux",
|
|
203
|
+
result.stderr.strip() or "Unknown error",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
logger.info("Stopped tmux session: %s", name)
|
|
207
|
+
return True
|
|
208
|
+
|
|
209
|
+
def attach(self, name: str) -> None:
|
|
210
|
+
"""Attach to a tmux session.
|
|
211
|
+
|
|
212
|
+
This method replaces the current process with tmux attach.
|
|
213
|
+
It does not return on success.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
name: Session name to attach to.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
SessionNotFoundError: If session doesn't exist.
|
|
220
|
+
SessionAttachError: If attach failed.
|
|
221
|
+
"""
|
|
222
|
+
if not self.exists(name):
|
|
223
|
+
raise SessionNotFoundError(name, "tmux")
|
|
224
|
+
|
|
225
|
+
binary = self.binary
|
|
226
|
+
logger.info("Attaching to tmux session: %s", name)
|
|
227
|
+
|
|
228
|
+
# Replace current process with tmux attach
|
|
229
|
+
try:
|
|
230
|
+
os.execvp(binary, [binary, "attach-session", "-t", name]) # noqa: S606
|
|
231
|
+
except OSError as e:
|
|
232
|
+
raise SessionAttachError(name, "tmux", str(e)) from e
|
|
233
|
+
|
|
234
|
+
def status(self, name: str) -> SessionStatus:
|
|
235
|
+
"""Get status of a tmux session.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
name: Session name to query.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
SessionStatus with current state.
|
|
242
|
+
|
|
243
|
+
Raises:
|
|
244
|
+
SessionNotFoundError: If session doesn't exist.
|
|
245
|
+
"""
|
|
246
|
+
# List format: #{session_name}:#{window_count}:#{session_created}:#{pid}
|
|
247
|
+
result = self._run(
|
|
248
|
+
[
|
|
249
|
+
"list-sessions",
|
|
250
|
+
"-F",
|
|
251
|
+
"#{session_name}:#{session_windows}:#{session_created}:#{pid}",
|
|
252
|
+
]
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if result.returncode != 0:
|
|
256
|
+
if "no server running" in result.stderr:
|
|
257
|
+
raise SessionNotFoundError(name, "tmux")
|
|
258
|
+
raise SessionNotFoundError(name, "tmux")
|
|
259
|
+
|
|
260
|
+
for line in result.stdout.strip().split("\n"):
|
|
261
|
+
if not line:
|
|
262
|
+
continue
|
|
263
|
+
parts = line.split(":")
|
|
264
|
+
if len(parts) >= 4 and parts[0] == name:
|
|
265
|
+
return SessionStatus(
|
|
266
|
+
name=name,
|
|
267
|
+
state=SessionState.RUNNING,
|
|
268
|
+
backend=BackendType.TMUX,
|
|
269
|
+
pid=int(parts[3]) if parts[3].isdigit() else None,
|
|
270
|
+
created_at=parts[2] if parts[2] else None,
|
|
271
|
+
window_count=int(parts[1]) if parts[1].isdigit() else 0,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
raise SessionNotFoundError(name, "tmux")
|
|
275
|
+
|
|
276
|
+
def logs(self, name: str, lines: int = 100) -> str:
|
|
277
|
+
"""Capture recent output from a tmux session.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
name: Session name to get logs from.
|
|
281
|
+
lines: Number of lines to capture.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
String with captured output (ANSI codes preserved).
|
|
285
|
+
|
|
286
|
+
Raises:
|
|
287
|
+
SessionNotFoundError: If session doesn't exist.
|
|
288
|
+
"""
|
|
289
|
+
if not self.exists(name):
|
|
290
|
+
raise SessionNotFoundError(name, "tmux")
|
|
291
|
+
|
|
292
|
+
# capture-pane -t {name} -p -S -{lines}
|
|
293
|
+
result = self._run(["capture-pane", "-t", name, "-p", "-S", f"-{lines}"])
|
|
294
|
+
|
|
295
|
+
if result.returncode != 0:
|
|
296
|
+
return ""
|
|
297
|
+
|
|
298
|
+
return result.stdout
|
|
299
|
+
|
|
300
|
+
def exists(self, name: str) -> bool:
|
|
301
|
+
"""Check if a tmux session exists.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
name: Session name to check.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
True if session exists, False otherwise.
|
|
308
|
+
"""
|
|
309
|
+
result = self._run(["has-session", "-t", name])
|
|
310
|
+
return result.returncode == 0
|
|
311
|
+
|
|
312
|
+
def list_sessions(self) -> list[SessionStatus]:
|
|
313
|
+
"""List all tmux sessions.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
List of SessionStatus for all sessions.
|
|
317
|
+
"""
|
|
318
|
+
result = self._run(
|
|
319
|
+
[
|
|
320
|
+
"list-sessions",
|
|
321
|
+
"-F",
|
|
322
|
+
"#{session_name}:#{session_windows}:#{session_created}:#{pid}",
|
|
323
|
+
]
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
if result.returncode != 0:
|
|
327
|
+
# No server running or no sessions
|
|
328
|
+
return []
|
|
329
|
+
|
|
330
|
+
sessions: list[SessionStatus] = []
|
|
331
|
+
for line in result.stdout.strip().split("\n"):
|
|
332
|
+
if not line:
|
|
333
|
+
continue
|
|
334
|
+
parts = line.split(":")
|
|
335
|
+
if len(parts) >= 4:
|
|
336
|
+
sessions.append(
|
|
337
|
+
SessionStatus(
|
|
338
|
+
name=parts[0],
|
|
339
|
+
state=SessionState.RUNNING,
|
|
340
|
+
backend=BackendType.TMUX,
|
|
341
|
+
pid=int(parts[3]) if parts[3].isdigit() else None,
|
|
342
|
+
created_at=parts[2] if parts[2] else None,
|
|
343
|
+
window_count=int(parts[1]) if parts[1].isdigit() else 0,
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
return sessions
|
|
348
|
+
|
|
349
|
+
def send_keys(self, name: str, keys: str, *, enter: bool = True) -> None:
|
|
350
|
+
"""Send keys to a tmux session.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
name: Session name to send keys to.
|
|
354
|
+
keys: Keys or text to send.
|
|
355
|
+
enter: If True, send Enter key after the text.
|
|
356
|
+
|
|
357
|
+
Raises:
|
|
358
|
+
SessionNotFoundError: If session doesn't exist.
|
|
359
|
+
"""
|
|
360
|
+
if not self.exists(name):
|
|
361
|
+
raise SessionNotFoundError(name, "tmux")
|
|
362
|
+
|
|
363
|
+
args = ["send-keys", "-t", name, keys]
|
|
364
|
+
if enter:
|
|
365
|
+
args.append("Enter")
|
|
366
|
+
|
|
367
|
+
self._run(args)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
__all__ = [
|
|
371
|
+
"TmuxRunner",
|
|
372
|
+
]
|