dbctl 0.6.2__tar.gz → 0.6.4__tar.gz
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.
- {dbctl-0.6.2 → dbctl-0.6.4}/.dbctl/connections.yaml +53 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/CHANGELOG.md +73 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/PKG-INFO +10 -4
- {dbctl-0.6.2 → dbctl-0.6.4}/README.md +6 -2
- {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/cli.py +127 -4
- {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/config.py +14 -2
- {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/db.py +23 -6
- {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/init.py +11 -1
- {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/multi.py +2 -2
- {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/tunnels/base.py +19 -3
- {dbctl-0.6.2 → dbctl-0.6.4}/docs/connections.md +67 -1
- {dbctl-0.6.2 → dbctl-0.6.4}/docs/tutorial.md +31 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/pyproject.toml +4 -2
- {dbctl-0.6.2 → dbctl-0.6.4}/.dbctl/operations.yaml +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/.github/workflows/ci.yml +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/.github-local/ci.yml +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/.gitignore +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/Makefile +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/__init__.py +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/__main__.py +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/audit.py +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/connections.py +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/execute.py +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/operations.py +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/reports.py +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/runtime.py +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/tunnels/__init__.py +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/tunnels/direct.py +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/tunnels/k8s.py +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/tunnels/ssh.py +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/dbctl/tunnels/ssm.py +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/docker-compose.yml +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/docs/ACTION_OUTPUT.md +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/docs/DESIGN.md +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/docs/SESSION_STATE.md +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/docs/logo.png +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/docs/logo_small.png +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/docs/operations.md +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/seed/mssql.sql +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/seed/mysql.sql +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/seed/postgres.sql +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/tests/test_bastion_tags.py +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/tests/test_connections_loader.py +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/tests/test_k8s_tunnel.py +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/tests/test_regressions.py +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/tests/test_smoke.py +0 -0
- {dbctl-0.6.2 → dbctl-0.6.4}/uv.lock +0 -0
|
@@ -226,3 +226,56 @@ connections:
|
|
|
226
226
|
safety:
|
|
227
227
|
confirm: true
|
|
228
228
|
read_only: true
|
|
229
|
+
|
|
230
|
+
# --------------------------------------------------------------------- #
|
|
231
|
+
# Oracle, SQLite, DuckDB reference templates.
|
|
232
|
+
# --------------------------------------------------------------------- #
|
|
233
|
+
|
|
234
|
+
# Oracle Database — oracledb thin mode (pure Python, no Instant Client).
|
|
235
|
+
# Use `database:` for the service name (or SID). Healthcheck uses
|
|
236
|
+
# `SELECT 1 FROM DUAL` which is the Oracle convention.
|
|
237
|
+
prod-oracle:
|
|
238
|
+
description: "REFERENCE: Oracle via oracledb thin mode (edit before using)"
|
|
239
|
+
aliases: []
|
|
240
|
+
type: direct
|
|
241
|
+
driver: oracle+oracledb
|
|
242
|
+
database: ORCLPDB1
|
|
243
|
+
username: app_admin
|
|
244
|
+
password: "<set-me>"
|
|
245
|
+
direct: { host: db.internal, port: 1521 }
|
|
246
|
+
healthcheck: { query: "SELECT 1 FROM DUAL", timeout_seconds: 10 }
|
|
247
|
+
safety:
|
|
248
|
+
confirm: true
|
|
249
|
+
read_only: true
|
|
250
|
+
|
|
251
|
+
# Local SQLite — file-based, no host/port needed (but config schema
|
|
252
|
+
# requires a `direct:` block). Use `url:` mode for a cleaner config:
|
|
253
|
+
# url: "sqlite:////absolute/path/to/mydata.db"
|
|
254
|
+
local-sqlite:
|
|
255
|
+
description: "REFERENCE: Local SQLite (edit path before using)"
|
|
256
|
+
aliases: []
|
|
257
|
+
type: direct
|
|
258
|
+
driver: sqlite
|
|
259
|
+
database: /tmp/mydata.db
|
|
260
|
+
username: ""
|
|
261
|
+
password: ""
|
|
262
|
+
direct: { host: localhost, port: 0 }
|
|
263
|
+
healthcheck: { query: "SELECT 1" }
|
|
264
|
+
safety:
|
|
265
|
+
confirm: true
|
|
266
|
+
read_only: true
|
|
267
|
+
|
|
268
|
+
# Local DuckDB — file-based (or ":memory:" for in-memory analytics).
|
|
269
|
+
local-duckdb:
|
|
270
|
+
description: "REFERENCE: Local DuckDB (edit path before using)"
|
|
271
|
+
aliases: []
|
|
272
|
+
type: direct
|
|
273
|
+
driver: duckdb
|
|
274
|
+
database: /tmp/mydata.duckdb
|
|
275
|
+
username: ""
|
|
276
|
+
password: ""
|
|
277
|
+
direct: { host: localhost, port: 0 }
|
|
278
|
+
healthcheck: { query: "SELECT 1" }
|
|
279
|
+
safety:
|
|
280
|
+
confirm: true
|
|
281
|
+
read_only: true
|
|
@@ -5,6 +5,79 @@ All notable changes to this project will be documented here.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.6.4] — 2026-08-03
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`tunnel open --port`/`-p` flag** — override the local bind port even
|
|
13
|
+
when the config says `local_port: 0` (auto-pick). Works for `ssm` /
|
|
14
|
+
`ssh` / `k8s` tunnels (creates a local listener on the chosen port) and
|
|
15
|
+
`direct` (changes which upstream port SQLAlchemy connects to).
|
|
16
|
+
- **`tunnel test <conn>` subcommand** — opens the tunnel, runs the
|
|
17
|
+
connection's healthcheck query, then closes the tunnel. Prints
|
|
18
|
+
`OK <conn> via <type> (host:port) — ok (latency, total)` (green) on
|
|
19
|
+
success or `FAIL <conn> <stage>: <msg>` (red) on failure. Exit codes
|
|
20
|
+
match the existing convention: 2 unknown conn, 3 tunnel error, 4 engine
|
|
21
|
+
error, 5 healthcheck failed.
|
|
22
|
+
- **`tunnel list` subcommand** — lists every configured connection with
|
|
23
|
+
its tunnel type (`ssm` / `ssh` / `k8s` / `direct`), driver, and key
|
|
24
|
+
parameters in a rich table. For `ssm`: bastion id/tags, remote
|
|
25
|
+
host:port, region. For `ssh`: bastion user@host:port, remote
|
|
26
|
+
host:port. For `k8s`: context, target, namespace, remote port. For
|
|
27
|
+
`direct`: host:port.
|
|
28
|
+
- **`tunnel` group help** — `dbctl tunnel --help` now explains the four
|
|
29
|
+
tunnel types (ssm/ssh/k8s/direct) and lists the three subcommands
|
|
30
|
+
(open/test/list) with one-liner descriptions.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- `build_tunnel()` in `dbctl/tunnels/base.py` accepts an optional
|
|
35
|
+
`override_port` parameter; the CLI `tunnel open --port` flag flows
|
|
36
|
+
through to it. The override is applied via `model_copy(update=)` so
|
|
37
|
+
the original config object is not mutated.
|
|
38
|
+
|
|
39
|
+
## [0.6.3] — 2026-08-03
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- **Oracle Database support** — `oracle+oracledb` driver (thin mode, pure
|
|
44
|
+
Python — no Oracle Instant Client needed). `oracledb>=2` added as a
|
|
45
|
+
core dependency. The init wizard offers it in the driver choice list;
|
|
46
|
+
`_default_port` returns 1521 for Oracle. Native-lib hint for Oracle
|
|
47
|
+
Instant Client (`libclntsh` / `libociei` / `libocci`) failure path
|
|
48
|
+
included in `dbctl.db._native_lib_hint`. Healthcheck query convention
|
|
49
|
+
is `SELECT 1 FROM DUAL`.
|
|
50
|
+
- **SQLite support** — `sqlite` driver (built into Python stdlib, no
|
|
51
|
+
extra dependency). File-based: `build_engine` skips host/port/
|
|
52
|
+
username/password injection (just `sqlite:///path`). Config validator
|
|
53
|
+
exempts file-based drivers (sqlite + duckdb) from the credential
|
|
54
|
+
requirement — `username` and `password` are not needed.
|
|
55
|
+
- **DuckDB support** — `duckdb` driver. `duckdb>=1` added as a core
|
|
56
|
+
dependency. Same file-based handling as SQLite (`duckdb:///path` or
|
|
57
|
+
`duckdb:///:memory:`).
|
|
58
|
+
- **`replay_spec.on_conflict`** field — the conflict-handling strategy
|
|
59
|
+
for replay mode. Defaults to `skip` (additive — replay new/changed
|
|
60
|
+
rows without breaking existing entries), unlike `copy_spec` which
|
|
61
|
+
defaults to `error`. Use `truncate` for a full refresh.
|
|
62
|
+
- **`logo_small.png`** added to top of every `docs/*.md` file; README
|
|
63
|
+
gets a centred logo_small footer (big logo stays at top).
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- **`replay-users` crashed on existing rows** — hardcoded
|
|
68
|
+
`on_conflict=error` meant any PK collision in the target aborted the
|
|
69
|
+
replay. Now uses `replay_spec.on_conflict` (default `skip`), so
|
|
70
|
+
`INSERT IGNORE` / `ON CONFLICT DO NOTHING` handles duplicates cleanly
|
|
71
|
+
and the report shows inserted vs skipped counts correctly.
|
|
72
|
+
- **File-based drivers (sqlite/duckdb) rejected by config validator**
|
|
73
|
+
— required username + password even though the drivers ignore them.
|
|
74
|
+
The validator now exempts `sqlite*` and `duckdb*` from the credential
|
|
75
|
+
requirement.
|
|
76
|
+
- **File-based drivers: `build_engine` injected host:port into the URL**
|
|
77
|
+
— SQLAlchemy rejected `sqlite://:***@localhost:0//path` with an
|
|
78
|
+
ArgumentError. Now skips host/port/username/password for file-based
|
|
79
|
+
drivers; the URL is just `sqlite:///path`.
|
|
80
|
+
|
|
8
81
|
## [0.6.2] — 2026-08-03
|
|
9
82
|
|
|
10
83
|
### Added
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dbctl
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.4
|
|
4
4
|
Summary: Generic CLI to monitor, control, and administer multiple databases via SSM, SSH, or direct connection.
|
|
5
5
|
Author: dbctl contributors
|
|
6
6
|
License: MIT
|
|
7
|
-
Keywords: cli,database,mssql,mysql,postgres,ssh,ssm,tunnel
|
|
7
|
+
Keywords: cli,database,duckdb,mssql,mysql,oracle,postgres,sqlite,ssh,ssm,tunnel
|
|
8
8
|
Classifier: Development Status :: 3 - Alpha
|
|
9
9
|
Classifier: Environment :: Console
|
|
10
10
|
Classifier: Intended Audience :: Developers
|
|
@@ -16,6 +16,8 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
16
16
|
Classifier: Topic :: Database
|
|
17
17
|
Requires-Python: >=3.12
|
|
18
18
|
Requires-Dist: click>=8.1
|
|
19
|
+
Requires-Dist: duckdb>=1
|
|
20
|
+
Requires-Dist: oracledb>=2
|
|
19
21
|
Requires-Dist: psycopg[binary]>=3.1
|
|
20
22
|
Requires-Dist: pydantic>=2
|
|
21
23
|
Requires-Dist: pymysql>=1.1
|
|
@@ -85,8 +87,9 @@ your shell history. (For ad-hoc exploration open the tunnel with
|
|
|
85
87
|
| `direct` | No tunnel — connect to the upstream host:port directly | none |
|
|
86
88
|
|
|
87
89
|
Each connection declares its SQLAlchemy URL scheme (`postgresql+psycopg`,
|
|
88
|
-
`mysql+pymysql`, `mariadb+pymysql`, `mssql+pyodbc`,
|
|
89
|
-
|
|
90
|
+
`mysql+pymysql`, `mariadb+pymysql`, `mssql+pyodbc`, `oracle+oracledb`,
|
|
91
|
+
`sqlite`, `duckdb`, …), a healthcheck query, optional introspection
|
|
92
|
+
(`info`) queries, and a `safety` policy.
|
|
90
93
|
|
|
91
94
|
Operations are declared separately in `operations.yaml`. Each operation is a
|
|
92
95
|
parameterised SQL block (using `$name` placeholders) with declared parameters;
|
|
@@ -192,6 +195,9 @@ dbctl pg history # per-connection audit log
|
|
|
192
195
|
dbctl pg again # re-run last op on pg
|
|
193
196
|
|
|
194
197
|
dbctl tunnel open pg # hold tunnel for ad-hoc psql
|
|
198
|
+
dbctl tunnel open pg --port 15432 # override the local bind port
|
|
199
|
+
dbctl tunnel test pg # open + healthcheck + close, report OK/FAIL
|
|
200
|
+
dbctl tunnel list # list all connections + tunnel info
|
|
195
201
|
```
|
|
196
202
|
|
|
197
203
|
> SQL Server needs an installed ODBC driver on the host (`ODBC Driver 18 for
|
|
@@ -53,8 +53,9 @@ your shell history. (For ad-hoc exploration open the tunnel with
|
|
|
53
53
|
| `direct` | No tunnel — connect to the upstream host:port directly | none |
|
|
54
54
|
|
|
55
55
|
Each connection declares its SQLAlchemy URL scheme (`postgresql+psycopg`,
|
|
56
|
-
`mysql+pymysql`, `mariadb+pymysql`, `mssql+pyodbc`,
|
|
57
|
-
|
|
56
|
+
`mysql+pymysql`, `mariadb+pymysql`, `mssql+pyodbc`, `oracle+oracledb`,
|
|
57
|
+
`sqlite`, `duckdb`, …), a healthcheck query, optional introspection
|
|
58
|
+
(`info`) queries, and a `safety` policy.
|
|
58
59
|
|
|
59
60
|
Operations are declared separately in `operations.yaml`. Each operation is a
|
|
60
61
|
parameterised SQL block (using `$name` placeholders) with declared parameters;
|
|
@@ -160,6 +161,9 @@ dbctl pg history # per-connection audit log
|
|
|
160
161
|
dbctl pg again # re-run last op on pg
|
|
161
162
|
|
|
162
163
|
dbctl tunnel open pg # hold tunnel for ad-hoc psql
|
|
164
|
+
dbctl tunnel open pg --port 15432 # override the local bind port
|
|
165
|
+
dbctl tunnel test pg # open + healthcheck + close, report OK/FAIL
|
|
166
|
+
dbctl tunnel list # list all connections + tunnel info
|
|
163
167
|
```
|
|
164
168
|
|
|
165
169
|
> SQL Server needs an installed ODBC driver on the host (`ODBC Driver 18 for
|
|
@@ -1279,13 +1279,36 @@ def history_show(ctx, run_id):
|
|
|
1279
1279
|
|
|
1280
1280
|
@main.group("tunnel")
|
|
1281
1281
|
def tunnel_cmd():
|
|
1282
|
-
"""
|
|
1282
|
+
"""Open, test, or list database tunnels.
|
|
1283
|
+
|
|
1284
|
+
\b
|
|
1285
|
+
Tunnels connect dbctl to databases that aren't directly reachable:
|
|
1286
|
+
- ssm: AWS SSM port-forward through an EC2 bastion
|
|
1287
|
+
- ssh: Classic ssh -N -L port-forward through a bastion
|
|
1288
|
+
- k8s: kubectl port-forward to a Service or Pod
|
|
1289
|
+
- direct: No tunnel — connect to upstream host:port
|
|
1290
|
+
|
|
1291
|
+
\b
|
|
1292
|
+
Subcommands:
|
|
1293
|
+
- tunnel open <conn> [--port N] Hold a tunnel open for ad-hoc work
|
|
1294
|
+
- tunnel test <conn> Open + healthcheck + close, report OK/FAIL
|
|
1295
|
+
- tunnel list List all connections with tunnel info
|
|
1296
|
+
"""
|
|
1283
1297
|
|
|
1284
1298
|
|
|
1285
1299
|
@tunnel_cmd.command("open")
|
|
1286
1300
|
@click.argument("name")
|
|
1301
|
+
@click.option(
|
|
1302
|
+
"-p",
|
|
1303
|
+
"--port",
|
|
1304
|
+
"port",
|
|
1305
|
+
type=click.INT,
|
|
1306
|
+
default=None,
|
|
1307
|
+
help="Override the local bind port (even if config says 0 = auto).",
|
|
1308
|
+
)
|
|
1287
1309
|
@click.pass_context
|
|
1288
|
-
def tunnel_open(ctx, name):
|
|
1310
|
+
def tunnel_open(ctx, name, port):
|
|
1311
|
+
"""Hold a tunnel open for ad-hoc work (Ctrl-C to close)."""
|
|
1289
1312
|
from dbctl.connections import resolve
|
|
1290
1313
|
|
|
1291
1314
|
conns, _ = registries(ctx)
|
|
@@ -1296,8 +1319,12 @@ def tunnel_open(ctx, name):
|
|
|
1296
1319
|
raise SystemExit(2)
|
|
1297
1320
|
from dbctl.tunnels.base import build_tunnel
|
|
1298
1321
|
|
|
1299
|
-
tun = build_tunnel(c)
|
|
1300
|
-
|
|
1322
|
+
tun = build_tunnel(c, override_port=port)
|
|
1323
|
+
try:
|
|
1324
|
+
tun.__enter__()
|
|
1325
|
+
except RuntimeError as e:
|
|
1326
|
+
err_console.print(f"[red]tunnel error:[/red] {e}")
|
|
1327
|
+
raise SystemExit(3)
|
|
1301
1328
|
console.print(f"[green]tunnel open:[/green] {tun.local_host}:{tun.local_port} -> {canonical}")
|
|
1302
1329
|
console.print("[dim]Ctrl-C to close...[/dim]")
|
|
1303
1330
|
try:
|
|
@@ -1309,6 +1336,102 @@ def tunnel_open(ctx, name):
|
|
|
1309
1336
|
tun.__exit__(None, None, None)
|
|
1310
1337
|
|
|
1311
1338
|
|
|
1339
|
+
@tunnel_cmd.command("test")
|
|
1340
|
+
@click.argument("name")
|
|
1341
|
+
@click.pass_context
|
|
1342
|
+
def tunnel_test(ctx, name):
|
|
1343
|
+
"""Open the tunnel, run the healthcheck, and close it. Reports OK or FAIL."""
|
|
1344
|
+
from dbctl.connections import resolve
|
|
1345
|
+
from dbctl.db import build_engine, healthcheck
|
|
1346
|
+
from dbctl.tunnels.base import build_tunnel
|
|
1347
|
+
|
|
1348
|
+
conns, _ = registries(ctx)
|
|
1349
|
+
try:
|
|
1350
|
+
canonical, c = resolve(name, conns)
|
|
1351
|
+
except KeyError as e:
|
|
1352
|
+
err_console.print(f"[red]{e}[/red]")
|
|
1353
|
+
raise SystemExit(2)
|
|
1354
|
+
|
|
1355
|
+
tun = build_tunnel(c)
|
|
1356
|
+
started = time.monotonic()
|
|
1357
|
+
try:
|
|
1358
|
+
tun.__enter__()
|
|
1359
|
+
except RuntimeError as e:
|
|
1360
|
+
elapsed = (time.monotonic() - started) * 1000
|
|
1361
|
+
err_console.print(f"[red]FAIL[/red] {canonical} tunnel: {e} ({elapsed:.0f}ms)")
|
|
1362
|
+
raise SystemExit(3)
|
|
1363
|
+
|
|
1364
|
+
try:
|
|
1365
|
+
engine = build_engine(c, tun)
|
|
1366
|
+
except Exception as e: # noqa: BLE001 - DBError, ImportError, etc.
|
|
1367
|
+
tun.__exit__(None, None, None)
|
|
1368
|
+
elapsed = (time.monotonic() - started) * 1000
|
|
1369
|
+
err_console.print(f"[red]FAIL[/red] {canonical} engine: {e} ({elapsed:.0f}ms)")
|
|
1370
|
+
raise SystemExit(4)
|
|
1371
|
+
|
|
1372
|
+
ok, latency_ms, msg = healthcheck(engine, c.healthcheck.query, c.healthcheck.timeout_seconds)
|
|
1373
|
+
tun.__exit__(None, None, None)
|
|
1374
|
+
total_ms = (time.monotonic() - started) * 1000
|
|
1375
|
+
|
|
1376
|
+
if ok:
|
|
1377
|
+
console.print(
|
|
1378
|
+
f"[green]OK[/green] {canonical} via {c.type.value} "
|
|
1379
|
+
f"({tun.local_host}:{tun.local_port}) — {msg} ({latency_ms:.1f}ms, total {total_ms:.0f}ms)"
|
|
1380
|
+
)
|
|
1381
|
+
else:
|
|
1382
|
+
err_console.print(f"[red]FAIL[/red] {canonical} healthcheck: {msg} ({total_ms:.0f}ms)")
|
|
1383
|
+
raise SystemExit(5)
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
@tunnel_cmd.command("list")
|
|
1387
|
+
@click.pass_context
|
|
1388
|
+
def tunnel_list(ctx):
|
|
1389
|
+
"""List all connections with their tunnel type and key parameters."""
|
|
1390
|
+
conns, _ = registries(ctx)
|
|
1391
|
+
if not conns:
|
|
1392
|
+
console.print("[dim]no connections configured[/dim]")
|
|
1393
|
+
return
|
|
1394
|
+
|
|
1395
|
+
from rich.table import Table as _Table
|
|
1396
|
+
|
|
1397
|
+
table = _Table(title="tunnels", header_style="bold cyan")
|
|
1398
|
+
table.add_column("name")
|
|
1399
|
+
table.add_column("type")
|
|
1400
|
+
table.add_column("driver")
|
|
1401
|
+
table.add_column("info")
|
|
1402
|
+
|
|
1403
|
+
for name, c in sorted(conns.items()):
|
|
1404
|
+
ttype = c.type.value
|
|
1405
|
+
driver = c.driver or "(from url)"
|
|
1406
|
+
info_parts = []
|
|
1407
|
+
if ttype == "direct":
|
|
1408
|
+
assert c.direct
|
|
1409
|
+
info_parts.append(f"host={c.direct.host}:{c.direct.port}")
|
|
1410
|
+
elif ttype == "ssm":
|
|
1411
|
+
assert c.ssm
|
|
1412
|
+
if c.ssm.bastion_instance_id:
|
|
1413
|
+
info_parts.append(f"bastion={c.ssm.bastion_instance_id}")
|
|
1414
|
+
elif c.ssm.bastion_tags:
|
|
1415
|
+
info_parts.append(f"bastion_tags={dict(c.ssm.bastion_tags)}")
|
|
1416
|
+
info_parts.append(f"remote={c.ssm.remote_host}:{c.ssm.remote_port}")
|
|
1417
|
+
if c.ssm.region:
|
|
1418
|
+
info_parts.append(f"region={c.ssm.region}")
|
|
1419
|
+
elif ttype == "ssh":
|
|
1420
|
+
assert c.ssh
|
|
1421
|
+
info_parts.append(f"bastion={c.ssh.user}@{c.ssh.host}:{c.ssh.port}")
|
|
1422
|
+
info_parts.append(f"remote={c.ssh.remote_host}:{c.ssh.remote_port}")
|
|
1423
|
+
elif ttype == "k8s":
|
|
1424
|
+
assert c.k8s
|
|
1425
|
+
info_parts.append(f"context={c.k8s.context}")
|
|
1426
|
+
info_parts.append(f"target={c.k8s.target}")
|
|
1427
|
+
if c.k8s.namespace:
|
|
1428
|
+
info_parts.append(f"ns={c.k8s.namespace}")
|
|
1429
|
+
info_parts.append(f"port={c.k8s.remote_port}")
|
|
1430
|
+
table.add_row(name, ttype, driver, ", ".join(info_parts))
|
|
1431
|
+
|
|
1432
|
+
console.print(table)
|
|
1433
|
+
|
|
1434
|
+
|
|
1312
1435
|
# --------------------------------------------------------------------------- #
|
|
1313
1436
|
# dashboard + history rendering
|
|
1314
1437
|
# --------------------------------------------------------------------------- #
|
|
@@ -247,16 +247,22 @@ class Connection(BaseModel):
|
|
|
247
247
|
if not self.database:
|
|
248
248
|
raise ValueError("'database' is required (or use 'url:' for a full connection string)")
|
|
249
249
|
|
|
250
|
+
# File-based drivers (sqlite, duckdb) have no auth — skip the
|
|
251
|
+
# credential requirement entirely. The username/password fields
|
|
252
|
+
# are accepted (and ignored by the driver) for config-schema
|
|
253
|
+
# compatibility, but none is required.
|
|
254
|
+
_file_based = self.driver.startswith(("sqlite", "duckdb"))
|
|
255
|
+
|
|
250
256
|
sources = [bool(self.password), bool(self.password_env), self.prompt]
|
|
251
257
|
if sum(sources) > 1:
|
|
252
258
|
raise ValueError("'password', 'password_env' and 'prompt' are mutually exclusive")
|
|
253
|
-
if not any(sources) and not self.windows_sso:
|
|
259
|
+
if not _file_based and not any(sources) and not self.windows_sso:
|
|
254
260
|
raise ValueError("set 'password', 'password_env', 'prompt: true', or 'windows_sso: true'")
|
|
255
261
|
if self.windows_sso and any(sources):
|
|
256
262
|
raise ValueError("'windows_sso' is mutually exclusive with password/password_env/prompt")
|
|
257
263
|
if self.windows_sso and not self.driver.startswith("mssql"):
|
|
258
264
|
raise ValueError("'windows_sso' is only supported with mssql+pyodbc driver")
|
|
259
|
-
if not self.windows_sso and not self.username:
|
|
265
|
+
if not _file_based and not self.windows_sso and not self.username:
|
|
260
266
|
raise ValueError("'username' is required (or set 'windows_sso: true' for mssql SSO)")
|
|
261
267
|
return self
|
|
262
268
|
|
|
@@ -339,11 +345,17 @@ class ReplaySpec(BaseModel):
|
|
|
339
345
|
``package.module:callable`` / ``package.module.callable`` resolving to a
|
|
340
346
|
``Callable[[dict], dict]``. The callable runs in-process; it must not
|
|
341
347
|
reach across connections.
|
|
348
|
+
|
|
349
|
+
`on_conflict` defaults to ``skip`` (unlike ``copy`` which defaults to
|
|
350
|
+
``error``) — a replay is typically additive (replay new/changed rows
|
|
351
|
+
from a source log into a target without breaking existing entries).
|
|
352
|
+
Use ``truncate`` if you want a full refresh instead.
|
|
342
353
|
"""
|
|
343
354
|
|
|
344
355
|
model_config = ConfigDict(extra="forbid")
|
|
345
356
|
batch_size: int = 10000
|
|
346
357
|
tables: list[str] | None = None # None = introspect src
|
|
358
|
+
on_conflict: OnConflict = OnConflict.skip # default skip (additive)
|
|
347
359
|
where: dict[str, str] = Field(default_factory=dict)
|
|
348
360
|
transform: str = "identity"
|
|
349
361
|
|
|
@@ -80,14 +80,12 @@ def _connect_args(conn: Connection, timeout: float) -> dict:
|
|
|
80
80
|
"""Driver-specific connect-time knobs (mainly connect_timeout)."""
|
|
81
81
|
args: dict = {}
|
|
82
82
|
driver = _driver_name(conn)
|
|
83
|
-
if driver.startswith(("postgresql", "mysql", "mariadb")):
|
|
83
|
+
if driver.startswith(("postgresql", "mysql", "mariadb", "oracle")):
|
|
84
84
|
args["connect_timeout"] = int(max(1, timeout))
|
|
85
|
+
# sqlite + duckdb are file-based — no connect_timeout; SQLAlchemy
|
|
86
|
+
# ignores it anyway, but we skip it so we don't pass an unknown kwarg
|
|
87
|
+
# to the underlying C library.
|
|
85
88
|
if conn.windows_sso:
|
|
86
|
-
# pyodbc: Trusted_Connection=yes tells the ODBC driver to use the
|
|
87
|
-
# current Windows user's credentials (Kerberos / NTLM). The ODBC
|
|
88
|
-
# Driver 17+ also supports Authentication=ActiveDirectoryIntegrated
|
|
89
|
-
# for Azure AD SSO — use that by setting it explicitly via
|
|
90
|
-
# connect_args in your config if needed.
|
|
91
89
|
args["Trusted_Connection"] = "yes"
|
|
92
90
|
return args
|
|
93
91
|
|
|
@@ -99,6 +97,11 @@ def build_engine(conn: Connection, tunnel: Tunnel, *, echo: bool = False) -> Eng
|
|
|
99
97
|
tunnel's local bind is NOT injected — the URL's own host:port wins. This
|
|
100
98
|
is intentional: a user who provides a full URL is taking responsibility
|
|
101
99
|
for the entire connection string.
|
|
100
|
+
|
|
101
|
+
File-based drivers (``sqlite``, ``duckdb``) never get host/port/username/
|
|
102
|
+
password injected — the URL is just ``driver:///path/to/file``. The
|
|
103
|
+
tunnel's local bind is irrelevant for file-based DBs (the file is
|
|
104
|
+
local), and injecting `host:port` makes SQLAlchemy reject the URL.
|
|
102
105
|
"""
|
|
103
106
|
driver = _driver_name(conn)
|
|
104
107
|
_check_driver_available(driver)
|
|
@@ -107,6 +110,11 @@ def build_engine(conn: Connection, tunnel: Tunnel, *, echo: bool = False) -> Eng
|
|
|
107
110
|
|
|
108
111
|
if conn.url:
|
|
109
112
|
url = make_url(conn.url)
|
|
113
|
+
elif driver.startswith(("sqlite", "duckdb")):
|
|
114
|
+
# File-based: URL is just "sqlite:///path" or "duckdb:///path".
|
|
115
|
+
# No host/port/username/password — those are meaningless for a
|
|
116
|
+
# local file. The database field IS the file path.
|
|
117
|
+
url = URL.create(driver, database=conn.database)
|
|
110
118
|
else:
|
|
111
119
|
password = resolve_password(conn)
|
|
112
120
|
url = URL.create(
|
|
@@ -138,6 +146,9 @@ def _check_driver_available(driver: str) -> None:
|
|
|
138
146
|
"mysql+pymysql": "pymysql",
|
|
139
147
|
"mariadb+pymysql": "pymysql",
|
|
140
148
|
"mssql+pyodbc": "pyodbc",
|
|
149
|
+
"oracle+oracledb": "oracledb",
|
|
150
|
+
"sqlite": "sqlite3", # stdlib — always available
|
|
151
|
+
"duckdb": "duckdb",
|
|
141
152
|
}
|
|
142
153
|
pkg = module_map.get(driver)
|
|
143
154
|
if pkg is None:
|
|
@@ -183,6 +194,12 @@ def _native_lib_hint(driver: str, library: str) -> str:
|
|
|
183
194
|
"(Debian/Ubuntu: `sudo apt install libpq5`, "
|
|
184
195
|
"RHEL/Fedora: `sudo dnf install libpq`, macOS: `brew install libpq`)."
|
|
185
196
|
)
|
|
197
|
+
if "libociei" in (library or "") or "libclntsh" in (library or "") or "libocci" in (library or ""):
|
|
198
|
+
return (
|
|
199
|
+
"missing Oracle Instant Client libs (libclntsh / libociei / libocci); "
|
|
200
|
+
"install Oracle Instant Client (download from oracle.com, or use "
|
|
201
|
+
"`pip install oracledb` with the default Thin mode which needs no native libs)."
|
|
202
|
+
)
|
|
186
203
|
return ""
|
|
187
204
|
|
|
188
205
|
|
|
@@ -67,7 +67,15 @@ def run_wizard(*, profile: str | None) -> None:
|
|
|
67
67
|
driver = click.prompt(
|
|
68
68
|
"driver (sqlalchemy url scheme)",
|
|
69
69
|
type=click.Choice(
|
|
70
|
-
[
|
|
70
|
+
[
|
|
71
|
+
"postgresql+psycopg",
|
|
72
|
+
"mysql+pymysql",
|
|
73
|
+
"mariadb+pymysql",
|
|
74
|
+
"mssql+pyodbc",
|
|
75
|
+
"oracle+oracledb",
|
|
76
|
+
"sqlite",
|
|
77
|
+
"duckdb",
|
|
78
|
+
],
|
|
71
79
|
case_sensitive=False,
|
|
72
80
|
),
|
|
73
81
|
default="postgresql+psycopg",
|
|
@@ -214,6 +222,8 @@ def _default_port(driver: str) -> int:
|
|
|
214
222
|
return 3306
|
|
215
223
|
if driver.startswith("mssql"):
|
|
216
224
|
return 1433
|
|
225
|
+
if driver.startswith("oracle"):
|
|
226
|
+
return 1521
|
|
217
227
|
return 5432
|
|
218
228
|
|
|
219
229
|
|
|
@@ -672,14 +672,14 @@ def run_replay(
|
|
|
672
672
|
each row before it lands in the insert batch. ``"identity"`` makes the
|
|
673
673
|
replay equivalent to a plain copy.
|
|
674
674
|
"""
|
|
675
|
-
from dbctl.config import CopySpec
|
|
675
|
+
from dbctl.config import CopySpec
|
|
676
676
|
|
|
677
677
|
# Adapt ReplaySpec → CopySpec so we reuse the copy machinery verbatim.
|
|
678
678
|
copy_spec = CopySpec(
|
|
679
679
|
batch_size=spec.batch_size,
|
|
680
680
|
tables=spec.tables,
|
|
681
681
|
where=spec.where,
|
|
682
|
-
on_conflict=
|
|
682
|
+
on_conflict=spec.on_conflict, # replay defaults to skip (additive)
|
|
683
683
|
)
|
|
684
684
|
transform = _resolve_transform(spec.transform)
|
|
685
685
|
return run_copy(
|
|
@@ -49,24 +49,40 @@ def _terminate(proc: subprocess.Popen) -> None:
|
|
|
49
49
|
proc.wait(timeout=5)
|
|
50
50
|
|
|
51
51
|
|
|
52
|
-
def build_tunnel(conn: Connection) -> Tunnel:
|
|
52
|
+
def build_tunnel(conn: Connection, *, override_port: int | None = None) -> Tunnel:
|
|
53
|
+
"""Construct a Tunnel for the connection's type.
|
|
54
|
+
|
|
55
|
+
``override_port`` lets the CLI (e.g. ``tunnel open --port 1234``) pin
|
|
56
|
+
the local bind port even when the config says ``local_port: 0`` (auto).
|
|
57
|
+
For ``direct`` tunnels the override replaces the upstream port (useful
|
|
58
|
+
for pointing at a different port than the config declares).
|
|
59
|
+
"""
|
|
53
60
|
from dbctl.tunnels.direct import DirectTunnel as _Direct
|
|
54
|
-
from dbctl.tunnels.k8s import K8sTunnel as
|
|
61
|
+
from dbctl.tunnels.k8s import K8sTunnel as _K8k
|
|
55
62
|
from dbctl.tunnels.ssh import SshTunnel as _Ssh
|
|
56
63
|
from dbctl.tunnels.ssm import SsmTunnel as _Ssm
|
|
57
64
|
|
|
58
65
|
match conn.type.value:
|
|
59
66
|
case "ssm":
|
|
60
67
|
assert conn.ssm
|
|
68
|
+
if override_port is not None:
|
|
69
|
+
# Mutate a copy so the original config is untouched.
|
|
70
|
+
conn.ssm = conn.ssm.model_copy(update={"local_port": override_port})
|
|
61
71
|
return _Ssm(conn.ssm)
|
|
62
72
|
case "ssh":
|
|
63
73
|
assert conn.ssh
|
|
74
|
+
if override_port is not None:
|
|
75
|
+
conn.ssh = conn.ssh.model_copy(update={"local_port": override_port})
|
|
64
76
|
return _Ssh(conn.ssh)
|
|
65
77
|
case "k8s":
|
|
66
78
|
assert conn.k8s
|
|
67
|
-
|
|
79
|
+
if override_port is not None:
|
|
80
|
+
conn.k8s = conn.k8s.model_copy(update={"local_port": override_port})
|
|
81
|
+
return _K8k(conn.k8s)
|
|
68
82
|
case "direct":
|
|
69
83
|
assert conn.direct
|
|
84
|
+
if override_port is not None:
|
|
85
|
+
conn.direct = conn.direct.model_copy(update={"port": override_port})
|
|
70
86
|
return _Direct(conn.direct)
|
|
71
87
|
case _: # pragma: no cover - exhaustive
|
|
72
88
|
raise ValueError(f"unknown tunnel type {conn.type!r}")
|
|
@@ -39,7 +39,7 @@ overlap with a clear message.
|
|
|
39
39
|
| `description` | string | no | shown in the dashboard and `dbctl connections list`. |
|
|
40
40
|
| `aliases` | list of strings | no | alternates that resolve back to this connection (e.g. `prod` → `db1`). |
|
|
41
41
|
| `type` | `ssm` \| `ssh` \| `k8s` \| `direct` | **yes** | selects the tunnel implementation. |
|
|
42
|
-
| `driver` | string | **yes** | SQLAlchemy URL scheme. Supported: `postgresql+psycopg`, `mysql+pymysql`, `mariadb+pymysql`, `mssql+pyodbc`. Any other SQLAlchemy scheme works as long as its driver is importable. |
|
|
42
|
+
| `driver` | string | **yes** | SQLAlchemy URL scheme. Supported: `postgresql+psycopg`, `mysql+pymysql`, `mariadb+pymysql`, `mssql+pyodbc`, `oracle+oracledb`, `sqlite`, `duckdb`. Any other SQLAlchemy scheme works as long as its driver is importable. |
|
|
43
43
|
| `database` | string | **yes** | database / catalog name passed to SQLAlchemy. |
|
|
44
44
|
| `username` | string | **yes** (unless `windows_sso`) | DB user. |
|
|
45
45
|
| `password` | string | see rule | plaintext DB password (local dev only — don't commit real secrets to YAML). Mutually exclusive with `password_env`, `prompt`, and `windows_sso`. |
|
|
@@ -320,6 +320,72 @@ connections:
|
|
|
320
320
|
safety: { confirm: false, read_only: false }
|
|
321
321
|
```
|
|
322
322
|
|
|
323
|
+
### Oracle Database (thin mode — no native client needed)
|
|
324
|
+
|
|
325
|
+
```yaml
|
|
326
|
+
connections:
|
|
327
|
+
prod-oracle:
|
|
328
|
+
description: "Production Oracle (oracledb thin mode)"
|
|
329
|
+
type: direct
|
|
330
|
+
driver: oracle+oracledb
|
|
331
|
+
database: ORCLPDB1 # service name (or SID)
|
|
332
|
+
username: app_admin
|
|
333
|
+
password_env: DBCTL_ORACLE_PASSWORD
|
|
334
|
+
direct: { host: db.internal, port: 1521 }
|
|
335
|
+
healthcheck: { query: "SELECT 1 FROM DUAL", timeout_seconds: 10 }
|
|
336
|
+
safety:
|
|
337
|
+
confirm: true
|
|
338
|
+
read_only: false
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
> `oracledb` defaults to **thin mode** (pure Python, no Oracle Instant
|
|
342
|
+
> Client needed). If you need thick mode (native Oracle client libs), set
|
|
343
|
+
> it via the `url:` field with `thick_mode=true` in the query string.
|
|
344
|
+
|
|
345
|
+
### Local SQLite database (file-based)
|
|
346
|
+
|
|
347
|
+
```yaml
|
|
348
|
+
connections:
|
|
349
|
+
local-sqlite:
|
|
350
|
+
description: "Local SQLite database"
|
|
351
|
+
type: direct
|
|
352
|
+
driver: sqlite
|
|
353
|
+
database: /path/to/mydata.db # absolute path to the .db file
|
|
354
|
+
username: "" # sqlite ignores these but config requires one
|
|
355
|
+
password: "" # sqlite ignores
|
|
356
|
+
direct: { host: localhost, port: 0 } # ignored by sqlite; required by config schema
|
|
357
|
+
healthcheck: { query: "SELECT 1" }
|
|
358
|
+
safety:
|
|
359
|
+
confirm: true
|
|
360
|
+
read_only: false
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
> SQLite and DuckDB are file-based — the `host` / `port` fields are
|
|
364
|
+
> ignored by the driver but `type: direct` still requires a `direct:`
|
|
365
|
+
> block. Use `url:` mode if you prefer:
|
|
366
|
+
> ```yaml
|
|
367
|
+
> url: "sqlite:////absolute/path/to/mydata.db"
|
|
368
|
+
> ```
|
|
369
|
+
> (Note the four slashes for absolute paths in SQLAlchemy's sqlite scheme.)
|
|
370
|
+
|
|
371
|
+
### Local DuckDB database (file-based)
|
|
372
|
+
|
|
373
|
+
```yaml
|
|
374
|
+
connections:
|
|
375
|
+
local-duckdb:
|
|
376
|
+
description: "Local DuckDB database"
|
|
377
|
+
type: direct
|
|
378
|
+
driver: duckdb
|
|
379
|
+
database: /path/to/mydata.duckdb # or ":memory:" for in-memory
|
|
380
|
+
username: "" # duckdb ignores
|
|
381
|
+
password: "" # duckdb ignores
|
|
382
|
+
direct: { host: localhost, port: 0 }
|
|
383
|
+
healthcheck: { query: "SELECT 1" }
|
|
384
|
+
safety:
|
|
385
|
+
confirm: true
|
|
386
|
+
read_only: false
|
|
387
|
+
```
|
|
388
|
+
|
|
323
389
|
### CloudNativePG cluster via kubectl port-forward
|
|
324
390
|
|
|
325
391
|
```yaml
|
|
@@ -585,6 +585,15 @@ tunnel open: 127.0.0.1:5433 -> pg
|
|
|
585
585
|
Ctrl-C to close...
|
|
586
586
|
```
|
|
587
587
|
|
|
588
|
+
Override the local port with `--port` (even if the config says `local_port: 0`
|
|
589
|
+
= auto-pick):
|
|
590
|
+
|
|
591
|
+
```bash
|
|
592
|
+
▶ uv run dbctl tunnel open pg --port 15432
|
|
593
|
+
tunnel open: 127.0.0.1:15432 -> pg
|
|
594
|
+
Ctrl-C to close...
|
|
595
|
+
```
|
|
596
|
+
|
|
588
597
|
In another shell, use that local bind with your favourite client:
|
|
589
598
|
|
|
590
599
|
```bash
|
|
@@ -594,6 +603,28 @@ PGPASSWORD=$DBCTL_PG_PASSWORD psql -h 127.0.0.1 -p 5433 -U app_admin -d app
|
|
|
594
603
|
The tunnel is torn down cleanly when you Ctrl-C the `dbctl tunnel open`
|
|
595
604
|
process — an `atexit` fallback covers hard kills.
|
|
596
605
|
|
|
606
|
+
### `tunnel test` — open + healthcheck + close
|
|
607
|
+
|
|
608
|
+
```bash
|
|
609
|
+
▶ uv run dbctl tunnel test pg
|
|
610
|
+
OK pg via direct (127.0.0.1:5433) — ok (31.2ms, total 185ms)
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
Opens the tunnel, runs the connection's healthcheck query, then closes the
|
|
614
|
+
tunnel. Prints `OK` (green) with latency on success, or `FAIL` (red) with
|
|
615
|
+
a clean error message on failure. Exit codes: 0 success, 2 unknown conn,
|
|
616
|
+
3 tunnel error, 4 engine error, 5 healthcheck failed.
|
|
617
|
+
|
|
618
|
+
### `tunnel list` — show all tunnels at a glance
|
|
619
|
+
|
|
620
|
+
```bash
|
|
621
|
+
▶ uv run dbctl tunnel list
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
Lists every configured connection with its tunnel type (`ssm`/`ssh`/`k8s`/
|
|
625
|
+
`direct`), driver, and key parameters (bastion, remote host, port, region,
|
|
626
|
+
context/namespace for k8s).
|
|
627
|
+
|
|
597
628
|
---
|
|
598
629
|
|
|
599
630
|
## 14. Use `dbctl init` to add the next connection
|
|
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dbctl"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.4"
|
|
8
8
|
description = "Generic CLI to monitor, control, and administer multiple databases via SSM, SSH, or direct connection."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
11
11
|
license = { text = "MIT" }
|
|
12
12
|
authors = [{ name = "dbctl contributors" }]
|
|
13
|
-
keywords = ["cli", "database", "postgres", "mysql", "mssql", "ssm", "ssh", "tunnel"]
|
|
13
|
+
keywords = ["cli", "database", "postgres", "mysql", "mssql", "oracle", "sqlite", "duckdb", "ssm", "ssh", "tunnel"]
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Development Status :: 3 - Alpha",
|
|
16
16
|
"Environment :: Console",
|
|
@@ -31,6 +31,8 @@ dependencies = [
|
|
|
31
31
|
"psycopg[binary]>=3.1",
|
|
32
32
|
"pymysql>=1.1",
|
|
33
33
|
"pyodbc>=5",
|
|
34
|
+
"oracledb>=2",
|
|
35
|
+
"duckdb>=1",
|
|
34
36
|
]
|
|
35
37
|
|
|
36
38
|
[project.optional-dependencies]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|