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.
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.2.0"
@@ -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
+ )
@@ -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
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ POSTGRES_BOOTSTRAP_SQL = "SELECT 1 AS jusi_bootstrap WHERE false"
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
@@ -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")
@@ -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]))
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [jusi.plugins.v1]
2
+ postgres = jusi_postgres.catalog:catalog_entry
@@ -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.