cloudshellgpt 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,328 @@
1
+ """Output formatter — renders results in multiple formats (table, json, yaml, csv).
2
+
3
+ Supports TTY auto-detection, Rich panels/progress, and multi-format output.
4
+ When stdout is not a TTY (piped), outputs plain JSON without colors.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import csv
10
+ import io
11
+ import json
12
+ import sys
13
+ from collections.abc import Generator
14
+ from contextlib import contextmanager
15
+ from typing import Any, Literal
16
+
17
+ import yaml
18
+ from rich.console import Console
19
+ from rich.panel import Panel
20
+ from rich.progress import Progress, SpinnerColumn, TextColumn
21
+ from rich.table import Table
22
+ from rich.text import Text
23
+
24
+ from cloudshellgpt.executor import ExecutionResult
25
+
26
+ FormatType = Literal["table", "json", "yaml", "csv", "raw"]
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Mensajes de error en español (humanizados)
30
+ # ---------------------------------------------------------------------------
31
+
32
+ ERROR_MESSAGES: dict[str, str] = {
33
+ "command_failed": "El comando falló con código de salida {exit_code}",
34
+ "command_failed_detail": "Detalle: {error}",
35
+ "stderr_output": "Salida de error del proceso:",
36
+ "suggestion_check_credentials": (
37
+ "Sugerencia: Verifica tus credenciales de AWS y permisos IAM."
38
+ ),
39
+ "suggestion_check_syntax": (
40
+ "Sugerencia: Revisa la sintaxis del comando o ejecuta con --dry-run primero."
41
+ ),
42
+ "suggestion_check_region": ("Sugerencia: Confirma que la región configurada es correcta."),
43
+ "dry_run_label": "Simulación (dry-run)",
44
+ "executed_label": "Ejecutado",
45
+ "duration_label": "Duración",
46
+ "command_label": "Comando",
47
+ "executing_label": "Ejecutando comando AWS...",
48
+ }
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Formatter
53
+ # ---------------------------------------------------------------------------
54
+
55
+
56
+ class Formatter:
57
+ """Formats execution results for human or machine consumption.
58
+
59
+ Auto-detects whether stdout is a TTY. When piped (no TTY), outputs plain
60
+ JSON without Rich formatting or colors to support scripting workflows.
61
+
62
+ Args:
63
+ format_type: Output format to use (table, json, yaml, csv, raw).
64
+ force_tty: Override TTY detection (useful for testing).
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ format_type: FormatType = "table",
70
+ force_tty: bool | None = None,
71
+ ) -> None:
72
+ self.format_type = format_type
73
+ self._is_tty = force_tty if force_tty is not None else sys.stdout.isatty()
74
+ self.console = Console(
75
+ force_terminal=self._is_tty,
76
+ no_color=not self._is_tty,
77
+ )
78
+
79
+ @property
80
+ def is_tty(self) -> bool:
81
+ """Whether the output is going to an interactive terminal."""
82
+ return self._is_tty
83
+
84
+ def render(self, result: ExecutionResult) -> None:
85
+ """Render an ExecutionResult in the configured format.
86
+
87
+ When not a TTY, always outputs plain JSON regardless of configured format.
88
+
89
+ Args:
90
+ result: The execution result to format.
91
+ """
92
+ if not self._is_tty:
93
+ self._render_plain_json(result)
94
+ return
95
+
96
+ if result.exit_code != 0:
97
+ self._render_error(result)
98
+ return
99
+
100
+ formatter = {
101
+ "table": self._render_table,
102
+ "json": self._render_json,
103
+ "yaml": self._render_yaml,
104
+ "csv": self._render_csv,
105
+ "raw": self._render_raw,
106
+ }.get(self.format_type, self._render_table)
107
+
108
+ formatter(result)
109
+
110
+ @contextmanager
111
+ def progress_spinner(self, message: str | None = None) -> Generator[None, None, None]:
112
+ """Context manager that shows a Rich spinner while work is in progress.
113
+
114
+ Only displays the spinner when output is a TTY. In non-TTY mode,
115
+ this is a no-op to avoid polluting piped output.
116
+
117
+ Args:
118
+ message: Optional message to display alongside the spinner.
119
+
120
+ Yields:
121
+ None — use as a context manager around long-running operations.
122
+ """
123
+ label = message or ERROR_MESSAGES["executing_label"]
124
+
125
+ if not self._is_tty:
126
+ yield
127
+ return
128
+
129
+ with Progress(
130
+ SpinnerColumn(),
131
+ TextColumn("[bold blue]{task.description}"),
132
+ console=self.console,
133
+ transient=True,
134
+ ) as progress:
135
+ progress.add_task(label, total=None)
136
+ yield
137
+
138
+ # ------------------------------------------------------------------
139
+ # TTY renderers (Rich formatting)
140
+ # ------------------------------------------------------------------
141
+
142
+ def _render_table(self, result: ExecutionResult) -> None:
143
+ """Render as a Rich table wrapped in an info panel."""
144
+ parsed = self._try_parse_json(result.stdout)
145
+
146
+ if isinstance(parsed, list) and parsed:
147
+ first = parsed[0]
148
+ if isinstance(first, dict):
149
+ table = Table(show_header=True, header_style="bold cyan")
150
+ for key in first.keys():
151
+ table.add_column(str(key))
152
+
153
+ for item in parsed[:50]:
154
+ row = [str(item.get(k, "")) for k in first.keys()]
155
+ table.add_row(*row)
156
+
157
+ panel = self._build_info_panel(result, table)
158
+ self.console.print(panel)
159
+
160
+ if len(parsed) > 50:
161
+ self.console.print(f"[dim]... y {len(parsed) - 50} más[/dim]")
162
+ return
163
+
164
+ # Fallback: wrap raw output in panel
165
+ panel = self._build_info_panel(result, Text(result.stdout))
166
+ self.console.print(panel)
167
+
168
+ def _render_json(self, result: ExecutionResult) -> None:
169
+ """Render as pretty-printed JSON with syntax highlighting."""
170
+ parsed = self._try_parse_json(result.stdout)
171
+ if parsed is not None:
172
+ self.console.print_json(json.dumps(parsed, indent=2, default=str))
173
+ else:
174
+ self.console.print(result.stdout)
175
+
176
+ def _render_yaml(self, result: ExecutionResult) -> None:
177
+ """Render as YAML output."""
178
+ parsed = self._try_parse_json(result.stdout)
179
+ if parsed is not None:
180
+ output = yaml.dump(parsed, default_flow_style=False, allow_unicode=True)
181
+ self.console.print(output)
182
+ else:
183
+ self.console.print(result.stdout)
184
+
185
+ def _render_csv(self, result: ExecutionResult) -> None:
186
+ """Render as CSV output."""
187
+ parsed = self._try_parse_json(result.stdout)
188
+ if isinstance(parsed, list) and parsed and isinstance(parsed[0], dict):
189
+ output = io.StringIO()
190
+ writer = csv.DictWriter(output, fieldnames=parsed[0].keys())
191
+ writer.writeheader()
192
+ writer.writerows(parsed)
193
+ self.console.print(output.getvalue())
194
+ else:
195
+ self.console.print(result.stdout)
196
+
197
+ def _render_raw(self, result: ExecutionResult) -> None:
198
+ """Render as plain text without extra formatting."""
199
+ self.console.print(result.stdout)
200
+
201
+ # ------------------------------------------------------------------
202
+ # Non-TTY renderer (plain JSON for piping)
203
+ # ------------------------------------------------------------------
204
+
205
+ def _render_plain_json(self, result: ExecutionResult) -> None:
206
+ """Output plain JSON without colors for non-TTY contexts (pipes, scripts).
207
+
208
+ Args:
209
+ result: The execution result to serialize.
210
+ """
211
+ data: dict[str, Any] = {
212
+ "command": result.command,
213
+ "exit_code": result.exit_code,
214
+ "duration_ms": result.duration_ms,
215
+ "dry_run": result.dry_run,
216
+ }
217
+
218
+ if result.exit_code == 0:
219
+ parsed = self._try_parse_json(result.stdout)
220
+ data["output"] = parsed if parsed is not None else result.stdout
221
+ else:
222
+ data["error"] = result.error
223
+ data["stderr"] = result.stderr
224
+
225
+ print(json.dumps(data, indent=2, default=str, ensure_ascii=False))
226
+
227
+ # ------------------------------------------------------------------
228
+ # Error rendering (Spanish, humanized)
229
+ # ------------------------------------------------------------------
230
+
231
+ def _render_error(self, result: ExecutionResult) -> None:
232
+ """Render an error result with context and suggestions in Spanish.
233
+
234
+ Args:
235
+ result: The failed execution result.
236
+ """
237
+ title = ERROR_MESSAGES["command_failed"].format(exit_code=result.exit_code)
238
+
239
+ error_parts: list[str] = []
240
+
241
+ if result.error:
242
+ error_parts.append(ERROR_MESSAGES["command_failed_detail"].format(error=result.error))
243
+
244
+ if result.stderr:
245
+ error_parts.append(f"\n{ERROR_MESSAGES['stderr_output']}")
246
+ error_parts.append(result.stderr.strip())
247
+
248
+ # Add contextual suggestion based on error content
249
+ suggestion = self._get_error_suggestion(result)
250
+ if suggestion:
251
+ error_parts.append(f"\n💡 {suggestion}")
252
+
253
+ body = "\n".join(error_parts) if error_parts else title
254
+
255
+ panel = Panel(
256
+ body,
257
+ title=f"[red]✗ {title}[/red]",
258
+ subtitle=f"[dim]{result.command}[/dim]",
259
+ border_style="red",
260
+ padding=(1, 2),
261
+ )
262
+ self.console.print(panel)
263
+
264
+ # ------------------------------------------------------------------
265
+ # Helpers
266
+ # ------------------------------------------------------------------
267
+
268
+ def _build_info_panel(self, result: ExecutionResult, content: Table | Text) -> Panel:
269
+ """Build a Rich Panel wrapping content with execution metadata.
270
+
271
+ Args:
272
+ result: Execution result for metadata.
273
+ content: The renderable content to wrap.
274
+
275
+ Returns:
276
+ A Rich Panel with command info.
277
+ """
278
+ status = (
279
+ ERROR_MESSAGES["dry_run_label"] if result.dry_run else ERROR_MESSAGES["executed_label"]
280
+ )
281
+ duration_s = result.duration_ms / 1000.0
282
+ subtitle = f"[dim]{ERROR_MESSAGES['duration_label']}: {duration_s:.2f}s | {status}[/dim]"
283
+
284
+ return Panel(
285
+ content,
286
+ title=f"[bold green]$ {result.command}[/bold green]",
287
+ subtitle=subtitle,
288
+ border_style="green",
289
+ padding=(0, 1),
290
+ )
291
+
292
+ def _get_error_suggestion(self, result: ExecutionResult) -> str:
293
+ """Return a contextual suggestion based on the error content.
294
+
295
+ Args:
296
+ result: The failed execution result.
297
+
298
+ Returns:
299
+ A suggestion string in Spanish, or empty string if no match.
300
+ """
301
+ combined = f"{result.stderr or ''} {result.error or ''}".lower()
302
+
303
+ if any(
304
+ kw in combined for kw in ("accessdenied", "unauthorized", "forbidden", "credentials")
305
+ ):
306
+ return ERROR_MESSAGES["suggestion_check_credentials"]
307
+
308
+ if any(kw in combined for kw in ("invalidregion", "could not connect", "endpoint")):
309
+ return ERROR_MESSAGES["suggestion_check_region"]
310
+
311
+ if any(kw in combined for kw in ("invalidparametervalue", "malformed", "syntax", "usage:")):
312
+ return ERROR_MESSAGES["suggestion_check_syntax"]
313
+
314
+ return ""
315
+
316
+ def _try_parse_json(self, text: str) -> Any:
317
+ """Try to parse text as JSON, return None if not valid.
318
+
319
+ Args:
320
+ text: Raw text to attempt JSON parsing on.
321
+
322
+ Returns:
323
+ Parsed JSON value, or None if parsing fails.
324
+ """
325
+ try:
326
+ return json.loads(text)
327
+ except (json.JSONDecodeError, TypeError):
328
+ return None
cloudshellgpt/i18n.py ADDED
@@ -0,0 +1,203 @@
1
+ """Internationalization — UI labels for CloudShellGPT."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # Supported UI translations. Keys are ISO 639-1 language codes.
6
+ # Falls back to English ("en") for unsupported languages.
7
+
8
+ TRANSLATIONS: dict[str, dict[str, str]] = {
9
+ "en": {
10
+ "plan_title": "Plan",
11
+ "flags_title": "🔍 Command flags",
12
+ "tip_title": "💡 Tip",
13
+ "related_title": "🔗 Related commands",
14
+ "risk_label": "Risk",
15
+ "cost_label": "Cost",
16
+ "command_label": "Command",
17
+ "explanation_label": "Explanation",
18
+ "proceed_prompt": "Proceed?",
19
+ "confirm_high_title": "Confirmation Required",
20
+ "confirm_high_banner": "⚠️ HIGH RISK OPERATION",
21
+ "confirm_critical_title": "⚠️ CRITICAL WARNING",
22
+ "confirm_critical_banner": "🚨 CRITICAL OPERATION — IRREVERSIBLE",
23
+ "affected_resources": "Affected resources",
24
+ "estimated_cost": "Estimated cost",
25
+ "type_resource": 'Type the resource name ("{resource}") to confirm',
26
+ "type_confirm": 'Type "confirm" to proceed',
27
+ "type_yes_i_understand": "To proceed with this critical operation, type yes-i-understand:",
28
+ "dry_run_performing": "Performing dry-run...",
29
+ "dry_run_success": "Dry-run successful — operation would succeed",
30
+ "dry_run_failed": "Dry-Run Failed",
31
+ "dry_run_result": "Dry-Run Result",
32
+ "dry_run_preview": "Dry-Run Preview",
33
+ "cancelled": "Cancelled.",
34
+ "confirmation_mismatch": "Confirmation did not match. Cancelled.",
35
+ "cost_unavailable": "⚠️ Cost estimation unavailable — proceed with caution",
36
+ "cost_preview_title": "Cost Preview",
37
+ "execution_success": "Executed",
38
+ "execution_failed": "Command failed with exit code {code}",
39
+ "execution_duration": "Duration: {duration}s",
40
+ "error_detail": "Detail",
41
+ "error_suggestion": "Suggestion: Check your AWS credentials and IAM permissions.",
42
+ "could_not_understand": "Could not understand: {intent}",
43
+ "did_you_mean": "Try being more specific. Example: 'list the S3 buckets' or 'create a new EC2 instance t3.micro'",
44
+ },
45
+ "es": {
46
+ "plan_title": "Plan",
47
+ "flags_title": "🔍 Flags del comando",
48
+ "tip_title": "💡 Consejo",
49
+ "related_title": "🔗 Comandos relacionados",
50
+ "risk_label": "Riesgo",
51
+ "cost_label": "Costo",
52
+ "command_label": "Comando",
53
+ "explanation_label": "Explicación",
54
+ "proceed_prompt": "¿Ejecutar?",
55
+ "confirm_high_title": "Confirmación requerida",
56
+ "confirm_high_banner": "⚠️ OPERACIÓN DE ALTO RIESGO",
57
+ "confirm_critical_title": "⚠️ ADVERTENCIA CRÍTICA",
58
+ "confirm_critical_banner": "🚨 OPERACIÓN CRÍTICA — IRREVERSIBLE",
59
+ "affected_resources": "Recursos afectados",
60
+ "estimated_cost": "Costo estimado",
61
+ "type_resource": 'Escribe el nombre del recurso ("{resource}") para confirmar',
62
+ "type_confirm": 'Escribe "confirm" para proceder',
63
+ "type_yes_i_understand": "Para continuar con esta operación crítica, escribe yes-i-understand:",
64
+ "dry_run_performing": "Ejecutando dry-run...",
65
+ "dry_run_success": "Dry-run exitoso — la operación sería exitosa",
66
+ "dry_run_failed": "Dry-Run fallido",
67
+ "dry_run_result": "Resultado Dry-Run",
68
+ "dry_run_preview": "Vista previa Dry-Run",
69
+ "cancelled": "Cancelado.",
70
+ "confirmation_mismatch": "La confirmación no coincide. Cancelado.",
71
+ "cost_unavailable": "⚠️ Estimación de costos no disponible — proceder con precaución",
72
+ "cost_preview_title": "Vista previa de costos",
73
+ "execution_success": "Ejecutado",
74
+ "execution_failed": "El comando falló con código de salida {code}",
75
+ "execution_duration": "Duración: {duration}s",
76
+ "error_detail": "Detalle",
77
+ "error_suggestion": "💡 Sugerencia: Verifica tus credenciales de AWS y permisos IAM.",
78
+ "could_not_understand": "No se pudo entender: {intent}",
79
+ "did_you_mean": "Sé más específico. Ejemplo: 'lista los buckets de S3' o 'crea una instancia EC2 t3.micro'",
80
+ },
81
+ "pt": {
82
+ "plan_title": "Plano",
83
+ "flags_title": "🔍 Flags do comando",
84
+ "tip_title": "💡 Dica",
85
+ "related_title": "🔗 Comandos relacionados",
86
+ "risk_label": "Risco",
87
+ "cost_label": "Custo",
88
+ "command_label": "Comando",
89
+ "explanation_label": "Explicação",
90
+ "proceed_prompt": "Executar?",
91
+ "confirm_high_title": "Confirmação necessária",
92
+ "confirm_high_banner": "⚠️ OPERAÇÃO DE ALTO RISCO",
93
+ "confirm_critical_title": "⚠️ AVISO CRÍTICO",
94
+ "confirm_critical_banner": "🚨 OPERAÇÃO CRÍTICA — IRREVERSÍVEL",
95
+ "affected_resources": "Recursos afetados",
96
+ "estimated_cost": "Custo estimado",
97
+ "type_resource": 'Digite o nome do recurso ("{resource}") para confirmar',
98
+ "type_confirm": 'Digite "confirm" para prosseguir',
99
+ "type_yes_i_understand": "Para prosseguir com esta operação crítica, digite yes-i-understand:",
100
+ "dry_run_performing": "Executando dry-run...",
101
+ "dry_run_success": "Dry-run bem-sucedido — a operação teria sucesso",
102
+ "dry_run_failed": "Dry-Run falhou",
103
+ "dry_run_result": "Resultado Dry-Run",
104
+ "dry_run_preview": "Pré-visualização Dry-Run",
105
+ "cancelled": "Cancelado.",
106
+ "confirmation_mismatch": "A confirmação não corresponde. Cancelado.",
107
+ "cost_unavailable": "⚠️ Estimativa de custos indisponível — proceda com cautela",
108
+ "cost_preview_title": "Pré-visualização de custos",
109
+ "execution_success": "Executado",
110
+ "execution_failed": "O comando falhou com código de saída {code}",
111
+ "execution_duration": "Duração: {duration}s",
112
+ "error_detail": "Detalhe",
113
+ "error_suggestion": "💡 Sugestão: Verifique suas credenciais AWS e permissões IAM.",
114
+ "could_not_understand": "Não foi possível entender: {intent}",
115
+ "did_you_mean": "Seja mais específico. Exemplo: 'liste os buckets do S3' ou 'crie uma instância EC2 t3.micro'",
116
+ },
117
+ "zh": {
118
+ "plan_title": "计划",
119
+ "flags_title": "🔍 命令参数",
120
+ "tip_title": "💡 提示",
121
+ "related_title": "🔗 相关命令",
122
+ "risk_label": "风险",
123
+ "cost_label": "费用",
124
+ "command_label": "命令",
125
+ "explanation_label": "说明",
126
+ "proceed_prompt": "执行?",
127
+ "confirm_high_title": "需要确认",
128
+ "confirm_high_banner": "⚠️ 高风险操作",
129
+ "confirm_critical_title": "⚠️ 严重警告",
130
+ "confirm_critical_banner": "🚨 严重操作 — 不可逆转",
131
+ "affected_resources": "受影响资源",
132
+ "estimated_cost": "预估费用",
133
+ "type_resource": '输入资源名称 ("{resource}") 以确认',
134
+ "type_confirm": '输入 "confirm" 以继续',
135
+ "type_yes_i_understand": "要继续此关键操作,请输入 yes-i-understand:",
136
+ "dry_run_performing": "正在执行试运行...",
137
+ "dry_run_success": "试运行成功 — 操作可以执行",
138
+ "dry_run_failed": "试运行失败",
139
+ "dry_run_result": "试运行结果",
140
+ "dry_run_preview": "试运行预览",
141
+ "cancelled": "已取消。",
142
+ "confirmation_mismatch": "确认不匹配。已取消。",
143
+ "cost_unavailable": "⚠️ 费用估算不可用 — 请谨慎操作",
144
+ "cost_preview_title": "费用预览",
145
+ "execution_success": "已执行",
146
+ "execution_failed": "命令失败,退出代码 {code}",
147
+ "execution_duration": "耗时: {duration}秒",
148
+ "error_detail": "详情",
149
+ "error_suggestion": "💡 建议:请检查您的 AWS 凭证和 IAM 权限。",
150
+ "could_not_understand": "无法理解: {intent}",
151
+ "did_you_mean": "请更具体。例如:'列出S3存储桶' 或 '创建一个EC2实例 t3.micro'",
152
+ },
153
+ "fr": {
154
+ "plan_title": "Plan",
155
+ "flags_title": "🔍 Flags de la commande",
156
+ "tip_title": "💡 Conseil",
157
+ "related_title": "🔗 Commandes associées",
158
+ "risk_label": "Risque",
159
+ "cost_label": "Coût",
160
+ "command_label": "Commande",
161
+ "explanation_label": "Explication",
162
+ "proceed_prompt": "Exécuter ?",
163
+ "confirm_high_title": "Confirmation requise",
164
+ "confirm_high_banner": "⚠️ OPÉRATION À HAUT RISQUE",
165
+ "confirm_critical_title": "⚠️ AVERTISSEMENT CRITIQUE",
166
+ "confirm_critical_banner": "🚨 OPÉRATION CRITIQUE — IRRÉVERSIBLE",
167
+ "affected_resources": "Ressources affectées",
168
+ "estimated_cost": "Coût estimé",
169
+ "type_resource": 'Tapez le nom de la ressource ("{resource}") pour confirmer',
170
+ "type_confirm": 'Tapez "confirm" pour continuer',
171
+ "type_yes_i_understand": "Pour poursuivre cette opération critique, tapez yes-i-understand :",
172
+ "dry_run_performing": "Exécution du dry-run...",
173
+ "dry_run_success": "Dry-run réussi — l'opération aurait réussi",
174
+ "dry_run_failed": "Dry-Run échoué",
175
+ "dry_run_result": "Résultat Dry-Run",
176
+ "dry_run_preview": "Aperçu Dry-Run",
177
+ "cancelled": "Annulé.",
178
+ "confirmation_mismatch": "La confirmation ne correspond pas. Annulé.",
179
+ "cost_unavailable": "⚠️ Estimation des coûts indisponible — procédez avec prudence",
180
+ "cost_preview_title": "Aperçu des coûts",
181
+ "execution_success": "Exécuté",
182
+ "execution_failed": "La commande a échoué avec le code {code}",
183
+ "execution_duration": "Durée : {duration}s",
184
+ "error_detail": "Détail",
185
+ "error_suggestion": "💡 Suggestion : Vérifiez vos identifiants AWS et permissions IAM.",
186
+ "could_not_understand": "Impossible de comprendre : {intent}",
187
+ "did_you_mean": "Soyez plus précis. Exemple : 'lister les buckets S3' ou 'créer une instance EC2 t3.micro'",
188
+ },
189
+ }
190
+
191
+
192
+ def get_labels(language: str) -> dict[str, str]:
193
+ """Get UI labels for the given language, falling back to English.
194
+
195
+ Args:
196
+ language: ISO 639-1 language code (e.g., 'es', 'en', 'pt', 'zh', 'fr').
197
+
198
+ Returns:
199
+ Dictionary of UI label keys to translated strings.
200
+ """
201
+ # Normalize: take first 2 chars, lowercase
202
+ lang = language[:2].lower() if language else "en"
203
+ return TRANSLATIONS.get(lang, TRANSLATIONS["en"])