terminaide 0.0.1__tar.gz

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.
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.1
2
+ Name: terminaide
3
+ Version: 0.0.1
4
+ Summary: Serve Python CLI applications in the browser using ttyd
5
+ Home-page: https://github.com/anotherbazeinthewall/terminaide
6
+ License: MIT
7
+ Author: Alex Basile
8
+ Author-email: basileaw@gmail.com
9
+ Requires-Python: >=3.12,<4.0
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Dist: chatline (>=0.0.5)
15
+ Requires-Dist: fastapi (>=0.104.0)
16
+ Requires-Dist: httpx (>=0.25.0)
17
+ Requires-Dist: jinja2 (>=3.1.2)
18
+ Requires-Dist: pydantic (>=2.4.2)
19
+ Requires-Dist: uvicorn (>=0.24.0)
20
+ Requires-Dist: websockets (>=12.0)
21
+ Project-URL: Repository, https://github.com/anotherbazeinthewall/terminaide
22
+ Description-Content-Type: text/markdown
23
+
24
+ # terminaide
25
+
26
+ A Python package for serving CLI applications in the browser using ttyd.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install terminaide
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ```python
37
+ from fastapi import FastAPI
38
+ from terminaide import serve_tty
39
+
40
+ app = FastAPI()
41
+
42
+ serve_tty(app, client_script="path/to/your/script.py")
43
+ ```
44
+
45
+ ## Development
46
+
47
+ ```bash
48
+ # Install dependencies
49
+ poetry install
50
+
51
+ # Run test server
52
+ poetry run python test-server/main.py
53
+ ```
@@ -0,0 +1,30 @@
1
+ # terminaide
2
+
3
+ A Python package for serving CLI applications in the browser using ttyd.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install terminaide
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ from fastapi import FastAPI
15
+ from terminaide import serve_tty
16
+
17
+ app = FastAPI()
18
+
19
+ serve_tty(app, client_script="path/to/your/script.py")
20
+ ```
21
+
22
+ ## Development
23
+
24
+ ```bash
25
+ # Install dependencies
26
+ poetry install
27
+
28
+ # Run test server
29
+ poetry run python test-server/main.py
30
+ ```
@@ -0,0 +1,100 @@
1
+ [tool.poetry]
2
+ name = "terminaide"
3
+ version = "0.0.1"
4
+ description = "Serve Python CLI applications in the browser using ttyd"
5
+ authors = ["Alex Basile <basileaw@gmail.com>"]
6
+ packages = [{ include = "terminaide" }]
7
+ readme = "README.md"
8
+ repository = "https://github.com/anotherbazeinthewall/terminaide"
9
+ license = "MIT"
10
+
11
+ [build-system]
12
+ requires = ["poetry-core"]
13
+ build-backend = "poetry.core.masonry.api"
14
+
15
+ [tool.poetry.dependencies]
16
+ python = "^3.12"
17
+ fastapi = ">=0.104.0"
18
+ uvicorn = ">=0.24.0"
19
+ jinja2 = ">=3.1.2"
20
+ httpx = ">=0.25.0"
21
+ websockets = ">=12.0"
22
+ pydantic = ">=2.4.2"
23
+ chatline = ">=0.0.5"
24
+
25
+ [tool.poetry.group.test-server.dependencies]
26
+ bs4 = ">=0.0.2"
27
+ requests = ">=2.32.3"
28
+ rich = "*"
29
+
30
+ [tool.poetry.group.dev.dependencies]
31
+ pyyaml = "*"
32
+ boto3 = "*"
33
+ botocore = "*"
34
+
35
+ [tool.poe.tasks]
36
+ serve-local = "python example/server.py"
37
+ serve-container = "docker compose up --build"
38
+
39
+ # Publish scripts
40
+ check-changes = "bash -c 'if [ ! -z \"$(git status --porcelain)\" ]; then echo \"Error: You have uncommitted changes.\"; exit 1; fi'"
41
+
42
+ publish-patch = """
43
+ bash -c '
44
+ poe check-changes && \
45
+ git pull origin main && \
46
+ VERSION=$(poetry version -s) && \
47
+ if git rev-parse "v$VERSION" >/dev/null 2>&1; then \
48
+ echo "Error: Tag v$VERSION already exists" && exit 1; \
49
+ fi && \
50
+ poetry version patch && \
51
+ VERSION=$(poetry version -s) && \
52
+ poetry build && \
53
+ poetry publish && \
54
+ git add pyproject.toml && \
55
+ git commit -m "release v$VERSION" && \
56
+ git tag v$VERSION && \
57
+ git push origin main && \
58
+ git push origin --tags
59
+ '
60
+ """
61
+
62
+ publish-minor = """
63
+ bash -c '
64
+ poe check-changes && \
65
+ git pull origin main && \
66
+ VERSION=$(poetry version -s) && \
67
+ if git rev-parse "v$VERSION" >/dev/null 2>&1; then \
68
+ echo "Error: Tag v$VERSION already exists" && exit 1; \
69
+ fi && \
70
+ poetry version minor && \
71
+ VERSION=$(poetry version -s) && \
72
+ poetry build && \
73
+ poetry publish && \
74
+ git add pyproject.toml && \
75
+ git commit -m "release v$VERSION" && \
76
+ git tag v$VERSION && \
77
+ git push origin main && \
78
+ git push origin --tags
79
+ '
80
+ """
81
+
82
+ publish-major = """
83
+ bash -c '
84
+ poe check-changes && \
85
+ git pull origin main && \
86
+ VERSION=$(poetry version -s) && \
87
+ if git rev-parse "v$VERSION" >/dev/null 2>&1; then \
88
+ echo "Error: Tag v$VERSION already exists" && exit 1; \
89
+ fi && \
90
+ poetry version major && \
91
+ VERSION=$(poetry version -s) && \
92
+ poetry build && \
93
+ poetry publish && \
94
+ git add pyproject.toml && \
95
+ git commit -m "release v$VERSION" && \
96
+ git tag v$VERSION && \
97
+ git push origin main && \
98
+ git push origin --tags
99
+ '
100
+ """
@@ -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()