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/drivers/custom.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Build and validate a user-defined driver (shared by the CLI and the driver manager UI)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from sqlide.config._toml import ConfigError
|
|
9
|
+
from sqlide.drivers.loader import DriverError, scan_driver_classes
|
|
10
|
+
from sqlide.drivers.registry import DriverDef
|
|
11
|
+
|
|
12
|
+
_ID = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_maven(coords: str) -> tuple[str, str, list[str]]:
|
|
16
|
+
"""'group:artifact[:classifier]' -> (group, artifact, classifiers)."""
|
|
17
|
+
parts = [p.strip() for p in coords.split(":")]
|
|
18
|
+
if len(parts) not in (2, 3) or not all(parts[:2]):
|
|
19
|
+
raise ConfigError("Maven coordinates: group:artifact[:classifier]")
|
|
20
|
+
return parts[0], parts[1], [parts[2] if len(parts) == 3 else ""]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_custom_driver(
|
|
24
|
+
id: str,
|
|
25
|
+
name: str = "",
|
|
26
|
+
*,
|
|
27
|
+
maven: str = "",
|
|
28
|
+
jars: list[str] | None = None,
|
|
29
|
+
class_name: str = "",
|
|
30
|
+
url_template: str = "",
|
|
31
|
+
default_port: int = 0,
|
|
32
|
+
dialect: str = "generic",
|
|
33
|
+
) -> DriverDef:
|
|
34
|
+
"""Validate the pieces. Jars are checked on disk and the driver class is detected from them."""
|
|
35
|
+
id = id.strip()
|
|
36
|
+
if not _ID.match(id):
|
|
37
|
+
raise ConfigError("Id: lowercase letters, digits, '-' and '_'")
|
|
38
|
+
jars = [j.strip() for j in (jars or []) if j.strip()]
|
|
39
|
+
if bool(maven.strip()) == bool(jars):
|
|
40
|
+
raise ConfigError("Give either Maven coordinates or jar files")
|
|
41
|
+
group = artifact = ""
|
|
42
|
+
classifiers = [""]
|
|
43
|
+
if maven.strip():
|
|
44
|
+
group, artifact, classifiers = parse_maven(maven)
|
|
45
|
+
resolved = [Path(j).expanduser() for j in jars]
|
|
46
|
+
for j in resolved:
|
|
47
|
+
if not j.is_file():
|
|
48
|
+
raise ConfigError(f"No such jar: {j}")
|
|
49
|
+
class_name = class_name.strip()
|
|
50
|
+
if resolved and not class_name:
|
|
51
|
+
try:
|
|
52
|
+
found = scan_driver_classes(resolved)
|
|
53
|
+
except DriverError as e:
|
|
54
|
+
raise ConfigError(str(e)) from e
|
|
55
|
+
if not found:
|
|
56
|
+
raise ConfigError("No JDBC driver found in the jars: enter the driver class")
|
|
57
|
+
class_name = found[0]
|
|
58
|
+
return DriverDef(
|
|
59
|
+
id=id,
|
|
60
|
+
name=name.strip() or id,
|
|
61
|
+
class_name=class_name,
|
|
62
|
+
url_template=url_template.strip(),
|
|
63
|
+
default_port=default_port,
|
|
64
|
+
dialect=dialect,
|
|
65
|
+
group=group,
|
|
66
|
+
artifact=artifact,
|
|
67
|
+
classifiers=classifiers,
|
|
68
|
+
jars=[str(j) for j in resolved],
|
|
69
|
+
)
|
sqlide/drivers/loader.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Load a JDBC driver through its own URLClassLoader (no DriverManager, no global state)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import zipfile
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from sqlide.drivers.registry import DriverDef
|
|
11
|
+
from sqlide.jvm.runtime import ensure_jvm
|
|
12
|
+
|
|
13
|
+
SERVICE_FILE = "META-INF/services/java.sql.Driver"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DriverError(Exception):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def scan_driver_classes(jars: list[Path]) -> list[str]:
|
|
21
|
+
"""Driver classes declared in the jars. Pure zip read, JVM not needed."""
|
|
22
|
+
found: list[str] = []
|
|
23
|
+
for jar in jars:
|
|
24
|
+
try:
|
|
25
|
+
with zipfile.ZipFile(jar) as z:
|
|
26
|
+
if SERVICE_FILE in z.namelist():
|
|
27
|
+
for line in z.read(SERVICE_FILE).decode("utf-8", "replace").splitlines():
|
|
28
|
+
line = line.split("#", 1)[0].strip()
|
|
29
|
+
if line and line not in found:
|
|
30
|
+
found.append(line)
|
|
31
|
+
except (OSError, zipfile.BadZipFile) as e:
|
|
32
|
+
raise DriverError(f"{jar}: {e}") from e
|
|
33
|
+
return found
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class LoadedDriver:
|
|
38
|
+
driver: Any # java.sql.Driver
|
|
39
|
+
loader: Any # java.net.URLClassLoader, set as thread context loader by sessions
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_cache: dict[tuple[str, ...], LoadedDriver] = {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_driver(defn: DriverDef, jars: list[Path]) -> LoadedDriver:
|
|
46
|
+
if not jars:
|
|
47
|
+
raise DriverError(f"driver '{defn.id}' has no jars; run: sqlide driver install {defn.id}")
|
|
48
|
+
key = (defn.class_name, *map(str, jars))
|
|
49
|
+
if key in _cache:
|
|
50
|
+
return _cache[key]
|
|
51
|
+
|
|
52
|
+
class_name = defn.class_name
|
|
53
|
+
if not class_name:
|
|
54
|
+
classes = scan_driver_classes(jars)
|
|
55
|
+
if len(classes) != 1:
|
|
56
|
+
raise DriverError(
|
|
57
|
+
f"driver '{defn.id}': cannot pick class automatically, candidates: {classes}"
|
|
58
|
+
)
|
|
59
|
+
class_name = classes[0]
|
|
60
|
+
|
|
61
|
+
ensure_jvm()
|
|
62
|
+
import jpype # deferred: JVM must be up before JClass lookups
|
|
63
|
+
|
|
64
|
+
File, URL = jpype.JClass("java.io.File"), jpype.JClass("java.net.URL")
|
|
65
|
+
jarray: Any = jpype.JArray # JPype stubs type this too narrowly
|
|
66
|
+
urls = jarray(URL)([File(str(j)).toURI().toURL() for j in jars])
|
|
67
|
+
parent = jpype.JClass("java.lang.ClassLoader").getSystemClassLoader()
|
|
68
|
+
loader = jpype.JClass("java.net.URLClassLoader")(urls, parent)
|
|
69
|
+
try:
|
|
70
|
+
cls = jpype.JClass("java.lang.Class").forName(class_name, True, loader)
|
|
71
|
+
driver = cls.getDeclaredConstructor().newInstance()
|
|
72
|
+
except Exception as e: # java exceptions surface as Python exceptions in JPype
|
|
73
|
+
raise DriverError(f"cannot load {class_name}: {e}") from e
|
|
74
|
+
_cache[key] = LoadedDriver(driver, loader)
|
|
75
|
+
return _cache[key]
|
sqlide/drivers/maven.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Maven Central client: pick latest stable version, download jar, verify sha1."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import xml.etree.ElementTree as ET
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
BASE_URL = os.environ.get("SQLIDE_MAVEN_URL", "https://repo1.maven.org/maven2")
|
|
15
|
+
|
|
16
|
+
Progress = Callable[[int, int | None], None] # (bytes_done, total_or_None)
|
|
17
|
+
|
|
18
|
+
_PRE = re.compile(r"^(alpha|beta|rc|cr|m|b|ea|dev|preview|snapshot)\d*$", re.I)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MavenError(Exception):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _tokens(version: str) -> list[str]:
|
|
26
|
+
return [t for t in re.split(r"[.\-_]", version) if t]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_stable(version: str) -> bool:
|
|
30
|
+
return not any(_PRE.match(t) for t in _tokens(version))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def version_key(version: str) -> tuple:
|
|
34
|
+
"""Sortable key: numeric tokens compare as numbers and rank above text tokens."""
|
|
35
|
+
return tuple((1, int(t), "") if t.isdigit() else (0, 0, t) for t in _tokens(version))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def pick_latest(versions: list[str], suffix: str = "") -> str:
|
|
39
|
+
pool = [v for v in versions if is_stable(v) and v.endswith(suffix)]
|
|
40
|
+
if not pool:
|
|
41
|
+
raise MavenError("no stable versions found")
|
|
42
|
+
return max(pool, key=version_key)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def jar_name(artifact: str, version: str, classifier: str) -> str:
|
|
46
|
+
return f"{artifact}-{version}" + (f"-{classifier}" if classifier else "") + ".jar"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MavenClient:
|
|
50
|
+
def __init__(self, client: httpx.Client | None = None, base_url: str = BASE_URL) -> None:
|
|
51
|
+
self._client = client or httpx.Client(timeout=30, follow_redirects=True)
|
|
52
|
+
self._base = base_url.rstrip("/")
|
|
53
|
+
|
|
54
|
+
def _dir(self, group: str, artifact: str) -> str:
|
|
55
|
+
return f"{self._base}/{group.replace('.', '/')}/{artifact}"
|
|
56
|
+
|
|
57
|
+
def versions(self, group: str, artifact: str) -> list[str]:
|
|
58
|
+
url = f"{self._dir(group, artifact)}/maven-metadata.xml"
|
|
59
|
+
r = self._client.get(url)
|
|
60
|
+
if r.status_code != 200:
|
|
61
|
+
raise MavenError(f"{url}: HTTP {r.status_code}")
|
|
62
|
+
return [v.text or "" for v in ET.fromstring(r.text).iter("version")]
|
|
63
|
+
|
|
64
|
+
def latest(self, group: str, artifact: str, suffix: str = "") -> str:
|
|
65
|
+
return pick_latest(self.versions(group, artifact), suffix)
|
|
66
|
+
|
|
67
|
+
def find_jar_url(
|
|
68
|
+
self, group: str, artifact: str, version: str, classifiers: list[str]
|
|
69
|
+
) -> tuple[str, str]:
|
|
70
|
+
"""First classifier whose jar exists -> (url, file name)."""
|
|
71
|
+
for c in classifiers or [""]:
|
|
72
|
+
name = jar_name(artifact, version, c)
|
|
73
|
+
url = f"{self._dir(group, artifact)}/{version}/{name}"
|
|
74
|
+
if self._client.head(url).status_code == 200:
|
|
75
|
+
return url, name
|
|
76
|
+
raise MavenError(f"{group}:{artifact}:{version}: no jar for classifiers {classifiers}")
|
|
77
|
+
|
|
78
|
+
def download(self, url: str, dest: Path, progress: Progress | None = None) -> Path:
|
|
79
|
+
"""Stream to a temp file, verify against <url>.sha1, then rename into place."""
|
|
80
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
tmp = dest.with_name(dest.name + ".part")
|
|
82
|
+
sha = hashlib.sha1()
|
|
83
|
+
try:
|
|
84
|
+
with self._client.stream("GET", url) as r:
|
|
85
|
+
if r.status_code != 200:
|
|
86
|
+
raise MavenError(f"{url}: HTTP {r.status_code}")
|
|
87
|
+
total = int(r.headers["content-length"]) if "content-length" in r.headers else None
|
|
88
|
+
done = 0
|
|
89
|
+
with tmp.open("wb") as f:
|
|
90
|
+
for chunk in r.iter_bytes(65536):
|
|
91
|
+
f.write(chunk)
|
|
92
|
+
sha.update(chunk)
|
|
93
|
+
done += len(chunk)
|
|
94
|
+
if progress:
|
|
95
|
+
progress(done, total)
|
|
96
|
+
self._verify(url, sha.hexdigest())
|
|
97
|
+
os.replace(tmp, dest)
|
|
98
|
+
finally:
|
|
99
|
+
tmp.unlink(missing_ok=True)
|
|
100
|
+
return dest
|
|
101
|
+
|
|
102
|
+
def _verify(self, url: str, actual: str) -> None:
|
|
103
|
+
r = self._client.get(url + ".sha1")
|
|
104
|
+
if r.status_code != 200:
|
|
105
|
+
raise MavenError(f"{url}.sha1: HTTP {r.status_code}, cannot verify")
|
|
106
|
+
expected = r.text.split()[0].strip().lower() if r.text.strip() else ""
|
|
107
|
+
if expected != actual:
|
|
108
|
+
raise MavenError(f"sha1 mismatch for {url}: expected {expected}, got {actual}")
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Driver definitions (built-in catalog + user drivers.toml) and what is installed on disk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import tomllib
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass, field, fields
|
|
9
|
+
from importlib import resources
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from sqlide.config import paths
|
|
13
|
+
from sqlide.config._toml import ConfigError, read_toml, write_toml
|
|
14
|
+
from sqlide.drivers.maven import MavenClient, MavenError, version_key
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class DriverDef:
|
|
19
|
+
id: str
|
|
20
|
+
name: str
|
|
21
|
+
class_name: str = "" # empty = detect from META-INF/services in the jars
|
|
22
|
+
url_template: str = ""
|
|
23
|
+
default_port: int = 0
|
|
24
|
+
dialect: str = "generic"
|
|
25
|
+
group: str = "" # Maven coordinates (downloadable driver)...
|
|
26
|
+
artifact: str = ""
|
|
27
|
+
classifiers: list[str] = field(default_factory=lambda: [""])
|
|
28
|
+
version_suffix: str = ""
|
|
29
|
+
jars: list[str] = field(default_factory=list) # ...or local jar paths (custom driver)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def is_maven(self) -> bool:
|
|
33
|
+
return bool(self.group and self.artifact)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _from_dict(raw: dict, origin: str) -> DriverDef:
|
|
37
|
+
known = {f.name for f in fields(DriverDef)}
|
|
38
|
+
if extra := set(raw) - known:
|
|
39
|
+
raise ConfigError(f"{origin}: unknown driver keys {sorted(extra)}")
|
|
40
|
+
try:
|
|
41
|
+
d = DriverDef(**raw)
|
|
42
|
+
except TypeError as e:
|
|
43
|
+
raise ConfigError(f"{origin}: {e}") from e
|
|
44
|
+
if not d.is_maven and not d.jars:
|
|
45
|
+
raise ConfigError(f"{origin}: driver '{d.id}' needs group+artifact or jars")
|
|
46
|
+
return d
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _builtin() -> dict[str, DriverDef]:
|
|
50
|
+
text = resources.files("sqlide.drivers").joinpath("catalog.toml").read_text("utf-8")
|
|
51
|
+
items = tomllib.loads(text)["driver"]
|
|
52
|
+
return {d["id"]: _from_dict(d, "catalog.toml") for d in items}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class DriverRegistry:
|
|
56
|
+
def __init__(self, drivers_dir: Path | None = None, user_file: Path | None = None) -> None:
|
|
57
|
+
self.dir = drivers_dir or paths.drivers_dir()
|
|
58
|
+
self.user_file = user_file or paths.drivers_file()
|
|
59
|
+
|
|
60
|
+
# --- definitions ---
|
|
61
|
+
def _user(self) -> dict[str, DriverDef]:
|
|
62
|
+
items = read_toml(self.user_file).get("driver", [])
|
|
63
|
+
return {d["id"]: _from_dict(d, str(self.user_file)) for d in items if "id" in d}
|
|
64
|
+
|
|
65
|
+
def all(self) -> dict[str, DriverDef]:
|
|
66
|
+
return {**_builtin(), **self._user()} # user entry overrides built-in by id
|
|
67
|
+
|
|
68
|
+
def get(self, driver_id: str) -> DriverDef:
|
|
69
|
+
try:
|
|
70
|
+
return self.all()[driver_id]
|
|
71
|
+
except KeyError:
|
|
72
|
+
raise ConfigError(f"unknown driver '{driver_id}'") from None
|
|
73
|
+
|
|
74
|
+
def add_custom(self, d: DriverDef) -> None:
|
|
75
|
+
_from_dict({f.name: getattr(d, f.name) for f in fields(d)}, "new driver") # validate
|
|
76
|
+
users = {**self._user(), d.id: d}
|
|
77
|
+
write_toml(
|
|
78
|
+
self.user_file,
|
|
79
|
+
{"driver": [{f.name: getattr(u, f.name) for f in fields(u)} for u in users.values()]},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# --- disk state ---
|
|
83
|
+
def installed_versions(self, driver_id: str) -> list[str]:
|
|
84
|
+
root = self.dir / driver_id
|
|
85
|
+
if not root.is_dir():
|
|
86
|
+
return []
|
|
87
|
+
vs = [p.name for p in root.iterdir() if p.is_dir() and any(p.glob("*.jar"))]
|
|
88
|
+
return sorted(vs, key=version_key)
|
|
89
|
+
|
|
90
|
+
def jar_paths(self, driver_id: str) -> list[Path]:
|
|
91
|
+
d = self.get(driver_id)
|
|
92
|
+
if d.jars:
|
|
93
|
+
return [Path(j).expanduser() for j in d.jars if Path(j).expanduser().is_file()]
|
|
94
|
+
versions = self.installed_versions(driver_id)
|
|
95
|
+
return sorted((self.dir / driver_id / versions[-1]).glob("*.jar")) if versions else []
|
|
96
|
+
|
|
97
|
+
def is_installed(self, driver_id: str) -> bool:
|
|
98
|
+
return bool(self.jar_paths(driver_id))
|
|
99
|
+
|
|
100
|
+
# --- install ---
|
|
101
|
+
def install(
|
|
102
|
+
self,
|
|
103
|
+
driver_id: str,
|
|
104
|
+
maven: MavenClient | None = None,
|
|
105
|
+
version: str | None = None,
|
|
106
|
+
progress: Callable[[int, int | None], None] | None = None,
|
|
107
|
+
) -> Path:
|
|
108
|
+
d = self.get(driver_id)
|
|
109
|
+
if not d.is_maven:
|
|
110
|
+
raise MavenError(f"driver '{driver_id}' is local-jar only, nothing to download")
|
|
111
|
+
maven = maven or MavenClient()
|
|
112
|
+
version = version or maven.latest(d.group, d.artifact, d.version_suffix)
|
|
113
|
+
url, name = maven.find_jar_url(d.group, d.artifact, version, d.classifiers)
|
|
114
|
+
return maven.download(url, self.dir / driver_id / version / name, progress)
|
|
115
|
+
|
|
116
|
+
def uninstall(self, driver_id: str) -> None:
|
|
117
|
+
shutil.rmtree(self.dir / driver_id, ignore_errors=True)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Export block: pluggable file formats. Importing the package registers all of them."""
|
|
2
|
+
|
|
3
|
+
from sqlide.export import csv_, html, json_, markdown, sql_insert, xlsx # noqa: F401
|
|
4
|
+
from sqlide.export.base import (
|
|
5
|
+
ExportCancelled,
|
|
6
|
+
Exporter,
|
|
7
|
+
ExportOptions,
|
|
8
|
+
all_exporters,
|
|
9
|
+
get,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = ["ExportCancelled", "ExportOptions", "Exporter", "all_exporters", "get"]
|
sqlide/export/base.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Exporter contract, options, registry. Formats live in sibling modules, one file each."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import Callable, Iterable, Iterator, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from sqlide.db.result import Column
|
|
13
|
+
|
|
14
|
+
Row = tuple[Any, ...]
|
|
15
|
+
PROGRESS_EVERY = 1000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ExportCancelled(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class ExportOptions:
|
|
24
|
+
header: bool = True
|
|
25
|
+
delimiter: str = "," # csv
|
|
26
|
+
bom: bool = False # csv: UTF-8 BOM, makes Excel pick the right encoding
|
|
27
|
+
table_name: str = "table_name" # sql insert
|
|
28
|
+
batch_size: int = 1 # sql insert: rows per INSERT
|
|
29
|
+
sheet_name: str = "Result" # xlsx
|
|
30
|
+
progress: Callable[[int], None] | None = None # rows written so far
|
|
31
|
+
should_cancel: Callable[[], bool] | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Exporter(ABC):
|
|
35
|
+
name: str # registry key, e.g. "csv"
|
|
36
|
+
label: str # shown in the UI
|
|
37
|
+
extension: str # with dot
|
|
38
|
+
|
|
39
|
+
def write(
|
|
40
|
+
self, columns: Sequence[Column], rows: Iterable[Row], path: Path, opts: ExportOptions
|
|
41
|
+
) -> int:
|
|
42
|
+
"""Write all rows to `path` atomically. Returns the number of rows written."""
|
|
43
|
+
path = Path(path)
|
|
44
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
tmp = path.with_name(f".{path.name}.part")
|
|
46
|
+
try:
|
|
47
|
+
count = self._write(list(columns), tracked(rows, opts), tmp, opts)
|
|
48
|
+
os.replace(tmp, path)
|
|
49
|
+
return count
|
|
50
|
+
finally:
|
|
51
|
+
tmp.unlink(missing_ok=True)
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def _write(
|
|
55
|
+
self, columns: list[Column], rows: Iterator[Row], tmp: Path, opts: ExportOptions
|
|
56
|
+
) -> int: ...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def tracked(rows: Iterable[Row], opts: ExportOptions) -> Iterator[Row]:
|
|
60
|
+
"""Wrap the row stream: progress callbacks and cooperative cancellation."""
|
|
61
|
+
for n, row in enumerate(rows, 1):
|
|
62
|
+
if opts.should_cancel is not None and opts.should_cancel():
|
|
63
|
+
raise ExportCancelled()
|
|
64
|
+
yield row
|
|
65
|
+
if opts.progress is not None and n % PROGRESS_EVERY == 0:
|
|
66
|
+
opts.progress(n)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_REGISTRY: dict[str, Exporter] = {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def register(cls: type[Exporter]) -> type[Exporter]:
|
|
73
|
+
inst = cls()
|
|
74
|
+
_REGISTRY[inst.name] = inst
|
|
75
|
+
return cls
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get(name: str) -> Exporter:
|
|
79
|
+
try:
|
|
80
|
+
return _REGISTRY[name]
|
|
81
|
+
except KeyError:
|
|
82
|
+
raise KeyError(f"unknown export format '{name}'") from None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def all_exporters() -> list[Exporter]:
|
|
86
|
+
return list(_REGISTRY.values())
|
sqlide/export/csv_.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""CSV and TSV."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from sqlide.db.result import Column
|
|
10
|
+
from sqlide.export.base import Exporter, ExportOptions, Row, register
|
|
11
|
+
from sqlide.grid.formatting import raw_text
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _Delimited(Exporter):
|
|
15
|
+
forced_delimiter: str | None = None
|
|
16
|
+
|
|
17
|
+
def _write(
|
|
18
|
+
self, columns: list[Column], rows: Iterator[Row], tmp: Path, opts: ExportOptions
|
|
19
|
+
) -> int:
|
|
20
|
+
delimiter = self.forced_delimiter or opts.delimiter or ","
|
|
21
|
+
count = 0
|
|
22
|
+
with tmp.open("w", encoding="utf-8-sig" if opts.bom else "utf-8", newline="") as f:
|
|
23
|
+
w = csv.writer(f, delimiter=delimiter, lineterminator="\n")
|
|
24
|
+
if opts.header:
|
|
25
|
+
w.writerow([c.name for c in columns])
|
|
26
|
+
for row in rows:
|
|
27
|
+
w.writerow([raw_text(v) for v in row])
|
|
28
|
+
count += 1
|
|
29
|
+
return count
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@register
|
|
33
|
+
class CsvExporter(_Delimited):
|
|
34
|
+
name, label, extension = "csv", "CSV", ".csv"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@register
|
|
38
|
+
class TsvExporter(_Delimited):
|
|
39
|
+
name, label, extension = "tsv", "TSV (tab separated)", ".tsv"
|
|
40
|
+
forced_delimiter = "\t"
|
sqlide/export/html.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Standalone HTML table."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from sqlide.db.result import Column
|
|
10
|
+
from sqlide.export.base import Exporter, ExportOptions, Row, register
|
|
11
|
+
from sqlide.grid.formatting import raw_text
|
|
12
|
+
|
|
13
|
+
_HEAD = (
|
|
14
|
+
'<!doctype html>\n<meta charset="utf-8">\n<style>'
|
|
15
|
+
"table{border-collapse:collapse;font:14px sans-serif}"
|
|
16
|
+
"th,td{border:1px solid #ccc;padding:4px 8px;text-align:left}"
|
|
17
|
+
"th{background:#f0f0f0}td.null{background:#fafafa}"
|
|
18
|
+
"</style>\n<table>\n"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@register
|
|
23
|
+
class HtmlExporter(Exporter):
|
|
24
|
+
name, label, extension = "html", "HTML table", ".html"
|
|
25
|
+
|
|
26
|
+
def _write(
|
|
27
|
+
self, columns: list[Column], rows: Iterator[Row], tmp: Path, opts: ExportOptions
|
|
28
|
+
) -> int:
|
|
29
|
+
count = 0
|
|
30
|
+
with tmp.open("w", encoding="utf-8") as f:
|
|
31
|
+
f.write(_HEAD)
|
|
32
|
+
f.write("<thead><tr>" + "".join(f"<th>{html.escape(c.name)}</th>" for c in columns))
|
|
33
|
+
f.write("</tr></thead>\n<tbody>\n")
|
|
34
|
+
for row in rows:
|
|
35
|
+
cells = (
|
|
36
|
+
'<td class="null"></td>'
|
|
37
|
+
if v is None
|
|
38
|
+
else f"<td>{html.escape(raw_text(v))}</td>"
|
|
39
|
+
for v in row
|
|
40
|
+
)
|
|
41
|
+
f.write("<tr>" + "".join(cells) + "</tr>\n")
|
|
42
|
+
count += 1
|
|
43
|
+
f.write("</tbody>\n</table>\n")
|
|
44
|
+
return count
|
sqlide/export/json_.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""JSON array and JSON Lines, streamed row by row."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from sqlide.db.result import Column
|
|
10
|
+
from sqlide.export.base import Exporter, ExportOptions, Row, register
|
|
11
|
+
from sqlide.grid.copyfmt import json_default
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _dump(names: list[str], row: Row) -> str:
|
|
15
|
+
return json.dumps(dict(zip(names, row, strict=True)), default=json_default, ensure_ascii=False)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@register
|
|
19
|
+
class JsonExporter(Exporter):
|
|
20
|
+
name, label, extension = "json", "JSON (array)", ".json"
|
|
21
|
+
|
|
22
|
+
def _write(
|
|
23
|
+
self, columns: list[Column], rows: Iterator[Row], tmp: Path, opts: ExportOptions
|
|
24
|
+
) -> int:
|
|
25
|
+
names, count = [c.name for c in columns], 0
|
|
26
|
+
with tmp.open("w", encoding="utf-8") as f:
|
|
27
|
+
f.write("[")
|
|
28
|
+
for row in rows:
|
|
29
|
+
f.write(("," if count else "") + "\n " + _dump(names, row))
|
|
30
|
+
count += 1
|
|
31
|
+
f.write("\n]\n" if count else "]\n")
|
|
32
|
+
return count
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@register
|
|
36
|
+
class JsonLinesExporter(Exporter):
|
|
37
|
+
name, label, extension = "jsonl", "JSON Lines", ".jsonl"
|
|
38
|
+
|
|
39
|
+
def _write(
|
|
40
|
+
self, columns: list[Column], rows: Iterator[Row], tmp: Path, opts: ExportOptions
|
|
41
|
+
) -> int:
|
|
42
|
+
names, count = [c.name for c in columns], 0
|
|
43
|
+
with tmp.open("w", encoding="utf-8") as f:
|
|
44
|
+
for row in rows:
|
|
45
|
+
f.write(_dump(names, row) + "\n")
|
|
46
|
+
count += 1
|
|
47
|
+
return count
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Markdown table."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from sqlide.db.result import Column
|
|
9
|
+
from sqlide.export.base import Exporter, ExportOptions, Row, register
|
|
10
|
+
from sqlide.grid.copyfmt import md_cell
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@register
|
|
14
|
+
class MarkdownExporter(Exporter):
|
|
15
|
+
name, label, extension = "markdown", "Markdown table", ".md"
|
|
16
|
+
|
|
17
|
+
def _write(
|
|
18
|
+
self, columns: list[Column], rows: Iterator[Row], tmp: Path, opts: ExportOptions
|
|
19
|
+
) -> int:
|
|
20
|
+
count = 0
|
|
21
|
+
with tmp.open("w", encoding="utf-8") as f:
|
|
22
|
+
f.write("| " + " | ".join(md_cell(c.name) for c in columns) + " |\n")
|
|
23
|
+
f.write("| " + " | ".join("---" for _ in columns) + " |\n")
|
|
24
|
+
for row in rows:
|
|
25
|
+
f.write("| " + " | ".join(md_cell(v) for v in row) + " |\n")
|
|
26
|
+
count += 1
|
|
27
|
+
return count
|
sqlide/export/service.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Run an export for each data scope. Blocking work happens off the UI thread."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from sqlide.db.result import Column
|
|
10
|
+
from sqlide.db.session import DbSession
|
|
11
|
+
from sqlide.export.base import Exporter, ExportOptions, Row
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def export_rows(
|
|
15
|
+
exporter: Exporter,
|
|
16
|
+
columns: Sequence[Column],
|
|
17
|
+
rows: Sequence[Row],
|
|
18
|
+
path: Path,
|
|
19
|
+
opts: ExportOptions,
|
|
20
|
+
) -> int:
|
|
21
|
+
"""Rows already in memory (grid view or selection)."""
|
|
22
|
+
return await asyncio.to_thread(exporter.write, columns, rows, path, opts)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def export_query(
|
|
26
|
+
exporter: Exporter, session: DbSession, sql: str, path: Path, opts: ExportOptions
|
|
27
|
+
) -> int:
|
|
28
|
+
"""Re-run `sql` on `session` and stream the whole first result set into the file.
|
|
29
|
+
|
|
30
|
+
Use a dedicated session: running a statement closes the session's open cursors.
|
|
31
|
+
"""
|
|
32
|
+
return await session.stream(
|
|
33
|
+
sql, lambda columns, rows: exporter.write(columns, rows, path, opts)
|
|
34
|
+
)
|