sqlsaber 0.35.0__py3-none-any.whl → 0.36.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.

@@ -1,7 +1,7 @@
1
1
  """Shared database setup logic for onboarding and CLI."""
2
2
 
3
3
  import getpass
4
- from dataclasses import dataclass
4
+ from dataclasses import dataclass, field
5
5
  from pathlib import Path
6
6
 
7
7
  from sqlsaber.application.prompts import Prompter
@@ -11,6 +11,21 @@ from sqlsaber.theme.manager import create_console
11
11
  console = create_console()
12
12
 
13
13
 
14
+ def _normalize_schemas(schemas: list[str]) -> list[str]:
15
+ """Deduplicate schema list while preserving order and case."""
16
+ normalized: list[str] = []
17
+ seen: set[str] = set()
18
+ for schema in schemas:
19
+ name = schema.strip()
20
+ if not name:
21
+ continue
22
+ if name in seen:
23
+ continue
24
+ seen.add(name)
25
+ normalized.append(name)
26
+ return normalized
27
+
28
+
14
29
  @dataclass
15
30
  class DatabaseInput:
16
31
  """Input data for database configuration."""
@@ -26,6 +41,7 @@ class DatabaseInput:
26
41
  ssl_ca: str | None = None
27
42
  ssl_cert: str | None = None
28
43
  ssl_key: str | None = None
44
+ exclude_schemas: list[str] = field(default_factory=list)
29
45
 
30
46
 
31
47
  async def collect_db_input(
@@ -69,11 +85,20 @@ async def collect_db_input(
69
85
  port = 0
70
86
  username = db_type
71
87
  password = ""
88
+ exclude_schemas: list[str] = []
72
89
  ssl_mode = None
73
90
  ssl_ca = None
74
91
  ssl_cert = None
75
92
  ssl_key = None
76
93
 
94
+ if db_type == "duckdb":
95
+ exclude_prompt = await prompter.text(
96
+ "Schemas to exclude (comma separated, optional):", default=""
97
+ )
98
+ if exclude_prompt is None:
99
+ return None
100
+ exclude_schemas = _normalize_schemas(exclude_prompt.split(","))
101
+
77
102
  else:
78
103
  # PostgreSQL/MySQL need connection details
79
104
  host = await prompter.text("Host:", default="localhost")
@@ -155,6 +180,13 @@ async def collect_db_input(
155
180
  "SSL client private key file:"
156
181
  )
157
182
 
183
+ exclude_prompt = await prompter.text(
184
+ "Schemas to exclude (comma separated, optional):", default=""
185
+ )
186
+ if exclude_prompt is None:
187
+ return None
188
+ exclude_schemas = _normalize_schemas(exclude_prompt.split(","))
189
+
158
190
  return DatabaseInput(
159
191
  name=name,
160
192
  type=db_type,
@@ -167,6 +199,7 @@ async def collect_db_input(
167
199
  ssl_ca=ssl_ca,
168
200
  ssl_cert=ssl_cert,
169
201
  ssl_key=ssl_key,
202
+ exclude_schemas=exclude_schemas,
170
203
  )
171
204
 
172
205
 
@@ -183,6 +216,7 @@ def build_config(db_input: DatabaseInput) -> DatabaseConfig:
183
216
  ssl_ca=db_input.ssl_ca,
184
217
  ssl_cert=db_input.ssl_cert,
185
218
  ssl_key=db_input.ssl_key,
219
+ exclude_schemas=_normalize_schemas(db_input.exclude_schemas),
186
220
  )
187
221
 
188
222
 
@@ -200,7 +234,9 @@ async def test_connection(config: DatabaseConfig, password: str | None) -> bool:
200
234
 
201
235
  try:
202
236
  connection_string = config.to_connection_string()
203
- db_conn = DatabaseConnection(connection_string)
237
+ db_conn = DatabaseConnection(
238
+ connection_string, excluded_schemas=config.exclude_schemas
239
+ )
204
240
  await db_conn.execute_query("SELECT 1 as test")
205
241
  await db_conn.close()
206
242
  return True
sqlsaber/cli/commands.py CHANGED
@@ -214,7 +214,9 @@ def query(
214
214
 
215
215
  # Create database connection
216
216
  try:
217
- db_conn = DatabaseConnection(connection_string)
217
+ db_conn = DatabaseConnection(
218
+ connection_string, excluded_schemas=resolved.excluded_schemas
219
+ )
218
220
  log.info("db.connection.created", db_type=type(db_conn).__name__)
219
221
  except Exception as e:
220
222
  log.exception("db.connection.error", error=str(e))
@@ -229,8 +231,10 @@ def query(
229
231
  # Single query mode with streaming
230
232
  streaming_handler = StreamingQueryHandler(console)
231
233
  db_type = sqlsaber_agent.db_type
234
+ model_name = sqlsaber_agent.agent.model.model_name
232
235
  console.print(
233
236
  f"[primary]Connected to:[/primary] {db_name} ({db_type})\n"
237
+ f"[primary]Model:[/primary] {model_name}\n"
234
238
  )
235
239
  log.info("query.execute.start", db_name=db_name, db_type=db_type)
236
240
  run = await streaming_handler.execute_streaming_query(
sqlsaber/cli/database.py CHANGED
@@ -26,6 +26,28 @@ db_app = cyclopts.App(
26
26
  )
27
27
 
28
28
 
29
+ def _normalize_schema_list(raw_schemas: list[str]) -> list[str]:
30
+ """Deduplicate schemas while preserving order and case."""
31
+ schemas: list[str] = []
32
+ seen: set[str] = set()
33
+ for schema in raw_schemas:
34
+ item = schema.strip()
35
+ if not item:
36
+ continue
37
+ if item in seen:
38
+ continue
39
+ seen.add(item)
40
+ schemas.append(item)
41
+ return schemas
42
+
43
+
44
+ def _parse_schema_list(raw: str | None) -> list[str]:
45
+ """Parse comma-separated schema list into cleaned list."""
46
+ if not raw:
47
+ return []
48
+ return _normalize_schema_list(raw.split(","))
49
+
50
+
29
51
  @db_app.command
30
52
  def add(
31
53
  name: Annotated[str, cyclopts.Parameter(help="Name for the database connection")],
@@ -71,6 +93,13 @@ def add(
71
93
  str | None,
72
94
  cyclopts.Parameter(["--ssl-key"], help="SSL client private key file path"),
73
95
  ] = None,
96
+ exclude_schemas: Annotated[
97
+ str | None,
98
+ cyclopts.Parameter(
99
+ ["--exclude-schemas"],
100
+ help="Comma-separated list of schemas to exclude from introspection",
101
+ ),
102
+ ] = None,
74
103
  interactive: Annotated[
75
104
  bool,
76
105
  cyclopts.Parameter(
@@ -119,6 +148,7 @@ def add(
119
148
  ssl_ca = db_input.ssl_ca
120
149
  ssl_cert = db_input.ssl_cert
121
150
  ssl_key = db_input.ssl_key
151
+ exclude_schema_list = _normalize_schema_list(db_input.exclude_schemas)
122
152
  else:
123
153
  # Non-interactive mode - use provided values or defaults
124
154
  if type == "sqlite":
@@ -160,6 +190,7 @@ def add(
160
190
  if questionary.confirm("Enter password?").ask()
161
191
  else ""
162
192
  )
193
+ exclude_schema_list = _parse_schema_list(exclude_schemas)
163
194
 
164
195
  # Create database config
165
196
  # At this point, all required values should be set
@@ -180,6 +211,7 @@ def add(
180
211
  ssl_ca=ssl_ca,
181
212
  ssl_cert=ssl_cert,
182
213
  ssl_key=ssl_key,
214
+ exclude_schemas=exclude_schema_list,
183
215
  )
184
216
 
185
217
  try:
@@ -219,6 +251,7 @@ def list():
219
251
  table.add_column("Port", style="warning")
220
252
  table.add_column("Database", style="info")
221
253
  table.add_column("Username", style="info")
254
+ table.add_column("Excluded Schemas", style="muted")
222
255
  table.add_column("SSL", style="success")
223
256
  table.add_column("Default", style="error")
224
257
 
@@ -241,6 +274,7 @@ def list():
241
274
  str(db.port) if db.port else "",
242
275
  db.database,
243
276
  db.username,
277
+ ", ".join(db.exclude_schemas) if db.exclude_schemas else "",
244
278
  ssl_status,
245
279
  is_default,
246
280
  )
@@ -249,6 +283,116 @@ def list():
249
283
  logger.info("db.list.complete", count=len(databases))
250
284
 
251
285
 
286
+ @db_app.command
287
+ def exclude(
288
+ name: Annotated[
289
+ str,
290
+ cyclopts.Parameter(help="Name of the database connection to update"),
291
+ ],
292
+ set_schemas: Annotated[
293
+ str | None,
294
+ cyclopts.Parameter(
295
+ ["--set"],
296
+ help="Replace excluded schemas with this comma-separated list",
297
+ ),
298
+ ] = None,
299
+ add_schemas: Annotated[
300
+ str | None,
301
+ cyclopts.Parameter(
302
+ ["--add"],
303
+ help="Add comma-separated schemas to the existing exclude list",
304
+ ),
305
+ ] = None,
306
+ remove_schemas: Annotated[
307
+ str | None,
308
+ cyclopts.Parameter(
309
+ ["--remove"],
310
+ help="Remove comma-separated schemas from the existing exclude list",
311
+ ),
312
+ ] = None,
313
+ clear: Annotated[
314
+ bool,
315
+ cyclopts.Parameter(
316
+ ["--clear", "--no-clear"],
317
+ help="Clear all excluded schemas",
318
+ ),
319
+ ] = False,
320
+ ):
321
+ """Update excluded schemas for a database connection."""
322
+ logger.info(
323
+ "db.exclude.start",
324
+ name=name,
325
+ set=bool(set_schemas),
326
+ add=bool(add_schemas),
327
+ remove=bool(remove_schemas),
328
+ clear=clear,
329
+ )
330
+ db_config = config_manager.get_database(name)
331
+ if not db_config:
332
+ console.print(
333
+ f"[bold error]Error: Database connection '{name}' not found[/bold error]"
334
+ )
335
+ logger.error("db.exclude.not_found", name=name)
336
+ sys.exit(1)
337
+
338
+ actions_selected = sum(
339
+ bool(flag)
340
+ for flag in [
341
+ set_schemas is not None,
342
+ add_schemas is not None,
343
+ remove_schemas is not None,
344
+ clear,
345
+ ]
346
+ )
347
+ if actions_selected > 1:
348
+ console.print(
349
+ "[bold error]Error: Specify only one of --set, --add, --remove, or --clear[/bold error]"
350
+ )
351
+ logger.error("db.exclude.multiple_actions", name=name)
352
+ sys.exit(1)
353
+
354
+ current = [*(db_config.exclude_schemas or [])]
355
+
356
+ if clear:
357
+ updated = []
358
+ elif set_schemas is not None:
359
+ updated = _parse_schema_list(set_schemas)
360
+ elif add_schemas is not None:
361
+ additions = _parse_schema_list(add_schemas)
362
+ updated = [*current]
363
+ current_set = set(current)
364
+ for schema in additions:
365
+ if schema not in current_set:
366
+ updated.append(schema)
367
+ current_set.add(schema)
368
+ elif remove_schemas is not None:
369
+ removals = set(_parse_schema_list(remove_schemas))
370
+ updated = [schema for schema in current if schema not in removals]
371
+ else:
372
+ console.print(
373
+ "[info]Update excluded schemas for "
374
+ f"[primary]{name}[/primary] (leave blank to clear)[/info]"
375
+ )
376
+ default_value = ", ".join(current)
377
+ response = questionary.text(
378
+ "Schemas to exclude (comma separated):", default=default_value
379
+ ).ask()
380
+ if response is None:
381
+ console.print("[warning]Operation cancelled[/warning]")
382
+ logger.info("db.exclude.cancelled", name=name)
383
+ return
384
+ updated = _parse_schema_list(response)
385
+
386
+ db_config.exclude_schemas = _normalize_schema_list(updated)
387
+ config_manager.update_database(db_config)
388
+
389
+ console.print(
390
+ f"[success]Updated excluded schemas for '{name}':[/success] "
391
+ f"{', '.join(db_config.exclude_schemas) if db_config.exclude_schemas else '(none)'}"
392
+ )
393
+ logger.info("db.exclude.success", name=name, count=len(db_config.exclude_schemas))
394
+
395
+
252
396
  @db_app.command
253
397
  def remove(
254
398
  name: Annotated[
@@ -259,7 +403,7 @@ def remove(
259
403
  logger.info("db.remove.start", name=name)
260
404
  if not config_manager.get_database(name):
261
405
  console.print(
262
- f"[bold error]Error:[/bold error] Database connection '{name}' not found"
406
+ f"[bold error]Error: Database connection '{name}' not found[/bold error]"
263
407
  )
264
408
  logger.error("db.remove.not_found", name=name)
265
409
  sys.exit(1)
@@ -269,17 +413,17 @@ def remove(
269
413
  ).ask():
270
414
  if config_manager.remove_database(name):
271
415
  console.print(
272
- f"[green]Successfully removed database connection '{name}'[/green]"
416
+ f"[success]Successfully removed database connection '{name}'[/success]"
273
417
  )
274
418
  logger.info("db.remove.success", name=name)
275
419
  else:
276
420
  console.print(
277
- f"[bold error]Error:[/bold error] Failed to remove database connection '{name}'"
421
+ f"[bold error]Error: Failed to remove database connection '{name}'[/bold error]"
278
422
  )
279
423
  logger.error("db.remove.failed", name=name)
280
424
  sys.exit(1)
281
425
  else:
282
- console.print("Operation cancelled")
426
+ console.print("[warning]Operation cancelled[/warning]")
283
427
  logger.info("db.remove.cancelled", name=name)
284
428
 
285
429
 
@@ -294,17 +438,19 @@ def set_default(
294
438
  logger.info("db.default.start", name=name)
295
439
  if not config_manager.get_database(name):
296
440
  console.print(
297
- f"[bold error]Error:[/bold error] Database connection '{name}' not found"
441
+ f"[bold error]Error: Database connection '{name}' not found[/bold error]"
298
442
  )
299
443
  logger.error("db.default.not_found", name=name)
300
444
  sys.exit(1)
301
445
 
302
446
  if config_manager.set_default_database(name):
303
- console.print(f"[green]Successfully set '{name}' as default database[/green]")
447
+ console.print(
448
+ f"[success]Successfully set '{name}' as default database[/success]"
449
+ )
304
450
  logger.info("db.default.success", name=name)
305
451
  else:
306
452
  console.print(
307
- f"[bold error]Error:[/bold error] Failed to set '{name}' as default"
453
+ f"[bold error]Error: Failed to set '{name}' as default[/bold error]"
308
454
  )
309
455
  logger.error("db.default.failed", name=name)
310
456
  sys.exit(1)
@@ -330,7 +476,7 @@ def test(
330
476
  db_config = config_manager.get_database(name)
331
477
  if not db_config:
332
478
  console.print(
333
- f"[bold error]Error:[/bold error] Database connection '{name}' not found"
479
+ f"[bold error]Error: Database connection '{name}' not found[/bold error]"
334
480
  )
335
481
  logger.error("db.test.not_found", name=name)
336
482
  sys.exit(1)
@@ -338,7 +484,7 @@ def test(
338
484
  db_config = config_manager.get_default_database()
339
485
  if not db_config:
340
486
  console.print(
341
- "[bold error]Error:[/bold error] No default database configured"
487
+ "[bold error]Error: No default database configured[/bold error]"
342
488
  )
343
489
  console.print(
344
490
  "Use 'sqlsaber db add <name>' to add a database connection"
@@ -350,14 +496,16 @@ def test(
350
496
 
351
497
  try:
352
498
  connection_string = db_config.to_connection_string()
353
- db_conn = DatabaseConnection(connection_string)
499
+ db_conn = DatabaseConnection(
500
+ connection_string, excluded_schemas=db_config.exclude_schemas
501
+ )
354
502
 
355
503
  # Try to connect and run a simple query
356
504
  await db_conn.execute_query("SELECT 1 as test")
357
505
  await db_conn.close()
358
506
 
359
507
  console.print(
360
- f"[green]✓ Connection to '{db_config.name}' successful[/green]"
508
+ f"[success]✓ Connection to '{db_config.name}' successful[/success]"
361
509
  )
362
510
  logger.info("db.test.success", name=db_config.name)
363
511
 
@@ -369,7 +517,7 @@ def test(
369
517
  ),
370
518
  error=str(e),
371
519
  )
372
- console.print(f"[bold error]✗ Connection failed:[/bold error] {e}")
520
+ console.print(f"[bold error]✗ Connection failed: {e}[/bold error]")
373
521
  sys.exit(1)
374
522
 
375
523
  asyncio.run(test_connection())
@@ -135,8 +135,10 @@ class InteractiveSession:
135
135
  )
136
136
 
137
137
  db_name = self.database_name or "Unknown"
138
+ model_name = self.sqlsaber_agent.agent.model.model_name
138
139
  self.console.print(
139
140
  f"[heading]\nConnected to {db_name} ({self._db_type_name()})[/heading]\n"
141
+ f"[heading]Model: {model_name}[/heading]\n"
140
142
  )
141
143
 
142
144
  if self._thread_id:
sqlsaber/cli/threads.py CHANGED
@@ -229,7 +229,7 @@ def list_threads(
229
229
  logger.info("threads.cli.list.empty")
230
230
  return
231
231
  table = Table(title="Threads")
232
- table.add_column("ID", style=tm.style("info"))
232
+ table.add_column("ID", style=tm.style("info"), no_wrap=True, min_width=36)
233
233
  table.add_column("Database", style=tm.style("accent"))
234
234
  table.add_column("Title", style=tm.style("success"))
235
235
  table.add_column("Last Activity", style=tm.style("muted"))
@@ -318,7 +318,9 @@ def resume(
318
318
  )
319
319
  return
320
320
 
321
- db_conn = DatabaseConnection(connection_string)
321
+ db_conn = DatabaseConnection(
322
+ connection_string, excluded_schemas=resolved.excluded_schemas
323
+ )
322
324
  try:
323
325
  sqlsaber_agent = SQLSaberAgent(db_conn, db_name)
324
326
  history = await store.get_thread_messages(thread_id)
@@ -4,7 +4,7 @@ import json
4
4
  import os
5
5
  import platform
6
6
  import stat
7
- from dataclasses import dataclass
7
+ from dataclasses import dataclass, field
8
8
  from pathlib import Path
9
9
  from typing import Any
10
10
  from urllib.parse import quote_plus
@@ -29,6 +29,7 @@ class DatabaseConfig:
29
29
  ssl_cert: str | None = None
30
30
  ssl_key: str | None = None
31
31
  schema: str | None = None
32
+ exclude_schemas: list[str] = field(default_factory=list)
32
33
 
33
34
  def to_connection_string(self) -> str:
34
35
  """Convert config to database connection string."""
@@ -149,6 +150,7 @@ class DatabaseConfig:
149
150
  "ssl_cert": self.ssl_cert,
150
151
  "ssl_key": self.ssl_key,
151
152
  "schema": self.schema,
153
+ "exclude_schemas": self.exclude_schemas,
152
154
  }
153
155
 
154
156
  @classmethod
@@ -166,6 +168,7 @@ class DatabaseConfig:
166
168
  ssl_cert=data.get("ssl_cert"),
167
169
  ssl_key=data.get("ssl_key"),
168
170
  schema=data.get("schema"),
171
+ exclude_schemas=list(data.get("exclude_schemas", [])),
169
172
  )
170
173
 
171
174
 
@@ -246,6 +249,16 @@ class DatabaseConfigManager:
246
249
 
247
250
  self._save_config(config)
248
251
 
252
+ def update_database(self, db_config: DatabaseConfig) -> None:
253
+ """Update an existing database configuration."""
254
+ config = self._load_config()
255
+
256
+ if db_config.name not in config["connections"]:
257
+ raise ValueError(f"Database '{db_config.name}' does not exist")
258
+
259
+ config["connections"][db_config.name] = db_config.to_dict()
260
+ self._save_config(config)
261
+
249
262
  def get_database(self, name: str) -> DatabaseConfig | None:
250
263
  """Get a database configuration by name."""
251
264
  config = self._load_config()
@@ -1,5 +1,7 @@
1
1
  """Database module for SQLSaber."""
2
2
 
3
+ from collections.abc import Iterable
4
+
3
5
  from .base import (
4
6
  DEFAULT_QUERY_TIMEOUT,
5
7
  BaseDatabaseConnection,
@@ -18,23 +20,28 @@ from .schema import SchemaManager
18
20
  from .sqlite import SQLiteConnection, SQLiteSchemaIntrospector
19
21
 
20
22
 
21
- def DatabaseConnection(connection_string: str) -> BaseDatabaseConnection:
23
+ def DatabaseConnection(
24
+ connection_string: str, *, excluded_schemas: Iterable[str] | None = None
25
+ ) -> BaseDatabaseConnection:
22
26
  """Factory function to create appropriate database connection based on connection string."""
23
27
  if connection_string.startswith("postgresql://"):
24
- return PostgreSQLConnection(connection_string)
28
+ conn = PostgreSQLConnection(connection_string)
25
29
  elif connection_string.startswith("mysql://"):
26
- return MySQLConnection(connection_string)
30
+ conn = MySQLConnection(connection_string)
27
31
  elif connection_string.startswith("sqlite:///"):
28
- return SQLiteConnection(connection_string)
32
+ conn = SQLiteConnection(connection_string)
29
33
  elif connection_string.startswith("duckdb://"):
30
- return DuckDBConnection(connection_string)
34
+ conn = DuckDBConnection(connection_string)
31
35
  elif connection_string.startswith("csv:///"):
32
- return CSVConnection(connection_string)
36
+ conn = CSVConnection(connection_string)
33
37
  else:
34
38
  raise ValueError(
35
39
  f"Unsupported database type in connection string: {connection_string}"
36
40
  )
37
41
 
42
+ conn.set_excluded_schemas(excluded_schemas)
43
+ return conn
44
+
38
45
 
39
46
  __all__ = [
40
47
  # Base classes and types
sqlsaber/database/base.py CHANGED
@@ -1,6 +1,8 @@
1
1
  """Base classes and type definitions for database connections and schema introspection."""
2
2
 
3
+ import os
3
4
  from abc import ABC, abstractmethod
5
+ from collections.abc import Iterable
4
6
  from typing import Any, TypedDict
5
7
 
6
8
  # Default query timeout to prevent runaway queries
@@ -62,6 +64,7 @@ class BaseDatabaseConnection(ABC):
62
64
  def __init__(self, connection_string: str):
63
65
  self.connection_string = connection_string
64
66
  self._pool = None
67
+ self._excluded_schemas: list[str] = []
65
68
 
66
69
  @property
67
70
  @abstractmethod
@@ -95,6 +98,27 @@ class BaseDatabaseConnection(ABC):
95
98
  """
96
99
  pass
97
100
 
101
+ def set_excluded_schemas(self, schemas: Iterable[str] | None) -> None:
102
+ """Set schemas to exclude from introspection for this connection."""
103
+ self._excluded_schemas = []
104
+ if not schemas:
105
+ return
106
+
107
+ seen: set[str] = set()
108
+ for schema in schemas:
109
+ clean = schema.strip()
110
+ if not clean:
111
+ continue
112
+ if clean in seen:
113
+ continue
114
+ seen.add(clean)
115
+ self._excluded_schemas.append(clean)
116
+
117
+ @property
118
+ def excluded_schemas(self) -> list[str]:
119
+ """Return list of excluded schemas for this connection."""
120
+ return list(self._excluded_schemas)
121
+
98
122
 
99
123
  class BaseSchemaIntrospector(ABC):
100
124
  """Abstract base class for database-specific schema introspection."""
@@ -130,3 +154,36 @@ class BaseSchemaIntrospector(ABC):
130
154
  async def list_tables_info(self, connection) -> list[dict[str, Any]]:
131
155
  """Get list of tables with basic information."""
132
156
  pass
157
+
158
+ @staticmethod
159
+ def _merge_excluded_schemas(
160
+ connection: BaseDatabaseConnection,
161
+ defaults: Iterable[str],
162
+ env_var: str | None = None,
163
+ ) -> list[str]:
164
+ """Combine default, connection, and environment schema exclusions."""
165
+
166
+ combined: list[str] = []
167
+ seen: set[str] = set()
168
+
169
+ def _add(items: Iterable[str]) -> None:
170
+ for item in items:
171
+ name = item.strip()
172
+ if not name:
173
+ continue
174
+ if name in seen:
175
+ continue
176
+ seen.add(name)
177
+ combined.append(name)
178
+
179
+ _add(defaults)
180
+ _add(getattr(connection, "excluded_schemas", []) or [])
181
+
182
+ if env_var:
183
+ raw = os.getenv(env_var, "")
184
+ if raw:
185
+ # Support comma-separated values
186
+ values = [part.strip() for part in raw.split(",")]
187
+ _add(values)
188
+
189
+ return combined
@@ -95,6 +95,13 @@ class DuckDBConnection(BaseDatabaseConnection):
95
95
  class DuckDBSchemaIntrospector(BaseSchemaIntrospector):
96
96
  """DuckDB-specific schema introspection."""
97
97
 
98
+ def _get_excluded_schemas(self, connection) -> list[str]:
99
+ """Return schemas to exclude during introspection."""
100
+ defaults = ["information_schema", "pg_catalog", "duckdb_catalog"]
101
+ return self._merge_excluded_schemas(
102
+ connection, defaults, env_var="SQLSABER_DUCKDB_EXCLUDE_SCHEMAS"
103
+ )
104
+
98
105
  async def _execute_query(
99
106
  self,
100
107
  connection,
@@ -131,11 +138,15 @@ class DuckDBSchemaIntrospector(BaseSchemaIntrospector):
131
138
  self, connection, table_pattern: str | None = None
132
139
  ) -> list[dict[str, Any]]:
133
140
  """Get tables information for DuckDB."""
134
- where_conditions = [
135
- "t.table_schema NOT IN ('information_schema', 'pg_catalog', 'duckdb_catalog')"
136
- ]
141
+ excluded = self._get_excluded_schemas(connection)
142
+ where_conditions: list[str] = []
137
143
  params: list[Any] = []
138
144
 
145
+ if excluded:
146
+ placeholders = ", ".join(["?"] * len(excluded))
147
+ where_conditions.append(f"t.table_schema NOT IN ({placeholders})")
148
+ params.extend(excluded)
149
+
139
150
  if table_pattern:
140
151
  if "." in table_pattern:
141
152
  schema_pattern, table_name_pattern = table_pattern.split(".", 1)
@@ -149,6 +160,9 @@ class DuckDBSchemaIntrospector(BaseSchemaIntrospector):
149
160
  )
150
161
  params.extend([table_pattern, table_pattern])
151
162
 
163
+ if not where_conditions:
164
+ where_conditions.append("1=1")
165
+
152
166
  query = f"""
153
167
  SELECT
154
168
  t.table_schema,
@@ -316,7 +330,16 @@ class DuckDBSchemaIntrospector(BaseSchemaIntrospector):
316
330
 
317
331
  async def list_tables_info(self, connection) -> list[dict[str, Any]]:
318
332
  """Get list of tables with basic information for DuckDB."""
319
- query = """
333
+ excluded = self._get_excluded_schemas(connection)
334
+ params: list[Any] = []
335
+ if excluded:
336
+ placeholders = ", ".join(["?"] * len(excluded))
337
+ where_clause = f"WHERE t.table_schema NOT IN ({placeholders})"
338
+ params.extend(excluded)
339
+ else:
340
+ where_clause = ""
341
+
342
+ query = f"""
320
343
  SELECT
321
344
  t.table_schema,
322
345
  t.table_name,
@@ -326,8 +349,8 @@ class DuckDBSchemaIntrospector(BaseSchemaIntrospector):
326
349
  LEFT JOIN duckdb_tables() dt
327
350
  ON t.table_schema = dt.schema_name
328
351
  AND t.table_name = dt.table_name
329
- WHERE t.table_schema NOT IN ('information_schema', 'pg_catalog', 'duckdb_catalog')
352
+ {where_clause}
330
353
  ORDER BY t.table_schema, t.table_name;
331
354
  """
332
355
 
333
- return await self._execute_query(connection, query)
356
+ return await self._execute_query(connection, query, tuple(params))
@@ -153,6 +153,13 @@ class MySQLConnection(BaseDatabaseConnection):
153
153
  class MySQLSchemaIntrospector(BaseSchemaIntrospector):
154
154
  """MySQL-specific schema introspection."""
155
155
 
156
+ def _get_excluded_schemas(self, connection) -> list[str]:
157
+ """Return schemas to exclude during introspection."""
158
+ defaults = ["information_schema", "performance_schema", "mysql", "sys"]
159
+ return self._merge_excluded_schemas(
160
+ connection, defaults, env_var="SQLSABER_MYSQL_EXCLUDE_SCHEMAS"
161
+ )
162
+
156
163
  def _build_table_filter_clause(self, tables: list) -> tuple[str, list]:
157
164
  """Build row constructor with bind parameters for table filtering.
158
165
 
@@ -178,10 +185,14 @@ class MySQLSchemaIntrospector(BaseSchemaIntrospector):
178
185
  async with pool.acquire() as conn:
179
186
  async with conn.cursor() as cursor:
180
187
  # Build WHERE clause for filtering
181
- where_conditions = [
182
- "table_schema NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')"
183
- ]
184
- params = []
188
+ excluded = self._get_excluded_schemas(connection)
189
+ where_conditions = []
190
+ params: list[Any] = []
191
+
192
+ if excluded:
193
+ placeholders = ", ".join(["%s"] * len(excluded))
194
+ where_conditions.append(f"table_schema NOT IN ({placeholders})")
195
+ params.extend(excluded)
185
196
 
186
197
  if table_pattern:
187
198
  # Support patterns like 'schema.table' or just 'table'
@@ -197,6 +208,9 @@ class MySQLSchemaIntrospector(BaseSchemaIntrospector):
197
208
  )
198
209
  params.extend([table_pattern, table_pattern])
199
210
 
211
+ if not where_conditions:
212
+ where_conditions.append("1=1")
213
+
200
214
  # Get tables
201
215
  tables_query = f"""
202
216
  SELECT
@@ -329,17 +343,26 @@ class MySQLSchemaIntrospector(BaseSchemaIntrospector):
329
343
  async with pool.acquire() as conn:
330
344
  async with conn.cursor() as cursor:
331
345
  # Get tables without row counts for better performance
332
- tables_query = """
346
+ excluded = self._get_excluded_schemas(connection)
347
+ params: list[Any] = []
348
+ if excluded:
349
+ placeholders = ", ".join(["%s"] * len(excluded))
350
+ where_clause = f"WHERE t.table_schema NOT IN ({placeholders})"
351
+ params.extend(excluded)
352
+ else:
353
+ where_clause = ""
354
+
355
+ tables_query = f"""
333
356
  SELECT
334
357
  t.table_schema,
335
358
  t.table_name,
336
359
  t.table_type,
337
360
  t.table_comment
338
361
  FROM information_schema.tables t
339
- WHERE t.table_schema NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
362
+ {where_clause}
340
363
  ORDER BY t.table_schema, t.table_name;
341
364
  """
342
- await cursor.execute(tables_query)
365
+ await cursor.execute(tables_query, params if params else None)
343
366
  rows = await cursor.fetchall()
344
367
 
345
368
  # Convert rows to dictionaries
@@ -135,7 +135,7 @@ class PostgreSQLConnection(BaseDatabaseConnection):
135
135
  class PostgreSQLSchemaIntrospector(BaseSchemaIntrospector):
136
136
  """PostgreSQL-specific schema introspection."""
137
137
 
138
- def _get_excluded_schemas(self) -> list[str]:
138
+ def _get_excluded_schemas(self, connection) -> list[str]:
139
139
  """Return schemas to exclude during introspection.
140
140
 
141
141
  Defaults include PostgreSQL system schemas and TimescaleDB internal
@@ -143,10 +143,7 @@ class PostgreSQLSchemaIntrospector(BaseSchemaIntrospector):
143
143
  environment variable `SQLSABER_PG_EXCLUDE_SCHEMAS` to a comma-separated
144
144
  list of schema names.
145
145
  """
146
- import os
147
-
148
- # Base exclusions: system schemas and TimescaleDB internal partitions
149
- excluded = [
146
+ defaults = [
150
147
  "pg_catalog",
151
148
  "information_schema",
152
149
  "_timescaledb_internal",
@@ -155,14 +152,9 @@ class PostgreSQLSchemaIntrospector(BaseSchemaIntrospector):
155
152
  "_timescaledb_catalog",
156
153
  ]
157
154
 
158
- extra = os.getenv("SQLSABER_PG_EXCLUDE_SCHEMAS", "")
159
- if extra:
160
- for item in extra.split(","):
161
- name = item.strip()
162
- if name and name not in excluded:
163
- excluded.append(name)
164
-
165
- return excluded
155
+ return self._merge_excluded_schemas(
156
+ connection, defaults, env_var="SQLSABER_PG_EXCLUDE_SCHEMAS"
157
+ )
166
158
 
167
159
  def _build_table_filter_clause(self, tables: list) -> tuple[str, list]:
168
160
  """Build VALUES clause with bind parameters for table filtering.
@@ -193,7 +185,7 @@ class PostgreSQLSchemaIntrospector(BaseSchemaIntrospector):
193
185
  where_conditions: list[str] = []
194
186
  params: list[Any] = []
195
187
 
196
- excluded = self._get_excluded_schemas()
188
+ excluded = self._get_excluded_schemas(connection)
197
189
  if excluded:
198
190
  placeholders = ", ".join(f"${i + 1}" for i in range(len(excluded)))
199
191
  where_conditions.append(f"table_schema NOT IN ({placeholders})")
@@ -354,7 +346,7 @@ class PostgreSQLSchemaIntrospector(BaseSchemaIntrospector):
354
346
  pool = await connection.get_pool()
355
347
  async with pool.acquire() as conn:
356
348
  # Exclude system schemas (and TimescaleDB internals) for performance
357
- excluded = self._get_excluded_schemas()
349
+ excluded = self._get_excluded_schemas(connection)
358
350
  params: list[Any] = []
359
351
  if excluded:
360
352
  placeholders = ", ".join(f"${i + 1}" for i in range(len(excluded)))
@@ -1,7 +1,5 @@
1
1
  """Database connection resolution from CLI input."""
2
2
 
3
- from __future__ import annotations
4
-
5
3
  from dataclasses import dataclass
6
4
  from pathlib import Path
7
5
  from urllib.parse import urlparse
@@ -21,6 +19,7 @@ class ResolvedDatabase:
21
19
 
22
20
  name: str # Human-readable name for display/logging
23
21
  connection_string: str # Canonical connection string for DatabaseConnection factory
22
+ excluded_schemas: list[str]
24
23
 
25
24
 
26
25
  SUPPORTED_SCHEMES = {"postgresql", "mysql", "sqlite", "duckdb", "csv"}
@@ -60,6 +59,7 @@ def resolve_database(
60
59
  return ResolvedDatabase(
61
60
  name=db_cfg.name,
62
61
  connection_string=db_cfg.to_connection_string(),
62
+ excluded_schemas=list(db_cfg.exclude_schemas),
63
63
  )
64
64
 
65
65
  # 1. Connection string?
@@ -71,22 +71,30 @@ def resolve_database(
71
71
  db_name = Path(urlparse(spec).path).stem or "database"
72
72
  else: # should not happen because of SUPPORTED_SCHEMES
73
73
  db_name = "database"
74
- return ResolvedDatabase(name=db_name, connection_string=spec)
74
+ return ResolvedDatabase(
75
+ name=db_name, connection_string=spec, excluded_schemas=[]
76
+ )
75
77
 
76
78
  # 2. Raw file path?
77
79
  path = Path(spec).expanduser().resolve()
78
80
  if path.suffix.lower() == ".csv":
79
81
  if not path.exists():
80
82
  raise DatabaseResolutionError(f"CSV file '{spec}' not found.")
81
- return ResolvedDatabase(name=path.stem, connection_string=f"csv:///{path}")
83
+ return ResolvedDatabase(
84
+ name=path.stem, connection_string=f"csv:///{path}", excluded_schemas=[]
85
+ )
82
86
  if path.suffix.lower() in {".db", ".sqlite", ".sqlite3"}:
83
87
  if not path.exists():
84
88
  raise DatabaseResolutionError(f"SQLite file '{spec}' not found.")
85
- return ResolvedDatabase(name=path.stem, connection_string=f"sqlite:///{path}")
89
+ return ResolvedDatabase(
90
+ name=path.stem, connection_string=f"sqlite:///{path}", excluded_schemas=[]
91
+ )
86
92
  if path.suffix.lower() in {".duckdb", ".ddb"}:
87
93
  if not path.exists():
88
94
  raise DatabaseResolutionError(f"DuckDB file '{spec}' not found.")
89
- return ResolvedDatabase(name=path.stem, connection_string=f"duckdb:///{path}")
95
+ return ResolvedDatabase(
96
+ name=path.stem, connection_string=f"duckdb:///{path}", excluded_schemas=[]
97
+ )
90
98
 
91
99
  # 3. Must be a configured name
92
100
  db_cfg: DatabaseConfig | None = config_mgr.get_database(spec)
@@ -96,5 +104,7 @@ def resolve_database(
96
104
  "Use 'sqlsaber db list' to see available connections."
97
105
  )
98
106
  return ResolvedDatabase(
99
- name=db_cfg.name, connection_string=db_cfg.to_connection_string()
107
+ name=db_cfg.name,
108
+ connection_string=db_cfg.to_connection_string(),
109
+ excluded_schemas=list(db_cfg.exclude_schemas),
100
110
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlsaber
3
- Version: 0.35.0
3
+ Version: 0.36.0
4
4
  Summary: SQLsaber - Open-source agentic SQL assistant
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -5,38 +5,38 @@ sqlsaber/agents/base.py,sha256=T05UsMZPwAlMhsJFpuuVI1RNDhdiwiEsgCWr9MbPoAU,2654
5
5
  sqlsaber/agents/pydantic_ai_agent.py,sha256=6_KppII8YcMw74KOGsYI5Dt6AP8WSduK3yAXCawVex4,10643
6
6
  sqlsaber/application/__init__.py,sha256=KY_-d5nEdQyAwNOsK5r-f7Tb69c63XbuEkHPeLpJal8,84
7
7
  sqlsaber/application/auth_setup.py,sha256=wbi9MaYl6q27LjcSBZmqFC12JtE5hrUHEX1NmD-7UVc,7778
8
- sqlsaber/application/db_setup.py,sha256=ZSgR9rJJVHttIjsbYQS9GEIyzkM09k5RLrVGdegrfYc,6859
8
+ sqlsaber/application/db_setup.py,sha256=QX4VX8X9BiCBS3kFDd1-cPcEsCX0NwqNAOhX44pPtC4,8127
9
9
  sqlsaber/application/model_selection.py,sha256=fSC06MZNKinHDR-csMFVYYJFyK8MydKf6pStof74Jp0,3191
10
10
  sqlsaber/application/prompts.py,sha256=4rMGcWpYJbNWPMzqVWseUMx0nwvXOkWS6GaTAJ5mhfc,3473
11
11
  sqlsaber/cli/__init__.py,sha256=qVSLVJLLJYzoC6aj6y9MFrzZvAwc4_OgxU9DlkQnZ4M,86
12
12
  sqlsaber/cli/auth.py,sha256=TmAD4BJr2gIjPeW2LA1QkKK5gUZ2OOS6vOmBvB6PC0M,7051
13
- sqlsaber/cli/commands.py,sha256=WocWlLrxA5kM8URfvIvWFtc0ocfgKWAwoYTxVNZhmM4,10962
13
+ sqlsaber/cli/commands.py,sha256=P49Qylnk_kEmGCP5fHY5YrNyDhhL_GU20qcXBAv_QLI,11167
14
14
  sqlsaber/cli/completers.py,sha256=g-hLDq5fiBx7gg8Bte1Lq8GU-ZxCYVs4dcPsmHPIcK4,6574
15
- sqlsaber/cli/database.py,sha256=BBGj0eyduh5DDXNLZLDtWfY9kWpeT_ZX0J9R9INZyyU,12421
15
+ sqlsaber/cli/database.py,sha256=RIBPFoLqXT5_RQ2rp-vYDh6wXqR6RAgbYTqZRrA9GkU,17212
16
16
  sqlsaber/cli/display.py,sha256=wxkEFceyyUloDUB49Gzqhl6JGRGq2cQdmJ3XQeYd6ok,18599
17
- sqlsaber/cli/interactive.py,sha256=bqeQVUgghKeZdsZ31LTcUTNt_jYyc2ujnAlUNRpxF5c,14181
17
+ sqlsaber/cli/interactive.py,sha256=K2SwD1uY19ZK_X9MMNLJk1K7BVhDT95if8WJDvsLF0g,14301
18
18
  sqlsaber/cli/memory.py,sha256=kAY5LLFueIF30gJ8ibfrFw42rOyy5wajeJGS4h5XQw4,9475
19
19
  sqlsaber/cli/models.py,sha256=aVHazP_fiT-Mj9AtCdjliDtq3E3fJrhgP4oF5p4CuwI,9593
20
20
  sqlsaber/cli/onboarding.py,sha256=iBGT-W-OJFRvQoEpuHYyO1c9Mym5c97eIefRvxGHtTg,11265
21
21
  sqlsaber/cli/streaming.py,sha256=jicSDLWQ3efitpdc2y4QsasHcEW8ogZ4lHcWmftq9Ao,6763
22
22
  sqlsaber/cli/theme.py,sha256=D6HIt7rmF00B5ZOCV5lXKzPICE4uppHdraOdVs7k5Nw,4672
23
- sqlsaber/cli/threads.py,sha256=zYvs1epmRRuQxOofF85eXk1_YHS6co7oq_F33DdNdf0,14643
23
+ sqlsaber/cli/threads.py,sha256=4pCpnEwkAW3TIaLaDfn0oixhW18LUjHvf14GuUX9ZWk,14737
24
24
  sqlsaber/config/__init__.py,sha256=olwC45k8Nc61yK0WmPUk7XHdbsZH9HuUAbwnmKe3IgA,100
25
25
  sqlsaber/config/api_keys.py,sha256=H7xBU1mflngXIlnaDjbTvuNqZsW_92AIve31eDLij34,4513
26
26
  sqlsaber/config/auth.py,sha256=G1uulySUclWSId8EIt1hPNmsUNhbfRzfp8VVQftYyG8,2964
27
- sqlsaber/config/database.py,sha256=Yec6_0wdzq-ADblMNnbgvouYCimYOY_DWHT9oweaISc,11449
27
+ sqlsaber/config/database.py,sha256=Mu92aYjc2ONNA4xbnDcEuq3A4yDFpsp8RY5aOjX7Fs0,12034
28
28
  sqlsaber/config/logging.py,sha256=vv4oCuQePeYQ7bMs0OLKj8ZSiNcFWbHmWtdC0lTsUyc,6173
29
29
  sqlsaber/config/oauth_flow.py,sha256=cDfaJjqr4spNuzxbAlzuJfk6SEe1ojSRAkoOWlvQYy0,11037
30
30
  sqlsaber/config/oauth_tokens.py,sha256=KCC2u3lOjdh0M-rd0K1rW0PWk58w7mqpodAhlPVp9NE,6424
31
31
  sqlsaber/config/providers.py,sha256=JFjeJv1K5Q93zWSlWq3hAvgch1TlgoF0qFa0KJROkKY,2957
32
32
  sqlsaber/config/settings.py,sha256=-nIBNt9E0tCRGd14bk4x-bNAwO12sbsjRsN8fFannK4,6449
33
- sqlsaber/database/__init__.py,sha256=Gi9N_NOkD459WRWXDg3hSuGoBs3xWbMDRBvsTVmnGAg,2025
34
- sqlsaber/database/base.py,sha256=ZxbDuk0W-3edo6aN2EZ65SMQ3Oxiud_jygc4ZWi2CmI,3751
33
+ sqlsaber/database/__init__.py,sha256=s1NgBAuLfWVDDXut4Mcm4wqbiC6jPQzNP4tVfm6pI-A,2184
34
+ sqlsaber/database/base.py,sha256=VFPlOx0_pfAj4-V8oe_r4cbnwh_B-amL48E6W_H8U-c,5502
35
35
  sqlsaber/database/csv.py,sha256=41wuP40FaGPfj28HMiD0I69uG0JbUxArpoTLC3MG2uc,4464
36
- sqlsaber/database/duckdb.py,sha256=zGa1akNzG1Jreyhq6jm93iFzmPtOC8WcS-7TtQxzxME,11961
37
- sqlsaber/database/mysql.py,sha256=wZ98wrA9E7hljGAcz5h4zBJdRJ0uPdvTZQmFqQVXDBk,14657
38
- sqlsaber/database/postgresql.py,sha256=0fyko76pEdn5GxtaW75lwf8OIKnj2NnaY7Z1j_heUNs,15685
39
- sqlsaber/database/resolver.py,sha256=wSCcn__aCqwIfpt_LCjtW2Zgb8RpG5PlmwwZHli1q_U,3628
36
+ sqlsaber/database/duckdb.py,sha256=s3dpGKXac-WsPSHzh_Y6TDcz7Lne_0FnOf38l52snWQ,12808
37
+ sqlsaber/database/mysql.py,sha256=sqeuJZTXrKIk06hqI6qe3uaSPbrkfz-DbV2OQjn1sZc,15616
38
+ sqlsaber/database/postgresql.py,sha256=mfk3_JOzJBR9wc1opjhZ5bvTk2Cq3YhFJGooZU9S0to,15473
39
+ sqlsaber/database/resolver.py,sha256=ENuXUPwfeDweHq7rrdL9eJAHS5UkSO_btAsW3onp_DY,3919
40
40
  sqlsaber/database/schema.py,sha256=T4dEYOk-Cy5XkhvvQ3GqAU2w584eRivbiu9UZ0nHTBo,7138
41
41
  sqlsaber/database/sqlite.py,sha256=juEVIhSwbeT0gMkEhkjwNUJn-Mif_IIDmXVqostNT-o,9918
42
42
  sqlsaber/memory/__init__.py,sha256=GiWkU6f6YYVV0EvvXDmFWe_CxarmDCql05t70MkTEWs,63
@@ -55,8 +55,8 @@ sqlsaber/tools/base.py,sha256=NKEEooliPKTJj_Pomwte_wW0Xd9Z5kXNfVdCRfTppuw,883
55
55
  sqlsaber/tools/registry.py,sha256=XmBzERq0LJXtg3BZ-r8cEyt8J54NUekgUlTJ_EdSYMk,2204
56
56
  sqlsaber/tools/sql_guard.py,sha256=dTDwcZP-N4xPGzcr7MQtKUxKrlDzlc1irr9aH5a4wvk,6182
57
57
  sqlsaber/tools/sql_tools.py,sha256=q479PNneuAlpaDCp2cyw1MFhLUY4vcUHV_ZyIuSMHK0,7796
58
- sqlsaber-0.35.0.dist-info/METADATA,sha256=2TaG6of-RcfHsYWODoipU909C40Kfq2qaWRE71sI85M,5915
59
- sqlsaber-0.35.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
60
- sqlsaber-0.35.0.dist-info/entry_points.txt,sha256=tw1mB0fjlkXQiOsC0434X6nE-o1cFCuQwt2ZYHv_WAE,91
61
- sqlsaber-0.35.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
62
- sqlsaber-0.35.0.dist-info/RECORD,,
58
+ sqlsaber-0.36.0.dist-info/METADATA,sha256=55XqjlW56tN7F_jTMcrRpyPEBmmNLWiHjtQnNjbXzf8,5915
59
+ sqlsaber-0.36.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
60
+ sqlsaber-0.36.0.dist-info/entry_points.txt,sha256=tw1mB0fjlkXQiOsC0434X6nE-o1cFCuQwt2ZYHv_WAE,91
61
+ sqlsaber-0.36.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
62
+ sqlsaber-0.36.0.dist-info/RECORD,,