dbctl 0.7.3__tar.gz → 0.7.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.7.3 → dbctl-0.7.4}/CHANGELOG.md +45 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/PKG-INFO +62 -8
- {dbctl-0.7.3 → dbctl-0.7.4}/README.md +59 -7
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/cli.py +11 -1
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/db.py +12 -4
- dbctl-0.7.4/dbctl/ui/__init__.py +8 -0
- dbctl-0.7.4/dbctl/ui/app.py +234 -0
- dbctl-0.7.4/dbctl/ui/connection_tree.py +539 -0
- dbctl-0.7.4/dbctl/ui/editor_tab.py +164 -0
- dbctl-0.7.4/dbctl/ui/grouping.py +63 -0
- dbctl-0.7.4/dbctl/ui/operation_tab.py +171 -0
- dbctl-0.7.4/dbctl/ui/registry.py +39 -0
- dbctl-0.7.4/dbctl/ui/results.py +29 -0
- dbctl-0.7.4/dbctl/ui/schema.py +89 -0
- dbctl-0.7.4/dbctl/ui/screens.py +146 -0
- dbctl-0.7.4/dbctl/ui/session.py +137 -0
- dbctl-0.7.4/dbctl/ui/splitter.py +109 -0
- dbctl-0.7.4/dbctl/ui/sql_templates.py +55 -0
- dbctl-0.7.4/dbctl/ui/tabs.py +87 -0
- dbctl-0.7.4/docs/logo_small.png +0 -0
- dbctl-0.7.4/docs/tui.md +184 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/pyproject.toml +14 -2
- {dbctl-0.7.3 → dbctl-0.7.4}/tests/test_regressions.py +3 -3
- dbctl-0.7.4/tests/ui/conftest.py +76 -0
- dbctl-0.7.4/tests/ui/test_app.py +43 -0
- dbctl-0.7.4/tests/ui/test_connection_tree.py +140 -0
- dbctl-0.7.4/tests/ui/test_connection_tree_grouping.py +200 -0
- dbctl-0.7.4/tests/ui/test_editor_tab.py +99 -0
- dbctl-0.7.4/tests/ui/test_grouping.py +112 -0
- dbctl-0.7.4/tests/ui/test_operation_launcher.py +119 -0
- dbctl-0.7.4/tests/ui/test_operation_tab.py +126 -0
- dbctl-0.7.4/tests/ui/test_resize.py +79 -0
- dbctl-0.7.4/tests/ui/test_schema.py +50 -0
- dbctl-0.7.4/tests/ui/test_screens.py +115 -0
- dbctl-0.7.4/tests/ui/test_session.py +74 -0
- dbctl-0.7.4/tests/ui/test_sql_templates.py +89 -0
- dbctl-0.7.4/tests/ui/test_status_bar_and_loading.py +135 -0
- dbctl-0.7.4/tests/ui/test_tab_resize.py +139 -0
- dbctl-0.7.4/uv.lock +1196 -0
- dbctl-0.7.3/docs/logo_small.png +0 -0
- dbctl-0.7.3/uv.lock +0 -810
- {dbctl-0.7.3 → dbctl-0.7.4}/.dbctl/connections.yaml +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/.dbctl/operations.yaml +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/.github/workflows/ci.yml +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/.github-local/ci.yml +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/.gitignore +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/Makefile +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/__init__.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/__main__.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/audit.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/config.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/connections.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/execute.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/init.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/multi.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/operations.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/refs.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/reports.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/runtime.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/tunnels/__init__.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/tunnels/azure.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/tunnels/base.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/tunnels/direct.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/tunnels/gcp.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/tunnels/k8s.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/tunnels/ssh.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/tunnels/ssm.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/docker-compose.yml +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/docs/ACTION_OUTPUT.md +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/docs/DESIGN.md +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/docs/SESSION_STATE.md +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/docs/connections.md +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/docs/logo.png +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/docs/operations.md +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/docs/tutorial.md +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/seed/mssql.sql +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/seed/mysql.sql +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/seed/postgres.sql +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/tests/test_azure_tunnel.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/tests/test_bastion_tags.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/tests/test_connections_loader.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/tests/test_copy_features.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/tests/test_gcp_tunnel.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/tests/test_k8s_tunnel.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/tests/test_refs.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/tests/test_smoke.py +0 -0
- {dbctl-0.7.3 → dbctl-0.7.4}/tests/test_sso_cache.py +0 -0
|
@@ -5,6 +5,51 @@ 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.7.4] — 2026-08-10
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`dbctl ui`** — an interactive [Textual](https://textual.textualize.io/)
|
|
13
|
+
TUI on top of the same connections/operations/tunnels/audit-log stack as
|
|
14
|
+
the CLI (`textual` is a base dependency, no separate install step). See
|
|
15
|
+
`docs/tui.md` for the full reference.
|
|
16
|
+
- **Connection tree** doubling as a lazily-loaded schema browser: `m`
|
|
17
|
+
toggles a simple `connection → table → column` view and a fuller
|
|
18
|
+
`connection → schema → Tables/Views → table/view → Columns/Indexes`
|
|
19
|
+
view; `g` toggles a flat list and a grouping that treats `-` as a path
|
|
20
|
+
separator (`in-gateway-ifp-dev` nests under `in-gateway` → `ifp`), with
|
|
21
|
+
single-child folders compressed and support for a name that's both a
|
|
22
|
+
group and a real connection at once (`ifp` / `ifp-gateway`). `c`/`d`/`t`
|
|
23
|
+
connect/disconnect/test-tunnel; `a`/`e` open `connections.yaml` in
|
|
24
|
+
`$EDITOR`.
|
|
25
|
+
- **Tabbed workspace**: a SQL editor (syntax-highlighted) or an
|
|
26
|
+
operation-launcher (a form built from a declared operation's
|
|
27
|
+
parameters) per tab, each with an independently resizable results
|
|
28
|
+
table and a status line (row count / rows-affected + duration) below
|
|
29
|
+
it. `Ctrl+N` new tab, `Ctrl+O` searchable operation launcher (type to
|
|
30
|
+
filter), `Ctrl+W` close, `Ctrl+R` run. Activating a table/view in the
|
|
31
|
+
tree opens a SQL tab pre-filled with a dialect-correct preview query
|
|
32
|
+
(`LIMIT` / `TOP` / `FETCH FIRST … ROWS ONLY`, with per-dialect
|
|
33
|
+
identifier quoting for mixed-case table/schema names).
|
|
34
|
+
- Connect/disconnect/test-tunnel and every SQL/operation run happen on a
|
|
35
|
+
background thread with a loading indicator, so the UI stays responsive
|
|
36
|
+
instead of freezing while a tunnel spins up or a query runs.
|
|
37
|
+
- Every run still respects `safety.read_only` / `safety.confirm` /
|
|
38
|
+
`allowed_operations` and is appended to `~/.dbctl/history.jsonl`,
|
|
39
|
+
exactly like a CLI-driven run.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- **`uv.lock` could resolve against a contributor's local package-index
|
|
44
|
+
override instead of public PyPI** — a machine-level `uv` config pointing
|
|
45
|
+
at an internal package mirror silently became the index every regenerated
|
|
46
|
+
`uv.lock` entry was resolved and hashed against, which both leaked an
|
|
47
|
+
internal hostname into the lock file and broke `uv sync --frozen` for
|
|
48
|
+
anyone (CI included) without access to that mirror. Pinned
|
|
49
|
+
`[[tool.uv.index]]` in `pyproject.toml` with `default = true` so this
|
|
50
|
+
project always locks against `pypi.org`/`files.pythonhosted.org`
|
|
51
|
+
regardless of what index a contributor's environment defaults to.
|
|
52
|
+
|
|
8
53
|
## [0.7.3] — 2026-08-04
|
|
9
54
|
|
|
10
55
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dbctl
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -25,8 +25,10 @@ Requires-Dist: pyodbc>=5
|
|
|
25
25
|
Requires-Dist: pyyaml>=6
|
|
26
26
|
Requires-Dist: rich>=13
|
|
27
27
|
Requires-Dist: sqlalchemy>=2
|
|
28
|
+
Requires-Dist: textual>=0.60
|
|
28
29
|
Provides-Extra: dev
|
|
29
30
|
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
30
32
|
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
31
33
|
Requires-Dist: pytest>=8; extra == 'dev'
|
|
32
34
|
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
@@ -71,11 +73,13 @@ leaving the terminal.
|
|
|
71
73
|
| DML is one `Ctrl-Enter` away | DML is **dry-run by default** until `--apply`; `--yes` skips the prompt |
|
|
72
74
|
| No cross-DB diff | `dbctl diff user-count prod-tenant1 int-tenant1` |
|
|
73
75
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
`dbctl tunnel
|
|
76
|
+
The declarative CLI above is still the primary interface: declaring
|
|
77
|
+
operations in YAML keeps "what can be run against this DB" discoverable from
|
|
78
|
+
a versioned file instead of buried in your shell history. For ad-hoc
|
|
79
|
+
exploration, `dbctl ui` (see below) gives you a schema browser and a SQL
|
|
80
|
+
editor without leaving `dbctl`'s tunnel/safety/audit model - or open the
|
|
81
|
+
tunnel with `dbctl tunnel open <conn>` and point your favourite client at
|
|
82
|
+
the local bind.
|
|
79
83
|
|
|
80
84
|
### How it reaches a database
|
|
81
85
|
|
|
@@ -352,6 +356,41 @@ reference.
|
|
|
352
356
|
Secret-typed **operation** parameters (`type: secret`) are redacted in the
|
|
353
357
|
audit log regardless of which DB password source the connection uses.
|
|
354
358
|
|
|
359
|
+
## Interactive UI (`dbctl ui`)
|
|
360
|
+
|
|
361
|
+
A [Textual](https://textual.textualize.io/) TUI for interactive work, on top
|
|
362
|
+
of the same connections/operations/tunnels/audit-log stack as the CLI - not
|
|
363
|
+
a separate code path with its own rules. `textual` is a base dependency, so
|
|
364
|
+
no separate install step.
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
dbctl ui
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
- **Left pane**: a connection tree that doubles as a lazily-loaded schema
|
|
371
|
+
browser (`m` toggles a simple `connection → table → column` view and a
|
|
372
|
+
fuller `connection → schema → Tables/Views → table/view →
|
|
373
|
+
Columns/Indexes` view). Expanding a connection connects it if needed;
|
|
374
|
+
activating (Enter) a table/view opens a SQL tab pre-filled with a
|
|
375
|
+
dialect-correct preview query using the real table name. `g` toggles a
|
|
376
|
+
grouped view that treats `-` as a path separator (`in-gateway-ifp-dev`
|
|
377
|
+
nests under `in-gateway` → `ifp`) - handy once a fleet has more than a
|
|
378
|
+
handful of connections.
|
|
379
|
+
- **Right pane**: a tabbed workspace of SQL editor / operation-launcher
|
|
380
|
+
tabs (`Ctrl+N` new tab, `Ctrl+O` searchable operation launcher, `Ctrl+W`
|
|
381
|
+
close, `Ctrl+R` run), each with an independently resizable results table
|
|
382
|
+
below it and a status line (row count / rows affected + duration) after
|
|
383
|
+
each run.
|
|
384
|
+
- Connecting, disconnecting, testing a tunnel, and every SQL/operation run
|
|
385
|
+
happen on a background thread with a loading indicator, so the UI stays
|
|
386
|
+
responsive instead of freezing while a tunnel spins up or a query runs.
|
|
387
|
+
- Every run still respects `safety.read_only` / `safety.confirm` /
|
|
388
|
+
`allowed_operations` and is appended to `~/.dbctl/history.jsonl`, exactly
|
|
389
|
+
like a CLI-driven run.
|
|
390
|
+
|
|
391
|
+
See [`docs/tui.md`](docs/tui.md) for the full keybinding reference and
|
|
392
|
+
per-dialect SQL details (row-limit clause, identifier quoting).
|
|
393
|
+
|
|
355
394
|
## Shell completion
|
|
356
395
|
|
|
357
396
|
```bash
|
|
@@ -375,6 +414,7 @@ dbctl/
|
|
|
375
414
|
├── docs/
|
|
376
415
|
│ ├── connections.md # connections.yaml reference
|
|
377
416
|
│ ├── operations.md # operations.yaml reference
|
|
417
|
+
│ ├── tui.md # dbctl ui reference (keybindings, dialect SQL)
|
|
378
418
|
│ └── DESIGN.md # architecture and design decisions
|
|
379
419
|
└── dbctl/
|
|
380
420
|
├── cli.py # dynamic groups + per-op Click commands
|
|
@@ -388,7 +428,21 @@ dbctl/
|
|
|
388
428
|
├── reports.py # rich tables / json / csv / yaml + diff + copy/sync/validate rendering
|
|
389
429
|
├── audit.py # history.jsonl
|
|
390
430
|
├── runtime.py # opened_conn() ctx-mgr (tunnel+engine+healthcheck)
|
|
391
|
-
|
|
431
|
+
├── init.py # dbctl init wizard
|
|
432
|
+
└── ui/ # Textual TUI (`dbctl ui`)
|
|
433
|
+
├── app.py # DbctlApp: connection tree + tabbed workspace
|
|
434
|
+
├── session.py # per-connection tunnel+engine kept open across tab-runs
|
|
435
|
+
├── connection_tree.py # lazy schema browser (simple/normal + flat/grouped views)
|
|
436
|
+
├── grouping.py # "-" as a path separator -> compressed connection-name trie
|
|
437
|
+
├── schema.py # sqlalchemy.inspect() wrapper for the tree
|
|
438
|
+
├── editor_tab.py # SQL editor tab
|
|
439
|
+
├── operation_tab.py # operation-launcher tab
|
|
440
|
+
├── sql_templates.py # dialect-aware default SELECT + identifier quoting
|
|
441
|
+
├── screens.py # confirm / new-tab / operation-launcher modal screens
|
|
442
|
+
├── splitter.py # draggable pane-resize bars
|
|
443
|
+
├── tabs.py # shared tab base: resize, toolbar, status bar, loading indicator
|
|
444
|
+
├── results.py # DataTable rendering helpers
|
|
445
|
+
└── registry.py # tolerant connections/operations loading
|
|
392
446
|
```
|
|
393
447
|
|
|
394
448
|
## Development
|
|
@@ -397,7 +451,7 @@ dbctl/
|
|
|
397
451
|
uv sync --extra dev
|
|
398
452
|
make help # list all Makefile targets
|
|
399
453
|
make check # lint + unit tests (the pre-commit gate)
|
|
400
|
-
make test # unit tests (~
|
|
454
|
+
make test # unit tests (~270 tests, sqlite-backed, no docker)
|
|
401
455
|
make typecheck # mypy strict (pre-existing debt; non-blocking)
|
|
402
456
|
make smoke # docker compose up + dbctl doctor against the fleet
|
|
403
457
|
make build # wheel + sdist via uv
|
|
@@ -37,11 +37,13 @@ leaving the terminal.
|
|
|
37
37
|
| DML is one `Ctrl-Enter` away | DML is **dry-run by default** until `--apply`; `--yes` skips the prompt |
|
|
38
38
|
| No cross-DB diff | `dbctl diff user-count prod-tenant1 int-tenant1` |
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
`dbctl tunnel
|
|
40
|
+
The declarative CLI above is still the primary interface: declaring
|
|
41
|
+
operations in YAML keeps "what can be run against this DB" discoverable from
|
|
42
|
+
a versioned file instead of buried in your shell history. For ad-hoc
|
|
43
|
+
exploration, `dbctl ui` (see below) gives you a schema browser and a SQL
|
|
44
|
+
editor without leaving `dbctl`'s tunnel/safety/audit model - or open the
|
|
45
|
+
tunnel with `dbctl tunnel open <conn>` and point your favourite client at
|
|
46
|
+
the local bind.
|
|
45
47
|
|
|
46
48
|
### How it reaches a database
|
|
47
49
|
|
|
@@ -318,6 +320,41 @@ reference.
|
|
|
318
320
|
Secret-typed **operation** parameters (`type: secret`) are redacted in the
|
|
319
321
|
audit log regardless of which DB password source the connection uses.
|
|
320
322
|
|
|
323
|
+
## Interactive UI (`dbctl ui`)
|
|
324
|
+
|
|
325
|
+
A [Textual](https://textual.textualize.io/) TUI for interactive work, on top
|
|
326
|
+
of the same connections/operations/tunnels/audit-log stack as the CLI - not
|
|
327
|
+
a separate code path with its own rules. `textual` is a base dependency, so
|
|
328
|
+
no separate install step.
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
dbctl ui
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
- **Left pane**: a connection tree that doubles as a lazily-loaded schema
|
|
335
|
+
browser (`m` toggles a simple `connection → table → column` view and a
|
|
336
|
+
fuller `connection → schema → Tables/Views → table/view →
|
|
337
|
+
Columns/Indexes` view). Expanding a connection connects it if needed;
|
|
338
|
+
activating (Enter) a table/view opens a SQL tab pre-filled with a
|
|
339
|
+
dialect-correct preview query using the real table name. `g` toggles a
|
|
340
|
+
grouped view that treats `-` as a path separator (`in-gateway-ifp-dev`
|
|
341
|
+
nests under `in-gateway` → `ifp`) - handy once a fleet has more than a
|
|
342
|
+
handful of connections.
|
|
343
|
+
- **Right pane**: a tabbed workspace of SQL editor / operation-launcher
|
|
344
|
+
tabs (`Ctrl+N` new tab, `Ctrl+O` searchable operation launcher, `Ctrl+W`
|
|
345
|
+
close, `Ctrl+R` run), each with an independently resizable results table
|
|
346
|
+
below it and a status line (row count / rows affected + duration) after
|
|
347
|
+
each run.
|
|
348
|
+
- Connecting, disconnecting, testing a tunnel, and every SQL/operation run
|
|
349
|
+
happen on a background thread with a loading indicator, so the UI stays
|
|
350
|
+
responsive instead of freezing while a tunnel spins up or a query runs.
|
|
351
|
+
- Every run still respects `safety.read_only` / `safety.confirm` /
|
|
352
|
+
`allowed_operations` and is appended to `~/.dbctl/history.jsonl`, exactly
|
|
353
|
+
like a CLI-driven run.
|
|
354
|
+
|
|
355
|
+
See [`docs/tui.md`](docs/tui.md) for the full keybinding reference and
|
|
356
|
+
per-dialect SQL details (row-limit clause, identifier quoting).
|
|
357
|
+
|
|
321
358
|
## Shell completion
|
|
322
359
|
|
|
323
360
|
```bash
|
|
@@ -341,6 +378,7 @@ dbctl/
|
|
|
341
378
|
├── docs/
|
|
342
379
|
│ ├── connections.md # connections.yaml reference
|
|
343
380
|
│ ├── operations.md # operations.yaml reference
|
|
381
|
+
│ ├── tui.md # dbctl ui reference (keybindings, dialect SQL)
|
|
344
382
|
│ └── DESIGN.md # architecture and design decisions
|
|
345
383
|
└── dbctl/
|
|
346
384
|
├── cli.py # dynamic groups + per-op Click commands
|
|
@@ -354,7 +392,21 @@ dbctl/
|
|
|
354
392
|
├── reports.py # rich tables / json / csv / yaml + diff + copy/sync/validate rendering
|
|
355
393
|
├── audit.py # history.jsonl
|
|
356
394
|
├── runtime.py # opened_conn() ctx-mgr (tunnel+engine+healthcheck)
|
|
357
|
-
|
|
395
|
+
├── init.py # dbctl init wizard
|
|
396
|
+
└── ui/ # Textual TUI (`dbctl ui`)
|
|
397
|
+
├── app.py # DbctlApp: connection tree + tabbed workspace
|
|
398
|
+
├── session.py # per-connection tunnel+engine kept open across tab-runs
|
|
399
|
+
├── connection_tree.py # lazy schema browser (simple/normal + flat/grouped views)
|
|
400
|
+
├── grouping.py # "-" as a path separator -> compressed connection-name trie
|
|
401
|
+
├── schema.py # sqlalchemy.inspect() wrapper for the tree
|
|
402
|
+
├── editor_tab.py # SQL editor tab
|
|
403
|
+
├── operation_tab.py # operation-launcher tab
|
|
404
|
+
├── sql_templates.py # dialect-aware default SELECT + identifier quoting
|
|
405
|
+
├── screens.py # confirm / new-tab / operation-launcher modal screens
|
|
406
|
+
├── splitter.py # draggable pane-resize bars
|
|
407
|
+
├── tabs.py # shared tab base: resize, toolbar, status bar, loading indicator
|
|
408
|
+
├── results.py # DataTable rendering helpers
|
|
409
|
+
└── registry.py # tolerant connections/operations loading
|
|
358
410
|
```
|
|
359
411
|
|
|
360
412
|
## Development
|
|
@@ -363,7 +415,7 @@ dbctl/
|
|
|
363
415
|
uv sync --extra dev
|
|
364
416
|
make help # list all Makefile targets
|
|
365
417
|
make check # lint + unit tests (the pre-commit gate)
|
|
366
|
-
make test # unit tests (~
|
|
418
|
+
make test # unit tests (~270 tests, sqlite-backed, no docker)
|
|
367
419
|
make typecheck # mypy strict (pre-existing debt; non-blocking)
|
|
368
420
|
make smoke # docker compose up + dbctl doctor against the fleet
|
|
369
421
|
make build # wheel + sdist via uv
|
|
@@ -1020,7 +1020,7 @@ def _aliases(conns):
|
|
|
1020
1020
|
|
|
1021
1021
|
def _root_list(ctx: click.Context) -> list[str]:
|
|
1022
1022
|
conns, ops = registries(ctx)
|
|
1023
|
-
static = ["connections", "operations", "status", "doctor", "init", "history", "tunnel"]
|
|
1023
|
+
static = ["connections", "operations", "status", "doctor", "init", "history", "tunnel", "ui"]
|
|
1024
1024
|
# multi-op operation-first top-level commands + deprecated verb-first groups
|
|
1025
1025
|
multi_ops = {n for n, o in ops.items() if o.scope.value == "multi"}
|
|
1026
1026
|
multi_modes = {o.mode.value for o in ops.values() if o.scope.value == "multi"}
|
|
@@ -1039,6 +1039,7 @@ def _root_get(ctx: click.Context, name: str):
|
|
|
1039
1039
|
"init": init_cmd,
|
|
1040
1040
|
"history": history_cmd,
|
|
1041
1041
|
"tunnel": tunnel_cmd,
|
|
1042
|
+
"ui": ui_cmd,
|
|
1042
1043
|
}
|
|
1043
1044
|
if name in static:
|
|
1044
1045
|
return static[name]
|
|
@@ -1329,6 +1330,15 @@ def init_cmd(ctx):
|
|
|
1329
1330
|
run_wizard(profile=ctx.obj.get("profile"))
|
|
1330
1331
|
|
|
1331
1332
|
|
|
1333
|
+
@click.command("ui")
|
|
1334
|
+
@click.pass_context
|
|
1335
|
+
def ui_cmd(ctx):
|
|
1336
|
+
"""Launch the interactive TUI: connection tree + tabbed SQL editor / operations."""
|
|
1337
|
+
from dbctl.ui.app import DbctlApp
|
|
1338
|
+
|
|
1339
|
+
DbctlApp(profile=ctx.obj.get("profile")).run()
|
|
1340
|
+
|
|
1341
|
+
|
|
1332
1342
|
@main.group("history")
|
|
1333
1343
|
def history_cmd():
|
|
1334
1344
|
"""Show the audit log."""
|
|
@@ -60,7 +60,7 @@ def resolve_password(conn: Connection) -> str | None:
|
|
|
60
60
|
raise DBError("no password source configured")
|
|
61
61
|
|
|
62
62
|
|
|
63
|
-
def
|
|
63
|
+
def driver_name(conn: Connection) -> str:
|
|
64
64
|
"""Return the SQLAlchemy driver scheme for this connection.
|
|
65
65
|
|
|
66
66
|
When the user provides a full ``url:`` string, the driver is the
|
|
@@ -79,7 +79,7 @@ def _driver_name(conn: Connection) -> str:
|
|
|
79
79
|
def _connect_args(conn: Connection, timeout: float) -> dict:
|
|
80
80
|
"""Driver-specific connect-time knobs (mainly connect_timeout)."""
|
|
81
81
|
args: dict = {}
|
|
82
|
-
driver =
|
|
82
|
+
driver = driver_name(conn)
|
|
83
83
|
if driver.startswith(("postgresql", "mysql", "mariadb", "oracle")):
|
|
84
84
|
args["connect_timeout"] = int(max(1, timeout))
|
|
85
85
|
# sqlite + duckdb are file-based — no connect_timeout; SQLAlchemy
|
|
@@ -111,7 +111,7 @@ def build_engine(conn: Connection, tunnel: Tunnel, *, echo: bool = False) -> Eng
|
|
|
111
111
|
|
|
112
112
|
conn = resolve_connection(conn)
|
|
113
113
|
|
|
114
|
-
driver =
|
|
114
|
+
driver = driver_name(conn)
|
|
115
115
|
_check_driver_available(driver)
|
|
116
116
|
|
|
117
117
|
from sqlalchemy import URL, make_url
|
|
@@ -264,4 +264,12 @@ def fmt_db_error(e: BaseException) -> str:
|
|
|
264
264
|
return msg
|
|
265
265
|
|
|
266
266
|
|
|
267
|
-
__all__ = [
|
|
267
|
+
__all__ = [
|
|
268
|
+
"build_engine",
|
|
269
|
+
"resolve_password",
|
|
270
|
+
"healthcheck",
|
|
271
|
+
"DBError",
|
|
272
|
+
"fmt_db_error",
|
|
273
|
+
"driver_name",
|
|
274
|
+
"text",
|
|
275
|
+
]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Interactive Textual UI for dbctl (`dbctl ui`).
|
|
2
|
+
|
|
3
|
+
This package only *consumes* the existing loaders/executors
|
|
4
|
+
(``dbctl.connections``, ``dbctl.operations``, ``dbctl.tunnels``, ``dbctl.db``,
|
|
5
|
+
``dbctl.execute``, ``dbctl.audit``) - it does not change their behavior.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""``dbctl ui`` entry point.
|
|
2
|
+
|
|
3
|
+
Composes a connection tree (left) with a tabbed workspace of SQL editor /
|
|
4
|
+
operation-launcher tabs (right), each with a results table below. Reuses the
|
|
5
|
+
same loaders/executors as the CLI - the only new state this module owns is
|
|
6
|
+
the per-connection tunnel+engine lifecycle in ``dbctl.ui.session.SessionManager``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
|
|
13
|
+
from textual import on
|
|
14
|
+
from textual.app import App, ComposeResult
|
|
15
|
+
from textual.containers import Horizontal
|
|
16
|
+
from textual.css.query import NoMatches
|
|
17
|
+
from textual.reactive import reactive
|
|
18
|
+
from textual.widgets import Footer, Header, TabbedContent, TabPane
|
|
19
|
+
|
|
20
|
+
from dbctl.ui.connection_tree import ConnectionActivated, ConnectionTree, TableActivated
|
|
21
|
+
from dbctl.ui.editor_tab import SqlEditorPane
|
|
22
|
+
from dbctl.ui.operation_tab import OperationPane
|
|
23
|
+
from dbctl.ui.registry import load_registries
|
|
24
|
+
from dbctl.ui.screens import NewTabScreen, OperationLauncherScreen
|
|
25
|
+
from dbctl.ui.session import SessionManager
|
|
26
|
+
from dbctl.ui.splitter import VerticalSplitter
|
|
27
|
+
from dbctl.ui.sql_templates import default_select, qualified_table
|
|
28
|
+
from dbctl.ui.tabs import EDITOR_HEIGHT_STEP, MAX_EDITOR_HEIGHT, MIN_EDITOR_HEIGHT, RunnableTab
|
|
29
|
+
|
|
30
|
+
MIN_TREE_WIDTH = 20
|
|
31
|
+
MAX_TREE_WIDTH = 80
|
|
32
|
+
DEFAULT_TREE_WIDTH = 32
|
|
33
|
+
TREE_WIDTH_STEP = 4
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DbctlApp(App[None]):
|
|
37
|
+
"""dbctl's interactive TUI: connection tree + tabbed SQL/operation workspace."""
|
|
38
|
+
|
|
39
|
+
TITLE = "dbctl"
|
|
40
|
+
|
|
41
|
+
CSS = """
|
|
42
|
+
#connection-tree {
|
|
43
|
+
border-right: solid $panel;
|
|
44
|
+
}
|
|
45
|
+
.pane-header {
|
|
46
|
+
padding: 0 1;
|
|
47
|
+
background: $panel;
|
|
48
|
+
}
|
|
49
|
+
.pane-toolbar {
|
|
50
|
+
height: auto;
|
|
51
|
+
padding: 0 1;
|
|
52
|
+
}
|
|
53
|
+
.param-row {
|
|
54
|
+
height: auto;
|
|
55
|
+
padding: 0 1;
|
|
56
|
+
}
|
|
57
|
+
.param-row Label {
|
|
58
|
+
width: 20;
|
|
59
|
+
content-align: right middle;
|
|
60
|
+
}
|
|
61
|
+
#sql-input, #param-form {
|
|
62
|
+
height: 1fr;
|
|
63
|
+
}
|
|
64
|
+
#results-table {
|
|
65
|
+
height: 1fr;
|
|
66
|
+
}
|
|
67
|
+
#run-loading {
|
|
68
|
+
height: 1fr;
|
|
69
|
+
}
|
|
70
|
+
#status-bar {
|
|
71
|
+
height: auto;
|
|
72
|
+
padding: 0 1;
|
|
73
|
+
background: $panel;
|
|
74
|
+
color: $text-muted;
|
|
75
|
+
}
|
|
76
|
+
#confirm-dialog, #new-tab-dialog, #operation-launcher-dialog {
|
|
77
|
+
width: 60;
|
|
78
|
+
height: auto;
|
|
79
|
+
padding: 1 2;
|
|
80
|
+
background: $panel;
|
|
81
|
+
border: thick $primary;
|
|
82
|
+
}
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
BINDINGS = [
|
|
86
|
+
("ctrl+r", "run_tab", "Run"),
|
|
87
|
+
("ctrl+n", "new_tab", "New tab"),
|
|
88
|
+
("ctrl+w", "close_tab", "Close tab"),
|
|
89
|
+
("ctrl+o", "launch_operation", "Run operation"),
|
|
90
|
+
("ctrl+left", "narrow_tree", "Narrow tree"),
|
|
91
|
+
("ctrl+right", "widen_tree", "Widen tree"),
|
|
92
|
+
("ctrl+up", "grow_editor", "Grow editor"),
|
|
93
|
+
("ctrl+down", "shrink_editor", "Shrink editor"),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
tree_width: reactive[int] = reactive(DEFAULT_TREE_WIDTH)
|
|
97
|
+
|
|
98
|
+
def __init__(self, profile: str | None = None) -> None:
|
|
99
|
+
super().__init__()
|
|
100
|
+
self.profile = profile
|
|
101
|
+
self.connections, self.operations, self._load_warnings = load_registries(profile)
|
|
102
|
+
self.sessions = SessionManager(self.connections)
|
|
103
|
+
self._tab_seq = 0
|
|
104
|
+
|
|
105
|
+
def compose(self) -> ComposeResult:
|
|
106
|
+
yield Header()
|
|
107
|
+
with Horizontal():
|
|
108
|
+
yield ConnectionTree(self.connections, self.sessions, profile=self.profile, id="connection-tree")
|
|
109
|
+
yield VerticalSplitter(min_value=MIN_TREE_WIDTH, max_value=MAX_TREE_WIDTH, id="tree-splitter")
|
|
110
|
+
yield TabbedContent(id="workspace")
|
|
111
|
+
yield Footer()
|
|
112
|
+
|
|
113
|
+
def on_mount(self) -> None:
|
|
114
|
+
for warning in self._load_warnings:
|
|
115
|
+
self.notify(warning, severity="warning", timeout=10)
|
|
116
|
+
|
|
117
|
+
def watch_tree_width(self, width: int) -> None:
|
|
118
|
+
with contextlib.suppress(NoMatches): # not mounted yet - re-fires once it is
|
|
119
|
+
self.query_one("#connection-tree").styles.width = width
|
|
120
|
+
|
|
121
|
+
def action_narrow_tree(self) -> None:
|
|
122
|
+
self.tree_width = max(MIN_TREE_WIDTH, self.tree_width - TREE_WIDTH_STEP)
|
|
123
|
+
|
|
124
|
+
def action_widen_tree(self) -> None:
|
|
125
|
+
self.tree_width = min(MAX_TREE_WIDTH, self.tree_width + TREE_WIDTH_STEP)
|
|
126
|
+
|
|
127
|
+
def on_unmount(self) -> None:
|
|
128
|
+
self.sessions.disconnect_all()
|
|
129
|
+
|
|
130
|
+
@on(ConnectionActivated)
|
|
131
|
+
def _open_default_tab(self, message: ConnectionActivated) -> None:
|
|
132
|
+
self.open_sql_tab(message.name)
|
|
133
|
+
|
|
134
|
+
@on(TableActivated)
|
|
135
|
+
def _open_table_tab(self, message: TableActivated) -> None:
|
|
136
|
+
conn = self.connections[message.conn_name]
|
|
137
|
+
target = qualified_table(conn, message.table, message.schema_name)
|
|
138
|
+
self.open_sql_tab(message.conn_name, sql=default_select(conn, target))
|
|
139
|
+
|
|
140
|
+
def _next_tab_id(self) -> str:
|
|
141
|
+
self._tab_seq += 1
|
|
142
|
+
return f"tab-{self._tab_seq}"
|
|
143
|
+
|
|
144
|
+
def open_sql_tab(self, conn_name: str, *, sql: str | None = None) -> None:
|
|
145
|
+
tabbed = self.query_one(TabbedContent)
|
|
146
|
+
tab_id = self._next_tab_id()
|
|
147
|
+
initial_sql = sql or default_select(self.connections[conn_name])
|
|
148
|
+
pane = SqlEditorPane(conn_name, self.sessions, initial_sql, self.profile, id=f"pane-{tab_id}")
|
|
149
|
+
tabbed.add_pane(TabPane(f"{conn_name}: sql", pane, id=tab_id))
|
|
150
|
+
tabbed.active = tab_id
|
|
151
|
+
|
|
152
|
+
def open_operation_tab(self, conn_name: str, op_name: str) -> None:
|
|
153
|
+
tabbed = self.query_one(TabbedContent)
|
|
154
|
+
tab_id = self._next_tab_id()
|
|
155
|
+
op = self.operations[op_name]
|
|
156
|
+
pane = OperationPane(conn_name, op_name, op, self.sessions, self.profile, id=f"pane-{tab_id}")
|
|
157
|
+
tabbed.add_pane(TabPane(f"{conn_name}: {op_name}", pane, id=tab_id))
|
|
158
|
+
tabbed.active = tab_id
|
|
159
|
+
|
|
160
|
+
def action_new_tab(self) -> None:
|
|
161
|
+
if not self.connections:
|
|
162
|
+
self.notify("no connections configured", severity="warning")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
def handle(result: tuple[str, str, str | None] | None) -> None:
|
|
166
|
+
if result is None:
|
|
167
|
+
return
|
|
168
|
+
kind, conn_name, op_name = result
|
|
169
|
+
if kind == "sql":
|
|
170
|
+
self.open_sql_tab(conn_name)
|
|
171
|
+
elif op_name is not None:
|
|
172
|
+
self.open_operation_tab(conn_name, op_name)
|
|
173
|
+
|
|
174
|
+
singles = {n: o for n, o in self.operations.items() if o.scope.value == "single"}
|
|
175
|
+
self.push_screen(NewTabScreen(list(self.connections), singles), handle)
|
|
176
|
+
|
|
177
|
+
def action_close_tab(self) -> None:
|
|
178
|
+
tabbed = self.query_one(TabbedContent)
|
|
179
|
+
if tabbed.active:
|
|
180
|
+
tabbed.remove_pane(tabbed.active)
|
|
181
|
+
|
|
182
|
+
def _active_runnable(self) -> RunnableTab | None:
|
|
183
|
+
pane = self.query_one(TabbedContent).active_pane
|
|
184
|
+
if pane is None:
|
|
185
|
+
return None
|
|
186
|
+
try:
|
|
187
|
+
return pane.query_one(RunnableTab)
|
|
188
|
+
except NoMatches:
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
def action_run_tab(self) -> None:
|
|
192
|
+
runnable = self._active_runnable()
|
|
193
|
+
if runnable is not None:
|
|
194
|
+
runnable.run_tab()
|
|
195
|
+
|
|
196
|
+
def action_grow_editor(self) -> None:
|
|
197
|
+
runnable = self._active_runnable()
|
|
198
|
+
if runnable is not None:
|
|
199
|
+
runnable.editor_height = min(MAX_EDITOR_HEIGHT, runnable.editor_height + EDITOR_HEIGHT_STEP)
|
|
200
|
+
|
|
201
|
+
def action_shrink_editor(self) -> None:
|
|
202
|
+
runnable = self._active_runnable()
|
|
203
|
+
if runnable is not None:
|
|
204
|
+
runnable.editor_height = max(MIN_EDITOR_HEIGHT, runnable.editor_height - EDITOR_HEIGHT_STEP)
|
|
205
|
+
|
|
206
|
+
def _resolve_launch_connection(self) -> str | None:
|
|
207
|
+
"""Pick the connection Ctrl+O should target: the active tab's
|
|
208
|
+
connection, else whatever's highlighted in the tree, else the only
|
|
209
|
+
connection if there's just one."""
|
|
210
|
+
runnable = self._active_runnable()
|
|
211
|
+
if runnable is not None:
|
|
212
|
+
return runnable.conn_name
|
|
213
|
+
name = self.query_one(ConnectionTree).connection_name_at_cursor()
|
|
214
|
+
if name:
|
|
215
|
+
return name
|
|
216
|
+
if len(self.connections) == 1:
|
|
217
|
+
return next(iter(self.connections))
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
def action_launch_operation(self) -> None:
|
|
221
|
+
singles = {n: o for n, o in self.operations.items() if o.scope.value == "single"}
|
|
222
|
+
if not singles:
|
|
223
|
+
self.notify("no operations configured", severity="warning")
|
|
224
|
+
return
|
|
225
|
+
conn_name = self._resolve_launch_connection()
|
|
226
|
+
if conn_name is None:
|
|
227
|
+
self.notify("highlight a connection in the tree first", severity="warning")
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
def handle(op_name: str | None) -> None:
|
|
231
|
+
if op_name is not None:
|
|
232
|
+
self.open_operation_tab(conn_name, op_name)
|
|
233
|
+
|
|
234
|
+
self.push_screen(OperationLauncherScreen(singles, conn_name), handle)
|