├── .gitignore ├── .python-version ├── LICENSE ├── README.md ├── README_ZH_TW.md ├── awsui ├── __init__.py ├── app.py ├── autocomplete.py ├── aws_cli.py ├── cheatsheet.py ├── command_parser.py ├── config.py ├── i18n.py ├── logging.py ├── models.py ├── parameter_metadata.py ├── q_assistant.py ├── resource_suggester.py └── service_model_loader.py ├── images ├── demo01.png ├── demo02.png ├── demo03.png ├── demo04.png └── logo.png ├── main.py ├── pyproject.toml ├── tests ├── __init__.py ├── conftest.py ├── test_autocomplete.py ├── test_command_parser.py ├── test_config.py ├── test_global_parameters.py ├── test_models.py ├── test_resource_suggester.py ├── test_service_model_loader.py ├── test_special_commands.py └── test_special_commands_no_autocomplete.py └── uv.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Virtual environments 10 | .venv 11 | venv/ 12 | env/ 13 | 14 | # Testing 15 | .pytest_cache/ 16 | .coverage 17 | htmlcov/ 18 | *.cover 19 | 20 | # IDEs 21 | .vscode/ 22 | .idea/ 23 | *.swp 24 | *.swo 25 | *~ 26 | 27 | # OS 28 | .DS_Store 29 | Thumbs.db 30 | 31 | # Logs 32 | *.log 33 | 34 | # AWS (optional - for testing) 35 | .aws/ 36 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 [junminhong](https://github.com/junminhong) 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 | # awsui 2 |

3 | 4 | awsui logo 5 | 6 |
7 |

8 | 9 |

10 | PyPI version 11 | PyPI status 12 | Python versions 13 | Downloads 14 |
15 | License: MIT 16 | Textual 17 | Ruff 18 |

19 | 20 |

21 | English 22 | 繁體中文 23 |

24 | 25 |

26 | A powerful, user-friendly terminal interface for AWS Profile and SSO management.
27 | Built with Textual for a modern, responsive TUI experience. 28 |

29 | 30 |

31 | ⚡ Fast🔐 Secure🤖 AI-Powered🌍 Bilingual 32 |

33 | 34 | ## ✨ Why awsui? 35 | 36 | - **⚡ Lightning Fast**: Search and switch between dozens of AWS profiles in milliseconds 37 | - **🔐 SSO Made Easy**: Automatic re-authentication when credentials expire - no manual login headaches 38 | - **🤖 AI-Powered**: Integrated Amazon Q Developer CLI for intelligent AWS assistance 39 | - **🎯 Smart CLI**: Command autocomplete with AWS CLI cheatsheet built-in 40 | - **🌍 Bilingual**: Full support for English and Traditional Chinese 41 | - **📊 Clear Visibility**: See profile details, account info, and current identity at a glance 42 | - **🎨 Modern UX**: Beautiful, keyboard-driven interface that respects your terminal theme 43 | 44 | ## 🎬 Demo 45 | 46 |

47 |

48 | Profile search and switching 49 |
⚡ Fast profile search and switching with real-time filtering
50 |
51 |

52 | 53 |

54 |

55 | AWS CLI execution 56 |
🎯 Smart CLI with command autocomplete and inline execution
57 |
58 |

59 | 60 |

61 |

62 | Amazon Q AI assistant 63 |
🤖 AI-powered Amazon Q Developer integration with streaming responses
64 |
65 |

66 | 67 |

68 |

69 | AWS CLI cheatsheet 70 |
📚 Built-in AWS CLI cheatsheet with quick reference for 15+ services
71 |
72 |

73 | 74 | ## 📋 Features 75 | 76 | ### Core Features 77 | - **Fast Profile Search**: Filter by name, account, role, or region with real-time fuzzy matching 78 | - **SSO Authentication**: Automatic `aws sso login` when tokens expire or on manual trigger 79 | - **Profile Details**: View comprehensive profile information including account, role, region, and session 80 | 81 | ### AI Assistant 82 | - **Amazon Q Integration**: Ask questions in natural language 83 | - **Context-Aware**: Automatically includes your current profile and region 84 | - **Streaming Responses**: Real-time output as Q processes your query 85 | - **Command Suggestions**: Get AWS CLI commands for common tasks 86 | 87 | ### CLI Features 88 | - **Command History**: Browse previous commands with ↑↓ 89 | - **Smart Autocomplete**: Suggestions from AWS CLI cheatsheet 90 | - **Inline Execution**: Run AWS CLI commands directly in the TUI 91 | - **Output Capture**: See command results with timing and exit codes 92 | - **Built-in Cheatsheet**: Quick reference for 15+ AWS services 93 | 94 | ### Developer Experience 95 | - **Structured Logging**: JSON logs to STDERR for debugging and monitoring 96 | - **Cross-Platform**: Linux, macOS, Windows (PowerShell) 97 | - **Keyboard-First**: Efficient navigation without touching the mouse 98 | - **Extensible**: Clean Python architecture for customization 99 | 100 | ## ⚡ Quick Start 101 | 102 | ```bash 103 | # Install with uv (recommended) 104 | uv tool install --python 3.13 awsui 105 | 106 | # Or install with pip 107 | pip install awsui 108 | 109 | # Launch the TUI 110 | awsui 111 | ``` 112 | 113 | That's it! Start managing your AWS profiles with ease. 🚀 114 | 115 | ## 📦 Requirements 116 | 117 | - **Python**: >= 3.13, < 3.14 118 | - **AWS CLI**: v2 (required) 119 | - **Amazon Q CLI**: Optional, for AI assistance ([installation guide](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-installing.html)) 120 | - **uv**: Recommended for dependency management ([installation guide](https://docs.astral.sh/uv/)) 121 | 122 | ## 🚀 Installation 123 | 124 | ### Option 1: Install with uv (Recommended) 125 | 126 | ```bash 127 | # Install as a tool (isolated environment) 128 | uv tool install --python 3.13 awsui 129 | 130 | # Run directly 131 | awsui 132 | ``` 133 | 134 | ### Option 2: Install with pip 135 | 136 | ```bash 137 | pip install awsui 138 | 139 | # Run 140 | awsui 141 | ``` 142 | 143 | ### Option 3: Development Setup 144 | 145 | ```bash 146 | # Clone the repository 147 | git clone https://github.com/junminhong/awsui.git 148 | cd awsui 149 | 150 | # Pin Python version 151 | uv python install 3.13 152 | uv python pin 3.13 153 | 154 | # Install dependencies 155 | uv sync 156 | 157 | # Run from source 158 | uv run awsui 159 | ``` 160 | 161 | ## 📖 Usage 162 | 163 | ### Interactive Mode 164 | 165 | Launch the TUI to select and switch profiles: 166 | 167 | ```bash 168 | awsui 169 | ``` 170 | 171 | **Keyboard Shortcuts:** 172 | 173 | | Category | Key | Action | 174 | |----------|-----|--------| 175 | | **🔍 Navigation** | `/` | Focus search box | 176 | | | `↑` `↓` | Navigate profiles | 177 | | | `Enter` | Apply selected profile | 178 | | | `Esc` | Leave input field | 179 | | **💻 CLI & Tools** | `c` | Focus CLI input | 180 | | | `a` | Toggle AI assistant panel | 181 | | | `h` | Show AWS CLI cheatsheet | 182 | | | `t` | Toggle left pane (profile list) | 183 | | **🔐 AWS** | `l` | Force SSO login for selected profile | 184 | | | `w` | Show current AWS identity (WhoAmI) | 185 | | **⚙️ System** | `Ctrl+L` | Clear CLI output | 186 | | | `Ctrl+U` | Clear CLI input | 187 | | | `?` | Show help | 188 | | | `q` | Quit | 189 | 190 | ### Pre-select Profile 191 | 192 | Skip interactive selection: 193 | 194 | ```bash 195 | # Pre-select a profile when launching the TUI 196 | awsui --profile my-prod-admin 197 | ``` 198 | 199 | ### Override Region 200 | 201 | Temporarily override AWS region: 202 | 203 | ```bash 204 | awsui --profile my-profile --region us-west-2 205 | ``` 206 | 207 | ### Language Selection 208 | 209 | ```bash 210 | # English (default) 211 | awsui --lang en 212 | 213 | # Traditional Chinese 214 | awsui --lang zh-TW 215 | ``` 216 | 217 | ### Debug Mode 218 | 219 | ```bash 220 | awsui --log-level DEBUG 2> awsui-debug.log 221 | ``` 222 | 223 | ## 🤖 AI Assistant (Amazon Q Developer) 224 | 225 | ### Setup 226 | 227 | 1. Install Amazon Q Developer CLI: 228 | ```bash 229 | # Follow official installation guide 230 | # https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-installing.html 231 | ``` 232 | 233 | 2. Verify installation: 234 | ```bash 235 | q --version 236 | ``` 237 | 238 | ### Usage 239 | 240 | 1. Press `a` in awsui to open AI assistant panel 241 | 2. Type your question (e.g., "How do I list all S3 buckets with encryption enabled?") 242 | 3. Press `Enter` to submit 243 | 4. View streaming response with AWS-specific context 244 | 5. Press `a` again to close panel 245 | 246 | The assistant automatically includes your current profile, region, and account context for more relevant answers. 247 | 248 | ## ⚙️ AWS Configuration 249 | 250 | ### SSO Session Configuration 251 | 252 | `~/.aws/config`: 253 | 254 | ```ini 255 | [sso-session my-company] 256 | sso_start_url = https://my-company.awsapps.com/start 257 | sso_region = us-east-1 258 | sso_registration_scopes = sso:account:access 259 | 260 | [profile production-admin] 261 | sso_session = my-company 262 | sso_account_id = 111111111111 263 | sso_role_name = AdministratorAccess 264 | region = us-east-1 265 | output = json 266 | 267 | [profile staging-developer] 268 | sso_session = my-company 269 | sso_account_id = 222222222222 270 | sso_role_name = DeveloperAccess 271 | region = us-west-2 272 | output = json 273 | ``` 274 | 275 | ### Assume Role Configuration 276 | 277 | ```ini 278 | [profile base] 279 | region = us-east-1 280 | 281 | [profile cross-account-admin] 282 | source_profile = base 283 | role_arn = arn:aws:iam::333333333333:role/AdminRole 284 | region = us-east-1 285 | ``` 286 | 287 | ### Legacy SSO (without sso-session) 288 | 289 | ```ini 290 | [profile legacy-sso] 291 | sso_start_url = https://my-company.awsapps.com/start 292 | sso_region = us-east-1 293 | sso_account_id = 444444444444 294 | sso_role_name = ViewOnlyAccess 295 | region = us-east-1 296 | ``` 297 | 298 | ## 📁 Project Structure 299 | 300 | ``` 301 | awsui/ 302 | ├── awsui/ 303 | │ ├── __init__.py 304 | │ ├── app.py # Main Textual application 305 | │ ├── models.py # Profile data models 306 | │ ├── config.py # AWS config parsing (~/.aws/config) 307 | │ ├── aws_cli.py # AWS CLI wrapper (SSO, STS) 308 | │ ├── q_assistant.py # Amazon Q Developer CLI integration 309 | │ ├── autocomplete.py # Command autocomplete engine 310 | │ ├── cheatsheet.py # AWS CLI command reference 311 | │ ├── i18n.py # Internationalization (EN/ZH-TW) 312 | │ └── logging.py # Structured JSON logging 313 | ├── tests/ 314 | │ ├── test_config.py 315 | │ ├── test_models.py 316 | │ └── __init__.py 317 | ├── docs/ 318 | │ ├── prd.md 319 | │ ├── constitution.md 320 | │ ├── specify.md 321 | │ ├── clarify.md 322 | │ ├── plan.md 323 | │ └── tasks.md 324 | ├── pyproject.toml 325 | ├── LICENSE 326 | ├── README.md 327 | └── README_ZH_TW.md 328 | ``` 329 | 330 | ## 🧪 Development 331 | 332 | ### Run Tests 333 | 334 | ```bash 335 | uv run pytest 336 | ``` 337 | 338 | ### Test Coverage 339 | 340 | ```bash 341 | uv run pytest --cov=awsui --cov-report=html 342 | open htmlcov/index.html 343 | ``` 344 | 345 | ### Install Dev Dependencies 346 | 347 | ```bash 348 | uv sync --dev 349 | ``` 350 | 351 | ### Code Quality 352 | 353 | ```bash 354 | # Linting (if configured) 355 | uv run ruff check awsui/ 356 | 357 | # Type checking (if configured) 358 | uv run mypy awsui/ 359 | ``` 360 | 361 | ## 🐛 Troubleshooting 362 | 363 |
364 | AWS CLI Not Found - E_NO_AWS: AWS CLI v2 not detected 365 | 366 |
367 | 368 | **Solution:** Install AWS CLI v2 following the [official guide](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) 369 | 370 | Verify installation: 371 | ```bash 372 | aws --version # Should show "aws-cli/2.x.x ..." 373 | ``` 374 | 375 |
376 | 377 |
378 | No Profiles Available - E_NO_PROFILES: No profiles detected 379 | 380 |
381 | 382 | **Solution:** Configure at least one profile: 383 | ```bash 384 | # For SSO 385 | aws configure sso-session 386 | 387 | # For legacy SSO 388 | aws configure sso 389 | 390 | # For static credentials 391 | aws configure 392 | ``` 393 | 394 |
395 | 396 |
397 | SSO Login Fails - E_LOGIN_FAIL: SSO login failed 398 | 399 |
400 | 401 | **Possible causes:** 402 | - Network connectivity issues 403 | - Invalid SSO start URL 404 | - MFA/2FA not completed 405 | - Browser not opening (check firewall/permissions) 406 | 407 | **Solution:** 408 | ```bash 409 | # Try manual login first 410 | aws sso login --profile your-profile-name 411 | 412 | # Check browser permissions 413 | # Ensure port 8080-8090 range is available for OAuth callback 414 | ``` 415 | 416 |
417 | 418 |
419 | Identity Check Fails - E_STS_FAIL: Unable to fetch identity 420 | 421 |
422 | 423 | **Possible causes:** 424 | - Credentials expired (SSO token or assume-role session) 425 | - Invalid profile configuration 426 | - Network/VPC issues 427 | - Missing IAM permissions 428 | 429 | **Solution:** 430 | ```bash 431 | # Force re-authentication 432 | # Press 'l' in awsui to trigger SSO login 433 | 434 | # Verify profile configuration 435 | cat ~/.aws/config 436 | 437 | # Test manually 438 | aws sts get-caller-identity --profile your-profile-name 439 | ``` 440 | 441 |
442 | 443 |
444 | Amazon Q Not Available - Amazon Q CLI not available 445 | 446 |
447 | 448 | **Solution:** Install Amazon Q Developer CLI: 449 | ```bash 450 | # macOS 451 | brew install amazon-q 452 | 453 | # Other platforms: follow official guide 454 | # https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-installing.html 455 | ``` 456 | 457 | Verify installation: 458 | ```bash 459 | q --version 460 | ``` 461 | 462 |
463 | 464 | ## 🔒 Security 465 | 466 | awsui follows AWS security best practices: 467 | 468 | - ✅ **Credential Handling**: Only uses AWS CLI's credential system - no credential storage or caching 469 | - ✅ **Temporary Credentials**: Leverages AWS STS and SSO for short-lived tokens 470 | - ✅ **Read-Only Config**: Only reads `~/.aws/config` and `~/.aws/credentials` - never writes 471 | - ✅ **Log Safety**: Sensitive data (tokens, secrets) automatically masked in logs 472 | - ✅ **Environment Isolation**: Supports `AWS_CONFIG_FILE` and `AWS_SHARED_CREDENTIALS_FILE` for custom config locations 473 | - ✅ **No Network Calls**: All AWS operations delegated to official AWS CLI 474 | - ✅ **Subprocess Safety**: Secure subprocess execution with proper escaping 475 | 476 | ## 🎯 Performance 477 | 478 | Target metrics: 479 | 480 | - **Startup time**: ≤ 300ms (cold start) 481 | - **Search response**: ≤ 50ms (keystroke to UI update) 482 | - **Profile switch**: ≤ 5s (including SSO login if needed) 483 | 484 | ## 🤝 Contributing 485 | 486 | Contributions are welcome! Please feel free to submit issues, feature requests, or pull requests. 487 | 488 | ### Guidelines 489 | 490 | 1. Fork the repository 491 | 2. Create a feature branch (`git checkout -b feature/amazing-feature`) 492 | 3. Make your changes 493 | 4. Add tests for new functionality 494 | 5. Ensure all tests pass (`uv run pytest`) 495 | 6. Commit your changes (`git commit -m 'Add amazing feature'`) 496 | 7. Push to the branch (`git push origin feature/amazing-feature`) 497 | 8. Open a Pull Request 498 | 499 | ### Development Setup 500 | 501 | See [Development](#-development) section above. 502 | 503 | ## 📄 License 504 | 505 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 506 | 507 | ## 🙏 Acknowledgments 508 | 509 | - [Textual](https://textual.textualize.io/) - Modern TUI framework for Python 510 | - [uv](https://docs.astral.sh/uv/) - Fast Python package installer and resolver 511 | - [AWS CLI](https://aws.amazon.com/cli/) - Official AWS command-line tool 512 | - [Amazon Q Developer](https://aws.amazon.com/q/developer/) - AI-powered assistant for AWS 513 | 514 | ## 📚 References 515 | 516 | - [AWS CLI SSO Configuration](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html) 517 | - [AWS CLI Assume Role](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-role.html) 518 | - [Textual Documentation](https://textual.textualize.io/) 519 | - [Amazon Q Developer CLI](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line.html) 520 | - [Python 3.13 Documentation](https://docs.python.org/3.13/) 521 | 522 | --- 523 | 524 |

✨ Made with ❤️ for AWS Developers ✨

525 | 526 |

527 | awsui - Making AWS Profile switching delightful! 🚀 528 |

529 | 530 |

531 | If you find this tool useful, please consider giving it a ⭐ on GitHub! 532 |

533 | 534 |

535 | 536 | GitHub stars 537 | 538 | 539 | GitHub forks 540 | 541 |

542 | 543 |

544 | Report Bug 545 | • 546 | Request Feature 547 | • 548 | PyPI Package 549 |

550 | -------------------------------------------------------------------------------- /README_ZH_TW.md: -------------------------------------------------------------------------------- 1 | # awsui 2 |

3 | 4 | awsui logo 5 | 6 |
7 |

8 | 9 |

10 | PyPI version 11 | PyPI status 12 | Python versions 13 | Downloads 14 |
15 | License: MIT 16 | Textual 17 | Ruff 18 |

19 | 20 |

21 | English 22 | 繁體中文 23 |

24 | 25 |

26 | 強大且易用的 AWS Profile 與 SSO 管理終端介面工具。
27 | 使用 Textual 打造現代化、高回應性的 TUI 體驗。 28 |

29 | 30 |

31 | ⚡ 快速🔐 安全🤖 AI 驅動🌍 雙語 32 |

33 | 34 | ## ✨ 為什麼選擇 awsui? 35 | - **⚡ 極速快捷**:在數十個 AWS profiles 間毫秒級搜尋與切換 36 | - **🔐 SSO 超簡單**:認證過期時自動重新登入 - 告別手動登入的煩惱 37 | - **🤖 AI 加持**:整合 Amazon Q Developer CLI,提供智慧型 AWS 協助 38 | - **🎯 聰明的 CLI**:內建 AWS CLI cheatsheet 的指令自動完成 39 | - **🌍 雙語支援**:完整支援英文與繁體中文 40 | - **📊 一目了然**:清楚顯示 profile 詳情、帳號資訊與當前身份 41 | - **🎨 現代化介面**:美觀、鍵盤導向的介面,尊重您的終端主題 42 | 43 | ## 🎬 展示 44 | 45 |

46 |

47 | Profile 搜尋與切換 48 |
⚡ 快速的 profile 搜尋與切換,即時過濾
49 |
50 |

51 | 52 |

53 |

54 | AWS CLI 執行 55 |
🎯 智慧 CLI,具備指令自動完成與內嵌執行
56 |
57 |

58 | 59 |

60 |

61 | Amazon Q AI 助手 62 |
🤖 AI 驅動的 Amazon Q Developer 整合,支援串流回應
63 |
64 |

65 | 66 |

67 |

68 | AWS CLI cheatsheet 69 |
📚 內建 AWS CLI cheatsheet,15+ 項服務的快速參考
70 |
71 |

72 | 73 | ## 📋 功能特色 74 | 75 | ### 核心功能 76 | - **快速 Profile 搜尋**:依名稱、帳號、角色或區域即時模糊搜尋過濾 77 | - **SSO 認證**:認證過期時自動執行 `aws sso login` 或手動觸發 78 | - **Profile 詳情**:檢視完整的 profile 資訊,包括帳號、角色、區域與 session 79 | 80 | ### AI 助手 81 | - **Amazon Q 整合**:使用自然語言提問 82 | - **情境感知**:自動包含您當前的 profile 與區域 83 | - **串流回應**:Q 處理查詢時的即時輸出 84 | - **指令建議**:取得常見任務的 AWS CLI 指令 85 | 86 | ### CLI 功能 87 | - **指令歷史**:使用 ↑↓ 瀏覽先前的指令 88 | - **智慧自動完成**:從 AWS CLI cheatsheet 取得建議 89 | - **內嵌執行**:直接在 TUI 中執行 AWS CLI 指令 90 | - **輸出擷取**:查看指令結果,包含執行時間與結束代碼 91 | - **內建 Cheatsheet**:15+ 個 AWS 服務的快速參考 92 | 93 | ### 開發者體驗 94 | - **結構化日誌**:輸出 JSON 格式日誌至 STDERR 便於除錯與監控 95 | - **跨平台**:Linux、macOS、Windows (PowerShell) 96 | - **鍵盤優先**:高效率的導航,無需使用滑鼠 97 | - **可擴展**:乾淨的 Python 架構便於客製化 98 | 99 | ## ⚡ 快速開始 100 | 101 | ```bash 102 | # 使用 uv 安裝(建議) 103 | uv tool install --python 3.13 awsui 104 | 105 | # 或使用 pip 安裝 106 | pip install awsui 107 | 108 | # 啟動 TUI 109 | awsui 110 | ``` 111 | 112 | 就這麼簡單!開始輕鬆管理您的 AWS profiles。🚀 113 | 114 | ## 📦 需求 115 | 116 | - **Python**: >= 3.13, < 3.14 117 | - **AWS CLI**: v2 (必要) 118 | - **Amazon Q CLI**: 選用,用於 AI 協助 ([安裝指南](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-installing.html)) 119 | - **uv**: 建議用於相依性管理 ([安裝指南](https://docs.astral.sh/uv/)) 120 | 121 | ## 🚀 安裝 122 | 123 | ### 方案 1:使用 uv 安裝(建議) 124 | 125 | ```bash 126 | # 安裝為工具(隔離環境) 127 | uv tool install --python 3.13 awsui 128 | 129 | # 直接執行 130 | awsui 131 | ``` 132 | 133 | ### 方案 2:使用 pip 安裝 134 | 135 | ```bash 136 | pip install awsui 137 | 138 | # 執行 139 | awsui 140 | ``` 141 | 142 | ### 方案 3:開發環境設定 143 | 144 | ```bash 145 | # Clone repository 146 | git clone https://github.com/junminhong/awsui.git 147 | cd awsui 148 | 149 | # 固定 Python 版本 150 | uv python install 3.13 151 | uv python pin 3.13 152 | 153 | # 安裝相依性 154 | uv sync 155 | 156 | # 從原始碼執行 157 | uv run awsui 158 | ``` 159 | 160 | ## 📖 使用方式 161 | 162 | ### 互動模式 163 | 164 | 啟動 TUI 來選擇與切換 profiles: 165 | 166 | ```bash 167 | awsui 168 | ``` 169 | 170 | **鍵盤快捷鍵:** 171 | 172 | | 分類 | 按鍵 | 功能 | 173 | |----------|-----|--------| 174 | | **🔍 導覽** | `/` | 聚焦搜尋框 | 175 | | | `↑` `↓` | 導覽 profiles | 176 | | | `Enter` | 套用選定的 profile | 177 | | | `Esc` | 離開輸入欄位 | 178 | | **💻 CLI 與工具** | `c` | 聚焦 CLI 輸入 | 179 | | | `a` | 切換 AI 助手面板 | 180 | | | `h` | 顯示 AWS CLI cheatsheet | 181 | | | `t` | 切換左側面板(profile 列表) | 182 | | **🔐 AWS** | `l` | 強制為選定的 profile 執行 SSO 登入 | 183 | | | `w` | 顯示當前 AWS 身份 (WhoAmI) | 184 | | **⚙️ 系統** | `Ctrl+L` | 清空 CLI 輸出 | 185 | | | `Ctrl+U` | 清空 CLI 輸入 | 186 | | | `?` | 顯示說明 | 187 | | | `q` | 離開程式 | 188 | 189 | ### 預先選擇 Profile 190 | 191 | 略過互動式選擇: 192 | 193 | ```bash 194 | # 啟動 TUI 前預選特定 profile 195 | awsui --profile my-prod-admin 196 | ``` 197 | 198 | ### 覆寫區域 199 | 200 | 暫時覆寫 AWS 區域: 201 | 202 | ```bash 203 | awsui --profile my-profile --region us-west-2 204 | ``` 205 | 206 | ### 語言選擇 207 | 208 | ```bash 209 | # 英文(預設) 210 | awsui --lang en 211 | 212 | # 繁體中文 213 | awsui --lang zh-TW 214 | ``` 215 | 216 | ### 除錯模式 217 | 218 | ```bash 219 | awsui --log-level DEBUG 2> awsui-debug.log 220 | ``` 221 | 222 | ## 🤖 AI 助手(Amazon Q Developer) 223 | 224 | ### 設定 225 | 226 | 1. 安裝 Amazon Q Developer CLI: 227 | ```bash 228 | # 依循官方安裝指南 229 | # https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-installing.html 230 | ``` 231 | 232 | 2. 驗證安裝: 233 | ```bash 234 | q --version 235 | ``` 236 | 237 | ### 使用方式 238 | 239 | 1. 在 awsui 中按 `a` 開啟 AI 助手面板 240 | 2. 輸入您的問題(例如:「如何列出所有啟用加密的 S3 buckets?」) 241 | 3. 按 `Enter` 送出 242 | 4. 查看帶有 AWS 特定情境的串流回應 243 | 5. 再按一次 `a` 關閉面板 244 | 245 | 助手會自動包含您當前的 profile、區域與帳號情境,以提供更相關的答案。 246 | 247 | ## ⚙️ AWS 設定 248 | 249 | ### SSO Session 設定 250 | 251 | `~/.aws/config`: 252 | 253 | ```ini 254 | [sso-session my-company] 255 | sso_start_url = https://my-company.awsapps.com/start 256 | sso_region = us-east-1 257 | sso_registration_scopes = sso:account:access 258 | 259 | [profile production-admin] 260 | sso_session = my-company 261 | sso_account_id = 111111111111 262 | sso_role_name = AdministratorAccess 263 | region = us-east-1 264 | output = json 265 | 266 | [profile staging-developer] 267 | sso_session = my-company 268 | sso_account_id = 222222222222 269 | sso_role_name = DeveloperAccess 270 | region = us-west-2 271 | output = json 272 | ``` 273 | 274 | ### Assume Role 設定 275 | 276 | ```ini 277 | [profile base] 278 | region = us-east-1 279 | 280 | [profile cross-account-admin] 281 | source_profile = base 282 | role_arn = arn:aws:iam::333333333333:role/AdminRole 283 | region = us-east-1 284 | ``` 285 | 286 | ### 傳統 SSO(沒有 sso-session) 287 | 288 | ```ini 289 | [profile legacy-sso] 290 | sso_start_url = https://my-company.awsapps.com/start 291 | sso_region = us-east-1 292 | sso_account_id = 444444444444 293 | sso_role_name = ViewOnlyAccess 294 | region = us-east-1 295 | ``` 296 | 297 | ## 📁 專案結構 298 | 299 | ``` 300 | awsui/ 301 | ├── awsui/ 302 | │ ├── __init__.py 303 | │ ├── app.py # 主要 Textual 應用程式 304 | │ ├── models.py # Profile 資料模型 305 | │ ├── config.py # AWS config 解析 (~/.aws/config) 306 | │ ├── aws_cli.py # AWS CLI 包裝器 (SSO, STS) 307 | │ ├── q_assistant.py # Amazon Q Developer CLI 整合 308 | │ ├── autocomplete.py # 指令自動完成引擎 309 | │ ├── cheatsheet.py # AWS CLI 指令參考 310 | │ ├── i18n.py # 國際化 (EN/ZH-TW) 311 | │ └── logging.py # 結構化 JSON 日誌 312 | ├── tests/ 313 | │ ├── test_config.py 314 | │ ├── test_models.py 315 | │ └── __init__.py 316 | ├── docs/ 317 | │ ├── prd.md 318 | │ ├── constitution.md 319 | │ ├── specify.md 320 | │ ├── clarify.md 321 | │ ├── plan.md 322 | │ └── tasks.md 323 | ├── pyproject.toml 324 | ├── LICENSE 325 | ├── README.md 326 | └── README_ZH_TW.md 327 | ``` 328 | 329 | ## 🧪 開發 330 | 331 | ### 執行測試 332 | 333 | ```bash 334 | uv run pytest 335 | ``` 336 | 337 | ### 測試覆蓋率 338 | 339 | ```bash 340 | uv run pytest --cov=awsui --cov-report=html 341 | open htmlcov/index.html 342 | ``` 343 | 344 | ### 安裝開發相依性 345 | 346 | ```bash 347 | uv sync --dev 348 | ``` 349 | 350 | ### 程式碼品質 351 | 352 | ```bash 353 | # Linting(如已設定) 354 | uv run ruff check awsui/ 355 | 356 | # Type checking(如已設定) 357 | uv run mypy awsui/ 358 | ``` 359 | 360 | ## 🐛 疑難排解 361 | 362 |
363 | 找不到 AWS CLI - E_NO_AWS: AWS CLI v2 not detected 364 | 365 |
366 | 367 | **解決方案:** 依循[官方指南](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html)安裝 AWS CLI v2 368 | 369 | 驗證安裝: 370 | ```bash 371 | aws --version # 應顯示 "aws-cli/2.x.x ..." 372 | ``` 373 | 374 |
375 | 376 |
377 | 沒有可用的 Profiles - E_NO_PROFILES: No profiles detected 378 | 379 |
380 | 381 | **解決方案:** 至少設定一個 profile: 382 | ```bash 383 | # 用於 SSO 384 | aws configure sso-session 385 | 386 | # 用於傳統 SSO 387 | aws configure sso 388 | 389 | # 用於靜態憑證 390 | aws configure 391 | ``` 392 | 393 |
394 | 395 |
396 | SSO 登入失敗 - E_LOGIN_FAIL: SSO login failed 397 | 398 |
399 | 400 | **可能原因:** 401 | - 網路連線問題 402 | - 無效的 SSO start URL 403 | - MFA/2FA 未完成 404 | - 瀏覽器未開啟(檢查防火牆/權限) 405 | 406 | **解決方案:** 407 | ```bash 408 | # 先嘗試手動登入 409 | aws sso login --profile your-profile-name 410 | 411 | # 檢查瀏覽器權限 412 | # 確保 port 8080-8090 範圍可用於 OAuth callback 413 | ``` 414 | 415 |
416 | 417 |
418 | 身份檢查失敗 - E_STS_FAIL: Unable to fetch identity 419 | 420 |
421 | 422 | **可能原因:** 423 | - 憑證過期(SSO token 或 assume-role session) 424 | - 無效的 profile 設定 425 | - 網路/VPC 問題 426 | - 缺少 IAM 權限 427 | 428 | **解決方案:** 429 | ```bash 430 | # 強制重新認證 431 | # 在 awsui 中按 'l' 觸發 SSO 登入 432 | 433 | # 驗證 profile 設定 434 | cat ~/.aws/config 435 | 436 | # 手動測試 437 | aws sts get-caller-identity --profile your-profile-name 438 | ``` 439 | 440 |
441 | 442 |
443 | Amazon Q 不可用 - Amazon Q CLI not available 444 | 445 |
446 | 447 | **解決方案:** 安裝 Amazon Q Developer CLI: 448 | ```bash 449 | # macOS 450 | brew install amazon-q 451 | 452 | # 其他平台:依循官方指南 453 | # https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-installing.html 454 | ``` 455 | 456 | 驗證安裝: 457 | ```bash 458 | q --version 459 | ``` 460 | 461 |
462 | 463 | ## 🔒 安全性 464 | 465 | awsui 遵循 AWS 安全最佳實踐: 466 | 467 | - ✅ **憑證處理**:僅使用 AWS CLI 的憑證系統 - 不儲存或快取憑證 468 | - ✅ **臨時憑證**:利用 AWS STS 與 SSO 取得短期 token 469 | - ✅ **唯讀設定**:僅讀取 `~/.aws/config` 和 `~/.aws/credentials` - 從不寫入 470 | - ✅ **日誌安全**:敏感資料(tokens、secrets)在日誌中自動遮罩 471 | - ✅ **環境隔離**:支援 `AWS_CONFIG_FILE` 和 `AWS_SHARED_CREDENTIALS_FILE` 用於自訂設定位置 472 | - ✅ **無網路呼叫**:所有 AWS 操作委派給官方 AWS CLI 473 | - ✅ **子程序安全**:使用適當的跳脫進行安全的子程序執行 474 | 475 | ## 🎯 效能 476 | 477 | 目標指標: 478 | 479 | - **啟動時間**:≤ 300ms(冷啟動) 480 | - **搜尋回應**:≤ 50ms(按鍵到 UI 更新) 481 | - **Profile 切換**:≤ 5s(包含 SSO 登入,如需要) 482 | 483 | ## 🤝 貢獻 484 | 485 | 歡迎貢獻!請隨時提交 issues、功能請求或 pull requests。 486 | 487 | ### 貢獻指南 488 | 489 | 1. Fork 此 repository 490 | 2. 建立功能分支(`git checkout -b feature/amazing-feature`) 491 | 3. 進行您的變更 492 | 4. 為新功能加入測試 493 | 5. 確保所有測試通過(`uv run pytest`) 494 | 6. Commit 您的變更(`git commit -m 'Add amazing feature'`) 495 | 7. Push 到分支(`git push origin feature/amazing-feature`) 496 | 8. 開啟 Pull Request 497 | 498 | ### 開發環境設定 499 | 500 | 參見上方[開發](#-開發)章節。 501 | 502 | ## 📄 授權 503 | 504 | 此專案使用 MIT License 授權 - 詳見 [LICENSE](LICENSE) 檔案。 505 | 506 | ## 🙏 致謝 507 | 508 | - [Textual](https://textual.textualize.io/) - Python 現代化 TUI 框架 509 | - [uv](https://docs.astral.sh/uv/) - 快速的 Python 套件安裝器與解析器 510 | - [AWS CLI](https://aws.amazon.com/cli/) - 官方 AWS 命令列工具 511 | - [Amazon Q Developer](https://aws.amazon.com/q/developer/) - AWS AI 助手 512 | 513 | ## 📚 參考資料 514 | 515 | - [AWS CLI SSO Configuration](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html) 516 | - [AWS CLI Assume Role](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-role.html) 517 | - [Textual Documentation](https://textual.textualize.io/) 518 | - [Amazon Q Developer CLI](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line.html) 519 | - [Python 3.13 Documentation](https://docs.python.org/3.13/) 520 | 521 | --- 522 | 523 |

✨ 用 ❤️ 為 AWS 開發者打造 ✨

524 | 525 |

526 | awsui - 讓 AWS Profile 切換變得愉快!🚀 527 |

528 | 529 |

530 | 如果您覺得此工具有用,請考慮在 GitHub 上給它一個 ⭐! 531 |

532 | 533 |

534 | 535 | GitHub stars 536 | 537 | 538 | GitHub forks 539 | 540 |

541 | 542 |

543 | 回報問題 544 | • 545 | 功能請求 546 | • 547 | PyPI 套件 548 |

549 | -------------------------------------------------------------------------------- /awsui/__init__.py: -------------------------------------------------------------------------------- 1 | """awsui - AWS Profile/SSO switcher TUI.""" 2 | 3 | __version__ = "0.1.0" -------------------------------------------------------------------------------- /awsui/autocomplete.py: -------------------------------------------------------------------------------- 1 | """Custom autocomplete widget for AWS CLI commands.""" 2 | 3 | from textual.widgets import OptionList 4 | from textual.widgets.option_list import Option 5 | from textual.message import Message 6 | 7 | from .command_parser import AWSCommandParser, CompletionContext 8 | from .parameter_metadata import get_parameter_metadata, format_parameter_help 9 | from .resource_suggester import ResourceSuggester 10 | 11 | 12 | class CommandAutocomplete(OptionList): 13 | """Enhanced autocomplete with fuzzy matching and highlighting.""" 14 | 15 | class CommandSelected(Message): 16 | """Message sent when a command is selected from autocomplete.""" 17 | 18 | def __init__(self, command: str) -> None: 19 | self.command = command 20 | super().__init__() 21 | 22 | def __init__(self, commands: list[str], command_categories: dict[str, str], *args, **kwargs): 23 | super().__init__(*args, **kwargs) 24 | self.all_commands = commands 25 | self.command_categories = command_categories 26 | self.filtered_commands: list[str] = [] 27 | self.display = False 28 | self.can_focus = False 29 | 30 | self.parser = AWSCommandParser() 31 | self.use_intelligent_autocomplete = True 32 | self.resource_suggester: ResourceSuggester | None = None 33 | self.enable_resource_suggestions = True 34 | 35 | def set_aws_context(self, profile: str | None, region: str | None) -> None: 36 | """Set AWS profile and region for resource suggestions.""" 37 | if self.enable_resource_suggestions: 38 | self.resource_suggester = ResourceSuggester(profile, region) 39 | 40 | def fuzzy_match(self, text: str, query: str) -> tuple[bool, int]: 41 | """ 42 | Check if query fuzzy matches text with scoring. 43 | Returns (matched, score) where higher score = better match. 44 | """ 45 | text_lower = text.lower() 46 | query_lower = query.lower() 47 | 48 | if query_lower in text_lower: 49 | position = text_lower.find(query_lower) 50 | score = 100 - (position * 2) 51 | return (True, max(score, 80)) 52 | 53 | text_idx = 0 54 | query_idx = 0 55 | matches = 0 56 | 57 | while text_idx < len(text_lower) and query_idx < len(query_lower): 58 | if text_lower[text_idx] == query_lower[query_idx]: 59 | matches += 1 60 | query_idx += 1 61 | text_idx += 1 62 | 63 | if query_idx == len(query_lower): 64 | score = int((matches / len(text_lower)) * 60) 65 | return (True, max(score, 20)) 66 | 67 | return (False, 0) 68 | 69 | def highlight_match(self, text: str, query: str) -> str: 70 | """Highlight matching substring in text.""" 71 | if not query: 72 | return text 73 | 74 | lower_text = text.lower() 75 | lower_query = query.lower() 76 | start = lower_text.find(lower_query) 77 | 78 | if start >= 0: 79 | end = start + len(query) 80 | return f"{text[:start]}[bold yellow]{text[start:end]}[/]{text[end:]}" 81 | 82 | return text 83 | 84 | def filter_commands(self, query: str, cursor_pos: int | None = None) -> None: 85 | """ 86 | Filter commands with intelligent or fuzzy matching. 87 | 88 | Args: 89 | query: The command line input 90 | cursor_pos: Cursor position in the input (for intelligent parsing) 91 | """ 92 | if not query or len(query) < 2: 93 | self.display = False 94 | self.filtered_commands = [] 95 | self.clear_options() 96 | return 97 | 98 | if self.use_intelligent_autocomplete and query.startswith("aws "): 99 | self._intelligent_filter(query, cursor_pos) 100 | else: 101 | self._fuzzy_filter(query) 102 | 103 | def _intelligent_filter(self, query: str, cursor_pos: int | None = None) -> None: 104 | """Use intelligent parser for context-aware suggestions.""" 105 | parsed = self.parser.parse(query, cursor_pos) 106 | suggestions = self.parser.get_suggestions(parsed) 107 | 108 | if (parsed.current_context == CompletionContext.PARAMETER_VALUE and 109 | self.resource_suggester and parsed.service and parsed.command): 110 | last_param = None 111 | for param, value in reversed(list(parsed.parameters.items())): 112 | if value is None: 113 | last_param = param 114 | break 115 | 116 | if last_param: 117 | resource_suggestions = self.resource_suggester.get_suggestions_for_parameter( 118 | parsed.service, parsed.command, last_param 119 | ) 120 | if resource_suggestions: 121 | query_lower = parsed.current_token.lower() 122 | filtered_resources = [ 123 | r for r in resource_suggestions 124 | if query_lower in r.lower() 125 | ] 126 | if filtered_resources: 127 | suggestions = filtered_resources[:10] 128 | 129 | if suggestions: 130 | self.filtered_commands = suggestions[:10] 131 | 132 | self.clear_options() 133 | for suggestion in self.filtered_commands: 134 | if parsed.current_context == CompletionContext.SERVICE: 135 | badge = "[dim cyan]SVC[/dim cyan]" 136 | description = "" 137 | elif parsed.current_context == CompletionContext.COMMAND: 138 | badge = "[dim green]CMD[/dim green]" 139 | description = "" 140 | elif parsed.current_context == CompletionContext.PARAMETER: 141 | badge = "[dim yellow]OPT[/dim yellow]" 142 | metadata = get_parameter_metadata(parsed.service, suggestion) 143 | if metadata: 144 | required_mark = "*" if metadata.required else "" 145 | description = f" [{metadata.param_type.value}]{required_mark} {metadata.description[:40]}" 146 | else: 147 | description = "" 148 | elif parsed.current_context == CompletionContext.PARAMETER_VALUE: 149 | badge = "[dim magenta]VAL[/dim magenta]" 150 | description = "" 151 | else: 152 | badge = "" 153 | description = "" 154 | 155 | highlighted = self.highlight_match(suggestion, parsed.current_token) 156 | label_text = f"{badge} {highlighted}{description}" 157 | self.add_option(Option(label_text, id=suggestion)) 158 | 159 | self.display = True 160 | if len(self.filtered_commands) > 0: 161 | self.highlighted = 0 162 | else: 163 | self._fuzzy_filter(query) 164 | 165 | def _fuzzy_filter(self, query: str) -> None: 166 | """Legacy fuzzy matching filter (fallback).""" 167 | matches = [] 168 | for cmd in self.all_commands: 169 | matched, score = self.fuzzy_match(cmd, query) 170 | if matched: 171 | matches.append((cmd, score)) 172 | 173 | if matches: 174 | matches.sort(key=lambda x: x[1], reverse=True) 175 | self.filtered_commands = [cmd for cmd, _ in matches[:10]] 176 | 177 | self.clear_options() 178 | for cmd in self.filtered_commands: 179 | category = self.command_categories.get(cmd, "") 180 | if category: 181 | badge_text = category.split("/")[0][:4].upper() 182 | badge = f"[dim cyan]{badge_text}[/dim cyan]" 183 | else: 184 | badge = "" 185 | 186 | highlighted = self.highlight_match(cmd, query) 187 | label_text = f"{badge} {highlighted}" if badge else highlighted 188 | self.add_option(Option(label_text, id=cmd)) 189 | 190 | self.display = True 191 | if len(self.filtered_commands) > 0: 192 | self.highlighted = 0 193 | else: 194 | self.display = False 195 | self.filtered_commands = [] 196 | self.clear_options() 197 | 198 | def get_selected_command(self) -> str | None: 199 | """Get currently highlighted command.""" 200 | if self.highlighted is not None and 0 <= self.highlighted < len(self.filtered_commands): 201 | return self.filtered_commands[self.highlighted] 202 | return None 203 | 204 | def move_cursor_down(self) -> None: 205 | """Move selection down.""" 206 | if self.filtered_commands and self.highlighted is not None: 207 | self.highlighted = min(self.highlighted + 1, len(self.filtered_commands) - 1) 208 | 209 | def move_cursor_up(self) -> None: 210 | """Move selection up.""" 211 | if self.filtered_commands and self.highlighted is not None: 212 | self.highlighted = max(self.highlighted - 1, 0) 213 | 214 | def smart_insert_selection( 215 | self, current_value: str, cursor_pos: int, selection: str 216 | ) -> tuple[str, int]: 217 | """ 218 | Intelligently insert the selected suggestion into the command line. 219 | 220 | Preserves all content before and after the current token being completed, 221 | replacing only the incomplete token with the selection. 222 | 223 | Args: 224 | current_value: Current command line text 225 | cursor_pos: Current cursor position 226 | selection: Selected suggestion to insert 227 | 228 | Returns: 229 | Tuple of (new_value, new_cursor_position) 230 | """ 231 | if selection.startswith("aws "): 232 | return (selection, len(selection)) 233 | 234 | if not self.use_intelligent_autocomplete or not current_value.strip().startswith("aws "): 235 | return (selection, len(selection)) 236 | 237 | parsed = self.parser.parse(current_value, cursor_pos) 238 | token_start = cursor_pos - len(parsed.current_token) 239 | 240 | # Find where the current token actually ends (could be beyond cursor) 241 | token_end = cursor_pos 242 | while token_end < len(current_value) and not current_value[token_end].isspace(): 243 | token_end += 1 244 | 245 | text_after_token = current_value[token_end:] 246 | 247 | if parsed.current_context in ( 248 | CompletionContext.SERVICE, 249 | CompletionContext.COMMAND, 250 | CompletionContext.PARAMETER, 251 | CompletionContext.PARAMETER_VALUE, 252 | ): 253 | new_value = ( 254 | current_value[:token_start] + 255 | selection + 256 | " " + 257 | text_after_token.lstrip() 258 | ) 259 | new_cursor = token_start + len(selection) + 1 260 | else: 261 | if parsed.current_token: 262 | new_value = ( 263 | current_value[:token_start] + 264 | selection + 265 | " " + 266 | text_after_token.lstrip() 267 | ) 268 | new_cursor = token_start + len(selection) + 1 269 | else: 270 | new_value = current_value.rstrip() + " " + selection + " " 271 | new_cursor = len(new_value) 272 | 273 | return (new_value, new_cursor) -------------------------------------------------------------------------------- /awsui/aws_cli.py: -------------------------------------------------------------------------------- 1 | """AWS CLI wrapper for STS and SSO operations.""" 2 | 3 | import json 4 | import subprocess 5 | import time 6 | from typing import Any, Callable, Dict 7 | 8 | 9 | def check_aws_cli_available() -> bool: 10 | """Check if AWS CLI v2 is available.""" 11 | try: 12 | result = subprocess.run( 13 | ["aws", "--version"], 14 | capture_output=True, 15 | text=True, 16 | timeout=5 17 | ) 18 | return result.returncode == 0 and "aws-cli/2" in result.stdout 19 | except (subprocess.SubprocessError, FileNotFoundError): 20 | return False 21 | 22 | 23 | def get_caller_identity(profile: str) -> Dict[str, Any] | None: 24 | """ 25 | Execute 'aws sts get-caller-identity' for the given profile. 26 | 27 | Returns parsed JSON response or None on failure. 28 | """ 29 | try: 30 | result = subprocess.run( 31 | ["aws", "sts", "get-caller-identity", "--profile", profile], 32 | capture_output=True, 33 | text=True, 34 | timeout=10 35 | ) 36 | if result.returncode == 0: 37 | return json.loads(result.stdout) 38 | return None 39 | except (subprocess.SubprocessError, json.JSONDecodeError): 40 | return None 41 | 42 | 43 | def _terminate_process(process: subprocess.Popen[Any]) -> None: 44 | """Terminate process gracefully, falling back to kill if needed.""" 45 | try: 46 | process.terminate() 47 | except Exception: 48 | return 49 | 50 | try: 51 | process.wait(timeout=5) 52 | except subprocess.TimeoutExpired: 53 | try: 54 | process.kill() 55 | except Exception: 56 | return 57 | try: 58 | process.wait(timeout=5) 59 | except subprocess.TimeoutExpired: 60 | pass 61 | 62 | 63 | def sso_login( 64 | profile: str, 65 | cancel_check: Callable[[], bool] | None = None, 66 | timeout: int = 300, 67 | poll_interval: float = 0.5 68 | ) -> bool: 69 | """ 70 | Execute 'aws sso login --profile ' with optional cancellation. 71 | 72 | Returns True if login succeeded and False otherwise. 73 | """ 74 | try: 75 | process = subprocess.Popen( 76 | ["aws", "sso", "login", "--profile", profile], 77 | stdout=subprocess.DEVNULL, 78 | stderr=subprocess.STDOUT 79 | ) 80 | except (OSError, ValueError, subprocess.SubprocessError): 81 | return False 82 | 83 | start_time = time.monotonic() 84 | 85 | while True: 86 | if cancel_check and cancel_check(): 87 | _terminate_process(process) 88 | return False 89 | 90 | result = process.poll() 91 | if result is not None: 92 | return result == 0 93 | 94 | if timeout and (time.monotonic() - start_time) >= timeout: 95 | _terminate_process(process) 96 | return False 97 | 98 | time.sleep(poll_interval) 99 | 100 | 101 | def ensure_authenticated( 102 | profile: str, 103 | cancel_check: Callable[[], bool] | None = None 104 | ) -> Dict[str, Any] | None: 105 | """ 106 | Ensure profile is authenticated, performing SSO login if needed. 107 | 108 | Returns caller identity on success, None on failure. 109 | """ 110 | if cancel_check and cancel_check(): 111 | return None 112 | 113 | identity = get_caller_identity(profile) 114 | if identity: 115 | return identity 116 | 117 | if cancel_check and cancel_check(): 118 | return None 119 | 120 | if sso_login(profile, cancel_check=cancel_check): 121 | if cancel_check and cancel_check(): 122 | return None 123 | return get_caller_identity(profile) 124 | 125 | return None 126 | -------------------------------------------------------------------------------- /awsui/cheatsheet.py: -------------------------------------------------------------------------------- 1 | """AWS CLI command cheatsheet for quick reference.""" 2 | 3 | AWS_CLI_CHEATSHEET = { 4 | "CloudFormation": [ 5 | "aws cloudformation list-stacks", 6 | "aws cloudformation describe-stacks --stack-name ", 7 | "aws cloudformation create-stack --stack-name --template-body file://template.yml", 8 | "aws cloudformation update-stack --stack-name --template-body file://template.yml", 9 | "aws cloudformation delete-stack --stack-name ", 10 | ], 11 | "CloudFront": [ 12 | "aws cloudfront list-distributions", 13 | "aws cloudfront create-invalidation --distribution-id --paths '/*'", 14 | "aws cloudfront get-distribution-config --id ", 15 | ], 16 | "CloudTrail": [ 17 | "aws cloudtrail describe-trails", 18 | "aws cloudtrail lookup-events --max-results 20", 19 | "aws cloudtrail start-logging --name ", 20 | ], 21 | "CloudWatch Logs": [ 22 | "aws logs tail /aws/lambda/function-name --follow", 23 | "aws logs describe-log-groups", 24 | "aws logs describe-log-streams --log-group-name ", 25 | ], 26 | "CloudWatch Metrics": [ 27 | "aws cloudwatch list-metrics --namespace AWS/EC2", 28 | "aws cloudwatch get-metric-statistics --namespace AWS/EC2 --metric-name CPUUtilization --dimensions Name=InstanceId,Value= --start-time --end-time --period 300 --statistics Average", 29 | "aws cloudwatch describe-alarms", 30 | ], 31 | "DynamoDB": [ 32 | "aws dynamodb list-tables", 33 | "aws dynamodb describe-table --table-name ", 34 | "aws dynamodb scan --table-name ", 35 | "aws dynamodb update-table --table-name --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5", 36 | ], 37 | "EC2": [ 38 | "aws ec2 describe-instances", 39 | "aws ec2 start-instances --instance-ids i-xxxxx", 40 | "aws ec2 stop-instances --instance-ids i-xxxxx", 41 | "aws ec2 reboot-instances --instance-ids i-xxxxx", 42 | "aws ec2 describe-security-groups", 43 | "aws ec2 describe-vpcs", 44 | "aws ec2 describe-subnets", 45 | "aws ec2 create-image --instance-id i-xxxxx --name ", 46 | ], 47 | "ECR": [ 48 | "aws ecr describe-repositories", 49 | "aws ecr get-login-password | docker login --username AWS --password-stdin .dkr.ecr..amazonaws.com", 50 | "aws ecr list-images --repository-name ", 51 | "aws ecr batch-delete-image --repository-name --image-ids imageTag=", 52 | ], 53 | "ECS": [ 54 | "aws ecs list-clusters", 55 | "aws ecs list-services --cluster ", 56 | "aws ecs describe-services --cluster --services ", 57 | "aws ecs update-service --cluster --service --force-new-deployment", 58 | ], 59 | "EKS": [ 60 | "aws eks list-clusters", 61 | "aws eks describe-cluster --name ", 62 | "aws eks update-kubeconfig --name ", 63 | ], 64 | "Glue": [ 65 | "aws glue get-databases", 66 | "aws glue get-tables --database-name ", 67 | "aws glue start-job-run --job-name ", 68 | ], 69 | "IAM": [ 70 | "aws iam list-users", 71 | "aws iam list-roles", 72 | "aws iam get-user", 73 | "aws iam list-policies", 74 | "aws iam attach-role-policy --role-name --policy-arn ", 75 | ], 76 | "Identity/STS": [ 77 | "aws sts get-caller-identity", 78 | "aws sts assume-role --role-arn --role-session-name ", 79 | "aws sts get-session-token", 80 | ], 81 | "Kinesis": [ 82 | "aws kinesis list-streams", 83 | "aws kinesis describe-stream --stream-name ", 84 | "aws kinesis get-shard-iterator --stream-name --shard-id --shard-iterator-type TRIM_HORIZON", 85 | ], 86 | "KMS": [ 87 | "aws kms list-keys", 88 | "aws kms describe-key --key-id ", 89 | "aws kms encrypt --key-id --plaintext fileb://data.txt --output text --query CiphertextBlob", 90 | ], 91 | "Lambda": [ 92 | "aws lambda list-functions", 93 | "aws lambda invoke --function-name output.txt", 94 | "aws lambda get-function --function-name ", 95 | "aws lambda update-function-code --function-name --zip-file fileb://function.zip", 96 | ], 97 | "Organizations": [ 98 | "aws organizations list-accounts", 99 | "aws organizations describe-organization", 100 | "aws organizations list-parents --child-id ", 101 | ], 102 | "RDS": [ 103 | "aws rds describe-db-instances", 104 | "aws rds describe-db-clusters", 105 | "aws rds create-db-snapshot --db-instance-identifier --db-snapshot-identifier ", 106 | ], 107 | "Redshift": [ 108 | "aws redshift describe-clusters", 109 | "aws redshift describe-snapshot-schedules", 110 | "aws redshift create-cluster-snapshot --cluster-identifier --snapshot-identifier ", 111 | ], 112 | "Route53": [ 113 | "aws route53 list-hosted-zones", 114 | "aws route53 list-resource-record-sets --hosted-zone-id ", 115 | "aws route53 change-resource-record-sets --hosted-zone-id --change-batch file://change.json", 116 | ], 117 | "S3": [ 118 | "aws s3 ls", 119 | "aws s3 ls s3://bucket-name", 120 | "aws s3 cp file.txt s3://bucket/", 121 | "aws s3 cp s3://bucket/file.txt .", 122 | "aws s3 sync ./dir s3://bucket/", 123 | "aws s3 mb s3://bucket-name", 124 | "aws s3 rb s3://bucket-name --force", 125 | "aws s3 rm s3://bucket/file.txt", 126 | ], 127 | "Secrets Manager": [ 128 | "aws secretsmanager list-secrets", 129 | "aws secretsmanager get-secret-value --secret-id ", 130 | "aws secretsmanager update-secret --secret-id --secret-string ''", 131 | ], 132 | "SNS": [ 133 | "aws sns list-topics", 134 | "aws sns publish --topic-arn --message \"Hello\"", 135 | "aws sns subscribe --topic-arn --protocol email --notification-endpoint ", 136 | ], 137 | "SQS": [ 138 | "aws sqs list-queues", 139 | "aws sqs send-message --queue-url --message-body 'Hello'", 140 | "aws sqs receive-message --queue-url ", 141 | "aws sqs purge-queue --queue-url ", 142 | ], 143 | "SSM": [ 144 | "aws ssm describe-instance-information", 145 | "aws ssm get-parameter --name --with-decryption", 146 | "aws ssm start-session --target ", 147 | "aws ssm send-command --document-name AWS-RunShellScript --targets Key=instanceids,Values= --parameters commands=['uptime']", 148 | ], 149 | "Step Functions": [ 150 | "aws stepfunctions list-state-machines", 151 | "aws stepfunctions describe-state-machine --state-machine-arn ", 152 | "aws stepfunctions start-execution --state-machine-arn --input file://input.json", 153 | ], 154 | } 155 | 156 | AWS_CLI_COMMANDS = [cmd for cmds in AWS_CLI_CHEATSHEET.values() for cmd in cmds] 157 | 158 | COMMAND_CATEGORIES = {} 159 | for category, commands in AWS_CLI_CHEATSHEET.items(): 160 | for cmd in commands: 161 | COMMAND_CATEGORIES[cmd] = category 162 | -------------------------------------------------------------------------------- /awsui/command_parser.py: -------------------------------------------------------------------------------- 1 | """AWS CLI command parser for intelligent autocomplete.""" 2 | 3 | import re 4 | from dataclasses import dataclass 5 | from enum import Enum 6 | 7 | from .service_model_loader import get_service_loader 8 | 9 | 10 | class CompletionContext(Enum): 11 | """Context for autocomplete suggestions.""" 12 | SERVICE = "service" # Completing AWS service name (e.g., "aws s3") 13 | COMMAND = "command" # Completing command (e.g., "aws s3 ls") 14 | PARAMETER = "parameter" # Completing parameter name (e.g., "--region") 15 | PARAMETER_VALUE = "value" # Completing parameter value (e.g., "--region us-") 16 | 17 | 18 | @dataclass 19 | class ParsedCommand: 20 | """Parsed AWS CLI command structure.""" 21 | raw_input: str 22 | service: str = "" 23 | command: str = "" 24 | subcommand: str = "" 25 | parameters: dict[str, str | None] = None 26 | current_context: CompletionContext = CompletionContext.SERVICE 27 | current_token: str = "" 28 | cursor_position: int = 0 29 | 30 | def __post_init__(self): 31 | if self.parameters is None: 32 | self.parameters = {} 33 | 34 | 35 | class AWSCommandParser: 36 | """Parser for AWS CLI commands to enable context-aware autocomplete. 37 | 38 | Uses a hybrid approach: 39 | - Fast path: Hardcoded common services and commands 40 | - Fallback: Dynamic loading from botocore for all AWS services 41 | """ 42 | 43 | SPECIAL_COMMANDS = ["help", "configure"] 44 | 45 | COMMON_SERVICES = [ 46 | "s3", "ec2", "lambda", "dynamodb", "rds", "iam", "sts", "cloudformation", 47 | "cloudfront", "cloudtrail", "cloudwatch", "logs", "ecr", "ecs", "eks", 48 | "sns", "sqs", "secretsmanager", "ssm", "stepfunctions", "kinesis", "kms", 49 | "glue", "organizations", "route53", "redshift" 50 | ] 51 | 52 | SERVICE_COMMANDS = { 53 | "s3": ["ls", "cp", "mv", "rm", "sync", "mb", "rb"], 54 | "ec2": ["describe-instances", "start-instances", "stop-instances", "reboot-instances", 55 | "describe-security-groups", "describe-vpcs", "describe-subnets", "create-image"], 56 | "lambda": ["list-functions", "invoke", "get-function", "update-function-code", 57 | "create-function", "delete-function"], 58 | "iam": ["list-users", "list-roles", "get-user", "list-policies", "attach-role-policy"], 59 | "sts": ["get-caller-identity", "assume-role", "get-session-token"], 60 | "cloudformation": ["list-stacks", "describe-stacks", "create-stack", "update-stack", "delete-stack"], 61 | "dynamodb": ["list-tables", "describe-table", "scan", "query", "update-table"], 62 | "ecr": ["describe-repositories", "get-login-password", "list-images", "batch-delete-image"], 63 | "ecs": ["list-clusters", "list-services", "describe-services", "update-service"], 64 | "eks": ["list-clusters", "describe-cluster", "update-kubeconfig"], 65 | "rds": ["describe-db-instances", "describe-db-clusters", "create-db-snapshot"], 66 | "logs": ["tail", "describe-log-groups", "describe-log-streams"], 67 | "cloudwatch": ["list-metrics", "get-metric-statistics", "describe-alarms"], 68 | } 69 | 70 | GLOBAL_PARAMETERS = [ 71 | "--version", 72 | "--debug", 73 | "--no-verify-ssl", 74 | "--no-paginate", 75 | "--output", 76 | "--query", 77 | "--profile", 78 | "--region", 79 | "--endpoint-url", 80 | "--no-cli-pager", 81 | "--color", 82 | "--no-sign-request", 83 | "--ca-bundle", 84 | "--cli-read-timeout", 85 | "--cli-connect-timeout", 86 | ] 87 | 88 | COMMON_PARAMETERS = [ 89 | "--region", 90 | "--output", 91 | "--profile", 92 | "--query", 93 | "--no-cli-pager", 94 | "--no-verify-ssl", 95 | "--endpoint-url", 96 | "--debug", 97 | "--no-paginate", 98 | "--max-items", 99 | "--page-size", 100 | ] 101 | 102 | PARAMETER_VALUES = { 103 | "--region": [ 104 | "us-east-1", "us-east-2", "us-west-1", "us-west-2", 105 | "eu-west-1", "eu-west-2", "eu-west-3", "eu-central-1", 106 | "ap-southeast-1", "ap-southeast-2", "ap-northeast-1", "ap-northeast-2", 107 | "sa-east-1", "ca-central-1" 108 | ], 109 | "--output": ["json", "yaml", "yaml-stream", "text", "table"], 110 | } 111 | 112 | SERVICE_PARAMETERS = { 113 | "s3": ["--recursive", "--exclude", "--include", "--delete", "--acl", "--storage-class"], 114 | "ec2": ["--instance-ids", "--security-group-ids", "--vpc-id", "--subnet-id", "--filters"], 115 | "lambda": ["--function-name", "--payload", "--zip-file", "--runtime", "--handler", "--role"], 116 | "iam": ["--role-name", "--policy-arn", "--user-name", "--group-name"], 117 | "cloudformation": ["--stack-name", "--template-body", "--template-url", "--parameters"], 118 | "dynamodb": ["--table-name", "--key", "--attribute-definitions", "--provisioned-throughput"], 119 | } 120 | 121 | def __init__(self): 122 | """Initialize parser with optional dynamic service loader.""" 123 | self.service_loader = get_service_loader() 124 | self.use_dynamic_loading = self.service_loader.is_available() 125 | 126 | def parse(self, command_line: str, cursor_pos: int | None = None) -> ParsedCommand: 127 | """ 128 | Parse AWS CLI command and determine current context for autocomplete. 129 | 130 | Args: 131 | command_line: The command line input 132 | cursor_pos: Cursor position (defaults to end of string) 133 | 134 | Returns: 135 | ParsedCommand with parsed structure and context 136 | """ 137 | if cursor_pos is None: 138 | cursor_pos = len(command_line) 139 | 140 | text_to_cursor = command_line[:cursor_pos] 141 | tokens = self._tokenize(text_to_cursor) 142 | 143 | parsed = ParsedCommand( 144 | raw_input=command_line, 145 | cursor_position=cursor_pos 146 | ) 147 | 148 | if not tokens: 149 | parsed.current_context = CompletionContext.SERVICE 150 | return parsed 151 | 152 | if tokens[0].lower() == "aws": 153 | tokens = tokens[1:] 154 | 155 | if not tokens: 156 | parsed.current_context = CompletionContext.SERVICE 157 | return parsed 158 | 159 | is_completing_new = text_to_cursor.endswith(" ") 160 | current_token = "" if is_completing_new else tokens[-1] if tokens else "" 161 | parsed.current_token = current_token 162 | 163 | if len(tokens) >= 1: 164 | potential_service = tokens[0] 165 | if self._is_valid_service(potential_service): 166 | parsed.service = potential_service 167 | elif not potential_service.startswith("-"): 168 | parsed.current_context = CompletionContext.SERVICE 169 | return parsed 170 | 171 | if len(tokens) >= 2 and parsed.service: 172 | potential_command = tokens[1] 173 | if not potential_command.startswith("-"): 174 | parsed.command = potential_command 175 | parsed.current_context = CompletionContext.COMMAND 176 | else: 177 | parsed.current_context = CompletionContext.COMMAND 178 | return parsed 179 | 180 | i = 2 if parsed.command else (1 if parsed.service else 0) 181 | expecting_value = None 182 | 183 | while i < len(tokens): 184 | token = tokens[i] 185 | 186 | if token.startswith("--"): 187 | if "=" in token: 188 | param, value = token.split("=", 1) 189 | parsed.parameters[param] = value 190 | else: 191 | parsed.parameters[token] = None 192 | expecting_value = token 193 | elif token.startswith("-") and len(token) == 2: 194 | parsed.parameters[token] = None 195 | expecting_value = token 196 | else: 197 | if expecting_value: 198 | parsed.parameters[expecting_value] = token 199 | expecting_value = None 200 | 201 | i += 1 202 | 203 | if is_completing_new or (tokens and not tokens[-1].startswith("-")): 204 | if expecting_value: 205 | parsed.current_context = CompletionContext.PARAMETER_VALUE 206 | parsed.current_token = current_token if not is_completing_new else "" 207 | elif parsed.service and parsed.command and is_completing_new: 208 | parsed.current_context = CompletionContext.PARAMETER 209 | parsed.current_token = "" 210 | elif parsed.service and not is_completing_new: 211 | parsed.current_context = CompletionContext.COMMAND 212 | parsed.current_token = current_token 213 | elif parsed.service: 214 | parsed.current_context = CompletionContext.COMMAND 215 | parsed.current_token = "" 216 | else: 217 | parsed.current_context = CompletionContext.SERVICE 218 | parsed.current_token = current_token if not is_completing_new else "" 219 | else: 220 | if current_token.startswith("--") or current_token.startswith("-"): 221 | parsed.current_context = CompletionContext.PARAMETER 222 | elif not parsed.service: 223 | parsed.current_context = CompletionContext.SERVICE 224 | elif not parsed.command: 225 | parsed.current_context = CompletionContext.COMMAND 226 | else: 227 | parsed.current_context = CompletionContext.PARAMETER 228 | 229 | return parsed 230 | 231 | def _tokenize(self, text: str) -> list[str]: 232 | """Tokenize command line, respecting quotes.""" 233 | tokens = [] 234 | current_token = "" 235 | in_quote = None 236 | 237 | for char in text: 238 | if char in ('"', "'"): 239 | if in_quote == char: 240 | in_quote = None 241 | elif in_quote is None: 242 | in_quote = char 243 | current_token += char 244 | elif char.isspace() and in_quote is None: 245 | if current_token: 246 | tokens.append(current_token) 247 | current_token = "" 248 | else: 249 | current_token += char 250 | 251 | if current_token: 252 | tokens.append(current_token) 253 | 254 | return tokens 255 | 256 | def _is_valid_service(self, service: str) -> bool: 257 | """Check if service is valid (hardcoded or from botocore).""" 258 | # Special commands (help, configure) are treated as "services" for parsing 259 | if service in self.SPECIAL_COMMANDS: 260 | return True 261 | if service in self.COMMON_SERVICES: 262 | return True 263 | if self.use_dynamic_loading: 264 | return service in self.service_loader.get_all_services() 265 | return False 266 | 267 | def _get_all_services(self) -> list[str]: 268 | """Get all available services (hybrid: hardcoded + dynamic).""" 269 | services = set(self.COMMON_SERVICES) 270 | if self.use_dynamic_loading: 271 | services.update(self.service_loader.get_all_services()) 272 | return sorted(services) 273 | 274 | def _get_service_commands(self, service: str) -> list[str]: 275 | """Get commands for a service (hybrid: dynamic preferred, hardcoded fallback).""" 276 | if self.use_dynamic_loading: 277 | commands = self.service_loader.get_service_operations(service) 278 | if commands: 279 | return commands 280 | 281 | if service in self.SERVICE_COMMANDS: 282 | return self.SERVICE_COMMANDS[service] 283 | 284 | return [] 285 | 286 | def _get_command_parameters(self, service: str, command: str) -> list[str]: 287 | """Get parameters for a command (hybrid: common + service-specific + dynamic).""" 288 | suggestions = list(self.COMMON_PARAMETERS) 289 | 290 | if service in self.SERVICE_PARAMETERS: 291 | suggestions.extend(self.SERVICE_PARAMETERS[service]) 292 | 293 | if self.use_dynamic_loading: 294 | dynamic_params = self.service_loader.get_operation_parameters(service, command) 295 | suggestions.extend(dynamic_params) 296 | 297 | return list(set(suggestions)) 298 | 299 | def get_suggestions(self, parsed: ParsedCommand) -> list[str]: 300 | """ 301 | Get autocomplete suggestions based on parsed command context. 302 | 303 | Uses hybrid approach: hardcoded (fast) + dynamic (complete). 304 | 305 | Args: 306 | parsed: Parsed command structure 307 | 308 | Returns: 309 | List of suggestion strings 310 | """ 311 | query = parsed.current_token.lower() 312 | 313 | if parsed.current_context == CompletionContext.SERVICE: 314 | all_services = self._get_all_services() 315 | services = [s for s in all_services if s.startswith(query)] 316 | special_cmds = [c for c in self.SPECIAL_COMMANDS if c.startswith(query)] 317 | 318 | if query.startswith("-"): 319 | global_params = [p for p in self.GLOBAL_PARAMETERS if p.startswith(query)] 320 | return global_params + special_cmds + services 321 | 322 | return special_cmds + services 323 | 324 | elif parsed.current_context == CompletionContext.COMMAND: 325 | if parsed.service in self.SPECIAL_COMMANDS: 326 | return [] 327 | commands = self._get_service_commands(parsed.service) 328 | return [c for c in commands if c.startswith(query)] 329 | 330 | elif parsed.current_context == CompletionContext.PARAMETER: 331 | if parsed.service in self.SPECIAL_COMMANDS: 332 | return [] 333 | 334 | if not parsed.service: 335 | return [p for p in self.GLOBAL_PARAMETERS if p.startswith(query)] 336 | 337 | suggestions = self._get_command_parameters(parsed.service, parsed.command) 338 | used_params = set(parsed.parameters.keys()) 339 | suggestions = [p for p in suggestions if p not in used_params] 340 | 341 | return [p for p in suggestions if p.startswith(query)] 342 | 343 | elif parsed.current_context == CompletionContext.PARAMETER_VALUE: 344 | last_param = None 345 | for param, value in reversed(list(parsed.parameters.items())): 346 | if value is None: 347 | last_param = param 348 | break 349 | 350 | if last_param and last_param in self.PARAMETER_VALUES: 351 | values = self.PARAMETER_VALUES[last_param] 352 | return [v for v in values if v.startswith(query)] 353 | 354 | return [] 355 | -------------------------------------------------------------------------------- /awsui/config.py: -------------------------------------------------------------------------------- 1 | """Configuration file parsing and discovery.""" 2 | 3 | import os 4 | from pathlib import Path 5 | from configparser import ConfigParser 6 | from typing import List 7 | from .models import Profile 8 | 9 | 10 | def get_config_paths() -> tuple[Path, Path]: 11 | """ 12 | Get AWS config and credentials file paths. 13 | 14 | Honors AWS_CONFIG_FILE and AWS_SHARED_CREDENTIALS_FILE environment variables. 15 | """ 16 | home = Path.home() 17 | 18 | config_path = os.environ.get("AWS_CONFIG_FILE") 19 | if config_path: 20 | config_file = Path(config_path) 21 | else: 22 | config_file = home / ".aws" / "config" 23 | 24 | credentials_path = os.environ.get("AWS_SHARED_CREDENTIALS_FILE") 25 | if credentials_path: 26 | credentials_file = Path(credentials_path) 27 | else: 28 | credentials_file = home / ".aws" / "credentials" 29 | 30 | return config_file, credentials_file 31 | 32 | 33 | def parse_profiles() -> List[Profile]: 34 | """ 35 | Parse AWS profiles from config and credentials files. 36 | 37 | Returns a list of Profile objects. 38 | """ 39 | config_file, credentials_file = get_config_paths() 40 | profiles: List[Profile] = [] 41 | 42 | # Parse config file 43 | if config_file.exists(): 44 | config = ConfigParser() 45 | config.read(config_file) 46 | 47 | for section in config.sections(): 48 | # Skip sso-session sections 49 | if section.startswith("sso-session "): 50 | continue 51 | 52 | # Profile sections start with "profile " 53 | if section.startswith("profile "): 54 | profile_name = section[8:] # Remove "profile " prefix 55 | else: 56 | # Default profile 57 | profile_name = section 58 | 59 | section_data = dict(config[section]) 60 | 61 | # Determine profile kind 62 | if "sso_session" in section_data or "sso_start_url" in section_data: 63 | kind = "sso" 64 | session = section_data.get("sso_session") 65 | account = section_data.get("sso_account_id") 66 | role = section_data.get("sso_role_name") 67 | elif "source_profile" in section_data and "role_arn" in section_data: 68 | kind = "assume" 69 | session = None 70 | # Extract account from role_arn if present 71 | role_arn = section_data.get("role_arn", "") 72 | account = role_arn.split(":")[4] if ":" in role_arn else None 73 | role = role_arn.split("/")[-1] if "/" in role_arn else None 74 | else: 75 | kind = "basic" 76 | session = None 77 | account = None 78 | role = None 79 | 80 | profile = Profile( 81 | name=profile_name, 82 | kind=kind, 83 | account=account, 84 | role=role, 85 | region=section_data.get("region"), 86 | session=session, 87 | source=str(config_file) 88 | ) 89 | profiles.append(profile) 90 | 91 | # Parse credentials file for basic profiles 92 | if credentials_file.exists(): 93 | creds = ConfigParser() 94 | creds.read(credentials_file) 95 | 96 | for section in creds.sections(): 97 | # Check if this profile already exists from config 98 | if any(p["name"] == section for p in profiles): 99 | continue 100 | 101 | # This is a basic credential profile 102 | profile = Profile( 103 | name=section, 104 | kind="basic", 105 | account=None, 106 | role=None, 107 | region=None, 108 | session=None, 109 | source=str(credentials_file) 110 | ) 111 | profiles.append(profile) 112 | 113 | return profiles -------------------------------------------------------------------------------- /awsui/i18n.py: -------------------------------------------------------------------------------- 1 | """Internationalization (i18n) - UI translations for awsui.""" 2 | 3 | LANG_ZH_TW = { 4 | "search_placeholder": "搜尋 profiles (按 / 聚焦)...", 5 | "cli_placeholder": "輸入 AWS CLI 指令(開始輸入顯示建議,空白時 ↑↓ 瀏覽歷史)", 6 | "no_profiles": "未偵測到 profiles", 7 | "no_profiles_hint": "請執行 'aws configure sso-session' 或 'aws configure sso' 建立", 8 | "no_aws_cli": "未偵測到 AWS CLI v2", 9 | "no_aws_cli_hint": "請依官方文件安裝 AWS CLI v2", 10 | "detail_name": "名稱", 11 | "detail_kind": "類型", 12 | "detail_account": "帳號", 13 | "detail_role": "角色", 14 | "detail_region": "區域", 15 | "detail_session": "SSO Session", 16 | "panel_profiles": "Profiles", 17 | "panel_profiles_help": "搜尋與切換", 18 | "panel_detail": "Profile 詳情", 19 | "detail_placeholder": "選擇 profile 以顯示資料", 20 | "panel_cli": "CLI 終端", 21 | "panel_cli_help": "輸出與歷史", 22 | "panel_cli_input": "指令輸入", 23 | "app_subtitle": "Profile 切換與 CLI 助手", 24 | "authenticating": "驗證中...", 25 | "login_required": "需要登入", 26 | "login_success": "登入成功", 27 | "login_failed": "登入失敗", 28 | "login_cancelling": "取消登入中...", 29 | "login_cancelled": "登入已取消", 30 | "login_in_progress": "登入處理中,請稍候", 31 | "auth_success": "驗證成功", 32 | "auth_failed": "驗證失敗", 33 | "auth_cancelled": "驗證已取消", 34 | "whoami": "當前身份", 35 | "whoami_updated": "身份資訊已更新", 36 | "whoami_failed": "無法取得身份資訊", 37 | "whoami_account": "帳號", 38 | "whoami_arn": "ARN", 39 | "whoami_user": "使用者 ID", 40 | "no_login_task": "目前沒有登入作業", 41 | "select_profile_first": "請先選擇 profile", 42 | "panel_ai": "AI 助手", 43 | "panel_ai_help": "Amazon Q 開發助手", 44 | "ai_placeholder": "詢問 Amazon Q (例: 如何列出所有 S3 buckets?)", 45 | "ai_not_available": "Amazon Q CLI 不可用", 46 | "ai_install_hint": "請先安裝 Amazon Q Developer CLI", 47 | "ai_spinner_wait": "正在查詢 Amazon Q", 48 | "ai_spinner_done": "查詢完成", 49 | "ai_spinner_error": "查詢失敗", 50 | "ai_querying": "查詢中...", 51 | "ai_query_failed": "查詢失敗", 52 | "ai_cancelled": "已取消查詢", 53 | "whoami_checking": "正在取得身份資訊...", 54 | "search_first_result": "已選擇第一個結果,共 {count} 個符合", 55 | "search_no_results": "沒有符合的 profiles", 56 | "left_pane_shown": "左側面板已顯示", 57 | "cli_fullscreen": "CLI 滿版模式 - 按 t 恢復", 58 | "cli_mode": "CLI 模式", 59 | "ai_mode": "AI 模式", 60 | "output_cleared": "輸出已清空", 61 | "help_displayed": "說明已顯示", 62 | "region_override_wip": "Region override - 功能開發中", 63 | "region_input_title": "覆寫區域", 64 | "region_input_placeholder": "輸入 AWS 區域(例: us-west-2)", 65 | "region_input_hint": "留空使用 profile 預設值", 66 | "region_override_set": "區域已覆寫為:{region}", 67 | "region_override_cleared": "區域覆寫已清除", 68 | "detail_region_override": "區域(已覆寫)", 69 | "login_loading": "登入 {profile}...", 70 | "profiles_loaded": "載入 {count} 個 profiles", 71 | "execute_success": "✓ 完成 ({duration}ms)", 72 | "execute_failure": "✗ 失敗 ({duration}ms)", 73 | "cli_error_exit": "✗ 錯誤 (exit code: {code}, {duration}ms)", 74 | "ai_error_exception": "✗ 發生錯誤: {error} ({duration}ms)", 75 | "cli_error_exception": "✗ 執行錯誤: {error}", 76 | "profile_none": "未選擇", 77 | "error_title": "錯誤", 78 | "cheatsheet_title": "AWS CLI Cheatsheet", 79 | "cheatsheet_dismiss": "按 Esc 或 q 關閉", 80 | "help_text": """[bold]快捷鍵說明:[/bold] 81 | 82 | / - 聚焦搜尋框 83 | c - 切換到 CLI 模式 84 | a - 切換到 AI 助手模式 85 | t - 切換左側面板顯示/隱藏 86 | h - 顯示 AWS CLI Cheatsheet 87 | Enter - 套用選定的 profile 88 | l - 強制執行 SSO login 89 | w - 顯示當前身份 (WhoAmI) 90 | Ctrl+L - 清空輸出區域 91 | Ctrl+U - 清空輸入框 92 | Esc - 離開輸入框 93 | ? - 顯示此說明 94 | q - 離開程式 95 | 96 | [bold]CLI 輸入框智慧導航:[/bold] 97 | 98 | 空白時: 99 | ↑↓ - 瀏覽歷史指令 100 | 101 | 瀏覽歷史時: 102 | ↑↓ - 繼續瀏覽(不會觸發 autocomplete) 103 | 修改內容 - 自動離開歷史模式 104 | 105 | 輸入內容後有建議時: 106 | ↑↓ - 在 autocomplete 建議中選擇 107 | Enter - 確認選擇 108 | 109 | 輸入內容但沒建議時: 110 | ↑↓ - 瀏覽歷史指令 111 | 112 | [bold]使用方式:[/bold] 113 | 114 | 1. 使用搜尋框過濾 profiles 115 | 2. 上下鍵選擇 profile 116 | 3. 按 c 進入 CLI 模式,或按 a 進入 AI 助手模式 117 | 4. CLI 模式:空白時按 ↑↓ 快速找歷史指令 118 | 5. CLI 模式:開始輸入,自動顯示建議(↑↓ 選擇) 119 | 6. AI 模式:直接輸入自然語言問題 120 | 7. Ctrl+U 快速清空輸入 121 | 8. 按 h 查看常用 AWS CLI 指令 122 | 9. 按 t 可隱藏左側面板,讓輸出區域滿版顯示""", 123 | } 124 | 125 | LANG_EN = { 126 | "search_placeholder": "Search profiles (press / to focus)...", 127 | "cli_placeholder": "AWS CLI command (type to see suggestions, use ↑↓ for history)", 128 | "no_profiles": "No profiles detected", 129 | "no_profiles_hint": "Please run 'aws configure sso-session' or 'aws configure sso'", 130 | "no_aws_cli": "AWS CLI v2 not detected", 131 | "no_aws_cli_hint": "Please install AWS CLI v2 per official documentation", 132 | "detail_name": "Name", 133 | "detail_kind": "Type", 134 | "detail_account": "Account", 135 | "detail_role": "Role", 136 | "detail_region": "Region", 137 | "detail_session": "SSO Session", 138 | "panel_profiles": "Profiles", 139 | "panel_profiles_help": "Search & switch", 140 | "panel_detail": "Profile Details", 141 | "detail_placeholder": "Select a profile to view details", 142 | "panel_cli": "Command Console", 143 | "panel_cli_help": "Output & history", 144 | "panel_cli_input": "Command Input", 145 | "app_subtitle": "Profile switcher & CLI helper", 146 | "authenticating": "Authenticating...", 147 | "login_required": "Login required", 148 | "login_success": "Login successful", 149 | "login_failed": "Login failed", 150 | "login_cancelling": "Cancelling login...", 151 | "login_cancelled": "Login cancelled", 152 | "login_in_progress": "Login already in progress", 153 | "auth_success": "Authentication successful", 154 | "auth_failed": "Authentication failed", 155 | "auth_cancelled": "Authentication cancelled", 156 | "whoami": "Current Identity", 157 | "whoami_updated": "Identity refreshed", 158 | "whoami_failed": "Unable to fetch identity", 159 | "whoami_account": "Account", 160 | "whoami_arn": "ARN", 161 | "whoami_user": "UserId", 162 | "no_login_task": "No login is currently running", 163 | "select_profile_first": "Select a profile first", 164 | "panel_ai": "AI Assistant", 165 | "panel_ai_help": "Amazon Q Developer", 166 | "ai_placeholder": "Ask Amazon Q (e.g., How to list all S3 buckets?)", 167 | "ai_not_available": "Amazon Q CLI not available", 168 | "ai_install_hint": "Please install Amazon Q Developer CLI first", 169 | "ai_spinner_wait": "Querying Amazon Q", 170 | "ai_spinner_done": "Query complete", 171 | "ai_spinner_error": "Query failed", 172 | "ai_querying": "Querying...", 173 | "ai_query_failed": "Query failed", 174 | "ai_cancelled": "Query cancelled", 175 | "whoami_checking": "Fetching identity...", 176 | "search_first_result": "Selected first result; {count} matches", 177 | "search_no_results": "No matching profiles", 178 | "left_pane_shown": "Left pane shown", 179 | "cli_fullscreen": "CLI fullscreen mode – press t to restore", 180 | "cli_mode": "Switched to CLI mode", 181 | "ai_mode": "Switched to AI mode", 182 | "output_cleared": "Output cleared", 183 | "help_displayed": "Help displayed", 184 | "region_override_wip": "Region override – coming soon", 185 | "region_input_title": "Override Region", 186 | "region_input_placeholder": "Enter AWS region (e.g., us-west-2)", 187 | "region_input_hint": "Leave empty to use profile default", 188 | "region_override_set": "Region override set to: {region}", 189 | "region_override_cleared": "Region override cleared", 190 | "detail_region_override": "Region (Override)", 191 | "login_loading": "Logging in to {profile}...", 192 | "profiles_loaded": "Loaded {count} profiles", 193 | "execute_success": "✓ Completed ({duration}ms)", 194 | "execute_failure": "✗ Failed ({duration}ms)", 195 | "cli_error_exit": "✗ Error (exit code: {code}, {duration}ms)", 196 | "ai_error_exception": "✗ Error: {error} ({duration}ms)", 197 | "cli_error_exception": "✗ Execution error: {error}", 198 | "profile_none": "No profile", 199 | "error_title": "Error", 200 | "cheatsheet_title": "AWS CLI Cheatsheet", 201 | "cheatsheet_dismiss": "Press Esc or q to close", 202 | "help_text": """[bold]Keyboard Shortcuts:[/bold] 203 | 204 | / - Focus search box 205 | c - Switch to CLI mode 206 | a - Switch to AI assistant mode 207 | t - Toggle left pane visibility 208 | h - Show AWS CLI Cheatsheet 209 | Enter - Apply selected profile 210 | l - Force SSO login 211 | w - Show current identity (WhoAmI) 212 | Ctrl+L - Clear output area 213 | Ctrl+U - Clear input field 214 | Esc - Exit input field 215 | ? - Show this help 216 | q - Quit 217 | 218 | [bold]CLI Input Smart Navigation:[/bold] 219 | 220 | When empty: 221 | ↑↓ - Browse command history 222 | 223 | While browsing history: 224 | ↑↓ - Continue browsing (won't trigger autocomplete) 225 | Type - Exit history mode 226 | 227 | When input has content with suggestions: 228 | ↑↓ - Select from autocomplete suggestions 229 | Enter - Confirm selection 230 | 231 | When input has content without suggestions: 232 | ↑↓ - Browse command history 233 | 234 | [bold]Quick Start:[/bold] 235 | 236 | 1. Use search box to filter profiles 237 | 2. Use ↑↓ keys to select profile 238 | 3. Press c for CLI mode, or press a for AI mode 239 | 4. CLI mode: Press ↑↓ when empty to browse history 240 | 5. CLI mode: Start typing to see suggestions (↑↓ to select) 241 | 6. AI mode: Enter natural language questions 242 | 7. Press Ctrl+U to quickly clear input 243 | 8. Press h to view common AWS CLI commands 244 | 9. Press t to hide left pane for fullscreen output""", 245 | } 246 | -------------------------------------------------------------------------------- /awsui/logging.py: -------------------------------------------------------------------------------- 1 | """Structured logging utility for awsui.""" 2 | 3 | import sys 4 | import json 5 | import logging 6 | from datetime import datetime 7 | 8 | 9 | class StructuredLogger: 10 | """JSON structured logger that outputs to STDERR.""" 11 | 12 | def __init__(self, level: str = "INFO"): 13 | self.level = getattr(logging, level.upper()) 14 | self.logger = logging.getLogger("awsui") 15 | self.logger.setLevel(self.level) 16 | 17 | # Remove any existing handlers 18 | self.logger.handlers.clear() 19 | 20 | # Add stderr handler with JSON formatter 21 | handler = logging.StreamHandler(sys.stderr) 22 | handler.setFormatter(JSONFormatter()) 23 | self.logger.addHandler(handler) 24 | 25 | def _log(self, level: str, action: str, **kwargs): 26 | """Internal log method.""" 27 | record = { 28 | "ts": datetime.utcnow().isoformat() + "Z", 29 | "level": level, 30 | "action": action, 31 | } 32 | record.update(kwargs) 33 | 34 | # Output to stderr as JSON 35 | json.dump(record, sys.stderr) 36 | sys.stderr.write("\n") 37 | sys.stderr.flush() 38 | 39 | def debug(self, action: str, **kwargs): 40 | """Log debug message.""" 41 | if self.level <= logging.DEBUG: 42 | self._log("DEBUG", action, **kwargs) 43 | 44 | def info(self, action: str, **kwargs): 45 | """Log info message.""" 46 | if self.level <= logging.INFO: 47 | self._log("INFO", action, **kwargs) 48 | 49 | def warning(self, action: str, **kwargs): 50 | """Log warning message.""" 51 | if self.level <= logging.WARNING: 52 | self._log("WARNING", action, **kwargs) 53 | 54 | def error(self, action: str, **kwargs): 55 | """Log error message.""" 56 | if self.level <= logging.ERROR: 57 | self._log("ERROR", action, **kwargs) 58 | 59 | 60 | class JSONFormatter(logging.Formatter): 61 | """JSON formatter for standard logging.""" 62 | 63 | def format(self, record: logging.LogRecord) -> str: 64 | log_data = { 65 | "ts": datetime.utcnow().isoformat() + "Z", 66 | "level": record.levelname, 67 | "action": record.getMessage(), 68 | } 69 | 70 | # Add extra fields if present 71 | if hasattr(record, "duration_ms"): 72 | log_data["duration_ms"] = record.duration_ms 73 | if hasattr(record, "profile"): 74 | log_data["profile"] = record.profile 75 | if hasattr(record, "result"): 76 | log_data["result"] = record.result 77 | 78 | return json.dumps(log_data) 79 | 80 | 81 | # Global logger instance 82 | _logger: StructuredLogger | None = None 83 | 84 | 85 | def get_logger(level: str = "INFO") -> StructuredLogger: 86 | """Get or create the global logger instance.""" 87 | global _logger 88 | if _logger is None: 89 | _logger = StructuredLogger(level) 90 | return _logger -------------------------------------------------------------------------------- /awsui/models.py: -------------------------------------------------------------------------------- 1 | """Data models and AWS config parsing.""" 2 | 3 | from typing import TypedDict, Literal 4 | 5 | 6 | class Profile(TypedDict): 7 | """AWS Profile representation.""" 8 | name: str 9 | kind: Literal["sso", "assume", "basic"] 10 | account: str | None 11 | role: str | None 12 | region: str | None 13 | session: str | None # sso-session name 14 | source: str # source file path -------------------------------------------------------------------------------- /awsui/parameter_metadata.py: -------------------------------------------------------------------------------- 1 | """AWS CLI parameter metadata for enhanced autocomplete.""" 2 | 3 | from dataclasses import dataclass 4 | from enum import Enum 5 | 6 | 7 | class ParameterType(Enum): 8 | """Parameter value types.""" 9 | STRING = "string" 10 | INTEGER = "integer" 11 | BOOLEAN = "boolean" 12 | LIST = "list" 13 | JSON = "json" 14 | FILE = "file" 15 | 16 | 17 | @dataclass 18 | class ParameterMetadata: 19 | """Metadata for an AWS CLI parameter.""" 20 | name: str 21 | description: str 22 | required: bool = False 23 | param_type: ParameterType = ParameterType.STRING 24 | example: str = "" 25 | 26 | 27 | # Common AWS CLI parameters with metadata 28 | COMMON_PARAMETER_METADATA = { 29 | "--region": ParameterMetadata( 30 | name="--region", 31 | description="AWS region to use for this command", 32 | required=False, 33 | param_type=ParameterType.STRING, 34 | example="us-east-1" 35 | ), 36 | "--output": ParameterMetadata( 37 | name="--output", 38 | description="Output format (json, yaml, text, table)", 39 | required=False, 40 | param_type=ParameterType.STRING, 41 | example="json" 42 | ), 43 | "--profile": ParameterMetadata( 44 | name="--profile", 45 | description="AWS credential profile to use", 46 | required=False, 47 | param_type=ParameterType.STRING, 48 | example="default" 49 | ), 50 | "--query": ParameterMetadata( 51 | name="--query", 52 | description="JMESPath query to filter output", 53 | required=False, 54 | param_type=ParameterType.STRING, 55 | example="Reservations[].Instances[].InstanceId" 56 | ), 57 | "--no-cli-pager": ParameterMetadata( 58 | name="--no-cli-pager", 59 | description="Disable AWS CLI pager", 60 | required=False, 61 | param_type=ParameterType.BOOLEAN, 62 | example="" 63 | ), 64 | "--max-items": ParameterMetadata( 65 | name="--max-items", 66 | description="Maximum number of items to return", 67 | required=False, 68 | param_type=ParameterType.INTEGER, 69 | example="100" 70 | ), 71 | } 72 | 73 | # Service-specific parameter metadata 74 | SERVICE_PARAMETER_METADATA = { 75 | "s3": { 76 | "--recursive": ParameterMetadata( 77 | name="--recursive", 78 | description="Recursively process files and subdirectories", 79 | required=False, 80 | param_type=ParameterType.BOOLEAN 81 | ), 82 | "--exclude": ParameterMetadata( 83 | name="--exclude", 84 | description="Exclude files matching pattern", 85 | required=False, 86 | param_type=ParameterType.STRING, 87 | example="*.log" 88 | ), 89 | "--include": ParameterMetadata( 90 | name="--include", 91 | description="Include only files matching pattern", 92 | required=False, 93 | param_type=ParameterType.STRING, 94 | example="*.txt" 95 | ), 96 | "--delete": ParameterMetadata( 97 | name="--delete", 98 | description="Delete files in destination not in source", 99 | required=False, 100 | param_type=ParameterType.BOOLEAN 101 | ), 102 | "--acl": ParameterMetadata( 103 | name="--acl", 104 | description="Access control list (private, public-read, etc.)", 105 | required=False, 106 | param_type=ParameterType.STRING, 107 | example="private" 108 | ), 109 | }, 110 | "ec2": { 111 | "--instance-ids": ParameterMetadata( 112 | name="--instance-ids", 113 | description="EC2 instance IDs to operate on", 114 | required=True, 115 | param_type=ParameterType.LIST, 116 | example="i-1234567890abcdef0" 117 | ), 118 | "--security-group-ids": ParameterMetadata( 119 | name="--security-group-ids", 120 | description="Security group IDs", 121 | required=False, 122 | param_type=ParameterType.LIST, 123 | example="sg-12345678" 124 | ), 125 | "--vpc-id": ParameterMetadata( 126 | name="--vpc-id", 127 | description="VPC ID", 128 | required=False, 129 | param_type=ParameterType.STRING, 130 | example="vpc-12345678" 131 | ), 132 | "--subnet-id": ParameterMetadata( 133 | name="--subnet-id", 134 | description="Subnet ID", 135 | required=False, 136 | param_type=ParameterType.STRING, 137 | example="subnet-12345678" 138 | ), 139 | "--filters": ParameterMetadata( 140 | name="--filters", 141 | description="Filter results by criteria", 142 | required=False, 143 | param_type=ParameterType.JSON, 144 | example='Name=instance-state-name,Values=running' 145 | ), 146 | }, 147 | "lambda": { 148 | "--function-name": ParameterMetadata( 149 | name="--function-name", 150 | description="Lambda function name or ARN", 151 | required=True, 152 | param_type=ParameterType.STRING, 153 | example="my-function" 154 | ), 155 | "--payload": ParameterMetadata( 156 | name="--payload", 157 | description="JSON payload to pass to function", 158 | required=False, 159 | param_type=ParameterType.JSON, 160 | example='{"key":"value"}' 161 | ), 162 | "--zip-file": ParameterMetadata( 163 | name="--zip-file", 164 | description="Deployment package (ZIP file)", 165 | required=False, 166 | param_type=ParameterType.FILE, 167 | example="fileb://function.zip" 168 | ), 169 | "--runtime": ParameterMetadata( 170 | name="--runtime", 171 | description="Runtime environment (python3.11, nodejs18.x, etc.)", 172 | required=False, 173 | param_type=ParameterType.STRING, 174 | example="python3.11" 175 | ), 176 | "--handler": ParameterMetadata( 177 | name="--handler", 178 | description="Function handler (file.method)", 179 | required=False, 180 | param_type=ParameterType.STRING, 181 | example="index.handler" 182 | ), 183 | "--role": ParameterMetadata( 184 | name="--role", 185 | description="IAM role ARN for function execution", 186 | required=True, 187 | param_type=ParameterType.STRING, 188 | example="arn:aws:iam::123456789012:role/lambda-role" 189 | ), 190 | }, 191 | "iam": { 192 | "--role-name": ParameterMetadata( 193 | name="--role-name", 194 | description="IAM role name", 195 | required=True, 196 | param_type=ParameterType.STRING, 197 | example="MyRole" 198 | ), 199 | "--policy-arn": ParameterMetadata( 200 | name="--policy-arn", 201 | description="IAM policy ARN", 202 | required=True, 203 | param_type=ParameterType.STRING, 204 | example="arn:aws:iam::aws:policy/ReadOnlyAccess" 205 | ), 206 | "--user-name": ParameterMetadata( 207 | name="--user-name", 208 | description="IAM user name", 209 | required=True, 210 | param_type=ParameterType.STRING, 211 | example="john.doe" 212 | ), 213 | "--group-name": ParameterMetadata( 214 | name="--group-name", 215 | description="IAM group name", 216 | required=True, 217 | param_type=ParameterType.STRING, 218 | example="Developers" 219 | ), 220 | }, 221 | "dynamodb": { 222 | "--table-name": ParameterMetadata( 223 | name="--table-name", 224 | description="DynamoDB table name", 225 | required=True, 226 | param_type=ParameterType.STRING, 227 | example="MyTable" 228 | ), 229 | "--key": ParameterMetadata( 230 | name="--key", 231 | description="Primary key of the item", 232 | required=False, 233 | param_type=ParameterType.JSON, 234 | example='{"id":{"S":"123"}}' 235 | ), 236 | "--attribute-definitions": ParameterMetadata( 237 | name="--attribute-definitions", 238 | description="Attribute definitions for table schema", 239 | required=False, 240 | param_type=ParameterType.JSON, 241 | example='AttributeName=id,AttributeType=S' 242 | ), 243 | }, 244 | "cloudformation": { 245 | "--stack-name": ParameterMetadata( 246 | name="--stack-name", 247 | description="CloudFormation stack name", 248 | required=True, 249 | param_type=ParameterType.STRING, 250 | example="my-stack" 251 | ), 252 | "--template-body": ParameterMetadata( 253 | name="--template-body", 254 | description="CloudFormation template file", 255 | required=False, 256 | param_type=ParameterType.FILE, 257 | example="file://template.yaml" 258 | ), 259 | "--template-url": ParameterMetadata( 260 | name="--template-url", 261 | description="S3 URL to CloudFormation template", 262 | required=False, 263 | param_type=ParameterType.STRING, 264 | example="https://s3.amazonaws.com/bucket/template.yaml" 265 | ), 266 | "--parameters": ParameterMetadata( 267 | name="--parameters", 268 | description="Stack parameters", 269 | required=False, 270 | param_type=ParameterType.JSON, 271 | example="ParameterKey=KeyName,ParameterValue=MyKey" 272 | ), 273 | }, 274 | } 275 | 276 | 277 | def get_parameter_metadata(service: str, parameter: str) -> ParameterMetadata | None: 278 | """ 279 | Get metadata for a parameter. 280 | 281 | Args: 282 | service: AWS service name 283 | parameter: Parameter name (e.g., "--region") 284 | 285 | Returns: 286 | ParameterMetadata if found, None otherwise 287 | """ 288 | # Check common parameters first 289 | if parameter in COMMON_PARAMETER_METADATA: 290 | return COMMON_PARAMETER_METADATA[parameter] 291 | 292 | # Check service-specific parameters 293 | if service in SERVICE_PARAMETER_METADATA: 294 | if parameter in SERVICE_PARAMETER_METADATA[service]: 295 | return SERVICE_PARAMETER_METADATA[service][parameter] 296 | 297 | return None 298 | 299 | 300 | def format_parameter_help(metadata: ParameterMetadata) -> str: 301 | """ 302 | Format parameter metadata as help text. 303 | 304 | Args: 305 | metadata: Parameter metadata 306 | 307 | Returns: 308 | Formatted help string 309 | """ 310 | required_marker = " *" if metadata.required else "" 311 | type_info = f"[{metadata.param_type.value}]" 312 | example_text = f" Example: {metadata.example}" if metadata.example else "" 313 | 314 | return f"{metadata.name}{required_marker} {type_info} - {metadata.description}{example_text}" 315 | -------------------------------------------------------------------------------- /awsui/q_assistant.py: -------------------------------------------------------------------------------- 1 | """Amazon Q Developer CLI integration for AI-powered assistance.""" 2 | 3 | import os 4 | import re 5 | import shutil 6 | import subprocess 7 | from typing import Callable 8 | 9 | 10 | def check_q_cli_available() -> bool: 11 | """ 12 | Check if Amazon Q Developer CLI is available. 13 | 14 | Returns True if 'q' command is found in PATH, False otherwise. 15 | """ 16 | return shutil.which("q") is not None 17 | 18 | 19 | def get_q_cli_version() -> str | None: 20 | """ 21 | Get Amazon Q Developer CLI version. 22 | 23 | Returns version string or None if unable to determine. 24 | """ 25 | try: 26 | result = subprocess.run( 27 | ["q", "--version"], 28 | capture_output=True, 29 | text=True, 30 | timeout=5 31 | ) 32 | if result.returncode == 0: 33 | return result.stdout.strip() 34 | return None 35 | except (subprocess.SubprocessError, FileNotFoundError): 36 | return None 37 | 38 | 39 | def query_q_cli( 40 | prompt: str, 41 | context: str | None = None, 42 | profile_name: str | None = None, 43 | region: str | None = None, 44 | cancel_check: Callable[[], bool] | None = None, 45 | timeout: int = 300 46 | ) -> tuple[str, bool]: 47 | """ 48 | Query Amazon Q Developer CLI with a prompt. 49 | 50 | Args: 51 | prompt: User's question or command to ask Q 52 | context: Optional context information (profile, region, etc.) 53 | profile_name: AWS profile name to use 54 | region: AWS region to use 55 | cancel_check: Optional callable that returns True if operation should be cancelled 56 | timeout: Timeout in seconds (default: 300) 57 | 58 | Returns: 59 | Tuple of (response_text, success) 60 | - response_text: Q's response or error message 61 | - success: True if query succeeded, False otherwise 62 | """ 63 | if not check_q_cli_available(): 64 | return ("Amazon Q Developer CLI not available. Please install it first.", False) 65 | 66 | full_prompt = prompt 67 | if context: 68 | full_prompt = f"{context}\n\n{prompt}" 69 | 70 | env = os.environ.copy() 71 | if profile_name: 72 | env["AWS_PROFILE"] = profile_name 73 | if region: 74 | env["AWS_DEFAULT_REGION"] = region 75 | 76 | try: 77 | # Note: --trust-all-tools allows Q to use tools without confirmation 78 | process = subprocess.Popen( 79 | ["q", "chat", "--no-interactive", "--trust-all-tools", "--wrap", "never", full_prompt], 80 | stdout=subprocess.PIPE, 81 | stderr=subprocess.PIPE, 82 | text=True, 83 | env=env 84 | ) 85 | 86 | if cancel_check and cancel_check(): 87 | process.terminate() 88 | try: 89 | process.wait(timeout=2) 90 | except subprocess.TimeoutExpired: 91 | process.kill() 92 | return ("Query cancelled.", False) 93 | 94 | try: 95 | stdout, stderr = process.communicate(timeout=timeout) 96 | except subprocess.TimeoutExpired: 97 | process.kill() 98 | stdout, stderr = process.communicate() 99 | return ("Query timed out after {timeout} seconds.", False) 100 | 101 | if process.returncode == 0: 102 | clean_output = clean_ansi_codes(stdout.strip()) 103 | return (clean_output, True) 104 | else: 105 | error_msg = stderr.strip() if stderr else "Unknown error occurred" 106 | clean_error = clean_ansi_codes(error_msg) 107 | return (f"Error: {clean_error}", False) 108 | 109 | except FileNotFoundError: 110 | return ("Amazon Q Developer CLI 'q' command not found.", False) 111 | except Exception as e: 112 | return (f"Unexpected error: {str(e)}", False) 113 | 114 | 115 | def stream_q_cli_query( 116 | prompt: str, 117 | context: str | None = None, 118 | profile_name: str | None = None, 119 | region: str | None = None, 120 | cancel_check: Callable[[], bool] | None = None, 121 | timeout: int = 300 122 | ) -> subprocess.Popen[str] | None: 123 | """ 124 | Start a streaming query to Amazon Q Developer CLI. 125 | 126 | Args: 127 | prompt: User's question or command to ask Q 128 | context: Optional context information (profile, region, etc.) 129 | profile_name: AWS profile name to use 130 | region: AWS region to use 131 | cancel_check: Optional callable that returns True if operation should be cancelled 132 | timeout: Timeout in seconds (default: 300) 133 | 134 | Returns: 135 | subprocess.Popen object with stdout available for streaming, or None on failure 136 | Caller is responsible for handling the process and reading from stdout 137 | """ 138 | if not check_q_cli_available(): 139 | return None 140 | 141 | if cancel_check and cancel_check(): 142 | return None 143 | 144 | full_prompt = prompt 145 | if context: 146 | full_prompt = f"{context}\n\n{prompt}" 147 | 148 | env = os.environ.copy() 149 | if profile_name: 150 | env["AWS_PROFILE"] = profile_name 151 | if region: 152 | env["AWS_DEFAULT_REGION"] = region 153 | 154 | try: 155 | # Note: --trust-all-tools allows Q to use tools without confirmation 156 | process = subprocess.Popen( 157 | ["q", "chat", "--no-interactive", "--trust-all-tools", "--wrap", "never", full_prompt], 158 | stdout=subprocess.PIPE, 159 | stderr=subprocess.PIPE, 160 | text=True, 161 | bufsize=1, # Line buffered 162 | env=env 163 | ) 164 | return process 165 | except (FileNotFoundError, subprocess.SubprocessError): 166 | return None 167 | 168 | 169 | def clean_ansi_codes(text: str) -> str: 170 | """ 171 | Remove ANSI escape codes from text. 172 | 173 | Args: 174 | text: Text potentially containing ANSI codes 175 | 176 | Returns: 177 | Clean text without ANSI codes 178 | """ 179 | ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') 180 | return ansi_escape.sub('', text) 181 | 182 | 183 | def format_aws_context( 184 | profile_name: str | None = None, 185 | region: str | None = None, 186 | account: str | None = None 187 | ) -> str: 188 | """ 189 | Format AWS context information for Q CLI query. 190 | 191 | Args: 192 | profile_name: Current AWS profile name 193 | region: Current AWS region 194 | account: Current AWS account ID 195 | 196 | Returns: 197 | Formatted context string 198 | """ 199 | context_parts = [] 200 | 201 | if profile_name: 202 | context_parts.append(f"Current AWS Profile: {profile_name}") 203 | if region: 204 | context_parts.append(f"Region: {region}") 205 | if account: 206 | context_parts.append(f"Account ID: {account}") 207 | 208 | if context_parts: 209 | return "Context: " + ", ".join(context_parts) 210 | return "" -------------------------------------------------------------------------------- /awsui/resource_suggester.py: -------------------------------------------------------------------------------- 1 | """Dynamic AWS resource suggester for intelligent autocomplete.""" 2 | 3 | import subprocess 4 | import json 5 | from datetime import datetime, timedelta 6 | from typing import Dict, List, Optional 7 | import threading 8 | 9 | 10 | class ResourceCache: 11 | """Thread-safe cache for AWS resource suggestions with TTL.""" 12 | 13 | def __init__(self, ttl_seconds: int = 300): 14 | """ 15 | Initialize cache. 16 | 17 | Args: 18 | ttl_seconds: Time-to-live for cached entries (default: 5 minutes) 19 | """ 20 | self.ttl_seconds = ttl_seconds 21 | self._cache: Dict[str, tuple[datetime, List[str]]] = {} 22 | self._lock = threading.Lock() 23 | 24 | def get(self, key: str) -> Optional[List[str]]: 25 | """Get cached value if still valid.""" 26 | with self._lock: 27 | if key in self._cache: 28 | timestamp, value = self._cache[key] 29 | if datetime.now() - timestamp < timedelta(seconds=self.ttl_seconds): 30 | return value 31 | else: 32 | del self._cache[key] 33 | return None 34 | 35 | def set(self, key: str, value: List[str]) -> None: 36 | """Set cached value with current timestamp.""" 37 | with self._lock: 38 | self._cache[key] = (datetime.now(), value) 39 | 40 | def clear(self) -> None: 41 | """Clear all cached entries.""" 42 | with self._lock: 43 | self._cache.clear() 44 | 45 | 46 | class ResourceSuggester: 47 | """Suggests AWS resource values by querying AWS CLI.""" 48 | 49 | def __init__(self, profile: Optional[str] = None, region: Optional[str] = None): 50 | """ 51 | Initialize resource suggester. 52 | 53 | Args: 54 | profile: AWS profile to use for queries 55 | region: AWS region to use for queries 56 | """ 57 | self.profile = profile 58 | self.region = region 59 | self.cache = ResourceCache(ttl_seconds=300) # 5 minute cache 60 | 61 | def _run_aws_command(self, command: List[str], timeout: int = 10) -> Optional[str]: 62 | """ 63 | Run AWS CLI command and return output. 64 | 65 | Args: 66 | command: AWS CLI command as list of arguments 67 | timeout: Command timeout in seconds 68 | 69 | Returns: 70 | Command output or None if failed 71 | """ 72 | try: 73 | if self.profile: 74 | command.extend(["--profile", self.profile]) 75 | if self.region: 76 | command.extend(["--region", self.region]) 77 | 78 | command.extend(["--output", "json"]) 79 | 80 | result = subprocess.run( 81 | command, 82 | capture_output=True, 83 | text=True, 84 | timeout=timeout, 85 | check=False, 86 | ) 87 | 88 | if result.returncode == 0: 89 | return result.stdout 90 | else: 91 | return None 92 | 93 | except (subprocess.TimeoutExpired, Exception): 94 | return None 95 | 96 | def get_ec2_instance_ids(self) -> List[str]: 97 | """Get list of EC2 instance IDs.""" 98 | cache_key = f"ec2:instances:{self.profile}:{self.region}" 99 | cached = self.cache.get(cache_key) 100 | if cached is not None: 101 | return cached 102 | 103 | output = self._run_aws_command( 104 | ["aws", "ec2", "describe-instances", "--query", "Reservations[].Instances[].InstanceId"] 105 | ) 106 | 107 | if output: 108 | try: 109 | instance_ids = json.loads(output) 110 | self.cache.set(cache_key, instance_ids) 111 | return instance_ids 112 | except json.JSONDecodeError: 113 | pass 114 | 115 | return [] 116 | 117 | def get_s3_buckets(self) -> List[str]: 118 | """Get list of S3 bucket names.""" 119 | cache_key = f"s3:buckets:{self.profile}" 120 | cached = self.cache.get(cache_key) 121 | if cached is not None: 122 | return cached 123 | 124 | output = self._run_aws_command( 125 | ["aws", "s3api", "list-buckets", "--query", "Buckets[].Name"] 126 | ) 127 | 128 | if output: 129 | try: 130 | buckets = json.loads(output) 131 | self.cache.set(cache_key, buckets) 132 | return buckets 133 | except json.JSONDecodeError: 134 | pass 135 | 136 | return [] 137 | 138 | def get_lambda_functions(self) -> List[str]: 139 | """Get list of Lambda function names.""" 140 | cache_key = f"lambda:functions:{self.profile}:{self.region}" 141 | cached = self.cache.get(cache_key) 142 | if cached is not None: 143 | return cached 144 | 145 | output = self._run_aws_command( 146 | ["aws", "lambda", "list-functions", "--query", "Functions[].FunctionName"] 147 | ) 148 | 149 | if output: 150 | try: 151 | functions = json.loads(output) 152 | self.cache.set(cache_key, functions) 153 | return functions 154 | except json.JSONDecodeError: 155 | pass 156 | 157 | return [] 158 | 159 | def get_dynamodb_tables(self) -> List[str]: 160 | """Get list of DynamoDB table names.""" 161 | cache_key = f"dynamodb:tables:{self.profile}:{self.region}" 162 | cached = self.cache.get(cache_key) 163 | if cached is not None: 164 | return cached 165 | 166 | output = self._run_aws_command( 167 | ["aws", "dynamodb", "list-tables", "--query", "TableNames"] 168 | ) 169 | 170 | if output: 171 | try: 172 | tables = json.loads(output) 173 | self.cache.set(cache_key, tables) 174 | return tables 175 | except json.JSONDecodeError: 176 | pass 177 | 178 | return [] 179 | 180 | def get_iam_roles(self) -> List[str]: 181 | """Get list of IAM role names.""" 182 | cache_key = f"iam:roles:{self.profile}" 183 | cached = self.cache.get(cache_key) 184 | if cached is not None: 185 | return cached 186 | 187 | output = self._run_aws_command( 188 | ["aws", "iam", "list-roles", "--query", "Roles[].RoleName"] 189 | ) 190 | 191 | if output: 192 | try: 193 | roles = json.loads(output) 194 | self.cache.set(cache_key, roles) 195 | return roles 196 | except json.JSONDecodeError: 197 | pass 198 | 199 | return [] 200 | 201 | def get_security_groups(self) -> List[str]: 202 | """Get list of security group IDs.""" 203 | cache_key = f"ec2:security-groups:{self.profile}:{self.region}" 204 | cached = self.cache.get(cache_key) 205 | if cached is not None: 206 | return cached 207 | 208 | output = self._run_aws_command( 209 | ["aws", "ec2", "describe-security-groups", "--query", "SecurityGroups[].GroupId"] 210 | ) 211 | 212 | if output: 213 | try: 214 | sgs = json.loads(output) 215 | self.cache.set(cache_key, sgs) 216 | return sgs 217 | except json.JSONDecodeError: 218 | pass 219 | 220 | return [] 221 | 222 | def get_vpcs(self) -> List[str]: 223 | """Get list of VPC IDs.""" 224 | cache_key = f"ec2:vpcs:{self.profile}:{self.region}" 225 | cached = self.cache.get(cache_key) 226 | if cached is not None: 227 | return cached 228 | 229 | output = self._run_aws_command( 230 | ["aws", "ec2", "describe-vpcs", "--query", "Vpcs[].VpcId"] 231 | ) 232 | 233 | if output: 234 | try: 235 | vpcs = json.loads(output) 236 | self.cache.set(cache_key, vpcs) 237 | return vpcs 238 | except json.JSONDecodeError: 239 | pass 240 | 241 | return [] 242 | 243 | def get_suggestions_for_parameter( 244 | self, service: str, command: str, parameter: str 245 | ) -> Optional[List[str]]: 246 | """ 247 | Get resource suggestions for a specific parameter. 248 | 249 | Args: 250 | service: AWS service (e.g., "ec2", "s3") 251 | command: Service command 252 | parameter: Parameter name (e.g., "--instance-ids") 253 | 254 | Returns: 255 | List of suggested values or None if not applicable 256 | """ 257 | param_map = { 258 | "--instance-ids": self.get_ec2_instance_ids, 259 | "--instance-id": self.get_ec2_instance_ids, 260 | "--function-name": self.get_lambda_functions, 261 | "--table-name": self.get_dynamodb_tables, 262 | "--role-name": self.get_iam_roles, 263 | "--security-group-ids": self.get_security_groups, 264 | "--vpc-id": self.get_vpcs, 265 | } 266 | 267 | if service == "s3" and command in ["cp", "ls", "sync", "rm", "rb"]: 268 | return self.get_s3_buckets() 269 | 270 | if parameter in param_map: 271 | return param_map[parameter]() 272 | 273 | return None 274 | -------------------------------------------------------------------------------- /awsui/service_model_loader.py: -------------------------------------------------------------------------------- 1 | """Dynamic AWS service model loader from botocore.""" 2 | 3 | import json 4 | import re 5 | import sys 6 | from pathlib import Path 7 | from typing import Dict, List, Optional 8 | from functools import lru_cache 9 | 10 | 11 | class ServiceModelLoader: 12 | """Load AWS service models dynamically from botocore data files.""" 13 | 14 | def __init__(self): 15 | self.botocore_data_path: Optional[Path] = None 16 | self._service_cache: Dict[str, dict] = {} 17 | self._find_botocore_path() 18 | 19 | def _find_botocore_path(self) -> None: 20 | """Find botocore data directory by checking common locations.""" 21 | possible_paths = [] 22 | 23 | # Try to import botocore and get its path 24 | try: 25 | import botocore 26 | botocore_module_path = Path(botocore.__file__).parent 27 | possible_paths.append(botocore_module_path / "data") 28 | except ImportError: 29 | pass 30 | 31 | # Check AWS CLI v2 bundled botocore (Homebrew on macOS) 32 | if sys.platform == "darwin": 33 | homebrew_paths = [ 34 | Path("/opt/homebrew/Cellar/awscli"), 35 | Path("/usr/local/Cellar/awscli"), 36 | ] 37 | for base_path in homebrew_paths: 38 | if base_path.exists(): 39 | for version_dir in base_path.glob("*/libexec/lib/python*/site-packages/awscli/botocore/data"): 40 | possible_paths.append(version_dir) 41 | 42 | # Check common Linux installation paths 43 | if sys.platform.startswith("linux"): 44 | linux_paths = [ 45 | Path("/usr/local/aws-cli/v2/current/dist/awscli/botocore/data"), 46 | Path("/usr/lib/python*/site-packages/botocore/data"), 47 | ] 48 | for pattern in linux_paths: 49 | for path in Path("/").glob(str(pattern).lstrip("/")): 50 | possible_paths.append(path) 51 | 52 | # Check user's site-packages 53 | try: 54 | import site 55 | for site_dir in site.getsitepackages(): 56 | possible_paths.append(Path(site_dir) / "botocore" / "data") 57 | possible_paths.append(Path(site_dir) / "awscli" / "botocore" / "data") 58 | except Exception: 59 | pass 60 | 61 | # Find the first valid path 62 | for path in possible_paths: 63 | if path.exists() and (path / "ec2").exists(): 64 | self.botocore_data_path = path 65 | return 66 | 67 | # Fallback: assume botocore is not available 68 | self.botocore_data_path = None 69 | 70 | def is_available(self) -> bool: 71 | """Check if botocore data is available.""" 72 | return self.botocore_data_path is not None 73 | 74 | @lru_cache(maxsize=512) 75 | def get_all_services(self) -> List[str]: 76 | """Get list of all available AWS services.""" 77 | if not self.botocore_data_path: 78 | return [] 79 | 80 | services = [] 81 | for service_dir in self.botocore_data_path.iterdir(): 82 | if service_dir.is_dir() and not service_dir.name.startswith("."): 83 | services.append(service_dir.name) 84 | 85 | return sorted(services) 86 | 87 | def _load_service_model(self, service: str) -> Optional[dict]: 88 | """Load service-2.json for a given service.""" 89 | if not self.botocore_data_path: 90 | return None 91 | 92 | if service in self._service_cache: 93 | return self._service_cache[service] 94 | 95 | service_path = self.botocore_data_path / service 96 | if not service_path.exists(): 97 | return None 98 | 99 | # Find the latest version directory 100 | version_dirs = [d for d in service_path.iterdir() if d.is_dir()] 101 | if not version_dirs: 102 | return None 103 | 104 | # Use the first (usually only) version 105 | version_dir = version_dirs[0] 106 | service_file = version_dir / "service-2.json" 107 | 108 | if not service_file.exists(): 109 | return None 110 | 111 | try: 112 | with open(service_file, "r", encoding="utf-8") as f: 113 | model = json.load(f) 114 | self._service_cache[service] = model 115 | return model 116 | except (json.JSONDecodeError, IOError): 117 | return None 118 | 119 | @staticmethod 120 | def _camel_to_kebab(name: str) -> str: 121 | """Convert CamelCase to kebab-case.""" 122 | # Insert hyphen before uppercase letters (except at start) 123 | s1 = re.sub("(.)([A-Z][a-z]+)", r"\1-\2", name) 124 | # Insert hyphen before uppercase letters followed by lowercase 125 | s2 = re.sub("([a-z0-9])([A-Z])", r"\1-\2", s1) 126 | return s2.lower() 127 | 128 | @lru_cache(maxsize=512) 129 | def get_service_operations(self, service: str) -> List[str]: 130 | """Get all operations (commands) for a service in kebab-case.""" 131 | model = self._load_service_model(service) 132 | if not model or "operations" not in model: 133 | return [] 134 | 135 | operations = model["operations"].keys() 136 | # Convert CamelCase to kebab-case for CLI 137 | return sorted([self._camel_to_kebab(op) for op in operations]) 138 | 139 | @lru_cache(maxsize=1024) 140 | def get_operation_parameters(self, service: str, operation: str) -> List[str]: 141 | """Get parameters for a specific operation.""" 142 | model = self._load_service_model(service) 143 | if not model or "operations" not in model: 144 | return [] 145 | 146 | # Convert kebab-case back to CamelCase to find in model 147 | operation_camel = "".join(word.capitalize() for word in operation.split("-")) 148 | 149 | if operation_camel not in model["operations"]: 150 | return [] 151 | 152 | operation_def = model["operations"][operation_camel] 153 | if "input" not in operation_def: 154 | return [] 155 | 156 | input_shape_name = operation_def["input"].get("shape") 157 | if not input_shape_name or "shapes" not in model: 158 | return [] 159 | 160 | input_shape = model["shapes"].get(input_shape_name, {}) 161 | members = input_shape.get("members", {}) 162 | 163 | # Convert member names to CLI parameter format (--parameter-name) 164 | parameters = [] 165 | for member_name in members.keys(): 166 | param_name = self._camel_to_kebab(member_name) 167 | parameters.append(f"--{param_name}") 168 | 169 | return sorted(parameters) 170 | 171 | def get_service_metadata(self, service: str) -> Optional[dict]: 172 | """Get service metadata (name, description, etc.).""" 173 | model = self._load_service_model(service) 174 | if not model: 175 | return None 176 | 177 | return model.get("metadata", {}) 178 | 179 | 180 | # Global singleton instance 181 | _loader_instance: Optional[ServiceModelLoader] = None 182 | 183 | 184 | def get_service_loader() -> ServiceModelLoader: 185 | """Get or create the global ServiceModelLoader instance.""" 186 | global _loader_instance 187 | if _loader_instance is None: 188 | _loader_instance = ServiceModelLoader() 189 | return _loader_instance 190 | -------------------------------------------------------------------------------- /images/demo01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junminhong/awsui/125deb208674a4350bfd5f912465cf9179a1e57f/images/demo01.png -------------------------------------------------------------------------------- /images/demo02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junminhong/awsui/125deb208674a4350bfd5f912465cf9179a1e57f/images/demo02.png -------------------------------------------------------------------------------- /images/demo03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junminhong/awsui/125deb208674a4350bfd5f912465cf9179a1e57f/images/demo03.png -------------------------------------------------------------------------------- /images/demo04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junminhong/awsui/125deb208674a4350bfd5f912465cf9179a1e57f/images/demo04.png -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junminhong/awsui/125deb208674a4350bfd5f912465cf9179a1e57f/images/logo.png -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | def main(): 2 | print("Hello from awsui!") 3 | 4 | 5 | if __name__ == "__main__": 6 | main() 7 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "awsui" 3 | version = "0.1.1" 4 | description = "AWS Profile/SSO switching TUI built with Textual and uv" 5 | readme = "README.md" 6 | requires-python = ">=3.13,<3.14" 7 | license = {text = "MIT"} 8 | authors = [ 9 | {name = "junminhong (jasper)"}, 10 | ] 11 | keywords = ["aws", "cli", "tui", "profile", "sso", "textual"] 12 | classifiers = [ 13 | "Development Status :: 4 - Beta", 14 | "Environment :: Console", 15 | "Intended Audience :: Developers", 16 | "Intended Audience :: System Administrators", 17 | "License :: OSI Approved :: MIT License", 18 | "Operating System :: OS Independent", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.13", 21 | "Topic :: System :: Systems Administration", 22 | "Topic :: Utilities", 23 | ] 24 | dependencies = [ 25 | "textual>=6.1.0", 26 | ] 27 | 28 | [project.urls] 29 | Homepage = "https://github.com/junminhong/awsui" 30 | Repository = "https://github.com/junminhong/awsui" 31 | "Bug Tracker" = "https://github.com/junminhong/awsui/issues" 32 | 33 | [project.scripts] 34 | awsui = "awsui.app:main" 35 | 36 | [build-system] 37 | requires = ["hatchling"] 38 | build-backend = "hatchling.build" 39 | 40 | [tool.pytest.ini_options] 41 | testpaths = ["tests"] 42 | python_files = ["test_*.py"] 43 | python_classes = ["Test*"] 44 | python_functions = ["test_*"] 45 | addopts = [ 46 | "-v", 47 | "--strict-markers", 48 | "--tb=short", 49 | ] 50 | 51 | [dependency-groups] 52 | dev = [ 53 | "pytest>=8.4.2", 54 | "pytest-cov>=7.0.0", 55 | "ruff>=0.13.2", 56 | ] 57 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Test suite for awsui.""" -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Pytest configuration and fixtures.""" 2 | 3 | import pytest 4 | import os 5 | 6 | 7 | @pytest.fixture 8 | def temp_aws_config(tmp_path): 9 | """Create a temporary AWS config file.""" 10 | config_dir = tmp_path / ".aws" 11 | config_dir.mkdir() 12 | config_file = config_dir / "config" 13 | return config_file 14 | 15 | 16 | @pytest.fixture 17 | def temp_aws_credentials(tmp_path): 18 | """Create a temporary AWS credentials file.""" 19 | config_dir = tmp_path / ".aws" 20 | config_dir.mkdir(exist_ok=True) 21 | credentials_file = config_dir / "credentials" 22 | return credentials_file 23 | 24 | 25 | @pytest.fixture 26 | def sample_sso_config(): 27 | """Return sample SSO configuration content.""" 28 | return """[sso-session corp] 29 | sso_start_url = https://example.awsapps.com/start 30 | sso_region = ap-northeast-1 31 | 32 | [profile test-sso] 33 | sso_session = corp 34 | sso_account_id = 111111111111 35 | sso_role_name = AdministratorAccess 36 | region = ap-northeast-1 37 | output = json 38 | """ 39 | 40 | 41 | @pytest.fixture 42 | def sample_assume_config(): 43 | """Return sample assume role configuration content.""" 44 | return """[profile base] 45 | region = us-east-1 46 | 47 | [profile test-assume] 48 | source_profile = base 49 | role_arn = arn:aws:iam::222222222222:role/MyRole 50 | region = us-west-2 51 | """ 52 | 53 | 54 | @pytest.fixture 55 | def mock_env(monkeypatch): 56 | """Provide a clean environment for testing.""" 57 | # Clear AWS-related environment variables 58 | for key in list(os.environ.keys()): 59 | if key.startswith("AWS_"): 60 | monkeypatch.delenv(key, raising=False) 61 | 62 | return monkeypatch -------------------------------------------------------------------------------- /tests/test_autocomplete.py: -------------------------------------------------------------------------------- 1 | """Tests for CommandAutocomplete widget.""" 2 | 3 | import pytest 4 | from awsui.autocomplete import CommandAutocomplete 5 | from awsui.command_parser import CompletionContext 6 | 7 | 8 | @pytest.fixture 9 | def autocomplete(): 10 | """Create autocomplete instance for testing.""" 11 | commands = [ 12 | "aws s3 ls", 13 | "aws s3 cp", 14 | "aws ec2 describe-instances", 15 | "aws lambda list-functions", 16 | ] 17 | categories = { 18 | "aws s3 ls": "s3/storage", 19 | "aws s3 cp": "s3/storage", 20 | "aws ec2 describe-instances": "ec2/compute", 21 | "aws lambda list-functions": "lambda/compute", 22 | } 23 | ac = CommandAutocomplete(commands, categories) 24 | return ac 25 | 26 | 27 | def test_filter_commands_with_trailing_space(autocomplete): 28 | """Test filter_commands works with trailing space (strip bug fix).""" 29 | autocomplete.filter_commands("aws ", 4) 30 | assert autocomplete.display is True 31 | assert len(autocomplete.filtered_commands) > 0 32 | 33 | 34 | def test_filter_commands_without_strip(autocomplete): 35 | """Test filter_commands doesn't strip query before checking startswith.""" 36 | autocomplete.filter_commands("aws ", 4) 37 | # Should use intelligent filter, not fuzzy 38 | assert autocomplete.display is True 39 | 40 | 41 | def test_smart_insert_full_command_replacement(autocomplete): 42 | """Test smart_insert replaces entire input for full commands.""" 43 | current_value = "aws s3" 44 | cursor_pos = 6 45 | selection = "aws s3 ls" 46 | 47 | new_value, new_cursor = autocomplete.smart_insert_selection( 48 | current_value, cursor_pos, selection 49 | ) 50 | 51 | assert new_value == "aws s3 ls" 52 | assert new_cursor == 9 53 | assert new_value.count("aws") == 1 54 | 55 | 56 | def test_smart_insert_token_insertion(autocomplete): 57 | """Test smart_insert correctly inserts token at cursor position.""" 58 | current_value = "aws s3 " 59 | cursor_pos = 7 60 | selection = "ls" 61 | 62 | new_value, new_cursor = autocomplete.smart_insert_selection( 63 | current_value, cursor_pos, selection 64 | ) 65 | 66 | assert new_value == "aws s3 ls " 67 | assert new_cursor == 10 68 | 69 | 70 | def test_smart_insert_replaces_partial_token(autocomplete): 71 | """Test smart_insert replaces partial token correctly.""" 72 | current_value = "aws s3 l" 73 | cursor_pos = 8 74 | selection = "ls" 75 | 76 | new_value, new_cursor = autocomplete.smart_insert_selection( 77 | current_value, cursor_pos, selection 78 | ) 79 | 80 | assert new_value == "aws s3 ls " 81 | assert new_value.endswith("ls ") 82 | 83 | 84 | def test_smart_insert_cursor_in_middle_of_token(autocomplete): 85 | """Test smart_insert with cursor in middle of token.""" 86 | current_value = "aws ec2 describe --instance-ids i-123" 87 | cursor_pos = 15 # In middle of "describe" 88 | selection = "describe-instances" 89 | 90 | new_value, new_cursor = autocomplete.smart_insert_selection( 91 | current_value, cursor_pos, selection 92 | ) 93 | 94 | # Should replace entire "describe" token 95 | assert "describe-instances" in new_value 96 | assert "describe" not in new_value or "describe-instances" in new_value 97 | # Parameters should be preserved 98 | assert "--instance-ids i-123" in new_value 99 | 100 | 101 | def test_smart_insert_preserves_text_after_token(autocomplete): 102 | """Test smart_insert preserves text after current token.""" 103 | current_value = "aws s3 ls --region us-east-1" 104 | cursor_pos = 8 # After "ls" 105 | selection = "cp" 106 | 107 | new_value, new_cursor = autocomplete.smart_insert_selection( 108 | current_value, cursor_pos, selection 109 | ) 110 | 111 | assert "--region us-east-1" in new_value 112 | assert "cp" in new_value 113 | 114 | 115 | def test_fuzzy_match_exact_substring(autocomplete): 116 | """Test fuzzy matching with exact substring.""" 117 | matched, score = autocomplete.fuzzy_match("aws s3 ls", "s3") 118 | assert matched is True 119 | assert score > 0 120 | 121 | 122 | def test_fuzzy_match_scattered_letters(autocomplete): 123 | """Test fuzzy matching with scattered letters.""" 124 | matched, score = autocomplete.fuzzy_match("describe-instances", "dscins") 125 | assert matched is True 126 | 127 | 128 | def test_intelligent_filter_calls_parser(autocomplete): 129 | """Test intelligent filter uses parser for suggestions.""" 130 | autocomplete.filter_commands("aws s3 ", 7) 131 | assert autocomplete.display is True 132 | # Should show s3 commands 133 | assert len(autocomplete.filtered_commands) > 0 134 | 135 | 136 | def test_filter_commands_min_length(autocomplete): 137 | """Test filter_commands requires minimum length.""" 138 | autocomplete.filter_commands("a", 1) 139 | assert autocomplete.display is False 140 | assert autocomplete.filtered_commands == [] 141 | 142 | 143 | def test_filter_commands_empty_input(autocomplete): 144 | """Test filter_commands with empty input.""" 145 | autocomplete.filter_commands("", 0) 146 | assert autocomplete.display is False 147 | assert autocomplete.filtered_commands == [] 148 | 149 | 150 | def test_get_selected_command(autocomplete): 151 | """Test getting currently selected command.""" 152 | autocomplete.filter_commands("aws s3 ", 7) 153 | autocomplete.highlighted = 0 154 | selected = autocomplete.get_selected_command() 155 | assert selected is not None 156 | assert selected in autocomplete.filtered_commands 157 | 158 | 159 | def test_move_cursor_down(autocomplete): 160 | """Test moving selection down.""" 161 | autocomplete.filter_commands("aws s3 ", 7) 162 | autocomplete.highlighted = 0 163 | initial = autocomplete.highlighted 164 | autocomplete.move_cursor_down() 165 | assert autocomplete.highlighted == initial + 1 166 | 167 | 168 | def test_move_cursor_up(autocomplete): 169 | """Test moving selection up.""" 170 | autocomplete.filter_commands("aws s3 ", 7) 171 | autocomplete.highlighted = 2 172 | autocomplete.move_cursor_up() 173 | assert autocomplete.highlighted == 1 174 | 175 | 176 | def test_move_cursor_up_boundary(autocomplete): 177 | """Test moving selection up doesn't go below 0.""" 178 | autocomplete.filter_commands("aws s3 ", 7) 179 | autocomplete.highlighted = 0 180 | autocomplete.move_cursor_up() 181 | assert autocomplete.highlighted == 0 182 | 183 | 184 | def test_move_cursor_down_boundary(autocomplete): 185 | """Test moving selection down doesn't exceed list.""" 186 | autocomplete.filter_commands("aws s3 ", 7) 187 | max_idx = len(autocomplete.filtered_commands) - 1 188 | autocomplete.highlighted = max_idx 189 | autocomplete.move_cursor_down() 190 | assert autocomplete.highlighted == max_idx 191 | -------------------------------------------------------------------------------- /tests/test_command_parser.py: -------------------------------------------------------------------------------- 1 | """Tests for AWS CLI command parser.""" 2 | 3 | import pytest 4 | from awsui.command_parser import AWSCommandParser, CompletionContext 5 | 6 | 7 | class TestAWSCommandParser: 8 | """Test AWS CLI command parsing functionality.""" 9 | 10 | @pytest.fixture 11 | def parser(self): 12 | parser = AWSCommandParser() 13 | parser.use_dynamic_loading = False 14 | return parser 15 | 16 | def test_parse_service_completion(self, parser): 17 | """Test service name completion.""" 18 | parsed = parser.parse("aws s") 19 | assert parsed.current_context == CompletionContext.SERVICE 20 | assert parsed.current_token == "s" 21 | assert parsed.service == "" 22 | 23 | def test_parse_service_selected(self, parser): 24 | """Test after service is selected.""" 25 | parsed = parser.parse("aws s3 ") 26 | assert parsed.current_context == CompletionContext.COMMAND 27 | assert parsed.service == "s3" 28 | assert parsed.command == "" 29 | 30 | def test_parse_command_completion(self, parser): 31 | """Test command completion.""" 32 | parsed = parser.parse("aws s3 l") 33 | assert parsed.current_context == CompletionContext.COMMAND 34 | assert parsed.service == "s3" 35 | assert parsed.current_token == "l" 36 | 37 | def test_parse_parameter_suggestion(self, parser): 38 | """Test parameter suggestion after command.""" 39 | parsed = parser.parse("aws s3 ls ") 40 | assert parsed.current_context == CompletionContext.PARAMETER 41 | assert parsed.service == "s3" 42 | assert parsed.command == "ls" 43 | 44 | def test_parse_parameter_completion(self, parser): 45 | """Test parameter name completion.""" 46 | parsed = parser.parse("aws ec2 describe-instances --reg") 47 | assert parsed.current_context == CompletionContext.PARAMETER 48 | assert parsed.current_token == "--reg" 49 | assert parsed.command == "describe-instances" 50 | 51 | def test_parse_parameter_value_completion(self, parser): 52 | """Parameter value context is active while awaiting a value.""" 53 | parsed = parser.parse("aws ec2 describe-instances --region ") 54 | assert parsed.current_context == CompletionContext.PARAMETER_VALUE 55 | assert parsed.current_token == "" 56 | assert parsed.parameters["--region"] is None 57 | 58 | def test_get_service_suggestions(self, parser): 59 | """Test service name suggestions.""" 60 | parsed = parser.parse("aws s") 61 | suggestions = parser.get_suggestions(parsed) 62 | assert "s3" in suggestions 63 | assert "sns" in suggestions 64 | assert "sqs" in suggestions 65 | 66 | def test_get_command_suggestions(self, parser): 67 | """Test command suggestions for service.""" 68 | parsed = parser.parse("aws s3 l") 69 | suggestions = parser.get_suggestions(parsed) 70 | assert "ls" in suggestions 71 | 72 | def test_get_parameter_suggestions(self, parser): 73 | """Test parameter suggestions.""" 74 | parsed = parser.parse("aws s3 ls --") 75 | suggestions = parser.get_suggestions(parsed) 76 | assert "--region" in suggestions 77 | assert "--output" in suggestions 78 | assert "--recursive" in suggestions 79 | 80 | def test_get_region_value_suggestions(self, parser): 81 | """Test region value suggestions.""" 82 | parsed = parser.parse("aws ec2 describe-instances --region ") 83 | suggestions = parser.get_suggestions(parsed) 84 | assert "us-east-1" in suggestions 85 | assert "us-west-2" in suggestions 86 | 87 | def test_multiple_parameters(self, parser): 88 | """Test parsing multiple parameters.""" 89 | parsed = parser.parse("aws ec2 describe-instances --region us-east-1 --output json ") 90 | assert parsed.current_context == CompletionContext.PARAMETER 91 | assert parsed.parameters["--region"] == "us-east-1" 92 | assert parsed.parameters["--output"] == "json" 93 | 94 | def test_tokenization_with_quotes(self, parser): 95 | """Test tokenization handles quotes.""" 96 | parsed = parser.parse('aws s3 cp "file with spaces.txt" s3://bucket/') 97 | assert parsed.service == "s3" 98 | assert parsed.command == "cp" 99 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | """Tests for config parsing.""" 2 | 3 | import os 4 | from pathlib import Path 5 | from tempfile import NamedTemporaryFile 6 | from awsui.config import parse_profiles 7 | 8 | 9 | def test_parse_sso_profile(): 10 | """Test parsing SSO profile from config.""" 11 | config_content = """[sso-session corp] 12 | sso_start_url = https://example.awsapps.com/start 13 | sso_region = ap-northeast-1 14 | 15 | [profile test-sso] 16 | sso_session = corp 17 | sso_account_id = 111111111111 18 | sso_role_name = AdministratorAccess 19 | region = ap-northeast-1 20 | output = json 21 | """ 22 | 23 | with NamedTemporaryFile(mode='w', suffix='.config', delete=False) as f: 24 | f.write(config_content) 25 | config_path = f.name 26 | 27 | try: 28 | # Set environment variable to use test config 29 | original_config = os.environ.get("AWS_CONFIG_FILE") 30 | os.environ["AWS_CONFIG_FILE"] = config_path 31 | 32 | profiles = parse_profiles() 33 | 34 | assert len(profiles) >= 1 35 | sso_profile = next((p for p in profiles if p["name"] == "test-sso"), None) 36 | assert sso_profile is not None 37 | assert sso_profile["kind"] == "sso" 38 | assert sso_profile["account"] == "111111111111" 39 | assert sso_profile["role"] == "AdministratorAccess" 40 | assert sso_profile["region"] == "ap-northeast-1" 41 | assert sso_profile["session"] == "corp" 42 | 43 | finally: 44 | # Restore environment 45 | if original_config: 46 | os.environ["AWS_CONFIG_FILE"] = original_config 47 | elif "AWS_CONFIG_FILE" in os.environ: 48 | del os.environ["AWS_CONFIG_FILE"] 49 | 50 | # Clean up temp file 51 | Path(config_path).unlink(missing_ok=True) 52 | 53 | 54 | def test_parse_assume_role_profile(): 55 | """Test parsing assume role profile.""" 56 | config_content = """[profile base] 57 | region = us-east-1 58 | 59 | [profile test-assume] 60 | source_profile = base 61 | role_arn = arn:aws:iam::222222222222:role/MyRole 62 | region = us-west-2 63 | """ 64 | 65 | with NamedTemporaryFile(mode='w', suffix='.config', delete=False) as f: 66 | f.write(config_content) 67 | config_path = f.name 68 | 69 | try: 70 | original_config = os.environ.get("AWS_CONFIG_FILE") 71 | os.environ["AWS_CONFIG_FILE"] = config_path 72 | 73 | profiles = parse_profiles() 74 | 75 | assume_profile = next((p for p in profiles if p["name"] == "test-assume"), None) 76 | assert assume_profile is not None 77 | assert assume_profile["kind"] == "assume" 78 | assert assume_profile["account"] == "222222222222" 79 | assert assume_profile["role"] == "MyRole" 80 | assert assume_profile["region"] == "us-west-2" 81 | 82 | finally: 83 | if original_config: 84 | os.environ["AWS_CONFIG_FILE"] = original_config 85 | elif "AWS_CONFIG_FILE" in os.environ: 86 | del os.environ["AWS_CONFIG_FILE"] 87 | 88 | Path(config_path).unlink(missing_ok=True) 89 | 90 | 91 | def test_parse_empty_config(): 92 | """Test parsing empty config file.""" 93 | with NamedTemporaryFile(mode='w', suffix='.config', delete=False) as f: 94 | f.write("") 95 | config_path = f.name 96 | 97 | try: 98 | original_config = os.environ.get("AWS_CONFIG_FILE") 99 | os.environ["AWS_CONFIG_FILE"] = config_path 100 | 101 | profiles = parse_profiles() 102 | # Should return empty list or only profiles from credentials file 103 | assert isinstance(profiles, list) 104 | 105 | finally: 106 | if original_config: 107 | os.environ["AWS_CONFIG_FILE"] = original_config 108 | elif "AWS_CONFIG_FILE" in os.environ: 109 | del os.environ["AWS_CONFIG_FILE"] 110 | 111 | Path(config_path).unlink(missing_ok=True) -------------------------------------------------------------------------------- /tests/test_global_parameters.py: -------------------------------------------------------------------------------- 1 | """Tests for global parameters support.""" 2 | 3 | import pytest 4 | from awsui.command_parser import AWSCommandParser, CompletionContext 5 | 6 | 7 | @pytest.fixture 8 | def parser(): 9 | parser = AWSCommandParser() 10 | parser.use_dynamic_loading = False 11 | return parser 12 | 13 | 14 | def test_global_parameters_at_root(parser): 15 | """Test global parameters shown when typing 'aws --'.""" 16 | parsed = parser.parse("aws --") 17 | assert parsed.current_context == CompletionContext.PARAMETER 18 | suggestions = parser.get_suggestions(parsed) 19 | assert "--version" in suggestions 20 | assert "--debug" in suggestions 21 | assert "--region" in suggestions 22 | assert "--profile" in suggestions 23 | 24 | 25 | def test_global_parameter_filter(parser): 26 | """Test filtering global parameters with prefix.""" 27 | parsed = parser.parse("aws --v") 28 | suggestions = parser.get_suggestions(parsed) 29 | assert "--version" in suggestions 30 | assert "--debug" not in suggestions 31 | 32 | 33 | def test_global_parameter_with_service(parser): 34 | """Test global parameters included with service parameters.""" 35 | parsed = parser.parse("aws s3 ls --") 36 | suggestions = parser.get_suggestions(parsed) 37 | assert "--region" in suggestions 38 | assert "--output" in suggestions 39 | assert "--recursive" in suggestions 40 | 41 | 42 | def test_global_parameter_before_service(parser): 43 | """Test global parameters can appear before service.""" 44 | parsed = parser.parse("aws --region ") 45 | assert parsed.current_context == CompletionContext.PARAMETER_VALUE 46 | suggestions = parser.get_suggestions(parsed) 47 | assert "us-east-1" in suggestions 48 | assert "us-west-2" in suggestions 49 | 50 | 51 | def test_no_parameters_without_dash(parser): 52 | """Test parameters not shown when not typing dash.""" 53 | parsed = parser.parse("aws s") 54 | suggestions = parser.get_suggestions(parsed) 55 | assert "s3" in suggestions 56 | assert "--version" not in suggestions 57 | assert "--region" not in suggestions 58 | 59 | 60 | def test_global_parameters_list_complete(parser): 61 | """Test all expected global parameters are defined.""" 62 | expected_params = [ 63 | "--version", 64 | "--debug", 65 | "--no-verify-ssl", 66 | "--no-paginate", 67 | "--output", 68 | "--query", 69 | "--profile", 70 | "--region", 71 | "--endpoint-url", 72 | "--no-cli-pager", 73 | ] 74 | for param in expected_params: 75 | assert param in parser.GLOBAL_PARAMETERS 76 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | """Tests for data models.""" 2 | 3 | from awsui.models import Profile 4 | 5 | 6 | def test_profile_sso(): 7 | """Test SSO profile creation.""" 8 | profile = Profile( 9 | name="test-sso", 10 | kind="sso", 11 | account="111111111111", 12 | role="AdministratorAccess", 13 | region="ap-northeast-1", 14 | session="corp", 15 | source="/path/to/config" 16 | ) 17 | 18 | assert profile["name"] == "test-sso" 19 | assert profile["kind"] == "sso" 20 | assert profile["account"] == "111111111111" 21 | assert profile["role"] == "AdministratorAccess" 22 | assert profile["region"] == "ap-northeast-1" 23 | assert profile["session"] == "corp" 24 | 25 | 26 | def test_profile_assume_role(): 27 | """Test assume role profile creation.""" 28 | profile = Profile( 29 | name="test-assume", 30 | kind="assume", 31 | account="222222222222", 32 | role="MyRole", 33 | region="us-west-2", 34 | session=None, 35 | source="/path/to/config" 36 | ) 37 | 38 | assert profile["name"] == "test-assume" 39 | assert profile["kind"] == "assume" 40 | assert profile["account"] == "222222222222" 41 | assert profile["role"] == "MyRole" 42 | assert profile["session"] is None 43 | 44 | 45 | def test_profile_basic(): 46 | """Test basic profile creation.""" 47 | profile = Profile( 48 | name="default", 49 | kind="basic", 50 | account=None, 51 | role=None, 52 | region=None, 53 | session=None, 54 | source="/path/to/credentials" 55 | ) 56 | 57 | assert profile["name"] == "default" 58 | assert profile["kind"] == "basic" 59 | assert profile["account"] is None 60 | assert profile["role"] is None -------------------------------------------------------------------------------- /tests/test_resource_suggester.py: -------------------------------------------------------------------------------- 1 | """Tests for awsui.resource_suggester.""" 2 | 3 | from datetime import datetime, timedelta 4 | 5 | from awsui.resource_suggester import ResourceCache, ResourceSuggester 6 | 7 | 8 | def test_resource_cache_respects_ttl(monkeypatch): 9 | cache = ResourceCache(ttl_seconds=10) 10 | start_time = datetime(2024, 1, 1, 0, 0, 0) 11 | 12 | class FrozenDatetime(datetime): 13 | @classmethod 14 | def now(cls): 15 | return start_time 16 | 17 | monkeypatch.setattr("awsui.resource_suggester.datetime", FrozenDatetime) 18 | cache.set("key", ["value"]) 19 | assert cache.get("key") == ["value"] 20 | 21 | class SlightlyLaterDatetime(datetime): 22 | @classmethod 23 | def now(cls): 24 | return start_time + timedelta(seconds=5) 25 | 26 | monkeypatch.setattr("awsui.resource_suggester.datetime", SlightlyLaterDatetime) 27 | assert cache.get("key") == ["value"] 28 | 29 | class ExpiredDatetime(datetime): 30 | @classmethod 31 | def now(cls): 32 | return start_time + timedelta(seconds=11) 33 | 34 | monkeypatch.setattr("awsui.resource_suggester.datetime", ExpiredDatetime) 35 | assert cache.get("key") is None 36 | 37 | 38 | def test_run_aws_command_appends_context(monkeypatch): 39 | captured = {} 40 | 41 | def fake_run(args, capture_output, text, timeout, check): 42 | captured["args"] = list(args) 43 | 44 | class Result: 45 | returncode = 0 46 | stdout = "[]" 47 | 48 | return Result() 49 | 50 | monkeypatch.setattr("awsui.resource_suggester.subprocess.run", fake_run) 51 | suggester = ResourceSuggester(profile="dev", region="us-west-2") 52 | command = ["aws", "ec2", "describe-instances"] 53 | 54 | output = suggester._run_aws_command(command) 55 | 56 | assert output == "[]" 57 | assert command == [ 58 | "aws", 59 | "ec2", 60 | "describe-instances", 61 | "--profile", 62 | "dev", 63 | "--region", 64 | "us-west-2", 65 | "--output", 66 | "json", 67 | ] 68 | assert captured["args"] == command 69 | 70 | 71 | def test_get_suggestions_for_parameter_uses_mapping(monkeypatch): 72 | suggester = ResourceSuggester() 73 | calls = {} 74 | 75 | def fake_instances(): 76 | calls["instances"] = True 77 | return ["i-123"] 78 | 79 | monkeypatch.setattr(suggester, "get_ec2_instance_ids", fake_instances) 80 | suggestions = suggester.get_suggestions_for_parameter( 81 | "ec2", "describe-instances", "--instance-ids" 82 | ) 83 | 84 | assert calls == {"instances": True} 85 | assert suggestions == ["i-123"] 86 | 87 | 88 | def test_get_suggestions_for_parameter_s3(monkeypatch): 89 | suggester = ResourceSuggester() 90 | monkeypatch.setattr(suggester, "get_s3_buckets", lambda: ["bucket-a"]) 91 | 92 | assert suggester.get_suggestions_for_parameter("s3", "ls", "--any") == ["bucket-a"] 93 | -------------------------------------------------------------------------------- /tests/test_service_model_loader.py: -------------------------------------------------------------------------------- 1 | """Tests for awsui.service_model_loader.""" 2 | 3 | import json 4 | 5 | from awsui.service_model_loader import ServiceModelLoader 6 | 7 | 8 | def test_camel_to_kebab_conversion(): 9 | assert ServiceModelLoader._camel_to_kebab("DescribeInstances") == "describe-instances" 10 | assert ServiceModelLoader._camel_to_kebab("DBCluster") == "db-cluster" 11 | 12 | 13 | def test_get_service_operations_reads_model(tmp_path): 14 | loader = ServiceModelLoader() 15 | service_dir = tmp_path / "ec2" / "2024-01-01" 16 | service_dir.mkdir(parents=True) 17 | service_file = service_dir / "service-2.json" 18 | service_file.write_text( 19 | json.dumps({ 20 | "operations": { 21 | "DescribeInstances": {}, 22 | "StartInstances": {}, 23 | } 24 | }), 25 | encoding="utf-8", 26 | ) 27 | 28 | loader.botocore_data_path = tmp_path 29 | loader._service_cache.clear() 30 | 31 | operations = loader.get_service_operations("ec2") 32 | assert operations == ["describe-instances", "start-instances"] 33 | 34 | # Removing the file should not break cached result on subsequent calls 35 | service_file.unlink() 36 | assert loader.get_service_operations("ec2") == operations 37 | 38 | 39 | def test_get_service_operations_uses_cache(monkeypatch): 40 | loader = ServiceModelLoader() 41 | calls = {"count": 0} 42 | 43 | def fake_load(service): 44 | calls["count"] += 1 45 | return {"operations": {"ListThings": {}}} 46 | 47 | monkeypatch.setattr(loader, "_load_service_model", fake_load) 48 | 49 | assert loader.get_service_operations("iot") == ["list-things"] 50 | assert loader.get_service_operations("iot") == ["list-things"] 51 | assert calls["count"] == 1 52 | 53 | 54 | def test_get_operation_parameters_returns_cli_names(monkeypatch): 55 | loader = ServiceModelLoader() 56 | 57 | def fake_load(service): 58 | return { 59 | "operations": { 60 | "DescribeInstances": { 61 | "input": {"shape": "DescribeInstancesRequest"} 62 | } 63 | }, 64 | "shapes": { 65 | "DescribeInstancesRequest": { 66 | "members": { 67 | "InstanceIds": {}, 68 | "MaxResults": {}, 69 | } 70 | } 71 | }, 72 | } 73 | 74 | monkeypatch.setattr(loader, "_load_service_model", fake_load) 75 | 76 | params = loader.get_operation_parameters("ec2", "describe-instances") 77 | assert set(params) == {"--instance-ids", "--max-results"} 78 | 79 | 80 | def test_get_operation_parameters_missing_input(monkeypatch): 81 | loader = ServiceModelLoader() 82 | 83 | def fake_load(service): 84 | return {"operations": {"Describe": {}}} 85 | 86 | monkeypatch.setattr(loader, "_load_service_model", fake_load) 87 | assert loader.get_operation_parameters("ec2", "describe") == [] 88 | -------------------------------------------------------------------------------- /tests/test_special_commands.py: -------------------------------------------------------------------------------- 1 | """Tests for special AWS CLI commands like help and configure.""" 2 | 3 | import pytest 4 | 5 | from awsui.command_parser import AWSCommandParser, CompletionContext 6 | 7 | 8 | @pytest.fixture() 9 | def parser(): 10 | parser = AWSCommandParser() 11 | parser.use_dynamic_loading = False 12 | return parser 13 | 14 | 15 | def test_help_shortcut_suggestion(parser): 16 | parsed = parser.parse("aws h") 17 | assert parsed.current_context == CompletionContext.SERVICE 18 | assert parsed.current_token == "h" 19 | assert parser.get_suggestions(parsed) == ["help"] 20 | 21 | 22 | def test_help_full_word(parser): 23 | parsed = parser.parse("aws help") 24 | assert parsed.service == "help" 25 | assert parsed.current_context == CompletionContext.COMMAND 26 | assert parsed.current_token == "help" 27 | 28 | 29 | def test_configure_shortcut_precedes_services(parser): 30 | parsed = parser.parse("aws c") 31 | suggestions = parser.get_suggestions(parsed) 32 | assert suggestions[0] == "configure" 33 | assert "cloudformation" in suggestions 34 | 35 | 36 | def test_special_commands_prioritized_at_root(parser): 37 | parsed = parser.parse("aws ") 38 | assert parsed.current_context == CompletionContext.SERVICE 39 | assert parser.get_suggestions(parsed)[:2] == AWSCommandParser.SPECIAL_COMMANDS 40 | -------------------------------------------------------------------------------- /tests/test_special_commands_no_autocomplete.py: -------------------------------------------------------------------------------- 1 | """Tests for special commands not showing autocomplete suggestions.""" 2 | 3 | import pytest 4 | from awsui.command_parser import AWSCommandParser, CompletionContext 5 | 6 | 7 | @pytest.fixture 8 | def parser(): 9 | parser = AWSCommandParser() 10 | parser.use_dynamic_loading = False 11 | return parser 12 | 13 | 14 | def test_help_with_trailing_space_no_suggestions(parser): 15 | """Test 'aws help ' does not show command suggestions.""" 16 | parsed = parser.parse("aws help ") 17 | assert parsed.service == "help" 18 | assert parsed.current_context == CompletionContext.COMMAND 19 | suggestions = parser.get_suggestions(parsed) 20 | assert suggestions == [] 21 | 22 | 23 | def test_help_with_parameter_no_suggestions(parser): 24 | """Test 'aws help --' does not show parameter suggestions.""" 25 | parsed = parser.parse("aws help --") 26 | assert parsed.service == "help" 27 | suggestions = parser.get_suggestions(parsed) 28 | assert suggestions == [] 29 | 30 | 31 | def test_help_typing_text_no_suggestions(parser): 32 | """Test 'aws help something' does not show suggestions.""" 33 | parsed = parser.parse("aws help something") 34 | assert parsed.service == "help" 35 | suggestions = parser.get_suggestions(parsed) 36 | assert suggestions == [] 37 | 38 | 39 | def test_configure_with_trailing_space_no_suggestions(parser): 40 | """Test 'aws configure ' does not show command suggestions.""" 41 | parsed = parser.parse("aws configure ") 42 | assert parsed.service == "configure" 43 | assert parsed.current_context == CompletionContext.COMMAND 44 | suggestions = parser.get_suggestions(parsed) 45 | assert suggestions == [] 46 | 47 | 48 | def test_configure_with_parameter_no_suggestions(parser): 49 | """Test 'aws configure --' does not show parameter suggestions.""" 50 | parsed = parser.parse("aws configure --") 51 | assert parsed.service == "configure" 52 | suggestions = parser.get_suggestions(parsed) 53 | assert suggestions == [] 54 | 55 | 56 | def test_help_recognized_as_service(parser): 57 | """Test 'help' is recognized as valid service.""" 58 | parsed = parser.parse("aws help") 59 | assert parsed.service == "help" 60 | assert parser._is_valid_service("help") is True 61 | 62 | 63 | def test_configure_recognized_as_service(parser): 64 | """Test 'configure' is recognized as valid service.""" 65 | parsed = parser.parse("aws configure") 66 | assert parsed.service == "configure" 67 | assert parser._is_valid_service("configure") is True 68 | 69 | 70 | def test_special_commands_in_constant(parser): 71 | """Test special commands are defined in SPECIAL_COMMANDS.""" 72 | assert "help" in parser.SPECIAL_COMMANDS 73 | assert "configure" in parser.SPECIAL_COMMANDS 74 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 3 3 | requires-python = "==3.13.*" 4 | 5 | [[package]] 6 | name = "awsui" 7 | version = "0.1.1" 8 | source = { editable = "." } 9 | dependencies = [ 10 | { name = "textual" }, 11 | ] 12 | 13 | [package.dev-dependencies] 14 | dev = [ 15 | { name = "pytest" }, 16 | { name = "pytest-cov" }, 17 | { name = "ruff" }, 18 | ] 19 | 20 | [package.metadata] 21 | requires-dist = [{ name = "textual", specifier = ">=6.1.0" }] 22 | 23 | [package.metadata.requires-dev] 24 | dev = [ 25 | { name = "pytest", specifier = ">=8.4.2" }, 26 | { name = "pytest-cov", specifier = ">=7.0.0" }, 27 | { name = "ruff", specifier = ">=0.13.2" }, 28 | ] 29 | 30 | [[package]] 31 | name = "colorama" 32 | version = "0.4.6" 33 | source = { registry = "https://pypi.org/simple" } 34 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } 35 | wheels = [ 36 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, 37 | ] 38 | 39 | [[package]] 40 | name = "coverage" 41 | version = "7.10.7" 42 | source = { registry = "https://pypi.org/simple" } 43 | sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } 44 | wheels = [ 45 | { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, 46 | { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, 47 | { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, 48 | { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, 49 | { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, 50 | { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, 51 | { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, 52 | { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, 53 | { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, 54 | { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, 55 | { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, 56 | { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, 57 | { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, 58 | { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, 59 | { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, 60 | { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, 61 | { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, 62 | { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, 63 | { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, 64 | { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, 65 | { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, 66 | { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, 67 | { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, 68 | { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, 69 | { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, 70 | { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, 71 | { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, 72 | ] 73 | 74 | [[package]] 75 | name = "iniconfig" 76 | version = "2.1.0" 77 | source = { registry = "https://pypi.org/simple" } 78 | sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } 79 | wheels = [ 80 | { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, 81 | ] 82 | 83 | [[package]] 84 | name = "linkify-it-py" 85 | version = "2.0.3" 86 | source = { registry = "https://pypi.org/simple" } 87 | dependencies = [ 88 | { name = "uc-micro-py" }, 89 | ] 90 | sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" } 91 | wheels = [ 92 | { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, 93 | ] 94 | 95 | [[package]] 96 | name = "markdown-it-py" 97 | version = "4.0.0" 98 | source = { registry = "https://pypi.org/simple" } 99 | dependencies = [ 100 | { name = "mdurl" }, 101 | ] 102 | sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } 103 | wheels = [ 104 | { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, 105 | ] 106 | 107 | [package.optional-dependencies] 108 | linkify = [ 109 | { name = "linkify-it-py" }, 110 | ] 111 | plugins = [ 112 | { name = "mdit-py-plugins" }, 113 | ] 114 | 115 | [[package]] 116 | name = "mdit-py-plugins" 117 | version = "0.5.0" 118 | source = { registry = "https://pypi.org/simple" } 119 | dependencies = [ 120 | { name = "markdown-it-py" }, 121 | ] 122 | sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } 123 | wheels = [ 124 | { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, 125 | ] 126 | 127 | [[package]] 128 | name = "mdurl" 129 | version = "0.1.2" 130 | source = { registry = "https://pypi.org/simple" } 131 | sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } 132 | wheels = [ 133 | { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, 134 | ] 135 | 136 | [[package]] 137 | name = "packaging" 138 | version = "25.0" 139 | source = { registry = "https://pypi.org/simple" } 140 | sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } 141 | wheels = [ 142 | { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, 143 | ] 144 | 145 | [[package]] 146 | name = "platformdirs" 147 | version = "4.4.0" 148 | source = { registry = "https://pypi.org/simple" } 149 | sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } 150 | wheels = [ 151 | { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, 152 | ] 153 | 154 | [[package]] 155 | name = "pluggy" 156 | version = "1.6.0" 157 | source = { registry = "https://pypi.org/simple" } 158 | sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } 159 | wheels = [ 160 | { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, 161 | ] 162 | 163 | [[package]] 164 | name = "pygments" 165 | version = "2.19.2" 166 | source = { registry = "https://pypi.org/simple" } 167 | sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } 168 | wheels = [ 169 | { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, 170 | ] 171 | 172 | [[package]] 173 | name = "pytest" 174 | version = "8.4.2" 175 | source = { registry = "https://pypi.org/simple" } 176 | dependencies = [ 177 | { name = "colorama", marker = "sys_platform == 'win32'" }, 178 | { name = "iniconfig" }, 179 | { name = "packaging" }, 180 | { name = "pluggy" }, 181 | { name = "pygments" }, 182 | ] 183 | sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } 184 | wheels = [ 185 | { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, 186 | ] 187 | 188 | [[package]] 189 | name = "pytest-cov" 190 | version = "7.0.0" 191 | source = { registry = "https://pypi.org/simple" } 192 | dependencies = [ 193 | { name = "coverage" }, 194 | { name = "pluggy" }, 195 | { name = "pytest" }, 196 | ] 197 | sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } 198 | wheels = [ 199 | { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, 200 | ] 201 | 202 | [[package]] 203 | name = "rich" 204 | version = "14.1.0" 205 | source = { registry = "https://pypi.org/simple" } 206 | dependencies = [ 207 | { name = "markdown-it-py" }, 208 | { name = "pygments" }, 209 | ] 210 | sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } 211 | wheels = [ 212 | { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, 213 | ] 214 | 215 | [[package]] 216 | name = "ruff" 217 | version = "0.13.2" 218 | source = { registry = "https://pypi.org/simple" } 219 | sdist = { url = "https://files.pythonhosted.org/packages/02/df/8d7d8c515d33adfc540e2edf6c6021ea1c5a58a678d8cfce9fae59aabcab/ruff-0.13.2.tar.gz", hash = "sha256:cb12fffd32fb16d32cef4ed16d8c7cdc27ed7c944eaa98d99d01ab7ab0b710ff", size = 5416417, upload-time = "2025-09-25T14:54:09.936Z" } 220 | wheels = [ 221 | { url = "https://files.pythonhosted.org/packages/6e/84/5716a7fa4758e41bf70e603e13637c42cfb9dbf7ceb07180211b9bbf75ef/ruff-0.13.2-py3-none-linux_armv6l.whl", hash = "sha256:3796345842b55f033a78285e4f1641078f902020d8450cade03aad01bffd81c3", size = 12343254, upload-time = "2025-09-25T14:53:27.784Z" }, 222 | { url = "https://files.pythonhosted.org/packages/9b/77/c7042582401bb9ac8eff25360e9335e901d7a1c0749a2b28ba4ecb239991/ruff-0.13.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ff7e4dda12e683e9709ac89e2dd436abf31a4d8a8fc3d89656231ed808e231d2", size = 13040891, upload-time = "2025-09-25T14:53:31.38Z" }, 223 | { url = "https://files.pythonhosted.org/packages/c6/15/125a7f76eb295cb34d19c6778e3a82ace33730ad4e6f28d3427e134a02e0/ruff-0.13.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c75e9d2a2fafd1fdd895d0e7e24b44355984affdde1c412a6f6d3f6e16b22d46", size = 12243588, upload-time = "2025-09-25T14:53:33.543Z" }, 224 | { url = "https://files.pythonhosted.org/packages/9e/eb/0093ae04a70f81f8be7fd7ed6456e926b65d238fc122311293d033fdf91e/ruff-0.13.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cceac74e7bbc53ed7d15d1042ffe7b6577bf294611ad90393bf9b2a0f0ec7cb6", size = 12491359, upload-time = "2025-09-25T14:53:35.892Z" }, 225 | { url = "https://files.pythonhosted.org/packages/43/fe/72b525948a6956f07dad4a6f122336b6a05f2e3fd27471cea612349fedb9/ruff-0.13.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6ae3f469b5465ba6d9721383ae9d49310c19b452a161b57507764d7ef15f4b07", size = 12162486, upload-time = "2025-09-25T14:53:38.171Z" }, 226 | { url = "https://files.pythonhosted.org/packages/6a/e3/0fac422bbbfb2ea838023e0d9fcf1f30183d83ab2482800e2cb892d02dfe/ruff-0.13.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f8f9e3cd6714358238cd6626b9d43026ed19c0c018376ac1ef3c3a04ffb42d8", size = 13871203, upload-time = "2025-09-25T14:53:41.943Z" }, 227 | { url = "https://files.pythonhosted.org/packages/6b/82/b721c8e3ec5df6d83ba0e45dcf00892c4f98b325256c42c38ef136496cbf/ruff-0.13.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c6ed79584a8f6cbe2e5d7dbacf7cc1ee29cbdb5df1172e77fbdadc8bb85a1f89", size = 14929635, upload-time = "2025-09-25T14:53:43.953Z" }, 228 | { url = "https://files.pythonhosted.org/packages/c4/a0/ad56faf6daa507b83079a1ad7a11694b87d61e6bf01c66bd82b466f21821/ruff-0.13.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aed130b2fde049cea2019f55deb939103123cdd191105f97a0599a3e753d61b0", size = 14338783, upload-time = "2025-09-25T14:53:46.205Z" }, 229 | { url = "https://files.pythonhosted.org/packages/47/77/ad1d9156db8f99cd01ee7e29d74b34050e8075a8438e589121fcd25c4b08/ruff-0.13.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1887c230c2c9d65ed1b4e4cfe4d255577ea28b718ae226c348ae68df958191aa", size = 13355322, upload-time = "2025-09-25T14:53:48.164Z" }, 230 | { url = "https://files.pythonhosted.org/packages/64/8b/e87cfca2be6f8b9f41f0bb12dc48c6455e2d66df46fe61bb441a226f1089/ruff-0.13.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bcb10276b69b3cfea3a102ca119ffe5c6ba3901e20e60cf9efb53fa417633c3", size = 13354427, upload-time = "2025-09-25T14:53:50.486Z" }, 231 | { url = "https://files.pythonhosted.org/packages/7f/df/bf382f3fbead082a575edb860897287f42b1b3c694bafa16bc9904c11ed3/ruff-0.13.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:afa721017aa55a555b2ff7944816587f1cb813c2c0a882d158f59b832da1660d", size = 13537637, upload-time = "2025-09-25T14:53:52.887Z" }, 232 | { url = "https://files.pythonhosted.org/packages/51/70/1fb7a7c8a6fc8bd15636288a46e209e81913b87988f26e1913d0851e54f4/ruff-0.13.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1dbc875cf3720c64b3990fef8939334e74cb0ca65b8dbc61d1f439201a38101b", size = 12340025, upload-time = "2025-09-25T14:53:54.88Z" }, 233 | { url = "https://files.pythonhosted.org/packages/4c/27/1e5b3f1c23ca5dd4106d9d580e5c13d9acb70288bff614b3d7b638378cc9/ruff-0.13.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939a1b2a960e9742e9a347e5bbc9b3c3d2c716f86c6ae273d9cbd64f193f22", size = 12133449, upload-time = "2025-09-25T14:53:57.089Z" }, 234 | { url = "https://files.pythonhosted.org/packages/2d/09/b92a5ccee289f11ab128df57d5911224197d8d55ef3bd2043534ff72ca54/ruff-0.13.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:50e2d52acb8de3804fc5f6e2fa3ae9bdc6812410a9e46837e673ad1f90a18736", size = 13051369, upload-time = "2025-09-25T14:53:59.124Z" }, 235 | { url = "https://files.pythonhosted.org/packages/89/99/26c9d1c7d8150f45e346dc045cc49f23e961efceb4a70c47dea0960dea9a/ruff-0.13.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3196bc13ab2110c176b9a4ae5ff7ab676faaa1964b330a1383ba20e1e19645f2", size = 13523644, upload-time = "2025-09-25T14:54:01.622Z" }, 236 | { url = "https://files.pythonhosted.org/packages/f7/00/e7f1501e81e8ec290e79527827af1d88f541d8d26151751b46108978dade/ruff-0.13.2-py3-none-win32.whl", hash = "sha256:7c2a0b7c1e87795fec3404a485096bcd790216c7c146a922d121d8b9c8f1aaac", size = 12245990, upload-time = "2025-09-25T14:54:03.647Z" }, 237 | { url = "https://files.pythonhosted.org/packages/ee/bd/d9f33a73de84fafd0146c6fba4f497c4565fe8fa8b46874b8e438869abc2/ruff-0.13.2-py3-none-win_amd64.whl", hash = "sha256:17d95fb32218357c89355f6f6f9a804133e404fc1f65694372e02a557edf8585", size = 13324004, upload-time = "2025-09-25T14:54:06.05Z" }, 238 | { url = "https://files.pythonhosted.org/packages/c3/12/28fa2f597a605884deb0f65c1b1ae05111051b2a7030f5d8a4ff7f4599ba/ruff-0.13.2-py3-none-win_arm64.whl", hash = "sha256:da711b14c530412c827219312b7d7fbb4877fb31150083add7e8c5336549cea7", size = 12484437, upload-time = "2025-09-25T14:54:08.022Z" }, 239 | ] 240 | 241 | [[package]] 242 | name = "textual" 243 | version = "6.1.0" 244 | source = { registry = "https://pypi.org/simple" } 245 | dependencies = [ 246 | { name = "markdown-it-py", extra = ["linkify", "plugins"] }, 247 | { name = "platformdirs" }, 248 | { name = "pygments" }, 249 | { name = "rich" }, 250 | { name = "typing-extensions" }, 251 | ] 252 | sdist = { url = "https://files.pythonhosted.org/packages/da/44/4b524b2f06e0fa6c4ede56a4e9af5edd5f3f83cf2eea5cb4fd0ce5bbe063/textual-6.1.0.tar.gz", hash = "sha256:cc89826ca2146c645563259320ca4ddc75d183c77afb7d58acdd46849df9144d", size = 1564786, upload-time = "2025-09-02T11:42:34.655Z" } 253 | wheels = [ 254 | { url = "https://files.pythonhosted.org/packages/54/43/f91e041f239b54399310a99041faf33beae9a6e628671471d0fcd6276af4/textual-6.1.0-py3-none-any.whl", hash = "sha256:a3f5e6710404fcdc6385385db894699282dccf2ad50103cebc677403c1baadd5", size = 707840, upload-time = "2025-09-02T11:42:32.746Z" }, 255 | ] 256 | 257 | [[package]] 258 | name = "typing-extensions" 259 | version = "4.15.0" 260 | source = { registry = "https://pypi.org/simple" } 261 | sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } 262 | wheels = [ 263 | { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, 264 | ] 265 | 266 | [[package]] 267 | name = "uc-micro-py" 268 | version = "1.0.3" 269 | source = { registry = "https://pypi.org/simple" } 270 | sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" } 271 | wheels = [ 272 | { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, 273 | ] 274 | --------------------------------------------------------------------------------