jusi-postgres 0.2.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.
- jusi_postgres/__init__.py +5 -0
- jusi_postgres/catalog.py +19 -0
- jusi_postgres/config.py +107 -0
- jusi_postgres/constants.py +3 -0
- jusi_postgres/ipc.py +129 -0
- jusi_postgres/kernel.py +19 -0
- jusi_postgres/metadata.py +121 -0
- jusi_postgres/runner.py +587 -0
- jusi_postgres/worker.py +108 -0
- jusi_postgres-0.2.0.dist-info/METADATA +126 -0
- jusi_postgres-0.2.0.dist-info/RECORD +14 -0
- jusi_postgres-0.2.0.dist-info/WHEEL +4 -0
- jusi_postgres-0.2.0.dist-info/entry_points.txt +2 -0
- jusi_postgres-0.2.0.dist-info/licenses/LICENSE +21 -0
jusi_postgres/catalog.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Lightweight Jusi 1.0 catalog provider; imports no runtime dependencies."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from jusi_sql import sql_catalog_entry
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def catalog_entry() -> dict[str, Any]:
|
|
12
|
+
return sql_catalog_entry(
|
|
13
|
+
plugin_id="postgres",
|
|
14
|
+
plugin_version=__version__,
|
|
15
|
+
distribution="jusi-postgres",
|
|
16
|
+
kernel_extension="jusi_postgres.kernel",
|
|
17
|
+
worker_entry_point="jusi_postgres.worker:create_worker",
|
|
18
|
+
provider_presentation={"syntax": "pgsql", "indent": "sql"},
|
|
19
|
+
)
|
jusi_postgres/config.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Iterator, Mapping
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
PLUGIN_OPTIONS = {
|
|
10
|
+
"provider",
|
|
11
|
+
"initial_fetch",
|
|
12
|
+
"krb5ccname",
|
|
13
|
+
"collect_metadata",
|
|
14
|
+
"skip_metadata",
|
|
15
|
+
"metadata_max_rows",
|
|
16
|
+
"metadata_schemas",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
DEFAULT_METADATA_MAX_ROWS = 1_000_000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class PostgresOptions:
|
|
24
|
+
connect: dict[str, Any]
|
|
25
|
+
initial_fetch: int
|
|
26
|
+
krb5ccname: str
|
|
27
|
+
collect_metadata: bool
|
|
28
|
+
metadata_max_rows: int
|
|
29
|
+
metadata_schemas: tuple[str, ...]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_postgres_options(options: Mapping[str, Any]) -> PostgresOptions:
|
|
33
|
+
raw_initial = options.get("initial_fetch", 100)
|
|
34
|
+
try:
|
|
35
|
+
initial_fetch = int(raw_initial)
|
|
36
|
+
except (TypeError, ValueError):
|
|
37
|
+
initial_fetch = 100
|
|
38
|
+
if initial_fetch < 0:
|
|
39
|
+
initial_fetch = 0
|
|
40
|
+
krb5ccname = str(options.get("krb5ccname", "")).strip()
|
|
41
|
+
collect_metadata = _parse_bool(options.get("collect_metadata", True), default=True)
|
|
42
|
+
if _parse_bool(options.get("skip_metadata", False), default=False):
|
|
43
|
+
collect_metadata = False
|
|
44
|
+
raw_metadata_max_rows = options.get("metadata_max_rows", DEFAULT_METADATA_MAX_ROWS)
|
|
45
|
+
try:
|
|
46
|
+
metadata_max_rows = int(raw_metadata_max_rows)
|
|
47
|
+
except (TypeError, ValueError):
|
|
48
|
+
metadata_max_rows = DEFAULT_METADATA_MAX_ROWS
|
|
49
|
+
if metadata_max_rows < 1:
|
|
50
|
+
metadata_max_rows = DEFAULT_METADATA_MAX_ROWS
|
|
51
|
+
metadata_schemas = _parse_metadata_schemas(options.get("metadata_schemas", ()))
|
|
52
|
+
connect = {str(key): value for key, value in options.items() if str(key) not in PLUGIN_OPTIONS}
|
|
53
|
+
return PostgresOptions(
|
|
54
|
+
connect=connect,
|
|
55
|
+
initial_fetch=initial_fetch,
|
|
56
|
+
krb5ccname=krb5ccname,
|
|
57
|
+
collect_metadata=collect_metadata,
|
|
58
|
+
metadata_max_rows=metadata_max_rows,
|
|
59
|
+
metadata_schemas=metadata_schemas,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_bool(value: Any, *, default: bool) -> bool:
|
|
64
|
+
if isinstance(value, bool):
|
|
65
|
+
return value
|
|
66
|
+
if value is None:
|
|
67
|
+
return default
|
|
68
|
+
normalized = str(value).strip().lower()
|
|
69
|
+
if normalized in {"1", "true", "yes", "on"}:
|
|
70
|
+
return True
|
|
71
|
+
if normalized in {"0", "false", "no", "off"}:
|
|
72
|
+
return False
|
|
73
|
+
return default
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_metadata_schemas(value: Any) -> tuple[str, ...]:
|
|
77
|
+
if isinstance(value, str):
|
|
78
|
+
raw_items = value.split(",")
|
|
79
|
+
elif isinstance(value, (list, tuple, set)):
|
|
80
|
+
raw_items = list(value)
|
|
81
|
+
else:
|
|
82
|
+
raw_items = []
|
|
83
|
+
schemas: list[str] = []
|
|
84
|
+
seen: set[str] = set()
|
|
85
|
+
for item in raw_items:
|
|
86
|
+
schema = str(item).strip()
|
|
87
|
+
if not schema or schema in seen:
|
|
88
|
+
continue
|
|
89
|
+
seen.add(schema)
|
|
90
|
+
schemas.append(schema)
|
|
91
|
+
return tuple(schemas)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@contextmanager
|
|
95
|
+
def kerberos_cache_env(path: str) -> Iterator[None]:
|
|
96
|
+
if not path:
|
|
97
|
+
yield
|
|
98
|
+
return
|
|
99
|
+
previous = os.environ.get("KRB5CCNAME")
|
|
100
|
+
os.environ["KRB5CCNAME"] = path
|
|
101
|
+
try:
|
|
102
|
+
yield
|
|
103
|
+
finally:
|
|
104
|
+
if previous is None:
|
|
105
|
+
os.environ.pop("KRB5CCNAME", None)
|
|
106
|
+
else:
|
|
107
|
+
os.environ["KRB5CCNAME"] = previous
|
jusi_postgres/ipc.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Private worker/application control channel for one PostgreSQL client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
import threading
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WorkerApplicationBridge:
|
|
13
|
+
def __init__(self, socket_path: str) -> None:
|
|
14
|
+
self.socket_path = socket_path
|
|
15
|
+
self._listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
16
|
+
self._listener.bind(socket_path)
|
|
17
|
+
self._listener.listen(1)
|
|
18
|
+
self._connection: socket.socket | None = None
|
|
19
|
+
self._connected = threading.Event()
|
|
20
|
+
self._closed = threading.Event()
|
|
21
|
+
self._write_lock = threading.Lock()
|
|
22
|
+
self._condition = threading.Condition()
|
|
23
|
+
self._responses: dict[str, dict[str, Any]] = {}
|
|
24
|
+
threading.Thread(target=self._accept, name="jusi-postgres-bridge", daemon=True).start()
|
|
25
|
+
|
|
26
|
+
def _accept(self) -> None:
|
|
27
|
+
try:
|
|
28
|
+
connection, _ = self._listener.accept()
|
|
29
|
+
self._connection = connection
|
|
30
|
+
self._connected.set()
|
|
31
|
+
with connection.makefile("r", encoding="utf-8") as stream:
|
|
32
|
+
for line in stream:
|
|
33
|
+
response = json.loads(line)
|
|
34
|
+
request_id = str(response.get("id", ""))
|
|
35
|
+
if request_id:
|
|
36
|
+
with self._condition:
|
|
37
|
+
self._responses[request_id] = response
|
|
38
|
+
self._condition.notify_all()
|
|
39
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
40
|
+
pass
|
|
41
|
+
finally:
|
|
42
|
+
self._closed.set()
|
|
43
|
+
self._connected.set()
|
|
44
|
+
with self._condition:
|
|
45
|
+
self._condition.notify_all()
|
|
46
|
+
|
|
47
|
+
def request(self, operation: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
48
|
+
if not self._connected.wait(10) or self._connection is None:
|
|
49
|
+
raise RuntimeError("PostgreSQL terminal application did not connect")
|
|
50
|
+
request_id = uuid4().hex
|
|
51
|
+
self._send({"id": request_id, "operation": operation, "payload": payload})
|
|
52
|
+
with self._condition:
|
|
53
|
+
while request_id not in self._responses and not self._closed.is_set():
|
|
54
|
+
self._condition.wait()
|
|
55
|
+
response = self._responses.pop(request_id, None)
|
|
56
|
+
if response is None:
|
|
57
|
+
raise RuntimeError("PostgreSQL terminal application disconnected")
|
|
58
|
+
return response
|
|
59
|
+
|
|
60
|
+
def interrupt(self) -> None:
|
|
61
|
+
if self._connection is not None and not self._closed.is_set():
|
|
62
|
+
self._send({"id": uuid4().hex, "operation": "interrupt", "payload": {}})
|
|
63
|
+
|
|
64
|
+
def _send(self, message: dict[str, Any]) -> None:
|
|
65
|
+
connection = self._connection
|
|
66
|
+
if connection is None:
|
|
67
|
+
raise RuntimeError("PostgreSQL terminal application is unavailable")
|
|
68
|
+
data = (json.dumps(message, ensure_ascii=False) + "\n").encode("utf-8")
|
|
69
|
+
with self._write_lock:
|
|
70
|
+
connection.sendall(data)
|
|
71
|
+
|
|
72
|
+
def close(self) -> None:
|
|
73
|
+
self._closed.set()
|
|
74
|
+
try:
|
|
75
|
+
self._listener.close()
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
78
|
+
if self._connection is not None:
|
|
79
|
+
try:
|
|
80
|
+
self._connection.shutdown(socket.SHUT_RDWR)
|
|
81
|
+
except OSError:
|
|
82
|
+
pass
|
|
83
|
+
self._connection.close()
|
|
84
|
+
try:
|
|
85
|
+
os.unlink(self.socket_path)
|
|
86
|
+
except FileNotFoundError:
|
|
87
|
+
pass
|
|
88
|
+
with self._condition:
|
|
89
|
+
self._condition.notify_all()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ApplicationController:
|
|
93
|
+
def __init__(self, socket_path: str, handler: Callable[[str, dict[str, Any]], dict[str, Any]]) -> None:
|
|
94
|
+
self._handler = handler
|
|
95
|
+
self._connection = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
96
|
+
self._connection.connect(socket_path)
|
|
97
|
+
self._write_lock = threading.Lock()
|
|
98
|
+
|
|
99
|
+
def start(self) -> None:
|
|
100
|
+
threading.Thread(target=self._read, name="jusi-postgres-control", daemon=True).start()
|
|
101
|
+
|
|
102
|
+
def _read(self) -> None:
|
|
103
|
+
try:
|
|
104
|
+
with self._connection.makefile("r", encoding="utf-8") as stream:
|
|
105
|
+
for line in stream:
|
|
106
|
+
message = json.loads(line)
|
|
107
|
+
operation = str(message.get("operation", ""))
|
|
108
|
+
if operation == "interrupt":
|
|
109
|
+
self._handle(message)
|
|
110
|
+
else:
|
|
111
|
+
threading.Thread(target=self._handle, args=(message,), daemon=True).start()
|
|
112
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
def _handle(self, message: dict[str, Any]) -> None:
|
|
116
|
+
payload = message.get("payload", {})
|
|
117
|
+
if not isinstance(payload, dict):
|
|
118
|
+
payload = {}
|
|
119
|
+
try:
|
|
120
|
+
result = self._handler(str(message.get("operation", "")), payload)
|
|
121
|
+
response = {"ok": True, "result": result}
|
|
122
|
+
except BaseException as exc:
|
|
123
|
+
response = {"ok": False, "error": "fatal", "message": f"{type(exc).__name__}: {exc}"}
|
|
124
|
+
response = {"id": str(message.get("id", "")), **response}
|
|
125
|
+
try:
|
|
126
|
+
with self._write_lock:
|
|
127
|
+
self._connection.sendall((json.dumps(response, ensure_ascii=False) + "\n").encode("utf-8"))
|
|
128
|
+
except OSError:
|
|
129
|
+
return
|
jusi_postgres/kernel.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Thin exact-provider adapter for the shared Jusi SQL kernel dispatcher."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from jusi_sql.kernel import SqlKernelAdapter
|
|
5
|
+
|
|
6
|
+
from . import __version__
|
|
7
|
+
from .constants import POSTGRES_BOOTSTRAP_SQL
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_adapter = SqlKernelAdapter(
|
|
11
|
+
plugin_id="postgres",
|
|
12
|
+
plugin_version=__version__,
|
|
13
|
+
selectors=("postgres", "postgresql"),
|
|
14
|
+
empty_sql=POSTGRES_BOOTSTRAP_SQL,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
jusi_kernel_adapter_v1 = _adapter.manifest
|
|
18
|
+
configure_jusi_runtime_v1 = _adapter.configure
|
|
19
|
+
load_ipython_extension = _adapter.load
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Iterable, Sequence
|
|
4
|
+
|
|
5
|
+
from jusi_sql import CompletionColumn, CompletionObject, MetadataSnapshot
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MetadataLimitExceeded(RuntimeError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_postgres_metadata(
|
|
13
|
+
conn: Any,
|
|
14
|
+
*,
|
|
15
|
+
max_rows: int = 1_000_000,
|
|
16
|
+
schemas: Sequence[str] = (),
|
|
17
|
+
) -> MetadataSnapshot:
|
|
18
|
+
collector = _BoundedMetadataCollector(max_rows=max_rows, schemas=schemas)
|
|
19
|
+
with conn.cursor() as cur:
|
|
20
|
+
cur.execute(*collector.query("SELECT nspname FROM pg_namespace", "nspname"))
|
|
21
|
+
schema_names = sorted(str(row[0]) for row in collector.fetch(cur, "schemas"))
|
|
22
|
+
cur.execute(
|
|
23
|
+
*collector.query(
|
|
24
|
+
"""
|
|
25
|
+
SELECT n.nspname, c.relname, c.relkind
|
|
26
|
+
FROM pg_class c
|
|
27
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
28
|
+
""",
|
|
29
|
+
"n.nspname",
|
|
30
|
+
extra_predicates=("c.relkind IN ('r', 'p', 'v', 'm', 'f')",),
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
objects = [
|
|
34
|
+
CompletionObject(schema=str(row[0]), name=str(row[1]), kind=_relation_kind(str(row[2])))
|
|
35
|
+
for row in collector.fetch(cur, "relations")
|
|
36
|
+
]
|
|
37
|
+
objects.sort(key=lambda item: (item.schema, item.name, item.kind))
|
|
38
|
+
cur.execute(
|
|
39
|
+
*collector.query(
|
|
40
|
+
"""
|
|
41
|
+
SELECT table_schema, table_name, column_name, data_type
|
|
42
|
+
FROM information_schema.columns
|
|
43
|
+
""",
|
|
44
|
+
"table_schema",
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
columns = [
|
|
48
|
+
CompletionColumn(schema=str(row[0]), table=str(row[1]), name=str(row[2]), data_type=str(row[3]))
|
|
49
|
+
for row in collector.fetch(cur, "columns")
|
|
50
|
+
]
|
|
51
|
+
columns.sort(key=lambda item: (item.schema, item.table, item.name))
|
|
52
|
+
cur.execute(
|
|
53
|
+
*collector.query(
|
|
54
|
+
"""
|
|
55
|
+
SELECT n.nspname, p.proname, pg_get_function_identity_arguments(p.oid)
|
|
56
|
+
FROM pg_proc p
|
|
57
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
58
|
+
""",
|
|
59
|
+
"n.nspname",
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
functions = [
|
|
63
|
+
CompletionObject(schema=str(row[0]), name=str(row[1]), kind="function", detail=str(row[2]))
|
|
64
|
+
for row in collector.fetch(cur, "functions")
|
|
65
|
+
]
|
|
66
|
+
functions.sort(key=lambda item: (item.schema, item.name, item.detail))
|
|
67
|
+
return MetadataSnapshot(schemas=schema_names, objects=objects, columns=columns, functions=functions)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _BoundedMetadataCollector:
|
|
71
|
+
def __init__(self, *, max_rows: int, schemas: Sequence[str]) -> None:
|
|
72
|
+
self.max_rows = max_rows
|
|
73
|
+
self.schemas = tuple(schema for schema in schemas if schema)
|
|
74
|
+
self.collected = 0
|
|
75
|
+
|
|
76
|
+
def query(
|
|
77
|
+
self,
|
|
78
|
+
base_query: str,
|
|
79
|
+
schema_column: str,
|
|
80
|
+
*,
|
|
81
|
+
extra_predicates: Sequence[str] = (),
|
|
82
|
+
) -> tuple[str, tuple[Any, ...]]:
|
|
83
|
+
predicates = list(extra_predicates)
|
|
84
|
+
params: list[Any] = []
|
|
85
|
+
predicates.append(f"{schema_column} NOT LIKE %s")
|
|
86
|
+
params.append("pg_toast%")
|
|
87
|
+
if self.schemas:
|
|
88
|
+
predicates.append(f"{schema_column} = ANY(%s)")
|
|
89
|
+
params.append(list(self.schemas))
|
|
90
|
+
remaining = self.max_rows - self.collected + 1
|
|
91
|
+
return f"{base_query} WHERE {' AND '.join(predicates)} LIMIT %s", (*params, remaining)
|
|
92
|
+
|
|
93
|
+
def fetch(self, cursor: Any, label: str) -> list[Any]:
|
|
94
|
+
remaining = self.max_rows - self.collected
|
|
95
|
+
rows = list(cursor.fetchmany(remaining + 1))
|
|
96
|
+
if len(rows) > remaining:
|
|
97
|
+
schema_hint = ""
|
|
98
|
+
if self.schemas:
|
|
99
|
+
schema_hint = f" Current filter: {', '.join(self.schemas)}."
|
|
100
|
+
raise MetadataLimitExceeded(
|
|
101
|
+
f"PostgreSQL metadata collection stopped after reaching {self.max_rows} rows while loading {label}."
|
|
102
|
+
f"{schema_hint} Schema filtering is required: add or narrow metadata_schemas,"
|
|
103
|
+
" raise metadata_max_rows, or disable metadata."
|
|
104
|
+
)
|
|
105
|
+
self.collected += len(rows)
|
|
106
|
+
return rows
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def relation_names(snapshot: MetadataSnapshot) -> Iterable[tuple[str, str]]:
|
|
110
|
+
for item in snapshot.objects:
|
|
111
|
+
yield item.schema, item.name
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _relation_kind(value: str) -> str:
|
|
115
|
+
return {
|
|
116
|
+
"r": "table",
|
|
117
|
+
"p": "table",
|
|
118
|
+
"v": "view",
|
|
119
|
+
"m": "view",
|
|
120
|
+
"f": "foreign table",
|
|
121
|
+
}.get(value, "relation")
|
jusi_postgres/runner.py
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import curses
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import threading
|
|
9
|
+
import uuid
|
|
10
|
+
from collections import deque
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import visidata
|
|
14
|
+
from visidata import ItemColumn, SequenceSheet, run, vd
|
|
15
|
+
|
|
16
|
+
from jusi_sql import (
|
|
17
|
+
MetadataCache,
|
|
18
|
+
MetadataSnapshot,
|
|
19
|
+
SqlCompletionRequest,
|
|
20
|
+
SqlSheetActions,
|
|
21
|
+
bind_sql_actions,
|
|
22
|
+
complete_sql,
|
|
23
|
+
install_visidata_commands,
|
|
24
|
+
sql_cache_directory,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from .config import DEFAULT_METADATA_MAX_ROWS, kerberos_cache_env, parse_postgres_options
|
|
28
|
+
from .constants import POSTGRES_BOOTSTRAP_SQL
|
|
29
|
+
from .ipc import ApplicationController
|
|
30
|
+
from .metadata import load_postgres_metadata
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
RESULT_QUERY_PREFIXES = ("select", "with", "values", "table")
|
|
34
|
+
POSTGRES_KEYWORDS = (
|
|
35
|
+
"SELECT", "FROM", "WHERE", "JOIN", "LEFT", "RIGHT", "FULL", "INNER", "OUTER",
|
|
36
|
+
"ON", "GROUP", "BY", "ORDER", "HAVING", "LIMIT", "OFFSET", "INSERT", "INTO",
|
|
37
|
+
"VALUES", "UPDATE", "SET", "DELETE", "RETURNING", "WITH", "AS", "DISTINCT",
|
|
38
|
+
"CREATE", "ALTER", "DROP", "TABLE", "VIEW", "INDEX", "BEGIN", "COMMIT", "ROLLBACK",
|
|
39
|
+
)
|
|
40
|
+
_PENDING_SHEETS: deque[Any] = deque()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RawBinaryValue:
|
|
44
|
+
__slots__ = ("value",)
|
|
45
|
+
|
|
46
|
+
def __init__(self, value: bytes | bytearray | memoryview) -> None:
|
|
47
|
+
self.value = value
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def size(self) -> int:
|
|
51
|
+
if isinstance(self.value, memoryview):
|
|
52
|
+
return self.value.nbytes
|
|
53
|
+
return len(self.value)
|
|
54
|
+
|
|
55
|
+
def tobytes(self) -> bytes:
|
|
56
|
+
if isinstance(self.value, memoryview):
|
|
57
|
+
return self.value.tobytes()
|
|
58
|
+
if isinstance(self.value, bytearray):
|
|
59
|
+
return bytes(self.value)
|
|
60
|
+
return self.value
|
|
61
|
+
|
|
62
|
+
def __str__(self) -> str:
|
|
63
|
+
return f"<binary data: {self.size} bytes>"
|
|
64
|
+
|
|
65
|
+
def __repr__(self) -> str:
|
|
66
|
+
return str(self)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PostgresSession:
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
alias: str,
|
|
74
|
+
connect_options: dict[str, Any],
|
|
75
|
+
krb5ccname: str = "",
|
|
76
|
+
initial_fetch: int = 100,
|
|
77
|
+
collect_metadata: bool = True,
|
|
78
|
+
metadata_max_rows: int = DEFAULT_METADATA_MAX_ROWS,
|
|
79
|
+
metadata_schemas: tuple[str, ...] = (),
|
|
80
|
+
) -> None:
|
|
81
|
+
self.alias = alias
|
|
82
|
+
self.connect_options = connect_options
|
|
83
|
+
self.krb5ccname = krb5ccname
|
|
84
|
+
self.initial_fetch = initial_fetch
|
|
85
|
+
self.collect_metadata = collect_metadata
|
|
86
|
+
self.metadata_max_rows = metadata_max_rows
|
|
87
|
+
self.metadata_schemas = metadata_schemas
|
|
88
|
+
self.conn: Any = None
|
|
89
|
+
self.metadata_conn: Any = None
|
|
90
|
+
self.metadata_conn_lock = threading.Lock()
|
|
91
|
+
self.metadata_warning_lock = threading.Lock()
|
|
92
|
+
self.metadata_warnings: list[str] = []
|
|
93
|
+
self.lock = threading.RLock()
|
|
94
|
+
self.notices: list[str] = []
|
|
95
|
+
self.sheets: list[PostgresResultSheet] = []
|
|
96
|
+
metadata_cache_options = dict(connect_options)
|
|
97
|
+
metadata_cache_options["__metadata_max_rows"] = metadata_max_rows
|
|
98
|
+
metadata_cache_options["__metadata_schemas"] = metadata_schemas
|
|
99
|
+
self.metadata = MetadataCache(
|
|
100
|
+
sql_cache_directory("postgres", alias, metadata_cache_options),
|
|
101
|
+
self._load_metadata,
|
|
102
|
+
on_warning=self._metadata_warning,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def connect(self) -> Any:
|
|
106
|
+
with self.lock:
|
|
107
|
+
if self.conn is None or self.conn.closed:
|
|
108
|
+
with kerberos_cache_env(self.krb5ccname):
|
|
109
|
+
self.conn = _connect_psycopg(self.connect_options)
|
|
110
|
+
self.conn.autocommit = False
|
|
111
|
+
try:
|
|
112
|
+
self.conn.add_notice_handler(self._notice_handler)
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
return self.conn
|
|
116
|
+
|
|
117
|
+
def with_connection(self, fn, *, blocking: bool = True): # type: ignore[no-untyped-def]
|
|
118
|
+
if not self.lock.acquire(blocking=blocking):
|
|
119
|
+
raise RuntimeError("PostgreSQL connection is busy")
|
|
120
|
+
try:
|
|
121
|
+
conn = self.connect()
|
|
122
|
+
return fn(conn)
|
|
123
|
+
finally:
|
|
124
|
+
self.lock.release()
|
|
125
|
+
|
|
126
|
+
def register_sheet(self, sheet: "PostgresResultSheet") -> None:
|
|
127
|
+
self.sheets.append(sheet)
|
|
128
|
+
|
|
129
|
+
def complete(self, request: SqlCompletionRequest) -> dict[str, Any]:
|
|
130
|
+
self.show_metadata_warnings()
|
|
131
|
+
snapshot = MetadataSnapshot() if not self.collect_metadata else self.metadata.snapshot()
|
|
132
|
+
return complete_sql(snapshot, request, keywords=POSTGRES_KEYWORDS)
|
|
133
|
+
|
|
134
|
+
def enter_cell(self) -> None:
|
|
135
|
+
if not self.collect_metadata:
|
|
136
|
+
return
|
|
137
|
+
self.metadata.ensure_fresh_async()
|
|
138
|
+
|
|
139
|
+
def followup(self, body: str) -> None:
|
|
140
|
+
sql = _followup_sql(body).strip()
|
|
141
|
+
if not sql:
|
|
142
|
+
return
|
|
143
|
+
self.enter_cell()
|
|
144
|
+
sheet = PostgresResultSheet(session=self, query=sql)
|
|
145
|
+
_queue_sheet(sheet)
|
|
146
|
+
|
|
147
|
+
def interrupt(self) -> None:
|
|
148
|
+
conn = self.conn
|
|
149
|
+
if conn is None or conn.closed:
|
|
150
|
+
return
|
|
151
|
+
try:
|
|
152
|
+
conn.cancel()
|
|
153
|
+
vd.warning("PostgreSQL query cancellation requested")
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
vd.warning(f"PostgreSQL cancellation failed: {exc}")
|
|
156
|
+
|
|
157
|
+
def commit(self) -> None:
|
|
158
|
+
conn = self.connect()
|
|
159
|
+
with self.lock:
|
|
160
|
+
conn.commit()
|
|
161
|
+
self._mark_cursors_closed("transaction committed")
|
|
162
|
+
self.metadata.mark_stale()
|
|
163
|
+
vd.status("PostgreSQL transaction committed")
|
|
164
|
+
|
|
165
|
+
def rollback(self) -> None:
|
|
166
|
+
conn = self.connect()
|
|
167
|
+
with self.lock:
|
|
168
|
+
conn.rollback()
|
|
169
|
+
self._mark_cursors_closed("transaction rolled back")
|
|
170
|
+
self.metadata.mark_stale()
|
|
171
|
+
vd.status("PostgreSQL transaction rolled back")
|
|
172
|
+
|
|
173
|
+
def close(self) -> None:
|
|
174
|
+
metadata_done = self.metadata.close(timeout=2.0)
|
|
175
|
+
if not metadata_done:
|
|
176
|
+
self._cancel_metadata()
|
|
177
|
+
for sheet in list(self.sheets):
|
|
178
|
+
sheet.close_cursor()
|
|
179
|
+
conn = self.conn
|
|
180
|
+
if conn is not None and not conn.closed:
|
|
181
|
+
conn.close()
|
|
182
|
+
|
|
183
|
+
def _load_metadata(self) -> Any:
|
|
184
|
+
with kerberos_cache_env(self.krb5ccname):
|
|
185
|
+
conn = _connect_psycopg(dict(self.connect_options))
|
|
186
|
+
with self.metadata_conn_lock:
|
|
187
|
+
self.metadata_conn = conn
|
|
188
|
+
try:
|
|
189
|
+
try:
|
|
190
|
+
conn.autocommit = True
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
return load_postgres_metadata(
|
|
194
|
+
conn,
|
|
195
|
+
max_rows=self.metadata_max_rows,
|
|
196
|
+
schemas=self.metadata_schemas,
|
|
197
|
+
)
|
|
198
|
+
finally:
|
|
199
|
+
with self.metadata_conn_lock:
|
|
200
|
+
self.metadata_conn = None
|
|
201
|
+
try:
|
|
202
|
+
conn.close()
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
def _cancel_metadata(self) -> None:
|
|
207
|
+
with self.metadata_conn_lock:
|
|
208
|
+
conn = self.metadata_conn
|
|
209
|
+
if conn is None or conn.closed:
|
|
210
|
+
return
|
|
211
|
+
try:
|
|
212
|
+
conn.cancel()
|
|
213
|
+
vd.warning("PostgreSQL metadata collection cancellation requested")
|
|
214
|
+
except Exception as exc:
|
|
215
|
+
vd.warning(f"PostgreSQL metadata cancellation failed: {exc}")
|
|
216
|
+
|
|
217
|
+
def _metadata_warning(self, message: str) -> None:
|
|
218
|
+
with self.metadata_warning_lock:
|
|
219
|
+
self.metadata_warnings.append(message)
|
|
220
|
+
|
|
221
|
+
def show_metadata_warnings(self) -> None:
|
|
222
|
+
with self.metadata_warning_lock:
|
|
223
|
+
warnings = list(self.metadata_warnings)
|
|
224
|
+
self.metadata_warnings.clear()
|
|
225
|
+
for message in warnings:
|
|
226
|
+
vd.warning(message)
|
|
227
|
+
|
|
228
|
+
def pop_notices(self) -> list[str]:
|
|
229
|
+
notices = list(self.notices)
|
|
230
|
+
self.notices.clear()
|
|
231
|
+
return notices
|
|
232
|
+
|
|
233
|
+
def _notice_handler(self, diagnostic: Any) -> None:
|
|
234
|
+
message = str(getattr(diagnostic, "message_primary", "") or diagnostic).strip()
|
|
235
|
+
if message:
|
|
236
|
+
self.notices.append(message)
|
|
237
|
+
|
|
238
|
+
def _mark_cursors_closed(self, reason: str) -> None:
|
|
239
|
+
for sheet in self.sheets:
|
|
240
|
+
sheet.cursor_closed_reason = reason
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class PostgresResultSheet(SequenceSheet):
|
|
244
|
+
rowtype = "rows"
|
|
245
|
+
|
|
246
|
+
def __init__(self, *, session: PostgresSession, query: str) -> None:
|
|
247
|
+
super().__init__(name=session.alias, source=query)
|
|
248
|
+
self.session = session
|
|
249
|
+
self.query = query
|
|
250
|
+
self.cursor_name = f"jusi_pg_{uuid.uuid4().hex}"
|
|
251
|
+
self.cursor: Any = None
|
|
252
|
+
self.exhausted = False
|
|
253
|
+
self.cursor_closed_reason = ""
|
|
254
|
+
self._buffer: list[Any] = []
|
|
255
|
+
session.register_sheet(self)
|
|
256
|
+
bind_sql_actions(self, SqlSheetActions(
|
|
257
|
+
fetch_more=self.fetch_more,
|
|
258
|
+
commit=session.commit,
|
|
259
|
+
rollback=session.rollback,
|
|
260
|
+
))
|
|
261
|
+
|
|
262
|
+
def iterload(self): # type: ignore[no-untyped-def]
|
|
263
|
+
try:
|
|
264
|
+
if _looks_like_result_query(self.query):
|
|
265
|
+
yield from self._load_result_query()
|
|
266
|
+
else:
|
|
267
|
+
yield from self._load_statement()
|
|
268
|
+
except Exception as exc:
|
|
269
|
+
vd.exceptionCaught(exc)
|
|
270
|
+
vd.warning(f"PostgreSQL query failed: {exc.__class__.__name__}: {exc}; press Ctrl-E for details")
|
|
271
|
+
|
|
272
|
+
def fetch_more(self, count: int) -> int:
|
|
273
|
+
if self.cursor_closed_reason:
|
|
274
|
+
vd.warning(f"PostgreSQL cursor is closed: {self.cursor_closed_reason}")
|
|
275
|
+
return 0
|
|
276
|
+
if self.exhausted or self.cursor is None:
|
|
277
|
+
vd.status("PostgreSQL cursor is exhausted")
|
|
278
|
+
return 0
|
|
279
|
+
try:
|
|
280
|
+
with self.session.lock:
|
|
281
|
+
rows = self._fetch_rows(count)
|
|
282
|
+
for row in rows:
|
|
283
|
+
self.addRow(_display_row(row))
|
|
284
|
+
self._notify_more()
|
|
285
|
+
return len(rows)
|
|
286
|
+
except Exception as exc:
|
|
287
|
+
vd.warning(f"PostgreSQL fetch failed: {exc}")
|
|
288
|
+
return 0
|
|
289
|
+
|
|
290
|
+
def close_cursor(self) -> None:
|
|
291
|
+
cursor = self.cursor
|
|
292
|
+
self.cursor = None
|
|
293
|
+
if cursor is None:
|
|
294
|
+
return
|
|
295
|
+
try:
|
|
296
|
+
cursor.close()
|
|
297
|
+
except Exception:
|
|
298
|
+
pass
|
|
299
|
+
|
|
300
|
+
def _load_result_query(self): # type: ignore[no-untyped-def]
|
|
301
|
+
conn = self.session.connect()
|
|
302
|
+
with self.session.lock:
|
|
303
|
+
self.cursor = conn.cursor(name=self.cursor_name)
|
|
304
|
+
self.cursor.execute(self.query)
|
|
305
|
+
description = self.cursor.description or []
|
|
306
|
+
column_names = [str(item.name) for item in description] if description else ["result"]
|
|
307
|
+
self.columns = [ItemColumn(name, index) for index, name in enumerate(column_names)]
|
|
308
|
+
rows = self._fetch_rows(self.session.initial_fetch)
|
|
309
|
+
if description:
|
|
310
|
+
yield column_names
|
|
311
|
+
for row in rows:
|
|
312
|
+
yield _display_row(row)
|
|
313
|
+
for notice in self.session.pop_notices():
|
|
314
|
+
vd.status(f"PostgreSQL notice: {notice}")
|
|
315
|
+
self.session.show_metadata_warnings()
|
|
316
|
+
self._notify_more()
|
|
317
|
+
|
|
318
|
+
def _load_statement(self): # type: ignore[no-untyped-def]
|
|
319
|
+
conn = self.session.connect()
|
|
320
|
+
with self.session.lock:
|
|
321
|
+
with conn.cursor() as cursor:
|
|
322
|
+
cursor.execute(self.query)
|
|
323
|
+
description = cursor.description or []
|
|
324
|
+
if description:
|
|
325
|
+
column_names = [str(item.name) for item in description]
|
|
326
|
+
self.columns = [ItemColumn(name, index) for index, name in enumerate(column_names)]
|
|
327
|
+
rows = cursor.fetchall()
|
|
328
|
+
yield column_names
|
|
329
|
+
for row in rows:
|
|
330
|
+
yield _display_row(row)
|
|
331
|
+
else:
|
|
332
|
+
self.columns = [ItemColumn("status", 0), ItemColumn("value", 1)]
|
|
333
|
+
status = str(getattr(cursor, "statusmessage", "") or "done")
|
|
334
|
+
yield ["status", status]
|
|
335
|
+
rowcount = int(getattr(cursor, "rowcount", -1) or -1)
|
|
336
|
+
if rowcount >= 0:
|
|
337
|
+
yield ["rowcount", rowcount]
|
|
338
|
+
for notice in self.session.pop_notices():
|
|
339
|
+
yield ["notice", notice]
|
|
340
|
+
self.session.metadata.mark_stale()
|
|
341
|
+
self.session.show_metadata_warnings()
|
|
342
|
+
|
|
343
|
+
def _fetch_rows(self, count: int) -> list[Any]:
|
|
344
|
+
if self.cursor is None:
|
|
345
|
+
return []
|
|
346
|
+
if count == 0:
|
|
347
|
+
rows = list(self._buffer)
|
|
348
|
+
self._buffer.clear()
|
|
349
|
+
rows.extend(self.cursor.fetchall())
|
|
350
|
+
self.exhausted = True
|
|
351
|
+
return rows
|
|
352
|
+
rows = list(self._buffer[:count])
|
|
353
|
+
self._buffer = self._buffer[count:]
|
|
354
|
+
remaining = count - len(rows)
|
|
355
|
+
if remaining > 0:
|
|
356
|
+
fetched = list(self.cursor.fetchmany(remaining + 1))
|
|
357
|
+
rows.extend(fetched[:remaining])
|
|
358
|
+
if len(fetched) > remaining:
|
|
359
|
+
self._buffer.append(fetched[-1])
|
|
360
|
+
else:
|
|
361
|
+
self.exhausted = True
|
|
362
|
+
return rows
|
|
363
|
+
|
|
364
|
+
def _notify_more(self) -> None:
|
|
365
|
+
if self._buffer or not self.exhausted:
|
|
366
|
+
vd.warning("PostgreSQL cursor has more rows; press 1-9 or gf to fetch more")
|
|
367
|
+
else:
|
|
368
|
+
vd.status("PostgreSQL cursor exhausted")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def install_postgres_commands() -> None:
|
|
372
|
+
install_visidata_commands(visidata)
|
|
373
|
+
if getattr(visidata.BaseSheet, "_jusi_postgres_commands_v1", False):
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
@visidata.BaseSheet.command("gb", "jusi-postgres-open-raw-value", "open raw PostgreSQL cell value", replay=False)
|
|
377
|
+
def _open_raw_value(sheet: Any) -> None:
|
|
378
|
+
value = getattr(sheet, "cursorValue", None)
|
|
379
|
+
if callable(value):
|
|
380
|
+
value = value()
|
|
381
|
+
value = value if value is not None else ""
|
|
382
|
+
extension = str(vd.input("extension: ") or "").strip().lstrip(".")
|
|
383
|
+
suffix = f".{extension}" if extension else ""
|
|
384
|
+
path = _write_raw_value_file(value, suffix)
|
|
385
|
+
opened = vd.openPath(visidata.Path(path))
|
|
386
|
+
vd.push(opened)
|
|
387
|
+
|
|
388
|
+
@visidata.BaseSheet.command("", "jusi-postgres-open-pending-sheet", "open pending PostgreSQL result", replay=False)
|
|
389
|
+
def _open_pending(_sheet: Any) -> None:
|
|
390
|
+
if not _PENDING_SHEETS:
|
|
391
|
+
return
|
|
392
|
+
next_sheet = _PENDING_SHEETS.popleft()
|
|
393
|
+
vd.push(next_sheet)
|
|
394
|
+
next_sheet.ensureLoaded()
|
|
395
|
+
|
|
396
|
+
setattr(visidata.BaseSheet, "_jusi_postgres_commands_v1", True)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _queue_sheet(sheet: PostgresResultSheet) -> None:
|
|
400
|
+
_PENDING_SHEETS.append(sheet)
|
|
401
|
+
vd.queueCommand("jusi-postgres-open-pending-sheet")
|
|
402
|
+
try:
|
|
403
|
+
curses.ungetch(curses.KEY_RESIZE)
|
|
404
|
+
except Exception:
|
|
405
|
+
pass
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _followup_sql(body: str) -> str:
|
|
409
|
+
first, separator, remainder = body.partition("\n")
|
|
410
|
+
header = first.strip()
|
|
411
|
+
if header == "%%sql" or header.startswith(("%%sql ", "%%sql\t")):
|
|
412
|
+
return remainder if separator else ""
|
|
413
|
+
return body
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _postgres_sheet(sheet: Any) -> PostgresResultSheet | None:
|
|
417
|
+
current = sheet
|
|
418
|
+
seen: set[int] = set()
|
|
419
|
+
while current is not None:
|
|
420
|
+
marker = id(current)
|
|
421
|
+
if marker in seen:
|
|
422
|
+
break
|
|
423
|
+
seen.add(marker)
|
|
424
|
+
if isinstance(current, PostgresResultSheet):
|
|
425
|
+
return current
|
|
426
|
+
current = getattr(current, "source", None)
|
|
427
|
+
vd.warning("No active PostgreSQL result sheet")
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _write_raw_value_file(value: Any, suffix: str) -> str:
|
|
432
|
+
if isinstance(value, RawBinaryValue):
|
|
433
|
+
value = value.tobytes()
|
|
434
|
+
if isinstance(value, memoryview):
|
|
435
|
+
value = value.tobytes()
|
|
436
|
+
if isinstance(value, bytearray):
|
|
437
|
+
value = bytes(value)
|
|
438
|
+
if isinstance(value, bytes):
|
|
439
|
+
with tempfile.NamedTemporaryFile("wb", prefix="jusi-postgres-value-", suffix=suffix, delete=False) as handle:
|
|
440
|
+
handle.write(value)
|
|
441
|
+
return handle.name
|
|
442
|
+
with tempfile.NamedTemporaryFile("w", encoding="utf-8", prefix="jusi-postgres-value-", suffix=suffix, delete=False) as handle:
|
|
443
|
+
handle.write(_value_to_text(value))
|
|
444
|
+
return handle.name
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _value_to_text(value: Any) -> str:
|
|
448
|
+
if isinstance(value, (dict, list, tuple)):
|
|
449
|
+
return json.dumps(value, ensure_ascii=False, default=str, indent=2)
|
|
450
|
+
return "" if value is None else str(value)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _display_row(row: Any) -> list[Any]:
|
|
454
|
+
return [_display_cell(value) for value in row]
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _display_cell(value: Any) -> Any:
|
|
458
|
+
if isinstance(value, RawBinaryValue):
|
|
459
|
+
return value
|
|
460
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
461
|
+
return RawBinaryValue(value)
|
|
462
|
+
return value
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _looks_like_result_query(sql: str) -> bool:
|
|
466
|
+
stripped = sql.lstrip().lower()
|
|
467
|
+
return stripped.startswith(RESULT_QUERY_PREFIXES)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _connect_psycopg(connect_options: dict[str, Any]) -> Any:
|
|
471
|
+
try:
|
|
472
|
+
import psycopg
|
|
473
|
+
except ModuleNotFoundError as exc:
|
|
474
|
+
raise RuntimeError(
|
|
475
|
+
"Missing dependency 'psycopg'. Install this plugin with dependencies, "
|
|
476
|
+
"for example: ./.venv/bin/python -m pip install -e ."
|
|
477
|
+
) from exc
|
|
478
|
+
conn = psycopg.connect(**connect_options)
|
|
479
|
+
_install_infinity_timestamp_loaders(conn)
|
|
480
|
+
return conn
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _install_infinity_timestamp_loaders(conn: Any) -> None:
|
|
484
|
+
try:
|
|
485
|
+
from psycopg.types.datetime import DateLoader, TimestampLoader, TimestamptzLoader
|
|
486
|
+
except Exception:
|
|
487
|
+
return
|
|
488
|
+
|
|
489
|
+
class DateInfinityLoader(DateLoader): # type: ignore[misc, valid-type]
|
|
490
|
+
def load(self, data: Any) -> Any:
|
|
491
|
+
text = bytes(data).decode("ascii")
|
|
492
|
+
if text in ("infinity", "-infinity"):
|
|
493
|
+
return text
|
|
494
|
+
return super().load(data)
|
|
495
|
+
|
|
496
|
+
class TimestampInfinityLoader(TimestampLoader): # type: ignore[misc, valid-type]
|
|
497
|
+
def load(self, data: Any) -> Any:
|
|
498
|
+
text = bytes(data).decode("ascii")
|
|
499
|
+
if text in ("infinity", "-infinity"):
|
|
500
|
+
return text
|
|
501
|
+
return super().load(data)
|
|
502
|
+
|
|
503
|
+
class TimestamptzInfinityLoader(TimestamptzLoader): # type: ignore[misc, valid-type]
|
|
504
|
+
def load(self, data: Any) -> Any:
|
|
505
|
+
text = bytes(data).decode("ascii")
|
|
506
|
+
if text in ("infinity", "-infinity"):
|
|
507
|
+
return text
|
|
508
|
+
return super().load(data)
|
|
509
|
+
|
|
510
|
+
conn.adapters.register_loader("date", DateInfinityLoader)
|
|
511
|
+
conn.adapters.register_loader("timestamp", TimestampInfinityLoader)
|
|
512
|
+
conn.adapters.register_loader("timestamptz", TimestamptzInfinityLoader)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _read_payload(path: Path) -> dict[str, Any]:
|
|
516
|
+
try:
|
|
517
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
518
|
+
finally:
|
|
519
|
+
path.unlink(missing_ok=True)
|
|
520
|
+
if not isinstance(value, dict):
|
|
521
|
+
raise RuntimeError("invalid PostgreSQL application payload")
|
|
522
|
+
return value
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _handle_application_operation(session: PostgresSession, operation: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
526
|
+
if operation == "followup":
|
|
527
|
+
body = payload.get("body")
|
|
528
|
+
if not isinstance(body, str):
|
|
529
|
+
raise ValueError("SQL followup requires string body")
|
|
530
|
+
session.followup(body)
|
|
531
|
+
return {"accepted": True}
|
|
532
|
+
if operation == "complete":
|
|
533
|
+
return session.complete(SqlCompletionRequest.from_payload(payload))
|
|
534
|
+
if operation == "interrupt":
|
|
535
|
+
session.interrupt()
|
|
536
|
+
return {"accepted": True}
|
|
537
|
+
raise ValueError(f"unsupported PostgreSQL application operation: {operation}")
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def run_postgres_application(payload_path: Path, socket_path: str) -> int:
|
|
541
|
+
session: PostgresSession | None = None
|
|
542
|
+
try:
|
|
543
|
+
from jusi.plugins.vd.application import install_editor_actions
|
|
544
|
+
|
|
545
|
+
install_postgres_commands()
|
|
546
|
+
install_editor_actions()
|
|
547
|
+
visidata.vd.timeouts_before_idle = -1
|
|
548
|
+
payload = _read_payload(payload_path)
|
|
549
|
+
query = str(payload.get("sql", "")).strip() or POSTGRES_BOOTSTRAP_SQL
|
|
550
|
+
alias = str(payload.get("alias", "")).strip()
|
|
551
|
+
if not alias:
|
|
552
|
+
raise RuntimeError("missing SQL target alias")
|
|
553
|
+
raw_options = payload.get("options")
|
|
554
|
+
if not isinstance(raw_options, dict):
|
|
555
|
+
raise RuntimeError("missing PostgreSQL target options")
|
|
556
|
+
options = parse_postgres_options(raw_options)
|
|
557
|
+
session = PostgresSession(
|
|
558
|
+
alias=alias,
|
|
559
|
+
connect_options=options.connect,
|
|
560
|
+
krb5ccname=options.krb5ccname,
|
|
561
|
+
initial_fetch=options.initial_fetch,
|
|
562
|
+
collect_metadata=options.collect_metadata,
|
|
563
|
+
metadata_max_rows=options.metadata_max_rows,
|
|
564
|
+
metadata_schemas=options.metadata_schemas,
|
|
565
|
+
)
|
|
566
|
+
controller = ApplicationController(
|
|
567
|
+
socket_path,
|
|
568
|
+
lambda operation, control_payload: _handle_application_operation(session, operation, control_payload),
|
|
569
|
+
)
|
|
570
|
+
controller.start()
|
|
571
|
+
session.enter_cell()
|
|
572
|
+
sheet = PostgresResultSheet(session=session, query=query)
|
|
573
|
+
run(sheet)
|
|
574
|
+
return 0
|
|
575
|
+
except Exception as exc:
|
|
576
|
+
sys.stderr.write(str(exc) + "\n")
|
|
577
|
+
sys.stderr.flush()
|
|
578
|
+
return 2
|
|
579
|
+
finally:
|
|
580
|
+
if session is not None:
|
|
581
|
+
session.close()
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
if __name__ == "__main__":
|
|
585
|
+
if len(sys.argv) != 4 or sys.argv[1] != "--application":
|
|
586
|
+
raise SystemExit("PostgreSQL application requires a private payload path and control socket")
|
|
587
|
+
raise SystemExit(run_postgres_application(Path(sys.argv[2]), sys.argv[3]))
|
jusi_postgres/worker.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Jusi 1.0 worker boundary; imports neither IPython nor VisiData."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from importlib.util import find_spec
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from jusi.plugin_api import OperationRejected, WorkerResult, copy_text, open_text, show_diff, terminal_surface
|
|
12
|
+
from jusi_sql import SqlCompletionRequest
|
|
13
|
+
from jusi_sql.worker import SqlExecuteRequest, SqlWorker, StagedApplicationPayload
|
|
14
|
+
|
|
15
|
+
from .ipc import WorkerApplicationBridge
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PostgresClientSession:
|
|
19
|
+
def __init__(self, context: object) -> None:
|
|
20
|
+
self.context = context
|
|
21
|
+
self.runtime_directory: Path | None = None
|
|
22
|
+
self.payload: StagedApplicationPayload | None = None
|
|
23
|
+
self.bridge: WorkerApplicationBridge | None = None
|
|
24
|
+
|
|
25
|
+
def execute(self, request: SqlExecuteRequest) -> WorkerResult:
|
|
26
|
+
if self.runtime_directory is not None:
|
|
27
|
+
raise OperationRejected("PostgreSQL client is already initialized", reason="conflict")
|
|
28
|
+
if find_spec("visidata") is None:
|
|
29
|
+
raise OperationRejected("%%sql requires VisiData; install jusi-postgres with dependencies", reason="unsupported")
|
|
30
|
+
self.runtime_directory = Path(tempfile.mkdtemp(prefix="jusi-postgres-", dir="/tmp"))
|
|
31
|
+
self.runtime_directory.chmod(0o700)
|
|
32
|
+
socket_path = str(self.runtime_directory / "control.sock")
|
|
33
|
+
try:
|
|
34
|
+
self.payload = StagedApplicationPayload(
|
|
35
|
+
{"alias": request.alias, "sql": request.sql, "options": request.options},
|
|
36
|
+
prefix="jusi-postgres-payload-",
|
|
37
|
+
)
|
|
38
|
+
self.bridge = WorkerApplicationBridge(socket_path)
|
|
39
|
+
return WorkerResult(
|
|
40
|
+
{"accepted": True, "alias": request.alias},
|
|
41
|
+
(terminal_surface(
|
|
42
|
+
"postgres_visidata",
|
|
43
|
+
(sys.executable, "-m", "jusi_postgres.runner", "--application", str(self.payload.path), socket_path),
|
|
44
|
+
environment_overrides={"TERM": os.environ.get("JUSI_SQL_TERM", "").strip() or "xterm-256color"},
|
|
45
|
+
signal=True,
|
|
46
|
+
),),
|
|
47
|
+
)
|
|
48
|
+
except BaseException:
|
|
49
|
+
self.close()
|
|
50
|
+
raise
|
|
51
|
+
|
|
52
|
+
def followup(self, body: str) -> dict[str, Any]:
|
|
53
|
+
return self._request("followup", {"body": body})
|
|
54
|
+
|
|
55
|
+
def complete(self, request: SqlCompletionRequest) -> dict[str, Any]:
|
|
56
|
+
return self._request("complete", {
|
|
57
|
+
"body": request.body,
|
|
58
|
+
"prefix": request.prefix,
|
|
59
|
+
"cursor_pos": request.cursor_pos,
|
|
60
|
+
"cursor_row": request.cursor_row,
|
|
61
|
+
"cursor_col": request.cursor_col,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
def interrupt(self) -> None:
|
|
65
|
+
if self.bridge is not None:
|
|
66
|
+
self.bridge.interrupt()
|
|
67
|
+
|
|
68
|
+
def editor_action(self, action: str, selection: dict[str, Any]) -> WorkerResult:
|
|
69
|
+
if action == "show_diff":
|
|
70
|
+
before, after = selection.get("before"), selection.get("after")
|
|
71
|
+
if not isinstance(before, str) or not isinstance(after, str):
|
|
72
|
+
raise OperationRejected("PostgreSQL diff selection requires before and after text", reason="invalid_request")
|
|
73
|
+
return show_diff(before, after, filetype="sql")
|
|
74
|
+
text = selection.get("text")
|
|
75
|
+
if not isinstance(text, str):
|
|
76
|
+
raise OperationRejected("PostgreSQL selection requires text", reason="invalid_request")
|
|
77
|
+
if action == "copy":
|
|
78
|
+
return copy_text(text, linewise=bool(selection.get("linewise", False)))
|
|
79
|
+
if action == "open":
|
|
80
|
+
return open_text(text, name="selection.sql", filetype="sql")
|
|
81
|
+
raise OperationRejected(f"Unsupported PostgreSQL editor action: {action}", reason="unsupported")
|
|
82
|
+
|
|
83
|
+
def _request(self, operation: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
84
|
+
if self.bridge is None:
|
|
85
|
+
raise OperationRejected("PostgreSQL client is not initialized", reason="conflict")
|
|
86
|
+
response = self.bridge.request(operation, payload)
|
|
87
|
+
if response.get("ok") is not True:
|
|
88
|
+
raise RuntimeError(str(response.get("message", "PostgreSQL terminal application failed")))
|
|
89
|
+
result = response.get("result", {})
|
|
90
|
+
return dict(result) if isinstance(result, dict) else {"accepted": True}
|
|
91
|
+
|
|
92
|
+
def close(self) -> None:
|
|
93
|
+
if self.bridge is not None:
|
|
94
|
+
self.bridge.close()
|
|
95
|
+
self.bridge = None
|
|
96
|
+
if self.payload is not None:
|
|
97
|
+
self.payload.close()
|
|
98
|
+
self.payload = None
|
|
99
|
+
if self.runtime_directory is not None:
|
|
100
|
+
try:
|
|
101
|
+
self.runtime_directory.rmdir()
|
|
102
|
+
except OSError:
|
|
103
|
+
pass
|
|
104
|
+
self.runtime_directory = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def create_worker(context: object) -> SqlWorker:
|
|
108
|
+
return SqlWorker(context, PostgresClientSession)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jusi-postgres
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: PostgreSQL provider plugin for Jusi SQL
|
|
5
|
+
Author: Jusi contributors
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 notawhaleble
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Python: >=3.9
|
|
29
|
+
Requires-Dist: jusi-sql<0.3,>=0.2
|
|
30
|
+
Requires-Dist: jusi<2,>=1.0
|
|
31
|
+
Requires-Dist: psycopg[binary]>=3.1
|
|
32
|
+
Requires-Dist: visidata<4,>=3
|
|
33
|
+
Provides-Extra: test
|
|
34
|
+
Requires-Dist: pytest<9,>=8; extra == 'test'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# jusi-postgres
|
|
38
|
+
|
|
39
|
+
`jusi-postgres` is the PostgreSQL exact-provider plugin for Jusi 1.0 and the
|
|
40
|
+
shared `jusi-sql` family. It exposes the `postgres`/`postgresql` provider for
|
|
41
|
+
`%%sql` cells. The family owns target selection, magic dispatch, completion
|
|
42
|
+
ranges, metadata caching, operation routing, and common VisiData SQL commands.
|
|
43
|
+
|
|
44
|
+
Example session config:
|
|
45
|
+
|
|
46
|
+
```toml
|
|
47
|
+
[sql.targets.analytics]
|
|
48
|
+
provider = "postgres"
|
|
49
|
+
host = "localhost"
|
|
50
|
+
port = 5432
|
|
51
|
+
dbname = "analytics"
|
|
52
|
+
user = "me"
|
|
53
|
+
initial_fetch = 100
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
All psycopg connection options may be provided in the target config. The
|
|
57
|
+
additional `krb5ccname` option points at a custom Kerberos credential cache and
|
|
58
|
+
is passed to libpq through `KRB5CCNAME` while connecting.
|
|
59
|
+
|
|
60
|
+
Metadata for SQL completion is collected asynchronously on a separate
|
|
61
|
+
connection. Large catalogs should be filtered or disabled:
|
|
62
|
+
|
|
63
|
+
```toml
|
|
64
|
+
[sql.targets.analytics]
|
|
65
|
+
provider = "postgres"
|
|
66
|
+
host = "db.example.com"
|
|
67
|
+
dbname = "analytics"
|
|
68
|
+
user = "me"
|
|
69
|
+
metadata_schemas = ["public", "analytics"]
|
|
70
|
+
metadata_max_rows = 1000000
|
|
71
|
+
collect_metadata = true
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Use `collect_metadata = false` to skip completion metadata for a target. If
|
|
75
|
+
collection crosses `metadata_max_rows`, it stops and warns that schema
|
|
76
|
+
filtering is required. Provider options belong in the target configuration;
|
|
77
|
+
the shared Jusi 1.0 `%%sql` syntax accepts one target alias.
|
|
78
|
+
|
|
79
|
+
VisiData mappings:
|
|
80
|
+
|
|
81
|
+
- `1` to `9`: fetch that many more rows for the active result sheet.
|
|
82
|
+
- `gf`: prompt for a row count to fetch; `0` fetches the rest of the cursor.
|
|
83
|
+
- `gc`: commit the current connection.
|
|
84
|
+
- `gr`: roll back the current connection.
|
|
85
|
+
- `gb`: write the selected raw cell value to a temporary file and open it with
|
|
86
|
+
VisiData. The command prompts for an optional extension; an empty extension
|
|
87
|
+
lets VisiData infer the file type from content and filename.
|
|
88
|
+
|
|
89
|
+
Follow-up cells reuse the same VisiData application, PostgreSQL connection,
|
|
90
|
+
transaction, and server-side cursors. `JusiInterrupt` requests cancellation on
|
|
91
|
+
that connection. Closing the client closes its cursors, metadata connection,
|
|
92
|
+
main connection, private control socket, and staged launch data.
|
|
93
|
+
|
|
94
|
+
## Local development database
|
|
95
|
+
|
|
96
|
+
Start a disposable PostgreSQL fixture:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
scripts/run-postgres.sh
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The script builds `jusi-postgres-dev`, runs a local container on
|
|
103
|
+
`127.0.0.1:55432`, waits for readiness, and prints this config:
|
|
104
|
+
|
|
105
|
+
```toml
|
|
106
|
+
[sql.targets.local_postgres]
|
|
107
|
+
provider = "postgres"
|
|
108
|
+
host = "127.0.0.1"
|
|
109
|
+
port = 55432
|
|
110
|
+
dbname = "jusi"
|
|
111
|
+
user = "jusi"
|
|
112
|
+
password = "jusi"
|
|
113
|
+
initial_fetch = 25
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The fixture creates `demo.accounts`, `demo.events`, `demo.account_summary`,
|
|
117
|
+
`demo.account_event_count(bigint)`, and `demo.blob_files` for result browsing,
|
|
118
|
+
completion checks, and raw bytea/blob handling.
|
|
119
|
+
|
|
120
|
+
For an already-running development container, install or refresh the blob table:
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
scripts/install-blob-fixture.sh
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The source zip fixture is stored at `fixtures/test-blob.zip`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
jusi_postgres/__init__.py,sha256=d6scTnvCGaeWi2znFmWZdJWIT_RDs_YpJgju1AmgQhU,85
|
|
2
|
+
jusi_postgres/catalog.py,sha256=7UA_Ya4BGiQDMpQNTxOIVeQalK0ZOPr4NW9kO4ZKqR0,567
|
|
3
|
+
jusi_postgres/config.py,sha256=K4PpoHtTiFlCOSSMlYTO0y64WVIDkMJ2iITLGQq9F1k,3132
|
|
4
|
+
jusi_postgres/constants.py,sha256=mTKICXjO4sCCnxJybDj0MQMpp7Ajw_WDG4toRn8iBME,102
|
|
5
|
+
jusi_postgres/ipc.py,sha256=ZJPFKXJkeJujnW7p_l9HSz4WLboOgllOjE_WJTuXMhk,5217
|
|
6
|
+
jusi_postgres/kernel.py,sha256=W2XwL_ERzCtz0ACYK5NO4vmxlrfzZO_XfWN0Fg5smO0,532
|
|
7
|
+
jusi_postgres/metadata.py,sha256=mxC_1bCfeht67doIlzDMzMgy3qIVHL1qqF2zqtPNms0,4437
|
|
8
|
+
jusi_postgres/runner.py,sha256=At60_N-Ipk-MUVB-M03adun_oNHfrCYoqf95VFpY2EQ,21073
|
|
9
|
+
jusi_postgres/worker.py,sha256=vrfj1QZWKGd9noVqUpfIQcbOIVaiy41M6Vf5oHB4A5g,4789
|
|
10
|
+
jusi_postgres-0.2.0.dist-info/METADATA,sha256=vVg230lQZ38jyDHz6hq0Z-7ft1voSPXCDN6g_tSwYKI,4540
|
|
11
|
+
jusi_postgres-0.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
12
|
+
jusi_postgres-0.2.0.dist-info/entry_points.txt,sha256=tonMp8WiCpv5BzuJKQZ9H82uPwhTVejvYA-4I90bmuE,65
|
|
13
|
+
jusi_postgres-0.2.0.dist-info/licenses/LICENSE,sha256=YSDMzYJrAu4QMyHcnOSrhmjYzkD9rEKha8G2CH4p3AA,1069
|
|
14
|
+
jusi_postgres-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 notawhaleble
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|