dbagent-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.
dbagent/config.py ADDED
@@ -0,0 +1,89 @@
1
+ """
2
+ Configuration and database profile manager.
3
+ Stores profiles and credentials in user home directory (~/.dbagent/profiles.json).
4
+ """
5
+
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Dict, Any, Optional
10
+ from dotenv import load_dotenv
11
+
12
+ load_dotenv()
13
+
14
+
15
+ class ConfigManager:
16
+ """Manages local profiles and AI API credentials."""
17
+
18
+ def __init__(self, config_dir: Optional[Path] = None):
19
+ self.config_dir = config_dir or Path.home() / ".dbagent"
20
+ self.config_file = self.config_dir / "config.json"
21
+ self.profiles_file = self.config_dir / "profiles.json"
22
+ self._ensure_dirs()
23
+
24
+ def _ensure_dirs(self) -> None:
25
+ self.config_dir.mkdir(parents=True, exist_ok=True)
26
+ if not self.config_file.exists():
27
+ self._save_json(self.config_file, {
28
+ "default_provider": "ollama",
29
+ "default_model": None,
30
+ "ollama_base_url": "http://localhost:11434",
31
+ "gemini_api_key": None,
32
+ "groq_api_key": None,
33
+ "openrouter_api_key": None,
34
+ })
35
+ if not self.profiles_file.exists():
36
+ self._save_json(self.profiles_file, {})
37
+
38
+ def _load_json(self, path: Path) -> Dict[str, Any]:
39
+ try:
40
+ with open(path, "r", encoding="utf-8") as f:
41
+ return json.load(f)
42
+ except Exception:
43
+ return {}
44
+
45
+ def _save_json(self, path: Path, data: Dict[str, Any]) -> None:
46
+ with open(path, "w", encoding="utf-8") as f:
47
+ json.dump(data, f, indent=2)
48
+
49
+ # --- LLM Settings ---
50
+
51
+ def get_setting(self, key: str, default: Any = None) -> Any:
52
+ # Check environment variable first
53
+ env_key = key.upper()
54
+ if os.getenv(env_key):
55
+ return os.getenv(env_key)
56
+
57
+ config = self._load_json(self.config_file)
58
+ return config.get(key, default)
59
+
60
+ def set_setting(self, key: str, value: Any) -> None:
61
+ config = self._load_json(self.config_file)
62
+ config[key] = value
63
+ self._save_json(self.config_file, config)
64
+
65
+ # --- Database Profiles ---
66
+
67
+ def save_profile(self, name: str, connection_url: str, description: Optional[str] = None) -> None:
68
+ profiles = self._load_json(self.profiles_file)
69
+ profiles[name] = {
70
+ "name": name,
71
+ "url": connection_url,
72
+ "description": description or f"Profile for {name}",
73
+ }
74
+ self._save_json(self.profiles_file, profiles)
75
+
76
+ def get_profile(self, name: str) -> Optional[Dict[str, Any]]:
77
+ profiles = self._load_json(self.profiles_file)
78
+ return profiles.get(name)
79
+
80
+ def list_profiles(self) -> Dict[str, Dict[str, Any]]:
81
+ return self._load_json(self.profiles_file)
82
+
83
+ def delete_profile(self, name: str) -> bool:
84
+ profiles = self._load_json(self.profiles_file)
85
+ if name in profiles:
86
+ del profiles[name]
87
+ self._save_json(self.profiles_file, profiles)
88
+ return True
89
+ return False
@@ -0,0 +1,75 @@
1
+ """
2
+ Base connector interface for database introspection and query execution.
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Dict, Any, List, Optional, Tuple, Set
7
+ from dbagent.schema.models import DatabaseSchema
8
+
9
+
10
+ class BaseConnector(ABC):
11
+ """Abstract interface for database connectors."""
12
+
13
+ @abstractmethod
14
+ def test_connection(self) -> Tuple[bool, str]:
15
+ """Test if the database connection is working. Returns (success, message)."""
16
+ pass
17
+
18
+ @abstractmethod
19
+ def get_table_names(self) -> List[str]:
20
+ """Get fast list of all table names without full column introspection."""
21
+ pass
22
+
23
+ @abstractmethod
24
+ def inspect_schema(
25
+ self,
26
+ table_names: Optional[List[str]] = None,
27
+ include_samples: bool = True,
28
+ max_samples: int = 2,
29
+ include_views: bool = True,
30
+ include_row_counts: bool = False,
31
+ ) -> DatabaseSchema:
32
+ """Introspect the database schema for all or specified tables."""
33
+ pass
34
+
35
+ @abstractmethod
36
+ def inspect_targeted(
37
+ self,
38
+ user_prompt: str,
39
+ max_tables: int = 8,
40
+ include_samples: bool = True,
41
+ ) -> DatabaseSchema:
42
+ """Introspect only the tables relevant to the user query on demand."""
43
+ pass
44
+
45
+ def resolve_tables(self, user_prompt: str) -> Tuple[List[str], List[str]]:
46
+ """
47
+ Resolve which tables the user is referring to.
48
+ Returns (exact_matches, fuzzy_matches).
49
+ Default implementation uses get_table_names with simple substring matching.
50
+ """
51
+ all_tables = self.get_table_names()
52
+ prompt_lower = user_prompt.lower()
53
+ exact = [t for t in all_tables if t.lower() in prompt_lower]
54
+ return exact, []
55
+
56
+ def refresh_cache(self) -> None:
57
+ """Invalidate any cached metadata. Override in subclasses."""
58
+ pass
59
+
60
+ @abstractmethod
61
+ def execute_query(
62
+ self,
63
+ query: str,
64
+ limit: int = 100,
65
+ ) -> Tuple[List[str], List[Dict[str, Any]], Optional[str]]:
66
+ """
67
+ Execute a read query.
68
+ Returns (columns, rows, error_message).
69
+ """
70
+ pass
71
+
72
+ @abstractmethod
73
+ def close(self) -> None:
74
+ """Close any open connections."""
75
+ pass
@@ -0,0 +1,25 @@
1
+ """
2
+ Connector factory for instantiating the right database connector.
3
+ """
4
+
5
+ from dbagent.connectors.base import BaseConnector
6
+ from dbagent.connectors.relational import RelationalConnector
7
+
8
+
9
+ def create_connector(connection_url: str, **kwargs) -> BaseConnector:
10
+ """Create a database connector based on the connection string URL."""
11
+ url = connection_url.strip()
12
+
13
+ # Normalize SQLite file paths
14
+ if url.endswith(".db") or url.endswith(".sqlite") or url.endswith(".sqlite3"):
15
+ if not url.startswith("sqlite:///"):
16
+ url = f"sqlite:///{url}"
17
+
18
+ # MongoDB
19
+ if url.startswith("mongodb://") or url.startswith("mongodb+srv://"):
20
+ from dbagent.connectors.mongo import MongoConnector
21
+ db_name = kwargs.get("database") or kwargs.get("db_name")
22
+ return MongoConnector(connection_url=url, db_name=db_name)
23
+
24
+ # All relational databases
25
+ return RelationalConnector(connection_url=url)
@@ -0,0 +1,237 @@
1
+ """
2
+ MongoDB Connector for document database schema introspection and query execution.
3
+ """
4
+
5
+ from typing import Dict, Any, List, Optional, Tuple, Set
6
+ from dbagent.connectors.base import BaseConnector
7
+ from dbagent.schema.models import (
8
+ DatabaseSchema,
9
+ TableModel,
10
+ ColumnModel,
11
+ IndexModel,
12
+ )
13
+
14
+
15
+ def _infer_bson_type(val: Any) -> str:
16
+ """Infer BSON type as a human-readable string."""
17
+ if val is None:
18
+ return "Null"
19
+ if isinstance(val, bool):
20
+ return "Boolean"
21
+ if isinstance(val, int):
22
+ return "Integer"
23
+ if isinstance(val, float):
24
+ return "Double"
25
+ if isinstance(val, str):
26
+ return "String"
27
+ if isinstance(val, list):
28
+ if val:
29
+ inner_type = _infer_bson_type(val[0])
30
+ return f"Array<{inner_type}>"
31
+ return "Array"
32
+ if isinstance(val, dict):
33
+ return "Object"
34
+ return type(val).__name__
35
+
36
+
37
+ class MongoConnector(BaseConnector):
38
+ """Connector for MongoDB instances."""
39
+
40
+ def __init__(self, connection_url: str, db_name: Optional[str] = None):
41
+ self.connection_url = connection_url
42
+ self.explicit_db_name = db_name
43
+ self._client = None
44
+ self._cached_collection_names: Optional[List[str]] = None
45
+
46
+ def _get_client(self):
47
+ if self._client is None:
48
+ import pymongo
49
+ self._client = pymongo.MongoClient(self.connection_url, serverSelectionTimeoutMS=5000)
50
+ return self._client
51
+
52
+ def _get_database(self):
53
+ client = self._get_client()
54
+ if self.explicit_db_name:
55
+ return client[self.explicit_db_name]
56
+ default_db = client.get_default_database(default=None)
57
+ if default_db is not None:
58
+ return default_db
59
+ # Fall back to first non-admin/non-local db
60
+ db_names = [d for d in client.list_database_names() if d not in ["admin", "config", "local"]]
61
+ if db_names:
62
+ return client[db_names[0]]
63
+ return client["test"]
64
+
65
+ def test_connection(self) -> Tuple[bool, str]:
66
+ try:
67
+ client = self._get_client()
68
+ client.admin.command("ping")
69
+ db = self._get_database()
70
+ return True, f"Successfully connected to MongoDB (database: '{db.name}')."
71
+ except Exception as e:
72
+ return False, f"MongoDB connection failed: {str(e)}"
73
+
74
+ def get_table_names(self) -> List[str]:
75
+ if self._cached_collection_names is not None:
76
+ return self._cached_collection_names
77
+ db = self._get_database()
78
+ self._cached_collection_names = db.list_collection_names()
79
+ return self._cached_collection_names
80
+
81
+ def refresh_cache(self) -> None:
82
+ """Invalidate cached collection names."""
83
+ self._cached_collection_names = None
84
+
85
+ def resolve_tables(self, user_prompt: str) -> Tuple[List[str], List[str]]:
86
+ """Resolve which collections the user is referring to with fuzzy matching."""
87
+ from dbagent.connectors.relational import _fuzzy_match_tables
88
+ all_cols = self.get_table_names()
89
+ return _fuzzy_match_tables(all_cols, user_prompt)
90
+
91
+ def inspect_schema(
92
+ self,
93
+ table_names: Optional[List[str]] = None,
94
+ include_samples: bool = True,
95
+ max_samples: int = 2,
96
+ include_views: bool = True,
97
+ include_row_counts: bool = False,
98
+ ) -> DatabaseSchema:
99
+ """Introspect MongoDB collections by sampling documents and inspecting indexes."""
100
+ db = self._get_database()
101
+ collection_names = db.list_collection_names()
102
+
103
+ target_collections = collection_names
104
+ if table_names is not None:
105
+ lower_targets = {t.lower() for t in table_names}
106
+ target_collections = [c for c in collection_names if c.lower() in lower_targets]
107
+
108
+ tables: List[TableModel] = []
109
+
110
+ for c_name in target_collections:
111
+ col = db[c_name]
112
+ doc_count = col.estimated_document_count() if include_row_counts else None
113
+
114
+ # Sample documents to infer schema
115
+ sample_docs = list(col.find().limit(max(max_samples, 10)))
116
+ fields_map: Dict[str, set] = {}
117
+ for doc in sample_docs:
118
+ for k, v in doc.items():
119
+ if k not in fields_map:
120
+ fields_map[k] = set()
121
+ fields_map[k].add(_infer_bson_type(v))
122
+
123
+ columns: List[ColumnModel] = []
124
+ for field_name, types in fields_map.items():
125
+ type_str = " | ".join(sorted(types)) if types else "Unknown"
126
+ is_pk = field_name == "_id"
127
+ columns.append(
128
+ ColumnModel(
129
+ name=field_name,
130
+ data_type=type_str,
131
+ is_nullable=not is_pk,
132
+ is_primary_key=is_pk,
133
+ )
134
+ )
135
+
136
+ # Indexes
137
+ indexes: List[IndexModel] = []
138
+ try:
139
+ raw_indexes = col.index_information()
140
+ for idx_name, idx_info in raw_indexes.items():
141
+ key_cols = [k[0] for k in idx_info.get("key", [])]
142
+ indexes.append(
143
+ IndexModel(
144
+ name=idx_name,
145
+ columns=key_cols,
146
+ is_unique=idx_info.get("unique", False),
147
+ )
148
+ )
149
+ except Exception:
150
+ pass
151
+
152
+ # Serialize sample rows
153
+ clean_samples = []
154
+ if include_samples:
155
+ for doc in sample_docs[:max_samples]:
156
+ clean_doc = {}
157
+ for k, v in doc.items():
158
+ clean_doc[k] = str(v) if not isinstance(v, (int, float, bool, str)) else v
159
+ clean_samples.append(clean_doc)
160
+
161
+ tables.append(
162
+ TableModel(
163
+ name=c_name,
164
+ is_view=False,
165
+ columns=columns,
166
+ primary_key=["_id"],
167
+ foreign_keys=[],
168
+ indexes=indexes,
169
+ row_count=doc_count,
170
+ sample_rows=clean_samples,
171
+ )
172
+ )
173
+
174
+ return DatabaseSchema(
175
+ dialect_name="mongodb",
176
+ database_name=db.name,
177
+ tables=tables,
178
+ )
179
+
180
+ def inspect_targeted(
181
+ self,
182
+ user_prompt: str,
183
+ max_tables: int = 8,
184
+ include_samples: bool = True,
185
+ ) -> DatabaseSchema:
186
+ """On-demand targeted MongoDB collection inspection."""
187
+ import re
188
+ all_cols = self.get_table_names()
189
+ prompt_lower = user_prompt.lower()
190
+
191
+ matched = []
192
+ for c in all_cols:
193
+ c_lower = c.lower()
194
+ if re.search(r"\b" + re.escape(c_lower) + r"\b", prompt_lower) or c_lower in prompt_lower:
195
+ matched.append(c)
196
+
197
+ if matched:
198
+ return self.inspect_schema(
199
+ table_names=matched[:max_tables],
200
+ include_samples=include_samples,
201
+ max_samples=2,
202
+ include_row_counts=False,
203
+ )
204
+
205
+ return self.inspect_schema(
206
+ table_names=all_cols[:max_tables],
207
+ include_samples=include_samples,
208
+ max_samples=2,
209
+ include_row_counts=False,
210
+ )
211
+
212
+ def execute_query(
213
+ self,
214
+ query: str,
215
+ limit: int = 100,
216
+ ) -> Tuple[List[str], List[Dict[str, Any]], Optional[str]]:
217
+ """For MongoDB, queries can be formatted as JSON filter or JS collection queries."""
218
+ try:
219
+ import json
220
+ # If query is JSON formatted e.g. {"collection": "users", "filter": {}}
221
+ parsed = json.loads(query)
222
+ col_name = parsed.get("collection")
223
+ filt = parsed.get("filter", {})
224
+ db = self._get_database()
225
+ cursor = db[col_name].find(filt).limit(limit)
226
+ docs = list(cursor)
227
+ if not docs:
228
+ return [], [], None
229
+ columns = list(docs[0].keys())
230
+ rows = [{k: str(v) for k, v in d.items()} for d in docs]
231
+ return columns, rows, None
232
+ except Exception as e:
233
+ return [], [], f"MongoDB execution error: {str(e)}"
234
+
235
+ def close(self) -> None:
236
+ if self._client:
237
+ self._client.close()