sqlbuddy-nwi 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ """Connection management for sql-buddy.
2
+
3
+ Public surface::
4
+
5
+ from connections import DBManager, LockedDBManager, connection_type
6
+
7
+ db = LockedDBManager() # read-only guarded clients (the default)
8
+ ck = db.ck # a Locked ClickHouse client
9
+ mysql = db.mysql # a Locked MySQL client
10
+
11
+ `LockedDBManager` is what the MCP server uses: every client it hands out is
12
+ wrapped by the read-only guard, so write keywords can never reach the network.
13
+ `DBManager` returns raw clients (used by tooling that supplies its own guard).
14
+
15
+ Implementation lives in focused submodules:
16
+ readonly - the SQL read-only filter
17
+ runtime - WSL / container / Windows-host-IP detection
18
+ wrappers - raw + Locked client wrappers
19
+ manager - DBManager / LockedDBManager + connection_type
20
+ """
21
+
22
+ from .manager import DBManager, LockedDBManager, connection_type
23
+ from .runtime import is_container, is_wsl
24
+
25
+ __all__ = [
26
+ 'DBManager',
27
+ 'LockedDBManager',
28
+ 'connection_type',
29
+ 'is_wsl',
30
+ 'is_container',
31
+ ]
@@ -0,0 +1,159 @@
1
+ """Shared config helpers for reading db.ini with cross-platform encoding support.
2
+
3
+ All modules that touch db.ini resolve its location and parse it through the
4
+ helpers here, never duplicating the path arithmetic.
5
+
6
+ Config-location resolution
7
+ (see docs/adr/0002-config-discovery-and-invocation-mode-on-publish.md):
8
+ config_path() - first existing candidate, for READ
9
+ config_write_path() - the detected home, for WRITE (the init wizard)
10
+ precedence: $SQL_BUDDY_CONFIG > platformdirs user dir > checkout-relative
11
+ """
12
+
13
+ import configparser
14
+ import os
15
+ import sys
16
+
17
+ APP_NAME = "sql-buddy"
18
+ _CONFIG_FILENAME = "db.ini"
19
+ _ENV_OVERRIDE = "SQL_BUDDY_CONFIG"
20
+
21
+ # config_util.py ships inside the `connections` package, so the checkout-relative
22
+ # config lives at <this dir>/config/db.ini.
23
+ _HERE = os.path.dirname(os.path.abspath(__file__))
24
+ _CHECKOUT_CONFIG_PATH = os.path.join(_HERE, "config", _CONFIG_FILENAME)
25
+ # A checkout is signalled by a `.git` entry at the repo root (a directory for a
26
+ # plain clone, a file for a linked worktree), or by a writable config dir
27
+ # holding the example. Wheels/sdists include neither, so installed copies never
28
+ # match here and correctly fall through to the per-user config dir.
29
+ _REPO_ROOT = os.path.dirname(_HERE)
30
+
31
+
32
+ def _env_config_path():
33
+ """$SQL_BUDDY_CONFIG may name the file directly or a directory holding it."""
34
+ raw = os.environ.get(_ENV_OVERRIDE)
35
+ if not raw:
36
+ return None
37
+ p = os.path.expanduser(raw)
38
+ if os.path.isdir(p):
39
+ return os.path.join(p, _CONFIG_FILENAME)
40
+ return p
41
+
42
+
43
+ def _user_config_path():
44
+ """Per-user config dir, or None if platformdirs isn't importable.
45
+
46
+ Returning None lets config_path()/config_write_path() degrade to the
47
+ checkout-relative path when the dependency is somehow absent, so a
48
+ platformdirs install failure can never break an existing checkout user.
49
+ """
50
+ try:
51
+ import platformdirs
52
+ except ImportError:
53
+ return None
54
+ return os.path.join(platformdirs.user_config_dir(APP_NAME), _CONFIG_FILENAME)
55
+
56
+
57
+ def is_checkout() -> bool:
58
+ """Whether sql-buddy is running from a source checkout vs an install.
59
+
60
+ Public so the CLI's invocation-mode detection can reuse the same signal
61
+ (see cli._mcp_entry). See _is_checkout docstring location for the rationale.
62
+ """
63
+ if os.path.exists(os.path.join(_REPO_ROOT, ".git")):
64
+ return True
65
+ cfg_dir = os.path.join(_HERE, "config")
66
+ example = os.path.join(cfg_dir, "db.ini.example")
67
+ return os.path.isfile(example) and os.access(cfg_dir, os.W_OK)
68
+
69
+
70
+ def config_write_path() -> str:
71
+ """Where the init wizard should write db.ini.
72
+
73
+ $SQL_BUDDY_CONFIG wins; otherwise a checkout writes checkout-relative (dev
74
+ back-compat) and an install writes to the per-user config dir.
75
+ """
76
+ env = _env_config_path()
77
+ if env is not None:
78
+ return env
79
+ if is_checkout():
80
+ return _CHECKOUT_CONFIG_PATH
81
+ return _user_config_path() or _CHECKOUT_CONFIG_PATH
82
+
83
+
84
+ def config_path() -> str:
85
+ """Where to READ db.ini from.
86
+
87
+ $SQL_BUDDY_CONFIG is strict (an explicit override). Otherwise the first
88
+ existing candidate among the per-user dir and the checkout-relative path. If
89
+ none exist yet, returns config_write_path() so callers can report the place
90
+ the user should create.
91
+ """
92
+ env = _env_config_path()
93
+ if env is not None:
94
+ return env
95
+ for candidate in (_user_config_path(), _CHECKOUT_CONFIG_PATH):
96
+ if candidate is not None and os.path.isfile(candidate):
97
+ return candidate
98
+ return config_write_path()
99
+
100
+
101
+ # Historical env override kept for back-compat: db_query used it as the root for
102
+ # ALL its caches (query_history + db_info_cache + db_schema_cache + table_list).
103
+ _CACHE_ENV_OVERRIDE = "DB_QUERY_HISTORY_DIR"
104
+ _CACHE_DIRNAME = "database"
105
+
106
+
107
+ def _user_cache_root():
108
+ """Per-user cache dir, or None if platformdirs isn't importable."""
109
+ try:
110
+ import platformdirs
111
+ except ImportError:
112
+ return None
113
+ return platformdirs.user_cache_dir(APP_NAME)
114
+
115
+
116
+ def cache_dir_path() -> str:
117
+ """Where sql-buddy writes runtime caches (query history, schema/info caches).
118
+
119
+ $DB_QUERY_HISTORY_DIR (historical override) > per-user cache dir (when not a
120
+ checkout) > the checkout-relative `database/` dir. Installed copies must
121
+ never write into the read-only package cache, so a non-checkout resolves to
122
+ the per-user cache dir; if platformdirs is somehow absent it falls back to
123
+ the checkout-relative path so a missing dep never crashes the server.
124
+ """
125
+ env = os.environ.get(_CACHE_ENV_OVERRIDE)
126
+ if env:
127
+ return os.path.expanduser(env)
128
+ if is_checkout():
129
+ return os.path.join(_REPO_ROOT, _CACHE_DIRNAME)
130
+ return _user_cache_root() or os.path.join(_REPO_ROOT, _CACHE_DIRNAME)
131
+
132
+
133
+ def resolve_env(value: str) -> str:
134
+ if isinstance(value, str) and value.startswith('$env:'):
135
+ var_name = value[5:]
136
+ resolved = os.environ.get(var_name)
137
+ if resolved is None:
138
+ raise EnvironmentError(
139
+ f'[配置缺失] $env:{var_name} 未在环境中设置。请确认 .env 文件存在(参考 .env.example)或手动设置该环境变量。'
140
+ )
141
+ return resolved
142
+ return value
143
+
144
+
145
+ def read_config(config_file: str) -> configparser.RawConfigParser:
146
+ cfg = configparser.RawConfigParser()
147
+ with open(config_file, "rb") as f:
148
+ raw = f.read()
149
+ encodings = ["utf-8-sig", "utf-8", "gbk"]
150
+ if sys.platform == "win32":
151
+ encodings.append("mbcs")
152
+ for enc in encodings:
153
+ try:
154
+ cfg.read_string(raw.decode(enc))
155
+ return cfg
156
+ except UnicodeDecodeError:
157
+ continue
158
+ cfg.read_string(raw.decode("utf-8", errors="replace"))
159
+ return cfg
connections/manager.py ADDED
@@ -0,0 +1,205 @@
1
+ """DBManager: builds and caches DB clients from db.ini sections.
2
+
3
+ `DBManager` returns raw driver clients; `LockedDBManager` returns clients
4
+ wrapped by the read-only guard (the "Locked" layer). Both honor the
5
+ self-managed SSH tunnel (`tunnel.py`): a tunneled connection connects to
6
+ `localhost:local_port`, and the cached client is rebuilt whenever the tunnel's
7
+ generation bumps (the previous TCP socket is dead after a restart).
8
+ """
9
+
10
+ import configparser
11
+ import os
12
+ import sys
13
+ import uuid
14
+ from contextlib import contextmanager
15
+ from typing import Any, Dict, Tuple
16
+
17
+ import pymysql
18
+ from clickhouse_driver import Client
19
+
20
+ from .config_util import read_config, resolve_env, config_path
21
+ from .runtime import get_windows_host_ip, is_wsl
22
+ from .wrappers import (
23
+ LockedClickHouseClient,
24
+ LockedMySQLConnectionWrapper,
25
+ MySQLConnectionWrapper,
26
+ )
27
+
28
+ # tunnel.py lives at the repo root; make sure it's importable regardless of cwd.
29
+ _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
30
+ if _REPO_ROOT not in sys.path:
31
+ sys.path.insert(0, _REPO_ROOT)
32
+ from tunnel import get_manager as _get_tunnel_manager
33
+
34
+
35
+ DEFAULT_REMOTE_DB_CREDS: Dict[str, Tuple[str, str]] = {}
36
+
37
+
38
+ def connection_type(cfg, section: str) -> str:
39
+ """Resolve a connection's DBMS type.
40
+
41
+ Reads an explicit `type = clickhouse|mysql` from the section; falls back to
42
+ inferring from the section-name prefix so older configs keep working.
43
+ """
44
+ explicit = cfg.get(section, 'type', fallback=None)
45
+ if explicit:
46
+ return explicit.strip().lower()
47
+ if section.startswith("ck"):
48
+ return 'clickhouse'
49
+ if section.startswith("db") or section.startswith("mysql"):
50
+ return 'mysql'
51
+ raise ValueError(
52
+ f"Unknown connection type for [{section}]; set `type = clickhouse|mysql` in db.ini"
53
+ )
54
+
55
+
56
+ class DBManager:
57
+ """Unlocked DBManager - returns raw connections."""
58
+
59
+ def __init__(self, config_file: str = None, remote_db_creds: Dict[str, Tuple[str, str]] = None, wsl_host_override: bool = True):
60
+ if config_file is None:
61
+ config_file = config_path()
62
+ if not os.path.exists(config_file):
63
+ raise FileNotFoundError(f"Config file not found: {config_file}")
64
+
65
+ cfg = read_config(config_file)
66
+ self.config_file = config_file
67
+ self._cfg = cfg
68
+ self._cache = {}
69
+ self._remote_db_creds = remote_db_creds or DEFAULT_REMOTE_DB_CREDS
70
+ self._wsl_host_override = wsl_host_override
71
+
72
+ @classmethod
73
+ def get(cls, name: str, config_file: str = None, **kwargs):
74
+ return cls(config_file, **kwargs).getattr(name)
75
+
76
+ @staticmethod
77
+ def load_config(config_file: str) -> configparser.RawConfigParser:
78
+ if config_file is None:
79
+ config_file = config_path()
80
+ if not os.path.exists(config_file):
81
+ raise FileNotFoundError(f"Config file not found: {config_file}")
82
+ return read_config(config_file)
83
+
84
+ def __getattr__(self, name: str):
85
+ section = name.replace("_", "-")
86
+ if section not in self._cfg:
87
+ raise AttributeError(f"No section [{section}] in config")
88
+
89
+ tm = _get_tunnel_manager()
90
+ block = tm.block_for(section)
91
+ if block is not None:
92
+ # Bring up (or confirm) the self-managed SSH tunnel before connecting.
93
+ tm.ensure_for(section)
94
+ # Generation changes when the tunnel was (re)started -> the cached client's
95
+ # TCP socket is dead and must be rebuilt. 0 for non-tunneled connections.
96
+ gen = tm.generation(block) if block is not None else 0
97
+
98
+ cached = self._cache.get(name)
99
+ if cached is not None and cached['gen'] == gen:
100
+ return cached['client']
101
+
102
+ ctype = connection_type(self._cfg, section)
103
+ if ctype == 'clickhouse':
104
+ client = self._create_clickhouse_client(section)
105
+ elif ctype in ('mysql', 'mariadb'):
106
+ client = self._create_mysql_client(section)
107
+ else:
108
+ raise ValueError(f"Unsupported connection type '{ctype}' for [{section}]")
109
+
110
+ self._cache[name] = {'client': client, 'gen': gen}
111
+ return client
112
+
113
+ def _resolve_conn_config(self, section: str, default_port: int) -> dict:
114
+ """Build driver kwargs for a section: strip meta keys, resolve host/port.
115
+
116
+ Self-tunneled connections connect to localhost:local_port (the SSH forward).
117
+ Otherwise the legacy WSL host-override applies when running inside WSL.
118
+ """
119
+ conn_config = dict(self._cfg[section])
120
+ # Meta keys consumed elsewhere, not by the DB driver.
121
+ for meta in ('type', 'tunnel', 'remote_host', 'remote_port', 'local_port'):
122
+ conn_config.pop(meta, None)
123
+ for k, v in list(conn_config.items()):
124
+ conn_config[k] = resolve_env(str(v))
125
+
126
+ if _get_tunnel_manager().block_for(section) is not None:
127
+ conn_config['host'] = 'localhost'
128
+ conn_config['port'] = int(_get_tunnel_manager().local_port_for(section))
129
+ else:
130
+ if self._wsl_host_override and is_wsl():
131
+ windows_ip = get_windows_host_ip()
132
+ if windows_ip:
133
+ conn_config['host'] = windows_ip
134
+ conn_config['port'] = int(conn_config.get('port', default_port))
135
+ return conn_config
136
+
137
+ def _create_clickhouse_client(self, section: str) -> Client:
138
+ return Client(**self._resolve_conn_config(section, 9000))
139
+
140
+ def _create_mysql_client(self, section: str) -> MySQLConnectionWrapper:
141
+ return MySQLConnectionWrapper(pymysql.connect(**self._resolve_conn_config(section, 3306)))
142
+
143
+ def all_clients(self) -> Dict[str, Any]:
144
+ return {name: entry['client'] for name, entry in self._cache.items()}
145
+
146
+ def close_all(self):
147
+ for entry in self._cache.values():
148
+ client = entry['client']
149
+ if hasattr(client, "disconnect"):
150
+ client.disconnect()
151
+ elif hasattr(client, "close"):
152
+ client.close()
153
+
154
+ @contextmanager
155
+ def session_scope(self, client_name: str = 'ck'):
156
+ client = getattr(self, client_name)
157
+ session_id = str(uuid.uuid4())
158
+ original_execute = client.execute
159
+ original_query_dataframe = getattr(client, 'query_dataframe', None)
160
+
161
+ def scoped_execute(query, params=None, settings=None, **kwargs):
162
+ if settings is None:
163
+ settings = {}
164
+ settings['session_id'] = session_id
165
+ return original_execute(query, params=params, settings=settings, **kwargs)
166
+
167
+ def scoped_query_dataframe(query, params=None, settings=None, **kwargs):
168
+ if settings is None:
169
+ settings = {}
170
+ settings['session_id'] = session_id
171
+ return original_query_dataframe(query, params=params, settings=settings, **kwargs)
172
+
173
+ class SessionClient:
174
+ def execute(self, *args, **kwargs):
175
+ return scoped_execute(*args, **kwargs)
176
+ def query_dataframe(self, *args, **kwargs):
177
+ if original_query_dataframe:
178
+ return scoped_query_dataframe(*args, **kwargs)
179
+ raise AttributeError("query_dataframe not available on this client")
180
+
181
+ yield SessionClient()
182
+
183
+ @contextmanager
184
+ def transaction_scope(self, connection: str):
185
+ conn = getattr(self, connection)
186
+ cursor = conn.cursor()
187
+ cursor.execute("SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED")
188
+ cursor.execute("SET SESSION innodb_lock_wait_timeout = 600")
189
+ cursor.close()
190
+ try:
191
+ yield conn
192
+ conn.commit()
193
+ except Exception:
194
+ conn.rollback()
195
+ raise
196
+
197
+
198
+ class LockedDBManager(DBManager):
199
+ """Returns read-only wrapped connections."""
200
+
201
+ def _create_clickhouse_client(self, section: str) -> LockedClickHouseClient:
202
+ return LockedClickHouseClient(super()._create_clickhouse_client(section))
203
+
204
+ def _create_mysql_client(self, section: str) -> LockedMySQLConnectionWrapper:
205
+ return LockedMySQLConnectionWrapper(super()._create_mysql_client(section))
@@ -0,0 +1,45 @@
1
+ """Read-only SQL guard.
2
+
3
+ The enforcement layer that makes every connection read-only by construction:
4
+ write/mutating keywords are rejected here, before any SQL reaches the network.
5
+ """
6
+
7
+ import re
8
+
9
+ # SQL verbs that mutate state. Matched as whole words (case-insensitive).
10
+ WRITE_KEYWORDS = [
11
+ 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER',
12
+ 'TRUNCATE', 'REPLACE', 'MERGE', 'CALL', 'EXEC', 'EXECUTE',
13
+ 'GRANT', 'REVOKE', 'LOCK', 'UNLOCK', 'SET GLOBAL',
14
+ 'KILL', 'LOAD DATA', 'START TRANSACTION', 'BEGIN'
15
+ ]
16
+
17
+ # Verbs a read-only query may begin with. Anything else is treated as a write.
18
+ READ_ONLY_KEYWORDS = [
19
+ 'SELECT', 'SHOW', 'DESCRIBE', 'DESC', 'EXPLAIN',
20
+ 'USE', 'WITH', 'EXISTS', 'CHECK', 'HELP'
21
+ ]
22
+
23
+
24
+ def _is_write_operation(sql: str) -> bool:
25
+ sql_upper = sql.strip().upper()
26
+ for keyword in READ_ONLY_KEYWORDS:
27
+ if sql_upper.startswith(keyword):
28
+ return False
29
+ for keyword in WRITE_KEYWORDS:
30
+ pattern = r'\b' + keyword + r'\b'
31
+ if re.search(pattern, sql_upper):
32
+ if keyword == 'CREATE' and 'SHOW' in sql_upper:
33
+ continue
34
+ return True
35
+ return False
36
+
37
+
38
+ def _filter_sql(sql: str) -> str:
39
+ """Pass through `sql` unchanged, or raise PermissionError if it mutates."""
40
+ if _is_write_operation(sql):
41
+ raise PermissionError(
42
+ "Write operation blocked by sql-buddy's read-only guard. "
43
+ f"Only READ queries are allowed. Blocked SQL: {sql[:100]}..."
44
+ )
45
+ return sql
connections/runtime.py ADDED
@@ -0,0 +1,51 @@
1
+ """Runtime environment detection.
2
+
3
+ Decides whether a connection's `host` should be rewritten to the Windows host
4
+ IP: when sql-buddy runs inside WSL but the user's tunnel is on the Windows side,
5
+ the driver must connect to the Windows host rather than `localhost`.
6
+ """
7
+
8
+ import os
9
+ from typing import Optional
10
+
11
+
12
+ def is_wsl() -> bool:
13
+ if is_container():
14
+ return False
15
+ try:
16
+ with open('/proc/version', 'r') as f:
17
+ version = f.read().lower()
18
+ return 'microsoft' in version or 'wsl' in version
19
+ except (FileNotFoundError, IOError):
20
+ return False
21
+
22
+
23
+ def is_container() -> bool:
24
+ if os.path.exists('/.dockerenv'):
25
+ return True
26
+ try:
27
+ with open('/proc/1/cgroup', 'r') as f:
28
+ cgroup = f.read()
29
+ if any(sig in cgroup for sig in ['docker', 'containerd', 'kubepods', 'lxc']):
30
+ return True
31
+ except (FileNotFoundError, IOError):
32
+ pass
33
+ for var in ['DOCKER_CONTAINER', 'DOCKER_ID', 'CONTAINER', 'KUBERNETES_SERVICE_HOST']:
34
+ if os.environ.get(var):
35
+ return True
36
+ return False
37
+
38
+
39
+ def get_windows_host_ip() -> Optional[str]:
40
+ if not is_wsl():
41
+ return None
42
+ try:
43
+ with open('/etc/resolv.conf', 'r') as f:
44
+ for line in f:
45
+ if line.strip().startswith('nameserver'):
46
+ ip = line.split()[1]
47
+ if ip and ip != '127.0.0.1':
48
+ return ip
49
+ except (FileNotFoundError, IOError):
50
+ pass
51
+ return None
@@ -0,0 +1,105 @@
1
+ """Connection wrappers: raw and read-only-guarded DB clients.
2
+
3
+ `MySQLConnectionWrapper` adapts a raw pymysql connection to the dataframe/
4
+ execute interface the rest of sql-buddy expects. The `Locked*` variants wrap a
5
+ client and route every statement through the read-only guard (see `readonly`)
6
+ so write keywords can never reach the network.
7
+
8
+ See UBIQUITOUS_LANGUAGE.md: "Locked" == the wrapper layer enforcing Read-only.
9
+ """
10
+
11
+ import pymysql
12
+ from clickhouse_driver import Client
13
+ import pandas as pd
14
+
15
+ from .readonly import _filter_sql
16
+
17
+
18
+ class MySQLConnectionWrapper:
19
+ def __init__(self, conn):
20
+ self._conn = conn
21
+
22
+ def __getattr__(self, name):
23
+ return getattr(self._conn, name)
24
+
25
+ def db_execute(self, sql: str, params: tuple = None) -> int:
26
+ cursor = self._conn.cursor()
27
+ try:
28
+ cursor.execute(sql, params)
29
+ self._conn.commit()
30
+ return cursor.rowcount
31
+ finally:
32
+ cursor.close()
33
+
34
+ def query_dataframe(self, sql: str) -> pd.DataFrame:
35
+ cursor = self._conn.cursor(pymysql.cursors.DictCursor)
36
+ try:
37
+ cursor.execute(sql)
38
+ rows = cursor.fetchall()
39
+ return pd.DataFrame(rows)
40
+ finally:
41
+ cursor.close()
42
+
43
+
44
+ class LockedMySQLConnectionWrapper:
45
+ def __init__(self, wrapped: MySQLConnectionWrapper):
46
+ self._wrapped = wrapped
47
+
48
+ def __getattr__(self, name):
49
+ return getattr(self._wrapped, name)
50
+
51
+ def cursor(self, *args, **kwargs):
52
+ return self._wrapped.cursor(*args, **kwargs)
53
+
54
+ def commit(self):
55
+ return self._wrapped.commit()
56
+
57
+ def rollback(self):
58
+ return self._wrapped.rollback()
59
+
60
+ def close(self):
61
+ return self._wrapped.close()
62
+
63
+ @property
64
+ def open(self):
65
+ return self._wrapped.open
66
+
67
+ def db_execute(self, sql: str, params: tuple = None) -> int:
68
+ _filter_sql(sql)
69
+ return self._wrapped.db_execute(sql, params)
70
+
71
+ def query_dataframe(self, sql: str) -> pd.DataFrame:
72
+ _filter_sql(sql)
73
+ return self._wrapped.query_dataframe(sql)
74
+
75
+
76
+ class LockedClickHouseClient:
77
+ def __init__(self, wrapped: Client):
78
+ self._wrapped = wrapped
79
+
80
+ def __getattr__(self, name):
81
+ return getattr(self._wrapped, name)
82
+
83
+ def execute(self, query, params=None, with_column_types=False, settings=None, **kwargs):
84
+ _filter_sql(query)
85
+ return self._wrapped.execute(
86
+ query, params=params,
87
+ with_column_types=with_column_types,
88
+ settings=settings, **kwargs
89
+ )
90
+
91
+ def query_dataframe(self, query, params=None, settings=None, **kwargs):
92
+ _filter_sql(query)
93
+ result = self._wrapped.execute(query, params=params, settings=settings, with_column_types=True, **kwargs)
94
+ if result:
95
+ data, columns = result
96
+ if data:
97
+ return pd.DataFrame(data, columns=[c[0] for c in columns])
98
+ return pd.DataFrame()
99
+
100
+ def disconnect(self):
101
+ return self._wrapped.disconnect()
102
+
103
+ @property
104
+ def connected(self):
105
+ return self._wrapped.connected