├── .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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
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 |
49 | ⚡ Fast profile search and switching with real-time filtering
50 |
51 |
52 |
53 |
54 |
55 |
56 | 🎯 Smart CLI with command autocomplete and inline execution
57 |
58 |
59 |
60 |
61 |
62 |
63 | 🤖 AI-powered Amazon Q Developer integration with streaming responses
64 |
65 |
66 |
67 |
68 |
69 |
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 |
537 |
538 |
539 |
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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
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 |
48 | ⚡ 快速的 profile 搜尋與切換,即時過濾
49 |
50 |
51 |
52 |
53 |
54 |
55 | 🎯 智慧 CLI,具備指令自動完成與內嵌執行
56 |
57 |
58 |
59 |
60 |
61 |
62 | 🤖 AI 驅動的 Amazon Q Developer 整合,支援串流回應
63 |
64 |
65 |
66 |
67 |
68 |
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 |
536 |
537 |
538 |
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 |
--------------------------------------------------------------------------------