terminaide 0.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.
terminaide/__init__.py ADDED
@@ -0,0 +1,148 @@
1
+ # terminaide/__init__.py
2
+
3
+ """
4
+ terminaide: Serve Python CLI applications in the browser using ttyd.
5
+
6
+ This package provides tools to easily serve Python CLI applications through
7
+ a browser-based terminal using ttyd. It handles binary installation and
8
+ management automatically across supported platforms.
9
+
10
+ Supported Platforms:
11
+ - Linux x86_64 (Docker containers)
12
+ - macOS ARM64 (Apple Silicon)
13
+ """
14
+
15
+ import logging
16
+ from fastapi import FastAPI
17
+ from pathlib import Path
18
+ from typing import Optional, Dict, Any, Union
19
+
20
+ # Configure package-level logging
21
+ logging.getLogger("terminaide").addHandler(logging.NullHandler())
22
+
23
+ # Core functionality
24
+ from .core.settings import TTYDConfig
25
+ from .serve import serve_tty, _configure_app
26
+
27
+ # Installation management
28
+ from .installer import setup_ttyd, get_platform_info
29
+
30
+ # Expose all exceptions
31
+ from .exceptions import (
32
+ terminaideError,
33
+ BinaryError,
34
+ InstallationError,
35
+ PlatformNotSupportedError,
36
+ DependencyError,
37
+ DownloadError,
38
+ TTYDStartupError,
39
+ TTYDProcessError,
40
+ ClientScriptError,
41
+ TemplateError,
42
+ ProxyError,
43
+ ConfigurationError
44
+ )
45
+
46
+ __version__ = "0.2.0" # Updated version number
47
+ __all__ = [
48
+ # Main functionality
49
+ "serve_tty",
50
+ "TTYDConfig",
51
+
52
+ # Binary management
53
+ "setup_ttyd",
54
+ "get_platform_info",
55
+
56
+ # Exceptions
57
+ "terminaideError",
58
+ "BinaryError",
59
+ "InstallationError",
60
+ "PlatformNotSupportedError",
61
+ "DependencyError",
62
+ "DownloadError",
63
+ "TTYDStartupError",
64
+ "TTYDProcessError",
65
+ "ClientScriptError",
66
+ "TemplateError",
67
+ "ProxyError",
68
+ "ConfigurationError"
69
+ ]
70
+
71
+ # Type aliases for better documentation
72
+ ThemeConfig = Dict[str, str]
73
+ TTYDOptions = Dict[str, Any]
74
+
75
+ def serve_tty(
76
+ app: FastAPI,
77
+ client_script: Union[str, Path],
78
+ *,
79
+ mount_path: str = "/tty",
80
+ port: int = 7681,
81
+ theme: Optional[ThemeConfig] = None,
82
+ ttyd_options: Optional[TTYDOptions] = None,
83
+ template_override: Optional[Union[str, Path]] = None,
84
+ debug: bool = False
85
+ ) -> None:
86
+ """
87
+ Configure FastAPI application to serve a Python script through a browser-based terminal.
88
+
89
+ This function automatically handles ttyd binary installation and setup for the
90
+ current platform. Supported platforms are Linux x86_64 (for Docker) and
91
+ macOS ARM64 (Apple Silicon).
92
+
93
+ Args:
94
+ app: FastAPI application instance
95
+ client_script: Path to Python script to run in terminal
96
+ mount_path: URL path to mount terminal (default: "/tty")
97
+ port: Port for ttyd process (default: 7681)
98
+ theme: Terminal theme configuration (default: {"background": "black"})
99
+ ttyd_options: Additional ttyd process options
100
+ template_override: Custom HTML template path
101
+ debug: Enable development mode with auto-reload (default: False)
102
+
103
+ Raises:
104
+ InstallationError: If ttyd binary installation fails
105
+ PlatformNotSupportedError: If running on an unsupported platform
106
+ DependencyError: If required system libraries are missing
107
+ TTYDStartupError: If ttyd fails to start
108
+ ClientScriptError: If client script cannot be found or executed
109
+ ConfigurationError: If provided configuration values are invalid
110
+
111
+ Example:
112
+ ```python
113
+ from fastapi import FastAPI
114
+ from terminaide import serve_tty
115
+
116
+ app = FastAPI()
117
+
118
+ # Basic usage
119
+ serve_tty(app, "client.py")
120
+
121
+ # Custom configuration
122
+ serve_tty(
123
+ app,
124
+ "client.py",
125
+ mount_path="/terminal",
126
+ theme={"background": "#1a1a1a"},
127
+ debug=True
128
+ )
129
+ ```
130
+
131
+ Notes:
132
+ - For Docker deployments, the package automatically handles ttyd installation
133
+ - Binary installation happens on first use and is cached for subsequent runs
134
+ - In Docker, required system libraries (libwebsockets, json-c) must be present
135
+ """
136
+ # Create configuration object
137
+ config = TTYDConfig(
138
+ client_script=client_script,
139
+ mount_path=mount_path,
140
+ port=port,
141
+ theme=theme or {"background": "black"},
142
+ ttyd_options=ttyd_options or {},
143
+ template_override=template_override,
144
+ debug=debug
145
+ )
146
+
147
+ # Configure the application with our ttyd setup
148
+ _configure_app(app, config)
@@ -0,0 +1,272 @@
1
+ # terminaide/core/manager.py
2
+
3
+ """
4
+ TTYd process management and lifecycle control.
5
+
6
+ This module is responsible for starting, monitoring, and stopping the ttyd process.
7
+ It ensures proper process cleanup and provides health monitoring capabilities.
8
+ The manager adapts its behavior based on whether we're using root or non-root
9
+ mounting configurations.
10
+ """
11
+
12
+ import os
13
+ import signal
14
+ import logging
15
+ import subprocess
16
+ from datetime import datetime
17
+ from typing import Optional, List, Dict, Any
18
+ from contextlib import asynccontextmanager
19
+ from pathlib import Path
20
+
21
+ from fastapi import FastAPI
22
+
23
+ from ..exceptions import TTYDStartupError, TTYDProcessError
24
+ from ..installer import setup_ttyd
25
+ from .settings import TTYDConfig
26
+
27
+ logger = logging.getLogger("terminaide")
28
+
29
+ class TTYDManager:
30
+ """
31
+ Manages the lifecycle of the ttyd process.
32
+
33
+ This class handles all aspects of the ttyd process, including:
34
+ - Process startup and shutdown
35
+ - Health monitoring
36
+ - Resource cleanup
37
+ - Signal handling
38
+ """
39
+
40
+ def __init__(self, config: TTYDConfig):
41
+ """
42
+ Initialize the TTYDManager.
43
+
44
+ Args:
45
+ config: TTYDConfig instance with process configuration
46
+ """
47
+ self.config = config
48
+ self.process: Optional[subprocess.Popen] = None
49
+ self._start_time: Optional[datetime] = None
50
+ self._ttyd_path: Optional[Path] = None
51
+ self._setup_ttyd()
52
+
53
+ def _setup_ttyd(self) -> None:
54
+ """
55
+ Set up ttyd binary and verify it's ready to use.
56
+
57
+ This method handles the installation and verification of the ttyd binary,
58
+ using our installer module to manage platform-specific binaries.
59
+ """
60
+ try:
61
+ self._ttyd_path = setup_ttyd()
62
+ logger.info(f"Using ttyd binary at: {self._ttyd_path}")
63
+ except Exception as e:
64
+ logger.error(f"Failed to set up ttyd: {e}")
65
+ raise TTYDStartupError(f"Failed to set up ttyd: {e}")
66
+
67
+ def _build_command(self) -> List[str]:
68
+ """
69
+ Build the ttyd command with all necessary arguments.
70
+
71
+ This method constructs the command line arguments for ttyd based on
72
+ the current configuration, taking into account both root and non-root
73
+ mounting scenarios.
74
+
75
+ Returns:
76
+ List of command arguments for ttyd process
77
+ """
78
+ if not self._ttyd_path:
79
+ raise TTYDStartupError("ttyd binary path not set")
80
+
81
+ cmd = [str(self._ttyd_path)]
82
+
83
+ # Basic configuration
84
+ cmd.extend(['-p', str(self.config.port)])
85
+ cmd.extend(['-i', self.config.ttyd_options.interface])
86
+
87
+ # Security settings
88
+ if not self.config.ttyd_options.check_origin:
89
+ cmd.append('--no-check-origin')
90
+
91
+ if self.config.ttyd_options.credential_required:
92
+ if not (self.config.ttyd_options.username and self.config.ttyd_options.password):
93
+ raise TTYDStartupError("Credentials required but not provided")
94
+ cmd.extend([
95
+ '-c',
96
+ f"{self.config.ttyd_options.username}:{self.config.ttyd_options.password}"
97
+ ])
98
+
99
+ # Debug mode settings
100
+ if self.config.debug:
101
+ cmd.extend(['-d', '3']) # Maximum debug output
102
+
103
+ # Terminal customization
104
+ theme_json = self.config.theme.model_dump_json()
105
+ cmd.extend(['-t', f'theme={theme_json}'])
106
+
107
+ # Explicitly set writable or read-only mode
108
+ if self.config.ttyd_options.writable:
109
+ cmd.append('--writable')
110
+ else:
111
+ cmd.append('-R')
112
+
113
+ # Add client script to run in the terminal
114
+ cmd.extend([
115
+ 'python',
116
+ str(self.config.client_script)
117
+ ])
118
+
119
+ return cmd
120
+
121
+ def start(self) -> None:
122
+ """
123
+ Start the ttyd process with the current configuration.
124
+
125
+ This method launches ttyd with appropriate settings and monitors
126
+ its startup to ensure it's running correctly.
127
+
128
+ Raises:
129
+ TTYDStartupError: If process fails to start
130
+ TTYDProcessError: If process is already running
131
+ """
132
+ if self.is_running:
133
+ raise TTYDProcessError("TTYd process is already running")
134
+
135
+ cmd = self._build_command()
136
+ cmd_str = ' '.join(cmd)
137
+ logger.info(f"Starting ttyd with command: {cmd_str}")
138
+
139
+ try:
140
+ # Start the process in a new session to isolate signals
141
+ self.process = subprocess.Popen(
142
+ cmd,
143
+ stdout=subprocess.PIPE,
144
+ stderr=subprocess.PIPE,
145
+ start_new_session=True
146
+ )
147
+ self._start_time = datetime.now()
148
+
149
+ # Monitor startup with longer timeout in debug mode
150
+ timeout = 4 if self.config.debug else 2
151
+ check_interval = 0.1
152
+ checks = int(timeout / check_interval)
153
+
154
+ for _ in range(checks):
155
+ if self.process.poll() is not None:
156
+ stderr = self.process.stderr.read().decode('utf-8')
157
+ logger.error(f"ttyd failed to start. Error: {stderr}")
158
+ raise TTYDStartupError(stderr=stderr)
159
+
160
+ if self.is_running:
161
+ mount_type = "root" if self.config.is_root_mounted else "non-root"
162
+ logger.info(
163
+ f"ttyd process started successfully with PID {self.process.pid} "
164
+ f"({mount_type} mounting)"
165
+ )
166
+ return
167
+
168
+ import time
169
+ time.sleep(check_interval)
170
+
171
+ logger.error("ttyd process did not start within timeout")
172
+ raise TTYDStartupError("ttyd process did not start within timeout")
173
+
174
+ except subprocess.SubprocessError as e:
175
+ logger.error(f"Failed to start ttyd: {e}")
176
+ raise TTYDStartupError(str(e))
177
+
178
+ def stop(self) -> None:
179
+ """
180
+ Stop the ttyd process if it's running.
181
+
182
+ This method ensures clean process termination using SIGTERM first,
183
+ followed by SIGKILL if necessary. It handles cases where the process
184
+ might have already been terminated.
185
+ """
186
+ if self.process:
187
+ logger.info("Stopping ttyd process...")
188
+
189
+ try:
190
+ # Try graceful shutdown first
191
+ if os.name == 'nt': # Windows
192
+ self.process.terminate()
193
+ else: # Unix-like
194
+ try:
195
+ pgid = os.getpgid(self.process.pid)
196
+ os.killpg(pgid, signal.SIGTERM)
197
+ except ProcessLookupError:
198
+ # Process is already gone, which is fine
199
+ pass
200
+
201
+ try:
202
+ self.process.wait(timeout=5) # Wait up to 5 seconds
203
+ except subprocess.TimeoutExpired:
204
+ # Force kill if graceful shutdown fails
205
+ if os.name == 'nt':
206
+ self.process.kill()
207
+ else:
208
+ try:
209
+ pgid = os.getpgid(self.process.pid)
210
+ os.killpg(pgid, signal.SIGKILL)
211
+ except ProcessLookupError:
212
+ # Process is already gone, which is fine
213
+ pass
214
+
215
+ try:
216
+ self.process.wait(timeout=1)
217
+ except subprocess.TimeoutExpired:
218
+ # If we still can't wait, the process is probably zombie or gone
219
+ pass
220
+
221
+ except Exception as e:
222
+ logger.warning(f"Error during process cleanup: {e}")
223
+
224
+ self.process = None
225
+ self._start_time = None
226
+ logger.info("ttyd process stopped successfully")
227
+
228
+ @property
229
+ def is_running(self) -> bool:
230
+ """Check if ttyd process is currently running."""
231
+ return bool(self.process and self.process.poll() is None)
232
+
233
+ @property
234
+ def uptime(self) -> Optional[float]:
235
+ """Get process uptime in seconds, if running."""
236
+ if self._start_time and self.is_running:
237
+ return (datetime.now() - self._start_time).total_seconds()
238
+ return None
239
+
240
+ def check_health(self) -> Dict[str, Any]:
241
+ """
242
+ Get comprehensive health check information about the process.
243
+
244
+ Returns:
245
+ Dictionary containing process status, uptime, and configuration details
246
+ """
247
+ return {
248
+ "status": "running" if self.is_running else "stopped",
249
+ "uptime": self.uptime,
250
+ "pid": self.process.pid if self.process else None,
251
+ "mounting": "root" if self.config.is_root_mounted else "non-root",
252
+ "terminal_path": self.config.terminal_path,
253
+ "ttyd_path": str(self._ttyd_path) if self._ttyd_path else None,
254
+ **self.config.get_health_check_info()
255
+ }
256
+
257
+ @asynccontextmanager
258
+ async def lifespan(self, app: FastAPI):
259
+ """
260
+ Manage ttyd process lifecycle within FastAPI application.
261
+
262
+ This context manager ensures proper startup and cleanup of the ttyd
263
+ process during the application lifecycle.
264
+
265
+ Usage:
266
+ app = FastAPI(lifespan=manager.lifespan)
267
+ """
268
+ try:
269
+ self.start()
270
+ yield
271
+ finally:
272
+ self.stop()
@@ -0,0 +1,281 @@
1
+ # terminaide/core/proxy.py
2
+
3
+ """
4
+ Proxy management for ttyd HTTP and WebSocket connections.
5
+
6
+ This module handles the proxying of both HTTP and WebSocket connections to the ttyd
7
+ process, with special handling for path management to support both root and non-root
8
+ mounting configurations.
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ import asyncio
14
+ from typing import Optional, Dict, Any
15
+ from urllib.parse import urljoin
16
+
17
+ import httpx
18
+ import websockets
19
+ import websockets.exceptions
20
+ from fastapi import Request, WebSocket, HTTPException
21
+ from fastapi.responses import Response, StreamingResponse
22
+
23
+ from ..exceptions import ProxyError
24
+ from .settings import TTYDConfig
25
+
26
+ logger = logging.getLogger("terminaide")
27
+
28
+ class ProxyManager:
29
+ """
30
+ Manages HTTP and WebSocket proxying for ttyd while maintaining same-origin security.
31
+
32
+ This class handles the complexities of proxying requests to the ttyd process,
33
+ including path rewriting and WebSocket connection management. It supports both
34
+ root ("/") and non-root ("/path") mounting configurations.
35
+ """
36
+
37
+ def __init__(self, config: TTYDConfig):
38
+ """
39
+ Initialize the proxy manager.
40
+
41
+ Args:
42
+ config: TTYDConfig instance with proxy configuration
43
+ """
44
+ self.config = config
45
+ self._client: Optional[httpx.AsyncClient] = None
46
+
47
+ # Build base URLs for the ttyd process
48
+ host = f"{self.config.ttyd_options.interface}:{self.config.port}"
49
+ self.target_url = f"http://{host}"
50
+ self.ws_url = f"ws://{host}/ws"
51
+
52
+ logger.info(
53
+ f"Proxy configured for ttyd at {self.target_url} "
54
+ f"(terminal path: {self.config.terminal_path})"
55
+ )
56
+
57
+ @property
58
+ def http_client(self) -> httpx.AsyncClient:
59
+ """Lazy initialization of HTTP client."""
60
+ if self._client is None:
61
+ self._client = httpx.AsyncClient(
62
+ timeout=httpx.Timeout(30.0),
63
+ follow_redirects=True
64
+ )
65
+ return self._client
66
+
67
+ async def cleanup(self) -> None:
68
+ """Clean up resources."""
69
+ if self._client:
70
+ await self._client.aclose()
71
+ self._client = None
72
+
73
+ def _strip_path_prefix(self, path: str) -> str:
74
+ """
75
+ Strip the mount path prefix from the request path.
76
+
77
+ This method handles both root and non-root mounting scenarios to ensure
78
+ requests are properly forwarded to ttyd.
79
+
80
+ Args:
81
+ path: Original request path
82
+
83
+ Returns:
84
+ Path with prefix stripped for ttyd
85
+ """
86
+ # For root mounting, we only need to strip the /terminal prefix
87
+ if self.config.is_root_mounted:
88
+ if path.startswith("/terminal/"):
89
+ return path.replace("/terminal", "", 1)
90
+ return "/"
91
+
92
+ # For non-root mounting, strip both the mount path and /terminal
93
+ prefix = self.config.terminal_path
94
+ if path.startswith(prefix):
95
+ return path.replace(prefix, "", 1) or "/"
96
+ return "/"
97
+
98
+ async def _handle_sourcemap(self, path: str) -> Response:
99
+ """Handle sourcemap requests with minimal response."""
100
+ return Response(
101
+ content=json.dumps({
102
+ "version": 3,
103
+ "file": path.split('/')[-1].replace('.map', ''),
104
+ "sourceRoot": "",
105
+ "sources": ["source.js"],
106
+ "sourcesContent": ["// Source code unavailable"],
107
+ "names": [],
108
+ "mappings": ";;;;;;;",
109
+ }),
110
+ media_type='application/json',
111
+ headers={'Access-Control-Allow-Origin': '*'}
112
+ )
113
+
114
+ async def proxy_http(self, request: Request) -> Response:
115
+ """
116
+ Proxy HTTP requests to ttyd.
117
+
118
+ This method handles path rewriting and forwards the request to the ttyd
119
+ process, supporting both root and non-root mounting configurations.
120
+
121
+ Args:
122
+ request: Incoming FastAPI request
123
+
124
+ Returns:
125
+ Proxied response from ttyd
126
+
127
+ Raises:
128
+ ProxyError: If proxying fails
129
+ """
130
+ path = request.url.path
131
+
132
+ # Handle sourcemap requests
133
+ if path.endswith('.map'):
134
+ return await self._handle_sourcemap(path)
135
+
136
+ # Strip the appropriate prefix based on mounting configuration
137
+ target_path = self._strip_path_prefix(path)
138
+
139
+ try:
140
+ # Forward the request to ttyd
141
+ headers = dict(request.headers)
142
+ headers.pop("host", None) # Remove host header
143
+
144
+ response = await self.http_client.request(
145
+ method=request.method,
146
+ url=urljoin(self.target_url, target_path),
147
+ headers=headers,
148
+ content=await request.body()
149
+ )
150
+
151
+ # Clean response headers that might cause issues
152
+ response_headers = {
153
+ k: v for k, v in response.headers.items()
154
+ if k.lower() not in {
155
+ 'content-encoding',
156
+ 'content-length',
157
+ 'transfer-encoding'
158
+ }
159
+ }
160
+
161
+ return StreamingResponse(
162
+ response.aiter_bytes(),
163
+ status_code=response.status_code,
164
+ headers=response_headers,
165
+ media_type=response.headers.get('content-type')
166
+ )
167
+
168
+ except httpx.RequestError as e:
169
+ logger.error(f"HTTP proxy error: {e}")
170
+ raise ProxyError(f"Failed to proxy request: {e}")
171
+
172
+ async def proxy_websocket(self, websocket: WebSocket) -> None:
173
+ """
174
+ Proxy WebSocket connections to ttyd.
175
+
176
+ This method handles the WebSocket connection to the ttyd process,
177
+ including proper error handling and cleanup.
178
+
179
+ Args:
180
+ websocket: Incoming WebSocket connection
181
+
182
+ Raises:
183
+ ProxyError: If WebSocket proxying fails
184
+ """
185
+ try:
186
+ # Accept the incoming connection with ttyd subprotocol
187
+ await websocket.accept(subprotocol='tty')
188
+
189
+ logger.info(f"Opening WebSocket connection to {self.ws_url}")
190
+
191
+ async with websockets.connect(
192
+ self.ws_url,
193
+ subprotocols=['tty'],
194
+ ping_interval=None,
195
+ close_timeout=5
196
+ ) as target_ws:
197
+ logger.info("WebSocket connection established")
198
+
199
+ # Set up bidirectional forwarding
200
+ async def forward(source: Any, dest: Any, is_client: bool = True) -> None:
201
+ """Forward data between WebSocket connections."""
202
+ try:
203
+ while True:
204
+ try:
205
+ # Handle different WebSocket implementations
206
+ if is_client:
207
+ data = await source.receive_bytes()
208
+ await dest.send(data)
209
+ else:
210
+ data = await source.recv()
211
+ if isinstance(data, bytes):
212
+ await dest.send_bytes(data)
213
+ else:
214
+ await dest.send_text(data)
215
+ except websockets.exceptions.ConnectionClosed:
216
+ logger.info(
217
+ f"{'Client' if is_client else 'Target'} "
218
+ "connection closed normally"
219
+ )
220
+ break
221
+ except Exception as e:
222
+ if not isinstance(e, asyncio.CancelledError):
223
+ logger.error(
224
+ f"{'Client' if is_client else 'Target'} "
225
+ f"connection error: {e}"
226
+ )
227
+ break
228
+
229
+ except asyncio.CancelledError:
230
+ logger.info(
231
+ f"{'Client' if is_client else 'Target'} "
232
+ "forwarding cancelled"
233
+ )
234
+ raise
235
+ except Exception as e:
236
+ if not isinstance(e, websockets.exceptions.ConnectionClosed):
237
+ logger.error(f"WebSocket forward error: {e}")
238
+
239
+ # Create forwarding tasks
240
+ tasks = [
241
+ asyncio.create_task(forward(websocket, target_ws)),
242
+ asyncio.create_task(forward(target_ws, websocket, False))
243
+ ]
244
+
245
+ try:
246
+ # Wait for either direction to complete
247
+ await asyncio.wait(
248
+ tasks,
249
+ return_when=asyncio.FIRST_COMPLETED
250
+ )
251
+ finally:
252
+ # Clean up tasks
253
+ for task in tasks:
254
+ if not task.done():
255
+ task.cancel()
256
+ try:
257
+ await task
258
+ except asyncio.CancelledError:
259
+ pass
260
+
261
+ except Exception as e:
262
+ logger.error(f"WebSocket proxy error: {e}")
263
+ if not isinstance(e, websockets.exceptions.ConnectionClosed):
264
+ raise ProxyError(f"WebSocket proxy error: {e}")
265
+
266
+ finally:
267
+ # Ensure WebSocket is closed
268
+ try:
269
+ await websocket.close()
270
+ except Exception:
271
+ pass # Connection already closed
272
+
273
+ def get_routes_info(self) -> Dict[str, Any]:
274
+ """Get information about proxy routes for monitoring."""
275
+ return {
276
+ "http_endpoint": self.target_url,
277
+ "ws_endpoint": self.ws_url,
278
+ "mount_path": self.config.mount_path,
279
+ "terminal_path": self.config.terminal_path,
280
+ "is_root_mounted": self.config.is_root_mounted
281
+ }