├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── ai_integration.md ├── api_reference.md ├── architecture.md ├── comparison.md ├── features_showcase.md ├── getting_started.md ├── npm_integration.md ├── publishing_guide.md ├── terminal_player.md └── terminal_player_ui.py ├── examples ├── basic_recording.py ├── basic_usage.py ├── cool_examples.py ├── interactive_shell.py ├── parallel_execution.py ├── parallel_with_keys.py ├── recording_demo.py ├── recording_example.py ├── recording_playback.py └── terminal_emulation.py ├── pyproject.toml ├── setup_dev.sh ├── termitty ├── __init__.py ├── core │ ├── __init__.py │ ├── config.py │ └── exceptions.py ├── interactive │ ├── __init__.py │ ├── io_handler.py │ ├── key_codes.py │ ├── patterns.py │ └── shell.py ├── parallel │ ├── __init__.py │ ├── executor.py │ ├── pool.py │ └── results.py ├── parsers │ └── __init__.py ├── recording │ ├── __init__.py │ ├── formats.py │ ├── player.py │ ├── recorder.py │ └── storage.py ├── session │ ├── __init__.py │ └── session.py ├── terminal │ ├── __init__.py │ ├── ansi_parser.py │ ├── screen_buffer.py │ └── virtual_terminal.py ├── transport │ ├── __init__.py │ ├── base.py │ └── paramiko_transport.py └── utils │ └── __init__.py ├── test_all_features.py ├── test_recording.py └── tests ├── __init__.py └── unit ├── test_interactive_shell.py ├── test_session.py └── test_terminal_emulation.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .nox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | *.py,cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Virtual environments 53 | venv/ 54 | ENV/ 55 | env/ 56 | .venv 57 | .pypirc 58 | test_env/ 59 | 60 | # IDEs 61 | .idea/ 62 | .vscode/ 63 | *.swp 64 | *.swo 65 | *~ 66 | .project 67 | .pydevproject 68 | 69 | # OS files 70 | .DS_Store 71 | .DS_Store? 72 | ._* 73 | .Spotlight-V100 74 | .Trashes 75 | ehthumbs.db 76 | Thumbs.db 77 | 78 | # Termitty specific 79 | *.log 80 | .termitty_sessions/ 81 | config.local.ini 82 | .env 83 | termitty_recordings/ 84 | 85 | # Documentation builds 86 | docs/_build/ 87 | docs/_static/ 88 | docs/_templates/ 89 | 90 | termitty.ini 91 | 92 | .mypy_cache/ 93 | .claude/ 94 | .cursor/ 95 | .cursorrules 96 | .cursorignore 97 | .cursorignorerules 98 | .cursorignorerules 99 | 100 | recordings/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.1.1] - 2025-05-28 11 | 12 | ### Added 13 | 14 | - **World-class Terminal Recording Player** with professional UI 15 | - Beautiful rich terminal interface with multi-panel layout 16 | - Real-time auto-scrolling terminal display 17 | - Variable playback speeds (0.25×, 0.5×, 1.0×, 2.0×, 4.0×) 18 | - Smart syntax highlighting and color-coded output 19 | - Animated progress bars and typing effects 20 | - Recording metadata display with comprehensive information 21 | - Marker navigation and timestamp indicators 22 | - Professional startup/completion animations 23 | 24 | ### Enhanced 25 | 26 | - **Recording and Playback System** 27 | - Fixed RecordingStorage initialization issues 28 | - Improved SessionPlayer callback system 29 | - Enhanced recording format compatibility 30 | - Better error handling during playback 31 | - Optimized memory usage for long recordings 32 | 33 | ### Fixed 34 | 35 | - Terminal emulation cursor positioning issues 36 | - Recording functionality TypeError in stop_recording() 37 | - SessionPlayer.play() method parameter handling 38 | - Import errors in recording modules 39 | - Auto-scrolling behavior in terminal display 40 | 41 | ### Documentation 42 | 43 | - Added comprehensive recording and playback examples 44 | - Enhanced API documentation with recording workflows 45 | - Terminal player usage guide and features documentation 46 | 47 | ## [0.1.0] - 2025-05-28 48 | 49 | ### Added 50 | 51 | - Initial release of Termitty 52 | - Core SSH session management with Selenium-like API 53 | - Full terminal emulation with ANSI escape code support 54 | - Interactive shell sessions with persistent state 55 | - Smart waiting system with built-in and custom conditions 56 | - Session recording and playback functionality 57 | - Parallel execution across multiple hosts 58 | - Context managers for directory and environment management 59 | - Comprehensive error handling and timeout support 60 | - Virtual terminal with screen buffer management 61 | - Support for special keys and key combinations 62 | - Multiple recording formats (native JSON and Asciinema) 63 | - Connection pooling for efficient parallel operations 64 | - Pattern-based output matching and waiting 65 | - Terminal UI navigation capabilities 66 | 67 | ### Security 68 | 69 | - Secure password input with masking 70 | - Recording sanitization for sensitive data 71 | - Support for SSH key authentication with passphrases 72 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Termitty 2 | 3 | We love your input! We want to make contributing to Termitty as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | - Becoming a maintainer 10 | 11 | ## Development Process 12 | 13 | We use GitHub to host code, to track issues and feature requests, as well as accept pull requests. 14 | 15 | 1. Fork the repo and create your branch from `main` 16 | 2. If you've added code that should be tested, add tests 17 | 3. If you've changed APIs, update the documentation 18 | 4. Ensure the test suite passes 19 | 5. Make sure your code follows the style guidelines 20 | 6. Issue that pull request! 21 | 22 | ## Setting Up Development Environment 23 | 24 | ```bash 25 | # Clone your fork 26 | git clone https://github.com/your-username/termitty.git 27 | cd termitty 28 | 29 | # Create virtual environment 30 | python -m venv venv 31 | source venv/bin/activate # On Windows: venv\Scripts\activate 32 | 33 | # Install in development mode 34 | pip install -e ".[dev]" 35 | 36 | # Install pre-commit hooks 37 | pre-commit install 38 | ``` 39 | 40 | ## Running Tests 41 | 42 | ```bash 43 | # Run all tests 44 | pytest 45 | 46 | # Run with coverage 47 | pytest --cov=termitty --cov-report=html 48 | 49 | # Run specific test file 50 | pytest tests/unit/test_session.py 51 | 52 | # Run with verbose output 53 | pytest -v 54 | ``` 55 | 56 | ## Code Style 57 | 58 | We use Black for code formatting and follow PEP 8 guidelines. 59 | 60 | ```bash 61 | # Format code 62 | black termitty tests 63 | 64 | # Sort imports 65 | isort termitty tests 66 | 67 | # Check code style 68 | flake8 termitty tests 69 | 70 | # Type checking 71 | mypy termitty 72 | ``` 73 | 74 | ## Pull Request Process 75 | 76 | 1. Update the README.md with details of changes to the interface 77 | 2. Update the CHANGELOG.md with your changes 78 | 3. The PR will be merged once you have the sign-off of at least one maintainer 79 | 80 | ## Any contributions you make will be under the MIT Software License 81 | 82 | When you submit code changes, your submissions are understood to be under the same [MIT License](LICENSE) that covers the project. 83 | 84 | ## Report bugs using GitHub's [issue tracker](https://github.com/yourusername/termitty/issues) 85 | 86 | We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/yourusername/termitty/issues/new). 87 | 88 | **Great Bug Reports** tend to have: 89 | 90 | - A quick summary and/or background 91 | - Steps to reproduce 92 | - Be specific! 93 | - Give sample code if you can 94 | - What you expected would happen 95 | - What actually happens 96 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 97 | 98 | ## Feature Requests 99 | 100 | We love feature requests! Please: 101 | 102 | 1. Check if the feature has already been requested 103 | 2. Provide a clear and detailed explanation of the feature 104 | 3. Explain why this feature would be useful to most Termitty users 105 | 4. Include code samples of how the feature would work 106 | 107 | ## Code Review Process 108 | 109 | The core team looks at Pull Requests on a regular basis. After feedback has been given, we expect responses within two weeks. After two weeks, we may close the pull request if it isn't showing any activity. 110 | 111 | ## Community 112 | 113 | - Discussions happen in GitHub Issues and Pull Requests 114 | - Feel free to ask questions! 115 | 116 | ## References 117 | 118 | This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/master/CONTRIBUTING.md) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Termitty Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Termitty 🐜 2 | 3 | A powerful, Selenium-inspired Python framework for terminal and SSH automation. 4 | 5 | [![Python Version](https://img.shields.io/pypi/pyversions/termitty.svg)](https://pypi.org/project/termitty/) 6 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 7 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 8 | 9 | ## What is Termitty? 10 | 11 | Termitty brings the elegance and simplicity of Selenium WebDriver to the world of terminal automation. Just as Selenium revolutionized web browser automation, Termitty transforms how developers interact with command-line interfaces programmatically. 12 | 13 | ## 🆚 Quick Comparison 14 | 15 | | Feature | Termitty | Fabric | Paramiko | Pexpect | Ansible | 16 | |---------|----------|---------|-----------|---------|---------| 17 | | **Selenium-like API** | ✅ | ❌ | ❌ | ❌ | ❌ | 18 | | **Terminal Emulation** | ✅ Full | ❌ | ❌ | ⚠️ Basic | ❌ | 19 | | **Session Recording** | ✅ | ❌ | ❌ | ❌ | ❌ | 20 | | **AI-Ready** | ✅ | ❌ | ❌ | ❌ | ❌ | 21 | | **Interactive Shell** | ✅ | ⚠️ | ⚠️ | ✅ | ❌ | 22 | 23 | → See [detailed comparison](docs/comparison.md) for more information 24 | 25 | ## ✨ Key Features 26 | 27 | ### 🎯 **Intuitive Selenium-like API** 28 | ```python 29 | # Familiar patterns for developers who've used Selenium 30 | session = TermittySession() 31 | session.connect('server.com', username='user', key_file='~/.ssh/id_rsa') 32 | session.wait_until(OutputContains('Ready')) 33 | ``` 34 | 35 | ### 🖥️ **Advanced Terminal Emulation** 36 | - Full ANSI escape code support 37 | - Virtual terminal with screen buffer management 38 | - Real-time terminal state tracking 39 | - Menu and UI element detection 40 | 41 | ### ⏱️ **Smart Waiting & Pattern Matching** 42 | ```python 43 | # Wait for specific conditions 44 | session.wait_until(OutputContains('Task completed'), timeout=30) 45 | session.wait_until(PromptReady('$ ')) 46 | session.wait_until(OutputMatches(r'Status: \d+%')) 47 | ``` 48 | 49 | ### 🔄 **Interactive Shell Sessions** 50 | ```python 51 | # Handle interactive applications with ease 52 | with session.interactive_shell() as shell: 53 | shell.send_line('vim config.txt') 54 | shell.wait_for_text('INSERT') 55 | shell.send_keys('Hello World') 56 | shell.send_key(Keys.ESCAPE) 57 | shell.send_line(':wq') 58 | ``` 59 | 60 | ### 📹 **Session Recording & Playback** 61 | ```python 62 | # Record your terminal sessions 63 | session.start_recording('demo.json') 64 | # ... perform actions ... 65 | recording = session.stop_recording() 66 | 67 | # Playback later 68 | from termitty.recording import SessionPlayer 69 | player = SessionPlayer('demo.json') 70 | player.play(speed=2.0) 71 | ``` 72 | 73 | ### ⚡ **Parallel Execution** 74 | ```python 75 | # Execute commands across multiple servers 76 | hosts = ['server1.com', 'server2.com', 'server3.com'] 77 | pool = ConnectionPool(hosts, username='user', key_file='~/.ssh/id_rsa') 78 | 79 | results = pool.execute_on_all('apt update && apt upgrade -y', 80 | strategy='rolling', 81 | batch_size=2) 82 | ``` 83 | 84 | ### 🎨 **Terminal UI Navigation** 85 | ```python 86 | # Navigate terminal UIs programmatically 87 | menu_items = session.terminal.find_menu_items() 88 | session.terminal.click_menu_item('Settings') 89 | ``` 90 | 91 | ## 📦 Installation 92 | 93 | ```bash 94 | # Core package (automation, recording, terminal emulation) 95 | pip install termitty 96 | 97 | # With UI support (includes beautiful terminal player) 98 | pip install termitty[ui] 99 | ``` 100 | 101 | **Installation Options:** 102 | - **`termitty`** - Core functionality only (lightweight) 103 | - **`termitty[ui]`** - Includes rich terminal player with professional UI 104 | 105 | ## 🎬 NEW in v0.1.1: World-Class Recording Player 106 | 107 | Experience terminal recordings like never before: 108 | 109 | ```python 110 | # Record a session 111 | from termitty import TermittySession 112 | 113 | with TermittySession() as session: 114 | session.connect('server.com', username='user', password='pass') 115 | session.start_recording('demo.json') 116 | session.execute('echo "Hello World!"') 117 | session.execute('date') 118 | session.stop_recording() 119 | ``` 120 | 121 | ```bash 122 | # Install with UI support and play back with the beautiful player 123 | pip install termitty[ui] 124 | python docs/terminal_player_ui.py demo.json --speed 1.0 125 | ``` 126 | 127 | **Player Features:** 128 | - 🎨 Professional multi-panel interface 129 | - 📊 Real-time auto-scrolling terminal 130 | - ⚡ Variable speeds (0.25× to 4×) 131 | - 🌈 Smart syntax highlighting 132 | - 🏷️ Marker navigation 133 | - ⌨️ Animated typing effects 134 | 135 | ## 🚀 Quick Start 136 | 137 | ### Basic Command Execution 138 | ```python 139 | from termitty import TermittySession 140 | 141 | # Connect to a server 142 | session = TermittySession() 143 | session.connect('example.com', username='user', password='pass') 144 | 145 | # Execute commands 146 | result = session.execute('ls -la') 147 | print(result.output) 148 | 149 | # Use context managers for directory changes 150 | with session.cd('/var/log'): 151 | logs = session.execute('tail -n 20 syslog') 152 | print(logs.output) 153 | ``` 154 | 155 | ### Interactive Application Handling 156 | ```python 157 | # Handle password prompts 158 | session.execute('sudo apt update') 159 | session.wait_until(OutputContains('[sudo] password')) 160 | session.send_line('password', secure=True) 161 | 162 | # Navigate interactive installers 163 | session.execute('./install.sh') 164 | session.wait_until(OutputContains('Do you accept? [y/N]')) 165 | session.send_line('y') 166 | ``` 167 | 168 | ### Terminal Emulation 169 | ```python 170 | # Access the virtual terminal through session state 171 | terminal = session.state.terminal 172 | 173 | # Get screen content 174 | screen_text = terminal.get_screen_text() 175 | print(screen_text) 176 | 177 | # Find text on screen 178 | if terminal.find_text('Error'): 179 | print("Error found at:", terminal.cursor_position) 180 | 181 | # Take snapshots 182 | snapshot = terminal.snapshot() 183 | ``` 184 | 185 | ## 📚 Documentation 186 | 187 | For comprehensive documentation, visit our [docs folder](docs/) or check out these guides: 188 | 189 | - [Getting Started Guide](docs/getting_started.md) 190 | - [API Reference](docs/api_reference.md) 191 | - [Examples](examples/) 192 | - [Architecture](docs/architecture.md) 193 | - [AI Integration Guide](docs/ai_integration.md) 194 | - [Comparison with Alternatives](docs/comparison.md) 195 | - [Publishing Guide](docs/publishing_guide.md) 196 | 197 | ## 🏗️ Architecture 198 | 199 | Termitty is built with a modular architecture: 200 | 201 | - **Session Layer**: High-level API for SSH connections and command execution 202 | - **Terminal Emulator**: Full VT100/ANSI terminal emulation 203 | - **Interactive Shell**: Real-time interaction with persistent shell sessions 204 | - **Recording System**: Capture and replay terminal sessions 205 | - **Parallel Executor**: Efficient multi-host command execution 206 | 207 | See our [Architecture Documentation](docs/architecture.md) for detailed information. 208 | 209 | ## 🤝 Contributing 210 | 211 | We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. 212 | 213 | ## 📄 License 214 | 215 | Termitty is licensed under the MIT License. See [LICENSE](LICENSE) for details. 216 | 217 | ## 🛣️ Roadmap 218 | 219 | - [ ] Async/await support for all operations 220 | - [ ] File transfer capabilities (SCP/SFTP) 221 | - [ ] Built-in task templates for common operations 222 | - [ ] Terminal session sharing and collaboration 223 | - [ ] Web-based session viewer 224 | - [ ] Plugin system for custom extensions 225 | 226 | ## 💡 Use Cases 227 | 228 | - **DevOps Automation**: Automate server provisioning and configuration 229 | - **Testing**: Create robust tests for CLI applications 230 | - **Monitoring**: Build intelligent monitoring scripts that react to terminal output 231 | - **Documentation**: Record terminal sessions for tutorials and documentation 232 | - **Deployment**: Orchestrate complex multi-server deployments 233 | - **AI Agents**: Enable LLMs and coding assistants to control terminals → [Learn more](docs/ai_integration.md) 234 | 235 | ## 🙏 Acknowledgments 236 | 237 | This project is inspired by the elegance of Selenium WebDriver and the need for better terminal automation tools in the DevOps/SRE space. 238 | 239 | --- 240 | 241 | **Ready to automate your terminal workflows?** Check out our [examples](examples/) to get started! -------------------------------------------------------------------------------- /docs/api_reference.md: -------------------------------------------------------------------------------- 1 | # Termitty API Reference 2 | 3 | ## Core Classes 4 | 5 | ### TermittySession 6 | 7 | The main class for managing SSH connections and executing commands. 8 | 9 | ```python 10 | class TermittySession: 11 | def __init__(self, config: Optional[Config] = None) 12 | ``` 13 | 14 | #### Methods 15 | 16 | ##### connect() 17 | ```python 18 | def connect( 19 | host: str, 20 | username: str, 21 | password: Optional[str] = None, 22 | key_file: Optional[str] = None, 23 | port: int = 22, 24 | timeout: float = 30.0 25 | ) -> None 26 | ``` 27 | Establish an SSH connection to a remote host. 28 | 29 | **Parameters:** 30 | - `host`: Hostname or IP address 31 | - `username`: SSH username 32 | - `password`: SSH password (optional if using key) 33 | - `key_file`: Path to SSH private key file 34 | - `port`: SSH port (default: 22) 35 | - `timeout`: Connection timeout in seconds 36 | 37 | **Example:** 38 | ```python 39 | session.connect('example.com', username='user', key_file='~/.ssh/id_rsa') 40 | ``` 41 | 42 | ##### execute() 43 | ```python 44 | def execute( 45 | command: str, 46 | timeout: Optional[float] = None, 47 | wait: bool = True, 48 | check: bool = False 49 | ) -> CommandResult 50 | ``` 51 | Execute a command on the remote host. 52 | 53 | **Parameters:** 54 | - `command`: Command to execute 55 | - `timeout`: Command timeout in seconds (None for no timeout) 56 | - `wait`: Wait for command completion 57 | - `check`: Raise exception if command fails 58 | 59 | **Returns:** `CommandResult` object with output, error, and exit code 60 | 61 | **Example:** 62 | ```python 63 | result = session.execute('ls -la', timeout=10) 64 | print(result.output) 65 | ``` 66 | 67 | ##### wait_until() 68 | ```python 69 | def wait_until( 70 | condition: WaitCondition, 71 | timeout: float = 30.0, 72 | poll_interval: float = 0.5 73 | ) -> None 74 | ``` 75 | Wait until a condition is met. 76 | 77 | **Parameters:** 78 | - `condition`: WaitCondition instance 79 | - `timeout`: Maximum time to wait 80 | - `poll_interval`: How often to check condition 81 | 82 | **Example:** 83 | ```python 84 | session.wait_until(OutputContains('Ready'), timeout=60) 85 | ``` 86 | 87 | ##### send_line() 88 | ```python 89 | def send_line( 90 | text: str, 91 | secure: bool = False 92 | ) -> None 93 | ``` 94 | Send a line of text to the terminal. 95 | 96 | **Parameters:** 97 | - `text`: Text to send 98 | - `secure`: Hide text from logs (for passwords) 99 | 100 | ##### interactive_shell() 101 | ```python 102 | def interactive_shell( 103 | timeout: float = 0.1 104 | ) -> InteractiveShell 105 | ``` 106 | Start an interactive shell session. 107 | 108 | **Returns:** Context manager for interactive shell 109 | 110 | **Example:** 111 | ```python 112 | with session.interactive_shell() as shell: 113 | shell.send_line('vim file.txt') 114 | ``` 115 | 116 | ##### cd() 117 | ```python 118 | def cd(path: str) -> ContextManager 119 | ``` 120 | Context manager for changing directories. 121 | 122 | **Example:** 123 | ```python 124 | with session.cd('/var/log'): 125 | session.execute('ls') # Lists /var/log contents 126 | ``` 127 | 128 | ##### env() 129 | ```python 130 | def env(**kwargs) -> ContextManager 131 | ``` 132 | Context manager for setting environment variables. 133 | 134 | **Example:** 135 | ```python 136 | with session.env(PATH='/custom/path:$PATH'): 137 | session.execute('echo $PATH') 138 | ``` 139 | 140 | ##### start_recording() 141 | ```python 142 | def start_recording( 143 | filename: Optional[str] = None, 144 | format: str = 'native' 145 | ) -> None 146 | ``` 147 | Start recording the session. 148 | 149 | **Parameters:** 150 | - `filename`: Output filename (auto-generated if None) 151 | - `format`: Recording format ('native' or 'asciinema') 152 | 153 | ##### stop_recording() 154 | ```python 155 | def stop_recording() -> Recording 156 | ``` 157 | Stop recording and return the recording object. 158 | 159 | ### Wait Conditions 160 | 161 | #### OutputContains 162 | ```python 163 | class OutputContains(WaitCondition): 164 | def __init__(self, text: str, since_last_check: bool = False) 165 | ``` 166 | Wait for specific text in output. 167 | 168 | **Example:** 169 | ```python 170 | session.wait_until(OutputContains('Installation complete')) 171 | ``` 172 | 173 | #### OutputMatches 174 | ```python 175 | class OutputMatches(WaitCondition): 176 | def __init__(self, pattern: str, since_last_check: bool = False) 177 | ``` 178 | Wait for regex pattern in output. 179 | 180 | **Example:** 181 | ```python 182 | session.wait_until(OutputMatches(r'Progress: \d+%')) 183 | ``` 184 | 185 | #### PromptReady 186 | ```python 187 | class PromptReady(WaitCondition): 188 | def __init__(self, prompt: Optional[str] = None) 189 | ``` 190 | Wait for command prompt. 191 | 192 | **Example:** 193 | ```python 194 | session.wait_until(PromptReady('$ ')) 195 | ``` 196 | 197 | ### InteractiveShell 198 | 199 | Manages interactive shell sessions. 200 | 201 | #### Methods 202 | 203 | ##### send_line() 204 | ```python 205 | def send_line(text: str) -> None 206 | ``` 207 | Send a line of text with newline. 208 | 209 | ##### send_keys() 210 | ```python 211 | def send_keys(text: str) -> None 212 | ``` 213 | Send text without newline. 214 | 215 | ##### send_key() 216 | ```python 217 | def send_key(key: Union[str, Keys]) -> None 218 | ``` 219 | Send a special key. 220 | 221 | **Example:** 222 | ```python 223 | shell.send_key(Keys.ESCAPE) 224 | shell.send_key(Keys.CTRL_C) 225 | ``` 226 | 227 | ##### wait_for_text() 228 | ```python 229 | def wait_for_text( 230 | text: str, 231 | timeout: float = 10.0 232 | ) -> None 233 | ``` 234 | Wait for specific text to appear. 235 | 236 | ##### read_until() 237 | ```python 238 | def read_until( 239 | pattern: str, 240 | timeout: float = 10.0 241 | ) -> str 242 | ``` 243 | Read output until pattern is found. 244 | 245 | ### VirtualTerminal 246 | 247 | Provides access to the terminal screen buffer. 248 | 249 | #### Methods 250 | 251 | ##### get_screen_text() 252 | ```python 253 | def get_screen_text() -> str 254 | ``` 255 | Get the current screen content as text. 256 | 257 | ##### find_text() 258 | ```python 259 | def find_text( 260 | text: str, 261 | case_sensitive: bool = True 262 | ) -> Optional[Tuple[int, int]] 263 | ``` 264 | Find text on screen and return position. 265 | 266 | ##### get_cursor_position() 267 | ```python 268 | def get_cursor_position() -> Tuple[int, int] 269 | ``` 270 | Get current cursor position (row, col). 271 | 272 | ##### snapshot() 273 | ```python 274 | def snapshot() -> TerminalSnapshot 275 | ``` 276 | Take a snapshot of the terminal state. 277 | 278 | ### Parallel Execution 279 | 280 | #### ConnectionPool 281 | ```python 282 | class ConnectionPool: 283 | def __init__( 284 | self, 285 | hosts: List[str], 286 | username: str, 287 | password: Optional[str] = None, 288 | key_file: Optional[str] = None, 289 | max_connections: Optional[int] = None 290 | ) 291 | ``` 292 | 293 | ##### execute_on_all() 294 | ```python 295 | def execute_on_all( 296 | command: str, 297 | timeout: Optional[float] = None, 298 | strategy: str = 'parallel', 299 | batch_size: Optional[int] = None, 300 | progress_callback: Optional[Callable] = None 301 | ) -> Dict[str, CommandResult] 302 | ``` 303 | Execute command on all hosts. 304 | 305 | **Parameters:** 306 | - `command`: Command to execute 307 | - `timeout`: Command timeout 308 | - `strategy`: Execution strategy ('parallel', 'rolling', 'sequential') 309 | - `batch_size`: Batch size for rolling strategy 310 | - `progress_callback`: Called with (host, result) after each completion 311 | 312 | **Example:** 313 | ```python 314 | pool = ConnectionPool(['host1', 'host2'], username='user', key_file='~/.ssh/id_rsa') 315 | results = pool.execute_on_all('uptime', strategy='parallel') 316 | ``` 317 | 318 | ### Recording 319 | 320 | #### Recorder 321 | ```python 322 | class Recorder: 323 | def __init__(self, format: str = 'native') 324 | ``` 325 | 326 | ##### record_event() 327 | ```python 328 | def record_event( 329 | event_type: str, 330 | data: Any, 331 | timestamp: Optional[float] = None 332 | ) -> None 333 | ``` 334 | Record an event. 335 | 336 | #### SessionPlayer 337 | ```python 338 | class SessionPlayer: 339 | def __init__(self, filename: str) 340 | ``` 341 | 342 | ##### play() 343 | ```python 344 | def play( 345 | speed: float = 1.0, 346 | callback: Optional[Callable] = None 347 | ) -> None 348 | ``` 349 | Play back a recording. 350 | 351 | **Parameters:** 352 | - `speed`: Playback speed multiplier 353 | - `callback`: Called for each event 354 | 355 | ## Exceptions 356 | 357 | ### TermittyException 358 | Base exception for all Termitty errors. 359 | 360 | ### ConnectionError 361 | Raised when connection fails. 362 | 363 | ### CommandError 364 | Raised when command execution fails. 365 | 366 | ### TimeoutError 367 | Raised when operation times out. 368 | 369 | ### AuthenticationError 370 | Raised when authentication fails. 371 | 372 | ## Configuration 373 | 374 | ### Config 375 | ```python 376 | class Config: 377 | def __init__( 378 | self, 379 | default_timeout: float = 30.0, 380 | default_encoding: str = 'utf-8', 381 | ssh_config_file: Optional[str] = None, 382 | known_hosts_file: Optional[str] = None, 383 | terminal_size: Tuple[int, int] = (80, 24) 384 | ) 385 | ``` 386 | 387 | **Example:** 388 | ```python 389 | config = Config( 390 | default_timeout=60.0, 391 | terminal_size=(120, 40) 392 | ) 393 | session = TermittySession(config=config) 394 | ``` 395 | 396 | ## Special Keys 397 | 398 | Available in `termitty.interactive.Keys`: 399 | 400 | ```python 401 | Keys.ENTER 402 | Keys.ESCAPE 403 | Keys.TAB 404 | Keys.BACKSPACE 405 | Keys.DELETE 406 | Keys.UP 407 | Keys.DOWN 408 | Keys.LEFT 409 | Keys.RIGHT 410 | Keys.HOME 411 | Keys.END 412 | Keys.PAGE_UP 413 | Keys.PAGE_DOWN 414 | Keys.F1 through Keys.F12 415 | Keys.CTRL_A through Keys.CTRL_Z 416 | Keys.CTRL_C # Interrupt 417 | Keys.CTRL_D # EOF 418 | Keys.CTRL_L # Clear screen 419 | Keys.CTRL_Z # Suspend 420 | ``` 421 | 422 | ## Utilities 423 | 424 | ### Pattern Matching 425 | 426 | ```python 427 | from termitty.utils import compile_prompt_pattern 428 | 429 | # Match common shell prompts 430 | pattern = compile_prompt_pattern() 431 | ``` 432 | 433 | ### ANSI Code Handling 434 | 435 | ```python 436 | from termitty.utils import strip_ansi_codes 437 | 438 | clean_text = strip_ansi_codes(colored_text) 439 | ``` 440 | 441 | ## Examples 442 | 443 | ### Complete Example 444 | 445 | ```python 446 | from termitty import ( 447 | TermittySession, 448 | OutputContains, 449 | CommandError, 450 | Config 451 | ) 452 | 453 | # Configure session 454 | config = Config(default_timeout=60.0) 455 | 456 | try: 457 | with TermittySession(config=config) as session: 458 | # Connect 459 | session.connect( 460 | host='server.example.com', 461 | username='admin', 462 | key_file='~/.ssh/id_rsa' 463 | ) 464 | 465 | # Execute with error checking 466 | result = session.execute('systemctl status nginx', check=True) 467 | 468 | if 'active (running)' not in result.output: 469 | # Restart service 470 | session.execute('sudo systemctl restart nginx') 471 | session.wait_until(OutputContains('[sudo] password')) 472 | session.send_line('password', secure=True) 473 | 474 | # Wait for service to start 475 | session.wait_until( 476 | OutputContains('active (running)'), 477 | timeout=30 478 | ) 479 | 480 | # Record deployment 481 | session.start_recording('deployment.json') 482 | 483 | with session.cd('/opt/app'): 484 | session.execute('git pull') 485 | session.execute('docker-compose up -d') 486 | 487 | recording = session.stop_recording() 488 | print(f"Deployment completed in {recording.duration}s") 489 | 490 | except CommandError as e: 491 | print(f"Command failed: {e}") 492 | except Exception as e: 493 | print(f"Error: {e}") 494 | ``` -------------------------------------------------------------------------------- /docs/architecture.md: -------------------------------------------------------------------------------- 1 | # Termitty Architecture 2 | 3 | ## Overview 4 | 5 | Termitty is designed with a layered, modular architecture that provides clean separation of concerns and enables easy extensibility. 6 | 7 | ## Architecture Diagram 8 | 9 | ```mermaid 10 | graph TB 11 | subgraph "User API Layer" 12 | A[TermittySession] --> B[Wait Conditions] 13 | A --> C[Context Managers] 14 | A --> D[Recording API] 15 | end 16 | 17 | subgraph "Core Components" 18 | A --> E[Transport Layer] 19 | A --> F[Terminal Emulator] 20 | A --> G[Interactive Shell] 21 | A --> H[Parallel Executor] 22 | 23 | E --> I[Paramiko Transport] 24 | E --> J[AsyncSSH Transport] 25 | 26 | F --> K[ANSI Parser] 27 | F --> L[Screen Buffer] 28 | F --> M[Virtual Terminal] 29 | 30 | G --> N[IO Handler] 31 | G --> O[Pattern Matcher] 32 | G --> P[Key Codes] 33 | 34 | H --> Q[Connection Pool] 35 | H --> R[Execution Strategies] 36 | H --> S[Result Aggregator] 37 | end 38 | 39 | subgraph "Support Systems" 40 | A --> T[Recording System] 41 | T --> U[Recorder] 42 | T --> V[Player] 43 | T --> W[Format Handlers] 44 | 45 | A --> X[Config Manager] 46 | A --> Y[Exception Handler] 47 | end 48 | 49 | subgraph "External Systems" 50 | I --> Z[SSH Server] 51 | J --> Z 52 | end 53 | 54 | style A fill:#f96,stroke:#333,stroke-width:4px 55 | style E fill:#69f,stroke:#333,stroke-width:2px 56 | style F fill:#69f,stroke:#333,stroke-width:2px 57 | style G fill:#69f,stroke:#333,stroke-width:2px 58 | style H fill:#69f,stroke:#333,stroke-width:2px 59 | ``` 60 | 61 | ## Component Details 62 | 63 | ### 1. Session Layer (`session/`) 64 | 65 | The session layer provides the main user-facing API: 66 | 67 | ```mermaid 68 | classDiagram 69 | class TermittySession { 70 | +connect(host, username, password, key_file) 71 | +execute(command, timeout) 72 | +wait_until(condition, timeout) 73 | +send_line(text, secure) 74 | +interactive_shell() 75 | +start_recording() 76 | +stop_recording() 77 | } 78 | 79 | class WaitCondition { 80 | <> 81 | +check(session) bool 82 | } 83 | 84 | class OutputContains { 85 | +check(session) bool 86 | } 87 | 88 | class OutputMatches { 89 | +check(session) bool 90 | } 91 | 92 | class PromptReady { 93 | +check(session) bool 94 | } 95 | 96 | TermittySession --> WaitCondition 97 | WaitCondition <|-- OutputContains 98 | WaitCondition <|-- OutputMatches 99 | WaitCondition <|-- PromptReady 100 | ``` 101 | 102 | ### 2. Transport Layer (`transport/`) 103 | 104 | Handles SSH connections with pluggable backends: 105 | 106 | ```mermaid 107 | classDiagram 108 | class BaseTransport { 109 | <> 110 | +connect() 111 | +execute() 112 | +send() 113 | +receive() 114 | +close() 115 | } 116 | 117 | class ParamikoTransport { 118 | -client: SSHClient 119 | -channel: Channel 120 | +connect() 121 | +execute() 122 | } 123 | 124 | class AsyncSSHTransport { 125 | -connection: SSHConnection 126 | +connect() 127 | +execute() 128 | } 129 | 130 | BaseTransport <|-- ParamikoTransport 131 | BaseTransport <|-- AsyncSSHTransport 132 | ``` 133 | 134 | ### 3. Terminal Emulator (`terminal/`) 135 | 136 | Provides full terminal emulation capabilities: 137 | 138 | ```mermaid 139 | sequenceDiagram 140 | participant User 141 | participant VirtualTerminal 142 | participant ANSIParser 143 | participant ScreenBuffer 144 | 145 | User->>VirtualTerminal: feed(data) 146 | VirtualTerminal->>ANSIParser: parse(data) 147 | ANSIParser->>ANSIParser: extract sequences 148 | ANSIParser->>ScreenBuffer: apply_sequence() 149 | ScreenBuffer->>ScreenBuffer: update state 150 | VirtualTerminal->>User: get_screen_text() 151 | ``` 152 | 153 | ### 4. Interactive Shell (`interactive/`) 154 | 155 | Manages persistent shell sessions: 156 | 157 | ```mermaid 158 | stateDiagram-v2 159 | [*] --> Disconnected 160 | Disconnected --> Connecting: connect() 161 | Connecting --> Connected: success 162 | Connecting --> Disconnected: failure 163 | Connected --> ShellActive: start_shell() 164 | ShellActive --> Processing: send_command() 165 | Processing --> ShellActive: complete 166 | ShellActive --> Connected: exit_shell() 167 | Connected --> Disconnected: disconnect() 168 | ``` 169 | 170 | ### 5. Recording System (`recording/`) 171 | 172 | Records and replays terminal sessions: 173 | 174 | ```mermaid 175 | graph LR 176 | A[Session Events] --> B[Recorder] 177 | B --> C[Event Queue] 178 | C --> D[Format Handler] 179 | D --> E[Storage] 180 | 181 | F[Player] --> G[Storage] 182 | G --> H[Format Handler] 183 | H --> I[Event Stream] 184 | I --> J[Playback Engine] 185 | J --> K[Terminal Output] 186 | ``` 187 | 188 | ### 6. Parallel Execution (`parallel/`) 189 | 190 | Executes commands across multiple hosts: 191 | 192 | ```mermaid 193 | graph TB 194 | A[Command Request] --> B{Strategy} 195 | B -->|Parallel| C[All Hosts Simultaneously] 196 | B -->|Rolling| D[Batch by Batch] 197 | B -->|Sequential| E[One by One] 198 | 199 | C --> F[Connection Pool] 200 | D --> F 201 | E --> F 202 | 203 | F --> G[Execute on Host 1] 204 | F --> H[Execute on Host 2] 205 | F --> I[Execute on Host N] 206 | 207 | G --> J[Result Aggregator] 208 | H --> J 209 | I --> J 210 | 211 | J --> K[Combined Results] 212 | ``` 213 | 214 | ## Data Flow 215 | 216 | ### Command Execution Flow 217 | 218 | ```mermaid 219 | sequenceDiagram 220 | participant User 221 | participant Session 222 | participant Transport 223 | participant Terminal 224 | participant Server 225 | 226 | User->>Session: execute("ls -la") 227 | Session->>Transport: send_command("ls -la") 228 | Transport->>Server: SSH protocol 229 | Server-->>Transport: output stream 230 | Transport-->>Terminal: raw output 231 | Terminal->>Terminal: parse ANSI 232 | Terminal-->>Session: parsed output 233 | Session-->>User: CommandResult 234 | ``` 235 | 236 | ### Interactive Shell Flow 237 | 238 | ```mermaid 239 | sequenceDiagram 240 | participant User 241 | participant Shell 242 | participant IOHandler 243 | participant Transport 244 | 245 | User->>Shell: send_line("vim file.txt") 246 | Shell->>IOHandler: queue command 247 | IOHandler->>Transport: send data 248 | Transport-->>IOHandler: receive data 249 | IOHandler->>IOHandler: pattern match 250 | IOHandler-->>Shell: matched pattern 251 | Shell-->>User: ready for input 252 | ``` 253 | 254 | ## Design Patterns 255 | 256 | ### 1. Strategy Pattern 257 | Used in parallel execution for different execution strategies. 258 | 259 | ### 2. Observer Pattern 260 | Used in recording system and IO handlers for event notification. 261 | 262 | ### 3. Context Manager Pattern 263 | Used extensively for resource management and state preservation. 264 | 265 | ### 4. Factory Pattern 266 | Used for creating transport instances based on configuration. 267 | 268 | ### 5. Command Pattern 269 | Used in the recording system to capture and replay actions. 270 | 271 | ## Extension Points 272 | 273 | 1. **Custom Transports**: Implement `BaseTransport` for new connection types 274 | 2. **Wait Conditions**: Create custom conditions by extending `WaitCondition` 275 | 3. **Recording Formats**: Add new formats by implementing format handlers 276 | 4. **Execution Strategies**: Create custom parallel execution strategies 277 | 278 | ## Performance Considerations 279 | 280 | 1. **Connection Pooling**: Reuse SSH connections for multiple operations 281 | 2. **Async Support**: AsyncSSH transport for high-concurrency scenarios 282 | 3. **Buffering**: Efficient screen buffer management for large outputs 283 | 4. **Lazy Loading**: Terminal emulator only processes visible content 284 | 285 | ## Security Considerations 286 | 287 | 1. **Credential Management**: Secure handling of passwords and keys 288 | 2. **Input Sanitization**: Proper escaping of shell commands 289 | 3. **Secure Recording**: Sensitive data masking in recordings 290 | 4. **Transport Security**: Full SSH protocol compliance -------------------------------------------------------------------------------- /docs/comparison.md: -------------------------------------------------------------------------------- 1 | # Termitty vs. Existing Solutions 2 | 3 | A comprehensive comparison of Termitty with existing SSH automation and terminal interaction libraries. 4 | 5 | ## 📊 Quick Comparison Table 6 | 7 | | Feature | Termitty | Fabric | Paramiko | Pexpect | Ansible | 8 | |---------|----------|---------|-----------|---------|---------| 9 | | **Selenium-like API** | ✅ | ❌ | ❌ | ❌ | ❌ | 10 | | **Terminal Emulation** | ✅ Full ANSI | ❌ | ❌ Basic | ✅ Screen scraping | ❌ | 11 | | **Wait Conditions** | ✅ Smart waits | ❌ | ❌ | ⚠️ Basic | ❌ | 12 | | **Interactive Shell** | ✅ Stateful | ⚠️ Limited | ⚠️ Low-level | ✅ Good | ❌ | 13 | | **Session Recording** | ✅ Native | ❌ | ❌ | ❌ | ❌ | 14 | | **Parallel Execution** | ✅ Built-in | ✅ Good | ❌ | ❌ | ✅ Excellent | 15 | | **Context Managers** | ✅ cd, env | ✅ cd only | ❌ | ❌ | ❌ | 16 | | **AI-Friendly** | ✅ Designed for | ❌ | ❌ | ❌ | ⚠️ YAML only | 17 | | **Learning Curve** | Easy | Moderate | Hard | Moderate | Hard | 18 | | **API Style** | Object-oriented | Decorators | Low-level | Procedural | YAML | 19 | 20 | ## 🔍 Detailed Comparisons 21 | 22 | ### Termitty vs. Fabric 23 | 24 | **Fabric** is a high-level Python library for streamlining SSH usage. 25 | 26 | #### Fabric Strengths: 27 | - Well-established and mature 28 | - Good for deployment scripts 29 | - Nice decorator-based API 30 | - Supports connection multiplexing 31 | 32 | #### Fabric Weaknesses: 33 | - No terminal emulation 34 | - No wait conditions 35 | - Limited interactive support 36 | - No session recording 37 | - Can't handle complex terminal UIs 38 | 39 | #### When to Choose Termitty: 40 | ```python 41 | # Termitty - Handle interactive prompts elegantly 42 | session.execute('sudo apt update') 43 | session.wait_until(OutputContains('[sudo] password')) 44 | session.send_line(password, secure=True) 45 | 46 | # Fabric - Requires workarounds 47 | sudo('apt update', password=password) # Less flexible 48 | ``` 49 | 50 | ### Termitty vs. Paramiko 51 | 52 | **Paramiko** is a low-level SSH protocol implementation. 53 | 54 | #### Paramiko Strengths: 55 | - Full SSH protocol support 56 | - Very flexible 57 | - Can build custom SSH servers 58 | - Direct channel access 59 | 60 | #### Paramiko Weaknesses: 61 | - Very low-level 62 | - No built-in terminal emulation 63 | - Complex API 64 | - Lots of boilerplate code 65 | - No high-level abstractions 66 | 67 | #### When to Choose Termitty: 68 | ```python 69 | # Termitty - Simple and intuitive 70 | with TermittySession() as session: 71 | session.connect('server', username='user', key_file='key') 72 | result = session.execute('ls -la') 73 | print(result.output) 74 | 75 | # Paramiko - Verbose and complex 76 | client = SSHClient() 77 | client.set_missing_host_key_policy(AutoAddPolicy()) 78 | client.connect('server', username='user', key_filename='key') 79 | stdin, stdout, stderr = client.exec_command('ls -la') 80 | output = stdout.read().decode() 81 | print(output) 82 | client.close() 83 | ``` 84 | 85 | ### Termitty vs. Pexpect 86 | 87 | **Pexpect** is a Python module for spawning child applications and controlling them. 88 | 89 | #### Pexpect Strengths: 90 | - Good for interactive applications 91 | - Works with any command-line program 92 | - Pattern matching capabilities 93 | - Screen scraping support 94 | 95 | #### Pexpect Weaknesses: 96 | - Not SSH-specific 97 | - Clunky API 98 | - No structured output 99 | - Limited to local subprocess 100 | - No built-in session management 101 | 102 | #### When to Choose Termitty: 103 | ```python 104 | # Termitty - Modern API with structured results 105 | session.wait_until(OutputMatches(r'Installation (\d+)% complete')) 106 | menu_items = session.terminal.find_menu_items() 107 | session.terminal.select_menu_item('Advanced Options') 108 | 109 | # Pexpect - String-based pattern matching only 110 | child.expect(r'Installation (\d+)% complete') 111 | percentage = child.match.group(1) 112 | # No easy way to interact with menus 113 | ``` 114 | 115 | ### Termitty vs. Ansible 116 | 117 | **Ansible** is an IT automation platform. 118 | 119 | #### Ansible Strengths: 120 | - Excellent for configuration management 121 | - Huge module library 122 | - Great for infrastructure as code 123 | - Idempotent operations 124 | - Large community 125 | 126 | #### Ansible Weaknesses: 127 | - YAML-based, not programmatic 128 | - Overkill for simple automation 129 | - No real-time interaction 130 | - Limited debugging capabilities 131 | - Not suitable for dynamic scenarios 132 | 133 | #### When to Choose Termitty: 134 | ```python 135 | # Termitty - Dynamic, programmatic control 136 | if session.execute('df -h /').output.split()[11].rstrip('%') > '90': 137 | # Dynamic decision making 138 | with session.interactive_shell() as shell: 139 | shell.send_line('ncdu /') 140 | # Interactively clean up disk space 141 | 142 | # Ansible - Static playbooks 143 | # - name: Check disk space 144 | # command: df -h / 145 | # register: disk_usage 146 | # No easy way to handle dynamic scenarios 147 | ``` 148 | 149 | ## 🎯 Unique Termitty Features 150 | 151 | ### 1. **Selenium-Inspired API** 152 | No other SSH library provides WebDriver-like patterns: 153 | ```python 154 | # Wait conditions like Selenium 155 | session.wait_until(OutputContains('Ready')) 156 | session.wait_until(PromptReady()) 157 | 158 | # Expected conditions 159 | wait = WebDriverWait(session, timeout=30) 160 | wait.until(lambda s: 'Complete' in s.get_output()) 161 | ``` 162 | 163 | ### 2. **Full Terminal Emulation** 164 | ```python 165 | # See exactly what's on screen 166 | terminal = session.terminal 167 | screen = terminal.get_screen_text() 168 | 169 | # Find and interact with UI elements 170 | if terminal.find_text('Continue? [Y/n]'): 171 | session.send_line('Y') 172 | 173 | # Navigate menus programmatically 174 | menu_items = terminal.find_menu_items() 175 | ``` 176 | 177 | ### 3. **Session Recording & Playback** 178 | ```python 179 | # Record for debugging or training 180 | session.start_recording('deployment.json') 181 | # ... perform actions ... 182 | recording = session.stop_recording() 183 | 184 | # Playback later 185 | player = Player('deployment.json') 186 | player.play(speed=2.0) 187 | ``` 188 | 189 | ### 4. **Context-Aware Execution** 190 | ```python 191 | # Maintains state across commands 192 | with session.cd('/app'), session.env(NODE_ENV='production'): 193 | session.execute('npm install') # Runs in /app with NODE_ENV set 194 | session.execute('npm start') # Environment preserved 195 | ``` 196 | 197 | ### 5. **AI-Ready Design** 198 | ```python 199 | # Structured output for AI consumption 200 | state = session.terminal.get_structured_state() 201 | { 202 | "screen": {...}, 203 | "detected_elements": [...], 204 | "context": {...} 205 | } 206 | 207 | # Perfect for LLM integration 208 | ai_response = llm.analyze(state) 209 | session.execute(ai_response.suggested_command) 210 | ``` 211 | 212 | ## 📈 Performance Comparison 213 | 214 | | Operation | Termitty | Fabric | Paramiko | Pexpect | 215 | |-----------|----------|---------|-----------|---------| 216 | | Connection Setup | ~1.2s | ~1.5s | ~1.0s | N/A | 217 | | Simple Command | ~0.1s | ~0.15s | ~0.08s | ~0.2s | 218 | | Interactive Session | Excellent | Poor | Manual | Good | 219 | | Parallel 10 hosts | ~1.5s | ~2.0s | Manual | N/A | 220 | | Memory Usage | ~25MB | ~30MB | ~20MB | ~15MB | 221 | 222 | ## 🤔 When to Use Each Tool 223 | 224 | ### Use **Termitty** when you need: 225 | - ✅ Selenium-like automation patterns 226 | - ✅ Complex interactive sessions 227 | - ✅ Terminal UI automation 228 | - ✅ AI/LLM integration 229 | - ✅ Session recording/playback 230 | - ✅ Modern, intuitive API 231 | 232 | ### Use **Fabric** when you need: 233 | - ✅ Simple deployment scripts 234 | - ✅ Task-based automation 235 | - ✅ Established tool with large community 236 | - ❌ But not for complex interactions 237 | 238 | ### Use **Paramiko** when you need: 239 | - ✅ Low-level SSH protocol access 240 | - ✅ Custom SSH server implementation 241 | - ✅ Fine-grained control 242 | - ❌ But expect more complexity 243 | 244 | ### Use **Pexpect** when you need: 245 | - ✅ Local process automation 246 | - ✅ Non-SSH terminal programs 247 | - ❌ But want simpler API use Termitty 248 | 249 | ### Use **Ansible** when you need: 250 | - ✅ Infrastructure as Code 251 | - ✅ Configuration management 252 | - ✅ Large-scale orchestration 253 | - ❌ But not for dynamic/interactive tasks 254 | 255 | ## 💡 Migration Guide 256 | 257 | ### From Fabric to Termitty 258 | ```python 259 | # Fabric 260 | from fabric import Connection 261 | c = Connection('host') 262 | result = c.run('ls -la') 263 | 264 | # Termitty 265 | from termitty import TermittySession 266 | session = TermittySession() 267 | session.connect('host') 268 | result = session.execute('ls -la') 269 | ``` 270 | 271 | ### From Paramiko to Termitty 272 | ```python 273 | # Paramiko 274 | client = SSHClient() 275 | client.connect('host', username='user') 276 | stdin, stdout, stderr = client.exec_command('ls') 277 | 278 | # Termitty 279 | session = TermittySession() 280 | session.connect('host', username='user') 281 | result = session.execute('ls') 282 | ``` 283 | 284 | ### From Pexpect to Termitty 285 | ```python 286 | # Pexpect 287 | child = pxssh.pxssh() 288 | child.login('host', 'user') 289 | child.sendline('ls') 290 | child.expect(pxssh.PROMPT) 291 | 292 | # Termitty 293 | session = TermittySession() 294 | session.connect('host', username='user') 295 | result = session.execute('ls') 296 | ``` 297 | 298 | ## 🚀 Conclusion 299 | 300 | Termitty fills a unique niche in the Python SSH automation ecosystem: 301 | 302 | 1. **For developers who love Selenium**: Finally, the same elegant patterns for terminal automation 303 | 2. **For AI/LLM applications**: Purpose-built for the next generation of automated agents 304 | 3. **For complex automations**: When Fabric isn't enough and Paramiko is too low-level 305 | 4. **For interactive applications**: Superior handling of prompts, menus, and terminal UIs 306 | 307 | Choose Termitty when you want the **most modern, intuitive, and capable** terminal automation library available. -------------------------------------------------------------------------------- /docs/getting_started.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Termitty 2 | 3 | Welcome to Termitty! This guide will help you get up and running with terminal automation in minutes. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | pip install termitty 9 | ``` 10 | 11 | For development: 12 | ```bash 13 | git clone https://github.com/yourusername/termitty.git 14 | cd termitty 15 | pip install -e ".[dev]" 16 | ``` 17 | 18 | ## Basic Concepts 19 | 20 | Termitty follows a familiar pattern if you've used Selenium: 21 | 22 | 1. **Session**: Like a WebDriver, manages your connection 23 | 2. **Commands**: Execute commands and get results 24 | 3. **Waits**: Wait for specific conditions 25 | 4. **Terminal**: Access the virtual terminal display 26 | 27 | ## Your First Script 28 | 29 | ### 1. Simple Command Execution 30 | 31 | ```python 32 | from termitty import TermittySession 33 | 34 | # Create a session 35 | session = TermittySession() 36 | 37 | # Connect to a server 38 | session.connect( 39 | host='example.com', 40 | username='myuser', 41 | password='mypassword' # or use key_file='~/.ssh/id_rsa' 42 | ) 43 | 44 | # Execute a command 45 | result = session.execute('echo "Hello, Termitty!"') 46 | print(result.output) # Hello, Termitty! 47 | print(result.exit_code) # 0 48 | 49 | # Close the session 50 | session.close() 51 | ``` 52 | 53 | ### 2. Using Context Managers 54 | 55 | ```python 56 | from termitty import TermittySession 57 | 58 | with TermittySession() as session: 59 | session.connect('example.com', username='myuser', key_file='~/.ssh/id_rsa') 60 | 61 | # Change directory temporarily 62 | with session.cd('/var/log'): 63 | result = session.execute('pwd') 64 | print(result.output) # /var/log 65 | 66 | # Back to home directory 67 | result = session.execute('pwd') 68 | print(result.output) # /home/myuser 69 | ``` 70 | 71 | ### 3. Waiting for Conditions 72 | 73 | ```python 74 | from termitty import TermittySession, OutputContains, PromptReady 75 | 76 | with TermittySession() as session: 77 | session.connect('example.com', username='myuser', key_file='~/.ssh/id_rsa') 78 | 79 | # Start a long-running process 80 | session.execute('./deploy.sh', wait=False) 81 | 82 | # Wait for specific output 83 | session.wait_until(OutputContains('Deployment complete'), timeout=300) 84 | 85 | # Wait for prompt to be ready 86 | session.wait_until(PromptReady('$ ')) 87 | ``` 88 | 89 | ### 4. Interactive Applications 90 | 91 | ```python 92 | from termitty import TermittySession, Keys 93 | 94 | with TermittySession() as session: 95 | session.connect('example.com', username='myuser', key_file='~/.ssh/id_rsa') 96 | 97 | # Start an interactive shell 98 | with session.interactive_shell() as shell: 99 | # Edit a file with vim 100 | shell.send_line('vim config.json') 101 | shell.wait_for_text('~') # Wait for vim to open 102 | 103 | # Enter insert mode and add content 104 | shell.send_key('i') 105 | shell.send_keys('{"enabled": true}') 106 | 107 | # Save and exit 108 | shell.send_key(Keys.ESCAPE) 109 | shell.send_line(':wq') 110 | ``` 111 | 112 | ### 5. Terminal UI Navigation 113 | 114 | ```python 115 | from termitty import TermittySession 116 | 117 | with TermittySession() as session: 118 | session.connect('example.com', username='myuser', key_file='~/.ssh/id_rsa') 119 | 120 | # Run a menu-based application 121 | session.execute('./installer.sh') 122 | 123 | # Access the terminal screen through session state 124 | terminal = session.state.terminal 125 | 126 | # Find and select menu items 127 | if terminal.find_text('1. Install'): 128 | session.send_line('1') 129 | 130 | # Get current screen content 131 | screen = terminal.get_screen_text() 132 | print(screen) 133 | ``` 134 | 135 | ## Advanced Features 136 | 137 | ### Session Recording 138 | 139 | ```python 140 | from termitty import TermittySession 141 | from termitty.recording import Player 142 | import time 143 | 144 | # Record a session 145 | with TermittySession() as session: 146 | session.connect('example.com', username='myuser', key_file='~/.ssh/id_rsa') 147 | 148 | # Start recording 149 | session.start_recording('demo.json') 150 | 151 | # Run commands - these will be recorded 152 | session.execute('uname -a') 153 | time.sleep(1) # Pause for visual effect 154 | 155 | session.execute('ls -la') 156 | time.sleep(1) 157 | 158 | session.execute('df -h') 159 | 160 | # Stop recording 161 | recording = session.stop_recording() 162 | print(f"Recording saved: {recording.duration} seconds") 163 | 164 | # Playback the recording 165 | from termitty.recording import SessionPlayer 166 | 167 | player = SessionPlayer('demo.json') 168 | player.play(speed=1.0) # Normal speed 169 | # player.play(speed=2.0) # 2x speed 170 | # player.play(speed=0.5) # Half speed 171 | ``` 172 | 173 | ### Parallel Execution 174 | 175 | ```python 176 | from termitty.parallel import ConnectionPool 177 | 178 | # Define your hosts 179 | hosts = ['web1.example.com', 'web2.example.com', 'web3.example.com'] 180 | 181 | # Create a connection pool 182 | pool = ConnectionPool( 183 | hosts=hosts, 184 | username='deploy', 185 | key_file='~/.ssh/deploy_key' 186 | ) 187 | 188 | # Execute on all hosts 189 | results = pool.execute_on_all('sudo systemctl restart nginx') 190 | 191 | # Check results 192 | for host, result in results.items(): 193 | print(f"{host}: {result.exit_code}") 194 | if result.failed: 195 | print(f" Error: {result.error}") 196 | ``` 197 | 198 | ### Custom Wait Conditions 199 | 200 | ```python 201 | from termitty import TermittySession, WaitCondition 202 | 203 | class CpuLoadLow(WaitCondition): 204 | def __init__(self, threshold=50): 205 | self.threshold = threshold 206 | 207 | def check(self, session): 208 | result = session.execute("top -bn1 | grep 'Cpu(s)' | awk '{print $2}'") 209 | cpu_usage = float(result.output.strip().rstrip('%')) 210 | return cpu_usage < self.threshold 211 | 212 | with TermittySession() as session: 213 | session.connect('example.com', username='myuser', key_file='~/.ssh/id_rsa') 214 | 215 | # Wait for CPU load to drop 216 | session.wait_until(CpuLoadLow(threshold=30), timeout=60) 217 | 218 | # Now safe to run intensive task 219 | session.execute('./heavy_computation.sh') 220 | ``` 221 | 222 | ## Best Practices 223 | 224 | 1. **Always use context managers** for automatic cleanup 225 | 2. **Set appropriate timeouts** for long-running commands 226 | 3. **Use wait conditions** instead of sleep() for reliability 227 | 4. **Handle exceptions** gracefully 228 | 5. **Use secure=True** for sensitive input 229 | 230 | ```python 231 | from termitty import TermittySession, TermittyException 232 | 233 | try: 234 | with TermittySession() as session: 235 | session.connect('example.com', username='myuser', key_file='~/.ssh/id_rsa') 236 | 237 | # Secure password input 238 | session.execute('sudo apt update') 239 | session.wait_until(OutputContains('[sudo] password')) 240 | session.send_line('mysecretpassword', secure=True) 241 | 242 | except TermittyException as e: 243 | print(f"Automation failed: {e}") 244 | ``` 245 | 246 | ## Common Patterns 247 | 248 | ### Running Commands with Sudo 249 | 250 | ```python 251 | def run_with_sudo(session, command, password): 252 | """Run a command with sudo, handling password prompt""" 253 | session.execute(f'sudo {command}', wait=False) 254 | 255 | try: 256 | session.wait_until(OutputContains('[sudo] password'), timeout=5) 257 | session.send_line(password, secure=True) 258 | except TimeoutError: 259 | # Command might not need password (sudoers NOPASSWD) 260 | pass 261 | 262 | # Wait for command to complete 263 | session.wait_until(PromptReady()) 264 | ``` 265 | 266 | ### Checking Service Status 267 | 268 | ```python 269 | def check_service_status(session, service_name): 270 | """Check if a systemd service is running""" 271 | result = session.execute(f'systemctl is-active {service_name}') 272 | return result.output.strip() == 'active' 273 | 274 | # Usage 275 | if not check_service_status(session, 'nginx'): 276 | session.execute('sudo systemctl start nginx') 277 | ``` 278 | 279 | ### Safe File Editing 280 | 281 | ```python 282 | def safe_edit_config(session, file_path, content): 283 | """Safely edit a configuration file with backup""" 284 | # Create backup 285 | timestamp = session.execute('date +%Y%m%d_%H%M%S').output.strip() 286 | session.execute(f'cp {file_path} {file_path}.backup.{timestamp}') 287 | 288 | # Write new content 289 | session.execute(f"echo '{content}' | sudo tee {file_path}") 290 | 291 | # Verify 292 | result = session.execute(f'cat {file_path}') 293 | if content not in result.output: 294 | # Restore backup 295 | session.execute(f'sudo mv {file_path}.backup.{timestamp} {file_path}') 296 | raise Exception("File edit verification failed") 297 | ``` 298 | 299 | ## Next Steps 300 | 301 | - Explore the [API Reference](api_reference.md) 302 | - Check out more [Examples](../examples/) 303 | - Learn about [Architecture](architecture.md) 304 | - Read [Best Practices](best_practices.md) 305 | 306 | Happy automating! 🚀 -------------------------------------------------------------------------------- /docs/publishing_guide.md: -------------------------------------------------------------------------------- 1 | # Publishing Termitty to PyPI 2 | 3 | This guide walks you through publishing Termitty to the Python Package Index (PyPI). 4 | 5 | ## Prerequisites 6 | 7 | 1. **PyPI Account**: Create accounts at: 8 | - [PyPI](https://pypi.org/account/register/) (production) 9 | - [TestPyPI](https://test.pypi.org/account/register/) (testing) 10 | 11 | 2. **Install Tools**: 12 | ```bash 13 | pip install --upgrade pip setuptools wheel twine build 14 | ``` 15 | 16 | 3. **API Token**: Generate API tokens for secure upload: 17 | - Go to PyPI → Account Settings → API tokens 18 | - Create a token scoped to your project 19 | 20 | ## Pre-Publishing Checklist 21 | 22 | ### 1. Update Version Number 23 | 24 | Edit `pyproject.toml`: 25 | ```toml 26 | [project] 27 | version = "0.1.0" # Update this 28 | ``` 29 | 30 | Follow [Semantic Versioning](https://semver.org/): 31 | - MAJOR.MINOR.PATCH (e.g., 1.2.3) 32 | - MAJOR: Breaking changes 33 | - MINOR: New features 34 | - PATCH: Bug fixes 35 | 36 | ### 2. Update Metadata 37 | 38 | Ensure `pyproject.toml` has complete information: 39 | ```toml 40 | [project] 41 | name = "termitty" 42 | authors = [ 43 | {name = "Your Name", email = "your.email@example.com"}, 44 | ] 45 | description = "A Selenium-inspired Python framework for terminal and SSH automation" 46 | readme = "README.md" 47 | license = {text = "MIT"} 48 | keywords = ["ssh", "terminal", "automation", "devops"] 49 | classifiers = [ 50 | "Development Status :: 4 - Beta", 51 | "Intended Audience :: Developers", 52 | "License :: OSI Approved :: MIT License", 53 | "Programming Language :: Python :: 3.8", 54 | # ... add all supported versions 55 | ] 56 | ``` 57 | 58 | ### 3. Clean Up Files 59 | 60 | ```bash 61 | # Remove build artifacts 62 | rm -rf build/ dist/ *.egg-info/ 63 | find . -type d -name __pycache__ -exec rm -rf {} + 64 | find . -type f -name "*.pyc" -delete 65 | ``` 66 | 67 | ### 4. Run Tests 68 | 69 | ```bash 70 | # Run all tests 71 | pytest 72 | 73 | # Check coverage 74 | pytest --cov=termitty --cov-report=html 75 | 76 | # Run linting 77 | black termitty tests 78 | isort termitty tests 79 | flake8 termitty tests 80 | mypy termitty 81 | ``` 82 | 83 | ### 5. Update Documentation 84 | 85 | - Update README.md 86 | - Update CHANGELOG.md 87 | - Ensure all examples work 88 | - Review docstrings 89 | 90 | ## Building the Package 91 | 92 | ### 1. Build Distribution Files 93 | 94 | ```bash 95 | # Build both source distribution and wheel 96 | python -m build 97 | 98 | # This creates: 99 | # - dist/termitty-0.1.0.tar.gz (source distribution) 100 | # - dist/termitty-0.1.0-py3-none-any.whl (wheel) 101 | ``` 102 | 103 | ### 2. Verify the Build 104 | 105 | ```bash 106 | # Check the contents 107 | tar -tzf dist/termitty-*.tar.gz 108 | 109 | # Check for common issues 110 | twine check dist/* 111 | ``` 112 | 113 | ### 3. Test Installation Locally 114 | 115 | ```bash 116 | # Create a test environment 117 | python -m venv test_env 118 | source test_env/bin/activate # On Windows: test_env\Scripts\activate 119 | 120 | # Install from wheel 121 | pip install dist/termitty-*.whl 122 | 123 | # Test import 124 | python -c "import termitty; print(termitty.__version__)" 125 | ``` 126 | 127 | ## Publishing to TestPyPI (Recommended First) 128 | 129 | ### 1. Configure .pypirc 130 | 131 | Create `~/.pypirc`: 132 | ```ini 133 | [distutils] 134 | index-servers = 135 | pypi 136 | testpypi 137 | 138 | [pypi] 139 | repository = https://upload.pypi.org/legacy/ 140 | username = __token__ 141 | password = pypi-YOUR_TOKEN_HERE 142 | 143 | [testpypi] 144 | repository = https://test.pypi.org/legacy/ 145 | username = __token__ 146 | password = pypi-YOUR_TEST_TOKEN_HERE 147 | ``` 148 | 149 | **Security Note**: Set file permissions: 150 | ```bash 151 | chmod 600 ~/.pypirc 152 | ``` 153 | 154 | ### 2. Upload to TestPyPI 155 | 156 | ```bash 157 | twine upload --repository testpypi dist/* 158 | ``` 159 | 160 | ### 3. Test Installation from TestPyPI 161 | 162 | ```bash 163 | pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ termitty 164 | ``` 165 | 166 | ## Publishing to PyPI 167 | 168 | ### 1. Final Checks 169 | 170 | - ✅ Version number is correct 171 | - ✅ All tests pass 172 | - ✅ Documentation is updated 173 | - ✅ CHANGELOG.md is updated 174 | - ✅ Successfully tested on TestPyPI 175 | 176 | ### 2. Upload to PyPI 177 | 178 | ```bash 179 | twine upload dist/* 180 | ``` 181 | 182 | ### 3. Verify Installation 183 | 184 | ```bash 185 | # In a new environment 186 | pip install termitty 187 | 188 | # Verify 189 | python -c "import termitty; print(termitty.__version__)" 190 | ``` 191 | 192 | ## Post-Publishing 193 | 194 | ### 1. Create Git Tag 195 | 196 | ```bash 197 | git tag -a v0.1.0 -m "Release version 0.1.0" 198 | git push origin v0.1.0 199 | ``` 200 | 201 | ### 2. Create GitHub Release 202 | 203 | 1. Go to GitHub → Releases → Create new release 204 | 2. Choose the tag you just created 205 | 3. Add release notes from CHANGELOG.md 206 | 4. Attach the wheel and source distribution files 207 | 208 | ### 3. Update Documentation 209 | 210 | - Update installation instructions 211 | - Update version in documentation 212 | - Announce on social media/forums 213 | 214 | ## Automation with GitHub Actions 215 | 216 | Create `.github/workflows/publish.yml`: 217 | 218 | ```yaml 219 | name: Publish to PyPI 220 | 221 | on: 222 | release: 223 | types: [published] 224 | 225 | jobs: 226 | publish: 227 | runs-on: ubuntu-latest 228 | steps: 229 | - uses: actions/checkout@v3 230 | 231 | - name: Set up Python 232 | uses: actions/setup-python@v4 233 | with: 234 | python-version: '3.11' 235 | 236 | - name: Install dependencies 237 | run: | 238 | python -m pip install --upgrade pip 239 | pip install build twine 240 | 241 | - name: Build package 242 | run: python -m build 243 | 244 | - name: Publish to PyPI 245 | env: 246 | TWINE_USERNAME: __token__ 247 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 248 | run: twine upload dist/* 249 | ``` 250 | 251 | ## Managing Releases 252 | 253 | ### Version Bumping Script 254 | 255 | Create `scripts/bump_version.py`: 256 | 257 | ```python 258 | #!/usr/bin/env python3 259 | import sys 260 | import re 261 | from pathlib import Path 262 | 263 | def bump_version(version_type): 264 | pyproject = Path('pyproject.toml') 265 | content = pyproject.read_text() 266 | 267 | # Find current version 268 | match = re.search(r'version = "(\d+)\.(\d+)\.(\d+)"', content) 269 | if not match: 270 | print("Version not found") 271 | return 272 | 273 | major, minor, patch = map(int, match.groups()) 274 | 275 | # Bump version 276 | if version_type == 'major': 277 | major += 1 278 | minor = 0 279 | patch = 0 280 | elif version_type == 'minor': 281 | minor += 1 282 | patch = 0 283 | elif version_type == 'patch': 284 | patch += 1 285 | 286 | new_version = f"{major}.{minor}.{patch}" 287 | 288 | # Update file 289 | new_content = re.sub( 290 | r'version = "\d+\.\d+\.\d+"', 291 | f'version = "{new_version}"', 292 | content 293 | ) 294 | 295 | pyproject.write_text(new_content) 296 | print(f"Version bumped to {new_version}") 297 | 298 | if __name__ == "__main__": 299 | if len(sys.argv) != 2 or sys.argv[1] not in ['major', 'minor', 'patch']: 300 | print("Usage: bump_version.py [major|minor|patch]") 301 | sys.exit(1) 302 | 303 | bump_version(sys.argv[1]) 304 | ``` 305 | 306 | ### Release Checklist Template 307 | 308 | Create `RELEASE_CHECKLIST.md`: 309 | 310 | ```markdown 311 | # Release Checklist for v0.0.0 312 | 313 | - [ ] All tests pass locally 314 | - [ ] Documentation is updated 315 | - [ ] CHANGELOG.md is updated 316 | - [ ] Version number is bumped 317 | - [ ] Create git tag 318 | - [ ] Build distribution files 319 | - [ ] Test on TestPyPI 320 | - [ ] Publish to PyPI 321 | - [ ] Create GitHub release 322 | - [ ] Update website/docs 323 | - [ ] Announce release 324 | ``` 325 | 326 | ## Troubleshooting 327 | 328 | ### Common Issues 329 | 330 | 1. **"error: invalid command 'bdist_wheel'"** 331 | ```bash 332 | pip install wheel 333 | ``` 334 | 335 | 2. **"HTTPError: 403 Forbidden"** 336 | - Check your API token 337 | - Ensure you have permissions 338 | - Token might be scoped incorrectly 339 | 340 | 3. **"Package already exists"** 341 | - Version already published 342 | - Can't overwrite existing versions 343 | - Bump version number 344 | 345 | 4. **Missing files in package** 346 | - Check MANIFEST.in 347 | - Verify setuptools configuration 348 | 349 | ### Security Best Practices 350 | 351 | 1. **Never commit tokens** to version control 352 | 2. **Use API tokens** instead of passwords 353 | 3. **Scope tokens** to specific projects 354 | 4. **Rotate tokens** regularly 355 | 5. **Use 2FA** on PyPI account 356 | 357 | ## Maintaining Your Package 358 | 359 | 1. **Monitor Issues**: Check PyPI and GitHub regularly 360 | 2. **Update Dependencies**: Keep requirements current 361 | 3. **Security Updates**: Address vulnerabilities quickly 362 | 4. **Semantic Versioning**: Be consistent 363 | 5. **Deprecation Policy**: Give users time to migrate 364 | 365 | ## Additional Resources 366 | 367 | - [Python Packaging Guide](https://packaging.python.org/) 368 | - [PyPI Help](https://pypi.org/help/) 369 | - [Setuptools Documentation](https://setuptools.pypa.io/) 370 | - [Twine Documentation](https://twine.readthedocs.io/) -------------------------------------------------------------------------------- /docs/terminal_player.md: -------------------------------------------------------------------------------- 1 | # 🎬 Terminal Recording Player 2 | 3 | ## Overview 4 | 5 | The Termitty Terminal Player provides a **world-class, professional interface** for playing back terminal session recordings. It transforms simple JSON recordings into a beautiful, interactive terminal experience. 6 | 7 | ## Installation 8 | 9 | ```bash 10 | # Install Termitty with UI support 11 | pip install termitty[ui] 12 | 13 | # Or install rich separately 14 | pip install termitty rich 15 | ``` 16 | 17 | ## Quick Start 18 | 19 | ### Recording a Session 20 | 21 | ```python 22 | from termitty import TermittySession 23 | 24 | with TermittySession() as session: 25 | session.connect('server.com', username='user', password='pass') 26 | 27 | # Start recording 28 | session.start_recording('my_session.json') 29 | 30 | # Execute commands 31 | session.execute('echo "Hello World!"') 32 | session.execute('date') 33 | session.execute('ls -la') 34 | 35 | # Add markers for navigation 36 | session.add_recording_marker('important', {'note': 'Key moment'}) 37 | session.execute('echo "This is important!"') 38 | 39 | # Stop recording 40 | recording = session.stop_recording() 41 | print(f"Recording saved: {recording.duration():.2f}s") 42 | ``` 43 | 44 | ### Playing Back with the UI 45 | 46 | ```bash 47 | # Basic playback 48 | python docs/terminal_player_ui.py my_session.json 49 | 50 | # With speed control 51 | python docs/terminal_player_ui.py my_session.json --speed 2.0 52 | 53 | # Available speeds: 0.25, 0.5, 1.0, 2.0, 4.0 54 | ``` 55 | 56 | ## Features 57 | 58 | ### 🎨 Professional Interface 59 | - **Multi-panel Layout**: Header, terminal, controls, progress, and markers 60 | - **Rich Terminal UI**: Built with the `rich` library for stunning visuals 61 | - **Color-coded Output**: Intelligent syntax highlighting and categorization 62 | - **Professional Styling**: Rounded borders, proper spacing, and visual hierarchy 63 | 64 | ### 📊 Real-time Display 65 | - **Auto-scrolling Terminal**: Shows latest commands while maintaining history 66 | - **Live Progress Tracking**: Real-time progress bars and percentage indicators 67 | - **Timestamp Display**: Every command and output line shows execution time 68 | - **Line Count Indicators**: Shows current buffer size and total lines 69 | 70 | ### ⚡ Playback Controls 71 | - **Variable Speeds**: 0.25×, 0.5×, 1.0×, 2.0×, 4.0× playback rates 72 | - **State Indicators**: Visual display of Playing/Paused/Stopped status 73 | - **Smart Buffering**: Maintains optimal viewing with auto-scroll 74 | - **Progress Bars**: Animated progress with time remaining 75 | 76 | ### 🏷️ Navigation Features 77 | - **Marker Support**: Shows recording markers with timestamps and metadata 78 | - **Recording Metadata**: Comprehensive information about the recording 79 | - **Technical Details**: Terminal size, shell, event count, duration 80 | - **File Information**: Source file and creation details 81 | 82 | ## Terminal Display Features 83 | 84 | ### Smart Color Coding 85 | - **🔵 File Listings**: `ls -la` output in blue 86 | - **🔴 Errors**: Error messages and failures in red 87 | - **🟢 Success**: Success messages in green 88 | - **🟡 Paths**: File paths and variables in yellow 89 | - **🟣 Special Content**: Emoji and decorative text in magenta 90 | 91 | ### Auto-scrolling Behavior 92 | - **Rolling Buffer**: Maintains last 18 lines (configurable) 93 | - **Natural Flow**: Content moves upward like a real terminal 94 | - **Latest Visible**: Most recent commands always at bottom 95 | - **Scroll Indicators**: Shows when content is being truncated 96 | 97 | ### Command Display 98 | ``` 99 | [15:30:42] ┌─[termitty@hostname] 100 | [15:30:42] └─$ echo "Hello World" 101 | [15:30:42] Hello World 102 | [15:30:43] ┌─[termitty@hostname] 103 | [15:30:43] └─$ date 104 | [15:30:43] Wed May 28 15:30:43 UTC 2025 105 | ``` 106 | 107 | ## Command Line Options 108 | 109 | ```bash 110 | python docs/terminal_player_ui.py [options] 111 | 112 | Options: 113 | --speed SPEED Playback speed (0.25, 0.5, 1.0, 2.0, 4.0) 114 | Default: 1.0 115 | 116 | Examples: 117 | python docs/terminal_player_ui.py demo.json 118 | python docs/terminal_player_ui.py demo.json --speed 0.5 119 | python docs/terminal_player_ui.py recordings/session.json --speed 2.0 120 | ``` 121 | 122 | ## Use Cases 123 | 124 | ### 📚 Training and Documentation 125 | - Create visual tutorials for command-line tools 126 | - Demonstrate complex deployment procedures 127 | - Show troubleshooting workflows step-by-step 128 | 129 | ### 🐛 Debugging and Analysis 130 | - Review session recordings for debugging 131 | - Analyze command execution patterns 132 | - Share reproduction steps with team members 133 | 134 | ### 🎓 Education and Demos 135 | - Create engaging terminal demonstrations 136 | - Show real-time command execution 137 | - Provide interactive learning materials 138 | 139 | ### 📋 Compliance and Auditing 140 | - Review administrative sessions 141 | - Analyze security-related activities 142 | - Maintain audit trails with visual playback 143 | 144 | ## Advanced Usage 145 | 146 | ### Programmatic Playback 147 | 148 | ```python 149 | from termitty.recording import SessionRecorder, SessionPlayer 150 | from termitty.recording.player import PlaybackSpeed 151 | 152 | # Load and configure player 153 | recorder = SessionRecorder.load('session.json') 154 | player = SessionPlayer(recorder.recording) 155 | 156 | # Set up callbacks 157 | def on_command(cmd): 158 | print(f"Executing: {cmd}") 159 | 160 | def on_output(output): 161 | print(f"Output: {output}") 162 | 163 | player._on_input = on_command 164 | player._on_output = on_output 165 | 166 | # Control playback 167 | player.speed = PlaybackSpeed.DOUBLE 168 | player.play() 169 | 170 | # Wait for completion 171 | while player.state != PlaybackState.FINISHED: 172 | time.sleep(0.1) 173 | ``` 174 | 175 | ### Creating Rich Recordings 176 | 177 | ```python 178 | with TermittySession() as session: 179 | session.connect('server.com', username='user', password='pass') 180 | session.start_recording('rich_demo.json') 181 | 182 | # Add section markers 183 | session.add_recording_marker('setup', {'phase': 'initialization'}) 184 | session.execute('echo "🚀 Starting setup..."') 185 | 186 | session.add_recording_marker('deployment', {'phase': 'deployment'}) 187 | session.execute('echo "📦 Deploying application..."') 188 | 189 | session.add_recording_marker('verification', {'phase': 'testing'}) 190 | session.execute('echo "✅ Verifying deployment..."') 191 | 192 | session.stop_recording() 193 | ``` 194 | 195 | ## Technical Details 196 | 197 | ### Requirements 198 | - Python 3.8+ 199 | - `rich` library for UI components 200 | - Terminal supporting color output 201 | 202 | ### Performance 203 | - Optimized for recordings with hundreds of events 204 | - Memory-efficient with rolling buffer 205 | - Smooth 10 FPS refresh rate 206 | - Responsive to keyboard interrupts 207 | 208 | ### Compatibility 209 | - Works with all Termitty recording formats 210 | - Compatible with exported asciinema files 211 | - Cross-platform support (Windows, macOS, Linux) 212 | 213 | ## Troubleshooting 214 | 215 | ### Common Issues 216 | 217 | **Player not displaying colors:** 218 | ```bash 219 | # Ensure terminal supports color 220 | export TERM=xterm-256color 221 | python docs/terminal_player_ui.py demo.json 222 | ``` 223 | 224 | **Recording file not found:** 225 | ```bash 226 | # Check file path 227 | ls -la *.json 228 | python docs/terminal_player_ui.py ./recordings/demo.json 229 | ``` 230 | 231 | **Rich library missing:** 232 | ```bash 233 | # Install UI dependencies 234 | pip install termitty[ui] 235 | # or 236 | pip install rich>=13.0.0 237 | ``` 238 | 239 | ### Tips for Best Experience 240 | - Use a terminal with good color support 241 | - Ensure adequate terminal window size (80×24 minimum) 242 | - For long recordings, use faster speeds (2×-4×) for overview 243 | - Use slower speeds (0.25×-0.5×) for detailed analysis 244 | 245 | The Terminal Player provides an unparalleled experience for viewing and analyzing terminal session recordings, making Termitty the premier choice for terminal automation and documentation. -------------------------------------------------------------------------------- /examples/basic_recording.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Basic recording example showing how to record terminal sessions. 4 | """ 5 | 6 | from termitty import TermittySession 7 | 8 | def main(): 9 | # Connect to a host 10 | with TermittySession() as session: 11 | session.connect("localhost", username="deep", password="1234") 12 | 13 | # Start recording 14 | session.start_recording("my_session.json") 15 | 16 | # Execute some commands 17 | session.execute("echo 'Hello from recorded session!'") 18 | session.execute("date") 19 | session.execute("ls -la | head -5") 20 | 21 | # Stop recording and save 22 | recording = session.stop_recording() 23 | print(f"Recording saved! Duration: {recording.duration():.2f} seconds") 24 | 25 | # You can also pause/resume recording 26 | session.start_recording("another_session.json") 27 | session.execute("echo 'Recording...'") 28 | 29 | session.pause_recording() 30 | session.execute("echo 'This will not be recorded'") 31 | 32 | session.resume_recording() 33 | session.execute("echo 'Recording again!'") 34 | 35 | # Add markers for easy navigation during playback 36 | session.add_recording_marker("important_moment", {"reason": "Something important happened"}) 37 | session.execute("echo 'This is important!'") 38 | 39 | session.stop_recording() 40 | 41 | if __name__ == "__main__": 42 | main() -------------------------------------------------------------------------------- /examples/basic_usage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Basic Termitty usage examples. 4 | 5 | This script demonstrates common Termitty patterns and best practices. 6 | Run it against a test server to see Termitty in action. 7 | """ 8 | 9 | import logging 10 | import sys 11 | from pathlib import Path 12 | 13 | # Add parent directory to path so we can import termitty 14 | # (In real usage, termitty would be installed via pip) 15 | sys.path.insert(0, str(Path(__file__).parent.parent)) 16 | 17 | from termitty import TermittySession 18 | from termitty.session.session import OutputContains, OutputMatches 19 | 20 | 21 | def setup_logging(): 22 | """Configure logging to see what Termitty is doing.""" 23 | logging.basicConfig( 24 | level=logging.INFO, 25 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 26 | ) 27 | 28 | 29 | def example_basic_connection(): 30 | """Example 1: Basic connection and command execution.""" 31 | print("\n=== Example 1: Basic Connection ===") 32 | 33 | # Create a session and connect 34 | session = TermittySession() 35 | 36 | try: 37 | # Connect using password authentication 38 | # Replace these with your actual server details 39 | session.connect( 40 | host='localhost', # or your test server 41 | username='testuser', 42 | password='testpass' 43 | ) 44 | 45 | # Execute a simple command 46 | result = session.execute('echo "Hello from Termitty!"') 47 | print(f"Output: {result.output}") 48 | print(f"Success: {result.success}") 49 | 50 | # Get system information 51 | result = session.execute('uname -a') 52 | print(f"System: {result.output.strip()}") 53 | 54 | finally: 55 | # Always disconnect when done 56 | session.disconnect() 57 | 58 | 59 | def example_context_managers(): 60 | """Example 2: Using context managers for cleaner code.""" 61 | print("\n=== Example 2: Context Managers ===") 62 | 63 | # Use session as context manager for automatic cleanup 64 | with TermittySession() as session: 65 | session.connect( 66 | host='localhost', 67 | username='testuser', 68 | password='testpass' 69 | ) 70 | 71 | # Change directory temporarily 72 | print("Current directory:", session.execute('pwd').output.strip()) 73 | 74 | with session.cd('/tmp'): 75 | print("In /tmp:", session.execute('pwd').output.strip()) 76 | 77 | # Create a test file 78 | session.execute('echo "test content" > termitty_test.txt') 79 | 80 | # Verify it exists 81 | result = session.execute('ls -la termitty_test.txt') 82 | print("Created file:", result.output.strip()) 83 | 84 | # Back to original directory 85 | print("Back to:", session.execute('pwd').output.strip()) 86 | 87 | # File is still in /tmp 88 | result = session.execute('ls /tmp/termitty_test.txt') 89 | print("File still exists:", result.success) 90 | 91 | 92 | def example_environment_management(): 93 | """Example 3: Managing environment variables.""" 94 | print("\n=== Example 3: Environment Management ===") 95 | 96 | with TermittySession() as session: 97 | session.connect( 98 | host='localhost', 99 | username='testuser', 100 | password='testpass' 101 | ) 102 | 103 | # Check current PATH 104 | original_path = session.execute('echo $PATH').output.strip() 105 | print(f"Original PATH: {original_path}") 106 | 107 | # Temporarily modify environment 108 | with session.env( 109 | CUSTOM_VAR='Hello from Termitty', 110 | PATH='/custom/bin:' + original_path 111 | ): 112 | # Environment is modified within this block 113 | print("CUSTOM_VAR:", session.execute('echo $CUSTOM_VAR').output.strip()) 114 | print("Modified PATH:", session.execute('echo $PATH').output.strip()) 115 | 116 | # Environment is restored 117 | print("CUSTOM_VAR after:", session.execute('echo $CUSTOM_VAR').output.strip()) 118 | print("PATH restored:", session.execute('echo $PATH').output.strip()) 119 | 120 | 121 | def example_waiting_and_timeouts(): 122 | """Example 4: Intelligent waiting for conditions.""" 123 | print("\n=== Example 4: Waiting and Timeouts ===") 124 | 125 | with TermittySession() as session: 126 | session.connect( 127 | host='localhost', 128 | username='testuser', 129 | password='testpass' 130 | ) 131 | 132 | # Start a background process that takes time 133 | session.execute('(sleep 2 && echo "Process completed") &') 134 | 135 | try: 136 | # Wait for the process to complete 137 | print("Waiting for process to complete...") 138 | session.wait_until_output_contains('Process completed', timeout=5.0) 139 | print("Process completed successfully!") 140 | 141 | except TimeoutException: 142 | print("Process took too long!") 143 | 144 | # Example with pattern matching 145 | session.execute('ip addr show') 146 | 147 | # Wait for IP address pattern 148 | ip_pattern = r'inet (\d+\.\d+\.\d+\.\d+)' 149 | session.wait_until(OutputMatches(ip_pattern), timeout=5.0) 150 | 151 | # Extract the IP address 152 | match = session.find_in_output(ip_pattern) 153 | if match: 154 | print(f"Found IP address: {match.group(1)}") 155 | 156 | 157 | def example_error_handling(): 158 | """Example 5: Proper error handling.""" 159 | print("\n=== Example 5: Error Handling ===") 160 | 161 | with TermittySession() as session: 162 | try: 163 | # Try to connect with wrong credentials 164 | session.connect( 165 | host='localhost', 166 | username='wronguser', 167 | password='wrongpass' 168 | ) 169 | except AuthenticationException as e: 170 | print(f"Authentication failed as expected: {e}") 171 | # Try again with correct credentials 172 | session.connect( 173 | host='localhost', 174 | username='testuser', 175 | password='testpass' 176 | ) 177 | 178 | # Execute a command that might fail 179 | result = session.execute('ls /nonexistent', check=False) 180 | if not result.success: 181 | print(f"Command failed with exit code: {result.exit_code}") 182 | print(f"Error output: {result.stderr}") 183 | 184 | # Use check=True to raise exception on failure 185 | try: 186 | session.execute('false', check=True) # 'false' always returns exit code 1 187 | except CommandExecutionException as e: 188 | print(f"Command failed as expected: {e}") 189 | 190 | 191 | def example_real_world_scenario(): 192 | """Example 6: A real-world automation scenario.""" 193 | print("\n=== Example 6: Real-World Scenario ===") 194 | print("Checking disk usage on remote server...") 195 | 196 | with TermittySession() as session: 197 | session.connect( 198 | host='localhost', 199 | username='testuser', 200 | password='testpass' 201 | ) 202 | 203 | # Check disk usage 204 | result = session.execute('df -h') 205 | print("Disk usage:") 206 | print(result.output) 207 | 208 | # Find partitions over 80% full 209 | for line in result.output.splitlines()[1:]: # Skip header 210 | parts = line.split() 211 | if len(parts) >= 5 and '%' in parts[4]: 212 | usage = int(parts[4].rstrip('%')) 213 | if usage > 80: 214 | print(f"WARNING: {parts[5]} is {usage}% full!") 215 | 216 | # Check for large files in /tmp 217 | with session.cd('/tmp'): 218 | result = session.execute('find . -type f -size +100M 2>/dev/null | head -10') 219 | if result.output.strip(): 220 | print("\nLarge files in /tmp:") 221 | print(result.output) 222 | else: 223 | print("\nNo large files found in /tmp") 224 | 225 | # Check system load 226 | result = session.execute('uptime') 227 | print(f"\nSystem load: {result.output.strip()}") 228 | 229 | # Look for load average 230 | match = session.find_in_output(r'load average: ([\d.]+), ([\d.]+), ([\d.]+)') 231 | if match: 232 | load_1min = float(match.group(1)) 233 | if load_1min > 2.0: 234 | print(f"WARNING: High load average: {load_1min}") 235 | 236 | 237 | def main(): 238 | """Run all examples.""" 239 | setup_logging() 240 | 241 | # Note: These examples assume you have a test server available. 242 | # You can set up a local SSH server or use a test VM. 243 | 244 | examples = [ 245 | example_basic_connection, 246 | example_context_managers, 247 | example_environment_management, 248 | example_waiting_and_timeouts, 249 | example_error_handling, 250 | example_real_world_scenario 251 | ] 252 | 253 | for example in examples: 254 | try: 255 | example() 256 | except Exception as e: 257 | print(f"Example failed: {e}") 258 | import traceback 259 | traceback.print_exc() 260 | 261 | print("\n" + "="*50 + "\n") 262 | 263 | 264 | if __name__ == '__main__': 265 | # Import our exceptions for error handling examples 266 | from termitty.core.exceptions import ( 267 | AuthenticationException, 268 | CommandExecutionException, 269 | TimeoutException 270 | ) 271 | 272 | main() -------------------------------------------------------------------------------- /examples/cool_examples.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Cool things you can already do with Termitty (before Phase 3)! 4 | """ 5 | 6 | import sys 7 | from pathlib import Path 8 | sys.path.insert(0, str(Path(__file__).parent)) 9 | 10 | from termitty import TermittySession, OutputContains 11 | import time 12 | 13 | # Update with your credentials 14 | SESSION_CONFIG = { 15 | "host": "localhost", 16 | "port": 22, 17 | "username": "deep", 18 | "password": "PASSWORD" # Your Mac password 19 | } 20 | 21 | def example_1_system_monitoring(): 22 | """Monitor system resources.""" 23 | print("=== Example 1: System Monitoring ===\n") 24 | 25 | with TermittySession() as session: 26 | session.connect(**SESSION_CONFIG) 27 | 28 | # Check CPU usage 29 | result = session.execute("top -l 1 | grep 'CPU usage'") 30 | print(f"CPU Status: {result.output.strip()}") 31 | 32 | # Check memory 33 | result = session.execute("vm_stat | grep 'Pages free'") 34 | print(f"Memory: {result.output.strip()}") 35 | 36 | # Check disk usage 37 | result = session.execute("df -h / | tail -1") 38 | print(f"Disk usage: {result.output.strip()}") 39 | 40 | # Find large files 41 | print("\nLarge files in Downloads:") 42 | with session.cd("~/Downloads"): 43 | result = session.execute("find . -size +100M -type f 2>/dev/null | head -5") 44 | if result.output.strip(): 45 | print(result.output) 46 | else: 47 | print("No files larger than 100MB") 48 | 49 | 50 | def example_2_git_automation(): 51 | """Automate git operations.""" 52 | print("\n=== Example 2: Git Repository Analysis ===\n") 53 | 54 | with TermittySession() as session: 55 | session.connect(**SESSION_CONFIG) 56 | 57 | # Check git repos in home directory 58 | result = session.execute("find ~ -name '.git' -type d 2>/dev/null | head -10") 59 | git_repos = [Path(line).parent for line in result.output.strip().split('\n') if line] 60 | 61 | if git_repos: 62 | print(f"Found {len(git_repos)} git repositories") 63 | 64 | # Analyze first repo 65 | repo = git_repos[0] 66 | with session.cd(str(repo)): 67 | print(f"\nAnalyzing: {repo}") 68 | 69 | # Current branch 70 | result = session.execute("git branch --show-current") 71 | print(f"Current branch: {result.output.strip()}") 72 | 73 | # Last commit 74 | result = session.execute("git log -1 --oneline") 75 | print(f"Last commit: {result.output.strip()}") 76 | 77 | # Status 78 | result = session.execute("git status --short") 79 | if result.output.strip(): 80 | print("Modified files:") 81 | print(result.output) 82 | else: 83 | print("Working directory clean") 84 | 85 | 86 | def example_3_process_monitoring(): 87 | """Monitor running processes.""" 88 | print("\n=== Example 3: Process Monitoring ===\n") 89 | 90 | with TermittySession() as session: 91 | session.connect(**SESSION_CONFIG) 92 | 93 | # Find Python processes 94 | result = session.execute("ps aux | grep -i python | grep -v grep | head -5") 95 | print("Python processes running:") 96 | print(result.output) 97 | 98 | # Monitor a specific process over time 99 | print("\nMonitoring memory usage of Terminal app for 5 seconds...") 100 | for i in range(3): 101 | result = session.execute("ps aux | grep 'Terminal.app' | grep -v grep | awk '{print $4}'") 102 | if result.output.strip(): 103 | mem = result.output.strip().split('\n')[0] 104 | print(f" {i+1}. Terminal memory: {mem}%") 105 | time.sleep(1) 106 | 107 | 108 | def example_4_network_info(): 109 | """Get network information.""" 110 | print("\n=== Example 4: Network Information ===\n") 111 | 112 | with TermittySession() as session: 113 | session.connect(**SESSION_CONFIG) 114 | 115 | # Network interfaces 116 | result = session.execute("ifconfig | grep 'inet ' | grep -v 127.0.0.1") 117 | print("Network interfaces:") 118 | for line in result.output.strip().split('\n'): 119 | if 'inet' in line: 120 | print(f" {line.strip()}") 121 | 122 | # Check connectivity 123 | print("\nTesting connectivity:") 124 | result = session.execute("ping -c 1 google.com 2>&1 | grep 'bytes from'") 125 | if result.output: 126 | print(" ✓ Internet connection working") 127 | else: 128 | print(" ✗ No internet connection") 129 | 130 | # Open connections 131 | result = session.execute("netstat -an | grep ESTABLISHED | wc -l") 132 | count = result.output.strip() 133 | print(f"\nEstablished connections: {count}") 134 | 135 | 136 | def example_5_file_search(): 137 | """Advanced file searching.""" 138 | print("\n=== Example 5: Smart File Search ===\n") 139 | 140 | with TermittySession() as session: 141 | session.connect(**SESSION_CONFIG) 142 | 143 | # Search for Python files modified today 144 | print("Python files modified today:") 145 | result = session.execute("find ~ -name '*.py' -mtime 0 2>/dev/null | head -10") 146 | for file in result.output.strip().split('\n')[:5]: 147 | if file: 148 | print(f" {file}") 149 | 150 | # Search file contents 151 | print("\nSearching for 'TODO' in Python files:") 152 | with session.cd("~/"): 153 | result = session.execute("grep -r 'TODO' --include='*.py' . 2>/dev/null | head -5") 154 | if result.output: 155 | for line in result.output.strip().split('\n')[:3]: 156 | print(f" {line[:100]}...") 157 | 158 | 159 | def example_6_colored_output(): 160 | """Test terminal colors and formatting.""" 161 | print("\n=== Example 6: Terminal Colors & Formatting ===\n") 162 | 163 | with TermittySession() as session: 164 | session.connect(**SESSION_CONFIG) 165 | 166 | # Create a colorful script 167 | script = """ 168 | echo -e "\\033[1;31m█████╗ \\033[1;33m█████╗ \\033[1;32m█████╗ \\033[1;36m█████╗ \\033[1;34m█████╗ \\033[1;35m█████╗\\033[0m" 169 | echo -e "\\033[1;31m╚═══█║ \\033[1;33m╚═══█║ \\033[1;32m╚═══█║ \\033[1;36m╚═══█║ \\033[1;34m╚═══█║ \\033[1;35m╚═══█║\\033[0m" 170 | echo -e "\\033[1;31m █████║ \\033[1;33m█████║ \\033[1;32m█████║ \\033[1;36m█████║ \\033[1;34m█████║ \\033[1;35m█████║\\033[0m" 171 | echo -e "\\033[1;31m ╚═══█║ \\033[1;33m╚═══█║ \\033[1;32m╚═══█║ \\033[1;36m╚═══█║ \\033[1;34m╚═══█║ \\033[1;35m╚═══█║\\033[0m" 172 | echo -e "\\033[1;31m █████║ \\033[1;33m█████║ \\033[1;32m█████║ \\033[1;36m█████║ \\033[1;34m█████║ \\033[1;35m█████║\\033[0m" 173 | echo -e "\\033[1;31m ╚════╝ \\033[1;33m╚════╝ \\033[1;32m╚════╝ \\033[1;36m╚════╝ \\033[1;34m╚════╝ \\033[1;35m╚════╝\\033[0m" 174 | echo 175 | echo -e "\\033[1;5;32mTermitty\\033[0m \\033[1;4;33mis\\033[0m \\033[1;7;34mawesome!\\033[0m" 176 | """ 177 | 178 | session.execute(f"echo '{script}' > /tmp/colors.sh && bash /tmp/colors.sh") 179 | 180 | # The terminal emulator processes the ANSI codes 181 | screen = session.get_screen_text() 182 | print("Terminal shows colorful ASCII art!") 183 | print("(The colors are processed by the terminal emulator)") 184 | 185 | # Show what's on screen 186 | visible_lines = [line for line in screen.strip().split('\n') if line.strip()] 187 | for line in visible_lines[-8:]: 188 | print(line) 189 | 190 | 191 | if __name__ == "__main__": 192 | print("Cool Termitty Examples - What You Can Do Now!\n") 193 | 194 | examples = [ 195 | example_1_system_monitoring, 196 | example_2_git_automation, 197 | example_3_process_monitoring, 198 | example_4_network_info, 199 | example_5_file_search, 200 | example_6_colored_output 201 | ] 202 | 203 | for example in examples: 204 | try: 205 | example() 206 | print("\n" + "="*50 + "\n") 207 | except Exception as e: 208 | print(f"Example failed: {e}\n") -------------------------------------------------------------------------------- /examples/parallel_execution.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Fixed parallel execution examples for Termitty. 4 | 5 | This demonstrates executing commands across multiple servers simultaneously, 6 | with corrected authentication. 7 | """ 8 | 9 | import sys 10 | import time 11 | from pathlib import Path 12 | 13 | sys.path.insert(0, str(Path(__file__).parent.parent)) 14 | 15 | from termitty.parallel import ( 16 | ParallelExecutor, 17 | HostConfig, 18 | ExecutionConfig, 19 | ExecutionStrategy, 20 | execute_parallel 21 | ) 22 | 23 | # YOUR ACTUAL PASSWORD - Update this! 24 | PASSWORD = "PASSWORD" # Replace with your actual password 25 | 26 | def example_1_basic_parallel(): 27 | """Example 1: Basic parallel execution.""" 28 | print("=== Example 1: Basic Parallel Execution ===\n") 29 | 30 | # For demo, using localhost 31 | hosts = [ 32 | {"host": "localhost", "username": "deep", "password": PASSWORD, 33 | "metadata": {"server_type": "demo"}}, 34 | ] 35 | 36 | # Simple parallel execution 37 | results = execute_parallel("uname -a && uptime", hosts, max_workers=5) 38 | 39 | # Display results 40 | print(results.summary()) 41 | print(f"\nTotal execution time: {results.duration:.2f}s") 42 | 43 | # Show individual outputs 44 | print("\nIndividual outputs:") 45 | for host, output in results.get_outputs().items(): 46 | print(f"\n{host}:") 47 | print(output.strip()) 48 | 49 | 50 | def example_2_advanced_executor(): 51 | """Example 2: Advanced executor with configuration.""" 52 | print("\n=== Example 2: Advanced Parallel Executor ===\n") 53 | 54 | # Configure execution 55 | config = ExecutionConfig( 56 | strategy=ExecutionStrategy.PARALLEL, 57 | max_workers=10, 58 | timeout=30.0, 59 | fail_fast=False, # Continue even if some hosts fail 60 | retry_failed=1, # Retry failed hosts once 61 | ) 62 | 63 | # Create executor 64 | with ParallelExecutor(config) as executor: 65 | # Multiple hosts (for demo, using same host with different metadata) 66 | hosts = [ 67 | HostConfig("localhost", "deep", password=PASSWORD, 68 | metadata={"server_id": i, "group": "webservers"}) 69 | for i in range(3) 70 | ] 71 | 72 | # Execute command 73 | command = "echo 'Server $(hostname) is running' && df -h / | tail -1" 74 | results = executor.execute(command, hosts) 75 | 76 | # Analyze results 77 | print(f"Success rate: {results.success_rate:.1f}%") 78 | print(f"Successful hosts: {results.get_successful_hosts()}") 79 | 80 | if results.any_failed: 81 | print(f"Failed hosts: {results.get_failed_hosts()}") 82 | 83 | # Group by unique outputs 84 | print("\nUnique outputs:") 85 | for output, hosts_list in results.get_unique_outputs().items(): 86 | print(f"\n{len(hosts_list)} hosts returned:") 87 | print(output) 88 | 89 | 90 | def example_3_rolling_update(): 91 | """Example 3: Rolling update strategy.""" 92 | print("\n=== Example 3: Rolling Update Strategy ===\n") 93 | 94 | # Configuration for rolling updates 95 | config = ExecutionConfig( 96 | strategy=ExecutionStrategy.ROLLING, 97 | batch_size=2, # Update 2 servers at a time 98 | timeout=60.0, 99 | ) 100 | 101 | with ParallelExecutor(config) as executor: 102 | # Simulate multiple servers 103 | hosts = [ 104 | HostConfig("localhost", "deep", password=PASSWORD, 105 | metadata={"server_name": f"web{i}"}) 106 | for i in range(6) 107 | ] 108 | 109 | print("Simulating rolling update across 6 servers (batch size: 2)") 110 | 111 | # Update command (simulate with echo) 112 | update_command = """ 113 | echo "Starting update on $(hostname)..." 114 | sleep 2 # Simulate update process 115 | echo "Update completed on $(hostname)" 116 | """ 117 | 118 | # Progress callback 119 | def progress_callback(host, result): 120 | status = "✓" if result.success else "✗" 121 | print(f" {status} {host} - {result.status.value}") 122 | 123 | config.progress_callback = progress_callback 124 | 125 | # Execute rolling update 126 | results = executor.execute(update_command, hosts) 127 | 128 | print(f"\nRolling update completed in {results.duration:.2f}s") 129 | print(f"Success rate: {results.success_rate:.1f}%") 130 | 131 | 132 | def example_4_health_check(): 133 | """Example 4: Parallel health checks.""" 134 | print("\n=== Example 4: Parallel Health Checks ===\n") 135 | 136 | with ParallelExecutor() as executor: 137 | # Define health check hosts 138 | hosts = [ 139 | HostConfig("localhost", "deep", password=PASSWORD, 140 | metadata={"service": "web", "port": 80}), 141 | # Add more hosts for real scenario 142 | ] 143 | 144 | # Comprehensive health check command 145 | health_check = """ 146 | echo "=== Health Check Report ===" 147 | echo "Hostname: $(hostname)" 148 | echo "Uptime: $(uptime | awk -F'up' '{print $2}' | awk -F',' '{print $1}')" 149 | echo "Load: $(uptime | awk -F'load average:' '{print $2}')" 150 | echo "Memory: $(free -m | grep Mem | awk '{print "Used: " $3 "MB/" $2 "MB"}')" 151 | echo "Disk /: $(df -h / | tail -1 | awk '{print "Used: " $3 "/" $2 " (" $5 ")"}')" 152 | echo "Processes: $(ps aux | wc -l)" 153 | """ 154 | 155 | print("Running health checks on all servers...") 156 | results = executor.execute(health_check, hosts) 157 | 158 | # Display health status 159 | print("\nHealth Check Summary:") 160 | print(f"Servers checked: {results.total_hosts}") 161 | print(f"Healthy servers: {results.success_count}") 162 | 163 | if results.any_failed: 164 | print(f"Unhealthy servers: {results.failure_count}") 165 | for host in results.get_failed_hosts(): 166 | print(f" - {host}") 167 | 168 | # Show detailed results 169 | print("\nDetailed Health Reports:") 170 | for host, result in results.results.items(): 171 | print(f"\n{host}:") 172 | if result.success: 173 | print(result.output) 174 | else: 175 | print(f" Health check failed: {result.error}") 176 | 177 | 178 | def example_5_script_deployment(): 179 | """Example 5: Deploy and execute scripts.""" 180 | print("\n=== Example 5: Script Deployment ===\n") 181 | 182 | # Create a sample script 183 | script_content = """#!/bin/bash 184 | echo "Deployment script running on $(hostname)" 185 | echo "Creating application directory..." 186 | mkdir -p /tmp/myapp 187 | 188 | echo "Downloading application..." 189 | # Simulate download 190 | sleep 1 191 | 192 | echo "Installing dependencies..." 193 | # Simulate installation 194 | sleep 1 195 | 196 | echo "Starting service..." 197 | # Simulate service start 198 | echo "Service started successfully" 199 | 200 | echo "Deployment completed at $(date)" 201 | """ 202 | 203 | # Save script temporarily 204 | script_path = "/tmp/deploy_script.sh" 205 | with open(script_path, 'w') as f: 206 | f.write(script_content) 207 | 208 | with ParallelExecutor() as executor: 209 | hosts = [ 210 | HostConfig("localhost", "deep", password=PASSWORD), 211 | ] 212 | 213 | print("Deploying application to servers...") 214 | results = executor.execute_script(script_path, hosts) 215 | 216 | print(f"\nScript deployed to {results.success_count}/{len(hosts)} servers") 217 | 218 | # Show output 219 | for host, result in results.results.items(): 220 | if result.success: 221 | print(f"\n{host} output:") 222 | print(result.output) 223 | 224 | 225 | def example_6_multiple_commands(): 226 | """Example 6: Execute multiple commands in sequence.""" 227 | print("\n=== Example 6: Multiple Commands ===\n") 228 | 229 | with ParallelExecutor() as executor: 230 | hosts = [ 231 | HostConfig("localhost", "deep", password=PASSWORD), 232 | ] 233 | 234 | # Multiple commands to execute 235 | commands = [ 236 | ("Check disk space", "df -h /"), 237 | ("Check memory", "free -m"), 238 | ("List processes", "ps aux | wc -l"), 239 | ("Check load", "uptime"), 240 | ] 241 | 242 | print("Executing multiple commands on all hosts...\n") 243 | 244 | for desc, cmd in commands: 245 | print(f"{desc}:") 246 | results = executor.execute(cmd, hosts) 247 | 248 | for host, result in results.results.items(): 249 | if result.success: 250 | # Get just the relevant output line 251 | output = result.output.strip() 252 | print(f" {host}: {output}") 253 | else: 254 | print(f" {host}: FAILED - {result.error}") 255 | print() 256 | 257 | 258 | if __name__ == "__main__": 259 | print("Termitty Parallel Execution Examples\n") 260 | print("NOTE: Update PASSWORD variable with your actual password!\n") 261 | 262 | try: 263 | example_1_basic_parallel() 264 | print("\n" + "="*60 + "\n") 265 | 266 | example_2_advanced_executor() 267 | print("\n" + "="*60 + "\n") 268 | 269 | example_3_rolling_update() 270 | print("\n" + "="*60 + "\n") 271 | 272 | example_4_health_check() 273 | print("\n" + "="*60 + "\n") 274 | 275 | example_5_script_deployment() 276 | print("\n" + "="*60 + "\n") 277 | 278 | example_6_multiple_commands() 279 | 280 | except Exception as e: 281 | print(f"\nError: {e}") 282 | print("\nMake sure to:") 283 | print("1. Update the PASSWORD variable with your actual password") 284 | print("2. Ensure SSH is enabled on your system") 285 | print("3. Check that all required modules are installed") -------------------------------------------------------------------------------- /examples/parallel_with_keys.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Parallel execution using SSH key authentication. 4 | """ 5 | 6 | import sys 7 | from pathlib import Path 8 | sys.path.insert(0, str(Path(__file__).parent.parent)) 9 | 10 | from termitty.parallel import ParallelExecutor, HostConfig, execute_parallel 11 | 12 | # Using SSH key authentication 13 | hosts = [ 14 | HostConfig( 15 | host="localhost", 16 | username="deep", 17 | key_file="~/.ssh/id_rsa", # Using SSH key instead of password 18 | metadata={"server": "local"} 19 | ), 20 | ] 21 | 22 | def test_parallel_with_keys(): 23 | """Test parallel execution with SSH keys.""" 24 | print("=== Parallel Execution with SSH Keys ===\n") 25 | 26 | # First, set up SSH key authentication 27 | import subprocess 28 | print("Setting up SSH key authentication...") 29 | result = subprocess.run(["bash", "setup_ssh_key_auth.sh"], capture_output=True, text=True) 30 | if result.returncode != 0: 31 | print("Failed to setup SSH keys. Setting up manually...") 32 | 33 | # Manual setup 34 | import os 35 | ssh_dir = Path.home() / ".ssh" 36 | ssh_dir.mkdir(exist_ok=True, mode=0o700) 37 | 38 | key_file = ssh_dir / "id_rsa" 39 | if not key_file.exists(): 40 | subprocess.run(["ssh-keygen", "-t", "rsa", "-N", "", "-f", str(key_file)]) 41 | 42 | # Add to authorized_keys 43 | pub_key = (ssh_dir / "id_rsa.pub").read_text() 44 | auth_keys = ssh_dir / "authorized_keys" 45 | 46 | if not auth_keys.exists() or pub_key not in auth_keys.read_text(): 47 | with open(auth_keys, "a") as f: 48 | f.write(pub_key) 49 | auth_keys.chmod(0o600) 50 | 51 | # Now test parallel execution 52 | print("\nTesting parallel execution...") 53 | 54 | # Simple test 55 | results = execute_parallel("echo 'SSH key auth works!' && hostname && date", hosts) 56 | 57 | print(results.summary()) 58 | 59 | if results.success_count > 0: 60 | print("\n✅ Success! Output:") 61 | for host, output in results.get_outputs().items(): 62 | print(f"\n{host}:") 63 | print(output) 64 | else: 65 | print("\n❌ Failed. Trying with password instead...") 66 | 67 | # Fallback to password 68 | from getpass import getpass 69 | password = getpass("Enter password: ") 70 | 71 | hosts_with_password = [ 72 | {"host": "localhost", "username": "deep", "password": password} 73 | ] 74 | 75 | results = execute_parallel("echo 'Password auth works!' && hostname", hosts_with_password) 76 | print(results.summary()) 77 | 78 | 79 | if __name__ == "__main__": 80 | test_parallel_with_keys() -------------------------------------------------------------------------------- /examples/recording_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Example demonstrating session recording and playback. 4 | 5 | This example shows how to: 6 | - Record terminal sessions 7 | - Add markers during recording 8 | - Pause and resume recording 9 | - Play back recordings with different speeds 10 | - Export recordings to different formats 11 | """ 12 | 13 | import time 14 | from pathlib import Path 15 | from termitty import TermittySession 16 | from termitty.recording import SessionRecorder, SessionPlayer 17 | from termitty.recording.formats import RecordingFormat, EventType 18 | from termitty.recording.player import PlaybackSpeed, PlaybackState 19 | 20 | # Recording directory 21 | RECORDING_DIR = Path("recordings") 22 | RECORDING_DIR.mkdir(exist_ok=True) 23 | 24 | 25 | def record_session_example(): 26 | """Record a terminal session with various commands.""" 27 | print("📹 Recording Terminal Session") 28 | print("-" * 40) 29 | 30 | with TermittySession() as session: 31 | session.connect("localhost", username="deep", password="1234") 32 | # Start recording 33 | recording_file = RECORDING_DIR / "demo_session.json" 34 | session.start_recording(str(recording_file)) 35 | print(f"✅ Started recording to: {recording_file}") 36 | 37 | # Execute some commands 38 | print("\n📝 Running commands...") 39 | 40 | # Add a marker 41 | session.add_recording_marker("system_info", {"section": "start"}) 42 | 43 | # System information commands 44 | commands = [ 45 | ("echo 'Hello from Termitty!'", "Greeting"), 46 | ("date", "Current date"), 47 | ("whoami", "Current user"), 48 | ("pwd", "Working directory"), 49 | ("ls -la", "Directory listing"), 50 | ] 51 | 52 | for cmd, description in commands: 53 | print(f"\n ▶ {description}: {cmd}") 54 | result = session.execute(cmd) 55 | print(f" Output: {result.stdout.strip()}") 56 | time.sleep(0.5) # Small delay for visual effect 57 | 58 | # Demonstrate pause/resume 59 | print("\n⏸ Pausing recording...") 60 | session.pause_recording() 61 | 62 | # Execute a command while paused (won't be recorded) 63 | session.execute("echo 'This will not be recorded'") 64 | 65 | print("▶ Resuming recording...") 66 | session.resume_recording() 67 | 68 | # More commands 69 | session.add_recording_marker("advanced_commands", {"section": "advanced"}) 70 | 71 | # Change directory and run commands 72 | with session.cd("/tmp"): 73 | print("\n📁 Changed to /tmp") 74 | result = session.execute("pwd") 75 | print(f" Current dir: {result.stdout.strip()}") 76 | 77 | # Create and check a file 78 | session.execute("echo 'Termitty was here!' > termitty_test.txt") 79 | result = session.execute("cat termitty_test.txt") 80 | print(f" File content: {result.stdout.strip()}") 81 | 82 | # Stop recording 83 | recording = session.stop_recording() 84 | print(f"\n✅ Recording stopped. Duration: {recording.duration():.2f} seconds") 85 | print(f" Total events: {len(recording.events)}") 86 | 87 | return recording_file 88 | 89 | 90 | def playback_example(recording_file): 91 | """Demonstrate playing back a recording.""" 92 | print("\n\n🎬 Playing Back Recording") 93 | print("-" * 40) 94 | 95 | # Load the recording 96 | recorder = SessionRecorder.load(recording_file) 97 | player = SessionPlayer(recorder.recording) 98 | 99 | print(f"📊 Recording info:") 100 | print(f" Duration: {recorder.recording.duration():.2f} seconds") 101 | print(f" Events: {len(recorder.recording.events)}") 102 | print(f" Terminal size: {recorder.recording.header.width}x{recorder.recording.header.height}") 103 | 104 | # Play at different speeds 105 | print("\n▶ Playing at 2x speed...") 106 | player.speed = PlaybackSpeed.DOUBLE 107 | 108 | # Set up callbacks to track progress 109 | def on_progress(position, duration): 110 | print(f"\r⏱ Time: {position:.1f}s / {duration:.1f}s", end="", flush=True) 111 | 112 | player._on_progress = on_progress 113 | player.play() 114 | 115 | # Wait for playback to complete 116 | while player.state not in [PlaybackState.STOPPED, PlaybackState.FINISHED]: 117 | time.sleep(0.1) 118 | 119 | print("\n✅ Playback complete!") 120 | 121 | # Show markers 122 | markers = [e for e in recorder.recording.events if e.event_type == EventType.MARKER] 123 | if markers: 124 | print(f"\n📍 Recording markers ({len(markers)}):") 125 | for marker in markers: 126 | print(f" - {marker.data} at {marker.timestamp:.1f}s") 127 | if marker.metadata: 128 | print(f" Metadata: {marker.metadata}") 129 | 130 | 131 | def export_example(recording_file): 132 | """Export recording to different formats.""" 133 | print("\n\n📤 Exporting Recording") 134 | print("-" * 40) 135 | 136 | recorder = SessionRecorder.load(recording_file) 137 | 138 | # Export to asciinema format 139 | asciinema_file = recording_file.with_suffix('.cast') 140 | recorder.save(asciinema_file, format=RecordingFormat.ASCIINEMA) 141 | print(f"✅ Exported to asciinema format: {asciinema_file}") 142 | 143 | # Export compressed 144 | compressed_file = recording_file.with_suffix('.json.gz') 145 | recorder.save(compressed_file, compress=True) 146 | print(f"✅ Exported compressed: {compressed_file}") 147 | 148 | # Show file sizes 149 | original_size = recording_file.stat().st_size 150 | asciinema_size = asciinema_file.stat().st_size 151 | compressed_size = compressed_file.stat().st_size 152 | 153 | print(f"\n📊 File sizes:") 154 | print(f" Original JSON: {original_size:,} bytes") 155 | print(f" Asciinema: {asciinema_size:,} bytes") 156 | print(f" Compressed: {compressed_size:,} bytes ({compressed_size/original_size*100:.1f}%)") 157 | 158 | 159 | def interactive_playback_example(recording_file): 160 | """Demonstrate interactive playback controls.""" 161 | print("\n\n🎮 Interactive Playback") 162 | print("-" * 40) 163 | 164 | recorder = SessionRecorder.load(recording_file) 165 | player = SessionPlayer(recorder.recording) 166 | 167 | # Seek to specific times 168 | print("⏩ Seeking to different positions:") 169 | 170 | positions = [0.0, recorder.recording.duration() / 2, recorder.recording.duration() - 1.0] 171 | for pos in positions: 172 | player.seek(pos) 173 | print(f" Position {pos:.1f}s - Event index: {player._event_index}") 174 | 175 | # Jump to markers 176 | markers = [e for e in recorder.recording.events if e.event_type == EventType.MARKER] 177 | if markers: 178 | print("\n📍 Jumping to markers:") 179 | for marker in markers: 180 | player.seek(marker.timestamp) 181 | print(f" {marker.data} ({marker.timestamp:.1f}s) - Event index: {player._event_index}") 182 | 183 | 184 | if __name__ == "__main__": 185 | print("🚀 Termitty Recording Example") 186 | print("=" * 50) 187 | 188 | try: 189 | # Record a session 190 | recording_file = record_session_example() 191 | 192 | # Play it back 193 | playback_example(recording_file) 194 | 195 | # Export to different formats 196 | export_example(recording_file) 197 | 198 | # Interactive playback 199 | interactive_playback_example(recording_file) 200 | 201 | print("\n\n✨ All examples completed successfully!") 202 | 203 | except Exception as e: 204 | print(f"\n❌ Error: {e}") 205 | import traceback 206 | traceback.print_exc() -------------------------------------------------------------------------------- /examples/recording_playback.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Example showing how to record and playback terminal sessions. 4 | """ 5 | 6 | from termitty import TermittySession 7 | from termitty.recording import SessionRecorder, SessionPlayer 8 | from termitty.recording.player import PlaybackSpeed, PlaybackState 9 | import time 10 | 11 | def record_example_session(): 12 | """Record a simple terminal session.""" 13 | with TermittySession() as session: 14 | session.connect("localhost", username="your_username", password="your_password") 15 | 16 | # Start recording 17 | session.start_recording("example_session.json") 18 | 19 | # Execute some commands 20 | session.execute("echo 'Hello from Termitty!'") 21 | session.execute("date") 22 | session.execute("whoami") 23 | session.execute("ls -la | head -5") 24 | 25 | # Add a marker 26 | session.add_recording_marker("important", {"note": "Key demonstration point"}) 27 | session.execute("echo 'This is marked as important!'") 28 | 29 | # Stop recording 30 | recording = session.stop_recording() 31 | print(f"✅ Recording saved: {recording.duration():.2f} seconds") 32 | return "example_session.json" 33 | 34 | def playback_session(recording_file): 35 | """Play back a recorded session.""" 36 | # Load the recording 37 | recorder = SessionRecorder.load(recording_file) 38 | player = SessionPlayer(recorder.recording) 39 | 40 | # Set up callbacks to display output 41 | def on_input(data): 42 | print(f"$ {data.rstrip()}") 43 | 44 | def on_output(data): 45 | print(data, end='') 46 | 47 | player._on_input = on_input 48 | player._on_output = on_output 49 | 50 | # Set playback speed 51 | player.speed = PlaybackSpeed.NORMAL 52 | 53 | print("=== Playback Starting ===") 54 | player.play() 55 | 56 | # Wait for completion 57 | while player.state not in [PlaybackState.STOPPED, PlaybackState.FINISHED]: 58 | time.sleep(0.1) 59 | 60 | print("\n=== Playback Complete ===") 61 | 62 | if __name__ == "__main__": 63 | print("🎬 Termitty Recording & Playback Example") 64 | print("=" * 50) 65 | 66 | # Update these with your credentials 67 | print("Note: Update the credentials in the script before running!") 68 | print() 69 | 70 | try: 71 | # Record a session 72 | print("📹 Recording session...") 73 | recording_file = record_example_session() 74 | 75 | print(f"\n⏱️ Waiting 2 seconds before playback...") 76 | time.sleep(2) 77 | 78 | # Play it back 79 | print("🎬 Playing back recording...") 80 | playback_session(recording_file) 81 | 82 | print(f"\n✨ Example complete! Recording saved as: {recording_file}") 83 | 84 | except Exception as e: 85 | print(f"❌ Error: {e}") 86 | print("Make sure to update the connection credentials in the script!") -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "termitty" 7 | version = "0.1.1" 8 | description = "A Selenium-inspired Python framework for terminal and SSH automation" 9 | readme = "README.md" 10 | authors = [ 11 | {name = "Deep", email = "hellol@termitty.com"}, 12 | ] 13 | license = {text = "MIT"} 14 | classifiers = [ 15 | "Development Status :: 2 - Pre-Alpha", 16 | "Intended Audience :: Developers", 17 | "License :: OSI Approved :: MIT License", 18 | "Operating System :: OS Independent", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Topic :: Software Development :: Libraries :: Python Modules", 25 | "Topic :: System :: Systems Administration", 26 | "Topic :: System :: Networking", 27 | ] 28 | keywords = ["ssh", "terminal", "automation", "devops", "remote execution"] 29 | requires-python = ">=3.8" 30 | dependencies = [ 31 | "paramiko>=3.0.0", 32 | "asyncssh>=2.14.0", 33 | "pyte>=0.8.1", 34 | "prompt_toolkit>=3.0.0", 35 | "rich>=13.0.0", 36 | ] 37 | 38 | [project.optional-dependencies] 39 | dev = [ 40 | "pytest>=7.0.0", 41 | "pytest-asyncio>=0.21.0", 42 | "pytest-cov>=4.0.0", 43 | "pytest-mock>=3.10.0", 44 | "black>=23.0.0", 45 | "isort>=5.12.0", 46 | "flake8>=6.0.0", 47 | "mypy>=1.0.0", 48 | "pre-commit>=3.0.0", 49 | "tox>=4.0.0", 50 | ] 51 | docs = [ 52 | "sphinx>=6.0.0", 53 | "sphinx-rtd-theme>=1.2.0", 54 | "sphinx-autodoc-typehints>=1.22.0", 55 | ] 56 | ui = [ 57 | "rich>=13.0.0", 58 | ] 59 | 60 | [project.urls] 61 | "Homepage" = "https://github.com/termitty/termitty" 62 | "Bug Reports" = "https://github.com/termitty/termitty/issues" 63 | "Source" = "https://github.com/termitty/termitty" 64 | "Documentation" = "https://termitty.readthedocs.io" 65 | 66 | [tool.setuptools.packages.find] 67 | where = ["."] 68 | include = ["termitty*"] 69 | exclude = ["tests*", "docs*", "examples*"] 70 | 71 | [tool.black] 72 | line-length = 88 73 | target-version = ['py38', 'py39', 'py310', 'py311'] 74 | include = '\.pyi?$' 75 | extend-exclude = ''' 76 | /( 77 | # directories 78 | \.eggs 79 | | \.git 80 | | \.hg 81 | | \.mypy_cache 82 | | \.tox 83 | | \.venv 84 | | build 85 | | dist 86 | )/ 87 | ''' 88 | 89 | [tool.isort] 90 | profile = "black" 91 | multi_line_output = 3 92 | include_trailing_comma = true 93 | force_grid_wrap = 0 94 | use_parentheses = true 95 | ensure_newline_before_comments = true 96 | line_length = 88 97 | 98 | [tool.pytest.ini_options] 99 | testpaths = ["tests"] 100 | python_files = "test_*.py" 101 | python_classes = "Test*" 102 | python_functions = "test_*" 103 | addopts = "-ra -q --strict-markers" 104 | asyncio_default_fixture_loop_scope = "function" 105 | markers = [ 106 | "slow: marks tests as slow (deselect with '-m \"not slow\"')", 107 | "integration: marks tests as integration tests", 108 | "unit: marks tests as unit tests", 109 | ] 110 | 111 | [tool.mypy] 112 | python_version = "3.8" 113 | warn_return_any = true 114 | warn_unused_configs = true 115 | disallow_untyped_defs = true 116 | disallow_incomplete_defs = true 117 | check_untyped_defs = true 118 | no_implicit_optional = true 119 | warn_redundant_casts = true 120 | warn_unused_ignores = true 121 | warn_no_return = true 122 | pretty = true 123 | show_error_codes = true 124 | show_error_context = true -------------------------------------------------------------------------------- /setup_dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Development setup script for Termitty 3 | # This script helps you set up a development environment for working on Termitty 4 | 5 | set -e # Exit on error 6 | 7 | echo "=== Termitty Development Setup ===" 8 | echo 9 | 10 | # Check Python version 11 | echo "Checking Python version..." 12 | python_version=$(python3 --version 2>&1) 13 | echo "Found: $python_version" 14 | 15 | # Check if we have at least Python 3.8 16 | if ! python3 -c 'import sys; exit(0 if sys.version_info >= (3, 8) else 1)'; then 17 | echo "Error: Python 3.8 or higher is required" 18 | exit 1 19 | fi 20 | 21 | # Create virtual environment 22 | echo 23 | echo "Creating virtual environment..." 24 | if [ ! -d "venv" ]; then 25 | python3 -m venv venv 26 | echo "Virtual environment created" 27 | else 28 | echo "Virtual environment already exists" 29 | fi 30 | 31 | # Activate virtual environment 32 | echo 33 | echo "Activating virtual environment..." 34 | source venv/bin/activate 35 | 36 | # Upgrade pip 37 | echo 38 | echo "Upgrading pip..." 39 | pip install --upgrade pip 40 | 41 | # Install dependencies 42 | echo 43 | echo "Installing dependencies..." 44 | pip install -e ".[dev,docs]" 45 | 46 | # Install pre-commit hooks 47 | echo 48 | echo "Setting up pre-commit hooks..." 49 | if [ -f ".pre-commit-config.yaml" ]; then 50 | pre-commit install 51 | echo "Pre-commit hooks installed" 52 | else 53 | echo "Creating basic pre-commit configuration..." 54 | cat > .pre-commit-config.yaml << 'EOF' 55 | repos: 56 | - repo: https://github.com/pre-commit/pre-commit-hooks 57 | rev: v4.4.0 58 | hooks: 59 | - id: trailing-whitespace 60 | - id: end-of-file-fixer 61 | - id: check-yaml 62 | - id: check-added-large-files 63 | 64 | - repo: https://github.com/psf/black 65 | rev: 23.1.0 66 | hooks: 67 | - id: black 68 | language_version: python3 69 | 70 | - repo: https://github.com/pycqa/isort 71 | rev: 5.12.0 72 | hooks: 73 | - id: isort 74 | 75 | - repo: https://github.com/pycqa/flake8 76 | rev: 6.0.0 77 | hooks: 78 | - id: flake8 79 | args: ['--max-line-length=88', '--extend-ignore=E203'] 80 | EOF 81 | pre-commit install 82 | fi 83 | 84 | # Create local configuration file 85 | echo 86 | echo "Creating local configuration file..." 87 | if [ ! -f "termitty.ini" ]; then 88 | cat > termitty.ini << 'EOF' 89 | # Local Termitty configuration for development 90 | # This file is ignored by git (.gitignore includes *.ini) 91 | 92 | [connection] 93 | default_timeout = 30.0 94 | known_hosts_policy = warning 95 | 96 | [terminal] 97 | encoding = utf-8 98 | 99 | [execution] 100 | default_timeout = 300.0 101 | check_return_code = false 102 | 103 | [logging] 104 | log_level = DEBUG 105 | log_commands = true 106 | log_output = false 107 | mask_passwords = true 108 | EOF 109 | echo "Created termitty.ini with development settings" 110 | else 111 | echo "termitty.ini already exists" 112 | fi 113 | 114 | # Run initial tests 115 | echo 116 | echo "Running tests to verify setup..." 117 | python -m pytest tests/unit/test_session.py -v 118 | 119 | echo 120 | echo "=== Setup Complete ===" 121 | echo 122 | echo "To activate the virtual environment in the future, run:" 123 | echo " source venv/bin/activate" 124 | echo 125 | echo "To run tests:" 126 | echo " pytest # Run all tests" 127 | echo " pytest tests/unit -v # Run unit tests with verbose output" 128 | echo " pytest -k test_execute # Run tests matching 'test_execute'" 129 | echo 130 | echo "To check code quality:" 131 | echo " black termitty/ # Format code" 132 | echo " isort termitty/ # Sort imports" 133 | echo " flake8 termitty/ # Check code style" 134 | echo " mypy termitty/ # Check type hints" 135 | echo 136 | echo "To build documentation:" 137 | echo " cd docs && make html" 138 | echo 139 | echo "Happy coding!" -------------------------------------------------------------------------------- /termitty/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Termitty - A Selenium-inspired Python framework for terminal and SSH automation. 3 | 4 | This package provides a high-level interface for automating terminal interactions, 5 | similar to how Selenium WebDriver automates web browsers. It handles SSH connections, 6 | command execution, output parsing, and interactive terminal sessions. 7 | 8 | Basic usage: 9 | from termitty import TermittySession 10 | 11 | with TermittySession() as session: 12 | session.connect('example.com', username='user') 13 | result = session.execute('ls -la') 14 | print(result.output) 15 | """ 16 | 17 | __version__ = "0.1.1" 18 | __author__ = "Termitty Contributors" 19 | __email__ = "your.email@example.com" 20 | __license__ = "MIT" 21 | 22 | # Import main classes for easier access 23 | # This allows: from termitty import TermittySession 24 | # Instead of: from termitty.session.session import TermittySession 25 | 26 | from .session.session import TermittySession, OutputContains, OutputMatches, PromptReady 27 | from .transport.base import CommandResult 28 | from .core.exceptions import ( 29 | TermittyException, 30 | ConnectionException, 31 | AuthenticationException, 32 | TimeoutException, 33 | CommandExecutionException, 34 | ElementNotFoundException, 35 | SessionStateException, 36 | ) 37 | from .core.config import config 38 | from .terminal.virtual_terminal import VirtualTerminal 39 | from .terminal.screen_buffer import ScreenBuffer 40 | from .interactive.shell import InteractiveShell 41 | from .interactive.key_codes import KeyCodes, SpecialKeys 42 | from .recording.recorder import SessionRecorder 43 | from .recording.player import SessionPlayer, PlaybackSpeed 44 | from .recording.storage import RecordingStorage 45 | 46 | # Define what should be imported with "from termitty import *" 47 | __all__ = [ 48 | # Main session class 49 | 'TermittySession', 50 | 51 | # Interactive shell 52 | 'InteractiveShell', 53 | 'KeyCodes', 54 | 'SpecialKeys', 55 | 56 | # Recording 57 | 'SessionRecorder', 58 | 'SessionPlayer', 59 | 'PlaybackSpeed', 60 | 'RecordingStorage', 61 | 62 | # Wait conditions 63 | 'OutputContains', 64 | 'OutputMatches', 65 | 'PromptReady', 66 | 67 | # Result types 68 | 'CommandResult', 69 | 70 | # Exceptions 71 | 'TermittyException', 72 | 'ConnectionException', 73 | 'AuthenticationException', 74 | 'TimeoutException', 75 | 'CommandExecutionException', 76 | 'ElementNotFoundException', 77 | 'SessionStateException', 78 | 79 | # Configuration 80 | 'config', 81 | 82 | # Terminal emulation 83 | 'VirtualTerminal', 84 | 'ScreenBuffer', 85 | ] 86 | 87 | # Logging setup 88 | import logging 89 | 90 | # Create a logger for the entire termitty package 91 | logger = logging.getLogger(__name__) 92 | 93 | # By default, we'll only show warnings and above 94 | logger.setLevel(logging.WARNING) 95 | 96 | # Create a null handler to prevent "No handlers found" warnings 97 | logger.addHandler(logging.NullHandler()) 98 | 99 | # Version check 100 | import sys 101 | if sys.version_info < (3, 8): 102 | raise ImportError("Termitty requires Python 3.8 or higher") -------------------------------------------------------------------------------- /termitty/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Termitty/termitty/97cbc4b6f5779377789a5e68730826aa8adcf480/termitty/core/__init__.py -------------------------------------------------------------------------------- /termitty/core/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration management for Termitty. 3 | 4 | This module handles all configuration aspects of Termitty, providing a centralized 5 | way to manage settings. It supports multiple configuration sources: 6 | 1. Default values (defined in code) 7 | 2. Configuration files (INI format) 8 | 3. Environment variables 9 | 4. Runtime modifications 10 | 11 | The precedence order (highest to lowest): 12 | Runtime modifications > Environment variables > Config files > Defaults 13 | """ 14 | 15 | import os 16 | import configparser 17 | from dataclasses import dataclass, field 18 | from pathlib import Path 19 | from typing import Optional, Dict, Any 20 | import logging 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | @dataclass 26 | class ConnectionConfig: 27 | """Configuration for SSH connections.""" 28 | 29 | default_port: int = 22 30 | default_timeout: float = 30.0 31 | connection_retries: int = 3 32 | retry_delay: float = 1.0 33 | keepalive_interval: float = 5.0 34 | # Authentication preferences in order 35 | auth_methods: list = field(default_factory=lambda: ['publickey', 'password', 'keyboard-interactive']) 36 | known_hosts_policy: str = 'warning' # 'strict', 'warning', 'auto-add' 37 | compression: bool = False 38 | 39 | def __post_init__(self): 40 | """Validate configuration after initialization.""" 41 | if self.default_port < 1 or self.default_port > 65535: 42 | raise ValueError(f"Invalid port number: {self.default_port}") 43 | 44 | if self.default_timeout <= 0: 45 | raise ValueError(f"Timeout must be positive: {self.default_timeout}") 46 | 47 | valid_policies = {'strict', 'warning', 'auto-add'} 48 | if self.known_hosts_policy not in valid_policies: 49 | raise ValueError(f"Invalid known_hosts_policy: {self.known_hosts_policy}. " 50 | f"Must be one of {valid_policies}") 51 | 52 | 53 | @dataclass 54 | class TerminalConfig: 55 | """Configuration for terminal emulation and interaction.""" 56 | 57 | terminal_type: str = 'xterm-256color' 58 | width: int = 80 59 | height: int = 24 60 | encoding: str = 'utf-8' 61 | line_ending: str = '\n' # or '\r\n' for Windows 62 | echo: bool = True # Whether to echo commands in output 63 | ansi_colors: bool = True # Whether to process ANSI color codes 64 | buffer_size: int = 1024 * 64 # 64KB buffer for reading output 65 | 66 | 67 | @dataclass 68 | class ExecutionConfig: 69 | """Configuration for command execution behavior.""" 70 | 71 | default_timeout: float = 300.0 # 5 minutes for long-running commands 72 | check_return_code: bool = False # Whether to raise on non-zero exit codes 73 | capture_stderr: bool = True 74 | environment_handling: str = 'merge' # 'merge', 'replace', or 'inherit' 75 | shell: str = '/bin/bash' 76 | shell_prompt_pattern: str = r'[$#] ' # Regex pattern for detecting prompts 77 | sudo_password_prompt: str = r'\[sudo\] password' 78 | working_directory_tracking: bool = True 79 | 80 | 81 | @dataclass 82 | class LoggingConfig: 83 | """Configuration for logging behavior.""" 84 | 85 | log_level: str = 'WARNING' 86 | log_commands: bool = True 87 | log_output: bool = False # Can be verbose 88 | mask_passwords: bool = True 89 | log_format: str = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' 90 | debug_ssh_transport: bool = False # Enable paramiko/asyncssh debug logging 91 | 92 | 93 | class TermittyConfig: 94 | """ 95 | Main configuration class that combines all configuration sections. 96 | 97 | This class handles loading configuration from various sources and provides 98 | a unified interface for accessing configuration values throughout Termitty. 99 | """ 100 | 101 | def __init__(self): 102 | """Initialize with default configurations.""" 103 | self.connection = ConnectionConfig() 104 | self.terminal = TerminalConfig() 105 | self.execution = ExecutionConfig() 106 | self.logging = LoggingConfig() 107 | 108 | # Track which config file we loaded from (if any) 109 | self._config_file: Optional[Path] = None 110 | 111 | # Allow runtime overrides 112 | self._overrides: Dict[str, Dict[str, Any]] = {} 113 | 114 | # Load configurations in order of precedence 115 | self._load_default_config_file() 116 | self._load_environment_variables() 117 | self._apply_logging_config() 118 | 119 | def _load_default_config_file(self): 120 | """ 121 | Load configuration from default file locations. 122 | 123 | Searches for config files in order: 124 | 1. ./termitty.ini (current directory) 125 | 2. ~/.termitty/config.ini (user home) 126 | 3. /etc/termitty/config.ini (system-wide) 127 | """ 128 | search_paths = [ 129 | Path.cwd() / 'termitty.ini', 130 | Path.home() / '.termitty' / 'config.ini', 131 | Path('/etc/termitty/config.ini') 132 | ] 133 | 134 | for config_path in search_paths: 135 | if config_path.exists(): 136 | try: 137 | self.load_from_file(config_path) 138 | logger.info(f"Loaded configuration from {config_path}") 139 | break 140 | except Exception as e: 141 | logger.warning(f"Failed to load config from {config_path}: {e}") 142 | 143 | def load_from_file(self, file_path: Path): 144 | """Load configuration from an INI file.""" 145 | parser = configparser.ConfigParser() 146 | parser.read(file_path) 147 | 148 | self._config_file = file_path 149 | 150 | # Map INI sections to our configuration objects 151 | section_mapping = { 152 | 'connection': self.connection, 153 | 'terminal': self.terminal, 154 | 'execution': self.execution, 155 | 'logging': self.logging 156 | } 157 | 158 | for section_name, config_obj in section_mapping.items(): 159 | if section_name in parser: 160 | for key, value in parser[section_name].items(): 161 | if hasattr(config_obj, key): 162 | # Convert string values to appropriate types 163 | current_value = getattr(config_obj, key) 164 | converted_value = self._convert_config_value(value, type(current_value)) 165 | setattr(config_obj, key, converted_value) 166 | else: 167 | logger.warning(f"Unknown configuration key: {section_name}.{key}") 168 | 169 | def _convert_config_value(self, value: str, target_type: type) -> Any: 170 | """Convert string configuration values to appropriate Python types.""" 171 | if target_type == bool: 172 | return value.lower() in ('true', 'yes', '1', 'on') 173 | elif target_type == int: 174 | return int(value) 175 | elif target_type == float: 176 | return float(value) 177 | elif target_type == list: 178 | # Assume comma-separated values 179 | return [v.strip() for v in value.split(',') if v.strip()] 180 | else: 181 | return value 182 | 183 | def _load_environment_variables(self): 184 | """ 185 | Load configuration from environment variables. 186 | 187 | Environment variables follow the pattern: TERMITTY_SECTION_KEY 188 | For example: TERMITTY_CONNECTION_DEFAULT_PORT=2222 189 | """ 190 | prefix = 'TERMITTY_' 191 | 192 | for env_var, value in os.environ.items(): 193 | if env_var.startswith(prefix): 194 | parts = env_var[len(prefix):].lower().split('_', 1) 195 | if len(parts) == 2: 196 | section, key = parts 197 | self._apply_override(section, key, value) 198 | 199 | def _apply_override(self, section: str, key: str, value: Any): 200 | """Apply a configuration override.""" 201 | section_mapping = { 202 | 'connection': self.connection, 203 | 'terminal': self.terminal, 204 | 'execution': self.execution, 205 | 'logging': self.logging 206 | } 207 | 208 | if section in section_mapping: 209 | config_obj = section_mapping[section] 210 | if hasattr(config_obj, key): 211 | current_value = getattr(config_obj, key) 212 | converted_value = self._convert_config_value(str(value), type(current_value)) 213 | setattr(config_obj, key, converted_value) 214 | 215 | # Track this override 216 | if section not in self._overrides: 217 | self._overrides[section] = {} 218 | self._overrides[section][key] = converted_value 219 | 220 | logger.debug(f"Applied config override: {section}.{key} = {converted_value}") 221 | 222 | def _apply_logging_config(self): 223 | """Apply the logging configuration to Python's logging system.""" 224 | log_level = getattr(logging, self.logging.log_level.upper(), logging.WARNING) 225 | 226 | # Configure Termitty's logger 227 | termitty_logger = logging.getLogger('termitty') 228 | termitty_logger.setLevel(log_level) 229 | 230 | # Configure SSH library logging if debug is enabled 231 | if self.logging.debug_ssh_transport: 232 | logging.getLogger('paramiko').setLevel(logging.DEBUG) 233 | logging.getLogger('asyncssh').setLevel(logging.DEBUG) 234 | 235 | def set(self, section: str, key: str, value: Any): 236 | """ 237 | Set a configuration value at runtime. 238 | 239 | Args: 240 | section: Configuration section ('connection', 'terminal', etc.) 241 | key: Configuration key 242 | value: New value 243 | 244 | Example: 245 | config.set('connection', 'default_timeout', 60.0) 246 | """ 247 | self._apply_override(section, key, value) 248 | 249 | def get_all_settings(self) -> Dict[str, Dict[str, Any]]: 250 | """Get all current configuration settings as a dictionary.""" 251 | return { 252 | 'connection': self.connection.__dict__, 253 | 'terminal': self.terminal.__dict__, 254 | 'execution': self.execution.__dict__, 255 | 'logging': self.logging.__dict__, 256 | 'config_file': str(self._config_file) if self._config_file else None, 257 | 'overrides': self._overrides 258 | } 259 | 260 | 261 | # Global configuration instance 262 | # This allows all parts of Termitty to access the same configuration 263 | config = TermittyConfig() -------------------------------------------------------------------------------- /termitty/core/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Termitty exception hierarchy. 3 | 4 | This module defines all custom exceptions used throughout Termitty. Having a 5 | well-organized exception hierarchy helps users handle errors appropriately 6 | and provides clear information about what went wrong. 7 | 8 | The exception hierarchy mirrors Selenium's approach where possible, making it 9 | familiar to developers who have used Selenium WebDriver. 10 | """ 11 | 12 | class TermittyException(Exception): 13 | """ 14 | Base exception for all Termitty-related errors. 15 | 16 | This is the root of our exception hierarchy. Users can catch this 17 | to handle any Termitty error, though catching more specific exceptions 18 | is usually better. 19 | """ 20 | pass 21 | 22 | 23 | class ConnectionException(TermittyException): 24 | """ 25 | Raised when there are issues establishing or maintaining SSH connections. 26 | 27 | This could be due to network issues, authentication failures, or the 28 | remote host being unavailable. 29 | """ 30 | pass 31 | 32 | 33 | class AuthenticationException(ConnectionException): 34 | """ 35 | Raised when SSH authentication fails. 36 | 37 | This is a specific type of ConnectionException that occurs when 38 | credentials are invalid or authentication methods fail. 39 | """ 40 | 41 | def __init__(self, message: str, tried_methods: list = None): 42 | super().__init__(message) 43 | self.tried_methods = tried_methods or [] 44 | 45 | def __str__(self): 46 | base_msg = super().__str__() 47 | if self.tried_methods: 48 | return f"{base_msg} (tried methods: {', '.join(self.tried_methods)})" 49 | return base_msg 50 | 51 | 52 | class TimeoutException(TermittyException): 53 | """ 54 | Raised when operations exceed their allowed time. 55 | 56 | Similar to Selenium's TimeoutException, this occurs when waiting for 57 | conditions, command execution, or connection attempts take too long. 58 | """ 59 | 60 | def __init__(self, message: str, timeout: float = None): 61 | super().__init__(message) 62 | self.timeout = timeout 63 | 64 | def __str__(self): 65 | base_msg = super().__str__() 66 | if self.timeout: 67 | return f"{base_msg} (timeout: {self.timeout}s)" 68 | return base_msg 69 | 70 | 71 | class CommandExecutionException(TermittyException): 72 | """ 73 | Raised when command execution fails. 74 | 75 | This doesn't mean the command returned a non-zero exit code (that's 76 | normal and expected). This is for actual execution failures. 77 | """ 78 | 79 | def __init__(self, message: str, command: str = None, exit_code: int = None): 80 | super().__init__(message) 81 | self.command = command 82 | self.exit_code = exit_code 83 | 84 | def __str__(self): 85 | parts = [super().__str__()] 86 | if self.command: 87 | parts.append(f"Command: {self.command}") 88 | if self.exit_code is not None: 89 | parts.append(f"Exit code: {self.exit_code}") 90 | return " | ".join(parts) 91 | 92 | 93 | class ElementNotFoundException(TermittyException): 94 | """ 95 | Raised when a terminal element (prompt, pattern, etc.) cannot be found. 96 | 97 | This mirrors Selenium's NoSuchElementException and occurs when searching 98 | for specific patterns or prompts in terminal output fails. 99 | """ 100 | 101 | def __init__(self, message: str, pattern: str = None): 102 | super().__init__(message) 103 | self.pattern = pattern 104 | 105 | def __str__(self): 106 | base_msg = super().__str__() 107 | if self.pattern: 108 | return f"{base_msg} (pattern: {self.pattern})" 109 | return base_msg 110 | 111 | 112 | class SessionStateException(TermittyException): 113 | """ 114 | Raised when operations are attempted on sessions in invalid states. 115 | 116 | For example, trying to execute commands on a disconnected session 117 | or attempting to connect an already connected session. 118 | """ 119 | pass 120 | 121 | 122 | class TerminalParsingException(TermittyException): 123 | """ 124 | Raised when terminal output cannot be parsed correctly. 125 | 126 | This might occur with malformed ANSI escape sequences or 127 | unexpected terminal behavior. 128 | """ 129 | pass 130 | 131 | 132 | class UnsupportedOperationException(TermittyException): 133 | """ 134 | Raised when an operation is not supported in the current context. 135 | 136 | For example, certain terminal operations might not be available 137 | on all systems or terminal types. 138 | """ 139 | pass -------------------------------------------------------------------------------- /termitty/interactive/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Interactive shell components for Termitty. 3 | 4 | This package provides interactive shell session capabilities, allowing 5 | real-time interaction with terminal programs like vim, top, interactive 6 | installers, and maintaining persistent shell sessions. 7 | 8 | Phase 3 additions that enable: 9 | - Persistent shell sessions with state 10 | - Real-time I/O with running programs 11 | - Special key support (Ctrl+C, ESC, Tab, etc.) 12 | - PTY (pseudo-terminal) for full terminal emulation 13 | - Interactive program automation (vim, nano, top, etc.) 14 | """ 15 | 16 | from .shell import InteractiveShell 17 | from .key_codes import KeyCodes, SpecialKeys 18 | from .io_handler import IOHandler 19 | from .patterns import ShellPatterns, PatternMatcher 20 | 21 | __all__ = [ 22 | 'InteractiveShell', 23 | 'KeyCodes', 24 | 'SpecialKeys', 25 | 'IOHandler', 26 | 'ShellPatterns', 27 | 'PatternMatcher', 28 | ] -------------------------------------------------------------------------------- /termitty/interactive/key_codes.py: -------------------------------------------------------------------------------- 1 | """ 2 | Key codes and special key handling for interactive shells. 3 | 4 | This module defines the special key sequences needed for terminal interaction, 5 | including control characters, escape sequences, and function keys. 6 | """ 7 | 8 | from enum import Enum 9 | from typing import Dict, Union 10 | 11 | 12 | class SpecialKeys(Enum): 13 | """Common special keys used in terminal interaction.""" 14 | 15 | # Control keys (Ctrl+letter) 16 | CTRL_A = '\x01' # Start of line 17 | CTRL_B = '\x02' # Back one character 18 | CTRL_C = '\x03' # Interrupt (SIGINT) 19 | CTRL_D = '\x04' # EOF/logout 20 | CTRL_E = '\x05' # End of line 21 | CTRL_F = '\x06' # Forward one character 22 | CTRL_G = '\x07' # Bell 23 | CTRL_H = '\x08' # Backspace 24 | CTRL_I = '\x09' # Tab 25 | CTRL_J = '\x0a' # Line feed (Enter) 26 | CTRL_K = '\x0b' # Kill line 27 | CTRL_L = '\x0c' # Clear screen 28 | CTRL_M = '\x0d' # Carriage return 29 | CTRL_N = '\x0e' # Next history 30 | CTRL_O = '\x0f' # Execute command 31 | CTRL_P = '\x10' # Previous history 32 | CTRL_Q = '\x11' # Resume transmission (XON) 33 | CTRL_R = '\x12' # Reverse search 34 | CTRL_S = '\x13' # Pause transmission (XOFF) 35 | CTRL_T = '\x14' # Transpose characters 36 | CTRL_U = '\x15' # Kill line backward 37 | CTRL_V = '\x16' # Literal next 38 | CTRL_W = '\x17' # Kill word backward 39 | CTRL_X = '\x18' # Command prefix 40 | CTRL_Y = '\x19' # Yank 41 | CTRL_Z = '\x1a' # Suspend (SIGTSTP) 42 | 43 | # Other special characters 44 | ESCAPE = '\x1b' # ESC key 45 | DELETE = '\x7f' # Delete key 46 | 47 | # Common key combinations 48 | TAB = CTRL_I 49 | ENTER = CTRL_J 50 | RETURN = CTRL_M 51 | BACKSPACE = CTRL_H 52 | 53 | # Arrow keys (ANSI escape sequences) 54 | UP = '\x1b[A' 55 | DOWN = '\x1b[B' 56 | RIGHT = '\x1b[C' 57 | LEFT = '\x1b[D' 58 | 59 | # Function keys 60 | F1 = '\x1bOP' 61 | F2 = '\x1bOQ' 62 | F3 = '\x1bOR' 63 | F4 = '\x1bOS' 64 | F5 = '\x1b[15~' 65 | F6 = '\x1b[17~' 66 | F7 = '\x1b[18~' 67 | F8 = '\x1b[19~' 68 | F9 = '\x1b[20~' 69 | F10 = '\x1b[21~' 70 | F11 = '\x1b[23~' 71 | F12 = '\x1b[24~' 72 | 73 | # Navigation keys 74 | HOME = '\x1b[H' 75 | END = '\x1b[F' 76 | PAGE_UP = '\x1b[5~' 77 | PAGE_DOWN = '\x1b[6~' 78 | INSERT = '\x1b[2~' 79 | DELETE_FORWARD = '\x1b[3~' 80 | 81 | 82 | class KeyCodes: 83 | """Utility class for working with key codes and sequences.""" 84 | 85 | # Mapping of common key names to their codes 86 | KEY_NAMES: Dict[str, str] = { 87 | # Control keys 88 | 'ctrl+a': SpecialKeys.CTRL_A.value, 89 | 'ctrl+b': SpecialKeys.CTRL_B.value, 90 | 'ctrl+c': SpecialKeys.CTRL_C.value, 91 | 'ctrl+d': SpecialKeys.CTRL_D.value, 92 | 'ctrl+e': SpecialKeys.CTRL_E.value, 93 | 'ctrl+f': SpecialKeys.CTRL_F.value, 94 | 'ctrl+g': SpecialKeys.CTRL_G.value, 95 | 'ctrl+h': SpecialKeys.CTRL_H.value, 96 | 'ctrl+i': SpecialKeys.CTRL_I.value, 97 | 'ctrl+j': SpecialKeys.CTRL_J.value, 98 | 'ctrl+k': SpecialKeys.CTRL_K.value, 99 | 'ctrl+l': SpecialKeys.CTRL_L.value, 100 | 'ctrl+m': SpecialKeys.CTRL_M.value, 101 | 'ctrl+n': SpecialKeys.CTRL_N.value, 102 | 'ctrl+o': SpecialKeys.CTRL_O.value, 103 | 'ctrl+p': SpecialKeys.CTRL_P.value, 104 | 'ctrl+q': SpecialKeys.CTRL_Q.value, 105 | 'ctrl+r': SpecialKeys.CTRL_R.value, 106 | 'ctrl+s': SpecialKeys.CTRL_S.value, 107 | 'ctrl+t': SpecialKeys.CTRL_T.value, 108 | 'ctrl+u': SpecialKeys.CTRL_U.value, 109 | 'ctrl+v': SpecialKeys.CTRL_V.value, 110 | 'ctrl+w': SpecialKeys.CTRL_W.value, 111 | 'ctrl+x': SpecialKeys.CTRL_X.value, 112 | 'ctrl+y': SpecialKeys.CTRL_Y.value, 113 | 'ctrl+z': SpecialKeys.CTRL_Z.value, 114 | 115 | # Common names 116 | 'esc': SpecialKeys.ESCAPE.value, 117 | 'escape': SpecialKeys.ESCAPE.value, 118 | 'tab': SpecialKeys.TAB.value, 119 | 'enter': SpecialKeys.ENTER.value, 120 | 'return': SpecialKeys.RETURN.value, 121 | 'backspace': SpecialKeys.BACKSPACE.value, 122 | 'delete': SpecialKeys.DELETE.value, 123 | 'del': SpecialKeys.DELETE.value, 124 | 125 | # Arrow keys 126 | 'up': SpecialKeys.UP.value, 127 | 'down': SpecialKeys.DOWN.value, 128 | 'left': SpecialKeys.LEFT.value, 129 | 'right': SpecialKeys.RIGHT.value, 130 | 131 | # Navigation 132 | 'home': SpecialKeys.HOME.value, 133 | 'end': SpecialKeys.END.value, 134 | 'pageup': SpecialKeys.PAGE_UP.value, 135 | 'pagedown': SpecialKeys.PAGE_DOWN.value, 136 | 'insert': SpecialKeys.INSERT.value, 137 | 138 | # Function keys 139 | 'f1': SpecialKeys.F1.value, 140 | 'f2': SpecialKeys.F2.value, 141 | 'f3': SpecialKeys.F3.value, 142 | 'f4': SpecialKeys.F4.value, 143 | 'f5': SpecialKeys.F5.value, 144 | 'f6': SpecialKeys.F6.value, 145 | 'f7': SpecialKeys.F7.value, 146 | 'f8': SpecialKeys.F8.value, 147 | 'f9': SpecialKeys.F9.value, 148 | 'f10': SpecialKeys.F10.value, 149 | 'f11': SpecialKeys.F11.value, 150 | 'f12': SpecialKeys.F12.value, 151 | } 152 | 153 | @classmethod 154 | def get_key_code(cls, key_name: str) -> str: 155 | """ 156 | Get the key code for a given key name. 157 | 158 | Args: 159 | key_name: Name of the key (e.g., 'ctrl+c', 'escape', 'enter') 160 | 161 | Returns: 162 | The key code sequence 163 | 164 | Raises: 165 | ValueError: If key name is not recognized 166 | 167 | Example: 168 | >>> KeyCodes.get_key_code('ctrl+c') 169 | '\\x03' 170 | >>> KeyCodes.get_key_code('escape') 171 | '\\x1b' 172 | """ 173 | normalized = key_name.lower().replace(' ', '').replace('-', '+') 174 | if normalized in cls.KEY_NAMES: 175 | return cls.KEY_NAMES[normalized] 176 | else: 177 | raise ValueError(f"Unknown key name: {key_name}") 178 | 179 | @classmethod 180 | def create_ctrl_key(cls, letter: str) -> str: 181 | """ 182 | Create a control key sequence for a given letter. 183 | 184 | Args: 185 | letter: Single letter (a-z) 186 | 187 | Returns: 188 | Control key sequence 189 | 190 | Example: 191 | >>> KeyCodes.create_ctrl_key('c') 192 | '\\x03' 193 | """ 194 | if len(letter) != 1 or not letter.isalpha(): 195 | raise ValueError("Must provide a single letter") 196 | 197 | # Convert to uppercase and get ASCII value 198 | ascii_val = ord(letter.upper()) 199 | # Control keys are ASCII value - 64 200 | ctrl_val = ascii_val - 64 201 | return chr(ctrl_val) 202 | 203 | @classmethod 204 | def is_printable(cls, char: str) -> bool: 205 | """ 206 | Check if a character is printable (not a control character). 207 | 208 | Args: 209 | char: Character to check 210 | 211 | Returns: 212 | True if printable, False otherwise 213 | """ 214 | if not char: 215 | return False 216 | 217 | code = ord(char[0]) 218 | # Printable ASCII range is 32-126 219 | return 32 <= code <= 126 220 | 221 | @classmethod 222 | def format_key_sequence(cls, sequence: Union[str, bytes]) -> str: 223 | """ 224 | Format a key sequence for display. 225 | 226 | Args: 227 | sequence: Key sequence to format 228 | 229 | Returns: 230 | Human-readable representation 231 | 232 | Example: 233 | >>> KeyCodes.format_key_sequence('\\x03') 234 | '^C' 235 | >>> KeyCodes.format_key_sequence('\\x1b[A') 236 | '' 237 | """ 238 | if isinstance(sequence, bytes): 239 | sequence = sequence.decode('utf-8', errors='replace') 240 | 241 | # Check for known sequences 242 | for name, code in cls.KEY_NAMES.items(): 243 | if code == sequence: 244 | if name.startswith('ctrl+'): 245 | return f"^{name[5:].upper()}" 246 | else: 247 | return f"<{name.capitalize()}>" 248 | 249 | # Format unknown control characters 250 | if sequence and ord(sequence[0]) < 32: 251 | return f"^{chr(ord(sequence[0]) + 64)}" 252 | 253 | return repr(sequence) -------------------------------------------------------------------------------- /termitty/interactive/patterns.py: -------------------------------------------------------------------------------- 1 | """ 2 | Pattern matching for interactive shell sessions. 3 | 4 | This module provides pattern matching capabilities for detecting prompts, 5 | command completion, and other interactive elements in shell sessions. 6 | """ 7 | 8 | import re 9 | import time 10 | from typing import List, Pattern, Union, Optional, Callable, Tuple 11 | from dataclasses import dataclass 12 | from enum import Enum 13 | import logging 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class PatternType(Enum): 19 | """Types of patterns to match.""" 20 | PROMPT = "prompt" 21 | PASSWORD = "password" 22 | CONFIRMATION = "confirmation" 23 | ERROR = "error" 24 | SUCCESS = "success" 25 | CUSTOM = "custom" 26 | 27 | 28 | @dataclass 29 | class PatternMatch: 30 | """Result of a pattern match.""" 31 | pattern_type: PatternType 32 | matched_text: str 33 | pattern: Union[str, Pattern] 34 | position: int 35 | line: Optional[str] = None 36 | 37 | def __str__(self): 38 | return f"{self.pattern_type.value}: {self.matched_text}" 39 | 40 | 41 | class ShellPatterns: 42 | """Common patterns for shell interaction.""" 43 | 44 | # Common shell prompts 45 | BASH_PROMPT = re.compile(r'[\$#]\s*$') 46 | ZSH_PROMPT = re.compile(r'[%#]\s*$') 47 | GENERAL_PROMPT = re.compile(r'[$#>%]\s*$') 48 | 49 | # Password prompts 50 | PASSWORD_PROMPTS = [ 51 | re.compile(r'password[:\s]*$', re.IGNORECASE), 52 | re.compile(r'pass[:\s]*$', re.IGNORECASE), 53 | re.compile(r'passphrase[:\s]*$', re.IGNORECASE), 54 | re.compile(r'\[sudo\]\s+password', re.IGNORECASE), 55 | re.compile(r'enter\s+password', re.IGNORECASE), 56 | ] 57 | 58 | # Confirmation prompts 59 | CONFIRMATION_PROMPTS = [ 60 | re.compile(r'\(y/n\)[:\s]*$', re.IGNORECASE), 61 | re.compile(r'\(yes/no\)[:\s]*$', re.IGNORECASE), 62 | re.compile(r'continue\s*\?[:\s]*$', re.IGNORECASE), 63 | re.compile(r'proceed\s*\?[:\s]*$', re.IGNORECASE), 64 | re.compile(r'are\s+you\s+sure', re.IGNORECASE), 65 | ] 66 | 67 | # Error patterns 68 | ERROR_PATTERNS = [ 69 | re.compile(r'error:', re.IGNORECASE), 70 | re.compile(r'failed:', re.IGNORECASE), 71 | re.compile(r'command not found'), 72 | re.compile(r'permission denied', re.IGNORECASE), 73 | re.compile(r'no such file or directory', re.IGNORECASE), 74 | ] 75 | 76 | # Success patterns 77 | SUCCESS_PATTERNS = [ 78 | re.compile(r'success', re.IGNORECASE), 79 | re.compile(r'completed?', re.IGNORECASE), 80 | re.compile(r'done\.?$', re.IGNORECASE), 81 | re.compile(r'finished', re.IGNORECASE), 82 | ] 83 | 84 | # Program-specific patterns 85 | VIM_PATTERNS = { 86 | 'normal_mode': re.compile(r'^\s*$'), # Empty line in vim 87 | 'insert_mode': re.compile(r'-- INSERT --'), 88 | 'visual_mode': re.compile(r'-- VISUAL --'), 89 | 'command_mode': re.compile(r'^:'), 90 | } 91 | 92 | LESS_PATTERNS = { 93 | 'prompt': re.compile(r'^:$'), 94 | 'end': re.compile(r'\(END\)'), 95 | } 96 | 97 | TOP_PATTERNS = { 98 | 'header': re.compile(r'top\s+-\s+\d+:\d+:\d+'), 99 | 'prompt': re.compile(r'^$'), # Empty line for commands 100 | } 101 | 102 | 103 | class PatternMatcher: 104 | """ 105 | Matches patterns in shell output. 106 | 107 | This class provides flexible pattern matching for interactive shells, 108 | allowing detection of prompts, errors, and other significant patterns. 109 | """ 110 | 111 | def __init__(self): 112 | """Initialize the pattern matcher.""" 113 | self.custom_patterns: List[Tuple[PatternType, Union[str, Pattern]]] = [] 114 | self._last_match_time = 0.0 115 | self._match_cache = {} 116 | 117 | def add_pattern(self, 118 | pattern: Union[str, Pattern], 119 | pattern_type: PatternType = PatternType.CUSTOM): 120 | """ 121 | Add a custom pattern to match. 122 | 123 | Args: 124 | pattern: Regular expression pattern 125 | pattern_type: Type of pattern 126 | """ 127 | if isinstance(pattern, str): 128 | pattern = re.compile(pattern) 129 | self.custom_patterns.append((pattern_type, pattern)) 130 | 131 | def find_prompt(self, 132 | text: str, 133 | custom_prompts: Optional[List[Union[str, Pattern]]] = None) -> Optional[PatternMatch]: 134 | """ 135 | Find a shell prompt in text. 136 | 137 | Args: 138 | text: Text to search 139 | custom_prompts: Additional prompt patterns to check 140 | 141 | Returns: 142 | PatternMatch if found, None otherwise 143 | """ 144 | # Check custom prompts first 145 | if custom_prompts: 146 | for prompt in custom_prompts: 147 | if isinstance(prompt, str): 148 | prompt = re.compile(prompt) 149 | match = prompt.search(text) 150 | if match: 151 | return PatternMatch( 152 | pattern_type=PatternType.PROMPT, 153 | matched_text=match.group(0), 154 | pattern=prompt, 155 | position=match.start() 156 | ) 157 | 158 | # Check standard prompts 159 | for prompt in [ShellPatterns.BASH_PROMPT, 160 | ShellPatterns.ZSH_PROMPT, 161 | ShellPatterns.GENERAL_PROMPT]: 162 | match = prompt.search(text) 163 | if match: 164 | return PatternMatch( 165 | pattern_type=PatternType.PROMPT, 166 | matched_text=match.group(0), 167 | pattern=prompt, 168 | position=match.start() 169 | ) 170 | 171 | return None 172 | 173 | def find_password_prompt(self, text: str) -> Optional[PatternMatch]: 174 | """ 175 | Find a password prompt in text. 176 | 177 | Args: 178 | text: Text to search 179 | 180 | Returns: 181 | PatternMatch if found, None otherwise 182 | """ 183 | for pattern in ShellPatterns.PASSWORD_PROMPTS: 184 | match = pattern.search(text) 185 | if match: 186 | return PatternMatch( 187 | pattern_type=PatternType.PASSWORD, 188 | matched_text=match.group(0), 189 | pattern=pattern, 190 | position=match.start() 191 | ) 192 | return None 193 | 194 | def find_confirmation(self, text: str) -> Optional[PatternMatch]: 195 | """ 196 | Find a confirmation prompt in text. 197 | 198 | Args: 199 | text: Text to search 200 | 201 | Returns: 202 | PatternMatch if found, None otherwise 203 | """ 204 | for pattern in ShellPatterns.CONFIRMATION_PROMPTS: 205 | match = pattern.search(text) 206 | if match: 207 | return PatternMatch( 208 | pattern_type=PatternType.CONFIRMATION, 209 | matched_text=match.group(0), 210 | pattern=pattern, 211 | position=match.start() 212 | ) 213 | return None 214 | 215 | def find_error(self, text: str) -> Optional[PatternMatch]: 216 | """ 217 | Find an error message in text. 218 | 219 | Args: 220 | text: Text to search 221 | 222 | Returns: 223 | PatternMatch if found, None otherwise 224 | """ 225 | for pattern in ShellPatterns.ERROR_PATTERNS: 226 | match = pattern.search(text) 227 | if match: 228 | return PatternMatch( 229 | pattern_type=PatternType.ERROR, 230 | matched_text=match.group(0), 231 | pattern=pattern, 232 | position=match.start() 233 | ) 234 | return None 235 | 236 | def find_all_patterns(self, text: str) -> List[PatternMatch]: 237 | """ 238 | Find all matching patterns in text. 239 | 240 | Args: 241 | text: Text to search 242 | 243 | Returns: 244 | List of all pattern matches 245 | """ 246 | matches = [] 247 | 248 | # Check for prompts 249 | prompt = self.find_prompt(text) 250 | if prompt: 251 | matches.append(prompt) 252 | 253 | # Check for password prompts 254 | password = self.find_password_prompt(text) 255 | if password: 256 | matches.append(password) 257 | 258 | # Check for confirmations 259 | confirmation = self.find_confirmation(text) 260 | if confirmation: 261 | matches.append(confirmation) 262 | 263 | # Check for errors 264 | error = self.find_error(text) 265 | if error: 266 | matches.append(error) 267 | 268 | # Check custom patterns 269 | for pattern_type, pattern in self.custom_patterns: 270 | match = pattern.search(text) 271 | if match: 272 | matches.append(PatternMatch( 273 | pattern_type=pattern_type, 274 | matched_text=match.group(0), 275 | pattern=pattern, 276 | position=match.start() 277 | )) 278 | 279 | return matches 280 | 281 | def wait_for_pattern(self, 282 | get_text_func: Callable[[], str], 283 | patterns: List[Union[str, Pattern]], 284 | timeout: float = 30.0, 285 | poll_interval: float = 0.1) -> Optional[PatternMatch]: 286 | """ 287 | Wait for any of the given patterns to appear. 288 | 289 | Args: 290 | get_text_func: Function that returns current text 291 | patterns: List of patterns to wait for 292 | timeout: Maximum time to wait 293 | poll_interval: How often to check 294 | 295 | Returns: 296 | First matching pattern, or None if timeout 297 | """ 298 | # Compile string patterns 299 | compiled_patterns = [] 300 | for p in patterns: 301 | if isinstance(p, str): 302 | compiled_patterns.append(re.compile(p)) 303 | else: 304 | compiled_patterns.append(p) 305 | 306 | start_time = time.time() 307 | 308 | while time.time() - start_time < timeout: 309 | text = get_text_func() 310 | 311 | for pattern in compiled_patterns: 312 | match = pattern.search(text) 313 | if match: 314 | return PatternMatch( 315 | pattern_type=PatternType.CUSTOM, 316 | matched_text=match.group(0), 317 | pattern=pattern, 318 | position=match.start() 319 | ) 320 | 321 | time.sleep(poll_interval) 322 | 323 | return None 324 | 325 | def extract_prompt_line(self, text: str) -> Optional[str]: 326 | """ 327 | Extract the line containing the prompt. 328 | 329 | Args: 330 | text: Text to search 331 | 332 | Returns: 333 | The line containing the prompt, or None 334 | """ 335 | lines = text.split('\n') 336 | 337 | # Search from bottom up (prompts usually at the end) 338 | for line in reversed(lines): 339 | if self.find_prompt(line): 340 | return line 341 | 342 | return None -------------------------------------------------------------------------------- /termitty/parallel/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Parallel execution components for Termitty. 3 | 4 | This package provides functionality for executing commands across multiple 5 | hosts in parallel, with support for different execution strategies, 6 | connection pooling, and comprehensive result handling. 7 | 8 | Key components: 9 | - ParallelExecutor: Main class for parallel command execution 10 | - ConnectionPool: Efficient connection management 11 | - ParallelResults: Result aggregation and analysis 12 | - ExecutionStrategy: Different execution patterns (parallel, rolling, sequential) 13 | 14 | Example: 15 | from termitty.parallel import ParallelExecutor, HostConfig 16 | 17 | executor = ParallelExecutor() 18 | hosts = [ 19 | HostConfig("server1", "admin", password="pass"), 20 | HostConfig("server2", "admin", key_file="~/.ssh/id_rsa"), 21 | ] 22 | 23 | results = executor.execute("uptime", hosts) 24 | print(results.summary()) 25 | """ 26 | 27 | from .executor import ( 28 | ParallelExecutor, 29 | ExecutionConfig, 30 | ExecutionStrategy, 31 | HostConfig, 32 | execute_parallel 33 | ) 34 | from .results import ( 35 | ParallelResults, 36 | HostResult, 37 | ResultStatus, 38 | ResultsCollector 39 | ) 40 | from .pool import ConnectionPool, PooledConnection 41 | 42 | __all__ = [ 43 | # Main executor 44 | 'ParallelExecutor', 45 | 'ExecutionConfig', 46 | 'ExecutionStrategy', 47 | 'HostConfig', 48 | 'execute_parallel', 49 | 50 | # Results 51 | 'ParallelResults', 52 | 'HostResult', 53 | 'ResultStatus', 54 | 'ResultsCollector', 55 | 56 | # Connection pooling 57 | 'ConnectionPool', 58 | 'PooledConnection', 59 | ] -------------------------------------------------------------------------------- /termitty/parallel/results.py: -------------------------------------------------------------------------------- 1 | """ 2 | Results handling for parallel execution. 3 | 4 | This module provides classes for collecting, aggregating, and analyzing 5 | results from parallel command execution across multiple hosts. 6 | """ 7 | 8 | import time 9 | from dataclasses import dataclass, field 10 | from typing import Dict, List, Optional, Any, Union 11 | from enum import Enum 12 | import statistics 13 | 14 | from ..transport.base import CommandResult 15 | from ..core.exceptions import TermittyException 16 | 17 | 18 | class ResultStatus(Enum): 19 | """Status of a parallel execution result.""" 20 | PENDING = "pending" 21 | SUCCESS = "success" 22 | FAILED = "failed" 23 | TIMEOUT = "timeout" 24 | ERROR = "error" 25 | SKIPPED = "skipped" 26 | 27 | 28 | @dataclass 29 | class HostResult: 30 | """Result from a single host.""" 31 | host: str 32 | status: ResultStatus 33 | command_result: Optional[CommandResult] = None 34 | error: Optional[Exception] = None 35 | start_time: float = 0.0 36 | end_time: float = 0.0 37 | duration: float = 0.0 38 | metadata: Dict[str, Any] = field(default_factory=dict) 39 | 40 | def __post_init__(self): 41 | if self.start_time and self.end_time: 42 | self.duration = self.end_time - self.start_time 43 | 44 | @property 45 | def success(self) -> bool: 46 | """Check if execution was successful.""" 47 | return self.status == ResultStatus.SUCCESS 48 | 49 | @property 50 | def output(self) -> str: 51 | """Get command output if available.""" 52 | if self.command_result: 53 | return self.command_result.output 54 | return "" 55 | 56 | @property 57 | def exit_code(self) -> Optional[int]: 58 | """Get exit code if available.""" 59 | if self.command_result: 60 | return self.command_result.exit_code 61 | return None 62 | 63 | def to_dict(self) -> Dict[str, Any]: 64 | """Convert to dictionary for serialization.""" 65 | return { 66 | 'host': self.host, 67 | 'status': self.status.value, 68 | 'success': self.success, 69 | 'output': self.output, 70 | 'exit_code': self.exit_code, 71 | 'error': str(self.error) if self.error else None, 72 | 'duration': self.duration, 73 | 'metadata': self.metadata 74 | } 75 | 76 | 77 | @dataclass 78 | class ParallelResults: 79 | """ 80 | Aggregated results from parallel execution. 81 | 82 | This class collects results from multiple hosts and provides 83 | methods for analysis and reporting. 84 | """ 85 | 86 | command: str 87 | total_hosts: int 88 | start_time: float = 0.0 89 | end_time: float = 0.0 90 | results: Dict[str, HostResult] = field(default_factory=dict) 91 | 92 | def __post_init__(self): 93 | if self.start_time == 0.0: 94 | self.start_time = time.time() 95 | 96 | def add_result(self, host: str, result: HostResult): 97 | """Add a result for a host.""" 98 | self.results[host] = result 99 | 100 | def mark_complete(self): 101 | """Mark the parallel execution as complete.""" 102 | self.end_time = time.time() 103 | 104 | @property 105 | def duration(self) -> float: 106 | """Total execution duration.""" 107 | if self.end_time: 108 | return self.end_time - self.start_time 109 | return time.time() - self.start_time 110 | 111 | @property 112 | def completed_count(self) -> int: 113 | """Number of completed hosts.""" 114 | return len(self.results) 115 | 116 | @property 117 | def success_count(self) -> int: 118 | """Number of successful executions.""" 119 | return sum(1 for r in self.results.values() if r.success) 120 | 121 | @property 122 | def failure_count(self) -> int: 123 | """Number of failed executions.""" 124 | return sum(1 for r in self.results.values() 125 | if r.status in (ResultStatus.FAILED, ResultStatus.ERROR)) 126 | 127 | @property 128 | def timeout_count(self) -> int: 129 | """Number of timed out executions.""" 130 | return sum(1 for r in self.results.values() 131 | if r.status == ResultStatus.TIMEOUT) 132 | 133 | @property 134 | def success_rate(self) -> float: 135 | """Percentage of successful executions.""" 136 | if self.completed_count == 0: 137 | return 0.0 138 | return (self.success_count / self.completed_count) * 100 139 | 140 | @property 141 | def all_succeeded(self) -> bool: 142 | """Check if all hosts succeeded.""" 143 | return self.success_count == self.total_hosts 144 | 145 | @property 146 | def any_failed(self) -> bool: 147 | """Check if any host failed.""" 148 | return self.failure_count > 0 149 | 150 | def get_successful_hosts(self) -> List[str]: 151 | """Get list of successful hosts.""" 152 | return [host for host, result in self.results.items() if result.success] 153 | 154 | def get_failed_hosts(self) -> List[str]: 155 | """Get list of failed hosts.""" 156 | return [host for host, result in self.results.items() 157 | if result.status in (ResultStatus.FAILED, ResultStatus.ERROR)] 158 | 159 | def get_by_status(self, status: ResultStatus) -> Dict[str, HostResult]: 160 | """Get results filtered by status.""" 161 | return {host: result for host, result in self.results.items() 162 | if result.status == status} 163 | 164 | def get_outputs(self) -> Dict[str, str]: 165 | """Get outputs from all hosts.""" 166 | return {host: result.output for host, result in self.results.items() 167 | if result.command_result} 168 | 169 | def get_unique_outputs(self) -> Dict[str, List[str]]: 170 | """Group hosts by unique output.""" 171 | output_groups = {} 172 | for host, result in self.results.items(): 173 | if result.command_result: 174 | output = result.output.strip() 175 | if output not in output_groups: 176 | output_groups[output] = [] 177 | output_groups[output].append(host) 178 | return output_groups 179 | 180 | def get_statistics(self) -> Dict[str, Any]: 181 | """Get execution statistics.""" 182 | durations = [r.duration for r in self.results.values() if r.duration > 0] 183 | 184 | stats = { 185 | 'total_hosts': self.total_hosts, 186 | 'completed': self.completed_count, 187 | 'successful': self.success_count, 188 | 'failed': self.failure_count, 189 | 'timeout': self.timeout_count, 190 | 'success_rate': self.success_rate, 191 | 'total_duration': self.duration, 192 | } 193 | 194 | if durations: 195 | stats.update({ 196 | 'min_duration': min(durations), 197 | 'max_duration': max(durations), 198 | 'avg_duration': statistics.mean(durations), 199 | 'median_duration': statistics.median(durations), 200 | }) 201 | 202 | return stats 203 | 204 | def summary(self) -> str: 205 | """Get a text summary of results.""" 206 | lines = [ 207 | f"Parallel Execution Summary", 208 | f"Command: {self.command}", 209 | f"Hosts: {self.completed_count}/{self.total_hosts}", 210 | f"Success: {self.success_count} ({self.success_rate:.1f}%)", 211 | f"Failed: {self.failure_count}", 212 | f"Duration: {self.duration:.2f}s", 213 | ] 214 | 215 | if self.any_failed: 216 | lines.append(f"\nFailed hosts: {', '.join(self.get_failed_hosts())}") 217 | 218 | # Show unique outputs 219 | unique_outputs = self.get_unique_outputs() 220 | if len(unique_outputs) > 1: 221 | lines.append("\nUnique outputs:") 222 | for output, hosts in unique_outputs.items(): 223 | lines.append(f" {len(hosts)} hosts: {output[:50]}...") 224 | 225 | return '\n'.join(lines) 226 | 227 | def raise_on_failure(self): 228 | """Raise exception if any executions failed.""" 229 | if self.any_failed: 230 | failed = self.get_failed_hosts() 231 | raise TermittyException( 232 | f"Parallel execution failed on {len(failed)} hosts: {', '.join(failed)}" 233 | ) 234 | 235 | def to_dict(self) -> Dict[str, Any]: 236 | """Convert to dictionary for serialization.""" 237 | return { 238 | 'command': self.command, 239 | 'total_hosts': self.total_hosts, 240 | 'duration': self.duration, 241 | 'statistics': self.get_statistics(), 242 | 'results': {host: result.to_dict() 243 | for host, result in self.results.items()} 244 | } 245 | 246 | 247 | class ResultsCollector: 248 | """ 249 | Collects and manages results from parallel executions. 250 | 251 | This class can track multiple parallel executions and provide 252 | aggregated statistics across all executions. 253 | """ 254 | 255 | def __init__(self): 256 | """Initialize the results collector.""" 257 | self.executions: List[ParallelResults] = [] 258 | self._current_execution: Optional[ParallelResults] = None 259 | 260 | def start_execution(self, command: str, hosts: List[str]) -> ParallelResults: 261 | """Start tracking a new parallel execution.""" 262 | execution = ParallelResults( 263 | command=command, 264 | total_hosts=len(hosts), 265 | start_time=time.time() 266 | ) 267 | self._current_execution = execution 268 | self.executions.append(execution) 269 | return execution 270 | 271 | def add_host_result(self, host: str, result: HostResult): 272 | """Add a result for a host in the current execution.""" 273 | if self._current_execution: 274 | self._current_execution.add_result(host, result) 275 | 276 | def complete_execution(self) -> Optional[ParallelResults]: 277 | """Mark current execution as complete.""" 278 | if self._current_execution: 279 | self._current_execution.mark_complete() 280 | execution = self._current_execution 281 | self._current_execution = None 282 | return execution 283 | return None 284 | 285 | def get_all_statistics(self) -> Dict[str, Any]: 286 | """Get statistics across all executions.""" 287 | if not self.executions: 288 | return {} 289 | 290 | total_hosts = sum(e.total_hosts for e in self.executions) 291 | total_success = sum(e.success_count for e in self.executions) 292 | total_duration = sum(e.duration for e in self.executions) 293 | 294 | return { 295 | 'total_executions': len(self.executions), 296 | 'total_hosts': total_hosts, 297 | 'total_success': total_success, 298 | 'overall_success_rate': (total_success / total_hosts * 100) if total_hosts > 0 else 0, 299 | 'total_duration': total_duration, 300 | 'executions': [e.to_dict() for e in self.executions] 301 | } 302 | 303 | def clear(self): 304 | """Clear all collected results.""" 305 | self.executions.clear() 306 | self._current_execution = None -------------------------------------------------------------------------------- /termitty/parsers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Termitty/termitty/97cbc4b6f5779377789a5e68730826aa8adcf480/termitty/parsers/__init__.py -------------------------------------------------------------------------------- /termitty/recording/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Session recording and playback functionality for Termitty. 3 | 4 | This package provides the ability to record terminal sessions for: 5 | - Debugging and troubleshooting 6 | - Creating demos and tutorials 7 | - Audit trails and compliance 8 | - Automated testing 9 | 10 | Features: 11 | - Record all input/output with timestamps 12 | - Multiple recording formats (JSON, asciinema) 13 | - Playback at different speeds 14 | - Search and filter recordings 15 | """ 16 | 17 | from .recorder import SessionRecorder, RecordingFormat 18 | from .player import SessionPlayer, PlaybackSpeed 19 | from .storage import RecordingStorage, Recording 20 | 21 | __all__ = [ 22 | 'SessionRecorder', 23 | 'RecordingFormat', 24 | 'SessionPlayer', 25 | 'PlaybackSpeed', 26 | 'RecordingStorage', 27 | 'Recording', 28 | ] -------------------------------------------------------------------------------- /termitty/recording/formats.py: -------------------------------------------------------------------------------- 1 | """ 2 | Recording formats and data structures for session recordings. 3 | 4 | This module defines the formats used for recording terminal sessions, 5 | including compatibility with standard formats like asciinema. 6 | """ 7 | 8 | from dataclasses import dataclass, field 9 | from enum import Enum 10 | from typing import List, Dict, Any, Optional, Union 11 | import json 12 | import time 13 | import uuid 14 | 15 | 16 | class RecordingFormat(Enum): 17 | """Supported recording formats.""" 18 | TERMITTY = "termitty" # Native Termitty format 19 | ASCIINEMA = "asciinema" # asciinema v2 format 20 | JSON = "json" # Simple JSON format 21 | 22 | 23 | class EventType(Enum): 24 | """Types of events in a recording.""" 25 | INPUT = "i" # User input 26 | OUTPUT = "o" # Terminal output 27 | RESIZE = "r" # Terminal resize 28 | MARKER = "m" # User-defined marker 29 | ERROR = "e" # Error event 30 | 31 | 32 | @dataclass 33 | class RecordingEvent: 34 | """A single event in a recording.""" 35 | timestamp: float # Seconds since start 36 | event_type: EventType # Type of event 37 | data: Union[str, bytes] # Event data 38 | metadata: Dict[str, Any] = field(default_factory=dict) 39 | 40 | def to_dict(self) -> Dict[str, Any]: 41 | """Convert to dictionary for serialization.""" 42 | return { 43 | "timestamp": self.timestamp, 44 | "event_type": self.event_type.value, 45 | "data": self.data if isinstance(self.data, str) else self.data.decode('utf-8', errors='replace'), 46 | "metadata": self.metadata 47 | } 48 | 49 | @classmethod 50 | def from_dict(cls, data: Dict[str, Any]) -> 'RecordingEvent': 51 | """Create from dictionary.""" 52 | return cls( 53 | timestamp=data["timestamp"], 54 | event_type=EventType(data["event_type"]), 55 | data=data["data"], 56 | metadata=data.get("metadata", {}) 57 | ) 58 | 59 | 60 | @dataclass 61 | class RecordingHeader: 62 | """Header information for a recording.""" 63 | version: int = 2 # Format version 64 | width: int = 80 # Terminal width 65 | height: int = 24 # Terminal height 66 | timestamp: float = field(default_factory=time.time) # Start time 67 | title: Optional[str] = None # Recording title 68 | env: Dict[str, Any] = field(default_factory=dict) # Environment info 69 | 70 | def __post_init__(self): 71 | """Initialize default environment info.""" 72 | if not self.env: 73 | self.env = { 74 | "SHELL": "/bin/bash", 75 | "TERM": "xterm-256color" 76 | } 77 | 78 | def to_dict(self) -> Dict[str, Any]: 79 | """Convert to dictionary.""" 80 | return { 81 | "version": self.version, 82 | "width": self.width, 83 | "height": self.height, 84 | "timestamp": self.timestamp, 85 | "title": self.title, 86 | "env": self.env 87 | } 88 | 89 | @classmethod 90 | def from_dict(cls, data: Dict[str, Any]) -> 'RecordingHeader': 91 | """Create from dictionary.""" 92 | return cls(**data) 93 | 94 | 95 | @dataclass 96 | class Recording: 97 | """A complete terminal session recording.""" 98 | id: str = field(default_factory=lambda: str(uuid.uuid4())) 99 | header: RecordingHeader = field(default_factory=RecordingHeader) 100 | events: List[RecordingEvent] = field(default_factory=list) 101 | metadata: Dict[str, Any] = field(default_factory=dict) 102 | 103 | def add_event(self, event_type: EventType, data: Union[str, bytes], metadata: Optional[Dict[str, Any]] = None): 104 | """Add an event to the recording.""" 105 | timestamp = time.time() - self.header.timestamp 106 | event = RecordingEvent( 107 | timestamp=timestamp, 108 | event_type=event_type, 109 | data=data, 110 | metadata=metadata or {} 111 | ) 112 | self.events.append(event) 113 | 114 | def add_input(self, data: Union[str, bytes]): 115 | """Add user input event.""" 116 | self.add_event(EventType.INPUT, data) 117 | 118 | def add_output(self, data: Union[str, bytes]): 119 | """Add terminal output event.""" 120 | self.add_event(EventType.OUTPUT, data) 121 | 122 | def add_resize(self, width: int, height: int): 123 | """Add terminal resize event.""" 124 | self.add_event( 125 | EventType.RESIZE, 126 | f"{width}x{height}", 127 | {"width": width, "height": height} 128 | ) 129 | 130 | def add_marker(self, name: str, description: Optional[str] = None): 131 | """Add a named marker for navigation.""" 132 | self.add_event( 133 | EventType.MARKER, 134 | name, 135 | {"description": description} if description else {} 136 | ) 137 | 138 | def duration(self) -> float: 139 | """Get total duration of recording in seconds.""" 140 | if not self.events: 141 | return 0.0 142 | return self.events[-1].timestamp 143 | 144 | def to_dict(self) -> Dict[str, Any]: 145 | """Convert to dictionary for serialization.""" 146 | return { 147 | "id": self.id, 148 | "header": self.header.to_dict(), 149 | "events": [e.to_dict() for e in self.events], 150 | "metadata": self.metadata 151 | } 152 | 153 | @classmethod 154 | def from_dict(cls, data: Dict[str, Any]) -> 'Recording': 155 | """Create from dictionary.""" 156 | recording = cls( 157 | id=data.get("id", str(uuid.uuid4())), 158 | header=RecordingHeader.from_dict(data["header"]), 159 | metadata=data.get("metadata", {}) 160 | ) 161 | recording.events = [RecordingEvent.from_dict(e) for e in data["events"]] 162 | return recording 163 | 164 | def to_asciinema_v2(self) -> str: 165 | """Convert to asciinema v2 format.""" 166 | # Header 167 | header = { 168 | "version": 2, 169 | "width": self.header.width, 170 | "height": self.header.height, 171 | "timestamp": self.header.timestamp, 172 | "env": self.header.env 173 | } 174 | if self.header.title: 175 | header["title"] = self.header.title 176 | 177 | lines = [json.dumps(header)] 178 | 179 | # Events 180 | for event in self.events: 181 | if event.event_type == EventType.OUTPUT: 182 | # asciinema format: [timestamp, "o", data] 183 | lines.append(json.dumps([ 184 | event.timestamp, 185 | "o", 186 | event.data 187 | ])) 188 | elif event.event_type == EventType.INPUT: 189 | # Include input as output with marker 190 | lines.append(json.dumps([ 191 | event.timestamp, 192 | "o", 193 | f"\033[36m{event.data}\033[0m" # Cyan color for input 194 | ])) 195 | 196 | return '\n'.join(lines) 197 | 198 | @classmethod 199 | def from_asciinema_v2(cls, content: str) -> 'Recording': 200 | """Create recording from asciinema v2 format.""" 201 | lines = content.strip().split('\n') 202 | if not lines: 203 | raise ValueError("Empty asciinema file") 204 | 205 | # Parse header 206 | header_data = json.loads(lines[0]) 207 | header = RecordingHeader( 208 | version=header_data.get("version", 2), 209 | width=header_data.get("width", 80), 210 | height=header_data.get("height", 24), 211 | timestamp=header_data.get("timestamp", time.time()), 212 | title=header_data.get("title"), 213 | env=header_data.get("env", {}) 214 | ) 215 | 216 | recording = cls(header=header) 217 | 218 | # Parse events 219 | for line in lines[1:]: 220 | if not line.strip(): 221 | continue 222 | 223 | event_data = json.loads(line) 224 | if len(event_data) >= 3: 225 | timestamp, event_type, data = event_data[:3] 226 | 227 | if event_type == "o": 228 | recording.events.append(RecordingEvent( 229 | timestamp=timestamp, 230 | event_type=EventType.OUTPUT, 231 | data=data 232 | )) 233 | 234 | return recording -------------------------------------------------------------------------------- /termitty/recording/recorder.py: -------------------------------------------------------------------------------- 1 | """ 2 | Session recorder for capturing terminal activity. 3 | 4 | This module provides the SessionRecorder class that captures all input 5 | and output during a terminal session for later playback or analysis. 6 | """ 7 | 8 | import time 9 | import logging 10 | from typing import Optional, Union, Callable, Any 11 | from pathlib import Path 12 | import json 13 | import gzip 14 | 15 | from .formats import Recording, RecordingFormat, EventType, RecordingHeader 16 | from ..core.exceptions import SessionStateException 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class SessionRecorder: 22 | """ 23 | Records terminal session activity. 24 | 25 | This class captures all input and output during a terminal session, 26 | creating recordings that can be played back or analyzed later. 27 | 28 | Features: 29 | - Real-time recording with minimal overhead 30 | - Multiple output formats (native, asciinema) 31 | - Compression support 32 | - Event markers for navigation 33 | - Pause/resume functionality 34 | """ 35 | 36 | def __init__(self, 37 | width: int = 80, 38 | height: int = 24, 39 | title: Optional[str] = None): 40 | """ 41 | Initialize a session recorder. 42 | 43 | Args: 44 | width: Terminal width 45 | height: Terminal height 46 | title: Optional title for the recording 47 | """ 48 | self.recording = Recording( 49 | header=RecordingHeader( 50 | width=width, 51 | height=height, 52 | title=title 53 | ) 54 | ) 55 | 56 | self._recording = False 57 | self._paused = False 58 | self._start_time = None 59 | 60 | # Callbacks 61 | self._on_input: Optional[Callable[[str], None]] = None 62 | self._on_output: Optional[Callable[[str], None]] = None 63 | 64 | logger.debug(f"Created recorder: {width}x{height}") 65 | 66 | @property 67 | def is_recording(self) -> bool: 68 | """Check if currently recording.""" 69 | return self._recording and not self._paused 70 | 71 | @property 72 | def duration(self) -> float: 73 | """Get current recording duration in seconds.""" 74 | if self._start_time: 75 | return time.time() - self._start_time 76 | return 0.0 77 | 78 | def start(self): 79 | """Start recording.""" 80 | if self._recording: 81 | logger.warning("Recording already in progress") 82 | return 83 | 84 | self._recording = True 85 | self._paused = False 86 | self._start_time = self.recording.header.timestamp 87 | 88 | logger.info(f"Started recording: {self.recording.id}") 89 | 90 | def stop(self) -> Recording: 91 | """ 92 | Stop recording and return the recording. 93 | 94 | Returns: 95 | The completed recording 96 | """ 97 | if not self._recording: 98 | raise SessionStateException("No recording in progress") 99 | 100 | self._recording = False 101 | self._paused = False 102 | 103 | # Add final metadata 104 | self.recording.metadata["duration"] = self.duration 105 | self.recording.metadata["event_count"] = len(self.recording.events) 106 | 107 | logger.info(f"Stopped recording: {self.recording.id} ({self.duration:.1f}s)") 108 | 109 | return self.recording 110 | 111 | def pause(self): 112 | """Pause recording.""" 113 | if not self._recording: 114 | raise SessionStateException("No recording in progress") 115 | 116 | self._paused = True 117 | logger.debug("Recording paused") 118 | 119 | def resume(self): 120 | """Resume recording.""" 121 | if not self._recording: 122 | raise SessionStateException("No recording in progress") 123 | 124 | self._paused = False 125 | logger.debug("Recording resumed") 126 | 127 | def record_input(self, data: Union[str, bytes]): 128 | """ 129 | Record user input. 130 | 131 | Args: 132 | data: Input data to record 133 | """ 134 | if not self.is_recording: 135 | return 136 | 137 | self.recording.add_input(data) 138 | 139 | if self._on_input: 140 | try: 141 | self._on_input(data if isinstance(data, str) else data.decode('utf-8', errors='replace')) 142 | except Exception as e: 143 | logger.error(f"Input callback error: {e}") 144 | 145 | def record_output(self, data: Union[str, bytes]): 146 | """ 147 | Record terminal output. 148 | 149 | Args: 150 | data: Output data to record 151 | """ 152 | if not self.is_recording: 153 | return 154 | 155 | self.recording.add_output(data) 156 | 157 | if self._on_output: 158 | try: 159 | self._on_output(data if isinstance(data, str) else data.decode('utf-8', errors='replace')) 160 | except Exception as e: 161 | logger.error(f"Output callback error: {e}") 162 | 163 | def record_resize(self, width: int, height: int): 164 | """ 165 | Record terminal resize event. 166 | 167 | Args: 168 | width: New terminal width 169 | height: New terminal height 170 | """ 171 | if not self.is_recording: 172 | return 173 | 174 | self.recording.add_resize(width, height) 175 | logger.debug(f"Recorded resize: {width}x{height}") 176 | 177 | def add_marker(self, name: str, description: Optional[str] = None): 178 | """ 179 | Add a named marker to the recording. 180 | 181 | Markers can be used for navigation during playback. 182 | 183 | Args: 184 | name: Marker name 185 | description: Optional description 186 | """ 187 | if not self._recording: # Allow markers even when paused 188 | raise SessionStateException("No recording in progress") 189 | 190 | self.recording.add_marker(name, description) 191 | logger.debug(f"Added marker: {name}") 192 | 193 | def set_input_callback(self, callback: Optional[Callable[[str], None]]): 194 | """Set callback for input events.""" 195 | self._on_input = callback 196 | 197 | def set_output_callback(self, callback: Optional[Callable[[str], None]]): 198 | """Set callback for output events.""" 199 | self._on_output = callback 200 | 201 | def save(self, 202 | filename: Union[str, Path], 203 | format: RecordingFormat = RecordingFormat.TERMITTY, 204 | compress: bool = False) -> Path: 205 | """ 206 | Save the recording to a file. 207 | 208 | Args: 209 | filename: Output filename 210 | format: Recording format to use 211 | compress: Whether to compress the output 212 | 213 | Returns: 214 | Path to the saved file 215 | """ 216 | if self._recording: 217 | logger.warning("Saving while still recording") 218 | 219 | filepath = Path(filename) 220 | 221 | # Add extension if not present 222 | if not filepath.suffix: 223 | if format == RecordingFormat.ASCIINEMA: 224 | filepath = filepath.with_suffix('.cast') 225 | elif compress: 226 | filepath = filepath.with_suffix('.json.gz') 227 | else: 228 | filepath = filepath.with_suffix('.json') 229 | 230 | # Convert to appropriate format 231 | if format == RecordingFormat.ASCIINEMA: 232 | content = self.recording.to_asciinema_v2() 233 | mode = 'w' 234 | else: 235 | # JSON or native format 236 | content = json.dumps(self.recording.to_dict(), indent=2) 237 | mode = 'w' 238 | 239 | # Write file 240 | if compress and not format == RecordingFormat.ASCIINEMA: 241 | with gzip.open(filepath, 'wt', encoding='utf-8') as f: 242 | f.write(content) 243 | else: 244 | with open(filepath, mode, encoding='utf-8') as f: 245 | f.write(content) 246 | 247 | logger.info(f"Saved recording to: {filepath}") 248 | return filepath 249 | 250 | @classmethod 251 | def load(cls, filename: Union[str, Path]) -> 'SessionRecorder': 252 | """ 253 | Load a recording from file. 254 | 255 | Args: 256 | filename: Path to recording file 257 | 258 | Returns: 259 | SessionRecorder with loaded recording 260 | """ 261 | filepath = Path(filename) 262 | 263 | # Detect format and compression 264 | if filepath.suffix == '.cast': 265 | # Asciinema format 266 | with open(filepath, 'r', encoding='utf-8') as f: 267 | content = f.read() 268 | recording = Recording.from_asciinema_v2(content) 269 | elif filepath.suffix == '.gz': 270 | # Compressed JSON 271 | with gzip.open(filepath, 'rt', encoding='utf-8') as f: 272 | data = json.load(f) 273 | recording = Recording.from_dict(data) 274 | else: 275 | # Regular JSON 276 | with open(filepath, 'r', encoding='utf-8') as f: 277 | data = json.load(f) 278 | recording = Recording.from_dict(data) 279 | 280 | # Create recorder with loaded recording 281 | recorder = cls( 282 | width=recording.header.width, 283 | height=recording.header.height, 284 | title=recording.header.title 285 | ) 286 | recorder.recording = recording 287 | 288 | logger.info(f"Loaded recording: {recording.id}") 289 | return recorder 290 | 291 | def get_statistics(self) -> dict: 292 | """ 293 | Get recording statistics. 294 | 295 | Returns: 296 | Dictionary with recording stats 297 | """ 298 | stats = { 299 | "id": self.recording.id, 300 | "duration": self.duration, 301 | "event_count": len(self.recording.events), 302 | "input_count": sum(1 for e in self.recording.events if e.event_type == EventType.INPUT), 303 | "output_count": sum(1 for e in self.recording.events if e.event_type == EventType.OUTPUT), 304 | "marker_count": sum(1 for e in self.recording.events if e.event_type == EventType.MARKER), 305 | "is_recording": self.is_recording, 306 | "is_paused": self._paused 307 | } 308 | 309 | # Calculate data sizes 310 | input_size = sum(len(e.data) for e in self.recording.events if e.event_type == EventType.INPUT) 311 | output_size = sum(len(e.data) for e in self.recording.events if e.event_type == EventType.OUTPUT) 312 | 313 | stats["input_bytes"] = input_size 314 | stats["output_bytes"] = output_size 315 | stats["total_bytes"] = input_size + output_size 316 | 317 | return stats -------------------------------------------------------------------------------- /termitty/session/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Termitty/termitty/97cbc4b6f5779377789a5e68730826aa8adcf480/termitty/session/__init__.py -------------------------------------------------------------------------------- /termitty/terminal/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Terminal emulation components for Termitty. 3 | 4 | This package provides terminal emulation capabilities, including: 5 | - Virtual terminal that maintains screen state 6 | - ANSI escape code parsing and interpretation 7 | - Cursor tracking and screen buffer management 8 | - Color and formatting support 9 | 10 | The terminal emulator allows Termitty to understand not just what text 11 | was output, but how it would appear on screen, enabling intelligent 12 | interaction with terminal-based user interfaces. 13 | """ 14 | 15 | from .ansi_parser import AnsiCode, AnsiParser 16 | from .screen_buffer import Cell, ScreenBuffer 17 | from .virtual_terminal import VirtualTerminal 18 | 19 | __all__ = [ 20 | "VirtualTerminal", 21 | "AnsiParser", 22 | "AnsiCode", 23 | "ScreenBuffer", 24 | "Cell", 25 | ] 26 | -------------------------------------------------------------------------------- /termitty/transport/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Termitty/termitty/97cbc4b6f5779377789a5e68730826aa8adcf480/termitty/transport/__init__.py -------------------------------------------------------------------------------- /termitty/transport/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Abstract base class for transport implementations. 3 | 4 | This module defines the interface that all transport backends must implement. 5 | By using an abstract base class, we ensure that different SSH implementations 6 | (Paramiko, AsyncSSH, etc.) provide a consistent interface to the rest of Termitty. 7 | 8 | This abstraction pattern is similar to how Selenium WebDriver works - you can 9 | switch between Chrome, Firefox, or Safari drivers without changing your test code. 10 | """ 11 | 12 | from abc import ABC, abstractmethod 13 | from dataclasses import dataclass 14 | from typing import Optional, Tuple, Union, Dict, Any, Callable 15 | import io 16 | from pathlib import Path 17 | 18 | from ..core.exceptions import ConnectionException, AuthenticationException 19 | 20 | 21 | @dataclass 22 | class ConnectionResult: 23 | """Result of a connection attempt.""" 24 | success: bool 25 | error: Optional[Exception] = None 26 | server_info: Optional[Dict[str, Any]] = None # Server version, capabilities, etc. 27 | 28 | 29 | @dataclass 30 | class CommandResult: 31 | """Result of executing a command.""" 32 | stdout: str 33 | stderr: str 34 | exit_code: int 35 | duration: float # Time taken to execute in seconds 36 | encoding: str = 'utf-8' 37 | 38 | @property 39 | def success(self) -> bool: 40 | """Command succeeded if exit code is 0.""" 41 | return self.exit_code == 0 42 | 43 | @property 44 | def output(self) -> str: 45 | """Combined stdout and stderr.""" 46 | # This mimics how terminal output actually appears - stderr mixed with stdout 47 | return self.stdout + self.stderr if self.stderr else self.stdout 48 | 49 | 50 | class TransportBase(ABC): 51 | """ 52 | Abstract base class for all transport implementations. 53 | 54 | This defines the interface that concrete transport classes must implement. 55 | The design allows for both synchronous and asynchronous implementations, 56 | though the initial version focuses on synchronous operations. 57 | 58 | The transport layer is responsible for: 59 | 1. Establishing and managing SSH connections 60 | 2. Authenticating with remote servers 61 | 3. Executing commands and capturing output 62 | 4. Managing the lifecycle of connections 63 | 5. Handling low-level SSH protocol details 64 | """ 65 | 66 | def __init__(self, config: Optional[Dict[str, Any]] = None): 67 | """ 68 | Initialize the transport with optional configuration. 69 | 70 | Args: 71 | config: Transport-specific configuration options 72 | """ 73 | self.config = config or {} 74 | self._connected = False 75 | self._connection = None 76 | self._host = None 77 | self._port = None 78 | self._username = None 79 | 80 | @property 81 | def connected(self) -> bool: 82 | """Check if transport is currently connected.""" 83 | return self._connected 84 | 85 | @property 86 | def connection_info(self) -> Dict[str, Any]: 87 | """Get information about the current connection.""" 88 | if not self._connected: 89 | return {} 90 | 91 | return { 92 | 'host': self._host, 93 | 'port': self._port, 94 | 'username': self._username, 95 | 'transport_type': self.__class__.__name__, 96 | 'connected': self._connected 97 | } 98 | 99 | @abstractmethod 100 | def connect(self, 101 | host: str, 102 | port: int = 22, 103 | username: Optional[str] = None, 104 | password: Optional[str] = None, 105 | key_filename: Optional[Union[str, Path]] = None, 106 | key_password: Optional[str] = None, 107 | timeout: float = 30.0, 108 | **kwargs) -> ConnectionResult: 109 | """ 110 | Establish an SSH connection to a remote host. 111 | 112 | This method must handle various authentication methods including: 113 | - Password authentication 114 | - Public key authentication 115 | - Keyboard-interactive authentication 116 | - Multi-factor authentication (if supported) 117 | 118 | Args: 119 | host: Hostname or IP address to connect to 120 | port: SSH port (default: 22) 121 | username: Username for authentication 122 | password: Password for authentication 123 | key_filename: Path to private key file 124 | key_password: Password for encrypted private key 125 | timeout: Connection timeout in seconds 126 | **kwargs: Additional transport-specific options 127 | 128 | Returns: 129 | ConnectionResult object indicating success/failure 130 | 131 | Raises: 132 | ConnectionException: If connection cannot be established 133 | AuthenticationException: If authentication fails 134 | """ 135 | pass 136 | 137 | @abstractmethod 138 | def disconnect(self) -> None: 139 | """ 140 | Close the SSH connection and clean up resources. 141 | 142 | This method should be idempotent - calling it multiple times 143 | should not cause errors. 144 | """ 145 | pass 146 | 147 | @abstractmethod 148 | def execute_command(self, 149 | command: str, 150 | timeout: Optional[float] = None, 151 | input_data: Optional[str] = None, 152 | environment: Optional[Dict[str, str]] = None, 153 | working_directory: Optional[str] = None, 154 | **kwargs) -> CommandResult: 155 | """ 156 | Execute a command on the remote host. 157 | 158 | This is the core method for running commands. It should handle: 159 | - Command execution with proper shell escaping 160 | - Capturing stdout and stderr 161 | - Handling command timeouts 162 | - Providing input to commands that read from stdin 163 | - Setting environment variables 164 | - Changing working directory before execution 165 | 166 | Args: 167 | command: Command to execute 168 | timeout: Maximum time to wait for command completion 169 | input_data: Data to send to command's stdin 170 | environment: Environment variables to set 171 | working_directory: Directory to execute command in 172 | **kwargs: Additional transport-specific options 173 | 174 | Returns: 175 | CommandResult object with output and exit code 176 | 177 | Raises: 178 | CommandExecutionException: If command execution fails 179 | TimeoutException: If command exceeds timeout 180 | """ 181 | pass 182 | 183 | @abstractmethod 184 | def create_sftp_client(self): 185 | """ 186 | Create an SFTP client for file transfer operations. 187 | 188 | Returns: 189 | SFTP client object (implementation-specific) 190 | 191 | Raises: 192 | ConnectionException: If not connected 193 | UnsupportedOperationException: If SFTP is not available 194 | """ 195 | pass 196 | 197 | @abstractmethod 198 | def create_interactive_shell(self, 199 | term_type: str = 'xterm-256color', 200 | width: int = 80, 201 | height: int = 24, 202 | **kwargs): 203 | """ 204 | Create an interactive shell session. 205 | 206 | This creates a PTY (pseudo-terminal) session that can be used 207 | for interactive commands, terminal applications, and real-time 208 | interaction. 209 | 210 | Args: 211 | term_type: Terminal type to emulate 212 | width: Terminal width in columns 213 | height: Terminal height in rows 214 | **kwargs: Additional terminal options 215 | 216 | Returns: 217 | Shell channel object (implementation-specific) 218 | 219 | Raises: 220 | ConnectionException: If not connected 221 | """ 222 | pass 223 | 224 | def validate_connection(self) -> bool: 225 | """ 226 | Check if the current connection is still valid. 227 | 228 | This method can be overridden by subclasses to provide 229 | transport-specific validation logic. 230 | 231 | Returns: 232 | True if connection is valid, False otherwise 233 | """ 234 | if not self._connected: 235 | return False 236 | 237 | try: 238 | # Try a simple command to test the connection 239 | result = self.execute_command('echo "test"', timeout=5.0) 240 | return result.success 241 | except Exception: 242 | return False 243 | 244 | def __enter__(self): 245 | """Context manager entry - returns self.""" 246 | return self 247 | 248 | def __exit__(self, exc_type, exc_val, exc_tb): 249 | """Context manager exit - ensures connection is closed.""" 250 | self.disconnect() 251 | return False # Don't suppress exceptions 252 | 253 | def __del__(self): 254 | """Destructor - attempt to close connection if still open.""" 255 | try: 256 | if self._connected: 257 | self.disconnect() 258 | except Exception: 259 | # Ignore errors during cleanup 260 | pass -------------------------------------------------------------------------------- /termitty/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Termitty/termitty/97cbc4b6f5779377789a5e68730826aa8adcf480/termitty/utils/__init__.py -------------------------------------------------------------------------------- /test_all_features.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Test all Termitty features to ensure everything is working. 4 | """ 5 | 6 | import sys 7 | import time 8 | import traceback 9 | from pathlib import Path 10 | sys.path.insert(0, str(Path(__file__).parent)) 11 | 12 | from termitty import TermittySession, config 13 | 14 | # Disable verbose logging 15 | config.logging.log_output = False 16 | 17 | # Update with your credentials 18 | PASSWORD = "PASSWORD" # Your actual password 19 | 20 | def test_phase1_basic_connection(): 21 | """Test Phase 1: Basic SSH connection and commands.""" 22 | print("Testing Phase 1: Basic Connection and Commands") 23 | 24 | try: 25 | with TermittySession() as session: 26 | session.connect("localhost", username="deep", password=PASSWORD) 27 | 28 | # Test basic command 29 | result = session.execute("echo 'Phase 1 works!'") 30 | assert result.success 31 | assert "Phase 1 works!" in result.output 32 | print(" ✓ Basic command execution") 33 | 34 | # Test directory context 35 | with session.cd("/tmp"): 36 | result = session.execute("pwd") 37 | assert "/tmp" in result.output 38 | print(" ✓ Directory context manager") 39 | 40 | # Test environment 41 | with session.env(TEST_VAR="test123"): 42 | result = session.execute("echo $TEST_VAR") 43 | assert "test123" in result.output 44 | print(" ✓ Environment context manager") 45 | 46 | print(" ✅ Phase 1: PASSED\n") 47 | return True 48 | 49 | except Exception as e: 50 | print(f" ❌ Phase 1: FAILED - {e}\n") 51 | return False 52 | 53 | 54 | def test_phase2_terminal_emulation(): 55 | """Test Phase 2: Terminal emulation.""" 56 | print("Testing Phase 2: Terminal Emulation") 57 | 58 | try: 59 | with TermittySession() as session: 60 | session.connect("localhost", username="deep", password=PASSWORD) 61 | 62 | # Test ANSI colors 63 | session.execute('echo -e "\\033[31mRed\\033[0m \\033[32mGreen\\033[0m"') 64 | screen = session.get_screen_text() 65 | assert len(screen) > 0 66 | print(" ✓ Terminal screen tracking") 67 | 68 | # Test screen content (more flexible check) 69 | session.execute("echo 'Find me on screen'") 70 | time.sleep(0.1) # Let terminal process 71 | 72 | # Check if text appears anywhere in terminal output 73 | visible_text = session.state.terminal.get_visible_text() 74 | last_output = session.state.last_result.output if session.state.last_result else "" 75 | 76 | assert "Find me" in visible_text or "Find me" in last_output 77 | print(" ✓ Screen text search") 78 | 79 | # Test menu detection 80 | session.execute('echo -e "[1] Option One\\n[2] Option Two"') 81 | time.sleep(0.1) # Let terminal process 82 | 83 | menu_items = session.find_menu_items() 84 | assert len(menu_items) >= 1 # At least one menu item found 85 | print(" ✓ Menu item detection") 86 | 87 | print(" ✅ Phase 2: PASSED\n") 88 | return True 89 | 90 | except Exception as e: 91 | print(f" ❌ Phase 2: FAILED - {e}\n") 92 | return False 93 | 94 | 95 | def test_phase3_interactive_shell(): 96 | """Test Phase 3: Interactive shell.""" 97 | print("Testing Phase 3: Interactive Shell") 98 | 99 | try: 100 | with TermittySession() as session: 101 | session.connect("localhost", username="deep", password=PASSWORD) 102 | 103 | with session.create_shell() as shell: 104 | # Test persistent state 105 | shell.send_line("MY_VAR=test123") 106 | shell.wait_for_prompt(timeout=5) 107 | 108 | shell.send_line("echo $MY_VAR") 109 | shell.wait_for_prompt(timeout=5) 110 | 111 | # Check output 112 | time.sleep(0.5) 113 | output = shell._receive_buffer.decode('utf-8', errors='replace') 114 | assert "test123" in output 115 | print(" ✓ Persistent shell state") 116 | 117 | # Test interrupt 118 | shell.send_line("sleep 10") 119 | time.sleep(1) 120 | shell.interrupt() 121 | shell.wait_for_prompt(timeout=5) 122 | print(" ✓ Interrupt handling (Ctrl+C)") 123 | 124 | # Test special keys 125 | shell.send("echo test") 126 | shell.send_key('tab') 127 | shell.send_line("") 128 | shell.wait_for_prompt(timeout=5) 129 | print(" ✓ Special key support") 130 | 131 | print(" ✅ Phase 3: PASSED\n") 132 | return True 133 | 134 | except Exception as e: 135 | print(f" ❌ Phase 3: FAILED - {e}\n") 136 | return False 137 | 138 | 139 | def test_phase4_advanced_features(): 140 | """Test Phase 4: Advanced features.""" 141 | print("Testing Phase 4: Advanced Features") 142 | 143 | try: 144 | # Test recording 145 | from termitty.recording import SessionRecorder 146 | 147 | recorder = SessionRecorder(title="Test Recording") 148 | recorder.start() 149 | recorder.record_output("Test output") 150 | recorder.record_input("Test input") 151 | recording = recorder.stop() 152 | 153 | assert len(recording.events) == 2 154 | print(" ✓ Session recording") 155 | 156 | # Test parallel execution 157 | from termitty.parallel import execute_parallel 158 | 159 | hosts = [{"host": "localhost", "username": "deep", "password": PASSWORD}] 160 | results = execute_parallel("echo 'Parallel test'", hosts) 161 | 162 | assert results.success_count == 1 163 | print(" ✓ Parallel execution") 164 | 165 | # Test file transfers (if available) 166 | try: 167 | from termitty.transfer import FileTransfer 168 | print(" ✓ File transfer module available") 169 | except ImportError: 170 | print(" ⚠ File transfer module not implemented yet") 171 | 172 | print(" ✅ Phase 4: PASSED\n") 173 | return True 174 | 175 | except Exception as e: 176 | print(f" ❌ Phase 4: FAILED - {e}\n") 177 | return False 178 | 179 | 180 | def main(): 181 | """Run all tests.""" 182 | print("=== Termitty Feature Test Suite ===\n") 183 | print(f"Testing with user: deep@localhost\n") 184 | 185 | results = [] 186 | 187 | # Run all phase tests 188 | results.append(("Phase 1", test_phase1_basic_connection())) 189 | results.append(("Phase 2", test_phase2_terminal_emulation())) 190 | results.append(("Phase 3", test_phase3_interactive_shell())) 191 | results.append(("Phase 4", test_phase4_advanced_features())) 192 | 193 | # Summary 194 | print("=== Test Summary ===") 195 | passed = sum(1 for _, result in results if result) 196 | total = len(results) 197 | 198 | for phase, result in results: 199 | status = "PASSED" if result else "FAILED" 200 | symbol = "✅" if result else "❌" 201 | print(f"{symbol} {phase}: {status}") 202 | 203 | print(f"\nTotal: {passed}/{total} phases passed") 204 | 205 | if passed == total: 206 | print("\n🎉 All Termitty features are working correctly!") 207 | else: 208 | print("\n⚠️ Some features need attention.") 209 | print("Check the error messages above for details.") 210 | 211 | 212 | if __name__ == "__main__": 213 | main() -------------------------------------------------------------------------------- /test_recording.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Test the improved text finding functionality with various corner cases. 4 | """ 5 | 6 | import sys 7 | from pathlib import Path 8 | sys.path.insert(0, str(Path(__file__).parent)) 9 | 10 | from termitty import TermittySession, config 11 | 12 | # Update with your credentials 13 | PASSWORD = "PASSWORD" 14 | 15 | def test_text_finding_corner_cases(): 16 | """Test various corner cases for text finding.""" 17 | print("Testing improved text finding with corner cases...\n") 18 | 19 | with TermittySession() as session: 20 | session.connect("localhost", username="deep", password=PASSWORD) 21 | 22 | # Test 1: Text with extra spaces 23 | print("1. Testing text with extra spaces:") 24 | session.execute("echo ' Hello World '") 25 | 26 | # Should find with exact spaces 27 | positions = session.find_on_screen(" Hello World ") 28 | print(f" Exact match: {len(positions)} found") 29 | 30 | # Should also find with normalized spaces 31 | positions = session.find_on_screen("Hello World") 32 | print(f" Normalized match: {len(positions)} found") 33 | 34 | # Test 2: Case-insensitive search 35 | print("\n2. Testing case-insensitive search:") 36 | session.execute("echo 'IMPORTANT MESSAGE'") 37 | 38 | positions = session.find_on_screen("important", case_sensitive=False) 39 | print(f" Case-insensitive: {len(positions)} found") 40 | 41 | # Test 3: Text split across lines 42 | print("\n3. Testing text split across lines:") 43 | session.execute("echo -e 'This is a very long line that might\\nwrap across multiple lines'") 44 | 45 | # The find_text method now handles this 46 | results = session.find_all_text("wrap across") 47 | print(f" Found in screen: {len(results['screen'])} positions") 48 | print(f" Found in output: {len(results['output'])} positions") 49 | 50 | # Test 4: Menu items with special characters 51 | print("\n4. Testing menu items with special characters:") 52 | session.execute('echo -e "[1] First Option\\n[2] Second Option\\n* Special Item"') 53 | 54 | # Find menu items 55 | menu_items = session.find_menu_items() 56 | print(f" Menu items found: {len(menu_items)}") 57 | 58 | # Find with regex 59 | positions = session.find_on_screen(r"\[\d+\]", regex=True) 60 | print(f" Regex matches: {len(positions)}") 61 | 62 | # Test 5: Wait for text with variations 63 | print("\n5. Testing wait_for_screen_text:") 64 | session.execute("sleep 1 && echo 'DELAYED OUTPUT'") 65 | 66 | found = session.wait_for_screen_text("delayed", timeout=3, case_sensitive=False) 67 | print(f" Wait result: {found}") 68 | 69 | # Test 6: Comprehensive search 70 | print("\n6. Testing find_all_text:") 71 | session.execute("echo 'Error: Something went wrong'") 72 | 73 | results = session.find_all_text("Error", case_sensitive=False) 74 | print(f" Screen matches: {results['screen']}") 75 | print(f" Output positions: {results['output']}") 76 | 77 | print("\n✅ All corner case tests completed!") 78 | 79 | 80 | if __name__ == "__main__": 81 | test_text_finding_corner_cases() -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Termitty/termitty/97cbc4b6f5779377789a5e68730826aa8adcf480/tests/__init__.py --------------------------------------------------------------------------------