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.
Files changed (85) hide show
  1. sqlide/__init__.py +3 -0
  2. sqlide/__main__.py +5 -0
  3. sqlide/app.py +46 -0
  4. sqlide/cli.py +64 -0
  5. sqlide/clipboard.py +65 -0
  6. sqlide/config/__init__.py +1 -0
  7. sqlide/config/_toml.py +41 -0
  8. sqlide/config/connections.py +76 -0
  9. sqlide/config/keymap.py +25 -0
  10. sqlide/config/paths.py +48 -0
  11. sqlide/config/secrets.py +82 -0
  12. sqlide/config/settings.py +32 -0
  13. sqlide/consoles.py +100 -0
  14. sqlide/db/__init__.py +1 -0
  15. sqlide/db/completion.py +127 -0
  16. sqlide/db/factory.py +37 -0
  17. sqlide/db/metadata.py +174 -0
  18. sqlide/db/result.py +52 -0
  19. sqlide/db/session.py +339 -0
  20. sqlide/db/types.py +84 -0
  21. sqlide/doctor.py +60 -0
  22. sqlide/drivers/__init__.py +1 -0
  23. sqlide/drivers/catalog.toml +103 -0
  24. sqlide/drivers/cli.py +74 -0
  25. sqlide/drivers/custom.py +69 -0
  26. sqlide/drivers/loader.py +75 -0
  27. sqlide/drivers/maven.py +108 -0
  28. sqlide/drivers/registry.py +117 -0
  29. sqlide/export/__init__.py +12 -0
  30. sqlide/export/base.py +86 -0
  31. sqlide/export/csv_.py +40 -0
  32. sqlide/export/html.py +44 -0
  33. sqlide/export/json_.py +47 -0
  34. sqlide/export/markdown.py +27 -0
  35. sqlide/export/service.py +34 -0
  36. sqlide/export/sql_insert.py +37 -0
  37. sqlide/export/xlsx.py +109 -0
  38. sqlide/grid/__init__.py +1 -0
  39. sqlide/grid/copyfmt.py +97 -0
  40. sqlide/grid/formatting.py +43 -0
  41. sqlide/grid/model.py +109 -0
  42. sqlide/history/__init__.py +5 -0
  43. sqlide/history/store.py +117 -0
  44. sqlide/jvm/__init__.py +1 -0
  45. sqlide/jvm/locate.py +98 -0
  46. sqlide/jvm/runtime.py +44 -0
  47. sqlide/sql/__init__.py +1 -0
  48. sqlide/sql/context.py +195 -0
  49. sqlide/sql/dialects.py +93 -0
  50. sqlide/sql/format.py +50 -0
  51. sqlide/sql/keywords.py +143 -0
  52. sqlide/sql/lexer.py +148 -0
  53. sqlide/sql/snippets.py +44 -0
  54. sqlide/sql/splitter.py +325 -0
  55. sqlide/ui/__init__.py +1 -0
  56. sqlide/ui/app.tcss +67 -0
  57. sqlide/ui/commands.py +59 -0
  58. sqlide/ui/keymap.py +32 -0
  59. sqlide/ui/screens/__init__.py +0 -0
  60. sqlide/ui/screens/connection_editor.py +113 -0
  61. sqlide/ui/screens/dialogs.py +110 -0
  62. sqlide/ui/screens/driver_manager.py +195 -0
  63. sqlide/ui/screens/export_dialog.py +158 -0
  64. sqlide/ui/screens/grid_dialogs.py +92 -0
  65. sqlide/ui/screens/history.py +130 -0
  66. sqlide/ui/screens/main.py +383 -0
  67. sqlide/ui/screens/settings.py +77 -0
  68. sqlide/ui/widgets/__init__.py +0 -0
  69. sqlide/ui/widgets/completion_popup.py +81 -0
  70. sqlide/ui/widgets/connections_list.py +54 -0
  71. sqlide/ui/widgets/console_export.py +124 -0
  72. sqlide/ui/widgets/console_tab.py +335 -0
  73. sqlide/ui/widgets/console_tabs.py +117 -0
  74. sqlide/ui/widgets/result_grid.py +435 -0
  75. sqlide/ui/widgets/result_panel.py +73 -0
  76. sqlide/ui/widgets/result_view.py +50 -0
  77. sqlide/ui/widgets/schema_tree.py +145 -0
  78. sqlide/ui/widgets/sql_editor.py +310 -0
  79. sqlide/ui/widgets/status_bar.py +32 -0
  80. sqlide/workspace.py +97 -0
  81. sqlide-0.1.0.dist-info/METADATA +151 -0
  82. sqlide-0.1.0.dist-info/RECORD +85 -0
  83. sqlide-0.1.0.dist-info/WHEEL +4 -0
  84. sqlide-0.1.0.dist-info/entry_points.txt +2 -0
  85. sqlide-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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
+ )
@@ -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]
@@ -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
@@ -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
+ )