sqlide 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqlide/__init__.py +3 -0
- sqlide/__main__.py +5 -0
- sqlide/app.py +46 -0
- sqlide/cli.py +64 -0
- sqlide/clipboard.py +65 -0
- sqlide/config/__init__.py +1 -0
- sqlide/config/_toml.py +41 -0
- sqlide/config/connections.py +76 -0
- sqlide/config/keymap.py +25 -0
- sqlide/config/paths.py +48 -0
- sqlide/config/secrets.py +82 -0
- sqlide/config/settings.py +32 -0
- sqlide/consoles.py +100 -0
- sqlide/db/__init__.py +1 -0
- sqlide/db/completion.py +127 -0
- sqlide/db/factory.py +37 -0
- sqlide/db/metadata.py +174 -0
- sqlide/db/result.py +52 -0
- sqlide/db/session.py +339 -0
- sqlide/db/types.py +84 -0
- sqlide/doctor.py +60 -0
- sqlide/drivers/__init__.py +1 -0
- sqlide/drivers/catalog.toml +103 -0
- sqlide/drivers/cli.py +74 -0
- sqlide/drivers/custom.py +69 -0
- sqlide/drivers/loader.py +75 -0
- sqlide/drivers/maven.py +108 -0
- sqlide/drivers/registry.py +117 -0
- sqlide/export/__init__.py +12 -0
- sqlide/export/base.py +86 -0
- sqlide/export/csv_.py +40 -0
- sqlide/export/html.py +44 -0
- sqlide/export/json_.py +47 -0
- sqlide/export/markdown.py +27 -0
- sqlide/export/service.py +34 -0
- sqlide/export/sql_insert.py +37 -0
- sqlide/export/xlsx.py +109 -0
- sqlide/grid/__init__.py +1 -0
- sqlide/grid/copyfmt.py +97 -0
- sqlide/grid/formatting.py +43 -0
- sqlide/grid/model.py +109 -0
- sqlide/history/__init__.py +5 -0
- sqlide/history/store.py +117 -0
- sqlide/jvm/__init__.py +1 -0
- sqlide/jvm/locate.py +98 -0
- sqlide/jvm/runtime.py +44 -0
- sqlide/sql/__init__.py +1 -0
- sqlide/sql/context.py +195 -0
- sqlide/sql/dialects.py +93 -0
- sqlide/sql/format.py +50 -0
- sqlide/sql/keywords.py +143 -0
- sqlide/sql/lexer.py +148 -0
- sqlide/sql/snippets.py +44 -0
- sqlide/sql/splitter.py +325 -0
- sqlide/ui/__init__.py +1 -0
- sqlide/ui/app.tcss +67 -0
- sqlide/ui/commands.py +59 -0
- sqlide/ui/keymap.py +32 -0
- sqlide/ui/screens/__init__.py +0 -0
- sqlide/ui/screens/connection_editor.py +113 -0
- sqlide/ui/screens/dialogs.py +110 -0
- sqlide/ui/screens/driver_manager.py +195 -0
- sqlide/ui/screens/export_dialog.py +158 -0
- sqlide/ui/screens/grid_dialogs.py +92 -0
- sqlide/ui/screens/history.py +130 -0
- sqlide/ui/screens/main.py +383 -0
- sqlide/ui/screens/settings.py +77 -0
- sqlide/ui/widgets/__init__.py +0 -0
- sqlide/ui/widgets/completion_popup.py +81 -0
- sqlide/ui/widgets/connections_list.py +54 -0
- sqlide/ui/widgets/console_export.py +124 -0
- sqlide/ui/widgets/console_tab.py +335 -0
- sqlide/ui/widgets/console_tabs.py +117 -0
- sqlide/ui/widgets/result_grid.py +435 -0
- sqlide/ui/widgets/result_panel.py +73 -0
- sqlide/ui/widgets/result_view.py +50 -0
- sqlide/ui/widgets/schema_tree.py +145 -0
- sqlide/ui/widgets/sql_editor.py +310 -0
- sqlide/ui/widgets/status_bar.py +32 -0
- sqlide/workspace.py +97 -0
- sqlide-0.1.0.dist-info/METADATA +151 -0
- sqlide-0.1.0.dist-info/RECORD +85 -0
- sqlide-0.1.0.dist-info/WHEEL +4 -0
- sqlide-0.1.0.dist-info/entry_points.txt +2 -0
- sqlide-0.1.0.dist-info/licenses/LICENSE +21 -0
sqlide/__init__.py
ADDED
sqlide/__main__.py
ADDED
sqlide/app.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Textual application: only wires the workspace to the main screen."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from textual.app import App
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
|
|
10
|
+
from sqlide.config.keymap import load_keymap
|
|
11
|
+
from sqlide.config.settings import save_settings
|
|
12
|
+
from sqlide.ui.commands import commands
|
|
13
|
+
from sqlide.ui.screens.main import MainScreen
|
|
14
|
+
from sqlide.workspace import Workspace
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SqlideApp(App):
|
|
18
|
+
CSS_PATH = str(Path(__file__).parent / "ui" / "app.tcss")
|
|
19
|
+
BINDINGS = [Binding("ctrl+q", "quit", "Quit", priority=True, id="app.quit")]
|
|
20
|
+
|
|
21
|
+
def __init__(self, workspace: Workspace | None = None, files: list[Path] | None = None) -> None:
|
|
22
|
+
super().__init__()
|
|
23
|
+
self.workspace = workspace or Workspace()
|
|
24
|
+
self._files = files or []
|
|
25
|
+
|
|
26
|
+
def on_mount(self) -> None:
|
|
27
|
+
self.set_keymap(load_keymap()) # user overrides from keymap.toml
|
|
28
|
+
if self.workspace.settings.theme in self.available_themes:
|
|
29
|
+
self.theme = self.workspace.settings.theme
|
|
30
|
+
self.theme_changed_signal.subscribe(self, self._remember_theme)
|
|
31
|
+
self.main = MainScreen(self.workspace, self._files)
|
|
32
|
+
self.push_screen(self.main)
|
|
33
|
+
|
|
34
|
+
def _remember_theme(self, theme) -> None:
|
|
35
|
+
settings = self.workspace.settings
|
|
36
|
+
if theme.name != settings.theme:
|
|
37
|
+
settings.theme = theme.name
|
|
38
|
+
save_settings(settings)
|
|
39
|
+
|
|
40
|
+
def get_system_commands(self, screen):
|
|
41
|
+
yield from super().get_system_commands(screen)
|
|
42
|
+
yield from commands(self)
|
|
43
|
+
|
|
44
|
+
async def action_quit(self) -> None:
|
|
45
|
+
await self.main.tabs.shutdown() # save consoles, cancel queries, close connections
|
|
46
|
+
self.exit()
|
sqlide/cli.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Command line entry point. Thin: dispatch only, no logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from sqlide import __version__
|
|
10
|
+
from sqlide.drivers import cli as driver_cli
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
14
|
+
p = argparse.ArgumentParser(prog="sqlide", description="Terminal SQL IDE over JDBC")
|
|
15
|
+
p.add_argument("--version", action="version", version=f"sqlide {__version__}")
|
|
16
|
+
sub = p.add_subparsers(dest="command")
|
|
17
|
+
d = sub.add_parser("doctor", help="check environment (JVM, drivers, config dirs)")
|
|
18
|
+
d.set_defaults(handler=lambda _: _doctor())
|
|
19
|
+
k = sub.add_parser("keys", help="list rebindable keys (override in keymap.toml)")
|
|
20
|
+
k.set_defaults(handler=lambda _: _keys())
|
|
21
|
+
driver_cli.register(sub)
|
|
22
|
+
return p
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _doctor() -> int:
|
|
26
|
+
from sqlide import doctor
|
|
27
|
+
|
|
28
|
+
return doctor.run()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _keys() -> int:
|
|
32
|
+
from sqlide.config import paths
|
|
33
|
+
from sqlide.ui.keymap import catalogue
|
|
34
|
+
|
|
35
|
+
print(f'Override in {paths.keymap_file()} ([keys] id = "key,key")\n')
|
|
36
|
+
infos = catalogue()
|
|
37
|
+
width = max(len(i.id) for i in infos)
|
|
38
|
+
for i in infos:
|
|
39
|
+
print(f"{i.id:<{width}} {i.keys:<40} {i.description}")
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
COMMANDS = {"doctor", "driver", "keys"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main(argv: list[str] | None = None) -> int:
|
|
47
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
48
|
+
if argv and argv[0] not in COMMANDS and not argv[0].startswith("-"):
|
|
49
|
+
return _run_tui([Path(a) for a in argv]) # `sqlide query.sql other.sql`
|
|
50
|
+
args = build_parser().parse_args(argv)
|
|
51
|
+
if getattr(args, "handler", None):
|
|
52
|
+
return args.handler(args)
|
|
53
|
+
return _run_tui([])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _run_tui(files: list[Path]) -> int:
|
|
57
|
+
from sqlide.app import SqlideApp
|
|
58
|
+
|
|
59
|
+
SqlideApp(files=files).run()
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
sys.exit(main())
|
sqlide/clipboard.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""System clipboard across macOS, Linux (X11/Wayland), WSL; OSC52 as the last resort."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
Backend = tuple[str, list[str], Callable[[str], bytes]]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _utf8(s: str) -> bytes:
|
|
15
|
+
return s.encode("utf-8")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _utf16(s: str) -> bytes: # clip.exe reads UTF-16LE when a BOM is present
|
|
19
|
+
return b"\xff\xfe" + s.encode("utf-16-le")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_wsl() -> bool:
|
|
23
|
+
try:
|
|
24
|
+
return "microsoft" in Path("/proc/version").read_text().lower()
|
|
25
|
+
except OSError:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def backends(platform: str | None = None, wsl: bool | None = None) -> list[Backend]:
|
|
30
|
+
"""Installed native clipboard tools in preference order."""
|
|
31
|
+
import sys
|
|
32
|
+
|
|
33
|
+
platform = platform or sys.platform
|
|
34
|
+
wsl = is_wsl() if wsl is None else wsl
|
|
35
|
+
candidates: list[Backend] = []
|
|
36
|
+
if platform == "darwin":
|
|
37
|
+
candidates.append(("pbcopy", ["pbcopy"], _utf8))
|
|
38
|
+
elif platform == "win32":
|
|
39
|
+
candidates.append(("clip", ["clip"], _utf16))
|
|
40
|
+
else:
|
|
41
|
+
if os.environ.get("WAYLAND_DISPLAY"):
|
|
42
|
+
candidates.append(("wl-copy", ["wl-copy"], _utf8))
|
|
43
|
+
if os.environ.get("DISPLAY"):
|
|
44
|
+
candidates.append(("xclip", ["xclip", "-selection", "clipboard", "-i"], _utf8))
|
|
45
|
+
candidates.append(("xsel", ["xsel", "--clipboard", "--input"], _utf8))
|
|
46
|
+
if wsl:
|
|
47
|
+
candidates.append(("clip.exe", ["clip.exe"], _utf16))
|
|
48
|
+
return [c for c in candidates if shutil.which(c[1][0])]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def copy_native(text: str) -> str | None:
|
|
52
|
+
"""Copy via the first working tool. Returns its name, or None if none worked."""
|
|
53
|
+
for name, cmd, encode in backends():
|
|
54
|
+
try:
|
|
55
|
+
subprocess.run(cmd, input=encode(text), check=True, timeout=5, capture_output=True)
|
|
56
|
+
return name
|
|
57
|
+
except (OSError, subprocess.SubprocessError):
|
|
58
|
+
continue
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def available() -> str:
|
|
63
|
+
"""Human-readable description for `sqlide doctor`."""
|
|
64
|
+
found = backends()
|
|
65
|
+
return found[0][0] if found else "none (falls back to OSC52 terminal escape)"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Configuration block: paths, settings, saved connections, password resolution."""
|
sqlide/config/_toml.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Tiny TOML IO shared by config modules: tolerant read, atomic write."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
import tomllib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import tomli_w
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigError(Exception):
|
|
15
|
+
"""Config file is unreadable or invalid."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read_toml(path: Path) -> dict[str, Any]:
|
|
19
|
+
"""Return {} when the file is missing; raise ConfigError when it is malformed."""
|
|
20
|
+
try:
|
|
21
|
+
with path.open("rb") as f:
|
|
22
|
+
return tomllib.load(f)
|
|
23
|
+
except FileNotFoundError:
|
|
24
|
+
return {}
|
|
25
|
+
except tomllib.TOMLDecodeError as e:
|
|
26
|
+
raise ConfigError(f"{path}: {e}") from e
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write_toml(path: Path, data: dict[str, Any], *, private: bool = False) -> None:
|
|
30
|
+
"""Write atomically (temp file + rename) so a crash never leaves a half file."""
|
|
31
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
|
|
33
|
+
try:
|
|
34
|
+
with os.fdopen(fd, "wb") as f:
|
|
35
|
+
tomli_w.dump(data, f)
|
|
36
|
+
if private:
|
|
37
|
+
os.chmod(tmp, 0o600)
|
|
38
|
+
os.replace(tmp, path)
|
|
39
|
+
except BaseException:
|
|
40
|
+
Path(tmp).unlink(missing_ok=True)
|
|
41
|
+
raise
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Saved connections. Passwords are never stored: only a reference or nothing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from sqlide.config import paths
|
|
11
|
+
from sqlide.config._toml import ConfigError, read_toml, write_toml
|
|
12
|
+
|
|
13
|
+
ENV_REF = re.compile(r"^\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class Connection:
|
|
18
|
+
name: str
|
|
19
|
+
driver: str # driver id, see drivers registry
|
|
20
|
+
url: str # JDBC url, e.g. jdbc:postgresql://host:5432/db
|
|
21
|
+
user: str = ""
|
|
22
|
+
password_ref: str = "" # "" = ask on connect, or "${env:VAR}"
|
|
23
|
+
password_cmd: str = "" # shell-free command whose stdout is the password
|
|
24
|
+
properties: dict[str, str] = field(default_factory=dict)
|
|
25
|
+
autocommit: bool = True
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
if not self.name.strip():
|
|
29
|
+
raise ConfigError("connection name is empty")
|
|
30
|
+
if self.password_ref and not ENV_REF.match(self.password_ref):
|
|
31
|
+
raise ConfigError(
|
|
32
|
+
f"{self.name}: password_ref must look like ${{env:VAR}}, "
|
|
33
|
+
"plain passwords are not stored"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ConnectionStore:
|
|
38
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
39
|
+
self.path = path or paths.connections_file()
|
|
40
|
+
|
|
41
|
+
def load(self) -> list[Connection]:
|
|
42
|
+
raw = read_toml(self.path)
|
|
43
|
+
out = []
|
|
44
|
+
for item in raw.get("connection", []):
|
|
45
|
+
try:
|
|
46
|
+
out.append(Connection(**item))
|
|
47
|
+
except TypeError as e:
|
|
48
|
+
raise ConfigError(f"{self.path}: bad connection entry: {e}") from e
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
def save(self, connections: list[Connection]) -> None:
|
|
52
|
+
names = [c.name for c in connections]
|
|
53
|
+
if len(names) != len(set(names)):
|
|
54
|
+
raise ConfigError("duplicate connection names")
|
|
55
|
+
data: dict[str, Any] = {"connection": [_to_dict(c) for c in connections]}
|
|
56
|
+
write_toml(self.path, data, private=True)
|
|
57
|
+
|
|
58
|
+
def upsert(self, conn: Connection) -> None:
|
|
59
|
+
items = [c for c in self.load() if c.name != conn.name]
|
|
60
|
+
self.save([*items, conn])
|
|
61
|
+
|
|
62
|
+
def remove(self, name: str) -> None:
|
|
63
|
+
self.save([c for c in self.load() if c.name != name])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _to_dict(c: Connection) -> dict[str, Any]:
|
|
67
|
+
return {
|
|
68
|
+
"name": c.name,
|
|
69
|
+
"driver": c.driver,
|
|
70
|
+
"url": c.url,
|
|
71
|
+
"user": c.user,
|
|
72
|
+
"password_ref": c.password_ref,
|
|
73
|
+
"password_cmd": c.password_cmd,
|
|
74
|
+
"properties": c.properties,
|
|
75
|
+
"autocommit": c.autocommit,
|
|
76
|
+
}
|
sqlide/config/keymap.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""User key overrides: keymap.toml maps binding ids to key strings.
|
|
2
|
+
|
|
3
|
+
[keys]
|
|
4
|
+
"editor.run" = "f6"
|
|
5
|
+
"main.history" = "ctrl+h,alt+e" # several keys: comma separated
|
|
6
|
+
|
|
7
|
+
`sqlide keys` lists every id with its default. Bad entries are ignored, never fatal.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from sqlide.config import paths
|
|
15
|
+
from sqlide.config._toml import ConfigError, read_toml
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_keymap(path: Path | None = None) -> dict[str, str]:
|
|
19
|
+
try:
|
|
20
|
+
raw = read_toml(path or paths.keymap_file()).get("keys", {})
|
|
21
|
+
except ConfigError:
|
|
22
|
+
return {}
|
|
23
|
+
if not isinstance(raw, dict):
|
|
24
|
+
return {}
|
|
25
|
+
return {k: v.strip() for k, v in raw.items() if isinstance(v, str) and v.strip()}
|
sqlide/config/paths.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Filesystem locations. Env overrides exist for tests and portable installs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from platformdirs import user_config_path, user_data_path
|
|
9
|
+
|
|
10
|
+
APP = "sqlide"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def config_dir() -> Path:
|
|
14
|
+
override = os.environ.get("SQLIDE_CONFIG_DIR")
|
|
15
|
+
return Path(override) if override else user_config_path(APP)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def data_dir() -> Path:
|
|
19
|
+
override = os.environ.get("SQLIDE_DATA_DIR")
|
|
20
|
+
return Path(override) if override else user_data_path(APP)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def settings_file() -> Path:
|
|
24
|
+
return config_dir() / "settings.toml"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def connections_file() -> Path:
|
|
28
|
+
return config_dir() / "connections.toml"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def keymap_file() -> Path:
|
|
32
|
+
return config_dir() / "keymap.toml"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def drivers_file() -> Path:
|
|
36
|
+
return config_dir() / "drivers.toml"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def drivers_dir() -> Path:
|
|
40
|
+
return data_dir() / "drivers"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def history_db() -> Path:
|
|
44
|
+
return data_dir() / "history.sqlite"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def consoles_dir() -> Path:
|
|
48
|
+
return data_dir() / "consoles"
|
sqlide/config/secrets.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Password resolution. Order: password_cmd -> ${env:VAR} -> prompt callback.
|
|
2
|
+
|
|
3
|
+
The session cache lives in memory only and dies with the process.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shlex
|
|
10
|
+
import subprocess
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
from sqlide.config._toml import ConfigError
|
|
14
|
+
from sqlide.config.connections import ENV_REF, Connection
|
|
15
|
+
|
|
16
|
+
CMD_TIMEOUT_S = 10
|
|
17
|
+
|
|
18
|
+
Prompt = Callable[[Connection], str | None] # None = user cancelled
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _split(cmd: str) -> list[str]:
|
|
22
|
+
if os.name != "nt":
|
|
23
|
+
return shlex.split(cmd)
|
|
24
|
+
# non-POSIX split keeps the quotes around tokens; strip them like CreateProcess would
|
|
25
|
+
return [
|
|
26
|
+
t[1:-1] if len(t) > 1 and t[0] == t[-1] == '"' else t for t in shlex.split(cmd, posix=False)
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PasswordResolver:
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self._cache: dict[str, str] = {}
|
|
33
|
+
|
|
34
|
+
def lookup(self, conn: Connection) -> str | None:
|
|
35
|
+
"""Non-interactive sources only. None = the user has to be asked. May block (cmd)."""
|
|
36
|
+
if conn.name in self._cache:
|
|
37
|
+
return self._cache[conn.name]
|
|
38
|
+
password = self._from_cmd(conn) or self._from_env(conn)
|
|
39
|
+
if password is not None:
|
|
40
|
+
self._cache[conn.name] = password
|
|
41
|
+
return password
|
|
42
|
+
|
|
43
|
+
def store(self, name: str, password: str) -> None:
|
|
44
|
+
self._cache[name] = password
|
|
45
|
+
|
|
46
|
+
def resolve(self, conn: Connection, prompt: Prompt) -> str | None:
|
|
47
|
+
"""Sync convenience: lookup, else ask via `prompt` (UI code uses lookup/store)."""
|
|
48
|
+
password = self.lookup(conn)
|
|
49
|
+
if password is None:
|
|
50
|
+
password = prompt(conn)
|
|
51
|
+
if password is not None:
|
|
52
|
+
self.store(conn.name, password)
|
|
53
|
+
return password
|
|
54
|
+
|
|
55
|
+
def forget(self, name: str) -> None:
|
|
56
|
+
self._cache.pop(name, None)
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _from_cmd(conn: Connection) -> str | None:
|
|
60
|
+
if not conn.password_cmd:
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
res = subprocess.run(
|
|
64
|
+
_split(conn.password_cmd),
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
timeout=CMD_TIMEOUT_S,
|
|
68
|
+
check=True,
|
|
69
|
+
)
|
|
70
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
71
|
+
raise ConfigError(f"{conn.name}: password_cmd failed: {e}") from e
|
|
72
|
+
return res.stdout.rstrip("\r\n")
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _from_env(conn: Connection) -> str | None:
|
|
76
|
+
m = ENV_REF.match(conn.password_ref)
|
|
77
|
+
if not m:
|
|
78
|
+
return None
|
|
79
|
+
value = os.environ.get(m.group(1))
|
|
80
|
+
if value is None:
|
|
81
|
+
raise ConfigError(f"{conn.name}: env var {m.group(1)} is not set")
|
|
82
|
+
return value
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""User settings. Unknown keys in the file are ignored; bad values fall back to defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass, fields
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from sqlide.config import paths
|
|
9
|
+
from sqlide.config._toml import read_toml, write_toml
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class Settings:
|
|
14
|
+
fetch_size: int = 500 # rows per page
|
|
15
|
+
split_on_blank_line: bool = True # blank line ends a statement (DataGrip-like)
|
|
16
|
+
autosave_consoles: bool = True
|
|
17
|
+
theme: str = "textual-dark"
|
|
18
|
+
history_limit: int = 5000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_settings(path: Path | None = None) -> Settings:
|
|
22
|
+
raw = read_toml(path or paths.settings_file())
|
|
23
|
+
defaults = Settings()
|
|
24
|
+
values = {}
|
|
25
|
+
for f in fields(Settings):
|
|
26
|
+
if f.name in raw and type(raw[f.name]) is type(getattr(defaults, f.name)):
|
|
27
|
+
values[f.name] = raw[f.name]
|
|
28
|
+
return Settings(**values)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def save_settings(settings: Settings, path: Path | None = None) -> None:
|
|
32
|
+
write_toml(path or paths.settings_file(), asdict(settings))
|
sqlide/consoles.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Console files and the list of open tabs. Pure storage: no UI, no JVM.
|
|
2
|
+
|
|
3
|
+
Consoles are scratch SQL files owned by the app: ``consoles/<connection>/console_N.sql``,
|
|
4
|
+
autosaved. Files opened by the user are saved only on request. The tab list is kept in
|
|
5
|
+
``state.toml`` so the next start reopens the same tabs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import tempfile
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from sqlide.config import paths
|
|
17
|
+
from sqlide.config._toml import ConfigError, read_toml, write_toml
|
|
18
|
+
|
|
19
|
+
CONSOLE, FILE = "console", "file"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class TabState:
|
|
24
|
+
kind: str # CONSOLE | FILE
|
|
25
|
+
path: str
|
|
26
|
+
connection: str = "" # intended connection name; connected lazily
|
|
27
|
+
active: bool = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def safe_dirname(name: str) -> str:
|
|
31
|
+
return re.sub(r"[^\w.\-]+", "_", name).strip("._") or "_"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def write_text_atomic(path: Path, text: str) -> None:
|
|
35
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
|
|
37
|
+
try:
|
|
38
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
|
|
39
|
+
f.write(text)
|
|
40
|
+
os.replace(tmp, path)
|
|
41
|
+
except BaseException:
|
|
42
|
+
Path(tmp).unlink(missing_ok=True)
|
|
43
|
+
raise
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ConsoleStore:
|
|
47
|
+
def __init__(self, root: Path | None = None, state_file: Path | None = None) -> None:
|
|
48
|
+
self.root = root or paths.consoles_dir()
|
|
49
|
+
self.state_file = state_file or (paths.data_dir() / "state.toml")
|
|
50
|
+
|
|
51
|
+
def new_console_file(self, connection: str | None) -> Path:
|
|
52
|
+
"""Reserve the next free ``console_N.sql`` for a connection (or no connection)."""
|
|
53
|
+
folder = self.root / safe_dirname(connection or "no-connection")
|
|
54
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
used = [
|
|
56
|
+
int(m.group(1))
|
|
57
|
+
for f in self.root.glob("*/console_*.sql")
|
|
58
|
+
if (m := re.fullmatch(r"console_(\d+)\.sql", f.name))
|
|
59
|
+
]
|
|
60
|
+
n = max(used, default=0) + 1 # unique across connections, so tab titles differ
|
|
61
|
+
path = folder / f"console_{n}.sql"
|
|
62
|
+
path.touch()
|
|
63
|
+
return path
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def read(path: Path) -> tuple[str, str]:
|
|
67
|
+
"""(text normalised to '\\n', original newline style) so saving can restore it."""
|
|
68
|
+
with path.open(encoding="utf-8", newline="") as f:
|
|
69
|
+
raw = f.read()
|
|
70
|
+
newline = "\r\n" if "\r\n" in raw else "\n"
|
|
71
|
+
return raw.replace("\r\n", "\n").replace("\r", "\n"), newline
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def write(path: Path, text: str, newline: str = "\n") -> None:
|
|
75
|
+
write_text_atomic(path, text.replace("\n", newline) if newline != "\n" else text)
|
|
76
|
+
|
|
77
|
+
# --- tab list ---
|
|
78
|
+
def load_state(self) -> list[TabState]:
|
|
79
|
+
try:
|
|
80
|
+
raw = read_toml(self.state_file).get("tab", [])
|
|
81
|
+
except ConfigError:
|
|
82
|
+
return [] # a damaged state file must never stop the app from starting
|
|
83
|
+
tabs = []
|
|
84
|
+
for item in raw:
|
|
85
|
+
try:
|
|
86
|
+
tabs.append(TabState(**item))
|
|
87
|
+
except TypeError:
|
|
88
|
+
continue
|
|
89
|
+
return [t for t in tabs if t.kind in (CONSOLE, FILE)]
|
|
90
|
+
|
|
91
|
+
def save_state(self, tabs: list[TabState]) -> None:
|
|
92
|
+
write_toml(
|
|
93
|
+
self.state_file,
|
|
94
|
+
{
|
|
95
|
+
"tab": [
|
|
96
|
+
{"kind": t.kind, "path": t.path, "connection": t.connection, "active": t.active}
|
|
97
|
+
for t in tabs
|
|
98
|
+
]
|
|
99
|
+
},
|
|
100
|
+
)
|
sqlide/db/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""DB block: JDBC sessions, typed results. Knows nothing about the UI."""
|