bhai-cli 0.1.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.
bhai_cli/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ BHAI-CLI: The Dual-Brain AI Coding Agent with Punjabi Swagger.
3
+
4
+ Lean heritage. Zero-state resilience. Universal LLM support via litellm.
5
+ Indic-native voice interface via Sarvam AI (Saaras v3 STT / Bulbul v3 TTS).
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+ __app_name__ = "bhai-cli"
@@ -0,0 +1,269 @@
1
+ """
2
+ Audio Engine for BHAI-CLI.
3
+
4
+ Handles:
5
+ - Live microphone recording via sounddevice (primary)
6
+ - File-based audio fallback (configurable)
7
+ - STT via Sarvam Saaras v3 (translate mode for code-mixed Punjabi/Hindi → English)
8
+ - TTS via Sarvam Bulbul v3 (English → Indian-accented speech)
9
+
10
+ Edge Case: If Sarvam fails, gracefully returns error flags so the orchestrator
11
+ can downgrade to text-only mode without crashing.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import base64
18
+ import io
19
+ import tempfile
20
+ import wave
21
+ from pathlib import Path
22
+ from typing import Optional
23
+
24
+ import httpx
25
+ from rich.console import Console
26
+
27
+ console = Console()
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Sarvam API Constants
31
+ # ---------------------------------------------------------------------------
32
+ SARVAM_BASE = "https://api.sarvam.ai"
33
+ STT_ENDPOINT = f"{SARVAM_BASE}/speech-to-text"
34
+ TTS_ENDPOINT = f"{SARVAM_BASE}/text-to-speech"
35
+
36
+ SAMPLE_RATE = 16000 # Optimal for Saaras v3
37
+ CHANNELS = 1
38
+ DTYPE = "int16"
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Audio Recording
43
+ # ---------------------------------------------------------------------------
44
+
45
+ class AudioRecorder:
46
+ """Thread-safe live microphone recorder using sounddevice."""
47
+
48
+ def __init__(self, sample_rate: int = SAMPLE_RATE, channels: int = CHANNELS):
49
+ self.sample_rate = sample_rate
50
+ self.channels = channels
51
+ self._frames: list = []
52
+ self._recording = False
53
+ self._stream = None
54
+
55
+ def _check_sounddevice(self):
56
+ """Check if sounddevice is available (optional dependency)."""
57
+ try:
58
+ import sounddevice # noqa: F401
59
+ return True
60
+ except ImportError:
61
+ console.print(
62
+ "[bold red]⚠ Audio support not installed.[/] "
63
+ "Run: [cyan]pip install bhai-cli\\[audio][/]"
64
+ )
65
+ return False
66
+
67
+ def start(self) -> bool:
68
+ """Start recording from microphone. Returns False if unavailable."""
69
+ if not self._check_sounddevice():
70
+ return False
71
+
72
+ import sounddevice as sd
73
+ import numpy as np
74
+
75
+ self._frames = []
76
+ self._recording = True
77
+
78
+ def callback(indata, frames, time_info, status):
79
+ if self._recording:
80
+ self._frames.append(indata.copy())
81
+
82
+ try:
83
+ self._stream = sd.InputStream(
84
+ samplerate=self.sample_rate,
85
+ channels=self.channels,
86
+ dtype=DTYPE,
87
+ callback=callback,
88
+ )
89
+ self._stream.start()
90
+ return True
91
+ except Exception as e:
92
+ console.print(f"[bold red]⚠ Microphone error:[/] {e}")
93
+ self._recording = False
94
+ return False
95
+
96
+ def stop(self) -> Optional[bytes]:
97
+ """Stop recording and return WAV bytes, or None on failure."""
98
+ self._recording = False
99
+ if self._stream:
100
+ self._stream.stop()
101
+ self._stream.close()
102
+ self._stream = None
103
+
104
+ if not self._frames:
105
+ return None
106
+
107
+ import numpy as np
108
+
109
+ audio_data = np.concatenate(self._frames, axis=0)
110
+
111
+ # Convert to WAV bytes
112
+ buf = io.BytesIO()
113
+ with wave.open(buf, "wb") as wf:
114
+ wf.setnchannels(self.channels)
115
+ wf.setsampwidth(2) # 16-bit = 2 bytes
116
+ wf.setframerate(self.sample_rate)
117
+ wf.writeframes(audio_data.tobytes())
118
+
119
+ return buf.getvalue()
120
+
121
+
122
+ def load_audio_file(filepath: str) -> Optional[bytes]:
123
+ """Load audio from a file path as bytes. Returns None on failure."""
124
+ path = Path(filepath)
125
+ if not path.exists():
126
+ console.print(f"[bold red]⚠ Audio file not found:[/] {filepath}")
127
+ return None
128
+ try:
129
+ return path.read_bytes()
130
+ except Exception as e:
131
+ console.print(f"[bold red]⚠ Failed to read audio file:[/] {e}")
132
+ return None
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Sarvam STT (Saaras v3)
137
+ # ---------------------------------------------------------------------------
138
+
139
+ async def speech_to_text(
140
+ audio_bytes: bytes,
141
+ api_key: str,
142
+ model: str = "saaras:v3",
143
+ mode: str = "translate",
144
+ ) -> tuple[bool, str]:
145
+ """
146
+ Transcribe/translate audio using Sarvam Saaras v3.
147
+
148
+ Args:
149
+ audio_bytes: WAV audio data.
150
+ api_key: Sarvam API subscription key.
151
+ model: STT model identifier.
152
+ mode: transcribe | translate | verbatim | translit | codemix
153
+
154
+ Returns:
155
+ (success: bool, text_or_error: str)
156
+ """
157
+ if not api_key:
158
+ return False, "[STT_ERROR] No Sarvam API key configured. Run: bhai-code setup"
159
+
160
+ try:
161
+ async with httpx.AsyncClient(timeout=30.0) as client:
162
+ response = await client.post(
163
+ STT_ENDPOINT,
164
+ headers={"api-subscription-key": api_key},
165
+ files={"file": ("audio.wav", audio_bytes, "audio/wav")},
166
+ data={"model": model, "mode": mode},
167
+ )
168
+
169
+ if response.status_code == 200:
170
+ data = response.json()
171
+ transcript = data.get("transcript", "")
172
+ if transcript:
173
+ return True, transcript
174
+ return False, "[STT_ERROR] Empty transcript returned."
175
+ else:
176
+ return False, f"[STT_ERROR] Sarvam API returned {response.status_code}: {response.text}"
177
+
178
+ except httpx.TimeoutException:
179
+ return False, "[STT_ERROR] Sarvam STT request timed out."
180
+ except Exception as e:
181
+ return False, f"[STT_ERROR] {type(e).__name__}: {e}"
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # Sarvam TTS (Bulbul v3)
186
+ # ---------------------------------------------------------------------------
187
+
188
+ async def text_to_speech(
189
+ text: str,
190
+ api_key: str,
191
+ model: str = "bulbul:v3",
192
+ speaker: str = "shubh",
193
+ language: str = "en-IN",
194
+ ) -> tuple[bool, Optional[bytes]]:
195
+ """
196
+ Convert text to speech using Sarvam Bulbul v3.
197
+
198
+ Args:
199
+ text: Text to speak (max 2500 chars).
200
+ api_key: Sarvam API subscription key.
201
+ model: TTS model identifier.
202
+ speaker: Voice speaker name.
203
+ language: Target language code.
204
+
205
+ Returns:
206
+ (success: bool, audio_bytes_or_none)
207
+ """
208
+ if not api_key:
209
+ return False, None
210
+
211
+ # Truncate to API limit
212
+ text = text[:2500]
213
+
214
+ try:
215
+ payload = {
216
+ "text": text,
217
+ "target_language_code": language,
218
+ "speaker": speaker,
219
+ "model": model,
220
+ "speech_sample_rate": 24000,
221
+ "pace": 1.0,
222
+ "temperature": 0.6,
223
+ }
224
+
225
+ async with httpx.AsyncClient(timeout=30.0) as client:
226
+ response = await client.post(
227
+ TTS_ENDPOINT,
228
+ headers={
229
+ "api-subscription-key": api_key,
230
+ "Content-Type": "application/json",
231
+ },
232
+ json=payload,
233
+ )
234
+
235
+ if response.status_code == 200:
236
+ data = response.json()
237
+ audios = data.get("audios", [])
238
+ if audios:
239
+ audio_bytes = base64.b64decode(audios[0])
240
+ return True, audio_bytes
241
+ return False, None
242
+ else:
243
+ console.print(
244
+ f"[dim]TTS warning: Sarvam returned {response.status_code}[/]"
245
+ )
246
+ return False, None
247
+
248
+ except Exception as e:
249
+ console.print(f"[dim]TTS fallback: {type(e).__name__}: {e}[/]")
250
+ return False, None
251
+
252
+
253
+ async def play_audio(audio_bytes: bytes) -> None:
254
+ """Play audio bytes through the default output device."""
255
+ try:
256
+ import sounddevice as sd
257
+ import soundfile as sf
258
+
259
+ buf = io.BytesIO(audio_bytes)
260
+ data, samplerate = sf.read(buf)
261
+ sd.play(data, samplerate)
262
+ sd.wait()
263
+ except ImportError:
264
+ # Save to temp file and play with system player as fallback
265
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
266
+ f.write(audio_bytes)
267
+ console.print(f"[dim]Audio saved to: {f.name}[/]")
268
+ except Exception as e:
269
+ console.print(f"[dim]Audio playback failed: {e}[/]")
bhai_cli/cli.py ADDED
@@ -0,0 +1,305 @@
1
+ """
2
+ BHAI-CLI — Typer CLI Application.
3
+
4
+ Commands:
5
+ bhai-code setup — Interactive configuration wizard
6
+ bhai-code text — Send a text prompt to BHAI
7
+ bhai-code listen — Voice input via Sarvam STT → BHAI pipeline
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ from typing import Optional
14
+
15
+ import typer
16
+ import pyfiglet
17
+ from rich.console import Console
18
+ from rich.panel import Panel
19
+ from rich.prompt import Prompt
20
+ from rich.table import Table
21
+ from rich.text import Text
22
+
23
+ from bhai_cli import __version__
24
+ from bhai_cli.config_manager import (
25
+ load_config,
26
+ save_config,
27
+ CONFIG_FILE,
28
+ )
29
+
30
+ console = Console()
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # CLI Setup
34
+ # ---------------------------------------------------------------------------
35
+
36
+ # "BHAI" in 6 Indian scripts
37
+ BHAI_LANGS = [
38
+ ("भाई", "Hindi"),
39
+ ("ভাই", "Bengali"),
40
+ ("ભાઈ", "Gujarati"),
41
+ ("ਭਾਈ", "Punjabi"),
42
+ ("భాయ్", "Telugu"),
43
+ ("ಭಾಯ್", "Kannada"),
44
+ ]
45
+
46
+
47
+ def print_banner() -> None:
48
+ """Print the BHAI-CLI ASCII banner with multilingual flair."""
49
+ ascii_banner = pyfiglet.figlet_format("BHAI - CLI", font="slant")
50
+ banner_text = Text(ascii_banner, style="bold bright_cyan")
51
+ console.print(banner_text)
52
+
53
+ # Show "BHAI" in multiple Indian scripts
54
+ lang_line = " · ".join(f"[bold]{word}[/][dim]({lang})[/]" for word, lang in BHAI_LANGS)
55
+ console.print(f" {lang_line}\n")
56
+
57
+ console.print(
58
+ "[bold yellow]🇮🇳 The Dual-Brain AI Coding Agent — 22 Indian Languages.[/]\n"
59
+ "[dim]Powered by litellm & Sarvam AI | v" + __version__ + "[/]\n"
60
+ )
61
+
62
+ app = typer.Typer(
63
+ name="bhai-code",
64
+ help="BHAI — Your 10x Senior SDE from India. 22 Languages. One CLI.",
65
+ add_completion=False,
66
+ rich_markup_mode="rich",
67
+ no_args_is_help=True,
68
+ )
69
+
70
+ def _version_callback(value: bool) -> None:
71
+ if value:
72
+ print_banner()
73
+ raise typer.Exit()
74
+
75
+ @app.callback(invoke_without_command=True)
76
+ def main(
77
+ ctx: typer.Context,
78
+ version: bool = typer.Option(
79
+ False, "--version", "-v",
80
+ help="Show version and exit.",
81
+ callback=_version_callback,
82
+ is_eager=True,
83
+ ),
84
+ ) -> None:
85
+ """[bold cyan]BHAI-CLI[/] — 10x Senior SDE Agent from India. 22 Languages."""
86
+ # Only print banner if no subcommand was invoked (which means help will be shown automatically via no_args_is_help)
87
+ if ctx.invoked_subcommand is None:
88
+ print_banner()
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # bhai-code setup
92
+ # ---------------------------------------------------------------------------
93
+
94
+ @app.command()
95
+ def setup() -> None:
96
+ """[bold yellow]⚙️ setup[/] — Interactive wizard for VibeLLM, CoderAgent, and Sarvam AI."""
97
+ print_banner()
98
+
99
+ console.print(
100
+ Panel(
101
+ "[bold white]Let's get your dual-brain engine running, veere.[/]\n"
102
+ "[dim]We need to configure your Persona (Brain A) and your Execution Engine (Brain B).[/]",
103
+ title="[bold yellow]Setup Wizard[/]",
104
+ border_style="yellow",
105
+ padding=(1, 2),
106
+ )
107
+ )
108
+
109
+ cfg = load_config()
110
+
111
+ console.print("\n[bold cyan]━━━ Brain A: VibeLLM (Persona & Intent) ━━━[/]")
112
+ console.print(
113
+ "[dim]Handles the BHAI persona and parses your intent.\n"
114
+ "Format: provider/model-name (e.g., sarvam/sarvam-105b, groq/llama-3.3-70b-versatile)[/]\n"
115
+ )
116
+ cfg.vibe.model = Prompt.ask("VibeLLM model", default=cfg.vibe.model)
117
+ cfg.vibe.api_key = Prompt.ask("VibeLLM API key", default=cfg.vibe.api_key or "", password=True)
118
+ cfg.vibe.api_base = Prompt.ask("VibeLLM API base URL [dim](optional)[/]", default=cfg.vibe.api_base or "")
119
+
120
+ console.print("\n[bold cyan]━━━ Brain B: CoderAgent (Tool Execution) ━━━[/]")
121
+ console.print(
122
+ "[dim]Executes coding tasks using MCP-style tools. Must support tool-calling.\n"
123
+ "Recommended: groq/llama-3.3-70b-versatile, openai/gpt-4o, anthropic/claude-sonnet-4-20250514[/]\n"
124
+ )
125
+ cfg.coder.model = Prompt.ask("CoderAgent model", default=cfg.coder.model)
126
+ cfg.coder.api_key = Prompt.ask("CoderAgent API key", default=cfg.coder.api_key or "", password=True)
127
+ cfg.coder.api_base = Prompt.ask("CoderAgent API base URL [dim](optional)[/]", default=cfg.coder.api_base or "")
128
+
129
+ console.print("\n[bold cyan]━━━ Sarvam AI (Voice Interface) ━━━[/]")
130
+ console.print("[dim]Optional: Enables voice input (STT) and output (TTS).[/]\n")
131
+ cfg.sarvam.api_key = Prompt.ask("Sarvam API key [dim](leave empty to skip)[/]", default=cfg.sarvam.api_key or "", password=True)
132
+
133
+ path = save_config(cfg)
134
+
135
+ console.print()
136
+ console.print(
137
+ Panel(
138
+ f"[bold green]✅ All set, boss! Config saved to:[/] {path}\n\n"
139
+ f"🧠 [bold]VibeLLM:[/] {cfg.vibe.model}\n"
140
+ f"🛠️ [bold]CoderAgent:[/] {cfg.coder.model}\n"
141
+ f"🎙️ [bold]Sarvam:[/] {'Active ✓' if cfg.sarvam.api_key else 'Skipped (Text-only)'}",
142
+ title="[bold green]Setup Complete[/]",
143
+ border_style="green",
144
+ padding=(1, 2),
145
+ )
146
+ )
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # bhai-code text
150
+ # ---------------------------------------------------------------------------
151
+
152
+ @app.command()
153
+ def text(
154
+ prompt: str = typer.Argument(..., help="Your message to BHAI."),
155
+ vibe_model: Optional[str] = typer.Option(None, "--vibe-model", "-vm", help="Override VibeLLM model."),
156
+ coder_model: Optional[str] = typer.Option(None, "--coder-model", "-cm", help="Override CoderAgent model."),
157
+ interactive: bool = typer.Option(False, "--interactive", "-i", help="Enter interactive chat mode."),
158
+ ) -> None:
159
+ """[bold magenta]💬 text[/] — Send a text prompt to BHAI."""
160
+ print_banner()
161
+ from bhai_cli.orchestrator import BhaiOrchestrator
162
+
163
+ cfg = load_config(vibe_model_override=vibe_model, coder_model_override=coder_model)
164
+ orchestrator = BhaiOrchestrator(cfg)
165
+
166
+ asyncio.run(orchestrator.process(prompt))
167
+
168
+ if interactive:
169
+ console.print("\n[bold cyan]Interactive mode — type 'exit' or 'quit' to leave.[/]\n")
170
+ while True:
171
+ try:
172
+ user_input = Prompt.ask("[bold green]You[/]")
173
+ if user_input.strip().lower() in ("exit", "quit", "bye", "chal hatja", "nikal"):
174
+ console.print("\n[bold yellow]BHAI:[/] Accha bhai, phir milenge! 🤙")
175
+ break
176
+ asyncio.run(orchestrator.process(user_input))
177
+ except (KeyboardInterrupt, EOFError):
178
+ console.print("\n[bold yellow]BHAI:[/] Theek hai boss, baad mein baat karte hain! 👋")
179
+ break
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # bhai-code listen
183
+ # ---------------------------------------------------------------------------
184
+
185
+ @app.command()
186
+ def listen(
187
+ vibe_model: Optional[str] = typer.Option(None, "--vibe-model", "-vm", help="Override VibeLLM model."),
188
+ coder_model: Optional[str] = typer.Option(None, "--coder-model", "-cm", help="Override CoderAgent model."),
189
+ file: Optional[str] = typer.Option(None, "--file", "-f", help="Path to an audio file instead of live mic."),
190
+ ) -> None:
191
+ """[bold red]🎙️ listen[/] — Voice input — speak to BHAI in any Indian language."""
192
+ print_banner()
193
+ from bhai_cli.orchestrator import BhaiOrchestrator
194
+ from bhai_cli.audio_engine import (
195
+ AudioRecorder, load_audio_file, speech_to_text, text_to_speech, play_audio,
196
+ )
197
+
198
+ cfg = load_config(vibe_model_override=vibe_model, coder_model_override=coder_model)
199
+
200
+ if not cfg.sarvam.api_key:
201
+ console.print(
202
+ Panel(
203
+ "[bold red]⚠ Sarvam API key not configured.[/]\n"
204
+ "Run [cyan]bhai-code setup[/] to add your Sarvam key,\n"
205
+ "or use [cyan]bhai-code text[/] for text-only mode.",
206
+ title="[bold red]Error[/]", border_style="red"
207
+ )
208
+ )
209
+ raise typer.Exit(1)
210
+
211
+ orchestrator = BhaiOrchestrator(cfg)
212
+ audio_bytes = None
213
+ audio_file = file or cfg.audio_fallback_file
214
+
215
+ if audio_file:
216
+ console.print(f"[dim]Loading audio from: {audio_file}[/]")
217
+ audio_bytes = load_audio_file(audio_file)
218
+ if audio_bytes is None:
219
+ raise typer.Exit(1)
220
+ else:
221
+ recorder = AudioRecorder()
222
+ console.print(
223
+ Panel(
224
+ "[bold white]Press ENTER to start recording, then ENTER again to stop.[/]",
225
+ title="[bold magenta]🎙️ Live Recording[/]",
226
+ border_style="magenta", padding=(1, 2),
227
+ )
228
+ )
229
+ input()
230
+
231
+ if not recorder.start():
232
+ console.print("[bold red]Failed to start recording. Falling back to text mode.[/]")
233
+ raise typer.Exit(1)
234
+
235
+ console.print("[bold red blink]🔴 Recording... Press ENTER to stop.[/]")
236
+ input()
237
+
238
+ audio_bytes = recorder.stop()
239
+ if audio_bytes is None:
240
+ console.print("[bold red]No audio captured.[/]")
241
+ raise typer.Exit(1)
242
+ console.print("[bold green]✅ Audio captured successfully.[/]")
243
+
244
+ with console.status("[bold cyan]Transcribing with Sarvam Saaras v3... 🎧[/]"):
245
+ success, transcript = asyncio.run(
246
+ speech_to_text(audio_bytes, api_key=cfg.sarvam.api_key, model=cfg.sarvam.stt_model, mode=cfg.sarvam.stt_mode)
247
+ )
248
+
249
+ if not success:
250
+ console.print(f"[bold red]STT failed:[/] {transcript}")
251
+ console.print("[dim]Falling back to text mode. Use bhai-code text instead.[/]")
252
+ raise typer.Exit(1)
253
+
254
+ console.print(f"[bold green]📝 You said:[/] {transcript}\n")
255
+ result = asyncio.run(orchestrator.process(transcript))
256
+
257
+ async def _speak(text: str) -> None:
258
+ ok, audio = await text_to_speech(
259
+ text, api_key=cfg.sarvam.api_key, model=cfg.sarvam.tts_model,
260
+ speaker=cfg.sarvam.tts_speaker, language=cfg.sarvam.tts_language,
261
+ )
262
+ if ok and audio:
263
+ with console.status("[bold magenta]Speaking... 🔊[/]"):
264
+ await play_audio(audio)
265
+
266
+ try:
267
+ asyncio.run(_speak(result[:2500]))
268
+ except Exception:
269
+ pass
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # bhai-code config
273
+ # ---------------------------------------------------------------------------
274
+
275
+ @app.command()
276
+ def config() -> None:
277
+ """[bold cyan]📋 config[/] — Show current configuration."""
278
+ print_banner()
279
+ cfg = load_config()
280
+
281
+ table = Table(title="BHAI Configuration Engine", show_header=True, header_style="bold cyan", border_style="dim", expand=True)
282
+ table.add_column("Key", style="bold white")
283
+ table.add_column("Value", style="cyan")
284
+
285
+ table.add_row("Config File", str(CONFIG_FILE))
286
+ table.add_section()
287
+ table.add_row("[yellow]VibeLLM Model[/]", cfg.vibe.model)
288
+ table.add_row("VibeLLM API Key", "••••" + cfg.vibe.api_key[-4:] if len(cfg.vibe.api_key) > 4 else "[red](not set)[/]")
289
+ table.add_row("VibeLLM API Base", cfg.vibe.api_base or "[dim](default)[/]")
290
+ table.add_section()
291
+ table.add_row("[yellow]CoderAgent Model[/]", cfg.coder.model)
292
+ table.add_row("CoderAgent API Key", "••••" + cfg.coder.api_key[-4:] if len(cfg.coder.api_key) > 4 else "[red](not set)[/]")
293
+ table.add_row("CoderAgent API Base", cfg.coder.api_base or "[dim](default)[/]")
294
+ table.add_section()
295
+ table.add_row("[yellow]Sarvam API Key[/]", "••••" + cfg.sarvam.api_key[-4:] if len(cfg.sarvam.api_key) > 4 else "[red](not set)[/]")
296
+ table.add_row("STT Model", cfg.sarvam.stt_model)
297
+ table.add_row("TTS Model", cfg.sarvam.tts_model)
298
+ table.add_section()
299
+ table.add_row("Command Timeout", f"{cfg.command_timeout}s")
300
+
301
+ console.print(table)
302
+ console.print()
303
+
304
+ if __name__ == "__main__":
305
+ app()