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,20 @@
1
+ _version.py,sha256=1eiY6aCIwIpr-2z7a8a_mIafcfROHIgB7o3LmtfWDBA,422
2
+ cli.py,sha256=pS_kxJIfx7MB3WK4QRBUzSFwaNUALwGxAhEDZgluJPo,47337
3
+ db_query.py,sha256=KzgCQeuSj4Q6yTf3Oqwn4uescR9ng91J6kosgfOMOHM,25951
4
+ db_security.py,sha256=cxVK17eqeD3e6UoK5wmdmTMPCiYzjGR9K7t9B2RBnjo,4510
5
+ init.py,sha256=hr290o_IHqk0HNl1MFEIVkzMTHyUjljDJ2wpwgENcv4,19885
6
+ server.py,sha256=afsewXZCoyKGwKpWlxLvYWAUxLlJJVa7q6OhhHsfHg0,11900
7
+ test_vendor.py,sha256=pY-5pCLvBw0vQFWrJ2b39IBOw7MVeeu2QCJvJI4Y2F4,1390
8
+ tunnel.py,sha256=LHDj75HUaQ7J4isvbnlyk6i_1ML6sNnwnDmzDsj1bM8,13047
9
+ connections/__init__.py,sha256=ZD5PdEy2zH6IttrHo44Vf0UteX3MucrmAUfmF0PkLRU,1035
10
+ connections/config_util.py,sha256=Co1yTc7WH2nWDQBDx37uJCju26egHIk08krwNFQu5OM,5768
11
+ connections/manager.py,sha256=ymNmormv6vymG-Ne7xTumqtGshcwpvhun_qt_YCnKSo,8110
12
+ connections/readonly.py,sha256=LhESIFiFxClBmlWLGUU6gXK4vum-UgAY6njtywjEdjM,1510
13
+ connections/runtime.py,sha256=IiEfYQv_qLr6YeGOJw3JhjTGJySAh7gd9KwLok6IB70,1525
14
+ connections/wrappers.py,sha256=HuMH3NHmNQEAVfmZ2ZESmxKDeUqMQUhPJN-o4FAvKdE,3099
15
+ sqlbuddy_nwi-0.3.1.dist-info/licenses/LICENSE,sha256=fgqAl1lZJDoYMWBcAy5mVE3ABFh3sY6KK0mqgcWzldU,1069
16
+ sqlbuddy_nwi-0.3.1.dist-info/METADATA,sha256=E2TaeWpndshNpcW2wIYQysEEI_O_sr4JTbhUZUCCmfE,33897
17
+ sqlbuddy_nwi-0.3.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
18
+ sqlbuddy_nwi-0.3.1.dist-info/entry_points.txt,sha256=Cyn50jUCXrPjjBMEgGudcT0EV91K1ioecNhlCOqA2RA,66
19
+ sqlbuddy_nwi-0.3.1.dist-info/top_level.txt,sha256=oXvsSUXss559LDH4TaluJRsblFqbJit9c8j2EemL7hg,77
20
+ sqlbuddy_nwi-0.3.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ sql-buddy = cli:main
3
+ sql-buddy-init = init:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ying.yuxiang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,9 @@
1
+ _version
2
+ cli
3
+ connections
4
+ db_query
5
+ db_security
6
+ init
7
+ server
8
+ test_vendor
9
+ tunnel
test_vendor.py ADDED
@@ -0,0 +1,50 @@
1
+ """Sanity-check that all vendored dependencies can be imported.
2
+
3
+ Run from the repo root (e.g. `python test_vendor.py`). Does NOT import the
4
+ application itself (which tries to read db.ini) — just the libraries.
5
+ """
6
+
7
+ import sys, os
8
+
9
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "_vendor"))
10
+ if sys.platform == "win32":
11
+ for _extra in (
12
+ os.path.join("_vendor", "win32", "lib"),
13
+ os.path.join("_vendor", "win32"),
14
+ os.path.join("_vendor", "pythonwin"),
15
+ ):
16
+ _path = os.path.join(os.path.dirname(os.path.abspath(__file__)), _extra)
17
+ if os.path.isdir(_path) and _path not in sys.path:
18
+ sys.path.insert(0, _path)
19
+ _dll = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_vendor", "pywin32_system32")
20
+ if os.path.isdir(_dll):
21
+ try:
22
+ os.add_dll_directory(_dll)
23
+ except OSError:
24
+ pass
25
+
26
+ MODULES = [
27
+ "clickhouse_driver",
28
+ "pymysql",
29
+ "paramiko",
30
+ "mcp",
31
+ "mcp.server.fastmcp",
32
+ "pydantic",
33
+ "httpx",
34
+ "pythoncom",
35
+ "pywintypes",
36
+ ]
37
+
38
+ errors = []
39
+ for mod in MODULES:
40
+ try:
41
+ __import__(mod)
42
+ print(f" OK {mod}")
43
+ except Exception as e:
44
+ errors.append((mod, str(e)))
45
+ print(f"FAIL {mod}: {e}")
46
+
47
+ if errors:
48
+ print(f"\n{len(errors)} module(s) failed.")
49
+ sys.exit(1)
50
+ print("\nAll vendored imports OK.")
tunnel.py ADDED
@@ -0,0 +1,345 @@
1
+ """Self-contained SSH tunnel manager for sql-buddy.
2
+
3
+ Reads `[ssh:<name>]` bastion blocks and per-connection tunnel mappings from
4
+ `db.ini`, spawns ONE `ssh -N` subprocess per bastion (lazy, on first use), keeps
5
+ it supervised for the lifetime of the process, and tears it down at exit.
6
+
7
+ Key-based auth only (the `-i <keyfile>` path). One bastion -> one tunnel, even
8
+ when many DB connections sit behind it: every connection referencing a given
9
+ `[ssh:*]` block contributes a `-L` forward to that single subprocess.
10
+
11
+ A DB connection opts in like this::
12
+
13
+ [ssh:prod]
14
+ host = 192.0.2.10
15
+ port = 22222
16
+ user = secret
17
+ key = ~/.ssh/prod_key
18
+
19
+ [ck]
20
+ tunnel = ssh:prod
21
+ remote_host = 10.0.0.21 # DB host AS SEEN FROM the bastion
22
+ remote_port = 9000 # DB port AS SEEN FROM the bastion
23
+ local_port = 10021 # localhost port ssh binds == driver connect port
24
+
25
+ The DB driver then connects to `localhost:local_port`; it never knows about SSH.
26
+ """
27
+
28
+ import atexit
29
+ import configparser
30
+ import logging
31
+ import os
32
+ import shutil
33
+ import socket
34
+ import subprocess
35
+ import sys
36
+ import threading
37
+ import time
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ # Import shared config helpers from the connections package, avoiding any
42
+ # circular-import risk by importing from the leaf module rather than the
43
+ # connections __init__ (which imports DBManager -> tunnel).
44
+ try:
45
+ from connections.config_util import (
46
+ read_config as _read_config,
47
+ resolve_env as _resolve_env,
48
+ config_path as _config_path,
49
+ )
50
+ except ImportError:
51
+ _resolve_env = None
52
+ _read_config = None
53
+ _config_path = None
54
+
55
+
56
+ _CONFIG_FILE = (_config_path() if _config_path
57
+ else os.path.join(os.path.dirname(os.path.abspath(__file__)),
58
+ 'connections', 'config', 'db.ini'))
59
+
60
+ # ssh defaults
61
+ DEFAULT_SSH_PORT = 22
62
+ DEFAULT_ALIVE_INTERVAL = 30
63
+ DEFAULT_ALIVE_COUNT_MAX = 3
64
+
65
+ # How long to wait (s) for freshly-forwarded local ports to accept connections.
66
+ PORT_READY_TIMEOUT = 15.0
67
+ PORT_READY_POLL_INTERVAL = 0.1
68
+ # Tail length kept for failure diagnostics.
69
+ STDERR_TAIL_LINES = 50
70
+
71
+
72
+ # --- config parsing -------------------------------------------------------
73
+
74
+ def _normalize(section: str) -> str:
75
+ """Match the connection manager's `_`/`-` interchangeability for connection names."""
76
+ return section.replace('_', '-')
77
+
78
+
79
+ def _ssh_blocks(cfg) -> dict:
80
+ """Return {block_name: section_dict} for every `[ssh:*]` section."""
81
+ blocks = {}
82
+ prefix = 'ssh:'
83
+ for section in cfg.sections():
84
+ if section.lower().startswith(prefix):
85
+ blocks[section[len(prefix):]] = dict(cfg[section])
86
+ return blocks
87
+
88
+
89
+ def _conn_tunnels(cfg) -> dict:
90
+ """Return {conn_section: (block, local_port, remote_host, remote_port)}
91
+ for every DB connection that declares a `tunnel` key. Raises on misconfig."""
92
+ out = {}
93
+ for section in cfg.sections():
94
+ if section.lower().startswith('ssh:'):
95
+ continue
96
+ if not cfg.has_option(section, 'tunnel'):
97
+ continue
98
+ block = cfg.get(section, 'tunnel').strip()
99
+ if block.lower().startswith('ssh:'):
100
+ block = block[4:] # `[ssh:prod]` section -> internal block name 'prod'
101
+ try:
102
+ local_port = int(cfg.get(section, 'local_port'))
103
+ remote_port = int(cfg.get(section, 'remote_port'))
104
+ except (configparser.NoOptionError, ValueError) as e:
105
+ raise ValueError(
106
+ f"[{section}] declares tunnel='{block}' but needs integer "
107
+ f"`local_port` and `remote_port` ({e})"
108
+ )
109
+ if not cfg.has_option(section, 'remote_host'):
110
+ raise ValueError(
111
+ f"[{section}] declares tunnel='{block}' but is missing `remote_host`"
112
+ )
113
+ remote_host = cfg.get(section, 'remote_host').strip()
114
+ out[section] = (block, local_port, remote_host, remote_port)
115
+ return out
116
+
117
+
118
+ def _port_open(port: int, host: str = '127.0.0.1', timeout: float = 0.5) -> bool:
119
+ try:
120
+ with socket.create_connection((host, port), timeout=timeout):
121
+ return True
122
+ except OSError:
123
+ return False
124
+
125
+
126
+ # --- manager --------------------------------------------------------------
127
+
128
+ class TunnelManager:
129
+ """Owns the ssh subprocesses for this process. Thread-safe singleton via get_manager()."""
130
+
131
+ def __init__(self, config_file: str = None):
132
+ self._config_file = config_file or _CONFIG_FILE
133
+ self._lock = threading.RLock()
134
+ self._cfg = None
135
+ self._blocks: dict = {} # {block: section params}
136
+ self._conn_tunnels: dict = {} # {conn_section: (block, lp, rh, rp)}
137
+ self._procs: dict = {} # {block: Popen}
138
+ self._gen: dict = {} # {block: generation int}
139
+ self._stderr_tail: dict = {} # {block: [lines]}
140
+ self._loaded = False
141
+ self._atexit_registered = False
142
+
143
+ # -- config --
144
+ def _load(self):
145
+ if self._loaded:
146
+ return
147
+ cfg = _read_config(self._config_file)
148
+ self._cfg = cfg
149
+ self._blocks = _ssh_blocks(cfg)
150
+ self._conn_tunnels = _conn_tunnels(cfg)
151
+ self._loaded = True
152
+ if not self._atexit_registered:
153
+ atexit.register(self.stop_all)
154
+ self._atexit_registered = True
155
+
156
+ def reload(self):
157
+ """Force a re-read of db.ini (e.g. after the user edits it)."""
158
+ with self._lock:
159
+ self._loaded = False
160
+ self._load()
161
+
162
+ # -- introspection (used by the connection manager) --
163
+ def tunnel_for(self, conn_section: str):
164
+ """Return (block, local_port, remote_host, remote_port) or None if not tunneled."""
165
+ self._load()
166
+ return self._conn_tunnels.get(_normalize(conn_section))
167
+
168
+ def block_for(self, conn_section: str):
169
+ """Return the ssh block name for a connection, or None if it isn't tunneled."""
170
+ t = self.tunnel_for(conn_section)
171
+ return t[0] if t else None
172
+
173
+ def local_port_for(self, conn_section: str):
174
+ """Return the local forward port for a tunneled connection, or None."""
175
+ t = self.tunnel_for(conn_section)
176
+ return t[1] if t else None
177
+
178
+ def generation(self, block: str) -> int:
179
+ """Bumped each time this block's tunnel is (re)started. 0 if never started."""
180
+ with self._lock:
181
+ return self._gen.get(block, 0)
182
+
183
+ # -- lifecycle --
184
+ def ensure_for(self, conn_section: str):
185
+ """Make sure the bastion tunnel serving conn_section is up. Returns block or None."""
186
+ block = self.block_for(conn_section)
187
+ if block is None:
188
+ return None
189
+ self._ensure_block(block)
190
+ return block
191
+
192
+ def _forwards_for(self, block: str) -> list:
193
+ """All (local_port, remote_host, remote_port) tuples using `block`."""
194
+ return [
195
+ (lp, rh, rp)
196
+ for (b, lp, rh, rp) in self._conn_tunnels.values()
197
+ if b == block
198
+ ]
199
+
200
+ def _build_cmd(self, block: str, forwards: list) -> list:
201
+ p = self._blocks[block]
202
+ # expanduser handles ~/… and ~\… on Windows; normpath fixes mixed slashes.
203
+ key = os.path.normpath(os.path.expanduser(_resolve_env(p['key'])))
204
+ ssh_port = int(_resolve_env(p.get('port', DEFAULT_SSH_PORT)))
205
+ alive_int = _resolve_env(p.get('server_alive_interval', DEFAULT_ALIVE_INTERVAL))
206
+ alive_max = _resolve_env(p.get('server_alive_count_max', DEFAULT_ALIVE_COUNT_MAX))
207
+ user, host = _resolve_env(p['user']), _resolve_env(p['host'])
208
+
209
+ # Prefer the resolved binary so GUI-spawned MCP (Trae) finds OpenSSH even
210
+ # when the shell PATH differs from the IDE's PATH.
211
+ ssh_bin = shutil.which('ssh') or 'ssh'
212
+ cmd = [
213
+ ssh_bin, '-N',
214
+ '-o', f'ServerAliveInterval={alive_int}',
215
+ '-o', f'ServerAliveCountMax={alive_max}',
216
+ '-o', 'ExitOnForwardFailure=yes', # die loudly on a port collision
217
+ '-o', 'StrictHostKeyChecking=accept-new', # no first-run prompt hang
218
+ '-p', str(ssh_port),
219
+ '-i', key,
220
+ ]
221
+ for lp, rh, rp in forwards:
222
+ cmd += ['-L', f'127.0.0.1:{lp}:{rh}:{rp}'] # localhost-only bind
223
+ cmd.append(f'{user}@{host}')
224
+ return cmd
225
+
226
+ def _popen_kwargs(self) -> dict:
227
+ """Subprocess flags that work under GUI MCP hosts (esp. Windows/Trae)."""
228
+ kwargs: dict = {
229
+ "stdin": subprocess.DEVNULL,
230
+ "stdout": subprocess.DEVNULL,
231
+ "stderr": subprocess.PIPE,
232
+ }
233
+ if sys.platform == "win32":
234
+ # CREATE_NO_WINDOW: no flashing console when Trae/Claude spawns us.
235
+ # CREATE_NEW_PROCESS_GROUP: detach from parent's console ctrl-events.
236
+ flags = 0
237
+ flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0)
238
+ flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
239
+ if flags:
240
+ kwargs["creationflags"] = flags
241
+ else:
242
+ # Own process group -> survives parent's signal storm.
243
+ kwargs["start_new_session"] = True
244
+ return kwargs
245
+
246
+ def _ensure_block(self, block: str):
247
+ self._load()
248
+ if block not in self._blocks:
249
+ raise ValueError(
250
+ f"Unknown ssh block '{block}'. Define an [ssh:{block}] section in db.ini."
251
+ )
252
+ forwards = self._forwards_for(block)
253
+ if not forwards:
254
+ return # nothing references this block yet -> nothing to forward
255
+
256
+ with self._lock:
257
+ proc = self._procs.get(block)
258
+ if proc is not None and proc.poll() is None:
259
+ return # already alive
260
+
261
+ cmd = self._build_cmd(block, forwards)
262
+ logger.info("Starting SSH tunnel for [%s]: %s", block, ' '.join(cmd))
263
+ try:
264
+ proc = subprocess.Popen(cmd, **self._popen_kwargs())
265
+ except FileNotFoundError as e:
266
+ raise RuntimeError(
267
+ f"SSH binary not found while starting tunnel [{block}]. "
268
+ f"On Windows install OpenSSH Client (Settings → Apps → "
269
+ f"Optional features). Original error: {e}"
270
+ ) from e
271
+ self._procs[block] = proc
272
+ self._stderr_tail[block] = []
273
+ self._gen[block] = self._gen.get(block, 0) + 1
274
+ self._start_drain(block, proc)
275
+
276
+ ports = [lp for (lp, _rh, _rp) in forwards]
277
+ if not self._wait_ready(block, proc, ports):
278
+ err = self._read_stderr(block)
279
+ self._terminate(block)
280
+ raise RuntimeError(
281
+ f"SSH tunnel for [{block}] did not come up on local ports {ports}. "
282
+ f"ssh stderr: {err or '(empty)'}"
283
+ )
284
+
285
+ def _start_drain(self, block: str, proc: subprocess.Popen):
286
+ """Continuously drain ssh stderr into a bounded tail (prevents pipe-buffer deadlock)."""
287
+ def drain():
288
+ stream = proc.stderr
289
+ if stream is None:
290
+ return
291
+ for line in iter(stream.readline, ''):
292
+ if isinstance(line, bytes):
293
+ line = line.decode('utf-8', errors='replace')
294
+ with self._lock:
295
+ tail = self._stderr_tail.setdefault(block, [])
296
+ tail.append(line.rstrip())
297
+ if len(tail) > STDERR_TAIL_LINES:
298
+ del tail[:-STDERR_TAIL_LINES]
299
+ threading.Thread(target=drain, name=f"ssh-drain:{block}", daemon=True).start()
300
+
301
+ def _wait_ready(self, block: str, proc: subprocess.Popen, ports: list,
302
+ timeout: float = PORT_READY_TIMEOUT) -> bool:
303
+ deadline = time.monotonic() + timeout
304
+ while time.monotonic() < deadline:
305
+ if proc.poll() is not None:
306
+ return False # ssh exited before ports opened
307
+ if all(_port_open(p) for p in ports):
308
+ return True
309
+ time.sleep(PORT_READY_POLL_INTERVAL)
310
+ return False
311
+
312
+ def _read_stderr(self, block: str) -> str:
313
+ with self._lock:
314
+ return '\n'.join(self._stderr_tail.get(block, []))
315
+
316
+ def _terminate(self, block: str):
317
+ with self._lock:
318
+ proc = self._procs.pop(block, None)
319
+ if proc is None:
320
+ return
321
+ if proc.poll() is None:
322
+ proc.terminate()
323
+ try:
324
+ proc.wait(timeout=5)
325
+ except subprocess.TimeoutExpired:
326
+ proc.kill()
327
+
328
+ def stop_all(self):
329
+ """Tear down every tunnel. Registered via atexit."""
330
+ with self._lock:
331
+ blocks = list(self._procs.keys())
332
+ for block in blocks:
333
+ logger.info("Stopping SSH tunnel for [%s]", block)
334
+ self._terminate(block)
335
+
336
+
337
+ # process-wide singleton
338
+ _manager: TunnelManager | None = None
339
+
340
+
341
+ def get_manager() -> TunnelManager:
342
+ global _manager
343
+ if _manager is None:
344
+ _manager = TunnelManager()
345
+ return _manager