sqlsaber 0.6.0__py3-none-any.whl → 0.7.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.

Potentially problematic release.


This version of sqlsaber might be problematic. Click here for more details.

@@ -0,0 +1,172 @@
1
+ """Command line completers for the CLI interface."""
2
+
3
+ from typing import List, Tuple
4
+
5
+ from prompt_toolkit.completion import Completer, Completion
6
+
7
+
8
+ class SlashCommandCompleter(Completer):
9
+ """Custom completer for slash commands."""
10
+
11
+ def get_completions(self, document, complete_event):
12
+ """Get completions for slash commands."""
13
+ # Only provide completions if the line starts with "/"
14
+ text = document.text
15
+ if text.startswith("/"):
16
+ # Get the partial command after the slash
17
+ partial_cmd = text[1:]
18
+
19
+ # Define available commands with descriptions
20
+ commands = [
21
+ ("clear", "Clear conversation history"),
22
+ ("exit", "Exit the interactive session"),
23
+ ("quit", "Exit the interactive session"),
24
+ ]
25
+
26
+ # Yield completions that match the partial command
27
+ for cmd, description in commands:
28
+ if cmd.startswith(partial_cmd):
29
+ yield Completion(
30
+ cmd,
31
+ start_position=-len(partial_cmd),
32
+ display_meta=description,
33
+ )
34
+
35
+
36
+ class TableNameCompleter(Completer):
37
+ """Custom completer for table names."""
38
+
39
+ def __init__(self):
40
+ self._table_cache: List[Tuple[str, str]] = []
41
+
42
+ def update_cache(self, tables_data: List[Tuple[str, str]]):
43
+ """Update the cache with fresh table data."""
44
+ self._table_cache = tables_data
45
+
46
+ def _get_table_names(self) -> List[Tuple[str, str]]:
47
+ """Get table names from cache."""
48
+ return self._table_cache
49
+
50
+ def get_completions(self, document, complete_event):
51
+ """Get completions for table names with fuzzy matching."""
52
+ text = document.text
53
+ cursor_position = document.cursor_position
54
+
55
+ # Find the last "@" before the cursor position
56
+ at_pos = text.rfind("@", 0, cursor_position)
57
+
58
+ if at_pos >= 0:
59
+ # Extract text after the "@" up to the cursor
60
+ partial_table = text[at_pos + 1 : cursor_position].lower()
61
+
62
+ # Check if this looks like a valid table reference context
63
+ # (not inside quotes, and followed by word characters or end of input)
64
+ if self._is_valid_table_context(text, at_pos, cursor_position):
65
+ # Get table names
66
+ tables = self._get_table_names()
67
+
68
+ # Collect matches with scores for ranking
69
+ matches = []
70
+
71
+ for table_name, description in tables:
72
+ table_lower = table_name.lower()
73
+ score = self._calculate_match_score(
74
+ partial_table, table_name, table_lower
75
+ )
76
+
77
+ if score > 0:
78
+ matches.append((score, table_name, description))
79
+
80
+ # Sort by score (higher is better) and yield completions
81
+ matches.sort(key=lambda x: x[0], reverse=True)
82
+
83
+ for score, table_name, description in matches:
84
+ yield Completion(
85
+ table_name,
86
+ start_position=at_pos
87
+ + 1
88
+ - cursor_position, # Start from after the @
89
+ display_meta=description if description else None,
90
+ )
91
+
92
+ def _is_valid_table_context(self, text: str, at_pos: int, cursor_pos: int) -> bool:
93
+ """Check if the @ is in a valid context for table completion."""
94
+ # Simple heuristic: avoid completion inside quoted strings
95
+
96
+ # Count quotes before the @ position
97
+ single_quotes = text[:at_pos].count("'") - text[:at_pos].count("\\'")
98
+ double_quotes = text[:at_pos].count('"') - text[:at_pos].count('\\"')
99
+
100
+ # If we're inside quotes, don't complete
101
+ if single_quotes % 2 == 1 or double_quotes % 2 == 1:
102
+ return False
103
+
104
+ # Check if the character after the cursor (if any) is part of a word
105
+ # This helps avoid breaking existing words
106
+ if cursor_pos < len(text):
107
+ next_char = text[cursor_pos]
108
+ if next_char.isalnum() or next_char == "_":
109
+ # We're in the middle of a word, check if it looks like a table name
110
+ partial = (
111
+ text[at_pos + 1 :].split()[0] if text[at_pos + 1 :].split() else ""
112
+ )
113
+ if not any(c in partial for c in [".", "_"]):
114
+ return False
115
+
116
+ return True
117
+
118
+ def _calculate_match_score(
119
+ self, partial: str, table_name: str, table_lower: str
120
+ ) -> int:
121
+ """Calculate match score for fuzzy matching (higher is better)."""
122
+ if not partial:
123
+ return 1 # Empty search matches everything with low score
124
+
125
+ # Score 100: Exact full name prefix match
126
+ if table_lower.startswith(partial):
127
+ return 100
128
+
129
+ # Score 90: Table name (after schema) prefix match
130
+ if "." in table_name:
131
+ table_part = table_name.split(".")[-1].lower()
132
+ if table_part.startswith(partial):
133
+ return 90
134
+
135
+ # Score 80: Exact table name match (for short names)
136
+ if "." in table_name:
137
+ table_part = table_name.split(".")[-1].lower()
138
+ if table_part == partial:
139
+ return 80
140
+
141
+ # Score 70: Word boundary matches (e.g., "user" matches "user_accounts")
142
+ if "." in table_name:
143
+ table_part = table_name.split(".")[-1].lower()
144
+ if table_part.startswith(partial + "_") or table_part.startswith(
145
+ partial + "-"
146
+ ):
147
+ return 70
148
+
149
+ # Score 50: Substring match in table name part
150
+ if "." in table_name:
151
+ table_part = table_name.split(".")[-1].lower()
152
+ if partial in table_part:
153
+ return 50
154
+
155
+ # Score 30: Substring match in full name
156
+ if partial in table_lower:
157
+ return 30
158
+
159
+ # Score 0: No match
160
+ return 0
161
+
162
+
163
+ class CompositeCompleter(Completer):
164
+ """Combines multiple completers."""
165
+
166
+ def __init__(self, *completers: Completer):
167
+ self.completers = completers
168
+
169
+ def get_completions(self, document, complete_event):
170
+ """Get completions from all registered completers."""
171
+ for completer in self.completers:
172
+ yield from completer.get_completions(document, complete_event)
sqlsaber/cli/display.py CHANGED
@@ -4,6 +4,7 @@ import json
4
4
  from typing import Optional
5
5
 
6
6
  from rich.console import Console
7
+ from rich.markdown import Markdown
7
8
  from rich.syntax import Syntax
8
9
  from rich.table import Table
9
10
 
@@ -243,3 +244,24 @@ class DisplayManager:
243
244
  self.show_error("Failed to parse plot result")
244
245
  except Exception as e:
245
246
  self.show_error(f"Error displaying plot: {str(e)}")
247
+
248
+ def show_markdown_response(self, content: list):
249
+ """Display the assistant's response as rich markdown."""
250
+ if not content:
251
+ return
252
+
253
+ # Extract text from content blocks
254
+ text_parts = []
255
+ for block in content:
256
+ if isinstance(block, dict) and block.get("type") == "text":
257
+ text = block.get("text", "")
258
+ if text:
259
+ text_parts.append(text)
260
+
261
+ # Join all text parts and display as markdown
262
+ full_text = "".join(text_parts).strip()
263
+ if full_text:
264
+ self.console.print() # Add spacing before markdown
265
+ markdown = Markdown(full_text)
266
+ self.console.print(markdown)
267
+ self.console.print() # Add spacing after markdown
@@ -4,43 +4,19 @@ import asyncio
4
4
  from typing import Optional
5
5
 
6
6
  import questionary
7
- from prompt_toolkit.completion import Completer, Completion
8
7
  from rich.console import Console
9
8
  from rich.panel import Panel
10
9
 
11
10
  from sqlsaber.agents.base import BaseSQLAgent
11
+ from sqlsaber.cli.completers import (
12
+ CompositeCompleter,
13
+ SlashCommandCompleter,
14
+ TableNameCompleter,
15
+ )
12
16
  from sqlsaber.cli.display import DisplayManager
13
17
  from sqlsaber.cli.streaming import StreamingQueryHandler
14
18
 
15
19
 
16
- class SlashCommandCompleter(Completer):
17
- """Custom completer for slash commands."""
18
-
19
- def get_completions(self, document, complete_event):
20
- """Get completions for slash commands."""
21
- # Only provide completions if the line starts with "/"
22
- text = document.text
23
- if text.startswith("/"):
24
- # Get the partial command after the slash
25
- partial_cmd = text[1:]
26
-
27
- # Define available commands with descriptions
28
- commands = [
29
- ("clear", "Clear conversation history"),
30
- ("exit", "Exit the interactive session"),
31
- ("quit", "Exit the interactive session"),
32
- ]
33
-
34
- # Yield completions that match the partial command
35
- for cmd, description in commands:
36
- if cmd.startswith(partial_cmd):
37
- yield Completion(
38
- cmd,
39
- start_position=-len(partial_cmd),
40
- display_meta=description,
41
- )
42
-
43
-
44
20
  class InteractiveSession:
45
21
  """Manages interactive CLI sessions."""
46
22
 
@@ -51,6 +27,7 @@ class InteractiveSession:
51
27
  self.streaming_handler = StreamingQueryHandler(console)
52
28
  self.current_task: Optional[asyncio.Task] = None
53
29
  self.cancellation_token: Optional[asyncio.Event] = None
30
+ self.table_completer = TableNameCompleter()
54
31
 
55
32
  def show_welcome_message(self):
56
33
  """Display welcome message for interactive mode."""
@@ -63,7 +40,8 @@ class InteractiveSession:
63
40
  "[bold green]SQLSaber - Use the agent Luke![/bold green]\n\n"
64
41
  "[bold]Your agentic SQL assistant.[/bold]\n\n\n"
65
42
  "[dim]Use '/clear' to reset conversation, '/exit' or '/quit' to leave.[/dim]\n\n"
66
- "[dim]Start a message with '#' to add something to agent's memory for this database.[/dim]",
43
+ "[dim]Start a message with '#' to add something to agent's memory for this database.[/dim]\n\n"
44
+ "[dim]Type '@' to get table name completions.[/dim]",
67
45
  border_style="green",
68
46
  )
69
47
  )
@@ -75,6 +53,39 @@ class InteractiveSession:
75
53
  "[dim]Press Ctrl+C during query execution to interrupt and return to prompt.[/dim]\n"
76
54
  )
77
55
 
56
+ async def _update_table_cache(self):
57
+ """Update the table completer cache with fresh data."""
58
+ try:
59
+ # Use the schema manager directly which has built-in caching
60
+ tables_data = await self.agent.schema_manager.list_tables()
61
+
62
+ # Parse the table information
63
+ table_list = []
64
+ if isinstance(tables_data, dict) and "tables" in tables_data:
65
+ for table in tables_data["tables"]:
66
+ if isinstance(table, dict):
67
+ name = table.get("name", "")
68
+ schema = table.get("schema", "")
69
+ full_name = table.get("full_name", "")
70
+
71
+ # Use full_name if available, otherwise construct it
72
+ if full_name:
73
+ table_name = full_name
74
+ elif schema and schema != "main":
75
+ table_name = f"{schema}.{name}"
76
+ else:
77
+ table_name = name
78
+
79
+ # No description needed - cleaner completions
80
+ table_list.append((table_name, ""))
81
+
82
+ # Update the completer cache
83
+ self.table_completer.update_cache(table_list)
84
+
85
+ except Exception:
86
+ # If there's an error, just use empty cache
87
+ self.table_completer.update_cache([])
88
+
78
89
  async def _execute_query_with_cancellation(self, user_query: str):
79
90
  """Execute a query with cancellation support."""
80
91
  # Create cancellation token
@@ -101,6 +112,9 @@ class InteractiveSession:
101
112
  """Run the interactive session loop."""
102
113
  self.show_welcome_message()
103
114
 
115
+ # Initialize table cache
116
+ await self._update_table_cache()
117
+
104
118
  while True:
105
119
  try:
106
120
  user_query = await questionary.text(
@@ -108,7 +122,9 @@ class InteractiveSession:
108
122
  qmark="",
109
123
  multiline=True,
110
124
  instruction="",
111
- completer=SlashCommandCompleter(),
125
+ completer=CompositeCompleter(
126
+ SlashCommandCompleter(), self.table_completer
127
+ ),
112
128
  ).ask_async()
113
129
 
114
130
  if not user_query:
sqlsaber/cli/streaming.py CHANGED
@@ -110,6 +110,14 @@ class StreamingQueryHandler:
110
110
  if explanation_started:
111
111
  self.display.show_newline() # Empty line for better readability
112
112
 
113
+ # Display the last assistant response as markdown
114
+ if hasattr(agent, "conversation_history") and agent.conversation_history:
115
+ last_message = agent.conversation_history[-1]
116
+ if last_message.get("role") == "assistant" and last_message.get(
117
+ "content"
118
+ ):
119
+ self.display.show_markdown_response(last_message["content"])
120
+
113
121
  def _stop_status(self, status):
114
122
  """Safely stop a status spinner."""
115
123
  try:
@@ -683,6 +683,13 @@ class SchemaManager:
683
683
 
684
684
  async def list_tables(self) -> Dict[str, Any]:
685
685
  """Get a list of all tables with basic information like row counts."""
686
+ # Check cache first
687
+ cache_key = "list_tables"
688
+ cached_data = self._get_cached_tables(cache_key)
689
+ if cached_data is not None:
690
+ return cached_data
691
+
692
+ # Fetch from database if not cached
686
693
  tables = await self.introspector.list_tables_info(self.db)
687
694
 
688
695
  # Format the result
@@ -699,4 +706,14 @@ class SchemaManager:
699
706
  }
700
707
  )
701
708
 
709
+ # Cache the result
710
+ self._schema_cache[cache_key] = (time.time(), result)
702
711
  return result
712
+
713
+ def _get_cached_tables(self, cache_key: str) -> Optional[Dict[str, Any]]:
714
+ """Get table list from cache if available and not expired."""
715
+ if cache_key in self._schema_cache:
716
+ cached_time, cached_data = self._schema_cache[cache_key]
717
+ if time.time() - cached_time < self.cache_ttl:
718
+ return cached_data
719
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlsaber
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: SQLSaber - Agentic SQL assistant like Claude Code
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -212,23 +212,24 @@ The MCP server uses your existing SQLSaber database configurations, so make sure
212
212
 
213
213
  ## How It Works
214
214
 
215
- SQLSaber uses an intelligent three-step process optimized for minimal token usage:
215
+ SQLSaber uses a multi-step process to gather the right context, provide it to the model, and execute SQL queries to get the right answers:
216
+
217
+ ![](./sqlsaber.svg)
216
218
 
217
219
  ### 🔍 Discovery Phase
218
220
 
219
221
  1. **List Tables Tool**: Quickly discovers available tables with row counts
220
- 2. **Pattern Matching**: Identifies relevant tables based on your query using SQL LIKE patterns
222
+ 2. **Pattern Matching**: Identifies relevant tables based on your query
221
223
 
222
224
  ### 📋 Schema Analysis
223
225
 
224
- 3. **Smart Introspection**: Analyzes only the specific table structures needed for your query
225
- 4. **Selective Loading**: Fetches schema information only for relevant tables
226
+ 3. **Smart Schema Introspection**: Analyzes only the specific table structures needed for your query
226
227
 
227
228
  ### ⚡ Execution Phase
228
229
 
229
- 5. **SQL Generation**: Creates optimized SQL queries based on natural language input
230
- 6. **Safe Execution**: Runs queries with built-in protections against destructive operations
231
- 7. **Result Formatting**: Presents results with syntax highlighting and explanations
230
+ 4. **SQL Generation**: Creates optimized SQL queries based on natural language input
231
+ 5. **Safe Execution**: Runs read-only queries with built-in protections against destructive operations
232
+ 6. **Result Formatting**: Presents results with explanations in tables and optionally, visualizes using plots
232
233
 
233
234
  ## Contributing
234
235
 
@@ -7,19 +7,20 @@ sqlsaber/agents/mcp.py,sha256=FKtXgDrPZ2-xqUYCw2baI5JzrWekXaC5fjkYW1_Mg50,827
7
7
  sqlsaber/agents/streaming.py,sha256=_EO390-FHUrL1fRCNfibtE9QuJz3LGQygbwG3CB2ViY,533
8
8
  sqlsaber/cli/__init__.py,sha256=qVSLVJLLJYzoC6aj6y9MFrzZvAwc4_OgxU9DlkQnZ4M,86
9
9
  sqlsaber/cli/commands.py,sha256=Dw24W0jij-8t1lpk99C4PBTgzFSag6vU-FZcjAYGG54,5074
10
+ sqlsaber/cli/completers.py,sha256=JWOCKAm0Prpy_O2QJsf_VbPWfy2lQQh6KutyG8FU4us,6462
10
11
  sqlsaber/cli/database.py,sha256=DUfyvNBDp47oFM_VAC_hXHQy_qyE7JbXtowflJpwwH8,12643
11
- sqlsaber/cli/display.py,sha256=lZW7BI2LusU5lJhov7u9kKWwfsqcXGSfjrw-kNO3FQA,9200
12
- sqlsaber/cli/interactive.py,sha256=RNEWyCM1gLLUsXaXaCFzXu0PFDVuAK8NBOOYYFTHTUE,6716
12
+ sqlsaber/cli/display.py,sha256=NIBWHUrX_8ZhDu6iW9v4fzx0zncnXa5WdQ9wfTrjKIM,10017
13
+ sqlsaber/cli/interactive.py,sha256=FvgtT45U-yblhbRImKqJ4jgBRNs0u7NhE2PcgoVUaVA,7429
13
14
  sqlsaber/cli/memory.py,sha256=LW4ZF2V6Gw6hviUFGZ4ym9ostFCwucgBTIMZ3EANO-I,7671
14
15
  sqlsaber/cli/models.py,sha256=3IcXeeU15IQvemSv-V-RQzVytJ3wuQ4YmWk89nTDcSE,7813
15
- sqlsaber/cli/streaming.py,sha256=2vLCYqqziQTO52erfgvnEk_hM3BoDM1TMBAXgT7KKfo,4548
16
+ sqlsaber/cli/streaming.py,sha256=DfwygmjEzAh9hZGKjrW9kS1A7MG5W9Ky_kCTzxziODQ,4970
16
17
  sqlsaber/config/__init__.py,sha256=olwC45k8Nc61yK0WmPUk7XHdbsZH9HuUAbwnmKe3IgA,100
17
18
  sqlsaber/config/api_keys.py,sha256=kLdoExF_My9ojmdhO5Ca7-ZeowsO0v1GVa_QT5jjUPo,3658
18
19
  sqlsaber/config/database.py,sha256=vKFOxPjVakjQhj1uoLcfzhS9ZFr6Z2F5b4MmYALQZoA,11421
19
20
  sqlsaber/config/settings.py,sha256=zjQ7nS3ybcCb88Ea0tmwJox5-q0ettChZw89ZqRVpX8,3975
20
21
  sqlsaber/database/__init__.py,sha256=a_gtKRJnZVO8-fEZI7g3Z8YnGa6Nio-5Y50PgVp07ss,176
21
22
  sqlsaber/database/connection.py,sha256=s8GSFZebB8be8sVUr-N0x88-20YfkfljJFRyfoB1gH0,15154
22
- sqlsaber/database/schema.py,sha256=9QoH-gADzWlepq-tGz3nPU3miSUU0koWmpDaoWvz8Q0,27951
23
+ sqlsaber/database/schema.py,sha256=3CfkyhxgD6SmiUoz7MQPlQLrrA007HOQLnGCvvsdJx0,28647
23
24
  sqlsaber/mcp/__init__.py,sha256=COdWq7wauPBp5Ew8tfZItFzbcLDSEkHBJSMhxzy8C9c,112
24
25
  sqlsaber/mcp/mcp.py,sha256=ACm1P1TnicjOptQgeLNhXg5xgZf4MYq2kqdfVdj6wh0,4477
25
26
  sqlsaber/memory/__init__.py,sha256=GiWkU6f6YYVV0EvvXDmFWe_CxarmDCql05t70MkTEWs,63
@@ -28,8 +29,8 @@ sqlsaber/memory/storage.py,sha256=DvZBsSPaAfk_DqrNEn86uMD-TQsWUI6rQLfNw6PSCB8,57
28
29
  sqlsaber/models/__init__.py,sha256=RJ7p3WtuSwwpFQ1Iw4_DHV2zzCtHqIzsjJzxv8kUjUE,287
29
30
  sqlsaber/models/events.py,sha256=q2FackB60J9-7vegYIjzElLwKebIh7nxnV5AFoZc67c,752
30
31
  sqlsaber/models/types.py,sha256=3U_30n91EB3IglBTHipwiW4MqmmaA2qfshfraMZyPps,896
31
- sqlsaber-0.6.0.dist-info/METADATA,sha256=Mvou6xXxA8T2Cfwq4y1bd0DYW46zBs4Qw7oXWNfihfE,5969
32
- sqlsaber-0.6.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
33
- sqlsaber-0.6.0.dist-info/entry_points.txt,sha256=jmFo96Ylm0zIKXJBwhv_P5wQ7SXP9qdaBcnTp8iCEe8,195
34
- sqlsaber-0.6.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
35
- sqlsaber-0.6.0.dist-info/RECORD,,
32
+ sqlsaber-0.7.0.dist-info/METADATA,sha256=tUV3WHkVZEXissVrKAaOooaZyn7e_NmMV_e-nNaoLVE,5986
33
+ sqlsaber-0.7.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
34
+ sqlsaber-0.7.0.dist-info/entry_points.txt,sha256=jmFo96Ylm0zIKXJBwhv_P5wQ7SXP9qdaBcnTp8iCEe8,195
35
+ sqlsaber-0.7.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
36
+ sqlsaber-0.7.0.dist-info/RECORD,,